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/aggregate-impact.yaml b/.github/workflows/aggregate-impact.yaml index 7740c688a2..fe842b9637 100644 --- a/.github/workflows/aggregate-impact.yaml +++ b/.github/workflows/aggregate-impact.yaml @@ -1,6 +1,8 @@ # Measure a pull request's base and head on the certified dataset and post # the comparison: receipts by tax head, spending and caseloads, poverty, # inequality, deciles and the standard reform costings, 2025-26 to 2031-32. +# For a pull request, the head is GitHub's merge commit and the base is that +# commit's first parent, so the report is exactly what merging would change. # # Both sides run the pull request head's copy of the measurement tool # (policyengine_uk/utils/aggregate_impact), each in an environment synced @@ -66,16 +68,15 @@ jobs: id: commits env: EVENT: ${{ github.event_name }} - PR_BASE: ${{ github.event.pull_request.base.sha }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_NUMBER: ${{ github.event.pull_request.number }} INPUT_BASE: ${{ inputs.base }} INPUT_HEAD: ${{ inputs.head }} run: | - # A pull request's head is its merge commit, whose first parent is - # the base, so the comparison is exactly what merging would change. + # A pull request's head is its merge commit. Its base is read from + # that commit once the head is checked out (Choose the base). if [ "$EVENT" = "pull_request" ]; then - base="$PR_BASE"; head="$GITHUB_SHA" + base=""; head="$GITHUB_SHA" base_name="$PR_BASE_REF"; head_name="#$PR_NUMBER merged into $PR_BASE_REF" else base="${INPUT_BASE:-main}"; head="${INPUT_HEAD:-$GITHUB_SHA}" @@ -93,11 +94,39 @@ jobs: with: ref: ${{ steps.commits.outputs.head }} path: head + # A pull request's merge commit and its parents, so that the next + # step can read the base from it. + fetch-depth: 2 + + - name: Choose the base + id: base + env: + EVENT: ${{ github.event_name }} + BASE: ${{ steps.commits.outputs.base }} + run: | + # A pull request's base is its merge commit's first parent: the tip + # of the branch it merges into, as GitHub made the merge commit for + # this run. The comparison is then exactly what merging would + # change. The event's pull_request.base.sha is not that commit: it + # lags the branch once the branch has moved on, and the report then + # counted the branch's newer merges as the pull request's impact + # (#2516's took in 26 commits from main). + if [ "$EVENT" = "pull_request" ]; then + # The merge commit, then its parents. + line=$(git -C head rev-list --parents -n 1 HEAD) + read -r -a commit <<< "$line" + if [ "${#commit[@]}" -ne 3 ]; then + echo "::error::The head ${commit[0]} has $((${#commit[@]} - 1)) parents, not 2, so it is not a pull request's merge commit and its base is unknown." + exit 1 + fi + BASE="${commit[1]}" + fi + echo "sha=$BASE" >> "$GITHUB_OUTPUT" - name: Check out the base uses: actions/checkout@v6 with: - ref: ${{ steps.commits.outputs.base }} + ref: ${{ steps.base.outputs.sha }} path: base - name: Install uv diff --git a/.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 a222e8885a..904d4fe883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,270 @@ +## [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 + +- Means-test the Council Tax Reduction applicant and their partner rather than the benefit unit's claimant and partner, so a household head who is liable for the council tax but shares a benefit unit with a young couple is assessed on her own income, allowance, premiums and exemption. + + +## [2.141.4] - 2026-10-20 + +### Fixed + +- A before-load `Scenario` change to DfE, DHSC or DfT spending now constructs: `adjust_budgets` skips a year with no allocation instead of writing NaN, and scales each year's allocation by that year's own budget change without carrying an earlier year's change forward. + + +## [2.141.3] - 2026-10-20 + +### Fixed + +- - The Aggregate impact report now compares a pull request's merge commit with that commit's first parent, the tip of the branch it merges into. It used the event's `pull_request.base.sha`, which lags that branch once it moves on, so newer merges to `main` were counted as the pull request's impact. +- - Fixed Universal Credit deductions and benefit cap reductions to report what the award actually loses under a protected minimum floor, so the components sum to the reduction applied instead of over-reporting it. Where the floor binds, deductions are met first and the cap reduction absorbs the rest, however heavily the award is capped. + - Added `uc_benefit_cap_reduction_after_floor`, the benefit cap reduction applied to a Universal Credit award after the protected floor and bounded by the award, leaving the regulation 81 amount `uc_benefit_cap_reduction` gross. + - Fixed a protected floor above 1 adding money to Universal Credit awards, by clamping the permitted reduction at zero. + - Fixed Universal Credit deductions applying to benefit units that do not claim Universal Credit. Deductions and the applied cap reduction use the same claim test as the award (`claims_universal_credit`), so a claim made when legacy benefits close keeps both. + - Corrected the effective date of the 25% deductions cap to 12 April 2021, and the one-penny minimum payable citation to Schedule 6, paragraph 3(1)(a) of SI 2013/380. + + +## [2.141.2] - 2026-10-20 + +### Fixed + +- - Council Tax Reduction and Income Support read their own pension contribution deduction shares (one-half in each scheme) instead of a hard-coded 0.5, and the Council Tax Reduction pensioner earnings disregards cap at net earnings with the scheme's own share, so a reform of Housing Benefit's share no longer moves them. +- - Carer's Allowance earnings read their pension contribution deduction share (one-half, Computation of Earnings Regulations 1996 regs. 10(4)(b) and 13) from a new parameter, `gov.dwp.carers_allowance.pension_contribution_deduction_rate`, instead of a hard-coded 0.5, so the share can be reformed. + + +## [2.141.1] - 2026-10-20 + +### Fixed + +- Give pension-age Housing Benefit claimants who attained State Pension age on or after 1 April 2021 (and couples who both did) the lower personal allowance in SI 2006/214 Sch 3 para 1(1)(c) and (2)(c), £238.00 and £363.25 a week in 2026-27, instead of the £256.00 and £383.35 kept by earlier cohorts. England's pensioner Council Tax Reduction applies the same split (SI 2012/2885 Sch 2 para 1); Scotland and Wales keep one rate. Adds `state_pension_age_date`, the day each person attains State Pension age. + + +## [2.141.0] - 2026-10-20 + +### Fixed + +- - Pension Credit income now counts industrial injuries disablement benefit, severe disablement allowance, incapacity benefit and maternity allowance (SPC Regs 2002 reg 15(1)); Savings Credit treats working tax credit, incapacity benefit, severe disablement allowance and maternity allowance as non-qualifying income (reg 9); Housing Benefit income now counts industrial injuries disablement benefit, severe disablement allowance and incapacity benefit. +- - Count war disablement pensions, war widow's or widower's pensions and Armed Forces Compensation Scheme guaranteed income payments of the claimant and partner in Pension Credit income, each payment less £10 a week (State Pension Credit Regulations 2002 Sch. IV para. 1). Housing Benefit and Council Tax Reduction continue to disregard them in full, including where they take the Pension Credit assessment of a savings-credit-only award. + +### Removed + +- - Removed the unused duplicate parameter `gov.dwp.pension_credit.income.savings_credit_excluded_sources`; `gov.dwp.pension_credit.savings_credit.excluded_income` is the list the model reads. + + +## [2.140.1] - 2026-10-20 + +### Fixed + +- - Fixed Housing Benefit applicable income to use the configured pension contribution deduction rate, preserving the current 50% deduction. +- Preserve dataset-supplied values for variables with formulas in default input DataFrame exports, so reloading an export retains inputs such as reported State Pension. Exclude calculated caches and model-generated pseudo-inputs while retaining recorded input storage and branch precedence. +- - Based TV licence charges on licensable viewing and corrected the HBAI free-licence value. + + +## [2.140.0] - 2026-10-20 + +### Added + +- Added normalized annual BHC and AHC squared poverty-gap components and documented person-weighted aggregation and undefined poverty-line handling. +- - Added a configurable Income Support withdrawal rate, preserving the existing 100% default. + +### Fixed + +- Use Scotland's working-age Council Tax Reduction young-person definition for the severe disability premium's non-dependant test within the applicant's benefit unit, retaining existing looked-after and other-benefit-unit treatment. +- Count Incapacity Benefit and Severe Disablement Allowance as Universal Credit unearned income from the GB transitional commencement, preserving earlier history and the existing annual-model limitations. +- Fix stale construction-time reform wrappers after foreign writes or direct edits while preserving copied scenarios and constructor readings; document mixed-composite replay, indexed-threshold construction and the existing rejection of built overrides whose keys contain no 1 January annual value. + + +## [2.139.5] - 2026-10-20 + +### Changed + +- - Documented employer NI incidence assumptions, input handling, revenue measures and current household examples. + +### Fixed + +- - Separated Universal Credit's higher disabled child condition from tax credits, included certified blindness, and prevented the lower and higher additions from stacking. +- - Corrected Working Tax Credit eligibility so age, disability and hours conditions applied to the same claimant or partner, while preserving the parental combined-hours and partner-exception routes. +- - Restricted the Child Tax Credit family element to families responsible for a qualifying child or young person born before 6 April 2017, while preserving earlier supported years. +- - Excluded Child Benefit from Great Britain's pensioner Council Tax Reduction general-rule income, preserving working-age and statutory-pay treatment. + + +## [2.139.4] - 2026-10-19 + +### Fixed + +- - Corrected dividend income-tax band thresholds from 2016 to 2025 to use their statutory tax-year dates. +- - Corrected historical Care to Learn weekly caps while preserving the existing annual date approximation. +- - Exempted non-dependants responsible for a child under five from Universal Credit housing cost contributions. +- - Applied the shared Pension Credit eligibility conditions to computed Savings Credit amounts. +- - Included statutory sick and maternity pay in the relevant earnings limits for pension contribution relief and annual-allowance income tests. + + +## [2.139.3] - 2026-10-19 + +### Fixed + +- - Updated Welsh residential Land Transaction Tax tables for annual model years from 2023 (main rates) and 2025 (higher rates), retaining their statutory change dates. + + ## [2.139.2] - 2026-10-19 ### Fixed diff --git a/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/sale-expense-share-split.fixed.md b/changelog.d/sale-expense-share-split.fixed.md new file mode 100644 index 0000000000..014f38dd95 --- /dev/null +++ b/changelog.d/sale-expense-share-split.fixed.md @@ -0,0 +1 @@ +- Value share-like capital by asset type in every means test: directly held shares, and any `corporate_wealth` a dataset does not itemise, take the 10% sale-expense deduction; unit trusts (ADM H1673-H1674) and stocks and shares ISAs (ADM H1656) count at full value, as does the survey's combined unit and investment trust bucket, an approximation for its investment trusts. diff --git a/changelog.d/wealth-share-components.added.md b/changelog.d/wealth-share-components.added.md new file mode 100644 index 0000000000..4a97f30c03 --- /dev/null +++ b/changelog.d/wealth-share-components.added.md @@ -0,0 +1 @@ +- Add `directly_held_shares` and `unit_and_investment_trusts` household inputs for the share-like components of `corporate_wealth`, count share-like capital in every means test as `corporate_wealth` or, where the components exceed it beyond float32 rounding, their sum (`share_like_capital`), and uprate the components and the ISA balances with per-capita GDP. diff --git a/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/employer-ni-incidence.md b/docs/book/assumptions/employer-ni-incidence.md new file mode 100644 index 0000000000..78fadb32a6 --- /dev/null +++ b/docs/book/assumptions/employer-ni-incidence.md @@ -0,0 +1,189 @@ +# Employer National Insurance incidence + +Employer National Insurance reforms can change wages, tax receipts and household +income through the model's incidence assumptions. The parameters under +`gov.contrib.policyengine.employer_ni` default to: + +| Parameter | Default | Implemented role | +|---|---:|---| +| `employee_incidence` | 1 | Scales the computed fixed-employer-cost pay adjustment | +| `consumer_incidence` | 0.5 | Allocates remaining employer-cost changes by consumption shares | +| `capital_incidence` | 0.5 | Allocates remaining employer-cost changes by corporate wealth shares | + +These values are adjustable model assumptions. No empirical estimate is claimed +for the defaults, and this page does not select a preferred incidence assumption. + +## Wage adjustment and supplied income + +The wage channel compares the baseline and reform employer-NI rate, secondary +threshold and employer-pension exemption. For its eligible earnings branch, it +solves for pay holding modeled employer cost fixed, then multiplies the pay change +by `employee_incidence`. At `1`, the full computed adjustment applies in the same +model year. The formula returns zero without a baseline, without a relevant +parameter change, at employee incidence zero, or for prior pay below the baseline +secondary threshold. This describes the implemented calculation, including its +threshold guard; it does not assert a general equilibrium response for every worker. + +`employment_income` adds this adjustment to `employment_income_before_lsr` and +other earnings components. An input actually stored in the final +`employment_income` holder overrides its formula. The UK `Simulation` constructor +moves situation inputs named `employment_income` to `employment_income_before_lsr`. +Consequently, putting that field in a constructor situation can still allow the +wage response. The example below demonstrates an override with `set_input` after +construction and before calculation. Dataset and other input routes should be +checked for which earnings holder they populate. + +The [wage formula](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py), +[earnings components](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/input/employment_income.py) +and [constructor remapping](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/simulation.py#L350) +provide the implementation details. + +## Consumer and capital channels + +Consumer and capital incidence apply to the remaining change in modeled employer +cost after the wage response. Each is zero when its own parameter is zero or +employee incidence is one. Otherwise, the formulas use person weights and allocate +that change using household consumption or corporate wealth per person. Their +weighted aggregate exposure must be nonzero; they also return zero when the +simulation has no dataset attribute. + +An attribute can be present with `dataset=None`, so that guard alone is not a +promise that every household calculation has zero residual incidence. The example +below has no representative dataset or consumption/wealth exposures and returns +zero for both residual channels. It cannot show their population effects. + +Setting `employee_incidence` to zero alone leaves the consumer and capital +parameters at `0.5`. Set **all three parameters to zero** to disable these three +employer-NI incidence channels. This does not disable unrelated labor-supply or +other behavioral responses. + +The response amounts enter `household_tax`; those same variables are not added to +`gov_tax`. See the [consumer](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_consumer_incidence.py) +and [capital](https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_capital_incidence.py) +allocation formulas. + +## Measures and aggregation + +Report these quantities separately: + +- Gross employer-NI liability or its reform change: `ni_employer`, a person amount. +- Net government balance: `gov_balance = gov_tax - gov_spending`, a household amount. + A positive reform change improves the modeled balance. Wage changes can affect + income tax, employee NI and benefits, so this is not a gross employer-NI yield. +- Household incidence here: the change in nominal `household_net_income`, defined + as `household_market_income + household_benefits - household_tax - pension_contributions`. + A negative change is a household loss under this measure. + +The example sums each output over one person or one household, as appropriate. +Population results require the matching person or household weights. Do not sum +household values repeated on person records as if they were distinct households. +These measures do not by themselves establish a representative budget costing. + +## Worked household example + +For model year 2026, consider one 40-year-old London employee with £40,000 of +pre-response earnings, no pension contributions and no other supplied income. +Raise the employer rate from 15% to 16%, holding the other parameters fixed. Current +main includes the annual secondary-threshold correction: £5,000 rather than the +old rounded-weekly equivalent of £4,992. Each comparison below applies the same +incidence assumptions to its baseline and reform. + +Annual changes in pounds, rounded to two decimal places: + +| Input and incidence assumptions | Employer NI | Income tax | Employee NI | Gross pay | Government balance | Household net income | +|---|---:|---:|---:|---:|---:|---:| +| Components; employee 1, consumer/capital 0.5 | +301.72 | −60.34 | −24.14 | −301.72 | +217.24 | −217.24 | +| Components; employee 0, consumer/capital 0.5 | +350.00 | 0.00 | 0.00 | 0.00 | +350.00 | 0.00 | +| Components; all three zero | +350.00 | 0.00 | 0.00 | 0.00 | +350.00 | 0.00 | +| Pay set after construction; employee 1 | +350.00 | 0.00 | 0.00 | 0.00 | +350.00 | 0.00 | + +At fixed pay, the extra liability is `0.01 × (40,000 − 5,000) = £350`. +With full wage incidence, the baseline employer cost is £45,250. Solving +`pay + 0.16 × (pay − 5,000) = 45,250` gives pay of approximately £39,698.28. +The income-tax and employee-NI changes partly offset the wage fall. The last three +rows coincide in this example; they do not imply identical population incidence. + +Run this snippet from a checkout's supported Python environment: + +```python +from policyengine_core.reforms import Reform +from policyengine_uk import Simulation + +YEAR = 2026 +SPAN = "2026-01-01.2026-12-31" +INCIDENCE = "gov.contrib.policyengine.employer_ni." +RATE = "gov.hmrc.national_insurance.class_1.rates.employer" +VARIABLES = [ + "ni_employer", + "income_tax", + "ni_employee", + "employment_income", + "gov_balance", + "household_net_income", +] + + +def result(employee, consumer, capital, direct_pay=False): + pay_input = "employment_income" if direct_pay else "employment_income_before_lsr" + situation = { + "people": {"worker": {"age": {YEAR: 40}, pay_input: {YEAR: 40_000}}}, + "benunits": {"family": {"members": ["worker"]}}, + "households": {"home": {"members": ["worker"], "region": {YEAR: "LONDON"}}}, + } + assumptions = { + INCIDENCE + "employee_incidence": {SPAN: employee}, + INCIDENCE + "consumer_incidence": {SPAN: consumer}, + INCIDENCE + "capital_incidence": {SPAN: capital}, + } + baseline = Simulation( + situation=situation, + reform=Reform.from_dict(assumptions, country_id="uk"), + ) + reform = Simulation( + situation=situation, + reform=Reform.from_dict({**assumptions, RATE: {SPAN: 0.16}}, country_id="uk"), + ) + if direct_pay: + # UK construction moves situation earnings to the pre-response input. + # Explicitly supply the final earnings holder after construction. + baseline.set_input("employment_income", YEAR, [40_000]) + reform.set_input("employment_income", YEAR, [40_000]) + before = {v: float(baseline.calculate(v, YEAR).sum()) for v in VARIABLES} + after = {v: float(reform.calculate(v, YEAR).sum()) for v in VARIABLES} + return { + "baseline": before, + "reform": after, + "delta": {v: after[v] - before[v] for v in VARIABLES}, + "residual_channels": { + v: float(reform.calculate(v, YEAR).sum()) + for v in ( + "employer_ni_response_consumer_incidence", + "employer_ni_response_capital_incidence", + ) + }, + } + + +rows = { + "Employee 1; consumer/capital 0.5": result(1, 0.5, 0.5), + "Employee 0; consumer/capital 0.5": result(0, 0.5, 0.5), + "All three zero": result(0, 0, 0), + "Pay set after construction; employee 1": result(1, 0.5, 0.5, direct_pay=True), +} +for label, row in rows.items(): + print(label, {v: round(value, 2) for v, value in row["delta"].items()}) +``` + +The residual-channel outputs are retained in `rows` for inspection. No private +microdata, representative population estimate or empirical calibration is used. + +## Comparing an official costing + +Match the definition and horizon of the specific official figure before comparing +it with a model result. For example, the +[OBR's October 2024 Economic and fiscal outlook](https://obr.uk/efo/economic-and-fiscal-outlook-october-2024/) +reported £25.7 billion of employer-NICs revenue in 2029–30 before indirect economic +effects (overview paragraph 1.7). Its paragraph 2.14 assumed about 60% of the cost +would pass to employees in the short term, with more adjustment later through +prices and nominal wages. These are dated forecast assumptions, not evidence for +the model's defaults. Other official headlines can use different definitions. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 3e8b4e1700..8d5ff9dd63 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -62,7 +62,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `corporate_wealth`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `sublet_income` +We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_secured_debt`, `other_residential_property_value`, `owned_land`, `owned_land_secured_debt`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` ## Council tax diff --git a/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/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..138f52dd4b 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -122,3 +122,9 @@ Notable exclusions: - **Some devolved tax policies**: Beyond property transaction taxes, other devolved policies may have limited coverage All parameter values include references to primary legislation and can be found in the [PolicyEngine UK parameters directory](https://github.com/PolicyEngine/policyengine-uk/tree/master/policyengine_uk/parameters). + +## Parameter cache ownership + +The UK tax-benefit system initializes Core state and installs parameter trees through Core's public operations. The existing `reset_parameter_caches()` method clears dated views on the system, the root and descendant parameter nodes without replacing the live tree or its leaves. Parameter processing retains its statutory timelines, baseline, structural reform nodes and rollback behavior; this change does not alter policy values or reform timing. + +The selected operations are available at the existing Core minimum version, 3.32.27, and in the locked 3.33.2 release, so this parameter-only migration does not change the dependency or lockfile. It implements part of [issue #2175](https://github.com/PolicyEngine/policyengine-uk/issues/2175). Supplied-input tracking, model-value provenance and clone, deletion and rebuild compatibility remain separate work. Those compatibility paths are retained, including the distinction between an amount supplied by a user and one stored by the model. diff --git a/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/disability-legacy-benefits.md b/docs/book/programs/gov/dwp/disability-legacy-benefits.md index 331a5c133e..dc63b96fca 100644 --- a/docs/book/programs/gov/dwp/disability-legacy-benefits.md +++ b/docs/book/programs/gov/dwp/disability-legacy-benefits.md @@ -65,6 +65,38 @@ addition. This is a deliberate simplification: SDA was closed to new claims in 2001 and the remaining caseload is small and old enough that nearly all recipients qualify for the highest age-related addition. +## Universal Credit income treatment + +Universal Credit counts a claimant's or assessed partner's Incapacity Benefit +(`incapacity_benefit`) and modeled SDA award (`sda`) as unearned income, reducing +the award pound for pound up to the maximum UC award. A positive `sda_reported` +input therefore counts the modeled maximum SDA amount, not the reported cash +amount. Other benefit units' income and same-unit nonclaimants' income remain +outside the existing assessed-claimant aggregation. + +[GB transitional regulation 25(1)](https://www.legislation.gov.uk/uksi/2014/1230/regulation/25) +adds both benefits from 16 June 2014. The model preserves the original 2013 +income list and adds them at that date, including in the later list containing +Carer Support Payment. Annual calculations from 2015 read the 30 April value. +The first, part-year 2014 annual treatment remains unresolved +([#1807](https://github.com/PolicyEngine/policyengine-uk/issues/1807)); the dated +entry does not provide assessment-period calculations or proration. The +[NI transitional counterpart](https://www.legislation.gov.uk/nisr/2016/226/regulation/26) +supports current commenced NI claims, not an assertion of NI commencement in +2014. + +Carer's Allowance enters UC income after the existing overlapping-benefit +adjustment. Within the allowance's positive residual, an extra pound of annual +IB replaces a pound of Carer's Allowance, leaving their combined UC income +unchanged. Once the allowance is zero, further IB increases UC income. This +annual netting does not represent benefit spells at different weekly rates. + +The focused household tests retain live UC eligibility, disability/carer +elements and final awards. Legacy receipt and disability-assessment inputs are +assumptions, not a re-derivation of IB/SDA entitlement. The care-hours interface +does not identify the person cared for or check their qualifying benefit. No +private-data impact or Axiom parity has been measured. + ## References - DLA primary statute: [Social Security Contributions and Benefits Act 1992 s. 71](https://www.legislation.gov.uk/ukpga/1992/4/section/71). diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 277482eaf4..a3e9c25f03 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -80,7 +80,11 @@ "source": [ "## Personal allowances\n", "\n", - "The applicable amount uses age- and household-composition bands. The chart below shows how the weekly allowances have evolved." + "The applicable amount uses age- and household-composition bands. The chart below shows how the weekly allowances have evolved.\n", + "\n", + "Since 1 April 2021 the pension-age allowances depend on when members of the benunit attained State Pension age ([SI 2006/214 Sch. 3 para. 1](https://www.legislation.gov.uk/uksi/2006/214/schedule/3), as amended by [SI 2021/188](https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made); Northern Ireland: [SR 2006/406 Sch. 4 para. 1](https://www.legislation.gov.uk/nisr/2006/406/schedule/4)). A single claimant or lone parent who attained it before 1 April 2021, or a couple in which either member did, keeps the higher allowance (`aged`, £256.00 single and £383.35 couple a week in 2026-27). Where every member attained it on or after that date, the lower allowance applies (`aged_from_cutoff`, £238.00 and £363.25). `housing_benefit_attained_pension_age_before_cutoff` compares each member's `state_pension_age_date`, the day set by their date of birth under the Pensions Act 1995 timetable, with `pension_age_cutoff`. The regulations do not settle a couple in which one member has not yet reached State Pension age and the other reached it after the cutoff; PolicyEngine gives it the lower allowance, following the explanatory note to SI 2021/188.\n", + "\n", + "Council Tax Reduction reuses these allowances. England's pensioner scheme has the same split and amounts ([SI 2012/2885 Sch. 2 para. 1](https://www.legislation.gov.uk/uksi/2012/2885/schedule/2), as amended by [SI 2021/29](https://www.legislation.gov.uk/uksi/2021/29/contents/made)). The Scottish ([SSI 2012/319 Sch. 1 para. 2](https://www.legislation.gov.uk/ssi/2012/319/schedule/1)) and Welsh ([SI 2013/3029 Sch. 2 para. 1](https://www.legislation.gov.uk/wsi/2013/3029/schedule/2)) schemes keep one pension-age rate, the higher one." ] }, { @@ -108,16 +112,18 @@ "params = {\n", " \"Single (younger)\": allowances.single.younger,\n", " \"Single (older)\": allowances.single.older,\n", - " \"Single (aged)\": allowances.single.aged,\n", + " \"Single (pension age)\": allowances.single.aged,\n", + " \"Single (pension age, reached from April 2021)\": allowances.single.aged_from_cutoff,\n", " \"Lone parent (younger)\": allowances.lone_parent.younger,\n", " \"Lone parent (older)\": allowances.lone_parent.older,\n", - " \"Lone parent (aged)\": allowances.lone_parent.aged,\n", + " \"Lone parent (pension age)\": allowances.lone_parent.aged,\n", " \"Couple (younger)\": allowances.couple.younger,\n", " \"Couple (older)\": allowances.couple.older,\n", - " \"Couple (aged)\": allowances.couple.aged,\n", + " \"Couple (pension age)\": allowances.couple.aged,\n", + " \"Couple (pension age, both reached from April 2021)\": allowances.couple.aged_from_cutoff,\n", "}\n", "\n", - "dates = [f\"{y}-04-01\" for y in range(2016, 2026)]\n", + "dates = [f\"{y}-04-01\" for y in range(2016, 2027)]\n", "rows = [\n", " {\"date\": d, \"composition\": name, \"weekly allowance\": p(d)}\n", " for d in dates\n", diff --git a/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/tax-credits.md b/docs/book/programs/gov/dwp/tax-credits.md index eac83f4600..abab3fd48f 100644 --- a/docs/book/programs/gov/dwp/tax-credits.md +++ b/docs/book/programs/gov/dwp/tax-credits.md @@ -47,7 +47,25 @@ from the means test, gated by the `would_claim_WTC` take-up input. The `min_hours` tree (`min_hours/default.yaml`, `lower.yaml`, `old_age.yaml`, `couple_with_children.yaml`) encodes the bands that gate WTC eligibility — typically 30 hours/week for adults with no kids, 16 for -lone parents, with a lower band for over-60s. +lone parents, with a lower band for workers aged 60 or over. + +For the joint-claim rules from 6 April 2012, an individual's own age or +disability must qualify that individual's hours. Two childless workers cannot +pool 15 hours each to meet the ordinary 30-hour route, and a nonworking +partner cannot lend their age or disability to the worker. Parents can combine +24 hours, with at least one claimant working 16 hours. Hours worked by a +child or other dependant do not count towards that total. The `min_age` and +`min_age_without_children_or_disability` parameters provide the 16-year and +25-year thresholds; the existing `min_hours.old_age` parameter provides 60. +Native annual formula selection retains the earlier formula for a year whose +start precedes 6 April 2012; this change does not certify all earlier rules. + +`is_disabled_for_benefits` remains a generic proxy for the statutory disabled +worker conditions. For parents, the model also retains its existing proxy +for an exception based on the other partner's disability. These flags do not +encode all regulation 9 or regulation 13 tests, incapacity, hospital or prison +status, carer's allowances, or the expected four-week and paid-work conditions. +The reported-award, migration, take-up and scheme-closure rules remain separate. ## Child Tax Credit (CTC) @@ -59,11 +77,44 @@ benunit worked. PolicyEngine computes it via `ctc_entitlement`, gated by | Element | Parameter | |---------|-----------| -| Family | `family_element.yaml` (extinct from 2017 for new claims) | +| Family | `family_element.yaml` (one amount, subject to the birth-date condition below) | | Per qualifying child | `child_element.yaml` | | Disabled child addition | `dis_child_element.yaml` | | Severely disabled child addition | `severe_dis_child_element.yaml` | +### Family element birth-date condition + +From 6 April 2017, the family element is included only if a claimant or +joint claimant is responsible for a qualifying child or qualifying young +person born **strictly before 6 April 2017**. A child born on that date +does not qualify. One qualifying child is enough for the single family +amount; an older adult or an excluded child cannot unlock it. The model +uses its existing CTC child/QYP responsibility mask, including its existing +looked-after-child and own-right-benefit approximations. + +`CTC_family_element` applies this condition separately from the individual +child element's two-child limit. Its activation and cutoff parameters live +under `child_tax_credit/eligibility/`; reforming them does not change the +individual-element cutoff. The existing flat £545 amount is unchanged. +Before the condition applies, supported model years retain the prior flat +family amount. The false activation value at 2015-01-01 anchors model +coverage, rather than statutory commencement. Annual parameters use the +existing April sampling convention; this is not a part-year award model. + +The rule reuses `birth_day`: a supplied valid `date_of_birth` must agree +with legal age on 6 October. A missing or zero date uses the existing +annual age and months-since-birthday fallback; `birth_year` is not a +replacement. Those conventions are not exact birth-date observations. +Explicit family-element and final-amount inputs retain the ordinary model +override contract and are not evidence of legal entitlement. Continuing +claims, income tests, minimum awards, other elements and 2025 closure are +unchanged; this update does not audit daily statutory award rounding. + +Historical coverage remains incomplete: this change addresses the +supported 2015+ birth-date rule only. The pre-2011 baby addition and +2003–2014 coverage remain follow-up work under issue #2050; no raw +historical values or annual-conversion rules were extended. + ### Two-child limit Since April 2017 the child element has been restricted to the first two diff --git a/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/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/usage/scenarios.md b/docs/book/usage/scenarios.md index 20b40689b3..706772f6d5 100644 --- a/docs/book/usage/scenarios.md +++ b/docs/book/usage/scenarios.md @@ -424,7 +424,12 @@ A dataset's later years are projected from its first by the economic assumptions All of this applies when the scenario or reform is passed to `Simulation(...)`. `Scenario.apply(simulation)` on a simulation that is already built edits its annualised values instead, as it always has: a `"2027-04-06.2028-04-05"` key there overrides those dates of the fiscal-year values, so it does not set fiscal year 2027, and the model logs a warning saying so. Pass the scenario when constructing the simulation for dated measures. The modifier returned by `Scenario.from_reform` behaves the same way on a built simulation for changes that are no longer effective, and raises if an external override key contains no 1 January annual value. -A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites always replay in their requested order; this can append duplicate override-log entries. An external key that contains no 1 January annual value raises with its path and key. + +During construction, the already-written statutory changes of an original dated reform keep the constructor's reading while their parameter paths remain untouched by foreign writes or direct parameter edits. This includes a value-equal reform called by a wrapper or a deep-copied scenario. The original modifiers still apply their single-day leftovers in order. If a user modifier edits those statutory paths and then requests the same reform, that request instead overrides the built, annualised values. An external composite replays its complete requested sequence; its children cannot skip a write merely because they equal an original constructor reform. Edits before data load remain foreign when a later modifier runs after data load. This boundary concerns parameter writes, not the different award values that Universal Credit rebalancing intentionally stores. + +A pure statutory or pure legacy reform can be a no-op on a copy, clone or branch only when its complete write sequence matches the latest recorded writes on its paths, the other log has no writes on those paths, and its values remain current. Mixed statutory and legacy composites on a built simulation always replay in their requested order; this can append duplicate override-log entries and change the copy's original construction reading. For example, a dict with a basic-rate window `"2026-06-01.2027-03-31": 0.31` and a VAT single-day key `"2027-01-01": 0.25` gives the 2027 basic rate its unchanged 20% construction value. Replaying its modifier on a clone overrides 1 January 2027 with 31%; the original simulation stays unchanged. Relative `Reform` classes and custom modifiers still run when called on a copy; a modifier that multiplies a parameter by 1.1 applies that multiplication to the copy's current value. When changing policy parameters on a branch, request `get_branch("policy", clone_system=True)`; the default branch shares its tax-benefit system with its parent. + +Dated measures on indexed thresholds also need `Simulation(scenario=...)` or `reform=...` for the statutory projection and indexation resume described above. Applying them to a baseline-built simulation overrides only its already annualised values; it does not recalculate the indexed path after the override ends. An external key that contains no 1 January annual value raises with its path and key, including `"2027-04-06"` and `"2027-04-06.2027-12-31"`. ### Creating complex scenarios with simulation modifiers diff --git a/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 740c0ccdba..557ea35f81 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -54,6 +54,29 @@ PolicyEngine does the same. `hbai_household_net_income` is `hbai_household_net_i In FRS-based datasets, `housing_service_charges` contains the annualised property charges recorded in `CHRGAMT1`–`CHRGAMT9`: ground rent, chief rent, service charges, regular maintenance, caravan site rent, factoring charges, other regular charges and combined charges. `CHRGAMT2`, historically feu duty, is unused in the FRS 2024–25 dictionary. The combined-charge question is an alternative to recording individual amounts. Water and sewerage and rent are separate inputs; calculator users should supply each cost once, with service charges additional to the other housing-cost inputs. See the [FRS 2024–25 question instructions](https://doc.ukdataservice.ac.uk/doc/9563/mrdoc/pdf/9563_frs_2024-25_question_instructions_final.pdf), property charges section (pages 98–99), and its [variable dictionaries](https://doc.ukdataservice.ac.uk/doc/9563/mrdoc/ukda_data_dictionaries.zip). +## Normalized squared poverty gaps + +`squared_poverty_gap_bhc` and `squared_poverty_gap_ahc` are annual household, dimensionless components of the Foster–Greer–Thorbecke poverty-severity measure (FGT2). Each is the square of its existing nonnegative `poverty_gap_bhc` or `poverty_gap_ahc`, divided by the matching positive `poverty_line_bhc` or `poverty_line_ahc` **before squaring**: + +$$ +s_h = \left(\frac{\max(z_h-y_h,0)}{z_h}\right)^2, \qquad z_h>0. +$$ + +For example, income at or above the line gives zero, income halfway to the line gives 0.25, and zero income gives one. Scaling income and its line by the same positive factor leaves the component unchanged. These are normalized components, not GBP-squared poverty gaps or sums. + +The statistical definition follows the [World Bank PIP methodology, section 4.1](https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetarypoverty). The variables retain the UK HBAI basis described above: negative BHC income is reset to zero, while AHC income can be negative. The AHC component can therefore exceed one. PIP's own negative-welfare preprocessing is not reproduced here, and these components are not an official DWP severity estimate. + +For a person-based population index, map each household's component to every person and take the existing survey-weighted **mean**, not a sum or a household-weighted mean. Given an already configured `Microsimulation` named `sim`: + +```python +component = sim.calculate("squared_poverty_gap_bhc", 2025, map_to="person") +fgt2 = component.mean(skipna=False) +``` + +For example, a one-person household with survey weight 2 and component 0.25, plus a three-person household with survey weight 1 and component 1, gives `(2 * 0.25 + 3 * 1) / (2 + 3) = 0.7`. A household-weighted mean would instead give 0.5. This tiny synthetic example is not a national estimate. + +A zero or negative poverty line has an undefined normalized component, returned as `NaN`. The explicit `skipna=False` preserves that invalid-domain signal in a weighted mean rather than silently dropping people; alternatively validate that all included poverty lines are positive before aggregation. No new income, poverty-line, equivalence, survey-weight or dataset assumptions are introduced. + ## Nowcasting/forecasting @@ -85,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/book/validation/uc-deductions.md b/docs/book/validation/uc-deductions.md index 7264e11ee0..69d0e258fc 100644 --- a/docs/book/validation/uc-deductions.md +++ b/docs/book/validation/uc-deductions.md @@ -12,12 +12,14 @@ The implementation separates **latent demand** from the **statutory cap**. `uc_l Enhanced FRS 2023/24 v1.40.3, against [DWP's Universal Credit deductions statistics](https://www.gov.uk/government/statistics/universal-credit-quarterly-statistics-29-april-2013-to-12-february-2026/universal-credit-deductions-statistics-march-2025-to-february-2026). +Shares below are of **all** UC households, not of those with deductions — the denominator both the model and DWP use. + | Statistic | Model | DWP published | |---|---|---| | Share of UC households with deductions | 47.0–47.1% | 46–47% | -| At-cap share, 25% regime (2024) | 13.4% | 13–14% (Mar–May 2025) | -| At-cap share, 15% regime (2025) | 26.4% | 21% (Jun 2025–Feb 2026) | -| Above-cap share (last resort) | 1.8% | 2% | +| At-cap share of all UC households, 25% regime (2024) | 13.4% | 13–14% (Mar–May 2025) | +| At-cap share of all UC households, 15% regime (2025) | 26.4% | 21% (Jun 2025–Feb 2026) | +| Above-cap share of all UC households (last resort) | 1.8% | 2% | | Mean monthly deduction, 25% regime | £66 | £67–68 | | Mean monthly deduction, 15% regime | £50 | £51–54 | | Mean annual gain, 25%→15% cap | £421 | £420 (gov.uk) | @@ -47,7 +49,13 @@ The latent distribution is observed in 2025 and applied to all model years as a ### The protected floor binds on last resort deductions -The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. It binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. +The `protected_floor` lever limits combined deductions and benefit cap reductions to (1 − floor) × the standard allowance. Where the current-law split already fits inside that allowance, nothing changes. Where it does not, deductions are met first from the protected award, up to the allowance, and the benefit cap reduction absorbs the rest. Both `uc_deductions` and `uc_benefit_cap_reduction_after_floor` come from one allocation (`_reduction_allocation.py`), so they agree by construction. + +JRF specifies the combined limit, not a priority between the two: their [September 2026 briefing](https://www.jrf.org.uk/social-security/a-protected-minimum-floor-in-universal-credit-to-cut-deep-child-poverty) proposes that "the sum of the 'capped amount' and any debt deductions is limited to the difference between a household's standard allowance and the level of the protected minimum floor". Debt first is the modeling choice, for three reasons. Under the Fair Repayment Rate the 15% deductions cap equals the allowance an 85% floor leaves, so cappable deductions never breach the floor on their own, and the policy JRF builds on (extending the Fair Repayment Rate "to all deductions, including the benefit cap") leaves deductions where that rate puts them. JRF's own example (standard allowance £98 a week, a £15 deduction, an £80 cap reduction, total limited to £15) is consistent with it. And it does not depend on how heavily the household is capped: if the deduction were limited by the gross cap reduction, a household capped down to a few pence would have its deduction squeezed to almost nothing even though the floor then removes most of that cap. The award is the same under either priority; only the split differs. + +Where deductions alone do exceed the allowance, the floor binds on the whole of `uc_deductions`, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions). JRF's briefing does not say whether their floor exempts those categories — its worked example involves only cappable deductions and the benefit cap — so this is a modeling choice. + +Both components report what the award actually loses, so `uc_deductions` and `uc_benefit_cap_reduction_after_floor` sum to the entitlement forgone, and the applied cap reduction never exceeds the award (a £1,000 cap reduction on a £500 award applies £500). `uc_benefit_cap_reduction`, the regulation 81 amount, itself stays gross, and so does `benefit_cap_reduction`, which adds it to the Housing Benefit reduction. Read `uc_benefit_cap_reduction_after_floor` for the cap reduction applied to a UC award under a floor reform. ### Other assumptions @@ -75,7 +83,7 @@ The `protected_floor` lever limits combined deductions and benefit cap reduction {"gov.dwp.universal_credit.deductions.protected_floor": {"2026-01-01.2030-12-31": 0.85}} ``` -A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from. +A cap of 1 − *x* is equivalent to a protected minimum floor at *x* of the standard allowance for capped deduction types. The dedicated `protected_floor` lever additionally binds on benefit cap reductions, which is where most of JRF's post-Fair-Repayment-Rate effect comes from — and, because deductions are met first, where nearly all of the modeled effect lands. ## Where the assignment lives 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 4418038221..a8f13ee526 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 @@ -104,7 +111,9 @@ gov.economic_assumptions.yoy_growth.capital_gains: - capital_gains_carried_interest gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - benunit_reported_capital +- cash_isa - corporate_wealth +- directly_held_shares - dividend_income - gross_financial_wealth - household_lifetime_isa_balance @@ -132,14 +141,29 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_rental_income - savings - savings_interest_income +- stocks_and_shares_isa - 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 +- unit_and_investment_trusts 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/contrib/policyengine/employer_ni/README.md b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md index 079c942a05..fecfda74b4 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/README.md @@ -1 +1,23 @@ -# Employer NI +# Employer NI incidence + +These parameters describe how the model allocates a change in employer National +Insurance costs. Their defaults, unchanged from 2010, are employee incidence `1`, +consumer incidence `0.5` and capital incidence `0.5`. They are adjustable modeling +assumptions; no empirical estimate is claimed for the defaults. + +For eligible earnings computed from components, employee incidence `1` applies the +computed fixed-employer-cost pay adjustment in the same model year. This changes +income tax and employee NI as well as gross pay. A supplied final +`employment_income` holder overrides that formula. The UK `Simulation` constructor +moves situation earnings into `employment_income_before_lsr`, so a constructor +field alone does not demonstrate that override. + +Setting employee incidence to `0` leaves the consumer and capital channels active +where population weights and exposure totals support them. Set all three incidence +parameters to `0` to disable these three channels. Other labor-supply responses +have separate controls. + +Report employer-NI liability, net `gov_balance` and household income effects +separately. The [assumptions page](../../../../../../docs/book/assumptions/employer-ni-incidence.md) +explains the implemented guards, aggregation, current worked example and a dated +OBR comparison. A single-household example does not establish population incidence. diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml index edb1a27015..89b8d73a41 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/capital_incidence.yaml @@ -1,6 +1,10 @@ -description: Fraction of (remaining after employee incidence) employer NI that is borne by capital. +description: >- + Share of the remaining employer-cost change allocated to capital after employee incidence; set all three incidence parameters to 0 to disable these channels. values: 2010-01-01: 0.5 metadata: label: Employer NI capital incidence unit: /1 + reference: + - title: PolicyEngine-UK implemented employer NI incidence mechanism + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_capital_incidence.py diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml index 765298c718..f23aab2df3 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/consumer_incidence.yaml @@ -1,6 +1,10 @@ -description: Fraction of (remaining after employee incidence) employer NI that is borne by consumers. +description: >- + Share of the remaining employer-cost change allocated to consumers after employee incidence; set all three incidence parameters to 0 to disable these channels. values: 2010-01-01: 0.5 metadata: label: Employer NI consumer incidence unit: /1 + reference: + - title: PolicyEngine-UK implemented employer NI incidence mechanism + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_response_consumer_incidence.py diff --git a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml index b0aaf7efeb..4925deb028 100644 --- a/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml +++ b/policyengine_uk/parameters/gov/contrib/policyengine/employer_ni/employee_incidence.yaml @@ -1,6 +1,10 @@ -description: Fraction of employer NI that is borne by employees. +description: >- + Share of the fixed-employer-cost pay change passed to employees in the same model year; set all three incidence parameters to 0 to disable these channels. values: 2010-01-01: 1 metadata: label: Employer NI employee incidence unit: /1 + reference: + - title: PolicyEngine-UK implemented employer NI incidence mechanism + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py 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/dfe/care_to_learn/README.md b/policyengine_uk/parameters/gov/dfe/care_to_learn/README.md new file mode 100644 index 0000000000..4ef060ab67 --- /dev/null +++ b/policyengine_uk/parameters/gov/dfe/care_to_learn/README.md @@ -0,0 +1,29 @@ +# Care to Learn amount history + +The weekly maximum per child was £160 outside London and £175 for a young +parent whose home address was in London in the official [2014–15 EFA guide, +paragraph 3](https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/310711/Care_to_Learn_guidance.pdf#page=4), +[2015–16 guide](https://www.gov.uk/guidance/care-to-learn-guide-for-institutions) +and [2022–23 ESFA guide](https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2022-to-2023-academic-year/care-to-learn-guide-for-the-2022-to-2023-academic-year). +The [2023–24 guide, section 1](https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2023-to-2024-academic-year/care-to-learn-guide-for-the-2023-to-2024-academic-year) +explicitly increases those amounts to £180 and £195 for that academic year. +The [2026–27 conditions, sections 2.2 and 4.1](https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding) +retain the newer amounts. Intervening annual editions were not individually +verified, and this history does not establish pre-2014 rates. + +The parameter dates `2015-01-01` and `2024-01-01` are annual model anchors. +They are not certified daily academic-year commencement dates or fiscal-year +averages. The model retains its existing 30 April parameter sample: annual +model years 2015–2023 use £160/£175, and 2024 onward uses £180/£195. The guides' +publication dates, examples of course start dates and summer-retainer deadlines +do not establish a universal day on which the rate changed. No academic-year +allocation or fiscal-year blending is introduced. + +The amount formula still caps each qualifying child's annual childcare costs +at 52 times the weekly maximum and pays the benefit-unit total once. That is an +annual modelling approximation: the actual scheme funds eligible childcare +and travel based on attendance and costs. Travel, course dates and funded +weeks are not modelled. Existing eligibility proxies, including current age +rather than age when the course started, remain unchanged; this rate history +does not certify all course-funding, other-parent, immigration or other +eligibility conditions. diff --git a/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml b/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml index 7fd191166c..8a416223ec 100644 --- a/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml +++ b/policyengine_uk/parameters/gov/dfe/care_to_learn/amount.yaml @@ -6,6 +6,14 @@ in_london: period: week label: Maximum weekly amount per child in London reference: + - title: EFA, Care to Learn guide 2014 to 2015, paragraph 3 (physical page 4) + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/310711/Care_to_Learn_guidance.pdf#page=4 + - title: ESFA, Care to Learn guide for institutions, 2015 to 2016 + href: https://www.gov.uk/guidance/care-to-learn-guide-for-institutions + - title: ESFA, Care to Learn guide 2022 to 2023, section 1 + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2022-to-2023-academic-year/care-to-learn-guide-for-the-2022-to-2023-academic-year + - title: ESFA, Care to Learn guide 2023 to 2024, section 1 (rate increase) + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2023-to-2024-academic-year/care-to-learn-guide-for-the-2023-to-2024-academic-year - title: ESFA, Care to Learn 2026 to 2027 academic year conditions of grant funding, sections 2.2 and 4.1 href: https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding - title: Documentation for Care to Learn scheme @@ -13,6 +21,8 @@ in_london: - title: House of Commons Library – Paying for Childcare in England (12 July 2024) href: https://researchbriefings.files.parliament.uk/documents/CBP-8054/CBP-8054.pdf#page=43 values: + # Annual model anchors; see README.md for the academic-year approximation. + 2015-01-01: 175 2024-01-01: 195 outside_london: @@ -22,6 +32,14 @@ outside_london: period: week label: Maximum weekly amount per child outside London reference: + - title: EFA, Care to Learn guide 2014 to 2015, paragraph 3 (physical page 4) + href: https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/310711/Care_to_Learn_guidance.pdf#page=4 + - title: ESFA, Care to Learn guide for institutions, 2015 to 2016 + href: https://www.gov.uk/guidance/care-to-learn-guide-for-institutions + - title: ESFA, Care to Learn guide 2022 to 2023, section 1 + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2022-to-2023-academic-year/care-to-learn-guide-for-the-2022-to-2023-academic-year + - title: ESFA, Care to Learn guide 2023 to 2024, section 1 (rate increase) + href: https://www.gov.uk/government/publications/care-to-learn-guide-for-the-2023-to-2024-academic-year/care-to-learn-guide-for-the-2023-to-2024-academic-year - title: ESFA, Care to Learn 2026 to 2027 academic year conditions of grant funding, sections 2.2 and 4.1 href: https://www.gov.uk/government/publications/care-to-learn-conditions-of-grant-funding/care-to-learn-academic-year-2026-to-2027-conditions-of-grant-funding - title: Documentation for Care to Learn scheme @@ -29,4 +47,6 @@ outside_london: - title: House of Commons Library – Paying for Childcare in England (12 July 2024) href: https://researchbriefings.files.parliament.uk/documents/CBP-8054/CBP-8054.pdf#page=43 values: + # Annual model anchors; see README.md for the academic-year approximation. + 2015-01-01: 160 2024-01-01: 180 diff --git a/policyengine_uk/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/ESA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml index 40f6f3fb9a..384cd7d7db 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 52620-52623 and 52685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 52671). - It is a mixed variable, though: it also holds unit trusts, which have no - costs of sale (DMG 52681), stocks and shares ISAs and employee share - options, and datasets built before private pension wealth was split out also - fold non-defined-benefit pension wealth into it, which is not capital at - all. The deduction reaches all of these until the data separate them. - Sources not counted as income-related ESA capital are ignored. + sale (DMG 52620-52623 and 52685). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 52671), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as + income-related ESA capital are ignored. metadata: label: Income-related ESA capital sources with sale expenses period: year @@ -27,13 +28,15 @@ metadata: href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=115 - title: Decision Makers' Guide chapter 52, para. 52623 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 + - title: Decision Makers' Guide chapter 52, paras. 52662-52664 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=124 - title: Decision Makers' Guide chapter 52, para. 52671 href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=126 - - title: Decision Makers' Guide chapter 52, paras. 52681 and 52685 - href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=128 + - title: Decision Makers' Guide chapter 52, paras. 52680-52681 and 52685 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=127 values: 2008-10-27: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml index 3eb35b83ef..5ab31e2faf 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -3,21 +3,26 @@ description: > Support Allowance capital. The Employment and Support Allowance Regulations 2008 treat capital as assessable unless it is disregarded under Schedule 9. The current mappings are `savings` for liquid capital, `owned_land` for land - held directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential - property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 9 - paragraph 1 disregards premises occupied as the claimant's home. - `private_pension_wealth` is excluded because Schedule 9 paragraphs 28 and 29 - disregard the value of the right to receive an occupational or personal - pension and the value of funds held under a personal pension scheme. Other - Schedule 9 disregards and the higher residential-care capital disregard are - not yet modeled because the current dataset does not isolate those states or - asset holdings. Because the dataset only provides household-level capital - stocks, the model allocates the full household total to benunits with a - reported income-related ESA award and only falls back to an adult-share - proxy when nobody in the household is on that reported claim path. That - allocation is a data approximation, not a legislated ownership rule. + held directly, the share-like sources, `other_residential_property_value` + for residential property other than the home, and + `non_residential_property_value` for other land and premises. + `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see + `share_like_capital`), so a household given only the components has them + counted and no holding counts twice. `main_residence_value` is excluded + because Schedule 9 paragraph 1 disregards premises occupied as the + claimant's home. `private_pension_wealth` is excluded because Schedule 9 + paragraphs 28 and 29 disregard the value of the right to receive an + occupational or personal pension and the value of funds held under a + personal pension scheme. Other Schedule 9 disregards and the higher + residential-care capital disregard are not yet modeled because the current + dataset does not isolate those states or asset holdings. Because the dataset + only provides household-level capital stocks, the model allocates the full + household total to benunits with a reported income-related ESA award and + only falls back to an adult-share proxy when nobody in the household is on + that reported claim path. That allocation is a data approximation, not a + legislated ownership rule. metadata: label: Income-related ESA countable capital sources period: year @@ -33,6 +38,7 @@ values: 2008-10-27: - savings - owned_land - - corporate_wealth + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml index 538833841b..78f4811cb3 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 29681), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as income-based JSA capital are ignored. + sale (DMG 29620-29623 and 29685). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 29671), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as income-based + JSA capital are ignored. metadata: label: Income-based JSA capital sources with sale expenses period: year @@ -25,13 +26,15 @@ metadata: href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Decision Makers' Guide chapter 29, para. 29671 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + - title: Decision Makers' Guide chapter 29, paras. 29680-29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: 1996-10-07: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index 6484484ab8..70311a1443 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -3,22 +3,26 @@ description: > Allowance capital. The Jobseeker's Allowance Regulations 1996 treat capital as assessable unless it is disregarded under Schedule 8. The current mappings are `savings` for liquid capital, `owned_land` for land held - directly, `corporate_wealth` as the broad available proxy for share-like - financial assets, `other_residential_property_value` for residential - property other than the home, and `non_residential_property_value` for other - land and premises. `main_residence_value` is excluded because Schedule 8 - paragraph 1 disregards premises occupied as the claimant's home. - `private_pension_wealth` is excluded because Schedule 8 paragraphs 28 and 29 - disregard the value of the right to receive an occupational or personal - pension and the value of funds held under a personal pension scheme. Other - Schedule 8 disregards, the higher residential-care capital disregard, and - the separate child-capital disregard are not yet modeled because the current - dataset does not isolate those states or asset holdings. Because the dataset - only provides household-level capital stocks, the model allocates the full - household total to benunits with a reported income-based JSA award and only - falls back to an adult-share proxy when nobody in the household is on that - reported claim path. That allocation is a data approximation, not a - legislated ownership rule. + directly, the share-like sources, `other_residential_property_value` for + residential property other than the home, and + `non_residential_property_value` for other land and premises. + `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see + `share_like_capital`), so a household given only the components has them + counted and no holding counts twice. `main_residence_value` is excluded + because Schedule 8 paragraph 1 disregards premises occupied as the + claimant's home. `private_pension_wealth` is excluded because Schedule 8 + paragraphs 28 and 29 disregard the value of the right to receive an + occupational or personal pension and the value of funds held under a + personal pension scheme. Other Schedule 8 disregards, the higher + residential-care capital disregard, and the separate child-capital disregard + are not yet modeled because the current dataset does not isolate those + states or asset holdings. Because the dataset only provides household-level + capital stocks, the model allocates the full household total to benunits + with a reported income-based JSA award and only falls back to an adult-share + proxy when nobody in the household is on that reported claim path. That + allocation is a data approximation, not a legislated ownership rule. metadata: label: Income-based JSA countable capital sources period: year @@ -34,6 +38,7 @@ values: 1996-10-07: - savings - owned_land - - corporate_wealth + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/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/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0f6b963c1e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/pension_contribution_deduction_rate.yaml @@ -0,0 +1,17 @@ +description: Carer's Allowance deducts this share of pension contributions when it calculates the net earnings and net profit that the earnings limit tests. +values: + 1996-11-25: 0.5 +metadata: + period: year + unit: /1 + label: Carer's Allowance earnings pension contribution deduction rate + economy: false + reference: + - title: The Social Security Benefit (Computation of Earnings) Regulations 1996, regulation 10(4)(b) (employed earners) + href: https://www.legislation.gov.uk/uksi/1996/2745/regulation/10 + - title: The Social Security Benefit (Computation of Earnings) Regulations 1996, regulation 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/1996/2745/regulation/13 + - title: The Social Security Benefit (Computation of Earnings) Regulations (Northern Ireland) 1996, regulation 10(4)(b) (employed earners) + href: https://www.legislation.gov.uk/nisr/1996/520/regulation/10 + - title: The Social Security Benefit (Computation of Earnings) Regulations (Northern Ireland) 1996, regulation 13(1)(b)(ii), (4)(c) and (10)(b) (self-employed earners) + href: https://www.legislation.gov.uk/nisr/1996/520/regulation/13 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml new file mode 100644 index 0000000000..9a36d00ec6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/couple/aged_from_cutoff.yaml @@ -0,0 +1,30 @@ +description: Personal allowance for Housing Benefit for a couple where both members attained State Pension age on or after the pension-age cutoff (1 April 2021). +values: + 2021-04-01: 270.3 + 2022-04-01: 278.7 + 2023-04-01: 306.85 + 2024-04-01: 332.95 + 2025-04-01: 346.6 + 2026-04-01: 363.25 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit couple personal allowance, State Pension age reached from April 2021 + uprating: gov.benefit_uprating_cpi + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(2)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2022-04-11 (set by the Social Security Benefits Up-rating Order 2022, SI 2022/292) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2022-04-11 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2023-04-10 (set by the Social Security Benefits Up-rating Order 2023, SI 2023/316) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2023-04-10 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2024-04-08 (set by the Social Security Benefits Up-rating Order 2024, SI 2024/242) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2024-04-08 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as in force on 2025-04-07 (set by the Social Security Benefits Up-rating Order 2025, SI 2025/295) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2025-04-07 + - title: SI 2006/214, Schedule 3, paragraph 1(2)(c), as substituted from April 2026 by the Social Security Benefits Up-rating Order 2026 (SI 2026/148), Schedule 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: Benefit and pension rates 2026 to 2027 (2025-26 and 2026-27 rates) + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml new file mode 100644 index 0000000000..17e5e79a24 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/lone_parent/aged_from_cutoff.yaml @@ -0,0 +1,30 @@ +description: Personal allowance for Housing Benefit for a lone parent who attained State Pension age on or after the pension-age cutoff (1 April 2021). +values: + 2021-04-01: 177.1 + 2022-04-01: 182.6 + 2023-04-01: 201.05 + 2024-04-01: 218.15 + 2025-04-01: 227.1 + 2026-04-01: 238 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit lone parent personal allowance, State Pension age reached from April 2021 + uprating: gov.benefit_uprating_cpi + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(1)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2022-04-11 (set by the Social Security Benefits Up-rating Order 2022, SI 2022/292) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2022-04-11 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2023-04-10 (set by the Social Security Benefits Up-rating Order 2023, SI 2023/316) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2023-04-10 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2024-04-08 (set by the Social Security Benefits Up-rating Order 2024, SI 2024/242) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2024-04-08 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2025-04-07 (set by the Social Security Benefits Up-rating Order 2025, SI 2025/295) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2025-04-07 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as substituted from April 2026 by the Social Security Benefits Up-rating Order 2026 (SI 2026/148), Schedule 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: Benefit and pension rates 2026 to 2027 (2025-26 and 2026-27 rates) + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml new file mode 100644 index 0000000000..0f78e4b9b5 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/pension_age_cutoff.yaml @@ -0,0 +1,13 @@ +description: Since April 2021, pension-age Housing Benefit personal allowances depend on when the claimant and any partner attained State Pension age. The higher allowances apply if the claimant or partner attained it before this date, written as a YYYYMMDD number; otherwise the lower allowances apply. Other members of the benefit unit do not count. +values: + 2021-04-01: 20210401 +metadata: + unit: date + label: Housing Benefit pension-age personal allowance cutoff + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 (SI 2006/214), Schedule 3, paragraph 1 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml new file mode 100644 index 0000000000..5c446ef80a --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/allowances/single/aged_from_cutoff.yaml @@ -0,0 +1,30 @@ +description: Personal allowance for Housing Benefit for a single claimant who attained State Pension age on or after the pension-age cutoff (1 April 2021). +values: + 2021-04-01: 177.1 + 2022-04-01: 182.6 + 2023-04-01: 201.05 + 2024-04-01: 218.15 + 2025-04-01: 227.1 + 2026-04-01: 238 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit single personal allowance, State Pension age reached from April 2021 + uprating: gov.benefit_uprating_cpi + reference: + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) (Amendment) Regulations 2021 (SI 2021/188), regulation 2(3), inserting paragraph 1(1)(c) of Schedule 3 to SI 2006/214 from 1 April 2021 + href: https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2022-04-11 (set by the Social Security Benefits Up-rating Order 2022, SI 2022/292) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2022-04-11 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2023-04-10 (set by the Social Security Benefits Up-rating Order 2023, SI 2023/316) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2023-04-10 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2024-04-08 (set by the Social Security Benefits Up-rating Order 2024, SI 2024/242) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2024-04-08 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as in force on 2025-04-07 (set by the Social Security Benefits Up-rating Order 2025, SI 2025/295) + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3/2025-04-07 + - title: SI 2006/214, Schedule 3, paragraph 1(1)(c), as substituted from April 2026 by the Social Security Benefits Up-rating Order 2026 (SI 2026/148), Schedule 6 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/3 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 (SR 2006/406), Schedule 4, paragraph 1 (the same amounts) + href: https://www.legislation.gov.uk/nisr/2006/406/schedule/4 + - title: Benefit and pension rates 2026 to 2027 (2025-26 and 2026-27 rates) + href: https://www.gov.uk/government/publications/benefit-and-pension-rates-2026-to-2027/proposed-benefit-and-pension-rates-2026-to-2027 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml index f57c9f5bd7..3c48fff910 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -7,14 +7,16 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (HBGM BW1 Annex F and BP1 F2.03). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (HBGM W1.513). It - is a mixed variable, though: it also holds unit trusts, which have no costs - of sale (HBGM W1.530-W1.531), stocks and shares ISAs and employee share - options, and datasets built before private pension wealth was split out also - fold non-defined-benefit pension wealth into it, which is not capital at - all. The deduction reaches all of these until the data separate them. - Sources not counted as Housing Benefit capital are ignored. + sale (HBGM BW1 Annex F and BP1 F2.03). + `share_like_capital_with_sale_expenses` is listed: the part of share-like + capital whose sale incurs expenses, namely directly held shares, valued less + 10% for selling costs (HBGM W1.513 and P1.513), and all of + `corporate_wealth` on datasets that do not itemise it. Unit and investment + trusts and stocks and shares ISAs are valued without the deduction; + `gov.dwp.share_like_capital.without_sale_expenses` lists them, with the + guidance and the approximation for investment trusts, which the survey + cannot separate from unit trusts. Sources not counted as Housing Benefit + capital are ignored. metadata: label: Housing Benefit capital sources with sale expenses period: year @@ -40,6 +42,8 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 - title: Housing Benefit Guidance Manual BP1, paras. P1.513 and P1.530-P1.531 href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=38 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 (ISAs, parallel IS and JSA guidance) + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Housing Benefit Guidance Manual BW1, Annex F href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=93 - title: Housing Benefit Guidance Manual BP1, paras. F2.00-F2.03 @@ -49,4 +53,4 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index e313576158..bafcd46cd6 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml @@ -1,24 +1,28 @@ description: > - Model variables currently mapped to countable Housing Benefit capital. - The Housing Benefit Regulations 2006 treat the whole of a claimant's capital - as assessable unless it is disregarded under Schedule 6. The current mappings - are `savings` for liquid capital, `owned_land` for land held directly, - `corporate_wealth` as the broad available proxy for share-like financial - assets such as stocks, shares, unit trusts, and investment ISAs, - `other_residential_property_value` for residential property other than the - home, and `non_residential_property_value` for other land and premises. + Model variables currently mapped to countable Housing Benefit capital. The + Housing Benefit Regulations 2006 treat the whole of a claimant's capital as + assessable unless it is disregarded under Schedule 6. The current mappings + are `savings` for liquid capital, `owned_land` for land held directly, the + share-like sources, `other_residential_property_value` for residential + property other than the home, and `non_residential_property_value` for other + land and premises. `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, + or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where they exceed it + beyond float32 rounding (see `share_like_capital`), so a household given + only the components has them counted and no holding counts twice. `main_residence_value` is excluded because Schedule 6 paragraph 1 disregards - premises occupied as the claimant's home. `private_pension_wealth` is - excluded because Schedule 6 paragraphs 31 and 32 disregard the value of the - right to receive an occupational or personal pension and the value of funds - held under a personal pension scheme (paragraph 24 of Schedule 6 to the - pension-age regulations and paragraphs 32 and 33 of Schedule 7 to the - Northern Ireland regulations make the same disregard). Datasets built before - private pension wealth was split out of `corporate_wealth` still carry it - inside that countable bucket. Other Schedule 6 disregards within these broad - asset classes are not yet split out separately. Because the - dataset only provides household-level capital stocks, the model allocates - them across benunits using an adult-share proxy. + premises occupied as the claimant's home (paragraph 26 of Schedule 6 to the + pension-age regulations). `private_pension_wealth` is excluded because + Schedule 6 paragraphs 31 and 32 disregard the value of the right to receive + an occupational or personal pension and the value of funds held under a + personal pension scheme (paragraph 24 of Schedule 6 to the pension-age + regulations and paragraphs 32 and 33 of Schedule 7 to the Northern Ireland + regulations make the same disregard). Datasets built before private pension + wealth was split out of `corporate_wealth` still carry it inside + `share_like_capital`. Other Schedule 6 disregards within these broad asset + classes are not yet split out separately. Because the dataset only provides + household-level capital stocks, the model allocates them across benunits + using an adult-share proxy. metadata: label: Housing Benefit countable capital sources period: year @@ -34,14 +38,15 @@ metadata: href: https://www.legislation.gov.uk/nisr/2006/405/schedule/7/paragraph/32 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 44(1) href: https://www.legislation.gov.uk/uksi/2006/214/regulation/44 - - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 1 - href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/1 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 26 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/26 - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 Schedule 6 para. 24 href: https://www.legislation.gov.uk/uksi/2006/214/schedule/6/paragraph/24 values: 2006-04-10: - savings - owned_land - - corporate_wealth + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml index baa3504bda..ef18e218f9 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -6,14 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 29620-29623 and 29685). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 29671). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 29681), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Income Support capital are ignored. + sale (DMG 29620-29623 and 29685). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 29671), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as Income + Support capital are ignored. metadata: label: Income Support capital sources with sale expenses period: year @@ -25,13 +26,15 @@ metadata: href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 - title: Decision Makers' Guide chapter 29, paras. 29620-29623 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Decision Makers' Guide chapter 29, paras. 29662-29664 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=135 - title: Decision Makers' Guide chapter 29, para. 29671 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=137 - - title: Decision Makers' Guide chapter 29, paras. 29681 and 29685 + - title: Decision Makers' Guide chapter 29, paras. 29680-29681 and 29685 href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=139 values: 2010-01-01: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index 5b28814633..06c779b9f0 100644 --- a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml @@ -3,6 +3,11 @@ description: > `private_pension_wealth` is excluded because Schedule 10 paragraphs 23 and 23A disregard the value of the right to receive an occupational or personal pension and the value of funds held under a personal pension scheme. + `share_like_capital_with_sale_expenses` and `share_like_capital_without_sale_expenses` split `share_like_capital`, which is `corporate_wealth` as supplied, or the sum of its + itemised components (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding (see + `share_like_capital`), so a household given only the components has them + counted and no holding counts twice. metadata: label: Income Support capital sources period: year @@ -22,6 +27,7 @@ values: 2010-01-01: - savings - owned_land - - corporate_wealth + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses - other_residential_property_value - non_residential_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..09b055db92 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,13 @@ +description: Income Support deducts this share of pension contributions when it calculates a claimant's net earnings and a self-employed earner's net profit. +values: + 1988-04-11: 0.5 +metadata: + period: year + unit: /1 + label: Income Support net earnings pension contribution deduction rate + economy: false + reference: + - title: The Income Support (General) Regulations 1987, regulation 36(3)(b) (employed earners) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/36 + - title: The Income Support (General) Regulations 1987, regulation 38(1)(b)(ii), (3)(c) and (9)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/38 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml new file mode 100644 index 0000000000..6e498ee614 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/withdrawal_rate.yaml @@ -0,0 +1,11 @@ +description: Share of assessable income deducted from the Income Support applicable amount. +values: + # Model anchor matching the existing means-test parameters, not legal commencement. + 2015-04-01: 1 +metadata: + period: year + unit: /1 + label: Income Support withdrawal rate + reference: + - title: Social Security Contributions and Benefits Act 1992, section 124(4) (6 April 2024) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/124/2024-04-06#section-124-4 diff --git a/policyengine_uk/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/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index 3da4354bd1..eb564bca84 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -14,6 +14,10 @@ values: 2002-01-01: - pension_credit_earnings - working_tax_credit + # War pensions (SPCA s.15(1)(g)) and AFCS guaranteed income payments + # (reg. 15(5)(aa)) of the claimant and partner, each payment less £10 a + # week (Sch. IV para. 1). + - pension_credit_war_pension_income - state_pension - private_pension_income - property_income_after_finance_costs @@ -26,3 +30,12 @@ values: # Supplement, which is a separate variable. - carers_allowance - carer_support_payment + # Reg. 15(1) does not list these either. Reg. 15(1)(c) excepts the constant + # attendance and exceptionally severe disablement increases of IIDB (SSCBA + # ss.104 and 105), but iidb is the whole reported payment and cannot + # separate them. Incapacity benefit's increases for a dependant other than + # a partner (reg. 15(1)(h)) are not separated either. + - iidb + - sda + - incapacity_benefit + - maternity_allowance diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml index 1dbbd6816f..dd312c18b8 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -6,14 +6,15 @@ description: > `other_residential_property_value` and `non_residential_property_value` are listed. `savings` is not listed: a bank or building society account is valued at its balance, and the costs of withdrawing money are not costs of - sale (DMG 84712-84715 and 84731). `corporate_wealth` is listed because - shares are valued less 10% for the costs of selling them (DMG 84763). It is - a mixed variable, though: it also holds unit trusts, which have no costs of - sale (DMG 84772), stocks and shares ISAs and employee share options, and - datasets built before private pension wealth was split out also fold non- - defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. Sources not - counted as Pension Credit capital are ignored. + sale (DMG 84712-84715 and 84731). `share_like_capital_with_sale_expenses` is + listed: the part of share-like capital whose sale incurs expenses, namely + directly held shares, valued less 10% for selling costs (DMG 84763), and all + of `corporate_wealth` on datasets that do not itemise it. Unit and + investment trusts and stocks and shares ISAs are valued without the + deduction; `gov.dwp.share_like_capital.without_sale_expenses` lists them, + with the guidance and the approximation for investment trusts, which the + survey cannot separate from unit trusts. Sources not counted as Pension + Credit capital are ignored. metadata: label: Pension Credit capital sources with sale expenses period: year @@ -27,13 +28,15 @@ metadata: href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 - title: Decision Makers' Guide chapter 84, para. 84731 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=102 + - title: Decision Makers' Guide chapter 84, paras. 84748-84750 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=105 - title: Decision Makers' Guide chapter 84, para. 84763 href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=107 - - title: Decision Makers' Guide chapter 84, para. 84772 - href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=109 + - title: Decision Makers' Guide chapter 84, paras. 84771-84772 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=108 values: 2003-10-06: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 0761c1b0f3..f979e88fc9 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml @@ -5,7 +5,11 @@ description: > (Part I) disregard the value of the right to receive income from an occupational or personal pension scheme or under a retirement annuity contract; the notional income rule for undrawn money purchase pots - (regulation 18) is not modelled. + (regulation 18) is not modelled. `share_like_capital` is `corporate_wealth` + as supplied, or the sum of its itemised components (`directly_held_shares`, + `unit_and_investment_trusts`, `stocks_and_shares_isa`) where they exceed it + beyond float32 rounding (see `share_like_capital`), so a household given + only the components has them counted and no holding counts twice. metadata: label: Pension Credit capital sources unit: list @@ -25,4 +29,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value - - corporate_wealth + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml deleted file mode 100644 index bedb5dd94b..0000000000 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/income/savings_credit_excluded_sources.yaml +++ /dev/null @@ -1,12 +0,0 @@ -description: Income sources which are deducted from income before Savings Credit calculations. -metadata: - economy: false - propagate_metadata_to_children: true - reference: - - href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/9 - title: The State Pension Credit Regulations 2002 reg. 9 - unit: currency-GBP -values: - 2002-01-01: - - jsa_contrib - - esa_contrib diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml new file mode 100644 index 0000000000..1f0a5066ac --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/war_pension_disregard.yaml @@ -0,0 +1,14 @@ +description: Weekly amount of each war disablement pension, war widow's or widower's pension and Armed Forces Compensation Scheme guaranteed income payment disregarded from income for Pension Credit. +values: + 2003-10-06: 10 +metadata: + unit: currency-GBP + period: week + label: Pension Credit war pension disregard + reference: + - title: State Pension Credit Regulations 2002 Sch. IV para. 1 + href: https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/1 + - title: State Pension Credit Regulations 2002 reg. 17(7) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/17 + - title: State Pension Credit Regulations 2002 reg. 1(1) (in force 6 October 2003) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/1 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml index e6fe669bbd..145e714047 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml @@ -1,12 +1,20 @@ description: Income sources which are deducted from Guarantee Credit income before Savings Credit calculations. metadata: + label: Savings Credit non-qualifying income sources propagate_metadata_to_children: true reference: - href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/9 title: The State Pension Credit Regulations 2002 reg. 9 - unit: currency-GBP + unit: list values: 2002-01-01: + # Reg. 9(c) and (g). - jsa_contrib - esa_contrib + # Reg. 9(a), (b), (d) and (e). Reg. 9(f) maintenance is not modelled: + # maintenance_income does not separate spousal from child maintenance. + - working_tax_credit + - incapacity_benefit + - sda + - maternity_allowance diff --git a/policyengine_uk/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/share_like_capital/without_sale_expenses.yaml b/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml new file mode 100644 index 0000000000..fa06ccc205 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/share_like_capital/without_sale_expenses.yaml @@ -0,0 +1,29 @@ +description: > + Itemised share-like holdings valued for every means test without the + deduction for expenses of sale. Unit trusts are valued at the bid price, + which the manager pays on withdrawal, with no costs of sale even when sold + through a broker (ADM H1673-H1674; DMG 29680-29681, 52680-52681, + 84771-84772; HBGM W1.530-W1.531). The Wealth and Assets Survey asks about + unit and investment trusts in one question, so the whole bucket is listed; + investment trusts are quoted shares, which take the deduction (ADM H1665), + and were 8.2% of the pooled funds held in stocks and shares ISAs at 5 April + 2022 and 6.9% at 5 April 2025 (HMRC ISA Table 9.6). An ISA is valued at + what the holder would get by withdrawing it, and no ISA paragraph applies + the deduction (ADM H1656-H1658; DMG 29662-29664, 52662, 84748-84750). + Everything else in share_like_capital takes the deduction: directly held + shares, and on datasets that do not itemise it, all of corporate_wealth. +metadata: + label: Share-like holdings without expenses of sale + period: year + unit: list + reference: + - title: Advice for Decision Making chapter H1, paras. H1656-H1658 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 + - title: Advice for Decision Making chapter H1, paras. H1673-H1674 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 + - title: HMRC Annual savings statistics 2026, ISA Table 9.6 + href: https://www.gov.uk/government/statistics/annual-savings-statistics-2026 +values: + 1996-10-07: + - unit_and_investment_trusts + - stocks_and_shares_isa diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml new file mode 100644 index 0000000000..4a1b580765 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml @@ -0,0 +1,13 @@ +description: Whether the Child Tax Credit family element requires a responsible child or qualifying young person born before the family-element cutoff. The false 2015 value anchors existing model coverage, not legal commencement. +values: + 2015-01-01: false + 2017-04-06: true +metadata: + unit: bool + period: year + label: Child Tax Credit family element birth date condition applies + reference: + - title: The Child Tax Credit (Amendment) Regulations 2017, regulations 1 and 4(b) + href: https://www.legislation.gov.uk/uksi/2017/387/regulation/4/made + - title: The Child Tax Credit (Amendment) Regulations 2017, regulation 1 + href: https://www.legislation.gov.uk/uksi/2017/387/regulation/1/made diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml new file mode 100644 index 0000000000..ae986873f6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml @@ -0,0 +1,12 @@ +description: A responsible child or qualifying young person must be born strictly before this YYYYMMDD date for the Child Tax Credit family element, when its birth date condition applies. +values: + 2017-04-06: 20170406 +metadata: + unit: date + period: year + label: Child Tax Credit family element cutoff date of birth + reference: + - title: The Child Tax Credit Regulations 2002, regulation 7(2)(a) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/7 + - title: Tax Credits Act 2002, section 9(2)(a) + href: https://www.legislation.gov.uk/ukpga/2002/21/section/9 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml new file mode 100644 index 0000000000..7d80b22d1e --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age.yaml @@ -0,0 +1,9 @@ +description: Minimum age for the specified Working Tax Credit 16-hour and parental routes. +values: + # Joint-claim variations inserted by SI 2012/848 from 6 April 2012. + 2012-04-06: 16 +metadata: + unit: year + reference: + title: The Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002, regulation 4 + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/4/2024-04-06 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml new file mode 100644 index 0000000000..6a50fc0c40 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/working_tax_credit/min_age_without_children_or_disability.yaml @@ -0,0 +1,9 @@ +description: Minimum age for the ordinary Working Tax Credit 30-hour route without children or a qualifying disability. +values: + # Joint-claim variations inserted by SI 2012/848 from 6 April 2012. + 2012-04-06: 25 +metadata: + unit: year + reference: + title: The Working Tax Credit (Entitlement and Maximum Rate) Regulations 2002, regulation 4 + href: https://www.legislation.gov.uk/uksi/2002/2005/regulation/4/2024-04-06 diff --git a/policyengine_uk/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/dwp/universal_credit/deductions/cap.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml index 9a62d98a81..bfe577cc36 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/cap.yaml @@ -1,8 +1,8 @@ -description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). +description: Maximum total deductions from a Universal Credit award, as a fraction of the standard allowance. Schedule 6 paragraph 4(1) of SI 2013/380 sets a statutory ceiling of 40%; within it, DWP sets the operational cap administratively - 30% from October 2019 (Budget 2018), 25% from 12 April 2021 (announced for October 2021 at Budget 2020, brought forward at Budget 2021; DWP Annual Report and Accounts 2020-21), and 15% (the Fair Repayment Rate) from 30 April 2025, which required no legislative change. Last resort deductions (housing and fuel arrears hardship deductions) and child maintenance deductions - separate categories in DWP guidance - may each exceed the cap (Explanatory Memorandum to SI 2025/387, paragraphs 5.10 to 5.15). values: 2015-04-01: 0.4 2019-10-01: 0.3 - 2021-04-01: 0.25 + 2021-04-12: 0.25 2025-04-30: 0.15 metadata: label: UC deductions cap @@ -23,5 +23,7 @@ metadata: href: https://assets.publishing.service.gov.uk/media/5e68d0dce90e070ac74f08ea/Budget_2020_Web_Accessible_Complete.pdf#page=73 - title: HM Treasury, Budget 2021 (25% cap brought forward to April 2021) href: https://www.gov.uk/government/publications/budget-2021-documents/budget-2021-html + - title: DWP Annual Report and Accounts 2020-21, p.57 (cap reduced from 30% to 25% from 12 April 2021) + href: https://assets.publishing.service.gov.uk/media/60fa97498fa8f5043411d83d/dwp-annual-report-and-accounts-2020-2021-web-ready.pdf#page=58 - title: Universal Credit change brings £420 boost to over a million households (Fair Repayment Rate, 30 April 2025) href: https://www.gov.uk/government/news/universal-credit-change-brings-420-boost-to-over-a-million-households diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml index 075b067fe6..47e5da327b 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/minimum_payable.yaml @@ -1,4 +1,4 @@ -description: Minimum Universal Credit payable after deductions, per assessment period. Schedule 6 of SI 2013/380 provides that deductions must not reduce the amount payable to the claimant below one penny. +description: Minimum Universal Credit payable after deductions, per assessment period. Schedule 6, paragraph 3(1)(a) of SI 2013/380 provides that deductions must not reduce the amount payable to the claimant below one penny. values: 2015-04-01: 0.01 metadata: @@ -6,5 +6,5 @@ metadata: unit: currency-GBP period: month reference: - - title: SI 2013/380, Schedule 6 (deductions from benefit and direct payment to third parties) - href: https://www.legislation.gov.uk/uksi/2013/380/schedule/6 + - title: SI 2013/380, Schedule 6, paragraph 3(1)(a) (deductions must leave at least one penny payable) + href: https://www.legislation.gov.uk/uksi/2013/380/schedule/6/paragraph/3 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml index 50fbf3aca3..490992509e 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/deductions/protected_floor.yaml @@ -1,4 +1,4 @@ -description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. The modeled floor binds on all deductions, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its worked example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. +description: Protected minimum floor in Universal Credit, as a fraction of the standard allowance. When above zero, combined deductions and benefit cap reductions are limited to (1 - floor) times the standard allowance, following JRF's proposal design (their worked example limits the reduction itself to 15% of the standard allowance). Zero - the value under current law, which has no such floor - leaves reductions unlimited. Setting 0.85 models JRF's proposal; this reform-lever parameter encodes no current-law value other than zero. Where the current-law split already fits inside the allowance nothing changes; where it does not, deductions are met first, up to the allowance, and the benefit cap reduction absorbs the rest (uc_benefit_cap_reduction_after_floor). JRF fixes the combined limit, not the priority between the two, so this ordering is a modeling choice; it reproduces JRF's September 2026 example, where a 15% deduction survives and the cap reduction goes. Where deductions do exceed it, the modeled floor binds on all of them, including the above-cap excess that current law exempts from the deductions cap (last resort and child maintenance deductions) - JRF's briefing does not state whether their floor exempts those categories, and its example involves only cappable deductions and the benefit cap, so treating the floor as binding on everything is a modeling choice rather than a stated feature of the proposal. values: 2015-04-01: 0 metadata: @@ -6,5 +6,5 @@ metadata: unit: /1 period: year reference: - - title: JRF, A protected minimum floor in Universal Credit (October 2025 briefing; proposal design source, not current law) - href: https://www.jrf.org.uk/social-security/protected-minimum-floor-in-universal-credit-new-policy-targeting-hardship + - title: JRF, A protected minimum floor in Universal Credit to cut deep child poverty (11 September 2026, replacing the 9 October 2025 briefing; proposal design source, not current law) + href: https://www.jrf.org.uk/social-security/a-protected-minimum-floor-in-universal-credit-to-cut-deep-child-poverty diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml new file mode 100644 index 0000000000..8821f5f5e4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/child/severely_disabled/benefit_amount_tolerance.yaml @@ -0,0 +1,10 @@ +description: Weekly tolerance for the modeled paid-amount proxy for qualifying DLA care and PIP daily living awards in the Universal Credit higher disabled child condition. This is an existing input approximation, not a statutory entitlement threshold. +values: + 2015-01-01: 10 +metadata: + unit: currency-GBP + period: week + label: UC higher disabled child benefit amount proxy tolerance + reference: + - title: Retained PolicyEngine-UK DLA and PIP paid-amount input convention + href: https://github.com/PolicyEngine/policyengine-uk/blob/b4dbaf57a4ebb1d1f0ba5f7a384795422828db75/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml new file mode 100644 index 0000000000..a59a386f11 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/elements/housing/non_dep_deduction/child_responsibility_age_limit.yaml @@ -0,0 +1,14 @@ +description: A non-dependant responsible for a child below this age is exempt from Universal Credit housing cost contributions. +values: + 2013-04-29: 5 +metadata: + unit: year + period: year + label: Universal Credit housing contribution child responsibility age limit + reference: + - title: Universal Credit Regulations 2013, Schedule 4 paragraph 16(2)(i) + href: https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16 + - title: Universal Credit Regulations 2013, regulation 4 + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/4 + - title: Universal Credit Regulations 2013, regulation 1 (commencement on 29 April 2013) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/1/made diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml index 5340959d89..5797b232a5 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -1,18 +1,21 @@ description: > Universal Credit financial capital sources (capital.sources) whose sale would incur expenses, so that they are valued at 90% of market value. - `corporate_wealth` is listed because shares are valued less 10% for the - costs of selling them (ADM H1665). It is a mixed variable, though: it also - holds unit trusts, which have no costs of sale (H1674), stocks and shares - ISAs, valued at their withdrawal value (H1656), and employee share options, - and datasets built before private pension wealth was split out also fold - non-defined-benefit pension wealth into it, which is not capital at all. The - deduction reaches all of these until the data separate them. `savings` is - not listed: the 10% applies where selling normally needs an agent, broker, - solicitor or accountant (H1604-H1605), the costs of withdrawing money are - not costs of sale (H1607), and an account is valued at its balance (H1675). - Countable property is valued through property_sources with the same rate, - and real property always has costs of sale (H1606). + `share_like_capital_with_sale_expenses` is listed: the part of share-like + capital whose sale incurs expenses, namely directly held shares, valued less + 10% for the costs of selling them (ADM H1665), and all of `corporate_wealth` + on datasets that do not itemise it (which then still folds in unit trusts, + ISAs and, on datasets built before private pension wealth was split out, + non-defined-benefit pension wealth). Unit and investment trusts and stocks + and shares ISAs are valued without the deduction; + `gov.dwp.share_like_capital.without_sale_expenses` lists them, with the + guidance and the approximation for investment trusts, which the survey + cannot separate from unit trusts. `savings` is not listed: the 10% applies + where selling normally needs an agent, broker, solicitor or accountant + (H1604-H1605), the costs of withdrawing money are not costs of sale (H1607), + and an account is valued at its balance (H1675). Countable property is + valued through property_sources with the same rate, and real property always + has costs of sale (H1606). metadata: label: Universal Credit capital sources with sale expenses period: year @@ -32,4 +35,4 @@ metadata: href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 values: 2013-04-29: - - corporate_wealth + - share_like_capital_with_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index e9d973704b..6da78e9ad1 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml @@ -1,32 +1,38 @@ description: > - Household financial proxies for Universal Credit: savings and corporate - financial wealth. A nonnegative benunit_reported_capital replaces these - financial proxies and covered person-level sources, including a Lifetime ISA. - Each benefit unit's complete uc_reported_capital override, otherwise its - observed financial total, reduces this financial pool. Subtracting complete - overrides retains the inherited allocation convention. An override precedes - regulation 77 company substitution and includes its claimant's and partner's - holdings: consume at least their disregarded holdings after applying one - household corporate_wealth budget without removing them again from the pool. - First clip each person's holding to [0, max(0, corporate_wealth)], with + Household financial proxies for Universal Credit: savings and share-like + financial wealth, in two parts: `share_like_capital_with_sale_expenses` + (directly held shares, and all of an unitemised `corporate_wealth`) and + `share_like_capital_without_sale_expenses` (unit and investment trusts and + stocks and shares ISAs). Together they are `share_like_capital`: + `corporate_wealth` as supplied, or the sum of its itemised components + (`directly_held_shares`, `unit_and_investment_trusts`, + `stocks_and_shares_isa`) where they exceed it beyond float32 rounding. A + nonnegative benunit_reported_capital replaces these financial proxies and + covered person-level sources, including a Lifetime ISA. Each benefit unit's + complete uc_reported_capital override, otherwise its observed financial + total, reduces this financial pool. Subtracting complete overrides retains + the inherited allocation convention. An override precedes regulation 77 + company substitution and includes its claimant's and partner's holdings: + consume at least their disregarded holdings after applying one household + share-like capital budget without removing them again from the pool. First + clip each person's holding to [0, max(0, the part with sale expenses)], with positive infinity mapped to the upper bound and negative infinity to zero. - If total bounded holdings exceed nonnegative corporate_wealth, scale each + If total bounded holdings exceed that nonnegative budget, scale each person's bounded holding proportionally before override floors and separate - pool deductions. - This is a defensive rule for inconsistent inputs, not a statutory determination; - regulation 77 substitution in the owner's own assessment is unchanged. - The residual is allocated by - claimant/partner counts to benefit units with neither a financial observation - nor a complete override, - including units that do not receive UC. This allocation is an assumption - about unobserved ownership, not a statutory rule. Countable property is - valued and shared independently through property_sources across ALL - claimant/partner adults, with complete-override units consuming their shares. - Main-home property and the named private_pension_wealth input are excluded - under Schedule 10 paragraphs 1 and 10. For missing-report households, the - legacy corporate_wealth proxy can still contain non-DB pension wealth that - is not separately identified and removed. Asset-specific Schedule 10 disregards - within these broad financial classes are not separately observed. + pool deductions. This is a defensive rule for inconsistent inputs, not a + statutory determination; regulation 77 substitution in the owner's own + assessment is unchanged. The residual is allocated by claimant/partner + counts to benefit units with neither a financial observation nor a complete + override, including units that do not receive UC. This allocation is an + assumption about unobserved ownership, not a statutory rule. Countable + property is valued and shared independently through property_sources across + ALL claimant/partner adults, with complete-override units consuming their + shares. Main-home property and the named private_pension_wealth input are + excluded under Schedule 10 paragraphs 1 and 10. For missing-report + households, the legacy corporate_wealth proxy can still contain non-DB + pension wealth that is not separately identified and removed. Asset-specific + Schedule 10 disregards within these broad financial classes are not + separately observed. metadata: label: Universal Credit financial capital proxy sources period: year @@ -41,4 +47,5 @@ metadata: values: 2013-04-29: - savings - - corporate_wealth + - share_like_capital_with_sale_expenses + - share_like_capital_without_sale_expenses diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml index ff0d282452..f49cc9ef8c 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml @@ -19,7 +19,13 @@ description: > reads from `self_employment_income`. Whether other letting can be a trade is unsettled: DWP staff guidance treats a self-employed landlord's rent as self-employed income, while letting is not a trade for tax, and the model - does not identify such landlords. + does not identify such landlords. Incapacity Benefit and Severe Disablement + Allowance count from 16 June 2014 under the GB transitional regulations; + the NI transitional counterpart supports them in commenced NI claims. + The 2013 list is preserved. Annual parameters from 2015 use the value at + 30 April: the June entry does not resolve the part-year 2014 award or date + Northern Ireland commencement. SDA uses the existing maximum-rate proxy, + and Carer's Allowance counts after its overlapping-benefit adjustment. metadata: economy: false label: Universal Credit unearned income sources @@ -29,6 +35,12 @@ metadata: reference: - title: The Universal Credit Regulations 2013 reg. 66(1) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/66 + - title: The Universal Credit (Transitional Provisions) Regulations 2014 reg. 25(1) (Incapacity Benefit and Severe Disablement Allowance) + href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/25 + - title: S.I. 2014/1230 reg. 1 as made (commencement on 16 June 2014) + href: https://www.legislation.gov.uk/uksi/2014/1230/regulation/1/made + - title: The Universal Credit (Transitional Provisions) Regulations (Northern Ireland) 2016 reg. 26(1) + href: https://www.legislation.gov.uk/nisr/2016/226/regulation/26 - title: The Universal Credit Regulations 2013 reg. 67 (retirement pension income) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/67 - title: State Pension Credit Act 2002 s. 16(1)(za), (a) (State Pension counts as retirement pension income) @@ -63,6 +75,17 @@ values: - private_pension_income - state_pension - uc_tariff_income + 2014-06-16: + - carers_allowance + - jsa_contrib + - esa_contrib + - maternity_allowance + - iidb + - private_pension_income + - state_pension + - uc_tariff_income + - incapacity_benefit + - sda 2023-11-19: - carers_allowance - uc_unearned_carer_support_payment @@ -73,3 +96,5 @@ values: - private_pension_income - state_pension - uc_tariff_income + - incapacity_benefit + - sda diff --git a/policyengine_uk/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..275812cdd4 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -1,5 +1,5 @@ -"""Index the personal allowance, the basic rate limit and the equivalent NICs -thresholds by September CPI once their freeze ends. +"""Index the personal allowance, basic and savings starting-rate limits, and +the equivalent NICs thresholds by September CPI once their freeze ends. Income tax ---------- @@ -12,6 +12,13 @@ the same percentage and round the result up to a multiple of £100. Each year starts from the rounded amount of the year before. +s21(3A) similarly raises the savings starting-rate limit, but rounds the +full result up to a multiple of £10. Finance Act 2026 s9 fixes that annual +limit at £5,000 through 2030-31 and disapplies s21 for those years. From +2031-32, ``index_savings_starting_rate_limit`` applies the statutory step +to the previous rounded year's limit. Future September CPI is a model +forecast, not a known statutory future amount. + Finance Act 2021 s5, as amended by Finance Act 2023 s5 and Finance Act 2026 s10, sets the basic rate limit at £37,700 and the personal allowance at £12,570 for 2022-23 to 2030-31 and disapplies ss21 and 57 for those years. @@ -85,7 +92,7 @@ (a reform dict, ``parameter_changes``, ``Reform.from_dict``) writes its changes onto the parameters before they are processed, as does a simulation modifier that resets and reprocesses them (as ``no_economic_assumptions`` does), so the -values are rebuilt with the change: a changed CPI forecast moves all seven, +values are rebuilt with the change: a changed CPI forecast moves all eight, and a change to the personal allowance or basic rate limit after 2030-31 also moves the aligned NICs thresholds (see ``policyengine_uk.utils.dated_reforms``). The exceptions edit the processed parameters, so they do not move the others: @@ -105,6 +112,8 @@ # ITA 2007 s57(3) Step 2 and s21(3) Step 2. PERSONAL_ALLOWANCE_MULTIPLE = Decimal(10) BASIC_RATE_LIMIT_MULTIPLE = Decimal(100) +# ITA 2007 s21(3A) Step 2. +SAVINGS_STARTING_RATE_LIMIT_MULTIPLE = Decimal(10) PENNY = Decimal("0.01") # Fiscal-year conversion reads each tax year's value at 30 April. FISCAL_YEAR_SAMPLE_MONTH_DAY = "04-30" @@ -124,6 +133,7 @@ INDEXED_THRESHOLDS = ( "gov.hmrc.income_tax.allowances.personal_allowance.amount", "gov.hmrc.income_tax.rates.uk[1].threshold", + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance", "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", @@ -162,6 +172,14 @@ def index_basic_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: return round_up_to_multiple(step_1, BASIC_RATE_LIMIT_MULTIPLE) +def index_savings_starting_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: + """ITA 2007 s21(1) and (3A): next year's annual savings starting limit.""" + if increase <= 0: + return previous + amount = previous * (1 + increase) + return round_up_to_multiple(amount, SAVINGS_STARTING_RATE_LIMIT_MULTIPLE) + + def weekly_equivalent(annual: Decimal) -> Decimal: """Annual amount over 52, to the penny, as the parameter files store it.""" return (annual / WEEKS_IN_YEAR).quantize(PENNY, ROUND_HALF_UP) @@ -311,9 +329,13 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: class_4 = parameters.gov.hmrc.national_insurance.class_4.thresholds personal_allowance_parameter = income_tax.allowances.personal_allowance.amount basic_rate_limit_parameter = income_tax.rates.uk.brackets[1].threshold + savings_starting_rate_limit_parameter = ( + income_tax.rates.savings_starter_rate.allowance + ) indexed = [ personal_allowance_parameter, basic_rate_limit_parameter, + savings_starting_rate_limit_parameter, class_1.primary_threshold, class_1.upper_earnings_limit, class_1.secondary_threshold, @@ -333,6 +355,12 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: basic_rate_limit = tax_year_path( basic_rate_limit_parameter, years, increases, index_basic_rate_limit ) + savings_starting_rate_limit = tax_year_path( + savings_starting_rate_limit_parameter, + years, + increases, + index_savings_starting_rate_limit, + ) higher_rate_threshold = { year: personal_allowance[year] + basic_rate_limit[year] for year in years } @@ -342,6 +370,9 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: write_unstated_tax_years(personal_allowance_parameter, personal_allowance) write_unstated_tax_years(basic_rate_limit_parameter, basic_rate_limit) + write_unstated_tax_years( + savings_starting_rate_limit_parameter, savings_starting_rate_limit + ) write_unstated_tax_years(class_4.lower_profits_limit, personal_allowance) write_unstated_tax_years(class_4.upper_profits_limit, higher_rate_threshold) write_unstated_tax_years( diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/README.md index 3a6da4412d..c21b449d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/README.md @@ -1 +1,44 @@ # Income Tax + +## Dividend bands + +[Income Tax Act 2007 s. 13](https://www.legislation.gov.uk/ukpga/2007/3/section/13) +ties the dividend rates to the corresponding UK taxable-income bands, including +when determining the dividend bands for a Scottish taxpayer. +[Section 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16) places +dividends above other income. The dividend nil-rate amount uses band capacity; +it does not reduce income when determining the remaining capacity in each band. + +The dated dividend scale records these basic-rate limits: + +| Tax year beginning 6 April | Basic-rate limit | Authority | +| --- | ---: | --- | +| 2016 | £32,000 | [Finance (No. 2) Act 2015 s. 6](https://www.legislation.gov.uk/ukpga/2015/33/section/6/enacted) | +| 2017 | £33,500 | [Finance Act 2016 s. 2](https://www.legislation.gov.uk/ukpga/2016/24/section/2/enacted) | +| 2018 | £34,500 | [SI 2017/1184 art 2](https://www.legislation.gov.uk/uksi/2017/1184/article/2/made) | +| 2019 and 2020 | £37,500 | [Finance Act 2019 s. 5](https://www.legislation.gov.uk/ukpga/2019/1/section/5/enacted) | +| From 2021 | £37,700 | [SI 2021/111 art 2](https://www.legislation.gov.uk/uksi/2021/111/article/2/made) | + +The additional-rate threshold falls from £150,000 to £125,140 from 6 April 2023 +under [Finance Act 2023 s. 6](https://www.legislation.gov.uk/ukpga/2023/1/section/6/enacted). +These limits apply after allowances. For example, in 2025, £49,000 of pay uses +£36,430 of taxable band capacity after the £12,570 Personal Allowance. Of a +further £2,000 of dividends, £1,270 fits in the basic band, including the £500 +dividend nil-rate amount. The charge is £770 × 8.75% + £730 × 33.75% = £313.75. + +The threshold history does not certify the pre-2016 dividend-tax-credit regime +or all historical allowance calculations. The inherited 2016 Personal Allowance +is a separate issue tracked in [#2359](https://github.com/PolicyEngine/policyengine-uk/issues/2359): +the inherited amount was £10,600 while the statutory amount is £11,000. The 2016 regression +uses £160,000 of dividends, which naturally withdraws the full allowance under +either amount. It exercises the dividend bands without selecting an allowance +through an intermediate input. Dividend rates, allowance allocation, Gift Aid +extensions and the optional extra dividend bracket retain their existing +interfaces. + +The existing fixed-order allowance allocation does not always implement the +most-beneficial allocation required by ITA 2007 s. 25(2) for mixed wages and +dividends. The 2017, 2021 and 2026 threshold regressions also use dividend-only +income with a naturally zero Personal Allowance. The selected 2018, 2019 and +2025 mixed-income cases have independently checked allowance allocations; +they do not certify the allocation formula for other situations. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml index 74fba94512..3cb13cd423 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/dividends.yaml @@ -55,6 +55,36 @@ brackets: label: Dividends higher rate threshold values: 2015-04-01: 37_500 + 2016-04-06: + value: 32_000 + metadata: + reference: + - title: Finance (No. 2) Act 2015 s. 6(a) + href: https://www.legislation.gov.uk/ukpga/2015/33/section/6/enacted + 2017-04-06: + value: 33_500 + metadata: + reference: + - title: Finance Act 2016 s. 2 + href: https://www.legislation.gov.uk/ukpga/2016/24/section/2/enacted + 2018-04-06: + value: 34_500 + metadata: + reference: + - title: The Income Tax (Indexation) Order 2017 art 2 + href: https://www.legislation.gov.uk/uksi/2017/1184/article/2/made + 2019-04-06: + value: 37_500 + metadata: + reference: + - title: Finance Act 2019 s. 5(1) + href: https://www.legislation.gov.uk/ukpga/2019/1/section/5/enacted + 2021-04-06: + value: 37_700 + metadata: + reference: + - title: The Income Tax (Indexation) Order 2021 art 2 + href: https://www.legislation.gov.uk/uksi/2021/111/article/2/made 2026-04-06: value: 37_700 metadata: @@ -79,6 +109,12 @@ brackets: threshold: values: 2015-04-01: 150_000 + 2023-04-06: + value: 125_140 + metadata: + reference: + - title: Finance Act 2023 s. 6(3)-(4) + href: https://www.legislation.gov.uk/ukpga/2023/1/section/6/enacted 2026-04-06: value: 125_140 metadata: diff --git a/policyengine_uk/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/income_tax/reliefs/pension_contribution/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md index 724d510f14..ae3324a83b 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/pension_contribution/README.md @@ -1 +1,19 @@ # Pension contributions + +Relevant UK earnings for pension contribution relief include employer-paid, +taxable statutory sick pay and statutory maternity pay. The model holds these +payments in `employment_benefits`, separately from `employment_income`, and +includes that aggregate once when limiting relief. Pension income does not +increase the relevant-earnings limit. The existing age limit, relief-at-source +basic amount and nonnegative trading-profit rules still apply. + +This follows [Finance Act 2004 section 189](https://www.legislation.gov.uk/ukpga/2004/12/section/189) +and [HMRC PTM044100](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm044100). +The annual monetary inputs represent actual employer-paid taxable payments; +they do not establish statutory-pay eligibility or identify exceptional payments +made directly by HMRC in survey data. + +Statutory paternity pay still depends on the separate change in +[PR #2368](https://github.com/PolicyEngine/policyengine-uk/pull/2368) to include it +in the taxable employment aggregate. This change addresses the existing SSP/SMP +omission in issue #2371; it does not complete that issue's SPP dependency. diff --git a/policyengine_uk/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/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/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0e9ece3f08 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/default_scheme/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,17 @@ +description: The England Council Tax Reduction default scheme deducts this share of pension contributions when it calculates an applicant's net earnings and a self-employed earner's net profit, for pensioners and for applicants who are not pensioners and have no award of Universal Credit. The default scheme applies where a billing authority has made no scheme of its own. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: England Council Tax Reduction default scheme pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 42(2)(b) and (c) and (5)(c) (employed earners who are pensioners) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/42 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 52(3)(b) and (c) and (6)(c) (employed earners who are not pensioners) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/52 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 61(1)(b)(ii) and (c)(ii), (3)(c) and (9)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/61 + - title: Local Government Finance Act 1992 Sch. 1A para. 4 (the default scheme applies where a billing authority has made no scheme) + href: https://www.legislation.gov.uk/ukpga/1992/14/schedule/1A/paragraph/4 diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0b4b2c2b9b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,13 @@ +description: The England Council Tax Reduction scheme for pensioners deducts this share of pension contributions when it calculates an applicant's net earnings and a self-employed earner's net profit. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: England Council Tax Reduction pensioner pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 1 para. 19(2)(b) and (c) and (5)(c) (employed earners) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Sch. 1 para. 29(1)(b)(ii), (2)(c) and (8)(b) (self-employed earners) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/29 diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..213fa1d994 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,11 @@ +description: The Kingston upon Thames Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Kingston upon Thames Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: Council Tax Reduction Scheme 2026 - 2027, paras. 52(3)(b) and (c) and (6)(c) (employed earners) and 61 (self-employed earners) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..e3dd4bc680 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,11 @@ +description: The Merton Council Tax Support scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Merton Council Tax Support pension contribution deduction rate + economy: false + reference: + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026, paras. 52(3)(b) and (c) and (6)(c) (employed earners) and 61 (self-employed earners) + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf diff --git a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..3a898a844b --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,11 @@ +description: The Newham Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Newham Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: Council Tax Reduction Scheme 2026, paras. 52(3)(b) and (c) and (6)(c) (employed earners) and 61 (self-employed earners) + href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026 diff --git a/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..ebac88d7f5 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/oxford/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,15 @@ +description: The Oxford Council Tax Reduction scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant who does not receive Universal Credit. An applicant who does is assessed on the net earned income DWP provides (Appendix 3), which the model does not yet do (issue 2520). +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Oxford Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Part 1 para. 18 (50% of contributions to occupational or personal pension schemes) + href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27 + - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Annex 5 (one half of personal pension scheme payments, self-employed earners) + href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27/3 + - title: Oxford City Council Council Tax Reduction Scheme 2026 to 2027, Appendix 3 (applicants who receive Universal Credit) + href: https://www.oxford.gov.uk/council-tax-reduction/council-tax-reduction-scheme-2026-27/4 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..b3681ebec4 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,19 @@ +description: The Scotland Council Tax Reduction schemes deduct this share of pension contributions when they calculate an applicant's net earnings and a self-employed earner's net profit, for applicants of pension age and for applicants under pension age with no award of Universal Credit. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Scotland Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 33(2)(b) and (c) and (4)(c) (employed earners of pension age) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/33 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 36(1)(b)(ii), (2)(c) and (8)(b) (self-employed earners of pension age) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/36 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 50(3)(b) and (c) and (5)(c) (employed earners under pension age with no award of Universal Credit, from 1 April 2022) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/50 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 51(9) (self-employed earners under pension age with no award of Universal Credit, whose net profit reg. 51(9) takes from SSI 2012/303 regs. 37 and 38) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/51 + - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 35(3)(b) and (c) and (6)(c) and reg. 37(1)(b)(ii), (3)(c) and (9)(b) (persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..0c847d4b13 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,21 @@ +description: The Wales Council Tax Reduction scheme deducts this share of pension contributions when it calculates an applicant's net earnings and a self-employed earner's net profit, for pensioners and for applicants who are not pensioners and have no award of Universal Credit. +values: + 2013-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Wales Council Tax Reduction pension contribution deduction rate + economy: false + reference: + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 1 para. 13(2)(b) and (c) and (5)(c) (employed earners who are pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 1 para. 23(1)(b)(ii), (2)(c) and (8)(b) (self-employed earners who are pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/23 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 15(3)(b) and (c) and (6)(c) (employed earners who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Sch. 6 para. 24(1)(b)(ii) and (c)(ii), (3)(c) and (9)(b) (self-employed earners who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/24 + - title: The Council Tax Reduction Schemes (Default Scheme) (Wales) Regulations 2013 Sch. paras. 39, 49 and 58 + href: https://www.legislation.gov.uk/wsi/2013/3035/schedule + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 Sch. 1 paras. 13 and 23 and Sch. 6 paras. 15 and 24 (2013-14 schemes; revoked from 1 April 2014) + href: https://www.legislation.gov.uk/wsi/2012/3144/contents/made diff --git a/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml b/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..93b9b5309f --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/means_test/pension_contribution_deduction_rate.yaml @@ -0,0 +1,15 @@ +description: The Westminster Council Tax Support scheme deducts this share of pension contributions when it calculates the net earnings and self-employed net profit of a working-age applicant. The council's published scheme says the default scheme forms the basis of its working-age scheme and does not restate this rule, so the default scheme's one-half is used. +values: + 2026-04-01: 0.5 +metadata: + period: year + unit: /1 + label: Westminster Council Tax Support pension contribution deduction rate + economy: false + reference: + - title: Council Tax Support scheme for 2026/27, section 4 (the Default Scheme Regulations 2012 form the basis of the working-age scheme) + href: https://www.westminster.gov.uk/sites/default/files/media/documents/council-tax-support-scheme-for-2026-27.pdf + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 52(3)(b) and (c) and (6)(c) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/52 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. para. 61 + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/61 diff --git a/policyengine_uk/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/wra/land_transaction_tax/residential/README.md b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md index a95cd5ccd6..033530c048 100644 --- a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md +++ b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/README.md @@ -1 +1,45 @@ -# Residential +# Residential Land Transaction Tax + +The scales apply marginal rates to the consideration for each purchase. The +main and additional-property purchase inputs select the main and higher tables +respectively; the final Land Transaction Tax charge applies only in Wales. + +[WSI 2022/1027, regulation 7](https://www.legislation.gov.uk/wsi/2022/1027/regulation/7/made) +replaced the main residential table from 10 October 2022: + +| Portion of consideration | Rate | +| --- | ---: | +| Up to £225,000 | 0% | +| Over £225,000 to £400,000 | 6% | +| Over £400,000 to £750,000 | 7.5% | +| Over £750,000 to £1,500,000 | 10% | +| Over £1,500,000 | 12% | + +[WSI 2024/1311, regulation 6](https://www.legislation.gov.uk/wsi/2024/1311/regulation/6/made) +replaced the higher residential table from 11 December 2024: + +| Portion of consideration | Rate | +| --- | ---: | +| Up to £180,000 | 5% | +| Over £180,000 to £250,000 | 8.5% | +| Over £250,000 to £400,000 | 10% | +| Over £400,000 to £750,000 | 12.5% | +| Over £750,000 to £1,500,000 | 15% | +| Over £1,500,000 | 17% | + +The raw parameter files retain these statutory dates and the preceding history. +The annual model samples transactional parameters on 30 April. Therefore the +main table changes in model year 2023, while the higher table changes in model +year 2025. Model years 2022 and 2024 retain their respective April tables; +neither year blends the old and new rates. + +The annual Household purchase inputs have no transaction date, contract date, +election or subsequent-contract-event fields. They cannot select a transaction +later in the change year or model protected old contracts. Under +[the 2022 regulations, regulations 4–6](https://www.legislation.gov.uk/wsi/2022/1027/made), +qualifying earlier contracts require an election which must not increase tax. +Under [the 2024 regulations, regulations 4–5](https://www.legislation.gov.uk/wsi/2024/1311/made), +qualifying earlier contracts are protected without an election requirement. +Both provisions have conditions and exclusions; an earlier contract alone is +not sufficient in every case. These transaction-specific provisions are outside +the annual input contract. diff --git a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml index 3729a95570..f3d47ece70 100644 --- a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml +++ b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/higher_rate.yaml @@ -2,31 +2,37 @@ brackets: - rate: 2018-04-01: 0.03 2020-12-22: 0.04 + 2024-12-11: 0.05 threshold: 2018-04-01: 0 - rate: 2018-04-01: 0.065 2020-12-22: 0.075 + 2024-12-11: 0.085 threshold: 2018-04-01: 180000 - rate: 2018-04-01: 0.08 2020-12-22: 0.09 + 2024-12-11: 0.1 threshold: 2018-04-01: 250000 - rate: 2018-04-01: 0.105 2020-12-22: 0.115 + 2024-12-11: 0.125 threshold: 2018-04-01: 400000 - rate: 2018-04-01: 0.13 2020-12-22: 0.14 + 2024-12-11: 0.15 threshold: 2018-04-01: 750000 - rate: 2018-04-01: 0.15 2020-12-22: 0.16 + 2024-12-11: 0.17 threshold: 2018-04-01: 1500000 description: Higher residential property progressive tax scale for non-primary residences. @@ -42,4 +48,7 @@ metadata: - href: https://www.legislation.gov.uk/wsi/2020/1618/regulation/6/made title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) (Amendment) Regulations 2020 + - href: https://www.legislation.gov.uk/wsi/2024/1311/regulation/6/made + title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) (Amendment) + Regulations 2024, regulation 6, Table 2 threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml index 627eb0fb87..e1856312e3 100644 --- a/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml +++ b/policyengine_uk/parameters/gov/wra/land_transaction_tax/residential/primary.yaml @@ -7,14 +7,18 @@ brackets: 2018-04-01: 0.035 2020-07-27: null 2021-07-01: 0.035 + 2022-10-10: 0.06 threshold: 2018-04-01: 180000 2020-07-27: null 2021-07-01: 180000 + 2022-10-10: 225000 - rate: 2018-04-01: 0.05 + 2022-10-10: null threshold: 2018-04-01: 250000 + 2022-10-10: null - rate: 2018-04-01: 0.075 threshold: @@ -43,4 +47,7 @@ metadata: - href: https://www.legislation.gov.uk/wsi/2021/238/regulation/2/made title: The Land Transaction Tax (Temporary Variation of Rates and Bands for Residential Property Transactions) (Wales) (Amendment) Regulations 2021 + - href: https://www.legislation.gov.uk/wsi/2022/1027/regulation/7/made + title: The Land Transaction Tax (Tax Bands and Tax Rates) (Wales) (Amendment) + Regulations 2022, regulation 7, Table 1 threshold_unit: currency-GBP diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index 2fa867458d..11657e674c 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/adjust_budgets.py b/policyengine_uk/reforms/policyengine/adjust_budgets.py index e010c853c4..4920c34536 100644 --- a/policyengine_uk/reforms/policyengine/adjust_budgets.py +++ b/policyengine_uk/reforms/policyengine/adjust_budgets.py @@ -59,9 +59,24 @@ class adjust_budget_reform(Reform): def apply(self): simulation = self.simulation + # Read every year's allocation before writing any. Each write is + # an input that carries over to later years without their own + # input, so reading a later year after writing an earlier one + # would stack the earlier year's change on top of its own. + spending_by_year = { + time_period: simulation.calculate(budget_variable, time_period) + for time_period in change_by_year + } + for time_period, budget_change in change_by_year.items(): - spending = simulation.calculate(budget_variable, time_period) - relative_increase = budget_change / (spending.sum() / 1e9) + spending = spending_by_year[time_period] + total_spending = spending.sum() / 1e9 + if total_spending == 0: + # Nothing is allocated in this year, so there is no + # allocation to scale. Dividing by zero would write NaN + # (rejected by set_input) or infinite spending. + continue + relative_increase = budget_change / total_spending simulation.set_input( budget_variable, diff --git a/policyengine_uk/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/simulation.py b/policyengine_uk/simulation.py index adfc42f929..3b1236e053 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -30,7 +30,7 @@ UKMultiYearDataset, UKSingleYearDataset, ) -from policyengine_uk.utils.scenario import Scenario +from policyengine_uk.utils.scenario import ConstructionReformState, Scenario from policyengine_uk.data.economic_assumptions import ( extend_single_year_dataset, reset_growthfactor_uprating, @@ -51,6 +51,7 @@ capital_gains_application_metadata, ) from policyengine_uk.utils.dependencies import get_variable_dependencies +from policyengine_uk.utils.input_dataframe_export import input_dataframe from policyengine_uk.utils.supplied_inputs import ( SUPPLIED_INPUT_VARIABLES, copy_model_values, @@ -298,6 +299,7 @@ def __init__( # of this scenario's single-day leftovers while rejecting unreadable # overrides from a user modifier's separate from_reform call. self._building_dated_reforms = scenario.dated_reforms if scenario else None + self._construction_reform_state = None self.branch_name = "default" self.invalidated_caches = set() @@ -335,8 +337,10 @@ def __init__( ) if before_data_load is not None: self.apply_scenario_parameters(before_data_load) + self._construction_reform_state = ConstructionReformState(self) if before_data_load.simulation_modifier is not None: before_data_load.simulation_modifier(self) + self._construction_reform_state.observe_user_edits(self) elif after_parameters_before_data: self.apply_scenario_parameters(after_data_load) self._refresh_capital_gains_projection() @@ -365,6 +369,8 @@ def __init__( # (including single-day reform-dict keys) leave the data as built. if after_data_load is not None and not after_parameters_before_data: self.apply_scenario_parameters(after_data_load) + if self._construction_reform_state is None and self._building_dated_reforms: + self._construction_reform_state = ConstructionReformState(self) # Universal Credit reform (July 2025). Needs closer integration in the baseline, # but adding here for ease of toggling on/off via the 'active' parameter. @@ -414,10 +420,13 @@ def __init__( self.baseline._cgt_warning_enabled = False self.baseline._defer_uc_health_warning = False self.baseline._building_dated_reforms = None + self.baseline._construction_reform_state = None self.calculated_periods = [] if after_data_load is not None: + if self._construction_reform_state is not None: + self._construction_reform_state.refresh_framework(self) if after_data_load.simulation_modifier is not None: after_data_load.simulation_modifier(self) self._refresh_capital_gains_projection() @@ -427,6 +436,7 @@ def __init__( self._defer_uc_health_warning = False self._building_dated_reforms = None + self._construction_reform_state = None warn_shadowed_lcwra_reform(self) def _refresh_capital_gains_projection(self) -> None: @@ -565,6 +575,9 @@ def set_input(self, variable_name: str, period: Period, value) -> None: def clone(self, *args, **kwargs) -> "Simulation": clone = super().clone(*args, **kwargs) + clone._construction_reform_state = copy.deepcopy( + getattr(self, "_construction_reform_state", None) + ) copy_model_values(self, clone) clone._cgt_input_provenance = copy.deepcopy( getattr(self, "_cgt_input_provenance", {}) @@ -1039,7 +1052,9 @@ def to_input_dataframe( """Export the simulation's inputs as a DataFrame that ``Simulation(dataset=...)`` loads back. - policyengine-core exports each input for the periods it was set for. + Export supplied values, including overrides of variables with formulas, + for the periods they were set for. Calculated caches and values stored + by the model itself are not supplied inputs. The identifiers are also given for every whole year a column names, where a simulation built from the frame holds them, so that the frame loads back to a simulation that exports the same frame. See @@ -1049,12 +1064,12 @@ def to_input_dataframe( include_computed_variables: If True, export every stored value, calculated ones included, as policyengine-core does. """ - return with_identifiers_in_each_year( - super().to_input_dataframe( - include_computed_variables=include_computed_variables - ), - self.tax_benefit_system, + frame = ( + super().to_input_dataframe(include_computed_variables=True) + if include_computed_variables + else input_dataframe(self) ) + return with_identifiers_in_each_year(frame, self.tax_benefit_system) def build_from_dataset(self, dataset: Dataset) -> None: """Build simulation from a Dataset object. diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index cfd1f67801..9d49758d48 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -4,7 +4,7 @@ from collections import OrderedDict from dataclasses import replace from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple # PolicyEngine core imports from policyengine_core.parameters import Parameter, ParameterNode @@ -81,6 +81,14 @@ logger = logging.getLogger(__name__) + +def _install_parameters(system, parameters: ParameterNode) -> None: + # A Core Reform delegates unknown instance methods to its baseline. Keep + # this orchestration local and call the public base operation explicitly: + # Reform.modify_parameters would also start from the baseline's tree. + TaxBenefitSystem.modify_parameters(system, lambda _: parameters) + + # Module constants COUNTRY_DIR = Path(__file__).parent DEFAULT_DATASET_ENV_VAR = "POLICYENGINE_UK_DEFAULT_DATASET" @@ -239,11 +247,13 @@ def neutralize_variable(self, variable_name: str) -> None: super().neutralize_variable(part) def reset_parameter_caches(self): - """Reset all caches in the tax-benefit system.""" - self._parameters_at_instant_cache = {} - for parameter in self.parameters.get_descendants(): - parameter._at_instant_cache = {} - self.parameters._at_instant_cache = {} + """Clear dated views through their Core owners, retaining the tree.""" + for node in [self.parameters, *self.parameters.get_descendants()]: + if isinstance(node, ParameterNode): + # The public operation clears this node and its ancestors; + # root-only clearing would leave cached descendants behind. + node.clear_parent_cache() + _install_parameters(self, self.parameters) def reset_parameters(self) -> None: """Reset parameters by reloading from the parameters directory. @@ -253,7 +263,6 @@ def reset_parameters(self) -> None: them survive the reload. Only a processed tree is kept, and a second reset before processing keeps the first one. """ - self._parameters_at_instant_cache = {} current = getattr(self, "parameters", None) if ( getattr(self, "_parameters_before_reset", None) is None @@ -262,7 +271,7 @@ def reset_parameters(self) -> None: ): self._parameters_before_reset = current self.load_parameters(self.parameters_dir) - self.parameters = add_removed_parameter_aliases(self.parameters) + _install_parameters(self, add_removed_parameter_aliases(self.parameters)) def process_parameters(self) -> None: """Process and transform parameters with UK-specific adjustments. @@ -284,7 +293,7 @@ def process_parameters(self) -> None: """ previous = (self.parameters, self._projected_timelines, self._raw_parameters) before_reset = getattr(self, "_parameters_before_reset", None) - self._parameters_at_instant_cache = {} + self.reset_parameter_caches() try: # Processing changes the tree it is given, so it gets a copy: if # it fails, the system keeps the tree it had. @@ -343,12 +352,12 @@ def apply_parameter_assignments( sorted(assignments, key=lambda assignment: assignment.order) ) ] - self._parameters_at_instant_cache = {} + self.reset_parameter_caches() statutory = self._raw_parameters is None key = _assignments_key(self.dated_assignments) if statutory else None cached = _rebuilt_parameters_cache.get(key) if key is not None else None if cached is not None: - self.parameters = cached[0].clone() + _install_parameters(self, cached[0].clone()) # Shared with the memo: read only through the copying # ``projected_timelines`` property. self._projected_timelines = cached[1] @@ -392,7 +401,7 @@ def parameters_with(self, assignments: List[ParameterAssignment]) -> ParameterNo returned is never this system's own. """ probe = copy.copy(self) - probe.parameters = self.parameters.clone() + _install_parameters(probe, self.parameters.clone()) probe.apply_parameter_assignments(assignments) return probe.parameters @@ -452,7 +461,8 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: # reloaded parameters are part of it. self._raw_parameters = raw.clone() if not assignments: - self.parameters, self._projected_timelines = process_parameter_tree(raw) + parameters, self._projected_timelines = process_parameter_tree(raw) + _install_parameters(self, parameters) return # Changes to parameters processing generates are written as each is # generated; the rest go on the statutory timeline first. @@ -468,9 +478,10 @@ def _rebuild(self, raw: ParameterNode, statutory: bool) -> None: projected = prior_law_projection(on_timeline, unreformed, statutory, generated) for assignment in on_timeline: write_assignment(raw.get_child(assignment.path), assignment, projected) - self.parameters, self._projected_timelines = process_parameter_tree( + parameters, self._projected_timelines = process_parameter_tree( raw, unreformed, generated=generated ) + _install_parameters(self, parameters) def _apply_legacy_overrides(self) -> None: if self.legacy_overrides: @@ -478,29 +489,9 @@ def _apply_legacy_overrides(self) -> None: def __init__(self, capital_gains_projection: CapitalGainsProjection | None = None): """Initialize the UK tax-benefit system with entities and parameters.""" - self._parameters_at_instant_cache: Dict[str, Any] = {} - self.variables = {} + super().__init__(entities=[Person, BenUnit, Household]) self.capital_gains_projection = capital_gains_projection - # Create copies of entity classes to avoid modifying originals - person, benunit, household = ( - copy.copy(Person), - copy.copy(BenUnit), - copy.copy(Household), - ) - - # Set up entities - self.entities = [person, benunit, household] - self.person_entity = person - self.group_entities = [benunit, household] - self.group_entity_keys = [entity.key for entity in self.group_entities] - - # Link entities to this tax-benefit system - for entity in self.entities: - entity.set_tax_benefit_system(self) - - self.variable_module_metadata = {} - # Load all variables from the variables directory self.add_variables_from_directory(COUNTRY_DIR / "variables") @@ -528,8 +519,7 @@ def __init__(self, capital_gains_projection: CapitalGainsProjection | None = Non # the full pipeline (saves ~0.5s from convert_to_fiscal_year_parameters). # Reforms rebuild the parameters from the statutory tree through # apply_parameter_assignments, so they still get the full pipeline. - self._parameters_at_instant_cache = {} - self.parameters = _processed_parameters_cache.clone() + _install_parameters(self, _processed_parameters_cache.clone()) else: self.reset_parameters() _statutory_parameters_cache = self.parameters.clone() diff --git a/policyengine_uk/tests/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_aggregate_impact_workflow.py b/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py index b9554b3e52..503f4a414e 100644 --- a/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py +++ b/policyengine_uk/tests/code_health/test_aggregate_impact_workflow.py @@ -1,17 +1,41 @@ -"""Data protection: only aggregates may leave the aggregate-impact job. +"""What the aggregate-impact job compares, and what may leave it. -The certified dataset is licensed survey microdata. The measurement keeps -household-level detail for the decile comparison under ``private/``; these -tests fail if the workflow could publish it, or could put the dataset where -other workflows can read it. +Comparison base. For a pull request the head is GitHub's merge commit and +the base is that commit's first parent, so the report is exactly what +merging would change. The event's ``pull_request.base.sha`` lags the base +branch once the branch moves on, and a report against it counted main's +newer merges as the pull request's impact (#2516's took in 26 commits from +main). These tests run the workflow's own step scripts on real histories, +with the head fetched as actions/checkout fetches it at the workflow's +fetch depth, and hold the job to: + +1. The base is the merge commit's first parent for every shape of history, + so no commit of the base branch is counted as the pull request's. +2. A head that is not a merge of two commits fails the job with an error + and sets no base. +3. No step reads ``pull_request.base.sha``, and the base checkout uses the + base the job chose. +4. A manual dispatch compares the commits it names, as before. + +Data protection. Only aggregates may leave the job. The certified dataset +is licensed survey microdata. The measurement keeps household-level detail +for the decile comparison under ``private/``; these tests fail if the +workflow could publish it, or could put the dataset where other workflows +can read it. """ import json +import os +import re +import subprocess +import tempfile from pathlib import Path import numpy as np import pytest import yaml +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st from policyengine_uk.utils.aggregate_impact.measure import ( HOUSEHOLDS_FILE, @@ -95,3 +119,243 @@ def test_a_failed_run_always_replaces_an_earlier_comment(): assert post["if"] == "always() && github.event_name == 'pull_request'" assert "if [ ! -s report/comment.md ]" in post["run"] assert "FAIL" in post["run"] + + +# The comparison base. + +GIT_ENV = { + **os.environ, + "GIT_CONFIG_GLOBAL": os.devnull, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_AUTHOR_NAME": "t", + "GIT_AUTHOR_EMAIL": "t@example.com", + "GIT_COMMITTER_NAME": "t", + "GIT_COMMITTER_EMAIL": "t@example.com", +} +SHA = "0123456789abcdef0123456789abcdef01234567" + + +def step_named(name): + return next(s for s in steps() if s.get("name") == name) + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + capture_output=True, + text=True, + env=GIT_ENV, + ).stdout.strip() + + +def run_step(name, workspace, **env): + """Run a step's script in ``workspace`` as Actions runs it. + + Each variable in the step's ``env`` is set, empty unless given, as an + expression with no value is. Returns the process and the outputs the + step wrote to ``GITHUB_OUTPUT``. + """ + step = step_named(name) + outputs = Path(workspace) / "github_output" + outputs.write_text("") + variables = {key: "" for key in step.get("env", {})} + variables.update(env) + process = subprocess.run( + ["bash", "--noprofile", "--norc", "-eo", "pipefail", "-c", step["run"]], + cwd=workspace, + env={**GIT_ENV, **variables, "GITHUB_OUTPUT": str(outputs)}, + capture_output=True, + text=True, + ) + written = dict(line.split("=", 1) for line in outputs.read_text().splitlines()) + return process, written + + +class Origin: + """A repository on GitHub's side, holding commits made directly.""" + + def __init__(self, path): + path.mkdir(parents=True) + git(path, "init", "-q") + # actions/checkout fetches the head by its SHA. + git(path, "config", "uploadpack.allowAnySHA1InWant", "true") + self.path = path + self.tree = git(path, "write-tree") + self.made = 0 + + def commit(self, *parents): + self.made += 1 + flags = [flag for parent in parents for flag in ("-p", parent)] + return git( + self.path, "commit-tree", self.tree, *flags, "-m", f"commit {self.made}" + ) + + def attributed(self, base, head): + """The commits a comparison of ``base`` with ``head`` counts.""" + return set(git(self.path, "rev-list", f"{base}..{head}").split()) + + +def check_out_the_head(workspace, origin, sha): + """Fetch ``sha`` into ``workspace/head`` as the workflow's checkout does.""" + depth = step_named("Check out the head")["with"].get("fetch-depth", 1) + head = Path(workspace) / "head" + head.mkdir(parents=True) + git(head, "init", "-q") + git(head, "fetch", "-q", "--no-tags", f"--depth={depth}", origin.path.as_uri(), sha) + git(head, "checkout", "-q", "--detach", sha) + + +def choose_the_base(workspace, origin, merge): + check_out_the_head(workspace, origin, merge) + return run_step("Choose the base", workspace, EVENT="pull_request") + + +def test_a_pull_request_is_compared_with_its_merge_commits_first_parent(tmp_path): + origin = Origin(tmp_path / "origin") + # The pull request branches from main at `forked`. The event's + # pull_request.base.sha can still name it after main has moved on. + forked = origin.commit(origin.commit()) + change = origin.commit(forked) + # Main moves on: two later merges. + tip = origin.commit(origin.commit(forked)) + # GitHub's merge commit for the run: main's tip, then the pull request. + merge = origin.commit(tip, change) + + process, outputs = choose_the_base(tmp_path / "runner", origin, merge) + + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == {"sha": tip} + assert origin.attributed(outputs["sha"], merge) == {merge, change} + # Against `forked`, main's two later merges counted as the pull request's. + assert len(origin.attributed(forked, merge) - {merge, change}) == 2 + + +@pytest.mark.parametrize("parents", [0, 1, 3]) +def test_a_head_that_does_not_merge_two_commits_has_no_base(tmp_path, parents): + origin = Origin(tmp_path / "origin") + head = origin.commit(*[origin.commit() for _ in range(parents)]) + + process, outputs = choose_the_base(tmp_path / "runner", origin, head) + + assert process.returncode != 0 + assert f"::error::The head {head} has {parents} parents, not 2" in process.stdout + assert outputs == {} + + +def test_the_events_base_sha_is_not_the_comparison_base(): + expressions = re.findall(r"\$\{\{(.*?)\}\}", WORKFLOW.read_text(), re.DOTALL) + assert expressions + assert not [e for e in expressions if "pull_request.base.sha" in e] + names = [s.get("name") for s in steps()] + order = [ + names.index("Choose the commits"), + names.index("Check out the head"), + names.index("Choose the base"), + names.index("Check out the base"), + ] + assert order == sorted(order) + head = step_named("Check out the head")["with"] + assert head["ref"] == "${{ steps.commits.outputs.head }}" + choose = step_named("Choose the base") + assert choose["id"] == "base" + assert choose["env"]["BASE"] == "${{ steps.commits.outputs.base }}" + assert step_named("Check out the base")["with"]["ref"] == ( + "${{ steps.base.outputs.sha }}" + ) + + +def test_a_pull_request_head_is_its_merge_commit(tmp_path): + process, outputs = run_step( + "Choose the commits", + tmp_path, + EVENT="pull_request", + GITHUB_SHA=SHA, + PR_BASE_REF="main", + PR_NUMBER="2516", + ) + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == { + "base": "", + "head": SHA, + "base_name": "main", + "head_name": "#2516 merged into main", + } + + +@pytest.mark.parametrize( + "inputs, expected", + [ + ( + {}, + {"base": "main", "head": SHA, "base_name": "main", "head_name": "dev"}, + ), + ( + {"INPUT_BASE": "2.139.0", "INPUT_HEAD": "fix"}, + { + "base": "2.139.0", + "head": "fix", + "base_name": "2.139.0", + "head_name": "fix", + }, + ), + ], +) +def test_a_dispatch_compares_the_commits_it_names(tmp_path, inputs, expected): + process, outputs = run_step( + "Choose the commits", + tmp_path, + EVENT="workflow_dispatch", + GITHUB_SHA=SHA, + GITHUB_REF_NAME="dev", + **inputs, + ) + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == expected + # The base passes through, with no head to read it from. + process, outputs = run_step( + "Choose the base", tmp_path, EVENT="workflow_dispatch", BASE=expected["base"] + ) + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == {"sha": expected["base"]} + + +@settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + before=st.integers(0, 2), + events=st.lists(st.sampled_from(["main", "pull request", "update"]), max_size=8), +) +def test_no_commit_of_the_base_branch_counts_as_the_pull_requests(before, events): + """Invariant 1, for any history: main's commits before and after the + fork, the pull request's commits, and merges of main into the pull + request ("Update branch").""" + with tempfile.TemporaryDirectory() as directory: + origin = Origin(Path(directory) / "origin") + main = origin.commit() + for _ in range(before): + main = origin.commit(main) + pull_request = origin.commit(main) + own = {pull_request} + merged = main + for event in events: + if event == "main": + main = origin.commit(main) + elif event == "pull request": + pull_request = origin.commit(pull_request) + own.add(pull_request) + elif main != merged: + pull_request = origin.commit(pull_request, main) + own.add(pull_request) + merged = main + merge = origin.commit(main, pull_request) + + process, outputs = choose_the_base(Path(directory) / "runner", origin, merge) + + assert process.returncode == 0, process.stdout + process.stderr + assert outputs == {"sha": main} + assert origin.attributed(outputs["sha"], merge) == own | {merge} 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/dwp_benefit_and_pension_rates.yaml b/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml index 37cf87bfda..80e5b9fd16 100644 --- a/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml +++ b/policyengine_uk/tests/fixtures/dwp_benefit_and_pension_rates.yaml @@ -1,15 +1,15 @@ # DWP "Benefit and pension rates" (and HMRC tax credit rates), by fiscal year -# (2025 = 2025-26), in each parameter's own unit and period. Each year's rate -# appears in that year's publication and as the previous-year column of the -# next year's, and the two agree, except UC's 2021-22 amounts: the temporary +# (2025 = 2025-26), in each parameter's own unit and period. Each year's rate to +# 2025-26 appears in that year's publication and as the previous-year column of +# the next year's, and the two agree, except UC's 2021-22 amounts: the temporary # uplift ended in October 2021, and the 2022-23 edition's previous-year column # gives the later amounts. The fixture holds the April 2021 amounts from the -# 2021-22 edition, which PolicyEngine reads at 30 April. For 2025-26 and -# 2026-27 the amounts match the -# Social Security Benefits Up-rating Orders 2025 (SI 2025/295) and 2026 (SI -# 2026/148), except the 2026-27 income-related ESA personal allowances, which -# are set under the Universal Credit Act 2025 s.5. Rows marked `derived` are -# computed from published rows as stated. Read by test_dwp_published_rates.py. +# 2021-22 edition, which PolicyEngine reads at 30 April. 2026-27 has only its +# own edition so far. For 2025-26 and 2026-27 the amounts match the Social +# Security Benefits Up-rating Orders 2025 (SI 2025/295) and 2026 (SI 2026/148), +# except the 2026-27 income-related ESA personal allowances, which are set under +# the Universal Credit Act 2025 s.5. Rows marked `derived` are computed from +# published rows as stated. Read by test_dwp_published_rates.py. sources: 2015: https://www.gov.uk/government/publications/proposed-benefit-and-pension-rates-2015-to-2016 diff --git a/policyengine_uk/tests/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/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/applicable_income/housing_benefit_applicable_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml index f5218d1b0b..d0370fcfbc 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income.yaml @@ -127,3 +127,139 @@ housing_benefit_applicable_income_childcare_element: 0 output: housing_benefit_applicable_income: 0 + +- name: Continuing council tenant retains the default half-pension deduction + # SI 2006/213 reg 36(3)(c): half of the personal pension contribution. + # Earnings are below the tax and NI thresholds. Net earnings are + # 12,000 - 0.5 * 2,000 = 11,000; the single earnings disregard is 5 * 52. + # Applicable income is 10,740 and the allowance is 92.05 * 52 = 4,786.60. + # HB = 10,000 - 0.65 * (10,740 - 4,786.60) = 6,130.29. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 # continuing award, not calculated cash + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit: 6_130.29 + housing_benefit_applicable_income: 10_740 + housing_benefit_net_earnings: 11_000 + housing_benefit_applicable_income_disregard: 260 + housing_benefit_applicable_amount: 4_786.60 + pension_contributions: 2_000 + income_tax: 0 + national_insurance: 0 + housing_benefit_non_dep_deductions: 0 + housing_benefit_benefit_cap_reduction: 0 + housing_benefit_on_passporting_benefit: false + housing_benefit_eligible: true + would_claim_housing_benefit: true + legacy_benefits_closed: false + universal_credit: 0 + +- name: Quarter-pension deduction reform reduces the continuing Housing Benefit award + # Model reform, not current law: 12,000 - 0.25 * 2,000 - 260 = 11,240. + # HB = 10,000 - 0.65 * (11,240 - 4,786.60) = 5,805.29. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit: 5_805.29 + housing_benefit_applicable_income: 11_240 + housing_benefit_net_earnings: 11_500 + housing_benefit_applicable_income_disregard: 260 + housing_benefit_applicable_amount: 4_786.60 + +- name: Zero pension deduction reform counts the full income + # Model reform: 12,000 - 0 * 2,000 - 260 = 11,740. + # HB = 10,000 - 0.65 * (11,740 - 4,786.60) = 5,480.29. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0 + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit_applicable_income: 11_740 + housing_benefit: 5_480.29 + housing_benefit_net_earnings: 12_000 + +- name: Full pension deduction reform increases the continuing Housing Benefit award + # Model reform: 12,000 - 1 * 2,000 - 260 = 9,740. + # HB = 10,000 - 0.65 * (9,740 - 4,786.60) = 6,780.29. + period: 2025 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 1 + people: + person: + age: 40 + employment_income: 12_000 + weekly_hours: 16 + personal_pension_contributions: 2_000 + housing_benefit_reported: 1_000 + benunits: + benunit: + members: [person] + would_claim_uc: false + households: + household: + members: [person] + tenure_type: RENT_FROM_COUNCIL + region: LONDON + num_bedrooms: 1 + rent: 10_000 + output: + housing_benefit: 6_780.29 + housing_benefit_applicable_income: 9_740 + housing_benefit_net_earnings: 10_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml new file mode 100644 index 0000000000..213e6a0c50 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_pension_age_personal_allowance.yaml @@ -0,0 +1,349 @@ +# Pension-age Housing Benefit personal allowances by when State Pension age was +# attained: SI 2006/214 Sch 3 para 1 (NI: SR 2006/406 Sch 4 para 1), as amended +# by SI 2021/188 from 1 April 2021. Higher rates (1)(b)/(2)(b) where a member +# attained pensionable age before 1 April 2021; lower rates (1)(c)/(2)(c) where +# every member attained it on or after. Amounts are the Up-rating Order rates. +# Each date of birth is placed by age and months_since_last_birthday, both +# measured at 6 October of the fiscal year. + +- name: Single woman who attained State Pension age on 31 March 2021 keeps the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.19355 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 256.00 * 52 # para 1(1)(b), claimant attained 31 March 2021 + +- name: Single man who attained State Pension age on 1 April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: true + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238.00 * 52 # para 1(1)(c), claimant attained 1 April 2021 + +- name: Single person aged 80 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + months_since_last_birthday: 4.1613 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 256.00 * 52 # para 1(1)(b), claimant attained 1 June 2006 + +- name: Single person aged 70 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + months_since_last_birthday: 6.0 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238.00 * 52 # para 1(1)(c), claimant attained 6 April 2022 + +- name: In 2021-22, a single person who attained State Pension age on 31 March 2021 gets the higher rate + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 66 + months_since_last_birthday: 6.19355 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 191.15 * 52 # para 1(1)(b), claimant attained 31 March 2021 + +- name: In 2021-22, a single person who attained State Pension age on 1 April 2021 gets the lower rate + period: 2021 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 66 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 177.10 * 52 # para 1(1)(c), claimant attained 1 April 2021 + +- name: Couple where one member attained State Pension age before April 2021 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 80 + months_since_last_birthday: 4.1613 + is_male: true + younger: + age: 70 + months_since_last_birthday: 6.0 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 1 June 2011, younger attained 6 April 2022 + +- name: Couple where both attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 68 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 363.25 * 52 # para 1(2)(c), older attained 6 April 2022, younger attained 1 January 2024 + +- name: Couple on either side of the cutoff by one day gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 71 + months_since_last_birthday: 6.19355 + is_male: true + younger: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 31 March 2021, younger attained 1 April 2021 + +- name: Mixed-age couple whose older member attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 60 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + # A protected mixed-age couple with no Universal Credit, so the + # pension-age regulations apply (HB (SPC) Regs 2006 reg 5). + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 363.25 * 52 # para 1(2)(c), older attained 6 April 2022, younger attained 1 January 2033 + +- name: Mixed-age couple whose older member attained State Pension age before April 2021 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 80 + months_since_last_birthday: 4.1613 + is_male: true + younger: + age: 60 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + housing_benefit_eligible: true + # A protected mixed-age couple with no Universal Credit, so the + # pension-age regulations apply (HB (SPC) Regs 2006 reg 5). + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + output: + housing_benefit_attained_pension_age_before_cutoff: true + housing_benefit_applicable_amount: 383.35 * 52 # para 1(2)(b), older attained 1 June 2011, younger attained 1 January 2033 + +- name: Pension-age lone parent who attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + months_since_last_birthday: 6.0 + is_male: false + grandchild: + age: 12 + benunits: + benunit: + members: [claimant, grandchild] + housing_benefit_eligible: true + benefits_premiums: 0 + output: + is_lone_parent: true + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238.00 * 52 # para 1(1)(c) + +- name: Before April 2021 every pension-age unit counts as attaining State Pension age before the cutoff + period: 2020 + input: + people: + claimant: + age: 66 + months_since_last_birthday: 0.0 + is_male: false + benunits: + benunit: + members: [claimant] + output: + housing_benefit_attained_pension_age_before_cutoff: true + +- name: Private renter aged 70 with a £30,000 State Pension is assessed at the lower rate + # The reported household. Income is 30,000 less 3,486 income tax; with no + # earnings there is no earnings disregard. At the lower rate, + # 9,000 - 0.65 x (26,514 - 238 x 52) is below zero. The higher rate would + # give 9,000 - 0.65 x (26,514 - 256 x 52) = 418.70. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + state_pension_reported: 30_000 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + rent: 9_000 + tenure_type: RENT_PRIVATELY + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238 * 52 + housing_benefit_applicable_income: 26_514 + housing_benefit: 0 + +- name: A council tenant aged 70 with the same income and £12,000 rent gets 608.40 a year less than at the higher rate + # A council tenancy, so the Local Housing Allowance does not cap the rent. + # 12,000 - 0.65 x (26,514 - 238 x 52) = 2,810.30; at the higher rate it would + # be 3,418.70, so the lower rate costs 0.65 x (256 - 238) x 52 = 608.40. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + state_pension_reported: 30_000 + benunits: + benunit: + members: [person] + households: + household: + members: [person] + rent: 12_000 + tenure_type: RENT_FROM_COUNCIL + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238 * 52 + housing_benefit: 2_810.30 + +- name: An older non-claimant in the benefit unit does not change the claimant's cohort + # Schedule 3 para 1 reads the claimant's and partner's ages. The claimant + # (born 1 January 1959) attained State Pension age in 2025, after the + # cutoff; the 80-year-old is not the claimant or partner, so the lower + # single rate applies: 238 x 52. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 67 + date_of_birth: 19590101 + is_claimant_or_partner: true + is_household_head: true + other_adult: + age: 80 + date_of_birth: 19460101 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, other_adult] + housing_benefit_eligible: true + housing_benefit_pension_age_regulations_apply: true + benefits_premiums: 0 + pension_age_benefits_premiums: 0 + households: + home: + members: [claimant, other_adult] + country: ENGLAND + output: + housing_benefit_attained_pension_age_before_cutoff: false + housing_benefit_applicable_amount: 238 * 52 diff --git a/policyengine_uk/tests/policy/baseline/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/income_support/income_support_withdrawal_rate.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_withdrawal_rate.yaml new file mode 100644 index 0000000000..aa927317f4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_withdrawal_rate.yaml @@ -0,0 +1,447 @@ +# Existing annual-model continuing-award proxy: the parent reports Income Support. +# No eligibility, allowance, assessable income or cash award is forced. The public +# YAML dotted-input route reforms only the new dimensionless withdrawal rate. +# 2024 model controls: 90.50 * 52 = 4,706; lone-parent disregard 20 * 52 = 1,040. +# Pay 6,500 gives assessable income 5,460: default cash 0, half-rate cash 1,976. +# Pay 2,000 gives assessable income 960: default cash 3,746, half-rate cash 4,226. +# These inherited allowance/disregard controls are not a new rates or historical audit. + +- name: Default withdrawal exhausts an award above the old income cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + output: + income_support: 0 + income_support_applicable_amount: 4706 + income_support_applicable_income: 5460 + income_support_eligible: true + income_tax: + - 0 + - 0 + national_insurance: + - 0 + - 0 + would_claim_IS: true +- name: Default withdrawal preserves cash below the old income cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + output: + income_support: 3746 + income_support_applicable_amount: 4706 + income_support_applicable_income: 960 + income_support_eligible: true +- name: Default withdrawal pays the full allowance at zero income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 0 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + output: + income_support: 4706 + income_support_applicable_income: 0 + income_support_eligible: true +- name: A half withdrawal rate pays cash above the old income cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 1976 + income_support_applicable_amount: 4706 + income_support_applicable_income: 5460 + income_support_eligible: true +- name: A half withdrawal rate applies only to income below the old cutoff + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 4226 + income_support_applicable_amount: 4706 + income_support_applicable_income: 960 +- name: A half withdrawal rate preserves the full allowance at zero income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 0 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 4706 +- name: A zero withdrawal rate preserves the allowance despite assessable income + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0 + output: + income_support: 4706 + income_support_applicable_income: 5460 +- name: A double withdrawal rate multiplies income rather than the allowance + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 2 + output: + income_support: 2786 + income_support_applicable_income: 960 +- name: The zero floor still applies under a double withdrawal rate + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 6500 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 2 + output: + income_support: 0 + income_support_eligible: true +- name: A half withdrawal rate does not create an award without an existing report + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 0 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + # No reports enable the unchanged claim-all fallback, but not eligibility. + would_claim_IS: true + income_support_eligible: false +- name: A half withdrawal rate preserves the remunerative work gate + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 832 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + income_support_remunerative_work_hours: + - 16 + - 0 + income_support_eligible: false +- name: A half withdrawal rate preserves the capital limit + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + savings: 16001 + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + income_support_assessable_capital: 16001 + income_support_eligible: false +- name: A half withdrawal rate does not reopen Income Support in 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + legacy_benefits_closed: true +- name: A half withdrawal rate preserves tax credit migration closure + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 100 + employment_income: 2000 + hours_worked: 520 + child_tax_credit_reported: 100 + child: + age: 4 + is_claimant_or_partner: false + benunits: + family: + members: + - parent + - child + households: + home: + members: + - parent + - child + region: LONDON + gov.dwp.income_support.means_test.withdrawal_rate: 0.5 + output: + income_support: 0 + legacy_benefits_closed: true diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..bf6c22ef1a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/pension_contribution_deduction_rate.yaml @@ -0,0 +1,151 @@ +# Income Support deducts one-half of pension contributions from net earnings +# (IS Regs 1987 reg 36(3)(b)) and net profit (reg 38(1)(b)(ii), (3)(c) and +# (9)(b)), with no reference to Housing Benefit's share. The rates below 0.5 +# are model reforms, not current law. +# +# Each person earns 10,000 a year, below the personal allowance and the primary +# threshold, so no income tax or National Insurance is due. The single +# claimant's earnings disregard is 5 a week (260 a year) and a couple's 10 a +# week (520 a year). + +- name: At current law Income Support deducts half of pension contributions + # 10,000 - 0.5 x 2,000 - 260 = 8,740. The same claimant without the + # contribution has 9,740 (means_test_income_members.yaml). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + pension_contributions: 2_000 + income_tax: 0 + national_insurance: 0 + income_support_applicable_income: 8_740 + +- name: A reform of the Income Support share moves Income Support applicable income + # Model reform, not current law: 10,000 - 0.25 x 2,000 - 260 = 9,240, which is + # 500 more than at half: 2,000 x (0.5 - 0.25). + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.income_support.means_test.pension_contribution_deduction_rate: 0.25 + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + income_support_applicable_income: 9_240 + # Housing Benefit keeps its own half: 10,000 - 0.5 x 2,000. + housing_benefit_net_earnings: 9_000 + +- name: A reform of the Housing Benefit share leaves Income Support applicable income + # Model reform, not current law. Housing Benefit net earnings become + # 10,000 - 0.25 x 2,000 = 9,500; Income Support keeps 8,740. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + output: + housing_benefit_net_earnings: 9_500 + income_support_applicable_income: 8_740 + +- name: A reform of a Council Tax Reduction share leaves Income Support applicable income + # Model reform, not current law. The claimant lives in England outside the + # councils whose schemes are modelled, so Council Tax Reduction reads the + # England default scheme's share: 10,000 - 0.25 x 2,000 = 9,500. Income + # Support keeps 8,740. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.default_scheme.means_test.pension_contribution_deduction_rate: 0.25 + people: + claimant: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + country: ENGLAND + output: + council_tax_reduction_applicable_income: 9_500 + income_support_applicable_income: 8_740 + +- name: A couple's contributions are summed at the Income Support share + # 20,000 - 0.5 x (2,000 + 1,000) - 520 = 17,980. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + income_support_applicable_income: 17_980 + +- name: A couple's Income Support applicable income falls by contributions times the change in share + # Model reform, not current law: 20,000 - 0.25 x 3,000 - 520 = 18,730, which + # is 750 more than at half: 3,000 x (0.5 - 0.25). + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.income_support.means_test.pension_contribution_deduction_rate: 0.25 + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + households: + household: + members: [head, partner] + output: + income_support_applicable_income: 18_730 diff --git a/policyengine_uk/tests/policy/baseline/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/tax_credits_working_hours.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml new file mode 100644 index 0000000000..b4c1d9e90e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/tax_credits_working_hours.yaml @@ -0,0 +1,476 @@ +# Same-worker WTC conditions, SI2002/2005reg4 (joint variations from6April2012). +# Amounts use unchanged2024 model parameters; no eligibility/element/final-award overrides. +# Generic disability and other-disabled-partner exception remain inherited proxies. +- name: A childless worker cannot borrow a nonworking partners age sixty + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 60 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 1040 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A worker aged sixty qualifies on their own sixteen hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 60 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A thirty hour worker cannot borrow a partners age twenty five + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 24 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A worker aged twenty five qualifies on their own thirty hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 5950 +- name: Two childless fifteen hour workers do not make one thirty hour worker + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 780 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 780 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A childless worker cannot borrow a nonworking partners disability + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: true + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 1040 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: A disabled worker qualifies on their own sixteen hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: true + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: true + working_tax_credit: 8870 +- name: A worker aged fifty nine still needs thirty hours without children + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 59 + hours_worked: 832 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Joint parents may combine sixteen and eight hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 3000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 416 + employment_income: 2000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: Joint parents twelve hours each do not meet the individual sixteen hour limb + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 624 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 624 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Joint parents sixteen and seven hours do not meet twenty four hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 364 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: Parents retain the other disabled partner exception proxy + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 0 + employment_income: 0 + is_parent: true + is_disabled_for_benefits: true + child_0: + age: 10 + hours_worked: 0 + employment_income: 0 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A childs eight hours cannot complete the parents twenty four hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 30 + hours_worked: 832 + employment_income: 5000 + is_parent: true + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 28 + hours_worked: 0 + employment_income: 0 + is_parent: true + is_disabled_for_benefits: false + child_0: + age: 15 + hours_worked: 416 + employment_income: 1000 + benunits: + family: + members: + - adult_0 + - adult_1 + - child_0 + output: + working_tax_credit: 0 + is_WTC_eligible: false +- name: An unreported historical claim remains ineligible + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 0 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: A closed historical claim remains ineligible + period: 2024 + absolute_error_margin: 0.01 + input: + people: + adult_0: + age: 40 + hours_worked: 0 + employment_income: 0 + is_parent: false + is_disabled_for_benefits: false + working_tax_credit_reported: 1 + adult_1: + age: 25 + hours_worked: 1560 + employment_income: 5000 + is_parent: false + is_disabled_for_benefits: false + benunits: + family: + members: + - adult_0 + - adult_1 + legacy_benefits_closed: true + output: + is_WTC_eligible: false + working_tax_credit: 0 +- name: A single ordinary worker qualifies at thirty hours + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 35 + hours_worked: 1560 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: false + benunits: + family: + members: + - worker + output: + is_WTC_eligible: true + working_tax_credit: 3450 +- name: A lone parent retains the sixteen hour route + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 30 + hours_worked: 832 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: true + child_0: + age: 10 + benunits: + family: + members: + - worker + - child_0 + output: + is_WTC_eligible: true + working_tax_credit: 4935 +- name: A single worker below the own age threshold does not qualify + period: 2024 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 24 + hours_worked: 1560 + employment_income: 5000 + working_tax_credit_reported: 1 + is_parent: false + benunits: + family: + members: + - worker + output: + is_WTC_eligible: false + working_tax_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml index bd55e902ca..dac7d76bc1 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/child_element/disability/uc_individual_severely_disabled_child_element.yaml @@ -4,7 +4,7 @@ input: age: 6 is_uc_claimant: false - is_severely_disabled_for_benefits: true + dla_sc_category: HIGHER output: uc_individual_severely_disabled_child_element: 5_942 @@ -14,7 +14,7 @@ input: age: 18 is_in_non_advanced_education: true - is_severely_disabled_for_benefits: true + dla_sc_category: HIGHER output: is_uc_claimant: true uc_individual_severely_disabled_child_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml index 6ed4fd7ae8..d41cde1a66 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claimant_child_element.yaml @@ -115,7 +115,7 @@ is_disabled_for_benefits: true person5: age: 2 - is_severely_disabled_for_benefits: true + dla_sc_category: HIGHER benunits: benunit: members: [person1, person2, person3, person4, person5] diff --git a/policyengine_uk/tests/policy/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_incapacity_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_incapacity_income.yaml new file mode 100644 index 0000000000..8ed08fb08f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_incapacity_income.yaml @@ -0,0 +1,200 @@ +# Annual public-input controls, not an audit of legacy entitlement or rates. +# Ages 60/58 in 2026 permit legacy IB/SDA histories; receipt remains an input. +# CA annual netting represents constant year-round amounts. The care-hours +# interface does not attribute care to a recipient: the represented PIP recipient +# documents the qualifying-care premise without certifying that missing link. +# UC eligibility, components, income reduction and final awards remain live. + +- name: IB reduces a working-age claimant's positive final UC award + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + incapacity_benefit_reported: 1_000 + country: ENGLAND + output: + universal_credit: 424.90 * 12 - 1_000 + incapacity_benefit: 1_000 + is_uc_eligible: true + uc_maximum_amount: 424.90 * 12 + uc_unearned_income: 1_000 + uc_income_reduction: 1_000 + +- name: No legacy income preserves a positive final UC award + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + country: ENGLAND + output: + universal_credit: 424.90 * 12 + uc_unearned_income: 0 + +- name: IB 0 counts alongside actual residual Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 60 + care_hours: 35 + incapacity_benefit_reported: 0 + recipient: + age: 55 + pip_dl_category: ENHANCED + benunits: + carer_unit: + members: [carer] + recipient_unit: + members: [recipient] + households: + household: + members: [carer, recipient] + country: ENGLAND + output: + universal_credit: [3115.48, 5098.80] + carers_allowance_pre_overlap: [4495.40, 0] + carers_allowance: [4495.40, 0] + uc_carer_element: [2512.08, 0] + is_uc_eligible: [true, true] + uc_maximum_amount: [7610.88, 5098.80] + uc_unearned_income: [4495.40, 0] + uc_income_reduction: [4495.40, 0] + +- name: IB 1000 counts alongside actual residual Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 60 + care_hours: 35 + incapacity_benefit_reported: 1000 + recipient: + age: 55 + pip_dl_category: ENHANCED + benunits: + carer_unit: + members: [carer] + recipient_unit: + members: [recipient] + households: + household: + members: [carer, recipient] + country: ENGLAND + output: + universal_credit: [3115.48, 5098.80] + carers_allowance_pre_overlap: [4495.40, 0] + carers_allowance: [3495.40, 0] + uc_carer_element: [2512.08, 0] + is_uc_eligible: [true, true] + uc_maximum_amount: [7610.88, 5098.80] + uc_unearned_income: [4495.40, 0] + uc_income_reduction: [4495.40, 0] + +- name: IB 5000 counts alongside actual residual Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 60 + care_hours: 35 + incapacity_benefit_reported: 5000 + recipient: + age: 55 + pip_dl_category: ENHANCED + benunits: + carer_unit: + members: [carer] + recipient_unit: + members: [recipient] + households: + household: + members: [carer, recipient] + country: ENGLAND + output: + universal_credit: [2610.88, 5098.80] + carers_allowance_pre_overlap: [4495.40, 0] + carers_allowance: [0.00, 0] + uc_carer_element: [2512.08, 0] + is_uc_eligible: [true, true] + uc_maximum_amount: [7610.88, 5098.80] + uc_unearned_income: [5000.00, 0] + uc_income_reduction: [5000.00, 0] + +# SDA is the existing maximum-rate proxy, not the reported cash amount. +# The public disability flag supplies the assessment premise; the calculator +# does not infer this flag from a disability benefit report as datasets do. +# Unchanged 2026 standard/health rates below are model controls, not a rate audit. +- name: SDA report 1 counts the full modeled award and live LCWRA element + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + sda_reported: 1 + is_disabled_for_benefits: true + partner: + age: 58 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + universal_credit: (666.97 + 217.26) * 12 - 119.35 * 52 + sda: [6206.20, 0] + uc_limited_capability_for_WRA: [true, false] + uc_LCWRA_element: 217.26 * 12 + is_uc_eligible: true + uc_maximum_amount: (666.97 + 217.26) * 12 + uc_unearned_income: 119.35 * 52 + uc_income_reduction: 119.35 * 52 + +# SDA is the existing maximum-rate proxy, not the reported cash amount. +# The public disability flag supplies the assessment premise; the calculator +# does not infer this flag from a disability benefit report as datasets do. +# Unchanged 2026 standard/health rates below are model controls, not a rate audit. +- name: SDA report 100 counts the full modeled award and live LCWRA element + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + sda_reported: 100 + is_disabled_for_benefits: true + partner: + age: 58 + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + universal_credit: (666.97 + 217.26) * 12 - 119.35 * 52 + sda: [6206.20, 0] + uc_limited_capability_for_WRA: [true, false] + uc_LCWRA_element: 217.26 * 12 + is_uc_eligible: true + uc_maximum_amount: (666.97 + 217.26) * 12 + uc_unearned_income: 119.35 * 52 + uc_income_reduction: 119.35 * 52 + +- name: A current Northern Ireland claimant's IB reduces final UC + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + incapacity_benefit_reported: 1_000 + country: NORTHERN_IRELAND + output: + universal_credit: 424.90 * 12 - 1_000 + is_uc_eligible: true + uc_unearned_income: 1_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml index 8069859343..48fdec83e2 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_deductions.yaml @@ -107,7 +107,8 @@ household: members: person output: - # Schedule 6 of SI 2013/380: the award cannot be deducted below one + # Schedule 6, paragraph 3(1)(a) of SI 2013/380: the award cannot be + # deducted below one # penny per assessment period (12p per year). uc_deductions: 499.88 universal_credit: 0.12 @@ -132,3 +133,49 @@ output: uc_deductions: 0 universal_credit: 6_000 + +- name: A claim made at legacy closure keeps the benefit cap and deductions + # A couple on Child Tax Credit moved off legacy benefits that would not + # otherwise claim Universal Credit, but claims on its migration notice + # (SI 2014/1230 reg 44). The award is still capped (SI 2013/376 reg 81) + # and deductions still apply, so both components use the award's + # claims_universal_credit test rather than would_claim_uc. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + adult_1: + age: 35 + is_claimant_or_partner: true + child_tax_credit_reported: 3_000 + adult_2: + age: 33 + is_claimant_or_partner: true + child_1: + age: 13 + child_2: + age: 11 + child_3: + age: 9 + child_4: + age: 7 + benunits: + benunit: + members: [adult_1, adult_2, child_1, child_2, child_3, child_4] + would_claim_uc: false + would_claim_uc_at_legacy_closure: true + uc_latent_deduction_rate: 0.25 + uc_deduction_combination: ADVANCE_ONLY + households: + household: + members: [adult_1, adult_2, child_1, child_2, child_3, child_4] + rent: 30_000 + tenure_type: RENT_FROM_COUNCIL + region: LONDON + output: + claims_universal_credit: true + universal_credit_pre_benefit_cap: 48_632.64 + uc_benefit_cap_reduction: 27_355.24 + uc_benefit_cap_reduction_after_floor: 27_355.24 + uc_deductions: 1_130.58 + universal_credit: 20_146.82 diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml new file mode 100644 index 0000000000..f1dbfc9544 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/free_tv_licence_value.yaml @@ -0,0 +1,101 @@ +- name: Case 1, BBC iPlayer use without television ownership. + period: 2026 + input: + household_owns_tv: false + tv_licence_required: true + tv_licence_free_licence_share: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 180 + +- name: Case 2, television ownership without licensable use. + period: 2026 + input: + household_owns_tv: true + tv_licence_required: false + tv_licence_free_licence_share: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 0 + +- name: Case 3, full free licence value enters HBAI income in kind. + period: 2026 + input: + tv_licence_required: true + tv_licence_free_licence_share: 1 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 180 + +- name: Case 4, blind discount is not a free licence in HBAI income. + period: 2026 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0 + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_discount: 0.5 + free_tv_licence_value: 0 + +- name: Case 5, a partial-year free licence contributes its proportional value. + period: 2020 + absolute_error_margin: 0.01 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0.3205479452 + would_evade_tv_licence_fee: false + output: + free_tv_licence_value: 50.49 + +- name: Case 6, an over-75 household on Pension Credit gets a full free licence. + period: 2026 + input: + age: 80 + pension_credit: 1 + tv_licence_required: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 1 + free_tv_licence_value: 180 + +- name: Case 7, age alone does not qualify after the Pension Credit condition begins. + period: 2026 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0 + free_tv_licence_value: 0 + +- name: Case 8, the 2020 transition determines both free value and paid liability. + period: 2020 + absolute_error_margin: 0.01 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + is_blind: false + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0.3205479452 + tv_licence_discount: 0.3205479452 + free_tv_licence_value: 50.49 + tv_licence: 107.01 + +- name: Case 9, the blind discount reduces only the paid part of the 2020 licence. + period: 2020 + absolute_error_margin: 0.01 + input: + age: 80 + pension_credit: 0 + tv_licence_required: true + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_free_licence_share: 0.3205479452 + tv_licence_discount: 0.6602739726 + free_tv_licence_value: 50.49 + tv_licence: 53.51 diff --git a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml index 4b62332a7c..7a33fb64d0 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dcms/bbc/tv-licence/tv_licence.yaml @@ -42,3 +42,44 @@ would_evade_tv_licence_fee: false output: tv_licence: 0 + +- name: TV licence required for BBC iPlayer without television ownership. + period: 2026 + input: + household_owns_tv: false + tv_licence_required: true + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 180 + +- name: Television ownership alone does not require a TV licence. + period: 2026 + input: + household_owns_tv: true + tv_licence_required: false + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 0 + +- name: Television ownership remains the fallback for existing inputs. + period: 2026 + input: + household_owns_tv: false + tv_licence_discount: 0 + would_evade_tv_licence_fee: false + output: + tv_licence: 0 + +- name: Blind discount applies only after a partial-year free licence ends. + period: 2020 + absolute_error_margin: 0.01 + input: + tv_licence_required: true + tv_licence_free_licence_share: 0.3205479452 + is_blind: true + would_evade_tv_licence_fee: false + output: + tv_licence_discount: 0.6602739726 + tv_licence: 53.51 diff --git a/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml b/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml new file mode 100644 index 0000000000..84d66af97d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dfe/care_to_learn/care_to_learn_history.yaml @@ -0,0 +1,256 @@ +# These are annual model-year anchors, not exact academic-year start dates. +# The inherited annual formula caps costs at 52 times the weekly maximum. +# Old rates: EFA 2014-15 p4 and ESFA 2022-23; new rates: ESFA 2023-24 s1. +- name: 2015 Care to Learn annual history EAST_OF_ENGLAND + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [8_320, 0] + +- name: 2015 Care to Learn annual history LONDON + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [9_100, 0] + +- name: 2023 Care to Learn annual history EAST_OF_ENGLAND + period: 2023 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [8_320, 0] + +- name: 2023 Care to Learn annual history LONDON + period: 2023 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [9_100, 0] + +- name: 2024 Care to Learn annual history EAST_OF_ENGLAND + period: 2024 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [9_360, 0] + +- name: 2024 Care to Learn annual history LONDON + period: 2024 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [10_140, 0] + +- name: 2026 Care to Learn annual history EAST_OF_ENGLAND + period: 2026 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [9_360, 0] + +- name: 2026 Care to Learn annual history LONDON + period: 2026 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: LONDON + output: + care_to_learn: [10_140, 0] + +- name: 2015 Care to Learn annual history LOW_COST + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 3_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [3_000, 0] + +- name: 2023 Care to Learn annual history SCOTLAND + period: 2023 + input: + people: + parent: + age: 18 + is_parent: true + current_education: UPPER_SECONDARY + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: SCOTLAND + region: SCOTLAND + output: + care_to_learn: [0, 0] + +- name: 2015 Care to Learn annual history NOT_IN_EDUCATION + period: 2015 + input: + people: + parent: + age: 18 + is_parent: true + current_education: NOT_IN_EDUCATION + is_apprentice: false + child: + age: 1 + childcare_expenses: 12_000 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + region: EAST_OF_ENGLAND + output: + care_to_learn: [0, 0] + diff --git a/policyengine_uk/tests/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/capital_valuation_share_split.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml new file mode 100644 index 0000000000..1563961bed --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation_share_split.yaml @@ -0,0 +1,261 @@ +# corporate_wealth itemised into directly_held_shares, unit_and_investment_trusts +# and stocks_and_shares_isa. share_like_capital_with_sale_expenses is the part +# valued less 10%: directly held shares plus anything not itemised (all of +# corporate_wealth when nothing is itemised). +# Capital is valued at market value less 10% where a sale would incur expenses +# (UC Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; IS Regs 1987 reg. 49; JSA +# Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). Shares +# take the 10% (ADM H1665; DMG 29671, 52671, 84763; HBGM W1.513). Unit trusts +# have no costs of sale (ADM H1673-H1674; DMG 29680-29681, 52680-52681, +# 84771-84772; HBGM W1.530-W1.531), and the survey bucket of unit and +# investment trusts is treated the same. An ISA is valued at what the person +# would get by withdrawing it, with no 10% (ADM H1656; DMG 29662, 52662, +# 84748). Expected values are worked by hand from those rules. + +- name: UC unsplit corporate_wealth keeps the flat 10% treatment + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # No components, so the residual is all 12,000 of corporate_wealth: + # 5,000 + 90% of 12,000 = 15,800, the same as before the split. + savings: 5_000 + corporate_wealth: 12_000 + output: + share_like_capital_with_sale_expenses: 12_000 + uc_assessable_capital: 15_800 + is_uc_eligible: true + # 9,800 above 6,000 is 40 steps of 250 (rounded up): 40 * 4.35 * 12. + uc_tariff_income: 2_088 + +- name: UC split of the same total exempts trusts and ISAs and crosses the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual max(0, 12,000 - (4,000 + 3,000 + 5,000)) = 0. Capital is + # 5,000 + 90% of 4,000 + 3,000 + 5,000 = 16,600, above the 16,000 + # limit; unsplit, the same household held 15,800 and qualified. + savings: 5_000 + corporate_wealth: 12_000 + directly_held_shares: 4_000 + unit_and_investment_trusts: 3_000 + stocks_and_shares_isa: 5_000 + output: + share_like_capital_with_sale_expenses: 4_000 + uc_assessable_capital: 16_600 + is_uc_eligible: false + +- name: UC counts the components when corporate_wealth is not given + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual max(0, 0 - 15,600) = 0. Capital is + # 1,000 + 90% of 10,000 + 2,600 + 3,000 = 15,600. + savings: 1_000 + directly_held_shares: 10_000 + unit_and_investment_trusts: 2_600 + stocks_and_shares_isa: 3_000 + output: + share_like_capital_with_sale_expenses: 10_000 + uc_assessable_capital: 15_600 + is_uc_eligible: true + # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up): 39 * 4.35 * 12. + uc_tariff_income: 2_035.8 + +- name: UC partial split values the part not itemised less 10% + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Only the ISA is itemised: residual 12,000 - 5,000 = 7,000. Share-like + # capital is 90% of 7,000 + 5,000 = 11,300; with savings 15,300 + # (unsplit it would be 4,000 + 10,800 = 14,800). + savings: 4_000 + corporate_wealth: 12_000 + stocks_and_shares_isa: 5_000 + output: + share_like_capital_with_sale_expenses: 7_000 + uc_assessable_capital: 15_300 + # 9,300 above 6,000 is 38 steps of 250 (37.2 rounded up): 38 * 4.35 * 12. + uc_tariff_income: 1_983.6 + +- name: UC components exceeding corporate_wealth leave a nil residual + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The components (7,000) exceed corporate_wealth (5,000), so the + # residual is max(0, -2,000) = 0, not a negative offset. Capital is + # 1,000 + 90% of 4,000 + 3,000 = 7,600. + savings: 1_000 + corporate_wealth: 5_000 + directly_held_shares: 4_000 + unit_and_investment_trusts: 3_000 + output: + share_like_capital_with_sale_expenses: 4_000 + uc_assessable_capital: 7_600 + # 1,600 above 6,000 is 7 steps of 250 (6.4 rounded up): 7 * 4.35 * 12. + uc_tariff_income: 365.4 + +- name: Housing Benefit split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + in_social_housing: true + housing_benefit_reported: 1 + benunits: + benunit: + members: person + would_claim_uc: false + households: + household: + members: person + # Residual 0. 1,100 + 90% of 5,000 + 4,000 + 6,000 = 15,600 (HBGM + # W1.513, W1.530); unsplit it would be 1,100 + 13,500 = 14,600. + savings: 1_100 + corporate_wealth: 15_000 + directly_held_shares: 5_000 + unit_and_investment_trusts: 4_000 + stocks_and_shares_isa: 6_000 + output: + share_like_capital_with_sale_expenses: 5_000 + housing_benefit_assessable_capital: 15_600 + # 9,600 above 6,000 is 39 steps of 250 (38.4 rounded up) at 1 a week. + housing_benefit_tariff_income: 2_028 + housing_benefit_eligible: true + +- name: Income Support split exempts unit trusts + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 90% of 6,000 + 4,000 = 9,400 (DMG 29671, 29681); + # unsplit it would be 9,000. + corporate_wealth: 10_000 + directly_held_shares: 6_000 + unit_and_investment_trusts: 4_000 + output: + income_support_assessable_capital: 9_400 + +- name: Income-based JSA split exempts stocks and shares ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 2,000 + 90% of 3,000 + 6,000 = 10,700 (DMG 29662, + # 29671); unsplit it would be 2,000 + 8,100 = 10,100. + savings: 2_000 + corporate_wealth: 9_000 + directly_held_shares: 3_000 + stocks_and_shares_isa: 6_000 + output: + jsa_income_assessable_capital: 10_700 + +- name: Income-related ESA split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 3,000 + 90% of 2,000 + 3,000 + 5,000 = 12,800 (DMG + # 52662, 52671, 52681); unsplit it would be 3,000 + 9,000 = 12,000. + savings: 3_000 + corporate_wealth: 10_000 + directly_held_shares: 2_000 + unit_and_investment_trusts: 3_000 + stocks_and_shares_isa: 5_000 + output: + esa_income_assessable_capital: 12_800 + +- name: Pension Credit split exempts trusts and ISAs + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # Residual 0. 2,100 + 90% of 10,000 + 8,000 + 12,000 = 31,100 (DMG + # 84748, 84763, 84772); unsplit it would be 2,100 + 27,000 = 29,100. + savings: 2_100 + corporate_wealth: 30_000 + directly_held_shares: 10_000 + unit_and_investment_trusts: 8_000 + stocks_and_shares_isa: 12_000 + output: + pension_credit_assessable_capital: 31_100 + # 21,100 above 10,000 is 43 steps of 500 (42.2 rounded up) at 1 a week. + pension_credit_deemed_income: 2_236 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..723a7a972b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_pension_contribution_deduction_rate.yaml @@ -0,0 +1,123 @@ +# Carer's Allowance net earnings deduct one-half of pension contributions +# (Computation of Earnings Regulations 1996 reg. 10(4)(b)) and of pension +# premiums from trading profit (regs. 13(1)(b)(ii), (4)(c) and (10)(b)). The +# share is gov.dwp.carers_allowance.pension_contribution_deduction_rate. Rates +# other than 0.5 below are model reforms, not current law. +# +# 2026-27: personal allowance, NI primary threshold and Class 4 lower profits +# limit £12,570; Class 4 main rate 6%; basic rate 20%. The earnings limit is +# £204 a week (£10,608 a year) and Carer's Allowance is £86.45 a week. Pay of +# £12,000 is under the allowance and the threshold, so the earnings test +# deducts no tax (it takes PAYE on the pay alone) and no Class 1. + +- name: At current law Carer's Allowance earnings deduct half of pension contributions + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.5 x 3,000 = 10,500, or £201.92 a week: under the limit. + pension_contributions: 3_000 + ni_class_1_employee: 0 + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: A lower share raises earnings by contributions times the change and ends entitlement + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.25 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 3_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.25 x 3,000 = 11,250, or £216.35 a week: over the limit. + # That is 750 more than at half: 3,000 x (0.5 - 0.25). + carers_allowance_earnings: 11_250 + is_gainfully_employed_for_carers_allowance: true + carers_allowance_pre_overlap: 0 + carers_allowance: 0 + +- name: At current law a carer with smaller contributions is over the earnings limit + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.5 x 2,000 = 11,000, or £211.54 a week: over the limit. + carers_allowance_earnings: 11_000 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: A higher share lowers earnings by contributions times the change and restores entitlement + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.75 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # 12,000 - 0.75 x 2,000 = 10,500, or £201.92 a week: under the limit. + # That is 500 less than at half: 2,000 x (0.75 - 0.5). + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: A lower share raises trading earnings by premiums times the change + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.carers_allowance.pension_contribution_deduction_rate: 0.4 + age: 45 + self_employment_income: 15_000 + personal_pension_contributions: 7_700 + care_hours: 40 + country: ENGLAND + output: + # Notional tax 20% x 2,430 = 486 and Class 4 6% x 2,430 = 145.80 (reg. + # 14). At half: 15,000 - 486 - 145.80 - 3,850 = 10,518.20, or £202.27 a + # week, under the limit. At 0.4: 15,000 - 486 - 145.80 - 3,080 = + # 11,288.20, or £217.08 a week, over it: 770 more, 7,700 x (0.5 - 0.4). + carers_allowance_earnings: 11_288.20 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: A reform of the Housing Benefit share leaves Carer's Allowance earnings + period: 2026 + absolute_error_margin: 0.01 + input: + # Model reform, not current law. + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + age: 45 + employment_income: 12_000 + personal_pension_contributions: 2_000 + care_hours: 40 + country: ENGLAND + output: + # The carer is over the limit, so no Carer's Allowance is paid and the + # £12,000 of pay, less relief on the contributions, is the only income: + # no income tax. Housing Benefit net earnings: 12,000 - 0.25 x 2,000 = + # 11,500. Carer's Allowance keeps its own half: 12,000 - 0.5 x 2,000 = + # 11,000, still £211.54 a week. + income_tax: 0 + housing_benefit_net_earnings: 11_500 + carers_allowance_earnings: 11_000 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 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_carer_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml index 15ac062765..d8da059e6f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_carer_income.yaml @@ -159,6 +159,8 @@ savings_credit: 0.6 * (238 - 208.07) * 52 - name: Housing Benefit and Council Tax Reduction no longer fall where Guarantee Credit used to end for a couple whose partner receives Carer's Allowance + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher couple allowance applies (SI 2006/214 Sch 3 para 1(2)(b)). # Before #1952, Pension Credit left out the partner's Carer's Allowance, so # Guarantee Credit ran on to a private pension of 9,700 and the Housing # Benefit and Council Tax Reduction passports ended at 9,800, when both @@ -171,19 +173,19 @@ input: people: claimant_1: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 9_700 carer_1: - age: 70 + age: 72 state_pension: 0 care_hours: 35 claimant_2: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 9_800 carer_2: - age: 70 + age: 72 state_pension: 0 care_hours: 35 benunits: @@ -225,6 +227,8 @@ council_tax_benefit: [1_323.32, 1_307.32] - name: Net income rises where Guarantee Credit now ends for a couple whose partner receives Carer's Allowance + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher couple allowance applies (SI 2006/214 Sch 3 para 1(2)(b)). # Guarantee Credit ends at a private pension of about 4,164 (income 21,392.80 # = the minimum guarantee). Across 4,100 to 4,200 net income rises by # 100 - 20 tax - 51.40 Guarantee Credit = 28.60: Housing Benefit and Council @@ -235,19 +239,19 @@ input: people: claimant_1: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 4_100 carer_1: - age: 70 + age: 72 state_pension: 0 care_hours: 35 claimant_2: - age: 70 + age: 72 state_pension: 13_500 private_pension_income: 4_200 carer_2: - age: 70 + age: 72 state_pension: 0 care_hours: 35 benunits: @@ -282,6 +286,8 @@ council_tax_benefit: [2_000, 2_000] - name: Pension-age Housing Benefit counts Carer Support Payment but not the Scottish Carer Supplement + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher couple allowance applies (SI 2006/214 Sch 3 para 1(2)(b)). # HB (SPC) Regs 2006 reg 29(1)(j)(xviiha), inserted by SI 2026/246 art 21(3). # Income is above the minimum guarantee, so no Guarantee Credit passport. # Nobody pays income tax: 12,000 and 6,000 + 5,103.80 are each below the @@ -291,11 +297,11 @@ input: people: claimant: - age: 70 + age: 72 state_pension: 0 private_pension_income: 12_000 carer: - age: 70 + age: 72 state_pension: 0 private_pension_income: 6_000 care_hours: 35 diff --git a/policyengine_uk/tests/policy/baseline/gov/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 42b0ef9066..b0c74c42cc 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 @@ -223,6 +227,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/pension_credit_prescribed_benefits_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml new file mode 100644 index 0000000000..1287e36891 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_prescribed_benefits_income.yaml @@ -0,0 +1,599 @@ +# Industrial injuries disablement benefit (IIDB), severe disablement allowance +# (SDA), incapacity benefit (IB) and maternity allowance (MA) in Pension Credit +# and Housing Benefit income. +# +# SPC Regs 2002 reg 15(1): "all social security benefits are prescribed except" +# a closed list. The list does not name the four benefits. For IIDB it excepts +# the increases under SSCBA 1992 ss.104 and 105 (constant attendance and +# exceptionally severe disablement; reg 15(1)(c)); (d) and (e) except payments +# under the Sch 8 schemes for old industrial injury cases, and (h) increases +# for a dependant other than a partner. +# Reg 9 makes WTC, IB, SDA and MA (with contribution-based JSA, contributory +# ESA and reg 15(5)(d) maintenance) non-qualifying income for Savings Credit; +# IIDB stays qualifying income. HB (SPC) Regs 2006 reg 29(1)(j) counts the same +# social security benefits for pension-age Housing Benefit, and HB Regs 2006 +# reg 40 counts working-age gross income less Sch 5. +# +# The awards are ordinary awards with no excepted increase, entered as reported +# amounts (SDA as a reported award, which the model pays at gov.dwp.sda.maximum). +# Weekly rates, x 52: +# SI 2026/148 Sch 1: IIDB 100% 233.90, 20% 46.78 (Part V); SDA 103.85 plus +# the higher age related addition 15.50 (Part III); long-term IB 146.60 +# (Part I). Art 9: SMP reg 6 rate 194.32, which is the MA standard rate under +# SSCBA s.35A(1)(b) from 6 April 2026 (art 1). Art 29: standard minimum +# guarantee 238.00 single and 363.25 couple; Savings Credit threshold 208.07 +# single and 329.75 couple. +# SI 2025/295 Sch 1 Part V: IIDB 20% 45.06; art 29: single standard minimum +# guarantee 227.10. +# The cases test classification, not whether a pensioner would be paid MA or +# IB. None of these claimants pays income tax, so tax does not enter any +# expected value; the model taxes IB, so the IB cases also assert that its +# income tax is zero. + +- name: Case 1, single pensioner with IIDB at 20 percent. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 1,000 + 2,432.56 = 3,432.56. + pension_credit_income: 1_000 + 46.78 * 52 + # 12,376 - 3,432.56 = 8,943.44. + guarantee_credit: 238 * 52 - (1_000 + 46.78 * 52) + +- name: Case 2, pensioner couple whose partner has IIDB at 100 percent. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 5,000 + 12,162.80 = 17,162.80. + pension_credit_income: 5_000 + 233.90 * 52 + # 18,889 - 17,162.80 = 1,726.20. + guarantee_credit: 363.25 * 52 - (5_000 + 233.90 * 52) + +- name: Case 3, single pensioner with SDA and the higher age related addition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + sda_reported: 1 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # The model pays 119.35 a week, the statutory 103.85 + 15.50. + sda: (103.85 + 15.50) * 52 + # 1,000 + 6,206.20 = 7,206.20. + pension_credit_income: 1_000 + (103.85 + 15.50) * 52 + # 12,376 - 7,206.20 = 5,169.80. + guarantee_credit: 238 * 52 - (1_000 + (103.85 + 15.50) * 52) + +- name: Case 4, pensioner couple whose partner has SDA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + sda_reported: 1 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 5,000 + 6,206.20 = 11,206.20. + pension_credit_income: 5_000 + (103.85 + 15.50) * 52 + # 18,889 - 11,206.20 = 7,682.80. + guarantee_credit: 363.25 * 52 - (5_000 + (103.85 + 15.50) * 52) + +- name: Case 5, single pensioner with long-term incapacity benefit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # IB is taxable, but 8,623.20 of income bears no tax in the model. + income_tax: 0 + # 1,000 + 7,623.20 = 8,623.20. + pension_credit_income: 1_000 + 146.60 * 52 + # 12,376 - 8,623.20 = 3,752.80. + guarantee_credit: 238 * 52 - (1_000 + 146.60 * 52) + +- name: Case 6, pensioner couple whose partner has long-term incapacity benefit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + income_tax: [0, 0] + # 5,000 + 7,623.20 = 12,623.20. + pension_credit_income: 5_000 + 146.60 * 52 + # 18,889 - 12,623.20 = 6,265.80. + guarantee_credit: 363.25 * 52 - (5_000 + 146.60 * 52) + +- name: Case 7, single pensioner with maternity allowance at the standard rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + maternity_allowance_reported: 194.32 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 1,000 + 10,104.64 = 11,104.64. + pension_credit_income: 1_000 + 194.32 * 52 + # 12,376 - 11,104.64 = 1,271.36. + guarantee_credit: 238 * 52 - (1_000 + 194.32 * 52) + +- name: Case 8, pensioner couple whose partner has maternity allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 5_000 + person2: + age: 70 + state_pension: 0 + maternity_allowance_reported: 194.32 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 5,000 + 10,104.64 = 15,104.64. + pension_credit_income: 5_000 + 194.32 * 52 + # 18,889 - 15,104.64 = 3,784.36. + guarantee_credit: 363.25 * 52 - (5_000 + 194.32 * 52) + +- name: Case 9, single pensioner with IIDB at 20 percent in 2025. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + iidb_reported: 45.06 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 1,000 + 2,343.12 = 3,343.12. + pension_credit_income: 1_000 + 45.06 * 52 + # SI 2025/295 art 29: single standard minimum guarantee 227.10. + # 11,809.20 - 3,343.12 = 8,466.08. + guarantee_credit: 227.10 * 52 - (1_000 + 45.06 * 52) + +- name: Case 10, IIDB is qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + # Aged 80, so State Pension age was reached before the 2016 cutoff. + age: 80 + state_pension: 0 + private_pension_income: 175.04 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 175.04 + 46.78 = 221.82 a week, 11,534.64 a year. + pension_credit_income: (175.04 + 46.78) * 52 + savings_credit_income: (175.04 + 46.78) * 52 + # 0.6 x (221.82 - 208.07) = 8.25 a week, 429.00 a year: below the + # maximum 0.6 x (238 - 208.07), and income is below the minimum + # guarantee, so nothing is withdrawn. + savings_credit: 0.6 * (221.82 - 208.07) * 52 + # (238 - 221.82) x 52 = 841.36. + guarantee_credit: (238 - 221.82) * 52 + +- name: Case 11, an IIDB recipient who reached State Pension age after the cutoff gets no Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 175.04 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + meets_savings_credit_age_requirement: false + savings_credit_income: (175.04 + 46.78) * 52 + savings_credit: 0 + guarantee_credit: (238 - 221.82) * 52 + +- name: Case 12, SDA is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 6_000 + sda_reported: 1 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 6,000 + 6,206.20 = 12,206.20. + pension_credit_income: 6_000 + (103.85 + 15.50) * 52 + # Reg 9(d): qualifying income is the 6,000 pension, below the 10,819.64 + # threshold, so no Savings Credit. Counting SDA as qualifying income + # would give 0.6 x (12,206.20 - 10,819.64) = 831.94. + savings_credit_income: 6_000 + is_savings_credit_eligible: false + savings_credit: 0 + # 12,376 - 12,206.20 = 169.80. + guarantee_credit: 238 * 52 - (6_000 + (103.85 + 15.50) * 52) + +- name: Case 13, incapacity benefit is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 4_500 + incapacity_benefit_reported: 146.60 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 12,123.20 of income bears no tax in the model. + income_tax: 0 + # 4,500 + 7,623.20 = 12,123.20. + pension_credit_income: 4_500 + 146.60 * 52 + # Reg 9(b). + savings_credit_income: 4_500 + savings_credit: 0 + # 12,376 - 12,123.20 = 252.80. + guarantee_credit: 238 * 52 - (4_500 + 146.60 * 52) + +- name: Case 14, maternity allowance is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 2_000 + maternity_allowance_reported: 194.32 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # 2,000 + 10,104.64 = 12,104.64. + pension_credit_income: 2_000 + 194.32 * 52 + # Reg 9(e). + savings_credit_income: 2_000 + savings_credit: 0 + # 12,376 - 12,104.64 = 271.36. + guarantee_credit: 238 * 52 - (2_000 + 194.32 * 52) + +- name: Case 15, working tax credit is not qualifying income for Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 10_000 + benunits: + benunit: + members: [person1] + # An illustrative award: reg 9(a) applies to any amount, and working + # tax credit rates are not set by the up-rating orders used here. + working_tax_credit: 1_000 + households: + household: + members: [person1] + country: ENGLAND + output: + # SPCA s.15(1)(b): 10,000 + 1,000 = 11,000. + pension_credit_income: 11_000 + # Reg 9(a): qualifying income is 10,000, below the 10,819.64 threshold. + # Counting the tax credit would give 0.6 x (11,000 - 10,819.64) = 108.22. + savings_credit_income: 10_000 + savings_credit: 0 + # 12,376 - 11,000 = 1,376. + guarantee_credit: 1_376 + +- name: Case 16, pension-age Housing Benefit counts IIDB once Guarantee Credit ends. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + private_pension_income: 1_000 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # 1,000 + 12,162.80 = 13,162.80, above the 12,376 minimum guarantee. + pension_credit_income: 1_000 + 233.90 * 52 + guarantee_credit: 0 + # HB (SPC) Regs reg 29(1)(j): the same 13,162.80. There are no earnings, + # so the earnings disregard (capped at net earnings) is nil. + housing_benefit_applicable_income: 1_000 + 233.90 * 52 + +- name: Case 17, working-age Housing Benefit counts incapacity benefit and SDA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + incapacity_benefit_reported: 146.60 * 52 + person2: + age: 45 + sda_reported: 1 + benunits: + benunit: + members: [person1, person2] + # A legacy Housing Benefit claim: a Universal Credit claim would + # passport Housing Benefit (housing_benefit_on_passporting_benefit) + # and make its income nil, and this case tests the means test itself. + claims_universal_credit: false + households: + household: + members: [person1, person2] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + income_tax: [0, 0] + # HB Regs 2006 reg 40(1): 7,623.20 + 6,206.20 = 13,829.40; no earnings, + # so no earnings disregard. + housing_benefit_applicable_income: (146.60 + 103.85 + 15.50) * 52 + +- name: Case 18, working tax credit does not add to a positive Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 221.82 * 52 + benunits: + benunit: + members: [person1] + # An illustrative award, as in Case 15. + working_tax_credit: 200 + households: + household: + members: [person1] + country: ENGLAND + output: + # 11,534.64 + 200 = 11,734.64. + pension_credit_income: 221.82 * 52 + 200 + # Reg 9(a): the pension only. + savings_credit_income: 221.82 * 52 + # 0.6 x (221.82 - 208.07) x 52 = 429.00; income is below the minimum + # guarantee, so nothing is withdrawn. Counting the tax credit would give + # 0.6 x (11,734.64 - 10,819.64) = 549.00. + savings_credit: 0.6 * (221.82 - 208.07) * 52 + # 12,376 - 11,734.64 = 641.36. + guarantee_credit: 238 * 52 - (221.82 * 52 + 200) + +- name: Case 19, a partner's SDA is not qualifying income for a couple's Savings Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 12_000 + person2: + age: 78 + state_pension: 0 + sda_reported: 1 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # 12,000 + 6,206.20 = 18,206.20. + pension_credit_income: 12_000 + (103.85 + 15.50) * 52 + # Reg 9(d): qualifying income is the 12,000 pension, below the couple + # threshold of 329.75 x 52 = 17,147. Counting the partner's SDA would give + # 0.6 x (18,206.20 - 17,147) = 635.52. + savings_credit_income: 12_000 + is_savings_credit_eligible: false + savings_credit: 0 + # 18,889 - 18,206.20 = 682.80. + guarantee_credit: 363.25 * 52 - (12_000 + (103.85 + 15.50) * 52) + +- name: Case 20, savings-credit-only Housing Benefit counts IIDB through the Pension Credit assessment. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 200 * 52 + iidb_reported: 46.78 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # 10,400 + 2,432.56 = 12,832.56, above the 12,376 minimum guarantee; no + # income tax (below the personal allowance; IIDB is not taxable). + pension_credit_income: 200 * 52 + 46.78 * 52 + guarantee_credit: 0 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (246.78 - 208.07)) * 52 + # = 0.6 * 29.93 * 52 = 933.816; amount B = 0.4 * (12,832.56 - + # 12,376) = 182.624; savings credit 751.192, so the award is + # savings credit only. + savings_credit: 751.192 + # HB (SPC) Regs 2006 reg 27: the Secretary of State's assessment plus the + # savings credit payable, no earnings disregard (no earnings): + # 12,832.56 + 751.192 = 13,583.752. On main IIDB was not Pension Credit + # income, Guarantee Credit was paid and Housing Benefit income was nil. + housing_benefit_applicable_income: 13_583.752 + +- name: Case 21, working-age Housing Benefit counts IIDB. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + iidb_reported: 233.90 * 52 + benunits: + benunit: + members: [person1] + # A legacy Housing Benefit claim (see Case 17). + claims_universal_credit: false + households: + household: + members: [person1] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + income_tax: 0 + # HB Regs 2006 reg 40(1): IIDB at the 100% rate, 233.90 * 52 = 12,162.80. + housing_benefit_applicable_income: 233.90 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml new file mode 100644 index 0000000000..c81788d84c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_war_pension_income.yaml @@ -0,0 +1,401 @@ +# War pensions and Armed Forces Compensation Scheme (AFCS) payments in +# Pension Credit income. +# +# SPCA 2002 s.15(1)(g) counts "a war disablement pension or war widow's or +# widower's pension"; SPC Regs 2002 reg 15(5)(aa) counts "a guaranteed income +# payment". Reg 17(7) applies Sch IV, whose para 1 disregards "£10 of any of +# the following": war disablement pensions, war widow's or widower's pensions +# and guaranteed income payments among them. PolicyEngine reads "£10 of any of +# the following" as £10 of each qualifying payment: afcs and +# war_widows_pension each get £10 a week, and an unused disregard does not +# pass to another payment or to the other member of a couple. The claimant's +# and the partner's payments count (SPCA s.5; reg 14: "any reference in this +# Part to the claimant shall apply equally to any partner of the claimant"). +# Reg 9 does not exclude these payments from Savings Credit qualifying income. +# +# Statutory 2026-27 rates (SI 2026/148 art 29): standard minimum guarantee +# £238.00 single and £363.25 couple (reg 6(1)); severe disability addition +# £86.05 (reg 6(5)(a)); Savings Credit threshold £208.07 single (reg 7(2)). +# Reg 7(1): maximum savings credit and amount A 60%, amount B 40%. The £10 +# disregard has been £10 since the Regulations were made. Payment amounts +# are illustrative weekly amounts (war pensions and AFCS payments are uprated +# by Ministry of Defence instruments, not by SI 2026/148). Annual amounts use +# the model's 52 weeks a year. + +- name: Case 1, an AFCS payment above £10 a week counts less £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # (30 - 10) * 52 = 1,040. + pension_credit_war_pension_income: (30 - 10) * 52 + pension_credit_income: (30 - 10) * 52 + minimum_guarantee: 238 * 52 + # 238 * 52 - 1,040 = 12,376 - 1,040 = 11,336. + guarantee_credit: (238 - 20) * 52 + +- name: Case 2, an AFCS payment below £10 a week is wholly disregarded. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 5 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # max(5 - 10, 0) * 52 = 0. + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + guarantee_credit: 238 * 52 + +- name: Case 3, an AFCS payment of exactly £10 a week is wholly disregarded. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 10 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # max(10 - 10, 0) * 52 = 0. + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + guarantee_credit: 238 * 52 + +- name: Case 4, a war widow's pension counts less £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + war_widows_pension: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # (30 - 10) * 52 = 1,040; 12,376 - 1,040 = 11,336. + pension_credit_war_pension_income: (30 - 10) * 52 + pension_credit_income: (30 - 10) * 52 + guarantee_credit: (238 - 20) * 52 + +- name: Case 5, one person with an AFCS payment and a war widow's pension gets £10 off each. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 20 * 52 + war_widows_pension: 15 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # ((20 - 10) + (15 - 10)) * 52 = 15 * 52 = 780; 12,376 - 780 = 11,596. + pension_credit_war_pension_income: (10 + 5) * 52 + pension_credit_income: (10 + 5) * 52 + guarantee_credit: (238 - 15) * 52 + +- name: Case 6, each member of a couple gets their own £10. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + state_pension: 0 + afcs: 30 * 52 + person2: + age: 70 + state_pension: 0 + war_widows_pension: 20 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # ((30 - 10) + (20 - 10)) * 52 = 30 * 52 = 1,560. + pension_credit_war_pension_income: (20 + 10) * 52 + pension_credit_income: (20 + 10) * 52 + minimum_guarantee: 363.25 * 52 + # 363.25 * 52 - 1,560 = 18,889 - 1,560 = 17,329. + guarantee_credit: (363.25 - 30) * 52 + +- name: Case 7, a partner's unused disregard does not pass to the claimant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 72 + state_pension: 0 + afcs: 30 * 52 + person2: + age: 70 + state_pension: 0 + afcs: 5 * 52 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + # max(30 - 10, 0) + max(5 - 10, 0) = 20 a week, not 30 + 5 - 20 = 15. + # 20 * 52 = 1,040; 18,889 - 1,040 = 17,849. + pension_credit_war_pension_income: 20 * 52 + pension_credit_income: 20 * 52 + guarantee_credit: (363.25 - 20) * 52 + +- name: Case 8, an AFCS payment is Savings Credit qualifying income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 198.07 * 52 + afcs: 40 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # Private pension 10,299.64 is below the personal allowance and AFCS is + # not taxable income, so there is no income tax. + income_tax: 0 + # 198.07 + (40 - 10) = 228.07 a week; 228.07 * 52 = 11,859.64. + pension_credit_war_pension_income: (40 - 10) * 52 + pension_credit_income: 228.07 * 52 + savings_credit_income: 228.07 * 52 + # 238 - 228.07 = 9.93 a week; 9.93 * 52 = 516.36. + guarantee_credit: 9.93 * 52 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (228.07 - 208.07)) + # = min(17.958, 12.00) = 12.00; amount B = 0.4 * 0 = 0. + # 12.00 * 52 = 624. Whole pennies, so reg 7(4) rounding does not arise. + savings_credit: 12 * 52 + +- name: Case 9, a dependant's and a third adult's payments do not count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 75 + state_pension: 0 + person2: + age: 72 + state_pension: 0 + person3: + age: 45 + afcs: 50 * 52 + person4: + age: 17 + afcs: 30 * 52 + benunits: + benunit: + members: [person1, person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + country: ENGLAND + output: + is_claimant_or_partner: [true, true, false, false] + # Only the claimant's and partner's payments count (SPCA s.5; reg 14). + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + +- name: Case 10, armed forces independence payment is not Pension Credit income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 30 * 52 + armed_forces_independence_payment: 100 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # Reg 15(1)(ab) excepts armed forces independence payment; only the AFCS + # payment counts: (30 - 10) * 52 = 1,040. + pension_credit_war_pension_income: (30 - 10) * 52 + pension_credit_income: (30 - 10) * 52 + # Armed forces independence payment gives the severe disability + # addition (Sch I para 1; reg 6(5)(a)): (238 + 86.05) * 52 = 16,850.60. + minimum_guarantee: (238 + 86.05) * 52 + # 16,850.60 - 1,040 = 15,810.60. + guarantee_credit: (238 + 86.05 - 20) * 52 + +- name: Case 11, Bereavement Support Payment is still not Pension Credit income. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + bsp: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # Reg 15(1)(n) excepts bereavement support payment. Unchanged behaviour: + # pension_credit_income and guarantee_credit are the same on main. + pension_credit_war_pension_income: 0 + pension_credit_income: 0 + guarantee_credit: 238 * 52 + +- name: Case 12, a reported war widow's pension is in the benefit totals and untaxed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + war_widows_pension_reported: 30 * 52 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + country: ENGLAND + output: + war_widows_pension: 30 * 52 + # It joins the gross benefit totals that bsp is in, so moving FRS code 9 + # from bsp to this variable leaves the totals unchanged. + household_benefits: 30 * 52 + hbai_benefits: 30 * 52 + gov_spending: 30 * 52 + pre_budget_change_household_benefits: 30 * 52 + # ITEPA 2003 s.639: no income tax on war widow's pensions. + social_security_income: 0 + income_tax: 0 + +- name: Case 13, an unused disregard on one payment does not pass to the person's other payment. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + state_pension: 0 + afcs: 5 * 52 + war_widows_pension: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # max(5 - 10, 0) + max(30 - 10, 0) = 20 a week, not 5 + 30 - 10 = 25. + # 20 * 52 = 1,040; 12,376 - 1,040 = 11,336. + pension_credit_war_pension_income: 20 * 52 + pension_credit_income: 20 * 52 + guarantee_credit: (238 - 20) * 52 + +- name: Case 14, savings-credit-only Housing Benefit and CTR do not count an AFCS payment. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 240 * 52 + afcs: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + region: NORTH_WEST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + council_tax: 1_500 + output: + # The private pension (12,480) is below the 12,570 personal allowance and + # the AFCS payment is not taxable income, so there is no income tax. + income_tax: 0 + pension_credit_war_pension_income: (30 - 10) * 52 + # 12,480 + 1,040 = 13,520 (260.00 a week), above the 12,376 minimum + # guarantee. + pension_credit_income: 13_520 + guarantee_credit: 0 + # Amount A = min(0.6 * (238 - 208.07), 0.6 * (260 - 208.07)) = 17.958 a + # week; amount B = 0.4 * (260 - 238) = 8.80 a week, whole pennies so reg + # 7(4) rounding does not arise; savings credit (17.958 - 8.80) * 52 = + # 476.216, so the award is savings credit only. + savings_credit: 476.216 + # HB (SPC) Regs 2006 reg 27: the Secretary of State's assessment plus the + # savings credit payable. The model takes the authority to disregard war + # pensions in full (SSAA 1992 s.134(8); HB/CTB Guidance Manual P2.576), so + # the 1,040 counted for Pension Credit comes out: 13,520 - 1,040 + 476.216 + # = 12,956.216. There are no earnings, so no earnings disregard. + housing_benefit_applicable_income: 12_956.216 + # The CTR savings-credit-only route: its general route counts no war + # pension, so the same 1,040 comes out. + council_tax_reduction_applicable_income: 12_956.216 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml index 1d613f2042..f43589f7f5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/savings_credit/savings_credit.yaml @@ -2,6 +2,7 @@ period: 2026 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: SINGLE savings_credit_income: 20_000 @@ -15,6 +16,7 @@ period: 2026 absolute_error_margin: 0.01 input: + age: 80 is_savings_credit_eligible: true relation_type: SINGLE savings_credit_income: 20_000 diff --git a/policyengine_uk/tests/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/state_pension_age_date.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_date.yaml new file mode 100644 index 0000000000..f9c1f230b3 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/state_pension_age_date.yaml @@ -0,0 +1,104 @@ +# The day State Pension age is attained, written as a YYYYMMDD number, at +# boundaries of Pensions Act 1995 Schedule 4 paragraph 1. Age and +# months_since_last_birthday place the date of birth named in each test at +# 6 October of the fiscal year. Expected days come from a day-level reading +# of the statute. + +- name: Woman born 5 April 1950 attains State Pension age on her 60th birthday + period: 2015 + input: + age: 65 + months_since_last_birthday: 6.0323 + is_male: false + output: + state_pension_age_date: 20100405 + +- name: Woman born 6 April 1950 attains it on 6 May 2010 (table 1) + period: 2015 + input: + age: 65 + months_since_last_birthday: 6.0 + is_male: false + output: + state_pension_age_date: 20100506 + +- name: Man born 5 December 1953 attains it on his 65th birthday + period: 2018 + input: + age: 64 + months_since_last_birthday: 10.03333 + is_male: true + output: + state_pension_age_date: 20181205 + +- name: Man born 6 December 1953 attains it on 6 March 2019 (table 2) + period: 2018 + input: + age: 64 + months_since_last_birthday: 10.0 + is_male: true + output: + state_pension_age_date: 20190306 + +- name: Man born 5 October 1954 attains it on 6 September 2020 (table 2) + period: 2020 + input: + age: 66 + months_since_last_birthday: 0.03333 + is_male: true + output: + state_pension_age_date: 20200906 + +- name: Woman born 6 October 1954 attains it on her 66th birthday + period: 2020 + input: + age: 66 + months_since_last_birthday: 0.0 + is_male: false + output: + state_pension_age_date: 20201006 + +- name: Woman born 31 March 1955 attains it on 31 March 2021, before the Housing Benefit cutoff + period: 2026 + input: + age: 71 + months_since_last_birthday: 6.19355 + is_male: false + output: + state_pension_age_date: 20210331 + +- name: Man born 1 April 1955 attains it on 1 April 2021, the Housing Benefit cutoff + period: 2026 + input: + age: 71 + months_since_last_birthday: 6.1613 + is_male: true + output: + state_pension_age_date: 20210401 + +- name: Person born 31 July 1960 attains it on 30 November 2026 (rule 7A) + period: 2026 + input: + age: 66 + months_since_last_birthday: 2.19355 + is_male: false + output: + state_pension_age_date: 20261130 + +- name: Person born 6 March 1961 attains it on their 67th birthday + period: 2028 + input: + age: 67 + months_since_last_birthday: 7.0 + is_male: true + output: + state_pension_age_date: 20280306 + +- name: Person born 6 April 1978 attains it on their 68th birthday + period: 2046 + input: + age: 68 + months_since_last_birthday: 6.0 + is_male: false + output: + state_pension_age_date: 20460406 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml new file mode 100644 index 0000000000..10e99d0342 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/tax_credits/ctc_family_element.yaml @@ -0,0 +1,83 @@ +# SI 2017/387 regs 1 and 4(b): strict responsible-child DOB condition. +# Existing 2024 child element 3455; family element 545; zero-income continuing claims. +- name: A child born on the family cutoff receives no family element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 1 + child: + age: 7 + date_of_birth: 20170406 + benunits: + family: + members: + - parent + - child + would_claim_uc: false + households: + household: + members: + - parent + - child + output: + child_tax_credit: 3455 + tax_credits: 3455 + CTC_family_element: 0 + CTC_child_element: 3455 +- name: A child born the day before the family cutoff retains one family element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 1 + child: + age: 7 + date_of_birth: 20170405 + benunits: + family: + members: + - parent + - child + would_claim_uc: false + households: + household: + members: + - parent + - child + output: + child_tax_credit: 4000 + tax_credits: 4000 + CTC_family_element: 545 + CTC_child_element: 3455 +- name: A child born after the family cutoff receives no family element + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child_tax_credit_reported: 1 + child: + age: 7 + date_of_birth: 20170407 + benunits: + family: + members: + - parent + - child + would_claim_uc: false + households: + household: + members: + - parent + - child + output: + child_tax_credit: 3455 + tax_credits: 3455 + CTC_family_element: 0 + CTC_child_element: 3455 diff --git a/policyengine_uk/tests/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/dividend_band_dates.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/dividend_band_dates.yaml new file mode 100644 index 0000000000..519e44f94e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/dividend_band_dates.yaml @@ -0,0 +1,101 @@ +# Literal official-source band arithmetic; the dividend nil rate occupies band capacity. +# PA0 cases isolate2016/2017/2021/2026 bands from separate allowance limitations. +- name: 2016 dividends with wages 0 and dividends 160000 + period: 2016 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 44185.0 + income_tax: 44185.0 +- name: 2017 dividends with wages 0 and dividends 160000 + period: 2017 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 43810.0 + income_tax: 43810.0 +- name: 2018 dividends with wages 40000 and dividends 10000 + period: 2018 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 40000 + dividend_income: 10000 + output: + dividend_income_tax: 1512.5 + income_tax: 7142.5 +- name: 2019 dividends with wages 40000 and dividends 10000 + period: 2019 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 40000 + dividend_income: 10000 + output: + dividend_income_tax: 600.0 + income_tax: 6100.0 +- name: 2021 dividends with wages 0 and dividends 160000 + period: 2021 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 42985.0 + income_tax: 42985.0 +- name: 2023 dividends with wages 130000 and dividends 10000 + period: 2023 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 130000 + dividend_income: 10000 + output: + dividend_income_tax: 3541.5 + income_tax: 48244.5 +- name: 2025 dividends with wages 49000 and dividends 2000 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 49000 + dividend_income: 2000 + output: + dividend_income_tax: 313.75 + income_tax: 7599.75 +- name: 2025 dividends with wages 130000 and dividends 10000 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 130000 + dividend_income: 10000 + output: + dividend_income_tax: 3738.25 + income_tax: 48441.25 +- name: 2026 dividends with wages 0 and dividends 160000 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + region: LONDON + employment_income: 0 + dividend_income: 160000 + output: + dividend_income_tax: 48976.21 + income_tax: 48976.21 diff --git a/policyengine_uk/tests/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/statutory_pay_pension_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml new file mode 100644 index 0000000000..ebf7430268 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/statutory_pay_pension_relief.yaml @@ -0,0 +1,243 @@ +# Actual employer-paid annual SSP/SMP, occupational net-pay member contributions, +# and an already-awarded defined-benefit pension where specified. +# FA 2004 s. 189 and HMRC PTM044100: statutory employment pay is relevant earnings. + +- name: SSP final ordinary tax + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 22000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: SMP relief cap + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 35 + employment_income: 20000 + statutory_sick_pay: 0 + statutory_maternity_pay: 2000 + self_employment_income: 0 + private_pension_income: 0 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 22000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 0.0 + +- name: Still capped at all relevant earnings + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 25000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 25000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Contributions already covered + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 12000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 12000 + pension_contributions_relief: 12000 + personal_pension_contributions_tax: 0 + income_tax: 3486.0 + +- name: Equal amount as wages + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 22000 + statutory_sick_pay: 0 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 0 + employee_pension_contributions: 22000 + pension_contributions_relief: 22000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Pension is not relevant earnings + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 0 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 0 + employee_pension_contributions: 22000 + pension_contributions_relief: 20000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Age75 exclusion retained + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 75 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + private_pension_income: 20000 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 22000 + pension_contributions_relief: 0 + personal_pension_contributions_tax: 0 + income_tax: 5886.0 + +- name: RAS basic amount retained + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 40 + employment_income: 1000 + statutory_sick_pay: 0 + statutory_maternity_pay: 1000 + self_employment_income: 0 + private_pension_income: 0 + employee_pension_contributions_reported: 0 + personal_pension_contributions: 3600 + output: + employment_benefits: 1000 + employee_pension_contributions: 0 + pension_contributions_relief: 3600 + personal_pension_contributions_tax: 0 + income_tax: 0.0 + +- name: Positive trading profit + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 5000 + private_pension_income: 20000 + employee_pension_contributions_reported: 27000 + personal_pension_contributions: 0 + output: + employment_benefits: 2000 + employee_pension_contributions: 27000 + pension_contributions_relief: 27000 + personal_pension_contributions_tax: 0 + income_tax: 1486.0 + +- name: Trading losses do not offset statutory employment pay for relief + period: 2026 + absolute_error_margin: 0.01 + input: + age: 60 + employment_income: 20000 + statutory_sick_pay: 2000 + self_employment_income: -5000 + employee_pension_contributions_reported: 25000 + output: + pension_contributions_relief: 22000 + +# Supplied ANI is an explicit input contract. Restore the same actual pension +# relief for equal total relevant earnings, regardless of the SSP wage split. +- name: Supplied ANI all-wages annual allowance control + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income: 22000 + statutory_sick_pay: 0 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5000 + output: + employment_benefits: 0 + pension_contributions_relief: 22000 + net_income: 27000 + pension_contributions_for_annual_allowance: 22000 + pension_annual_allowance_threshold_income: 5000 + pension_annual_allowance_adjusted_income: 27000 + +- name: Supplied ANI SSP preserves annual allowance earnings base + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income: 20000 + statutory_sick_pay: 2000 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 22000 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5000 + output: + employment_benefits: 2000 + pension_contributions_relief: 22000 + net_income: 27000 + pension_contributions_for_annual_allowance: 22000 + pension_annual_allowance_threshold_income: 5000 + pension_annual_allowance_adjusted_income: 27000 diff --git a/policyengine_uk/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 new file mode 100644 index 0000000000..405fc1ffc3 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -0,0 +1,846 @@ +# The Council Tax Reduction means test covers the applicant's own income and +# their partner's (SSI 2012/319 regs 14, 21, 23 and 26; SI 2012/2885 Sch 1 +# para 11; WSI 2013/3029 Sch 1 para 5 and Sch 6 para 7; SSI 2021/249 reg 36). +# The applicant is the person liable for the council tax. Where that is a +# household head who is not the benefit unit's claimant or partner (here a +# grandmother sharing a benefit unit with a young couple and their baby), the +# means test is the head's own, as a single person; the benefit unit's +# benefits belong to the couple. None of these cases sets applicable income. +# +# Rates for 2026 (parameters): pension-age single personal allowance +# £256/week, working-age single aged 25 or over £95.55/week, couple aged 18 +# or over £150.15/week, disability premium (single) £44.85/week, severe +# disability premium (single) £86.05/week; 52 weeks. Scotland, Wales and the +# English pensioner scheme: maximum reduction 100%, withdrawal 20%. Newham: +# maximum 70%, withdrawal 25%. Personal allowance £12,570, basic rate 20%. + +- name: A grandmother head with a young couple in her benefit unit and no income of her own gets the full reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + council_tax_reduction_head_applies_alone: [true] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + # The couple's Child Benefit for the baby is theirs, not hers. + council_tax_reduction_applicable_income: [0] + # A single pensioner: 256 * 52. + council_tax_reduction_applicable_amount: [13_312] + simulated_council_tax_reduction_benunit: [1_800] + council_tax_reduction: 1_800 + +- name: The same grandmother with a £100,000 private pension gets no reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 100_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_applicable_amount: [13_312] + # Her pension after tax is far above £13,312, and 20% of the excess is + # more than the £1,800 council tax. + simulated_council_tax_reduction_benunit: [0] + council_tax_reduction: 0 + +- name: In England a grandmother head's own £20,000 pension is withdrawn at 20% above her allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 20_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + employment_income: 10_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # Her pension less income tax of (20,000 - 12,570) * 20% = 1,486. The + # young mother's earnings are not hers. + council_tax_reduction_applicable_income: [18_514] + council_tax_reduction_applicable_amount: [13_312] + # 1,800 - (18,514 - 13,312) * 20%. + simulated_council_tax_reduction_benunit: [759.60] + +- name: The young couple's income-based Jobseeker's Allowance does not passport the grandmother head + # Provisional until in-unit non-dependants land (#2017): the 19-year-old + # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in + # her family), but the model charges a deduction only for non-dependants + # in another benefit unit, so this case has none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + private_pension_income: 20_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + jsa_income: 4_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true, false] + council_tax_reduction_liable_person: [true, false, false, false] + is_council_tax_reduction_applicant_or_partner: [true, false, false, false] + council_tax_reduction_relevant_income_based_benefit: [false] + # Neither the couple's JSA nor their Child Benefit is her income. + council_tax_reduction_applicable_income: [18_514] + simulated_council_tax_reduction_benunit: [759.60] + +- name: A head who applies alone is a single person, with premiums on her own disability only + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_disabled_for_benefits: true + # Attendance Allowance qualifies her for the severe disability premium. + attendance_allowance: 4_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + is_disabled_for_benefits: true + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # Disability (44.85) and severe disability (86.05) premiums at the single + # rate: (44.85 + 86.05) * 52. The young mother's disability does not give + # the grandmother a premium, and at 17 neither parent is a non-dependant + # who bars the severe disability premium. + council_tax_reduction_premiums: [6_806.80] + # Single aged 25 or over, 95.55 * 52 = 4,968.60, plus the premiums. + council_tax_reduction_applicable_amount: [11_775.40] + +- name: Adult parents in a lone-applying head's benefit unit are her non-dependants and bar her severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_disabled_for_benefits: true + # Attendance Allowance qualifies her for the severe disability premium. + attendance_allowance: 4_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + is_disabled_for_benefits: true + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # At 19 the parents are residents aged 18 or over outside her family, so + # the severe disability premium is not payable (HB Regs 2006 Sch 3 para + # 14(2)(a)(ii), which the model applies to Council Tax Reduction too). + # Only the disability premium remains: 44.85 * 52. + council_tax_reduction_premiums: [2_332.20] + council_tax_reduction_applicable_amount: [7_300.80] + +- name: A blind grandmother head who applies alone has no non-dependant deduction in Merton + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_blind: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + son: + age: 30 + is_household_head: false + employment_income: 30_000 + weekly_hours: 40 + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + son: + members: [son] + claims_all_entitled_benefits: false + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [false, false] + # The exemption is the applicant's or partner's (SI 2012/2885 Sch 1 para + # 8(6); the Merton scheme follows it). + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, false, false, true] + merton_council_tax_reduction_non_dep_deductions: [0, 0] + merton_council_tax_reduction: [1_800, 0] + +- name: The young couple's Universal Credit does not put a grandmother head on Newham's Universal Credit route + # Provisional until in-unit non-dependants land (#2017): the 19-year-old + # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in + # her family), but the model charges a deduction only for non-dependants + # in another benefit unit, so this case has none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 55 + is_household_head: true + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + employment_income: 30_000 + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + universal_credit: 6_000 + child_benefit: 1_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: NEWHAM + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: [true] + # The young father's earnings and the couple's award are not hers. + council_tax_reduction_applicable_income: [0] + # Single aged 25 or over: 95.55 * 52. + council_tax_reduction_applicable_amount: [4_968.60] + # No excess income: 1,800 * 70%. On the Universal Credit route the + # couple's earnings and award would leave nothing. + newham_council_tax_reduction: [1_260] + +- name: A partner under 18 is not liable, but their income counts in the couple's means test + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 19 + is_household_head: true + is_benunit_head: false + partner: + # The benefit unit's head, so the HBAI fallback does not read the + # 17-year-old as a dependent child. + age: 17 + is_household_head: false + is_benunit_head: true + employment_income: 10_000 + benunits: + couple: + members: [head, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The model would otherwise pay Child Benefit for the 17-year-old + # partner. SI 2006/223 reg 12 bars it while a qualifying young person + # lives with a partner as a couple, unless that partner is in + # education or training; that is a separate issue. + child_benefit: 0 + households: + home: + members: [head, partner] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [true, true] + council_tax_reduction_liable_person: [true, false] + council_tax_reduction_head_applies_alone: [false] + is_council_tax_reduction_applicant_or_partner: [true, true] + # Below the personal allowance and the National Insurance threshold. + council_tax_reduction_applicable_income: [10_000] + # The couple rate where the elder is 18 or over: 150.15 * 52 = 7,807.80. + council_tax_reduction_applicable_amount: [7_807.80] + # 1,800 - (10,000 - 7,807.80) * 20%. + simulated_council_tax_reduction_benunit: [1_361.56] + +- name: A head under 18 with an adult partner claims through the partner, and the head's income counts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 17 + is_household_head: true + is_benunit_head: true + employment_income: 10_000 + partner: + age: 18 + is_household_head: false + is_benunit_head: false + benunits: + couple: + members: [head, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The model would otherwise pay Child Benefit for the 17-year-old + # partner. SI 2006/223 reg 12 bars it while a qualifying young person + # lives with a partner as a couple, unless that partner is in + # education or training; that is a separate issue. + child_benefit: 0 + households: + home: + members: [head, partner] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [true, true] + council_tax_reduction_liable_person: [false, true] + is_council_tax_reduction_applicant_or_partner: [true, true] + council_tax_reduction_applicable_income: [10_000] + council_tax_reduction_applicable_amount: [7_807.80] + simulated_council_tax_reduction_benunit: [1_361.56] + +- name: A pensioner who is not the applicant or partner does not make a young couple's claim a pensioner's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: false + mother: + age: 19 + is_parent: true + is_household_head: true + father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, mother, father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, mother, father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The mother is the head and the couple are the benefit unit's claimant + # and partner, so they are the applicant and partner. + council_tax_reduction_head_applies_alone: [false] + is_council_tax_reduction_applicant_or_partner: [false, true, true, false] + council_tax_reduction_pensioner: [false] + # A working-age couple aged 18 or over: 150.15 * 52, not the pension-age + # couple rate. + council_tax_reduction_applicable_amount: [7_807.80] + +- name: The young couple's Universal Credit does not remove a grandmother head's tariff income from capital in Kingston upon Thames + # Provisional until in-unit non-dependants land (#2017): the 19-year-old + # parents are her non-dependants (SI 2012/2885 reg 9(1); they are not in + # her family), but the model charges a deduction only for non-dependants + # in another benefit unit, so this case has none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 55 + is_household_head: true + private_pension_income: 5_000 + young_mother: + age: 19 + is_parent: true + is_household_head: false + young_father: + age: 19 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + universal_credit: 6_000 + child_benefit: 1_000 + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + council_tax: 1_800 + savings: 10_000 + output: + council_tax_reduction_head_applies_alone: [true] + council_tax_reduction_applicable_income: [5_000] + council_tax_reduction_applicable_amount: [4_968.60] + # Tariff income ceil((10,000 - 6,000) / 250) = £16 a week, 832 a year, + # since she has no award. 1,800 - (5,000 + 832 - 4,968.60) * 20%. + kingston_upon_thames_council_tax_reduction: [1_627.32] + +- name: A blind grandmother head's exemption does not exempt a sharer's claim from a non-dependant deduction in Merton + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 60 + is_household_head: true + is_blind: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + sharer: + age: 40 + is_household_head: false + son: + age: 30 + is_household_head: false + employment_income: 30_000 + weekly_hours: 40 + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + claims_all_entitled_benefits: true + would_claim_uc: false + son_family: + members: [son] + claims_all_entitled_benefits: false + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby, sharer, son] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_liable_person: [true, false, false, false, true, false] + council_tax_reduction_joint_liability_share: [0.5, 0.5, 1] + # The grandmother applies alone and is blind, so her claim has no + # deduction. The sharer is neither her nor her partner, so the sharer's + # claim bears half the son's deduction: Merton charges £13.30 a week for + # gross weekly income of £485 or more in remunerative work (30,000 / 52 = + # 576.92), 13.30 * 52 / 2 = 345.80 (Merton scheme para 30(5)-(6)). + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + merton_council_tax_reduction_non_dep_deductions: [0, 345.80, 0] + # Each claim covers half the £1,800; neither has income above its + # allowance. + merton_council_tax_reduction: [900, 554.20, 0] + +- name: A disabled pensioner head who applies alone gets no working-age disability premiums + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + is_disabled_for_benefits: true + is_enhanced_disabled_for_benefits: true + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # The pension-age schedule has no adult disability premium, and its + # enhanced disability premium is for a child or young person (SI 2012/2885 + # Sch 2 Part 3, paras 6-9). + council_tax_reduction_premiums: [0] + council_tax_reduction_applicable_amount: [13_312] + simulated_council_tax_reduction_benunit: [1_800] + +- name: A pensioner head who applies alone and receives Attendance Allowance gets the pension-age severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + is_disabled_for_benefits: true + attendance_allowance: 4_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + # The single severe disability premium only: 86.05 * 52 = 4,474.60. The + # parents are 17, so not non-dependants who bar it. + council_tax_reduction_premiums: [4_474.60] + council_tax_reduction_applicable_amount: [17_786.60] + +- name: A carer paid for caring for a lone-applying head ends her severe disability premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 80 + is_household_head: true + attendance_allowance: 4_000 + young_mother: + age: 17 + is_parent: true + is_household_head: false + # Paid for caring for the grandmother. + carers_allowance: 4_000 + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: [true] + # A carer benefit is paid for caring for her, so no severe disability + # premium (SI 2012/2885 Sch 2 para 6(2)(a)(iii)), and she is not a carer + # herself. + council_tax_reduction_premiums: [0] + council_tax_reduction_applicable_amount: [13_312] + +- name: A working pensioner head who applies alone has the single earnings disregards on her own earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: true + employment_income: 10_000 + weekly_hours: 30 + young_mother: + age: 17 + is_parent: true + is_household_head: false + young_father: + age: 17 + is_parent: true + is_household_head: false + baby: + age: 0 + is_household_head: false + benunits: + family: + members: [grandmother, young_mother, young_father, baby] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + home: + members: [grandmother, young_mother, young_father, baby] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_pensioner: [true] + # Her own earnings, below the personal allowance and with no National + # Insurance over State Pension age. + council_tax_reduction_net_earnings: [10_000] + # The single disregard of £5 a week plus the additional £17.10, which she + # earns by working 30 hours a week at 25 or over (SI 2012/2885 Sch 4 + # paras 8(a) and 10): (5 + 17.10) * 52. Not the couple's £10 rate, and not + # the young couple's work conditions. + council_tax_reduction_pensioner_earnings_disregard: [1_149.20] + council_tax_reduction_applicable_income: [8_850.80] + +- name: The benefit unit's guarantee credit does not disregard a lone-applying head's income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 95 + is_household_head: true + private_pension_income: 100_000 + claimant: + age: 70 + is_parent: true + is_household_head: false + partner: + age: 70 + is_parent: true + is_household_head: false + benunits: + family: + members: [head, claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + # The benefit unit's claimant and partner, not the head, get + # guarantee credit. + in_receipt_of_guarantee_credit: true + households: + home: + members: [head, claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 0 + output: + is_claimant_or_partner: [false, true, true] + council_tax_reduction_head_applies_alone: [true] + # Her own £100,000 pension is assessed, not disregarded as a guarantee + # credit recipient's would be (SI 2012/2885 Sch 1 para 13). + simulated_council_tax_reduction_benunit: [0] + +- name: The benefit unit's guarantee credit does not disregard a lone-applying head's capital + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 95 + is_household_head: true + claimant: + age: 70 + is_parent: true + is_household_head: false + partner: + age: 70 + is_parent: true + is_household_head: false + benunits: + family: + members: [head, claimant, partner] + claims_all_entitled_benefits: true + would_claim_uc: false + in_receipt_of_guarantee_credit: true + households: + home: + members: [head, claimant, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 1_800 + savings: 20_000 + output: + council_tax_reduction_head_applies_alone: [true] + # Household savings stand in for her capital, above the £16,000 limit; + # a guarantee credit recipient's capital would be disregarded. + council_tax_reduction_assessable_capital: [20_000] + simulated_council_tax_reduction_benunit: [0] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml index 793efe1508..bf327983e5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_family_scheme.yaml @@ -53,6 +53,8 @@ council_tax_reduction: 1_575 - name: Oxford, working-age head and pension-age sharer each claim under their own scheme + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: @@ -61,7 +63,7 @@ age: 40 is_household_head: true sharer: - age: 70 + age: 72 benunits: head_family: members: [head] @@ -969,6 +971,7 @@ output: is_council_tax_reduction_applicant_or_partner: [true, false, false, false] council_tax_reduction_head_applies_alone: [true] - council_tax_reduction_relevant_income_based_benefit: [true] + # Nor does it passport her: the passport is the applicant's award. + council_tax_reduction_relevant_income_based_benefit: [false] council_tax_reduction_pensioner: [true] simulated_council_tax_reduction_benunit: [1_800] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml index 186b79ccc3..f18404306e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_support_payment.yaml @@ -217,7 +217,7 @@ scottish_carer_supplement: 0 council_tax_reduction_applicable_income: 7_000 + 83.30 * 52 council_tax_reduction_applicable_amount: (92.05 + 46.40) * 52 - # 2,000 - 0.2 x (11,331.60 - 7,198.88) = 1,173.46 + # 2,000 - 0.2 x (11,331.60 - 7,199.40) = 1,173.56 council_tax_reduction: 2_000 - 0.2 * (7_000 + 83.30 * 52 - (92.05 + 46.40) * 52) - name: In 2024 the Scottish carer is still on Carer's Allowance, which was already counted @@ -411,12 +411,14 @@ council_tax_reduction: 2_000 - name: An English pensioner carer's Council Tax Reduction is unchanged + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: carer: - age: 70 + age: 72 state_pension: 0 private_pension_income: 14_000 care_hours: 35 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml new file mode 100644 index 0000000000..2679c0baa8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_age_personal_allowance.yaml @@ -0,0 +1,191 @@ +# Pension-age Council Tax Reduction personal allowances by country. England's +# prescribed requirements (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29) give +# the lower rates where every member attained State Pension age on or after +# 1 April 2021; the Scottish (SSI 2012/319 Sch 1 para 2) and Welsh (SI 2013/3029 +# Sch 2 para 1) schemes have one pension-age rate. Amounts are for 2026-27. + +- name: In England, a single person who attained State Pension age on 31 March 2021 gets the higher rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.19355 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: LONDON + output: + council_tax_reduction_applicable_amount: 256.00 * 52 + +- name: In England, a single person who attained State Pension age on 1 April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: NORTH_EAST + output: + council_tax_reduction_applicable_amount: 238.00 * 52 + +- name: In England, a couple who both attained State Pension age after April 2021 get the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 68 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + households: + household: + members: [older, younger] + region: SOUTH_WEST + output: + council_tax_reduction_applicable_amount: 363.25 * 52 + +- name: In Scotland, a single person who attained State Pension age on 1 April 2021 keeps the one pension-age rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: SCOTLAND + output: + council_tax_reduction_applicable_amount: 256.00 * 52 + +- name: In Scotland, a couple who both attained State Pension age after April 2021 keep the one pension-age rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 68 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + households: + household: + members: [older, younger] + region: SCOTLAND + output: + council_tax_reduction_applicable_amount: 383.35 * 52 + +- name: In Wales, a single person who attained State Pension age on 1 April 2021 keeps the one pension-age rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 71 + months_since_last_birthday: 6.1613 + is_male: false + benunits: + benunit: + members: [claimant] + benefits_premiums: 0 + households: + household: + members: [claimant] + region: WALES + output: + council_tax_reduction_applicable_amount: 256.00 * 52 + +- name: In England, a mixed-age couple whose older member attained State Pension age after April 2021 gets the lower rate + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + months_since_last_birthday: 6.0 + is_male: true + younger: + age: 60 + months_since_last_birthday: 9.1613 + is_male: false + benunits: + benunit: + members: [older, younger] + benefits_premiums: 0 + # A mixed-age couple with no Universal Credit claims as pensioners + # (SI 2012/2885 reg 3(1)(a)). + council_tax_reduction_pensioner: true + households: + household: + members: [older, younger] + region: EAST_MIDLANDS + output: + council_tax_reduction_applicable_amount: 363.25 * 52 + +- name: In England, an older non-applicant does not change the applicant's cohort + # SI 2012/2885 Sch 2 para 1 reads the applicant's and partner's ages. The + # applicant attained State Pension age in 2025, after the cutoff, so the + # lower single rate applies: 238 x 52. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 67 + date_of_birth: 19590101 + is_claimant_or_partner: true + is_household_head: true + other_adult: + age: 80 + date_of_birth: 19460101 + is_claimant_or_partner: false + benunits: + family: + members: [claimant, other_adult] + council_tax_reduction_pensioner: true + benefits_premiums: 0 + pension_age_benefits_premiums: 0 + households: + home: + members: [claimant, other_adult] + country: ENGLAND + output: + council_tax_reduction_attained_pension_age_before_cutoff: false + council_tax_reduction_applicable_amount: 238 * 52 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml index 5a29964d97..93b3c63aff 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_credit.yaml @@ -13,12 +13,14 @@ # central path in 2039-40, where the State Pension figures below come from. - name: England guarantee credit recipient with income above the applicable amount gets maximum CTR + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 18_712 benunits: benunit: @@ -73,12 +75,14 @@ council_tax_reduction: 2_000 - name: Entitled to guarantee credit but not claiming Pension Credit keeps the taper + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 18_712 benunits: benunit: @@ -103,12 +107,14 @@ council_tax_reduction: 1_165.68 - name: England pensioner without Pension Credit keeps the taper + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 18_712 benunits: benunit: @@ -512,12 +518,14 @@ council_tax_reduction: 1_999.60 - name: Capital of exactly 16,000 is within the limit for a pensioner without Pension Credit + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 13_000 benunits: benunit: diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml new file mode 100644 index 0000000000..dedf76043f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_child_benefit.yaml @@ -0,0 +1,212 @@ +# Current GB pensioner income exclusions: England Sch1 para16(1)(j)(vi), +# Wales Sch1 para10(1)(j)(vi), Scotland reg27(1)(j)(vii). +# Model2026: eldest Child Benefit27.05*52=1406.60; aged allowance256*52=13312. +# These genuine claim/takeup inputs use the general rules, not PC overrides. +- name: England pensioner Child Benefit is excluded before the CTR taper + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 72, is_parent: true, state_pension: 16000} + child: {age: 9} + benunits: + family: + members: [parent, child] + would_claim_pc: false + would_claim_uc: false + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + income_tax: [686, 0] + council_tax_reduction_pensioner: true + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + council_tax_reduction_applicable_income: 15314 + council_tax_reduction_applicable_amount: 13312 + # 3000 - .2*(16000 - 686 - 13312) = 2599.60. + council_tax_benefit: 2599.60 + council_tax_reduction: 2599.60 + +- name: Scottish pensioner Child Benefit does not reduce the final CTR award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: SCOTLAND + local_authority: GLASGOW_CITY + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 12000 + council_tax_benefit: 3000 + council_tax_reduction: 3000 + +- name: Welsh pensioner Child Benefit does not reduce the final CTR award + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: WALES + local_authority: CARDIFF + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 12000 + council_tax_benefit: 3000 + council_tax_reduction: 3000 + +- name: Not claiming Child Benefit preserves the pensioner general-rule income + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 72, is_parent: true, state_pension: 16000} + child: {age: 9} + benunits: + family: + members: [parent, child] + would_claim_pc: false + would_claim_uc: false + would_claim_child_benefit: false + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 0 + council_tax_reduction_applicable_income: 15314 + council_tax_reduction: 2599.60 + +- name: Excluding pensioner Child Benefit retains the zero floor and liability cap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, personal_pension_contributions: 4000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 5000 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 3000 + +- name: A Guarantee Credit family still uses the whole-income disregard + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 80, is_parent: true} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: true, would_claim_uc: false} + households: + home: + members: [parent, child] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + in_receipt_of_guarantee_credit: true + council_tax_reduction_applicable_income: 0 + council_tax_reduction: 3000 + +- name: Northern Ireland intermediate income is preserved outside this GB change + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: {age: 70, is_parent: true, state_pension: 12000} + child: {age: 9} + benunits: + family: {members: [parent, child], would_claim_pc: false, would_claim_uc: false} + households: + home: + members: [parent, child] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_applicable_income: 13406.60 + council_tax_reduction_scheme_supported: false + council_tax_reduction: 0 + +- name: Pensioner Child Benefit exclusion keeps SSP and SMP counted once as earnings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: {age: 70, is_parent: true, state_pension: 12000, statutory_sick_pay: 1000} + partner: {age: 30, is_parent: true, gender: FEMALE, statutory_maternity_pay: 2000} + baby: {age: 0} + benunits: + family: + members: [older, partner, baby] + would_claim_pc: false + would_claim_uc: false + households: + home: + members: [older, partner, baby] + region: LONDON + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 3000 + savings: 0 + output: + child_benefit: 1406.60 + council_tax_reduction_pensioner: true + income_tax: [0, 0, 0] + council_tax_reduction_pensioner_earnings_disregard: 520 + # Default marriage election removes the otherwise86tax. + # 12000 + 1000 + 2000 - 0 tax - 10*52 disregard = 14480. + council_tax_reduction_applicable_income: 14480 + council_tax_reduction: 3000 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml index c077000081..1f42bf1d23 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.yaml @@ -419,12 +419,14 @@ council_tax_reduction_pensioner_earnings_disregard: 0 - name: England pensioner with State Pension and earnings has 5 a week of earnings disregarded in CTR + # Aged 72: State Pension age was reached in 2019, before 1 April 2021, so + # the higher personal allowance applies (SI 2012/2885 Sch 2 para 1). period: 2026 absolute_error_margin: 0.01 input: people: pensioner: - age: 70 + age: 72 state_pension: 15_000 employment_income: 1_000 benunits: 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..ea63047128 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -0,0 +1,606 @@ +# 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. + # The couple are in her benefit unit, so the model charges no + # non-dependant deduction for them (#2017). + council_tax_benefit: 1_466.368 + +- name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income + period: 2026 + absolute_error_margin: 0.01 + 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/pension_contribution_deduction_rate.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml new file mode 100644 index 0000000000..38fc3f1288 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/pension_contribution_deduction_rate.yaml @@ -0,0 +1,808 @@ +# Each Council Tax Reduction scheme deducts its own share of pension +# contributions from earnings, one-half in every scheme modelled: +# - England pensioners: SI 2012/2885 Sch 1 paras 19(2)(b)-(c) and 29; +# - England default scheme: SI 2012/2886 Sch paras 42, 52 and 61; +# - Wales: WSI 2013/3029 Sch 1 paras 13 and 23 and Sch 6 paras 15 and 24; +# - Scotland: SSI 2012/319 regs 33 and 36 and SSI 2021/249 regs 50 and 51(9); +# - Merton, Kingston upon Thames and Newham: their schemes' paras 52 and 61; +# - Oxford: its scheme's para 18 and Annex 5; +# - Westminster: the default scheme, which its scheme says it is based on. +# None refers to Housing Benefit's share, so a reform of one scheme's share +# moves that scheme's families only, and a reform of Housing Benefit's moves +# none of them. The rates below 0.5 are model reforms, not current law. +# +# Every person earns 10,000 a year, below the personal allowance and the +# primary threshold, so no income tax or National Insurance is due, and pays +# 2,000 into a personal pension. Universal Credit and Pension Credit are not +# claimed. Applicable income is 10,000 - rate x 2,000, less the single +# pensioner's 5 a week (260 a year) earnings disregard (SI 2012/2885 Sch 4 +# para 8(a)) for the 70-year-old. Net earnings are 10,000 - rate x 2,000. + +- name: At current law every scheme deducts half of pension contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + wales: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_pensioner: + age: 70 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + merton: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + kingston: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + newham: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + westminster: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + oxford: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + wales_family: + members: [wales] + would_claim_uc: false + would_claim_pc: false + england_pensioner_family: + members: [england_pensioner] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + merton_family: + members: [merton] + would_claim_uc: false + would_claim_pc: false + kingston_family: + members: [kingston] + would_claim_uc: false + would_claim_pc: false + newham_family: + members: [newham] + would_claim_uc: false + would_claim_pc: false + westminster_family: + members: [westminster] + would_claim_uc: false + would_claim_pc: false + oxford_family: + members: [oxford] + would_claim_uc: false + would_claim_pc: false + households: + scotland_household: + members: [scotland] + country: SCOTLAND + wales_household: + members: [wales] + country: WALES + england_pensioner_household: + members: [england_pensioner] + country: ENGLAND + england_default_household: + members: [england_default] + country: ENGLAND + merton_household: + members: [merton] + country: ENGLAND + local_authority: MERTON + kingston_household: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + newham_household: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + westminster_household: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + oxford_household: + members: [oxford] + country: ENGLAND + local_authority: OXFORD + output: + # In order: Scotland and Wales at working age, the England pensioner, + # England working age outside the modelled councils, and working age in + # Merton, Kingston upon Thames, Newham, Westminster and Oxford. + pension_contributions: [2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000, 2000] + income_tax: [0, 0, 0, 0, 0, 0, 0, 0, 0] + national_insurance: [0, 0, 0, 0, 0, 0, 0, 0, 0] + council_tax_reduction_pensioner: [false, false, true, false, false, false, false, false, false] + council_tax_reduction_pension_contribution_deduction_rate: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + # 10,000 - 0.5 x 2,000, less 260 for the pensioner. + council_tax_reduction_applicable_income: [9000, 9000, 8740, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_net_earnings: [9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000] + housing_benefit_net_earnings: [9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0, 260, 0, 0, 0, 0, 0, 0] + +- name: A reform of the Scotland share moves only that scheme's applicable income + # Model reform, not current law. + # Scotland, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, England working age outside the modelled councils: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.scotland.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + households: + scotland_household: + members: [scotland] + country: SCOTLAND + england_default_household: + members: [england_default] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Wales share moves only that scheme's applicable income + # Model reform, not current law. + # Wales, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, England working age outside the modelled councils: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.wales.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + wales: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + wales_family: + members: [wales] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + households: + wales_household: + members: [wales] + country: WALES + england_default_household: + members: [england_default] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the England pensioner share moves only that scheme's applicable income + # Model reform, not current law. + # England pensioner scheme: 10,000 - 0.25 x 2,000 - 260 = 9,240. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.pensioners.means_test.pension_contribution_deduction_rate: 0.25 + people: + england_pensioner: + age: 70 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + england_pensioner_family: + members: [england_pensioner] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + england_pensioner_household: + members: [england_pensioner] + country: ENGLAND + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9240, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [260, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the England default scheme share moves only that scheme's applicable income + # Model reform, not current law. + # England working age outside the modelled councils: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.default_scheme.means_test.pension_contribution_deduction_rate: 0.25 + people: + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + england_default_household: + members: [england_default] + country: ENGLAND + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Merton share moves only that scheme's applicable income + # Model reform, not current law. + # Merton, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.merton.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + merton: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + merton_family: + members: [merton] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + merton_household: + members: [merton] + country: ENGLAND + local_authority: MERTON + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Kingston upon Thames share moves only that scheme's applicable income + # Model reform, not current law. + # Kingston upon Thames, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.kingston_upon_thames.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + kingston: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + kingston_family: + members: [kingston] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + kingston_household: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Newham share moves only that scheme's applicable income + # Model reform, not current law. + # Newham, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.newham.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + newham: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + newham_family: + members: [newham] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + newham_household: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Westminster share moves only that scheme's applicable income + # Model reform, not current law. + # Westminster, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.westminster.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + westminster: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + westminster_family: + members: [westminster] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + westminster_household: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Oxford share moves only that scheme's applicable income + # Model reform, not current law. + # Oxford, working age: 10,000 - 0.25 x 2,000 = 9,500. + # Control, Scotland, working age: 10,000 - 0.5 x 2,000 = 9,000. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.oxford.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + oxford: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + oxford_family: + members: [oxford] + would_claim_uc: false + would_claim_pc: false + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + households: + oxford_household: + members: [oxford] + country: ENGLAND + local_authority: OXFORD + scotland_household: + members: [scotland] + country: SCOTLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: [0.25, 0.5] + council_tax_reduction_applicable_income: [9500, 9000] + council_tax_reduction_net_earnings: [9500, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0] + housing_benefit_net_earnings: [9000, 9000] + +- name: A reform of the Housing Benefit share moves no Council Tax Reduction scheme + # Model reform, not current law. Housing Benefit net earnings become + # 10,000 - 0.25 x 2,000 = 9,500; every scheme's figures stay as at current law. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + scotland: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + wales: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_pensioner: + age: 70 + employment_income: 10_000 + personal_pension_contributions: 2_000 + england_default: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + merton: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + kingston: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + newham: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + westminster: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + oxford: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + benunits: + scotland_family: + members: [scotland] + would_claim_uc: false + would_claim_pc: false + wales_family: + members: [wales] + would_claim_uc: false + would_claim_pc: false + england_pensioner_family: + members: [england_pensioner] + would_claim_uc: false + would_claim_pc: false + england_default_family: + members: [england_default] + would_claim_uc: false + would_claim_pc: false + merton_family: + members: [merton] + would_claim_uc: false + would_claim_pc: false + kingston_family: + members: [kingston] + would_claim_uc: false + would_claim_pc: false + newham_family: + members: [newham] + would_claim_uc: false + would_claim_pc: false + westminster_family: + members: [westminster] + would_claim_uc: false + would_claim_pc: false + oxford_family: + members: [oxford] + would_claim_uc: false + would_claim_pc: false + households: + scotland_household: + members: [scotland] + country: SCOTLAND + wales_household: + members: [wales] + country: WALES + england_pensioner_household: + members: [england_pensioner] + country: ENGLAND + england_default_household: + members: [england_default] + country: ENGLAND + merton_household: + members: [merton] + country: ENGLAND + local_authority: MERTON + kingston_household: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + newham_household: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + westminster_household: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + oxford_household: + members: [oxford] + country: ENGLAND + local_authority: OXFORD + output: + housing_benefit_net_earnings: [9500, 9500, 9500, 9500, 9500, 9500, 9500, 9500, 9500] + council_tax_reduction_pension_contribution_deduction_rate: [0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5] + council_tax_reduction_applicable_income: [9000, 9000, 8740, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_net_earnings: [9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000, 9000] + council_tax_reduction_pensioner_earnings_disregard: [0, 0, 260, 0, 0, 0, 0, 0, 0] + +- name: A pensioner's net earnings below the disregard are all disregarded at current law + # Net earnings are 300 - 0.5 x 200 = 200, below the single pensioner's 260, so + # the disregard is 200 (SI 2012/2885 Sch 4 para 8(a), capped at net earnings). + # Applicable income is the State Pension and earnings, 5,300, less 0.5 x 200 + # and the 200 disregard: 5,000. Income tax is nil (5,300 is below the + # personal allowance). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + income_tax: 0 + council_tax_reduction_pension_contribution_deduction_rate: 0.5 + housing_benefit_net_earnings: 200 + council_tax_reduction_net_earnings: 200 + council_tax_reduction_pensioner_earnings_disregard: 200 + council_tax_reduction_applicable_income: 5_000 + +- name: A reform of the England pensioner share moves the disregard cap with the deduction + # Model reform, not current law. Net earnings for Council Tax Reduction are + # 300 - 0.25 x 200 = 250, still below 260, so 250 is disregarded. Applicable + # income is 5,300 - 0.25 x 200 - 250 = 5,000: earnings wholly disregarded + # deduct nothing, whatever the share. Housing Benefit net earnings stay 200. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.england.council_tax_reduction.pensioners.means_test.pension_contribution_deduction_rate: 0.25 + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: 0.25 + housing_benefit_net_earnings: 200 + council_tax_reduction_net_earnings: 250 + council_tax_reduction_pensioner_earnings_disregard: 250 + council_tax_reduction_applicable_income: 5_000 + +- name: A reform of the Housing Benefit share leaves the Council Tax Reduction disregard cap + # Model reform, not current law. Housing Benefit net earnings become + # 300 - 0.25 x 200 = 250, but the Council Tax Reduction cap stays at its own + # 300 - 0.5 x 200 = 200, so the disregard stays 200 and applicable income + # 5,000. Reading Housing Benefit's net earnings, the disregard was 250 and + # applicable income 5,300 - 0.5 x 200 - 250 = 4,950. The outputs use only + # variables that exist before this change, so the case fails there by value. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + housing_benefit_net_earnings: 250 + council_tax_reduction_pensioner_earnings_disregard: 200 + council_tax_reduction_applicable_income: 5_000 + +- name: Under a reform of the Housing Benefit share Council Tax Reduction net earnings keep the scheme's share + # Model reform, not current law. The same pensioner: the England pensioner + # scheme's share stays 0.5, so its net earnings stay 300 - 0.5 x 200 = 200. + period: 2026 + absolute_error_margin: 0.01 + input: + gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate: 0.25 + people: + pensioner: + age: 70 + employment_income: 300 + personal_pension_contributions: 200 + state_pension: 5_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [pensioner] + country: ENGLAND + output: + council_tax_reduction_pension_contribution_deduction_rate: 0.5 + council_tax_reduction_net_earnings: 200 + +- name: A couple's contributions are summed at the scheme's share + # Scotland, working age. Each partner earns 10,000 and they contribute 2,000 + # and 1,000. Applicable income is 20,000 - 0.5 x 3,000 = 18,500. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [head, partner] + country: SCOTLAND + output: + council_tax_reduction_applicable_income: 18_500 + council_tax_reduction_net_earnings: 18_500 + +- name: A couple's applicable income falls by their contributions times the change in share + # Model reform, not current law. At a quarter, 20,000 - 0.25 x 3,000 = 19,250: + # 750 more than at half, which is 3,000 x (0.5 - 0.25). + period: 2026 + absolute_error_margin: 0.01 + input: + gov.local_authorities.scotland.council_tax_reduction.means_test.pension_contribution_deduction_rate: 0.25 + people: + head: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 2_000 + partner: + age: 40 + employment_income: 10_000 + personal_pension_contributions: 1_000 + benunits: + benunit: + members: [head, partner] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [head, partner] + country: SCOTLAND + output: + council_tax_reduction_applicable_income: 19_250 + council_tax_reduction_net_earnings: 19_250 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml new file mode 100644 index 0000000000..8e67b31977 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/severe_disability_premium_young_person.yaml @@ -0,0 +1,167 @@ +# SSI2021/249 regs6,8(2)(a),Sch1para13. Existing2026 model controls: +# allowance95.55*52=4968.60; severe86.05*52=4474.60; withdrawal20%. +# PIP qualifies SDP but generic disability flag is separately supplied/defaultfalse. +# Pension12570:no tax; PIP disregarded; Child Benefit claim declined. +# CTR=2000-20%*(12570-allowance):479.72 or1374.64. +# Annual UC terminal-date input represents unavailable assessment-period detail. +# Reverse case supplies CB family status: input-contract composition, +# not a naturally derived eligibility pair or full statutory rate audit. +# Shared HB premium component and IS applicable amount are protected below. +# This household has no continuing HB claim, so its gated HB applicable +# amount is0; it is not used as a positive HB-payment regression oracle. +- name: 'Terminal-date input contract: CB young person after UC terminal date removes + Scottish CTR premium' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: false + is_claimant_or_partner: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 479.72 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 4474.6 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 4968.6 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 0 + working_age_benefits_premiums: 4474.6 + benefits_premiums: 4474.6 + income_support_applicable_amount: 9443.2 +- name: Before terminal date both definitions retain Scottish CTR premium + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + is_claimant_or_partner: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 1374.64 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 4474.6 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 9443.2 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 4474.6 + working_age_benefits_premiums: 4474.6 + benefits_premiums: 4474.6 + income_support_applicable_amount: 9443.2 +- name: 'Supplied-status contract: UC young person outside CB family restores Scottish + CTR premium' + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 45 + is_claimant_or_partner: true + pip_dl_category: ENHANCED + private_pension_income: 12570 + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + family: + members: + - applicant + - student + claims_all_entitled_benefits: true + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + would_claim_CTC: false + would_claim_WTC: false + would_claim_child_benefit: false + households: + home: + members: + - applicant + - student + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 2000 + savings: 0 + output: + simulated_council_tax_reduction_benunit: 1374.64 + disability_premium: 0 + enhanced_disability_premium: 0 + severe_disability_premium: 0 + council_tax_reduction_pensioner: false + council_tax_reduction_applicable_income: 12570 + council_tax_reduction_applicable_amount: 9443.2 + council_tax_reduction_non_dep_deductions: 0 + severe_disability_premium_before_non_dependant_test: 4474.6 + council_tax_reduction_severe_disability_premium: 4474.6 + working_age_benefits_premiums: 0 + benefits_premiums: 0 + income_support_applicable_amount: 4968.6 diff --git a/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml b/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml new file mode 100644 index 0000000000..c3149c6685 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/wra/land_transaction_tax/residential_rate_changes.yaml @@ -0,0 +1,166 @@ +# Literal marginal-band sums from WSI 2022/1027 reg 7 and WSI 2024/1311 reg 6. +# Annual parameters sample 30 April; these are not dated/protected-contract cases. +- name: Main purchase retains the April 2022 table + period: 2022 + input: + age: 45 + region: WALES + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 4_950 + +- name: Main purchase uses the new table in annual 2023 + period: 2023 + input: + age: 45 + region: WALES + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 4_500 + +- name: Main purchase at 400000 retains the April 2022 table + period: 2022 + input: + age: 45 + region: WALES + main_residential_property_purchased: 400_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 9_950 + +- name: New main table raises tax at the 400000 boundary + period: 2023 + input: + age: 45 + region: WALES + main_residential_property_purchased: 400_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 10_500 + +- name: Additional purchase retains the April 2024 table + period: 2024 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 16_950 + +- name: Additional purchase uses the new table in annual 2025 + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 19_950 + +- name: Distinct main and additional purchases use their respective tables + period: 2026 + input: + age: 45 + region: WALES + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 24_450 + +- name: Main purchase reaches every current residential band + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 2_000_000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 171_750 + +- name: Additional purchase reaches every current higher band + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 2_000_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 271_200 + +- name: No purchase leaves Welsh LTT at zero + period: 2025 + input: + age: 45 + region: WALES + main_residential_property_purchased: 0 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 0 + +- name: English purchases do not incur Welsh LTT + period: 2025 + input: + age: 45 + region: LONDON + main_residential_property_purchased: 300_000 + additional_residential_property_purchased: 300_000 + non_residential_property_purchased: 0 + rent: 0 + cumulative_residential_rent: 0 + non_residential_rent: 0 + cumulative_non_residential_rent: 0 + output: + land_transaction_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml b/policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml new file mode 100644 index 0000000000..386859e939 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/income/squared_poverty_gap.yaml @@ -0,0 +1,80 @@ +# Normalized statistical components; input income and lines isolate the +# existing HBAI gap contract. No poverty gap or squared output is supplied. +- name: Income above both lines has zero squared poverty gaps + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 120 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 0 + squared_poverty_gap_ahc: 0 + +- name: Income exactly on the lines has zero squared poverty gaps + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 100 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 0 + squared_poverty_gap_ahc: 0 + +- name: Half-line income gives a normalized squared gap of one quarter + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 50 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 0.25 + squared_poverty_gap_ahc: 0.25 + poverty_gap_bhc: 50 + poverty_gap_ahc: 50 + +- name: Zero income gives a normalized squared gap of one + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 0 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + output: + squared_poverty_gap_bhc: 1 + squared_poverty_gap_ahc: 1 + +- name: Common currency rescaling preserves normalized squared gaps + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 5000 + poverty_line_bhc: 10000 + poverty_line_ahc: 10000 + output: + squared_poverty_gap_bhc: 0.25 + squared_poverty_gap_ahc: 0.25 + +- name: Each squared gap uses its matching income and poverty line + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: 50 + poverty_line_bhc: 100 + poverty_line_ahc: 80 + rent: 90 + output: + squared_poverty_gap_bhc: 0.25 + squared_poverty_gap_ahc: 2.25 + hbai_household_net_income_ahc: -40 + poverty_gap_bhc: 50 + poverty_gap_ahc: 120 + +- name: Existing BHC reset and negative AHC income are preserved + period: 2025 + input: + hbai_household_net_income_before_reset_to_zero: -50 + poverty_line_bhc: 100 + poverty_line_ahc: 100 + rent: 50 + output: + squared_poverty_gap_bhc: 1 + squared_poverty_gap_ahc: 2.25 + hbai_household_net_income: 0 + hbai_household_net_income_ahc: -50 diff --git a/policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml b/policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml new file mode 100644 index 0000000000..db7f5b2901 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/uc_child_responsibility_housing_contribution.yaml @@ -0,0 +1,674 @@ +# UC Regs 2013 Sch 4 para 16(2)(i), reg 4: own-family responsibility, strict under-5 age. +# Source-derived 2026 annual contribution: 12 x 96.55 = 1,158.60. +# Unchanged single-over-25 standard allowance: 12 x 424.90 = 5,098.80. +# With no earnings/capital/debt and nonbinding cap, renter UC = standard + council rent - contributions. +# The non-dependant family does not claim UC; legal roles and membership, rather than forced awards/ages/exemptions, establish responsibility. + +- name: UC housing contribution - non-claiming parent of a four-year-old is exempt + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 +- name: UC housing contribution - exact fifth birthday ends the parent exemption + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 5 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + - 0 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - childless non-dependant retains the deduction + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - looked-after child does not confer responsibility + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + is_looked_after_by_local_authority: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + - 0 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - both partners responsible for their young child are exempt + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + partner: + age: 28 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - partner + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - partner + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 +- name: UC housing contribution - an unrelated family does not inherit parent exemption + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + outsider: + age: 35 + is_claimant_or_partner: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + outsider_family: + members: + - outsider + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + - outsider + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + - 1158.6 + uc_non_dep_deductions: + - 1158.6 + - 0 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + - 0 + universal_credit: + - 9940.2 + - 0 + - 0 +- name: UC housing contribution - non-claimant adult in the family does not inherit responsibility + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + outsider: + age: 35 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + - outsider + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + - outsider + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + - 1158.6 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - reversed people and family order with final award requested + first + period: 2026 + absolute_error_margin: 0.02 + input: + people: + child: + age: 4 + is_claimant_or_partner: false + parent: + age: 30 + is_claimant_or_partner: true + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + benunits: + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + households: + household: + members: + - child + - parent + - renter + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + universal_credit: + - 0 + - 11098.8 + uc_housing_costs_element: + - 0 + - 6000 + uc_non_dep_deductions: + - 0 + - 0 + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deduction_exempt: + - false + - true + - false +- name: UC housing contribution - existing under-21 exception remains without parent responsibility + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 20 + is_claimant_or_partner: true + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 +- name: UC housing contribution - responsibility starts with a newborn + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 0 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 + +# Configuration controls are appended after the cited parameter exists. +- name: UC housing contribution - lowering responsibility age limit removes the exemption + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 4 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + gov.dwp.universal_credit.elements.housing.non_dep_deduction.child_responsibility_age_limit: 4 + output: + uc_non_dep_deduction_exempt: + - false + - false + - false + uc_individual_non_dep_deduction: + - 0 + - 1158.6 + - 0 + uc_non_dep_deductions: + - 1158.6 + - 0 + uc_housing_costs_element: + - 4841.4 + - 0 + universal_credit: + - 9940.2 + - 0 +- name: UC housing contribution - raising responsibility age limit exempts a five-year-old + period: 2026 + absolute_error_margin: 0.02 + input: + people: + renter: + age: 60 + is_household_head: true + is_claimant_or_partner: true + parent: + age: 30 + is_claimant_or_partner: true + child: + age: 5 + is_claimant_or_partner: false + benunits: + renter_family: + members: + - renter + would_claim_uc: true + claims_all_entitled_benefits: false + parent_family: + members: + - parent + - child + would_claim_uc: false + claims_all_entitled_benefits: false + households: + household: + members: + - renter + - parent + - child + rent: 6000 + tenure_type: RENT_FROM_COUNCIL + region: NORTH_WEST + gov.dwp.universal_credit.elements.housing.non_dep_deduction.child_responsibility_age_limit: 6 + output: + uc_non_dep_deduction_exempt: + - false + - true + - false + uc_individual_non_dep_deduction: + - 0 + - 0 + - 0 + uc_non_dep_deductions: + - 0 + - 0 + uc_housing_costs_element: + - 6000 + - 0 + universal_credit: + - 11098.8 + - 0 diff --git a/policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml b/policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml new file mode 100644 index 0000000000..c1318369e7 --- /dev/null +++ b/policyengine_uk/tests/policy/integration/uc_disabled_child_addition.yaml @@ -0,0 +1,202 @@ +# Classification: UC Regs 2013 reg 24(2), with the existing statutory-blind +# input. Cash anchors are the unchanged model's 2026 monthly amounts: +# standard 424.90, ordinary child 303.94, lower 164.79, higher 514.71. +# A parent with a post-April-2017 child and no recovered debt therefore gets +# 12 * (424.90 + 303.94 + the single applicable disability addition). +# Only blind/no-award cases assert final UC: the existing generic disability +# flag also defaults LCWRA for child members, a separate model approximation. + +- name: A certified blind dependent receives the higher UC child addition without a disability benefit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + is_blind: true + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 6_176.52] + uc_disability_elements: 6_176.52 + universal_credit: 14_922.60 + +- name: Blindness and highest DLA care give one higher addition rather than both tiers + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + is_blind: true + dla_sc_category: HIGHER + is_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 6_176.52] + +- name: Middle DLA care retains only the lower disabled child addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + dla_sc_category: MIDDLE + is_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 1_977.48] + uc_individual_severely_disabled_child_element: [0, 0] + +- name: A nonblind child without a qualifying award receives no disability addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + benunits: + family: + members: [parent, child] + uc_has_deduction: false + households: + home: + members: [parent, child] + region: LONDON + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 0] + universal_credit: 8_746.08 + +- name: An enhanced PIP daily living qualifying young person receives only the higher addition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_benunit_head: true + young_person: + age: 18 + is_benunit_head: false + is_in_non_advanced_education: true + age_started_or_accepted_current_education_or_training: 17 + pip_dl_category: ENHANCED + is_disabled_for_benefits: true + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + output: + is_uc_claimant: [true, false] + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 6_176.52] + +- name: A blind young claimant does not receive a child addition for themselves + period: 2026 + absolute_error_margin: 0.01 + input: + age: 18 + is_in_non_advanced_education: true + is_blind: true + is_disabled_for_benefits: true + output: + is_uc_claimant: true + uc_individual_disabled_child_element: 0 + uc_individual_severely_disabled_child_element: 0 + +- name: A blind outside adult does not create a child addition for another family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + outside_adult: + age: 30 + is_blind: true + benunits: + family: + members: [parent, child] + other_family: + members: [outside_adult] + households: + home: + members: [parent, child, outside_adult] + output: + uc_individual_disabled_child_element: [0, 0, 0] + uc_individual_severely_disabled_child_element: [0, 0, 0] + +- name: A stored tax credit severe flag alone is not evidence of the UC higher condition + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + is_parent: true + is_benunit_head: true + child: + age: 8 + is_benunit_head: false + is_severely_disabled_for_benefits: true + benunits: + family: + members: [parent, child] + households: + home: + members: [parent, child] + output: + uc_individual_disabled_child_element: [0, 0] + uc_individual_severely_disabled_child_element: [0, 0] diff --git a/policyengine_uk/tests/test_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_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_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py new file mode 100644 index 0000000000..35b018ccb3 --- /dev/null +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -0,0 +1,405 @@ +"""Share-like capital in the means tests, itemised and valued by asset type. + +The capital lists name share_like_capital's two parts: +share_like_capital_with_sale_expenses (directly held shares, and all of an +unitemised corporate_wealth), which the sale-expense lists value less 10%, and +share_like_capital_without_sale_expenses (unit and investment trusts, stocks +and shares ISAs), valued in full. + +Invariants: +1. share_like_capital is corporate_wealth as supplied (any sign, any fraction) + unless the itemised components exceed it beyond the rounding tolerance, in + which case it is their sum. +2. With no itemised holdings, the part with sale expenses is all of + share_like_capital and the other part is exactly nil; otherwise the part + with sale expenses is share_like_capital less trusts and ISAs, floored at + nil, and the two parts add up to share_like_capital. +3. Differential: for identical unitemised inputs, every programme's assessable + capital and UC eligibility equal those under the lists before itemising + (corporate_wealth valued less 10%), exactly, annually and monthly. +4. Split effect: a consistent split raises a working-age single adult's UC + capital by 10% of trusts plus ISAs, against the unitemised lists. +5. A disregarded company holding (UC reg 77(2)) leaves the UC capital pool + from the part with sale expenses, whether it was entered within + corporate_wealth or as directly held shares. +6. The exempt list exists from the first date any capital list does. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.variables.household.wealth.share_like_capital import ( + RELATIVE_TOLERANCE, +) + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) +WITH, WITHOUT = ( + "share_like_capital_with_sale_expenses", + "share_like_capital_without_sale_expenses", +) +LEGAL_RATE = 0.1 +NODES = { + "uc_assessable_capital": "gov.dwp.universal_credit.means_test.capital", + "housing_benefit_assessable_capital": "gov.dwp.housing_benefit.means_test.capital", + "income_support_assessable_capital": "gov.dwp.income_support.means_test.capital", + "jsa_income_assessable_capital": "gov.dwp.JSA.income.capital", + "esa_income_assessable_capital": "gov.dwp.ESA.income.capital", + "pension_credit_assessable_capital": "gov.dwp.pension_credit.income.capital", +} +YEAR = 2026 +SETTINGS = settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +system = CountryTaxBenefitSystem() + + +def _node(path): + node = system.parameters + for part in path.split("."): + node = getattr(node, part) + return node + + +def _parameter(path): + return _node(path)(f"{YEAR}-01-01") + + +def _unitemised(sources): + """The list before itemising: corporate_wealth where the share parts are.""" + out = [] + for source in sources: + if source == WITHOUT: + continue + out.append("corporate_wealth" if source == WITH else source) + return out + + +UNITEMISED_REFORM = { + f"{node}.{name}": {str(YEAR): _unitemised(_parameter(f"{node}.{name}"))} + for node in NODES.values() + for name in ("sources", "sale_expenses.sources") +} + +# Pounds and pence, as a dataset stores them in float64; the engine rounds +# each to float32, which is the rounding the tolerance has to absorb. +money = st.integers(min_value=0, max_value=20_000_000).map(lambda pence: pence / 100) +signed_money = st.integers(min_value=-500_000, max_value=20_000_000).map( + lambda pence: pence / 100 +) + + +def _situation(households, age=None): + people, benunits, hhs = {}, {}, {} + for i, assets in enumerate(households): + people[f"p{i}"] = {"age": {YEAR: age or (70 if i % 2 else 30)}} + benunits[f"b{i}"] = {"members": [f"p{i}"], "would_claim_uc": {YEAR: True}} + hhs[f"h{i}"] = { + "members": [f"p{i}"], + **{name: {YEAR: float(value)} for name, value in assets.items()}, + } + return {"people": people, "benunits": benunits, "households": hhs} + + +def _results(households, reform=None, period=YEAR, age=None): + sim = Simulation(situation=_situation(households, age), reform=reform) + out = {v: sim.calculate(v, period) for v in NODES} + out["is_uc_eligible"] = sim.calculate("is_uc_eligible", period) + for v in ("share_like_capital", WITH, WITHOUT): + out[v] = sim.calculate(v, YEAR) + return out + + +@pytest.mark.parametrize("variable", NODES) +def test_lists_name_the_two_parts(variable): + sources = list(_parameter(f"{NODES[variable]}.sources")) + sale = list(_parameter(f"{NODES[variable]}.sale_expenses.sources")) + assert WITH in sources and WITHOUT in sources + assert not {"corporate_wealth", "share_like_capital", *COMPONENTS} & set(sources) + assert WITH in sale and WITHOUT not in sale + assert not {"corporate_wealth", *COMPONENTS} & set(sale) + without = set(_parameter("gov.dwp.share_like_capital.without_sale_expenses")) + assert without == {"unit_and_investment_trusts", "stocks_and_shares_isa"} + + +@SETTINGS +@given( + st.lists( + st.tuples(signed_money, st.tuples(money, money, money), st.booleans()), + min_size=1, + max_size=40, + ) +) +# Subtracting trusts and ISA one at a time leaves £0.015625 here; grouped, nil. +@example([(399_999.984375, (0.0, 200_000.0, 199_999.984375), True)]) +def test_share_like_capital_and_its_parts(rows): + households = [] + for corporate_wealth, components, itemise in rows: + assets = {"corporate_wealth": corporate_wealth} + if itemise: + assets |= dict(zip(COMPONENTS, components)) + households.append(assets) + got = _results(households) + for i, assets in enumerate(households): + cw = np.float32(assets["corporate_wealth"]) + values = [np.float32(assets.get(c, 0)) for c in COMPONENTS] + # The engine's float32 grouping: components added left to right, the + # exempt holdings summed before they are subtracted. + itemised = np.float32(np.float32(values[0] + values[1]) + values[2]) + exempt = np.float32(values[1] + values[2]) + tolerance = RELATIVE_TOLERANCE * max(abs(cw), itemised) + share_like = got["share_like_capital"][i] + if itemised > 0 and itemised - cw > tolerance: + assert share_like == itemised + else: + assert share_like == cw + if itemised == 0: + assert got[WITH][i] == share_like and got[WITHOUT][i] == 0 + else: + assert got[WITH][i] == max(np.float32(0), np.float32(share_like - exempt)) + assert got[WITHOUT][i] == np.float32(share_like - got[WITH][i]) + + +def test_float32_grouping_of_the_exempt_holdings(): + """Trusts and ISA summed first: corporate_wealth of £399,999.984375 and + £200,000 + £199,999.984375 of exempt holdings both round to £400,000 in + float32, so the part with sale expenses is nil; subtracting the two one at + a time would leave £0.015625.""" + households = [ + { + "corporate_wealth": 399_999.984375, + "unit_and_investment_trusts": 200_000, + "stocks_and_shares_isa": 199_999.984375, + } + ] + got = _results(households) + assert got[WITH][0] == 0 + + +def test_an_empty_exempt_list_values_everything_less_10_percent(): + """A ruling that every share-like holding takes the 10% is a parameter edit.""" + reform = {"gov.dwp.share_like_capital.without_sale_expenses": {str(YEAR): []}} + households = [ + {"savings": 1_000, "corporate_wealth": 12_000}, + { + "savings": 1_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 5_000, + }, + ] + got = _results(households, reform=reform, age=30) + assert list(got[WITH]) == [12_000, 12_000] + assert list(got[WITHOUT]) == [0, 0] + # £1,000 + 90% of £12,000 either way. + assert got["uc_assessable_capital"] == pytest.approx([11_800, 11_800]) + + +def test_the_exempt_list_starts_with_the_first_capital_list(): + exempt = system.parameters.gov.dwp.share_like_capital.without_sale_expenses + first = min( + min(entry.instant_str for entry in _node(f"{node}.sources").values_list) + for node in NODES.values() + ) + assert first == "1996-10-07" + assert min(entry.instant_str for entry in exempt.values_list) == first + + +@pytest.mark.parametrize( + "variable", ["income_support_assessable_capital", "jsa_income_assessable_capital"] +) +def test_legacy_capital_before_universal_credit(variable): + """2012: the legacy lists apply, and so does the exempt list (from 1996).""" + situation = _situation([{"savings": 1_000, "corporate_wealth": 12_000}], age=30) + for entity in situation.values(): + for name, values in entity.items(): + for key, by_year in list(values.items()): + if isinstance(by_year, dict) and YEAR in by_year: + values[key] = {2012: by_year[YEAR]} + capital = Simulation(situation=situation).calculate(variable, 2012)[0] + assert capital == pytest.approx(11_800) + + +@SETTINGS +@given( + st.lists(st.tuples(money, signed_money), min_size=1, max_size=30), +) +def test_unitemised_inputs_match_the_lists_before_itemising(rows): + households = [{"savings": s, "corporate_wealth": cw} for s, cw in rows] + households += [ + {"savings": 6_001, "corporate_wealth": -2}, + {"savings": 0, "corporate_wealth": 17_777.78}, # 90% is £16,000.002 + ] + # A projected year as well, where the inputs are uprated. + for period in (YEAR, f"{YEAR}-01", YEAR + 1, f"{YEAR + 1}-01"): + new = _results(households, period=period) + old = _results(households, reform=UNITEMISED_REFORM, period=period) + for variable in (*NODES, "is_uc_eligible"): + assert np.array_equal(new[variable], old[variable]), (variable, period) + + +@SETTINGS +@given( + st.lists(st.tuples(money, st.tuples(money, money, money)), min_size=1, max_size=30) +) +def test_a_consistent_split_exempts_trusts_and_isas(rows): + households = [ + { + "savings": savings, + "corporate_wealth": float(sum(components)), + **dict(zip(COMPONENTS, components)), + } + for savings, components in rows + ] + new = _results(households, age=30) + old = _results(households, reform=UNITEMISED_REFORM, age=30) + for i, (_, (_, trusts, isa)) in enumerate(rows): + exempt = np.float32(trusts) + np.float32(isa) + assert new["uc_assessable_capital"][i] - old["uc_assessable_capital"][ + i + ] == pytest.approx(LEGAL_RATE * exempt, rel=1e-5, abs=0.05) + # Uprated separately in float32, the components still round to their + # aggregate in a projected year, so share-like capital is the aggregate. + sim = Simulation(situation=_situation(households, age=30)) + for year in (YEAR, YEAR + 1): + assert np.array_equal( + sim.calculate("share_like_capital", year), + sim.calculate("corporate_wealth", year), + ), year + + +def test_components_alone_are_valued_by_type(): + households = [ + { + "savings": 1_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 5_000, + } + ] + got = _results(households, age=30) + assert got["share_like_capital"][0] == 12_000 + assert got[WITH][0] == 4_000 and got[WITHOUT][0] == 8_000 + # £1,000 + 90% of £4,000 + £3,000 + £5,000. + assert got["uc_assessable_capital"][0] == pytest.approx(12_600) + + +def test_a_component_penny_at_the_limit_counts(): + """A penny of shares with no corporate_wealth counts like a penny of it.""" + as_component = [{"savings": 16_000, "directly_held_shares": 0.01}] + as_aggregate = [{"savings": 16_000, "corporate_wealth": 0.01}] + for period in (YEAR, f"{YEAR}-01"): + component = _results(as_component, period=period, age=30) + aggregate = _results(as_aggregate, period=period, age=30) + assert component["share_like_capital"][0] == np.float32(0.01) + for variable in (*NODES, "is_uc_eligible"): + assert np.array_equal(component[variable], aggregate[variable]), variable + assert not component["is_uc_eligible"][0] + + +def test_a_genuine_excess_over_the_aggregate_counts(): + """Ten pence of components above the aggregate is a holding, not rounding.""" + households = [ + { + "corporate_wealth": 16_000, + "directly_held_shares": 10_000.10, + "stocks_and_shares_isa": 6_000, + } + ] + assert _results(households)["share_like_capital"][0] == np.float32(16_000.10) + + +def test_float32_rounding_at_the_limit_keeps_the_aggregate(): + """Components whose float32 sum overshoots £16,000 by a fraction of a + penny keep the aggregate; an unitemised penny at the limit counts as + before itemising.""" + split = [ + { + "corporate_wealth": 16_000, + "directly_held_shares": 6_744.81, + "unit_and_investment_trusts": 6_581.24, + "stocks_and_shares_isa": 2_673.95, + } + ] + itemised = np.float32( + np.float32(np.float32(6_744.81) + np.float32(6_581.24)) + np.float32(2_673.95) + ) + assert itemised > np.float32(16_000) + assert _results(split)["share_like_capital"][0] == 16_000 + unitemised = [ + {"savings": 15_999.99, "corporate_wealth": 0.01}, + {"savings": 6_001, "corporate_wealth": -2}, + ] + for period in (YEAR, f"{YEAR}-01"): + new = _results(unitemised, period=period) + old = _results(unitemised, reform=UNITEMISED_REFORM, period=period) + for variable in (*NODES, "is_uc_eligible"): + assert np.array_equal(new[variable], old[variable]), (variable, period) + + +def test_one_unit_of_float32_rounding_above_a_power_of_two_keeps_the_aggregate(): + """Pence-valued components whose float32 sum lands one unit in the last place + (£0.00195) above £16,384.01: more than 1e-7 of the value, less than 1e-6.""" + households = [ + { + "corporate_wealth": 16_384.01, + "directly_held_shares": 16_015.98, + "unit_and_investment_trusts": 286.98, + "stocks_and_shares_isa": 81.05, + } + ] + itemised = np.float32( + np.float32(np.float32(16_015.98) + np.float32(286.98)) + np.float32(81.05) + ) + assert itemised - np.float32(16_384.01) == np.float32(0.001953125) + assert _results(households)["share_like_capital"][0] == np.float32(16_384.01) + + +def test_a_disregarded_company_holding_leaves_the_part_with_sale_expenses(): + """UC reg 77(2): the owner's £30,000 holding leaves the pool at the 90% its + shares are counted at, whether it was entered within corporate_wealth or + as directly held shares. Two unreported adults share £10,000 of savings.""" + from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, + ) + + node = NODES["uc_assessable_capital"] + assert HOLDINGS_SOURCE in _parameter(f"{node}.sources") + assert HOLDINGS_SOURCE in _parameter(f"{node}.sale_expenses.sources") + for shares in ({"corporate_wealth": 30_000}, {"directly_held_shares": 30_000}): + situation = { + "people": { + "owner": { + "age": {YEAR: 40}, + "stands_as_sole_owner_or_partner_of_company": {YEAR: True}, + "owned_company_carries_on_trade": {YEAR: True}, + "owned_company_holding_value": {YEAR: 30_000}, + }, + "lodger": {"age": {YEAR: 30}}, + }, + "benunits": { + "owner_unit": {"members": ["owner"]}, + "lodger_unit": {"members": ["lodger"]}, + }, + "households": { + "household": { + "members": ["owner", "lodger"], + "savings": {YEAR: 10_000}, + **{name: {YEAR: value} for name, value in shares.items()}, + } + }, + } + sim = Simulation(situation=situation) + assert sim.calculate("household_uc_company_holding_pool_scale", YEAR)[0] == 1 + # The pool counts the shares at 90%, and the holding leaves it at that. + assert sim.calculate("household_uc_pool_company_holdings", YEAR)[0] == 27_000 + assert list(sim.calculate("uc_assessable_capital", YEAR)) == [5_000, 5_000] diff --git a/policyengine_uk/tests/test_capital_valuation_properties.py b/policyengine_uk/tests/test_capital_valuation_properties.py index 855a88c59c..492027f9a5 100644 --- a/policyengine_uk/tests/test_capital_valuation_properties.py +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -7,7 +7,9 @@ Regs 1987 reg. 49; JSA Regs 1996 reg. 111; ESA Regs 2008 reg. 113; SPC Regs 2002 reg. 19). A bank or building society account is valued at its balance (ADM H1675); land and property always have costs of sale (H1606) and shares -are valued less 10% (H1665). +are valued less 10% (H1665). The households here hold share-like wealth only +as unitemised corporate_wealth, which all takes the 10%; itemised holdings +(unit trusts and ISAs without it) are tested in test_capital_share_components. Invariants, for any generated population of single-adult households: @@ -66,14 +68,29 @@ } PENSION_AGE_ONLY = {"pension_credit_assessable_capital"} # The assets whose sale incurs expenses, fixed here from the law rather than -# read from the parameters: real property always (ADM H1606), shares (H1665); -# never a bank or building society balance (H1675). +# read from the parameters: real property always (ADM H1606), shares (H1665), +# and unitemised corporate wealth with them; never a bank or building society +# balance (H1675). SALE_EXPENSE_ASSETS = { "owned_land", "other_residential_property_value", "non_residential_property_value", - "corporate_wealth", + "share_like_capital_with_sale_expenses", +} +# How each capital source reads an unitemised household's inputs: all of its +# corporate_wealth has expenses of sale, none is without. +SHARE_LIKE_SOURCES = { + "share_like_capital_with_sale_expenses": lambda assets: assets["corporate_wealth"], + "share_like_capital_without_sale_expenses": lambda assets: 0.0, } + + +def asset_value(unit, source): + if source in SHARE_LIKE_SOURCES: + return SHARE_LIKE_SOURCES[source](unit["assets"]) + return unit["assets"][source] + + LEGAL_RATE = 0.1 ASSETS = [ "savings", @@ -173,7 +190,7 @@ def expected_capital(unit, node, variable=None): - unit["debts"][DEBTS[source]], ) for source in node.sources: - value = unit["assets"][source] + value = asset_value(unit, source) if source in SALE_EXPENSE_ASSETS: value *= 1 - LEGAL_RATE if source in DEBTS: @@ -216,7 +233,7 @@ def check_valuation(units): hb_checked["working_age_differential"] += 1 # Debt secured on property never reaches the other assets. floor = sum( - unit["assets"][s] * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) + asset_value(unit, s) * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) for s in node.sources if s not in DEBTS ) diff --git a/policyengine_uk/tests/test_care_to_learn_history.py b/policyengine_uk/tests/test_care_to_learn_history.py new file mode 100644 index 0000000000..e892a52e3d --- /dev/null +++ b/policyengine_uk/tests/test_care_to_learn_history.py @@ -0,0 +1,75 @@ +"""Annual-model history and rate overrides, not an academic-day allocation.""" + +from pathlib import Path + +import pytest +from policyengine_core.parameters import ParameterNode + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.utils.scenario import Scenario + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def test_raw_amount_keeps_the_annual_model_history_anchors(): + directory = Path(__file__).parents[1] / "parameters/gov/dfe/care_to_learn" + raw = ParameterNode(directory_path=str(directory)).amount + # These are deliberately annual model anchors, not legal daily switches. + for date in ("2015-01-01", "2023-12-31"): + assert raw(date).in_london == 175 + assert raw(date).outside_london == 160 + for date in ("2024-01-01", "2026-04-30"): + assert raw(date).in_london == 195 + assert raw(date).outside_london == 180 + + +def test_processed_amount_keeps_old_and_current_annual_caps(): + parameters = CountryTaxBenefitSystem().parameters + for year, london, elsewhere in ( + (2015, 175, 160), + (2023, 175, 160), + (2024, 195, 180), + (2026, 195, 180), + ): + amount = parameters(str(year)).gov.dfe.care_to_learn.amount + assert amount.in_london == london + assert amount.outside_london == elsewhere + + +def test_historical_rate_override_reaches_final_household_benefits(): + situation = { + "people": { + "parent": { + "age": {2015: 18}, + "is_parent": {2015: True}, + "current_education": {2015: "UPPER_SECONDARY"}, + "is_apprentice": {2015: False}, + }, + "child": {"age": {2015: 1}, "childcare_expenses": {2015: 12_000}}, + }, + "benunits": {"family": {"members": ["parent", "child"]}}, + "households": { + "home": { + "members": ["parent", "child"], + "country": {2015: "ENGLAND"}, + "region": {2015: "EAST_OF_ENGLAND"}, + } + }, + } + baseline = Simulation(situation=situation) + reformed = Simulation( + situation=situation, + scenario=Scenario( + parameter_changes={ + "gov.dfe.care_to_learn.amount.outside_london": {2015: 200} + } + ), + ) + # Model-contract arithmetic: 160 * 52 = 8320, 200 * 52 = 10400. + assert baseline.calculate("care_to_learn", 2015).tolist() == [8_320, 0] + assert reformed.calculate("care_to_learn", 2015).tolist() == [10_400, 0] + difference = reformed.calculate("household_benefits", 2015) - baseline.calculate( + "household_benefits", 2015 + ) + assert difference[0] == pytest.approx(2_080, abs=0.01) 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_carers_allowance_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py new file mode 100644 index 0000000000..be9c21dea3 --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py @@ -0,0 +1,194 @@ +"""Property-based tests for Carer's Allowance's pension contribution share. + +The Carer's Allowance earnings limit tests net earnings under the Social +Security Benefit (Computation of Earnings) Regulations 1996, which deduct one +half of pension contributions from an employed earner's net earnings (reg. +10(4)(b)) and one half of pension premiums from a self-employed earner's net +profit (regs. 13(1)(b)(ii), (4)(c) and (10)(b)). Northern Ireland's 1996 +Regulations (SR 1996/520) say the same. The model reads the share from +gov.dwp.carers_allowance.pension_contribution_deduction_rate. + +Invariants, for any generated population of working-age carers in any UK +country and year, with pay, trading profit (a loss counts as nil) and +employee and personal pension contributions of at most 30% of earnings, so +that no floor binds: + +1. Exactness: when the share moves from 0.5 to r, carers_allowance_earnings + falls by exactly the person's contributions x (r - 0.5). +2. Locality: the Housing Benefit share is reformed at the same time to a + different value, so earnings that read Housing Benefit's share would miss + invariant 1. +3. Eligibility follows earnings through the limit: on both sides the person + is gainfully employed exactly when weekly earnings, with a fraction of a + penny disregarded (reg. 5), exceed the limit, and then has no underlying + entitlement. The pence are counted in the precision the model stores + earnings in (float32), as the formula counts them. A lower share can only end entitlement and a higher share can + only restore it. +4. Current law: the share is 0.5 in every year from 2015 (the first year the + model simulates) to 2030. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +CA_SHARE = "gov.dwp.carers_allowance.pension_contribution_deduction_rate" +HB_SHARE = "gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate" +# Every period the model simulates. +ALL_YEARS = "2000-01-01.2100-12-31" +COUNTRIES = ["ENGLAND", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +YEARS = [2016, 2020, 2024, 2026, 2030] +# Shares on a grid of twentieths, each exact in binary. +SHARES = [i / 20 for i in range(21) if i != 10] +# The model stores amounts as float32, whose spacing is about 0.008 at the +# largest earnings drawn (up to 120,000), so two stored values can differ from +# the exact difference by more than a penny. +TOLERANCE = 0.05 + +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@st.composite +def carers(draw): + pay = draw(st.floats(0, 60_000, allow_nan=False)) + profit = draw(st.floats(-20_000, 60_000, allow_nan=False)) + earnings = pay + max(0.0, profit) + # Net earnings before the pension deduction are more than 60% of + # earnings in every year and country tested (about 70% at the top of + # the range), so contributions of at most 30% leave them positive at + # any share up to one. + employee_share = draw(st.one_of(st.just(0.0), st.floats(0, 0.15))) + personal_share = draw(st.one_of(st.just(0.0), st.floats(0, 0.15))) + return dict( + country=draw(st.sampled_from(COUNTRIES)), + age=draw(st.integers(25, 60)), + employment_income=pay, + self_employment_income=profit, + employee_pension_contributions=round(pay * employee_share, 2), + personal_pension_contributions=round(earnings * personal_share, 2), + ) + + +@st.composite +def reforms(draw): + ca, hb = draw( + st.lists(st.sampled_from(SHARES), min_size=2, max_size=2, unique=True) + ) + return dict(ca=ca, hb=hb) + + +def situation(people, year): + persons, benunits, households = {}, {}, {} + for i, carer in enumerate(people): + name = f"carer_{i}" + persons[name] = { + key: {year: value} for key, value in carer.items() if key != "country" + } + persons[name]["care_hours"] = {year: 40} + benunits[f"b{i}"] = {"members": [name]} + households[f"h{i}"] = { + "members": [name], + "country": {year: carer["country"]}, + } + return {"people": persons, "benunits": benunits, "households": households} + + +VARIABLES = [ + "pension_contributions", + "carers_allowance_earnings", + "is_gainfully_employed_for_carers_allowance", + "carers_allowance_pre_overlap", +] + + +def calculate(people, year, reform=None): + sim = Simulation(situation=situation(people, year), reform=reform) + # Keep each variable's stored dtype: the rounding check below must count + # pence in the precision the formula does. + return {v: np.asarray(sim.calculate(v, year)) for v in VARIABLES} + + +def over_limit(earnings, limit): + # Reg. 5: a fraction of a penny is disregarded. ``earnings`` is the stored + # array, so this counts the pence as is_gainfully_employed_for_carers_allowance + # does, in float32: promoting to float64 first floors 10,608.52 a year, + # stored as 10,608.51953125, to 204.00 a week instead of 204.01. + return np.floor(earnings / 52 * 100 + 1e-6) / 100 > limit + + +# A carer one penny a week over the 2026-27 limit (10,608.52 / 52 = 204.01), on +# the float32 boundary described in over_limit. +PENNY_OVER = dict( + country="ENGLAND", + age=45, + employment_income=10_608.52, + self_employment_income=0.0, + employee_pension_contributions=0.0, + personal_pension_contributions=0.0, +) + + +@PROPERTY_SETTINGS +@given( + people=st.lists(carers(), min_size=1, max_size=24), + shares=reforms(), + year=st.sampled_from(YEARS), +) +@example(people=[PENNY_OVER], shares=dict(ca=0.25, hb=0.75), year=2026) +def test_earnings_fall_by_contributions_times_the_change_in_share(people, shares, year): + base = calculate(people, year) + moved = calculate( + people, + year, + { + CA_SHARE: {ALL_YEARS: shares["ca"]}, + HB_SHARE: {ALL_YEARS: shares["hb"]}, + }, + ) + limit = system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit + for i, carer in enumerate(people): + case = (carer, shares, year) + contributions = ( + carer["employee_pension_contributions"] + + carer["personal_pension_contributions"] + ) + stored = float(base["pension_contributions"][i]) + assert abs(stored - contributions) < TOLERANCE, case + before = float(base["carers_allowance_earnings"][i]) + after = float(moved["carers_allowance_earnings"][i]) + if contributions > 0: + # Floors are slack: earnings stay positive on both sides. + assert before > 0 and after > 0, case + # Invariants 1 and 2: exactly contributions x the change in the + # Carer's Allowance share, not the Housing Benefit share. + change = before - after + assert abs(change - contributions * (shares["ca"] - 0.5)) < TOLERANCE, case + # Invariant 3: the gainful-employment test follows earnings. + for values in (base, moved): + over = over_limit(values["carers_allowance_earnings"][i], limit) + gainful = bool(values["is_gainfully_employed_for_carers_allowance"][i]) + assert gainful == over, case + if gainful: + assert values["carers_allowance_pre_overlap"][i] == 0, case + was = bool(base["is_gainfully_employed_for_carers_allowance"][i]) + now = bool(moved["is_gainfully_employed_for_carers_allowance"][i]) + if shares["ca"] < 0.5: + assert now or not was, case + else: + assert was or not now, case + + +def test_the_share_is_one_half_in_every_year(): + for year in range(2015, 2031): + for day in ("01-01", "04-30"): + parameters = system.parameters(f"{year}-{day}") + share = parameters.gov.dwp.carers_allowance + assert share.pension_contribution_deduction_rate == 0.5, (year, day) diff --git a/policyengine_uk/tests/test_construction_reform_wrappers.py b/policyengine_uk/tests/test_construction_reform_wrappers.py new file mode 100644 index 0000000000..04a29ea6a1 --- /dev/null +++ b/policyengine_uk/tests/test_construction_reform_wrappers.py @@ -0,0 +1,427 @@ +"""Construction-owned reform wrappers retain their reading until edited.""" + +import copy +from decimal import Decimal, ROUND_CEILING + +import numpy as np +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.scenarios.uc_reform import UCHealthElementOverrideWarning +from policyengine_uk.utils.scenario import Scenario + + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +BASIC_RATE = "gov.hmrc.income_tax.rates.uk[0].rate" +YEARS = (2026, 2027, 2028) +SITUATION = { + "people": {"person1": {"age": {year: 40 for year in YEARS}}}, + "benunits": {"benunit": {"members": ["person1"]}}, + "households": {"household": {"members": ["person1"]}}, +} +A = {BASIC_RATE: {"2027-01-01.2027-12-31": 0.31}} +L = {BASIC_RATE: {"2027-01-01": 0.35}} +VAT = "gov.hmrc.vat.standard_rate" +PA = "gov.hmrc.income_tax.allowances.personal_allowance.amount" +CPI = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +STATUTORY_CPI = "gov.economic_assumptions.yoy_growth.september_cpi_uprating" +FISCAL_A = {BASIC_RATE: {"2026-04-06.2027-04-05": 0.31}} + + +def parameter(simulation, path, date): + return simulation.tax_benefit_system.parameters.get_child(path)(date) + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("edit", ["legacy", "direct"]) +@pytest.mark.parametrize("composite", [False, True]) +def test_equal_constructor_wrapper_after_edit_matches_apply( + before_data_load, edit, composite +): + def build(use_apply): + def wrapper(simulation): + if edit == "legacy": + Scenario.from_reform(L).apply(simulation) + else: + simulation.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2027", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + requested = Scenario.from_reform((L, A) if composite else A) + if use_apply: + requested.apply(simulation) + else: + requested.simulation_modifier(simulation) + + own = Scenario.from_reform(A) + own.applied_before_data_load = before_data_load + return Simulation( + situation=SITUATION, + scenario=own + + Scenario( + simulation_modifier=wrapper, + applied_before_data_load=before_data_load, + ), + ) + + direct, applied = build(False), build(True) + assert parameter(applied, BASIC_RATE, "2027-01-01") == 0.31 + assert parameter(direct, BASIC_RATE, "2027-01-01") == 0.31 + for date in ("2026-12-31", "2027-01-01", "2027-07-01", "2027-12-31", "2028-01-01"): + assert parameter(direct, BASIC_RATE, date) == parameter( + applied, BASIC_RATE, date + ) + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("deepcopy", [False, True]) +@pytest.mark.parametrize("reform_class", [False, True]) +def test_disjoint_composer_member_wrappers_keep_constructor_reading( + before_data_load, deepcopy, reform_class +): + # The acquired scenario_composition/v1 contract: all dated changes first, + # followed by every member's modifier, including fresh equal member calls. + reforms = (FISCAL_A, {VAT: {"2027-01-01.2027-12-31": 0.27}}) + members = [Scenario.from_reform(r) for r in reforms] + + def wrapper(simulation): + for reform in reforms: + request = Reform.from_dict(reform) if reform_class else reform + Scenario.from_reform(request).simulation_modifier(simulation) + + if reform_class: + members = [Scenario.from_reform(Reform.from_dict(r)) for r in reforms] + scenario = Scenario( + dated_reforms=[d for member in members for d in member.dated_reforms], + simulation_modifier=wrapper, + applied_before_data_load=before_data_load, + ) + if deepcopy: + scenario = copy.deepcopy(scenario) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert parameter(simulation, VAT, "2027-01-01") == 0.27 + assert simulation.tax_benefit_system.legacy_overrides == [] + assert simulation._construction_reform_state is None + assert simulation.baseline._construction_reform_state is None + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("deepcopy", [False, True]) +def test_pristine_mixed_own_wrapper_keeps_legacy_and_statutory_readings( + before_data_load, deepcopy +): + changes = {**FISCAL_A, VAT: {"2027-01-01": 0.27}} + + def wrapper(simulation): + Scenario.from_reform(changes).simulation_modifier(simulation) + + scenario = Scenario.from_reform(changes) + scenario.applied_before_data_load = before_data_load + scenario += Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ) + if deepcopy: + scenario = copy.deepcopy(scenario) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert parameter(simulation, VAT, "2027-01-01") == 0.27 + assert parameter(simulation, VAT, "2027-01-02") == 0.20 + + +@pytest.mark.parametrize("before_data_load", [False, True]) +def test_unrelated_direct_edit_does_not_replay_own_fiscal_change(before_data_load): + def wrapper(simulation): + simulation.tax_benefit_system.parameters.get_child(VAT).update( + period="2027", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + Scenario.from_reform(FISCAL_A).simulation_modifier(simulation) + + scenario = Scenario.from_reform(FISCAL_A) + scenario.applied_before_data_load = before_data_load + simulation = Simulation( + situation=SITUATION, + scenario=scenario + + Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ), + ) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert parameter(simulation, VAT, "2027-01-01") == 0.40 + assert simulation.tax_benefit_system.legacy_overrides == [] + + +@pytest.mark.parametrize("edit", ["legacy", "direct"]) +def test_foreign_edit_survives_phase_boundary_as_foreign(edit): + def before(simulation): + if edit == "legacy": + Scenario.from_reform(L).apply(simulation) + else: + simulation.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2027", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + + def after(simulation): + Scenario.from_reform(A).simulation_modifier(simulation) + + scenario = Scenario.from_reform(A) + scenario.applied_before_data_load = True + scenario += Scenario(simulation_modifier=before, applied_before_data_load=True) + scenario += Scenario(simulation_modifier=after) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.31 + + +@pytest.mark.parametrize("before_data_load", [False, True]) +def test_direct_edit_outside_window_still_disables_construction_privilege( + before_data_load, +): + def wrapper(simulation): + simulation.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2030", value=0.40 + ) + simulation.tax_benefit_system.reset_parameter_caches() + Scenario.from_reform(FISCAL_A).simulation_modifier(simulation) + + scenario = Scenario.from_reform(FISCAL_A) + scenario.applied_before_data_load = before_data_load + simulation = Simulation( + situation=SITUATION, + scenario=scenario + + Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ), + ) + # A direct edit has no write-log record. Even an edit outside A's dates + # ends its path's construction privilege: A now takes the built reading. + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2030-01-01") == 0.40 + + +@pytest.mark.parametrize("before_data_load", [False, True]) +@pytest.mark.parametrize("deepcopy", [False, True]) +def test_pristine_equal_member_keeps_constructor_later_member_order( + before_data_load, deepcopy +): + later = {BASIC_RATE: {"2027-01-01.2027-12-31": 0.35}} + + def wrapper(simulation): + Scenario.from_reform(A).simulation_modifier(simulation) + + scenario = Scenario.from_reform((A, later)) + scenario.applied_before_data_load = before_data_load + scenario += Scenario( + simulation_modifier=wrapper, applied_before_data_load=before_data_load + ) + if deepcopy: + scenario = copy.deepcopy(scenario) + simulation = Simulation(situation=SITUATION, scenario=scenario) + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.35 + assert simulation.tax_benefit_system.legacy_overrides == [] + + +@pytest.mark.parametrize("before_data_load", [False, True]) +def test_original_lcwra_shadow_is_not_a_foreign_parameter_edit(before_data_load): + situation = copy.deepcopy(SITUATION) + situation["people"]["person1"]["uc_limited_capability_for_WRA"] = { + year: True for year in YEARS + } + situation["benunits"]["benunit"].update( + would_claim_uc={year: True for year in YEARS}, + uc_receives_new_claimant_health_element={year: False for year in YEARS}, + ) + path = "gov.dwp.universal_credit.elements.disabled.amount" + changes = {path: {"2026-01-01.2028-12-31": 999.0}} + own = Scenario.from_reform(changes) + own.applied_before_data_load = before_data_load + with pytest.warns(UCHealthElementOverrideWarning): + control = Simulation(situation=situation, scenario=own) + + def wrapper(simulation): + Scenario.from_reform(changes).simulation_modifier(simulation) + + with pytest.warns(UCHealthElementOverrideWarning) as recorded: + simulation = Simulation( + situation=situation, + scenario=own + + Scenario( + simulation_modifier=wrapper, + applied_before_data_load=before_data_load, + ), + ) + assert len(recorded) == 1 + assert parameter(simulation, path, "2027-01-01") == 999.0 + expected = control.calculate("uc_LCWRA_element", 2027) + assert np.all((expected > 0) & (expected < 999.0 * 12)) + np.testing.assert_array_equal( + simulation.calculate("uc_LCWRA_element", 2027), expected + ) + assert simulation.tax_benefit_system.legacy_overrides == [] + + +class ScaleAllowance(Reform): + def apply(self): + target = self.parameters.get_child(PA) + target.update(period="2027", value=target("2027") * 1.1) + + +def scale_allowance(simulation): + target = simulation.tax_benefit_system.parameters.get_child(PA) + target.update(period="2027", value=target("2027") * 1.1) + simulation.tax_benefit_system.reset_parameter_caches() + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +@pytest.mark.parametrize("representation", ["class", "modifier", "cpi_dict"]) +def test_relative_reforms_on_copies_match_explicit_apply(copy_kind, representation): + if representation == "class": + scenario = Scenario.from_reform(ScaleAllowance) + elif representation == "modifier": + scenario = Scenario(simulation_modifier=scale_allowance) + else: + scenario = Scenario.from_reform({CPI: {"2027": 0.08}}) + original = Simulation(situation=SITUATION) + original_allowance = parameter(original, PA, "2027") + original_cpi = parameter(original, CPI, "2027") + direct = ( + original.clone() + if copy_kind == "clone" + else original.get_branch("copy", clone_system=True) + ) + applied = original.clone() + scenario.simulation_modifier(direct) + scenario.apply(applied) + for path in (PA, CPI): + for year in YEARS: + assert parameter(direct, path, str(year)) == parameter( + applied, path, str(year) + ) + if representation == "cpi_dict": + assert parameter(direct, CPI, "2027") == 0.08 + else: + assert parameter(direct, PA, "2027") == pytest.approx(original_allowance * 1.1) + assert parameter(original, PA, "2027") == original_allowance + assert parameter(original, CPI, "2027") == original_cpi + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +def test_mixed_replay_on_copy_changes_its_own_construction_reading(copy_kind): + changes = { + BASIC_RATE: {"2026-06-01.2027-03-31": 0.31}, + VAT: {"2027-01-01": 0.25}, + } + original = Simulation(situation=SITUATION, reform=changes) + assert parameter(original, BASIC_RATE, "2027-01-01") == 0.20 + simulation = ( + original.clone() + if copy_kind == "clone" + else original.get_branch("copy", clone_system=True) + ) + Scenario.from_reform(changes).simulation_modifier(simulation) + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.31 + assert parameter(simulation, VAT, "2027-01-01") == 0.25 + assert parameter(original, BASIC_RATE, "2027-01-01") == 0.20 + + +def test_indexed_freeze_needs_construction_for_indexation_resume(): + changes = {PA: {"2026-04-06.2033-04-05": 12_570}} + constructed = Simulation(situation=SITUATION, reform=changes) + built = Simulation(situation=SITUATION) + baseline_2034 = parameter(built, PA, "2034") + Scenario.from_reform(changes).apply(built) + increase = Decimal(str(parameter(constructed, STATUTORY_CPI, "2033"))) + expected = float( + (Decimal(12_570) * (1 + increase) / 10).to_integral_value( + rounding=ROUND_CEILING + ) + * 10 + ) + assert parameter(constructed, PA, "2033") == expected + assert parameter(built, PA, "2033") == 12_570 + assert parameter(built, PA, "2034") == baseline_2034 + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +def test_isolated_construction_copy_preserves_parent_reading_and_tax(copy_kind): + situation = copy.deepcopy(SITUATION) + situation["people"]["person1"]["employment_income"] = { + year: 30_000 for year in YEARS + } + observations = {} + + def signature(simulation): + system = simulation.tax_benefit_system + target = system.parameters.get_child(BASIC_RATE) + return copy.deepcopy( + ( + [(entry.instant_str, entry.value) for entry in target.values_list], + system.dated_assignments, + system.legacy_overrides, + ) + ) + + def wrapper(parent): + before = signature(parent) + parent_state = parent._construction_reform_state + checkpoint_before = copy.deepcopy(parent_state.__dict__) + child = ( + parent.clone(clone_tax_benefit_system=True) + if copy_kind == "clone" + else parent.get_branch("construction_copy", clone_system=True) + ) + assert child.tax_benefit_system is not parent.tax_benefit_system + child_state = child._construction_reform_state + observations["separate_checkpoint_fields"] = ( + child_state is not parent_state + and all( + getattr(child_state, field) is not getattr(parent_state, field) + for field in ("owned", "paths", "dirty", "expected") + ) + and child_state.expected[BASIC_RATE] + is not parent_state.expected[BASIC_RATE] + ) + assert child_state.owned == parent_state.owned + child.tax_benefit_system.parameters.get_child(BASIC_RATE).update( + period="2027", value=0.40 + ) + child.tax_benefit_system.reset_parameter_caches() + Scenario.from_reform(FISCAL_A).simulation_modifier(child) + assert parameter(child, BASIC_RATE, "2027-01-01") == 0.31 + assert signature(parent) == before + assert parameter(parent, BASIC_RATE, "2027-01-01") == 0.20 + observations["parent_checkpoint_unchanged"] = ( + parent_state.__dict__ == checkpoint_before + ) + Scenario.from_reform(FISCAL_A).simulation_modifier(parent) + observations["parent_timeline_and_logs_unchanged"] = signature(parent) == before + + own = Scenario.from_reform(FISCAL_A) + control = Simulation(situation=situation, scenario=own) + simulation = Simulation( + situation=situation, + scenario=own + Scenario(simulation_modifier=wrapper), + ) + np.testing.assert_array_equal(control.calculate("income_tax", 2027), [3_486.0]) + np.testing.assert_array_equal( + simulation.calculate("employment_income", 2027), [30_000] + ) + np.testing.assert_array_equal( + simulation.calculate("personal_allowance", 2027), [12_570] + ) + np.testing.assert_array_equal( + simulation.calculate("income_tax", 2027), control.calculate("income_tax", 2027) + ) + assert parameter(simulation, BASIC_RATE, "2026-01-01") == 0.31 + assert parameter(simulation, BASIC_RATE, "2027-01-01") == 0.20 + assert observations == { + "separate_checkpoint_fields": True, + "parent_checkpoint_unchanged": True, + "parent_timeline_and_logs_unchanged": True, + } diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py new file mode 100644 index 0000000000..82450619df --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -0,0 +1,412 @@ +"""Property-based tests for whose circumstances the Council Tax Reduction +means test assesses. + +The means test covers the applicant's own income and their partner's (SSI +2012/319 regs 14, 21, 23 and 26; SI 2012/2885 Sch 1 para 11; WSI 2013/3029 +Sch 1 para 5 and Sch 6 para 7; SSI 2021/249 reg 36), and the applicant is the +person liable for the council tax. The model takes the household head as +liable (council_tax_reduction_liable_person). Usually the head is the +benefit unit's claimant or partner, but a grandmother who heads the household +can share a benefit unit with a young couple who are its claimant and +partner (#1896's parent-couple rule). She then applies alone. + +Invariants, for any generated population of households: heads alone, in a +couple (with partners aged 16 and over), or sharing a benefit unit with two +parents aged 16 to 30 and their baby; another family, sometimes a sharer; +income-based Jobseeker's Allowance and Universal Credit supplied for the +head's benefit unit; the head's disability, Attendance Allowance, Carer's +Allowance and blindness; all eight modelled schemes. + +1. Head's income counts: raising the liable household head's own private + pension by £5,000 raises their family's applicable income, unless the + family is on Pension Credit, whose assessment the pensioner schemes use + instead. Raising a young parent's earnings by £5,000 leaves the family's + applicable income unchanged where the head applies alone. +2. Monotone: raising the head's pension never raises any family's simulated + reduction. +3. High income: with £250,000 more pension, the head's family gets no + simulated reduction, unless an income-based benefit passports it or its + applicant has a Universal Credit award (both supplied as inputs here; + those routes are outside this invariant). +4. Identity: a liable head is always an applicant or partner of their own + family. Where the head does not apply alone, the applicant and partner + are exactly the benefit unit's claimant and partner (ordinary families + are unchanged). Where the head applies alone, the head is the only one. +5. Differential: a head of any age from 18 sharing a benefit unit with + parents aged 16 or 17 and their baby has the same applicable income, + applicable amount, premiums, passport, scheme, exemption and simulated + reduction as the same head entered as her own benefit unit, with the young + family as another. The two inputs describe the same people, so the law + gives the same answer. Two limits keep the inputs comparable: no one + claims Universal Credit, Income Support or Pension Credit by take-up (a + head inside another couple's benefit unit cannot be given her own award), + and the parents are under 18 (older ones would be her non-dependants, + which the model charges a deduction for only when they are in another + benefit unit). +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +SCHEMES = [ + ("ENGLAND", "MAIDSTONE"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("WALES", "CARDIFF"), +] +SHAPES = ["alone", "couple", "young_parents"] +# The head's own circumstances that the premiums and the exemption read: +# disability, Attendance Allowance (a severe disability premium qualifying +# benefit, which also exempts her from non-dependant deductions), Carer's +# Allowance (the carer premium) and blindness. +head_circumstances = st.fixed_dictionaries( + dict( + is_disabled_for_benefits=st.booleans(), + is_enhanced_disabled_for_benefits=st.booleans(), + attendance_allowance=st.sampled_from([0, 0, 4_000]), + carers_allowance=st.sampled_from([0, 0, 4_000]), + is_blind=st.booleans(), + ) +) +money = st.floats(0, 40_000, allow_nan=False, allow_infinity=False) +# Incomes, bills and savings around the means-test thresholds, so awards are +# often part-withdrawn rather than nil or full in both representations. +pension = st.one_of(st.sampled_from([0, 5_000, 8_000, 12_000, 20_000]), money) +council_tax = st.one_of(st.just(1_800), st.floats(0, 4_000, allow_nan=False)) +savings = st.one_of(st.just(0), st.floats(0, 20_000, allow_nan=False)) +head_age = st.one_of( + st.sampled_from([18, 25, 40, 60, 66, 68, 80]), st.integers(18, 100) +) +PENSION_STEP = 5_000 +HIGH_PENSION = 250_000 + + +@st.composite +def households(draw): + return dict( + scheme=draw(st.sampled_from(SCHEMES)), + shape=draw(st.sampled_from(SHAPES)), + head_age=draw(head_age), + head_pension=draw(pension), + head_flags=draw(head_circumstances), + partner_age=draw(st.integers(16, 100)), + parent_age=draw(st.integers(16, 30)), + other_earnings=draw(st.lists(money, min_size=2, max_size=2)), + jsa_income=draw(st.sampled_from([None, None, 3_000])), + universal_credit=draw(st.sampled_from([None, None, 5_000])), + other_family=draw(st.sampled_from([None, "non_dependant", "sharer"])), + other_age=draw(st.integers(16, 90)), + council_tax=draw(council_tax), + savings=draw(savings), + ) + + +def person_input(values): + return {key: {YEAR: value} for key, value in values.items()} + + +def add_household(people, benunits, homes, name, house, variant): + """Add one household. variant: "base", "pension", "high_pension" or + "parent_earnings" (the young mother's earnings raised).""" + country, local_authority = house["scheme"] + pension = house["head_pension"] + { + "pension": PENSION_STEP, + "high_pension": HIGH_PENSION, + }.get(variant, 0) + head = f"{name}_head" + people[head] = person_input( + dict( + age=house["head_age"], + is_household_head=True, + private_pension_income=pension, + **house["head_flags"], + ) + ) + family = [head] + if house["shape"] == "couple": + people[f"{name}_partner"] = person_input( + dict( + age=house["partner_age"], + is_household_head=False, + employment_income=house["other_earnings"][0], + ) + ) + family.append(f"{name}_partner") + elif house["shape"] == "young_parents": + for i, parent in enumerate(["mother", "father"]): + earnings = house["other_earnings"][i] + if variant == "parent_earnings" and parent == "mother": + earnings += PENSION_STEP + people[f"{name}_{parent}"] = person_input( + dict( + age=house["parent_age"], + is_parent=True, + is_household_head=False, + employment_income=earnings, + ) + ) + family.append(f"{name}_{parent}") + people[f"{name}_baby"] = person_input(dict(age=0, is_household_head=False)) + family.append(f"{name}_baby") + head_benunit = dict( + members=family, + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + ) + for benefit in ["jsa_income", "universal_credit"]: + if house[benefit] is not None: + head_benunit[benefit] = {YEAR: house[benefit]} + benunits[f"{name}_family"] = head_benunit + members = list(family) + if house["other_family"] is not None: + other = f"{name}_other" + people[other] = person_input( + dict( + age=house["other_age"], + is_household_head=False, + employment_income=house["other_earnings"][1], + ) + ) + benunits[f"{name}_other_family"] = dict( + members=[other], + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + liable_for_share_of_household_rent={ + YEAR: house["other_family"] == "sharer" + }, + ) + members.append(other) + homes[name] = person_input( + dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + savings=house["savings"], + ) + ) + homes[name]["members"] = members + + +VARIANTS = ["base", "pension", "high_pension", "parent_earnings"] + + +def simulate(population): + """One simulation holding every household in every variant.""" + people, benunits, homes = {}, {}, {} + for h, house in enumerate(population): + for variant in VARIANTS: + add_household(people, benunits, homes, f"h{h}_{variant}", house, variant) + return Simulation( + situation=dict(people=people, benunits=benunits, households=homes) + ) + + +def by_benunit(sim, variable, names): + """A benefit-unit variable for the named benefit units.""" + values = sim.calculate(variable, YEAR) + ids = list(sim.populations["benunit"].ids) + return np.array([values[ids.index(name)] for name in names]) + + +@given(st.lists(households(), min_size=1, max_size=6)) +@PROPERTY_SETTINGS +def test_council_tax_reduction_assesses_the_liable_head(population): + sim = simulate(population) + head_family = { + variant: [f"h{h}_{variant}_family" for h in range(len(population))] + for variant in VARIANTS + } + + def family(variable, variant): + return by_benunit(sim, variable, head_family[variant]) + + head_alone = family("council_tax_reduction_head_applies_alone", "base") + + # 1. The head's pension counts; a young parent's earnings do not where + # the head applies alone. A family on Pension Credit is assessed through + # Pension Credit instead (guarantee credit: its whole income disregarded; + # savings credit only: Pension Credit's assessment of income; SI 2012/2885 + # Sch 1 paras 13-14), so it is outside the first part. + income = family("council_tax_reduction_applicable_income", "base") + on_pension_credit = np.zeros(len(population), dtype=bool) + for variant in ["base", "pension"]: + on_pension_credit |= family("in_receipt_of_guarantee_credit", variant) + on_pension_credit |= family("in_receipt_of_savings_credit_only", variant) + raised = family("council_tax_reduction_applicable_income", "pension") + assert (raised > income)[~on_pension_credit].all() + parent_income = family("council_tax_reduction_applicable_income", "parent_earnings") + assert np.allclose(parent_income[head_alone], income[head_alone]) + + # 2. Monotone in the head's pension, for every family. + all_ids = list(sim.populations["benunit"].ids) + simulated = sim.calculate("simulated_council_tax_reduction_benunit", YEAR) + + def every_family(variant): + return np.array( + [simulated[all_ids.index(name)] for name in _all_names(population, variant)] + ) + + base_ctr = every_family("base") + for variant in ["pension", "high_pension"]: + assert (every_family(variant) <= base_ctr + 1e-6).all() + + # 3. No reduction on a very high income, outside the passport and UC + # routes. + passported = family("council_tax_reduction_relevant_income_based_benefit", "base") + universal_credit = family("universal_credit", "base") > 0 + high = family("simulated_council_tax_reduction_benunit", "high_pension") + assessed_on_income = ~passported & ~(universal_credit & ~head_alone) + assert (high[assessed_on_income] == 0).all() + + # 4. Identity. + liable = sim.calculate("council_tax_reduction_liable_person", YEAR) + head = sim.calculate("is_resolved_household_head", YEAR) + applicant = sim.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + claimant_or_partner = sim.calculate("is_claimant_or_partner", YEAR) + alone = sim.populations["person"].benunit( + "council_tax_reduction_head_applies_alone", YEAR + ) + assert (applicant[liable & head]).all() + assert (applicant[~alone] == claimant_or_partner[~alone]).all() + assert (applicant[alone] == (liable & head)[alone]).all() + + +def _all_names(population, variant): + """Every benefit unit of every household in one variant, in order.""" + names = [] + for h, house in enumerate(population): + names.append(f"h{h}_{variant}_family") + if house["other_family"] is not None: + names.append(f"h{h}_{variant}_other_family") + return names + + +@st.composite +def grandmother_households(draw): + return dict( + scheme=draw(st.sampled_from(SCHEMES)), + head_age=draw(head_age), + head_pension=draw(pension), + head_flags=draw(head_circumstances), + parent_age=draw(st.integers(16, 17)), + parent_earnings=draw(st.lists(money, min_size=2, max_size=2)), + jsa_income=draw(st.sampled_from([None, 3_000])), + universal_credit=draw(st.sampled_from([None, 5_000])), + council_tax=draw(council_tax), + savings=draw(savings), + ) + + +def add_grandmother_household(people, benunits, homes, name, house, split): + country, local_authority = house["scheme"] + head = f"{name}_grandmother" + people[head] = person_input( + dict( + age=house["head_age"], + is_household_head=True, + private_pension_income=house["head_pension"], + **house["head_flags"], + ) + ) + young = [] + for i, parent in enumerate(["mother", "father"]): + people[f"{name}_{parent}"] = person_input( + dict( + age=house["parent_age"], + is_parent=True, + is_household_head=False, + employment_income=house["parent_earnings"][i], + ) + ) + young.append(f"{name}_{parent}") + people[f"{name}_baby"] = person_input(dict(age=0, is_household_head=False)) + young.append(f"{name}_baby") + young_benefits = { + benefit: {YEAR: house[benefit]} + for benefit in ["jsa_income", "universal_credit"] + if house[benefit] is not None + } + common = dict( + claims_all_entitled_benefits={YEAR: True}, + would_claim_uc={YEAR: False}, + would_claim_IS={YEAR: False}, + would_claim_pc={YEAR: False}, + ) + if split: + benunits[f"{name}_family"] = dict(members=[head], **common) + benunits[f"{name}_young"] = dict(members=young, **common, **young_benefits) + else: + benunits[f"{name}_family"] = dict( + members=[head] + young, **common, **young_benefits + ) + homes[name] = person_input( + dict( + country=country, + local_authority=local_authority, + council_tax=house["council_tax"], + savings=house["savings"], + ) + ) + homes[name]["members"] = [head] + young + + +DIFFERENTIAL_VARIABLES = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_reduction_premiums", + "council_tax_reduction_relevant_income_based_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicant_has_non_dep_exemption", + "simulated_council_tax_reduction_benunit", +] + + +@given(st.lists(grandmother_households(), min_size=1, max_size=6)) +@PROPERTY_SETTINGS +def test_head_applying_alone_matches_her_own_benefit_unit(population): + people, benunits, homes = {}, {}, {} + for h, house in enumerate(population): + for split in [False, True]: + add_grandmother_household( + people, + benunits, + homes, + f"h{h}_{'split' if split else 'merged'}", + house, + split, + ) + sim = Simulation(situation=dict(people=people, benunits=benunits, households=homes)) + merged = [f"h{h}_merged_family" for h in range(len(population))] + split = [f"h{h}_split_family" for h in range(len(population))] + assert by_benunit(sim, "council_tax_reduction_head_applies_alone", merged).all() + assert not by_benunit(sim, "council_tax_reduction_head_applies_alone", split).any() + for variable in DIFFERENTIAL_VARIABLES: + assert np.allclose( + by_benunit(sim, variable, merged), by_benunit(sim, variable, split) + ), variable + household_ids = list(sim.populations["household"].ids) + for variable in [ + "council_tax_reduction", + ]: + values = sim.calculate(variable, YEAR) + merged_values = [ + values[household_ids.index(f"h{h}_merged")] for h in range(len(population)) + ] + split_values = [ + values[household_ids.index(f"h{h}_split")] for h in range(len(population)) + ] + assert np.allclose(merged_values, split_values), variable diff --git a/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py new file mode 100644 index 0000000000..b041c55dca --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_child_benefit_properties.py @@ -0,0 +1,216 @@ +"""Pensioner Child Benefit exclusion and unchanged adjacent CTR contracts. + +These model2026 tests use actual Child Benefit claims and family incomes, not +forced applicable income, pensioner status or reduction awards. The 20% cash +relation is conditional on an interior award; the existing floor/cap still apply. +""" + +from copy import deepcopy + +import numpy as np +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +COUNTRIES = { + "ENGLAND": ("LONDON", "MAIDSTONE"), + "SCOTLAND": ("SCOTLAND", "GLASGOW_CITY"), + "WALES": ("WALES", "CARDIFF"), +} + + +def family(country="ENGLAND", pension=16000, age=70, children=1, council_tax=4000): + region, authority = COUNTRIES.get(country, ("NORTHERN_IRELAND", "BELFAST")) + people = {"parent": {"age": {YEAR: age}, "is_parent": {YEAR: True}}} + people["parent"][ + "state_pension" if age >= 67 else "employment_income_before_lsr" + ] = {YEAR: pension} + for index in range(children): + people[f"child{index}"] = {"age": {YEAR: 9 + index}} + return { + "people": people, + "benunits": { + "family": { + "members": list(people), + "would_claim_pc": {YEAR: False}, + "would_claim_uc": {YEAR: False}, + } + }, + "households": { + "home": { + "members": list(people), + "region": {YEAR: region}, + "local_authority": {YEAR: authority}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + "council_tax": {YEAR: council_tax}, + "savings": {YEAR: 0}, + } + }, + } + + +def calculate(data, claim, status_first=False): + data = deepcopy(data) + for unit in data["benunits"].values(): + unit["would_claim_child_benefit"] = {YEAR: claim} + sim = Simulation(situation=data) + first = ( + "council_tax_reduction_pensioner" + if status_first + else "council_tax_reduction_applicable_income" + ) + sim.calculate(first, YEAR) + names = [ + "child_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicable_income", + "council_tax_reduction_applicable_amount", + "council_tax_benefit", + "council_tax_reduction", + "household_net_income", + "in_receipt_of_guarantee_credit", + "in_receipt_of_savings_credit_only", + ] + return {name: np.asarray(sim.calculate(name, YEAR)) for name in names} + + +@settings(max_examples=4, deadline=None, derandomize=True) +@given( + st.sampled_from(sorted(COUNTRIES)), + st.integers(15000, 19000), + st.integers(1, 2), + st.booleans(), +) +def test_pensioner_ctr_is_invariant_to_actual_child_benefit_claim( + country, pension, children, status_first +): + data = family(country, pension, children=children) + paid = calculate(data, True, status_first) + unpaid = calculate(data, False, not status_first) + assert paid["child_benefit"][0] > 0 + assert unpaid["child_benefit"][0] == 0 + assert paid["council_tax_reduction_pensioner"][0] + assert not paid["in_receipt_of_guarantee_credit"][0] + assert not paid["in_receipt_of_savings_credit_only"][0] + np.testing.assert_allclose( + paid["council_tax_reduction_applicable_income"], + unpaid["council_tax_reduction_applicable_income"], + atol=0.01, + rtol=0, + ) + np.testing.assert_allclose( + paid["council_tax_reduction"], + unpaid["council_tax_reduction"], + atol=0.01, + rtol=0, + ) + # The payable award reaches the household consumer. No other benefit/tax + # changes in these owner-occupier, no-PC/no-UC, below-HICBC families. + np.testing.assert_allclose( + paid["household_net_income"] - unpaid["household_net_income"], + paid["child_benefit"], + atol=0.02, + rtol=0, + ) + # Named old-law counterfactual: before the exclusion, an interior award + # counted this cash and lost exactly 20% of it. The new award is not clipped. + income = unpaid["council_tax_reduction_applicable_income"][0] + amount = unpaid["council_tax_reduction_applicable_amount"][0] + liability = 4000 + old_award = max( + 0, liability - 0.2 * max(0, income + paid["child_benefit"][0] - amount) + ) + assert income > amount and old_award > 0 + assert ( + abs( + paid["council_tax_reduction"][0] + - old_award + - 0.2 * paid["child_benefit"][0] + ) + < 0.02 + ) + + +def test_working_age_and_northern_ireland_keep_counting_child_benefit(): + for country, authority in [ + ("SCOTLAND", "GLASGOW_CITY"), + ("WALES", "CARDIFF"), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("NORTHERN_IRELAND", "BELFAST"), + ]: + age = 70 if country == "NORTHERN_IRELAND" else 40 + data = family(country, 18000, age=age) + data["households"]["home"]["local_authority"] = {YEAR: authority} + paid, unpaid = calculate(data, True), calculate(data, False, True) + assert paid["child_benefit"][0] > 0 + if age == 40: + assert not paid["council_tax_reduction_pensioner"][0] + np.testing.assert_allclose( + paid["council_tax_reduction_applicable_income"] + - unpaid["council_tax_reduction_applicable_income"], + paid["child_benefit"], + atol=0.02, + rtol=0, + ) + assert ( + paid["council_tax_reduction"][0] + <= unpaid["council_tax_reduction"][0] + 0.01 + ) + if authority == "OXFORD": + # Oxford already removes CB before its own band selection. + np.testing.assert_allclose( + paid["council_tax_reduction"], + unpaid["council_tax_reduction"], + atol=0.01, + rtol=0, + ) + + +def test_own_family_pensioner_mask_and_query_order_ignore_outside_family(): + data = family() + data["people"].update( + { + "other": {"age": {YEAR: 40}, "employment_income_before_lsr": {YEAR: 18000}}, + "other_child": {"age": {YEAR: 7}}, + } + ) + data["benunits"]["other_family"] = { + "members": ["other", "other_child"], + "would_claim_pc": {YEAR: False}, + "would_claim_uc": {YEAR: False}, + } + data["households"]["home"]["members"] += ["other", "other_child"] + paid, unpaid = calculate(data, True), calculate(data, False, True) + np.testing.assert_array_equal( + paid["council_tax_reduction_pensioner"], [True, False] + ) + delta = ( + paid["council_tax_reduction_applicable_income"] + - unpaid["council_tax_reduction_applicable_income"] + ) + np.testing.assert_allclose(delta, [0, paid["child_benefit"][1]], atol=0.02, rtol=0) + reversed_data = deepcopy(data) + reversed_data["people"] = dict(reversed(list(data["people"].items()))) + reversed_data["benunits"] = dict(reversed(list(data["benunits"].items()))) + for unit in reversed_data["benunits"].values(): + unit["members"] = list(reversed(unit["members"])) + reverse = calculate(reversed_data, True, True) + for name in [ + "child_benefit", + "council_tax_reduction_pensioner", + "council_tax_reduction_applicable_income", + "council_tax_benefit", + ]: + np.testing.assert_allclose(reverse[name][::-1], paid[name], atol=0.02, rtol=0) + np.testing.assert_allclose( + reverse["council_tax_reduction"], + paid["council_tax_reduction"], + atol=0.02, + rtol=0, + ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py index e6d1de97c7..811373c441 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pension_credit_properties.py @@ -23,8 +23,8 @@ nothing otherwise. 4. Differential: everyone else keeps the previous formula, max(0, L - 0.2 x max(0, I - A) - ND) when household savings are within 16,000, where I is the - income definition on main before this change less the schemes' pensioner - earnings disregard, both recomputed here (the income from its components, + income definition on main before this change less pensioners' Child Benefit + and the schemes' pensioner earnings disregard, recomputed here (income from its components, the disregard from the statutory sums) rather than read from the variables under test. 5. Metamorphic: a guarantee credit recipient's CTR does not change when the @@ -264,7 +264,8 @@ def situation( # council_tax_reduction_applicable_income on main before this change (1c5b4d04): # these incomes and benefits, less income tax, National Insurance and half of # pension contributions, floored at zero. Since #2033, a pensioner family's -# earnings disregard also comes off before the floor. +# earnings disregard also comes off before the floor. These GB families now +# exclude Child Benefit on the pensioner general-rules route as well. MAIN_INCOME_COMPONENTS = [ "employment_income", "self_employment_income", @@ -362,6 +363,11 @@ def benunit_total(names): values["income_under_main_definition"] = np.maximum( 0, benunit_total(MAIN_INCOME_COMPONENTS) + - np.where( + values["council_tax_reduction_pensioner"], + benunit_total(["child_benefit"]), + 0, + ) - benunit_total(MAIN_DEDUCTIONS) - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), diff --git a/policyengine_uk/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_council_tax_reduction_severe_disability_family.py b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py new file mode 100644 index 0000000000..4334138476 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py @@ -0,0 +1,141 @@ +"""CTR family-mask input contracts, separate from derived eligibility tests. + +SSI 2021/249 regs 6 and 8(2)(a), Sch 1 para 13 retain an applicant's +UC young person in their family. These finite grids deliberately supply the +two young-person statuses to exercise both directions independently of the +upstream eligibility rules. The policy YAML uses school/course inputs and +the annual terminal-date input, plus actual PIP, pension and council tax. + +Looked-after and other-benefit-unit treatment remains the existing model +baseline, not a certification of reg 8(2)(b) and its paragraph (3) overrides. +""" + +from itertools import product + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +def input_grid(rows, country="SCOTLAND", year=2026, applicant_age=45): + people, benunits, households = {}, {}, {} + for i, row in enumerate(rows): + applicant, student = f"a{i}", f"s{i}" + people[applicant] = { + "age": applicant_age, + "is_claimant_or_partner": True, + "pip_dl_category": "ENHANCED", + } + people[student] = { + "age": row.get("age", 19), + "is_claimant_or_partner": False, + "is_child_or_qualifying_young_person_for_child_benefit": row["cb"], + "is_qualifying_young_person_for_universal_credit": row["uc"], + "is_looked_after_by_local_authority": row.get("looked_after", False), + "is_blind": row.get("blind", False), + "pip_dl_category": "STANDARD" if row.get("benefit", False) else "NONE", + } + members = [applicant, student] + benunits[f"b{i}"] = { + "members": [applicant] if row.get("other_bu", False) else members, + "would_claim_uc": False, + "would_claim_IS": False, + "would_claim_pc": False, + } + if row.get("other_bu", False): + benunits[f"other{i}"] = {"members": [student]} + households[f"h{i}"] = {"members": members, "country": country} + situation = {"people": people, "benunits": benunits, "households": households} + # A situation's default period is the model default, not necessarily year. + for group in situation.values(): + for values in group.values(): + for name, value in list(values.items()): + if name != "members": + values[name] = {year: value} + return Simulation(situation=situation), [f"b{i}" for i in range(len(rows))] + + +def values(sim, variable, names, year=2026): + result = sim.calculate(variable, year) + ids = list(sim.populations["benunit"].ids) + return np.array([result[ids.index(name)] for name in names]) + + +@pytest.mark.parametrize( + "country,year,applicant_age,scottish_working_age", + [ + ("SCOTLAND", 2026, 45, True), + ("SCOTLAND", 2021, 45, False), + ("SCOTLAND", 2026, 70, False), + ("ENGLAND", 2026, 45, False), + ("WALES", 2026, 45, False), + ("NORTHERN_IRELAND", 2026, 45, False), + ], +) +def test_own_family_status_contract_by_applicant_scheme( + country, year, applicant_age, scottish_working_age +): + rows = [ + dict(cb=cb, uc=uc, looked_after=looked_after) + for cb, uc, looked_after in product([False, True], repeat=3) + ] + sim, names = input_grid(rows, country, year, applicant_age) + expected = [ + not ( + row["uc"] if scottish_working_age and not row["looked_after"] else row["cb"] + ) + for row in rows + ] + assert ( + values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + year, + ).tolist() + == expected + ) + # HB and IS retain the original Child Benefit family classification. + assert values( + sim, "has_non_dependant_for_severe_disability_premium", names, year + ).tolist() == [not row["cb"] for row in rows] + + +def test_age_boundary_and_blind_or_benefit_exceptions(): + rows = [ + dict(age=age, cb=True, uc=False, blind=blind, benefit=benefit) + for age, blind, benefit in product( + [17.999, 18, 19, 20], [False, True], [False, True] + ) + ] + sim, names = input_grid(rows) + expected = [ + row["age"] >= 18 and not row["blind"] and not row["benefit"] for row in rows + ] + assert ( + values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + ).tolist() + == expected + ) + # The shared gate still disregards these own-BU Child Benefit young people. + assert not values( + sim, "has_non_dependant_for_severe_disability_premium", names + ).any() + + +def test_other_benefit_units_keep_existing_treatment(): + rows = [ + dict(cb=cb, uc=uc, looked_after=looked_after, other_bu=True) + for cb, uc, looked_after in product([False, True], repeat=3) + ] + sim, names = input_grid(rows) + assert values(sim, "has_non_dependant_for_severe_disability_premium", names).all() + assert values( + sim, + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + names, + ).all() diff --git a/policyengine_uk/tests/test_ctc_family_element.py b/policyengine_uk/tests/test_ctc_family_element.py new file mode 100644 index 0000000000..3de929f8b0 --- /dev/null +++ b/policyengine_uk/tests/test_ctc_family_element.py @@ -0,0 +1,241 @@ +"""Family-only DOB eligibility and its inherited annual/input contracts. + +SI 2002/2007 reg 7(2)(a), amended by SI 2017/387 reg 4(b), requires a +responsible child/QYP born strictly before 6 April 2017. The 2024 cash +oracles use unchanged child3455/family545, zero income and a reported +continuing claim. They do not audit daily statutory award rounding. +""" + +from pathlib import Path + +import pytest + +from policyengine_core.parameters.helpers import load_parameter_file +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +PREFIX = "gov.dwp.tax_credits.child_tax_credit.eligibility." + + +def situation(children, year=2024, parent=None, benunit_inputs=None): + facts = {"parent": {"age": 35, "child_tax_credit_reported": 1}} + facts["parent"].update(parent or {}) + facts.update(children) + people = { + name: {key: {year: value} for key, value in person.items()} + for name, person in facts.items() + } + members = list(people) + return { + "people": people, + "benunits": { + "family": { + "members": members, + "would_claim_uc": {year: False}, + **{key: {year: value} for key, value in (benunit_inputs or {}).items()}, + } + }, + "households": {"home": {"members": members}}, + } + + +def child(born=20170406, age=7, **other): + return {"child": {"age": age, "date_of_birth": born, **other}} + + +def assert_cash(sim, expected, year=2024): + assert sim.calculate("child_tax_credit", year)[0] == pytest.approx( + expected, abs=0.01 + ) + assert sim.calculate("tax_credits", year)[0] == pytest.approx(expected, abs=0.01) + + +def test_raw_commencement_and_annual_mapping_preserve_prior_supported_years(): + root = Path(__file__).resolve().parents[1] / "parameters" + applies = load_parameter_file( + str( + root + / "gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_birth_date_condition_applies.yaml" + ), + name="applies", + ) + cutoff = load_parameter_file( + str( + root + / "gov/dwp/tax_credits/child_tax_credit/eligibility/family_element_born_before.yaml" + ), + name="cutoff", + ) + assert ( + applies("2015-01-01") is False + ) # Existing coverage anchor, not legal commencement. + assert applies("2017-04-05") is False + assert applies("2017-04-06") is True + assert cutoff.values_list[-1].instant_str == "2017-04-06" + assert cutoff("2017-04-06") == 20170406 + system = CountryTaxBenefitSystem() + for year, expected in [(2015, False), (2016, False), (2017, True), (2024, True)]: + p = system.parameters( + str(year) + ).gov.dwp.tax_credits.child_tax_credit.eligibility + assert p.family_element_birth_date_condition_applies == expected + + +@pytest.mark.parametrize("year", [2015, 2016]) +def test_prior_supported_years_keep_the_existing_flat_family_element(year): + sim = Simulation(situation=situation(child((year - 1) * 10000 + 406, 1), year)) + assert sim.calculate("CTC_family_element", year)[0] == 545 + + +def test_annual_2017_uses_the_qualified_april_condition(): + sim = Simulation(situation=situation(child(20170406, 0), 2017)) + assert_cash(sim, 2780, 2017) # Unchanged 2017 child-element baseline; family0. + assert sim.calculate("CTC_family_element", 2017)[0] == 0 + + +def test_a_genuine_qualifying_young_person_can_unlock_the_family_element(): + sim = Simulation( + situation=situation( + child(20060901, 18, current_education="UPPER_SECONDARY"), parent={"age": 45} + ) + ) + assert sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", 2024 + ).tolist() == [False, True] + assert_cash(sim, 4000) + + +def test_an_older_excluded_member_cannot_unlock_a_younger_childs_family_element(): + # Existing looked-after proxy excludes the older child; its wider legal + # limitations are unchanged. Adult claimants are also outside the mask. + sim = Simulation( + situation=situation( + { + "older": { + "age": 10, + "date_of_birth": 20140405, + "is_looked_after_by_local_authority": True, + }, + **child(), + }, + parent={"age": 40}, + ) + ) + assert sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", 2024 + ).tolist() == [False, False, True] + assert_cash(sim, 3455) + assert sim.calculate("CTC_family_element", 2024)[0] == 0 + + +def test_multiple_qualifying_children_receive_one_flat_family_amount(): + sim = Simulation( + situation=situation( + { + "older": {"age": 10, "date_of_birth": 20140405}, + "younger": {"age": 8, "date_of_birth": 20160405}, + } + ) + ) + assert_cash(sim, 7455) # 2 * 3455 + one545, not545perchild. + assert sim.calculate("CTC_family_element", 2024)[0] == 545 + + +@pytest.mark.parametrize("months,expected", [(5, 3455), (6, 3455), (7, 4000)]) +def test_partial_dob_inputs_retain_the_existing_annual_age_fallback(months, expected): + sim = Simulation( + situation=situation( + child(0, 7, months_since_last_birthday=months), + parent={"date_of_birth": 19890101}, + ) + ) + assert_cash(sim, expected) + + +def test_birth_year_override_does_not_replace_the_existing_dob_helper(): + sim = Simulation(situation=situation(child(0, 7, birth_year=2000))) + assert_cash(sim, 3455) + + +@pytest.mark.parametrize( + "born,cutoff,expected,individual_exempt", + [(20170406, 20170407, 4000, False), (20170405, 20170405, 3455, True)], +) +def test_family_cutoff_reforms_do_not_move_the_individual_element_cutoff( + born, cutoff, expected, individual_exempt +): + sim = Simulation( + situation=situation(child(born)), + reform={PREFIX + "family_element_born_before": cutoff}, + ) + assert_cash(sim, expected) + assert ( + bool(sim.calculate("is_CTC_child_limit_exempt", 2024)[1]) is individual_exempt + ) + + +def test_family_activation_is_independently_reformable(): + sim = Simulation( + situation=situation(child()), + reform={PREFIX + "family_element_birth_date_condition_applies": False}, + ) + assert_cash(sim, 4000) + + +def test_individual_element_exception_does_not_waive_the_family_condition(): + sim = Simulation( + situation=situation(child()), + reform={"gov.contrib.two_child_limit.age_exemption.child_tax_credit": 10}, + ) + assert bool(sim.calculate("is_CTC_child_limit_exempt", 2024)[1]) + assert_cash(sim, 3455) + assert sim.calculate("CTC_family_element", 2024)[0] == 0 + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_family_boundaries_are_local_across_units_member_and_request_order(reverse): + people, benunits = {}, {} + for name, born in [("before", 20170405), ("on", 20170406)]: + inputs = situation(child(born)) + names = [name + "_parent", name + "_child"] + people.update( + {new: value for new, value in zip(names, inputs["people"].values())} + ) + benunits[name] = { + "members": names[::-1] if reverse else names, + "would_claim_uc": {2024: False}, + } + if reverse: + people = dict(reversed(list(people.items()))) + benunits = dict(reversed(list(benunits.items()))) + sim = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": {"shared": {"members": list(people)}}, + } + ) + if reverse: + sim.calculate("is_CTC_child_limit_exempt", 2024) + sim.calculate("CTC_family_element", 2024) + for variable in ["child_tax_credit", "tax_credits"]: + values = dict( + zip(sim.populations["benunit"].ids, sim.calculate(variable, 2024)) + ) + assert values == pytest.approx({"before": 4000, "on": 3455}, abs=0.01) + + +@pytest.mark.parametrize( + "variable,amount", [("CTC_family_element", 123), ("child_tax_credit", 222)] +) +def test_explicit_amount_inputs_remain_overrides_not_entitlement_proof( + variable, amount +): + sim = Simulation(situation=situation(child(), benunit_inputs={variable: amount})) + assert sim.calculate(variable, 2024)[0] == amount + if variable == "CTC_family_element": + assert_cash(sim, 3578) # 3455 + explicitly supplied123. + + +def test_scheme_closure_still_overrides_an_old_childs_family_qualification(): + sim = Simulation(situation=situation(child(20170405, 8), 2025)) + assert_cash(sim, 0, 2025) diff --git a/policyengine_uk/tests/test_dataframe_dataset.py b/policyengine_uk/tests/test_dataframe_dataset.py index 0f803680d0..38ae8c7841 100644 --- a/policyengine_uk/tests/test_dataframe_dataset.py +++ b/policyengine_uk/tests/test_dataframe_dataset.py @@ -59,11 +59,6 @@ group's own integer identifier is given beside it; and members of a group that give different values for one of its variables. -Limit of the default export, which is not the loader's: ``to_input_dataframe()`` -leaves out values a dataset supplies for a variable that has a formula, such -as ``state_pension_reported``. ``to_input_dataframe(include_computed_variables= -True)`` exports them, and must be taken before the simulation calculates -anything, since it also exports every calculated value. """ import io diff --git a/policyengine_uk/tests/test_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_dividend_band_dates.py b/policyengine_uk/tests/test_dividend_band_dates.py new file mode 100644 index 0000000000..969b759580 --- /dev/null +++ b/policyengine_uk/tests/test_dividend_band_dates.py @@ -0,0 +1,73 @@ +"""Dated dividend limits and preserved rate/reform interfaces. + +ITA 2007 s.13 uses the corresponding UK taxable-income bands. Earlier +unchanged values below are regression controls, not a pre-2016 law audit. +""" + +from pathlib import Path + +import pytest +import yaml +from policyengine_core.parameters import Parameter + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem + +PATH = "gov.hmrc.income_tax.rates.dividends" +FILE = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/hmrc/income_tax/rates/dividends.yaml" +) + + +@pytest.mark.parametrize( + "bracket,before,after,old,new", + [ + (1, "2016-04-05", "2016-04-06", 37_500, 32_000), + (1, "2017-04-05", "2017-04-06", 32_000, 33_500), + (1, "2018-04-05", "2018-04-06", 33_500, 34_500), + (1, "2019-04-05", "2019-04-06", 34_500, 37_500), + (1, "2021-04-05", "2021-04-06", 37_500, 37_700), + (2, "2023-04-05", "2023-04-06", 150_000, 125_140), + ], +) +def test_raw_dividend_limits_change_on_the_statutory_tax_year_date( + bracket, before, after, old, new +): + data = yaml.safe_load(FILE.read_text())["brackets"][bracket]["threshold"] + data["values"] = {str(date): value for date, value in data["values"].items()} + raw = Parameter(f"{PATH}[{bracket}].threshold", data=data, file_path=str(FILE)) + assert raw(before) == old + assert raw(after) == new + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "year,basic_limit,additional_limit", + [ + (2016, 32_000, 150_000), + (2017, 33_500, 150_000), + (2018, 34_500, 150_000), + (2019, 37_500, 150_000), + (2020, 37_500, 150_000), + (2021, 37_700, 150_000), + (2023, 37_700, 125_140), + (2025, 37_700, 125_140), + (2026, 37_700, 125_140), + ], +) +def test_annual_dividend_limits_include_restoration_and_current_years( + system, year, basic_limit, additional_limit +): + scale = system.parameters.get_child(PATH)(f"{year}-01-01") + assert scale.thresholds == [0, basic_limit, additional_limit, 10_000_000] + + +def test_2026_dividend_rates_and_optional_extra_bracket_are_unchanged(system): + scale = system.parameters.get_child(PATH)("2026-01-01") + assert scale.rates == [0.1075, 0.3575, 0.3935, 0.3935] + assert scale.thresholds == [0, 37_700, 125_140, 10_000_000] diff --git a/policyengine_uk/tests/test_dwp_published_rates.py b/policyengine_uk/tests/test_dwp_published_rates.py index 5323fd9de6..f4282725df 100644 --- a/policyengine_uk/tests/test_dwp_published_rates.py +++ b/policyengine_uk/tests/test_dwp_published_rates.py @@ -2,9 +2,12 @@ The fixture fixtures/dwp_benefit_and_pension_rates.yaml lists, for each parameter with a published counterpart, the rate for each fiscal year as -published by DWP (and HMRC for tax credits). Each year appears in two -documents, that year's and the next year's, and the fixture holds the value -both agree on. Values are in the parameter's own unit and period. +published by DWP (and HMRC for tax credits). Each year to 2025-26 appears in +two documents, that year's and the next year's, and the fixture holds the +value both agree on; for UC's 2021-22 amounts, which changed in October 2021, +it holds the April amounts. 2026-27 has only its own edition so far and is +checked against SI 2026/148. Values are in the parameter's own unit and +period. Invariants: diff --git a/policyengine_uk/tests/test_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_housing_benefit_pension_age_cohorts.py b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py new file mode 100644 index 0000000000..3334321751 --- /dev/null +++ b/policyengine_uk/tests/test_housing_benefit_pension_age_cohorts.py @@ -0,0 +1,311 @@ +"""Pension-age Housing Benefit personal allowances by when State Pension age +was attained. + +The Housing Benefit (Persons who have attained the qualifying age for state +pension credit) Regulations 2006 (SI 2006/214) Sch 3 para 1, as amended by SI +2021/188 from 1 April 2021 (Northern Ireland: SR 2006/406 Sch 4 para 1): a +single claimant or lone parent who attained pensionable age before 1 April +2021 gets the higher allowance in (1)(b), one who attained it on or after gets +(1)(c); a couple gets (2)(b) where one or both members attained it before, and +(2)(c) where both attained it on or after. + +Invariants, for any pension-age single person or couple in any year: + +1. Differential: the pension-age personal allowance in + housing_benefit_applicable_amount is the higher rate exactly when a member's + attainment day, read from the statute day by day + (test_state_pension_age.reference_attainment_day), is before 1 April 2021, + and the lower rate otherwise. +2. Bounds: the lower rate is below the higher rate in every year, including + uprated years, and lone parents get the single rates. +3. Monotonicity: among pension-age single people of one sex in one year, an + earlier date of birth never gives a lower allowance. +4. Before April 2021 there was one pension-age rate, which every pension-age + unit gets. +5. The lower rates, the higher rates and the cutoff equal the statute's table + for every year 2021-22 to 2026-27 (hard-coded below from legislation.gov.uk + and DWP's Benefit and pension rates). +6. Council Tax Reduction: England's pensioner scheme has the same split and + amounts (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29), so its + allowance equals Housing Benefit's; the Scottish and Welsh schemes have + one pension-age rate, the higher one. +7. Only the claimant and partner (HB) or the applicant and partner (CTR) + count: adding another member of any age never changes either allowance. +""" + +from datetime import date, timedelta + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from test_state_pension_age import grid_months, reference_attainment_day + +CUTOFF = date(2021, 4, 1) +YEARS = list(range(2019, 2031)) +P = system.parameters.gov.dwp.housing_benefit.allowances + + +def rates(year: int, couple: bool) -> tuple: + """(higher, lower) weekly pension-age allowance; lower is None before the + cutoff took effect.""" + node = P.couple if couple else P.single + at = f"{year}-06-01" + return node.aged(at), node.aged_from_cutoff(at) + + +def build(units: list, year: int, region: str = "LONDON") -> Simulation: + """Each unit is a list of (birth, male). Units are eligible with no + premiums, so the applicable amount is 52 times the personal allowance. + + Which table applies is set here, not left to the model: a unit with a + member over State Pension age is under the pension-age Housing Benefit + regulations and is a Council Tax Reduction pensioner. That leaves the + cohort rule as the only thing under test (a mixed-age couple on Universal + Credit would otherwise be in the working-age tables).""" + people, benunits = {}, {} + mid_year = grid_months(date(year, 10, 6)) + for u, members in enumerate(units): + names = [] + for k, (birth, male) in enumerate(members): + age_in_months = mid_year - grid_months(birth) + age = int(age_in_months // 12) + name = f"u{u}p{k}" + people[name] = { + "age": {year: age}, + "months_since_last_birthday": {year: age_in_months - 12 * age}, + "is_male": {year: male}, + } + names.append(name) + pension_age = at_pension_age(members, year) + benunits[f"u{u}"] = { + "members": names, + "housing_benefit_eligible": {year: True}, + "housing_benefit_pension_age_regulations_apply": {year: pension_age}, + "council_tax_reduction_pensioner": {year: pension_age}, + "benefits_premiums": {year: 0}, + } + return Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": {"h": {"members": list(people), "region": {year: region}}}, + } + ) + + +def at_pension_age(members: list, year: int) -> bool: + mid_year = date(year, 10, 6) + return any(reference_attainment_day(b, m) <= mid_year for b, m in members) + + +def expected_allowance(members: list, year: int) -> float: + higher, lower = rates(year, couple=len(members) == 2) + attained = [reference_attainment_day(b, m) for b, m in members] + if lower is None or any(day < CUTOFF for day in attained): + return higher + return lower + + +def check(units: list, year: int) -> np.ndarray: + """Invariant 1 for every unit with a member over State Pension age.""" + amount = build(units, year).calculate("housing_benefit_applicable_amount", year) + weekly = amount / 52 + wrong = [ + (members, weekly[i], expected_allowance(members, year)) + for i, members in enumerate(units) + if at_pension_age(members, year) + and abs(weekly[i] - expected_allowance(members, year)) > 0.005 + ] + assert not wrong, wrong[:10] + return weekly + + +BIRTHS = [date(1938, 1, 1) + timedelta(days=d) for d in range(0, 23 * 366, 5)] +EDGES = [date(1955, 4, 1) + timedelta(days=shift) for shift in range(-3, 4)] + + +@pytest.mark.parametrize("year", YEARS) +def test_single_allowance_matches_statute_for_every_birth_date(year): + """Invariants 1, 3 and 4 for single people born every fifth day from 1938 + and either side of 1 April 1955, the birthday that reaches 66 on the + cutoff.""" + births = sorted(set(BIRTHS) | set(EDGES)) + for male in (True, False): + units = [[(birth, male)] for birth in births] + weekly = check(units, year) + eligible = [weekly[i] for i, u in enumerate(units) if at_pension_age(u, year)] + assert all(a >= b - 1e-6 for a, b in zip(eligible, eligible[1:])) + if year < CUTOFF.year: + assert np.allclose(eligible, rates(year, couple=False)[0]) + + +@pytest.mark.parametrize("year", range(2021, 2041)) +def test_lower_rates_are_below_higher_rates(year): + """Invariant 2, including years uprated past the last published rate.""" + for couple in (False, True): + higher, lower = rates(year, couple) + assert lower < higher + at = f"{year}-06-01" + assert P.lone_parent.aged_from_cutoff(at) == P.single.aged_from_cutoff(at) + + +MEMBER = st.tuples( + st.dates(min_value=date(1930, 1, 1), max_value=date(1962, 12, 31)), + st.booleans(), +) + + +@settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + units=st.lists(st.lists(MEMBER, min_size=1, max_size=2), min_size=1, max_size=25), + year=st.sampled_from(YEARS), +) +def test_any_single_or_couple_follows_the_cohort_rule(units, year): + """Invariant 1 for random single people and couples, including couples + where only one member is over State Pension age.""" + check(units, year) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + units=st.lists(st.lists(MEMBER, min_size=1, max_size=2), min_size=1, max_size=20), + year=st.sampled_from(YEARS), + region=st.sampled_from(["LONDON", "NORTH_WEST", "SCOTLAND", "WALES"]), +) +def test_council_tax_reduction_splits_only_in_england(units, year, region): + """Invariant 6 for random single people and couples.""" + sim = build(units, year, region) + hb = sim.calculate("housing_benefit_applicable_amount", year) / 52 + ctr = sim.calculate("council_tax_reduction_applicable_amount", year) / 52 + for i, members in enumerate(units): + if not at_pension_age(members, year): + continue + if region in ("SCOTLAND", "WALES"): + expected = rates(year, couple=len(members) == 2)[0] + else: + expected = hb[i] + assert abs(ctr[i] - expected) < 0.005, (members, region, ctr[i], expected) + + +# SI 2006/214 Sch 3 para 1(1)(c) and (2)(c), weekly: inserted by SI 2021/188 reg +# 2(3) from 1 April 2021, then set by the Up-rating Orders SI 2022/292, +# 2023/316, 2024/242, 2025/295 and 2026/148 (legislation.gov.uk point-in-time +# texts at 11 April 2022, 10 April 2023, 8 April 2024, 7 April 2025 and the +# current text). +STATUTE_LOWER_RATES = { + 2021: (177.10, 270.30), + 2022: (182.60, 278.70), + 2023: (201.05, 306.85), + 2024: (218.15, 332.95), + 2025: (227.10, 346.60), + 2026: (238.00, 363.25), +} + + +@pytest.mark.parametrize("year", sorted(STATUTE_LOWER_RATES)) +def test_lower_rates_and_cutoff_match_the_statute(year): + """Invariant 5.""" + single, couple = STATUTE_LOWER_RATES[year] + at = f"{year}-06-01" + assert P.single.aged_from_cutoff(at) == pytest.approx(single, abs=1e-6) + assert P.lone_parent.aged_from_cutoff(at) == pytest.approx(single, abs=1e-6) + assert P.couple.aged_from_cutoff(at) == pytest.approx(couple, abs=1e-6) + assert P.pension_age_cutoff(at) == 20210401 + + +# SI 2006/214 Sch 3 para 1(1)(b) and (2)(b), weekly, for those who attained State +# Pension age before 1 April 2021, set by the Up-rating Orders SI 2021/162, +# 2022/292, 2023/316, 2024/242, 2025/295 and 2026/148; the same amounts are in +# DWP's Benefit and pension rates for each year. +STATUTE_HIGHER_RATES = { + 2021: (191.15, 286.05), + 2022: (197.10, 294.90), + 2023: (217.00, 324.70), + 2024: (235.20, 352.00), + 2025: (244.40, 366.00), + 2026: (256.00, 383.35), +} + + +@pytest.mark.parametrize("year", sorted(STATUTE_HIGHER_RATES)) +def test_higher_rates_match_the_statute(year): + """Invariant 5.""" + single, couple = STATUTE_HIGHER_RATES[year] + at = f"{year}-06-01" + assert P.single.aged(at) == pytest.approx(single, abs=1e-6) + assert P.lone_parent.aged(at) == pytest.approx(single, abs=1e-6) + assert P.couple.aged(at) == pytest.approx(couple, abs=1e-6) + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + units=st.lists(st.lists(MEMBER, min_size=1, max_size=2), min_size=1, max_size=15), + others=st.lists(MEMBER, min_size=15, max_size=15), + year=st.sampled_from(YEARS), +) +def test_other_members_do_not_change_the_cohort(units, others, year): + """Invariant 7: the cohort reads only the claimant and partner (HB) or + the applicant and partner (England CTR). Adding a member who is neither, + of any age, never changes either allowance.""" + plain = build(units, year) + people, benunits = {}, {} + mid_year = grid_months(date(year, 10, 6)) + for u, members in enumerate(units): + names = [] + for k, (birth, male) in enumerate(members + [others[u]]): + age_in_months = mid_year - grid_months(birth) + age = int(age_in_months // 12) + name = f"u{u}p{k}" + people[name] = { + "age": {year: age}, + "months_since_last_birthday": {year: age_in_months - 12 * age}, + "is_male": {year: male}, + "is_claimant_or_partner": {year: k < len(members)}, + "is_council_tax_reduction_applicant_or_partner": { + year: k < len(members) + }, + } + names.append(name) + pension_age = at_pension_age(members, year) + benunits[f"u{u}"] = { + "members": names, + "housing_benefit_eligible": {year: True}, + "housing_benefit_pension_age_regulations_apply": {year: pension_age}, + "council_tax_reduction_pensioner": {year: pension_age}, + "benefits_premiums": {year: 0}, + } + with_others = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": {"h": {"members": list(people), "region": {year: "LONDON"}}}, + } + ) + for variable in ( + "housing_benefit_applicable_amount", + "council_tax_reduction_applicable_amount", + ): + np.testing.assert_allclose( + with_others.calculate(variable, year), + plain.calculate(variable, year), + atol=0.005, + ) diff --git a/policyengine_uk/tests/test_input_dataframe_export.py b/policyengine_uk/tests/test_input_dataframe_export.py new file mode 100644 index 0000000000..f983c7dc13 --- /dev/null +++ b/policyengine_uk/tests/test_input_dataframe_export.py @@ -0,0 +1,299 @@ +"""Input export preserves supplied values, including formula overrides. + +All amounts below are synthetic input identities, not statutory rate oracles. +""" + +import numpy as np +import pandas as pd +import pytest +from policyengine_core.data_storage import OnDiskStorage +from policyengine_core.periods import period +from policyengine_core.simulations import Simulation as CoreSimulation + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.input_dataframe import with_identifiers_in_each_year +from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.model_api import Reform +from policyengine_uk.utils.supplied_inputs import set_model_value + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2025 +FORMULA = "state_pension_reported" + + +def built(): + return Simulation( + situation={ + "people": {"person": {"age": {YEAR: 71}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + + +def values(simulation, variable, year=YEAR): + return simulation.to_input_dataframe()[f"{variable}__{year}"].tolist() + + +def test_dataset_formula_input_round_trip_preserves_final_pension(): + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [71], + FORMULA: [9_500.0], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "region": ["LONDON"], + "council_tax": [0], + "tenure_type": ["OWNED_OUTRIGHT"], + "rent": [0], + } + ), + fiscal_year=YEAR, + ) + original = Simulation(dataset=dataset, scenario=no_economic_assumptions) + assert original.calculate("state_pension", YEAR).tolist() == [9_500] + exported = original.to_input_dataframe() + reloaded = Simulation(dataset=exported, scenario=no_economic_assumptions) + assert reloaded.calculate("state_pension", YEAR).tolist() == [9_500] + assert exported[f"{FORMULA}__{YEAR}"].tolist() == [9_500] + assert reloaded.calculate(FORMULA, YEAR).tolist() == [9_500] + + +@pytest.mark.parametrize("variable", ["hours_worked", FORMULA, "employment_income"]) +def test_supplied_structural_formula_and_adds_values_are_exported(variable): + sim = built() + sim.set_input(variable, YEAR, [100]) + assert values(sim, variable) == [100] + + +def test_export_does_not_calculate_values(monkeypatch): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + + def calculate(*args, **kwargs): + pytest.fail("default export must read supplied arrays, not calculate") + + monkeypatch.setattr(sim, "calculate", calculate) + assert values(sim, FORMULA) == [100] + + +def test_derived_only_and_carried_periods_are_not_inputs(): + sim = built() + sim.set_input("hours_worked", YEAR, [100]) + assert sim.calculate("hours_worked", YEAR + 1).tolist() == [100] + sim.calculate("state_pension", YEAR) + exported = sim.to_input_dataframe() + assert "hours_worked__2026" not in exported + assert "state_pension__2025" not in exported + assert exported["hours_worked__2025"].tolist() == [100] + + +@pytest.mark.parametrize("disk_survives", [False, True]) +def test_only_a_surviving_recorded_storage_tier_is_exported(disk_survives, tmp_path): + sim = built() + sim.set_input("hours_worked", YEAR, [100]) + holder = sim.get_holder("hours_worked") + source_period = period(YEAR) + if disk_survives: + holder._disk_storage = OnDiskStorage(str(tmp_path)) + holder._disk_storage.put(np.array([100.0]), source_period, sim.branch_name) + holder._user_input_storage[(sim.branch_name, str(source_period))] = ( + source_period, + frozenset({"disk"}), + ) + holder._memory_storage.put( + np.array([999.0]), source_period, sim.branch_name, derived=True + ) + exported = sim.to_input_dataframe() + if disk_survives: + assert exported["hours_worked__2025"].tolist() == [100] + else: + assert "hours_worked__2025" not in exported + + +@pytest.mark.parametrize("actual_input", [False, True]) +def test_model_values_are_omitted_until_a_real_input_is_set(actual_input): + sim = built() + set_model_value(sim, "adjusted_net_income", YEAR, np.array([123.0])) + if actual_input: + sim.set_input("adjusted_net_income", YEAR, [123]) + assert values(sim, "adjusted_net_income") == [123] + else: + assert "adjusted_net_income__2025" not in sim.to_input_dataframe() + + +def test_deleted_array_with_a_stale_key_is_not_exported(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + holder = sim.get_holder(FORMULA) + holder._memory_storage.delete(period(YEAR)) + assert (FORMULA, sim.branch_name, period(YEAR)) in sim._user_input_keys + assert f"{FORMULA}__{YEAR}" not in sim.to_input_dataframe() + + +@pytest.mark.parametrize("copy_kind", ["clone", "branch"]) +def test_copies_keep_independent_inputs_and_model_markers(copy_kind): + parent = built() + set_model_value(parent, "adjusted_net_income", YEAR, np.array([123.0])) + parent.set_input(FORMULA, YEAR, [100]) + child = parent.clone() if copy_kind == "clone" else parent.get_branch("child") + child.set_input(FORMULA, YEAR, [200]) + child.set_input("adjusted_net_income", YEAR, [123]) + assert values(parent, FORMULA) == [100] + assert values(child, FORMULA) == [200] + assert "adjusted_net_income__2025" not in parent.to_input_dataframe() + assert values(child, "adjusted_net_income") == [123] + + +def test_nearest_surviving_branch_input_precedes_ancestors(): + parent = built() + parent.set_input(FORMULA, YEAR, [100]) + child = parent.get_branch("child") + child.set_input(FORMULA, YEAR, [200]) + nested = child.get_branch("nested") + assert values(nested, FORMULA) == [200] + nested.set_input(FORMULA, YEAR, [300]) + assert values(nested, FORMULA) == [300] + assert values(parent, FORMULA) == [100] + + +def test_entity_projection_row_order_dates_and_round_trip(): + supplied = pd.DataFrame( + { + "person_id__2025": [30, 10, 20], + "person_benunit_id__2025": [2, 1, 2], + "person_household_id__2025": [2, 1, 2], + "age__2025": [40, 41, 42], + "benunit_reported_capital__2025": [200, 100, 200], + "council_tax__2025": [2_000, 1_000, 2_000], + "state_pension_reported__2025": [300, 100, 200], + } + ) + sim = Simulation(dataset=supplied) + sim.set_input(FORMULA, 2027, [600, 400, 500]) + exported = sim.to_input_dataframe() + for variable in ("benunit_reported_capital", "council_tax", FORMULA): + assert ( + exported[f"{variable}__2025"].tolist() + == supplied[f"{variable}__2025"].tolist() + ) + assert exported[f"{FORMULA}__2027"].tolist() == [600, 400, 500] + assert exported["person_id__2027"].tolist() == [30, 10, 20] + reloaded = Simulation(dataset=exported) + pd.testing.assert_frame_equal(reloaded.to_input_dataframe().copy(), exported.copy()) + + +def test_only_periods_matching_the_variable_definition_are_exported(): + sim = built() + holder = sim.get_holder(FORMULA) + monthly = period("2025-02") + holder._memory_storage.put(np.array([100.0]), monthly, sim.branch_name) + sim._user_input_keys.add((FORMULA, sim.branch_name, monthly)) + assert f"{FORMULA}__2025-02" not in sim.to_input_dataframe() + + +def test_annual_periods_keep_their_dates_and_sorted_order(): + sim = built() + for source_period, amount in [(2027, 300), (2025, 100), (2026, 200)]: + sim.set_input(FORMULA, source_period, [amount]) + exported = sim.to_input_dataframe() + columns = [name for name in exported if name.startswith(f"{FORMULA}__")] + assert columns == [f"{FORMULA}__{year}" for year in (2025, 2026, 2027)] + assert exported[columns].iloc[0].tolist() == [100, 200, 300] + + +def test_an_array_without_an_input_record_is_not_exported(): + sim = built() + sim.get_holder(FORMULA)._memory_storage.put(np.array([999.0]), period(YEAR)) + assert f"{FORMULA}__{YEAR}" not in sim.to_input_dataframe() + + +def test_register_only_input_uses_its_storage_marks(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + holder = sim.get_holder(FORMULA) + holder._user_input_storage.pop((sim.branch_name, str(period(YEAR)))) + assert values(sim, FORMULA) == [100] + + +def test_a_replaced_child_input_falls_back_to_the_surviving_parent(): + parent = built() + parent.set_input(FORMULA, YEAR, [100]) + child = parent.get_branch("child") + child.set_input(FORMULA, YEAR, [200]) + child.get_holder(FORMULA)._memory_storage.put( + np.array([999.0]), period(YEAR), child.branch_name, derived=True + ) + assert values(child, FORMULA) == [100] + assert values(parent, FORMULA) == [100] + + +def test_deletion_then_real_input_clears_a_model_marker(): + sim = built() + set_model_value(sim, "adjusted_net_income", YEAR, np.array([123.0])) + sim.delete_arrays("adjusted_net_income", YEAR) + sim.set_input("adjusted_net_income", YEAR, [123]) + assert values(sim, "adjusted_net_income") == [123] + + +def test_a_rebuild_does_not_export_a_dropped_input_or_its_derived_replacement(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + sim.build_from_situation( + { + "people": {"other": {"age": {YEAR: 40}}}, + "benunits": {"other": {"members": ["other"]}}, + "households": {"other": {"members": ["other"]}}, + } + ) + sim.get_holder(FORMULA)._memory_storage.put( + np.array([999.0]), period(YEAR), sim.branch_name, derived=True + ) + assert f"{FORMULA}__{YEAR}" not in sim.to_input_dataframe() + + +def test_a_reform_input_survives_a_calculation_reset(): + class EnterPension(Reform): + def apply(self): + self.simulation.set_input(FORMULA, YEAR, [100]) + + sim = built() + sim.apply_reform(EnterPension) + sim.reset_calculations() + assert values(sim, FORMULA) == [100] + + +def test_export_does_not_change_provenance_or_stored_values(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + holder = sim.get_holder(FORMULA) + keys = set(sim._user_input_keys) + records = dict(holder._user_input_storage) + stored = holder._memory_storage.get(period(YEAR)).copy() + first = sim.to_input_dataframe() + pd.testing.assert_frame_equal(sim.to_input_dataframe().copy(), first.copy()) + assert sim._user_input_keys == keys + assert holder._user_input_storage == records + np.testing.assert_array_equal(holder._memory_storage.get(period(YEAR)), stored) + + +def test_computed_export_keeps_core_delegation(): + sim = built() + sim.set_input(FORMULA, YEAR, [100]) + sim.calculate("state_pension", YEAR) + expected = with_identifiers_in_each_year( + CoreSimulation.to_input_dataframe(sim, include_computed_variables=True), + sim.tax_benefit_system, + ) + pd.testing.assert_frame_equal( + sim.to_input_dataframe(include_computed_variables=True).copy(), expected.copy() + ) diff --git a/policyengine_uk/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 62efc0e89e..7850e3a79a 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -36,8 +36,12 @@ member reports never changes any of these readers for the claimant's family or for its existing members; - adding such a member changes a reader only by ending the family's severe - disability premium, which a non-dependant aged 18 or over does (IS Regs - 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para 14(2)(a)(ii)). That + disability premium, or, for the two Council Tax Reduction readers, by + becoming the household head (these families flag none, so the eldest + member is the head) who is liable and applies alone, so that the applicant + changes (CTR_APPLICANT_READERS). A non-dependant aged 18 or over ends the + premium (IS Regs 1987 Sch 2 para 13(2)(a)(ii); HB Regs 2006 Sch 3 para + 14(2)(a)(ii)). That rule is subject to para 13(3), which ignores a non-dependant on a qualifying disability benefit, a blind one and some carers; the added members here are none of those. The family's Income Support then falls by @@ -57,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 @@ -244,6 +247,40 @@ def other_members(draw): } +# Council Tax Reduction reads the applicant and partner, and the applicant is +# the person liable for the council tax, whom the model takes to be the +# household head (is_resolved_household_head; PolicyEngine/policyengine-uk#2009 +# and #2078). These families flag no head, so the head is the eldest member: an +# added member older than the claimant and partner becomes the liable head, +# who then applies alone (council_tax_reduction_head_applies_alone). Their +# presence then changes the applicant, which is intended, so those families +# are left out of the comparison with the family on its own for these two +# readers. What the added member reports still never changes them: the first +# property compares a silent added member with the member as drawn for every +# family. +CTR_APPLICANT_READERS = [ + "council_tax_reduction_applicable_income", + "council_tax_reduction_relevant_income_based_benefit", +] + + +def became_ctr_applicant(sim, k, starts, sizes): + """For each of the first k units, whether the member added in unit k + i + made it a family whose household head applies alone, and checks that this + happens only where that member is the resolved household head.""" + head_alone = sim.calculate("council_tax_reduction_head_applies_alone", YEAR) + head = sim.calculate("is_resolved_household_head", YEAR) + changed = [] + for i in range(k): + became = bool(head_alone[k + i]) and not bool(head_alone[i]) + if became: + added = starts[k + i] + sizes[k + i] - 1 + assert head[added], i + event("added member became the Council Tax Reduction applicant") + changed.append(became) + return changed + + def situation(units, year=YEAR, benunit_inputs=None): people, benunits, households = {}, {}, {} for i, (adults, non_couple, household, other) in enumerate(units): @@ -387,6 +424,7 @@ def test_other_members_awards_never_change_the_readers(drawn): fall = income_support[i] - income_support[k + i] lost = premium[i] - premium[k + i] assert -0.01 <= fall <= lost + 0.01, drawn[i] + new_applicant = became_ctr_applicant(sim, k, starts, sizes) for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): @@ -395,6 +433,8 @@ def test_other_members_awards_never_change_the_readers(drawn): variable, drawn[i], ) + if variable in CTR_APPLICANT_READERS and new_applicant[i]: + continue # Their presence changes one only by ending the premium, and # never the awards the family's own members report. if not premium_ended[i] or variable in PREMIUM_FREE_READERS: @@ -589,9 +629,12 @@ def _family_readers_never_change(drawn, enter_is_mixed_age_couple=False): k = len(drawn) for i in range(k): assert not flags[starts[k + i] + sizes[k + i] - 1], drawn[i] + new_applicant = became_ctr_applicant(sim, k, starts, sizes) for variable in FAMILY_READERS: values = sim.calculate(variable, YEAR) for i in range(k): + if variable in CTR_APPLICANT_READERS and new_applicant[i]: + continue assert np.isclose(values[i], values[k + i], atol=0.01), ( variable, drawn[i], 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_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_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py new file mode 100644 index 0000000000..27b066f7db --- /dev/null +++ b/policyengine_uk/tests/test_pension_contribution_deduction_rate_properties.py @@ -0,0 +1,273 @@ +"""Property-based tests for the legacy means tests' pension contribution shares. + +Each scheme's regulations state their own share of pension contributions to +deduct from earnings, one-half in every scheme modelled, and none refers to +another scheme's: + +- Housing Benefit: SI 2006/213 regs 36(3)(b)-(c) and 38; SI 2006/214 regs + 36(2)(b)-(c) and 39 (gov.dwp.housing_benefit.means_test); +- Income Support: SI 1987/1967 regs 36(3)(b) and 38 (gov.dwp.income_support); +- Council Tax Reduction, each scheme under gov.local_authorities: + the England pensioner scheme (SI 2012/2885 Sch 1 paras 19 and 29), Wales + (WSI 2013/3029 Sch 1 paras 13 and 23, Sch 6 paras 15 and 24), Scotland (SSI + 2012/319 regs 33 and 36, SSI 2021/249 regs 50 and 51(9)), the working-age + schemes of Merton, Kingston upon Thames, Newham, Westminster and Oxford, and + the England default scheme (SI 2012/2886 Sch paras 42, 52 and 61) for every + other family. + +Invariants, for any generated population of families whose floors are slack +(earnings of at least 15,000 per adult and contributions of at most a fifth of +them, so no net-earnings, disregard or applicable-income floor binds, and no +Pension Credit or Universal Credit claim): + +1. Exactness: when each scheme's share moves from 0.5 to r, the scheme's + applicable income falls by exactly the family's contributions x (r - 0.5), + and so do the scheme's net earnings. +2. Locality: every share is drawn distinct, so a family that read another + scheme's share (or Housing Benefit's) would miss invariant 1. Pensioners in + Merton follow the England pensioner scheme, not Merton's, and families in + Northern Ireland, which has no Council Tax Reduction, read the England + default scheme's share. An explicit example puts a contributing family on + every route, so each is tested whatever else Hypothesis draws. +3. The pensioner earnings disregards do not move when the disregard caps are + slack. +4. Differential: at current law Council Tax Reduction net earnings equal + Housing Benefit net earnings, as both deduct half. +5. Current law: every share is 0.5 in every financial year from 2015 (the + first year the model simulates) to 2030, Income Support's and Housing + Benefit's too. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +CTR = "gov.local_authorities.{}.council_tax_reduction{}.means_test.pension_contribution_deduction_rate" +PARAMETERS = { + "england_pensioner": CTR.format("england", ".pensioners"), + "england_default": CTR.format("england", ".default_scheme"), + "scotland": CTR.format("scotland", ""), + "wales": CTR.format("wales", ""), + "merton": CTR.format("merton", ""), + "kingston_upon_thames": CTR.format("kingston_upon_thames", ""), + "newham": CTR.format("newham", ""), + "westminster": CTR.format("westminster", ""), + "oxford": CTR.format("oxford", ""), + "housing_benefit": ( + "gov.dwp.housing_benefit.means_test.pension_contribution_deduction_rate" + ), + "income_support": ( + "gov.dwp.income_support.means_test.pension_contribution_deduction_rate" + ), +} +# Each route: (country, local authority, pension age, the share it reads). +ROUTES = { + "england_pensioner": ("ENGLAND", "MAIDSTONE", True, "england_pensioner"), + "merton_pensioner": ("ENGLAND", "MERTON", True, "england_pensioner"), + "england_default": ("ENGLAND", "MAIDSTONE", False, "england_default"), + "scotland_pension_age": ("SCOTLAND", "GLASGOW_CITY", True, "scotland"), + "scotland_working_age": ("SCOTLAND", "GLASGOW_CITY", False, "scotland"), + "wales_pension_age": ("WALES", "CARDIFF", True, "wales"), + "wales_working_age": ("WALES", "CARDIFF", False, "wales"), + "merton": ("ENGLAND", "MERTON", False, "merton"), + "kingston_upon_thames": ( + "ENGLAND", + "KINGSTON_UPON_THAMES", + False, + "kingston_upon_thames", + ), + "newham": ("ENGLAND", "NEWHAM", False, "newham"), + "westminster": ("ENGLAND", "WESTMINSTER", False, "westminster"), + "oxford": ("ENGLAND", "OXFORD", False, "oxford"), + "northern_ireland": ("NORTHERN_IRELAND", "BELFAST", False, "england_default"), +} +# State Pension age is 66 to 67 over the years tested, so 68 and over is +# pension age and 55 and under working age. +WORKING_AGE = (25, 55) +PENSION_AGE = (68, 90) +# Shares other than 0.5, on a grid of twentieths so that every pair differs +# by at least 0.05. The model holds the share as float32 (0.05 reads back as +# 0.0500000007), so shares are compared within RATE_TOLERANCE. +SHARES = [i / 20 for i in range(21) if i != 10] +TOLERANCE = 0.05 +RATE_TOLERANCE = 1e-6 + + +@st.composite +def families(draw): + route = draw(st.sampled_from(sorted(ROUTES))) + pension_age = ROUTES[route][2] + low, high = PENSION_AGE if pension_age else WORKING_AGE + head_age = draw(st.integers(low, high)) + adults = [] + for _ in range(draw(st.integers(1, 2))): + earnings = draw(st.floats(15_000, 60_000, allow_nan=False)) + share = draw(st.one_of(st.just(0.0), st.floats(0.01, 0.2))) + # Partners within five years of each other, so neither is presumed + # the other's child. + age = draw(st.integers(max(low, head_age - 5), min(high, head_age + 5))) + adults.append( + dict( + age=age, + employment_income=earnings, + personal_pension_contributions=round(earnings * share, 2), + ) + ) + return dict(route=route, adults=adults) + + +@st.composite +def reforms(draw): + shares = draw( + st.lists( + st.sampled_from(SHARES), + min_size=len(PARAMETERS), + max_size=len(PARAMETERS), + unique=True, + ) + ) + return dict(zip(PARAMETERS, shares)) + + +def situation(units, year): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + country, local_authority, _, _ = ROUTES[unit["route"]] + names = [] + for j, adult in enumerate(unit["adults"]): + name = f"a{i}_{j}" + people[name] = {key: {year: value} for key, value in adult.items()} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: False}, + "would_claim_pc": {year: False}, + "income_support": {year: 0.0}, + "jsa_income": {year: 0.0}, + "esa_income": {year: 0.0}, + } + households[f"h{i}"] = { + "members": names, + "country": {year: country}, + "local_authority": {year: local_authority}, + "savings": {year: 0.0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +VARIABLES = [ + "council_tax_reduction_pension_contribution_deduction_rate", + "council_tax_reduction_applicable_income", + "council_tax_reduction_net_earnings", + "council_tax_reduction_pensioner_earnings_disregard", + "council_tax_reduction_pensioner", + "income_support_applicable_income", + "housing_benefit_net_earnings", +] + + +def calculate(units, year, reform=None): + sim = Simulation(situation=situation(units, year), reform=reform) + return {v: np.asarray(sim.calculate(v, year), dtype=float) for v in VARIABLES} + + +def contributions(unit): + return sum(adult["personal_pension_contributions"] for adult in unit["adults"]) + + +def every_route(): + """One contributing family on each route, a couple on one of them, so that + every route is tested whatever Hypothesis draws.""" + units = [] + for route in sorted(ROUTES): + age = 70 if ROUTES[route][2] else 40 + adult = dict( + age=age, employment_income=20_000.0, personal_pension_contributions=2_000.0 + ) + units.append(dict(route=route, adults=[adult])) + # A partner two years older, in the same age band as the head. + partner_age = units[0]["adults"][0]["age"] + 2 + units[0]["adults"].append( + dict( + age=partner_age, + employment_income=30_000.0, + personal_pension_contributions=1_500.0, + ) + ) + return units + + +@PROPERTY_SETTINGS +@given( + st.lists(families(), min_size=1, max_size=26), + reforms(), + st.sampled_from([2026, 2028, 2030]), +) +@example( + units=every_route(), + shares=dict(zip(PARAMETERS, SHARES)), + year=2026, +) +def test_each_scheme_moves_by_contributions_times_its_own_change_in_share( + units, shares, year +): + base = calculate(units, year) + reform = {PARAMETERS[key]: {str(year): share} for key, share in shares.items()} + moved = calculate(units, year, reform) + for i, unit in enumerate(units): + country, _, pension_age, scheme = ROUTES[unit["route"]] + c = contributions(unit) + case = (unit, shares, year) + # The route is the one intended: pension-age families in Great + # Britain are the schemes' pensioners. + in_great_britain = country != "NORTHERN_IRELAND" + pensioner = base["council_tax_reduction_pensioner"][i] + assert bool(pensioner) == (pension_age and in_great_britain), case + # Floors are slack: income stays positive on both sides. + for values in (base, moved): + assert values["council_tax_reduction_applicable_income"][i] > 0, case + assert values["income_support_applicable_income"][i] > 0, case + rate = "council_tax_reduction_pension_contribution_deduction_rate" + assert base[rate][i] == 0.5, case + assert abs(moved[rate][i] - shares[scheme]) < RATE_TOLERANCE, case + # Invariant 1 (with 2): exactly contributions x the scheme's change. + for variable, share in ( + ("council_tax_reduction_applicable_income", shares[scheme]), + ("council_tax_reduction_net_earnings", shares[scheme]), + ("income_support_applicable_income", shares["income_support"]), + ("housing_benefit_net_earnings", shares["housing_benefit"]), + ): + change = base[variable][i] - moved[variable][i] + assert abs(change - c * (share - 0.5)) < TOLERANCE, (variable, case) + # Invariant 3: slack caps leave the disregards where they were. + disregard = "council_tax_reduction_pensioner_earnings_disregard" + assert abs(base[disregard][i] - moved[disregard][i]) < TOLERANCE, case + # Invariant 4: equal shares, equal net earnings. + assert ( + abs( + base["council_tax_reduction_net_earnings"][i] + - base["housing_benefit_net_earnings"][i] + ) + < TOLERANCE + ), case + + +def test_every_share_is_one_half_in_every_year(): + for year in range(2015, 2031): + # Read in each financial year (30 April, the model's convention). + parameters = system.parameters(f"{year}-04-30") + for key, path in PARAMETERS.items(): + node = parameters + for name in path.split("."): + node = getattr(node, name) + assert node == 0.5, (key, year) diff --git a/policyengine_uk/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 new file mode 100644 index 0000000000..cbdfaa5d61 --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_prescribed_benefits_properties.py @@ -0,0 +1,365 @@ +"""Prescribed benefits in Pension Credit and Housing Benefit income. + +The State Pension Credit Regulations 2002 reg 15(1) prescribe "all social +security benefits" as income except a closed list. The list does not name +industrial injuries disablement benefit (IIDB), severe disablement allowance +(SDA), incapacity benefit (IB) or maternity allowance (MA). For IIDB it +excepts the constant attendance and exceptionally severe disablement +increases (reg 15(1)(c)), which the reported IIDB amount cannot separate; +(d) and (e) except payments under the Sch 8 schemes for old industrial +injury cases. +Pension-age Housing Benefit counts the same benefits under HB (SPC) Regs 2006 +reg 29(1)(j), whose exceptions for these benefits match reg 15(1)'s. Reg 9 +makes working tax credit, IB, SDA, MA, contribution-based JSA and contributory +ESA non-qualifying income for Savings Credit; IIDB stays qualifying income. + +Draws: single people and couples over State Pension age in England, Wales or +Scotland, renting from the council in 2026, with State Pension, a private +pension and any of the four benefits (each on a drawn member, at a drawn +amount). Some families also have a carer receiving Carer's Allowance or, in +Scotland, Carer Support Payment, so the sources #1952 added are covered +together with these. There are no earnings, pension contributions or +children, and capital is at most £10,000, so neither Pension Credit deemed +income nor Housing Benefit tariff income arises. The benefit amounts are +inputs standing for actual awards, independent of the model's rate formulas. + +Invariants: + +1. Differential: Pension Credit and pension-age Housing Benefit assess the + same income. Where neither Guarantee Credit nor Savings Credit is due and + Housing Benefit income is above its zero floor, Housing Benefit applicable + income equals Pension Credit income. (With a savings-credit-only award, + Housing Benefit takes the Secretary of State's assessment plus the savings + credit, HB (SPC) Regs 2006 reg 27, by design.) There are no earnings, so + the Housing Benefit earnings disregard, which is capped at net earnings, is + nil. There is no other intended difference on this domain: both measures deduct the claimant's and + partner's income tax and National Insurance, and neither counts the + Scottish Carer Supplement (SPC Regs reg 15(1)(ri); HB (SPC) Regs reg + 29(1)(j)(xviiha)). +2. Guarantee Credit is non-increasing in each of the four benefits, and while + it is positive it falls pound for pound with the extra Pension Credit + income, which is the extra benefit less the extra income tax and National + Insurance (the model taxes IB), less any Carer's Allowance or Carer Support + Payment the benefit displaces: SDA, IB and MA overlap with them (Social + Security (Overlapping Benefits) Regulations 1979; carers_allowance_overlapping_benefits). +3. Savings Credit income never exceeds Pension Credit income. Adding an amount + of a reg 9 source (working tax credit, IB, SDA, MA, contribution-based JSA + or contributory ESA) changes Savings Credit income only through income tax + and National Insurance and through any Carer's Allowance or Carer Support + Payment it displaces (as in invariant 2), and never raises Savings Credit. + Draws for this invariant are aged 77 or over, old enough to have reached + State Pension age, on the model's timetable by date of birth, before the + April 2016 Savings Credit cutoff. +4. Boundary: on the private-pension step where Guarantee Credit ends, + household net income, excluding the TV licence fee, does not fall. The free + TV licence for over-75s on Pension Credit is a genuine statutory cliff, so + 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 +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +PENSIONS = np.arange(0, 25_001, 500) +AMOUNTS = np.arange(0, 15_001, 1_250) +PRESCRIBED_BENEFITS = ("iidb", "sda", "incapacity_benefit", "maternity_allowance") +# Reg 9 sources and the input each is set through: the tax base reads +# reported contribution-based JSA and contributory ESA. +REG_9_INPUTS = { + "working_tax_credit": "working_tax_credit", + "incapacity_benefit": "incapacity_benefit", + "sda": "sda", + "maternity_allowance": "maternity_allowance", + "jsa_contrib": "jsa_contrib_reported", + "esa_contrib": "esa_contrib_reported", +} +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +REGIONS = { + "ENGLAND": "NORTH_WEST", + "WALES": "WALES", + "SCOTLAND": "SCOTLAND", +} + + +def money(high, low=0): + return st.floats(low, high, allow_nan=False, allow_infinity=False) + + +@st.composite +def family(draw, min_age=67, high=12_000, state_pension=15_000): + adults = [ + dict( + age=draw(st.integers(min_age, 95)), + state_pension=draw(money(state_pension)), + ) + for _ in range(draw(st.integers(1, 2))) + ] + members = st.integers(0, len(adults) - 1) + return dict( + adults=adults, + # Each benefit goes to one drawn member; an amount of zero is common. + benefits={ + benefit: (draw(members), draw(st.one_of(st.just(0.0), money(high)))) + for benefit in PRESCRIBED_BENEFITS + }, + # As in #1952, at most one carer, by caring hours or a reported award. + carer=draw(st.one_of(st.none(), members)), + by_hours=draw(st.booleans()), + country=draw(st.sampled_from(tuple(REGIONS))), + rent=draw(money(12_000)), + council_tax=draw(money(3_000)), + savings=draw(st.one_of(st.just(0.0), money(10_000))), + ) + + +def situation(rows): + """One benefit unit and household per (family, private pension, inputs) row. + + The inputs map a variable to an amount added to the family's first adult, + or set for the benefit unit when the variable is working tax credit. + """ + people, benunits, households = {}, {}, {} + for r, (fam, pension, inputs) in enumerate(rows): + names = [] + for j, a in enumerate(fam["adults"]): + name = f"p{r}_{j}" + person = { + "age": {YEAR: a["age"]}, + "state_pension": {YEAR: a["state_pension"]}, + # All private pension goes to the first adult. + "private_pension_income": {YEAR: float(pension) * (j == 0)}, + } + for benefit, (recipient, amount) in fam["benefits"].items(): + person[benefit] = {YEAR: amount * (j == recipient)} + if j == fam["carer"]: + if fam["by_hours"]: + person["care_hours"] = {YEAR: 35} + else: + person["carers_allowance_reported"] = {YEAR: 1} + if j == 0: + for variable, amount in inputs.items(): + if variable != "working_tax_credit": + drawn = person.get(variable, {YEAR: 0.0})[YEAR] + person[variable] = {YEAR: drawn + float(amount)} + people[name] = person + names.append(name) + benunit = {"members": names} + if "working_tax_credit" in inputs: + benunit["working_tax_credit"] = {YEAR: float(inputs["working_tax_credit"])} + benunits[f"b{r}"] = benunit + households[f"h{r}"] = { + "members": names, + "country": {YEAR: fam["country"]}, + "region": {YEAR: REGIONS[fam["country"]]}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: fam["rent"]}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(rows, shape, variables): + """Each variable per benefit unit (summed over members), reshaped.""" + simulation = Simulation(situation=situation(rows)) + return { + variable: np.asarray( + simulation.calculate(variable, YEAR, map_to="benunit") + ).reshape(shape) + for variable in variables + } + + +def pension_grid(families, variables): + rows = [(fam, pension, {}) for fam in families for pension in PENSIONS] + return calculate(rows, (len(families), len(PENSIONS)), variables) + + +def with_amount(fam, benefit, amount): + """The family with one benefit's award set to amount, on its recipient.""" + recipient, _ = fam["benefits"][benefit] + return {**fam, "benefits": {**fam["benefits"], benefit: (recipient, amount)}} + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_pension_credit_and_housing_benefit_assess_the_same_prescribed_benefits( + families, +): + g = pension_grid( + families, + [ + "guarantee_credit", + "savings_credit", + "pension_credit_income", + "housing_benefit_eligible", + "housing_benefit_applicable_income", + ], + ) + hb_income = g["housing_benefit_applicable_income"] + # Where neither credit is due, and away from the zero floor on Housing + # Benefit income. + compared = ( + (g["guarantee_credit"] <= 0) + & (g["savings_credit"] <= 0) + & g["housing_benefit_eligible"].astype(bool) + & (hb_income > 0) + ) + pc_income = g["pension_credit_income"] + assert np.allclose(hb_income[compared], pc_income[compared], atol=0.01), ( + families, + hb_income[compared], + pc_income[compared], + ) + + +@PROPERTY_SETTINGS +@given(st.lists(family(state_pension=8_000), min_size=1, max_size=4)) +def test_guarantee_credit_falls_pound_for_pound_with_each_prescribed_benefit( + families, +): + rows = [ + (with_amount(fam, benefit, amount), 0, {}) + for fam in families + for benefit in PRESCRIBED_BENEFITS + for amount in AMOUNTS + ] + shape = (len(families), len(PRESCRIBED_BENEFITS), len(AMOUNTS)) + g = calculate( + rows, + shape, + [ + "guarantee_credit", + "pension_credit_income", + "income_tax", + "national_insurance", + "carers_allowance", + "carer_support_payment", + ], + ) + gc = g["guarantee_credit"] + pc_income = g["pension_credit_income"] + tax = g["income_tax"] + g["national_insurance"] + carer_benefit = g["carers_allowance"] + g["carer_support_payment"] + assert np.all(np.diff(gc, axis=-1) <= 0.01), (families, gc) + # The extra Pension Credit income is the extra benefit less the extra tax + # and less any carer's benefit the benefit displaces. + extra_income = np.diff(pc_income, axis=-1) + positive_income = (pc_income[..., :-1] > 0) & (pc_income[..., 1:] > 0) + expected = ( + np.diff(AMOUNTS) - np.diff(tax, axis=-1) + np.diff(carer_benefit, axis=-1) + ) + assert np.allclose( + extra_income[positive_income], expected[positive_income], atol=0.01 + ), (families, pc_income, tax, carer_benefit) + # While Guarantee Credit is paid, it falls by exactly that much. + paid = gc[..., 1:] > 0 + assert np.allclose(-np.diff(gc, axis=-1)[paid], extra_income[paid], atol=0.01), ( + families, + gc, + pc_income, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + family(min_age=77, high=3_000, state_pension=4_000), min_size=2, max_size=4 + ), + # Private pensions that put qualifying income near the Savings Credit + # window, so that Savings Credit is often payable before the addition. + st.lists(money(18_000, low=8_000), min_size=4, max_size=4), + money(15_000), +) +def test_reg_9_sources_change_savings_credit_income_only_through_tax( + families, pensions, amount +): + rows = [ + (fam, pension, {REG_9_INPUTS[source]: added}) + for fam, pension in zip(families, pensions) + for source in REG_9_INPUTS + for added in (0, amount) + ] + shape = (len(families), len(REG_9_INPUTS), 2) + g = calculate( + rows, + shape, + [ + "pension_credit_income", + "savings_credit_income", + "savings_credit", + "income_tax", + "national_insurance", + "carers_allowance", + "carer_support_payment", + ], + ) + sc_income = g["savings_credit_income"] + assert np.all(sc_income <= g["pension_credit_income"] + 0.01), (families, g) + savings_credit = g["savings_credit"] + assert np.all(savings_credit[..., 1] <= savings_credit[..., 0] + 0.01), ( + families, + amount, + savings_credit, + ) + tax = g["income_tax"] + g["national_insurance"] + extra_tax = tax[..., 1] - tax[..., 0] + carer_benefit = g["carers_allowance"] + g["carer_support_payment"] + carer_change = carer_benefit[..., 1] - carer_benefit[..., 0] + assert np.allclose( + sc_income[..., 1], + np.maximum(sc_income[..., 0] - extra_tax + carer_change, 0), + atol=0.01, + ), (families, amount, sc_income, tax, carer_benefit) + + +@PROPERTY_SETTINGS +@given(st.lists(family(high=4_000, state_pension=4_000), min_size=1, max_size=4)) +def test_net_income_does_not_fall_where_guarantee_credit_ends(families): + g = pension_grid( + families, + [ + "guarantee_credit", + "household_net_income", + "tv_licence", + "housing_benefit", + "council_tax_benefit", + "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]}", + f"net income change {step[i, k]:.2f}", + f"HB {g['housing_benefit'][i, k]:.2f} -> " + f"{g['housing_benefit'][i, k + 1]:.2f}", + f"CTR {g['council_tax_benefit'][i, k]:.2f} -> " + f"{g['council_tax_benefit'][i, k + 1]:.2f}", + ) diff --git a/policyengine_uk/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_pension_credit_war_pension_properties.py b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py new file mode 100644 index 0000000000..122cfcb68e --- /dev/null +++ b/policyengine_uk/tests/test_pension_credit_war_pension_properties.py @@ -0,0 +1,316 @@ +"""War pensions and AFCS payments in Pension Credit income. + +State Pension Credit Act 2002 s.15(1)(g) counts "a war disablement pension or +war widow's or widower's pension", and the State Pension Credit Regulations +2002 reg 15(5)(aa) count "a guaranteed income payment" of the Armed Forces +Compensation Scheme. Reg 17(7) applies Sch IV, whose para 1 disregards "£10 of +any of the following". `pension_credit_war_pension_income` counts the +claimant's and partner's `afcs` and `war_widows_pension`, each payment less +£10 a week, and Pension Credit income includes it. + +Invariants, for single people and couples over State Pension age renting from +the council in 2026, with any mix of AFCS payments and war widow's pensions, +and with a carer receiving Carer's Allowance or Carer Support Payment in some +families (the sources #1952 added): + +1. Differential with Housing Benefit. Where neither Guarantee Credit nor + Savings Credit is due (with a savings-credit-only award Housing Benefit + uses the Pension Credit assessment plus the savings credit, HB (SPC) Regs + 2006 reg 27) and neither income floor binds, Housing Benefit applicable income plus its + income disregard equals Pension Credit income less the counted war pension + income. The difference is intended: the Housing Benefit (Persons who have + attained the qualifying age for state pension credit) Regulations 2006 reg + 29(1) count these payments and Sch 5 para 1 disregards £10 of them, but the + Social Security Administration Act 1992 s.134(8) lets an authority modify + its scheme to disregard "the whole or part" of a prescribed war + disablement or war widow's pension, and `housing_benefit_applicable_income` + counts neither `afcs` nor `war_widows_pension` (on the savings-credit-only + route it removes the war pension income the Pension Credit assessment + counts; see invariant 5). That is an assumption that + the authority runs such a local scheme with a full disregard; it is not a + statement about any particular authority. The draws have no earnings, no + pension contributions and capital of at most £10,000, so neither tariff + income nor the earnings rules apply. +2. Bounds and closed form. The counted amount is between 0 and the gross + payments of the claimant and partner, and equals the sum over the + claimant's and partner's payments of max(0, payment - £520 a year). +3. Monotone. Guarantee Credit does not rise as one person's AFCS payment + rises, and does not change while that payment is at or below £10 a week, + whatever war widow's pension the person also receives. +4. Metamorphic. Pension Credit income and Guarantee Credit depend on the + claimant's and partner's payments only as a collection: reassigning the + amounts between the two sources and between the claimant and partner + changes neither, because each payment has its own £10 a week. +5. Housing Benefit and Council Tax Reduction disregard war pensions in full on + every route. Comparing each family with and without its payments, where + neither run pays Guarantee Credit, both runs take the same route + (savings-credit-only or general) and neither income floor binds, Housing + Benefit applicable income and Council Tax Reduction applicable income + change by exactly the change in Pension Credit paid: on the + savings-credit-only route they add the savings credit payable (HB (SPC) + Regs 2006 reg 27), which the payments reduce, and nothing else. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS = 52 +# Sch IV para 1: £10 a week of each payment. +DISREGARD = 10 * WEEKS +PENSIONS = np.arange(0, 20_001, 1_000) +# Weekly AFCS payments for invariant 3, through the disregard and beyond. +AFCS_GRID = np.arange(0, 60.01, 2.5) +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +REGIONS = { + "ENGLAND": "NORTH_WEST", + "WALES": "WALES", + "SCOTLAND": "SCOTLAND", +} + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +def weekly_payment(): + """Annual amount of a weekly payment, often at or near £10 a week.""" + weekly = st.one_of( + st.just(0.0), + st.sampled_from([5.0, 9.99, 10.0, 10.01, 25.0, 80.0]), + money(150), + ) + return weekly.map(lambda w: w * WEEKS) + + +@st.composite +def adult(draw, min_age=67): + return dict( + age=draw(st.integers(min_age, 95)), + state_pension=draw(money(15_000)), + afcs=draw(weekly_payment()), + war_widows_pension=draw(weekly_payment()), + ) + + +@st.composite +def family(draw, min_age=67): + adults = [draw(adult(min_age)) for _ in range(draw(st.integers(1, 2)))] + return dict( + adults=adults, + # A carer in some families, by caring hours or a reported award. + carer=draw(st.one_of(st.none(), st.integers(0, len(adults) - 1))), + by_hours=draw(st.booleans()), + country=draw(st.sampled_from(tuple(REGIONS))), + rent=draw(money(12_000)), + council_tax=draw(money(3_000)), + savings=draw(st.one_of(st.just(0.0), money(10_000))), + private_pension=draw(money(15_000)), + # Invariant 4 reassigns the payments in this order. + order=draw(st.permutations(range(2 * len(adults)))), + ) + + +def person_inputs(fam, j, a, private_pension, afcs, war_widows_pension): + person = { + "age": {YEAR: a["age"]}, + "state_pension": {YEAR: a["state_pension"]}, + # All private pension goes to the first adult. + "private_pension_income": {YEAR: float(private_pension) * (j == 0)}, + "afcs": {YEAR: float(afcs)}, + "war_widows_pension": {YEAR: float(war_widows_pension)}, + } + if fam["carer"] == j: + if fam["by_hours"]: + person["care_hours"] = {YEAR: 35} + else: + person["carers_allowance_reported"] = {YEAR: 1} + return person + + +def household(fam, names): + return { + "members": names, + "country": {YEAR: fam["country"]}, + "region": {YEAR: REGIONS[fam["country"]]}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: fam["rent"]}, + "council_tax": {YEAR: fam["council_tax"]}, + "savings": {YEAR: fam["savings"]}, + } + + +def simulate(rows): + """One Simulation with one benefit unit per row. + + Each row is (family, private pension, [(afcs, war widow's pension) per + adult]). Returns a function giving a benefit-unit array per variable. + """ + people, benunits, households = {}, {}, {} + for r, (fam, private_pension, payments) in enumerate(rows): + names = [] + for j, (a, (afcs, wwp)) in enumerate(zip(fam["adults"], payments)): + name = f"p{r}_{j}" + people[name] = person_inputs(fam, j, a, private_pension, afcs, wwp) + names.append(name) + benunits[f"b{r}"] = {"members": names} + households[f"h{r}"] = household(fam, names) + simulation = Simulation( + situation={ + "people": people, + "benunits": benunits, + "households": households, + } + ) + + def get(variable): + return np.asarray(simulation.calculate(variable, YEAR, map_to="benunit")) + + return get + + +def drawn_payments(fam): + return [(a["afcs"], a["war_widows_pension"]) for a in fam["adults"]] + + +def reassigned_payments(fam): + """The family's payments, reassigned among sources and adults by `order`.""" + amounts = [amount for pair in drawn_payments(fam) for amount in pair] + moved = [amounts[k] for k in fam["order"]] + return [(moved[2 * j], moved[2 * j + 1]) for j in range(len(fam["adults"]))] + + +def pension_grid(families, payments=drawn_payments): + """Each family once per private pension on the PENSIONS grid.""" + rows = [(fam, pension, payments(fam)) for fam in families for pension in PENSIONS] + get = simulate(rows) + shape = (len(families), len(PENSIONS)) + return lambda variable: get(variable).reshape(shape) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_pension_credit_and_housing_benefit_differ_only_by_war_pensions( + families, +): + get = pension_grid(families) + gc = get("guarantee_credit") + sc = get("savings_credit") + hb_income = get("housing_benefit_applicable_income") + hb_eligible = get("housing_benefit_eligible").astype(bool) + pc_income = get("pension_credit_income") + war = get("pension_credit_war_pension_income") + before_disregard = hb_income + get("housing_benefit_applicable_income_disregard") + for i, fam in enumerate(families): + # Where neither credit is due: with a savings-credit-only award, + # Housing Benefit takes the Secretary of State's assessment plus the + # savings credit (HB (SPC) Regs 2006 reg 27), by design. + compared = (gc[i] <= 0) & (sc[i] <= 0) & hb_eligible[i] & (hb_income[i] > 0) + compared &= pc_income[i] > 0 + assert np.allclose( + before_disregard[i][compared], + (pc_income[i] - war[i])[compared], + atol=0.01, + ), (fam, before_disregard[i][compared], pc_income[i][compared]) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_counted_war_pension_income_bounds_and_closed_form(families): + get = pension_grid(families) + war = get("pension_credit_war_pension_income") + gross = get("afcs") + get("war_widows_pension") + for i, fam in enumerate(families): + expected = sum( + max(0.0, payment - DISREGARD) + for pair in drawn_payments(fam) + for payment in pair + ) + assert np.all(war[i] >= 0), fam + assert np.all(war[i] <= gross[i] + 0.01), fam + assert np.allclose(war[i], expected, atol=0.01), (fam, war[i], expected) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_guarantee_credit_does_not_rise_with_an_afcs_payment(families): + # The first adult's AFCS payment runs over the grid; their war widow's + # pension and the partner's payments stay as drawn. + # No private pension, so that Guarantee Credit is usually paid and the + # property is not met vacuously by a Guarantee Credit of nil throughout. + rows = [ + ( + fam, + 0.0, + [(weekly * WEEKS, fam["adults"][0]["war_widows_pension"])] + + drawn_payments(fam)[1:], + ) + for fam in families + for weekly in AFCS_GRID + ] + gc = simulate(rows)("guarantee_credit").reshape(len(families), len(AFCS_GRID)) + within_disregard = AFCS_GRID <= 10 + for i, fam in enumerate(families): + assert np.all(np.diff(gc[i]) <= 0.01), (fam, gc[i]) + assert np.allclose(gc[i][within_disregard], gc[i][0], atol=0.01), ( + fam, + gc[i], + ) + + +@PROPERTY_SETTINGS +@given(st.lists(family(), min_size=1, max_size=4)) +def test_reassigning_payments_between_sources_and_partners(families): + get = pension_grid(families) + get_moved = pension_grid(families, payments=reassigned_payments) + for variable in ["pension_credit_income", "guarantee_credit"]: + original, after = get(variable), get_moved(variable) + for i, fam in enumerate(families): + assert np.allclose(original[i], after[i], atol=0.01), (fam, variable) + + +def no_payments(fam): + return [(0.0, 0.0) for _ in fam["adults"]] + + +# Aged 77 or over, so that Savings Credit, and with it the savings-credit-only +# route, is often due. +@PROPERTY_SETTINGS +@given(st.lists(family(min_age=77), min_size=1, max_size=4)) +def test_housing_benefit_and_ctr_disregard_war_pensions_on_every_route(families): + with_payments = pension_grid(families) + without = pension_grid(families, payments=no_payments) + variables = [ + "guarantee_credit", + "in_receipt_of_savings_credit_only", + "pension_credit", + "housing_benefit_applicable_income", + "council_tax_reduction_applicable_income", + ] + a = {v: with_payments(v) for v in variables} + b = {v: without(v) for v in variables} + same_route = ( + (a["guarantee_credit"] <= 0) + & (b["guarantee_credit"] <= 0) + & ( + a["in_receipt_of_savings_credit_only"].astype(bool) + == b["in_receipt_of_savings_credit_only"].astype(bool) + ) + ) + paid_change = a["pension_credit"] - b["pension_credit"] + for income in [ + "housing_benefit_applicable_income", + "council_tax_reduction_applicable_income", + ]: + compared = same_route & (a[income] > 0) & (b[income] > 0) + assert np.allclose( + (a[income] - b[income])[compared], paid_change[compared], atol=0.01 + ), (families, income) diff --git a/policyengine_uk/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 new file mode 100644 index 0000000000..04a2275d1c --- /dev/null +++ b/policyengine_uk/tests/test_savings_credit_eligibility.py @@ -0,0 +1,152 @@ +"""Savings Credit shares Pension Credit eligibility, not just its local age test. + +The literal amounts use the existing 2026 model rates. The mixed-age case's +old positive amount is an off-route diagnostic, not lawful entitlement. The +positive controls reached pensionable age before April 2016; no protected +mixed-age Savings Credit continuity is inferred from reported Pension Credit. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation +from policyengine_uk.variables.gov.dwp.pension_credit.savings_credit.savings_credit import ( + savings_credit, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2026 + + +def situation(): + units = [ + ([(80, 20_000, 0), (60, 0, 0)], 6_240), + ([(80, 9_000, 0), (78, 9_000, 0)], 6_240), + ([(80, 11_000, 2_000)], 7_800), + ([(40, 0, 0)], 6_240), + ] + people, benunits, households = {}, {}, {} + for i, (adults, rent) in enumerate(units): + names = [] + for j, (age, pension, private_pension) in enumerate(adults): + name = f"p{i}_{j}" + names.append(name) + people[name] = { + "age": {YEAR: age}, + "is_claimant_or_partner": {YEAR: True}, + "state_pension": {YEAR: pension}, + "private_pension_income": {YEAR: private_pension}, + } + benunits[f"b{i}"] = { + "members": names, + "would_claim_pc": {YEAR: True}, + "would_claim_housing_benefit": {YEAR: True}, + "would_claim_uc": {YEAR: False}, + } + households[f"h{i}"] = { + "members": names, + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MAIDSTONE"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: rent}, + "council_tax": {YEAR: 2_000}, + "savings": {YEAR: 0}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def ungated_simulation(): + """Retain the former amount formula for a downstream differential control.""" + sim = Simulation(situation=situation()) + sim.tax_benefit_system.update_variable( + type("savings_credit", (savings_credit,), {"defined_for": None}) + ) + return sim + + +@pytest.mark.parametrize("first", ["savings_credit", "pension_credit"]) +def test_unprotected_mixed_age_savings_credit_is_zero(first): + sim = Simulation(situation=situation()) + sim.calculate(first, YEAR) + # The older member meets the local pre-2016 age condition, but the + # unprotected younger partner excludes the couple from Pension Credit. + assert sim.calculate("is_savings_credit_eligible", YEAR).tolist() == [ + True, + True, + True, + False, + ] + assert sim.calculate("is_pension_credit_eligible", YEAR).tolist() == [ + False, + True, + True, + False, + ] + # Couple: 0.6 * (18,000 - 329.75*52) = 511.80. + # Single: 0.6*(238-208.07)*52 - 0.4*(12,914-238*52) = 718.616. + np.testing.assert_allclose( + sim.calculate("savings_credit", YEAR), + [0, 511.80, 718.616, 0], + atol=0.01, + rtol=0, + ) + + +def test_shared_gate_preserves_final_pension_credit_and_consumers(): + fixed = Simulation(situation=situation()) + former = ungated_simulation() + # The old off-route calculation was 0.6*(18,766-329.75*52) = 971.40, with the default married + # couple receiving the existing Marriage Allowance of 1,260. + assert former.calculate("savings_credit", YEAR)[0] == pytest.approx( + 971.40, abs=0.01 + ) + assert fixed.calculate("savings_credit", YEAR)[0] == 0 + consumers = [ + "guarantee_credit", + "pension_credit_entitlement", + "pension_credit", + "in_receipt_of_savings_credit_only", + "housing_benefit_applicable_income", + "housing_benefit_assessable_capital", + "housing_benefit_tariff_income", + "housing_benefit", + "council_tax_reduction_applicable_income", + "council_tax_reduction_assessable_capital", + "council_tax_reduction", + "targeted_childcare_entitlement", + ] + for variable in consumers: + np.testing.assert_allclose( + fixed.calculate(variable, YEAR), + former.calculate(variable, YEAR), + atol=0.01, + rtol=0, + err_msg=variable, + ) + # Both-old couple: Guarantee Credit 18,889-18,000 = 889, plus 511.80. + for variable in ("pension_credit_entitlement", "pension_credit"): + np.testing.assert_allclose( + fixed.calculate(variable, YEAR), + [0, 1_400.80, 718.616, 0], + atol=0.01, + rtol=0, + ) + # The couple gets full rent on Guarantee Credit. The single's HB is + # 7,800 - 0.65*(12,914+718.616-256*52) = 7,591.5996. + np.testing.assert_allclose( + fixed.calculate("housing_benefit", YEAR), + [0, 6_240, 7_591.5996, 0], + atol=0.01, + rtol=0, + ) + + +def test_direct_savings_credit_input_keeps_its_existing_override_behavior(): + data = situation() + data["benunits"]["b0"]["savings_credit"] = {YEAR: 123} + sim = Simulation(situation=data) + assert sim.calculate("savings_credit", YEAR)[0] == 123 + # An explicitly supplied component does not create a Pension Credit award. + assert sim.calculate("pension_credit_entitlement", YEAR)[0] == 0 + assert sim.calculate("pension_credit", YEAR)[0] == 0 + assert sim.calculate("housing_benefit", YEAR)[0] == 0 diff --git a/policyengine_uk/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_scenario_baseline_parameters.py b/policyengine_uk/tests/test_scenario_baseline_parameters.py new file mode 100644 index 0000000000..1e272b0810 --- /dev/null +++ b/policyengine_uk/tests/test_scenario_baseline_parameters.py @@ -0,0 +1,209 @@ +"""A Scenario's parameter changes must not leak into ``parameters.baseline``. + +``Simulation.apply_parameter_changes`` reloads and reprocesses the parameter +tree. Before #2188 it cloned the baseline child from the already reformed +tree, so ``baseline_vat`` and ``adjust_budgets`` compared a reform with +itself. +""" + +import numpy as np +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.reforms.policyengine.adjust_budgets import adjust_budgets +from policyengine_uk.system import system + +VAT = "gov.hmrc.vat.standard_rate" + +SITUATION = { + "people": {"adult": {"age": {2026: 40}}}, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "full_rate_vat_consumption": {2026: 10_000}, + "reduced_rate_vat_consumption": {2026: 0}, + } + }, +} + + +def _vat_change(sim): + return float(sim.calculate("vat", 2026)[0] - sim.calculate("baseline_vat", 2026)[0]) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"scenario": Scenario(parameter_changes={VAT: 0.22})}, + {"scenario": Scenario(parameter_changes={VAT: {"2026": 0.22}})}, + {"reform": {VAT: 0.22}}, + { + "reform": Reform.from_dict( + {VAT: {"2000-01-01.2100-12-31": 0.22}}, country_id="uk" + ) + }, + ], + ids=["scenario-scalar", "scenario-year", "reform-dict", "core-reform"], +) +def test_every_reform_route_scores_vat_against_the_unreformed_baseline(kwargs): + sim = Simulation(situation=SITUATION, **kwargs) + # All spending here is standard-rated, so raising the rate from 20% to + # 22% raises VAT by a tenth, whatever the grossing factor (526.32 at the + # current factor). + unreformed_vat = float(Simulation(situation=SITUATION).calculate("vat", 2026)[0]) + assert _vat_change(sim) == pytest.approx(0.1 * unreformed_vat, abs=0.01) + assert _vat_change(sim) > 0 + assert sim.tax_benefit_system.parameters.baseline.gov.hmrc.vat.standard_rate( + "2026-06-01" + ) == pytest.approx(0.2) + + +def test_no_reform_has_no_vat_change(): + assert _vat_change(Simulation(situation=SITUATION)) == 0 + + +@pytest.mark.parametrize( + "path, instant", + [ + (VAT, "2026-06-01"), + ("gov.hmrc.income_tax.allowances.personal_allowance.amount", "2028-06-01"), + ("gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD", "2029-06-01"), + ("gov.hmrc.fuel_duty.petrol_and_diesel", "2027-02-01"), + ("gov.dfe.education_spending", "2027-01-01"), + ], +) +def test_scenario_leaves_the_baseline_tree_identical_to_an_unreformed_system( + path, instant +): + changes = { + VAT: 0.25, + "gov.hmrc.income_tax.allowances.personal_allowance.amount": 15_000, + "gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD": 500, + "gov.hmrc.fuel_duty.petrol_and_diesel": 0.70, + "gov.dfe.education_spending": 150, + } + sim = Simulation(situation=SITUATION, scenario=Scenario(parameter_changes=changes)) + reformed = sim.tax_benefit_system.parameters + unreformed = system.parameters + assert reformed.baseline.get_child(path)(instant) == pytest.approx( + unreformed.baseline.get_child(path)(instant) + ) + # The reform itself still reaches the processed tree. + assert reformed.get_child(path)(instant) != pytest.approx( + unreformed.get_child(path)(instant) + ) + + +def test_adjust_budgets_sees_a_scenario_spending_change(): + sim = Simulation( + situation=SITUATION, + scenario=Scenario(parameter_changes={"gov.dfe.education_spending": 150}), + ) + assert adjust_budgets(sim.tax_benefit_system.parameters, 2026) is not None + assert adjust_budgets(system.parameters, 2026) is None + + +# Juaristi22's review of #2193 (C1): once the baseline tree stayed unreformed, +# a before-load Scenario spending change registered the budget adjustment, and +# Simulation and Microsimulation stopped constructing because the adjustment +# read ``self.simulation`` from a tax-benefit system that never had one. + +DFE = "gov.dfe.education_spending" +ALLOCATION = 1e9 # Synthetic £1bn allocation: routing only, not an estimate. + +BUDGET_SITUATION = { + "people": { + "adult": {"age": {2026: 40}, "employment_income": {2026: 30_000}}, + }, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": { + "household": { + "members": ["adult"], + "region": {2026: "LONDON"}, + "dfe_education_spending": {2026: ALLOCATION}, + # Fixed weights, so the Microsimulation's weighted total equals + # the allocation in every year. + "household_weight": {year: 1.0 for year in range(2023, 2030)}, + } + }, +} + +BEFORE_LOAD_DFE_150 = Scenario( + parameter_changes={DFE: 150}, applied_before_data_load=True +) + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_before_load_spending_scenario_constructs(simulation_class): + sim = simulation_class(situation=BUDGET_SITUATION, scenario=BEFORE_LOAD_DFE_150) + net_income = np.asarray(sim.calculate("household_net_income", 2026)) + assert np.isfinite(net_income).all() + assert np.isfinite( + np.asarray(sim.baseline.calculate("household_net_income", 2026)) + ).all() + + +@pytest.mark.parametrize("simulation_class", [Simulation, Microsimulation]) +def test_before_load_spending_scenario_scales_the_allocation(simulation_class): + sim = simulation_class(situation=BUDGET_SITUATION, scenario=BEFORE_LOAD_DFE_150) + parameters = sim.tax_benefit_system.parameters + for year in range(2026, 2030): + baseline_budget = parameters.baseline.gov.dfe.education_spending(year) + assert parameters.gov.dfe.education_spending(year) == 150 + assert baseline_budget < 150 + # The allocation is scaled so its total rises by the budget change + # (in £bn) for each year on its own, without carrying an earlier + # year's change forward. + expected = ALLOCATION + (150 - baseline_budget) * 1e9 + reformed = np.asarray(sim.calculate("dfe_education_spending", year)) + assert reformed[0] == pytest.approx(expected, rel=1e-6) + baseline = np.asarray(sim.baseline.calculate("dfe_education_spending", year)) + assert baseline[0] == pytest.approx(ALLOCATION) + # 2026: £150bn against the unreformed £107.28bn. + assert np.asarray(sim.calculate("dfe_education_spending", 2026))[0] == ( + pytest.approx(43.7207e9, rel=1e-5) + ) + # No allocation before 2026, so there is nothing to scale there. + for year in range(2023, 2026): + assert np.asarray(sim.calculate("dfe_education_spending", year))[0] == 0 + # Other budgets are untouched. + for variable in ("nhs_spending", "dft_subsidy_spending"): + assert np.asarray(sim.calculate(variable, 2026))[0] == pytest.approx( + np.asarray(sim.baseline.calculate(variable, 2026))[0] + ) + + +def test_structural_reforms_can_read_the_simulation_they_are_applied_to(): + # adjust_budgets and disable_simulated_benefits read ``self.simulation`` + # inside Reform.apply, where ``self`` is the tax-benefit system. + seen = [] + + class reads_simulation(Reform): + def apply(self): + seen.append(self.simulation) + + sim = Simulation(situation=SITUATION) + sim.apply_reform(reads_simulation) + clone = sim.clone() + clone.apply_reform(reads_simulation) + assert seen == [sim, clone] + + +@pytest.mark.microsimulation +def test_before_load_spending_scenario_constructs_on_the_dataset(): + sim = Microsimulation(scenario=BEFORE_LOAD_DFE_150) + year = 2026 + parameters = sim.tax_benefit_system.parameters + budget_change = 150 - parameters.baseline.gov.dfe.education_spending(year) + weights = np.asarray(sim.calculate("household_weight", year, unweighted=True)) + reformed = np.asarray(sim.calculate("dfe_education_spending", year)) + # ``sim.baseline`` is a plain Simulation, so weight its values here. + baseline = np.asarray(sim.baseline.calculate("dfe_education_spending", year)) + reformed_total = (reformed * weights).sum() / 1e9 + baseline_total = (baseline * weights).sum() / 1e9 + assert baseline_total > 0 + assert reformed_total == pytest.approx(baseline_total + budget_change, rel=1e-4) + assert np.isfinite(np.asarray(sim.calculate("household_net_income", year))).all() 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_squared_poverty_gap.py b/policyengine_uk/tests/test_squared_poverty_gap.py new file mode 100644 index 0000000000..d2d6f51541 --- /dev/null +++ b/policyengine_uk/tests/test_squared_poverty_gap.py @@ -0,0 +1,77 @@ +"""FGT2 components and person-weighted aggregation on tiny synthetic data. + +The .7 oracle differs from a .5 household-weighted mean: one person carries +weight2 and component.25; three people each carry weight1 and component1. +These are technical fixtures, not representative population estimates. +""" + +import numpy as np +import pytest + +from policyengine_uk import CountryTaxBenefitSystem, Microsimulation, Simulation + + +YEAR = 2025 + + +def situation(sizes, weights, lines, incomes): + people, families, households = {}, {}, {} + for h, (size, weight, line, income) in enumerate( + zip(sizes, weights, lines, incomes) + ): + members = [f"h{h}_p{i}" for i in range(size)] + for i, person in enumerate(members): + people[person] = {"age": {YEAR: 40 if i == 0 else 8}} + families[f"family{h}"] = {"members": members} + households[f"home{h}"] = { + "members": members, + "household_weight": {YEAR: weight}, + "hbai_household_net_income_before_reset_to_zero": {YEAR: income}, + "poverty_line_bhc": {YEAR: line}, + "poverty_line_ahc": {YEAR: line}, + } + return {"people": people, "benunits": families, "households": households} + + +@pytest.mark.parametrize("basis", ["bhc", "ahc"]) +def test_person_mapped_weighted_mean_differs_from_household_mean(basis): + sim = Microsimulation(situation=situation([1, 3], [2, 1], [100, 100], [50, 0])) + variable = f"squared_poverty_gap_{basis}" + component = sim.calculate(variable, YEAR, map_to="person") + np.testing.assert_allclose(component, [0.25, 1, 1, 1]) + np.testing.assert_allclose(component.weights, [2, 1, 1, 1]) + assert component.mean(skipna=False) == pytest.approx(0.7) + assert sim.calculate(variable, YEAR).mean(skipna=False) == pytest.approx(0.5) + + +@pytest.mark.parametrize("basis", ["bhc", "ahc"]) +def test_undefined_household_is_not_dropped_from_person_mean(basis): + sim = Microsimulation(situation=situation([1, 3], [2, 1], [100, 0], [50, 0])) + with np.errstate(all="raise"): + component = sim.calculate(f"squared_poverty_gap_{basis}", YEAR, map_to="person") + assert component.iloc[0] == 0.25 + assert np.isnan(component.iloc[1:]).all() + assert np.isnan(component.mean(skipna=False)) + + +@pytest.mark.parametrize("basis", ["bhc", "ahc"]) +def test_mixed_positive_zero_negative_lines_do_not_emit_division_warnings(basis): + sim = Simulation( + situation=situation( + [1, 1, 1, 1], [1, 1, 1, 1], [100, 0, -100, 200], [50, 0, 0, 100] + ) + ) + with np.errstate(all="raise"): + component = sim.calculate(f"squared_poverty_gap_{basis}", YEAR) + np.testing.assert_allclose(component, [0.25, np.nan, np.nan, 0.25], equal_nan=True) + + +def test_components_are_annual_dimensionless_household_variables_without_alias(): + system = CountryTaxBenefitSystem() + for basis in ["bhc", "ahc"]: + variable = system.get_variable(f"squared_poverty_gap_{basis}") + assert variable is not None + assert variable.entity.key == "household" + assert str(variable.definition_period) == "year" + assert variable.unit == "/1" + assert "squared_poverty_gap" not in system.variables diff --git a/policyengine_uk/tests/test_state_pension_age.py b/policyengine_uk/tests/test_state_pension_age.py index a8cb7d948c..67541b5a61 100644 --- a/policyengine_uk/tests/test_state_pension_age.py +++ b/policyengine_uk/tests/test_state_pension_age.py @@ -291,6 +291,38 @@ def test_status_matches_statute_for_every_birth_date(year): assert not mismatches, mismatches[:10] +@pytest.mark.parametrize("year", YEARS) +def test_attainment_date_matches_statute_for_every_birth_date(year): + """state_pension_age_date is the day a day-level reading of the statute + gives, for every birth date in the differential set, and the same day + whichever year the person is placed from.""" + sim, keys = differential_simulation() + model = sim.calculate("state_pension_age_date", year) + expected = np.array([ymd(reference_attainment_day(b, m)) for b, m in keys]) + wrong = np.flatnonzero(model != expected) + assert not len(wrong), [(keys[i], model[i], expected[i]) for i in wrong[:10]] + + +@pytest.mark.parametrize("year", [2021, 2026, 2030]) +def test_attainment_date_agrees_with_state_pension_age(year): + """Differential: state_pension_age_date and state_pension_age read the + same timetable from the same instant of birth, so the age implied by the + attainment date is state_pension_age.""" + from policyengine_uk.utils.dates import birth_instant_from_age + from policyengine_uk.utils.dates import yyyymmdd_to_grid_months + + sim, keys = differential_simulation() + attained = yyyymmdd_to_grid_months(sim.calculate("state_pension_age_date", year)) + birth = birth_instant_from_age( + year, + sim.calculate("age", year), + sim.calculate("months_since_last_birthday", year), + ) + implied = (attained - birth) / 12 + model = sim.calculate("state_pension_age", year) + np.testing.assert_allclose(implied, model, atol=1e-9) + + def mid_year_share(year: int, age: int, male: bool) -> float: """Share of people aged `age` on 6 October who have attained pensionable age by then, with birthdays spread evenly over the year.""" diff --git a/policyengine_uk/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_state_pension_credit_qualifying_age.py b/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py index 7748b39a26..b6808ab82a 100644 --- a/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py +++ b/policyengine_uk/tests/test_state_pension_credit_qualifying_age.py @@ -189,16 +189,18 @@ def assert_consumers_follow_the_qualifying_age(births, male, year): assert np.array_equal( sim.calculate("is_housing_benefit_benefit_cap_exempt", year), attained ) - aged = sim.tax_benefit_system.parameters( + allowances = sim.tax_benefit_system.parameters( f"{year}-06-01" - ).gov.dwp.housing_benefit.allowances.single.aged - assert np.array_equal( - np.isclose( - sim.calculate("council_tax_reduction_applicable_amount", year), - aged * WEEKS_IN_YEAR, - ), - attained, - ) + ).gov.dwp.housing_benefit.allowances + amount = sim.calculate("council_tax_reduction_applicable_amount", year) + at_pension_age_rate = np.isclose(amount, allowances.single.aged * WEEKS_IN_YEAR) + if allowances.pension_age_cutoff is not None: + # England's lower rate for those who attained State Pension age on or + # after 1 April 2021 (SI 2012/2885 Sch 2 para 1). + at_pension_age_rate |= np.isclose( + amount, allowances.single.aged_from_cutoff * WEEKS_IN_YEAR + ) + assert np.array_equal(at_pension_age_rate, attained) if year <= 2023: assert np.array_equal( sim.calculate("winter_fuel_allowance", year) > 0, attained diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index eead7689d9..92d4d82100 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -57,3 +57,40 @@ def test_pension_credit_capital_stocks_are_not_prorated_monthly(): simulation.calculate("pension_credit_assessable_capital", "2026-01")[0] == 33_600 ) + + +def test_itemised_share_like_stocks_are_not_prorated_monthly(): + stocks = { + "savings": 1_000, + "corporate_wealth": 12_000, + "directly_held_shares": 4_000, + "unit_and_investment_trusts": 3_000, + "stocks_and_shares_isa": 2_000, + } + for age, capital in ( + (30, "uc_assessable_capital"), + (70, "pension_credit_assessable_capital"), + ): + situation = { + "people": {"person": {"age": {"2026": age}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + **{name: {"2026": value} for name, value in stocks.items()}, + } + }, + } + simulation = Simulation(situation=situation) + + for name, value in stocks.items(): + assert simulation.calculate(name, "2026-01")[0] == value, name + # 12,000 less the unit trusts and ISA (5,000): the 4,000 of shares + # and the 3,000 not itemised. + assert simulation.calculate("share_like_capital_with_sale_expenses", "2026-01")[ + 0 + ] == (7_000) + # Shares and the part not itemised less 10% for the expenses of + # sale; unit trusts and the ISA at their withdrawal value: + # 1,000 + 90% of 7,000 + 3,000 + 2,000 = 1,000 + 6,300 + 5,000. + assert simulation.calculate(capital, "2026-01")[0] == 12_300, capital diff --git a/policyengine_uk/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..0e8961f917 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -408,6 +408,9 @@ def series(base, first, last, month_day="04-06"): } }, "rates": { + "savings_starter_rate": { + "allowance": series(5_000, 2021, 2030) + }, "uk": { "brackets": [ { @@ -424,7 +427,7 @@ def series(base, first, last, month_day="04-06"): ), }, ] - } + }, }, }, "national_insurance": { 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_tv_licence_metadata.py b/policyengine_uk/tests/test_tv_licence_metadata.py new file mode 100644 index 0000000000..6c90645b81 --- /dev/null +++ b/policyengine_uk/tests/test_tv_licence_metadata.py @@ -0,0 +1,8 @@ +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.model_api import GBP + + +def test_free_tv_licence_value_uses_currency_units(): + system = CountryTaxBenefitSystem() + + assert system.variables["free_tv_licence_value"].unit == GBP diff --git a/policyengine_uk/tests/test_uc_deductions.py b/policyengine_uk/tests/test_uc_deductions.py index a06e740ba4..7ec582d90c 100644 --- a/policyengine_uk/tests/test_uc_deductions.py +++ b/policyengine_uk/tests/test_uc_deductions.py @@ -9,6 +9,12 @@ YEAR = 2025 +FLOOR_85 = { + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } +} + def make_situation(**benunit_overrides): benunit = { @@ -161,9 +167,9 @@ def test_protected_floor_inactive_when_reductions_stay_above_it(self): ) def test_protected_floor_binds_on_the_above_cap_excess(self): - # Latent demand of 30% is 15% cappable plus a 15% last resort excess - # that current law exempts from the cap, giving a 20% deduction rate - # under the 15% cap. The modeled floor limits combined reductions + # Latent demand of 30% is 25% cappable (cut to 15% by the cap) plus a + # 5% last resort excess that current law exempts from the cap, giving + # a 20% deduction rate. The modeled floor limits combined reductions # regardless of category, so it cuts into that excess. JRF's briefing # does not say whether their floor exempts last resort and child # maintenance deductions; this pins the modeling choice. @@ -186,6 +192,180 @@ def test_protected_floor_binds_on_the_above_cap_excess(self): 6_000 - 0.15 * sa ) + def test_floor_components_reconcile_with_the_reduction_applied(self): + # uc_deductions and uc_benefit_cap_reduction_after_floor are what the + # award actually loses, so they must sum to the entitlement forgone. Before + # the floor was pushed into the components, they reported £960 and + # £1,000 against a £720 reduction actually applied. + situation = make_situation( + uc_benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } + }, + ) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + deductions = reform.calculate("uc_deductions", YEAR)[0] + cap_reduction = reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[ + 0 + ] + universal_credit = reform.calculate("universal_credit", YEAR)[0] + assert deductions + cap_reduction == pytest.approx(0.15 * sa) + assert deductions + cap_reduction == pytest.approx(6_000 - universal_credit) + # The gross regulation 81 reduction is unchanged: benefit_cap_reduction + # and the deduction incidence read it. + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 1_000 + + def test_the_benefit_cap_reduction_absorbs_the_floor_first(self): + # Deductions of 10% of the standard allowance fit inside a 15% floor + # allowance, so they survive intact and the cap reduction keeps only + # the 5% left. test_jrf_2026_example_keeps_a_full_rate_deduction + # covers JRF's own example, where a 15% deduction fills the allowance. + situation = make_situation( + uc_benefit_cap_reduction={YEAR: 2_500}, + uc_latent_deduction_rate={YEAR: 0.10}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 0.85 + } + }, + ) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.10 * sa) + assert reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[ + 0 + ] == pytest.approx(0.05 * sa) + + def test_jrf_2026_example_keeps_a_full_rate_deduction(self): + # JRF's September 2026 briefing: standard allowance £98 a week, a + # Fair Repayment Rate deduction of £15 and a benefit cap reduction of + # £80; an 85% floor limits the total reduction to £15 and the + # household gains £80. Latent demand of 25% is cut to the 15% cap, + # which exactly fills the floor allowance, so the deduction survives + # whole and the cap reduction falls to zero. + situation = make_situation( + uc_latent_deduction_rate={YEAR: 0.25}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + sa = Simulation(situation=situation).calculate("uc_standard_allowance", YEAR)[0] + situation["benunits"]["benunit"]["uc_benefit_cap_reduction"] = { + YEAR: 80 / 98 * sa + } + reform = Simulation(situation=situation, reform=FLOOR_85) + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.15 * sa) + assert reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[ + 0 + ] == pytest.approx(0, abs=0.01) + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx( + 6_000 - 0.15 * sa + ) + + @pytest.mark.parametrize("gross_cap_reduction", [5_500, 5_900]) + def test_heavy_capping_does_not_squeeze_the_deduction(self, gross_cap_reduction): + # Under the gross cap the penny rule leaves room for only £499.88 or + # £99.88 of deductions. The floor removes all but the allowance, so + # the deduction is met in full from the protected award and the cap + # reduction absorbs the floor: £720.25 and £0, not £499.88/£220.37 + # or £99.88/£620.37. + situation = make_situation( + uc_benefit_cap_reduction={YEAR: gross_cap_reduction}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation(situation=situation, reform=FLOOR_85) + sa = reform.calculate("uc_standard_allowance", YEAR)[0] + assert reform.calculate("uc_deductions", YEAR)[0] == pytest.approx(0.15 * sa) + assert reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[0] == 0 + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx( + 6_000 - 0.15 * sa + ) + assert ( + reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == gross_cap_reduction + ) + + @pytest.mark.parametrize("floor", [0, 0.85]) + def test_applied_cap_never_exceeds_the_award_lost(self, floor): + # A £1,000 gross cap reduction on a £500 award takes £500, not £1,000 + # (no floor) or the £720.25 floor allowance. The gross regulation 81 + # figure is unchanged. + situation = make_situation( + universal_credit_pre_benefit_cap={YEAR: 500}, + uc_benefit_cap_reduction={YEAR: 1_000}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": floor + } + }, + ) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[0] == 500 + assert reform.calculate("universal_credit", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction", YEAR)[0] == 1_000 + + def test_floor_changes_nothing_where_it_does_not_bind(self): + # A £700 award, a £1,000 gross cap and deduction demand above the + # floor allowance: the cap already takes the whole award, which is + # less than the £720.25 allowance, so the floor leaves the current-law + # split alone rather than moving the loss from the cap to deductions. + situation = make_situation( + universal_credit_pre_benefit_cap={YEAR: 700}, + uc_benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation(situation=situation, reform=FLOOR_85) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[0] == 700 + assert reform.calculate("universal_credit", YEAR)[0] == 0 + + def test_a_floor_above_the_standard_allowance_adds_nothing(self): + # (1 - floor) x standard allowance goes negative above a floor of 1; + # subtracting it added £960 to a £6,000 award. The allowance clamps + # at zero, where the floor protects the standard allowance entirely. + situation = make_situation( + uc_benefit_cap_reduction={YEAR: 1_000}, + uc_latent_deduction_rate={YEAR: 0.25}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + reform = Simulation( + situation=situation, + reform={ + "gov.dwp.universal_credit.deductions.protected_floor": { + "2025-01-01.2025-12-31": 1.2 + } + }, + ) + assert reform.calculate("uc_deductions", YEAR)[0] == 0 + assert reform.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[0] == 0 + assert reform.calculate("universal_credit", YEAR)[0] == pytest.approx(6_000) + + def test_no_deductions_without_a_universal_credit_claim(self): + # Datasets are meant to impute uc_latent_deduction_rate directly, at + # which point nothing else gates deductions on the claim. + sim = Simulation( + situation=make_situation( + would_claim_uc={YEAR: False}, + uc_latent_deduction_rate={YEAR: 0.30}, + uc_deduction_combination={YEAR: "ADVANCE_ONLY"}, + ) + ) + assert sim.calculate("universal_credit", YEAR)[0] == 0 + assert sim.calculate("uc_deductions", YEAR)[0] == 0 + assert sim.calculate("uc_benefit_cap_reduction_after_floor", YEAR)[0] == 0 + def test_zeroing_every_type_share_leaves_a_valid_enum_array(self): situation = make_situation( uc_deduction_random_draw={YEAR: 0.0}, diff --git a/policyengine_uk/tests/test_uc_disabled_child_addition_properties.py b/policyengine_uk/tests/test_uc_disabled_child_addition_properties.py new file mode 100644 index 0000000000..86c654f768 --- /dev/null +++ b/policyengine_uk/tests/test_uc_disabled_child_addition_properties.py @@ -0,0 +1,164 @@ +"""UC reg 24(2): one disabled-child tier, with an independent blind limb. + +The exact award categories provide the entitlement classification oracle. +The amount-only checks separately preserve the existing, nonstatutory paid- +amount proxy; they do not establish entitlement from part-year payments. +Generated reported categories test the conditional input contract, not the +underlying age or overlapping-award eligibility of each generated combination. +2026 cash anchors are the unchanged monthly amounts 164.79 and 514.71. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import WEEKS_IN_YEAR + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +YEAR = 2026 +LOWER = 1_977.48 +HIGHER = 6_176.52 + + +def family(child, reverse=False): + people = { + "parent": { + "age": {YEAR: 35}, + "is_parent": {YEAR: True}, + "is_benunit_head": {YEAR: True}, + }, + "child": { + "age": {YEAR: 8}, + "is_benunit_head": {YEAR: False}, + **{name: {YEAR: value} for name, value in child.items()}, + }, + } + names = list(people) + if reverse: + people = dict(reversed(list(people.items()))) + names.reverse() + return { + "people": people, + "benunits": {"family": {"members": names}}, + "households": {"home": {"members": names}}, + } + + +def child_additions(sim): + ids = list(sim.populations["person"].ids) + child = ids.index("child") + return ( + sim.calculate("uc_individual_disabled_child_element", YEAR)[child], + sim.calculate("uc_individual_severely_disabled_child_element", YEAR)[child], + ) + + +@settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + age=st.integers(0, 15), + dla=st.sampled_from(["NONE", "LOWER", "MIDDLE", "HIGHER"]), + pip=st.sampled_from(["NONE", "STANDARD", "ENHANCED"]), + blind=st.booleans(), + lower_flag=st.booleans(), + reverse=st.booleans(), +) +def test_exact_award_categories_and_blindness_select_one_tier( + age, dla, pip, blind, lower_flag, reverse +): + sim = Simulation( + situation=family( + { + "age": age, + "dla_sc_category": dla, + "pip_dl_category": pip, + "is_blind": blind, + "is_disabled_for_benefits": lower_flag, + }, + reverse=reverse, + ) + ) + higher = blind or dla == "HIGHER" or pip == "ENHANCED" + expected = (LOWER if lower_flag and not higher else 0, HIGHER if higher else 0) + actual = child_additions(sim) + np.testing.assert_allclose(actual, expected, atol=0.01, rtol=0) + assert not (actual[0] > 0 and actual[1] > 0) + # Claimant roles remain excluded, even when entity/member order changes. + parent = list(sim.populations["person"].ids).index("parent") + for variable in ( + "uc_individual_disabled_child_element", + "uc_individual_severely_disabled_child_element", + ): + assert sim.calculate(variable, YEAR)[parent] == 0 + + +@pytest.mark.parametrize( + "awards, higher", + [ + ({"dla_sc": (114.6 - 10) * WEEKS_IN_YEAR - 1}, False), + ({"dla_sc": (114.6 - 10) * WEEKS_IN_YEAR}, True), + ({"pip_dl": (114.6 - 10) * WEEKS_IN_YEAR + 1}, True), + ({"dla_sc_category": "HIGHER", "dla_sc": 0}, False), + ({"pip_dl_category": "ENHANCED", "pip_dl": 0}, False), + ], + ids=[ + "DLA-below-proxy", + "DLA-at-proxy", + "PIP-above-proxy", + "DLA-direct-zero", + "PIP-direct-zero", + ], +) +def test_existing_direct_amount_proxy_and_override_precedence(awards, higher): + # Weekly 114.60 is the existing 2026 high/enhanced rate; 10 is the + # retained modeling tolerance, not a statutory entitlement threshold. + sim = Simulation(situation=family({**awards, "is_disabled_for_benefits": True})) + np.testing.assert_allclose( + child_additions(sim), + (0, HIGHER) if higher else (LOWER, 0), + atol=0.01, + rtol=0, + ) + + +def test_zero_higher_rate_reform_does_not_pay_the_lower_tier_instead(): + reform = Reform.from_dict( + { + "gov.dwp.universal_credit.elements.child.severely_disabled.amount": { + "2026-01-01.2026-12-31": 0 + } + }, + country_id="uk", + ) + sim = Simulation( + situation=family( + {"dla_sc_category": "HIGHER", "is_disabled_for_benefits": True} + ), + reform=reform, + ) + np.testing.assert_array_equal(child_additions(sim), [0, 0]) + + +def test_amount_proxy_tolerance_is_configurable_without_changing_award_rates(): + reform = Reform.from_dict( + { + "gov.dwp.universal_credit.elements.child.severely_disabled.benefit_amount_tolerance": { + "2026-01-01.2026-12-31": 0 + } + }, + country_id="uk", + ) + sim = Simulation( + situation=family( + {"dla_sc": (114.6 - 10) * WEEKS_IN_YEAR, "is_disabled_for_benefits": True} + ), + reform=reform, + ) + np.testing.assert_allclose(child_additions(sim), [LOWER, 0], atol=0.01, rtol=0) diff --git a/policyengine_uk/tests/test_uc_incapacity_income.py b/policyengine_uk/tests/test_uc_incapacity_income.py new file mode 100644 index 0000000000..3594a20d12 --- /dev/null +++ b/policyengine_uk/tests/test_uc_incapacity_income.py @@ -0,0 +1,116 @@ +"""Income classification, role boundaries and dated annual parameter controls.""" + +from pathlib import Path + +import pytest +import policyengine_uk +from policyengine_core.parameters import ParameterNode +from policyengine_uk import CountryTaxBenefitSystem, Simulation + + +ORIGINAL = [ + "carers_allowance", + "jsa_contrib", + "esa_contrib", + "maternity_allowance", + "iidb", + "private_pension_income", + "state_pension", + "uc_tariff_income", +] + + +def _income(parameters): + return parameters.gov.dwp.universal_credit.means_test.income_definitions.unearned + + +@pytest.mark.parametrize( + "on, included, csp", + [ + ("2013-04-29", False, False), + ("2014-06-15", False, False), + ("2014-06-16", True, False), + ("2023-11-18", True, False), + ("2023-11-19", True, True), + ], +) +def test_raw_statutory_income_list_boundaries(on, included, csp): + # Raw statutory dates: this does not certify a 2014 annual UC award. + raw = ParameterNode( + directory_path=str(Path(policyengine_uk.__file__).parent / "parameters") + ) + actual = list(_income(raw)(on)) + expected = ORIGINAL + (["incapacity_benefit", "sda"] if included else []) + if csp: + expected.append("uc_unearned_carer_support_payment") + assert len(actual) == len(set(actual)) == len(expected) + assert set(actual) == set(expected) + if not included: + assert actual == ORIGINAL + + +@pytest.mark.parametrize( + "year, csp", [(2015, False), (2023, False), (2024, True), (2026, True)] +) +def test_processed_annual_list_keeps_legacy_income(year, csp): + actual = list(_income(CountryTaxBenefitSystem().parameters)(str(year))) + expected = ORIGINAL + ["incapacity_benefit", "sda"] + if csp: + expected.append("uc_unearned_carer_support_payment") + assert len(actual) == len(set(actual)) == len(expected) + assert set(actual) == set(expected) + + +@pytest.mark.parametrize( + "reported, amount", + [("incapacity_benefit_reported", 1000), ("sda_reported", 6206.20)], +) +def test_assessed_partner_income_counts_but_other_unit_income_does_not( + reported, amount +): + sim = Simulation( + situation={ + "people": { + "claimant": {"age": {2026: 60}}, + "partner": {"age": {2026: 58}, reported: {2026: 1000}}, + "other": {"age": {2026: 60}, reported: {2026: 2000}}, + }, + "benunits": { + "couple": {"members": ["claimant", "partner"]}, + "other": {"members": ["other"]}, + }, + "households": { + "household": { + "members": ["claimant", "partner", "other"], + "country": {2026: "ENGLAND"}, + } + }, + } + ) + assert sim.calculate("is_uc_assessed_claimant", 2026).tolist() == [True, True, True] + assert sim.calculate("uc_unearned_income", 2026).tolist() == pytest.approx( + [amount, amount if reported == "sda_reported" else 2000] + ) + + +@pytest.mark.parametrize("reported", ["incapacity_benefit_reported", "sda_reported"]) +def test_same_unit_nonclaimant_report_is_excluded_input_contract(reported): + # Not a lawful legacy award to a child: exercises the supplied-input role + # contract without certifying legacy entitlement or child LCWRA treatment. + sim = Simulation( + situation={ + "people": { + "parent": {"age": {2026: 60}}, + "child": {"age": {2026: 17}, reported: {2026: 1000}}, + }, + "benunits": {"family": {"members": ["parent", "child"]}}, + "households": { + "household": { + "members": ["parent", "child"], + "country": {2026: "ENGLAND"}, + } + }, + } + ) + assert sim.calculate("is_uc_assessed_claimant", 2026).tolist() == [True, False] + assert sim.calculate("uc_unearned_income", 2026).tolist() == [0] 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_war_widows_pension.py b/policyengine_uk/tests/test_war_widows_pension.py index 2356787ed0..8d12f38e13 100644 --- a/policyengine_uk/tests/test_war_widows_pension.py +++ b/policyengine_uk/tests/test_war_widows_pension.py @@ -52,10 +52,21 @@ def test_war_widows_pension_reported_declares_cpi_uprating(): @pytest.mark.parametrize("aggregate", AGGREGATES_THAT_COUNT_BSP) def test_war_widows_pension_counts_wherever_bsp_does(aggregate): + # £40,000 of private pension keeps the widow out of every means test, so + # each aggregate moves by the payment itself. War widow's pension is + # Pension Credit income (SPCA 2002 s.15(1)(g)) and BSP is not (SPC Regs + # 2002 reg 15(1)(n)), so for a widow on Pension Credit the two would + # change the totals by different amounts. def value(person_inputs): sim = Simulation( situation={ - "people": {"widow": {"age": 70, **person_inputs}}, + "people": { + "widow": { + "age": 70, + "private_pension_income": {2026: 40_000}, + **person_inputs, + } + }, "benunits": {"benunit": {"members": ["widow"]}}, "households": {"household": {"members": ["widow"]}}, } 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/tests/test_wealth_components.py b/policyengine_uk/tests/test_wealth_components.py new file mode 100644 index 0000000000..489d6ac0f8 --- /dev/null +++ b/policyengine_uk/tests/test_wealth_components.py @@ -0,0 +1,47 @@ +"""corporate_wealth's components share its entity, quantity type and uprating +metadata (class uprating and uprating_indices.yaml index), the condition for a +dataset's identity corporate_wealth == sum of components to survive projection.""" + +from pathlib import Path + +import yaml + +from policyengine_uk import CountryTaxBenefitSystem + +COMPONENTS = ( + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +) + +system = CountryTaxBenefitSystem() +UPRATING_INDICES = yaml.safe_load( + (Path(__file__).parents[1] / "data" / "uprating_indices.yaml").read_text() +) + + +def _uprating_index(variable): + return [index for index, names in UPRATING_INDICES.items() if variable in names] + + +def test_components_match_corporate_wealth(): + corporate_wealth = system.variables["corporate_wealth"] + for name in COMPONENTS: + variable = system.variables[name] + assert variable.is_input_variable() + assert variable.entity.key == corporate_wealth.entity.key + assert variable.quantity_type == corporate_wealth.quantity_type + assert variable.uprating == corporate_wealth.uprating + assert _uprating_index(name) == _uprating_index("corporate_wealth") + + +def test_cash_isa_is_uprated_with_savings(): + assert system.variables["cash_isa"].uprating == system.variables["savings"].uprating + assert _uprating_index("cash_isa") == _uprating_index("savings") + + +def test_components_are_not_part_of_total_wealth(): + """total_wealth adds corporate_wealth, which already holds the components.""" + adds = system.variables["total_wealth"].adds + assert "corporate_wealth" in adds + assert not set(COMPONENTS) & set(adds) diff --git a/policyengine_uk/tests/test_welsh_residential_ltt_rates.py b/policyengine_uk/tests/test_welsh_residential_ltt_rates.py new file mode 100644 index 0000000000..ce9ce4e6f7 --- /dev/null +++ b/policyengine_uk/tests/test_welsh_residential_ltt_rates.py @@ -0,0 +1,147 @@ +"""Welsh residential LTT dates and the existing annual sampling convention. + +WSI 2022/1027 reg 7 replaces the main table from 10 October 2022; +WSI 2024/1311 reg 6 replaces the higher table from 11 December 2024. +Annual purchase inputs cannot select transaction dates or protected contracts. +""" + +from pathlib import Path + +import numpy as np +import pytest +import yaml +from policyengine_core.parameters import ParameterScale + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem + +PATH = "gov.wra.land_transaction_tax.residential" +DIRECTORY = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/wra/land_transaction_tax/residential" +) +OLD_THRESHOLDS = [0, 180_000, 250_000, 400_000, 750_000, 1_500_000] +MAIN_THRESHOLDS = [0, 225_000, 400_000, 750_000, 1_500_000] +OLD_MAIN_RATES = [0, 0.035, 0.05, 0.075, 0.1, 0.12] +MAIN_RATES = [0, 0.06, 0.075, 0.1, 0.12] +OLD_HIGHER_RATES = [0.04, 0.075, 0.09, 0.115, 0.14, 0.16] +HIGHER_RATES = [0.05, 0.085, 0.1, 0.125, 0.15, 0.17] + + +def raw_scale(name): + file = DIRECTORY / f"{name}.yaml" + data = yaml.safe_load(file.read_text()) + for bracket in data["brackets"]: + for component in ["threshold", "rate"]: + bracket[component] = { + str(date): value for date, value in bracket[component].items() + } + return ParameterScale(f"{PATH}.{name}", data=data, file_path=str(file)) + + +@pytest.mark.parametrize( + "date,thresholds,rates", + [ + ("2018-04-01", OLD_THRESHOLDS, OLD_MAIN_RATES), + ( + "2020-07-27", + [0, 250_000, 400_000, 750_000, 1_500_000], + [0, 0.05, 0.075, 0.1, 0.12], + ), + ("2021-07-01", OLD_THRESHOLDS, OLD_MAIN_RATES), + ("2022-10-09", OLD_THRESHOLDS, OLD_MAIN_RATES), + ("2022-10-10", MAIN_THRESHOLDS, MAIN_RATES), + ], +) +def test_raw_main_table_preserves_history_and_october_change(date, thresholds, rates): + raw = raw_scale("primary") + assert raw(date).thresholds == thresholds + assert raw(date).rates == rates + assert not raw.metadata.get("fiscal_year_blend", False) + assert not raw.metadata.get("preserve_calendar_dates", False) + + +@pytest.mark.parametrize( + "date,rates", + [ + ("2018-04-01", [0.03, 0.065, 0.08, 0.105, 0.13, 0.15]), + ("2020-12-21", [0.03, 0.065, 0.08, 0.105, 0.13, 0.15]), + ("2020-12-22", OLD_HIGHER_RATES), + ("2024-12-10", OLD_HIGHER_RATES), + ("2024-12-11", HIGHER_RATES), + ], +) +def test_raw_higher_table_preserves_history_and_december_change(date, rates): + raw = raw_scale("higher_rate") + assert raw(date).thresholds == OLD_THRESHOLDS + assert raw(date).rates == rates + + +@pytest.mark.parametrize( + "name,date,expected", + [ + ( + "primary", + "2022-10-10", + [0, 0, 0, 1_500, 4_500, 10_500, 18_000, 36_750, 41_750, 111_750, 171_750], + ), + ( + "higher_rate", + "2024-12-11", + [ + 0, + 9_000, + 12_825, + 14_950, + 19_950, + 29_950, + 42_450, + 73_700, + 81_200, + 186_200, + 271_200, + ], + ), + ], +) +def test_new_tables_apply_marginal_rates_across_all_bands(name, date, expected): + prices = np.array( + [ + 0, + 180_000, + 225_000, + 250_000, + 300_000, + 400_000, + 500_000, + 750_000, + 800_000, + 1_500_000, + 2_000_000, + ] + ) + np.testing.assert_allclose( + raw_scale(name)(date).calc(prices), expected, rtol=0, atol=1e-8 + ) + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +@pytest.mark.parametrize( + "name,year,thresholds,rates", + [ + ("primary", 2022, OLD_THRESHOLDS, OLD_MAIN_RATES), + ("primary", 2023, MAIN_THRESHOLDS, MAIN_RATES), + ("higher_rate", 2024, OLD_THRESHOLDS, OLD_HIGHER_RATES), + ("higher_rate", 2025, OLD_THRESHOLDS, HIGHER_RATES), + ], +) +def test_annual_tables_keep_april30_transactional_sampling( + system, name, year, thresholds, rates +): + scale = system.parameters.get_child(f"{PATH}.{name}")(f"{year}-01-01") + assert scale.thresholds == thresholds + assert scale.rates == rates diff --git a/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py b/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py new file mode 100644 index 0000000000..45c54a012a --- /dev/null +++ b/policyengine_uk/tests/test_working_tax_credit_eligibility_properties.py @@ -0,0 +1,116 @@ +"""WTC worker conditions stay with their person and benefit unit. + +These controls use genuine work/claim inputs and literal final-award oracles. +They exercise composition and parameter hooks, rather than duplicate the +production predicate. Disability retains the model's existing generic proxy. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + + +YEAR = 2024 + + +def situation(families, reverse=False): + people, benunits, households = {}, {}, {} + for i, (adults, children) in enumerate(families): + names = [] + for j, inputs in enumerate(adults + children): + name = f"person_{i}_{j}" + people[name] = {key: {YEAR: value} for key, value in inputs.items()} + people[name]["is_claimant_or_partner"] = {YEAR: j < len(adults)} + names.append(name) + if reverse: + names.reverse() + benunits[f"family_{i}"] = {"members": names} + households[f"household_{i}"] = {"members": names} + if reverse: + people = dict(reversed(list(people.items()))) + return {"people": people, "benunits": benunits, "households": households} + + +def worker(age, hours, earnings=5_000, **inputs): + return { + "age": age, + "hours_worked": hours * 52, + "employment_income": earnings if hours else 0, + **inputs, + } + + +@pytest.mark.parametrize("reverse", [False, True]) +def test_worker_conditions_do_not_move_between_people_or_families(reverse): + families = [ + ([worker(40, 0, working_tax_credit_reported=1), worker(24, 30)], []), + ([worker(40, 0, working_tax_credit_reported=1), worker(25, 30)], []), + ( + [ + worker(30, 16, is_parent=True, working_tax_credit_reported=1), + worker(28, 0, is_parent=True), + ], + [worker(15, 8, earnings=1_000)], + ), + ( + [ + worker( + 30, + 16, + earnings=3_000, + is_parent=True, + working_tax_credit_reported=1, + ), + worker(28, 8, earnings=2_000, is_parent=True), + ], + [{"age": 10}], + ), + ] + simulation = Simulation(situation=situation(families, reverse)) + # Request the final award first, so an eligibility-only cache cannot hide + # a cross-person or cross-family error in the cash consumer. + np.testing.assert_allclose( + simulation.calculate("working_tax_credit", YEAR), + [0, 5_950, 0, 4_935], + atol=0.01, + rtol=0, + ) + np.testing.assert_array_equal( + simulation.calculate("is_WTC_eligible", YEAR), [False, True, False, True] + ) + + +@pytest.mark.parametrize( + "age,hours,disabled,parameter,value,baseline_cash", + [ + (25, 30, False, "min_age_without_children_or_disability", 26, 3_450), + (16, 16, True, "min_age", 17, 6_370), + (60, 16, False, "min_hours.old_age", 61, 2_435), + ], +) +def test_worker_age_parameters_change_actual_cash_awards( + age, hours, disabled, parameter, value, baseline_cash +): + inputs = situation( + [ + ( + [ + worker( + age, + hours, + is_disabled_for_benefits=disabled, + working_tax_credit_reported=1, + ) + ], + [], + ) + ] + ) + baseline = Simulation(situation=inputs) + reform = Simulation( + situation=inputs, + reform={f"gov.dwp.tax_credits.working_tax_credit.{parameter}": value}, + ) + assert baseline.calculate("working_tax_credit", YEAR)[0] == baseline_cash + assert reform.calculate("working_tax_credit", YEAR)[0] == 0 diff --git a/policyengine_uk/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/input_dataframe_export.py b/policyengine_uk/utils/input_dataframe_export.py new file mode 100644 index 0000000000..afed6ca230 --- /dev/null +++ b/policyengine_uk/utils/input_dataframe_export.py @@ -0,0 +1,79 @@ +"""Export recorded inputs without treating formulas or caches as provenance.""" + +import pandas as pd + +from policyengine_uk.utils.supplied_inputs import _is_model_value + + +def _legacy_recorded_array(holder, period, branch_name): + """Read the surviving input tier on Core 3.32.27. + + Core 3.33.2 supplies ``Simulation._get_recorded_input_array``. The + supported minimum instead exposes only the holder's combined predicate; + mirror its recorded-location/has/not-derived checks before reading a tier. + An ordinary holder read would let a calculated memory value hide an input + still stored on disk. + """ + recorded = getattr(holder, "_user_input_storage", {}).get( + (branch_name, str(period)) + ) + locations = recorded[1] if recorded is not None else {"memory", "disk"} + for location, storage in ( + ("memory", holder._memory_storage), + ("disk", holder._disk_storage), + ): + if ( + location in locations + and storage is not None + and storage.has(period, branch_name) + and not storage.is_derived(period, branch_name) + ): + return storage.get(period, branch_name) + return None + + +def _recorded_array(simulation, variable, period, branch_names, input_keys): + holder = simulation.get_holder(variable) + for branch_name in branch_names: + key = (variable, branch_name, period) + if key not in input_keys or not holder._stores_user_input(period, branch_name): + continue + reader = getattr(simulation, "_get_recorded_input_array", None) + values = ( + reader(variable, period) + if reader is not None + else _legacy_recorded_array(holder, period, branch_name) + ) + # The Core reader and the fallback choose this same first surviving + # branch. Match the model marker to that source, not another branch + # whose input happens to have the same numeric value. + if values is None or _is_model_value(simulation, key, values): + return None + return values + return None + + +def input_dataframe(simulation): + """Project actual supplied arrays to people, including formula overrides. + + The simulation's existing input record, storage marks and UK model-value + markers remain authoritative. Export neither calculates nor records data. + """ + branch_names = simulation._get_visible_branch_names() + input_keys = getattr(simulation, "_user_input_keys", set()) + periods_by_variable = {} + for variable, branch_name, period in input_keys: + if branch_name in branch_names: + periods_by_variable.setdefault(variable, set()).add(period) + + columns = {} + for name, variable in simulation.tax_benefit_system.variables.items(): + for period in sorted(periods_by_variable.get(name, ()), key=str): + if variable.definition_period != period.unit: + continue + values = _recorded_array(simulation, name, period, branch_names, input_keys) + if values is not None: + columns[f"{name}__{period}"] = simulation.map_result( + values, variable.entity.key, simulation.persons.entity.key + ) + return pd.DataFrame(columns) 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/parameters.py b/policyengine_uk/utils/parameters.py index e7d1be6aed..7410e59535 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -179,6 +179,28 @@ class RemovedParameter(NamedTuple): "gov.simulation.labour_supply_responses.obr_elasticities": RemovedParameter( "removed OBR elasticities switch", _OBR_ELASTICITIES_REPLACEMENT ), + "gov.dwp.pension_credit.income.savings_credit_excluded_sources": RemovedParameter( + "removed duplicate Savings Credit excluded-income list", + "The model never read it. Change " + "gov.dwp.pension_credit.savings_credit.excluded_income, the list of " + "income that is not qualifying income for Savings Credit (SPC Regs " + "2002 reg 9), instead.", + ), + "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.", + ), } diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 754fd23a2d..8b5ecb3ff0 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -3,6 +3,45 @@ from policyengine_uk.utils.supplied_inputs import supplied_input +def trading_profits_before_losses(person, period, hmrc): + """Trading profits chargeable under ITTOIA 2005 Part 2, before losses. + + 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. + """ + 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) + ) + + def pension_contributions_relief_amount( contributions, relevant_uk_earnings, ras_contributions, age, hmrc ): @@ -10,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( @@ -27,28 +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 = ( - person("employment_income", period) - - returned - + np.maximum(0, person("self_employment_income", period)) - ) + 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) @@ -62,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, @@ -118,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/scenario.py b/policyengine_uk/utils/scenario.py index 021e070b1f..3871b09481 100644 --- a/policyengine_uk/utils/scenario.py +++ b/policyengine_uk/utils/scenario.py @@ -72,6 +72,78 @@ def _is_reform_from_dict(reform: Type[Reform]) -> bool: ) +class ConstructionReformState: + """The constructor's writes, until user code edits their parameter paths. + + Ownership compares contents so copied scenarios and member wrappers keep + the construction reading. Current values alone cannot identify writes: + a foreign override can have the same value but a different date reading. + """ + + def __init__(self, simulation): + from policyengine_uk.tax_benefit_system import ( + parameter_reads_calendar_years, + reform_parameter_defines, + ) + + self.owned = copy.deepcopy(tuple(simulation._building_dated_reforms or ())) + self.paths = set() + for dated in self.owned: + assignments, _ = dated.read( + reform_parameter_defines(), + calendar_years=parameter_reads_calendar_years(), + ) + self.paths.update(entry.path for entry in assignments) + self.dirty = set() + self.expected = {} + self.refresh_framework(simulation) + + @staticmethod + def _current(simulation, path): + system = simulation.tax_benefit_system + target = system.parameters.get_child(path) + return copy.deepcopy( + ( + [(entry.instant_str, entry.value) for entry in target.values_list], + [entry for entry in system.dated_assignments if entry.path == path], + [entry for entry in system.legacy_overrides if entry.path == path], + ) + ) + + def _check_paths(self, simulation, paths): + for path in paths: + if self.expected[path] != self._current(simulation, path): + self.dirty.add(path) + return not self.dirty.intersection(paths) + + def pristine(self, simulation, dated_reforms): + if any(dated not in self.owned for dated in dated_reforms): + return False + paths = self.paths.intersection( + path for dated in dated_reforms for path in dated.changes + ) + return self._check_paths(simulation, paths) + + def accept_owned(self, simulation, dated): + # Only called after a pristine own modifier writes its leftovers. + for path in self.paths.intersection(dated.changes): + self.expected[path] = self._current(simulation, path) + + def observe_user_edits(self, simulation): + self._check_paths(simulation, self.paths) + + def refresh_framework(self, simulation): + # The framework may change clean paths between the two user phases. + # A user edit observed before that boundary must not become owned. + for path in self.paths - self.dirty: + self.expected[path] = self._current(simulation, path) + + +def _construction_modifiers_are_pristine(dated_reforms, simulation): + state = getattr(simulation, "_construction_reform_state", None) + return state is not None and state.pristine(simulation, dated_reforms) + + def _legacy_override_modifier(dated: DatedReform) -> Callable[[Simulation], None]: """Override changes that are no longer effective on the simulation. @@ -90,13 +162,26 @@ def modifier(simulation: Simulation) -> None: # Scenario.apply edits an already-built simulation's annualised # parameters, so there every change of the reform is an override. - every_change = getattr(simulation, APPLYING_TO_BUILT_SIMULATION, False) + applying = getattr(simulation, APPLYING_TO_BUILT_SIMULATION, False) + construction_owned = not applying and _construction_modifiers_are_pristine( + (dated,), simulation + ) + state = getattr(simulation, "_construction_reform_state", None) + construction_replay = ( + not applying + and state is not None + and dated in state.owned + and not construction_owned + ) + every_change = applying or construction_replay + if construction_replay: + _warn_annualised_overrides() if every_change: _, legacy = read_override_reform( dated.changes, dated.reading, lambda path: False ) overriding = True - elif dated in (getattr(simulation, "_building_dated_reforms", None) or ()): + elif construction_owned: _, legacy = dated.read( reform_parameter_defines(), warn=True, @@ -114,11 +199,7 @@ def modifier(simulation: Simulation) -> None: parameter_reads_calendar_years(), simulation.tax_benefit_system, ) - overriding = overriding or ( - bool(legacy) - and dated - not in (getattr(simulation, "_building_dated_reforms", None) or ()) - ) + overriding = overriding or (bool(legacy) and not construction_owned) if legacy: if overriding: if not every_change: @@ -138,6 +219,8 @@ def modifier(simulation: Simulation) -> None: require_annual_value=overriding, ) raise + if construction_owned: + simulation._construction_reform_state.accept_owned(simulation, dated) from policyengine_uk.scenarios.uc_reform import warn_shadowed_lcwra_reform warn_shadowed_lcwra_reform(simulation) @@ -238,7 +321,7 @@ def chained(simulation: Simulation) -> None: replay = ( dated is not None and not applying - and not getattr(simulation, "_building_dated_reforms", None) + and not _construction_modifiers_are_pristine(dated, simulation) ) if replay: _warn_annualised_overrides() 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/gov/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 6626fc9842..9e725b692d 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -3,18 +3,29 @@ class free_tv_licence_value(Variable): label = "free TV licence value" + documentation = ( + "Value included in HBAI income for the share of the year covered by " + "the complete age-based exemption. The blind discount is excluded." + ) entity = Household definition_period = YEAR value_type = float - unit = "/1" + unit = GBP reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", - "https://www.tvlicensing.co.uk/reducedfee", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + "https://www.legislation.gov.uk/uksi/2026/104/made", + "https://www.gov.uk/free-discount-tv-licence", + "https://www.gov.uk/government/statistics/households-below-average-income-for-financial-years-ending-1995-to-2025/households-below-average-income-background-information-and-methodology-report-fye-2025", ) def formula(household, period, parameters): - owns_tv = household("household_owns_tv", period) - discount = household("tv_licence_discount", period) + licence_required = household("tv_licence_required", period) + free_licence_share = household( + "tv_licence_free_licence_share", + period, + ) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (owns_tv & ~would_evade) * fee * discount + return (licence_required & ~would_evade) * fee * free_licence_share diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py index ef0fb57f54..ad6c0eb9e7 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence.py @@ -8,12 +8,15 @@ class tv_licence(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2003/21/section/363" + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + ) category = TAX def formula(household, period, parameters): - owns_tv = household("household_owns_tv", period) + licence_required = household("tv_licence_required", period) discount = household("tv_licence_discount", period) would_evade = household("would_evade_tv_licence_fee", period) fee = parameters(period).gov.dcms.bbc.tv_licence.colour - return (owns_tv & ~would_evade) * fee * (1 - discount) + return (licence_required & ~would_evade) * fee * (1 - discount) diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py index 655b1eca18..c220d08179 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_discount.py @@ -1,5 +1,4 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.excise import fiscal_year_segments class tv_licence_discount(Variable): @@ -17,24 +16,14 @@ class tv_licence_discount(Variable): def formula(household, period, parameters): person = household.members tv_licence = parameters(period).gov.dcms.bbc.tv_licence - - # Aged discount - claims_pc = add(household, period, ["pension_credit"]) > 0 - aged_discount = 0 - for aged_rules, share in fiscal_year_segments( - parameters.gov.dcms.bbc.tv_licence.discount.aged, - period.start.year, - ): - aged = person("age", period) >= aged_rules.min_age - has_aged = household.any(aged) - meets_pc_requirement = not_(aged_rules.must_claim_pc) | claims_pc - aged_discount += ( - has_aged * meets_pc_requirement * aged_rules.discount * share - ) + free_licence_share = household( + "tv_licence_free_licence_share", + period, + ) # Blind discount is_blind = person("is_blind", period) has_blind = household.any(is_blind) blind_discount = has_blind * tv_licence.discount.blind.discount - return max_(aged_discount, blind_discount) + return free_licence_share + (1 - free_licence_share) * blind_discount diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py new file mode 100644 index 0000000000..5be64444cc --- /dev/null +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_free_licence_share.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.excise import fiscal_year_segments + + +class tv_licence_free_licence_share(Variable): + label = "Share of the year covered by a free TV licence" + documentation = ( + "Share of the year for which the household qualifies for the complete " + "age-based TV licence exemption. This excludes the blind discount." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", + "https://www.gov.uk/government/news/bbc-and-governments-joint-statement-on-delaying-the-tv-licence-fee-for-over-75s", + "https://www.gov.uk/free-discount-tv-licence", + ) + + def formula(household, period, parameters): + person = household.members + claims_pc = add(household, period, ["pension_credit"]) > 0 + free_licence_share = 0 + for aged_rules, share in fiscal_year_segments( + parameters.gov.dcms.bbc.tv_licence.discount.aged, + period.start.year, + ): + has_aged = household.any(person("age", period) >= aged_rules.min_age) + meets_pc_requirement = not_(aged_rules.must_claim_pc) | claims_pc + free_licence_share += ( + has_aged * meets_pc_requirement * aged_rules.discount * share + ) + return free_licence_share diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py new file mode 100644 index 0000000000..f7acf05b1d --- /dev/null +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/tv_licence_required.py @@ -0,0 +1,22 @@ +from policyengine_uk.model_api import * + + +class tv_licence_required(Variable): + label = "TV licence required" + documentation = ( + "Whether anyone in the household watches or records live television " + "or uses BBC iPlayer. When this is not provided directly, television " + "ownership is used as a backward-compatible proxy. That proxy can " + "overstate liability for owners who use only non-BBC on-demand services " + "and understate it for non-owners who watch live television or BBC iPlayer." + ) + entity = Household + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/ukpga/2003/21/section/363", + "https://www.legislation.gov.uk/uksi/2016/704/regulation/9/made", + ) + + def formula(household, period, parameters): + return household("household_owns_tv", period) diff --git a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py index 61be081395..7239d88489 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/would_evade_tv_licence_fee.py @@ -5,7 +5,8 @@ class would_evade_tv_licence_fee(Variable): label = "Would evade TV licence fee" documentation = ( "Whether this household would unlawfully evade the TV licence fee. " - "Generated stochastically in the dataset using evasion rates." + "Generated stochastically in the dataset using evasion rates. The " + "realised result does not change in response to reforms of the fee." ) entity = Household definition_period = YEAR 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/CTC_family_element.py b/policyengine_uk/variables/gov/dwp/CTC_family_element.py index 6d4b74d0ad..07babc5bdb 100644 --- a/policyengine_uk/variables/gov/dwp/CTC_family_element.py +++ b/policyengine_uk/variables/gov/dwp/CTC_family_element.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day class CTC_family_element(Variable): @@ -6,11 +7,23 @@ class CTC_family_element(Variable): entity = BenUnit label = "CTC entitlement in the Family Element" definition_period = YEAR - reference = "Tax Credits Act 2002 s. 9" + reference = [ + "https://www.legislation.gov.uk/ukpga/2002/21/section/9", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/7", + ] unit = GBP defined_for = "is_CTC_eligible" def formula(benunit, period, parameters): - return parameters( - period - ).gov.dwp.tax_credits.child_tax_credit.elements.family_element + p = parameters(period).gov.dwp.tax_credits.child_tax_credit + if not p.eligibility.family_element_birth_date_condition_applies: + return p.elements.family_element + + person = benunit.members + qualifying_child = person( + "is_child_or_qualifying_young_person_for_child_tax_credit", period + ) + born_before = ( + birth_day(person, period) < p.eligibility.family_element_born_before + ) + return p.elements.family_element * benunit.any(qualifying_child & born_before) 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 b49b6618d0..e3dc980d29 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py @@ -9,8 +9,10 @@ class carers_allowance_earnings(Variable): documentation = ( "Net earnings from employment and self-employment, as the Carer's " "Allowance gainful-employment test counts them (Computation of " - "Earnings Regulations 1996), less half of the person's pension " - "contributions. A trading loss counts as nil.\n\n" + "Earnings Regulations 1996), less a share of the person's pension " + "contributions: one half in law (regs. 10(4)(b) and 13(4)(c)), read " + "from gov.dwp.carers_allowance.pension_contribution_deduction_rate. " + "A trading loss counts as nil.\n\n" "Employment (reg. 10(4)): gross pay less the income tax and primary " "Class 1 contributions deducted from it. The law deducts the tax " "actually withheld; the model approximates it as the tax PAYE would " @@ -119,7 +121,12 @@ def formula(person, period, parameters): * class_2_contribution_weeks(period.start.year) ) - half_pension_contributions = 0.5 * person("pension_contributions", period) + # Regs. 10(4)(b) and 13(1)(b)(ii), (4)(c) and (10)(b): one half of + # pension contributions and premiums. + pension_share = parameters( + period + ).gov.dwp.carers_allowance.pension_contribution_deduction_rate + pension_deduction = pension_share * person("pension_contributions", period) return max_( 0, pay @@ -129,5 +136,5 @@ def formula(person, period, parameters): - trading_tax - trading_class_4 - trading_class_2 - - half_pension_contributions, + - pension_deduction, ) 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/_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py new file mode 100644 index 0000000000..5ac9560f4d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/_net_earnings.py @@ -0,0 +1,49 @@ +from policyengine_uk.model_api import * + + +def person_net_earnings(person, period, pension_contribution_deduction_rate): + """A person's net earnings under the legacy means tests' earnings rules. + + Employment income plus self-employment profit (each floored at zero) plus + statutory sick and maternity pay, less the income tax attributed to them, + Class 1, 2 and 4 National Insurance and the scheme's share of pension + contributions, floored at zero. Housing Benefit + (housing_benefit_net_earnings) and the Council Tax Reduction pensioner + earnings disregards (council_tax_reduction_net_earnings) use these + deductions. Each scheme's regulations state their own share of pension + contributions, so each passes its own rate. + """ + employment_income = person("employment_income", period) + self_employment_income = person("self_employment_income", period) + # Statutory sick and maternity pay are earnings (HB Regs 2006 reg + # 35(1)(i); pension age reg 35(1)(h)). The model taxes them as employment income + # (employment_benefits) but leaves them out of total_income. + statutory_pay = person("employment_benefits", period) + earnings = ( + max_(employment_income, 0) + max_(self_employment_income, 0) + statutory_pay + ) + # Income tax is attributed by the earnings' share of total income, + # with statutory pay added and before any negative component (a + # loss), so that a loss set against other income does not drop or + # inflate the share of tax on these earnings. + income_before_losses = person("total_income", period) + statutory_pay + system = person.simulation.tax_benefit_system + for component in system.get_variable("total_income").adds: + income_before_losses += max_(-person(component, period), 0) + earnings_share = np.divide( + earnings, + income_before_losses, + out=np.zeros_like(earnings), + where=income_before_losses > 0, + ) + income_tax = person("income_tax", period) * min_(earnings_share, 1) + national_insurance = add( + person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] + ) + pension_contributions = ( + person("pension_contributions", period) * pension_contribution_deduction_rate + ) + return max_( + earnings - income_tax - national_insurance - pension_contributions, + 0, + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index b97d9ac316..762b749466 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -67,6 +67,17 @@ def formula(benunit, period, parameters): "statutory_sick_pay", "statutory_maternity_pay", "ssmg", + # Counted by both regimes: HB (SPC) Regs 2006 reg 29(1)(j), whose + # exceptions touching these are the IIDB increases under SSCBA + # ss.104-105 (head (iii)) and non-partner dependant increases (head + # (viii)); heads (iv) and (v) cover the Sch 8 schemes for old + # industrial injury cases. And HB Regs 2006 reg 40 with Sch 5, + # whose para 9 attendance allowance disregard covers the + # ss.104-105 increases (reg 2(1)). iidb is the whole reported + # payment and cannot separate those increases. + "iidb", + "sda", + "incapacity_benefit", ] INCOME_COMPONENTS = [ "employment_income", @@ -90,8 +101,10 @@ def formula(benunit, period, parameters): benunit, period, ["basic_income"], members ) # Reduce increased income by pension contributions and tax + p = parameters(period).gov.dwp.housing_benefit.means_test pension_contributions = ( - add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) + * p.pension_contribution_deduction_rate ) TAX_COMPONENTS = ["income_tax", "national_insurance"] tax = add_for_members(benunit, period, TAX_COMPONENTS, members) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py index 8f8d97ac9a..d060fcf3c0 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_net_earnings.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.applicable_income._net_earnings import ( + person_net_earnings, +) class housing_benefit_net_earnings(Variable): @@ -12,8 +15,9 @@ class housing_benefit_net_earnings(Variable): "in one employment is not set against another) plus statutory sick " "and maternity pay (regulation 35(1)(i); pension age regulation " "35(1)(h)), less income tax, " - "Class 1, 2 and 4 National Insurance, and half of pension " - "contributions, floored at zero. For an employee the regulations " + "Class 1, 2 and 4 National Insurance, and Housing Benefit's share of " + "pension contributions (pension_contribution_deduction_rate, one-half " + "in law), floored at zero. For an employee the regulations " "deduct the income tax deducted from the earnings (regulation 36(3); " "pension age regulation 36(2)); for the self-employed, a notional " "basic-rate tax on the profit alone less personal reliefs (regulation " @@ -46,40 +50,8 @@ class housing_benefit_net_earnings(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.housing_benefit.means_test person = benunit.members - employment_income = person("employment_income", period) - self_employment_income = person("self_employment_income", period) - # Statutory sick and maternity pay are earnings (reg 35(1)(i); pension - # age reg 35(1)(h)). The model taxes them as employment income - # (employment_benefits) but leaves them out of total_income. - statutory_pay = person("employment_benefits", period) - earnings = ( - max_(employment_income, 0) + max_(self_employment_income, 0) + statutory_pay - ) - # Income tax is attributed by the earnings' share of total income, - # with statutory pay added and before any negative component (a - # loss), so that a loss set against other income does not drop or - # inflate the share of tax on these earnings. - income_before_losses = person("total_income", period) + statutory_pay - system = person.simulation.tax_benefit_system - for component in system.get_variable("total_income").adds: - income_before_losses += max_(-person(component, period), 0) - earnings_share = np.divide( - earnings, - income_before_losses, - out=np.zeros_like(earnings), - where=income_before_losses > 0, - ) - income_tax = person("income_tax", period) * min_(earnings_share, 1) - national_insurance = add( - person, period, ["ni_class_1_employee", "ni_class_2", "ni_class_4"] - ) - pension_contributions = ( - person("pension_contributions", period) - * p.pension_contribution_deduction_rate - ) - net_earnings = max_( - earnings - income_tax - national_insurance - pension_contributions, - 0, + net_earnings = person_net_earnings( + person, period, p.pension_contribution_deduction_rate ) # The claimant and partner: a child's or young person's earnings are # not the claimant's (reg 25(3); pension age reg 23(3)). diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py index 74fb57f8e4..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 " @@ -33,11 +28,13 @@ class housing_benefit_savings_credit_only_income(Variable): "maintenance disregards (neither income definition counts " "maintenance), the exempt work disregard, the income and capital of a " "partner treated as a household member but not in the Pension Credit " - "assessment, income of a non-dependant treated as the claimant's, and " - "war pensions (Great Britain: local schemes under section 134(8) of " + "assessment, and income of a non-dependant treated as the claimant's. " + "War pensions (Great Britain: local schemes under section 134(8) of " "the Social Security Administration Act 1992; Northern Ireland: war " - "pension income above £10 disregarded under Schedule 6 paragraph 1); " - "the model has no war pension income." + "pension income above £10 disregarded under Schedule 6 paragraph 1) " + "are taken as wholly disregarded, as in the general route: the war " + "pension income the Secretary of State counts " + "(pension_credit_war_pension_income) is removed from the assessment." ) definition_period = YEAR unit = GBP @@ -51,8 +48,16 @@ class housing_benefit_savings_credit_only_income(Variable): def formula(benunit, period, parameters): # SI 2006/214 reg 27(1) and (3) (NI: SR 2006/406 reg 25): the - # Secretary of State's assessment of net income for the award. - pension_credit_income = benunit("pension_credit_income", period) + # Secretary of State's assessment of net income for the award, less + # the war pension income it counts: the model takes the authority to + # disregard war pensions in full under a section 134(8) local scheme, + # as the general route does (HB/CTB Guidance Manual P2.576 lets it + # modify the Pension Service's figure for that). + pension_credit_income = max_( + 0, + benunit("pension_credit_income", period) + - benunit("pension_credit_war_pension_income", period), + ) # Reg 27(4)(a): the savings credit payable. A savings-credit-only # award pays only the savings credit, so this is the Pension Credit # paid (under the Pension Credit freeze, the frozen amount). @@ -63,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/housing_benefit/housing_benefit_applicable_amount.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py index cba43eaafe..35b27aa0a7 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_applicable_amount.py @@ -31,20 +31,39 @@ def formula(benunit, period, parameters): single = benunit("is_single_person", period) couple = benunit("is_couple", period) lone_parent = benunit("is_lone_parent", period) + # Pension-age allowances (SI 2006/214 Sch 3 para 1; NI: SR 2006/406 + # Sch 4 para 1). Since 1 April 2021 units whose members all attained + # State Pension age on or after the cutoff get the lower rates in + # sub-paragraphs (1)(c) and (2)(c); the rest keep the higher rates. + if p.pension_age_cutoff is None: + single_aged, couple_aged, lone_parent_aged = ( + p.single.aged, + p.couple.aged, + p.lone_parent.aged, + ) + else: + before_cutoff = benunit( + "housing_benefit_attained_pension_age_before_cutoff", period + ) + single_aged = where(before_cutoff, p.single.aged, p.single.aged_from_cutoff) + couple_aged = where(before_cutoff, p.couple.aged, p.couple.aged_from_cutoff) + lone_parent_aged = where( + before_cutoff, p.lone_parent.aged, p.lone_parent.aged_from_cutoff + ) single_personal_allowance = ( u_25 * p.single.younger + o_25 * p.single.older - + pension_age_regulations * p.single.aged + + pension_age_regulations * single_aged ) couple_personal_allowance = ( u_18 * p.couple.younger + o_18 * p.couple.older - + pension_age_regulations * p.couple.aged + + pension_age_regulations * couple_aged ) lone_parent_personal_allowance = ( u_18 * p.lone_parent.younger + o_18 * p.lone_parent.older - + pension_age_regulations * p.lone_parent.aged + + pension_age_regulations * lone_parent_aged ) personal_allowance = ( single * single_personal_allowance diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py new file mode 100644 index 0000000000..4ef0a58602 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_attained_pension_age_before_cutoff.py @@ -0,0 +1,56 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_attained_pension_age_before_cutoff(Variable): + value_type = bool + entity = BenUnit + label = "claimant or partner attained State Pension age before the Housing Benefit cutoff" + documentation = ( + "Whether the Housing Benefit claimant or their partner attained State " + "Pension age before the pension-age cutoff (1 April 2021). Such units " + "keep the higher pension-age personal allowance; units where neither " + "did get the lower one. Other members of the benefit unit do not " + "count. Before the " + "cutoff took effect there was a single pension-age allowance, the " + "higher one, so this is true for every unit then." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", + "https://www.legislation.gov.uk/uksi/2021/188/regulation/2/made", + "https://www.legislation.gov.uk/nisr/2006/406/schedule/4", + ) + + def formula(benunit, period, parameters): + cutoff = parameters( + period + ).gov.dwp.housing_benefit.allowances.pension_age_cutoff + if cutoff is None: + return np.ones(benunit.count, dtype=bool) + # "Pensionable age" is State Pension age under Pensions Act 1995 Sch 4 + # para 1 (SSCBA 1992 s.122(1), applied to these regulations by the + # Interpretation Act 1978 s.11), which state_pension_age_date follows. + # + # Para 1(2)(b) keeps the higher couple rate where "one member or both + # members" attained pensionable age before the cutoff, and (2)(c) gives + # the lower one where "both members" attained it on or after. Neither + # covers a couple where one member has not reached it and the other did + # so after the cutoff. In law that couple cannot be on pension-age + # Housing Benefit: mixed-age couples keep it only under the savings for + # awards from before 15 May 2019 (SI 2019/37 art 4), when the older + # member had already reached it. PolicyEngine can still give such a + # unit pension-age rates through a reported continuing award; it gets + # the lower rate, since the explanatory note to SI 2021/188 keeps the + # uplift only where "one or more members ... attained pensionable age + # before 1st April 2021". + # + # Dates are compared as YYYYMMDD integers, which are exact; as float32 + # they would not be (20210401 would round to 20210400). + # + # Schedule 3 para 1 reads the claimant's and partner's ages ("the + # claimant", "one member or both members" of a couple), so other + # members of the benefit unit do not count. + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + attained = person("state_pension_age_date", period) + return benunit.any(claimant_or_partner & (attained < cutoff)) diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index eea502562e..b0bad392d8 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -7,7 +7,11 @@ class income_support_applicable_income(Variable): label = "Relevant income for Income Support means test" definition_period = YEAR unit = GBP - reference = "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8" + reference = ( + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/36", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/38", + ) def formula(benunit, period, parameters): # Members whose income counts: the claimant and partner and, as the model did @@ -35,8 +39,10 @@ def formula(benunit, period, parameters): income += add_for_members(benunit, period, ["social_security_income"], members) income += benunit("income_support_tariff_income", period) income -= tax + # IS Regs 1987 reg 36(3)(b) and reg 38(1)(b)(ii), (3)(c) and (9)(b). income -= ( - add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 + add_for_members(benunit, period, ["pension_contributions"], members) + * IS.means_test.pension_contribution_deduction_rate ) # Schedule 8 paras 5, 6 and 10 use mutually exclusive claimant types. mt = IS.means_test diff --git a/policyengine_uk/variables/gov/dwp/income_support_entitlement.py b/policyengine_uk/variables/gov/dwp/income_support_entitlement.py index 3cfebc284a..e27374ebc2 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_entitlement.py +++ b/policyengine_uk/variables/gov/dwp/income_support_entitlement.py @@ -12,4 +12,5 @@ def formula(benunit, period, parameters): amount = benunit("income_support_applicable_amount", period) income = benunit("income_support_applicable_income", period) eligible = benunit("income_support_eligible", period) - return max_(0, amount - income) * eligible + rate = parameters(period).gov.dwp.income_support.means_test.withdrawal_rate + return max_(0, amount - rate * income) * eligible diff --git a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py index 84b2d6c770..9e18d4d049 100644 --- a/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py +++ b/policyengine_uk/variables/gov/dwp/is_WTC_eligible.py @@ -51,3 +51,46 @@ def formula(benunit, period, parameters): | (medium_req & meets_medium) | (higher_req & meets_higher) ) & already_claiming + + def formula_2012_04_06(benunit, period, parameters): + WTC = parameters(period).gov.dwp.tax_credits.working_tax_credit + person = benunit.members + claimant = person("is_claimant_or_partner", period) + age = person("age", period) + hours = person("weekly_hours", period) + disabled = person("is_disabled_for_benefits", period) + works_lower_hours = claimant & (hours >= WTC.min_hours.lower) + working_at_minimum_age = works_lower_hours & (age >= WTC.min_age) + disabled_worker = benunit.any(working_at_minimum_age & disabled) + older_worker = benunit.any(works_lower_hours & (age >= WTC.min_hours.old_age)) + + responsible_for_child = benunit( + "is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit", + period, + ) + ordinary_worker = ~responsible_for_child & benunit.any( + claimant + & (age >= WTC.min_age_without_children_or_disability) + & (hours >= WTC.min_hours.default) + ) + lone_parent = ( + benunit("is_single", period) + & responsible_for_child + & benunit.any(working_at_minimum_age) + ) + joint_parents = benunit("is_couple", period) & responsible_for_child + meets_joint_hours = ( + benunit.sum(hours * claimant) >= WTC.min_hours.couple_with_children + ) & benunit.any(working_at_minimum_age) + # Preserve the existing other-disabled-partner exception proxy. This + # generic flag does not encode every reg 13 exception or disability test. + partner_exception_proxy = benunit.any(claimant & disabled) & benunit.any( + working_at_minimum_age + ) + parents = joint_parents & (meets_joint_hours | partner_exception_proxy) + already_claiming = ( + add(benunit, period, ["working_tax_credit_reported"]) > 0 + ) & ~benunit("legacy_benefits_closed", period) + return ( + disabled_worker | older_worker | ordinary_worker | lone_parent | parents + ) & already_claiming 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_reported_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py index 536c490645..58fcbcf789 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_reported_capital.py @@ -13,7 +13,7 @@ class pension_credit_reported_capital(Variable): "the benefit unit's recorded capital (benunit_reported_capital) unless " "set directly. When this is 0 or more it replaces every capital source " "in Pension Credit assessable capital: the household proxy (savings, " - "land, property and corporate_wealth) and the person-level sources such " + "land, property and share_like_capital) and the person-level sources such " "as a Lifetime ISA. The model applies neither the Schedule V disregards " "nor the reg. 19 valuation to it, so it must already be the countable " "figure, and reforms to the capital source parameters do not reach a " 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..d8be62b50a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -0,0 +1,81 @@ +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.thresholds.lower_profits_limit + upper = class_4.thresholds.upper_profits_limit + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.rates.main * min_(max_(0, profit - lower), upper - lower), + 0, + ) + 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/pension_credit_war_pension_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py new file mode 100644 index 0000000000..1abb1d31ec --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_war_pension_income.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class pension_credit_war_pension_income(Variable): + label = "War pension income counted for Pension Credit" + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + documentation = ( + "The claimant's and partner's war disablement pensions, war widow's or " + "widower's pensions and Armed Forces Compensation Scheme guaranteed " + "income payments, each payment less the Sch. IV para. 1 disregard " + "(£10 of each payment, the reading PolicyEngine applies). afcs holds " + "Armed Forces Compensation Scheme payments, including guaranteed " + "income payments, and war disablement pensions together, so a person " + "with both gets one £10 on the sum, not £10 on each. war_widows_pension " + "holds war widow's or widower's pensions. Each of the two variables " + "gets its own weekly disregard, which does not pass to the other or to " + "the other member of a couple. Dependants' and other benefit-unit " + "members' payments do not count (State Pension Credit Act 2002 s.5; " + "reg. 14). Not modelled: Sch. IV para. 1(cc), under which the unused " + "part of the £10 on a guaranteed income payment abated below £10 by a " + "pension or payment within article 31(1)(a) or (b) of the Armed Forces " + "and Reserve Forces (Compensation Scheme) Order 2005 (an Armed Forces " + "Pension Scheme pension or Early Departure Scheme payment) comes off " + "that pension or payment; the " + "components that Sch. IV paras 2 to 6 and 12 disregard in full; and " + "reg. 15(5)(a) and (ab) payments that count in full. The data cannot " + "separate any of them." + ) + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/15", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/14", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/15", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/IV/paragraph/1", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.pension_credit.income + person = benunit.members + disregard = p.war_pension_disregard * WEEKS_IN_YEAR + counted = 0 + for payment in ["afcs", "war_widows_pension"]: + counted += max_(person(payment, period) - disregard, 0) + return benunit.sum(counted * person("is_claimant_or_partner", period)) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py index 65a2474b9f..62b6eb3e2c 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit.py @@ -8,6 +8,13 @@ class savings_credit(Variable): value_type = float unit = GBP reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" + documentation = ( + "The savings credit element of State Pension Credit, zero unless the " + "benefit unit meets the shared Pension Credit entitlement conditions. " + "This is the computed component before Pension Credit take-up; a " + "positive amount does not by itself establish an award in payment." + ) + defined_for = "is_pension_credit_eligible" def formula(benunit, period, parameters): income = benunit("savings_credit_income", period) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py index 856e7e99f1..418d15124f 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/savings_credit_income.py @@ -21,8 +21,15 @@ def formula(benunit, period, parameters): pc = parameters(period).gov.dwp.pension_credit person = benunit.members is_claimant_or_partner = person("is_claimant_or_partner", period) - excluded_income = benunit.sum( - add(person, period, pc.savings_credit.excluded_income) - * is_claimant_or_partner - ) + excluded_income = 0 + for source in pc.savings_credit.excluded_income: + # Person-level sources count for the claimant and partner only, + # each floored at nil as pension_credit_income counts them; + # benefit-unit sources (working tax credit) count once. + if person.entity.get_variable(source).entity.is_person: + excluded_income += benunit.sum( + max_(0, person(source, period)) * is_claimant_or_partner + ) + else: + excluded_income += add(benunit, period, [source]) return max_(0, guarantee_credit_income - excluded_income) diff --git a/policyengine_uk/variables/gov/dwp/state_pension_age_date.py b/policyengine_uk/variables/gov/dwp/state_pension_age_date.py new file mode 100644 index 0000000000..d343cfd694 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/state_pension_age_date.py @@ -0,0 +1,44 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import ( + add_months_to_yyyymmdd, + birth_instant, + grid_months_to_yyyymmdd, +) + + +class state_pension_age_date(Variable): + value_type = int + entity = Person + label = "date this person attains State Pension age" + documentation = ( + "The day on which this person attains State Pension age (pensionable " + "age), written as a YYYYMMDD number. It is set by their date of birth: " + "date_of_birth where given, otherwise from age and " + "months_since_last_birthday." + ) + definition_period = YEAR + unit = "date" + reference = ( + "https://www.legislation.gov.uk/ukpga/1995/26/schedule/4", + "https://www.legislation.gov.uk/ukpga/2014/19/section/26", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.state_pension.age + # The day of birth, read as state_pension_age reads it: the day + # starting at or after the instant of birth (birth_instant). + birth_date = grid_months_to_yyyymmdd(birth_instant(person, period)) + + # Pensions Act 1995 Sch 4 para 1 rule (1): men born before 6 December + # 1953 attain pensionable age at 65. Everyone else follows the + # timetable in rules (2) to (10), which sets either an age or a day. + male_rule = person("is_male", period) & (birth_date < p.male.born_before) + age_rule = where(male_rule, p.male.age, p.age_by_birth_date.calc(birth_date)) + day_rule = where(male_rule, 0, p.day_by_birth_date.calc(birth_date)) + # An age of N years and M months is attained at the commencement of + # the anniversary (Family Law Reform Act 1969 s.9(1)): the same day of + # the month, or the month's last day where that day does not exist, + # which also gives the three days in rule (7A). Where the statute sets + # a day instead, the day rule is later; otherwise it is zero. + anniversary = add_months_to_yyyymmdd(birth_date, age_rule) + return np.maximum(anniversary, day_rule) diff --git a/policyengine_uk/variables/gov/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/benefit_cap/uc_benefit_cap_reduction_after_floor.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction_after_floor.py new file mode 100644 index 0000000000..dfc789654f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction_after_floor.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) + + +class uc_benefit_cap_reduction_after_floor(Variable): + label = "UC benefit cap reduction applied" + documentation = ( + "Benefit cap reduction actually taken off this benefit unit's " + "Universal Credit award. Under current law this is " + "uc_benefit_cap_reduction (the regulation 81 amount), bounded by the " + "award: a cap reduction larger than the award takes the award to " + "zero and no further. Where a protected minimum floor binds, " + "deductions are met first, up to the floor allowance, and this takes " + "whatever allowance is left. uc_benefit_cap_reduction itself stays " + "gross, because benefit_cap_reduction and the deduction incidence " + "read it, and the floor is a reform to the award, not to the cap." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + defined_for = "claims_universal_credit" + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.universal_credit.deductions + # JRF's September 2026 example (standard allowance £98 a week, a £15 + # Fair Repayment Rate deduction, an £80 cap reduction, an 85% floor) + # limits the total to £15. Meeting the deduction first, as here, + # removes the whole cap reduction. + _, cap_reduction = allocate_uc_reductions( + benunit("universal_credit_pre_benefit_cap", period), + benunit("uc_benefit_cap_reduction", period), + benunit("uc_deduction_rate", period) + * benunit("uc_standard_allowance", period), + benunit("uc_maximum_reduction", period), + p.minimum_payable * MONTHS_IN_YEAR, + ) + return cap_reduction diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py new file mode 100644 index 0000000000..beaee1644c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/meets_uc_higher_disabled_child_condition.py @@ -0,0 +1,41 @@ +from policyengine_uk.model_api import * + + +class meets_uc_higher_disabled_child_condition(Variable): + value_type = bool + entity = Person + label = "Meets the Universal Credit higher disabled child condition" + definition_period = YEAR + documentation = ( + "The higher UC disabled child condition has an independent certified " + "blindness limb and qualifying DLA care or PIP daily living awards. " + "The model reads DLA and PIP amounts, computed from their categories " + "unless an amount is supplied directly. It retains the existing " + "nonstatutory weekly amount tolerance for those paid-amount proxies; " + "they do not establish underlying entitlement from partial payments. " + "Separate Scottish awards and benefit payable but for a hospital " + "stay are not represented. The child amount separately checks " + "dependent child or qualifying young person status and excludes " + "the UC claimant. The shared tax-credit severe disability flag, " + "attendance allowance and AFIP do not establish this UC condition." + ) + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/24", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/25", + ) + + def formula(person, period, parameters): + dwp = parameters(period).gov.dwp + tolerance = ( + dwp.universal_credit.elements.child.severely_disabled.benefit_amount_tolerance + * WEEKS_IN_YEAR + ) + highest_dla_care = ( + person("dla_sc", period) + >= dwp.dla.self_care.higher * WEEKS_IN_YEAR - tolerance + ) + enhanced_pip_daily_living = ( + person("pip_dl", period) + >= dwp.pip.daily_living.enhanced * WEEKS_IN_YEAR - tolerance + ) + return person("is_blind", period) | highest_dla_care | enhanced_pip_daily_living diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py index 189e875499..33b5412777 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/severe_disability/uc_individual_severely_disabled_child_element.py @@ -7,7 +7,7 @@ class uc_individual_severely_disabled_child_element(Variable): label = "Universal Credit severely disabled child element" definition_period = YEAR unit = GBP - defined_for = "is_severely_disabled_for_benefits" + defined_for = "meets_uc_higher_disabled_child_condition" reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/24/2" def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py index 578b8a388f..0ad1ebdc85 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/disability/uc_individual_disabled_child_element.py @@ -15,4 +15,5 @@ def formula(person, period, parameters): child = person( "is_child_or_qualifying_young_person_for_universal_credit", period ) & ~person("is_uc_claimant", period) - return (child * p.amount) * MONTHS_IN_YEAR + higher_condition = person("meets_uc_higher_disabled_child_condition", period) + return (child * ~higher_condition * p.amount) * MONTHS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py new file mode 100644 index 0000000000..b556390750 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/_reduction_allocation.py @@ -0,0 +1,63 @@ +"""Split a Universal Credit award's reductions between debt deductions and +the benefit cap. + +uc_deductions and uc_benefit_cap_reduction_after_floor both call this, so +they agree by construction, and neither reads the other, which keeps the +graph acyclic. +""" + +from policyengine_uk.model_api import * + + +def allocate_uc_reductions( + pre_cap_award, gross_cap_reduction, deduction_demand, max_reduction, minimum_payable +): + """Return (deductions, applied cap reduction) for each benefit unit. + + pre_cap_award: universal_credit_pre_benefit_cap. + gross_cap_reduction: uc_benefit_cap_reduction, the regulation 81 amount + of SI 2013/376. benefit_cap_reduction and uc_has_deduction read it + too, so it is never floored. + deduction_demand: the deduction rate times the standard allowance, + before the one-penny rule. + max_reduction: uc_maximum_reduction, infinite when no protected floor is + in force (current law). + minimum_payable: the one-penny rule of Schedule 6 paragraph 3(1)(a) of + SI 2013/380, annualised. + + Current law: regulation 81 reduces the award by the cap first, and + deductions come out of what is left, leaving at least a penny payable + (main's operations, in main's order). The applied cap reduction is the + award actually lost, so it is bounded by the award; the gross reduction + can exceed it. + + Under a protected floor nothing changes unless that split takes more + than the floor allowance. Where it does, deductions are met first from + the protected award, up to the allowance, and the cap reduction takes + whatever allowance is left. Working from the deduction demand rather + than the current-law deduction matters for heavily capped awards: there + the penny rule had already squeezed the deduction under the gross cap, + but the floor removes most of that cap. + """ + floor_active = np.isfinite(max_reduction) + + # Current law. + award = max_(pre_cap_award - gross_cap_reduction, 0) + deductions = min_(deduction_demand, max_(award - minimum_payable, 0)) + cap_reduction = min_(gross_cap_reduction, max_(pre_cap_award - deductions, 0)) + + # Protected floor binding: deductions first, the cap reduction absorbs. + binds = floor_active & (cap_reduction + deductions > max_reduction) + floor_deductions = min_( + min_(deduction_demand, max_reduction), + max_(pre_cap_award - minimum_payable, 0), + ) + penny = where(floor_deductions > 0, minimum_payable, 0) + floor_cap_reduction = min_( + min_(gross_cap_reduction, max_(max_reduction - floor_deductions, 0)), + max_(pre_cap_award - floor_deductions - penny, 0), + ) + return ( + where(binds, floor_deductions, deductions), + where(binds, floor_cap_reduction, cap_reduction), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py index 0daf7d74ab..ca3bc4f7fe 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_deductions.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.deductions._reduction_allocation import ( + allocate_uc_reductions, +) class uc_deductions(Variable): @@ -7,27 +10,38 @@ class uc_deductions(Variable): "Money deducted from this benefit unit's Universal Credit award to " "repay debts: advance repayments, government debt (benefit and tax " "credit overpayments) and third party deductions. Deductions leave " - "at least one penny per assessment period payable (Schedule 6 of " - "SI 2013/380). Per-household statistics are validated against the " - "DWP deductions statistics; weighted aggregates are not, because " - "they scale with the model's UC caseload, which falls short of the " - "administrative count. Annualized from the surveyed monthly rate: " - "for deduction types with spells shorter than a year (e.g. advance " - "recovery), annual per-household amounts are upper bounds." + "at least one penny per assessment period payable (Schedule 6, " + "paragraph 3(1)(a) of SI 2013/380). Per-household statistics are " + "validated against the DWP deductions statistics; weighted " + "aggregates are not, because they scale with the model's UC " + "caseload, which falls short of the administrative count. " + "Annualized from the surveyed monthly rate: for deduction types " + "with spells shorter than a year (e.g. advance recovery), annual " + "per-household amounts are upper bounds." ) entity = BenUnit definition_period = YEAR value_type = float unit = GBP + defined_for = "claims_universal_credit" def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.deductions rate = benunit("uc_deduction_rate", period) standard_allowance = benunit("uc_standard_allowance", period) - award = max_( - benunit("universal_credit_pre_benefit_cap", period) - - benunit("uc_benefit_cap_reduction", period), - 0, + # Shared with uc_benefit_cap_reduction_after_floor. Under current law + # the cap reduces the award first and deductions leave a penny of + # what is left. Where a protected minimum floor binds, deductions are met + # first, up to the floor allowance, and the cap reduction absorbs the + # rest. The floor then also binds on the above-cap excess that + # current law exempts from the deductions cap (last resort and child + # maintenance deductions); JRF's briefing does not say whether their + # floor exempts those categories, so that is a modeling choice. + deductions, _ = allocate_uc_reductions( + benunit("universal_credit_pre_benefit_cap", period), + benunit("uc_benefit_cap_reduction", period), + rate * standard_allowance, + benunit("uc_maximum_reduction", period), + p.minimum_payable * MONTHS_IN_YEAR, ) - minimum_payable = p.minimum_payable * MONTHS_IN_YEAR - return min_(rate * standard_allowance, max_(award - minimum_payable, 0)) + return deductions diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py new file mode 100644 index 0000000000..82e37fa76d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/deductions/uc_maximum_reduction.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class uc_maximum_reduction(Variable): + label = "UC maximum combined reduction" + documentation = ( + "Most that benefit cap reductions and deductions combined may take " + "off a Universal Credit award under the protected minimum floor: " + "(1 - floor) x the standard allowance. Infinite when the floor is " + "zero - the value under current law, which has no such floor - so " + "reductions are unlimited." + ) + entity = BenUnit + definition_period = YEAR + value_type = float + unit = GBP + + def formula(benunit, period, parameters): + # A protected minimum floor (zero under current law) caps combined + # benefit cap reductions and deductions at (1 - floor) x the standard + # allowance, per JRF's protected minimum floor design (their worked + # example limits the reduction itself to 15% of the standard + # allowance). Zero means reductions are unlimited. + floor_rate = parameters( + period + ).gov.dwp.universal_credit.deductions.protected_floor + standard_allowance = benunit("uc_standard_allowance", period) + # A floor above 1 would make the allowance negative; subtracting a + # negative reduction would add money to the award. Clamp at zero, + # where the floor protects the whole standard allowance. + return where( + floor_rate > 0, + max_((1 - floor_rate) * standard_allowance, 0), + np.inf, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py index 79a152ab34..5f676f005f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_company_holding_pool_scale.py @@ -1,5 +1,10 @@ from policyengine_uk.model_api import * +# The capital source that holds a disregarded company holding: a holding is +# shares in a company, which have expenses of sale, so it sits in that part of +# the share-like capital the UC financial pool counts and is budgeted against it. +HOLDINGS_SOURCE = "share_like_capital_with_sale_expenses" + class household_uc_company_holding_pool_scale(Variable): value_type = float @@ -8,10 +13,11 @@ class household_uc_company_holding_pool_scale(Variable): documentation = ( "Common proportional scale for disregarded company holdings when " "deducting them from the household financial pool. Before budgeting, " - "clip each person's holding to [0, max(0, corporate_wealth)], mapping " + "clip each person's holding to [0, max(0, share_like_capital_with_sale_expenses)], mapping " "positive infinity to the upper bound and negative infinity to zero. " "Total bounded holdings " - "share one budget of nonnegative corporate_wealth across override " + "share one budget of nonnegative share_like_capital_with_sale_expenses (the share-like " + "capital the pool counts) across override " "floors and separate pool deductions. This allocation is independent " "of row order and treats holders equally. It is a defensive rule for " "inconsistent inputs, not a statutory determination. It does not " @@ -21,16 +27,16 @@ class household_uc_company_holding_pool_scale(Variable): unit = "/1" def formula(household, period, parameters): - corporate_wealth = max_(0, household("corporate_wealth", period)) + share_like = max_(0, household(HOLDINGS_SOURCE, period)) person = household.members holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household("corporate_wealth", period)) + upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) holdings = household.sum(np.clip(finite_holding, 0, upper_bound)) return where( holdings > 0, - min_(1, corporate_wealth / where(holdings > 0, holdings, 1)), + min_(1, share_like / where(holdings > 0, holdings, 1)), 1, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py index c4d6236d29..f5053eb7a0 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_pool_company_holdings.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) from policyengine_uk.utils.capital_valuation import ( counted_share, exact_amount, @@ -23,18 +26,18 @@ class household_uc_pool_company_holdings(Variable): "company substitution, so it includes its claimant's and partner's " "holdings. household_uc_reported_financial_capital consumes the override " "or, if larger, their effective holdings after the shared household " - "corporate-wealth budget is applied. Removing " + "share-like capital budget is applied. Removing " "those holdings again here would count the same disregard twice. " "All other holdings leave the pool here, including a dependant's " "holding even when their benunit supplies a report or override. " "When financial reports and holdings are compatible, no benunit counts " "any part of a disregarded holding. An inconsistent financial report " "that omits a known holding can leave it in the residual pool. A holding " - "is shares, held within the household's corporate_wealth. Each " - "person's holding is first clipped to [0, max(0, corporate_wealth)], " + "is shares, held within the household's share_like_capital_with_sale_expenses. Each " + "person's holding is first clipped to [0, max(0, share_like_capital_with_sale_expenses)], " "with positive infinity mapped to the upper bound and negative infinity " "to zero before any multiplication. All household holdings then share " - "one nonnegative corporate_wealth budget " + "one nonnegative share_like_capital_with_sale_expenses budget " "through household_uc_company_holding_pool_scale: when their total " "exceeds it, scale each person's holding proportionally before " "either the override floor or this deduction. This is a defensive " @@ -42,7 +45,8 @@ class household_uc_pool_company_holdings(Variable): "shared budget prevents holdings from removing ordinary savings or " "property. It does not change regulation 77 substitution in the " "owner's own assessment. The holdings leave the pool at the value at " - "which the pool counts corporate_wealth: 90% where regulation 49(1)(a) " + "which the pool counts the shares that hold them " + "(share_like_capital_with_sale_expenses): 90% where regulation 49(1)(a) " "deducts 10% for the expenses of selling shares." ) definition_period = YEAR @@ -59,9 +63,7 @@ def formula(household, period, parameters): has_financial_report | has_complete_override ) & claimant_or_partner holding = exact_amount(person("uc_company_holding_disregard", period)) - upper_bound = max_( - 0, exact_amount(person.household("corporate_wealth", period)) - ) + upper_bound = max_(0, exact_amount(person.household(HOLDINGS_SOURCE, period))) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) @@ -70,10 +72,10 @@ def formula(household, period, parameters): * person.household("household_uc_company_holding_pool_scale", period) * ~off_reported_capital ) - # The holdings sit in corporate_wealth, which the pool counts at the + # The holdings sit in HOLDINGS_SOURCE, which the pool counts at the # reg. 49(1) value, so they leave it at that value. p = parameters(period).gov.dwp.universal_credit.means_test.capital return round_to_penny( - holdings * counted_share(p.sale_expenses, "corporate_wealth"), + holdings * counted_share(p.sale_expenses, HOLDINGS_SOURCE), p.round_down, ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py index 0a09bcfec1..e843036494 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/household_uc_reported_financial_capital.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) from policyengine_uk.utils.capital_valuation import ( counted_share, exact_amount, @@ -18,13 +21,13 @@ class household_uc_reported_financial_capital(Variable): "replaces ordinary sources before regulation 77 company substitution, " "so it includes its claimant's and partner's disregarded holdings. " "Consume the larger of the override and those holdings after applying " - "household_uc_company_holding_pool_scale and valuing them as " - "corporate_wealth is valued (90% under regulation 49(1)). Each " - "person's holding is " - "first clipped to [0, max(0, corporate_wealth)], with positive infinity " + "household_uc_company_holding_pool_scale and valuing them as the " + "shares that hold them are valued (90% under regulation 49(1)). Each " + "person's holding is first clipped to [0, max(0, " + "share_like_capital_with_sale_expenses)], with positive infinity " "mapped to the upper bound and negative infinity to zero before any " "multiplication. All household holdings share " - "one nonnegative corporate_wealth budget: if their total exceeds it, " + "one nonnegative share_like_capital_with_sale_expenses budget: if their total exceeds it, " "scale each person's holding proportionally before either this " "override floor or the separate pool deduction. This is a defensive " "rule for inconsistent inputs, not a statutory determination. " @@ -52,9 +55,7 @@ def formula(household, period, parameters): ) complete_override = exact_amount(person.benunit("uc_reported_capital", period)) holding = exact_amount(person("uc_company_holding_disregard", period)) - upper_bound = max_( - 0, exact_amount(person.household("corporate_wealth", period)) - ) + upper_bound = max_(0, exact_amount(person.household(HOLDINGS_SOURCE, period))) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) @@ -62,7 +63,7 @@ def formula(household, period, parameters): own_holdings = person.benunit.sum( bounded_holdings * person("is_uc_claimant", period) ) * person.household("household_uc_company_holding_pool_scale", period) - # The pool counts corporate_wealth, and the holdings in it, at the reg. + # The pool counts the shares, and the holdings in them, at the reg. # 49(1) value, rounded to the nearest penny (reg. 6(1)). A complete # override is a countable figure, so the holdings it must at least # cover are counted at that value. A recorded financial total is used @@ -73,7 +74,7 @@ def formula(household, period, parameters): # assessment still takes the full holding off the report. p = parameters(period).gov.dwp.universal_credit.means_test.capital valued_holdings = round_to_penny( - own_holdings * counted_share(p.sale_expenses, "corporate_wealth"), + own_holdings * counted_share(p.sale_expenses, HOLDINGS_SOURCE), p.round_down, ) consumed_capital = where( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py index 6c81d42cad..be35b6197f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deduction_exempt.py @@ -10,17 +10,32 @@ class uc_non_dep_deduction_exempt(Variable): "No housing costs contribution is deducted for a non-dependant who is " "receiving Pension Credit, the middle or higher rate of the DLA care " "component, the PIP daily living component or Attendance Allowance, or " - "who receives Carer's Allowance or Carer Support Payment. Underlying " + "who receives Carer's Allowance or Carer Support Payment, or who is " + "responsible for a child under the child responsibility age limit. " + "Benefit-unit membership represents the child's normal residence " + "with the claimant or partner; the existing Universal Credit child " + "age measure excludes children looked after by a local authority. Underlying " "entitlement reduced to nil by overlapping benefits does not qualify " "for the carer exemption." ) reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16" def formula(person, period, parameters): + p = parameters( + period + ).gov.dwp.universal_credit.elements.housing.non_dep_deduction + # Sch 4 para 16(2)(i) and reg 4: responsibility belongs to the + # claimant or partner of the child's own family, independently of + # whether that family claims Universal Credit. + responsible_for_young_child = person("is_claimant_or_partner", period) & ( + person.benunit("uc_youngest_child_age", period) + < p.child_responsibility_age_limit + ) return ( person.benunit("uc_non_dep_receives_pension_credit", period) | person("dla_sc_middle_plus", period) | (person("pip_dl", period) > 0) | (person("attendance_allowance", period) > 0) | person("receives_carer_benefit", period) + | responsible_for_young_child ) 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/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/dwp/universal_credit/uc_assessable_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py index dbee1c5ab3..d15fb67635 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_assessable_capital.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) from policyengine_uk.utils.capital_valuation import ( counted_share, exact_amount, @@ -34,7 +37,8 @@ class uc_assessable_capital(Variable): "Under regulation 77, disregarded company holdings are replaced by " "the claimant/partner's share of company capital. Regulation 49(1) " "values the financial pool's sources whose sale would incur expenses " - "(sale_expenses.sources, shares in corporate_wealth) at 90%, and a " + "(sale_expenses.sources: directly held shares, and all of an " + "unitemised corporate_wealth) at 90%, and a " "disregarded holding comes off at the value at which it was counted: " "90% in the pool and in a complete override, which is a countable " "figure, and in full from a recorded financial total, which is used " @@ -58,9 +62,10 @@ def formula(benunit, period, parameters): household_financial_capital = valued_capital( lambda variable: household(variable, period), p ) - # A reg. 77 holding sits in corporate_wealth, so it is counted at the - # same 90% as the shares around it wherever they are valued. - holding_value = counted_share(p.sale_expenses, "corporate_wealth") + # A reg. 77 holding sits in the shares with sale expenses, so it is + # counted at the same 90% as the shares around it wherever they are + # valued. + holding_value = counted_share(p.sale_expenses, HOLDINGS_SOURCE) claimant_or_partner = benunit.members("is_uc_claimant", period) benunit_claimants = benunit.sum(claimant_or_partner) person_capital = sum( diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py index b508828d55..cce696e238 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_reported_capital.py @@ -22,7 +22,7 @@ class uc_reported_capital(Variable): "override can change a missing-report unit's capital. The complete " "override consumes its amount, or its claimant's and partner's " "disregarded company holdings after their proportional share of the " - "household corporate_wealth budget if larger, " + "household share_like_capital_with_sale_expenses budget if larger, " "from the financial pool, taking " "precedence over the same unit's financial report. " "This variable does not derive from the financial-only FRS total. " diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py index 3f512f70e6..9323eb1ecc 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/universal_credit.py @@ -11,26 +11,11 @@ class universal_credit(Variable): def formula(benunit, period, parameters): uc_max_entitlement = benunit("universal_credit_pre_benefit_cap", period) - benefit_cap_reduction = benunit("uc_benefit_cap_reduction", period) + # Both components already reflect the protected minimum floor (zero, + # and so inactive, under current law), so the reported components sum + # to the reduction actually applied. Summing them before subtracting + # keeps main's float arithmetic, so current-law awards are unchanged + # to the bit. + benefit_cap_reduction = benunit("uc_benefit_cap_reduction_after_floor", period) deductions = benunit("uc_deductions", period) - # A protected minimum floor (zero under current law) caps combined - # benefit cap reductions and deductions at (1 - floor) x the standard - # allowance, per JRF's protected minimum floor design (their worked - # example limits the reduction itself to 15% of the standard - # allowance). Zero means reductions are unlimited. - # The floor binds on the whole of uc_deductions, including the above- - # cap excess that current law exempts from the deductions cap (last - # resort and child maintenance deductions). JRF's briefing does not - # say whether their floor exempts those categories - its worked - # example involves only cappable deductions and the benefit cap - so - # this is a modeling choice, tracked for follow-up. - floor_rate = parameters( - period - ).gov.dwp.universal_credit.deductions.protected_floor - total_reductions = benefit_cap_reduction + deductions - max_reductions = where( - floor_rate > 0, - (1 - floor_rate) * benunit("uc_standard_allowance", period), - np.inf, - ) - return max_(uc_max_entitlement - min_(total_reductions, max_reductions), 0) + return max_(uc_max_entitlement - (benefit_cap_reduction + deductions), 0) diff --git a/policyengine_uk/variables/gov/dwp/war_widows_pension.py b/policyengine_uk/variables/gov/dwp/war_widows_pension.py index 762833d871..e6987680b1 100644 --- a/policyengine_uk/variables/gov/dwp/war_widows_pension.py +++ b/policyengine_uk/variables/gov/dwp/war_widows_pension.py @@ -5,7 +5,25 @@ class war_widows_pension(Variable): value_type = float entity = Person label = "War Widow's or Widower's Pension" + documentation = ( + "A war widow's or widower's pension as defined in State Pension Credit " + "Act 2002 s.17(1): a widow's, widower's or surviving civil partner's " + "pension or allowance granted in respect of a death due to service or " + "war injury and payable under an enactment named in the definition of " + "war disablement pension, or granted under a scheme in s.641(1)(e) or " + "(f) of the Income Tax (Earnings and Pensions) Act 2003. This is the " + "gross payment. Amounts wholly disregarded by " + "State Pension Credit Regulations 2002 Sch. IV paras 2 to 6 and 12 " + "(for example a supplementary pension under article 23(2) of the " + "Service Pensions Order 2006) are not separated. Family Resources " + "Survey benefit code 9 belongs here; the datasets still add it to " + "bsp_reported (policyengine-uk-data#578, microcosm#1222)." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/17", + "https://www.legislation.gov.uk/ukpga/2003/1/section/639", + ) adds = ["war_widows_pension_reported"] 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/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..ef7b99db7f 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_additional.py @@ -16,4 +16,19 @@ def formula(person, period, parameters): parameters.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR ) upper_earnings_limit_income = max_(income - upper_earnings_limit, 0) - return parameters.rates.employee.additional * upper_earnings_limit_income + ordinary = parameters.rates.employee.additional * upper_earnings_limit_income + directors = parameters.directors.full_year_category_a_2022_23 + if not directors.in_effect: + return ordinary + + # Match the annual primary component; this is not an interim payroll charge. + scale = directors.employee_scale + annual_income = person("ni_class_1_income", period.this_year) + annual_additional = scale.rates[2] * max_( + annual_income - scale.thresholds[2], 0 + ) + return where( + person("is_full_year_category_a_director", period.this_year), + annual_additional / MONTHS_IN_YEAR, + ordinary, + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py index 69b5db4986..f29602fa40 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employee_primary.py @@ -26,4 +26,20 @@ def formula(person, period, parameters): max_(income - primary_threshold, 0) - upper_earnings_limit_income ) - return parameters.rates.employee.main * primary_threshold_income + ordinary = parameters.rates.employee.main * primary_threshold_income + directors = parameters.directors.full_year_category_a_2022_23 + if not directors.in_effect: + return ordinary + + # These are annual earnings-period amounts, allocated evenly to model months. + scale = directors.employee_scale + annual_income = person("ni_class_1_income", period.this_year) + annual_primary = scale.rates[1] * min_( + max_(annual_income - scale.thresholds[1], 0), + scale.thresholds[2] - scale.thresholds[1], + ) + return where( + person("is_full_year_category_a_director", period.this_year), + annual_primary / MONTHS_IN_YEAR, + ordinary, + ) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py index a54fc2da39..184c12e3a9 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py @@ -10,6 +10,7 @@ class ni_class_1_income(Variable): unit = GBP adds = [ "employment_income", + "salary_sacrifice_pension_ni_excess", "statutory_sick_pay", "statutory_maternity_pay", "statutory_paternity_pay", diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py index ba71d85791..d9a8cce55d 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employee(Variable): documentation = ( "Additional employee National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to NI (but gets income tax relief)." + "The excess remains an employer pension contribution and is included " + "only in NIC earnings. This reporting approximation is separate " + "from the actual Class 1 contribution curves and liability flags." ) entity = Person definition_period = YEAR @@ -16,9 +17,8 @@ class salary_sacrifice_pension_ni_employee(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to NI - excess = person("salary_sacrifice_returned_to_income", period) + # NIC-only earnings; this does not become an actual member payment. + excess = person("salary_sacrifice_pension_ni_excess", period) # Use existing NI Class 1 parameters ni_params = parameters(period).gov.hmrc.national_insurance.class_1 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py index ed9de113a4..c20736b37c 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employer(Variable): documentation = ( "Additional employer National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " - "The full excess is redirected to regular pension contributions " - "and subject to employer NI." + "The excess remains an employer pension contribution and is included " + "only in NIC earnings. This reporting measure is separate from " + "the actual Class 1 contribution curve and liability flags." ) entity = Person definition_period = YEAR @@ -16,9 +17,8 @@ class salary_sacrifice_pension_ni_employer(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Get the excess that's redirected to regular pension - # This is the amount subject to employer NI - excess = person("salary_sacrifice_returned_to_income", period) + # NIC-only earnings; this does not become an actual member payment. + excess = person("salary_sacrifice_pension_ni_excess", period) # Use existing NI Class 1 employer rate parameter ni_params = parameters(period).gov.hmrc.national_insurance.class_1 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py new file mode 100644 index 0000000000..452ebf4841 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class salary_sacrifice_pension_ni_excess(Variable): + label = "Salary sacrifice pension contributions subject to NICs" + documentation = ( + "Employer pension contributions through salary sacrifice above the NICs " + "exemption cap. This is added only to Class 1 earnings: it is neither " + "cash employment income nor an actual member pension contribution. " + "The existing annual model samples the 2029-04-06 cap; payroll interval " + "rules and individual transaction dates are not modelled." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2026/15/section/1/enacted" + + def formula(person, period, parameters): + sacrifice = person( + "pension_contributions_via_salary_sacrifice_from_pay", period + ) + cap = parameters( + period + ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap + return max_(sacrifice - cap, 0) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/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 95f4db1d41..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,8 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import ( - annual_allowance_net_income, - pension_contributions_relief_amount, -) +from policyengine_uk.utils.pensions import annual_allowance_net_income class pension_annual_allowance_threshold_income(Variable): @@ -10,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 " @@ -25,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 ) @@ -33,29 +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 = ( - person("employment_income", period) - - returned - + max_(0, person("self_employment_income", period)) - ) - 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 8fdf23d848..3ba60f7cd9 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import pension_contributions_relief_amount +from policyengine_uk.utils.pensions import ( + pension_contributions_relief_amount, + relevant_uk_earnings, +) class pension_contributions_relief(Variable): @@ -14,6 +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 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." @@ -36,21 +43,19 @@ class pension_contributions_relief(Variable): title="Finance Act 2004 s. 191", href="https://www.legislation.gov.uk/ukpga/2004/12/section/191", ), + dict( + title="HMRC PTM044100: relevant UK earnings", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm044100", + ), ] unit = GBP def formula(person, period, parameters): hmrc = parameters(period).gov.hmrc contributions = person("pension_contributions", period) - # Relevant UK earnings (s. 189(2)) are employment income and trading - # income. A trading loss gives no relevant earnings; it does not - # reduce employment income. - relevant_uk_earnings = person("employment_income", period) + max_( - 0, person("self_employment_income", period) - ) return pension_contributions_relief_amount( contributions, - relevant_uk_earnings, + 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 db0d6ba794..52524b2828 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_legacy.py @@ -10,6 +10,15 @@ def is_full_time_student_non_dep(person, period): ) +def applicant_has_universal_credit(benunit, period): + """Whether the Council Tax Reduction applicant has the benefit unit's + Universal Credit award. The award belongs to the benefit unit's claimant + and partner, so not to a household head who applies alone.""" + return (benunit("universal_credit", period) > 0) & ~benunit( + "council_tax_reduction_head_applies_alone", period + ) + + def legacy_council_tax_reduction( benunit, period, @@ -25,7 +34,7 @@ def legacy_council_tax_reduction( applicable_amount = benunit("council_tax_reduction_applicable_amount", period) applicable_income = benunit("council_tax_reduction_applicable_income", period) universal_credit = benunit("universal_credit", period) - has_uc_award = universal_credit > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) uc_applicable_amount = benunit("uc_maximum_amount", period) uc_applicable_income = ( benunit("uc_earned_income", period) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py index a63c5c4032..27d9323edb 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_amount.py @@ -1,10 +1,20 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_applicable_amount(Variable): value_type = float entity = BenUnit label = "applicable Council Tax Reduction amount" + documentation = ( + "The applicant's personal allowance and premiums. Ages, couple status " + "and lone-parent status are those of the Council Tax Reduction " + "applicant and partner (is_council_tax_reduction_applicant_or_partner). " + "Where the household head applies alone " + "(council_tax_reduction_head_applies_alone), the head is a single " + "person with no partner or children, whatever the benefit unit's " + "claimant and partner are. Premiums are council_tax_reduction_premiums." + ) definition_period = YEAR unit = GBP reference = ( @@ -23,7 +33,13 @@ def formula(benunit, period, parameters): # Wales and Scotland also have separate pensioner allowance tables. # The shared definition includes qualifying age and award exclusions. pensioner = benunit("council_tax_reduction_pensioner", period) - eldest_age = benunit("eldest_claimant_or_partner_age", period) + # The applicant's and partner's ages. A household head who applies alone + # (council_tax_reduction_head_applies_alone) is a single person with no + # partner or children: the benefit unit's are its claimant's. + person = benunit.members + applicant = person("is_council_tax_reduction_applicant_or_partner", period) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + eldest_age = benunit.max(where(applicant, person("age", period), -np.inf)) older_age_threshold = p.age_threshold.older younger_age_threshold = p.age_threshold.younger u_18 = eldest_age < younger_age_threshold @@ -33,19 +49,45 @@ def formula(benunit, period, parameters): # Each scheme's own young person decides who is a lone parent: the # Universal Credit definition in Scotland's working-age scheme (SSI # 2021/249 regs 4 and 6), the Child Benefit definition elsewhere. - couple = benunit("is_couple", period) - lone_parent = benunit("is_council_tax_reduction_lone_parent", period) + couple = ~head_applies_alone & benunit("is_couple", period) + lone_parent = ~head_applies_alone & benunit( + "is_council_tax_reduction_lone_parent", period + ) single = ~couple & ~lone_parent + # Pension-age personal allowances. England's prescribed requirements + # (SI 2012/2885 Sch 2 para 1, amended by SI 2021/29 for schemes for + # financial years from 2021-22) split them by when State Pension age + # was attained, with the same cutoff and amounts as Housing Benefit. + # The Scottish (SSI 2012/319 Sch 1 para 2) and Welsh (SI 2013/3029 + # Sch 2 para 1) schemes keep one pension-age rate, the higher one. + # England's rows (2) and (5) do not cover a couple where one member has + # not reached State Pension age and the other did so after the cutoff; + # as for Housing Benefit, such a couple gets the lower rate. + single_aged, couple_aged, lone_parent_aged = ( + p.single.aged, + p.couple.aged, + p.lone_parent.aged, + ) + if p.pension_age_cutoff is not None: + in_england = benunit.household("country", period) == Country.ENGLAND + lower = in_england & ~benunit( + "council_tax_reduction_attained_pension_age_before_cutoff", period + ) + single_aged = where(lower, p.single.aged_from_cutoff, single_aged) + couple_aged = where(lower, p.couple.aged_from_cutoff, couple_aged) + lone_parent_aged = where( + lower, p.lone_parent.aged_from_cutoff, lone_parent_aged + ) single_personal_allowance = ( - u_25 * p.single.younger + o_25 * p.single.older + pensioner * p.single.aged + u_25 * p.single.younger + o_25 * p.single.older + pensioner * single_aged ) couple_personal_allowance = ( - u_18 * p.couple.younger + o_18 * p.couple.older + pensioner * p.couple.aged + u_18 * p.couple.younger + o_18 * p.couple.older + pensioner * couple_aged ) lone_parent_personal_allowance = ( u_18 * p.lone_parent.younger + o_18 * p.lone_parent.older - + pensioner * p.lone_parent.aged + + pensioner * lone_parent_aged ) personal_allowance = ( single * single_personal_allowance @@ -54,9 +96,5 @@ def formula(benunit, period, parameters): ) * WEEKS_IN_YEAR # The pension-age schedules have only the severe disability and carer # premiums; the same pensioner test chooses the schedule. - premiums = where( - pensioner, - benunit("pension_age_benefits_premiums", period), - benunit("working_age_benefits_premiums", period), - ) + premiums = benunit("council_tax_reduction_premiums", period) return personal_allowance + premiums diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 55575e1987..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 @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country class council_tax_reduction_applicable_income(Variable): @@ -12,17 +13,36 @@ class council_tax_reduction_applicable_income(Variable): "use the Secretary of State's Pension Credit assessment of income, plus " "the savings credit payable, where the award is savings credit only. " "Otherwise a pension-age family's net earnings are reduced by the " - "pensioner earnings disregards. The other adjustments those " + "pensioner earnings disregards. Great Britain's pensioner schemes " + "also exclude Child Benefit from general-rule income; statutory sick " + "and maternity pay remain counted as earnings. The other adjustments those " "provisions allow (childcare charges, the £20 earnings disregards, " - "maintenance disregards and the rest) are not modelled." + "maintenance disregards and the rest) are not modelled. " + "The income counted is the Council Tax Reduction applicant's and " + "partner's (is_council_tax_reduction_applicant_or_partner), with, as " + "the model did before, the income of the family's children or young " + "persons. Where the household head applies alone " + "(council_tax_reduction_head_applies_alone), the benefit unit's own " + "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. 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 reference = [ "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/13", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/14", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/7", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", "https://www.legislation.gov.uk/ssi/2012/319/regulation/24", "https://www.legislation.gov.uk/ssi/2012/319/regulation/25", "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", @@ -30,28 +50,37 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/21", + "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): - # Members whose income counts: the claimant and partner and, as the model did - # before, the programme's own children or young persons. The regulations count - # only the claimant's and partner's (CTR (Prescribed Requirements) (England) - # Regs 2012 Sch 1 para 11); dropping dependants' own income is a follow-up. - # Anyone else in the benefit unit does not count. + # Members whose income counts: the applicant and partner and, as the model + # did before, the programme's own children or young persons. The regulations + # count only the applicant's and partner's (CTR (Prescribed Requirements) + # (England) Regs 2012 Sch 1 para 11; SSI 2012/319 reg 21 and SSI 2021/249 + # reg 36, which also exclude a child's); dropping dependants' own income is + # a follow-up. Anyone else in the benefit unit does not count. A household + # head who applies alone is the applicant, whatever else the benefit unit + # reads her as, and the benefit unit's children are its claimant's. person = benunit.members - # A household head who applies alone - # (council_tax_reduction_head_applies_alone) is not the benefit unit's - # claimant, so this mask has reached them only as a Child Benefit young - # person. The applicant's own income always counts (SSI 2021/249 reg - # 36(1)), so they stay counted where Scotland's working-age scheme no - # longer reads them as a young person. - applicant_counted_as_young_person = person( - "is_council_tax_reduction_applicant_or_partner", period - ) & person("is_child_or_young_person_for_legacy_benefits", period) - members = ( - person("is_claimant_or_partner", period) - | person("is_child_or_young_person_for_council_tax_reduction", period) - | applicant_counted_as_young_person + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + applicant_family = ~head_applies_alone + members = person("is_council_tax_reduction_applicant_or_partner", period) | ( + person("is_child_or_young_person_for_council_tax_reduction", period) + & benunit.project(applicant_family) ) # Only the claimant's and partner's income-related awards: the scheme # counts the applicant's and partner's income (Sch 1 para 11 above). A @@ -90,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", @@ -97,14 +177,36 @@ def formula(benunit, period, parameters): "private_pension_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income - benefits = add_for_members( - benunit, period, benunit_means_tested_benefits, members + # The benefit unit's awards are its claimant's and partner's. + benefits = applicant_family * add( + benunit, period, benunit_means_tested_benefits + ) + country = benunit.household("country", period) + in_great_britain = ( + (country == Country.ENGLAND) + | (country == Country.SCOTLAND) + | (country == Country.WALES) + ) + pensioner = benunit("council_tax_reduction_pensioner", period) + # England Sch 1 para 16(1)(j)(vi), Wales Sch 1 para 10(1)(j)(vi) + # and Scotland reg 27(1)(j)(vii) exclude this exact BenUnit award. + # Remove it before the general-rules floor. SSP/SMP remain counted + # in personal_benefits as earnings; working-age/NI income is unchanged. + # A head who applies alone counts none of the benefit unit's awards, so + # there is no Child Benefit to remove. + benefits -= where( + pensioner & in_great_britain & applicant_family, + add_for_members(benunit, period, ["child_benefit"], members), + 0, ) income = add_for_members(benunit, period, income_components, members) personal_benefit_income = add_for_members( benunit, period, personal_benefits, members ) - credits = add_for_members(benunit, period, ["tax_credits"], 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 if not bi.interactions.include_in_means_tests: @@ -112,9 +214,13 @@ def formula(benunit, period, parameters): benunit, period, ["basic_income"], members ) - pension_contributions = ( - add_for_members(benunit, period, ["pension_contributions"], members) * 0.5 - ) + # The scheme's own share of pension contributions (one-half in each: + # SI 2012/2885 Sch 1 para 19(2)(b)-(c), WSI 2013/3029 Sch 1 para 13 + # and Sch 6 para 15, SSI 2012/319 reg 33 and SSI 2021/249 reg 50(3), + # and the councils' schemes). + pension_contributions = add_for_members( + benunit, period, ["pension_contributions"], members + ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) tax = add_for_members( benunit, period, ["income_tax", "national_insurance"], members ) @@ -135,15 +241,21 @@ def formula(benunit, period, parameters): # to take account of the savings credit payable. The Pension Credit # paid on a savings-credit-only award is that savings credit (under the # Pension Credit freeze, the frozen amount). - in_receipt_of_guarantee_credit = benunit( + # The benefit unit's Pension Credit is its claimant's and partner's. + in_receipt_of_guarantee_credit = applicant_family & benunit( "in_receipt_of_guarantee_credit", period ) - has_savings_credit_only_award = benunit( + has_savings_credit_only_award = applicant_family & benunit( "in_receipt_of_savings_credit_only", period ) - savings_credit_only_income = benunit("pension_credit_income", period) + benunit( - "pension_credit", period - ) + # The general route counts no war pension (as Housing Benefit, a full + # disregard), so the war pension income in the Secretary of State's + # assessment is taken out here too. + savings_credit_only_income = max_( + 0, + benunit("pension_credit_income", period) + - benunit("pension_credit_war_pension_income", period), + ) + benunit("pension_credit", period) return select( [in_receipt_of_guarantee_credit, has_savings_credit_only_award], [0, savings_credit_only_income], diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py index 16b63ab2a9..46ef19a7d1 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_assessable_capital.py @@ -31,10 +31,14 @@ class council_tax_reduction_assessable_capital(Variable): ] def formula(benunit, period, parameters): - in_receipt_of_guarantee_credit = benunit( + # The benefit unit's Pension Credit is its claimant's and partner's, not + # a household head's who applies alone + # (council_tax_reduction_head_applies_alone). + applicant_family = ~benunit("council_tax_reduction_head_applies_alone", period) + in_receipt_of_guarantee_credit = applicant_family & benunit( "in_receipt_of_guarantee_credit", period ) - has_savings_credit_only_award = benunit( + has_savings_credit_only_award = applicant_family & benunit( "in_receipt_of_savings_credit_only", period ) return select( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py new file mode 100644 index 0000000000..6e2374f16d --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_attained_pension_age_before_cutoff.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_attained_pension_age_before_cutoff(Variable): + value_type = bool + entity = BenUnit + label = "CTR applicant or partner attained State Pension age before the cutoff" + documentation = ( + "Whether the Council Tax Reduction applicant or their partner attained " + "State Pension age before 1 April 2021, the cutoff England's pensioner " + "scheme shares with Housing Benefit. Such applicants keep the higher " + "pension-age personal allowance; other members of the household do " + "not count. Before the cutoff took effect there was a single " + "pension-age allowance, the higher one, so this is true for every unit " + "then." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2" + + def formula(benunit, period, parameters): + cutoff = parameters( + period + ).gov.dwp.housing_benefit.allowances.pension_age_cutoff + if cutoff is None: + return np.ones(benunit.count, dtype=bool) + # SI 2012/2885 Sch 2 para 1 reads the applicant's and partner's ages, + # the same people the pensioner test reads + # (is_council_tax_reduction_applicant_or_partner). Dates are compared + # as exact YYYYMMDD integers. + person = benunit.members + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + attained = person("state_pension_age_date", period) + return benunit.any(applicant_or_partner & (attained < cutoff)) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py new file mode 100644 index 0000000000..ca9ace1c02 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_net_earnings.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.applicable_income._net_earnings import ( + person_net_earnings, +) + + +class council_tax_reduction_net_earnings(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction net earnings" + documentation = ( + "Net earnings of the Council Tax Reduction applicant and partner " + "(is_council_tax_reduction_applicant_or_partner), from which the " + "pensioner Council Tax Reduction earnings disregards are taken. The " + "earnings and deductions are those of housing_benefit_net_earnings, " + "except the share of pension contributions, which is the scheme's own " + "(council_tax_reduction_pension_contribution_deduction_rate): " + "SI 2012/2885 Sch 1 para 19(2)(b)-(c), WSI 2013/3029 Sch 1 para " + "13(2)(b)-(c) and SSI 2012/319 reg 33(2)(b)-(c) each deduct one-half " + "and do not refer to Housing Benefit's share. Where the applicant is " + "the benefit unit's claimant, this equals housing_benefit_net_earnings " + "while the two shares are equal; where the household head applies " + "alone, it is the head's own net earnings." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/29", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/23", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/36", + ) + + def formula(benunit, period, parameters): + person = benunit.members + rate = benunit( + "council_tax_reduction_pension_contribution_deduction_rate", period + ) + net_earnings = person_net_earnings(person, period, benunit.project(rate)) + # The applicant and partner (SI 2012/2885 Sch 1 para 11 and the + # Welsh and Scottish equivalents). + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + return benunit.sum(net_earnings * applicant_or_partner) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py new file mode 100644 index 0000000000..d18898ecfa --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pension_contribution_deduction_rate.py @@ -0,0 +1,95 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_england_pensioner_scheme, + is_kingston_upon_thames_working_age, + is_merton_working_age, + is_newham_working_age, + is_oxford_working_age, + is_scotland_scheme, + is_wales_scheme, + is_westminster_working_age, +) + + +class council_tax_reduction_pension_contribution_deduction_rate(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction pension contribution deduction rate" + documentation = ( + "The share of pension contributions deducted from earnings in the " + "Council Tax Reduction means test, under the family's own scheme. " + "Each scheme sets its own share, one-half in every scheme modelled: " + "the England pensioner scheme (SI 2012/2885 Sch 1 paras 19 and 29), " + "Wales (WSI 2013/3029 Sch 1 paras 13 and 23 and Sch 6 paras 15 and " + "24), Scotland (SSI 2012/319 regs 33 and 36; SSI 2021/249 reg 50, and " + "for the self-employed reg 51(9), which applies SSI 2012/303 regs 37 " + "and 38) and the working-age schemes of Merton, Kingston upon Thames, " + "Newham, Westminster and Oxford. None of them refers to Housing " + "Benefit's share, so a reform of one scheme's share moves only that " + "scheme. English working-age families outside those councils, whose " + "reductions the model takes as reported, and families outside Great " + "Britain, where there is no Council Tax Reduction, read the England " + "default scheme's share (SI 2012/2886 Sch paras 42, 52 and 61). " + "A working-age applicant with an award of Universal Credit is " + "assessed instead on the Secretary of State's Universal Credit " + "figures, which deduct relievable pension contributions in full " + "(UC Regs 2013 reg 55(5)(a) for employed earnings and reg 57(2) step 4 " + "for self-employed earnings): in Wales (WSI 2013/3029 Sch 6 para 9), " + "Scotland (SSI 2021/249 regs 49(6)(a) and 51(2)) and Oxford (its " + "scheme's Appendix 3). The model has no such route for them yet " + "(#1962 for Wales and Scotland, #2520 for Oxford), so the share here " + "reaches those applicants too. Merton, Kingston upon Thames, Newham " + "and Westminster already assess them on Universal Credit income " + "(_legacy.py)." + ) + definition_period = YEAR + unit = "/1" + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/29", + "https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/52", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/15", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/55", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/57", + ) + + def formula(benunit, period, parameters): + la = parameters(period).gov.local_authorities + household = benunit.household + country = household("country", period) + local_authority = household("local_authority", period) + # The scheme follows the applicant's own family (SI 2012/2885 reg 3), + # as in simulated_council_tax_reduction_benunit. + has_pensioner = benunit("council_tax_reduction_pensioner", period) + + def rate(ctr): + return ctr.means_test.pension_contribution_deduction_rate + + return select( + [ + is_england_pensioner_scheme(country, has_pensioner), + is_scotland_scheme(country), + is_wales_scheme(country), + is_merton_working_age(local_authority, country, has_pensioner), + is_kingston_upon_thames_working_age( + local_authority, country, has_pensioner + ), + is_newham_working_age(local_authority, country, has_pensioner), + is_westminster_working_age(local_authority, country, has_pensioner), + is_oxford_working_age(local_authority, country, has_pensioner), + ], + [ + rate(la.england.council_tax_reduction.pensioners), + rate(la.scotland.council_tax_reduction), + rate(la.wales.council_tax_reduction), + rate(la.merton.council_tax_reduction), + rate(la.kingston_upon_thames.council_tax_reduction), + rate(la.newham.council_tax_reduction), + rate(la.westminster.council_tax_reduction), + rate(la.oxford.council_tax_reduction), + ], + default=rate(la.england.council_tax_reduction.default_scheme), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py index 17e26734c0..0915423fff 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_pensioner_earnings_disregard.py @@ -17,9 +17,11 @@ class council_tax_reduction_pensioner_earnings_disregard(Variable): "disregards and the additional amount. The amounts and work " "conditions are those of the Housing Benefit pension-age schedule " "(SI 2006/214 Sch 4), and an employee's actual earnings have the same " - "deductions (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 para " - "13, SSI 2012/319 reg 33), so the Housing Benefit net earnings and " - "work conditions are used. The notional tax on estimated earnings " + "kinds of deduction (SI 2012/2885 Sch 1 para 19, WSI 2013/3029 Sch 1 " + "para 13, SSI 2012/319 reg 33), so the Housing Benefit work " + "conditions and earnings rules are used, with the scheme's own share " + "of pension contributions (council_tax_reduction_net_earnings). The " + "notional tax on estimated earnings " "and on self-employed profit can differ (Scotland deducts only the " "personal allowance, SSI 2012/319 regs 33(4) and 37(1)); the model " "applies the Housing Benefit approximation to all of them. The earnings test leaves out " @@ -61,12 +63,19 @@ def national(amount): default=england[amount], ) - net_earnings = benunit("housing_benefit_net_earnings", period) + # The applicant's and partner's earnings. A household head who applies + # alone (council_tax_reduction_head_applies_alone) is a single person + # with no partner or children. + net_earnings = benunit("council_tax_reduction_net_earnings", period) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) # SI 2012/2885 Sch 4 paras 2 and 8; WSI 2013/3029 Sch 3 paras 2 and 8; # SSI 2012/319 Sch 2 paras 2 and 8. A lone parent has £25 in all, # not £25 plus para 8's £5, as for Housing Benefit. weekly_amount = select( - [benunit("is_lone_parent", period), benunit("is_couple", period)], + [ + ~head_applies_alone & benunit("is_lone_parent", period), + ~head_applies_alone & benunit("is_couple", period), + ], [national("lone_parent"), national("couple")], default=national("single"), ) @@ -77,11 +86,39 @@ def national(amount): covers_additional = np.round(net_earnings.astype(float), 2) >= np.round( (standard + additional_amount).astype(float), 2 ) - additional = where( + # A head who applies alone has no partner or child, so only the + # conditions on her own age, hours and disability can apply (HB (SPC) + # Regs 2006 Sch 4 para 9(2)(b)(i) and (iv), which these schemes share). + hb_disregard = parameters( + period + ).gov.dwp.housing_benefit.means_test.income_disregard + person = benunit.members + head = person( + "is_council_tax_reduction_applicant_or_partner", period + ) & benunit.project(head_applies_alone) + hours = person("weekly_hours", period) + head_meets_conditions = benunit.any( + head + & ( + ( + (hours >= hb_disregard.worker_hours) + & (person("age", period) >= hb_disregard.worker_age) + ) + | ( + (hours >= hb_disregard.worker_hours_lower) + & person("is_disabled_for_benefits", period) + ) + ) + ) + meets_conditions = where( + head_applies_alone, + head_meets_conditions, benunit( "meets_housing_benefit_additional_earnings_disregard_conditions", period - ) - & covers_additional, + ), + ) + additional = where( + meets_conditions & covers_additional, additional_amount, 0, ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py new file mode 100644 index 0000000000..d6bc318621 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -0,0 +1,101 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_premiums(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction disability and carer premiums" + documentation = ( + "The disability and carer premiums in the Council Tax Reduction " + "applicable amount, which are the applicant's. A pensioner " + "(council_tax_reduction_pensioner) has the pension-age schedule (the " + "severe disability and carer premiums), anyone else the working-age " + "schedule. Where the applicant is the benefit unit's claimant, these " + "are the benefit unit's premiums (pension_age_benefits_premiums or " + "working_age_benefits_premiums). Where the household head applies " + "alone (council_tax_reduction_head_applies_alone), they are the " + "premiums of a single claimant on the head's own circumstances, under " + "the same conditions: the disability and enhanced disability premiums " + "on her own disability (working age only); the severe disability " + "premium if she receives a qualifying benefit, no carer benefit is " + "paid for caring for her, and no other resident aged 18 or over is " + "her non-dependant (everyone else in the household, since she has no " + "partner or children, unless they receive a qualifying benefit or are " + "blind); and the carer premium if she is entitled to a carer benefit. " + "Whom a carer benefit is paid for caring for comes from " + "is_cared_for_by_carer_benefit_recipient, which gives a carer in her " + "own benefit unit to her but a carer in another benefit unit only to " + "that unit's claimants and partners. So a carer outside her benefit " + "unit does not end her severe disability premium, though in law they " + "would. Such a carer is under 18, blind or on a qualifying benefit, " + "since any other resident aged 18 or over already bars the premium as " + "her non-dependant." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/2", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/3", + ) + + def formula(benunit, period, parameters): + pensioner = benunit("council_tax_reduction_pensioner", period) + # The working-age severe disability premium takes Council Tax + # Reduction's own family test (council_tax_reduction_severe_disability_ + # premium, which differs from the shared premium in Scotland). + benefit_unit_premiums = where( + pensioner, + benunit("pension_age_benefits_premiums", period), + benunit("working_age_benefits_premiums", period) + + ( + benunit("council_tax_reduction_severe_disability_premium", period) + - benunit("severe_disability_premium", period) + ), + ) + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + person = benunit.members + head = person( + "is_council_tax_reduction_applicant_or_partner", period + ) & benunit.project(head_applies_alone) + + def head_has(condition): + return benunit.any(head & condition) + + disability = parameters(period).gov.dwp.disability_premia + carer = parameters(period).gov.dwp.carer_premium + # The severe disability premium's non-dependants: every resident aged + # 18 or over outside the applicant's family who neither receives a + # qualifying benefit nor is blind (has_non_dependant_for_severe_ + # disability_premium). The head's family is the head alone. + counted = ( + (person("age", period) >= disability.severe_non_dependant_age) + & ~person("receives_severe_disability_premium_qualifying_benefit", period) + & ~person("is_blind", period) + ) + others_counted = benunit.max(person.household.sum(counted)) - benunit.sum( + counted & head + ) + severe = ( + head_has( + person("receives_severe_disability_premium_qualifying_benefit", period) + & ~person("is_cared_for_by_carer_benefit_recipient", period) + ) + & (others_counted == 0) + ) * disability.severe_single + carer_premium = ( + head_has(person("is_entitled_to_carer_benefit", period)) * carer.single + ) + # The pension-age schedules have no adult disability or enhanced + # disability premium. + disability_premiums = ~pensioner * ( + head_has(person("is_disabled_for_benefits", period)) + * disability.disability_single + + head_has(person("is_enhanced_disabled_for_benefits", period)) + * disability.enhanced_single + ) + head_premiums = (disability_premiums + severe + carer_premium) * WEEKS_IN_YEAR + return where(head_applies_alone, head_premiums, benefit_unit_premiums) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py index cb9300b1c1..317fecffc5 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_relevant_income_based_benefit.py @@ -11,13 +11,17 @@ class council_tax_reduction_relevant_income_based_benefit(Variable): "tax reduction schemes to their maximum reduction. Only the " "applicant's and partner's awards count: another member of the " "benefit unit, such as a non-dependent adult, who has an award of " - "their own claims in their own right." + "their own claims in their own right. Where the household head " + "applies alone (council_tax_reduction_head_applies_alone), the " + "benefit unit's awards belong to its claimant and partner, not to the " + "head, so they do not passport her." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2012/2886/schedule" def formula(benunit, period, parameters): - return ( + head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) + return ~head_applies_alone & ( add( benunit, period, diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py new file mode 100644 index 0000000000..ba30175f1e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_severe_disability_premium.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_severe_disability_premium(Variable): + value_type = float + entity = BenUnit + label = "Working-age CTR severe disability premium" + documentation = ( + "The shared legacy premium's amount before its non-dependant test, " + "with the Council Tax Reduction-specific family test applied. This " + "allows either direction of a difference between the Universal Credit " + "and Child Benefit young-person definitions. The test retains the " + "existing classification for looked-after residents and other benefit " + "units; see has_non_dependant_for_council_tax_reduction_severe_disability_premium. " + "Pension-age Council Tax Reduction continues to use its separate premium." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/13", + ) + + def formula(benunit, period, parameters): + amount = benunit("severe_disability_premium_before_non_dependant_test", period) + no_non_dependant = ~benunit( + "has_non_dependant_for_council_tax_reduction_severe_disability_premium", + period, + ) + return amount * no_non_dependant diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py new file mode 100644 index 0000000000..60c1037520 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/has_non_dependant_for_council_tax_reduction_severe_disability_premium.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country + + +class has_non_dependant_for_council_tax_reduction_severe_disability_premium(Variable): + value_type = bool + entity = BenUnit + label = "Has a non-dependant who blocks the CTR severe disability premium" + documentation = ( + "Uses the applicant family's Council Tax Reduction young-person definition " + "for non-looked-after members of its own benefit unit in Scotland's " + "working-age scheme. The applicant's own pensioner status and the dated " + "young-person parameter select that scheme. All other schemes retain " + "the shared Housing Benefit/Income Support test. Blind residents and " + "qualifying-benefit recipients remain disregarded. Looked-after members " + "retain the exact existing Child Benefit family classification, and " + "other benefit units retain the existing treatment. Reg 8(2)(b), subject " + "to reg 8(3), also excludes some young people outside the applicant's " + "responsibility; the model lacks complete tenancy and responsibility " + "relations for those looked-after and other-benefit-unit cases. They " + "remain outside this partial correction. Other limitations of " + "has_non_dependant_for_severe_disability_premium also remain." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/13", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.disability_premia + person = benunit.members + counted = ( + (person("age", period) >= p.severe_non_dependant_age) + & ~person("receives_severe_disability_premium_qualifying_benefit", period) + & ~person("is_blind", period) + ) + child_or_young_person = where( + person("is_looked_after_by_local_authority", period), + person("is_child_or_qualifying_young_person_for_child_benefit", period), + person("is_child_or_young_person_for_council_tax_reduction", period), + ) + family = person("is_claimant_or_partner", period) | child_or_young_person + in_household = benunit.max(person.household.sum(counted)) + non_dependant = in_household > benunit.sum(counted & family) + scotland = parameters( + period + ).gov.local_authorities.scotland.council_tax_reduction.working_age + scottish_working_age = ( + (benunit.household("country", period) == Country.SCOTLAND) + & ~benunit("council_tax_reduction_pensioner", period) + & scotland.young_person_follows_universal_credit + ) + return where( + scottish_working_age, + non_dependant, + benunit("has_non_dependant_for_severe_disability_premium", period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py index 46afbc7d86..0a37929f95 100644 --- a/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/kingston_upon_thames/council_tax_reduction/kingston_upon_thames_council_tax_reduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * import numpy as np from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, legacy_council_tax_reduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( @@ -25,7 +26,7 @@ def formula(benunit, period, parameters): household("country", period), benunit("council_tax_reduction_pensioner", period), ) - has_uc_award = benunit("universal_credit", period) > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) weekly_tariff_income = np.ceil( max_(0, capital - ctr.means_test.tariff_income_threshold) diff --git a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py index 208dab3929..274ffa526f 100644 --- a/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/oxford/council_tax_reduction/oxford_council_tax_reduction.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * import numpy as np +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, +) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_oxford_working_age, ) @@ -24,8 +27,7 @@ def formula(benunit, period, parameters): "council_tax_reduction_claimant_benunit", period ) would_claim = benunit("would_claim_council_tax_reduction", period) - universal_credit = benunit("universal_credit", period) - has_uc_award = universal_credit > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) capital_eligible = capital <= ctr.means_test.capital_limit @@ -35,7 +37,11 @@ def formula(benunit, period, parameters): ) annual_income = benunit("council_tax_reduction_applicable_income", period) - child_benefit = benunit("child_benefit", period) + # Applicable income counts Child Benefit only where the applicant is + # the benefit unit's claimant, so only then is it taken off again. + child_benefit = benunit("child_benefit", period) * ~benunit( + "council_tax_reduction_head_applies_alone", period + ) weekly_income = max_(0, annual_income - child_benefit) / WEEKS_IN_YEAR weekly_income += where(has_uc_award, 0, weekly_tariff_income) relevant_income_based_benefit = benunit( diff --git a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py index eaad011382..6eaa8c1d74 100644 --- a/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/westminster/council_tax_reduction/westminster_council_tax_reduction.py @@ -1,6 +1,7 @@ from policyengine_uk.model_api import * import numpy as np from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, legacy_council_tax_reduction, ) from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( @@ -23,7 +24,7 @@ def formula(benunit, period, parameters): household("country", period), benunit("council_tax_reduction_pensioner", period), ) - has_uc_award = benunit("universal_credit", period) > 0 + has_uc_award = applicant_has_universal_credit(benunit, period) capital = household("savings", period) weekly_tariff_income = np.ceil( max_(0, capital - ctr.means_test.tariff_income_threshold) diff --git a/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py b/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py index 2a2ca1b08d..241d772e78 100644 --- a/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py +++ b/policyengine_uk/variables/household/demographic/is_severely_disabled_for_benefits.py @@ -30,10 +30,10 @@ class is_severely_disabled_for_benefits(Variable): "use the stored value instead of this formula " "(policyengine-uk-data#494 aligns its definition with this one). " "The legacy severe disability premium has its own, wider list " - "(receives_severe_disability_premium_qualifying_benefit). The flag " - "also gates the Universal Credit higher disabled child addition, " - "whose condition (UC Regs 2013 reg 24(2)(b)) adds blindness and has " - "no armed forces independence payment limb; that is not yet separated." + "(receives_severe_disability_premium_qualifying_benefit). Universal " + "Credit uses its separate higher disabled child condition " + "(meets_uc_higher_disabled_child_condition), which includes certified " + "blindness and excludes attendance allowance and AFIP." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index 0518743097..d748f85fb4 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -50,47 +50,8 @@ class severe_disability_premium(Variable): unit = GBP def formula(benunit, period, parameters): - # Whom a carer benefit is paid for caring for is attributed by - # is_cared_for_by_carer_benefit_recipient, which the Pension Credit - # severe disability addition shares. The hospital rules that keep a - # patient treated as in receipt (and then pay the single rate) are not - # modelled. - p = parameters(period).gov.dwp.disability_premia - person = benunit.members - claimant_or_partner = person("is_claimant_or_partner", period) - qualifies = person( - "receives_severe_disability_premium_qualifying_benefit", period - ) - qualifying_member = claimant_or_partner & qualifies - qualifying = benunit.sum(qualifying_member) - partner_treated_as_absent = benunit.any( - claimant_or_partner & ~qualifies & person("is_blind", period) - ) - cared_for = benunit.sum( - qualifying_member - & person("is_cared_for_by_carer_benefit_recipient", period) - ) - # Number of single rates payable: two for a couple who both qualify - # with no carer paid for either, one when a carer is paid for only - # one of them, and one for a single claimant (or a couple whose - # other partner is blind) with no carer paid for them. - couple = benunit("is_couple", period) - single_condition = where( - couple, - (qualifying == 1) & partner_treated_as_absent, - qualifying >= 1, - ) & (cared_for == 0) - rates = where( - couple & (qualifying >= 2), - max_(2 - cared_for, 0), - single_condition, - ) - weekly_amount = select( - [rates >= 2, rates == 1], - [p.severe_couple, p.severe_single], - default=0, - ) + amount = benunit("severe_disability_premium_before_non_dependant_test", period) no_non_dependant = ~benunit( "has_non_dependant_for_severe_disability_premium", period ) - return weekly_amount * WEEKS_IN_YEAR * no_non_dependant + return amount * no_non_dependant diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py b/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py new file mode 100644 index 0000000000..eac30436c8 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium_before_non_dependant_test.py @@ -0,0 +1,63 @@ +from policyengine_uk.model_api import * + + +class severe_disability_premium_before_non_dependant_test(Variable): + value_type = float + entity = BenUnit + label = "Severe disability premium before the non-dependant test" + documentation = ( + "The shared legacy premium's annual amount after its qualifying-benefit, " + "partner and carer conditions, before its non-dependant condition. " + "This is a component, not a paid award. It retains the assumptions and " + "limitations documented by severe_disability_premium. Council Tax " + "Reduction can apply its own family definition to this amount." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", + ) + + def formula(benunit, period, parameters): + # Whom a carer benefit is paid for caring for is attributed by + # is_cared_for_by_carer_benefit_recipient, which the Pension Credit + # severe disability addition shares. The hospital rules that keep a + # patient treated as in receipt (and then pay the single rate) are not + # modelled. + p = parameters(period).gov.dwp.disability_premia + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + qualifies = person( + "receives_severe_disability_premium_qualifying_benefit", period + ) + qualifying_member = claimant_or_partner & qualifies + qualifying = benunit.sum(qualifying_member) + partner_treated_as_absent = benunit.any( + claimant_or_partner & ~qualifies & person("is_blind", period) + ) + cared_for = benunit.sum( + qualifying_member + & person("is_cared_for_by_carer_benefit_recipient", period) + ) + # Number of single rates payable: two for a couple who both qualify + # with no carer paid for either, one when a carer is paid for only + # one of them, and one for a single claimant (or a couple whose + # other partner is blind) with no carer paid for them. + couple = benunit("is_couple", period) + single_condition = where( + couple, + (qualifying == 1) & partner_treated_as_absent, + qualifying >= 1, + ) & (cared_for == 0) + rates = where( + couple & (qualifying >= 2), + max_(2 - cared_for, 0), + single_condition, + ) + weekly_amount = select( + [rates >= 2, rates == 1], + [p.severe_couple, p.severe_single], + default=0, + ) + return weekly_amount * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py b/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py new file mode 100644 index 0000000000..84b8df03a4 --- /dev/null +++ b/policyengine_uk/variables/household/income/squared_poverty_gap_ahc.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * +import numpy as np + + +class squared_poverty_gap_ahc(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "/1" + label = "Normalized squared poverty gap, after housing costs" + documentation = ( + "Dimensionless household FGT2 component: the square of the existing " + "after housing costs poverty gap divided by its matching poverty line. " + "Uses the existing HBAI income basis; a nonpositive line is undefined " + "and returns NaN." + ) + reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetarypoverty" + + def formula(household, period, parameters): + gap = household("poverty_gap_ahc", period) + line = household("poverty_line_ahc", period) + normalized_gap = np.divide( + gap, + line, + out=np.full_like(gap, np.nan, dtype=float), + where=line > 0, + ) + return np.square(normalized_gap) diff --git a/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py b/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py new file mode 100644 index 0000000000..b13b7b2579 --- /dev/null +++ b/policyengine_uk/variables/household/income/squared_poverty_gap_bhc.py @@ -0,0 +1,28 @@ +from policyengine_uk.model_api import * +import numpy as np + + +class squared_poverty_gap_bhc(Variable): + value_type = float + entity = Household + definition_period = YEAR + unit = "/1" + label = "Normalized squared poverty gap, before housing costs" + documentation = ( + "Dimensionless household FGT2 component: the square of the existing " + "before housing costs poverty gap divided by its matching poverty line. " + "Uses the existing HBAI income basis; a nonpositive line is undefined " + "and returns NaN." + ) + reference = "https://worldbank.github.io/PIP-Methodology-2022-05/surveyestimates.html#monetarypoverty" + + def formula(household, period, parameters): + gap = household("poverty_gap_bhc", period) + line = household("poverty_line_bhc", period) + normalized_gap = np.divide( + gap, + line, + out=np.full_like(gap, np.nan, dtype=float), + where=line > 0, + ) + return np.square(normalized_gap) diff --git a/policyengine_uk/variables/household/wealth/share_like_capital.py b/policyengine_uk/variables/household/wealth/share_like_capital.py new file mode 100644 index 0000000000..96c6eabb72 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + +# Itemised holdings within this share of the larger of corporate_wealth and +# their sum are the same holdings rounded differently, not extra wealth. +# Simulations hold each value as float32 (24-bit significand, spacing 2^-23 of +# the value): a dataset that builds corporate_wealth as the float64 sum of +# three components and stores all four rounds each once, and the engine adds +# the three components in float32, so the two totals differ by at most about +# 2.5 units in the last place, 3e-7 of the value. 1e-6 leaves a margin of more +# than three times that and still counts any genuine excess above a millionth. +# The bound assumes the dataset stores float64 tables, as policyengine-uk-data +# and microcosm do; float32 tables would add a rounding per side for each year +# a dataset is projected forward. +RELATIVE_TOLERANCE = 1e-6 + + +class share_like_capital(Variable): + label = "share-like capital" + documentation = ( + "Share-like holdings counted by the means-test capital tests: " + "corporate_wealth as supplied, unless its itemised components " + "(directly_held_shares, unit_and_investment_trusts and " + "stocks_and_shares_isa) are positive and add up to more than it by more " + "than a millionth of the larger of the two, in which case their sum. " + "Datasets that carry only corporate_wealth, or build it as the " + "components' sum, therefore count exactly corporate_wealth (any sign): " + "the millionth absorbs float32 rounding of the sum. A household given " + "only components, with no corporate_wealth, has them counted in full. " + "Private pension wealth is not part of it; datasets built before " + "private pension wealth was split out of corporate_wealth still carry " + "it here." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + corporate_wealth = household("corporate_wealth", period) + itemised = add( + household, + period, + [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", + ], + ) + tolerance = RELATIVE_TOLERANCE * max_(abs(corporate_wealth), itemised) + # Compare the difference, which float32 computes exactly for close + # values, rather than adding the tolerance to corporate_wealth, which + # would round it up to a whole unit in the last place. + exceeds = (itemised > 0) & (itemised - corporate_wealth > tolerance) + return where(exceeds, itemised, corporate_wealth) diff --git a/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py b/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py new file mode 100644 index 0000000000..d5136dd2b3 --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital_with_sale_expenses.py @@ -0,0 +1,37 @@ +from policyengine_uk.model_api import * + +COMPONENTS = [ + "directly_held_shares", + "unit_and_investment_trusts", + "stocks_and_shares_isa", +] + + +class share_like_capital_with_sale_expenses(Variable): + label = "share-like capital with expenses of sale" + documentation = ( + "The part of share_like_capital whose sale would incur expenses, so " + "that the means tests value it less 10%: share_like_capital less the " + "itemised holdings listed in gov.dwp.share_like_capital." + "without_sale_expenses (unit and investment trusts, stocks and shares " + "ISAs), floored at nil. A household with no itemised holdings has all " + "of its share-like capital here, so datasets that carry only " + "corporate_wealth keep the flat 10% on it." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + share_like = household("share_like_capital", period) + itemised = add(household, period, COMPONENTS) + # Summed from a numeric zero so that an empty list (every holding + # taking the deduction) still works. + without = 0 * share_like + for variable in parameters( + period + ).gov.dwp.share_like_capital.without_sale_expenses: + without = without + household(variable, period) + return where(itemised > 0, max_(0, share_like - without), share_like) diff --git a/policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py b/policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py new file mode 100644 index 0000000000..ad777f2f0b --- /dev/null +++ b/policyengine_uk/variables/household/wealth/share_like_capital_without_sale_expenses.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class share_like_capital_without_sale_expenses(Variable): + label = "share-like capital without expenses of sale" + documentation = ( + "The part of share_like_capital the means tests value at full value: " + "share_like_capital less share_like_capital_with_sale_expenses. Nil " + "for a household with no itemised holdings." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + + def formula(household, period, parameters): + return household("share_like_capital", period) - household( + "share_like_capital_with_sale_expenses", period + ) diff --git a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py index 5c9b4d6ccb..caa743d61e 100644 --- a/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py +++ b/policyengine_uk/variables/household/wealth/stocks_and_shares_isa.py @@ -4,7 +4,8 @@ class stocks_and_shares_isa(Variable): label = "stocks and shares ISA holdings" documentation = ( - "Amount held in stocks and shares (investment) Individual Savings Accounts" + "Amount held in stocks and shares (investment) Individual Savings " + "Accounts. Part of corporate_wealth, so the two must never be summed." ) entity = Household definition_period = YEAR 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/corporate_wealth.py b/policyengine_uk/variables/input/corporate_wealth.py index e1dabe6b62..c86bda946e 100644 --- a/policyengine_uk/variables/input/corporate_wealth.py +++ b/policyengine_uk/variables/input/corporate_wealth.py @@ -4,11 +4,11 @@ class corporate_wealth(Variable): label = "corporate wealth" documentation = ( - "Wealth held in corporations directly or through investment funds: UK " - "shares, employee shares and options, unit and investment trusts, and " - "stocks and shares ISAs, imputed from the Wealth and Assets Survey. The " - "stocks and shares ISA component is also exported on its own as " - "stocks_and_shares_isa, so the two must never be summed. Private pension " + "Wealth held in corporations directly or through investment funds, " + "outside pensions: directly_held_shares (UK shares and employee shares " + "and options), unit_and_investment_trusts and stocks_and_shares_isa. " + "Datasets that carry those components build this variable as their " + "exact sum, so it must never be summed with any of them. Private pension " "wealth is carried separately in private_pension_wealth; datasets built " "before that split folded it into this variable." ) diff --git a/policyengine_uk/variables/input/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/directly_held_shares.py b/policyengine_uk/variables/input/directly_held_shares.py new file mode 100644 index 0000000000..5fe9fd079f --- /dev/null +++ b/policyengine_uk/variables/input/directly_held_shares.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class directly_held_shares(Variable): + label = "directly held shares" + documentation = ( + "Value of shares the household holds directly, outside ISAs and pooled " + "funds: shares in UK companies, listed or not (the Wealth and Assets " + "Survey question), and employee shares and share options. Part of " + "corporate_wealth, so the two must never be summed. Imputed for the " + "Enhanced FRS from the Wealth and Assets Survey's UK shares and " + "employee shares and options (policyengine-uk-data#501); overseas " + "shares are not imputed. Quoted shares are valued at their price less " + "10% for the expenses of sale (Advice for Decision Making H1665 for " + "Universal Credit; Decision Makers' Guide 29671, 52671 and 84763 for " + "Income Support and JSA, ESA and Pension Credit); unquoted shares need " + "an expert valuation (ADM H1679-H1681)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 diff --git a/policyengine_uk/variables/input/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/owned_company/owned_company_holding_value.py b/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py index 2624e2cc7e..bf9700e36e 100644 --- a/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py +++ b/policyengine_uk/variables/input/owned_company/owned_company_holding_value.py @@ -8,8 +8,9 @@ class owned_company_holding_value(Variable): documentation = ( "The value of the person's holding (their shares) in the company in " "which they stand as sole owner or partner, to the extent that it is " - "already counted in the household's capital (for example within " - "corporate_wealth) or in uc_reported_capital." + "already counted in the household's capital (within its share-like " + "capital: corporate_wealth or its itemised components) or in " + "uc_reported_capital." ) definition_period = YEAR unit = GBP 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/unit_and_investment_trusts.py b/policyengine_uk/variables/input/unit_and_investment_trusts.py new file mode 100644 index 0000000000..2352d63f49 --- /dev/null +++ b/policyengine_uk/variables/input/unit_and_investment_trusts.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class unit_and_investment_trusts(Variable): + label = "unit and investment trusts" + documentation = ( + "Value of the household's holdings of unit trusts and investment trusts " + "outside ISAs. The Wealth and Assets Survey asks about both in one " + "question, so the Enhanced FRS cannot separate them " + "(policyengine-uk-data#501). Part of corporate_wealth, so the two must " + "never be summed. Unit trusts are valued at the bid price with no " + "deduction for expenses of sale, because the holder withdraws through " + "the manager (Advice for Decision Making H1673-H1674; Decision Makers' " + "Guide 29680-29681). Investment trusts are companies whose shares are " + "quoted, so they fall under the quoted-share rule (ADM H1665)." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + quantity_type = STOCK + default_value = 0 diff --git a/policyengine_uk/variables/input/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 78c8b1b5a5..9bc3d83dd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "policyengine-uk" -version = "2.139.2" +version = "2.146.0" 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")