diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index a1bdda7f7f..4689a1449e 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -19,6 +19,18 @@ # Keep entries narrow. An entry that matches nothing in a run is reported as a # warning; remove it once its bug is fixed. Failures can never be accepted. +[[allow]] +nodeid = "policyengine_uk/tests/test_subsample.py::test_neutralized_inputs_and_model_values_survive_subsampling[[]*]" +outcome = "xfailed" +reason = "^needs policyengine-core holder resync$" +why = "Issue #2254 / PR #2265 waits for core to resync restored holders after subsampling. Both ownership variants retain every assertion under strict xfail." + +[[allow]] +nodeid = "policyengine_uk/tests/test_input_records.py::test_restoring_reform_can_replace_a_neutralized_input[[]subsampled-*]" +outcome = "xfailed" +reason = "^needs policyengine-core holder resync$" +why = "Issue #2254 / PR #2265 waits for core to accept writes immediately after restoring a subsampled holder. Direct supplied/model controls remain unmarked." + [[allow]] nodeid = "policyengine_uk/tests/test_uc_state_pension_properties.py::test_state_pension_does_not_change_partners_marriage_allowance" outcome = "xfailed" diff --git a/.gitignore b/.gitignore index d0f289aae2..5df73da1f9 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,7 @@ policyengine_uk/calibration/*.h5 !uprating_growth_factors.csv !policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv !policyengine_uk/tests/fixtures/energy_prices/*.csv +!policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv # Virtual environments .venv/ diff --git a/changelog.d/1971.fixed.md b/changelog.d/1971.fixed.md new file mode 100644 index 0000000000..9cbbd48171 --- /dev/null +++ b/changelog.d/1971.fixed.md @@ -0,0 +1 @@ +Pay the legacy enhanced disability premium for an award of enhanced PIP daily living or armed forces independence payment as well as the highest DLA care rate, and apply its State Pension Credit age limit. Count an award only where its amount is positive, and count an amount entered without its category when it reaches `52 × (top weekly rate − £1)`. Give five working-age Council Tax Reduction schemes their own lists. Scottish councils count attendance allowance at either rate from April 2023, and the Scottish Ministers removed the age limit from June 2022. Merton, Kingston upon Thames and Westminster count either PIP daily living rate and omit armed forces independence payment; Newham counts either PIP daily living rate and armed forces independence payment. Apply Kingston upon Thames's own list in 2013-14 to 2017-18 and from 2023-24, and assume the Housing Benefit list in 2018-19 to 2022-23, for which the model cites no complete scheme text. diff --git a/changelog.d/2004.fixed.md b/changelog.d/2004.fixed.md new file mode 100644 index 0000000000..2087926885 --- /dev/null +++ b/changelog.d/2004.fixed.md @@ -0,0 +1 @@ +The model now calculates Housing Benefit and national Council Tax Reduction non-dependant deductions under each scheme's rules. Bands include their lower edge. For a non-exempt non-dependant outside remunerative work, including someone on Income Support, income-based JSA or income-related ESA, the council deducts the lowest amount whatever their income. The council bands a couple on its joint gross income plus the family's awards, counting Universal Credit after the benefit cap and before debt deductions, and normally makes one deduction, the higher. Under Wales's working-age scheme, it deducts both members' amounts when their own Universal Credit award assesses either member. It deducts separately for each other adult. The model counts other investment income, ISA interest and ISA dividends in gross income, and treats a self-employment or property loss as nil. Councils apply the claimant and non-dependant exemptions, and for the Universal Credit exemption the model reads Universal Credit's own calculation of earned income. The model gives the England pensioner, Scottish and Welsh Council Tax Reduction schemes their own scales from 2013-14, and follows each jointly liable applicant's own scheme. For the Universal Credit benefit cap, the model now totals welfare benefits without Housing Benefit (`uc_benefit_cap_welfare_benefits`). A caller who supplies the combined total, `benefit_cap_welfare_benefits`, must also supply `housing_benefit_pre_benefit_cap` or `uc_benefit_cap_welfare_benefits` for every benefit unit; otherwise the model raises `ValueError` when it calculates the Universal Credit cap. diff --git a/changelog.d/2008.fixed.md b/changelog.d/2008.fixed.md new file mode 100644 index 0000000000..11354b34f2 --- /dev/null +++ b/changelog.d/2008.fixed.md @@ -0,0 +1 @@ +Give each member of the benefit unit aged 16 or over who is not the claimant or partner a bedroom in the Universal Credit LHA size criteria (UC Regs 2013 Sch 4 para 10(1)(b) and (c)), except a qualifying young person no one is responsible for (para 9(2)(g)) and a household head who does not claim and is not a qualifying young person the renter is responsible for, whom the formula treats as liable for the rent (para 9(2)(d)). The bedroom reads the definition that the housing cost contribution uses (is_benefit_unit_non_dependant_for_universal_credit), and the claim that bears an in-unit non-dependant's contribution has their bedroom. diff --git a/changelog.d/2014.fixed.md b/changelog.d/2014.fixed.md new file mode 100644 index 0000000000..95686667b1 --- /dev/null +++ b/changelog.d/2014.fixed.md @@ -0,0 +1 @@ +- Charge Universal Credit, Housing Benefit and national Council Tax Reduction non-dependant deductions for members of the claimant's own benefit unit who are not the claimant, partner or a child or young person, such as an adult son (UC Regs 2013 Sch 4 para 9; HB Regs 2006 and HB (SPC) Regs 2006 reg 3; SI 2012/2885 reg 9 and the Welsh and Scottish equivalents). Housing Benefit and Council Tax Reduction apportion every non-dependant between joint occupiers or jointly liable applicants, a lodger's adult son included, and a household head is never her own benefit unit's non-dependant. Apply the Universal Credit renter exemption from housing cost contributions (Sch 4 para 15) where a claimant, not a partner whom reg 3(3) leaves out of the claim, is blind or receives a listed benefit, armed forces independence payment included (reg 2's attendance allowance), and give the para 16(2)(b) Pension Credit exemption only to the claimant or partner who receives it, so a pensioner's adult son is no longer exempt by her award. diff --git a/changelog.d/2090.fixed.md b/changelog.d/2090.fixed.md new file mode 100644 index 0000000000..fd65a3c255 --- /dev/null +++ b/changelog.d/2090.fixed.md @@ -0,0 +1 @@ +The model now deducts from Council Tax Reduction income only the income tax and National Insurance each scheme allows: in Scotland's working-age scheme the extra tax earnings cause and the notional tax on self-employed profit, and in the pensioner schemes of England, Wales and Scotland that same earnings tax, the tax on the other counted income and the notional tax on profit, with no tax on savings, dividends, rent from property or the Scottish Carer Supplement, and no High Income Child Benefit Charge, where earnings do not raise them. diff --git a/changelog.d/2106.fixed.md b/changelog.d/2106.fixed.md new file mode 100644 index 0000000000..92b771fb40 --- /dev/null +++ b/changelog.d/2106.fixed.md @@ -0,0 +1 @@ +The model now sets the Personal Allowance, other allowances and trade loss relief against non-savings, savings and dividend income in the way that gives the least tax (ITA 2007 s. 25(2)), and from 2027-28 against other income before property, savings and dividend income (s. 25(3A)), where it previously always took them from non-savings income first. Pension relief stays on non-savings income, and the income tax that Universal Credit deducts from earnings still follows the order non-savings, savings, dividends. diff --git a/changelog.d/2120.fixed.md b/changelog.d/2120.fixed.md new file mode 100644 index 0000000000..a5b29b8b7d --- /dev/null +++ b/changelog.d/2120.fixed.md @@ -0,0 +1 @@ +The model now counts a carer's Universal Credit award with the carer element, where the award comes to at least one penny, when deciding the severe disability premium and the Pension Credit severe disability addition, and in Scotland's working-age council tax reduction it counts such an award for the carer premium from 1 April 2022 (#2120, #2124). diff --git a/changelog.d/2254.fixed.md b/changelog.d/2254.fixed.md new file mode 100644 index 0000000000..eab64a3be3 --- /dev/null +++ b/changelog.d/2254.fixed.md @@ -0,0 +1 @@ +`Simulation.subsample()` no longer raises `TypeError` about a missing `dataset` argument: it keeps a weighted sample of households with every supplied weight total unchanged, and replaces the baseline and `dataset` with copies holding the same households. It keeps the stored inputs and model values of neutralised variables for the sampled records. diff --git a/changelog.d/2307.fixed.md b/changelog.d/2307.fixed.md new file mode 100644 index 0000000000..b05817f4f9 --- /dev/null +++ b/changelog.d/2307.fixed.md @@ -0,0 +1 @@ +`supplied_input` and `supplied_input_periods` no longer count a value cached over a supplied input as supplied, and read an input stored on disk from disk when a value is cached over it in memory (#2307). diff --git a/changelog.d/2310.fixed.md b/changelog.d/2310.fixed.md new file mode 100644 index 0000000000..4a3bda8989 --- /dev/null +++ b/changelog.d/2310.fixed.md @@ -0,0 +1 @@ +Pay Carer's Allowance and Carer Support Payment, and count a carer for the Universal Credit carer element, on caring hours only where the new input `cares_for_severely_disabled_person` says the person cared for gets a qualifying disability benefit; it defaults to true, so results change only where a calculation sets it false. diff --git a/changelog.d/2350.fixed.md b/changelog.d/2350.fixed.md new file mode 100644 index 0000000000..84d4dc45bf --- /dev/null +++ b/changelog.d/2350.fixed.md @@ -0,0 +1 @@ +The model now reports Scottish tax bands from 2017-18 using Scottish limits for non-savings, non-dividend income and UK limits for savings and dividends, and tests the Marriage Allowance income conditions directly under ITA 2007 s. 55B(2)(b) and (ba), allowing income at a rate limit and savings at a permitted nil rate. It gives the Marriage Allowance recipient a Step 6 tax reduction of the basic rate, or the Scottish basic rate, on the transferable amount (s. 55B(1) and (3)), in place of a deduction from taxable income. A reduction larger than the tax on earnings now also comes off the tax the Welsh working-age Council Tax Reduction route disregards on pensions and benefits. diff --git a/changelog.d/2541.fixed.md b/changelog.d/2541.fixed.md new file mode 100644 index 0000000000..eb1ea7a24c --- /dev/null +++ b/changelog.d/2541.fixed.md @@ -0,0 +1 @@ +Fixed the Carer's Allowance earnings limit treating earnings a penny a week over the 2020-21 to 2024-25 limits (£128, £132, £139 and £151) as within them, and the Budget 2026 earnings taper taking a penny a week less than the pence above the limit off some awards. Net earnings for Carer's Allowance and the Scottish Carer Support Payment now apply the Computation of Earnings Regulations' rounding to each step in exact pence, and the formula gives the weekly excess over the Carer's Allowance limit in whole pence for a limit in whole pence. diff --git a/changelog.d/2565.fixed.md b/changelog.d/2565.fixed.md new file mode 100644 index 0000000000..aee0e6c5ca --- /dev/null +++ b/changelog.d/2565.fixed.md @@ -0,0 +1 @@ +The model no longer pays income-related ESA to a claimant who has reached pensionable age, or to a couple that would get State Pension Credit without that award, whether the model's formulas, an input or a reform set that Pension Credit, and it pays no Pension Credit to a couple while it pays the couple income-related ESA (Welfare Reform Act 2007 s.1(3)(c) and Sch 1 para 6(1)(c) and (d)). diff --git a/changelog.d/2577.fixed.md b/changelog.d/2577.fixed.md new file mode 100644 index 0000000000..728a3642b5 --- /dev/null +++ b/changelog.d/2577.fixed.md @@ -0,0 +1 @@ +The model now counts overseas and other real estate in property wealth and in every means-tested capital assessment that includes household property. The Pension Credit formula now adds owned land, non-residential property and residential property other than the main residence to a financial-only capital report instead of letting the report replace them. The household land formula now applies regional land intensity to residential, non-residential and other real estate instead of property_wealth, and adds direct UK land once at full value. On the certified file, whose producer imputed the saved property_wealth total and the components separately, household land at data-year values rises from about £5,113bn to £5,521bn ([measurement](https://github.com/PolicyEngine/policyengine-uk/pull/2688#issuecomment-6449462413)). diff --git a/changelog.d/2633.added.md b/changelog.d/2633.added.md new file mode 100644 index 0000000000..0f32cdb04c --- /dev/null +++ b/changelog.d/2633.added.md @@ -0,0 +1 @@ +Added property tests for VAT grossing (the routing between the survey coverage and the deprecated combined factor, including the closeness band, and VAT as the statutory liability over the shipped product, times one minus private school fees' share of VAT receipts, on a vector of households), a test that the model grosses 2024 by the same product as later years, and a record of how the coverage of 0.67 performed on the Microcosm UK cuts `6cba5d71` and `a133f37a`, which followed `f9d1922c` (#2633). diff --git a/changelog.d/2633.changed.md b/changelog.d/2633.changed.md new file mode 100644 index 0000000000..8abad51e1f --- /dev/null +++ b/changelog.d/2633.changed.md @@ -0,0 +1 @@ +Re-derived the survey VAT consumption coverage (`gov.simulation.vat.survey_consumption_coverage`) on the certified Microcosm UK 2024-25 national cut `a6299504` and moved it from 0.67 to 0.69: on that cut, 2024-25 total `vat` comes to £176.06bn at 0.67, 3.0% above OBR receipts of £170.994bn, and £171.02bn at 0.69, 0.02% above. Moved the deprecated combined factor `gov.simulation.microdata_vat_coverage` to the new product, 0.483. diff --git a/changelog.d/2773.fixed.md b/changelog.d/2773.fixed.md new file mode 100644 index 0000000000..0692a444a9 --- /dev/null +++ b/changelog.d/2773.fixed.md @@ -0,0 +1 @@ +The aggregate checker reports non-finite baseline values as blocking failures and continues checking readable households. diff --git a/changelog.d/2791.fixed.md b/changelog.d/2791.fixed.md new file mode 100644 index 0000000000..2b44e7be4a --- /dev/null +++ b/changelog.d/2791.fixed.md @@ -0,0 +1 @@ +Pay the Scottish Carer Supplement for each award week in which Carer Support Payment is payable, up to 52 a year, counting the weeks from the larger of the payment and the entitlement before the overlapping-benefit reduction, so a carer paid 13 weeks of Carer Support Payment after moving to England gets £152.10 of supplement in 2026-27 rather than £608.40. diff --git a/changelog.d/2801.fixed.md b/changelog.d/2801.fixed.md new file mode 100644 index 0000000000..2af8ea2323 --- /dev/null +++ b/changelog.d/2801.fixed.md @@ -0,0 +1 @@ +The model now dates the main residential rates in s. 55(1B) Table A from 4 December 2014, first-time buyers' relief from 22 November 2017, the higher rates for additional dwellings from 1 April 2016 and the 2016 non-residential rates from 17 March 2016. Before 17 March 2016 it computes SDLT on a non-residential purchase as one percentage of the whole price. It computes SDLT on purchases in Wales until 31 March 2018 and Land Transaction Tax from 1 April 2018. diff --git a/changelog.d/2804.fixed.md b/changelog.d/2804.fixed.md new file mode 100644 index 0000000000..8a73e94f80 --- /dev/null +++ b/changelog.d/2804.fixed.md @@ -0,0 +1 @@ +Excluded from the Universal Credit qualifying young person test a person who reports their own income-related ESA, income-based JSA, Income Support, Housing Benefit, Child Tax Credit or Working Tax Credit in a year the model pays that benefit, unless the model's legacy closure, or for a claimant or partner the family's Universal Credit claim, has ended the award on that report or a reform neutralises it, and a claimant or partner on the couple's tax credit award (SI 2014/1230 reg. 28). The Universal Credit education condition reads that test as it stands on the first day of a claimant's Universal Credit award, while a Housing Benefit award (in Northern Ireland, a rent rebate or allowance) or an income-related ESA award the claim ends still runs, and reads every reported existing benefit for the migration day's reg. 60(1) protection, and the formula for Scotland's working-age Council Tax Reduction reads the Universal Credit Regulations 2013 reg. 5 test without reg. 28. The supplied input `housing_benefit_report_is_rate_rebate_only` marks a Northern Ireland report that covers only a rate rebate, which the claim ends the day before the award (SR 2016/226 reg. 6(2)). diff --git a/changelog.d/2863.fixed.md b/changelog.d/2863.fixed.md new file mode 100644 index 0000000000..f15cc8fa6f --- /dev/null +++ b/changelog.d/2863.fixed.md @@ -0,0 +1 @@ +The student loan repayment formula now charges Plan 5 borrowers nothing before 6 April 2026, as SI 2009/470 reg. 15(2BA) requires. diff --git a/changelog.d/2874.changed.md b/changelog.d/2874.changed.md new file mode 100644 index 0000000000..aaf800f40b --- /dev/null +++ b/changelog.d/2874.changed.md @@ -0,0 +1 @@ +The test suite fails on any new random draw, sampling call or hash of entity ids in the package, and lists today's draws in a ratchet. diff --git a/changelog.d/2875.fixed.md b/changelog.d/2875.fixed.md new file mode 100644 index 0000000000..4f36797bf2 --- /dev/null +++ b/changelog.d/2875.fixed.md @@ -0,0 +1 @@ +The real income variables now price to the year in gov.simulation.price_base_year (2026) instead of the year the simulation runs, and inflation_adjustment_ahc uses one price index on both sides of its ratio. diff --git a/changelog.d/cb-qyp-school-leaver-routes.fixed.md b/changelog.d/cb-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..b8cf3f0d0c --- /dev/null +++ b/changelog.d/cb-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +Added the Child Benefit qualifying young person conditions for young people who have left education (Child Benefit (General) Regulations 2006 regs 4, 5 and 7), which the Housing Benefit, legacy benefit and Council Tax Reduction young persons follow: a 16-year-old counts until the 31 August after their 16th birthday, in any year that starts before it, two new inputs place the 20-week extension period and the terminal date, and the model pays no Child Benefit for a young person who qualifies only through the terminal date and works 24 hours or more a week (reg. 7(3)). A young person who reports their own Universal Credit, Employment and Support Allowance or income-based Jobseeker's Allowance, or their own Income Support while DWP pays it, is not one, on any route (reg. 8). diff --git a/changelog.d/certified-aggregate-one-shard.changed.md b/changelog.d/certified-aggregate-one-shard.changed.md new file mode 100644 index 0000000000..6e523dee82 --- /dev/null +++ b/changelog.d/certified-aggregate-one-shard.changed.md @@ -0,0 +1 @@ +Run every test in test_aggregate_impact_certified.py in one CI shard, so the other six shards no longer build its simulations. diff --git a/changelog.d/ctc-qyp-school-leaver-routes.fixed.md b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md new file mode 100644 index 0000000000..091fbd5db6 --- /dev/null +++ b/changelog.d/ctc-qyp-school-leaver-routes.fixed.md @@ -0,0 +1 @@ +Counted a 16-year-old who has left education as a Child Tax Credit qualifying young person until the 31 August after their 16th birthday, in any year that starts before it (Child Tax Credit Regulations 2002 reg. 4), and added an input for the 20 weeks after leaving while registered for work or training (reg. 5(3)(b)). Remunerative work of 24 hours a week ends both for a young person who has left education (reg. 5(4)(a)), and Income Support received by the young person ends every route (reg. 5(4)(c)). diff --git a/changelog.d/qyp-income-support-exclusion.fixed.md b/changelog.d/qyp-income-support-exclusion.fixed.md new file mode 100644 index 0000000000..8dec0770f0 --- /dev/null +++ b/changelog.d/qyp-income-support-exclusion.fixed.md @@ -0,0 +1 @@ +Tested that Pension Credit, Child Benefit and Child Tax Credit each stop counting a young person who receives Income Support in their own right as a qualifying young person, and Universal Credit does not (SPC Regs 2002 reg. 4A(5); Child Benefit (General) Regulations 2006 reg. 8(2)(a); CTC Regs 2002 reg. 5(4)(c); UC Regs 2013 reg. 5(5)), in the baseline and under reforms that remove Income Support. Pension Credit and Child Tax Credit read the benefit unit's calculated Income Support for whichever of the claimant and partner receives it, so a reform that pays no Income Support ends the exclusion for that person; Child Benefit reads the young person's own report while Income Support is in payment. diff --git a/changelog.d/qyp-terminal-date-19.fixed.md b/changelog.d/qyp-terminal-date-19.fixed.md new file mode 100644 index 0000000000..72d9b148e8 --- /dev/null +++ b/changelog.d/qyp-terminal-date-19.fixed.md @@ -0,0 +1 @@ +Placed the 1 September following a qualifying young person's 19th birthday from their date of birth, for Universal Credit and Pension Credit (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 reg. 4A(1)(b)). The model now counts a 19-year-old in education for Universal Credit by default in household calculations, as the microdata already did, and applies the date to Pension Credit, which had no terminal date. diff --git a/changelog.d/state-pension-reported-weeks.changed.md b/changelog.d/state-pension-reported-weeks.changed.md new file mode 100644 index 0000000000..615785998d --- /dev/null +++ b/changelog.d/state-pension-reported-weeks.changed.md @@ -0,0 +1 @@ +Document that state_pension_reported is the weekly State Pension times 52, the weeks the State Pension components read it back at, and test the route from a survey's weekly amount to Income Tax under the Pension Tax Allowance. diff --git a/changelog.d/uc-capital-sale-expenses.fixed.md b/changelog.d/uc-capital-sale-expenses.fixed.md new file mode 100644 index 0000000000..02d0588143 --- /dev/null +++ b/changelog.d/uc-capital-sale-expenses.fixed.md @@ -0,0 +1 @@ +Value Universal Credit, Housing Benefit, Income Support, income-based JSA, income-related ESA and Pension Credit capital at market value less 10% where a sale would incur expenses and less any debt secured on the asset, as UC Regs 2013 reg. 49(1) and its legacy equivalents require. The 10% applies to land, property and share-like capital (share_like_capital), not to cash savings; share-like capital also holds unit trusts, which have no costs of sale, and in datasets built before the private pension split it holds pension wealth, so the deduction reaches those too until the data separate them. Adds secured-debt inputs for other residential property, non-residential property and land, and the council tax reduction formula deducts the debt secured on land from the land on its own household route. Each programme rounds a fraction of a penny in its capital calculations as its regulations require: to the nearest penny for Universal Credit (UC Regs 2013 reg. 6(1)) and Housing Benefit, and down for the legacy benefits and Pension Credit. The model works out a Lifetime ISA's surrender value from its balance and rounds it under the same rule, and keeps explicit Lifetime ISA capital inputs and reformed valuation formulas. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 501dd5c9e2..9fdc626b28 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -106,7 +106,7 @@ We derive these rates as calendar-year nominal GDP growth from the OBR's October |-------------|------|------|------|------|------|------|------|------|------|------| | Growth rate | 10.0% | 5.2% | 4.0% | 4.6% | 4.0% | 3.1% | 3.4% | 3.6% | 3.4% | 3.3% | -We use per capita GDP for these variables: `benunit_reported_capital`, `cash_isa`, `cash_isa_balance`, `consumer_debt`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `income_not_otherwise_charged`, `individual_savings_account_dividend_income`, `lifetime_isa_balance`, `lump_sum_income`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `mortgage_debt`, `net_financial_wealth`, `non_residential_property_purchased`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_residential_property_secured_debt`, `owned_company_capital`, `owned_company_holding_value`, `owned_company_trade_assets`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `savings`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `stocks_and_shares_isa_balance`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `benunit_reported_capital`, `cash_isa`, `cash_isa_balance`, `consumer_debt`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `income_not_otherwise_charged`, `individual_savings_account_dividend_income`, `lifetime_isa_balance`, `lump_sum_income`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `mortgage_debt`, `net_financial_wealth`, `non_residential_property_purchased`, `non_residential_property_secured_debt`, `non_residential_property_value`, `other_property_value`, `other_residential_property_secured_debt`, `overseas_property_value`, `owned_company_capital`, `owned_company_holding_value`, `owned_company_trade_assets`, `owned_land`, `owned_land_secured_debt`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `rent_paid_as_boarder`, `rent_paid_as_lodger`, `savings`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `stocks_and_shares_isa_balance`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` ## Capital gains diff --git a/docs/book/assumptions/mp30k-uk-switch-plan.md b/docs/book/assumptions/mp30k-uk-switch-plan.md index 374e9209f3..bec4a26a15 100644 --- a/docs/book/assumptions/mp30k-uk-switch-plan.md +++ b/docs/book/assumptions/mp30k-uk-switch-plan.md @@ -115,14 +115,13 @@ A documented gate must pass before any environment's default changes: 1. Dataset choice stays a runtime setting: no code in this repo branches on which dataset is loaded. One parameter is tied to the dataset, though: - `gov.simulation.vat.survey_consumption_coverage` is calibrated to one - Microcosm UK 2024-25 national cut only - (`microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68`; policyengine.py - 6.10.0 pins the later cut - `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71`), puts 2024-25 - VAT 38% (policyengine-uk-data 1.56.16) or 44% (1.58.0) above receipts on - the Enhanced FRS 2024-25, and has to be re-derived when the dataset it - should match changes (`docs/engineering/vat-coverage.md`). + `gov.simulation.vat.survey_consumption_coverage` matches 2024-25 receipts + on one Microcosm UK 2024-25 national cut only + (`microcosm-uk-2024-25-national-20261025T091414Z-a6299504`, which + policyengine.py 6.11.1 and 6.12.0 certify), and puts 2024-25 VAT 38% above + receipts on the Enhanced FRS 2024-25 (policyengine-uk-data 1.58.1). + Re-derive it, with `docs/engineering/vat-coverage.md`, whenever the dataset + it should match changes. 2. While both are in use, CI runs the dataset-dependent tests on both. 3. The default is set per environment: for direct use of this package through `POLICYENGINE_UK_DEFAULT_DATASET`; for this repository's tests diff --git a/docs/book/programs/gov/dwp/disability-legacy-benefits.md b/docs/book/programs/gov/dwp/disability-legacy-benefits.md index b1ee65b3f0..bfffe78a05 100644 --- a/docs/book/programs/gov/dwp/disability-legacy-benefits.md +++ b/docs/book/programs/gov/dwp/disability-legacy-benefits.md @@ -94,7 +94,8 @@ 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 +does not identify the person cared for, and reads their qualifying benefit only +from the input `cares_for_severely_disabled_person`, which defaults to true. No private-data impact or Axiom parity has been measured. ## References diff --git a/docs/book/programs/gov/dwp/housing-benefit.ipynb b/docs/book/programs/gov/dwp/housing-benefit.ipynb index 4d714f603f..37cee6281d 100644 --- a/docs/book/programs/gov/dwp/housing-benefit.ipynb +++ b/docs/book/programs/gov/dwp/housing-benefit.ipynb @@ -50,7 +50,7 @@ "\n", "1. **Eligible rent**: the rent the benunit is liable for (`benunit_rent`). Where the Local Housing Allowance applies (`LHA_eligible`: renting, and no member in social housing), the eligible rent is the maximum rent (LHA), which is the LHA rate for the Housing Benefit category of dwelling (`housing_benefit_LHA_category`: the shared accommodation rate for a young individual with no non-dependant, or a claimant entitled to one bedroom who lacks exclusive use of self-contained accommodation, unless the severe disability premium applies, reg 13D(2)) or, where the rent is lower, the rent (`LHA_cap`; SI 2006/213 regs [12D(2)(a)](https://www.legislation.gov.uk/uksi/2006/213/regulation/12D) and [13D(5)](https://www.legislation.gov.uk/uksi/2006/213/regulation/13D)).\n", "2. **Applicable amount** (`housing_benefit_applicable_amount`): a personal allowance derived from the benunit's composition (single, couple, lone parent) and age, plus any disability/carer premiums. This is the \"minimum income\" the household needs before HB tapers begin.\n", - "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51, and the Northern Ireland regulations at SR 2006/405 regs 13C, 14D, 68 and 69 and SR 2006/406 regs 13C, 14D, 48 and 49. Applicable income counts earnings, pensions and benefits net of the income tax and National Insurance on them. Income derived from capital (rent from property, interest and dividends) is not income: regulation 46(4) of the Housing Benefit Regulations 2006 treats it as capital, and Schedule 5 paragraph 22 of the pension-age regulations disregards it. The capital counts instead through `housing_benefit_tariff_income` and the capital limit. Rent for letting part of the home is the exception: it counts, less £20 a week per occupier.\n", + "3. **Means test** (`housing_benefit_entitlement`): the appropriate maximum Housing Benefit is the eligible rent less `housing_benefit_non_dep_deductions` for any non-dependant adults living in the household ([reg. 70](https://www.legislation.gov.uk/uksi/2006/213/regulation/70)). Under reg 74 (SI 2006/214 reg 55), for a non-dependant aged 18 or over in remunerative work (16 hours a week or more) the council deducts the amount for the band containing their normal gross weekly income, counting a couple's joint income and including each band's lower edge; for one not in remunerative work it deducts the lowest amount. It makes one deduction for a couple, the higher. Councils make no deduction for full-time students, State Pension Credit recipients, or under-25s on Income Support, income-based JSA or Universal Credit without earned income, and none for any non-dependant if the claimant or partner is blind or receives Attendance Allowance, the DLA care component, the PIP daily living component or Armed Forces Independence Payment. The published `withdrawal_rate` (currently 0.65) of the benunit's `housing_benefit_applicable_income` in excess of the applicable amount is deducted from that maximum ([SSCBA 1992 s. 130(3)(b)](https://www.legislation.gov.uk/ukpga/1992/4/section/130), [reg. 71](https://www.legislation.gov.uk/uksi/2006/213/regulation/71)). Because the LHA caps the eligible rent, the taper comes off the LHA: a private tenant with rent above the LHA and income above the applicable amount receives the LHA less the taper, not the lower of the LHA and the rent less the taper. The pension-age regulations (SI 2006/214) have the same rules at regs 12D, 13D, 50 and 51, and the Northern Ireland regulations at SR 2006/405 regs 13C, 14D, 68 and 69 and SR 2006/406 regs 13C, 14D, 48 and 49. Applicable income counts earnings, pensions and benefits net of the income tax and National Insurance on them. Income derived from capital (rent from property, interest and dividends) is not income: regulation 46(4) of the Housing Benefit Regulations 2006 treats it as capital, and Schedule 5 paragraph 22 of the pension-age regulations disregards it. The capital counts instead through `housing_benefit_tariff_income` and the capital limit. Rent for letting part of the home is the exception: it counts, less £20 a week per occupier.\n", " If the claimant or partner is on Universal Credit, Income Support, income-based Jobseeker's Allowance or income-related Employment and Support Allowance (`housing_benefit_on_passporting_benefit`), the whole of the family's earnings, other income and capital is disregarded ([SI 2006/213 Sch. 4 para. 12](https://www.legislation.gov.uk/uksi/2006/213/schedule/4/paragraph/12), [Sch. 5 para. 4](https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/4) and [Sch. 6 para. 5](https://www.legislation.gov.uk/uksi/2006/213/schedule/6/paragraph/5); Northern Ireland: [SR 2006/405 Sch. 5 para. 12](https://www.legislation.gov.uk/nisr/2006/405/schedule/5/paragraph/12), [Sch. 6 para. 4](https://www.legislation.gov.uk/nisr/2006/405/schedule/6/paragraph/4) and [Sch. 7 para. 5](https://www.legislation.gov.uk/nisr/2006/405/schedule/7/paragraph/5)). `housing_benefit_applicable_income` and `housing_benefit_assessable_capital` are then zero, so the award is that maximum. This applies at any age: a mixed-age couple whose younger member is on one of these benefits is assessed under the working-age regulations ([SI 2006/213 reg. 5(1)(b)](https://www.legislation.gov.uk/uksi/2006/213/regulation/5)). Income Support, income-based Jobseeker's Allowance and income-related Employment and Support Allowance count when a positive amount is paid on the claimant's or partner's award (`in_receipt_of_income_support_jsa_ib_or_esa_ir`, which reads `claimant_or_partner_jsa_income` and `claimant_or_partner_esa_income`). Another member's own award, such as a non-dependant's, is payable to them and does not passport the claimant. A person is on Universal Credit on any day they are entitled to it, whether it is in payment or not ([reg. 2(3B)](https://www.legislation.gov.uk/uksi/2006/213/regulation/2)); PolicyEngine reads that as Universal Credit above nil before the benefit cap and deductions (`is_uc_entitled`), for a family that claims it, so the passport adds no circular dependency through the benefit cap. The universal credit limb applies from 28 October 2013 in Great Britain and 8 May 2018 in Northern Ireland (`gov.dwp.housing_benefit.means_test.universal_credit_passport`). It matters only where Housing Benefit is paid alongside Universal Credit, for specified or temporary accommodation, which the eligibility rules above do not yet model.\n", "4. **Benefit cap** (`housing_benefit`): the post-means-test award is finally reduced by `housing_benefit_benefit_cap_reduction`, the excess of the family's welfare benefits over the Housing Benefit cap (`housing_benefit_benefit_cap`, SI 2006/213 reg 75CA), leaving at least 50 pence a week (reg 75D). The cap does not apply where the claimant or couple is entitled to working tax credit (reg 75E), the claimant or partner receives a benefit listed in reg 75F, or the claim is under the pension-age regulations (reg 5). Universal Credit has its own cap.\n", "\n", diff --git a/docs/book/programs/gov/hmrc/stamp-duty.ipynb b/docs/book/programs/gov/hmrc/stamp-duty.ipynb index 31976439f5..5fab2894ae 100644 --- a/docs/book/programs/gov/hmrc/stamp-duty.ipynb +++ b/docs/book/programs/gov/hmrc/stamp-duty.ipynb @@ -16,6 +16,22 @@ "The regulation of SDLT is defined in part 4 of the [Finance Act 2003](https://www.legislation.gov.uk/ukpga/2003/14/part/4)." ] }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Start dates in 2014 to 2018\n", + "\n", + "This page lists the effective date Parliament set for each change. Each model year reads the parameters in force on 30 April:\n", + "\n", + "- Parliament started the main residential rates in s55(1B) Table A, charged on slices of the price, on 4 December 2014 ([Stamp Duty Land Tax Act 2015 s2(2)](https://www.legislation.gov.uk/ukpga/2015/1/section/2)). Every model year from 2015 reads them.\n", + "- Parliament started first-time buyers' relief on 22 November 2017 ([Finance Act 2018 s41(8)](https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted)), so the model grants it from model year 2018.\n", + "- Parliament started the higher rates for additional dwellings on 1 April 2016 ([Finance Act 2016 s128(5)](https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted)). In model year 2015 a buyer of an additional dwelling pays the main rates at any price, and a reform to the main rates reaches that buyer too. A reform can move the switch through `gov.hmrc.stamp_duty.residential.purchase.additional.higher_rates_apply`.\n", + "- Parliament moved non-residential purchases to sliced rates, and added a 2% band on lease rent above £5 million, from 17 March 2016 ([Finance Act 2016 s127(12)](https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted)). Before then HMRC charged the whole price of a non-residential purchase at the percentage for its band under [s55(2) Table B as at 30 April 2015](https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30): 0% up to £150,000, 1% up to £250,000, 3% up to £500,000 and 4% above. The model applies that table in model year 2015, so a £1,000,000 purchase pays £40,000 in model year 2015 and £39,500 from model year 2016.\n", + "- HMRC charged SDLT on land in Wales until 31 March 2018 ([Finance Act 2003 s48(1)(a) as at 30 April 2017](https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30)). Parliament ended that charge from 1 April 2018 ([Wales Act 2014 s16](https://www.legislation.gov.uk/ukpga/2014/29/section/16), on the date the Treasury appointed in [SI 2018/214](https://www.legislation.gov.uk/uksi/2018/214/article/2/made)), and the Welsh Revenue Authority charges Land Transaction Tax from then ([Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) Act 2017 s2](https://www.legislation.gov.uk/anaw/2017/1/section/2)). The model computes SDLT on purchases in Wales up to model year 2017 and Land Transaction Tax from model year 2018. A reform can move the switch through `gov.hmrc.stamp_duty.applies_in_wales`." + ] + }, { "attachments": {}, "cell_type": "markdown", diff --git a/docs/book/usage/simulations.md b/docs/book/usage/simulations.md index d60c196479..ac11a0337a 100644 --- a/docs/book/usage/simulations.md +++ b/docs/book/usage/simulations.md @@ -545,6 +545,21 @@ weighted_total = (unweighted_values * modified_weights).sum() print(f"Total with 10% higher weights: £{weighted_total / 1e9:.1f}bn") ``` +### Subsampling a microsimulation + +`subsample` cuts a simulation built from a dataset down to a weighted random sample of its households: + +```python +sim = Microsimulation( + dataset="hf://policyengine/policyengine-uk-data/enhanced_frs_2022_23.h5" +) +sim.subsample(n=10_000, seed=0, time_period=2026) +``` + +It draws `n` households with replacement, each with probability proportional to its weight in `time_period` (with the default `quantize_weights=True`), and keeps every household drawn at least once, with its benefit units and people. So the sample holds at most `n` households. A household's new weight is proportional to the number of times it was drawn, and the total household weight of every year is unchanged. The values the simulation was given are kept for the sampled records; results are calculated again on the sample, so population estimates then carry sampling error. + +The simulation's `baseline` and `dataset` are replaced with copies cut down to the same households; the objects they held before are left as they were. The `dataset` holds the rows of the dataset the simulation was built from, so it holds whole years only and does not include values set with `set_input` after the simulation was built. The same `seed` always draws the same households. + ## Debugging simulations When simulations don't behave as expected: diff --git a/docs/book/validation/student-loan-repayments.ipynb b/docs/book/validation/student-loan-repayments.ipynb index 253cded1e8..26ded31d59 100644 --- a/docs/book/validation/student-loan-repayments.ipynb +++ b/docs/book/validation/student-loan-repayments.ipynb @@ -20,7 +20,7 @@ "- **Plan 1**: loans for courses begun on or before 31 August 2012 under England's and Wales's student support regulations ([SI 2009/470 reg. 3A](https://www.legislation.gov.uk/uksi/2009/470/regulation/3A)), and every Student Finance Northern Ireland loan ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). 9% of income above £24,990.\n", "- **Plan 2**: Student Finance England loans for courses begun from 1 September 2012 to 31 July 2023, and Student Finance Wales loans for courses begun on or after 1 September 2012 ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). 9% of income above £27,295.\n", "- **Plan 4**: loans under Scottish student loan regulations from 1998 onwards whose repayments the Scottish Ministers have HMRC collect ([SI 2009/470 reg. 3D](https://www.legislation.gov.uk/uksi/2009/470/regulation/3D)). GOV.UK puts every Student Awards Agency Scotland loan on Plan 4. 9% of income above £31,395.\n", - "- **Plan 5**: Student Finance England loans for courses begun on or after 1 August 2023 ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). Its threshold is £25,000, but SI 2009/470 reg. 15(2BA), which [SI 2022/1335 reg. 15(9)](https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made) inserted, requires no repayment of a Plan 5 loan on or before 5 April 2026, so Plan 5 borrowers owe nothing in 2024-25 or 2025-26. The model doesn't yet apply reg. 15(2BA): it charges Plan 5 borrowers 9% of income above £25,000 in those years too.\n", + "- **Plan 5**: Student Finance England loans for courses begun on or after 1 August 2023 ([GOV.UK](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on)). Its threshold is £25,000, but SI 2009/470 reg. 15(2BA), which [SI 2022/1335 reg. 15(9)](https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made) inserted, requires no repayment of a Plan 5 loan on or before 5 April 2026, so Plan 5 borrowers owe nothing in 2024-25 or 2025-26. From 2026-27 they repay 9% of income above that threshold.\n", "- **Postgraduate**: 6% of income above £21,000.\n", "\n", "The FRS captures reported student loan repayments, while PolicyEngine calculates repayments based on income and loan plan type." @@ -146,7 +146,7 @@ "source": [ "## What can make the two measures differ\n", "\n", - "The code compares two variables. `student_loan_repayments` holds the repayments a dataset reports; where a dataset supplies none, its formula returns `student_loan_repayment`, and the two match exactly. `student_loan_repayment` applies the plan's rate to income above the plan's threshold, and caps the result at `student_loan_balance` where the balance is known. They can differ for these reasons:\n", + "The code compares two variables. `student_loan_repayments` holds the repayments a dataset reports; where a dataset supplies none, its formula returns `student_loan_repayment`, and the two match exactly. `student_loan_repayment` applies the plan's rate to income above the plan's threshold, charges a Plan 5 borrower nothing before 2026-27, and caps the result at `student_loan_balance` where the balance is known. They can differ for these reasons:\n", "\n", "1. **Annual income**: the model applies the threshold to a year's income (`student_loan_income`, net income less benefits in kind), not to each pay period, so it doesn't reflect how income varies within the year.\n", "\n", @@ -154,9 +154,7 @@ "\n", "3. **One plan per person**: `student_loan_plan` gives each person one plan, so the model can't charge someone who holds loans on two plans for both.\n", "\n", - "4. **Missing plans**: a person with no plan (`NONE`) gets no modelled repayment, whatever they report. [#1764](https://github.com/PolicyEngine/policyengine-uk/issues/1764) reports a run on `enhanced_frs_2023_24.h5` with policyengine-uk 2.88.43 for 2026 in which 5,570 people had a reported repayment and none had a modelled one, because no person had a plan.\n", - "\n", - "5. **Plan 5 before April 2026**: the model charges Plan 5 borrowers repayments that the law doesn't require until 6 April 2026 (see the plan list above)." + "4. **Missing plans**: a person with no plan (`NONE`) gets no modelled repayment, whatever they report. [#1764](https://github.com/PolicyEngine/policyengine-uk/issues/1764) reports a run on `enhanced_frs_2023_24.h5` with policyengine-uk 2.88.43 for 2026 in which 5,570 people had a reported repayment and none had a modelled one, because no person had a plan." ] }, { diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 6303e0ba16..feb5968b63 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -84,8 +84,9 @@ order, each against the extended limits. Property income is taxed on top of and savings and dividends on top of `non_savings_taxable_income` (`earned_taxable_income` plus property income after allowances). Both bases derive from `net_income`, so the gift raises the limits without also shrinking -the income beneath them. They differ only when Marriage Allowance applies, -which `earned_taxable_income` subtracts (#1947). The Personal Savings Allowance +the income beneath them. The two bases come to the same amount; the Marriage +Allowance reduces neither, since the model gives it as a tax reduction at Step 6 +(`marriage_allowance_tax_reduction`). The Personal Savings Allowance compares net income less allowances with the UK basic and higher rate limits, both extended by the gross gift ([s. 12B](https://www.legislation.gov.uk/ukpga/2007/3/section/12B), including s. 12B(8) for Scottish taxpayers, with s. 414(2)(b)). diff --git a/docs/engineering/property-wealth-components.md b/docs/engineering/property-wealth-components.md new file mode 100644 index 0000000000..a641dc5021 --- /dev/null +++ b/docs/engineering/property-wealth-components.md @@ -0,0 +1,242 @@ +# Property wealth components and UK land + +The model derives worldwide gross property wealth from six household stock +inputs. The companion [policyengine-uk-data change #543](https://github.com/PolicyEngine/policyengine-uk-data/pull/543) +preserves the seven Wealth and Assets Survey (WAS) round-8 gross-property +terms in those six inputs. The earlier [data change #501](https://github.com/PolicyEngine/policyengine-uk-data/pull/501) +combines second homes and buy-to-let into one other-residential input and +separates property wealth from financial and private-pension wealth. + +## Source and input contract + +| WAS round-8 source | Saved household input | Coverage | +| --- | --- | --- | +| `DVhValueR8` | `main_residence_value` | Main residence | +| `DVHseValR8_sum` | `other_residential_property_value` | Second UK homes, including holiday homes and time-shares | +| `DVBltValR8_sum` | `other_residential_property_value` | UK residential property let for profit, added to second homes | +| `DVBlDValR8_sum` | `non_residential_property_value` | Other UK buildings | +| `DVLUKValR8_sum` | `owned_land` | Direct UK land-only holdings | +| `DVLOSValR8_sum` | `overseas_property_value` | Overseas land and property, including time-shares | +| `DVOprValR8_sum` | `other_property_value` | Other real estate with unspecified geography and property type | + +The data producer combines second homes and buy-to-let before exporting +`other_residential_property_value`. There is no additional buy-to-let model +input to add to an aggregate, benefit-capital list or purchase calculation. +The associated `other_residential_property_secured_debt` likewise combines +their identified secured debt. + +The [WAS round-8 derived-variable specification](https://doc.ukdataservice.ac.uk/doc/7215/mrdoc/excel/7215_property_dv_spec_r8.xlsx), +worksheet `DV_Spec_Property_R8`, defines `DVPropertyR8` as the sum of all +seven source terms. This is gross property value. The separate `HpropWR8` +measure deducts mortgage liabilities. The model's gross wealth aggregates +retain this distinction from net benefit-capital valuations. + +The data producer must reconcile the seven source terms before imputation, +with documented missing, not-applicable and refusal values handled +consistently and reported separately for complete-case and operational +zero-filled reconciliation. It must also remove a saved `property_wealth` +column, including one inherited from an earlier dataset. A conditioning-total +draw and independently imputed component draws do not have an accounting +identity: the model derives the released total from the saved components. + +## Aggregates and land + +The formulas under `policyengine_uk/variables/household/wealth/` implement: + +```text +residential_property_value = main_residence_value + + other_residential_property_value + +property_wealth = residential_property_value + + non_residential_property_value + + owned_land + + overseas_property_value + + other_property_value + +total_wealth = property_wealth + corporate_wealth + + private_pension_wealth + savings + +household_land_value = (residential_property_value + + non_residential_property_value + + other_property_value) * regional_intensity + + owned_land +``` + +Residential property covers the identified domestic residential categories. +Overseas and other-real-estate aggregates do not identify residential +subcomponents. Direct UK land enters worldwide property wealth once and UK +household land once at its full value. Identified overseas land and buildings +contribute to worldwide wealth and contribute zero to UK household land. +`total_wealth` receives all property categories through `property_wealth`; +adding land, overseas or other property separately would duplicate them. + +`household_land_value.py` retains the existing regional intensities and the +dwelling-weighted fallback for an unknown region. Summing the domestic base +directly avoids subtracting components from an already rounded aggregate, +so float32 cancellation cannot reduce land below directly owned UK land. +Unclassified other real +estate uses that regional approximation. Its unknown geography means this +can attribute overseas holdings to UK land. Improved source classification, +or excluding unclassified holdings from the domestic land base, requires a +separate modelling decision. + +## Uprating and periods + +`overseas_property_value` and `other_property_value` are annual household +stocks, defaulting to zero, with GDP-per-capita uprating. Their variable +definitions use `gov.economic_assumptions.indices.obr.per_capita.gdp` and +their dataset registrations use +`gov.economic_assumptions.yoy_growth.obr.per_capita.gdp` in +`policyengine_uk/data/uprating_indices.yaml`. Both registrations are required: +Core carries situation inputs forward using the variable attribute, while +`policyengine_uk/data/economic_assumptions.py` projects stored dataset inputs +using the YAML map. Stored annual inputs take precedence over formula and +fallback calculations. + +Current `main_residence_value` and `other_residential_property_value` use the +model's annual OBR house-price index in both routes. Non-residential property +and direct UK land retain GDP-per-capita uprating. The property-component +change preserves these existing choices; the historical specification's +blanket GDP treatment for residential inputs is obsolete. Each projected +year must retain the aggregate identity using the individually uprated +components, rather than grow a saved property total with a single index. +Monthly requests for these stocks retain the full stock amount rather than +one twelfth of the annual amount. + +The upstream regional house-price adjustment to main-residence imputation is +separate from the model's projection indexes. Source reconciliation must +precede that adjustment; the other mapped source categories do not acquire +the adjustment through the aggregate. + +## Benefit-capital routing + +Countable capital reads configured sources, rather than `property_wealth`. +The new overseas and other-property inputs enter each affected programme's +capital calculation once and receive the existing real-property +sale-expense treatment. The shared `policyengine_uk/utils/capital_valuation.py` helper +deducts source-specific sale expenses before mapped secured debt, with the +existing zero floor for secured-property categories. A source appearing only +in a sale-expense list does not become countable capital. + +Capital parameters are under these paths relative to +`policyengine_uk/parameters/`: + +| Programme | Capital directory | +| --- | --- | +| Universal Credit | `gov/dwp/universal_credit/means_test/capital/` | +| Housing Benefit | `gov/dwp/housing_benefit/means_test/capital/` | +| Income Support | `gov/dwp/income_support/means_test/capital/` | +| Income-based JSA | `gov/dwp/JSA/income/capital/` | +| Income-related ESA | `gov/dwp/ESA/income/capital/` | +| Pension Credit | `gov/dwp/pension_credit/income/capital/` | + +Universal Credit keeps financial `sources.yaml` separate from +`property_sources.yaml` and `uc_property_capital`. Its existing domestic +property valuation variables remain in that property pool, independent of +financial `benunit_reported_capital` observations. The new overseas and +unclassified other-property categories follow the same route through +`uc_overseas_property_capital` and `uc_other_property_capital`, which deduct +sale expenses once before `uc_property_capital` allocates their net values. +They are absent from the financial and financial sale-expense source lists. +A financial-only report leaves all separately valued property counted. +A complete `uc_reported_capital` override replaces the ordinary assessment +before the existing company-capital substitution. All countable property +shares remain allocated across all claimant/partner adults, with +complete-override units consuming their shares rather than redistributing +them. Direct `owned_land` remains outside UC countable sources; adding it +requires a separate model decision. + +Pension Credit retains its source lists and pension-age-adult allocation. +Its assessment combines a financial-only benefit-unit report with separately +valued land and property before final rounding. Its independent complete +override, `pension_credit_reported_capital`, defaults to -1; an explicitly +supplied nonnegative override still replaces the whole total. Combining the +financial report and property directly in the assessment avoids an intermediate +rounded aggregate that could reduce counted property for large values. +Housing Benefit's savings-credit-only route inherits that corrected total. +If a dataset stores a financial-only survey total in +`pension_credit_reported_capital` rather than in `benunit_reported_capital`, +the formula treats that total as a complete override and counts none of the +household's land or property for that unit. The data producer must move such a +total to `benunit_reported_capital` before the formula can count the property. +Ordinary Housing Benefit, Income Support, income-based JSA and income-related +ESA do not replace their source pools with a financial-only report, so their +existing routing is retained. `share_like_capital` retains the component-aware financial fallback, +so the new property sources do not restore `corporate_wealth` lists or add +the underlying share components twice. Main residence and the separately +identified private-pension input remain excluded from countable sources. + +These broad asset categories do not identify every statutory disregard or +individual's beneficial ownership. Their allocation is a model assumption, +not an observation of legal ownership or a determination that every holding +is countable. No new secured-debt inputs are exported for overseas or other +real estate, so the mapping cannot identify or deduct debt on those holdings. +Legacy financial aggregates may also contain pension wealth that cannot be +separately identified. Gross worldwide wealth remains distinct from these +limited net capital proxies. + +UC unearned income continues to use its claimant unearned-income source +list. Actual interest, dividends and rent are excluded at every capital +level; countable capital affects tariff income. There is no historical +property-income gate to extend. + +## Purchases and downstream consumers + +Current main uses explicit transaction inputs for +`additional_residential_property_purchased` and +`non_residential_property_purchased`. Holding other-residential or +non-residential stock does not automatically create a purchase when +`property_purchased` is true. This supersedes the historical stock-based +purchase proxy described in the original companion specification. A domestic +additional-dwelling transaction is supplied once, including a buy-to-let +purchase; overseas and unclassified other-property stocks create no UK SDLT +transaction. `main_residential_property_purchased` retains its existing +main-residence-value calculation. + +The current consumer paths below replace historical wheel file-and-line +citations. Paths are relative to `policyengine_uk/variables/`. + +| Consumer | Current path and dependency | +| --- | --- | +| Residential and worldwide property | `household/wealth/residential_property_value.py` and `property_wealth.py` use the component identities above | +| Total wealth and wealth deciles | `household/wealth/total_wealth.py` includes property once; `household_wealth_decile.py` reads total wealth with person-weighted household weights | +| Wealth taxes | `contrib/ubi_center/wealth_tax.py` reads `net_wealth`, which deducts mortgage, consumer and student-loan debt from total wealth; `contrib/cec/non_primary_residence_wealth_tax.py` subtracts main residence from gross total wealth | +| UK land and LVT | `household/wealth/household_land_value.py` estimates domestic household land; `land_value.py` adds corporate land; `contrib/ubi_center/land_value_tax.py` applies the configured combined, household and corporate rates | +| Domestic property purchases | `household/consumption/main_residential_property_purchased.py`, `additional_residential_property_purchased.py` and `non_residential_property_purchased.py` retain their separate transaction routing | +| SDLT | `gov/hmrc/sdlt_on_residential_property_transactions.py` and `sdlt_on_non_residential_property_transactions.py` read transactions; `stamp_duty_land_tax.py` aggregates liabilities and `expected_sdlt.py` carries the household liability | +| High Value Council Tax Surcharge | `gov/hmrc/high_value_council_tax_surcharge.py` reads main residence and ownership tenure directly | +| Benefit capital | `gov/dwp/universal_credit/uc_assessable_capital.py` and `uc_property_capital.py`; the assessable-capital variables for Housing Benefit, Pension Credit, Income Support, JSA and ESA use the configured programme sources | +| UC unearned income | `gov/dwp/universal_credit/income/uc_unearned_income.py` reads configured claimant income sources, without a property-income gate | + +Changing the aggregate alone does not directly change benefit capital, +purchase transactions or the high-value surcharge at fixed inputs and +weights. Completing the missing components and their routing can change +capital; subsequent recalibration can also change weighted outputs. + +## Release boundary and validation + +The model and the data release carrying the component contract must land in +lockstep, after the data #501 prerequisite. That data release must depend on +a model release with the new inputs and capital-valuation routing. Its +compatibility guard must reject a model unable to carry the new components. + +Existing datasets are not migrated. A stored `property_wealth` input remains +authoritative and overrides the formula, even after the model changes. The +new release must stop storing it and remove inherited copies; updating only +the installed model cannot repair old saved totals. Household land reads the +domestic component inputs directly, including when a saved worldwide total +is present, so that total does not determine the domestic land base. + +Synthetic tests cover isolated and mixed components, aggregate identities, +land accounting, household LVT, monthly stocks, situation and dataset +uprating, benefit valuation and transaction routing without licensed data. +They do not establish reconciliation of the licensed WAS household source. +The upstream seven-term reconciliation must run on that licensed source +before release, with its missing-value report and the second-home/buy-to-let +combination checked before imputation. + +Population and LVT effects with the compatible data release stay unmeasured +until that release exists and its rebuild and recalibration finish. Existing +land target definitions and target weights stay the same, but calculated land, +losses and fitted geographic weights can change. Production rebuilding, +recalibration and impact measurement are separate release work. diff --git a/docs/engineering/vat-coverage.md b/docs/engineering/vat-coverage.md index 2fa766c5fe..b64edecc23 100644 --- a/docs/engineering/vat-coverage.md +++ b/docs/engineering/vat-coverage.md @@ -15,7 +15,7 @@ comparison uses `vat + private_school_vat`: theoretical liability; the rest falls on government, charities, VAT-exempt businesses and new housing. This is a published quantity and does not depend on the dataset. -- `gov.simulation.vat.survey_consumption_coverage` (0.67): the share of +- `gov.simulation.vat.survey_consumption_coverage` (0.69): the share of household VAT liability that the dataset's consumption captures. This depends on the dataset's consumption imputation and must be re-derived when the default dataset changes. @@ -26,27 +26,226 @@ to households in proportion to their own VAT-liable spending. These two factors replace `gov.simulation.microdata_vat_coverage`, a single undated 0.38 added in January 2023 against an older LCFS-based consumption -imputation, with no derivation recorded. Its product with today's datasets put -2024-25 VAT 24% above OBR receipts on Microcosm UK 2024-25 and 71% above on -Enhanced FRS 2024-25 (policyengine-uk-data 1.56.16). +imputation, with no derivation recorded. Its product with the October 2026 +datasets put 2024-25 VAT 24% above OBR receipts on Microcosm UK 2024-25 +(`f9d1922c`) and 71% above on Enhanced FRS 2024-25 (policyengine-uk-data +1.56.16). `gov.simulation.microdata_vat_coverage` is kept as a deprecated parameter so that existing reforms to it still work. Its value is now the product of the two -factors (0.469). If a reform sets it to any other value, `vat` and -`baseline_vat` use that value in place of the two factors -(`policyengine_uk/utils/vat.py`); otherwise they use the two factors. New -reforms should change the two factors instead. +factors (0.483). If a reform sets it to any other value, `vat` uses that value +in place of the two factors (`policyengine_uk/utils/vat.py`), and +`baseline_vat`, which reads the unreformed parameters, keeps the two factors. +New reforms should change the two factors instead. ## Which datasets the factor applies to -The survey coverage of 0.67 is calibrated to one dataset: the certified -Microcosm UK 2024-25 national release. It does not carry over to other -datasets, because their consumption imputations capture a different share of -household VAT. On Enhanced FRS 2024-25 it puts VAT 35% to 44% above receipts, -depending on the release and year (see the tables below). Re-derive the coverage, with -the calculation below, whenever the dataset it should match changes. +The survey coverage of 0.69 matches 2024-25 receipts on one dataset: the +certified Microcosm UK 2024-25 national release `a6299504`. It does not carry +over to other datasets, because their consumption imputations capture a +different share of household VAT. On Enhanced FRS 2024-25 +(policyengine-uk-data 1.58.1) it puts total `vat` 38% above receipts in +2024-25, falling to 32% in 2026-27 (see +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026)). Re-derive +the coverage, with the calculation below, whenever the dataset it should match +changes. + +As of November 2026, policyengine.py certifies `a6299504`: its commit +`324ecad65` certified the cut, release 6.11.1 shipped it, and release 6.12.0 +still certifies it. The coverage comes from that cut, in +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026). +policyengine.py certified `6cba5d71` before it, on 20 October 2026 (commit +`a66e883c4`). policyengine.py#715 proposed `a133f37a` and closed unmerged, so +no policyengine.py release certified that cut. Before the energy split, 0.67 +put VAT 7.5% to 10.3% above receipts on `a133f37a` (see +[Later Microcosm cuts](#later-microcosm-cuts-24-october-2026)). + +## Derivation on `a6299504` (November 2026) + +The parameter takes this derivation's value. The model figures in this section +come from runs on 3 November 2026 with policyengine-uk 2.157.1 (commit +`36bbbf42f`), policyengine-core 3.33.2, NumPy 2.5.4, pandas 3.1.0 and Python +3.14, with `HUGGING_FACE_TOKEN` and `POLICYENGINE_UK_DEFAULT_DATASET` unset. +That commit ships a coverage of 0.67; the runs at 0.69 set +`gov.simulation.vat.survey_consumption_coverage` to 0.69 in every year by +reform. -## Derivation (October 2026) +### Inputs + +| Input | Value | Source | +|---|---|---| +| Microcosm UK 2024-25 national release | `microcosm_uk_2024_25.h5`, Hugging Face dataset repository `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261025T091414Z-a6299504` (revision `994cac89d902af9a985af7c1182d74717a582ce0`), SHA-256 `b96a8cd625b645a409c063b1de5158532708a2f0d3c3897a794a8fb0bbd5064e`, 342,348,841 bytes; 78,234 households and 181,842 persons, 28.87 million weighted households in 2024-25 | Private repository | +| Build | `uk-frs-calibration-attempt-20261025T091414Z-a6299504`, at Microcosm code pin `67ee2ddf3`, with policyengine-uk 2.151.0 | PolicyEngine/microcosm | +| Certification | policyengine.py commit `324ecad65` ("Certify the Microcosm UK national a6299504 with policyengine-uk 2.151.0", 26 October 2026, through policyengine.py#720); release 6.11.1 (27 October 2026) shipped it, and release 6.12.0 (2 November 2026) still certifies it | PolicyEngine/policyengine.py | +| VAT receipts | £170.994bn (2024-25 outturn), £180.169bn (2025-26), £187.704bn (2026-27) | OBR, Economic and fiscal outlook March 2026, detailed forecast tables: receipts, sheet "3.8 Current receipts (on a cash basis)", row 13 "Value added tax", cells D13, E13 and F13 (see below) | +| Enhanced FRS 2024-25 | `enhanced_frs_2024_25.h5`, Hugging Face model repository `policyengine/policyengine-uk-data-private`, policyengine-uk-data 1.58.1 (revision `2526edbbb687a3b54dbb11272e5389e4b62d2ac2`, SHA-256 `056e80fd8e9151d78f432a25cde863340baee81304c6216a37fb575ce8563784`, 128,409,762 bytes), the release that `policyengine_uk/tests/certified_dataset.yaml` names | Private repository | +| Household share of VAT liabilities | 0.7 | `household_share_of_receipts` | +| Ready reckoner | £8.8bn for one point on the standard rate, 2026-27 | HMRC, Direct effects of illustrative tax changes (June 2025) | +| Statutory rates | 20% standard, 5% reduced | `gov.hmrc.vat` | + +Microcosm's fit for this cut holds `obr.vat` out under a measure exclusion +(PolicyEngine/microcosm#1278) that a Microcosm maintainer approved on +24 October 2026 and that expires on 24 January 2027, so the fitted weights +don't target the receipts that this derivation compares VAT with. The cut's +calibration diagnostics carry one warn-only VAT warning, +`uk_consumption_drift`: the total `full_rate_vat_base` moves by more than 2% +of its design value between the design and the final weights. + +OBR publishes the receipts workbook at +. +On 3 November 2026 that URL served 187,899 bytes with SHA-256 +`f65b6cb7f96931d4abd85940794eb60f6f65afca8bbf16c74a4548bf92766a28`, last +modified on 3 March 2026, and the +[Internet Archive's copy](https://web.archive.org/web/20261007143332/https://obr.uk/docs/d055fbf02d5b3g6jq8l2/efo-march-2026-detailed-forecast-tables-receipts.xlsx) +holds the same bytes. In sheet "3.8 Current receipts (on a cash basis)", row 13 +"Value added tax" gives, in £ billion, D13 = 170.994 (2024-25 outturn), +E13 = 180.16893868541794 (2025-26) and F13 = 187.70436725810845 (2026-27). The +sheet's note in row 66 reads "All tax lines prior to accruals adjustments on +tax line are on a cash basis". + +### Method + +The coverage follows the energy-split method in +[the energy section](#energy-bills-are-not-grossed-up): + +```text +pre-scaling non-energy VAT = 0.20 x sum(full_rate_vat_consumption) + + 0.05 x sum(reduced_rate_vat_consumption) +energy-bill VAT = sum(domestic_energy_vat) +survey coverage = pre-scaling non-energy VAT + / (0.7 x (OBR VAT receipts - energy-bill VAT)) +``` + +Every sum weights households by `household_weight`. + +A run of the whole cut in one process needed more than the 8 GiB of memory +available, so the runs split it into ten household chunks. Chunk k holds every +tenth household from the k-th, by sorted `household_id`, with its persons and +benefit units. Each chunk runs `Microsimulation` on +`extend_single_year_dataset(chunk, parameters, end_year=2026)`. Each +household's VAT depends only on its own inputs: `full_rate_vat_consumption` +multiplies the household's `consumption` by its own +`full_rate_vat_expenditure_rate`, the uprating multiplies each household's +inputs by growth factors, and `vat` divides each household's statutory +liability by one scalar factor. So weighted VAT totals over the ten chunks sum +exactly to the whole cut's. As a check, for each VAT input and `vat` in +2024-25 to 2026-27, chunk 0 matched a separate run of the same households to +the last digit, and chunk 0's two halves summed to it within 2.7e-16 of the +total. + +### Results + +At the coverage of 0.67 that `36bbbf42f` ships: + +| Fiscal year | Standard-rated base | Reduced-rated base | Pre-scaling non-energy VAT | Energy-bill VAT | OBR VAT receipts | Coverage | Total `vat` at 0.67 | Against receipts | +|---|---|---|---|---|---|---|---|---| +| 2024-25 | £407.40bn | £0.00bn | £81.48bn | £2.33bn | £170.994bn (outturn) | 0.690 | £176.06bn | +3.0% | +| 2025-26 | £422.51bn | £0.00bn | £84.50bn | £2.42bn | £180.169bn | 0.679 | £181.09bn | +0.5% | +| 2026-27 | £433.75bn | £0.00bn | £86.75bn | £1.75bn | £187.704bn | 0.666 | £185.18bn | -1.3% | + +Unrounded (£): standard-rated base 407,399,379,048, 422,514,711,572 and +433,745,364,638; energy-bill VAT 2,328,998,935, 2,415,410,320 and +1,749,888,519; pre-scaling non-energy VAT 81,479,875,810, 84,502,942,314 and +86,749,072,928; total `vat` at 0.67 176,060,075,948, 181,087,241,846 and +185,178,679,581. The run read private school fees' share of receipts as 0, +0.008353 and 0.008311. + +### Value + +The parameter takes 0.69 (product 0.483). The 2024-25 value sets it, as in +the [`f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026): +2024-25 is the cut's own year, which `apply_uprating` leaves as the file gives +it, and OBR's figure for it is outturn. Later years add the model's uprating +and, beyond outturn, OBR's forecasts, so the factor stays undated. On the +March 2026 EFO's receipts the coverage falls 3.4% from 2024-25 to 2026-27. + +The runs here predate the October 2026 EFO that `main` now uses. That EFO +gives 2025-26 outturn of £179.861bn and forecasts £186.154bn for 2026-27, and +`main` uprates consumption with its October forecasts. The 2025-26 and 2026-27 +rows here record policyengine-uk 2.157.1, which used the March 2026 EFO for +both. + +### Totals at 0.67 and 0.69 + +| Dataset | Fiscal year | Total `vat` at 0.67 | Against receipts | Total `vat` at 0.69 | Against receipts | +|---|---|---|---|---|---| +| Microcosm UK 2024-25 (`a6299504`) | 2024-25 | £176.06bn | +3.0% | £171.02bn | +0.02% | +| Microcosm UK 2024-25 (`a6299504`) | 2025-26 | £181.09bn | +0.5% | £175.91bn | -2.4% | +| Microcosm UK 2024-25 (`a6299504`) | 2026-27 | £185.18bn | -1.3% | £179.86bn | -4.2% | +| Enhanced FRS 2024-25, uk-data 1.58.1 | 2024-25 | £242.54bn | +41.8% | £235.58bn | +37.8% | +| Enhanced FRS 2024-25, uk-data 1.58.1 | 2025-26 | £249.37bn | +38.4% | £242.22bn | +34.4% | +| Enhanced FRS 2024-25, uk-data 1.58.1 | 2026-27 | £255.28bn | +36.0% | £247.93bn | +32.1% | + +Unrounded Microcosm totals at 0.69 (£): 171,024,393,280, 175,908,348,963 and +179,861,903,668. The Enhanced FRS run used the whole file in one process. On +that file the same calculation gives a coverage of 0.954, 0.939 and 0.921, from +a standard-rated base of £563.06bn, £583.95bn and £599.47bn, a reduced-rated +base of £0.00bn, and energy-bill VAT of £2.43bn, £2.43bn and £1.76bn. + +Total `vat` leaves out `private_school_vat`, which the receipts include: about +£1.5bn a year from 2025-26 on HMRC's costing (see +[Private school VAT](#private-school-vat-splitting-the-existing-receipts-total)). +Model year 2024 has no school charge. + +### Bound + +On `a6299504` two routes bound the coverage, at a household share of 0.7: + +- receipts across 2024-25 to 2026-27 give 0.666 to 0.690; +- the ready reckoner, which does not use the level of receipts: one point on + the standard rate raises £4.34bn before scaling in 2026-27 (1% of + £433.75bn), so, leaving out the school share, matching HMRC's £8.8bn needs + a product of 0.493 (coverage 0.704). + +So the coverage lies between 0.666 and 0.704, and 0.69 sits inside that range. +At 0.69, a run that sets the standard rate to 21% for 2026-27 alone raises +total `vat` in that year from £179.862bn to £188.767bn, by £8.906bn, exceeding +the ready reckoner by 1.2%. That rise matches 1% of the 2026-27 standard-rated +base, times one minus the school share, over the product: +£4,337,453,646 x (1 - 0.008311) / 0.483 = £8,905,600,556, within £8,968 of the +run's £8,905,591,588. + +### Reproducing + +The datasets sit in private Hugging Face repositories. Anyone with +PolicyEngine access can rerun the calculation; anyone else can check the +arithmetic from the published aggregates above. + +```python +from huggingface_hub import hf_hub_download +from policyengine_uk import Microsimulation + +path = hf_hub_download( + "policyengine/populace-uk-private", + "microcosm_uk_2024_25.h5", + repo_type="dataset", + revision="994cac89d902af9a985af7c1182d74717a582ce0", +) +sim = Microsimulation(dataset=path) +# OBR March 2026 EFO, table 3.8, cash-basis VAT receipts +receipts = {2024: 170.994e9, 2025: 180.169e9, 2026: 187.704e9} +for year, obr in receipts.items(): + p = sim.tax_benefit_system.parameters(f"{year}-01-01").gov.hmrc.vat + full = sim.calculate("full_rate_vat_consumption", year).sum() + reduced = sim.calculate("reduced_rate_vat_consumption", year).sum() + energy = sim.calculate("domestic_energy_vat", year).sum() + raw = full * p.standard_rate + reduced * p.reduced_rate + print(year, raw / 1e9, energy / 1e9, raw / (0.7 * (obr - energy))) +``` + +A run of the whole cut in one process needed more than 8 GiB of memory, so the +runs above summed ten household chunks (see [Method](#method)). + +For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository +`policyengine/policyengine-uk-data-private` at policyengine-uk-data 1.58.1 +(`revision="2526edbbb687a3b54dbb11272e5389e4b62d2ac2"`). For an earlier cut, +set `revision` to the tag or full revision that its inputs table gives. + +## Earlier derivation on `f9d1922c` (October 2026) + +This section records the October 2026 derivation on `f9d1922c`, which set the +coverage at 0.67 before the energy split. The +[`a6299504` derivation](#derivation-on-a6299504-november-2026) replaces it. ### Inputs @@ -80,9 +279,11 @@ equal to it: combined receipts are `(1 - s) * sum(w * G) + sum(w * E) + sum(w * P0)` (see the receipt-partition derivation below), so they differ from `G + E` by `sum(w * P0) - s * sum(w * G)`. On the certified Enhanced FRS -(policyengine-uk-data 1.58.1) that difference is -£5.5m to -£142.5m a year from -2025-26 to 2031-32, at most 0.06% of VAT (Aggregate impact run -[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457)). +(policyengine-uk-data 1.58.1) at 0.67 that difference came to -£5.5m to +-£142.5m a year from 2025-26 to 2031-32, at most 0.06% of VAT (Aggregate impact +run +[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457), +24 October 2026). ### Results @@ -98,14 +299,14 @@ derivation below), so they differ from `G + E` by | Enhanced FRS 2024-25, uk-data 1.58.0 | 2025-26 | £593.92bn | £34.54bn | £120.51bn | £180.2bn | 0.669 | 0.956 | | Enhanced FRS 2024-25, uk-data 1.58.0 | 2026-27 | £609.89bn | £35.47bn | £123.75bn | £187.7bn | 0.659 | 0.942 | -The parameter takes the Microcosm 2024-25 value, 0.67 (product 0.469), the +The parameter took the Microcosm 2024-25 value, 0.67 (product 0.469), the latest outturn year. With that product, the gross amount against receipts was as below, before energy bills were split out. On the certified Enhanced FRS -(policyengine-uk-data 1.58.1), current-law `vat + private_school_vat` is -within 0.06% of the receipt-calibrated `G + E` (see above); the other rows -weren't checked against that bound: +(policyengine-uk-data 1.58.1) at 0.67, current-law `vat + private_school_vat` +came within 0.06% of the receipt-calibrated `G + E` (see above); no run +checked the other rows against that bound: | Dataset | 2024-25 | 2025-26 | 2026-27 | |---|---|---|---| @@ -117,50 +318,30 @@ weren't checked against that bound: Total VAT depends only on the product, so the household share being a rounded "around 70%" changes how the product is split between the two factors, -not the totals. On Microcosm the product is bounded by: +not the totals. On `f9d1922c` two routes bounded the product: - the receipts route across 2024-25 to 2026-27: 0.459 to 0.471 (coverage 0.655 to 0.672); - the ready reckoner, which does not use the level of receipts: one point on - the standard rate raises £4.24bn before scaling in 2026-27 (1% of - £423.66bn), so matching HMRC's £8.8bn needs a product of 0.481 (coverage + the standard rate raised £4.24bn before scaling in 2026-27 (1% of + £423.66bn), so matching HMRC's £8.8bn needed a product of 0.481 (coverage 0.688). -So on Microcosm the product lies between 0.459 and 0.481, and the coverage -between 0.655 and 0.688 at a household share of 0.7. The value used, 0.469 -(0.67), sits inside that range, and the totals it gives are within 2.2% of -receipts from 2024-25 to 2026-27. The implied product moves by less than 3% -over those years, so the factors are not dated by year. +So on `f9d1922c` the product lay between 0.459 and 0.481, and the coverage +between 0.655 and 0.688 at a household share of 0.7. The value then used, +0.469 (0.67), sat inside that range, and the totals it gave came within 2.2% of +receipts from 2024-25 to 2026-27. The implied product moved by less than 3% +over those years. ### Reproducing -The two datasets are in private Hugging Face repositories. Anyone with -PolicyEngine access can rerun the calculation; anyone else can check the -arithmetic from the published aggregates in the results table. - -```python -from huggingface_hub import hf_hub_download -from policyengine_uk import Microsimulation - -path = hf_hub_download( - "policyengine/populace-uk-private", - "microcosm_uk_2024_25.h5", - repo_type="dataset", - revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88", -) -sim = Microsimulation(dataset=path) -# OBR March 2026 EFO, table 3.8, cash-basis VAT receipts -receipts = {2024: 170.994e9, 2025: 180.169e9, 2026: 187.704e9} -for year, obr in receipts.items(): - p = sim.tax_benefit_system.parameters(f"{year}-01-01").gov.hmrc.vat - full = sim.calculate("full_rate_vat_consumption", year).sum() - reduced = sim.calculate("reduced_rate_vat_consumption", year).sum() - raw = full * p.standard_rate + reduced * p.reduced_rate - print(year, raw / 1e9, raw / obr, raw / obr / 0.7) -``` - -For Enhanced FRS, download `enhanced_frs_2024_25.h5` from the model repository -`policyengine/policyengine-uk-data-private` at the release tag (for example +Run the snippet in +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026) with +`revision="f9d1922cddab6b54a0dd37794a9bac74e3780c88"`. For the pre-split rows +above, run it on policyengine-uk as of #2187, before energy bills left +`reduced_rate_vat_consumption`, and divide pre-scaling VAT by the receipts +themselves (`raw / obr / 0.7`). For Enhanced FRS 1.56.16 or 1.58.0, download +`enhanced_frs_2024_25.h5` at that release tag (for example `revision="1.58.0"`). ## Energy bills are not grossed up @@ -194,7 +375,8 @@ actually pay on it. With energy split out, the coverage is derived against receipts net of energy VAT: coverage = pre-scaling non-energy VAT / (0.7 x (receipts - energy VAT)). -On the same inputs as the derivation above: +On the same inputs as the +[`f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026): | Dataset | Year | Pre-scaling non-energy VAT | Energy-bill VAT | OBR VAT receipts | Survey coverage | |---|---|---|---|---|---| @@ -206,13 +388,19 @@ On the same inputs as the derivation above: The pre-scaling non-energy VAT is the standard-rated part alone, since the generic reduced-rate base is empty once domestic fuel and power is taken out. -The Microcosm 2024-25 value rounds to the same 0.67 as the derivation above, -so the parameter is unchanged, and the bound above still holds (0.650 to 0.670 -by receipts, 0.688 by the ready reckoner). With it, total `vat` on Microcosm -is within 0.1% of 2024-25 receipts and 3.0% below in 2026-27; on Enhanced FRS -it is 38% (1.56.16) or 44% (1.58.0) above 2024-25 receipts. - -To reproduce, run the snippet above on this version and subtract +On `f9d1922c` the 2024-25 value rounded to the same 0.67 as the pre-split +derivation, so the energy split left the parameter at 0.67, and the `f9d1922c` +bound still held (0.650 to 0.670 by receipts, 0.688 by the ready reckoner). +With 0.67, total `vat` on `f9d1922c` came within 0.1% of 2024-25 receipts and +fell 3.0% below them in 2026-27; on Enhanced FRS it exceeded 2024-25 receipts +by 38% (1.56.16) or 44% (1.58.0). The +[`a6299504` derivation](#derivation-on-a6299504-november-2026) sets the +current value. + +To reproduce these rows, run the snippet in +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026) with each +dataset's revision, and for Enhanced FRS with the repository and file that +section gives. The snippet subtracts `sim.calculate("domestic_energy_vat", year).sum()` from the receipts before dividing. @@ -224,8 +412,8 @@ The OBR VAT total already includes VAT on private school fees. HMRC's £1,505m in 2025-26, £1,560m in 2026-27, £1,610m in 2027-28, £1,665m in 2028-29 and £1,725m in 2029-30. Adding a separate school VAT head to the gross amount above would count that receipt twice. The model instead takes -the fees' share of receipts out of the gross amount, keeping the survey -coverage at 0.67. +the fees' share of receipts out of the gross amount, which leaves the survey +coverage unchanged. For each household, let `G` be the grossed-up VAT on recorded consumption other than energy bills (the statutory liability divided by the two factors), @@ -310,26 +498,69 @@ that coincidence likelier. A household situation would also measure its own school charge against its own VAT. The official share keeps each household's VAT a function of its own consumption. -Measured on the certified Enhanced FRS (Aggregate impact run -[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457)), -the official share changes current-law combined receipts by -£0.14bn in -2025-26, -£0.13bn in 2026-27, -£0.10bn in 2027-28, -£0.08bn in 2028-29 and --£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two overstatements -nearly offset there: VAT is 40% above OBR receipts (£252.5bn against -£180.2bn in 2025-26), and the modelled school charge is £1.95bn, against -HMRC's £1.505bn, because +Measured on the certified Enhanced FRS at a coverage of 0.67 (Aggregate impact +run +[40116746457](https://github.com/PolicyEngine/policyengine-uk/actions/runs/40116746457), +24 October 2026), the official share changes current-law combined receipts by +-£0.14bn in 2025-26, -£0.13bn in 2026-27, -£0.10bn in 2027-28, -£0.08bn in +2028-29 and -£0.01bn in 2029-30, at most 0.06% of that dataset's VAT. Two +overstatements nearly offset in that run: VAT exceeded OBR receipts by 40% +(£252.5bn against £180.2bn in 2025-26), and the modelled school charge came to +£1.95bn, against HMRC's £1.505bn, because attendance is imputed for 751,000 weighted pupils (policyengine-uk-data issue 568). The difference belongs to those two calibrations, not to the split. -The receipts check for the coverage factor is unchanged. Model year 2024 has -no school charge and a zero share, so, using the rounded 2024-25 Microcosm -inputs in the energy table above, `79.23 / (0.67 * 0.7) + 1.99 = £170.924bn`, -compared with OBR's £170.994bn: about 0.04% below receipts. The existing -differences from receipts, including Enhanced FRS's larger overstatement, still require -dataset-specific calibration. Aggregate impact CI compares the combined VAT -report group against the PR's base. +The split leaves the receipts check for the coverage factor unchanged. Model +year 2024 has no school charge and a zero share, so, using the rounded 2024-25 +inputs for `a6299504`, `81.48 / (0.69 * 0.7) + 2.33 = £171.026bn`, against +OBR's £170.994bn: 0.02% above receipts. The run at 0.69 gives £171.02bn on the +unrounded inputs (see +[Derivation on `a6299504`](#derivation-on-a6299504-november-2026)). With 0.67, +the rounded `f9d1922c` inputs gave `79.23 / (0.67 * 0.7) + 1.99 = £170.924bn`, +about 0.04% below receipts. The existing differences from receipts, including +Enhanced FRS's larger overstatement, still require dataset-specific +calibration. Aggregate impact CI compares the combined VAT report group +against the PR's base. Model year 2024 represents 2024-25 but reads annual policy before the January 2025 commencement, so `P0` and the share are both zero for that year. The £460m part-year receipt (0.27% of £170,994m) remains in general VAT; this change does not model its separate timing. + +## Later Microcosm cuts (24 October 2026) + +These runs predate the energy split above, so they use the formula in +[the `f9d1922c` derivation](#earlier-derivation-on-f9d1922c-october-2026). They record why the coverage has to be +re-derived (PolicyEngine/policyengine-uk#2633). They do not set the parameter. + +| Input | Value | +|---|---| +| `a133f37a` | Hugging Face `policyengine/populace-uk-private`, tag `microcosm-uk-2024-25-national-20261023T075039Z-a133f37a` (revision `c3a751d9`), SHA-256 `64f83900ca3a1242fb017585234844ce89f743d32e41cd2b6558057777f914aa` | +| `6cba5d71` | tag `microcosm-uk-2024-25-national-20261019T083323Z-6cba5d71` (revision `bb225848`), SHA-256 `810ba980005a8b907cb51464279fa5e0125464b7af9a4871b7f6744c30951ece` | + +The `a133f37a` rows were computed with policyengine-uk 2.152.7 and +policyengine-core 3.33.2, and again with policyengine-uk 2.147.2 and +policyengine-core 3.33.3, which give the same totals. The `6cba5d71` rows were +computed with policyengine-uk 2.147.2 and policyengine-core 3.33.3. + +| Dataset | Year | Standard-rated base | Reduced-rated base | Pre-scaling VAT | Receipts | Product | Survey coverage | +|---|---|---|---|---|---|---|---| +| Microcosm UK 2024-25 (`a133f37a`) | 2024-25 | £435.60bn | £26.76bn | £88.46bn | £171.0bn | 0.517 | 0.739 | +| Microcosm UK 2024-25 (`a133f37a`) | 2025-26 | £453.65bn | £27.87bn | £92.12bn | £180.2bn | 0.511 | 0.730 | +| Microcosm UK 2024-25 (`a133f37a`) | 2026-27 | £465.85bn | £28.62bn | £94.60bn | £187.7bn | 0.504 | 0.720 | +| Microcosm UK 2024-25 (`6cba5d71`) | 2024-25 | | | £88.51bn | £171.0bn | 0.518 | 0.739 | +| Microcosm UK 2024-25 (`6cba5d71`) | 2025-26 | | | £92.18bn | £180.2bn | 0.512 | 0.731 | +| Microcosm UK 2024-25 (`6cba5d71`) | 2026-27 | | | £94.66bn | £187.7bn | 0.504 | 0.720 | + +These runs, all before the energy split, show the gap: + +- With 0.67, total `vat` on `a133f37a` was 10.3%, 9.0% and 7.5% above receipts + in 2024-25 to 2026-27 (£188.61bn, £196.43bn and £201.71bn). +- On `6cba5d71`, a reform run that raises the standard rate from 20% to 21% + changed total `vat` and `gov_balance` by £9.94bn in 2026-27 and £9.68bn in + 2025-26 (reform minus baseline; policyengine-uk 2.135.7 and 2.152.6 agree). + That is 12.9% above HMRC's £8.8bn ready reckoner. +- With 0.74 (product 0.518), total `vat` on `a133f37a` was 0.1%, 1.3% and 2.7% + below receipts. +- With 0.74, a one-point reform run cost £8.99bn in 2026-27, 2.2% above the + ready reckoner. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index fd1f7c16fa..40fc2b7750 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -162,8 +162,8 @@ def growth_rate( # # Past this year policyengine-core serves each stored input from the last # projected year. An input whose variable has a formula is calculated by it -# instead (pension_credit_reported_capital reads benunit_reported_capital, -# which the Enhanced FRS does not store, so it falls to -1); one whose +# instead (the complete pension_credit_reported_capital override defaults +# to -1, while financial observations are assessed separately); one whose # variable has an `uprating` attribute is scaled by that index; any other, # rent and council tax among them, is held at its last projected value. # Simulation grows the household totals in STORED_HOUSEHOLD_TOTALS itself. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index e8507cb9e4..c4a3b37a8c 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -1052,7 +1052,7 @@ spending_heads: totals: [gov_spending, household_benefits] sign: nonnegative - id: scottish_carer_supplement - label: Carer's Allowance Supplement (Scotland) + label: Scottish Carer Supplement variable: scottish_carer_supplement entity: person report_group: disability_and_carers_benefits diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index ac650a2d71..6dcd4f572a 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -133,12 +133,17 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - net_financial_wealth - non_residential_property_value - non_residential_property_secured_debt +- other_property_value - other_residential_property_secured_debt +- overseas_property_value # Non-residential purchase prices and land retain GDP-per-capita growth. - non_residential_property_purchased - owned_land -# A formula of benunit_reported_capital; listed so a dataset that stores it -# directly is uprated like the input. +- owned_land_secured_debt +# The formula returns -1, meaning no complete override; +# pension_credit_assessable_capital assesses a financial-only report through +# benunit_reported_capital instead. This index uprates programme observations +# that a dataset stores directly in the column. - pension_credit_reported_capital - pension_income - private_pension_wealth diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml new file mode 100644 index 0000000000..2a17ce9a84 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in an income-related Employment and Support + Allowance capital calculation is disregarded (true) rather than rounded to + the nearest penny (false). A fraction of a penny is treated as a penny + only where that is to the claimant's advantage, and is otherwise + disregarded; for capital that means rounding down. +values: + 2008-10-27: true +metadata: + label: Income-related ESA capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Employment and Support Allowance Regulations 2008 reg. 3(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/3 + - title: The Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 3(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/3 diff --git a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..75be7bc6d2 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,20 @@ +description: > + Share of the current market or surrender value deducted from income-related + ESA capital where there would be expenses attributable to sale. It is a flat + 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. +values: + 2008-10-27: 0.1 +metadata: + label: Income-related ESA capital sale expense deduction + period: year + unit: /1 + reference: + - title: Employment and Support Allowance Regulations 2008 reg. 113(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations 2008 reg. 114 + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/114 + - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 + - title: Decision Makers' Guide chapter 52, para. 52624 + href: https://assets.publishing.service.gov.uk/media/69d8f30ad3e08b8871b665ac/dmg-Ch-52.pdf#page=116 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 new file mode 100644 index 0000000000..f0ec363c41 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,50 @@ +description: > + Capital sources whose sale would incur expenses, so that income-related ESA + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 52622), so `owned_land`, + `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). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) 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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +metadata: + label: Income-related ESA capital sources with sale expenses + period: year + unit: list + reference: + - title: Employment and Support Allowance Regulations 2008 reg. 113(a) + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/113 + - title: Employment and Support Allowance Regulations 2008 reg. 114 + href: https://www.legislation.gov.uk/uksi/2008/794/regulation/114 + - title: Employment and Support Allowance Regulations (Northern Ireland) 2008 reg. 113(a) + href: https://www.legislation.gov.uk/nisr/2008/280/regulation/113 + - title: Decision Makers' Guide chapter 52, paras. 52620-52622 + 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, 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 +values: + 2008-10-27: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital + - overseas_property_value + - other_property_value 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 8665590684..46eed51677 100644 --- a/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/ESA/income/capital/sources.yaml @@ -29,6 +29,11 @@ description: > back to claimant and partner shares when nobody in the household reports such an award. That allocation is a data approximation, not a legislated ownership rule. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Income-related ESA countable capital sources period: year @@ -48,3 +53,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml new file mode 100644 index 0000000000..262e1207b7 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in an income-based Jobseeker's Allowance + capital calculation is disregarded (true) rather than rounded to the + nearest penny (false). A fraction of a penny is treated as a penny only + where that is to the claimant's advantage, and is otherwise disregarded; + for capital that means rounding down. +values: + 1996-10-07: true +metadata: + label: Income-based JSA capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Jobseeker's Allowance Regulations 1996 reg. 92 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/92 + - title: The Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 92 + href: https://www.legislation.gov.uk/nisr/1996/198/regulation/92 diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..9b945b7f5f --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,23 @@ +description: > + Share of the current market or surrender value deducted from income-based + JSA capital where there would be expenses attributable to sale. It is a flat + 10% whatever the actual costs, taken from the gross value before any + encumbrance secured on the asset is deducted. Before 1 October 2007 the same + deduction sat in reg. 111(a)(i) alongside a separate valuation for National + Savings Certificates; SI 2007/2618 substituted the regulation in its current + form. +values: + 1996-10-07: 0.1 +metadata: + label: Income-based JSA capital sale expense deduction + period: year + unit: /1 + reference: + - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations 1996 reg. 112 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/112 + - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/7 + - title: Decision Makers' Guide chapter 29, para. 29624 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=125 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 new file mode 100644 index 0000000000..3a6b46c6ab --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,48 @@ +description: > + Capital sources whose sale would incur expenses, so that income-based JSA + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 29622), so `owned_land`, + `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). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) 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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +metadata: + label: Income-based JSA capital sources with sale expenses + period: year + unit: list + reference: + - title: Jobseeker's Allowance Regulations 1996 reg. 111(a) + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/111 + - title: Jobseeker's Allowance Regulations 1996 reg. 112 + href: https://www.legislation.gov.uk/uksi/1996/207/regulation/112 + - title: Jobseeker's Allowance Regulations (Northern Ireland) 1996 reg. 111(a), as substituted by S.R. 2007/396 reg. 7(11) + 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, 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 + 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 + - share_like_capital + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml index d30ee0a503..b24f57c2f9 100644 --- a/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/JSA/income/capital/sources.yaml @@ -29,6 +29,11 @@ description: > reported income-based JSA award and falls back to claimant and partner shares when nobody in the household reports such an award. That allocation is a data approximation, not a legislated ownership rule. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Income-based JSA countable capital sources period: year @@ -48,3 +53,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/disability_premia/enhanced_qualifying_benefits.yaml b/policyengine_uk/parameters/gov/dwp/disability_premia/enhanced_qualifying_benefits.yaml new file mode 100644 index 0000000000..2b27fd8483 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/disability_premia/enhanced_qualifying_benefits.yaml @@ -0,0 +1,41 @@ +description: > + Claimants and partners in Great Britain qualify for the legacy enhanced + disability premium through the care component of disability living allowance + at the highest rate (has_highest_dla_sc_award), the daily living component + of personal independence payment at the enhanced rate + (has_enhanced_pip_dl_award) and armed forces independence payment. The model + applies each programme's further conditions, such as the age limit, + elsewhere. Claimants also qualify through the Scottish equivalents (the care + components of child disability payment and Scottish adult disability living + allowance at the highest rate, and the daily living component of adult + disability payment at the enhanced rate), for which the model has no + separate variables. Claimants qualify through the PIP daily living component + from 8 April 2013 under S.I. 2013/388, and through armed forces independence + payment from the same date under S.I. 2013/591. The + enhanced_qualifying_benefits_northern_ireland parameter holds Northern + Ireland's list. The model reads the list as at 30 April of each year from + 2015 and does not prorate a change made during a year. +metadata: + label: Legacy benefit enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: The Housing Benefit Regulations 2006 Sch. 3 para. 15(1)(b), (c), (d) + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15 + - title: The Income Support (General) Regulations 1987 Sch. 2 para. 13A(1ZA) + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A + - title: The Employment and Support Allowance Regulations 2008 Sch. 4 para. 7(1)(b)-(d) + href: https://www.legislation.gov.uk/uksi/2008/794/schedule/4/paragraph/7 + - title: The Jobseeker's Allowance Regulations 1996 Sch. 1 para. 15A(1ZA) + href: https://www.legislation.gov.uk/uksi/1996/207/schedule/1/paragraph/15A + - title: The Personal Independence Payment (Supplementary Provisions and Consequential Amendments) Regulations 2013 reg. 2 + href: https://www.legislation.gov.uk/uksi/2013/388/regulation/2 + - title: "The Armed Forces and Reserve Forces Compensation Scheme (Consequential Provisions: Subordinate Legislation) Order 2013 art. 2" + href: https://www.legislation.gov.uk/uksi/2013/591/article/2 +values: + 2006-03-06: + - has_highest_dla_sc_award + 2013-04-08: + - has_highest_dla_sc_award + - has_enhanced_pip_dl_award + - armed_forces_independence_payment diff --git a/policyengine_uk/parameters/gov/dwp/disability_premia/enhanced_qualifying_benefits_northern_ireland.yaml b/policyengine_uk/parameters/gov/dwp/disability_premia/enhanced_qualifying_benefits_northern_ireland.yaml new file mode 100644 index 0000000000..9b6b7b4338 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/disability_premia/enhanced_qualifying_benefits_northern_ireland.yaml @@ -0,0 +1,47 @@ +description: > + Claimants and partners in Northern Ireland qualify for the legacy enhanced + disability premium through the care component of disability living allowance + at the highest rate. In Income Support, Jobseeker's Allowance, Housing + Benefit and Employment and Support Allowance, they also qualify through + armed forces independence payment from 24 December 2013 under S.I. + 2013/3021, and through the daily living component of personal independence + payment at the enhanced rate from 20 June 2016 under S.R. 2016/228. The + model reads the list as at 30 April of each year from 2015 and does not + prorate a change made during a year, so it counts the daily living component + from 2017. The model dates the first value from the Great Britain list's + start, not from the commencement of S.R. 2006/405 on 20 November 2006. +metadata: + label: Northern Ireland legacy benefit enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: The Housing Benefit Regulations (Northern Ireland) 2006 Sch. 4 para. 15(1)(aa), (b), (c) + href: https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/15 + - title: The Employment and Support Allowance Regulations (Northern Ireland) 2008 Sch. 4 para. 7(1)(aa), (b), (c) + href: https://www.legislation.gov.uk/nisr/2008/280/schedule/4/paragraph/7 + - title: S.I. 2013/3021 art. 1 (commencement) + href: https://www.legislation.gov.uk/uksi/2013/3021/article/1 + - title: S.I. 2013/3021 art. 14(5)(d) (Income Support) + href: https://www.legislation.gov.uk/uksi/2013/3021/article/14 + - title: S.I. 2013/3021 art. 17(7)(d) (Jobseeker's Allowance) + href: https://www.legislation.gov.uk/uksi/2013/3021/article/17 + - title: S.I. 2013/3021 art. 26(6)(d) (Housing Benefit) + href: https://www.legislation.gov.uk/uksi/2013/3021/article/26 + - title: S.R. 2016/228 reg. 1 (commencement) + href: https://www.legislation.gov.uk/nisr/2016/228/regulation/1 + - title: S.R. 2016/228 reg. 11 (Income Support) + href: https://www.legislation.gov.uk/nisr/2016/228/regulation/11 + - title: S.R. 2016/228 reg. 14 (Jobseeker's Allowance) + href: https://www.legislation.gov.uk/nisr/2016/228/regulation/14 + - title: S.R. 2016/228 reg. 30(7)(d) (Housing Benefit) + href: https://www.legislation.gov.uk/nisr/2016/228/regulation/30 +values: + 2006-03-06: + - has_highest_dla_sc_award + 2013-12-24: + - has_highest_dla_sc_award + - armed_forces_independence_payment + 2016-06-20: + - has_highest_dla_sc_award + - has_enhanced_pip_dl_award + - armed_forces_independence_payment diff --git a/policyengine_uk/parameters/gov/dwp/disability_premia/top_rate_amount_tolerance.yaml b/policyengine_uk/parameters/gov/dwp/disability_premia/top_rate_amount_tolerance.yaml new file mode 100644 index 0000000000..9561f197f1 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/disability_premia/top_rate_amount_tolerance.yaml @@ -0,0 +1,11 @@ +description: The model counts a DLA care or PIP daily living amount as a top-rate award for the enhanced disability premium when the amount reaches 52 × (top weekly rate − this weekly tolerance). policyengine-uk-data uses the same weekly tolerance when it derives award categories from reported amounts. +values: + 2015-01-01: 1 +metadata: + period: week + unit: currency-GBP + label: Enhanced disability premium top-rate amount tolerance + economy: false + reference: + - title: policyengine-uk-data, disability_benefits.py, CATEGORY_THRESHOLD_WEEKLY_TOLERANCE + href: https://github.com/PolicyEngine/policyengine-uk-data/blob/575ae3764cf0823ccf4c302fe7b640824cdb32c1/policyengine_uk_data/datasets/disability_benefits.py#L58 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..30153e66fb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/round_down.yaml @@ -0,0 +1,22 @@ +description: > + Whether a fraction of a penny in a Housing Benefit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). Any + amount determined under the regulations may, if appropriate, be rounded to + the nearest whole penny, disregarding less than half a penny and treating + half a penny or more as a penny. That is the regulations' only rounding + rule, and the model applies it. +values: + 2006-04-10: false +metadata: + label: Housing Benefit capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Housing Benefit Regulations 2006 reg. 80 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/80 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 61(6) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/61 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 78(8) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/78 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 59(7) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/59 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..b442c18a52 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,31 @@ +description: > + Share of the current market or surrender value deducted from Housing Benefit + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 47(a)(i) (reg. 45(a)(i) at pension age) alongside a separate + valuation for National Savings Certificates; SI 2007/2618 substituted the + regulation in its current form. +values: + 2006-04-10: 0.1 +metadata: + label: Housing Benefit capital sale expense deduction + period: year + unit: /1 + reference: + - title: The Housing Benefit Regulations 2006 reg. 47(a) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit Regulations 2006 reg. 48 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/48 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 46 + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/46 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/43 + - title: Housing Benefit Guidance Manual BW1, para. W1.430 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 + - title: MH v London Borough of Waltham Forest (HB) [2016] UKUT 161 (AAC), para. 43 + href: https://www.gov.uk/administrative-appeals-tribunal-decisions/mh-v-london-borough-of-waltham-forest-hb-2016-ukut-161-aac 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 new file mode 100644 index 0000000000..4dae861f35 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,65 @@ +description: > + Capital sources whose sale would incur expenses, so that Housing Benefit + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (R(IS) 21/93, as DMG 29622 applies it; HBGM W1.430 + states the general 10% rule), so `owned_land`, + `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). `share_like_capital` + (`corporate_wealth`, or its itemised components where they exceed it) 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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +metadata: + label: Housing Benefit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Housing Benefit Regulations 2006 reg. 47(a) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/47 + - title: The Housing Benefit Regulations 2006 reg. 48 + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/48 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 45(a) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/45 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 46 + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/46 + - title: The Housing Benefit Regulations (Northern Ireland) 2006 reg. 44(a) + href: https://www.legislation.gov.uk/nisr/2006/405/regulation/44 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations (Northern Ireland) 2006 reg. 43(a) + href: https://www.legislation.gov.uk/nisr/2006/406/regulation/43 + - title: Housing Benefit Guidance Manual BW1, para. W1.430 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=36 + - title: Housing Benefit Guidance Manual BP1, para. P1.430 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=31 + - title: Decision Makers' Guide chapter 29, para. 29622 (real property, R(IS) 21/93) + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 + - title: Housing Benefit Guidance Manual BW1, para. W1.513 + href: https://assets.publishing.service.gov.uk/media/5a749328ed915d0e8e3994c0/hbgm-bw1-assessment-of-capital.pdf#page=43 + - title: Housing Benefit Guidance Manual BW1, paras. W1.530-W1.531 + 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: 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 + href: https://assets.publishing.service.gov.uk/media/5a7ec5d840f0b6230268b71f/hbgm-bp1-assessment-of-capital.pdf#page=83 +values: + 2006-04-10: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/capital/sources.yaml index bd01c601ac..03ad2c478b 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 @@ -29,6 +29,11 @@ description: > classes are not yet split out separately. For capital whose owner the data do not identify, the model allocates the household remainder across benunits using an adult-share proxy. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Housing Benefit countable capital sources period: year @@ -56,3 +61,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/age_threshold.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/age_threshold.yaml index c08cba87a4..d22b71993c 100644 --- a/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/age_threshold.yaml +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/age_threshold.yaml @@ -1,11 +1,13 @@ -description: A non dependent deduction is provided under Housing Benefit for filers at or above this age threshold. +description: Housing Benefit makes a non-dependant deduction only for non-dependants at or above this age. metadata: label: Housing benefit non dependent deduction age threshold period: year unit: year - reference: - - title: The Housing Benefit Regulations 2006 sec. 74 (1) - href: https://www.legislation.gov.uk/uksi/2006/213/regulation/74/2024-04-01 + reference: + - title: The Housing Benefit Regulations 2006 reg. 74(1) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/74 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 55(1) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/55 values: - 2019-04-01: 18 + 2006-03-06: 18 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/income_related_benefit_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/income_related_benefit_age_limit.yaml new file mode 100644 index 0000000000..643e0c4133 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/non_dep_deduction/income_related_benefit_age_limit.yaml @@ -0,0 +1,12 @@ +description: Housing Benefit makes no deduction for a non-dependant under this age who is on Income Support or income-based Jobseeker's Allowance, or entitled to Universal Credit calculated on the basis that they have no earned income. +values: + 2006-03-06: 25 +metadata: + label: Housing Benefit non-dependant income-related benefit exemption age limit + unit: year + period: year + reference: + - title: The Housing Benefit Regulations 2006 reg. 74(8) + href: https://www.legislation.gov.uk/uksi/2006/213/regulation/74 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006 reg. 55(8) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/55 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..b628746c46 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/round_down.yaml @@ -0,0 +1,18 @@ +description: > + Whether a fraction of a penny in an Income Support capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). A + fraction of a penny is treated as a penny only where that is to the + claimant's advantage, and is otherwise disregarded; for capital that means + rounding down. The Northern Ireland regulation disregards any fraction of + a penny in capital. +values: + 2010-01-01: true +metadata: + label: Income Support capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Income Support (General) Regulations 1987 reg. 27 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/27 + - title: The Income Support (General) Regulations (Northern Ireland) 1987 reg. 27 + href: https://www.legislation.gov.uk/nisr/1987/459/regulation/27 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..e8658f8330 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,22 @@ +description: > + Share of the current market or surrender value deducted from Income Support + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 49(a)(i) alongside a separate valuation for National Savings + Certificates; SI 2007/2618 substituted the regulation in its current form. +values: + 2010-01-01: 0.1 +metadata: + label: Income Support capital sale expense deduction + period: year + unit: /1 + reference: + - title: Income Support (General) Regulations 1987 reg. 49(a) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations 1987 reg. 50 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/50 + - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) + href: https://www.legislation.gov.uk/nisr/2007/396/regulation/4 + - title: Decision Makers' Guide chapter 29, para. 29624 + href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=125 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 new file mode 100644 index 0000000000..1e5b7bfecc --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,48 @@ +description: > + Capital sources whose sale would incur expenses, so that Income Support + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 29622), so `owned_land`, + `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). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) 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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +metadata: + label: Income Support capital sources with sale expenses + period: year + unit: list + reference: + - title: Income Support (General) Regulations 1987 reg. 49(a) + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/49 + - title: Income Support (General) Regulations 1987 reg. 50 + href: https://www.legislation.gov.uk/uksi/1987/1967/regulation/50 + - title: Income Support (General) Regulations (Northern Ireland) 1987 reg. 49(a), as substituted by S.R. 2007/396 reg. 4(10) + 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, 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 + 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 + - share_like_capital + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/capital/sources.yaml index e4ea10e98b..d212c2acf9 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 @@ -14,6 +14,11 @@ description: > `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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Income Support capital sources period: year @@ -37,3 +42,5 @@ values: - share_like_capital - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml new file mode 100644 index 0000000000..09616dbd7b --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/child/eligibility/terminal_date_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age from which Pension Credit qualifying young person status is limited to the period before the 1 September following the 19th birthday. +values: + 0001-01-01: 19 +metadata: + unit: year + label: Pension Credit qualifying young person terminal date age limit + reference: + - title: The State Pension Credit Regulations 2002 reg. 4A(1)(b) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml new file mode 100644 index 0000000000..9baa6e68fb --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/property_sources.yaml @@ -0,0 +1,19 @@ +description: > + Household real-property capital retained alongside the benefit unit's + reported financial capital. The financial-only observation excludes land + and property, so it replaces savings and investments while these sources + retain their regulation 19 valuation and pension-age-adult allocation. + Only sources also present in capital.sources are counted. +metadata: + label: Pension Credit property capital sources + unit: list + reference: + - title: State Pension Credit Regulations 2002 reg. 19 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 +values: + 2003-10-06: + - owned_land + - other_residential_property_value + - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml new file mode 100644 index 0000000000..4170770dd6 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/round_down.yaml @@ -0,0 +1,17 @@ +description: > + Whether a fraction of a penny in a Pension Credit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). A + fraction of a penny is treated as a penny only where that is to the + claimant's advantage, and is otherwise disregarded; for capital that means + rounding down. DMG 84763 values shares the same way. +values: + 2003-10-06: true +metadata: + label: Pension Credit capital rounded down to the penny + period: year + unit: bool + reference: + - title: State Pension Credit Regulations 2002 reg. 24A + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/24A + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 24A + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/24A diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..6b527fac5c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/rate.yaml @@ -0,0 +1,22 @@ +description: > + Share of the current market or surrender value deducted from Pension Credit + capital where there would be expenses attributable to sale. It is a flat 10% + whatever the actual costs, taken from the gross value before any encumbrance + secured on the asset is deducted. Before 1 October 2007 the same deduction + sat in reg. 19(a)(i) alongside a separate valuation for National Savings + Certificates; SI 2007/2618 substituted the regulation in its current form. +values: + 2003-10-06: 0.1 +metadata: + label: Pension Credit capital sale expense deduction + period: year + unit: /1 + reference: + - title: State Pension Credit Regulations 2002 reg. 19(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: State Pension Credit Regulations 2002 reg. 20 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/20 + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84716 + href: https://assets.publishing.service.gov.uk/media/6a5e38bb8b7e4fa537e693d5/dmg-ch84.pdf#page=99 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 new file mode 100644 index 0000000000..e963fbee05 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sale_expenses/sources.yaml @@ -0,0 +1,50 @@ +description: > + Capital sources whose sale would incur expenses, so that Pension Credit + values them at 90% of market value. Costs of sale normally arise where an + agent, broker, auctioneer, solicitor or accountant is needed, and real + property always has them (DMG 84714), so `owned_land`, + `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). `share_like_capital` (`corporate_wealth`, + or its itemised components where they exceed it) 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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + when counted. These broad household sources cannot identify individual + beneficial ownership or every statutory disregard; no separate secured + debt is available for either category. +metadata: + label: Pension Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: State Pension Credit Regulations 2002 reg. 19(a) + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 + - title: State Pension Credit Regulations 2002 reg. 20 + href: https://www.legislation.gov.uk/uksi/2002/1792/regulation/20 + - title: State Pension Credit Regulations (Northern Ireland) 2003 reg. 19(a) + href: https://www.legislation.gov.uk/nisr/2003/28/regulation/19 + - title: Decision Makers' Guide chapter 84, paras. 84712-84715 + 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, 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 +values: + 2003-10-06: + - owned_land + - other_residential_property_value + - non_residential_property_value + - share_like_capital + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/income/capital/sources.yaml index 6c5a9bd5ca..b9f792f97f 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 @@ -16,6 +16,11 @@ description: > `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. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. metadata: label: Pension Credit capital sources unit: list @@ -37,3 +42,5 @@ values: - other_residential_property_value - non_residential_property_value - share_like_capital + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..2266a1fe0c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Tax Credit qualifying young person for 20 weeks while registered for work or training. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Tax Credit extension period age limit + reference: + - title: The Child Tax Credit Regulations 2002 reg. 5(3)(b) + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 diff --git a/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..dc614090d1 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/tax_credits/child_tax_credit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Tax Credit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Tax Credit remunerative work hours + reference: + - title: The Child Tax Credit Regulations 2002 reg. 2(1), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2002/2007/regulation/2 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml deleted file mode 100644 index 9cd29f27b4..0000000000 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sale_cost_rate.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: > - Proportion deducted from current property market value where sale entails - expenses, before subtracting secured encumbrances (regulation 49). Broad - household property inputs are assumed to incur sale expenses. Cash financial - proxies do not receive this deduction. -values: - 2013-04-29: 0.1 -metadata: - label: Universal Credit property sale expense deduction - unit: /1 - period: year - reference: - - title: The Universal Credit Regulations 2013 reg. 49(1) - href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml index 9e6786ee38..97e1b4fd45 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/property_sources.yaml @@ -10,6 +10,9 @@ description: > to their number in each benefit unit, including units with financial reports, complete overrides and no UC receipt. A complete override consumes its share. This is an assumption about unobserved ownership, not a statutory rule. + Overseas land and property and unclassified other real estate follow this + same route, so a financial-only report cannot replace their counted values. + Neither category has an identified secured-debt input. metadata: label: Universal Credit countable property sources period: year @@ -23,3 +26,5 @@ values: 2013-04-29: - uc_other_residential_property_capital - uc_non_residential_property_capital + - uc_overseas_property_capital + - uc_other_property_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml new file mode 100644 index 0000000000..6b34443074 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/round_down.yaml @@ -0,0 +1,19 @@ +description: > + Whether a fraction of a penny in a Universal Credit capital calculation is + disregarded (true) rather than rounded to the nearest penny (false). + Regulation 6(1) disregards a fraction of less than half a penny and treats + any other fraction as a penny, for every amount calculated under the + regulations, including the reg. 49 valuation and the capital tested + against the limit. ADM H1665 rounds a share valuation down in the + claimant's favour, but the regulation governs. +values: + 2013-04-29: false +metadata: + label: Universal Credit capital rounded down to the penny + period: year + unit: bool + reference: + - title: The Universal Credit Regulations 2013 reg. 6(1) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 7(1) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/7 diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml new file mode 100644 index 0000000000..8ea5180e07 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/rate.yaml @@ -0,0 +1,22 @@ +description: > + Share of the current market or surrender value that the Universal Credit + formulas deduct from capital where there would be expenses attributable to + sale. The formulas deduct a flat 10% whatever the actual costs, from the + gross value and before any encumbrance secured on the asset. They apply it + to the countable property in property_sources (other residential, + non-residential, overseas and unclassified other property, which they treat + as having costs of sale) and to the financial sources in + sale_expenses.sources. +values: + 2013-04-29: 0.1 +metadata: + label: Universal Credit capital sale expense deduction + period: year + unit: /1 + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1602-H1608 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 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 new file mode 100644 index 0000000000..b096c77937 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sale_expenses/sources.yaml @@ -0,0 +1,42 @@ +description: > + Universal Credit financial capital sources (capital.sources) whose sale + would incur expenses, so that they are valued at 90% of market value. + `share_like_capital` (`corporate_wealth`, or its itemised components where + they exceed it) 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). + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. Both use the real-property sale-expense treatment + through their separate property valuations. These broad household sources + cannot identify individual beneficial ownership or every statutory + disregard; no separate secured debt is available for either category. +metadata: + label: Universal Credit capital sources with sale expenses + period: year + unit: list + reference: + - title: The Universal Credit Regulations 2013 reg. 49(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/49 + - title: The Universal Credit Regulations (Northern Ireland) 2016 reg. 49(1)(a) + href: https://www.legislation.gov.uk/nisr/2016/216/regulation/49 + - title: Advice for Decision Making chapter H1, paras. H1604-H1607 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=44 + - title: Advice for Decision Making chapter H1, para. H1656 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=55 + - title: Advice for Decision Making chapter H1, para. H1665 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=57 + - title: Advice for Decision Making chapter H1, paras. H1673-H1675 + href: https://assets.publishing.service.gov.uk/media/6a5e0ac7a2662d4563fe39e0/adm-ch-h1.pdf#page=59 +values: + 2013-04-29: + - share_like_capital diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/capital/sources.yaml index a7a116c5f3..b3bc99c9c5 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 @@ -37,6 +37,12 @@ description: > pension wealth that is not separately identified and removed. Asset-specific Schedule 10 disregards within these broad financial classes are not separately observed. + `overseas_property_value` covers overseas land and property, and + `other_property_value` covers other real estate with unspecified + geography and type. These broad household categories cannot identify + individual beneficial ownership or additional statutory disregards. + No separate secured-debt input is available for either category. They use + the separate property_sources route, independently of financial reports. metadata: label: Universal Credit financial capital proxy sources period: year diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml new file mode 100644 index 0000000000..54c952537e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/extension_period_age_limit.yaml @@ -0,0 +1,9 @@ +description: Age limit below which a person who has left education or training may be a Child Benefit qualifying young person during an extension period. +values: + 0001-01-01: 18 +metadata: + unit: year + label: Child Benefit extension period age limit + reference: + - title: Child Benefit (General) Regulations 2006 reg. 5(1) + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/5 diff --git a/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml new file mode 100644 index 0000000000..e6271ff838 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/child_benefit/eligibility/remunerative_work_hours.yaml @@ -0,0 +1,9 @@ +description: Weekly hours of paid work from which a person is in remunerative work for Child Benefit rules. +values: + 0001-01-01: 24 +metadata: + unit: hour + label: Child Benefit remunerative work hours + reference: + - title: Child Benefit (General) Regulations 2006 reg. 1(3), "remunerative work" + href: https://www.legislation.gov.uk/uksi/2006/223/regulation/1 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml deleted file mode 100644 index 526252c4ed..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/eligible_bands.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: Tax bands whose members may transfer personal allowance under the Marriage Allowance election (ITA 2007 s.55B). A transferor is eligible if they are not liable at the higher rate or above; the Scottish intermediate rate is treated as within the basic-rate envelope for this purpose. -values: - 0000-01-01: - - NONE - - STARTER - - BASIC - - INTERMEDIATE -metadata: - unit: list - label: Marriage Allowance eligible tax bands - period: year - reference: - - title: "Income Tax Act 2007, s. 55B" - href: https://www.legislation.gov.uk/ukpga/2007/3/section/55B diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml index bbde24b1b1..a63a25a863 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/marriage_allowance/max.yaml @@ -1,4 +1,4 @@ -description: Maximum Marriage Allowance taxable income reduction, as a percentage of the full Personal Allowance. +description: Maximum Marriage Allowance transferable amount, as a percentage of the full Personal Allowance. The recipient gets a tax reduction of the appropriate percentage of that amount. values: 2016-04-01: 0.1 metadata: diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml index d40254f49b..f4e83a3d2c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/earned_taxable_income_exclusions.yaml @@ -5,7 +5,6 @@ values: - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance metadata: unit: list diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml new file mode 100644 index 0000000000..91ffd405cf --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/reliefs/other_income_first.yaml @@ -0,0 +1,14 @@ +description: Income Tax deducts reliefs and allowances from income other than property, savings and dividend income before it deducts them from property, savings or dividend income, and then in the way that gives the least tax. +values: + # Before 2027-28, the taxpayer deducts them under s. 25(2) alone. + 0000-01-01: false + 2027-04-06: true +metadata: + unit: bool + period: year + label: Reliefs and allowances deducted from other income first + reference: + - title: Income Tax Act 2007 s. 25(2) and (3A) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/25 + - title: Finance Act 2026 s. 6(6) and (8) (s. 25(3A) has effect from 2027-28) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/6/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml index 8e27c45473..f3fa7fad4f 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml @@ -10,6 +10,10 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 - title: Income Tax Act 2007 s. 29(2) and (3) href: https://www.legislation.gov.uk/ukpga/2007/3/section/29 + - title: Income Tax Act 2007 s. 27(2) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/27 + - title: Income Tax Act 2007 s. 55A(2) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/55A - title: Budget 2026, paragraph 6.182 (Pension Tax Allowance) href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=135 values: @@ -19,3 +23,9 @@ values: - property_finance_cost_relief # Budget 2026, 2027-28 to 2029-30; zero in other years. - pension_tax_allowance + # ITA 2007 s. 55A(2) and s. 55B(1). Section 27(5) does not list Chapter 3A, + # so s. 27(2) sets its place: the order that gives the greatest reduction. + # Each reduction above has an amount fixed before Step 6, so every order + # deducts the same total. Listing this one last leaves the amounts of the + # reductions above as they were. + - marriage_allowance_tax_reduction diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml new file mode 100644 index 0000000000..0023c852ef --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/applies_in_wales.yaml @@ -0,0 +1,29 @@ +description: Whether HMRC charges Stamp Duty Land Tax on land in Wales. When it does not, the Welsh Revenue Authority charges Land Transaction Tax there instead. +values: + 2003-07-10: + value: true + metadata: + reference: + - title: Finance Act 2003 s. 48(1)(a), as at 30 April 2017 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30 + 2018-04-01: + value: false + metadata: + note: Wales Act 2014 s. 16(5) and (6) keep SDLT for transactions under some contracts entered into on or before 17 December 2014. The model has no contract date, so it applies the switch to every purchase. + reference: + - title: Wales Act 2014 s. 16(2), (4) + href: https://www.legislation.gov.uk/ukpga/2014/29/section/16 + - title: The Wales Act 2014, Sections 16 and 19 (Disapplication of UK Stamp Duty Land Tax and UK Landfill Tax) (Appointed Date) Order 2018 (SI 2018/214) art. 2(a) + href: https://www.legislation.gov.uk/uksi/2018/214/article/2/made + - title: Finance Act 2003 s. 48(1)(a), as at 1 April 2018 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2018-04-01 +metadata: + unit: bool + label: Stamp Duty applies in Wales + reference: + - title: Finance Act 2003 s. 48(1)(a), as at 30 April 2017 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30 + - title: Wales Act 2014 s. 16(2), (4) + href: https://www.legislation.gov.uk/ukpga/2014/29/section/16 + - title: The Wales Act 2014, Sections 16 and 19 (Disapplication of UK Stamp Duty Land Tax and UK Landfill Tax) (Appointed Date) Order 2018 (SI 2018/214) art. 2(a) + href: https://www.legislation.gov.uk/uksi/2018/214/article/2/made diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml index 3f73561c63..30b6829647 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase.yaml @@ -5,27 +5,51 @@ brackets: 2003-07-10: 0 - rate: 2003-07-10: 0.01 - 2016-09-15: 0.02 + 2016-03-17: + value: 0.02 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: 2003-07-10: 150000 - rate: 2003-07-10: 0.03 - 2016-09-15: 0.05 + 2016-03-17: + value: 0.05 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: 2003-07-10: 250000 - rate: 2003-07-10: 0.04 - 2016-09-15: null + 2016-03-17: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: 2003-07-10: 500000 - 2016-09-15: null -description: Stamp Duty Land Tax progressive rates for non-residential property transfers. + 2016-03-17: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) +description: Stamp Duty Land Tax rates for non-residential property transfers, by band of the price. gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price sets whether HMRC charges the rate for the band the price falls in on the whole price, or each band's rate on the slice of the price in that band. metadata: economy: false label: Stamp Duty on non-residential property propagate_metadata_to_children: true rate_unit: /1 reference: + - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + title: Finance Act 2003 s. 55(2) Table B, as at 30 April 2015 + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127 - href: https://www.legislation.gov.uk/ukpga/2003/14/part/4 title: Finance Act 2003 s. 55 threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml new file mode 100644 index 0000000000..e7574ef994 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/purchase_charged_on_whole_price.yaml @@ -0,0 +1,22 @@ +description: Whether HMRC charges a non-residential purchase one percentage of the whole price, the rate for the band the price falls in. When false, HMRC charges each band's rate on the slice of the price in that band. +values: + 2003-07-10: + value: true + metadata: + reference: + - title: Finance Act 2003 s. 55(2), as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + 2016-03-17: + value: false + metadata: + reference: + - title: Finance Act 2016 s. 127(12) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted +metadata: + unit: bool + label: Stamp Duty on non-residential purchases charged on the whole price + reference: + - title: Finance Act 2003 s. 55(2), as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Finance Act 2016 s. 127(12) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml index 7139990825..b55ae172ed 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/non_residential/rent.yaml @@ -8,9 +8,32 @@ brackets: threshold: 2003-07-10: 150000 - rate: - 2016-09-15: 0.02 + 2003-07-10: + value: null + metadata: + note: Before 17 March 2016 HMRC charged no band above the 1% band. + reference: + - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5/paragraph/2/2015-04-30 + title: Finance Act 2003 schedule 5 paragraph 2(3) Table B, as at 30 April 2015 + 2016-03-17: + value: 0.02 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold: - 2016-09-15: 5000000 + 2003-07-10: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5/paragraph/2/2015-04-30 + title: Finance Act 2003 schedule 5 paragraph 2(3) Table B, as at 30 April 2015 + 2016-03-17: + value: 5000000 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) description: Stamp Duty Land Tax progressive rates for cumulative rents on non-residential property. metadata: @@ -21,4 +44,8 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5 title: Finance Act 2003 schedule 5 + - href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/5/paragraph/2/2015-04-30 + title: Finance Act 2003 schedule 5 paragraph 2(3) Table B, as at 30 April 2015 + - href: https://www.legislation.gov.uk/ukpga/2016/24/section/127/enacted + title: Finance Act 2016 s. 127(12) threshold_unit: currency-GBP diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml new file mode 100644 index 0000000000..27be750b2b --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/higher_rates_apply.yaml @@ -0,0 +1,27 @@ +description: Whether HMRC charges a purchase of an additional dwelling at the higher rates for additional dwellings. When false, HMRC charges that purchase at the main residential rates, gov.hmrc.stamp_duty.residential.purchase.main.subsequent. +values: + 2003-07-10: + value: false + metadata: + note: From 4 December 2014 (Stamp Duty Land Tax Act 2015 s. 2(2)) to 31 March 2016, HMRC charged a buyer of an additional dwelling the main rates in s. 55(1B) Table A at any price. Before 4 December 2014 that buyer paid the rates any other buyer paid. + reference: + - title: Finance Act 2003 s. 55(1B) Table A, as at 30 April 2015 + href: https://www.legislation.gov.uk/ukpga/2003/14/section/55/2015-04-30 + - title: Stamp Duty Land Tax Act 2015 s. 2(2) + href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + 2016-04-01: + value: true + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA +metadata: + unit: bool + label: Stamp Duty higher rates for additional dwellings apply + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA diff --git a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml index 2640c724b5..1ef8124821 100644 --- a/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml +++ b/policyengine_uk/parameters/gov/hmrc/stamp_duty/residential/purchase/additional/min.yaml @@ -1,9 +1,16 @@ description: Minimum value for taxability of additional residential property. values: - 2016-09-15: 40_000 + 2016-04-01: + value: 40_000 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 3(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA metadata: unit: currency-GBP label: Stamp Duty secondary residence minimum value reference: - - title: Finance Act 2003 schedule 4ZA(3)(4)(b) + - title: Finance Act 2003 schedule 4ZA paragraph 3(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 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 a8eca87163..2880c94bbe 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 @@ -1,12 +1,33 @@ description: Stamp Duty Land Tax progressive rates for residential property transfers that are not replacements of an individual's primary residence. brackets: - rate: - 2016-09-15: 0.03 + 2016-04-01: + value: 0.03 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.05 threshold: - 2016-09-15: 0 + 2016-04-01: + value: 0 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - rate: - 2016-09-15: 0.05 + 2016-04-01: + value: 0.05 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: null metadata: @@ -19,12 +40,19 @@ brackets: reference: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1)(b), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2022-09-23: null 2025-04-01: 0.07 threshold: - 2016-09-15: 125_000 + 2016-04-01: + value: 125_000 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: null metadata: @@ -37,15 +65,29 @@ brackets: reference: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1)(b), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2022-09-23: null 2025-04-01: 125_000 - rate: - 2016-09-15: 0.08 + 2016-04-01: + value: 0.08 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.10 threshold: - 2016-09-15: 250_000 + 2016-04-01: + value: 250_000 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2020-07-08: value: 500_000 metadata: @@ -61,22 +103,52 @@ brackets: - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1), (3), inserted by Finance Act 2021 s. 87(3) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1A - rate: - 2016-09-15: 0.13 + 2016-04-01: + value: 0.13 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.15 threshold: - 2016-09-15: 925_000 + 2016-04-01: + value: 925_000 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA - rate: - 2016-09-15: 0.15 + 2016-04-01: + value: 0.15 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA 2024-10-31: 0.17 threshold: - 2016-09-15: 1_500_000 + 2016-04-01: + value: 1_500_000 + metadata: + reference: + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA metadata: threshold_unit: currency-GBP rate_unit: /1 label: Stamp Duty on secondary residences reference: - - title: Finance Act 2003 schedule 4ZA(1)(2) + - title: Finance Act 2003 schedule 4ZA paragraph 1(2) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/4ZA + - title: Finance Act 2016 s. 128(5) + href: https://www.legislation.gov.uk/ukpga/2016/24/section/128/enacted - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 href: https://www.legislation.gov.uk/ukpga/2020/15/section/1/enacted - title: Finance Act 2021 s. 87 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 6c8ccd9dbd..c134d6a669 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,6 +1,20 @@ 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 + 2003-07-10: + value: 0 + metadata: + note: Parliament gave Schedule 6ZA effect from 22 November 2017 (Finance Act 2018 s. 41(8)). Before then a first-time buyer paid the main rates, so no purchase qualifies for the relief. The model applies no first-time buyers' relief other than Schedule 6ZA. + reference: + - title: Finance Act 2018 s. 41(8) + href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted + 2017-11-22: + value: 500_000 + metadata: + reference: + - title: Finance Act 2018 s. 41(8) + href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted + - title: Finance Act 2003 schedule 6ZA(1)(3) + href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 2020-07-08: value: 0 metadata: @@ -24,6 +38,8 @@ metadata: reference: - title: Finance Act 2003 schedule 6ZA(1)(3) href: https://www.legislation.gov.uk/ukpga/2003/14/schedule/6ZA/2018-03-15 + - title: Finance Act 2018 s. 41(8) + href: https://www.legislation.gov.uk/ukpga/2018/3/section/41/enacted - title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1(5) href: https://www.legislation.gov.uk/ukpga/2020/15/section/1 - title: Finance Act 2021 s. 87 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 8033317913..dd83480f85 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 @@ -1,12 +1,12 @@ brackets: - rate: - 2018-03-15: 0 + 2017-11-22: 0 threshold: - 2018-03-15: 0 + 2017-11-22: 0 - rate: - 2018-03-15: 0.05 + 2017-11-22: 0.05 threshold: - 2018-03-15: 300000 + 2017-11-22: 300000 2022-09-23: 425000 2025-04-01: 300000 description: Stamp Duty Land Tax progressive rates for residential property transfers. @@ -18,6 +18,8 @@ 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/2018/3/section/41/enacted + title: Finance Act 2018 s. 41(8) - 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 83e1569b74..e1635b4d1c 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 @@ -5,7 +5,15 @@ brackets: 2003-07-10: 0 - rate: 2003-07-10: 0.01 - 2015-02-12: 0.02 + 2014-12-04: + value: 0.02 + metadata: + note: Parliament inserted s. 55(1B) Table A (Stamp Duty Land Tax Act 2015 s. 1(3)) with effect for any land transaction whose effective date is, or is after, 4 December 2014 (s. 2(2)). A purchaser under a contract entered into before that date could elect out (s. 2(3) to (5)); the model has no contract date, so it doesn't apply that election. + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) 2020-07-08: value: null metadata: @@ -44,7 +52,14 @@ brackets: 2025-04-01: 125000 - rate: 2003-07-10: 0.03 - 2015-02-12: 0.05 + 2014-12-04: + value: 0.05 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2003-07-10: 250000 2020-07-08: @@ -63,22 +78,64 @@ brackets: title: Stamp Duty Land Tax (Temporary Relief) Act 2020 s. 1A(1), (2), inserted by Finance Act 2021 s. 87(3) - rate: 2003-07-10: 0.04 - 2015-02-12: 0.1 + 2014-12-04: + value: 0.1 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2003-07-10: 500000 - 2015-02-12: 925000 + 2014-12-04: + value: 925000 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) - rate: 2008-04-08: 0.05 - 2015-02-12: 0.12 + 2014-12-04: + value: 0.12 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2003-07-10: 1000000 - 2015-02-12: 1500000 + 2014-12-04: + value: 1500000 + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) - rate: 2012-07-17: 0.07 - 2015-02-12: null + 2014-12-04: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) threshold: 2012-07-17: 2000000 - 2015-02-12: null + 2014-12-04: + value: null + metadata: + reference: + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) description: Stamp Duty Land Tax progressive rates for residential property transfers. metadata: economy: false @@ -88,6 +145,10 @@ metadata: reference: - href: https://www.legislation.gov.uk/ukpga/2003/14/section/55 title: Finance Act 2003 s. 55 + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/1/enacted + title: Stamp Duty Land Tax Act 2015 s. 1(3) + - href: https://www.legislation.gov.uk/ukpga/2015/1/section/2 + title: Stamp Duty Land Tax Act 2015 s. 2(2) - 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/2021/26/section/87/enacted diff --git a/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml b/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml new file mode 100644 index 0000000000..59c96cf873 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/student_loans/repayments_due/plan_5.yaml @@ -0,0 +1,18 @@ +description: Plan 5 student loan borrowers owe repayments if this is true. A borrower need not repay any part of a Plan 5 loan on or before 5 April 2026, so from 6 April 2026 the student loan repayment formula charges Plan 5 borrowers. +values: + 2015-04-06: false + 2026-04-06: + value: true + metadata: + reference: + - title: SI 2009/470 reg. 15(2BB) (omits paragraph (2BA) on 6 April 2026) + href: https://www.legislation.gov.uk/uksi/2009/470/regulation/15 +metadata: + unit: bool + period: year + label: Plan 5 student loan repayments due + reference: + - title: SI 2009/470 reg. 15(2BA) and (2BB), inserted by SI 2022/1335 reg. 15(9) + href: https://www.legislation.gov.uk/uksi/2009/470/regulation/15 + - title: SI 2022/1335 reg. 15(9) + href: https://www.legislation.gov.uk/uksi/2022/1335/regulation/15/made diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml index 692911d156..b97a29b379 100644 --- a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/rate.yaml @@ -4,8 +4,8 @@ description: > a flat 10% whatever the actual costs, taken from the market value before any encumbrance secured on the asset is deducted (DMG 29624). The encumbrance is then deducted where an asset-specific secured-debt input - exists (other residential and non-residential property); those inputs - default to nil because the survey does not record them. + exists (land, other residential property and non-residential property); + those inputs default to nil because the survey does not record them. values: 2013-04-01: 0.1 metadata: @@ -15,16 +15,28 @@ metadata: reference: - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 1 para. 32 href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/32 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 1 para. 33 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/33 - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012, Schedule para. 65 href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/65 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012, Schedule para. 66 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/66 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 1 para. 26 href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/26 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 1 para. 27 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/27 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 6 para. 28 href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/28 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 6 para. 29 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/29 - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 42 href: https://www.legislation.gov.uk/ssi/2012/319/regulation/42 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 43 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/43 - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 46 (persons under pension age, to 31 March 2022) href: https://www.legislation.gov.uk/ssi/2012/303/regulation/46 + - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 47 (capital outside the United Kingdom; persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/regulation/47/2022-03-31 - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 70 href: https://www.legislation.gov.uk/ssi/2021/249/regulation/70 - title: Decision Makers' Guide chapter 29, para. 29624 (sale expenses before encumbrances) diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/sources.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/sources.yaml index 43cd6052c6..9ba09fdf99 100644 --- a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/sources.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sale_expenses/sources.yaml @@ -2,11 +2,19 @@ description: > Council tax reduction capital sources whose sale would incur expenses, so that they are valued at 90% of market value. Real property always has costs of sale (R(IS) 21/93, as DMG 29622 applies it to the identically worded - Income Support rule), so `owned_land`, `other_residential_property_value` - and `non_residential_property_value` are listed. `savings` is not: a bank or + Income Support rule), so `owned_land`, `other_residential_property_value`, + `non_residential_property_value`, `overseas_property_value` and + `other_property_value` are listed. `savings` is not: a bank or building society account is valued at its balance, and the costs of withdrawing money, such as travel to a building society, are not costs of - sale (DMG 29623, on the identically worded Income Support rule). + sale (DMG 29623, on the identically worded Income Support rule). Under each + scheme's rule for capital outside the United Kingdom, councils take the same + 10%, where there would be expenses attributable to sale, and any encumbrance + secured on the capital off its value under either limb, because each rule's + closing words follow both of its limbs; under SSI 2021/249, councils take + regulation 70(1)'s deductions off the value regulation 70(2) gives. The data + hold no secured debt for overseas or other property, so the formula takes + only the 10% off them. metadata: label: Council tax reduction capital sources with sale expenses period: year @@ -14,10 +22,24 @@ metadata: reference: - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 1 para. 32(a) href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/32 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 1 para. 33 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/33 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012, Schedule para. 66 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/66 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 1 para. 27 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/27 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 6 para. 28(a) href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/28 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 6 para. 29 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/29 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 43 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/43 + - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 47 (capital outside the United Kingdom; persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/regulation/47/2022-03-31 - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 70(1)(a) href: https://www.legislation.gov.uk/ssi/2021/249/regulation/70 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 70(2) (capital outside the United Kingdom, valued under reg. 70(1)) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/70 - title: Decision Makers' Guide chapter 29, para. 29622 (real property, R(IS) 21/93) href: https://assets.publishing.service.gov.uk/media/69d8f3a496c86b751317022c/dmg-Ch-29.pdf#page=124 - title: Decision Makers' Guide chapter 29, para. 29623 (costs of withdrawing money are not costs of sale) @@ -27,3 +49,5 @@ values: - owned_land - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sources.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sources.yaml index c8d6ee6133..a746b6be18 100644 --- a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sources.yaml +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/capital/sources.yaml @@ -12,7 +12,16 @@ description: > because the home is disregarded. `corporate_wealth` is also left out for now: in the data it is mostly imputed pension wealth, which the schemes disregard, and it cannot yet be separated (issue 1936). Land and property - are valued less sale expenses (`sale_expenses`). + are valued less sale expenses (`sale_expenses`). Councils value land and + property outside the United Kingdom (`overseas_property_value`) at its market + value in that country or, where that country prohibits transferring that + value to the United Kingdom, at the price it would realise if sold in the + United Kingdom to a willing buyer (the English pensioner rules, Schedule 1 + paragraph 33; the English default scheme, paragraph 66; the Welsh rules, + Schedule 1 paragraph 27 and Schedule 6 paragraph 29; the Scottish + pension-age scheme, regulation 43; SSI 2012/303 regulation 47 to 31 March + 2022, then SSI 2021/249 regulation 70(2)). `other_property_value` holds + other real estate whose location and type the data don't record. metadata: label: Council tax reduction capital sources unit: list @@ -20,25 +29,41 @@ metadata: reference: - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 reg. 11(2) and Schedule 1 para. 31 href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/31 + - title: The Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012 Schedule 1 para. 33 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/33 - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012, Schedule para. 63 href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/63 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012, Schedule para. 66 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/uksi/2012/2886/schedule/paragraph/66 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 reg. 30 href: https://www.legislation.gov.uk/wsi/2013/3029/regulation/30 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 1 para. 25 href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/25 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 1 para. 27 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/27 - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 6 para. 26 href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/26 + - title: The Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013 Schedule 6 para. 29 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/29 - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 regs. 40 and 41 href: https://www.legislation.gov.uk/ssi/2012/319/regulation/41 + - title: The Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012 reg. 43 (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/43 - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 43 (persons under pension age, to 31 March 2022) href: https://www.legislation.gov.uk/ssi/2012/303/regulation/43 + - title: The Council Tax Reduction (Scotland) Regulations 2012 reg. 47 (capital outside the United Kingdom; persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/regulation/47/2022-03-31 - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 67 href: https://www.legislation.gov.uk/ssi/2021/249/regulation/67 - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 69 href: https://www.legislation.gov.uk/ssi/2021/249/regulation/69 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 70(2) (capital outside the United Kingdom) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/70 values: 2013-04-01: - savings - owned_land - other_residential_property_value - non_residential_property_value + - overseas_property_value + - other_property_value diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/non_dep_deduction/amount.yaml new file mode 100644 index 0000000000..f7d2106bc1 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/non_dep_deduction/amount.yaml @@ -0,0 +1,154 @@ +description: England pensioner Council Tax Reduction deducts this weekly amount for each non-dependant aged 18 or over, by the non-dependant's normal gross weekly income if they are in remunerative work; a non-dependant not in remunerative work pays the lowest amount. + +# Sums from paragraph 8 of Schedule 1 to the Council Tax Reduction Schemes +# (Prescribed Requirements) (England) Regulations 2012 (SI 2012/2885), as +# substituted for each financial year by the amending instruments in the +# references. Each year's sums apply from 1 April. +brackets: +- threshold: + 2013-04-01: 0 + amount: + 2013-04-01: 3.65 + 2014-04-01: 3.7 + 2015-04-01: 3.74 + 2016-04-01: 3.77 + 2017-04-01: 3.8 + 2018-04-01: 3.9 + 2019-04-01: 4 + 2020-04-01: 4.05 + 2021-04-01: 4.05 + 2022-04-01: 4.2 + 2023-04-01: 4.6 + 2024-04-01: 4.9 + 2025-04-01: 5 + 2026-04-01: 5.2 +- threshold: + 2013-04-01: 186 + 2014-04-01: 188 + 2015-04-01: 189 + 2016-04-01: 195 + 2017-04-01: 196.95 + 2018-04-01: 202.85 + 2019-04-01: 207.7 + 2020-04-01: 217 + 2021-04-01: 217 + 2022-04-01: 224 + 2023-04-01: 236 + 2024-04-01: 256 + 2025-04-01: 266 + 2026-04-01: 279 + amount: + 2013-04-01: 7.25 + 2014-04-01: 7.45 + 2015-04-01: 7.52 + 2016-04-01: 7.58 + 2017-04-01: 7.65 + 2018-04-01: 7.9 + 2019-04-01: 8.1 + 2020-04-01: 8.25 + 2021-04-01: 8.3 + 2022-04-01: 8.55 + 2023-04-01: 9.4 + 2024-04-01: 10.05 + 2025-04-01: 10.2 + 2026-04-01: 10.6 +- threshold: + 2013-04-01: 322 + 2014-04-01: 326 + 2015-04-01: 328 + 2016-04-01: 338 + 2017-04-01: 341.4 + 2018-04-01: 351.65 + 2019-04-01: 360.1 + 2020-04-01: 377 + 2021-04-01: 377 + 2022-04-01: 389 + 2023-04-01: 410 + 2024-04-01: 445 + 2025-04-01: 463 + 2026-04-01: 485 + amount: + 2013-04-01: 9.15 + 2014-04-01: 9.4 + 2015-04-01: 9.49 + 2016-04-01: 9.56 + 2017-04-01: 9.65 + 2018-04-01: 9.95 + 2019-04-01: 10.2 + 2020-04-01: 10.35 + 2021-04-01: 10.4 + 2022-04-01: 10.7 + 2023-04-01: 11.8 + 2024-04-01: 12.6 + 2025-04-01: 12.8 + 2026-04-01: 13.3 +- threshold: + 2013-04-01: 401 + 2014-04-01: 406 + 2015-04-01: 408 + 2016-04-01: 420 + 2017-04-01: 424.2 + 2018-04-01: 436.9 + 2019-04-01: 447.4 + 2020-04-01: 469 + 2021-04-01: 469 + 2022-04-01: 484 + 2023-04-01: 511 + 2024-04-01: 554 + 2025-04-01: 577 + 2026-04-01: 605 + amount: + 2013-04-01: 10.95 + 2014-04-01: 11.25 + 2015-04-01: 11.36 + 2016-04-01: 11.45 + 2017-04-01: 11.55 + 2018-04-01: 11.9 + 2019-04-01: 12.2 + 2020-04-01: 12.4 + 2021-04-01: 12.45 + 2022-04-01: 12.85 + 2023-04-01: 14.15 + 2024-04-01: 15.1 + 2025-04-01: 15.35 + 2026-04-01: 15.95 +metadata: + label: England pensioner CTR non-dependant deduction + amount_unit: currency-GBP + threshold_unit: currency-GBP + period: week + type: single_amount + economy: false + propagate_metadata_to_children: true + uprating: gov.benefit_uprating_cpi + reference: + - title: Council Tax Reduction Schemes (Prescribed Requirements) (England) Regulations 2012, Sch. 1 para. 8 + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8 + - title: SI 2012/3085, sums for 2013-14 + href: https://www.legislation.gov.uk/uksi/2012/3085/made + - title: SI 2013/3181, sums for 2014-15 + href: https://www.legislation.gov.uk/uksi/2013/3181/made + - title: SI 2014/3312, sums for 2015-16 + href: https://www.legislation.gov.uk/uksi/2014/3312/made + - title: SI 2015/2041, sums for 2016-17 + href: https://www.legislation.gov.uk/uksi/2015/2041/made + - title: SI 2016/1262, sums for 2017-18 + href: https://www.legislation.gov.uk/uksi/2016/1262/made + - title: SI 2017/1305, sums for 2018-19 + href: https://www.legislation.gov.uk/uksi/2017/1305/made + - title: SI 2018/1346, sums for 2019-20 + href: https://www.legislation.gov.uk/uksi/2018/1346/made + - title: SI 2020/23, sums for 2020-21 + href: https://www.legislation.gov.uk/uksi/2020/23/made + - title: SI 2021/29, sums for 2021-22 + href: https://www.legislation.gov.uk/uksi/2021/29/made + - title: SI 2022/25, sums for 2022-23 + href: https://www.legislation.gov.uk/uksi/2022/25/made + - title: SI 2023/16, sums for 2023-24 + href: https://www.legislation.gov.uk/uksi/2023/16/made + - title: SI 2024/29, sums for 2024-25 + href: https://www.legislation.gov.uk/uksi/2024/29/made + - title: SI 2025/39, sums for 2025-26 + href: https://www.legislation.gov.uk/uksi/2025/39/made + - title: SI 2026/27, sums for 2026-27 + href: https://www.legislation.gov.uk/uksi/2026/27/made diff --git a/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml new file mode 100644 index 0000000000..75410b1ece --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/england/council_tax_reduction/pensioners/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml @@ -0,0 +1,11 @@ +description: England pensioner Council Tax Reduction makes no deduction for a non-dependant entitled to Universal Credit calculated on the basis that they have no earned income, from the year this applies (Sch. 1 para. 8(8)(c)). +values: + 2013-04-01: false + 2015-04-01: true +metadata: + label: England pensioner CTR exempts non-dependants on Universal Credit without earned income + unit: bool + period: year + reference: + - title: SI 2012/2885 Sch. 1 para. 8(8)(c), inserted for 2015-16 by SI 2014/3312 reg. 2(5)(a)(vi) + href: https://www.legislation.gov.uk/uksi/2014/3312/regulation/2/made diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml new file mode 100644 index 0000000000..820db3b0f5 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml @@ -0,0 +1,65 @@ +description: > + The Royal Borough of Kingston upon Thames counts the care component of + disability living allowance at the highest rate and the daily living + component of personal independence payment at either rate, for the applicant + or a member of the applicant's family, towards the enhanced disability + premium in its working-age Council Tax Reduction scheme. It omits armed + forces independence payment, and counts each award only for a person under + the qualifying age for State Pension Credit. The council made its scheme + from 1 April 2013 by adopting the Default Scheme, in whose Sch 3 para 12 + the Secretary of State sets this list. It kept the scheme apart from + uprating for 2014-15, 2015-16 and 2016-17, and revised other provisions + from April 2016 and April 2017. Its truncated 2021-22 and 2022-23 scheme + copies print the qualifying benefit list, but a lost font hides part of + the family-member age condition. It keeps the same para 12 in its scheme + texts for 2023 to 2026-27. In its 2021-22, 2022-23 and 2023 texts the + council cites no revision of its own after December 2015. In its 2023 + text it adds the Energy Bills Rebate disregard from 1 April 2022 and + leaves para 12 alone. The Secretary of State requires that disregard of + every scheme from 2022-23 (S.I. 2022/127 reg 3). The council lists no + Council Tax Reduction decision in its decision registers for 2017 to 2026, + though it leaves some Council resolutions out of them. In its 2023-24 + budget report it says the scheme "remains unchanged". The model applies + the list in 2013-14 to 2017-18 and from 2023-24. It cites no complete + scheme text for 2018-19 to 2022-23. For those years it assumes the Housing + Benefit list, as it does for councils without a list of their own. The + model reads the list as at 30 April of each year. +metadata: + label: Kingston upon Thames Council Tax Reduction enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: Council, 11 December 2012, item 42 (a scheme from 1 April 2013 adopting the Default Scheme) + href: https://moderngov.kingston.gov.uk/mgAi.aspx?ID=20744 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. 3 para. 12 (as made) + href: https://www.legislation.gov.uk/uksi/2012/2886/appendix/3/regulation/12/made + - title: Council, 8 October 2013, item 42 (no changes for 2014/15 other than the Government's changes to the Default Scheme) + href: https://moderngov.kingston.gov.uk/ieListDocuments.aspx?CId=137&MId=7156&Ver=4 + - title: Decision 2535, 17 December 2013 (2014/15 the same as 2013/14 apart from uprating) + href: https://moderngov.kingston.gov.uk/ieDecisionDetails.aspx?ID=2535 + - title: Decision 2707, 16 July 2014 (2015/16 and 2016/17 unchanged apart from uprating) + href: https://moderngov.kingston.gov.uk/ieDecisionDetails.aspx?ID=2707 + - title: Decision 3042, 15 December 2015 (revisions from 1 April 2016 and 1 April 2017, none to Sch 3 para 12) + href: https://moderngov.kingston.gov.uk/ieDecisionDetails.aspx?ID=3042 + - title: Council Tax Reduction Scheme 2021-2022 (truncated archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20221206070019id_/https://www.kingston.gov.uk/downloads/file/616/council-tax-reduction-scheme-2021-2022 + - title: Council Tax Reduction Scheme 2022-2023 (truncated archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20221206083043id_/https://www.kingston.gov.uk/downloads/file/513/council-tax-reduction-scheme-2022-2023 + - title: Budget Report 2023/24 to 2026/27, para. 51 (the Council Tax Support Scheme "remains unchanged"), approved by Budget Council on 2 March 2023 + href: https://moderngov.kingston.gov.uk/mgConvert2PDF.aspx?ID=99625 + - title: Council Tax Reduction Scheme 2023, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/downloads/CTRS_2023_Final.pdf + - title: Council Tax Reduction Scheme 2023, "Basis for the local scheme" (based on the Default Scheme Regulations) + href: https://www.kingston.gov.uk/sites/default/files/downloads/CTRS_2023_Final.pdf + - title: The Council Tax (Demand Notices and Reduction Schemes) (England) (Amendment) Regulations 2022, reg. 3 (energy rebate disregard in every scheme) + href: https://www.legislation.gov.uk/uksi/2022/127/regulation/3/made + - title: Council Tax Reduction Scheme 2024-2025, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/downloads/Council_Tax_Reduction_Scheme_2024_2025.pdf + - title: Council Tax Reduction Scheme 2025 - 2026, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/2025-04/Council_Tax_Reduction_Scheme_2025_26.pdf + - title: Council Tax Reduction Scheme 2026 - 2027, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf +values: + 2013-04-01: + - has_highest_dla_sc_award + - pip_dl diff --git a/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/enhanced_disability_premium/qualifying_benefits_apply.yaml b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/enhanced_disability_premium/qualifying_benefits_apply.yaml new file mode 100644 index 0000000000..0a3c505a07 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/kingston_upon_thames/council_tax_reduction/enhanced_disability_premium/qualifying_benefits_apply.yaml @@ -0,0 +1,39 @@ +description: > + If this is true, the model applies Kingston upon Thames's own qualifying + benefit list for the enhanced disability premium in its working-age + Council Tax Reduction scheme. The model applies that list in 2013-14 to + 2017-18 and from 2023-24. It cites no complete Kingston scheme text for + 2018-19 to 2022-23. For those years it assumes the Housing Benefit list, + as it does for councils without a list of their own. The model reads this + parameter as at 30 April of each year, like the qualifying benefit list. +metadata: + label: Kingston upon Thames Council Tax Reduction enhanced disability premium qualifying benefits apply + period: year + unit: bool + reference: + - title: Council, 11 December 2012, item 42 (a scheme from 1 April 2013 adopting the Default Scheme) + href: https://moderngov.kingston.gov.uk/mgAi.aspx?ID=20744 + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. 3 para. 12 (as made) + href: https://www.legislation.gov.uk/uksi/2012/2886/appendix/3/regulation/12/made + - title: Decision 2535, 17 December 2013 (2014/15 the same as 2013/14 apart from uprating) + href: https://moderngov.kingston.gov.uk/ieDecisionDetails.aspx?ID=2535 + - title: Decision 2707, 16 July 2014 (2015/16 and 2016/17 unchanged apart from uprating) + href: https://moderngov.kingston.gov.uk/ieDecisionDetails.aspx?ID=2707 + - title: Decision 3042, 15 December 2015 (revisions from 1 April 2016 and 1 April 2017, none to Sch 3 para 12) + href: https://moderngov.kingston.gov.uk/ieDecisionDetails.aspx?ID=3042 + - title: Council Tax Reduction Scheme 2021-2022 (truncated archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20221206070019id_/https://www.kingston.gov.uk/downloads/file/616/council-tax-reduction-scheme-2021-2022 + - title: Council Tax Reduction Scheme 2022-2023 (truncated archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20221206083043id_/https://www.kingston.gov.uk/downloads/file/513/council-tax-reduction-scheme-2022-2023 + - title: Council Tax Reduction Scheme 2023, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/downloads/CTRS_2023_Final.pdf + - title: Council Tax Reduction Scheme 2024-2025, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/downloads/Council_Tax_Reduction_Scheme_2024_2025.pdf + - title: Council Tax Reduction Scheme 2025 - 2026, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/2025-04/Council_Tax_Reduction_Scheme_2025_26.pdf + - title: Council Tax Reduction Scheme 2026 - 2027, Sch. 3 para. 12(1)(b), (c) + href: https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf +values: + 2013-04-01: true + 2018-04-01: false + 2023-04-01: true diff --git a/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml new file mode 100644 index 0000000000..c5b835d9eb --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/merton/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml @@ -0,0 +1,59 @@ +description: > + The London Borough of Merton counts the care component of disability living + allowance at the highest rate and the daily living component of personal + independence payment at either rate, for the applicant or a member of the + applicant's family, towards the enhanced disability premium in its + working-age Council Tax Support scheme. It omits armed forces independence + payment, and counts each award only for a person under the qualifying age + for State Pension Credit. The council sets this list in its scheme rules for + 2013-14, 2014-15, 2015-16, 2020-21, 2021-22, 2022-23, 2023-24, 2025-26 and + 2026-27, and links its 2023 rules as its 2024-25 scheme. In its Cabinet and + Council reports for 2017/18 to 2020/21 the council says it continued the + same scheme through 2019/20, so the model also applies the list from + 2016-17 to 2019-20, for which it cites no scheme text. The model reads the + list as at 30 April of each year. +metadata: + label: Merton Council Tax Support enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: Council Tax Reduction Scheme L B Merton April 2013 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20150530202253id_/http://www.merton.gov.uk/council_tax_reduction_scheme_l_b_merton_april_2013_with_uprated_amounts_final_310113-2.pdf + - title: Council Tax Reduction Scheme L B Merton April 2014 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20150530203929id_/http://www.merton.gov.uk/council_tax_reduction_scheme_l_b_merton_april_2014_with_uprated_amounts_final_280114.pdf + - title: Council Tax Reduction Scheme L B Merton April 2015 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20150530203354id_/http://www.merton.gov.uk/council_tax_reduction_scheme_l_b_merton_april_2015_with_uprated_amounts_final_280115.pdf + - title: Cabinet report on the Council Tax Support Scheme 2017/18 (archived copy; the Council continued the same scheme) + href: https://web.archive.org/web/20240912160841id_/https://democracy.merton.gov.uk/documents/s15005/Council%20Tax%20Support%20Scheme.pdf + - title: Cabinet report on the Council Tax Support Scheme 2018/19, 13 November 2017 (archived copy) + href: https://web.archive.org/web/20250420052844id_/https://democracy.merton.gov.uk/documents/s20013/Council%20Tax%20Support%20Scheme%20Cabinet%20Report%201819.pdf + - title: Council report on the Council Tax Support Scheme 2018/19, 22 November 2017 (archived copy) + href: https://web.archive.org/web/20250513135239id_/https://democracy.merton.gov.uk/documents/s20163/Council%20Tax%20Support%20Scheme%20Full%20Council%20Report%201819.pdf + - title: Cabinet report on the Council Tax Support Scheme 2019/20 (archived copy) + href: https://web.archive.org/web/20220325065644id_/https://democracy.merton.gov.uk/documents/s25279/Council%20Tax%20Support%20Scheme%20and%20Council%20Tax%20Technical%20Reforms%20201920%20full%20Council%20eport.pdf + - title: Council report on the Council Tax Support Scheme 2019/20, 21 November 2018 (archived copy) + href: https://web.archive.org/web/20200919054918id_/https://democracy.merton.gov.uk/documents/s25438/Council%20Tax%20Support%20Scheme%20and%20Council%20Tax%20Technical%20Reforms%20201920%20full%20Council%20report%202.pdf + - title: Cabinet report on the Council Tax Support Scheme 2020/21, 11 November 2019 (archived copy) + href: https://web.archive.org/web/20220208235004id_/https://democracy.merton.gov.uk/documents/s30060/Council%20Tax%20Support%20Scheme%20202021.pdf + - title: Council report on the Council Tax Support Scheme 2020/21, 5 February 2020 (archived copy) + href: https://web.archive.org/web/20200928082644id_/https://democracy.merton.gov.uk/documents/s30950/Council%20Tax%20Support%20Sceme%20202021.pdf + - title: Council Tax Reduction Scheme L B Merton 2020-21 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20230207113512id_/https://www.merton.gov.uk/system/files?file=council20tax20reduction20scheme20l20b20merton20final20formatting20numbers202020-21.pdf + - title: Council Tax Reduction Scheme L B Merton 2021-22 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20240911170941id_/https://www.merton.gov.uk/system/files?file=council20tax20reduction20scheme20l20b20merton202021-2220final.pdf + - title: Council Tax Reduction Scheme L B Merton 2022-2023 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20230129084916id_/https://www.merton.gov.uk/system/files/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202022-2023.pdf + - title: Council Tax Reduction Scheme L B Merton 2022-2023 final (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20260117195021id_/https://www.merton.gov.uk/system/files/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202022-2023%20final.pdf + - title: Council Tax Reduction Scheme L B Merton 2023-24 (archived copy), Sch. 3 para. 12(1)(b), (c) + href: https://web.archive.org/web/20240718130610id_/https://www.merton.gov.uk/system/files/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202023-24.pdf + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2023, Sch. 3 para. 12(1)(b), (c) + href: https://www.merton.gov.uk/system/files/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202023-24_0.pdf + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2025, Sch. 3 para. 12(1)(b), (c) + href: https://www.merton.gov.uk/sites/default/files/2025-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202025-26.pdf + - title: The London Borough of Merton Local Council Tax Support Scheme Rules 2026, Sch. 3 para. 12(1)(b), (c) + href: https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf +values: + 2013-04-01: + - has_highest_dla_sc_award + - pip_dl diff --git a/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml new file mode 100644 index 0000000000..52818af0fb --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/newham/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml @@ -0,0 +1,61 @@ +description: > + The London Borough of Newham counts the care component of disability living + allowance at the highest rate and the daily living component of personal + independence payment at either rate, for the applicant or a member of the + applicant's family, towards the enhanced disability premium in its + working-age Council Tax Reduction scheme, and from 1 April 2014 armed forces + independence payment. It counts each award only for a person under the + qualifying age for State Pension Credit. From its 2022 text the council + also counts the care component of child disability payment at the highest + rate, for which the model has no separate variable. The council sets these + lists in its texts for 2013, 2014, 2017, 2022, 2023 and 2026. For the years + between, the council says in its budget reports that the scheme will remain + unchanged (2015-16, 2016-17) or proposes no changes (2020-21, 2021-22), says + in its 2024-25 Council Tax booklet that it is keeping the scheme in place, + and changed other provisions in its 2019-20 and 2025-26 revisions; in its + texts on either side of each of those years, and of 2018-19, it counts the + same DLA care, PIP daily living and armed forces independence payment + awards. The model reads the list as at 30 April of each year. +metadata: + label: Newham Council Tax Reduction enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: Council, 24 January 2013, minutes (adopting the local scheme in Appendix 1 from 1 April 2013) + href: https://mgov.newham.gov.uk/documents/g9739/Printed%20minutes%2024th-Jan-2013%2018.00%20Council.pdf?T=1 + - title: Council Tax Reduction Scheme 2013 (Appendix 1), Sch. 3 para. 12(1)(b), (c) + href: https://mgov.newham.gov.uk/documents/s74450/Appendix%201%20-%20LB%20Newham%20Local%20CTR%20Scheme_16_01_13.pdf + - title: Council, 9 December 2013, minutes (adopting the scheme in Appendix 2 from 1 April 2014) + href: https://mgov.newham.gov.uk/documents/g9807/Printed%20minutes%2009th-Dec-2013%2020.00%20Council.pdf?T=1 + - title: Council Tax Reduction Scheme 2014, Sch. 3 para. 12(1)(b), (c), (d) + href: https://mgov.newham.gov.uk/documents/s85542/LB%20Newham%20Local%20CTR%20Scheme_1415_12_11_13A.pdf + - title: Budget proposals report (Council, 23 February 2015), appendix 1, para. 7.12 (the 2015/16 scheme "will remain unchanged") + href: https://mgov.newham.gov.uk/documents/s94929/Mayors%20Budget%20Proposals%20Report%20-%20appendix%201.pdf + - title: Budget proposals report (Council, 22 February 2016), para. 7.15 (the 2016/17 scheme "will remain unchanged") + href: https://mgov.newham.gov.uk/documents/s102326/Mayors%20Budget%20Proposals%20Report%20PDF.pdf + - title: Council Tax Reduction Scheme 2017 (Appendix 1), Sch. 3 para. 12(1)(b), (c), (d) + href: https://mgov.newham.gov.uk/documents/s110300/Appendix%201%20LB%20Newham%20Local%20CTR%20Scheme%201718.pdf + - title: Council, 18 February 2019, minutes (a lower minimum contribution from 1 April 2019) + href: https://mgov.newham.gov.uk/documents/g12186/Printed%20minutes%2018th-Feb-2019%2019.00%20Council.pdf?T=1 + - title: Budget report 2020-21 (Council, 2 March 2020), para. 4.31 ("No changes to the council tax support scheme are proposed in 2020/21") + href: https://mgov.newham.gov.uk/documents/s135762/Budget%20Report%202020-21%20to%202022-23.pdf + - title: Budget report 2021-22 (Council, 1 March 2021), para. 5.19 (no changes proposed) + href: https://mgov.newham.gov.uk/documents/s143898/210219%20Budget%20report%202021-22%20-%202023%20FINAL.pdf + - title: Council, 28 February 2022, supplementary agenda (Council Tax Reduction Scheme 2022), Sch. 3 para. 12(1)(b), (c), (d) + href: https://mgov.newham.gov.uk/documents/b31136/Supplementary%20Agenda%20-%20Council%20Tax%20Reduction%20Scheme%2028th-Feb-2022%2019.00%20Council.pdf?T=9 + - title: Council Tax Reduction Scheme 2023, Sch. 3 para. 12(1)(b), (c), (d) + href: https://www.newham.gov.uk/downloads/file/5876/council-tax-reduction-scheme-2023 + - title: Council Tax booklet 2024-25, Mayor's message ("keeping firmly in place the 90% council tax reduction scheme") + href: https://www.newham.gov.uk/downloads/file/7105/council-tax-2024-25-budget-booklet + - title: Budget setting report 2025-26 (Council, 27 February 2025), para. 8.5 (changes to maximum support, taper, capital limit and non-dependant deductions) + href: https://mgov.newham.gov.uk/documents/s179147/Final%20Budget%20Setting%20Report%20Full%20Councilx.pdf + - title: Council Tax Reduction Scheme 2026, Sch. 3 para. 12(1)(n), (o), (p) + href: https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026 +values: + 2013-04-01: + - has_highest_dla_sc_award + - pip_dl + 2014-04-01: + - has_highest_dla_sc_award + - pip_dl + - armed_forces_independence_payment diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 0000000000..3b5c0874b7 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,160 @@ +description: Scotland Council Tax Reduction deducts this weekly amount for each non-dependant aged 18 or over, by the non-dependant's normal gross weekly income if they are in remunerative work; a non-dependant not in remunerative work pays the lowest amount. + +# Sums from regulation 48 of the Council Tax Reduction (State Pension Credit) +# (Scotland) Regulations 2012 (SSI 2012/319) and, for people under pension +# age, regulation 67 of SSI 2012/303 to 2021-22 and regulation 90 of the +# Council Tax Reduction (Scotland) Regulations 2021 (SSI 2021/249) from +# 2022-23, as substituted for each financial year by the amending instruments +# in the references. Each year's sums apply from 1 April. +brackets: +- threshold: + 2013-04-01: 0 + amount: + 2013-04-01: 3.65 + 2014-04-01: 3.75 + 2015-04-01: 3.85 + 2016-04-01: 3.9 + 2017-04-01: 3.95 + 2018-04-01: 4.05 + 2019-04-01: 4.15 + 2020-04-01: 4.25 + 2021-04-01: 4.3 + 2022-04-01: 4.45 + 2023-04-01: 4.9 + 2024-04-01: 5.25 + 2025-04-01: 5.35 + 2026-04-01: 5.55 +- threshold: + 2013-04-01: 186 + 2014-04-01: 188 + 2015-04-01: 189 + 2016-04-01: 195 + 2017-04-01: 200 + 2018-04-01: 204 + 2019-04-01: 209 + 2020-04-01: 213 + 2021-04-01: 213 + 2022-04-01: 228 + 2023-04-01: 241 + 2024-04-01: 260 + 2025-04-01: 273 + 2026-04-01: 286 + amount: + 2013-04-01: 7.25 + 2014-04-01: 7.5 + 2015-04-01: 7.7 + 2016-04-01: 7.75 + 2017-04-01: 7.85 + 2018-04-01: 8.1 + 2019-04-01: 8.3 + 2020-04-01: 8.45 + 2021-04-01: 8.5 + 2022-04-01: 8.8 + 2023-04-01: 9.7 + 2024-04-01: 10.35 + 2025-04-01: 10.55 + 2026-04-01: 10.95 +- threshold: + 2013-04-01: 322 + 2014-04-01: 326 + 2015-04-01: 328 + 2016-04-01: 338 + 2017-04-01: 346 + 2018-04-01: 354 + 2019-04-01: 363 + 2020-04-01: 370 + 2021-04-01: 370 + 2022-04-01: 396 + 2023-04-01: 418 + 2024-04-01: 451 + 2025-04-01: 474 + 2026-04-01: 497 + amount: + 2013-04-01: 9.15 + 2014-04-01: 9.5 + 2015-04-01: 9.8 + 2016-04-01: 9.85 + 2017-04-01: 9.95 + 2018-04-01: 10.25 + 2019-04-01: 10.5 + 2020-04-01: 10.7 + 2021-04-01: 10.8 + 2022-04-01: 11.15 + 2023-04-01: 12.3 + 2024-04-01: 13.15 + 2025-04-01: 13.4 + 2026-04-01: 13.9 +- threshold: + 2013-04-01: 401 + 2014-04-01: 406 + 2015-04-01: 408 + 2016-04-01: 420 + 2017-04-01: 430 + 2018-04-01: 439 + 2019-04-01: 451 + 2020-04-01: 458 + 2021-04-01: 458 + 2022-04-01: 490 + 2023-04-01: 517 + 2024-04-01: 558 + 2025-04-01: 586 + 2026-04-01: 614 + amount: + 2013-04-01: 10.95 + 2014-04-01: 11.35 + 2015-04-01: 11.7 + 2016-04-01: 11.8 + 2017-04-01: 11.9 + 2018-04-01: 12.25 + 2019-04-01: 12.55 + 2020-04-01: 12.8 + 2021-04-01: 12.9 + 2022-04-01: 13.3 + 2023-04-01: 14.65 + 2024-04-01: 15.65 + 2025-04-01: 15.95 + 2026-04-01: 16.55 +metadata: + label: Scotland CTR non-dependant deduction + amount_unit: currency-GBP + threshold_unit: currency-GBP + period: week + type: single_amount + economy: false + propagate_metadata_to_children: true + uprating: gov.benefit_uprating_cpi + reference: + - title: Council Tax Reduction (State Pension Credit) (Scotland) Regulations 2012, reg. 48 + href: https://www.legislation.gov.uk/ssi/2012/319/regulation/48 + - title: Council Tax Reduction (Scotland) Regulations 2012, reg. 67 (to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/regulation/67 + - title: Council Tax Reduction (Scotland) Regulations 2021, reg. 90 + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/90 + - title: SSI 2013/49, sums for 2013-14 + href: https://www.legislation.gov.uk/ssi/2013/49/made + - title: SSI 2014/35, sums for 2014-15 + href: https://www.legislation.gov.uk/ssi/2014/35/made + - title: SSI 2015/46, sums for 2015-16 + href: https://www.legislation.gov.uk/ssi/2015/46/made + - title: SSI 2016/81, sums for 2016-17 + href: https://www.legislation.gov.uk/ssi/2016/81/made + - title: SSI 2017/41, sums for 2017-18 + href: https://www.legislation.gov.uk/ssi/2017/41/made + - title: SSI 2018/69, sums for 2018-19 + href: https://www.legislation.gov.uk/ssi/2018/69/made + - title: SSI 2019/29, sums for 2019-20 + href: https://www.legislation.gov.uk/ssi/2019/29/made + - title: SSI 2020/25, sums for 2020-21 + href: https://www.legislation.gov.uk/ssi/2020/25/made + - title: SSI 2021/51, sums for 2021-22 + href: https://www.legislation.gov.uk/ssi/2021/51/made + - title: SSI 2022/52, sums for 2022-23 + href: https://www.legislation.gov.uk/ssi/2022/52/made + - title: SSI 2023/38, sums for 2023-24 + href: https://www.legislation.gov.uk/ssi/2023/38/made + - title: SSI 2024/35, sums for 2024-25 + href: https://www.legislation.gov.uk/ssi/2024/35/made + - title: SSI 2025/24, sums for 2025-26 + href: https://www.legislation.gov.uk/ssi/2025/24/made + - title: SSI 2026/56, sums for 2026-27 + href: https://www.legislation.gov.uk/ssi/2026/56/made diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml new file mode 100644 index 0000000000..7ee2d4484a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml @@ -0,0 +1,13 @@ +description: Scotland Council Tax Reduction makes no deduction for a non-dependant entitled to Universal Credit calculated on the basis that they have no earned income, from the year this applies (SSI 2012/319 reg. 48(8)(c), SSI 2012/303 reg. 67(8)(c) and SSI 2021/249 reg. 90(8)(c)). +values: + 2013-04-01: false + 2016-04-01: true +metadata: + label: Scotland CTR exempts non-dependants on Universal Credit without earned income + unit: bool + period: year + reference: + - title: SSI 2012/319 reg. 48(8)(c) and SSI 2012/303 reg. 67(8)(c), inserted from 1 April 2016 by SSI 2016/81 + href: https://www.legislation.gov.uk/ssi/2016/81/made + - title: Council Tax Reduction (Scotland) Regulations 2021, reg. 90(8)(c) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/90 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/working_age_exempt_partner_of_income_related_benefit_claimant.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/working_age_exempt_partner_of_income_related_benefit_claimant.yaml new file mode 100644 index 0000000000..d9f934cc99 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/non_dep_deduction/working_age_exempt_partner_of_income_related_benefit_claimant.yaml @@ -0,0 +1,17 @@ +description: Scotland's working-age Council Tax Reduction scheme makes no deduction for a non-dependant who is, or whose partner is, on Income Support, income-based JSA or income-related ESA and who is not on Universal Credit (a "qualifying income-related benefit claimant"), from the year this applies (SSI 2021/249 reg. 90(8)(a) with reg. 4(1)). Before April 2022 (SSI 2012/303 reg. 67(8)(a)), and in the pension-age scheme, only a non-dependant who is on one of these benefits is exempt. +values: + 2013-04-01: false + 2022-04-01: true +metadata: + label: Scotland working-age CTR exempts a non-dependant whose partner is on an income-related benefit + unit: bool + period: year + reference: + - title: Council Tax Reduction (Scotland) Regulations 2021, reg. 90(8)(a) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/90 + - title: Council Tax Reduction (Scotland) Regulations 2021, reg. 4(1) ("qualifying income-related benefit claimant") + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/4 + - title: Council Tax Reduction (Scotland) Regulations 2021, reg. 1 (in force 1 April 2022) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/1 + - title: Council Tax Reduction (Scotland) Regulations 2012, reg. 67(8)(a) + href: https://www.legislation.gov.uk/ssi/2012/303/regulation/67 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml new file mode 100644 index 0000000000..98947fb13a --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/carer_premium_counts_uc_carer_element.yaml @@ -0,0 +1,15 @@ +description: Whether Scottish councils pay the working-age Council Tax Reduction carer premium to an applicant or partner whose award of Universal Credit includes the carer element, as well as on entitlement to Carer's Allowance or Carer Support Payment. +values: + 2013-04-01: false + 2022-04-01: true +metadata: + unit: bool + period: year + label: Scottish working-age Council Tax Reduction carer premium counts the Universal Credit carer element + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 6(1)(c) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6 + - title: The Council Tax Reduction (Scotland) Regulations 2021 reg. 1 (in force 1 April 2022) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/1 + - title: The Council Tax Reduction (Scotland) Regulations 2012 Sch. 1 para. 14 (persons under pension age, to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/14/2021-04-01 diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/enhanced_disability_premium/age_limit_applies.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/enhanced_disability_premium/age_limit_applies.yaml new file mode 100644 index 0000000000..ae81518926 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/enhanced_disability_premium/age_limit_applies.yaml @@ -0,0 +1,26 @@ +description: > + If this is true, Scottish councils include the enhanced disability premium + in a working-age Council Tax Reduction applicable amount only for an + applicant or partner under the qualifying age for State Pension Credit. The + Scottish Ministers set this limit in the 2012 scheme, which ran until 31 + March 2022. In the 2021 scheme they set the limit at pensionable age, which + the model reads as the same qualifying age (the two have agreed for everyone + since 2019-20), and from 20 June 2022 they removed it (S.S.I. 2022/161). The + model reads this as at 30 April of each year from 2015, so it applies the + limit in 2022 and not from 2023. +metadata: + label: Scottish working-age Council Tax Reduction enhanced disability premium age limit applies + period: year + unit: bool + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 Sch. 1 para. 12(1)(b), (ba), (c) (to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/12/2021-04-01 + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 14(1) as in force on 1 April 2022 + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/14/2022-04-01 + - title: The Council Tax Reduction (Scotland) Amendment (No. 3) Regulations 2022 reg. 1 (in force 20 June 2022) + href: https://www.legislation.gov.uk/ssi/2022/161/regulation/1 + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 14(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/14 +values: + 2013-04-01: true + 2022-06-20: false diff --git a/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/enhanced_disability_premium/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/enhanced_disability_premium/qualifying_benefits.yaml new file mode 100644 index 0000000000..f1ed21c9ef --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/scotland/council_tax_reduction/working_age/enhanced_disability_premium/qualifying_benefits.yaml @@ -0,0 +1,36 @@ +description: > + Scottish councils count the care component of disability living allowance at + the highest rate, the daily living component of personal independence + payment at the enhanced rate, armed forces independence payment and, from 1 + April 2023, attendance allowance at either rate (S.S.I. 2023/38), for the + applicant or partner, toward the enhanced disability premium in working-age + Council Tax Reduction. Scottish councils also count Scottish benefits (child + disability payment, adult disability payment, pension age disability payment + and Scottish adult disability living allowance), for which the model has no + separate variables. Applicants and family members qualify through armed + forces independence payment from 11 June 2013 under S.S.I. 2013/142. The + model reads the list as at 30 April of each year from 2015. +metadata: + label: Scottish working-age Council Tax Reduction enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: The Council Tax Reduction (Scotland) Regulations 2012 Sch. 1 para. 12(1) (to 31 March 2022) + href: https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/12/2021-04-01 + - title: The Council Tax Reduction (Scotland) Regulations 2021 Sch. 1 para. 14(1) + href: https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/14 + - title: The Council Tax Reduction and Council Tax (Discounts) (Miscellaneous Amendment) (Scotland) Regulations 2023 reg. 16(c) + href: https://www.legislation.gov.uk/ssi/2023/38/regulation/16 +values: + 2013-04-01: + - has_highest_dla_sc_award + - has_enhanced_pip_dl_award + 2013-06-11: + - has_highest_dla_sc_award + - has_enhanced_pip_dl_award + - armed_forces_independence_payment + 2023-04-01: + - has_highest_dla_sc_award + - has_enhanced_pip_dl_award + - armed_forces_independence_payment + - attendance_allowance diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/non_dep_deduction/amount.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/non_dep_deduction/amount.yaml new file mode 100644 index 0000000000..72afa4a599 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/non_dep_deduction/amount.yaml @@ -0,0 +1,162 @@ +description: Wales Council Tax Reduction deducts this weekly amount for each non-dependant aged 18 or over, by the non-dependant's normal gross weekly income if they are in remunerative work; a non-dependant not in remunerative work pays the lowest amount. + +# Sums from paragraph 3 of Schedule 1 (pensioners) and paragraph 5 of Schedule +# 6 (persons who are not pensioners) to the Council Tax Reduction Schemes and +# Prescribed Requirements (Wales) Regulations 2013 (WSI 2013/3029); for +# 2013-14, the same paragraphs of WSI 2012/3144, as substituted for each +# financial year by the amending instruments in the references. Each year's +# sums apply from 1 April. +# 2016-17: the English text sets the first band at £194; the Welsh text has +# £195 (WSI 2017/46 reg. 3(a)(iii)-(v) replaced both). +brackets: +- threshold: + 2013-04-01: 0 + amount: + 2013-04-01: 3.65 + 2014-04-01: 3.75 + 2015-04-01: 3.9 + 2016-04-01: 4.05 + 2017-04-01: 4.2 + 2018-04-01: 4.35 + 2019-04-01: 4.55 + 2020-04-01: 4.85 + 2021-04-01: 5.1 + 2022-04-01: 5.3 + 2023-04-01: 5.45 + 2024-04-01: 5.8 + 2025-04-01: 6.25 + 2026-04-01: 6.7 +- threshold: + 2013-04-01: 186 + 2014-04-01: 188 + 2015-04-01: 189 + 2016-04-01: 194 + 2017-04-01: 200 + 2018-04-01: 205 + 2019-04-01: 210 + 2020-04-01: 217 + 2021-04-01: 217 + 2022-04-01: 224 + 2023-04-01: 236 + 2024-04-01: 256 + 2025-04-01: 266 + 2026-04-01: 279 + amount: + 2013-04-01: 7.25 + 2014-04-01: 7.5 + 2015-04-01: 7.8 + 2016-04-01: 8.1 + 2017-04-01: 8.4 + 2018-04-01: 8.7 + 2019-04-01: 9.15 + 2020-04-01: 9.75 + 2021-04-01: 10.2 + 2022-04-01: 10.6 + 2023-04-01: 10.9 + 2024-04-01: 11.55 + 2025-04-01: 12.45 + 2026-04-01: 13.35 +- threshold: + 2013-04-01: 322 + 2014-04-01: 326 + 2015-04-01: 328 + 2016-04-01: 338 + 2017-04-01: 346 + 2018-04-01: 355 + 2019-04-01: 365 + 2020-04-01: 377 + 2021-04-01: 377 + 2022-04-01: 389 + 2023-04-01: 410 + 2024-04-01: 445 + 2025-04-01: 463 + 2026-04-01: 485 + amount: + 2013-04-01: 9.15 + 2014-04-01: 9.45 + 2015-04-01: 9.85 + 2016-04-01: 10.25 + 2017-04-01: 10.6 + 2018-04-01: 10.95 + 2019-04-01: 11.5 + 2020-04-01: 12.25 + 2021-04-01: 12.85 + 2022-04-01: 13.35 + 2023-04-01: 13.7 + 2024-04-01: 14.5 + 2025-04-01: 15.65 + 2026-04-01: 16.8 +- threshold: + 2013-04-01: 401 + 2014-04-01: 406 + 2015-04-01: 408 + 2016-04-01: 420 + 2017-04-01: 430 + 2018-04-01: 440 + 2019-04-01: 450 + 2020-04-01: 469 + 2021-04-01: 469 + 2022-04-01: 484 + 2023-04-01: 511 + 2024-04-01: 554 + 2025-04-01: 577 + 2026-04-01: 605 + amount: + 2013-04-01: 10.95 + 2014-04-01: 11.3 + 2015-04-01: 11.75 + 2016-04-01: 12.25 + 2017-04-01: 12.7 + 2018-04-01: 13.1 + 2019-04-01: 13.75 + 2020-04-01: 14.65 + 2021-04-01: 15.35 + 2022-04-01: 15.95 + 2023-04-01: 16.4 + 2024-04-01: 17.35 + 2025-04-01: 18.7 + 2026-04-01: 20.05 +metadata: + label: Wales CTR non-dependant deduction + amount_unit: currency-GBP + threshold_unit: currency-GBP + period: week + type: single_amount + economy: false + propagate_metadata_to_children: true + uprating: gov.benefit_uprating_cpi + reference: + - title: Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013, Sch. 1 para. 3 (pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3 + - title: Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2013, Sch. 6 para. 5 (persons who are not pensioners) + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5 + - title: Council Tax Reduction Schemes and Prescribed Requirements (Wales) Regulations 2012 (2013-14), Sch. 1 para. 3 and Sch. 6 para. 5 + href: https://www.legislation.gov.uk/wsi/2012/3144/schedule/1/paragraph/3 + - title: WSI 2013/112, sums for 2013-14 + href: https://www.legislation.gov.uk/wsi/2013/112/made + - title: WSI 2014/66, sums for 2014-15 + href: https://www.legislation.gov.uk/wsi/2014/66/made + - title: WSI 2015/44, sums for 2015-16 + href: https://www.legislation.gov.uk/wsi/2015/44/made + - title: WSI 2016/50, sums for 2016-17 + href: https://www.legislation.gov.uk/wsi/2016/50/made + - title: WSI 2017/46, sums for 2017-18 + href: https://www.legislation.gov.uk/wsi/2017/46/made + - title: WSI 2018/14, sums for 2018-19 + href: https://www.legislation.gov.uk/wsi/2018/14/made + - title: WSI 2019/11, sums for 2019-20 + href: https://www.legislation.gov.uk/wsi/2019/11/made + - title: WSI 2020/16, sums for 2020-21 + href: https://www.legislation.gov.uk/wsi/2020/16/made + - title: WSI 2021/34, sums for 2021-22 + href: https://www.legislation.gov.uk/wsi/2021/34/made + - title: WSI 2022/51, sums for 2022-23 + href: https://www.legislation.gov.uk/wsi/2022/51/made + - title: WSI 2023/47, sums for 2023-24 + href: https://www.legislation.gov.uk/wsi/2023/47/made + - title: WSI 2024/56, sums for 2024-25 + href: https://www.legislation.gov.uk/wsi/2024/56/made + - title: WSI 2025/59, sums for 2025-26 + href: https://www.legislation.gov.uk/wsi/2025/59/made + - title: WSI 2026/13, sums for 2026-27 + href: https://www.legislation.gov.uk/wsi/2026/13/made diff --git a/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml new file mode 100644 index 0000000000..d7ec14e179 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/wales/council_tax_reduction/non_dep_deduction/exempt_universal_credit_without_earned_income.yaml @@ -0,0 +1,11 @@ +description: Wales Council Tax Reduction makes no deduction for a non-dependant entitled to Universal Credit calculated on the basis that they have no earned income, from the year this applies (Sch. 1 para. 3(8)(c) and Sch. 6 para. 5(8)(c)). +values: + 2013-04-01: false + 2015-04-01: true +metadata: + label: Wales CTR exempts non-dependants on Universal Credit without earned income + unit: bool + period: year + reference: + - title: WSI 2013/3029 Sch. 1 para. 3(8)(c) and Sch. 6 para. 5(8)(c), added for 2015-16 by WSI 2015/44 + href: https://www.legislation.gov.uk/wsi/2015/44/made diff --git a/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml b/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml new file mode 100644 index 0000000000..f9379c3faf --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/westminster/council_tax_reduction/enhanced_disability_premium/qualifying_benefits.yaml @@ -0,0 +1,36 @@ +description: > + The model counts the care component of disability living allowance at the + highest rate and the daily living component of personal independence + payment at either rate, for the applicant or a member of the applicant's + family, towards the enhanced disability premium in Westminster City + Council's working-age Council Tax Support scheme. It omits armed forces + independence payment, and counts each award only for a person under the + qualifying age for State Pension Credit. This is the Default Scheme's list + (S.I. 2012/2886 Sch 3 para 12). The council has approved the same scheme, + based on the Default Scheme Regulations, since 2013/14, and its Cabinet + says from December 2020 that the council's local scheme mirrors those + Regulations. The model cites no full Westminster scheme text. + The model reads the list as at 30 April of each year. +metadata: + label: Westminster Council Tax Support enhanced disability premium qualifying benefits + period: year + unit: list + reference: + - title: Report of the Cabinet, 10 December 2018, Council Tax (archived copy; the same scheme for 2019/20, based on the Default Scheme Regulations) + href: https://web.archive.org/web/20220927103652id_/https://committees.westminster.gov.uk/documents/s30518/Report%20of%20the%20Cabinet%20on%2010%20Dec%202018%20-%20Council%20Tax.pdf + - title: Statement of decision, Council Tax Discounts including Council Tax Local Reduction Scheme, 2020/21 (archived copy) + href: https://web.archive.org/web/20250501010325id_/https://committees.westminster.gov.uk/documents/d1138/Printed%20decision%20Council%20Tax%20Discounts%20including%20Council%20Tax%20Local%20Reduction%20Scheme%20and%20Council%20T.pdf?T=5 + - title: Report of the Cabinet, 14 December 2020, Council Tax Discounts (archived copy; the Default Scheme Regulations "are mirrored within the City Council's local scheme") + href: https://web.archive.org/web/20240621074656id_/https://committees.westminster.gov.uk/documents/s40219/Report%20of%20the%20Cabinet%2014%20December%202020%20-Council%20Tax%20Discounts.pdf + - title: Cabinet report, 13 December 2021, Council Tax Taxbase and Discounts (archived copy) + href: https://web.archive.org/web/20211205134208id_/https://committees.westminster.gov.uk/documents/s45385/Cabinet%20Report%20-%20Council%20Tax%20Taxbase%20and%20Discounts%20Report.pdf + - title: Cabinet report, 12 December 2022, Council Tax Discounts (archived copy) + href: https://web.archive.org/web/20230106164259id_/https://committees.westminster.gov.uk/documents/s50264/Council%20Tax%20Discounts.pdf + - title: Cabinet report, 11 December 2023, Council Tax Discounts including Council Tax Local Reduction Scheme (archived copy) + href: https://web.archive.org/web/20250207010503id_/https://committees.westminster.gov.uk/documents/s57091/Council%20Tax%20Discounts%20including%20Council%20Tax%20Local%20Reduction%20Scheme%20and%20Council%20Tax%20Base%20report.pdf + - title: The Council Tax Reduction Schemes (Default Scheme) (England) Regulations 2012 Sch. 3 para. 12 (as made) + href: https://www.legislation.gov.uk/uksi/2012/2886/appendix/3/regulation/12/made +values: + 2013-04-01: + - has_highest_dla_sc_award + - pip_dl diff --git a/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml b/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml index 54e8d87667..1ad053af43 100644 --- a/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/microdata_vat_coverage.yaml @@ -1,4 +1,4 @@ -description: Deprecated. Combined VAT grossing factor, kept so that existing reforms to this path still work. Its value is gov.simulation.vat.survey_consumption_coverage times gov.simulation.vat.household_share_of_receipts (0.67 x 0.7). If a reform sets it to a different value, vat uses it in place of the two factors; otherwise vat uses the two factors. New reforms should change the two factors instead. +description: Deprecated. Combined VAT grossing factor, kept so that existing reforms to this path still work. Its value is gov.simulation.vat.survey_consumption_coverage times gov.simulation.vat.household_share_of_receipts (0.69 x 0.7). If a reform sets it to a different value, vat uses it in place of the two factors; otherwise vat uses the two factors. New reforms should change the two factors instead. metadata: unit: /1 period: year @@ -8,4 +8,4 @@ metadata: - title: docs/engineering/vat-coverage.md (derivation of the two factors this combines) href: https://github.com/PolicyEngine/policyengine-uk/blob/main/docs/engineering/vat-coverage.md values: - 2010-01-01: 0.469 + 2010-01-01: 0.483 diff --git a/policyengine_uk/parameters/gov/simulation/price_base_year.yaml b/policyengine_uk/parameters/gov/simulation/price_base_year.yaml new file mode 100644 index 0000000000..7bdf4352f9 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/price_base_year.yaml @@ -0,0 +1,8 @@ +description: Calendar year whose prices the real income variables use. inflation_adjustment and inflation_adjustment_ahc divide the price index on 1 January of this year by the index in the period, so a simulation gives the same real incomes whatever year it runs in. A modelling convention with no legislation behind it. +values: + 0000-01-01: 2026 +metadata: + unit: year + period: year + economy: false + label: Price base year for real incomes diff --git a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml index 121d970d74..4ac312c888 100644 --- a/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml +++ b/policyengine_uk/parameters/gov/simulation/vat/survey_consumption_coverage.yaml @@ -4,36 +4,40 @@ metadata: period: year economy: false derivation: >- - Calibrated to the certified Microcosm UK 2024-25 national release only - (policyengine/populace-uk-private, tag - microcosm-uk-2024-25-national-20261002T230158Z-5c6b3f68, revision - f9d1922c). VAT on household energy bills (domestic_energy_vat) is not - grossed up, so the coverage applies only to VAT on other recorded - consumption. On that release, pre-scaling VAT on that consumption is 0.20 x - £396.15bn = £79.23bn in 2024 (the generic reduced-rate base is empty once - domestic fuel and power is taken out), and VAT on energy bills is £1.99bn. - OBR VAT receipts for 2024-25 (outturn, March 2026 EFO table 3.8, cash - basis) are £170.994bn. Coverage = 79.23 / (0.7 x (170.994 - 1.99)) = 0.670. - Before energy bills were split out, the same calculation gave 0.672, so - the rounded value is unchanged. Across 2024-25 to 2026-27 the coverage is - 0.650 to 0.670, and the HMRC ready reckoner (£8.8bn for one point on the - standard rate in 2026-27) implies 0.688. On Enhanced FRS 2024-25 the same - calculation gives 0.927 (policyengine-uk-data 1.56.16) or 0.967 (1.58.0), - so this value puts total VAT on that dataset 38% or 44% above 2024-25 + This value matches 2024-25 VAT receipts on one dataset only: the certified + Microcosm UK 2024-25 national release (policyengine/populace-uk-private, + tag microcosm-uk-2024-25-national-20261025T091414Z-a6299504, revision + 994cac89), whose fit excludes the obr.vat receipts target. The vat formula + adds VAT on household energy bills (domestic_energy_vat) without grossing + it up, so the coverage applies only to VAT on other recorded consumption. + On that release, pre-scaling VAT on that consumption comes to 0.20 x + £407.40bn = £81.48bn in 2024 (reduced_rate_vat_consumption takes domestic + fuel and power's share of spending out of the reduced-rate share, which + leaves the generic reduced-rate base empty), and VAT on energy bills comes + to £2.33bn. OBR VAT receipts for 2024-25 (outturn, March 2026 EFO table + 3.8, cash basis) come to £170.994bn. Coverage = 81.48 / (0.7 x (170.994 - + 2.33)) = 0.690. From 2024-25 to 2026-27 the coverage falls from 0.690 to + 0.666, and the HMRC ready reckoner (£8.8bn for one point on the standard + rate in 2026-27) implies 0.704, leaving out the school share. On Enhanced + FRS 2024-25 (policyengine-uk-data 1.58.1) the same calculation gives 0.954 + for 2024-25, so this value puts total VAT on that file 38% above 2024-25 receipts. Re-derive when the dataset it should match changes, with docs/engineering/vat-coverage.md, which lists the inputs, file hashes and - per-dataset results. These receipts already include private school VAT. - The vat formula therefore removes school-fee VAT's share of receipts from - the grossed-up amount (not from energy-bill VAT) before gov_tax adds - private_school_vat. At current law on Enhanced FRS 2024-25 - (policyengine-uk-data 1.58.1), the two heads sum to the receipt-calibrated - VAT to within the share's accuracy (at most 0.06% below it), so no extra - school receipts are added to this denominator. See the receipt-partition - derivation in that document. + per-dataset results. These receipts already include private school VAT. The + vat formula therefore removes school-fee VAT's share of receipts from the + grossed-up amount (not from energy-bill VAT) before gov_tax adds + private_school_vat. At a coverage of 0.67 on Enhanced FRS 2024-25 + (policyengine-uk-data 1.58.1), the two heads summed to the + receipt-calibrated VAT at current law to within the share's accuracy, at + most 0.06% below it (Aggregate impact run 40116746457, 24 October 2026), + so this denominator needs no extra school receipts. See the + receipt-partition derivation in that document. reference: - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts, table 3.8 href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + - title: OBR, Economic and fiscal outlook March 2026, detailed forecast tables, receipts (efo-march-2026-detailed-forecast-tables-receipts.xlsx), sheet 3.8, row 13 (Value added tax, cash basis), cell D13 for 2024-25 outturn (£bn) + href: https://obr.uk/docs/d055fbf02d5b3g6jq8l2/efo-march-2026-detailed-forecast-tables-receipts.xlsx - title: HMRC, Direct effects of illustrative tax changes bulletin (June 2025) href: https://www.gov.uk/government/statistics/direct-effects-of-illustrative-tax-changes/direct-effects-of-illustrative-tax-changes-bulletin-january-2025 values: - 2010-01-01: 0.67 + 2010-01-01: 0.69 diff --git a/policyengine_uk/programs.yaml b/policyengine_uk/programs.yaml index eece69c539..748b16ec31 100644 --- a/policyengine_uk/programs.yaml +++ b/policyengine_uk/programs.yaml @@ -672,7 +672,7 @@ programs: variable: council_tax_reduction parameter_prefix: gov.local_authorities verified_start_year: 2013 - notes: National support for the Scotland and Wales schemes (working-age rules including the Universal Credit routes, the income-related benefit passport and devolved applicable amounts) and the England pensioner scheme (including the Pension Credit guarantee credit disregard and savings-credit-only rules), with reported CTB/CTR retained for unsupported English working-age local schemes. + notes: National support for the Scotland and Wales schemes (working-age rules including the Universal Credit routes, the income-related benefit passport and devolved applicable amounts) and the England pensioner scheme (including the Pension Credit guarantee credit disregard and savings-credit-only rules), and each scheme's own non-dependant deductions and exemptions, with reported CTB/CTR retained for unsupported English working-age local schemes. # --- Treasury programs --- - id: cost_of_living_support diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 16ddaa7960..2dd2427fa5 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,6 +1,9 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.income_tax import net_income_from_adjusted_net_income from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.hmrc.income_tax.allowances.meets_marriage_allowance_income_conditions import ( + liable_only_at_marriage_allowance_rates, +) from typing import Union, Optional @@ -34,23 +37,22 @@ def formula(person, period): class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person meets the statutory Marriage Allowance income " + "conditions or this reform's expansion conditions." + ) entity = Person definition_period = YEAR value_type = bool reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - base_eligible = np.isin(band.decode_to_str(), eligible_bands) - # Expand to higher bands if the reform's expansion conditions are met. - bands = band.possible_values - expansion = person("meets_expanded_ma_conditions", period) & ( - (band == bands.HIGHER) | (band == bands.ADDITIONAL) + base_eligible = liable_only_at_marriage_allowance_rates( + person, period, parameters ) + # Expand to people liable above those rates who meet the reform's + # expansion conditions. + expansion = person("meets_expanded_ma_conditions", period) & ~base_eligible return base_eligible | expansion class marriage_allowance(Variable): diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index e000f0f9a3..f32010b536 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -89,6 +89,7 @@ drop_calculated_years, retained_input_variables, ) +from policyengine_uk.utils.subsample import subsample_simulation from policyengine_uk.utils.supplied_inputs import ( SUPPLIED_INPUT_VARIABLES, copy_model_values, @@ -1652,6 +1653,75 @@ def build_from_ids( self.build_from_populations(builder.populations) + def subsample( + self, + n: Optional[int] = None, + frac: Optional[float] = None, + seed=None, + time_period=None, + quantize_weights: bool = True, + ) -> "Simulation": + """Cut the simulation down to a weighted random sample of households. + + Draws ``n`` households with replacement and keeps each household + drawn at least once, with its benefit units and people. So the sample + holds at most ``n`` households. Only households with a positive + weight in ``time_period`` can be drawn: with ``quantize_weights`` + (the default), each with probability proportional to that weight; + without it, each with equal probability. A period before the first + one the simulation was given household weights for has none, so the + first given weights are used instead. + + Every value the simulation was given (from its dataset or through + ``set_input``) is kept for the sampled records, for every period, and + so is every value the model stored as its own (``set_model_value``), + which stays the model's. So does the record of where each capital + gains input came from, and what the dataset declared about how its + amounts grow (its ISA income contracts and savings income series). Calculated values are dropped and recalculated + on the sample, and the tracer starts again. + + Each supplied weight is replaced so that its total is unchanged in + every period: a household's new weight is proportional to the number + of times it was drawn, and also to its old weight without + ``quantize_weights``. A zero weight stays zero. With + ``quantize_weights``, a household's new weight in ``time_period``, + averaged over draws, is its old one; in another period that holds + only if the household's share of the total weight is the same there. + + ``baseline`` is replaced with a copy subsampled to the same + households, which keeps its own policy and inputs. The baseline the + simulation held before is left as it was, since another simulation + (a clone of this one) can hold it too. ``dataset`` is replaced with + the rows of the dataset the simulation was built from for the + sampled records, with its weights rescaled in the same way. Like the + dataset before, it holds whole years only, and not values set with + ``set_input`` after the simulation was built. + + Args: + n: The number of households to draw. + frac: The number to draw as a share of all households, used when + ``n`` is not given. + seed: A number or text that fixes the draw. The same seed always + draws the same households; without one, a fixed seed is used. + time_period: Draw households by their weight in this period. + Defaults to the default calculation period. + quantize_weights: If false, draw households with a positive + weight with equal probability, and make a household's new + weight proportional to its old weight times the number of + times it was drawn. + + Returns: + This simulation, subsampled. + """ + return subsample_simulation( + self, + n=n, + frac=frac, + seed=seed, + time_period=time_period, + quantize_weights=quantize_weights, + ) + def move_values(self, variable_donor: str, variable_target: str) -> None: """Move values from one variable to another across all branches. diff --git a/policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py b/policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py new file mode 100644 index 0000000000..89f3cdfa88 --- /dev/null +++ b/policyengine_uk/tests/aggregate_impact/counted_once_base_reference.py @@ -0,0 +1,1073 @@ +"""The counted-once check as it stood at 89bd11fec, without commentary. + +Keep this independent of the production tool: it is the differential oracle +for #2773, whose fix must publish exactly what this did for every model whose +heads and totals are finite at baseline. It holds the check (``guard.py``), +how ``measure`` records and publishes its result (``record_guard_checks``, +``publishable_guard``) and how ``render`` describes it (``_guard``). +""" + +from __future__ import annotations + +import math +from typing import Iterable, Mapping, Optional + +import numpy as np + +from policyengine_uk.utils.aggregate_impact.partition import Partition +from policyengine_uk.utils.aggregate_impact.privacy import MIN_CELL, count_text, small +from policyengine_uk.utils.aggregate_impact.spec import fiscal_year_label + + +def _raw(sim, variable, period): + try: + result = sim.calculate(variable, period, unweighted=True) + except TypeError: + result = sim.calculate(variable, period) + return np.asarray(getattr(result, "values", result)) + + +def discard_formula_results(sim) -> bool: + reset = getattr(sim, "_invalidate_all_caches", None) + if reset is None: + return False + reset() + return True + + +class _InputSnapshot(dict): + def __init__(self, values, *, owner, origins): + super().__init__(values) + self.owner = owner + self.origins = origins + + +def _visible_branches(sim): + branches = [] + branch = sim + while branch is not None: + if branch.branch_name not in branches: + branches.append(branch.branch_name) + branch = getattr(branch, "parent_branch", None) + if "default" not in branches: + branches.append("default") + return branches + + +def _capture_input_origins(sim, variables): + from policyengine_uk.utils.supplied_inputs import SUPPLIED_INPUT_VARIABLES + + names = set(variables) & SUPPLIED_INPUT_VARIABLES + if not names: + return {} + branches = set(_visible_branches(sim)) + keys = set(getattr(sim, "_user_input_keys", None) or ()) + model = getattr(sim, "_model_values", None) or {} + origins = {} + for name in names: + if name not in sim.tax_benefit_system.variables: + continue + holder = sim.get_holder(name) + slots = [] + for key in keys | set(model): + variable, branch, period = key + if variable != name or branch not in branches: + continue + value = holder._get_array_from_storage(period, branch) + if value is None: + continue + marker = model.get(key) + is_model = marker is not None and np.array_equal(marker, value) + if key in keys or is_model: + slots.append((branch, period, np.array(value, copy=True), is_model)) + origins[name] = tuple(slots) + return origins + + +def snapshot_inputs(sim, variables) -> dict: + variables = tuple(variables) + discard_formula_results(sim) + origins = _capture_input_origins(sim, variables) + snapshot = _InputSnapshot({}, owner=sim, origins=origins) + for variable in variables: + if variable not in sim.tax_benefit_system.variables: + continue + holder = sim.get_holder(variable) + periods = holder.get_known_periods() + if periods: + snapshot[variable] = [ + (period, np.array(holder.get_array(period), copy=True)) + for period in periods + ] + return snapshot + + +def _guard_snapshot(sim, snapshot, variables): + if snapshot is None: + return snapshot_inputs(sim, variables) + if isinstance(snapshot, _InputSnapshot): + if snapshot.owner is not sim: + raise ValueError("Input snapshot belongs to a different simulation") + return snapshot + from policyengine_uk.utils.supplied_inputs import SUPPLIED_INPUT_VARIABLES + + origins = _capture_input_origins(sim, variables) + for name in set(snapshot) & SUPPLIED_INPUT_VARIABLES: + for period, value in snapshot[name]: + if not any( + ( + saved_period == period and np.array_equal(saved_value, value) + for _, saved_period, saved_value, _ in origins.get(name, ()) + ) + ): + raise ValueError("Plain input snapshot has no matching original slot") + return _InputSnapshot(snapshot, owner=sim, origins=origins) + + +def _set_guard_value(sim, variable, period, value, touched=None): + from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + set_model_value, + ) + + if touched is not None: + touched.add(variable) + if variable in SUPPLIED_INPUT_VARIABLES: + set_model_value(sim, variable, period, value) + else: + sim.set_input(variable, period, value) + + +def _restore(sim, variable, snapshot): + from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + drop_missing_model_values, + drop_missing_supplied_inputs, + set_model_value, + ) + + if variable not in SUPPLIED_INPUT_VARIABLES: + sim.delete_arrays(variable) + for period, values in snapshot.get(variable, []): + sim.set_input(variable, period, values) + return + if not isinstance(snapshot, _InputSnapshot) or snapshot.owner is not sim: + raise ValueError("Registered restoration needs its original input snapshot") + if variable not in snapshot.origins: + raise ValueError("Registered variable is absent from original origin capture") + holder = sim.get_holder(variable) + holder.delete_arrays(branch_name=sim.branch_name) + drop_missing_supplied_inputs(sim, variable) + drop_missing_model_values(sim, variable) + for branch, period, value, is_model in snapshot.origins[variable]: + if branch == sim.branch_name: + if is_model: + set_model_value(sim, variable, period, value) + else: + sim.set_input(variable, period, value) + + +def _months(year): + from policyengine_core.periods import period as make_period + + return make_period(str(year)).get_subperiods("month") + + +def _household(sim, values, entity): + values = np.asarray(values, dtype=np.float64) + if entity == "household": + return values + return np.asarray(sim.map_result(values, entity, "household"), dtype=np.float64) + + +def _head_arrays(sim, year, variables) -> dict: + definitions = sim.tax_benefit_system.variables + return { + v: _household(sim, _raw(sim, v, year), definitions[v].entity.key) + for v in variables + } + + +ABSOLUTE_SLACK_GBP = 0.02 +RELATIVE_SLACK = 2e-06 + + +def _slack(scale: np.ndarray) -> np.ndarray: + return ABSOLUTE_SLACK_GBP + RELATIVE_SLACK * np.abs(scale) + + +def _response_slack(response: np.ndarray, expected: np.ndarray) -> np.ndarray: + return ABSOLUTE_SLACK_GBP * np.abs(expected) + RELATIVE_SLACK * np.abs(response) + + +def _moves( + response: np.ndarray, expected: np.ndarray, allowances: int = 1 +) -> np.ndarray: + beyond = np.abs(response) > allowances * _response_slack(response, expected) + return beyond | ~np.isfinite(response) + + +def _contains( + response: np.ndarray, expected: np.ndarray, allowances: int = 1 +) -> np.ndarray: + close = np.abs(response - expected) <= allowances * _response_slack( + response, expected + ) + return close | ~np.isfinite(response) + + +def _rises(response: np.ndarray, slack: np.ndarray) -> np.ndarray: + return (response > slack) | ~np.isfinite(response) + + +def _not_finite_units(values) -> np.ndarray: + if isinstance(values, list): + return np.logical_or.reduce([~np.isfinite(v) for _, v in values]) + return ~np.isfinite(values) + + +def _held_values(at_first, baseline, units): + if isinstance(at_first, list): + return [ + (month, np.where(units, start, value)) + for (month, value), (_, start) in zip(at_first, baseline) + ] + return np.where(units, baseline, at_first) + + +def _households_text(households) -> str: + if households is None: + return f"fewer than {MIN_CELL} households" + return count_text(households, "households") + + +def problem_text(kind, head, component, households, not_finite=False, weighted=True): + clause = ", or is not finite," if not_finite and weighted else "" + if kind == "own_head": + text = f"{head} does not move by exactly what was added to {component}{clause}" + elif kind == "contains": + text = f"{head} rises one for one with {component}" + ( + clause or ", so it contains it," + ) + elif kind == "unconfirmed": + text = f"{head} responds to {component}{clause}" + elif kind == "rises": + text = f"{head} rises{clause} when {component} does" + elif kind == "moves": + text = f"{head} moves{clause} when {component} does" + elif kind == "mediated": + text = f"{head} responds only through other heads{clause}" + else: + text = f"the model's {head} moves by other than its heads{clause} when {component} does" + text += f" in {_households_text(households)}" + if not_finite and (not weighted): + text += ", and is not finite in households with no weight" + return text + + +def _unheld_where(households, of_them=False) -> str: + if households == 0: + return "households with no weight" + what = "of them" if of_them else "households" + if households is None or small(households): + return f"fewer than {MIN_CELL} {what}" + return f"{households:,} {what}" + + +def _unheld_words(heads, component, name=str) -> str: + names = [name(v) for v in heads] + if len(names) == 1: + listed, which = (names[0], "which is") + else: + listed = f"{', '.join(names[:-1])} and {names[-1]}" + which = "one or more of which is" + return f"the check could not hold {listed}, {which} not finite there once {name(component)} rises" + + +def not_held_note(component, head, heads, households, name=str) -> str: + return f"in {_unheld_where(households)} {_unheld_words(heads, component, name)}, so it measured {name(head)} again there only with the other heads at their baseline values" + + +def _problem( + total, + head, + kind, + component, + where, + weights, + perturbed, + nan, + not_held=(), + unjudged=None, +): + weighted = weights > 0 + count = int(np.count_nonzero(where & weighted)) + base = float(np.sum(weights * perturbed)) + share = float(np.sum(weights * where)) / base if base else 0.0 + not_finite = bool((where & nan).any()) + not_finite_weighted = bool((where & nan & weighted).any()) + unjudged = np.zeros_like(where) if unjudged is None else unjudged & where + unheld = int(np.count_nonzero(unjudged & weighted)) if not_held else 0 + text = problem_text(kind, head, component, count, not_finite, not_finite_weighted) + message = f"{total}: {text}" + if kind == "unconfirmed": + message += f" where {_unheld_words(list(not_held), component)}, so it could not confirm whether {head} contains {component}" + elif not_held: + message += f" (in {_unheld_where(unheld, of_them=True)} {_unheld_words(list(not_held), component)}, so it could not show that {head} responds only through other heads)" + return { + "total": total, + "head": head, + "kind": kind, + "households": count, + "share": share, + "message": message, + "not_finite": not_finite, + "not_finite_weighted": not_finite_weighted, + "heads_not_held": list(not_held), + "households_not_held": unheld, + "where": where, + "nan": nan, + "unjudged": unjudged, + } + + +def judge_total( + *, + total: str, + is_tax: bool, + component: str, + own_variable: str, + declared_double_count: bool, + zero_baseline: bool, + weights: np.ndarray, + expected: np.ndarray, + head_changes: Mapping[str, np.ndarray], + head_scales: Mapping[str, np.ndarray], + model_change: Optional[np.ndarray], + model_scale: Optional[np.ndarray], + allowed_readers: Iterable[str], + second_changes: Optional[Mapping[str, np.ndarray]] = None, +) -> dict: + added = float(np.sum(weights * expected)) + allowed = set(allowed_readers) + perturbed = expected > 0 + responses, problems, screens, own_problem = ({}, [], [], None) + + def problem(head, kind, where, nan): + return _problem(total, head, kind, component, where, weights, perturbed, nan) + + for variable, change in head_changes.items(): + responses[variable] = float(np.sum(weights * change)) / added if added else 0.0 + slack = _slack(head_scales[variable]) + if variable == own_variable: + off = (np.abs(change - expected) > slack) | ~np.isfinite(change) + if off.any(): + own_problem = problem(variable, "own_head", off, ~np.isfinite(change)) + continue + reader = variable in allowed or (zero_baseline and (not is_tax)) + if reader: + if second_changes is not None: + slope = second_changes[variable] - change + response_slack = _response_slack(slope, expected) + containing = perturbed & _contains(slope, expected, 2) + linear = perturbed & (np.abs(slope) > 2 * response_slack) & ~containing + if linear.any(): + screens.append( + problem(variable, "contains", linear, ~np.isfinite(slope)) + ) + else: + slope = change + containing = perturbed & _contains(change, expected) + containing = containing | ~np.isfinite(slope) + if containing.any(): + problems.append( + problem(variable, "contains", containing, ~np.isfinite(slope)) + ) + elif is_tax: + moved = _moves(change, expected) + if moved.any(): + problems.append(problem(variable, "moves", moved, ~np.isfinite(change))) + else: + rises = _rises(change, slack) + if rises.any(): + problems.append(problem(variable, "rises", rises, ~np.isfinite(change))) + partition_coefficient = float(sum(responses.values())) + model_coefficient = None + if model_change is not None: + model_coefficient = ( + float(np.sum(weights * model_change)) / added if added else 0.0 + ) + heads_change = sum(head_changes.values()) + if declared_double_count: + heads_change = heads_change + expected + not_finite = ~np.isfinite(model_change) | ~np.isfinite(heads_change) + beyond = np.abs(model_change - heads_change) > _slack(model_scale) + mismatch = beyond | not_finite + if mismatch.any(): + problems.append(problem(total, "model_total", mismatch, not_finite)) + return { + "responses": responses, + "coefficient": partition_coefficient, + "model_coefficient": model_coefficient, + "own_problem": own_problem, + "problems": problems, + "screens": screens, + } + + +def _perturb( + sim, variable, year, baseline, added, monthly, hold=(), held_at=None, touched=None +): + discard_formula_results(sim) + if monthly: + for month, values in baseline[variable]: + _set_guard_value(sim, variable, month, values + added / 12, touched=touched) + else: + _set_guard_value( + sim, variable, year, baseline[variable] + added, touched=touched + ) + held_at = baseline if held_at is None else held_at + for held in hold: + if isinstance(held_at[held], list): + for month, values in held_at[held]: + _set_guard_value(sim, held, month, values, touched=touched) + else: + _set_guard_value(sim, held, year, held_at[held], touched=touched) + + +def _capture(sim, variables, year, layout) -> dict: + out = {} + for v in variables: + if isinstance(layout[v], list): + out[v] = [ + (month, _raw(sim, v, month).astype(np.float64)) + for month, _ in layout[v] + ] + else: + out[v] = _raw(sim, v, year).astype(np.float64) + return out + + +def breaks_on_slope(kind, where, expected, slack, slope): + if kind == "contains": + return where & _contains(slope, expected, allowances=2) + if kind == "rises": + return where & _rises(slope, 2 * slack) + if kind == "moves": + return where & _moves(slope, expected, allowances=2) + return where + + +def still_breaks(kind, where, expected, slack, first, second=None): + if kind == "contains": + if second is not None: + return where & _contains(second - first, expected, allowances=2) + return where & _contains(first, expected) + if kind == "rises": + return where & _rises(first, slack) + if kind == "moves": + return where & _moves(first, expected) + return where + + +def _held_breaks(kind, where, expected, slack, first, second, slope_held, unjudged): + held_at_baseline = still_breaks(kind, where, expected, slack, first, second) + judged = where & ~unjudged + return held_at_baseline | breaks_on_slope(kind, judged, expected, slack, slope_held) + + +def _held_nan(kind, first, second, slope_held, unjudged): + nan = ~np.isfinite(first) | ~np.isfinite(slope_held) & ~unjudged + if kind == "contains" and second is not None: + nan = nan | ~np.isfinite(second) + return nan + + +DIRECT_KINDS = ("contains", "rises", "moves") + + +def counted_once( + sim, + partition: Partition, + year: int, + tolerance: float, + snapshot: Optional[Mapping] = None, + active: Iterable[str] = (), + variables_to_check: Optional[Iterable[str]] = None, +) -> dict: + if not discard_formula_results(sim): + return { + "status": "skipped", + "reason": "this policyengine-core has no way to discard formula results", + } + snapshot = _guard_snapshot(sim, snapshot, partition.component_variables()) + active = set(active) + definitions = sim.tax_benefit_system.variables + totals = [t for t in partition.totals if partition.heads_in(t, active)] + heads_of = {t: [h.variable for h in partition.heads_in(t, active)] for t in totals} + all_heads = list(dict.fromkeys((v for t in totals for v in heads_of[t]))) + model_totals = [t for t in totals if t in definitions] + hw = _raw(sim, "household_weight", year).astype(np.float64) + heads0 = _head_arrays(sim, year, all_heads) + model0 = {t: _raw(sim, t, year).astype(np.float64) for t in model_totals} + baseline = {} + for variable in partition.component_variables(): + if definitions[variable].definition_period == "month": + baseline[variable] = [ + (month, _raw(sim, variable, month).astype(np.float64)) + for month in _months(year) + ] + else: + baseline[variable] = _raw(sim, variable, year).astype(np.float64) + declared = {dc.variable: dc for dc in partition.double_counts} + head_by_variable = {h.variable: h for h in partition.heads} + selected = None if variables_to_check is None else set(variables_to_check) + results, double_count_results = ([], []) + touched = set() + try: + for variable in partition.component_variables(): + if selected is not None and variable not in selected: + continue + definition = definitions[variable] + entity = definition.entity.key + in_totals = [ + t for t in totals if variable in partition.components(t, active) + ] + record = { + "variable": variable, + "totals": in_totals, + "coefficients": {}, + "model_coefficients": {}, + "responses": {}, + "problems": [], + "mediated": [], + "heads_held_skipped": False, + "heads_not_held": {}, + "households_not_held": {}, + "units": 0, + "status": "pass", + "detail": "", + } + if definition.value_type is not float: + record.update(status="skipped", detail="not a money variable") + results.append(record) + continue + monthly = definition.definition_period == "month" + year_values = ( + sum((values for _, values in baseline[variable])) + if monthly + else baseline[variable] + ) + nonzero = year_values != 0 + zero_baseline = not bool(nonzero.any()) + mask = np.ones_like(nonzero) if zero_baseline else nonzero + added = mask.astype(np.float64) + expected = _household(sim, added, entity) + record["units"] = int(round(float(np.sum(expected[hw > 0])))) + if not float(np.sum(hw * expected)) > 0: + record.update( + status="skipped", + detail="non-zero only in households with no weight", + ) + results.append(record) + continue + own = declared[variable].contained_in if variable in declared else variable + own_head = head_by_variable[own] + needed = list(dict.fromkeys((v for t in in_totals for v in heads_of[t]))) + readers_of = { + total: [ + v + for v in heads_of[total] + if own_head.id in head_by_variable[v].reads + ] + for total in in_totals + } + _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + heads1 = _head_arrays(sim, year, needed) + model1 = { + t: _raw(sim, t, year).astype(np.float64) + for t in in_totals + if t in model0 + } + may_respond = { + v + for total in in_totals + for v in heads_of[total] + if v != own + and ( + v in readers_of[total] + or (zero_baseline and (not partition.is_tax_total(total))) + ) + } + heads2 = None + if may_respond: + _perturb( + sim, variable, year, baseline, 2 * added, monthly, touched=touched + ) + heads2 = _head_arrays(sim, year, sorted(may_respond)) + problems, screens = ([], []) + for total in in_totals: + verdict = judge_total( + total=total, + is_tax=partition.is_tax_total(total), + component=variable, + own_variable=own, + declared_double_count=variable in declared + and total in declared[variable].totals, + zero_baseline=zero_baseline, + weights=hw, + expected=expected, + head_changes={v: heads1[v] - heads0[v] for v in heads_of[total]}, + head_scales={v: heads0[v] for v in heads_of[total]}, + model_change=model1[total] - model0[total] + if total in model1 + else None, + model_scale=model0.get(total), + allowed_readers=readers_of[total], + second_changes=None + if heads2 is None + else {v: heads2[v] - heads0[v] for v in heads2}, + ) + record["coefficients"][total] = verdict["coefficient"] + if verdict["model_coefficient"] is not None: + record["model_coefficients"][total] = verdict["model_coefficient"] + record["responses"][total] = { + v: r + for v, r in verdict["responses"].items() + if not abs(r) <= tolerance + } + problems += verdict["problems"] + screens += verdict["screens"] + if verdict["own_problem"] and variable not in declared: + problems.append(verdict["own_problem"]) + mediated = [] + perturbed_units = expected > 0 + for head in dict.fromkeys( + [p["head"] for p in problems if p["kind"] in DIRECT_KINDS] + + [screen["head"] for screen in screens] + ): + held = [v for v in all_heads if v not in (variable, own, head)] + _perturb( + sim, + variable, + year, + baseline, + added, + monthly, + hold=held, + touched=touched, + ) + first = _head_arrays(sim, year, [head])[head] - heads0[head] + second = None + if any( + ( + p["head"] == head and p["kind"] == "contains" + for p in problems + screens + ) + ): + _perturb( + sim, + variable, + year, + baseline, + 2 * added, + monthly, + hold=held, + touched=touched, + ) + second = _head_arrays(sim, year, [head])[head] - heads0[head] + for v in held: + _restore(sim, v, snapshot) + _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + at_first = _capture(sim, held, year, baseline) + head_first = _head_arrays(sim, year, [head])[head] + held_at, unheld_in = ({}, {}) + for v in held: + units = _not_finite_units(at_first[v]) + held_at[v] = at_first[v] + if units.any(): + held_at[v] = _held_values(at_first[v], baseline[v], units) + entity_key = definitions[v].entity.key + unheld_in[v] = _household(sim, units, entity_key) > 0 + _perturb( + sim, + variable, + year, + baseline, + 2 * added, + monthly, + hold=held, + held_at=held_at, + touched=touched, + ) + slope_held = _head_arrays(sim, year, [head])[head] - head_first + for v in held: + _restore(sim, v, snapshot) + not_held = list(unheld_in) + unjudged = np.zeros(len(hw), dtype=bool) + for v in not_held: + unjudged = unjudged | unheld_in[v] + if not_held: + record["heads_not_held"][head] = not_held + record["households_not_held"][head] = int( + np.count_nonzero(unjudged & (hw > 0)) + ) + + def unheld(where): + return [v for v in not_held if bool((unheld_in[v] & where).any())] + + slack = _slack(heads0[head]) + kept = [] + for p in problems: + if p["head"] != head or p["kind"] not in DIRECT_KINDS: + kept.append(p) + continue + breaks = _held_breaks( + p["kind"], + p["where"], + expected, + slack, + first, + second, + slope_held, + unjudged, + ) + rests = p["where"] & unjudged & ~breaks + direct = breaks | rests + cleared = p["where"] & ~direct + if cleared.any(): + via = [ + v + for v in needed + if v not in (variable, own, head) + and bool( + ( + np.abs(heads1[v] - heads0[v])[cleared] + > _slack(heads0[v])[cleared] + ).any() + ) + ] + nan = cleared & p["nan"] + mediated.append( + { + "total": p["total"], + "head": head, + "kind": p["kind"], + "households": int(np.count_nonzero(cleared & (hw > 0))), + "via": via, + "not_finite": bool(nan.any()), + "not_finite_weighted": bool((nan & (hw > 0)).any()), + } + ) + if direct.any(): + kept.append( + _problem( + p["total"], + head, + p["kind"], + variable, + direct, + hw, + perturbed_units, + p["nan"] + | _held_nan( + p["kind"], first, second, slope_held, unjudged + ), + unheld(rests), + rests, + ) + ) + for screen in screens: + if screen["head"] != head: + continue + confirmed = _held_breaks( + "contains", + screen["where"], + expected, + slack, + first, + second, + slope_held, + unjudged, + ) + unconfirmed = screen["where"] & unjudged & ~confirmed + nan = _held_nan("contains", first, second, slope_held, unjudged) + if confirmed.any(): + not_held_there, rests = ((), None) + same = next( + ( + k + for k in kept + if (k["total"], k["head"], k["kind"]) + == (screen["total"], head, "contains") + ), + None, + ) + if same is not None: + kept.remove(same) + confirmed = confirmed | same["where"] + not_held_there, rests = ( + same["heads_not_held"], + same["unjudged"], + ) + kept.append( + _problem( + screen["total"], + head, + "contains", + variable, + confirmed, + hw, + perturbed_units, + nan if same is None else nan | same["nan"], + not_held_there, + rests, + ) + ) + if unconfirmed.any(): + kept.append( + _problem( + screen["total"], + head, + "unconfirmed", + variable, + unconfirmed, + hw, + perturbed_units, + nan, + unheld(unconfirmed), + unconfirmed, + ) + ) + problems = kept + problems = [ + {k: v for k, v in p.items() if k not in ("where", "nan", "unjudged")} + for p in problems + ] + record["mediated"] = mediated + record["heads_held_skipped"] = bool(record["heads_not_held"]) + if variable in declared: + dc = declared[variable] + container_change = heads1[own] - heads0[own] + k = float(np.sum(hw * container_change)) / float(np.sum(hw * expected)) + finite = bool(np.isfinite(container_change).all()) + present = finite and ( + not bool( + ( + np.abs(container_change - expected) > _slack(heads0[own]) + ).any() + ) + ) + row = { + "variable": variable, + "contained_in": dc.contained_in, + "coefficient": k, + "issue": dc.issue, + "status": "pass" if present else "fail", + "not_finite": not finite, + "detail": "" + if present + else f"{dc.contained_in} moves by {k:.4f} per unit of {variable}: the model no longer counts it twice, so remove the declaration and give it its own head", + } + if not finite: + row["detail"] = ( + f"{dc.contained_in} is not finite when {variable} rises, so the check cannot show that {dc.contained_in} still contains {variable}" + ) + double_count_results.append(row) + if problems: + record.update( + status="fail", + problems=problems, + detail="; ".join((p["message"] for p in problems)), + ) + else: + record["detail"] = "; ".join( + ( + not_held_note( + variable, head, heads, record["households_not_held"][head] + ) + for head, heads in record["heads_not_held"].items() + ) + ) + results.append(record) + _restore(sim, variable, snapshot) + finally: + for variable in sorted(touched): + _restore(sim, variable, snapshot) + discard_formula_results(sim) + heads_again = _head_arrays(sim, year, all_heads) + reproduces = all((np.array_equal(heads_again[v], heads0[v]) for v in all_heads)) + return { + "status": "ran", + "year": year, + "tolerance": tolerance, + "components": results, + "double_counts": double_count_results, + "reset_reproduces_baseline": bool(reproduces), + } + + +def publishable_guard(guard: dict) -> dict: + out = dict(guard) + components = [] + for row in guard.get("components") or []: + row = dict(row) + few_units = small(row.get("units") or 0) + if few_units: + row["units"] = None + for key in ("responses", "coefficients", "model_coefficients"): + if key in row: + row[key] = {} + for key in ("problems", "mediated"): + cleaned = [] + for item in row.get(key) or []: + item = dict(item) + if few_units or small(item.get("households") or 0): + item.pop("share", None) + if small(item.get("households") or 0): + item["households"] = None + if small(item.get("households_not_held") or 0): + item["households_not_held"] = None + cleaned.append(item) + row[key] = cleaned + if "households_not_held" in row: + row["households_not_held"] = { + head: None if small(count or 0) else count + for head, count in row["households_not_held"].items() + } + components.append(row) + if "components" in guard: + out["components"] = components + units = { + row.get("variable"): row.get("units") for row in guard.get("components") or [] + } + double_counts = [] + for row in guard.get("double_counts") or []: + row = dict(row) + if small(units.get(row.get("variable")) or 0): + row["coefficient"] = None + if row.get("status") != "pass" and (not row.get("not_finite")): + row["detail"] = ( + f"{row['contained_in']} no longer moves one for one with {row['variable']} (fewer than {MIN_CELL} units): the model no longer counts it twice, so remove the declaration and give it its own head" + ) + double_counts.append(row) + if "double_counts" in guard: + out["double_counts"] = double_counts + return out + + +def record_guard_checks(rec, guard: dict) -> None: + for result in guard.get("components", []): + check_id = f"counted_once:{result['variable']}" + label = f"Raising {result['variable']} by £1 raises its own head by £1, moves no other tax head and raises no other benefit" + if result["status"] == "skipped": + rec.check( + check_id, + label, + None, + result["detail"], + section="counted_once", + scope="", + ) + elif result["status"] == "pass": + rec.check( + check_id, + label, + True, + section="counted_once", + scope="", + note=result["detail"], + ) + for item in result.get("problems") or []: + rec.check( + check_id, + label, + False, + item["message"], + section="counted_once", + scope=f"{item['total']}:{item['head']}:{item['kind']}", + ) + for result in publishable_guard(guard).get("double_counts", []): + rec.check( + f"double_count_present:{result['variable']}", + f"{result['variable']} is still contained in {result['contained_in']}", + result["status"] == "pass", + result["detail"], + section="counted_once", + ) + if "reset_reproduces_baseline" in guard: + rec.check( + "counted_once:reset_reproduces_baseline", + "The counted-once check leaves the baseline unchanged", + bool(guard["reset_reproduces_baseline"]), + "recomputed baseline totals differ from the first run", + section="counted_once", + ) + rec.check( + "counted_once:ran", + "The counted-once check ran", + guard.get("status") == "ran", + guard.get("reason", ""), + severity="warn", + section="counted_once", + ) + + +def _finite(value) -> bool: + return isinstance(value, (int, float)) and math.isfinite(value) + + +def _guard(summary) -> str: + guard = (summary.get("head") or {}).get("guard") or {} + if guard.get("status") != "ran": + return f"Counted-once check not run: {guard.get('reason', 'unknown reason')}." + components = guard.get("components", []) + tested = [c for c in components if c["status"] != "skipped"] + failed = [c for c in tested if c["status"] == "fail"] + skipped = [c for c in components if c["status"] == "skipped"] + lines = [ + f"The check raised each of {len(tested)} components by £1 per unit in {fiscal_year_label(guard['year'])} and read every head of its totals household by household: its own head must rise by exactly £1, no other tax head may move, no other benefit may rise, and the model's own total must move by what its heads moved. A response that is not finite breaks every rule. The check measures a head that breaks a rule twice more: once holding every other head at its baseline value, and once holding every other head at its value after the first £1 while the component rises again. The head fails where either measurement still shows the response. The check cannot hold a head at a value that is not finite. In each unit where another head is not finite after the first £1, it holds that head at its baseline value, and the second measurement judges no household the unit belongs to. There a head fails wherever it first broke a rule, or wherever the check could not confirm whether its response contains the component. {len(failed)} failed and the check skipped {len(skipped)}." + ] + for c in failed: + problems = c.get("problems") or [{"message": c.get("detail", "")}] + lines.append( + f"- `{c['variable']}`: " + "; ".join((p["message"] for p in problems)) + ) + for c in skipped: + lines.append(f"- `{c['variable']}` skipped: {c.get('detail', '')}") + code = "`{}`".format + for c in tested: + counts = c.get("households_not_held") or {} + for head, heads in (c.get("heads_not_held") or {}).items(): + note = not_held_note(c["variable"], head, heads, counts.get(head), code) + lines.append(f"- `{c['variable']}`: {note}") + for c in tested: + for m in c.get("mediated") or []: + moved = ", ".join((code(v) for v in m["via"])) or "none" + text = problem_text( + "mediated", + code(m["head"]), + code(c["variable"]), + m.get("households"), + m.get("not_finite", False), + m.get("not_finite_weighted", True), + ) + lines.append( + f"- `{c['variable']}`: {text} (other heads that moved there: {moved}; {m['total']}; not a failure)" + ) + for dc in guard.get("double_counts", []): + if dc.get("not_finite"): + lines.append( + f"- declared double count `{dc['variable']}`: `{dc['contained_in']}` is NOT FINITE when `{dc['variable']}` rises, so the check cannot show that `{dc['contained_in']}` still contains `{dc['variable']}` ({dc['issue']})" + ) + continue + state = "still contained" if dc["status"] == "pass" else "NO LONGER contained" + coefficient = ( + f"coefficient {dc['coefficient']:.3f}" + if _finite(dc.get("coefficient")) + else "coefficient not shown, fewer than 10 units" + ) + lines.append( + f"- declared double count `{dc['variable']}` is {state} in `{dc['contained_in']}` ({coefficient}; {dc['issue']})" + ) + return "\n".join(lines) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py index d33dc14140..db5fec11e5 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard.py @@ -776,12 +776,13 @@ def test_two_heads_that_turn_not_finite_fail_by_name(response, route): assert result["reset_reproduces_baseline"] -def core_counted_once(formulas, part, monthly): +def core_counted_once(formulas, part, monthly, **options): """counted_once on a policyengine-core simulation of one household. ``formulas`` maps each head to None (an input, 5 in every period) or a function of a reader by name and the period. Heads are monthly or - yearly; gov_tax and household_weight are yearly. + yearly; gov_tax and household_weight are yearly. ``options`` go to + counted_once. """ from policyengine_core.country_template import CountryTaxBenefitSystem from policyengine_core.country_template.entities import Household @@ -829,7 +830,7 @@ def household_variable(name, formula=None, default=0.0, definition_period=None): }, ) with np.errstate(invalid="ignore"): - return sim, counted_once(sim, part, YEAR, 1e-6) + return sim, counted_once(sim, part, YEAR, 1e-6, **options) @pytest.mark.parametrize("monthly", [False, True], ids=["yearly", "monthly"]) diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py new file mode 100644 index 0000000000..01027184cf --- /dev/null +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_guard_baseline.py @@ -0,0 +1,884 @@ +"""The counted-once check where a head, a model total or a component is not +finite at baseline (#2773), on the fake simulations of +``test_aggregate_impact_guard`` and on policyengine-core. + +Such a value has no change to read. The check names each variable it could +not read once, as a failure of a ``finite:`` check; it fails no component for +it, never gives the engine NaN, and judges every other reading as before. +``counted_once_base_reference`` holds the check as it stood before: every +model whose heads, totals and checked components are finite at baseline must +publish exactly what it published. +""" + +import json +from types import SimpleNamespace + +import counted_once_base_reference as reference +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st +from test_aggregate_impact_guard import ( + GATED, + HEADS_OF, + MEDIATED_NAN, + MEDIATED_NAN_READS, + OVERLAP, + READS, + SHAPES, + YEAR, + EntitySim, + FakeSim, + by_variable, + core_counted_once, + declared_reads, + gated_taxes, + household_by_household, + kinds, + mediated_in_two_totals, + messages, + model, + overlap_model, + partition, + run_not_finite, + shapes_formulas, + two_tax_totals, +) + +from policyengine_uk.utils.aggregate_impact import guard, measure, render +from policyengine_uk.utils.aggregate_impact.compare import ( + ALWAYS_BLOCKING_PREFIXES, + classify_check, +) + +NOT_FINITE_VALUES = [np.nan, np.inf, -np.inf] + + +def named(result): + """{variable: head id} of what the result says is not finite at baseline.""" + return { + item["variable"]: item["head"] + for item in result.get("not_finite_at_baseline", []) + } + + +def note(variable, where="fewer than 10 households"): + return ( + f"{variable} is not finite at baseline in {where}, so the counted-once " + "check could not confirm it there" + ) + + +def recorded(result): + rec = measure.Recorder() + measure.record_guard_checks(rec, result) + return rec.checks + + +# tax_a is sqrt(-1): NaN at baseline, whatever tax_b does. +NAN_TAX_A = { + "tax_b": None, + "tax_a": lambda f, period: np.sqrt(-1 + 0 * f("tax_b")), +} + + +@pytest.mark.parametrize("monthly", [False, True], ids=["yearly", "monthly"]) +def test_a_component_nan_at_baseline_is_named_and_never_given_to_the_engine( + monthly, +): + """Issue #2773, case 1, on policyengine-core. Raising tax_a, NaN at + baseline, gave the engine NaN, and the check stopped with "ValueError: + Unable to set value for variable "tax_a", as the input contains NaN + values." Now the check raises no unit where tax_a is not finite, so it + skips tax_a, which has no other unit, and passes tax_b. It names tax_a, + and gov_tax, which adds it, once each.""" + sim, result = core_counted_once(NAN_TAX_A, partition(["tax_a", "tax_b"]), monthly) + rows = by_variable(result) + assert list(rows) == ["tax_a", "tax_b"] + assert rows["tax_a"]["status"] == "skipped" + assert rows["tax_a"]["detail"] == ( + "the check could raise it only in households with no weight or where it " + "is not finite at baseline" + ) + assert rows["tax_b"]["status"] == "pass" and rows["tax_b"]["detail"] == "" + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert [item["message"] for item in result["not_finite_at_baseline"]] == [ + note("tax_a"), + note("gov_tax"), + ] + assert result["reset_reproduces_baseline"] + assert np.isnan(sim.calculate("tax_a", YEAR)).all() + + +@pytest.mark.parametrize("monthly", [False, True], ids=["yearly", "monthly"]) +def test_holding_a_head_nan_at_baseline_raises_nothing(monthly): + """Issue #2773, case 2: tax_c is tax_b, so it moves with tax_b. To measure + tax_c again, the check held tax_a at its baseline, NaN, and the engine + raised the same ValueError. Now it leaves tax_a to its formula, which + gives it back NaN in every period, so tax_a is held at its baseline and + the check finds tax_c moving with tax_b, as it does.""" + formulas = {**NAN_TAX_A, "tax_c": lambda f, period: f("tax_b")} + _, result = core_counted_once( + formulas, + partition(["tax_a", "tax_b", "tax_c"]), + monthly, + variables_to_check=["tax_b"], + ) + row = by_variable(result)["tax_b"] + assert messages(row) == { + ("tax_c", "moves"): ( + "gov_tax: tax_c moves when tax_b does in fewer than 10 households" + ) + } + (problem,) = row["problems"] + assert problem["heads_not_held"] == [] and problem["households_not_held"] == 0 + assert "heads_not_held_at_baseline" not in problem + assert not problem["not_finite"] and not row["heads_held_skipped"] + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert result["reset_reproduces_baseline"] + + +def test_a_head_nan_at_baseline_fails_no_component_it_ignores(): + """Issue #2773, cases 3 and 4. Checking tax_b alone, the check read tax_a's + change as NaN - NaN and failed tax_b with "gov_tax: tax_a moves, or is + not finite, when tax_b does in fewer than 10 households" and "gov_tax: + the model's gov_tax moves by other than its heads, or is not finite, when + tax_b does in fewer than 10 households", and its reset check returned + False. Now no rule reads tax_a, or gov_tax, which adds it, in that + household: tax_b passes, its own head rising by exactly 1. No response or + coefficient comes from a household that cannot be read, and the reset + check finds tax_a NaN where it started.""" + _, result = core_counted_once( + NAN_TAX_A, + partition(["tax_a", "tax_b"]), + False, + variables_to_check=["tax_b"], + ) + (row,) = result["components"] + assert row["status"] == "pass" and row["problems"] == [] + assert row["responses"] == {"gov_tax": {"tax_b": 1.0}} + assert row["coefficients"] == {"gov_tax": 1.0} + assert row["model_coefficients"] == {} + assert result["reset_reproduces_baseline"] is True + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + + +class LastComputationSim(FakeSim): + """FakeSim whose ``watched`` variable takes ``after`` the ``at``-th time + it is computed: with ``at`` the count of a run that changed nothing, the + final recomputation, which the reset check reads.""" + + def __init__(self, formulas, inputs, weights, watched, after=None, at=None): + super().__init__(formulas, inputs, weights) + self.watched, self.after, self.at, self.computations = watched, after, at, 0 + + def calculate(self, variable, period, unweighted=True, map_to=None): + key = (variable, str(period)) + if variable == self.watched and key not in self.inputs | self.cache: + self.computations += 1 + if self.computations == self.at: + self.cache[key] = np.asarray(self.after, dtype=float) + return super().calculate(variable, period, unweighted, map_to) + + +@pytest.mark.parametrize( + "before, after, same", + [ + ([np.nan, 1.0], [np.nan, 1.0], True), + ([np.inf, 1.0], [np.inf, 1.0], True), + ([1.0, 1.0], [np.nan, 1.0], False), + ([np.nan, 1.0], [1.0, np.nan], False), + ([np.nan, 1.0], [0.0, 1.0], False), + ([np.inf, 1.0], [-np.inf, 1.0], False), + ([np.nan, 1.0], [np.nan, 1.5], False), + ], +) +def test_the_reset_check_matches_nan_only_with_nan_in_the_same_place( + before, after, same +): + """The reset check treats NaN as equal to NaN in the same household and to + nothing else, so a value that turns NaN after the reset, or a NaN that + moves, still counts as a change.""" + formulas = { + "tax_b": lambda f: 5.0 + 0 * f("x"), + "tax_a": lambda f: np.array(before) + 0 * f("x"), + "gov_tax": lambda f: f("tax_a") + f("tax_b"), + } + part = partition(["tax_a", "tax_b"]) + + def check(sim): + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(invalid="ignore"): + return guard.counted_once(sim, part, YEAR, 1e-6, snapshot) + + probe = LastComputationSim(formulas, {"x": (1, 1)}, (1, 1), "tax_a") + assert check(probe)["reset_reproduces_baseline"] + sim = LastComputationSim( + formulas, {"x": (1, 1)}, (1, 1), "tax_a", after, probe.computations + ) + assert check(sim)["reset_reproduces_baseline"] is same + + +# Three households of weights 1.5, 2 and 0.5; ``FIRST`` marks the first. +WEIGHTS = (1.5, 2.0, 0.5) +FIRST = np.array([True, False, False]) + + +def run_three(formulas, part, **options): + sim = FakeSim(formulas, {"x": (1000, 2000, 3000)}, WEIGHTS) + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(divide="ignore", invalid="ignore"): + return guard.counted_once(sim, part, YEAR, 1e-6, snapshot, **options) + + +@pytest.mark.parametrize("bad", NOT_FINITE_VALUES) +def test_a_head_not_finite_in_one_household_is_judged_in_the_others(bad): + """tax_a contains tax_b, and is not finite at baseline in the first + household. The check fails tax_b for tax_a in the other two, with their + share of the weight, and reads neither tax_a nor gov_tax in the first.""" + formulas = model(contains=True) + formulas["tax_a"] = lambda f: np.where(FIRST, bad, f("tax_b") + 0 * f("x")) + result = run_three(formulas, partition(["tax_a", "tax_b"])) + row = by_variable(result)["tax_b"] + assert messages(row) == { + ("tax_a", "moves"): ( + "gov_tax: tax_a moves when tax_b does in fewer than 10 households" + ) + } + (problem,) = row["problems"] + assert problem["households"] == 2 and problem["share"] == pytest.approx(2.5 / 4) + assert row["responses"]["gov_tax"] == {"tax_a": 1.0, "tax_b": 1.0} + assert row["model_coefficients"] == {"gov_tax": 2.0} + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert result["not_finite_at_baseline"][0]["households"] == 1 + assert result["reset_reproduces_baseline"] + + +@pytest.mark.parametrize("bad", NOT_FINITE_VALUES) +def test_a_component_not_finite_in_one_household_is_checked_in_the_others(bad): + """tax_a is not finite at baseline in the first household, and tax_c + holds tax_a, or 3 where tax_a is not finite. Raising tax_a, the check + sets the first household's tax_a to 0, which moves tax_c there by -3; + it judges nothing there, and fails tax_a for tax_c in the other two.""" + formulas = model(contains=False) + formulas["tax_a"] = lambda f: np.where(FIRST, bad, 0.1 * f("x")) + formulas["tax_c"] = lambda f: np.where(np.isfinite(f("tax_a")), f("tax_a"), 3.0) + formulas["gov_tax"] = lambda f: f("tax_a") + f("tax_b") + f("tax_c") + result = run_three( + formulas, partition(["tax_a", "tax_b", "tax_c"]), variables_to_check=["tax_a"] + ) + (row,) = result["components"] + assert row["units"] == 2 + assert kinds(row) == {("tax_c", "moves")} + (problem,) = row["problems"] + assert problem["households"] == 2 and problem["share"] == 1.0 + assert named(result) == {"tax_a": "tax_a", "gov_tax": None} + assert result["reset_reproduces_baseline"] + + +def mediated_with(tax_n): + """tax_a responds to tax_b only through tax_d, which declares its read of + tax_b; tax_n, a tax no head reads, is ``tax_n``.""" + return { + "tax_b": lambda f: 5.0 + 0 * f("x"), + "tax_d": lambda f: 0.2 * f("tax_b"), + "tax_a": lambda f: 0.5 * f("tax_d"), + "tax_n": tax_n, + "gov_tax": lambda f: f("tax_a") + f("tax_b") + f("tax_d") + f("tax_n"), + } + + +MEDIATED = partition(["tax_b", "tax_d", "tax_a", "tax_n"], reads={"tax_d": ["tax_b"]}) + + +@pytest.mark.parametrize("bad", NOT_FINITE_VALUES) +def test_a_held_head_not_finite_at_baseline_is_held_by_its_formula(bad): + """tax_n, a tax no head reads, is not finite at baseline in the first + household. Its formula gives it back its baseline in every held + measurement, so the check holds it exactly and clears tax_a, which + responds only through tax_d, in all three households: tax_b's result is + the one it gets with tax_n finite.""" + result = run_three( + mediated_with(lambda f: np.where(FIRST, bad, 7.0)), + MEDIATED, + variables_to_check=["tax_b"], + ) + finite = run_three( + mediated_with(lambda f: 7.0 + 0 * f("x")), + MEDIATED, + variables_to_check=["tax_b"], + ) + (row,) = result["components"] + assert row["status"] == "pass" and row["mediated"][0]["households"] == 3 + assert row == finite["components"][0] + assert named(result) == {"tax_n": "tax_n", "gov_tax": None} + assert "not_finite_at_baseline" not in finite + + +MOVES_WITH_TAX_B = ( + "the check could not hold tax_n, which is not finite at baseline in some " + "households and moves there with tax_b" +) + + +def test_a_held_head_that_moves_leaves_only_those_households_unjudged(): + """tax_n is NaN at baseline in the first household and tax_b in the + others, so it moves with tax_b there. Left to its formula in the held + measurements, it gives back its baseline only in the first household, + which the check judges: tax_a, carried by tax_d, is mediated there. In + the other two, tax_a's failure stays, and the check could not confirm + tax_d, a declared reader with a slope of 0.2; both name tax_n. tax_n + fails as it contains tax_b.""" + result = run_three( + mediated_with(lambda f: np.where(FIRST, np.nan, f("tax_b"))), + MEDIATED, + variables_to_check=["tax_b"], + ) + (row,) = result["components"] + assert messages(row) == { + ("tax_n", "moves"): ( + "gov_tax: tax_n moves when tax_b does in fewer than 10 households" + ), + ("tax_a", "moves"): ( + "gov_tax: tax_a moves when tax_b does in fewer than 10 households (in " + f"fewer than 10 of them {MOVES_WITH_TAX_B}, so it could not show that " + "tax_a responds only through other heads)" + ), + ("tax_d", "unconfirmed"): ( + "gov_tax: tax_d responds to tax_b in fewer than 10 households where " + f"{MOVES_WITH_TAX_B}, so it could not confirm whether tax_d contains " + "tax_b" + ), + } + assert {p["head"]: p["households"] for p in row["problems"]} == { + "tax_n": 2, + "tax_a": 2, + "tax_d": 2, + } + for problem in row["problems"]: + if problem["head"] != "tax_n": + assert problem["heads_not_held_at_baseline"] == ["tax_n"] + assert problem["households_not_held"] == 2 + (mediated,) = row["mediated"] + assert (mediated["head"], mediated["households"]) == ("tax_a", 1) + assert row["heads_not_held"] == {} and row["heads_held_skipped"] + + +@pytest.mark.parametrize( + "entity, people", + [ + ("person", [(0, 0), (1, 1), (1, 1)]), + ("benunit", [(0, 0), (1, 1), (2, 1)]), + ], +) +def test_units_that_cancel_within_a_household_still_count_as_moved(entity, people): + """tax_n, a head of people or benefit units, is NaN at baseline in the + first household. In the second it moves with tax_b, up in one unit and + down in the other, so the household's sum stays the same. The head still + moved, so the second household's failures stay and name tax_n.""" + formulas = { + "tax_b": lambda f: np.ones(2) * 5, + "tax_d": lambda f: 0.2 * f("tax_b"), + "tax_a": lambda f: 0.5 * f("tax_d"), + "tax_n": lambda f: np.array([np.nan, f("tax_b")[1], -f("tax_b")[1]]), + "gov_tax": lambda f: ( + f("tax_b") + + f("tax_d") + + f("tax_a") + + sim.map_result(f("tax_n"), entity, "household") + ), + } + sim = EntitySim(formulas, {"x": (1, 1)}, (1, 1), {"tax_n": entity}, people) + part = partition( + ["tax_b", "tax_d", "tax_a", "tax_n"], + reads={"tax_d": ["tax_b"]}, + entities={"tax_n": entity}, + ) + with np.errstate(all="ignore"): + result = guard.counted_once( + sim, + part, + YEAR, + 1e-6, + guard.snapshot_inputs(sim, part.component_variables()), + variables_to_check=("tax_b",), + ) + problems = by_variable(result)["tax_b"]["problems"] + assert {p["head"]: p["households"] for p in problems} == {"tax_a": 1, "tax_d": 1} + assert all(p["heads_not_held_at_baseline"] == ["tax_n"] for p in problems) + assert result["reset_reproduces_baseline"] + + +def test_a_problem_names_heads_the_check_could_not_hold_for_either_reason(): + """One household: ben_c turns NaN once tax_b rises, and tax_n, NaN at + baseline, turns 0. tax_a's failure names both, each with its reason.""" + formulas = mediated_with(lambda f: np.where(f("tax_b") > 5.5, 0.0, np.nan)) + formulas["ben_c"] = lambda f: np.sqrt(5 - f("tax_b")) + formulas["gov_spending"] = lambda f: f("ben_c") + part = partition( + ["tax_b", "tax_d", "tax_a", "tax_n"], ["ben_c"], reads={"tax_d": ["tax_b"]} + ) + _, result = run_not_finite(formulas, part, x=(1,), weights=(1,)) + row = by_variable(result)["tax_b"] + assert messages(row)[("tax_a", "moves")] == ( + "gov_tax: tax_a moves when tax_b does in fewer than 10 households (in " + "fewer than 10 of them the check could not hold ben_c, which is not " + "finite there once tax_b rises, and tax_n, which is not finite at " + "baseline in some households and moves there with tax_b, so it could " + "not show that tax_a responds only through other heads)" + ) + problem = next(p for p in row["problems"] if p["head"] == "tax_a") + assert problem["heads_not_held"] == ["ben_c"] + assert problem["heads_not_held_at_baseline"] == ["tax_n"] + assert row["heads_not_held"]["tax_a"] == ["ben_c"] + assert all(p["head"] != "tax_n" for p in row["problems"]) + + +def test_a_model_total_not_finite_at_baseline_is_named_and_fails_no_component(): + """gov_tax adds a NaN term in the first household that no head holds. The + check reads gov_tax in the other two only, fails no component for it, + names it, and records it under finite:metrics.""" + formulas = model(contains=False) + formulas["gov_tax"] = lambda f: f("tax_a") + f("tax_b") + np.where(FIRST, np.nan, 0) + result = run_three(formulas, partition(["tax_a", "tax_b"])) + assert all(row["status"] == "pass" for row in result["components"]) + assert named(result) == {"gov_tax": None} + checks = recorded(result) + assert checks["finite:metrics"]["failing"] == { + f"counted_once:{YEAR}:gov_tax": f"2026-27: {note('gov_tax')}" + } + assert checks["finite:metrics"]["label"] == "Every reported figure is finite" + + +def declared_double_count(container): + """tax_a contains tax_b, as the partition declares, and is ``container`` + of tax_b and x.""" + formulas = model(contains=True) + formulas["tax_a"] = container + return formulas, partition(["tax_a"], [], [("tax_b", "tax_a")]) + + +def test_a_container_not_finite_in_one_household_is_judged_in_the_others(): + formulas, part = declared_double_count( + lambda f: np.where(FIRST, np.nan, 0.1 * f("x") + f("tax_b")) + ) + result = run_three(formulas, part) + (row,) = result["double_counts"] + assert row["status"] == "pass" and row["coefficient"] == pytest.approx(1.0) + assert by_variable(result)["tax_b"]["status"] == "pass" + + +def test_a_container_not_finite_everywhere_skips_the_double_count(): + """The check cannot show that tax_a still contains tax_b, and says so; a + finite: check names tax_a.""" + formulas, part = declared_double_count(lambda f: np.nan + f("tax_b")) + result = run_three(formulas, part) + (row,) = result["double_counts"] + assert row["status"] == "skipped" and row["coefficient"] is None + assert row["detail"] == ( + "tax_a is not finite at baseline in every household with weight where " + "the check raised tax_b, so it cannot show that tax_a still contains tax_b" + ) + checks = recorded(result) + assert checks["double_count_present:tax_b"]["status"] == "skipped" + assert checks["finite:tax_a"]["status"] == "fail" + text = render._guard({"head": {"guard": measure.publishable_guard(result)}}) + assert ( + f"- declared double count `tax_b` not checked: {row['detail']} " + f"({row['issue']})" in text + ) + + +def test_a_double_count_not_finite_everywhere_is_skipped_and_named(): + """tax_b, the declared double count, is NaN at baseline everywhere: the + check skips it, says why, and names it under finite:metrics.""" + formulas, part = declared_double_count(lambda f: 0.1 * f("x") + f("tax_b")) + formulas["tax_b"] = lambda f: np.nan + 0 * f("x") + result = run_three(formulas, part) + assert by_variable(result)["tax_b"]["status"] == "skipped" + (row,) = result["double_counts"] + assert row["status"] == "skipped" and row["detail"] == ( + "the check could raise tax_b only in households with no weight or where " + "it is not finite at baseline, so it cannot show that tax_a still " + "contains tax_b" + ) + assert named(result) == {"tax_a": "tax_a", "gov_tax": None, "tax_b": None} + assert set(recorded(result)["finite:metrics"]["failing"]) == { + f"counted_once:{YEAR}:gov_tax", + f"counted_once:{YEAR}:tax_b", + } + + +def test_a_benefit_unit_not_finite_at_baseline_leaves_only_its_household(): + """ben_u, a benefit of each benefit unit, is NaN at baseline in the second + household's second unit; ben_w, a household benefit, adds each unit's + finite ben_u. Raising ben_u, the check judges neither the second + household nor ben_u's change there, and fails ben_u for ben_w in the + first and third households alone.""" + people = [(0, 0), (1, 1), (2, 1), (3, 2)] + bad = np.array([False, False, True, False]) + formulas = { + "tax_b": lambda f: 5.0 + 0 * f("x"), + "gov_tax": lambda f: f("tax_b"), + "ben_u": lambda f: np.where(bad, np.nan, 100.0), + "ben_w": lambda f: sim.map_result( + np.nan_to_num(f("ben_u")), "benunit", "household" + ), + "gov_spending": lambda f: ( + sim.map_result(f("ben_u"), "benunit", "household") + f("ben_w") + ), + } + sim = EntitySim(formulas, {"x": (1, 1, 1)}, (1, 2, 4), {"ben_u": "benunit"}, people) + part = partition(["tax_b"], ["ben_u", "ben_w"], entities={"ben_u": "benunit"}) + result = guard.counted_once( + sim, part, YEAR, 1e-6, guard.snapshot_inputs(sim, part.component_variables()) + ) + row = by_variable(result)["ben_u"] + assert row["units"] == 2 + (problem,) = row["problems"] + assert (problem["head"], problem["kind"], problem["households"]) == ( + "ben_w", + "rises", + 2, + ) + assert problem["share"] == 1.0 + assert result["not_finite_at_baseline"][0] == { + "variable": "ben_u", + "head": "ben_u", + "label": "ben_u", + "households": 1, + "message": note("ben_u"), + } + + +def test_the_named_result_blocks_whatever_the_base_did(): + """A head not finite at baseline fails its own finite: check in a scope + naming the year and the head, which a comparison blocks on even when + the base fails it too. No counted-once check fails for it.""" + _, result = core_counted_once(NAN_TAX_A, partition(["tax_a", "tax_b"]), False) + checks = recorded(result) + scope = f"counted_once:{YEAR}:tax_a" + assert checks["finite:tax_a"]["failing"] == {scope: f"2026-27: {note('tax_a')}"} + assert checks["finite:tax_a"]["label"] == "tax_a is finite for every unit" + assert checks["finite:tax_a"]["section"] == "finite" + assert all( + check["status"] != "fail" + for check_id, check in checks.items() + if check_id.startswith("counted_once:") + ) + for check_id, check in checks.items(): + if check["status"] == "fail": + assert check_id.startswith(ALWAYS_BLOCKING_PREFIXES) + assert ( + classify_check("fail", "fail", True, check["failing"], check["failing"]) + == "regression" + ) + + +@pytest.mark.parametrize("households", [3, 12]) +def test_the_published_guard_keeps_no_small_count_of_not_finite_households( + households, +): + """The published guard and the report keep a count of 10 or more + households, and say "fewer than 10" for fewer.""" + formulas = model(contains=False) + some = np.arange(12) < households + formulas["tax_a"] = lambda f: np.where(some, np.nan, 0.1 * f("x")) + sim = FakeSim(formulas, {"x": (1,) * 12}, (1,) * 12) + part = partition(["tax_a", "tax_b"]) + result = guard.counted_once( + sim, part, YEAR, 1e-6, guard.snapshot_inputs(sim, part.component_variables()) + ) + published = measure.publishable_guard(result) + shown = None if households < 10 else households + assert [item["households"] for item in published["not_finite_at_baseline"]] == [ + shown, + shown, + ] + where = "fewer than 10 households" if households < 10 else "12 households" + text = render._guard({"head": {"guard": published}}) + assert f"- {note('`tax_a`', where)}" in text.splitlines() + assert f"- {note('`gov_tax`', where)}" in text.splitlines() + + +# The property's extra heads: no head reads them, and each is not finite at +# baseline in any set of households (none, for a finite head). +EXTRA_TOTAL = {"tax_n": "gov_tax", "ben_n": "gov_spending"} + + +@st.composite +def extra_heads(draw): + shapes = draw(st.lists(SHAPES, min_size=1, max_size=3)) + extra = {} + for head in sorted(draw(st.sets(st.sampled_from(sorted(EXTRA_TOTAL))))): + where = draw( + st.lists(st.booleans(), min_size=len(shapes), max_size=len(shapes)) + ) + extra[head] = (np.array(where), draw(st.sampled_from(NOT_FINITE_VALUES))) + return shapes, extra + + +def with_extra_heads(shapes, extra, reads): + """The properties' model and partition, with ``extra`` heads.""" + formulas = shapes_formulas(shapes) + heads_of = {total: list(heads) for total, heads in HEADS_OF.items()} + for head, (where, value) in extra.items(): + formulas[head] = lambda f, where=where, value=value: np.where(where, value, 7.0) + heads_of[EXTRA_TOTAL[head]].append(head) + for total, heads in heads_of.items(): + formulas[total] = lambda f, heads=heads: sum(f(v) for v in heads) + part = partition(heads_of["gov_tax"], heads_of["gov_spending"], reads=reads) + return formulas, part + + +NAN_IN_FIRST = {"tax_n": (np.array([True, False]), np.nan)} + + +@settings(max_examples=40, deadline=None) +@given(case=extra_heads(), reads=READS) +@example(case=([MEDIATED_NAN, MEDIATED_NAN], NAN_IN_FIRST), reads=MEDIATED_NAN_READS) +@example( + case=( + [("half", "flat", "flat", "flat"), ("flat", "flat", "flat", "flat")], + {"ben_n": (np.array([True, True]), -np.inf)}, + ), + reads=[True, False, False, False, False, False], +) +def test_heads_not_finite_at_baseline_are_named_and_change_nothing_else(case, reads): + """For any heads, with any of the extra heads (which no head reads, and + which respond to nothing) not finite at baseline in any households, the + check returns, names exactly those heads, and the totals that add them, + in blocking finite: checks, and fails no component for them. In each + household it finds what it finds with those heads removed, except that + it reads no total that adds a head not finite there.""" + shapes, extra = case + households = len(shapes) + declared = declared_reads(reads) + bad = {head: where for head, (where, _) in extra.items() if where.any()} + formulas, part = with_extra_heads(shapes, extra, declared) + kept = {head: spec for head, spec in extra.items() if head not in bad} + formulas_without, part_without = with_extra_heads(shapes, kept, declared) + _, result = run_not_finite( + formulas, part, x=(1,) * households, weights=(1,) * households + ) + bad_totals = {EXTRA_TOTAL[head] for head in bad} + assert named(result) == { + **{head: head for head in bad}, + **{total: None for total in bad_totals}, + } + checks = recorded(result) + failing = { + c + for c, check in checks.items() + if check["status"] == "fail" and c.startswith(ALWAYS_BLOCKING_PREFIXES) + } + assert failing == {f"finite:{head}" for head in bad} | ( + {"finite:metrics"} if bad_totals else set() + ) + for row in result["components"]: + assert all(p["head"] not in bad for p in row["problems"]) + if row["variable"] in bad: + assert row["status"] != "fail" + assert row["heads_held_skipped"] == ( + bool(row["heads_not_held"]) + or any(p.get("heads_not_held_at_baseline") for p in row["problems"]) + ) + assert result["reset_reproduces_baseline"] + + found = household_by_household(formulas, part, households) + without = household_by_household(formulas_without, part_without, households) + for k in range(households): + here = {head for head, where in bad.items() if where[k]} + for component, before in without[k].items(): + after = found[k][component] + if not here: + assert after == before + continue + totals_here = {EXTRA_TOTAL[head] for head in here} + assert after == { + **before, + "problems": [ + p + for p in before["problems"] + if not (p[2] == "model_total" and p[0] in totals_here) + ], + } + + +class SetValuesSim(FakeSim): + """FakeSim that keeps every value given to ``set_input``. Like + policyengine-core, FakeSim refuses NaN but takes an infinite value.""" + + def __init__(self, formulas, inputs, weights): + super().__init__(formulas, inputs, weights) + self.set_values = [] + + def set_input(self, variable, period, values): + self.set_values.append(np.array(values, dtype=float)) + super().set_input(variable, period, values) + + +@settings(max_examples=40, deadline=None) +@given(case=extra_heads(), reads=READS) +def test_the_check_raises_and_holds_variables_only_at_finite_values(case, reads): + """Whatever is NaN or infinite at baseline, the check raises and holds + variables only at finite values. It also restores the supplied inputs, + which are finite here, so every value it gives the engine is finite.""" + shapes, extra = case + households = len(shapes) + formulas, part = with_extra_heads(shapes, extra, declared_reads(reads)) + sim = SetValuesSim(formulas, {"x": (1,) * households}, (1,) * households) + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(divide="ignore", invalid="ignore"): + guard.counted_once(sim, part, YEAR, 1e-6, snapshot) + assert sim.set_values + assert all(np.isfinite(values).all() for values in sim.set_values) + + +def published(check, record, publish, describe, formulas, part, x, weights): + """What one implementation publishes for a model, as JSON: the check's + result, the checks it records, the published guard and the report's + section.""" + sim = FakeSim(formulas, {"x": x}, weights) + snapshot = guard.snapshot_inputs(sim, part.component_variables()) + with np.errstate(divide="ignore", invalid="ignore"): + result = check(sim, part, YEAR, 1e-6, snapshot) + rec = measure.Recorder() + record(rec, result) + publishable = publish(result) + return json.dumps( + { + "result": result, + "checks": list(rec.checks.values()), + "published": publishable, + "report": describe({"head": {"guard": publishable}}), + } + ) + + +NOW = ( + guard.counted_once, + measure.record_guard_checks, + measure.publishable_guard, + render._guard, +) +BEFORE = ( + reference.counted_once, + reference.record_guard_checks, + reference.publishable_guard, + reference._guard, +) + + +def assert_publishes_as_before(formulas, part, x, weights): + assert published(*NOW, formulas, part, x, weights) == published( + *BEFORE, formulas, part, x, weights + ) + + +def stale_double_count(): + return declared_double_count(lambda f: 0.1 * f("x")) + + +# The guard tests' own models, on their three households. +MODELS = { + "containment": lambda: (model(contains=True), partition(["tax_a", "tax_b"])), + "means test": lambda: ( + model(contains=False), + partition(["tax_a"], ["ben_c", "ben_d"]), + ), + "rising benefit": lambda: ( + model(interaction=0.5), + partition(["tax_a"], ["ben_c", "ben_d"]), + ), + "declared double count": lambda: declared_double_count( + lambda f: 0.1 * f("x") + f("tax_b") + ), + "stale double count": stale_double_count, + "overlapping benefits": lambda: (overlap_model(), OVERLAP), + "overlap containing ib": lambda: (overlap_model(uc_contains_ib=1.0), OVERLAP), + "gated containment": lambda: ( + { + **gated_taxes(), + "ben_c": lambda f: 100 + 0 * f("x"), + "gov_spending": lambda f: f("ben_c"), + }, + GATED, + ), +} + + +@pytest.mark.parametrize("name", MODELS) +def test_every_finite_model_publishes_what_it_did_before(name): + """Differential against ``counted_once_base_reference``: with every head + and total finite at baseline, the check, the checks it records, the + published guard and the report say exactly what they said before, in + the same order.""" + formulas, part = MODELS[name]() + assert_publishes_as_before(formulas, part, (1000, 2000, 3000), (1.5, 2.0, 0.5)) + + +@pytest.mark.parametrize("nan_in", [(), (4,)], ids=["finite", "NaN once raised"]) +def test_twelve_households_in_two_totals_publish_what_they_did_before(nan_in): + assert_publishes_as_before( + mediated_in_two_totals(nan_in), two_tax_totals(), (1,) * 12, (1,) * 12 + ) + + +@settings(max_examples=60, deadline=None) +@given( + shapes=st.lists(SHAPES, min_size=1, max_size=3), + reads=READS, + weights=st.lists(st.sampled_from([0.0, 0.5, 1.0, 2.0]), min_size=3, max_size=3), +) +@example(shapes=[MEDIATED_NAN], reads=MEDIATED_NAN_READS, weights=[1.0, 1.0, 1.0]) +def test_finite_baselines_publish_what_they_did_before(shapes, reads, weights): + """Differential against ``counted_once_base_reference`` for the guard + tests' random heads, which are finite at baseline and may turn infinite + or NaN once raised, on up to three households of any weights.""" + households = len(shapes) + declared = declared_reads(reads) + part = partition(HEADS_OF["gov_tax"], HEADS_OF["gov_spending"], reads=declared) + assert_publishes_as_before( + shapes_formulas(shapes), + part, + (1,) * households, + tuple(weights[:households]), + ) + + +@settings(max_examples=100, deadline=None) +@given( + values=st.lists( + st.one_of( + st.floats(allow_nan=False, allow_infinity=False), + st.sampled_from(NOT_FINITE_VALUES), + ), + min_size=1, + max_size=6, + ), + monthly=st.booleans(), +) +def test_the_check_sets_finite_values_bit_for_bit_and_others_at_zero(values, monthly): + values = np.array(values) + layout = [("2026-01", values), ("2026-02", values[::-1])] if monthly else values + settable = guard._settable(layout) + pairs = zip(layout, settable) if monthly else [((None, values), (None, settable))] + for (_, before), (_, after) in pairs: + finite = np.isfinite(before) + assert np.isfinite(after).all() + assert (after[~finite] == 0).all() + assert before[finite].tobytes() == after[finite].tobytes() + + +def test_a_finite_held_head_keeps_the_words_it_had(): + assert guard._unheld_words(["ben_c"], "tax_b") == reference._unheld_words( + ["ben_c"], "tax_b" + ) + assert guard._unheld_words(["ben_c", "tax_g"], "tax_b") == ( + reference._unheld_words(["ben_c", "tax_g"], "tax_b") + ) + assert guard._unheld_words([], "tax_b", at_baseline=["tax_n", "tax_m"]) == ( + "the check could not hold tax_n and tax_m, one or more of which is not " + "finite at baseline in some households and moves there with tax_b" + ) diff --git a/policyengine_uk/tests/code_health/test_benefit_cap_lists_unique.py b/policyengine_uk/tests/code_health/test_benefit_cap_lists_unique.py new file mode 100644 index 0000000000..c4bb47b45b --- /dev/null +++ b/policyengine_uk/tests/code_health/test_benefit_cap_lists_unique.py @@ -0,0 +1,27 @@ +from collections import Counter + +import pytest + +from policyengine_uk.variables.gov.dwp.benefit_cap_welfare_benefits import ( + CLAIMANT_OR_PARTNER_BENEFITS, + FAMILY_BENEFITS, +) +from policyengine_uk.variables.gov.dwp.universal_credit.benefit_cap.uc_benefit_cap_welfare_benefits import ( + UC_FAMILY_BENEFITS, +) +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + FAMILY_GROSS_INCOME_BENEFITS, +) + + +@pytest.mark.parametrize( + "names", + [ + CLAIMANT_OR_PARTNER_BENEFITS + FAMILY_BENEFITS, + CLAIMANT_OR_PARTNER_BENEFITS + UC_FAMILY_BENEFITS, + FAMILY_GROSS_INCOME_BENEFITS, + ], + ids=["housing-benefit-cap", "universal-credit-cap", "non-dependant-family-income"], +) +def test_each_name_occurs_once(names): + assert not {name: n for name, n in Counter(names).items() if n != 1} diff --git a/policyengine_uk/tests/code_health/test_ci_test_shards.py b/policyengine_uk/tests/code_health/test_ci_test_shards.py index 0193a5b67f..cae882cdef 100644 --- a/policyengine_uk/tests/code_health/test_ci_test_shards.py +++ b/policyengine_uk/tests/code_health/test_ci_test_shards.py @@ -40,6 +40,9 @@ the least loaded shard, whatever order the table lists them in, so no shard's listed load exceeds another's by more than the longest test; the table parses and each entry names a test function that exists. +13. The tests of a file in ``SHARED_FIXTURE_FILES`` all run in one shard, listed + or not, at any number of shards. The deal gives the file the weight of its + longest test, and the bound in 12 holds with that weight. """ import ast @@ -245,6 +248,69 @@ def test_a_missing_table_leaves_every_test_to_the_hash(conftest, tmp_path): assert conftest.read_shard_durations(tmp_path / "absent.toml") == {} +# Invariant 13: a file with a costly module fixture runs in one shard. + +CERTIFIED = "policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py" + +_certified_ids = st.builds( + lambda name, params: f"{CERTIFIED}::{name}{params}", _identifier, _params +) + + +def test_the_certified_aggregate_tests_share_one_shard(conftest): + source = (REPO_ROOT / CERTIFIED).read_text(encoding="utf-8") + names = [ + node.name + for node in ast.parse(source).body + if isinstance(node, ast.FunctionDef) and node.name.startswith("test_") + ] + assert len(names) > 1 + for shards in (1, 3, 5, 7): + placed = {conftest.shard_of(f"{CERTIFIED}::{name}", shards) for name in names} + assert len(placed) == 1, (shards, placed) + + +@PROPERTY_SETTINGS +@given(nodeid=_certified_ids, shards=st.integers(1, 8)) +def test_any_test_in_a_shared_fixture_file_joins_the_others(conftest, nodeid, shards): + assert conftest.shard_of(nodeid, shards) == conftest.shard_of( + f"{CERTIFIED}::test_anything", shards + ) + + +def test_a_shared_fixture_file_hashes_as_one_without_a_table(conftest, monkeypatch): + monkeypatch.setattr(conftest, "_BALANCED", {7: {}}) + assert len({conftest.shard_of(f"{CERTIFIED}::test_{k}", 7) for k in range(20)}) == 1 + + +@PROPERTY_SETTINGS +@given( + certified=st.dictionaries( + _certified_ids, st.floats(0, 3_000, allow_nan=False), max_size=8 + ), + others=_durations, + shards=st.integers(1, 8), + data=st.data(), +) +def test_the_deal_moves_a_shared_fixture_file_as_one( + conftest, certified, others, shards, data +): + durations = {**others, **certified} + placed = conftest.balanced_shards(durations, shards) + assert placed.keys() == durations.keys() + assert all(1 <= shard <= shards for shard in placed.values()) + assert len({placed[nodeid] for nodeid in certified}) <= 1 + load = [0.0] * shards + for nodeid, seconds in others.items(): + load[placed[nodeid] - 1] += seconds + if certified: + load[placed[next(iter(certified))] - 1] += max(certified.values()) + assert max(load) - min(load) <= max(durations.values(), default=0) + 1e-6 + # The same placement whatever order the table lists the tests in. + reordered = dict(data.draw(st.permutations(list(durations.items())))) + assert conftest.balanced_shards(reordered, shards) == placed + + # Invariant 4: the collection listing. diff --git a/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py b/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py new file mode 100644 index 0000000000..805fb1a246 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_formulas_do_not_read_the_clock.py @@ -0,0 +1,319 @@ +"""No module under ``policyengine_uk/variables`` may read the clock. + +A variable whose formula, label or class body reads the wall clock gives +different answers for the same inputs depending on when the simulation runs +(#2875: ``inflation_adjustment`` priced real incomes to the year +``datetime.datetime.now()`` returned). A formula that needs a reference year +reads it from the parameters instead, as the real income variables read +``gov.simulation.price_base_year``. + +The test parses every module under ``policyengine_uk/variables`` with ``ast`` +and fails on any expression that names a clock function, at module level, in +a class body or in a formula, whether the code calls it or only refers to it +(``default=datetime.now`` counts). It resolves ``import x``, ``import x as y``, +``from x import y`` and ``from x import y as z`` at module level and in nested +scopes, so ``import datetime as dt; dt.datetime.now()`` and +``from time import time as clock; clock()`` both fail. An ``import`` that +nothing reads the clock through passes. + +Each name resolves in the innermost scope that imports it, as in Python: a +function's ``import math as datetime`` leaves the module's ``datetime`` alone, +and a class body's imports reach only that class body, not its methods. +Decorators, defaults, annotations and base classes resolve in the enclosing +scope. + +The scan stays under ``variables``. Outside it, +``utils/aggregate_impact/measure.py`` times its own stages with ``time.time``, +which changes no output. + +Limits: the detector does not follow names rebound by assignment +(``now = datetime.now``; a later ``now()`` passes, though the assignment +itself fails), ``getattr`` with a string, ``__import__``, ``importlib``, or a +clock read inside a helper module outside ``variables`` that a formula calls. +It treats an imported name as bound for the whole scope that imports it, +wherever the import sits in that scope, so a local variable or argument that +shadows an import can raise a false alarm, and two imports of one name in one +scope both count. It reads ``global`` and ``nonlocal`` imports as local to their +function. ``time.localtime``, +``time.gmtime``, ``time.ctime`` and ``time.asctime`` read the clock only when +called without a time argument, and ``time.strftime`` only with one argument, +so the detector flags those forms only. It does not inspect string arguments, +so ``numpy.datetime64("now")`` and ``pandas.Timestamp("now")`` pass. +""" + +import ast +from pathlib import Path + +import pytest + +VARIABLES_ROOT = Path(__file__).parents[2] / "variables" +PACKAGE_ROOT = VARIABLES_ROOT.parent + +# Functions that read the clock whenever the code names them. +CLOCK_FUNCTIONS = { + "datetime.datetime.now", + "datetime.datetime.today", + "datetime.datetime.utcnow", + "datetime.date.today", + "time.time", + "time.time_ns", + "time.monotonic", + "time.monotonic_ns", + "time.perf_counter", + "time.perf_counter_ns", + "time.process_time", + "time.process_time_ns", + "time.thread_time", + "time.thread_time_ns", + "pandas.Timestamp.now", + "pandas.Timestamp.today", + "pandas.Timestamp.utcnow", +} + +# Functions that read the clock only when called with at most this many +# positional arguments and no keyword arguments. +CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT = { + "time.localtime": 0, + "time.gmtime": 0, + "time.ctime": 0, + "time.asctime": 0, + "time.strftime": 1, +} + + +# Nodes whose bodies Python runs in a scope of their own. +SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) + + +def _scope_imports(scope: ast.AST) -> dict[str, set[str]]: + """Map each name an import directly in ``scope`` binds to the dotted names + it can stand for. A nested scope's imports bind names of its own.""" + aliases: dict[str, set[str]] = {} + pending = list(ast.iter_child_nodes(scope)) + while pending: + node = pending.pop() + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + aliases.setdefault(alias.asname, set()).add(alias.name) + else: + root = alias.name.split(".")[0] + aliases.setdefault(root, set()).add(root) + elif isinstance(node, ast.ImportFrom): + if node.module and not node.level: + for alias in node.names: + if alias.name != "*": + aliases.setdefault(alias.asname or alias.name, set()).add( + f"{node.module}.{alias.name}" + ) + elif not isinstance(node, SCOPE_NODES): + pending.extend(ast.iter_child_nodes(node)) + return aliases + + +def _qualified_names(node: ast.AST, scopes: list[dict[str, set[str]]]) -> set[str]: + """The dotted names a ``Name`` or ``Attribute`` chain can stand for, from + the innermost of ``scopes`` that imports its root.""" + attributes = [] + while isinstance(node, ast.Attribute): + attributes.append(node.attr) + node = node.value + if not isinstance(node, ast.Name): + return set() + for aliases in reversed(scopes): + if node.id in aliases: + return { + ".".join([target, *reversed(attributes)]) for target in aliases[node.id] + } + return set() + + +def clock_reads(source: str) -> list[tuple[int, str]]: + """Each clock read in ``source`` as (line, dotted function name). + + The walk carries two lists of import scopes for each node: the ones its + own names resolve in, and the ones a scope nested in it can see. Python + hides a class body's names from the scopes nested in that class.""" + tree = ast.parse(source) + module = [_scope_imports(tree)] + reads = [] + conditional = {} + pending = [(tree, module, module)] + while pending: + node, scopes, visible = pending.pop() + if isinstance(node, SCOPE_NODES): + own = [*visible, _scope_imports(node)] + nested = visible if isinstance(node, ast.ClassDef) else own + body = node.body if isinstance(node.body, list) else [node.body] + for child in ast.iter_child_nodes(node): + if any(child is statement for statement in body): + pending.append((child, own, nested)) + else: + pending.append((child, scopes, visible)) + continue + if isinstance(node, ast.Call): + for name in _qualified_names(node.func, scopes): + limit = CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT.get(name) + if limit is not None: + conditional[id(node.func)] = ( + len(node.args) <= limit + and not node.keywords + and not any(isinstance(arg, ast.Starred) for arg in node.args) + ) + elif isinstance(node, (ast.Name, ast.Attribute)) and not isinstance( + node.ctx, (ast.Store, ast.Del) + ): + for name in _qualified_names(node, scopes): + if name in CLOCK_FUNCTIONS: + reads.append((node.lineno, name)) + elif name in CLOCK_FUNCTIONS_WITHOUT_TIME_ARGUMENT: + if conditional.get(id(node), False): + reads.append((node.lineno, name)) + pending.extend((child, scopes, visible) for child in ast.iter_child_nodes(node)) + return sorted(reads) + + +DETECTOR_CASES = [ + # Reads the clock. + ("import datetime\nx = datetime.datetime.now()", ["datetime.datetime.now"]), + ("import datetime\nx = datetime.date.today()", ["datetime.date.today"]), + ( + "from datetime import datetime\nx = datetime.utcnow()", + ["datetime.datetime.utcnow"], + ), + ( + "from datetime import datetime\nx = datetime.today()", + ["datetime.datetime.today"], + ), + ("from datetime import date\nx = date.today()", ["datetime.date.today"]), + ("import datetime as dt\nx = dt.datetime.now().year", ["datetime.datetime.now"]), + ("from datetime import datetime as D\nx = D.now()", ["datetime.datetime.now"]), + ("import time\nx = time.time()", ["time.time"]), + ("import time\nx = time.time_ns()", ["time.time_ns"]), + ("from time import time\nx = time()", ["time.time"]), + ("from time import time_ns as clock\nx = clock()", ["time.time_ns"]), + ("import time as t\nx = t.perf_counter()", ["time.perf_counter"]), + ("import time\nx = time.localtime()", ["time.localtime"]), + ("import time\nx = time.strftime('%Y')", ["time.strftime"]), + ("import pandas as pd\nx = pd.Timestamp.now()", ["pandas.Timestamp.now"]), + # A reference without a call still reads the clock when used. + ("from datetime import datetime\nf = datetime.now", ["datetime.datetime.now"]), + ( + "import datetime\n" + "class v:\n" + " label = f'{datetime.datetime.now().year} prices'\n", + ["datetime.datetime.now"], + ), + ( + "class v:\n" + " def formula(household, period, parameters):\n" + " import datetime\n" + " return datetime.datetime.now().year\n", + ["datetime.datetime.now"], + ), + # A function's import leaves the module's binding alone. + ( + "import datetime\n" + "def helper():\n" + " import math as datetime\n" + " return datetime.sqrt(4)\n" + "x = datetime.datetime.now()\n", + ["datetime.datetime.now"], + ), + # A function's import rebinds the name inside that function. + ( + "import math as datetime\n" + "def formula():\n" + " import datetime\n" + " return datetime.datetime.now()\n", + ["datetime.datetime.now"], + ), + # A nested function sees the enclosing function's import. + ( + "def outer():\n" + " import datetime\n" + " def inner():\n" + " return datetime.datetime.now()\n" + " return inner\n", + ["datetime.datetime.now"], + ), + # A class body sees its own import, and a lambda sees the module's. + ( + "class v:\n" + " import datetime\n" + " label = f'{datetime.datetime.now().year} prices'\n", + ["datetime.datetime.now"], + ), + ("import time\nf = lambda: time.time()", ["time.time"]), + # A default runs in the enclosing scope, before the function's import. + ( + "import datetime\n" + "def formula(now=datetime.datetime.now):\n" + " import math as datetime\n" + " return datetime.sqrt(now())\n", + ["datetime.datetime.now"], + ), + # Two imports of one name in one scope both count. + ( + "import datetime\nimport math as datetime\nx = datetime.datetime.now()\n", + ["datetime.datetime.now"], + ), + # Does not read the clock. + ("import datetime\nimport time\nimport numpy as np", []), + ("import datetime\nx = datetime.date(2026, 1, 1)", []), + ("import datetime\nx = datetime.datetime(2026, 1, 1).year", []), + ("from datetime import date\nx = date.fromisoformat('2026-01-01')", []), + ("import time\nx = time.localtime(0)", []), + ("import time\nx = time.strftime('%Y', (2026, 1, 1, 0, 0, 0, 0, 1, 0))", []), + ("now = 1\nx = now", []), + # A function's import binds nothing outside that function. + ( + "def helper():\n" + " import datetime\n" + " return datetime.date(2026, 1, 1)\n" + "x = datetime.datetime.now()\n", + [], + ), + # A function's import hides the module's binding inside that function. + ( + "import datetime\n" + "def helper():\n" + " import math as datetime\n" + " return datetime.datetime.now()\n", + [], + ), + # A class body's import does not reach its methods. + ( + "import math as datetime\n" + "class v:\n" + " import datetime\n" + " def formula():\n" + " return datetime.datetime.now()\n", + [], + ), + ("x = household.now()\ny = parameters.time", []), +] + + +@pytest.mark.parametrize( + "source, expected", + DETECTOR_CASES, + ids=[f"case-{i}" for i in range(len(DETECTOR_CASES))], +) +def test_detector(source, expected): + assert [name for _, name in clock_reads(source)] == expected + + +def test_variables_do_not_read_the_clock(): + paths = sorted(VARIABLES_ROOT.rglob("*.py")) + assert len(paths) > 100, "The scan found too few variable modules." + found = {} + for path in paths: + reads = clock_reads(path.read_text()) + if reads: + found[str(path.relative_to(PACKAGE_ROOT))] = reads + assert not found, ( + "These modules under policyengine_uk/variables read the clock, so " + "their variables change with the date a simulation runs. Read a " + f"reference date from the parameters instead: {found}" + ) diff --git a/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py b/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py new file mode 100644 index 0000000000..5929361c11 --- /dev/null +++ b/policyengine_uk/tests/code_health/test_no_engine_time_randomness.py @@ -0,0 +1,4222 @@ +"""The model draws nothing while a simulation is built or run. + +A formula is a pure, deterministic function of its inputs +(policyengine-core#500), and seeding does not exempt a draw. The dataset +build draws stochastic inputs such as take-up flags and stores them as input +variables; the model compares them with its rules. policyengine-core#519 +checks formulas for numpy and stdlib randomness when variables register, but +a hand-written integer hash of entity ids passes that check, so this scan +looks for the hash as well. + +Every module of the package except tests is parsed (formulas, reforms, +scenarios, simulation code, data loading, uprating and utilities). A name +resolves to everything it may refer to where it is used: the targets of every +binding that may be the one read, in each scope Python looks in, so the scan +errs toward reporting. Bindings come from imports, named imports and re-exports +followed across the package, star imports, assignments of a name or +attribute chain (``xp = np``, also in tuples), default arguments, ``global`` +and ``nonlocal`` (the nearest enclosing function that binds the name, +wherever in its body; either sends the name there in the code nested in +its scope too, unless that code binds the name itself), functions, lambdas +and comprehensions. An attribute +of a package module, or of a module EXTERNAL_EXPORTS maps, is what that +module binds under the name, so ``api.np.random`` is numpy's random module +where ``api`` is ``policyengine_core.model_api``. No code sees the names a +class body it is nested in binds, or its ``global`` and ``nonlocal`` +statements. A module or class body looks further out +when no binding is sure to have run before the use with no ``del`` or +``except ... as`` cleanup that may come between (all are in an ``if``, loop, +``try``, ``with``, ``match`` or ``assert``, in a comprehension, past the +first operand of ``and``, ``or`` or a chained comparison, or in a branch of +a conditional expression, or the use comes first). A class body that binds +the name anywhere (an annotation alone or a ``del`` counts) then looks in +the module, never in a function around it, and so does what the body +evaluates: its annotations, its methods' defaults and annotations, and +type-parameter bounds and ``type`` values in it. A function may run +as soon as its ``def`` has run, so the scopes around it count only the +bindings before the ``def``, and every later deletion. A function's ``del`` +of a name it declares ``global`` runs whenever the function is called, so +after the ``def`` it may come between any binding and any use. A generator +expression runs its body as it is iterated, so the same holds for it from +the expression (its first iterable runs at once). A class body, +a decorated function and a lambda may run before the statement that creates +them has bound its names (a decorator may call the function, and an +assignment's value may call a lambda in it), so code inside them doesn't +take those bindings as sure. Nor does a use take as sure a binding its own +statement makes (a multi-line assignment, or a default or base on a later +line of its ``def`` or ``class``): Python makes those after evaluating it, +and treating a walrus earlier in the statement the same way errs toward +reporting. An annotation is read as code that may run any time after its +statement starts, seeing a class body around it, which covers both ways +Python may evaluate it: with its statement, as up to 3.13, and whenever the +annotations are read, as 3.14 defers them (PEP 649). A type parameter's +bound or default and a ``type`` alias's value, which Python evaluates when +they are read (PEP 695, 3.12+; defaults PEP 696, 3.13+), are read the same +way. A ``type`` statement +binds its alias's name, but a type parameter's own name is not taken as +bound, which errs toward reporting. An annotation alone binds no value in a +module or class body. + +So ``from numpy import random as r`` followed by ``r.random(3)`` is caught, +as are ``xp = np`` followed by ``xp.random.uniform()``, ``from +policyengine_uk.model_api import np as lib`` followed by +``lib.random.uniform()``, and a ``try: import numpy as lib`` with a +fallback, while a local variable called ``random`` is not. A star import of +a package module binds its ``__all__`` (built from string literals with +``=``, ``+``, ``+=``, ``append``, ``extend``, ``insert`` or ``remove``; any +other ``__all__`` exports every name) or, without one, every name it binds +without a leading underscore. It may not bind a name an earlier or removed +``__all__`` held (an import cycle can see that one), one the module binds +only conditionally or deletes, or any name where ``__all__`` is built +otherwise, so the scan also looks further out. Where the module, while it +runs, may import the importing one (directly or through other modules, +counting every import in them, even in a function; the submodules a star +import of a package loads: the module each name its ``__all__`` lists +names below the package (``sub.leaf`` too, with ``sub``) or, where +``__all__`` is built otherwise, every module below it, and the same where +a literal ``__import__`` ``fromlist`` holds ``*`` (read as Python iterates +it: a list's, tuple's or set's elements, a dict's keys or a string's +characters, a literal ``*`` or ``**`` entry's own in its place); any call +whose literal arguments name a module as ``importlib.import_module`` or +``__import__`` would read them, whatever name the call is made under, a +relative one against its literal ``package`` (or the module's +``__package__`` or ``__name__``) or the package its ``globals`` give, as +Python reads them (``globals()`` or a dict literal, a literal ``**`` +entry's own entries in its place and the last of a repeated key counting: +``__package__``, else ``__spec__``, else ``__name__`` and ``__path__``); +the packages an import starts; the code of modules it may +reach without importing them, as another module may have loaded them: an +attribute chain, ``getattr`` with a literal name, ``sys.modules`` with a +literal key, or the module a literal dynamic import returns, from a name +any import, or assignment (or walrus) of such a chain or a conditional +choice of one (``if``-``else``, ``and``, ``or``), in the module binds, a +submodule a star import of a package may bind, or in a package's +``__init__`` its own submodule; and the imports in code that may run +later, when it is called or read (functions, lambdas, generator +expressions, annotations, type parameters and ``type`` values), in the +packages around a module, which have run before it, and in the modules +they import or reach as they run, since its code may call that code), the +star import may see it part-run, before it binds a name or sets ``__all__``: it may then bind any +name the module binds without a leading underscore, and binds none surely. +EXTERNAL_EXPORTS lists the +names numpy and ``policyengine_core.model_api`` bind where they matter here; +``np``, ``numpy`` and ``pd`` are bound to numpy and pandas wherever nothing +else binds them. Reported, as (file, enclosing function or class, kind): +- ``stochastic import``: importing ``random``, ``secrets`` or + ``numpy.random`` (or anything from them), including through + ``importlib.import_module`` or ``__import__`` with literal arguments, + positional or keyword (``package``, ``fromlist`` and ``level`` too); +- ``random source``: a name or attribute that resolves into ``numpy.random``, + ``random`` or ``secrets``, or to ``uuid.uuid1``, ``uuid.uuid4`` or + ``os.urandom``, including through ``getattr`` with a literal name; +- ``builtin hash``: any use of the unshadowed ``hash``, which Python salts + per process; +- ``hash library``: importing a hashing or checksum library (``hashlib``, + ``zlib``, ``uuid``, ...); +- ``hash use``: a name or attribute that resolves into one of those + libraries or to pandas' hashing functions, so that a new use of an + import EXEMPT allows still counts; +- ``hash constant``: an integer constant of a common hash mixer + (splitmix64, murmur3, FNV, xxHash, PCG), which catches a copied hash; +- ``xorshift``: the step ``x ^ (x >> k)`` such mixers are built from, + written with operators, ``^=`` or numpy's ``bitwise_xor`` and shifts; +- ``sampling call``: ``.sample(...)`` with sampling arguments, or a method + named ``shuffle``, ``permutation``, ``choice`` and the like; +- ``id modulo``: ``... % n``, ``%=``, ``np.mod``, ``divmod`` and the like, + where the dividend reads an id (a name or string ending ``_id``); +- ``draw helper call``: a use, under any name it is imported or re-exported + as, of a top-level function of the package (one defined in an ``if`` or + ``try`` too) whose body has a finding EXEMPT does not cover, or that + itself uses such a function, so that calling an existing draw again also + counts. + +This is a ratchet over recognised patterns, not a proof that no +hand-written draw exists. It does not follow ``eval`` or ``exec``, imports +of a computed name (a relative dynamic import whose ``package`` or +``globals`` is computed is read against the module's own package), +package search paths changed as the code runs (a ``__path__`` assignment) +or import hooks, code outside the package (what it imports, or calls back +into), values reached through objects or containers (``self.r = +np.random`` is caught where ``np.random`` is read; a later +``self.r.uniform()`` is not; nor are functions one module sets on another +or registers for a later call), names bound otherwise than by an import or +an assignment to a plain name (unpacking, ``for``, ``with``, parameters), +the values calls return (``r = +importlib.import_module("random")`` is caught at the import; a later +``r.random()`` is not), names ``match`` patterns capture, or methods as draw +helpers. It looks for draws, not for other reads that differ between runs, +such as the clock (policyengine-uk#2875). A lookup that nests deeper than +MAX_RESOLUTION_DEPTH fails the scan rather than passing unresolved. + +EXEMPT lists reviewed occurrences that are not draws, and the sample a caller +asks for with Simulation.subsample, which draws after the simulation is built +and which no formula reads; RATCHET lists today's engine-time draws that still +have to move into the data. Both count exact +occurrences per (file, enclosing scope, kind): an extra occurrence fails, and +so does an entry that no longer matches as many as it says, so entries are +lowered and removed as sites go. RATCHET_CEILING only ever falls, and +RATCHET must reach zero. +""" + +import ast +from collections import Counter +from functools import cache +from itertools import permutations +from pathlib import Path +import sys +from typing import NamedTuple + +import pytest + + +PACKAGE = Path(__file__).resolve().parents[2] +TESTS = PACKAGE / "tests" + +RATCHET = { + # The splitmix64 finaliser that maps entity ids to uniform draws on + # [0, 1): the three splitmix64 constants the scan recognises and three + # xorshift steps. The formula, uprating and simulation loading entries + # below call it, or call what calls it. + "utils/stochastic.py": { + ("splitmix64_uniform", "hash constant"): 3, + ("splitmix64_uniform", "xorshift"): 3, + }, + # Formula: hash of benunit_id (salt 0) that decides whether a UC + # deduction applies and how large it is, in simulations built from data. + "variables/gov/dwp/universal_credit/deductions/uc_deduction_random_draw.py": { + ("uc_deduction_random_draw.formula", "draw helper call"): 1, + }, + # Formula: hash of benunit_id (salt 1) that picks the UC deduction type + # combination, in simulations built from data. + "variables/gov/dwp/universal_credit/deductions/uc_deduction_type_random_draw.py": { + ("uc_deduction_type_random_draw.formula", "draw helper call"): 1, + }, + # Formula: hash of household_id (salt 2) that decides whether a household + # on a qualifying benefit receives the Warm Home Discount, in simulations + # built from data. + "variables/gov/ofgem/warm_home_discount_random_draw.py": { + ("warm_home_discount_random_draw.formula", "draw helper call"): 1, + }, + # Formula: hash of person_id (salt 2) that orders people within each year + # of age and sex before stratified_uniform spreads them over the year, in + # simulations built from data. + "variables/household/demographic/months_since_last_birthday.py": { + ("months_since_last_birthday.formula", "draw helper call"): 1, + }, + # Uprating: uprate_student_loan_plans hashes person_id (salt 3) and + # assigns a plan to new borrowers whose draw falls below the take-up + # rate. apply_single_year_uprating calls it, apply_uprating calls that, + # and extend_single_year_dataset calls apply_uprating. + "data/economic_assumptions.py": { + ("uprate_student_loan_plans", "draw helper call"): 1, + ("apply_single_year_uprating", "draw helper call"): 1, + ("apply_uprating", "draw helper call"): 1, + ("extend_single_year_dataset", "draw helper call"): 1, + }, + # Simulation loading: extends a single-year dataset through + # extend_single_year_dataset, and so the student loan draw. + "simulation.py": { + ("Simulation._extend_dataset_with_cgt_provenance", "draw helper call"): 1, + }, + # Labour supply: a seeded numpy RandomState that draws participation + # responses. + "dynamics/participation.py": { + ("apply_participation_responses", "random source"): 1, + }, + # UC rebalancing: a seeded numpy Generator draws one value per benefit + # unit and compares it with the new claimant share. Simulation.__init__ + # applies it to every simulation through the Scenario object, a call the + # scan does not follow; the Scenario at module level passes on the + # modifier. + "scenarios/uc_reform.py": { + ("add_universal_credit_reform", "random source"): 1, + ("", "draw helper call"): 1, + }, + # PIP phase-in scenario: seeds numpy's global stream and draws one value + # per person, which removes PIP where it falls below the phase-in share; + # the Scenario at module level passes on the modifier. + "scenarios/pip_reform.py": { + ("modify_simulation", "random source"): 2, + ("", "draw helper call"): 1, + }, +} +# The number of RATCHET occurrences. Lower it when one goes; never raise it. +RATCHET_CEILING = 21 + +EXEMPT = { + # SHA-256 fingerprint of the files that shape the data build, for + # provenance. + "build_metadata.py": { + ("", "hash library"): 1, + ("get_data_build_fingerprint", "hash use"): 1, + }, + # SHA-256 of the capital gains projection's metadata and provenance, for + # identifying the projection. + "data/capital_gains_projection.py": { + ("", "hash library"): 1, + ("_fingerprint", "hash use"): 1, + }, + # SHA-256 of a dataset's bucket, path and generation, naming its local + # cache directory. + "data/dataset_sources.py": { + ("", "hash library"): 1, + ("_cached_dataset_path", "hash use"): 1, + }, + # SHA-256 of the uprating parameters, which the simulation compares for + # equality to decide whether a cached projection still applies. + "data/economic_assumptions.py": { + ("", "hash library"): 1, + ("uprating_input_fingerprint", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 of a file it records. + "utils/aggregate_impact/__main__.py": { + ("", "hash library"): 1, + ("_sha256", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 content check of a pinned dataset file. + "utils/aggregate_impact/dataset.py": { + ("", "hash library"): 1, + ("sha256_file", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 identities of the tool's files, the + # measured spec and reforms, the model's files and the installed + # distributions, stamped on a result. + "utils/aggregate_impact/measure.py": { + ("", "hash library"): 1, + ("_digest_tool_files", "hash use"): 1, + ("definitions_digest", "hash use"): 1, + ("model_fingerprint", "hash use"): 1, + ("runtime_identity", "hash use"): 1, + }, + # Aggregate impact tool: SHA-256 of a partition file, recorded with it. + "utils/aggregate_impact/partition.py": { + ("", "hash library"): 1, + ("load_partition", "hash use"): 1, + }, + # Offline LHA rates builder: SHA-256 content check of a downloaded source + # file. + "utils/build_lha_published_rates.py": { + ("", "hash library"): 1, + ("fetch", "hash use"): 1, + }, + # Simulation.subsample: a seeded draw of households when a caller asks for + # a sample. SHA-256 turns the seed into a number in every process, and the + # module-level random source is the return annotation of _random_state. + "utils/subsample.py": { + ("", "hash library"): 1, + ("", "random source"): 1, + ("_random_state", "hash use"): 1, + ("_random_state", "random source"): 1, + ("draw_household_counts", "sampling call"): 1, + }, +} +OFFLINE_MODULES = set() + +# Names modules outside the package bind, where they matter here, for star +# and named imports alike. numpy's __all__ includes its random module. +EXTERNAL_EXPORTS = { + "numpy": {"random": "numpy.random"}, + "policyengine_core.model_api": { + "np": "numpy", + "numpy": "numpy", + "pd": "pandas", + # Core's guard that raises on formula-time randomness. + "random": "policyengine_core.commons.formulas.random", + }, +} +# What a name bound nowhere in the module refers to. +UNBOUND_NAMES = { + "np": "numpy", + "numpy": "numpy", + "pd": "pandas", + "hash": "builtins.hash", + "getattr": "builtins.getattr", + "__import__": "builtins.__import__", + "divmod": "builtins.divmod", +} +DYNAMIC_IMPORTS = {"importlib.import_module", "builtins.__import__"} +XOR_FUNCTIONS = {"numpy.bitwise_xor", "operator.xor"} +SHIFT_FUNCTIONS = { + "numpy.right_shift", + "numpy.left_shift", + "operator.rshift", + "operator.lshift", +} +MOD_FUNCTIONS = { + "numpy.mod", + "numpy.remainder", + "numpy.fmod", + "operator.mod", + "builtins.divmod", +} +STOCHASTIC_MODULES = ("numpy.random", "random", "secrets") +STOCHASTIC_CALLABLES = {"uuid.uuid1", "uuid.uuid4", "os.urandom"} +HASH_MODULES = { + "hashlib", + "zlib", + "binascii", + "uuid", + "mmh3", + "xxhash", + "blake3", + "crcmod", +} +HASH_APIS = ( + "pandas.util.hash_pandas_object", + "pandas.util.hash_array", + "pandas.core.util.hashing", +) +SAMPLING_METHODS = { + "shuffle", + "permutation", + "permuted", + "choice", + "choices", + "randint", + "randrange", +} +SAMPLING_KEYWORDS = {"n", "frac", "random_state", "weights", "replace"} +HASH_CONSTANTS = { + # splitmix64: golden gamma and the two finalizer multipliers + 0x9E3779B97F4A7C15, + 0xBF58476D1CE4E5B9, + 0x94D049BB133111EB, + # murmur3 fmix64 and fmix32 + 0xFF51AFD7ED558CCD, + 0xC4CEB9FE1A85EC53, + 0x85EBCA6B, + 0xC2B2AE35, + # FNV-1a 64-bit and 32-bit offset bases and primes + 0xCBF29CE484222325, + 0x100000001B3, + 0x811C9DC5, + 0x01000193, + # xxHash64 primes + 0x9E3779B185EBCA87, + 0xC2B2AE3D27D4EB4F, + 0x165667B19E3779F9, + 0x85EBCA77C2B2AE63, + 0x27D4EB2F165667C5, + # PCG and Knuth MMIX 64-bit LCG multiplier + 0x5851F42D4C957F2D, +} +FUNCTION_NODES = (ast.FunctionDef, ast.AsyncFunctionDef) +COMPREHENSION_NODES = (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp) +TYPE_ALIAS = getattr(ast, "TypeAlias", ()) # Python 3.12+ +# Statements whose bodies may not run, so a binding inside may not happen. +COMPOUND_NODES = ( + ast.If, + ast.For, + ast.AsyncFor, + ast.While, + ast.Try, + ast.TryStar, + ast.With, + ast.AsyncWith, + ast.Match, +) +# Lookups nested deeper than this are cut, and the package scan fails +# (test_package_lookups_stay_within_the_depth_limit). +MAX_RESOLUTION_DEPTH = 64 +# What ``from module import *`` binds when the module has no __all__, and +# when its __all__ is built in a way this scan does not evaluate. +DEFAULT_PUBLIC = "names without a leading underscore" +EVERY_NAME = "every name" + + +def _matches(target: str, prefixes) -> bool: + return any( + target == prefix or target.startswith(prefix + ".") for prefix in prefixes + ) + + +def _is_stochastic(target: str | None) -> bool: + if not target: + return False + return target in STOCHASTIC_CALLABLES or _matches(target, STOCHASTIC_MODULES) + + +def _classify(target: str) -> str | None: + """The kind of finding a use of ``target`` is, if any.""" + if _is_stochastic(target): + return "random source" + if target == "builtins.hash": + return "builtin hash" + if target.split(".")[0] in HASH_MODULES or _matches(target, HASH_APIS): + return "hash use" + return None + + +def _absolute(module: str, level: int, package: str | None) -> str: + """The absolute name of a ``from`` import's module.""" + if not level: + return module + if package is None: + return "." * level + module + parts = package.split(".") + parent = ".".join(parts[: len(parts) - level + 1]) + return f"{parent}.{module}" if module else parent + + +def _dotted(node): + """('np', ['random', 'seed']) for np.random.seed, else None.""" + parts = [] + while isinstance(node, ast.Attribute): + parts.append(node.attr) + node = node.value + if isinstance(node, ast.Name): + return node.id, list(reversed(parts)) + return None + + +def _literal(node) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _targets(target) -> set: + """The dotted names a binding's target may be.""" + if target is None: + return set() + if isinstance(target, str): + return {target} + return set(target.targets()) + + +class _Alias: + """A name bound, at ``line``, to another name or attribute chain.""" + + def __init__(self, scope, value, line): + self.scope = scope + self.root, self.attributes = _dotted(value) + self.line = line + + def targets(self): + # The value is evaluated where and when the binding is made. + roots = self.scope.modules.guarded( + self, + frozenset(), + lambda: frozenset(self.scope.resolve(self.root, self.line)), + ) + return self.scope.modules.expand_all( + ".".join([root] + self.attributes) for root in roots + ) + + +class _Scope: + """One namespace: a module, class, function, lambda or comprehension, or + the annotations of one statement.""" + + def __init__(self, kind, qualname, parent, modules, line=None): + self.kind = kind + self.qualname = qualname + self.parent = parent + self.modules = modules + # Where the scope starts: a function's def, for instance. + self.line = line + # name -> [(line, target, conditional)]. The target is what the name + # refers to (a module, function or other import), an _Alias to + # resolve, or None for a value. A conditional binding + # (in an if, loop, try, with or match, or a deletion) may not happen. + self.bindings = {} + # name -> [line] of each del or except ... as cleanup that may unbind + # it. + self.deletions = {} + # name -> [line] of each def whose function may unbind it when + # called, which may be at any time after that line. + self.deletions_after = {} + # For a scope that may run before the statement creating it has bound + # its names (a class body, a decorated function, a lambda), that + # statement's (first line, last line). + self.unfinished = None + # (line, module, conditional, importing module) for each star + # import. + self.stars = [] + # Names a global or nonlocal statement gives to another scope. + self.declared = {} + # For a function, lambda or class body, the names Python makes + # local to it. + self.local_names = frozenset() + + def bind(self, name, line, target=None, conditional=False): + owner = self.declared.get(name) + if owner is not None: + # Bound in that scope whenever this one runs, which may be never. + return owner.bind(name, line, target, conditional=True) + self.bindings.setdefault(name, []).append((line, target, conditional)) + + def delete(self, name, line): + """Record that ``name`` may be unbound at ``line``.""" + self.bind(name, line, None, conditional=True) + owner = self.declared.get(name, self) + # A deletion in a function, of a name another scope binds, happens + # when the function is called: in the owner's time, any time after + # the def of the outermost function between them. + called_after = None + scope = self + while scope is not owner: + if scope.kind in ("function", "lambda"): + called_after = scope.line + scope = scope.parent + if called_after is None: + owner.deletions.setdefault(name, []).append(line) + else: + owner.deletions_after.setdefault(name, []).append(called_after) + + def own(self, name, line, deferred, unfinished=()): + """(targets, bound, falls through) for ``name`` in this scope alone. + + The use is at ``line`` or, with ``deferred``, at any time after it; + a None line is once the scope has run. A binding within one of the + ``unfinished`` (first line, last line) spans may not have happened + yet. + """ + entries = [(entry[0], entry[2]) for entry in self.bindings.get(name, [])] + targets = set() + for _, target, _ in self.bindings.get(name, []): + targets |= _targets(target) + for star_line, base, conditional, importer in self.stars: + provided = self.modules.star_binds(base, name, importer) + if provided is not None: + targets |= provided + sure = self.modules.star_binds_surely(base, name, importer) + entries.append((star_line, conditional or not sure)) + if not entries: + return targets, False, True + if self.kind not in ("module", "class"): + # A name bound anywhere in a function is local to all of it. + return targets, True, False + # A module or class body looks further out unless a binding is sure + # to have run before the use, with no deletion that may come between. + deletions = self.deletions.get(name, []) + deletions_after = self.deletions_after.get(name, []) + + def holds(bound_line): + if deferred or line is None: + return ( + (line is None or bound_line <= line) + and all(deleted < bound_line for deleted in deletions) + and not deletions_after + ) + return ( + bound_line < line + and not any(bound_line <= deleted <= line for deleted in deletions) + and not any(after <= line for after in deletions_after) + ) + + sure = [ + entry_line + for entry_line, conditional in entries + if not conditional + and not any(first <= entry_line <= last for first, last in unfinished) + ] + return targets, True, not any(map(holds, sure)) + + def resolve(self, name, line=None, deferred=False, statement=None) -> set: + """Every dotted target ``name`` may refer to at ``line``. + + The answer errs toward reporting: the targets of every binding that + may be the one read, in each scope Python looks in until a binding is + sure to hold. ``deferred`` says the code may run at any time after + ``line``. ``statement`` is the (first line, last line) of the + statement the use is in, whose own bindings may not have happened. + """ + targets = set() + # The scope a global or nonlocal statement sends the name to, until + # the lookup reaches it. + owner = None + first = True + unfinished = [statement] if statement else [] + scope = self + while scope is not None: + if scope.kind == "annotation": + # Up to 3.13 an annotation is evaluated with its statement. + # 3.14 defers it to whenever the annotations are read (PEP + # 649), any time after the statement starts, in a scope that + # also sees a class body around it. Reading it as deferred + # from the statement covers both, since no binding the + # statement makes is sure (resolve). + line, deferred = scope.line, True + scope = scope.parent + continue + if scope is owner: + owner = None + # A global or nonlocal name skips the scopes up to the one it + # names, and no code nested in a class body sees the names it + # binds or declares. + if owner is None and (first or scope.kind != "class"): + # A global or nonlocal statement here sends the name there, + # here and in the code nested here. + owner = scope.declared.get(name) + if owner is None: + found, bound, falls = scope.own(name, line, deferred, unfinished) + targets |= found + if bound and not falls: + return targets + if scope.kind == "class" and name in scope.local_names: + # A class body that binds the name reads it from its + # namespace, then the module's (LOAD_NAME): the + # functions around it are skipped. + owner = scope + while owner.parent is not None: + owner = owner.parent + if scope.unfinished is not None: + # It may run before its statement's bindings have happened. + unfinished.append(scope.unfinished) + if scope.kind in ("function", "lambda", "generator"): + # A function may run as soon as its def has run, and a + # generator expression's body as soon as it is made, so the + # scopes around them are read as of then, and any later + # deletion counts. + line, deferred = scope.line, True + first = False + scope = scope.parent + if name in UNBOUND_NAMES: + targets.add(UNBOUND_NAMES[name]) + return targets + + +def _comprehension_walrus_targets(node) -> set: + """Names a walrus binds anywhere in a comprehension, which Python binds + in the function around it. A lambda inside is a scope of its own, apart + from its defaults.""" + targets = set() + stack = [node] + while stack: + child = stack.pop() + if isinstance(child, ast.NamedExpr): + targets.add(child.target.id) + if isinstance(child, ast.Lambda): + stack.extend(child.args.defaults) + stack.extend(d for d in child.args.kw_defaults if d) + continue + stack.extend(ast.iter_child_nodes(child)) + return targets + + +def _function_locals(node) -> frozenset: + """The names Python makes local to a function or lambda, wherever its + body binds them: its parameters and every name bound outside nested + scopes, less those a global or nonlocal statement sends elsewhere.""" + arguments = node.args + parameters = arguments.posonlyargs + arguments.args + arguments.kwonlyargs + parameters += [arguments.vararg, arguments.kwarg] + names = {argument.arg for argument in parameters if argument is not None} + body = node.body if isinstance(node.body, list) else [node.body] + return frozenset(names) | _block_locals(body) + + +def _block_locals(body) -> frozenset: + """The names Python makes local to a block of statements, wherever it + binds them: every name bound outside nested scopes, an annotation alone + and a ``del`` included, less those a global or nonlocal statement sends + elsewhere.""" + names, declared = set(), set() + stack = list(body) + while stack: + child = stack.pop() + if isinstance(child, (ast.Global, ast.Nonlocal)): + declared.update(child.names) + continue + if isinstance(child, FUNCTION_NODES + (ast.ClassDef, ast.Lambda)): + # Only its name, decorators, defaults, annotations and bases are + # this scope's. Up to 3.13 annotations are evaluated here, so a + # walrus in one binds here; 3.14 defers them and refuses a walrus + # in one, so the earlier rule covers both. + if not isinstance(child, ast.Lambda): + names.add(child.name) + stack.extend(getattr(child, "decorator_list", [])) + if isinstance(child, ast.ClassDef): + stack.extend(child.bases + [k.value for k in child.keywords]) + else: + stack.extend(child.args.defaults) + stack.extend(d for d in child.args.kw_defaults if d) + if isinstance(child, FUNCTION_NODES): + stack.extend([child.returns] if child.returns else []) + stack.extend( + argument.annotation + for argument in ast.walk(child.args) + if isinstance(argument, ast.arg) and argument.annotation + ) + continue + if isinstance(child, COMPREHENSION_NODES): + # Its loop variables are its own; a walrus binds here. Since 3.12 + # CPython inlines a list, set or dict comprehension (PEP 709; not a + # generator expression), and a nested nonlocal of a loop + # variable's name then names a cell here that only such nonlocals + # set, not the binding further out that 3.11 and a generator + # expression name. Following 3.11 keeps every value either can + # read. + names.update(_comprehension_walrus_targets(child)) + stack.append(child.generators[0].iter) + continue + if isinstance(child, ast.Name) and not isinstance(child.ctx, ast.Load): + names.add(child.id) + elif isinstance(child, (ast.Import, ast.ImportFrom)): + names.update( + (alias.asname or alias.name).split(".")[0] + for alias in child.names + if alias.name != "*" + ) + elif isinstance(child, ast.ExceptHandler) and child.name: + names.add(child.name) + elif isinstance(child, (ast.MatchAs, ast.MatchStar)) and child.name: + names.add(child.name) + elif isinstance(child, ast.MatchMapping) and child.rest: + names.add(child.rest) + stack.extend(ast.iter_child_nodes(child)) + return frozenset(names - declared) + + +def _may_not_run(node, child) -> bool: + """Whether ``child``, a part of ``node``, may not run when ``node`` does: + an operand of ``and`` or ``or`` after the first, a chained comparison's + after the second, a conditional expression's branches, and all of an + ``assert``, which ``python -O`` drops.""" + if isinstance(node, ast.BoolOp): + return child is not node.values[0] + if isinstance(node, ast.Compare): + return any(child is part for part in node.comparators[1:]) + if isinstance(node, ast.IfExp): + return child is not node.test + return isinstance(node, ast.Assert) + + +def _type_parameter_values(node) -> list: + """The bounds and defaults of a definition's type parameters (PEP 695, + Python 3.12+; defaults 3.13+), which Python evaluates when they are + read.""" + values = [] + for parameter in getattr(node, "type_params", []): + for field in ("bound", "default_value"): + value = getattr(parameter, field, None) + if value is not None: + values.append(value) + return values + + +def _alias(scope, value, line): + """What binding ``value`` at ``line`` binds: an _Alias of a name or chain.""" + if value is not None and _dotted(value) is not None: + return _Alias(scope, value, line) + return None + + +def _is_all(node) -> bool: + return isinstance(node, ast.Name) and node.id == "__all__" + + +def _strings(node, current): + """The set of strings ``node`` builds, with ``current`` as __all__.""" + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + values = [_literal(element) for element in node.elts] + return None if None in values else set(values) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left, right = _strings(node.left, current), _strings(node.right, current) + return None if left is None or right is None else left | right + if _is_all(node): + return set(current) + return None + + +class _Exports(NamedTuple): + # Names __all__ holds once the module has run. + final: frozenset + # Names it held before, or that were removed, which a module importing it + # during an import cycle may see. + earlier: frozenset + + +def _public_names(tree): + """What ``from module import *`` binds, from the module's ``__all__``. + + An _Exports, DEFAULT_PUBLIC without an ``__all__``, or EVERY_NAME for an + ``__all__`` built other than from string literals with ``=``, ``+``, + ``+=``, ``append``, ``extend``, ``insert`` and ``remove`` at the top + level. + """ + uses = {node for node in ast.walk(tree) if _is_all(node)} + if not uses: + return DEFAULT_PUBLIC + names, seen, handled = set(), set(), set() + for statement in tree.body: + value = None + if isinstance(statement, (ast.Assign, ast.AnnAssign)): + targets = ( + statement.targets + if isinstance(statement, ast.Assign) + else [statement.target] + ) + if len(targets) == 1 and _is_all(targets[0]) and statement.value: + value = _strings(statement.value, names) + if value is None: + return EVERY_NAME + names = value + elif ( + isinstance(statement, ast.AugAssign) + and _is_all(statement.target) + and isinstance(statement.op, ast.Add) + ): + value = _strings(statement.value, names) + if value is None: + return EVERY_NAME + names = names | value + elif ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Call) + and isinstance(statement.value.func, ast.Attribute) + and _is_all(statement.value.func.value) + ): + method, args = statement.value.func.attr, statement.value.args + if method == "append" and len(args) == 1 and _literal(args[0]): + value = {_literal(args[0])} + elif method == "insert" and len(args) == 2 and _literal(args[1]): + value = {_literal(args[1])} + elif method == "extend" and len(args) == 1: + value = _strings(args[0], names) + elif method == "remove": + removed = _literal(args[0]) if len(args) == 1 else None + # Without a literal, any name may be the one removed. + names = names - {removed} if removed else set() + value = set() + if value is None: + return EVERY_NAME + names = names | value + if value is not None: + handled |= {node for node in ast.walk(statement) if _is_all(node)} + seen |= names + if uses - handled: + return EVERY_NAME + return _Exports(frozenset(names), frozenset(seen - names)) + + +class Modules: + """The package's modules, built on demand, for names one imports from another. + + ``sources`` maps module names to (package name, a function returning the + source). A lookup is a pure function of the sources: a cycle is cut + where a lookup meets itself, and a result computed across a cut is not + kept, so no answer depends on the order of lookups. A lookup nested + deeper than ``max_depth`` is cut too, and recorded in ``exhausted``: a + result kept from a shallower lookup could fill that cut in another order. + """ + + def __init__(self, sources: dict, external=None, max_depth=MAX_RESOLUTION_DEPTH): + self.sources = sources + self.external = EXTERNAL_EXPORTS if external is None else external + self.max_depth = max_depth + self.exhausted = set() + self.tops = {} + self.publics = {} + # module -> the package modules it may start, or whose code it may + # reach, when it runs. + self.imported = {} + # module -> the modules it star-imports from. + self.star_bases = {} + # module -> (the package modules it imports or reaches as it runs, + # the packages it star-imports from as it runs). + self.run_imports = {} + # module -> (the package modules code in it that may run later + # imports or reaches, the packages that code star-imports from). + self.later_imports = {} + # package -> the modules below it; every module and package around one. + self._below = None + self._known = None + self._reached = {} + # the packages around a module -> (the package modules that ran + # before it, the package modules their later code may start). + self._ran = {} + self._memo = {} + self._active = set() + self._cuts = 0 + + def top(self, module): + if module not in self.tops: + package, read = self.sources[module] + tree = ast.parse(read(), filename=module) + self.register(module, _build_scopes(tree, module, package, self)[0], tree) + return self.tops[module] + + def register(self, module, top, tree): + if module not in self.tops: + self.tops[module] = top + self.publics[module] = _public_names(tree) + package = self._package(module) + (run, run_stars), (later, later_stars) = _imports_in(tree, package, module) + self._index() + run_reached, later_reached = _references_in( + tree, package, run_stars | later_stars, self._known, module + ) + run, later = run | run_reached, later | later_reached + # Its code may call a function that imports, so every import + # counts. + self.imported[module] = self._imports_of(module, run | later) + self.star_bases[module] = frozenset(run_stars | later_stars) + self.run_imports[module] = ( + self._imports_of(module, run), + frozenset(run_stars), + ) + self.later_imports[module] = ( + self._imports_of(module, later), + frozenset(later_stars), + ) + + def _package(self, module): + return self.sources.get(module, (None,))[0] + + def _imports_of(self, module, names) -> frozenset: + """The package modules ``module`` may start, or reach, through + ``names`` (from ``_imports_in`` and ``_references_in``): each one and + the packages around it. The packages around ``module`` have started + before it. Star imports' submodules are added by ``reached``.""" + started = _started(module) + imported = set() + for name in names: + parts = name.split(".") + for end in range(1, len(parts) + 1): + prefix = ".".join(parts[:end]) + if prefix in self.sources and prefix not in started: + imported.add(prefix) + return frozenset(imported) + + def reached(self, module) -> frozenset: + """The package modules ``module`` may start while it runs, directly + or through those it starts.""" + if module not in self._reached: + seen, stack = set(), [module] + while stack: + current = stack.pop() + self.top(current) + fresh = ( + self.imported[current] + | self.star_loads(current) + | self.called(current) + ) - seen + seen |= fresh + stack.extend(fresh) + self._reached[module] = frozenset(seen) + return self._reached[module] + + def called(self, module) -> frozenset: + """The package modules ``module``'s code may start by calling or + reading code that may run later in the modules that ran before it: + the packages around it, and the package modules their code, as it + ran, imported, directly or through the modules it started.""" + around = _started(module) - {module} + if around not in self._ran: + ran = {package for package in around if package in self.sources} + stack = list(ran) + while stack: + current = stack.pop() + self.top(current) + names, bases = self.run_imports[current] + fresh = (names | self._star_children(bases)) - ran + ran |= fresh + stack.extend(fresh) + starts = set() + for current in ran: + names, bases = self.later_imports[current] + starts |= names | self._star_children(bases) + self._ran[around] = (frozenset(ran), frozenset(starts)) + return self._ran[around][1] - _started(module) + + def star_loads(self, module) -> frozenset: + """The modules ``module``'s star imports of packages may start below + them: what each name a package's ``__all__`` lists, in any version + of it, names below the package, with the packages on the way + (Python imports the package's name, a dot and the listed name, + dotted or not), or every module below it where ``__all__`` is built + otherwise. A star import of a package without ``__all__`` starts + none. The packages around ``module`` have started before it.""" + return self._star_children(self.star_bases[module]) - _started(module) + + def _index(self): + if self._below is None: + self._below, known = {}, set() + for name in self.sources: + known |= _started(name) + parts = name.split(".") + for end in range(1, len(parts)): + self._below.setdefault(".".join(parts[:end]), set()).add(name) + self._known = frozenset(known) + + def _star_children(self, bases) -> frozenset: + """The modules below them star imports from ``bases`` may start.""" + self._index() + loads = set() + for base in bases: + if base not in self.sources: + continue + self.top(base) + public = self.publics[base] + if public == EVERY_NAME: + loads |= self._below.get(base, set()) + elif isinstance(public, _Exports): + for listed in public.final | public.earlier: + parts = f"{base}.{listed}".split(".") + for end in range(base.count(".") + 2, len(parts) + 1): + path = ".".join(parts[:end]) + if path in self.sources: + loads.add(path) + return frozenset(loads) + + def cyclic(self, importer, base) -> bool: + """Whether ``importer``'s star import of ``base`` may run while + ``base`` is still running, and see only what it has bound so far: + when ``base`` may start ``importer``, or is it.""" + return importer == base or importer in self.reached(base) + + def guarded(self, key, cut, compute): + """``compute()``, or ``cut`` where the lookup meets itself.""" + if key in self._memo: + return self._memo[key] + if key in self._active or len(self._active) > self.max_depth: + if key not in self._active: + self.exhausted.add(key) + self._cuts += 1 + return cut + self._active.add(key) + cuts = self._cuts + try: + result = compute() + finally: + self._active.discard(key) + if self._cuts == cuts and not isinstance(key, _Alias): + self._memo[key] = result + return result + + def binding(self, module, name): + """(bound, targets, falls through) for ``module``'s top-level + ``name`` once it ran: whether any statement binds it, what to, and + whether it may be unbound after all.""" + + def compute(): + targets, bound, falls = self.top(module).own(name, None, True) + return bound, frozenset(targets), falls + + cut = (False, frozenset(), True) + return self.guarded(("binding", module, name), cut, compute) + + def expand(self, target, start=1) -> frozenset: + """``target`` and what else it names, where a prefix of it at least + ``start`` parts long is a package module or one ``external`` maps and + the next part is a name that module binds. + + The binding's targets, expanded, replace that prefix and name, and + only the rest is expanded further: a dotted name is both a module + path and a chain of attributes, and ``a.b`` may bind ``b`` to a path + through the submodule ``a.b``. + """ + + def compute(): + found = {target} + parts = target.split(".") + for index in range(start, len(parts)): + base, name = ".".join(parts[:index]), parts[index] + if base in self.sources: + bound = self.binding(base, name)[1] + else: + mapped = self.external.get(base, {}).get(name) + bound = {mapped} if mapped else set() + for head in self.expand_all(bound): + named = ".".join([head] + parts[index + 1 :]) + if named != target: + found |= self.expand(named, len(head.split("."))) + return frozenset(found) + + key = ("expand", target, start) + return self.guarded(key, frozenset({target}), compute) + + def expand_all(self, targets) -> set: + return set().union(*map(self.expand, targets)) + + def star_binds(self, base, name, importer): + """What ``from base import *`` in ``importer`` binds ``name`` to, or + None.""" + if base not in self.sources: + mapped = self.external.get(base, {}).get(name) + return {mapped} if mapped else None + self.top(base) + public = self.publics[base] + if public == DEFAULT_PUBLIC: + exported = not name.startswith("_") + elif public == EVERY_NAME: + exported = True + else: + exported = name in public.final or name in public.earlier + if not exported and not name.startswith("_"): + # In an import cycle it may run before base sets __all__, + # and then binds every public name base has bound. + exported = self.cyclic(importer, base) and self.binding(base, name)[0] + if not exported: + return None + bound, targets, _ = self.binding(base, name) + if bound: + return set(targets) + if isinstance(public, _Exports): + return {f"{base}.{name}"} # A submodule __all__ lists. + return None + + def star_binds_surely(self, base, name, importer) -> bool: + """Whether ``from base import *`` in ``importer``, which star_binds + says may bind ``name``, is sure to.""" + if base not in self.sources: + return True + if self.cyclic(importer, base): + return False # It may run before base binds anything. + public = self.publics[base] + if public == EVERY_NAME: + return False + if isinstance(public, _Exports) and name not in public.final: + return False # Only an earlier or removed __all__ held it. + bound, _, falls = self.binding(base, name) + return not (bound and falls) + + +def _build_scopes(tree, module, package, modules): + """(module scope, the innermost scope of every node, the (first line, + last line) of the innermost statement around every node) of ``tree``.""" + top = _Scope("module", "", None, modules) + scope_of, statement_of = {}, {} + + def assign(target, value, scope, conditional, evaluated_in=None): + """Bind ``target`` in ``scope`` to ``value``, evaluated in + ``evaluated_in`` (by default ``scope``).""" + scope_of[target] = scope + if isinstance(target, ast.Name): + line = target.lineno + alias = _alias(evaluated_in or scope, value, line) + scope.bind(target.id, line, alias, conditional) + elif ( + isinstance(target, (ast.Tuple, ast.List)) + and isinstance(value, (ast.Tuple, ast.List)) + and len(target.elts) == len(value.elts) + ): + for element, element_value in zip(target.elts, value.elts): + assign(element, element_value, scope, conditional, evaluated_in) + else: + visit(target, scope, conditional) + + def bind_arguments(inner, outer, arguments, line): + positional = arguments.posonlyargs + arguments.args + defaults = [None] * (len(positional) - len(arguments.defaults)) + defaults += arguments.defaults + pairs = list(zip(positional, defaults)) + pairs += list(zip(arguments.kwonlyargs, arguments.kw_defaults)) + pairs += [(arguments.vararg, None), (arguments.kwarg, None)] + for argument, default in pairs: + if argument is not None: + inner.bind(argument.arg, line, _alias(outer, default, line)) + + def declare(inner, body): + """Send names a global or nonlocal statement in ``body`` names to + the scope that binds them.""" + stack = list(body) + while stack: + node = stack.pop() + if isinstance(node, ast.Global): + for name in node.names: + inner.declared[name] = top + elif isinstance(node, ast.Nonlocal): + # The nearest enclosing function that makes the name local, + # wherever in its body it binds it; class bodies are skipped. + for name in node.names: + owner = inner.parent + while owner is not None and not ( + owner.kind in ("function", "lambda") + and name in owner.local_names + ): + owner = owner.parent + if owner is not None: + inner.declared[name] = owner + elif not isinstance(node, FUNCTION_NODES + (ast.ClassDef, ast.Lambda)): + stack.extend(ast.iter_child_nodes(node)) + + def visit_annotations(annotations, scope, line, conditional): + # Evaluated in the outer scope, with the statement or later (see + # _Scope.resolve); the statement's own bindings are not sure for + # them, as for any use in it. + if not annotations: + return + inner = _Scope("annotation", scope.qualname, scope, modules, line) + for annotation in annotations: + visit(annotation, inner, conditional) + + def visit_function(node, scope, conditional): + # Decorators and defaults are evaluated in the outer scope. + outer = list(getattr(node, "decorator_list", [])) + outer += node.args.defaults + [d for d in node.args.kw_defaults if d] + for child in outer: + visit(child, scope, conditional) + if not isinstance(node, ast.Lambda): + annotations = [node.returns] if node.returns else [] + annotations += [ + argument.annotation + for argument in ast.walk(node.args) + if isinstance(argument, ast.arg) and argument.annotation + ] + annotations += _type_parameter_values(node) + visit_annotations(annotations, scope, node.lineno, conditional) + name = getattr(node, "name", "") + if not isinstance(node, ast.Lambda): + top_level = scope.parent is None + target = f"{module}.{name}" if top_level else None + scope.bind(name, node.lineno, target, conditional) + qualname = name if scope.parent is None else f"{scope.qualname}.{name}" + kind = "lambda" if isinstance(node, ast.Lambda) else "function" + inner = _Scope(kind, qualname, scope, modules, node.lineno) + if getattr(node, "decorator_list", None): + # A decorator may call the function before its name is bound. A + # lambda's body is part of its own statement, which every use + # in it already treats as unfinished (resolve). + inner.unfinished = statement_span[-1] + inner.local_names = _function_locals(node) + body = node.body if isinstance(node.body, list) else [node.body] + declare(inner, body) + bind_arguments(inner, scope, node.args, node.lineno) + for child in body: + visit(child, inner) + + def visit_class(node, scope, conditional): + for child in node.decorator_list + node.bases: + visit(child, scope, conditional) + for keyword in node.keywords: + visit(keyword.value, scope, conditional) + visit_annotations(_type_parameter_values(node), scope, node.lineno, conditional) + top_level = scope.parent is None + target = f"{module}.{node.name}" if top_level else None + scope.bind(node.name, node.lineno, target, conditional) + qualname = node.name if top_level else f"{scope.qualname}.{node.name}" + inner = _Scope("class", qualname, scope, modules, node.lineno) + inner.unfinished = statement_span[-1] + inner.local_names = _block_locals(node.body) + declare(inner, node.body) + for child in node.body: + visit(child, inner) + + def visit_comprehension(node, scope, conditional): + # The first iterable is evaluated in the enclosing scope; the rest + # in the comprehension's own, reported under the enclosing name. + visit(node.generators[0].iter, scope, conditional) + # A generator expression runs the rest when it is iterated. + kind = "generator" if isinstance(node, ast.GeneratorExp) else "comprehension" + inner = _Scope(kind, scope.qualname, scope, modules, node.lineno) + # The rest may run any number of times, a generator expression's + # only as it is iterated, so a walrus in it may not bind. + for index, generator in enumerate(node.generators): + scope_of[generator] = inner + if index: + visit(generator.iter, inner, True) + assign(generator.target, None, inner, False) + for condition in generator.ifs: + visit(condition, inner, True) + if isinstance(node, ast.DictComp): + elements = [node.key, node.value] + else: + elements = [node.elt] + for element in elements: + visit(element, inner, True) + + # (first line, last line) of each statement being visited, innermost + # last; a definition's decorators are part of its statement. + statement_span = [(1, 1)] + + def visit(node, scope, conditional=False): + if not isinstance(node, ast.stmt): + return visit_node(node, scope, conditional) + lines = [node.lineno] + lines += [d.lineno for d in getattr(node, "decorator_list", [])] + statement_span.append((min(lines), node.end_lineno)) + try: + return visit_node(node, scope, conditional) + finally: + statement_span.pop() + + def visit_node(node, scope, conditional): + scope_of[node] = scope + statement_of[node] = statement_span[-1] + if isinstance(node, FUNCTION_NODES + (ast.Lambda,)): + return visit_function(node, scope, conditional) + if isinstance(node, ast.ClassDef): + return visit_class(node, scope, conditional) + if isinstance(node, COMPREHENSION_NODES): + return visit_comprehension(node, scope, conditional) + if isinstance(node, TYPE_ALIAS): + # Its value is evaluated when it is read, like an annotation. + scope_of[node.name] = scope + scope.bind(node.name.id, node.lineno, None, conditional) + values = [node.value] + _type_parameter_values(node) + return visit_annotations(values, scope, node.lineno, conditional) + if isinstance(node, ast.Assign): + visit(node.value, scope, conditional) + for target in node.targets: + assign(target, node.value, scope, conditional) + return + if isinstance(node, ast.AnnAssign): + body = scope.kind in ("module", "class") + if body and node.simple: + visit_annotations([node.annotation], scope, node.lineno, conditional) + else: + # 3.14 never evaluates this one; earlier versions do, with + # the statement, outside a function. + visit(node.annotation, scope, conditional) + if node.value is not None: + visit(node.value, scope, conditional) + elif body and isinstance(node.target, ast.Name): + # An annotation alone binds no value here; in a function it + # still makes the name local. + scope_of[node.target] = scope + return + return assign(node.target, node.value, scope, conditional) + if isinstance(node, ast.NamedExpr): + visit(node.value, scope, conditional) + # The target belongs to the scope around any comprehensions or + # annotations; the value is evaluated where the expression is. + owner = scope + while owner.kind in ("comprehension", "generator", "annotation"): + owner = owner.parent + return assign(node.target, node.value, owner, conditional, scope) + if isinstance(node, ast.Import): + for alias in node.names: + if alias.asname: + scope.bind(alias.asname, node.lineno, alias.name, conditional) + else: + root = alias.name.split(".")[0] + scope.bind(root, node.lineno, root, conditional) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + for alias in node.names: + if alias.name == "*": + scope.stars.append((node.lineno, base, conditional, module)) + else: + # A submodule or a name base binds: expand decides. + name = alias.asname or alias.name + scope.bind(name, node.lineno, f"{base}.{alias.name}", conditional) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store): + scope.bind(node.id, node.lineno, None, conditional) + elif isinstance(node, ast.Name) and isinstance(node.ctx, ast.Del): + scope.delete(node.id, node.lineno) + elif isinstance(node, ast.ExceptHandler) and node.name: + # Python deletes the name when the handler ends. + scope.bind(node.name, node.lineno, None, True) + scope.delete(node.name, node.end_lineno) + inside = conditional or isinstance(node, COMPOUND_NODES) + for child in ast.iter_child_nodes(node): + visit(child, scope, inside or _may_not_run(node, child)) + + visit(tree, top) + return top, scope_of, statement_of + + +def _reads_an_id(node) -> bool: + for child in ast.walk(node): + if isinstance(child, ast.Name) and child.id.endswith("_id"): + return True + if ( + isinstance(child, ast.Constant) + and isinstance(child.value, str) + and child.value.endswith("_id") + ): + return True + return False + + +def _is_xorshift(left, right, shift_of) -> bool: + """``x ^ (x >> k)`` or ``x ^ (x << k)``, in either order. + + ``shift_of`` gives the shifted operand of a shift, or None. + """ + for shifted, other in ((left, right), (right, left)): + operand = shift_of(shifted) + if operand is not None and ast.unparse(operand) == ast.unparse(other): + return True + return False + + +def _argument(call, position, keyword): + """The argument ``call`` passes at ``position`` or as ``keyword``.""" + if len(call.args) > position: + return call.args[position] + for passed in call.keywords: + if passed.arg == keyword: + return passed.value + return None + + +def _anchor(node, module): + """The package a literal, or the module's ``__name__``, names, else None + (callers read anything computed, ``__package__`` or ``__spec__.parent`` + too, as the module's own package).""" + if _literal(node) is not None: + return _literal(node) + if isinstance(node, ast.Name) and node.id == "__name__": + return module + return None + + +def _is_none(node) -> bool: + return isinstance(node, ast.Constant) and node.value is None + + +def _dict_entries(node) -> list: + """(key, value) for each entry of dict literal ``node``, in order, a + literal ``**`` dict's own entries in its place; a computed ``**`` entry + gives (None, the mapping).""" + entries = [] + for key, value in zip(node.keys, node.values): + if key is None and isinstance(value, ast.Dict): + entries += _dict_entries(value) + else: + entries.append((key, value)) + return entries + + +def _iterated(node) -> list: + """The items Python gets iterating ``node``, as far as it is literal: a + string's characters, a list's, tuple's or set's elements and a dict's + keys, a ``*`` or ``**`` entry's own in its place. An item that is not a + literal string is None; a computed iterable gives none.""" + if isinstance(node, (ast.List, ast.Tuple, ast.Set)): + items = [] + for element in node.elts: + if isinstance(element, ast.Starred): + items += _iterated(element.value) + else: + items.append(_literal(element)) + return items + if isinstance(node, ast.Dict): + return [_literal(key) for key, _ in _dict_entries(node)] + return list(_literal(node) or "") + + +def _globals_package(node, package, module): + """The package a relative ``__import__`` with ``globals`` ``node`` + imports from, as Python reads it: ``__package__``, else + ``__spec__.parent``, else ``__name__``, the package around it unless + ``__path__`` is set; None where it has neither, and Python raises. + A dict literal's literal ``**`` entries are read in place; anything + else (``globals()``, a computed mapping, or a dict literal with a + computed key or ``**`` entry) is read as the module's own globals.""" + if not isinstance(node, ast.Dict): + return package + pairs = _dict_entries(node) + if not all(isinstance(key, ast.Constant) for key, _ in pairs): + return package + # The last of a repeated key wins, as in the dict Python builds; a + # None value then reads as absent. Keys that aren't strings are + # never ones Python reads. + entries = {key.value: value for key, value in pairs} + given = {key: value for key, value in entries.items() if not _is_none(value)} + if "__package__" in given: + return _anchor(given["__package__"], module) or package + if "__spec__" in given: + return package + if "__name__" not in given: + return None + name = _anchor(given["__name__"], module) + if name is None: + return package + return name if "__path__" in entries else name.rpartition(".")[0] + + +def _dynamic_imports(call, called, package, module=None) -> list: + """Absolute names a call of a dynamic import function imports, where its + arguments are literals: ``base.*`` where an ``__import__`` ``fromlist`` + holds ``*``.""" + names = [] + if "importlib.import_module" in called: + name = _literal(_argument(call, 0, "name")) + given = _argument(call, 1, "package") + relative_to = _anchor(given, module) + if relative_to is None and given is not None and _literal(given) is None: + # A computed package is read as the module's own; without one, + # Python raises. + relative_to = None if _is_none(given) else package + if name and name.startswith("."): + level = len(name) - len(name.lstrip(".")) + names.append(_absolute(name.lstrip("."), level, relative_to)) + elif name: + names.append(name) + if "builtins.__import__" in called: + name = _literal(_argument(call, 0, "name")) + level = _argument(call, 4, "level") + level = level.value if isinstance(level, ast.Constant) else 0 + if name is not None and isinstance(level, int): + relative_to = _globals_package( + _argument(call, 1, "globals"), package, module + ) + base = _absolute(name, level, relative_to) if level else name + names.append(base) + items = _iterated(_argument(call, 3, "fromlist")) + names += [f"{base}.{item}" for item in items if item] + return names + + +# Code that may run after the statement holding it, when it is called or +# read, so after the module has run: as may annotations (deferred from 3.14), +# type parameters and ``type`` values. +LATER_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda, ast.GeneratorExp) +LATER_FIELDS = ("annotation", "type_params") + + +def _later_walk(tree): + """(node, whether it may run later) for every node of ``tree``: in a + function, lambda or generator expression (with its decorators, defaults + and first iterable, which run at once, to be safe), an annotation, type + parameters or a ``type`` alias.""" + stack = [(tree, False)] + while stack: + node, later = stack.pop() + yield node, later + later = later or isinstance(node, LATER_NODES) or isinstance(node, TYPE_ALIAS) + for field, value in ast.iter_fields(node): + for child in value if isinstance(value, list) else [value]: + if isinstance(child, ast.AST): + stack.append((child, later or field in LATER_FIELDS)) + + +def _imports_in(tree, package, module=None): + """((names, star bases) of the imports that run with ``tree``'s module, + (names, star bases) of those in code that may run later (``_later_walk``). + + Names are absolute, as an import, or a call whose literal arguments name + a module as ``importlib.import_module`` or ``__import__`` would read + them, imports them, whatever name the call is made under: an alias, a + re-export or ``getattr`` may make it one. Star bases are the packages a + star import, or ``__import__`` with ``*`` in its ``fromlist``, loads + from.""" + found = {False: (set(), set()), True: (set(), set())} + for node, later in _later_walk(tree): + names, stars = found[later] + if isinstance(node, ast.Import): + names.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + names.add(base) + names.update(f"{base}.{alias.name}" for alias in node.names) + if any(alias.name == "*" for alias in node.names): + stars.add(base) + elif isinstance(node, ast.Call): + for name in _dynamic_imports(node, DYNAMIC_IMPORTS, package, module): + names.add(name) + if name.endswith(".*"): + stars.add(name[: -len(".*")]) + return found[False], found[True] + + +# What _references_in's rules read, with the paths on the way. +REFERENCE_NAMES = frozenset( + {"getattr", "builtins", "builtins.getattr", "sys", "sys.modules", "sys.modules.get"} +) + + +def _references_in(tree, package, stars, known, module=None): + """(paths code that runs with ``tree``'s module may name, paths code that + may run later may name; ``_later_walk``). + + Another module may have loaded a module, which is then an attribute of + its package, so code can reach it, and call its functions, without + importing it. Each name, attribute chain, ``getattr`` with a literal + name, ``sys.modules`` read with a literal key (``[]`` or ``get``) and + literal dynamic import (the module or its top package, which it + returns), and each branch of a conditional expression, ``and`` or + ``or``, names every path its first name may: what any import in the + module, or assignment of such an expression (or walrus), binds the name + to (an assignment may name the name itself, as in ``x = x.sub``), the name + itself, the name below each package in ``stars`` (a star import of a + package binds its loaded submodules), and in a package's ``__init__`` + the name below the package. Only paths in ``known`` (each package module + and the packages around it), from which a module may yet be named, and + those the rules read are kept. Which paths are package modules is the + caller's to say.""" + bound = {} + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + bound.setdefault(name, []).append(alias.name if alias.asname else name) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + for alias in node.names: + if alias.name != "*": + name = alias.asname or alias.name + bound.setdefault(name, []).append(f"{base}.{alias.name}") + elif isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Name) and node.value is not None: + bound.setdefault(target.id, []).append(node.value) + around = list(stars) + ([package] if package and module in (None, package) else []) + keep = known | REFERENCE_NAMES + names = {} + + def name_paths(name): + if name not in names: + names[name] = ({name} | {f"{base}.{name}" for base in around}) & keep + return names[name] + + def paths(node): + return _paths(node) & keep + + def _paths(node): + if isinstance(node, ast.Name): + return name_paths(node.id) + if isinstance(node, ast.Attribute): + return {f"{path}.{node.attr}" for path in paths(node.value)} + if isinstance(node, ast.Subscript): + key = _literal(node.slice) + if key and "sys.modules" in paths(node.value): + return {key} + return set() + if isinstance(node, ast.IfExp): + return paths(node.body) | paths(node.orelse) + if isinstance(node, ast.BoolOp): + return set().union(*map(paths, node.values)) + if isinstance(node, ast.NamedExpr): + return paths(node.value) + if not isinstance(node, ast.Call): + return set() + called = paths(node.func) + if called & {"getattr", "builtins.getattr"} and len(node.args) >= 2: + attribute = _literal(node.args[1]) + if attribute: + return {f"{path}.{attribute}" for path in paths(node.args[0])} + if "sys.modules.get" in called and node.args and _literal(node.args[0]): + return {_literal(node.args[0])} + returned = set() + for name in _dynamic_imports(node, DYNAMIC_IMPORTS, package, module): + if not name.endswith(".*"): + returned |= {name, name.split(".")[0]} + return returned + + # Bind each name to what its imports and assignments name, until no + # path is new: kept paths are finite, so this ends. + changed = True + while changed: + changed = False + for name, values in bound.items(): + for value in values: + fresh = ({value} & keep if isinstance(value, str) else paths(value)) - ( + name_paths(name) + ) + if fresh: + names[name] |= fresh + changed = True + + found = {False: set(), True: set()} + for node, later in _later_walk(tree): + if isinstance(node, (ast.Name, ast.Attribute, ast.Subscript, ast.Call)): + found[later] |= paths(node) + return found[False], found[True] + + +def _started(module) -> frozenset: + """``module`` and the packages around it, which start before it runs.""" + parts = module.split(".") + return frozenset(".".join(parts[:end]) for end in range(1, len(parts) + 1)) + + +class Analysis(NamedTuple): + module: str + # (line, enclosing scope, kind), without draw helper calls. + findings: list + # (line, enclosing scope, targets) for each use that may name something + # in the package. + references: list + # Names of the module's top-level functions, also those defined in an + # if or try. + functions: list + # (line, absolute module name) for each import, static or literal dynamic. + imports: list + + +def analyse( + source: str, + filename: str = "", + module: str = "m", + package: str | None = None, + modules: Modules | None = None, +) -> Analysis: + """Draw patterns, package references and imports of one module. + + ``modules`` resolves names imported from other package modules; without + it the module stands alone. + """ + tree = ast.parse(source, filename=filename) + if modules is None: + modules = Modules({module: (package, lambda: source)}) + top, scope_of, statement_of = _build_scopes(tree, module, package, modules) + modules.register(module, top, tree) + parents = { + child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node) + } + root_package = module.split(".")[0] + "." + findings, references, imports = [], [], [] + + def scope_for(node): + while node not in scope_of: + node = parents[node] + return scope_of[node] + + def statement_for(node): + while node not in statement_of: + node = parents[node] + return statement_of[node] + + def report(node, kind): + findings.append((node.lineno, scope_for(node).qualname, kind)) + + def report_targets(node, targets): + for kind in sorted({_classify(target) for target in targets} - {None}): + report(node, kind) + + def targets_of(node) -> set: + dotted = _dotted(node) + if dotted is None: + return set() + root, attributes = dotted + return modules.expand_all( + ".".join([target] + attributes) + for target in scope_for(node).resolve( + root, node.lineno, statement=statement_for(node) + ) + ) + + def shift_of(node): + if isinstance(node, ast.BinOp) and isinstance( + node.op, (ast.RShift, ast.LShift) + ): + return node.left + if isinstance(node, ast.Call) and node.args: + if targets_of(node.func) & SHIFT_FUNCTIONS: + return node.args[0] + return None + + def check_import(node, name): + imports.append((node.lineno, name)) + if _is_stochastic(name): + report(node, "stochastic import") + elif name.split(".")[0] in HASH_MODULES: + report(node, "hash library") + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + check_import(node, alias.name) + elif isinstance(node, ast.ImportFrom): + base = _absolute(node.module or "", node.level, package) + names = [base] + [f"{base}.{alias.name}" for alias in node.names] + imports.extend((node.lineno, name) for name in names) + if any(map(_is_stochastic, names)): + report(node, "stochastic import") + elif base.split(".")[0] in HASH_MODULES: + report(node, "hash library") + elif isinstance(node, (ast.Name, ast.Attribute)) and isinstance( + node.ctx, ast.Load + ): + parent = parents.get(node) + if isinstance(parent, ast.Attribute) and parent.value is node: + continue # Only the whole chain counts. + targets = targets_of(node) + report_targets(node, targets) + named = frozenset(t for t in targets if t.startswith(root_package)) + if named: + references.append((node.lineno, scope_for(node).qualname, named)) + if isinstance(node, ast.Call): + func = node.func + called = targets_of(func) + if called & DYNAMIC_IMPORTS: + for name in _dynamic_imports(node, called, package, module): + check_import(node, name) + if "builtins.getattr" in called and len(node.args) >= 2: + attribute = _literal(node.args[1]) + if attribute: + owners = targets_of(node.args[0]) + report_targets( + node, + modules.expand_all(f"{owner}.{attribute}" for owner in owners), + ) + if called & XOR_FUNCTIONS and len(node.args) == 2: + if _is_xorshift(*node.args, shift_of): + report(node, "xorshift") + if called & MOD_FUNCTIONS and node.args and _reads_an_id(node.args[0]): + report(node, "id modulo") + if isinstance(func, ast.Attribute): + keywords = {keyword.arg for keyword in node.keywords} + if func.attr in SAMPLING_METHODS or ( + func.attr == "sample" + and (node.args or keywords & SAMPLING_KEYWORDS) + ): + report(node, "sampling call") + elif isinstance(node, ast.Constant): + value = node.value + if ( + isinstance(value, int) + and not isinstance(value, bool) + and value in HASH_CONSTANTS + ): + report(node, "hash constant") + elif isinstance(node, (ast.BinOp, ast.AugAssign)): + left = node.left if isinstance(node, ast.BinOp) else node.target + right = node.right if isinstance(node, ast.BinOp) else node.value + if isinstance(node.op, ast.BitXor) and _is_xorshift(left, right, shift_of): + report(node, "xorshift") + if isinstance(node.op, ast.Mod) and _reads_an_id(left): + report(node, "id modulo") + functions = [ + node.name + for node in ast.walk(tree) + if isinstance(node, FUNCTION_NODES) and scope_of.get(node) is top + ] + return Analysis(module, findings, references, functions, imports) + + +def helper_calls(analysis: Analysis, helpers) -> list: + """(line, scope, "draw helper call") for each use of a draw helper.""" + return [ + (line, scope, "draw helper call") + for line, scope, targets in analysis.references + if any(_matches(target, helpers) for target in targets) + ] + + +def draw_helpers(analyses: dict, exempt: dict) -> set: + """Top-level functions with a finding ``exempt`` does not cover. + + A function that uses one of them is one too, so this is a fixed point + over the package. + """ + helpers = set() + while True: + added = set() + for relative, analysis in analyses.items(): + allowed = exempt.get(relative, {}) + counted = Counter( + (scope, kind) + for _, scope, kind in analysis.findings + + helper_calls(analysis, helpers) + ) + for name in analysis.functions: + target = f"{analysis.module}.{name}" + if target not in helpers and any( + count > allowed.get(key, 0) + for key, count in counted.items() + if key[0] == name or key[0].startswith(name + ".") + ): + added.add(target) + if not added: + return helpers + helpers |= added + + +def draw_findings(source: str, filename: str = "", exempt=None) -> list: + """(line, enclosing scope, kind) for every draw pattern in ``source``. + + ``exempt`` is the file's EXEMPT entries, which decide which of its + functions are draw helpers. + """ + analysis = analyse(source, filename) + helpers = draw_helpers({filename: analysis}, {filename: exempt or {}}) + return sorted(analysis.findings + helper_calls(analysis, helpers)) + + +def count_findings(findings_by_file: dict) -> dict: + """{file: {(scope, kind): [lines]}} from {file: draw_findings(...)}.""" + counted = {} + for relative, findings in findings_by_file.items(): + for line, scope, kind in findings: + counted.setdefault(relative, {}).setdefault((scope, kind), []).append(line) + return counted + + +def check_allowlists(counted: dict, ratchet: dict, exempt: dict): + """(violations, stale entries) of counted findings against the lists.""" + violations, stale = [], [] + allowed = {} + for allowlist in (ratchet, exempt): + for relative, entries in allowlist.items(): + for key, count in entries.items(): + allowed[(relative, key)] = allowed.get((relative, key), 0) + count + for relative, entries in sorted(counted.items()): + for (scope, kind), lines in sorted(entries.items()): + limit = allowed.get((relative, (scope, kind)), 0) + if len(lines) > limit: + violations.append( + f"{relative}:{','.join(map(str, lines))} in {scope}: {kind} " + f"({len(lines)} found, {limit} allowed)" + ) + for (relative, (scope, kind)), limit in sorted(allowed.items()): + found = len(counted.get(relative, {}).get((scope, kind), [])) + if found < limit: + stale.append( + f"{relative} in {scope}: {kind} ({limit} allowed, {found} found)" + ) + return violations, stale + + +def package_modules(): + """(relative path, module name, package name, path) of every module.""" + for path in sorted(PACKAGE.rglob("*.py")): + if TESTS in path.parents: + continue + relative = path.relative_to(PACKAGE).as_posix() + dotted = f"{PACKAGE.name}.{relative.removesuffix('.py').replace('/', '.')}" + if dotted.endswith(".__init__"): + module = dotted.removesuffix(".__init__") + yield relative, module, module, path + else: + yield relative, dotted, dotted.rsplit(".", 1)[0], path + + +def _reader(path): + return lambda: path.read_text(encoding="utf-8") + + +class PackageScan(NamedTuple): + counts: dict + helpers: set + imports: dict + modules: Modules + + +@cache +def package_scan() -> PackageScan: + """Every module analysed once per test session.""" + listed = list(package_modules()) + modules = Modules( + {module: (package, _reader(path)) for _, module, package, path in listed} + ) + analyses = { + relative: analyse( + path.read_text(encoding="utf-8"), relative, module, package, modules + ) + for relative, module, package, path in listed + } + helpers = draw_helpers(analyses, EXEMPT) + counts = count_findings( + { + relative: analysis.findings + helper_calls(analysis, helpers) + for relative, analysis in analyses.items() + } + ) + imports = {relative: analysis.imports for relative, analysis in analyses.items()} + return PackageScan(counts, helpers, imports, modules) + + +@pytest.fixture(scope="module", autouse=True) +def _release_package_scan(): + """Free the package scan when this module's tests finish.""" + yield + package_scan.cache_clear() + + +def _kinds(source: str) -> set: + return {kind for _, _, kind in draw_findings(source)} + + +def _on_this_python(cases, minor=-1) -> list: + """The cases whose syntax this Python parses. + + Each case gives the Python 3 minor version it needs at index ``minor``. + Filtering them as the tests are collected, rather than skipping them, + leaves no skipped outcome on an older Python. + """ + return [case for case in cases if sys.version_info >= (3, case[minor])] + + +@pytest.mark.parametrize( + "source, kind", + [ + ("import random\n", "stochastic import"), + ("from random import uniform\nuniform()\n", "random source"), + ("import random as rnd\nrnd.random()\n", "random source"), + ("import secrets\n", "stochastic import"), + ("import numpy.random as npr\nnpr.uniform()\n", "random source"), + ("from numpy import random as r\nr.random(3)\n", "random source"), + ("from numpy import random as r\n", "stochastic import"), + ("from numpy.random import default_rng as g\ng(43)\n", "random source"), + ("np.random.seed(1)\n", "random source"), + ("import numpy as n\nn.random.default_rng(0)\n", "random source"), + ("def f():\n return np.random.random(3)\n", "random source"), + ("import uuid\nuuid.uuid4()\n", "random source"), + ("import os\nos.urandom(8)\n", "random source"), + # Aliases, star imports and scopes. + ("import numpy as np\nxp = np\nxp.random.uniform()\n", "random source"), + ("r = np.random\ndef f():\n return r.uniform()\n", "random source"), + ("xp, k = np, 1\nxp.random.uniform()\n", "random source"), + ("def f(xp=np):\n return xp.random.uniform()\n", "random source"), + ("from numpy import *\nrandom.uniform()\n", "random source"), + ("getattr(np, 'random').uniform()\n", "random source"), + ( + "import numpy as np\n" + "def f():\n" + " return [np for np in np.random.uniform(size=2)]\n", + "random source", + ), + ("if (r := np.random):\n r.uniform()\n", "random source"), + # A walrus in a comprehension binds outside it, to a value read inside. + ( + "import numpy as np\n[(lib := np) for _ in [0]]\nlib.random.uniform()\n", + "random source", + ), + # A name may refer to any binding that may be the one read. + ( + "from policyengine_core.model_api import np as lib\nlib.random.uniform()\n", + "random source", + ), + # An attribute of a module is what the module binds under the name. + ( + "import policyengine_core.model_api as api\napi.np.random.uniform()\n", + "random source", + ), + ( + "from policyengine_core import model_api\nmodel_api.np.random.uniform()\n", + "random source", + ), + ( + "import policyengine_core.model_api as api\nlib = api.np\nlib.random.uniform()\n", + "random source", + ), + ( + "import numpy as lib\n" + "values = [lib.random.uniform() for _ in range(1)]\n" + "lib = object()\n", + "random source", + ), + ( + "import numpy as np\n" + "class Outer:\n" + " np = object()\n" + " class Inner:\n" + " value = np.random.uniform()\n", + "random source", + ), + ( + "class Outer:\n hash = 1\n class Inner:\n key = hash('x')\n", + "builtin hash", + ), + ("import numpy as lib; alias = lib; alias.random.uniform()\n", "random source"), + ( + "try:\n" + " import numpy as lib\n" + "except ImportError:\n" + " lib = None\n" + "lib.random.uniform()\n", + "random source", + ), + ( + "def setup():\n" + " global lib\n" + " import numpy as lib\n" + "def f():\n" + " return lib.random.uniform()\n", + "random source", + ), + ( + "def outer():\n" + " lib = None\n" + " def setup():\n" + " nonlocal lib\n" + " import numpy as lib\n" + " return lib.random.uniform()\n", + "random source", + ), + # A global name skips the functions between it and the module. + ( + "import numpy as lib\n" + "def outer():\n" + " lib = None\n" + " def inner():\n" + " global lib\n" + " return lib.random.random()\n" + " return inner\n", + "random source", + ), + # nonlocal names the nearest function that binds the name, not the + # nearest function, and wherever in its body the binding is. + ( + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " def inner():\n" + " nonlocal lib\n" + " lib.random.random()\n" + " lib = None\n" + " inner()\n" + " middle()\n", + "random source", + ), + ( + "def outer():\n" + " def middle():\n" + " def inner():\n" + " nonlocal lib\n" + " lib = None\n" + " return lib.random.random()\n" + " return inner\n" + " import numpy as lib\n" + " return middle\n", + "random source", + ), + # A walrus in a nested function's annotation binds in the function + # around it, so middle binds lib and inner reads numpy. + ( + "import numpy as np\n" + "def outer():\n" + " lib = None\n" + " def middle():\n" + " def annotated(x: (lib := np)):\n" + " pass\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + "random source", + ), + ( + "import numpy as np\n" + "def outer():\n" + " lib = None\n" + " def middle():\n" + " def annotated() -> (lib := np):\n" + " pass\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + "random source", + ), + # A walrus in a lambda binds in the lambda, even inside a + # comprehension, so middle doesn't bind lib. + ( + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " [(lambda: (lib := None)) for marker in ()]\n" + " def inner():\n" + " nonlocal lib\n" + " lib.random.random()\n" + " lib = None\n" + " inner()\n" + " middle()\n", + "random source", + ), + ("if flag:\n hash = str\nkey = hash('x')\n", "builtin hash"), + ("key = hash('x')\nhash = str\n", "builtin hash"), + # A deletion, or the end of an except clause, may unbind a shadow. + ("hash = str\ndel hash\nkey = hash('x')\n", "builtin hash"), + ( + "class A:\n hash = str\n del hash\n key = hash('x')\n", + "builtin hash", + ), + ( + "hash = str\n" + "try:\n" + " pass\n" + "except ValueError as hash:\n" + " pass\n" + "key = hash('x')\n", + "builtin hash", + ), + ( + "hash = str\n" + "def clear():\n" + " global hash\n" + " del hash\n" + "clear()\n" + "key = hash('x')\n", + "builtin hash", + ), + # A function's del of a global may run after a later binding. + ( + "def clear():\n" + " global hash\n" + " del hash\n" + "hash = str\n" + "clear()\n" + "key = hash('x')\n", + "builtin hash", + ), + ( + "def clear():\n" + " global hash\n" + " if flag:\n" + " del hash\n" + "hash = str\n" + "clear()\n" + "key = hash('x')\n", + "builtin hash", + ), + ( + "def outer():\n" + " def clear():\n" + " global hash\n" + " del hash\n" + " return clear\n" + "hash = str\n" + "outer()()\n" + "def consumer(value):\n" + " return hash(value)\n", + "builtin hash", + ), + # Code may run before the statement creating it binds its names: a + # decorator may call the function, a class body runs before its + # name is bound, and an assignment's value may call a lambda in it. + ( + "def eager(f):\n" + " return f('x')\n" + "@eager\n" + "def hash(value):\n" + " return hash(value)\n", + "builtin hash", + ), + ("class hash:\n answer = hash('x')\n", "builtin hash"), + ("hash = (lambda value: hash(value))('x')\n", "builtin hash"), + # 3.14 evaluates annotations when they are read (PEP 649), after any + # later deletion; up to 3.13 they run with their statement. + ("hash = str\ndef f(x: hash('x')):\n pass\ndel hash\n", "builtin hash"), + ("hash = str\ndef f() -> hash('x'):\n pass\ndel hash\n", "builtin hash"), + ( + "class C:\n" + " hash = str\n" + " def f(self, x: hash('x')):\n" + " pass\n" + " del hash\n", + "builtin hash", + ), + ( + "class C:\n hash = str\n y: hash('x') = 1\n del hash\n", + "builtin hash", + ), + ("hash = str\ny: hash('x') = 1\ndel hash\n", "builtin hash"), + ("def hash(x: hash('x')):\n pass\n", "builtin hash"), + # A statement binds its names only after evaluating them. + ("hash = (\n hash('x')\n)\n", "builtin hash"), + ("def hash(\n value=hash('x'),\n):\n return value\n", "builtin hash"), + ("class hash(\n make(hash('x')),\n):\n pass\n", "builtin hash"), + ("(\n hash\n) = (\n hash('x')\n)\n", "builtin hash"), + # An annotation alone binds no value in a module or class body. + ("hash: int\nkey = hash('x')\n", "builtin hash"), + ( + "import numpy as lib\nclass C:\n lib: object\n answer = lib.random.uniform()\n", + "random source", + ), + # A function may run before a later binding, or after a deletion. + ( + "def consumer(value):\n" + " return hash(value)\n" + "answer = consumer('x')\n" + "hash = str\n", + "builtin hash", + ), + ("hash = str\ndef f(x):\n return hash(x)\ndel hash\n", "builtin hash"), + # A walrus in a generator expression binds around it. + ( + "import numpy as np\nlist((lib := np) for _ in 'x')\nlib.random.uniform()\n", + "random source", + ), + ("import importlib\nimportlib.import_module('random')\n", "stochastic import"), + ( + "import importlib\nimportlib.import_module(name='random')\n", + "stochastic import", + ), + ("__import__('numpy.random')\n", "stochastic import"), + ("__import__('numpy', fromlist=['random'])\n", "stochastic import"), + # Python iterates a fromlist, unpacked entries too. + ("__import__('numpy', fromlist={**{'random': None}})\n", "stochastic import"), + ("__import__('numpy', fromlist=[*('random',)])\n", "stochastic import"), + ("__import__('numpy', fromlist={*'x', *{'random': 0}})\n", "stochastic import"), + # At any depth. + ( + "__import__('numpy', fromlist={**{**{'random': None}}})\n", + "stochastic import", + ), + ("__import__('numpy', fromlist=[*[*('random',)]])\n", "stochastic import"), + ("from importlib import import_module as load\nload('zlib')\n", "hash library"), + # Hashes. + ('key = hash("x")\n', "builtin hash"), + ("keys = map(hash, names)\n", "builtin hash"), + ("import builtins\nbuiltins.hash(1)\n", "builtin hash"), + ( + "class A:\n hash = 1\n def m(self, x):\n return hash(x)\n", + "builtin hash", + ), + ("import hashlib\n", "hash library"), + ("from zlib import crc32\n", "hash library"), + ("import hashlib\ndef f(b):\n return hashlib.sha256(b)\n", "hash use"), + ("from zlib import crc32\ncrc32(b'x') % 12\n", "hash use"), + ("pd.util.hash_pandas_object(df)\n", "hash use"), + ("m = np.uint64(0x94D049BB133111EB)\n", "hash constant"), + ("gamma = 11400714819323198485\n", "hash constant"), + ("h = 0xCBF29CE484222325\n", "hash constant"), + ("x = x ^ (x >> 30)\n", "xorshift"), + ("x = (x << 13) ^ x\n", "xorshift"), + ("x ^= x >> 30\n", "xorshift"), + ("x = np.bitwise_xor(x, np.right_shift(x, 30))\n", "xorshift"), + # Sampling and ids. + ("df = df.sample(frac=0.5, random_state=1)\n", "sampling call"), + ("df = df.sample(5)\n", "sampling call"), + ("rng.shuffle(values)\n", "sampling call"), + ("pick = rng.choice(5)\n", "sampling call"), + ("month = person_id % 12\n", "id modulo"), + ('half = person("person_id", period) % 2\n', "id modulo"), + ("person_id %= 12\n", "id modulo"), + ("month = np.mod(person_id, 12)\n", "id modulo"), + ("month, _ = divmod(person_id, 12)\n", "id modulo"), + # A call of a function that draws. + ( + "def mix(x):\n return x ^ (x >> 3)\ndef f(x):\n return mix(x)\n", + "draw helper call", + ), + ], +) +def test_detector_flags_each_draw_pattern(source, kind): + assert kind in _kinds(source) + + +@pytest.mark.parametrize( + "source", + [ + "from typing import Generator\ndef f(Generator):\n return Generator\n", + "random = 3\nrandom + 1\n", + "def f(random):\n return random.random()\n", + "def hash(x):\n return x\nhash(1)\n", + # A binding sure to run before a function's def hides the builtin + # from its body; so does a binding after a deletion. + "def hash(x):\n return x\ndef f(x):\n return hash(x)\n", + "hash = str\nclass A:\n def m(self, x):\n return hash(x)\n", + "hash = str\ndel hash\nhash = repr\nkey = hash('x')\n", + "def f(x):\n hash = str\n del hash\n return x\n", + # Deleting a local leaves it unbound (UnboundLocalError); the + # builtin stays hidden. + "def f():\n hash = str\n del hash\n return hash('x')\n", + # Nothing can call an undecorated function before its def binds it. + "def hash(x):\n return hash(x - 1) if x else 0\nhash(3)\n", + "def f(hash):\n return hash(1)\n", + "class A:\n def hash(self):\n return 1\n def m(self):\n" + " return self.hash()\n", + '"""np.random.seed is banned"""\nlabel = "np.random"\n', + "obj.random_seed\nobj.sample()\n", + "months = np.ceil(weeks * 12 / 52)\nposition = (month - 1 - start) % 12\n", + "flags = x ^ y\nbits = x >> 3\nbig = 0xFFFFFFFFFFFFFFFF\n", + "rank = person.get_rank(person.household, person_id)\n", + "import numpy as np\nnp.random_like = 1\nvalue = np.where(a, b, c)\n", + "def f():\n np = object()\n return np.random\n", + "xs = [random for random in range(3)]\n", + "xp = object()\nxp.random.uniform()\n", + "getattr(np, 'where')(a, b, c)\n", + "import importlib\nimportlib.import_module('numpy')\n", + "def helper(x):\n return x + 1\ndef f(x):\n return helper(x)\n", + # A binding sure to have run hides the builtin. + "hash = str\nkey = hash('x')\n", + "class A:\n hash = str\n key = hash('x')\n", + "def f():\n hash = str\n return hash('x')\n", + # An annotation reads a binding sure to have run before its + # statement; a deferred one in a class body also sees the class. + "hash = str\ndef f(x: hash('x')):\n pass\n", + "class C:\n hash = str\n def f(self, x: hash('x')):\n pass\n", + "hash = str\ny: hash('x') = 1\n", + # A statement's bindings count for the statements after it. + "hash = str\nkey = (\n hash('x')\n)\n", + "class A:\n hash = str\n key = (\n hash('x')\n )\n", + "def hash(x):\n return (\n hash(x - 1)\n ) if x else 0\nhash(1)\n", + # An annotated value binds; in a function an annotation alone makes + # the name local (UnboundLocalError), hiding the builtin. + "hash: object = str\nkey = hash('x')\n", + "def f():\n hash: object\n return hash('x')\n", + # The walrus value is the comprehension's own np, not numpy. + "import numpy as np\n[(lib := np) for np in [object()]]\nlib.random.uniform()\n", + # nonlocal stops at the nearest function binding the name. + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " lib = object()\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + # A walrus in a lambda's default binds where the comprehension runs, + # so in middle. + "def outer():\n" + " import numpy as lib\n" + " def middle():\n" + " [(lambda x=(lib := None): x) for marker in ()]\n" + " def inner():\n" + " nonlocal lib\n" + " return lib.random.random()\n" + " return inner\n" + " return middle\n", + ], +) +def test_detector_skips_deterministic_code(source): + assert draw_findings(source) == [] + + +# Python runs these when they are used, not where they are written, so a +# later deletion of a shadowing global counts; a list comprehension runs at +# once. Each needs the syntax of the Python 3 minor version given. +@pytest.mark.parametrize( + "source, reaches_builtin, minor", + _on_this_python( + [ + ("hash = str\ng = (hash(v) for v in 'x')\ndel hash\nlist(g)\n", True, 11), + ("hash = str\ng = (hash(v) for v in 'x')\nlist(g)\n", False, 11), + ("hash = str\nkeys = [hash(v) for v in 'x']\ndel hash\n", False, 11), + ("hash = str\ntype X = hash('x')\ndel hash\nX.__value__\n", True, 12), + ("hash = str\ntype X = hash('x')\nX.__value__\n", False, 12), + ( + "hash = str\n" + "type X[T: hash('x')] = list[T]\n" + "del hash\n" + "X.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "hash = str\n" + "def f[T: hash('x')]():\n" + " pass\n" + "del hash\n" + "f.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "hash = str\n" + "class C[T: hash('x')]:\n" + " pass\n" + "del hash\n" + "C.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "class C:\n" + " hash = str\n" + " def m[T: hash('x')](self):\n" + " pass\n" + " del hash\n" + "C.m.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + "hash = str\n" + "def f[T = hash('x')]():\n" + " pass\n" + "del hash\n" + "f.__type_params__[0].__default__\n", + True, + 13, + ), + ] + ), +) +def test_code_python_runs_when_used_sees_later_deletions( + source, reaches_builtin, minor +): + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in draw_findings(source)} == expected + + +# Each is the body of a function outer that binds hash = str. A class body +# that binds hash anywhere, even by an annotation alone, a del, or a later, +# conditional or failed binding, reads it from its own namespace, then the +# module's, so here the builtin; so does what the body evaluates, its +# methods' defaults and annotations and its first comprehension iterables +# included. Code nested in the class reads outer's. Each needs the syntax of +# the Python 3 minor version given. +@pytest.mark.parametrize( + "body, reaches_builtin, minor", + _on_this_python( + [ + (" class C:\n key = hash('x')\n hash = int\n", True, 11), + (" class C:\n hash: int\n key = hash('x')\n", True, 11), + ( + " class C:\n" + " if False:\n" + " hash = int\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " hash = int\n" + " del hash\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " try:\n" + " raise ValueError\n" + " except ValueError as hash:\n" + " pass\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " try:\n" + " del hash\n" + " except NameError:\n" + " pass\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " try:\n" + " from no_such_module_anywhere import hash\n" + " except ImportError:\n" + " pass\n" + " key = hash('x')\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " y: hash('x')\n" + " C.__annotations__\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " def m(self, x: hash('x')):\n" + " pass\n" + " C.m.__annotations__\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " def m(self, x=hash('x')):\n" + " pass\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " keys = [v for v in [hash('x')]]\n", + True, + 11, + ), + ( + " class C:\n" + " hash: int\n" + " def m[T: hash('x')](self):\n" + " pass\n" + " C.m.__type_params__[0].__bound__\n", + True, + 12, + ), + ( + " class C:\n" + " hash: int\n" + " type X = hash('x')\n" + " C.X.__value__\n", + True, + 12, + ), + # Past a nonlocal statement around the class. + ( + " def middle():\n" + " nonlocal hash\n" + " class C:\n" + " hash: int\n" + " key = hash('x')\n" + " middle()\n", + True, + 11, + ), + # The class binds hash first, or not at all, or the read is in code + # nested in the class: outer's str. + (" class C:\n hash = str\n key = hash('x')\n", False, 11), + (" class C:\n key = hash('x')\n", False, 11), + ( + " class C:\n" + " hash: int\n" + " def m(self):\n" + " return hash('x')\n" + " C().m()\n", + False, + 11, + ), + ( + " class C:\n hash: int\n keys = [hash(v) for v in 'x']\n", + False, + 11, + ), + ] + ), +) +def test_a_class_body_that_binds_a_name_reads_it_from_the_module( + body, reaches_builtin, minor +): + findings = draw_findings("def outer():\n hash = str\n" + body) + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in findings} == expected + + +def test_a_reform_factory_class_reads_a_name_it_annotates_from_the_module(): + # An annotation alone makes lib local to the class body, which then + # reads numpy from the module, not the factory's None. + source = ( + "import numpy as lib\n" + "def create_reform():\n" + " lib = None\n" + " class reform_variable:\n" + " lib: object\n" + " value = lib.random.uniform()\n" + " return reform_variable\n" + ) + assert [kind for _, _, kind in draw_findings(source)] == ["random source"] + + +# A walrus binds only if it runs: in a comprehension it may run any number +# of times, in a generator expression only as it is iterated, past the first +# operand of and, or or a chained comparison only if those reach it, and in +# a conditional expression only on its branch. So these look further out, +# here to the builtin. A walrus where it is sure to run binds. +@pytest.mark.parametrize( + "source, reaches_builtin", + [ + ("[(hash := str) for _ in ()]\nkey = hash('x')\n", True), + ("g = ((hash := str) for _ in 'x')\nkey = hash('x')\nlist(g)\n", True), + ("[v for v in '' if (hash := str)]\nkey = hash('x')\n", True), + ("{(hash := str): v for v in ()}\nkey = hash('x')\n", True), + ("flag = False\nflag and (hash := str)\nkey = hash('x')\n", True), + ("flag = True\nflag or (hash := str)\nkey = hash('x')\n", True), + ("1 < 0 < (hash := str)\nkey = hash('x')\n", True), + ("(hash := str) if False else None\nkey = hash('x')\n", True), + ("(hash := str)\nkey = hash('x')\n", False), + ("(hash := str) and None\nkey = hash('x')\n", False), + ("None if (hash := str) else None\nkey = hash('x')\n", False), + ("[] == (hash := str)\nkey = hash('x')\n", False), + ], +) +def test_a_walrus_that_may_not_run_binds_nothing_surely(source, reaches_builtin): + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in draw_findings(source)} == expected + + +def test_a_walrus_in_an_assert_binds_nothing_surely(): + # python -O drops the assert. + source = "assert (hash := str)\nkey = hash('x')\n" + assert [kind for _, _, kind in draw_findings(source)] == ["builtin hash"] + + +# A type alias needs Python 3.12 syntax. +if sys.version_info >= (3, 12): + + def test_a_type_alias_binds_its_name(): + # The alias isn't callable, so Python raises before any builtin runs. + source = "type hash = str\ntry:\n hash('x')\nexcept TypeError:\n pass\n" + assert draw_findings(source) == [] + + +# Each is the body of a function outer that binds hash = str. A global +# statement in a function sends hash to the module, and so to the builtin, +# in all the code nested in that function, classes and their methods +# included. One in a class body does so in that body and what it evaluates +# (its annotations, and its methods' defaults and annotations), but not in +# the code nested in it. Python 3.11 to 3.14 read each the same way. +@pytest.mark.parametrize( + "body, reaches_builtin", + [ + ( + " def middle():\n" + " global hash\n" + " def f(x: hash('x')):\n" + " pass\n" + " f.__annotations__\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def f() -> hash('x'):\n" + " pass\n" + " f.__annotations__\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def f(x=hash('x')):\n" + " pass\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " (lambda: hash('x'))()\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " [hash(v) for v in 'x']\n" + " list(hash(v) for v in 'x')\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " class C:\n" + " key = hash('x')\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " class C:\n" + " y: hash('x') = 1\n" + " C.__annotations__\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " class C:\n" + " def m(self):\n" + " return hash('x')\n" + " C().m()\n" + " middle()\n", + True, + ), + ( + " def middle():\n" + " global hash\n" + " def inner():\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " inner()\n" + " middle()\n", + True, + ), + # inner's global skips middle's binding too. + ( + " def middle():\n" + " hash = str\n" + " def inner():\n" + " global hash\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " inner()\n" + " middle()\n", + True, + ), + ( + " class C:\n" + " global hash\n" + " key = hash('x')\n" + " keys = [v for v in [hash('x')]]\n", + True, + ), + ( + " class C:\n" + " global hash\n" + " y: hash('x')\n" + " C.__annotations__\n", + True, + ), + ( + " class C:\n" + " global hash\n" + " def m(self, x: hash('x')) -> hash('y'):\n" + " pass\n" + " C.m.__annotations__\n", + True, + ), + # Not in the code nested in a class body. + ( + " class C:\n" + " global hash\n" + " def m(self):\n" + " return hash('x')\n" + " C().m()\n", + False, + ), + ( + " class C:\n" + " global hash\n" + " keys = [hash(v) for v in 'x']\n", + False, + ), + ( + " class C:\n" + " global hash\n" + " class D:\n" + " key = hash('x')\n", + False, + ), + ( + " class C:\n" + " global hash\n" + " y: (lambda: hash('x'))()\n" + " C.__annotations__\n", + False, + ), + # nonlocal names outer's hash; a nested binding is the nested code's. + ( + " def middle():\n" + " nonlocal hash\n" + " def f():\n" + " return hash('x')\n" + " f()\n" + " middle()\n", + False, + ), + ( + " def middle():\n" + " global hash\n" + " def f():\n" + " hash = str\n" + " return hash('x')\n" + " f()\n" + " middle()\n", + False, + ), + ], +) +def test_global_and_nonlocal_reach_the_code_nested_in_their_scope( + body, reaches_builtin +): + findings = draw_findings("def outer():\n hash = str\n" + body) + expected = {"builtin hash"} if reaches_builtin else set() + assert {kind for _, _, kind in findings} == expected + + +def test_draw_helpers_are_followed_through_calls(): + source = ( + "def mix(x):\n" + " return x ^ (x >> 3)\n" + "def month(x):\n" + " return mix(x) % 12\n" + "def formula(x):\n" + " return month(x) + 1\n" + ) + found = {(scope, kind) for _, scope, kind in draw_findings(source)} + assert ("month", "draw helper call") in found + assert ("formula", "draw helper call") in found + # A function whose findings are exempt is not a draw helper. + exempt = {("mix", "xorshift"): 1} + found = {(scope, kind) for _, scope, kind in draw_findings(source, exempt=exempt)} + assert found == {("mix", "xorshift")} + + +def test_allowlist_counts_occurrences_per_scope(): + source = ( + "def first():\n" + " return np.random.random()\n" + "def second():\n" + " return np.random.random()\n" + ) + counted = count_findings({"m.py": draw_findings(source)}) + one = {"m.py": {("first", "random source"): 1}} + # An occurrence in a new function of an allowlisted file fails. + violations, stale = check_allowlists(counted, one, {}) + assert len(violations) == 1 and "second" in violations[0] and not stale + # A second occurrence in an allowlisted scope fails. + twice = count_findings({"m.py": draw_findings(source.replace("second", "first"))}) + violations, _ = check_allowlists(twice, one, {}) + assert violations and "2 found, 1 allowed" in violations[0] + # An entry that allows more than it matches is stale. + _, stale = check_allowlists(counted, {"m.py": {("first", "random source"): 2}}, {}) + assert stale and "2 allowed, 1 found" in stale[0] + + +def test_reusing_an_allowed_hash_or_draw_spends_an_occurrence(): + checksum = ( + "import hashlib\n" + "def checksum(data):\n" + " return hashlib.sha256(data).hexdigest()\n" + ) + exempt = {("", "hash library"): 1, ("checksum", "hash use"): 1} + + def check(source, ratchet=None): + findings = draw_findings(source, "m.py", exempt=exempt) + counted = count_findings({"m.py": findings}) + return check_allowlists(counted, {"m.py": ratchet or {}}, {"m.py": exempt}) + + assert check(checksum) == ([], []) + # A new function hashing ids through the exempt import fails. + reuse = checksum + ( + "def draw(person_id):\n" + " return hashlib.sha256(bytes(person_id)).digest()[0] % 12\n" + ) + violations, _ = check(reuse) + assert any("in draw: hash use" in violation for violation in violations) + # So does a new call of a ratcheted draw. + legacy = checksum + ( + "def legacy(x):\n" + " return x ^ (x >> 3)\n" + "def stored(x):\n" + " return legacy(x)\n" + ) + ratchet = {("legacy", "xorshift"): 1, ("stored", "draw helper call"): 1} + assert check(legacy, ratchet) == ([], []) + violations, _ = check(legacy + "def more(x):\n return legacy(x)\n", ratchet) + assert any("in more: draw helper call" in violation for violation in violations) + + +def _package_findings(sources: dict, order=None, exempt=None) -> dict: + """{module: findings} of a package ``pkg`` given {module: source}. + + ``order`` is the order the modules are analysed in. + """ + modules = Modules( + { + f"pkg.{name}": ("pkg", lambda source=source: source) + for name, source in sources.items() + } + ) + analyses = { + name: analyse(sources[name], f"{name}.py", f"pkg.{name}", "pkg", modules) + for name in order or sources + } + helpers = draw_helpers(analyses, exempt or {}) + return { + name: sorted(analysis.findings + helper_calls(analysis, helpers)) + for name, analysis in analyses.items() + } + + +def _package_kinds(sources: dict, module: str = "user") -> list: + return [kind for _, _, kind in _package_findings(sources)[module]] + + +def test_named_imports_and_reexports_resolve_to_what_they_name(): + api = "import numpy as lib\nfrom numpy import random as draws\n" + user = ( + "from .api import lib\n" + "lib.random.uniform()\n" + "from .api import draws as d\n" + "d.uniform()\n" + ) + assert _package_kinds({"api": api, "user": user}) == ["random source"] * 2 + # A draw helper re-exported by name is still a draw helper. + helper = "def mix(x):\n return x ^ (x >> 3)\n" + found = _package_findings( + { + "a": helper, + "b": "from .a import mix\n", + "c": "from .b import mix\ndef consumer(x):\n return mix(x)\n", + } + ) + assert ("consumer", "draw helper call") in {(s, k) for _, s, k in found["c"]} + + +def test_module_attributes_resolve_to_what_the_module_binds(): + # A name passed on as an attribute of another module. + sources = { + "api": "import numpy as lib\n", + "bridge": "from . import api\nlib = api.lib\n", + "user": "from .bridge import lib\nlib.random.uniform()\n", + } + assert _package_kinds(sources) == ["random source"] + # A draw helper reached as an attribute of a module that re-exports it. + found = _package_findings( + { + "a": "def mix(x):\n return x ^ (x >> 3)\n", + "b": "from .a import mix\n", + "c": "from . import b\ndef consumer(x):\n return b.mix(x)\n", + } + ) + assert ("consumer", "draw helper call") in {(s, k) for _, s, k in found["c"]} + + +def test_a_package_that_rebinds_its_submodules_name_resolves(): + # Both __init__ files bind w to the function pkg.w.w defines, so the + # attribute pkg.w is that function, though "pkg.w" also names a package. + sources = { + "pkg": ("pkg", "from .w import w\n"), + "pkg.w": ("pkg.w", "from .w import w\n"), + "pkg.w.w": ("pkg.w", "def w(x):\n return x ^ (x >> 3)\n"), + "pkg.user": ("pkg", "import pkg\ndef consumer(x):\n return pkg.w(x)\n"), + } + # This resolution needs depth 4. A low limit makes one that expands + # pkg.w.w.w into ever longer paths fail fast, with a recorded cut, + # instead of searching for minutes. + modules = Modules( + { + module: (package, lambda source=source: source) + for module, (package, source) in sources.items() + }, + max_depth=5, + ) + analyses = { + module: analyse(source, f"{module}.py", module, package, modules) + for module, (package, source) in sources.items() + } + helpers = draw_helpers(analyses, {}) + assert "pkg.w.w.w" in helpers + calls = helper_calls(analyses["pkg.user"], helpers) + assert [(scope, kind) for _, scope, kind in calls] == [ + ("consumer", "draw helper call") + ] + assert not modules.exhausted + + +def test_lookups_cut_by_the_depth_limit_are_recorded(): + # lib passes along 12 re-exports, each read where it arrives. From the + # consumer end, its lookup nests past a limit of 4 and is cut; from the + # numpy end, kept results keep each lookup shallow. A cut is recorded, so + # the package scan fails instead of depending on the order. + sources = {"m0": "import numpy as lib\n"} + sources |= {f"m{i}": f"from .m{i - 1} import lib\nlib\n" for i in range(1, 13)} + sources["user"] = "from .m12 import lib\nlib.random.uniform()\n" + consumer_first = list(reversed(sources)) + for order, max_depth, cut in [ + (consumer_first, 4, True), + (list(sources), 4, False), + (consumer_first, MAX_RESOLUTION_DEPTH, False), + ]: + modules = Modules( + { + f"pkg.{name}": ("pkg", lambda source=source: source) + for name, source in sources.items() + }, + max_depth=max_depth, + ) + analyses = { + name: analyse(sources[name], f"{name}.py", f"pkg.{name}", "pkg", modules) + for name in order + } + assert bool(modules.exhausted) == cut + if not cut: + kinds = [kind for _, _, kind in analyses["user"].findings] + assert kinds == ["random source"] + + +@pytest.mark.parametrize( + "api", + [ + "import numpy as lib\nleft, right = lib, 1\n", + "import numpy as _lib\n__all__ = ['left'] + []\nleft = _lib\n", + "import numpy as _lib\n__all__ = []\n__all__.append('left')\nleft = _lib\n", + "import numpy as _lib\n__all__ = ('x',)\n__all__ += ['left']\nleft = _lib\n", + "import numpy as _lib\n__all__ = names()\nleft = _lib\n", + ], +) +def test_star_imports_bind_what_the_module_exports(api): + user = "from .api import *\nleft.random.uniform()\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + + +def test_star_imports_bind_private_names_only_through_all(): + user = "from .api import *\n_lib.random.uniform()\n" + assert _package_kinds({"api": "import numpy as _lib\n", "user": user}) == [] + api = "import numpy as _lib\n__all__ = ['_lib']\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + + +def test_star_imports_see_an_earlier_all_in_a_cycle(): + # Importing api first runs user while __all__ still lists lib. + api = "import numpy as lib\n__all__ = ['lib']\nfrom . import user\n__all__ = []\n" + user = "from .api import *\nlib.random.uniform()\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + # Or before api sets __all__, when every public name it has bound is + # exported. + api = "import numpy as lib\nfrom . import user\n__all__ = []\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + # A private name is exported only while an earlier __all__ lists it. + api = "import numpy as _lib\n__all__ = ['_lib']\nfrom . import user\n__all__ = []\n" + user = "from .api import *\n_lib.random.uniform()\n" + assert _package_kinds({"api": api, "user": user}) == ["random source"] + + +@pytest.mark.parametrize( + "api", + [ + # Importing api first runs user before __all__ lists hash, or before + # api binds it. + "__all__ = []\nfrom . import user\nhash = str\n__all__ = ['hash']\n", + "hash = str\n__all__ = []\nfrom . import user\n__all__ = ['hash']\n", + "from . import user\nhash = str\n", + # Through another module, a package's __init__, a function that + # imports, or a literal dynamic import. + "from . import other\nhash = str\n", + "import pkg.sub.leaf\nhash = str\n", + "def load():\n from . import user\nload()\nhash = str\n", + "import importlib\nimportlib.import_module('pkg.user')\nhash = str\n", + ], +) +def test_star_imports_in_an_import_cycle_bind_nothing_surely(api): + sources = { + "api": api, + "other": "from . import user\n", + "sub": "from pkg import user\n", + "sub.leaf": "", + "user": "from .api import *\nkey = hash('x')\n", + } + assert _package_kinds(sources) == ["builtin hash"] + # A module's star import of itself sees itself part-run too. + itself = {"user": "from .user import *\nkey = hash('x')\nhash = str\n"} + assert _package_kinds(itself) == ["builtin hash"] + + +def _modules_of(sources: dict) -> Modules: + """Modules for {module: source}; a module with submodules is a package.""" + packages = { + module: module + if any(other.startswith(module + ".") for other in sources) + else module.rsplit(".", 1)[0] + for module in sources + } + return Modules( + { + module: (packages[module], lambda source=source: source) + for module, source in sources.items() + } + ) + + +def _assert_users_find(sources: dict, reaches_builtin: bool): + """Each module that reads ``hash('x')`` finds the builtin, or not, in + every order the modules are analysed in.""" + users = [module for module, source in sources.items() if "hash('x')" in source] + expected = ["builtin hash"] if reaches_builtin else [] + for order in permutations(sources): + modules = _modules_of(sources) + analyses = { + module: analyse( + sources[module], + f"{module}.py", + module, + modules.sources[module][0], + modules, + ) + for module in order + } + for user in users: + assert [kind for _, _, kind in analyses[user].findings] == expected + + +# Some import order starts user while api is part-run: through the modules +# a star import of a package loads (what its __all__ lists, in any version +# of it, dotted names too, with the packages on the way, or every module +# below it where __all__ is built otherwise), or a dynamic import under +# another name or relative to the package its arguments give. In the +# controls no order does: without __all__ a star import of a package loads +# none of its submodules, one of the package around the importer starts +# nothing, as that has run, a dotted name loads only what it names, and a +# relative import's own package is the one its globals give. +@pytest.mark.parametrize( + "sources, reaches_builtin", + [ + ( + { + "pkg": "__all__ = ['user']\n", + "pkg.api": "from . import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['leaf']\n", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "from pkg.sub import *\nhash = str\n", + }, + True, + ), + # Imported first, pkg.sub imports api while its __all__ lists leaf. + ( + { + "pkg.sub": "__all__ = ['leaf']\nfrom .. import api\n__all__ = []\n", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "names = ['leaf']\n__all__ = list(names)\n", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.api": "from importlib import import_module as load\n" + "load('pkg.user')\n" + "hash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.api": "import importlib\n" + "getattr(importlib, 'import_module')('pkg.user')\n" + "hash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "", + "pkg.sub.leaf": "from .. import user\n", + "pkg.api": "from .sub import *\nhash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + False, + ), + # pkg.sub started before api, so api's star import of pkg starts no + # module again. Python cannot bind sub, part-run, and api catches it. + ( + { + "pkg": "__all__ = ['sub']\n", + "pkg.sub": "from . import user\n", + "pkg.sub.api": "try:\n" + " from .. import *\n" + "except AttributeError:\n" + " pass\n" + "hash = str\n", + "pkg.sub.user": "from .api import *\nkey = hash('x')\n", + }, + False, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "from builtins import __import__ as load\n" + "load('pkg.sub', fromlist=('*',))\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('sub', globals(), None, ['*'], 1)\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist='*')\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + False, + ), + ( + { + "pkg.sub": "__all__ = ['branch.user']\n", + "pkg.sub.branch": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + # Python loads pkg.sub.branch.user, then fails to bind 'branch.user'. + ( + { + "pkg.sub": "__all__ = ['branch.user']\n", + "pkg.sub.branch": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "try:\n" + " from .sub import *\n" + "except AttributeError:\n" + " pass\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['branch.leaf']\n", + "pkg.sub.branch": "from . import user\n", + "pkg.sub.branch.leaf": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "names = ['branch.user']\n__all__ = list(names)\n", + "pkg.sub.branch": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['branch.other']\n", + "pkg.sub.branch": "", + "pkg.sub.branch.other": "", + "pkg.sub.branch.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.sub', fromlist=['*'])\nhash = str\n", + }, + False, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('sub', {'__package__': 'pkg.other'}, None, ['*'], 1)\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('sub', {'__name__': 'pkg.other.mod'}, None, ['*'], 1)\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__(\n" + " 'sub', {'__name__': 'pkg.other', '__path__': []}, None, ['*'], 1\n" + ")\n" + "hash = str\n", + }, + True, + ), + # The last of a repeated key wins: __package__ is None, so __name__ + # gives the package. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__(\n" + " 'sub',\n" + " {'__package__': 'pkg', '__package__': None, '__name__': 'pkg.other.mod'},\n" + " None,\n" + " ['*'],\n" + " 1,\n" + ")\n" + "hash = str\n", + }, + True, + ), + # A dict fromlist is iterated by key. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist={'*': None})\nhash = str\n", + }, + True, + ), + # And so are a literal ``**`` or ``*`` entry's own items. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist={**{'*': None}})\n" + "hash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist=[*['*']])\nhash = str\n", + }, + True, + ), + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__(\n" + " 'sub', {**{'__package__': 'pkg.other'}}, None, ['*'], 1\n" + ")\n" + "hash = str\n", + }, + True, + ), + # An empty one names nothing: no star load, so user never runs. + ( + { + "pkg.other": "", + "pkg.other.sub": "__all__ = ['user']\n", + "pkg.other.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.api": "__import__('pkg.other.sub', fromlist={**{}})\nhash = str\n", + }, + False, + ), + ( + { + "pkg.api": "import importlib\n" + "importlib.import_module('.user', __package__)\n" + "hash = str\n", + "pkg.user": "from .api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + "pkg.other": "", + "pkg.other.sub": "__all__ = []\n", + "pkg.api": "__import__('sub', {'__package__': 'pkg.other'}, None, ['*'], 1)\n" + "hash = str\n", + }, + False, + ), + ], +) +def test_star_imports_see_cycles_through_star_loads_and_renamed_imports( + sources, reaches_builtin +): + _assert_users_find({"pkg": ""} | sources, reaches_builtin) + + +@pytest.mark.parametrize( + "sources, reaches_builtin", + [ + ( + { + "pkg": "def load():\n from . import user\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "from .helpers import go\ndef load():\n go()\n", + "pkg.helpers": "def go():\n from . import user\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "def load():\n from .mid import user\n", + "pkg.mid": "", + "pkg.mid.api": "from pkg import load\nload()\nhash = str\n", + "pkg.mid.user": "from pkg.mid.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "def load():\n __import__('pkg.sub', fromlist=['*'])\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.sub": "__all__ = ['user']\n", + "pkg.sub.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "load = lambda: __import__('pkg.user')\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "loads = (__import__('pkg.user') for _ in [0])\n", + "pkg.api": "from pkg import loads\nnext(loads)\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "class Loader:\n" + " def load(self):\n" + " from . import user\n", + "pkg.api": "from pkg import Loader\nLoader().load()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + ( + { + "pkg": "from .sub import *\n", + "pkg.sub": "__all__ = ['helpers']\n", + "pkg.sub.helpers": "def go():\n from .. import user\n", + "pkg.api": "from pkg import helpers\nhelpers.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # pkg ran its import of other before api started; nothing api may + # call imports user. + ( + { + "pkg": "from . import other\ndef load():\n return other\n", + "pkg.other": "VALUE = 1\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + # pkg.mid ran, with its import of user, before api started: a + # function's import of it starts nothing. + ( + { + "pkg": "def load():\n from . import mid\n", + "pkg.mid": "from . import user\n", + "pkg.mid.api": "from .. import load\nhash = str\n", + "pkg.mid.user": "from pkg.mid.api import *\nkey = hash('x')\n", + }, + False, + ), + ], +) +def test_star_imports_see_cycles_through_code_the_packages_around_may_run( + sources, reaches_builtin +): + # The packages around a module, and what they imported as they ran, have + # run before it, but its code may call their functions: an import in + # one is an edge. + _assert_users_find(sources, reaches_builtin) + + +# main loads pkg.helper, then api reaches it without an import, as an +# attribute of its package, and calls code in it that starts user while api +# is part-run. +@pytest.mark.parametrize( + "api, helper", + [ + ("import pkg\npkg.helper.go()\n", None), + ( + "import pkg\npkg.helper.trigger\n", + "def __getattr__(name):\n from . import user\n", + ), + ("import pkg as p\np.helper.go()\n", None), + ("import pkg\ngetattr(pkg, 'helper').go()\n", None), + ("import sys\nsys.modules['pkg.helper'].go()\n", None), + ("import sys\nsys.modules.get('pkg.helper').go()\n", None), + ("__import__('pkg').helper.go()\n", None), + ("from pkg import *\nhelper.go()\n", None), + ], +) +def test_star_imports_see_cycles_through_modules_reached_without_an_import(api, helper): + sources = { + "pkg": "", + "pkg.helper": helper or "def go():\n from . import user\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": api + "hash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + } + _assert_users_find(sources, True) + + +@pytest.mark.parametrize( + "sources, reaches_builtin", + [ + # An alias, assigned, of a package whose submodule main loaded. + ( + { + "pkg.sub": "", + "pkg.sub.helper": "def go():\n from .. import user\n", + "pkg.main": "import pkg.sub.helper\nimport pkg.api\n", + "pkg.api": "import pkg\nh = pkg.sub\nh.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # An alias bound to a path below itself. + ( + { + "pkg.sub": "", + "pkg.sub.helper": "def go():\n from .. import user\n", + "pkg.main": "import pkg.sub.helper\nimport pkg.api\n", + "pkg.api": "import pkg\nh = pkg\nh = h.sub\nh.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # A function in a module api imports reaches it. + ( + { + "pkg.helper": "def go():\n from . import user\n", + "pkg.x": "import pkg\ndef run():\n pkg.helper.go()\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": "from pkg.x import run\nrun()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # So does a function in the package around api, by its own name for + # its submodule. + ( + { + "pkg": "def load():\n helper.go()\n", + "pkg.helper": "def go():\n from . import user\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": "from pkg import load\nload()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + True, + ), + # The code api reaches starts nothing, or nothing that starts user. + ( + { + "pkg": "from . import helper\n", + "pkg.helper": "def go():\n return 1\n", + "pkg.api": "import pkg\npkg.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + ( + { + "pkg": "from . import helper\n", + "pkg.helper": "def go():\n from . import other\n", + "pkg.other": "VALUE = 1\n", + "pkg.api": "import pkg\npkg.helper.go()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + # A plain module's bare name is its own, not its package's submodule. + ( + { + "pkg.helper": "def go():\n from . import user\n", + "pkg.main": "import pkg.helper\nimport pkg.api\n", + "pkg.api": "def helper():\n pass\nhelper()\nhash = str\n", + "pkg.user": "from pkg.api import *\nkey = hash('x')\n", + }, + False, + ), + ], +) +def test_star_imports_see_cycles_through_modules_reached_by_aliases_and_functions( + sources, reaches_builtin +): + _assert_users_find({"pkg": ""} | sources, reaches_builtin) + + +def test_a_computed_all_may_name_any_module_below_its_package(): + # __all__ holds nothing when this one runs, but a computed __all__ may + # hold a dotted name, so a star import of the package may load any module + # below it, and the scan errs toward reporting. + sources = { + "pkg": "__all__ = list([])\n", + "pkg.sub": "from . import user\n", + "pkg.sub.api": "from .. import *\nhash = str\n", + "pkg.sub.user": "from .api import *\nkey = hash('x')\n", + } + _assert_users_find(sources, True) + + +@pytest.mark.parametrize( + "source, run, later", + [ + ("import pkg\npkg.a.f()\n", {"pkg.a"}, set()), + ("import pkg\ndef f():\n pkg.a.g()\n", set(), {"pkg.a"}), + ("import pkg as p\nx = p.a\nx.b.f()\n", {"pkg.a", "pkg.a.b"}, set()), + ("from pkg import a as b\nb.c.f()\n", {"pkg.a", "pkg.a.c"}, set()), + ("import pkg\ng = getattr(pkg, 'a')\n", {"pkg.a"}, set()), + ("import sys\nsys.modules['pkg.a'].f()\n", {"pkg.a"}, set()), + ("from sys import modules\nmodules.get('pkg.a').f()\n", {"pkg.a"}, set()), + # sys.modules holds the module itself, not its top package. + ("import sys\nsys.modules['pkg.sub'].a.f()\n", {"pkg.sub.a"}, set()), + ("import sys\nsys.modules.get('pkg.sub').a.f()\n", {"pkg.sub.a"}, set()), + ("f = lambda: __import__('pkg').a.f()\n", set(), {"pkg.a"}), + # __import__ returns the top package; import_module the module. + ("__import__('pkg.sub').a.f()\n", {"pkg.a", "pkg.sub.a"}, set()), + ("from pkg.sub import *\na.f()\n", {"pkg.sub.a"}, set()), + ("pkg.a.f()\n", {"pkg.a"}, set()), + # A name bound to a path below itself. + ("import pkg\nx = pkg\nx = x.a\nx.b.f()\n", {"pkg.a", "pkg.a.b"}, set()), + ("import pkg\nx = pkg.a\ny = x.b\nx = y\nx.f()\n", {"pkg.a", "pkg.a.b"}, set()), + # y's binding reads x's, bound after it. + ( + "import pkg\nfor i in range(2):\n y = x if i else pkg\n x = y.a\n" + "y.b.f()\n", + {"pkg.a", "pkg.a.b"}, + set(), + ), + # Either branch, inline or bound. + ( + "import pkg\nh = pkg.a if c else None\nh.b.f()\n", + {"pkg.a", "pkg.a.b"}, + set(), + ), + ("import pkg\nh = c or pkg.a\nh.c.f()\n", {"pkg.a", "pkg.a.c"}, set()), + ("import pkg\nh: object = pkg.a\nh.b.f()\n", {"pkg.a", "pkg.a.b"}, set()), + ( + "import pkg\n(h := pkg.a).b.f()\nh.c\n", + {"pkg.a", "pkg.a.b", "pkg.a.c"}, + set(), + ), + # A plain module's bare name is its own, not its package's submodule. + ("a.f()\n", set(), set()), + ], +) +def test_references_are_split_by_when_they_may_run(source, run, later): + tree = ast.parse(source) + (_, run_stars), (_, later_stars) = _imports_in(tree, "pkg", "pkg.m") + modules = {"pkg.a", "pkg.a.b", "pkg.a.c", "pkg.sub.a"} + known = {path for module in modules for path in _started(module)} + found = _references_in(tree, "pkg", run_stars | later_stars, known, "pkg.m") + assert (found[0] & modules, found[1] & modules) == (run, later) + + +@pytest.mark.parametrize( + "source", + [ + "import pkg\npkg.a.f()\nx.y.z\n", + "import pkg as p\nq = p.a\nq.b.c.f()\nimport os\nos.path.join('a')\n", + "import sys\nsys.modules['pkg.a'].f()\nsys.modules['os'].sep\ngetattr(o, 'n')\n", + ], +) +def test_reference_paths_stay_within_the_known_paths(source): + # Only paths a package module may yet be named from, and the names the + # rules read, are kept, so the binding fixpoint has finitely many paths + # to add and ends. + modules = {"pkg.a", "pkg.a.b"} + known = {path for module in modules for path in _started(module)} + run, later = _references_in(ast.parse(source), "pkg", set(), known, "pkg.m") + assert run | later <= known | REFERENCE_NAMES + + +# What Python imports for each call, read from its arguments. +@pytest.mark.parametrize( + "call, imported", + [ + ("__import__('sub', globals(), None, ['x'], 1)", ["pkg.sub", "pkg.sub.x"]), + ( + "__import__('sub', {'__package__': 'pkg.other'}, None, (), 1)", + ["pkg.other.sub"], + ), + ( + "__import__('sub', {'__package__': None, '__name__': 'pkg.o.m'}, level=1)", + ["pkg.o.sub"], + ), + ( + "__import__('sub', {'__name__': 'pkg.o', '__path__': None}, level=1)", + ["pkg.o.sub"], + ), + ("__import__('sub', {'__name__': 'pkg.o.m'}, level=2)", ["pkg.sub"]), + # The last of a repeated key wins, and None then reads as absent. + ( + "__import__('sub', {'__package__': 'pkg', '__package__': None, " + "'__name__': 'pkg.o.m'}, level=1)", + ["pkg.o.sub"], + ), + ( + "__import__('sub', {'__package__': None, '__package__': 'pkg.o'}, level=1)", + ["pkg.o.sub"], + ), + ("__import__('sub', {'__name__': 'pkg.o.m', '__name__': None}, level=1)", []), + ("__import__('sub', globals(), None, {'x': 0}, 1)", ["pkg.sub", "pkg.sub.x"]), + # Unpacked literal entries are read in place; computed ones give none. + ( + "__import__('sub', globals(), None, {**{'x': 0}}, 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ( + "__import__('sub', globals(), None, [*computed, 'x'], 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ("__import__('sub', {**{'__package__': 'pkg.o'}}, level=1)", ["pkg.o.sub"]), + ( + "__import__('sub', {'__package__': 'pkg', **{'__package__': None}, " + "'__name__': 'pkg.o.m'}, level=1)", + ["pkg.o.sub"], + ), + ( + "__import__('sub', {**{'__package__': 'pkg.o'}, **other}, level=1)", + ["pkg.sub"], + ), + ("__import__('sub', {1: 0, '__package__': 'pkg.o'}, level=1)", ["pkg.o.sub"]), + # At any depth. + ( + "__import__('sub', globals(), None, {**{**{'x': 0}}}, 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ( + "__import__('sub', globals(), None, [*[*['x']]], 1)", + ["pkg.sub", "pkg.sub.x"], + ), + ("__import__('sub', {**{**{'__package__': 'pkg.o'}}}, level=1)", ["pkg.o.sub"]), + ( + "__import__('sub', {'__spec__': spec, '__name__': 'x.y'}, level=1)", + ["pkg.sub"], + ), + ("__import__('sub', {'__package__': computed}, level=1)", ["pkg.sub"]), + ("__import__('sub', {'__name__': name}, level=1)", ["pkg.sub"]), + ("__import__('sub', {**other}, level=1)", ["pkg.sub"]), + ("__import__('sub', mapping, level=1)", ["pkg.sub"]), + ("__import__('sub', {}, level=1)", []), + ("import_module('.sub', __package__)", ["pkg.sub"]), + ("import_module('.sub', package=__spec__.parent)", ["pkg.sub"]), + ("import_module('.sub', __name__)", ["pkg.m.sub"]), + ("import_module('..sub', 'pkg.o')", ["pkg.sub"]), + ("import_module('.sub', computed)", ["pkg.sub"]), + ("import_module('.sub')", []), + ("import_module('.sub', None)", []), + ], +) +def test_relative_dynamic_imports_resolve_as_python_reads_them(call, imported): + node = ast.parse(call, mode="eval").body + names = _dynamic_imports(node, DYNAMIC_IMPORTS, "pkg", "pkg.m") + assert [name for name in names if name.startswith("pkg.")] == imported + + +@pytest.mark.parametrize( + "source, run, later", + [ + ("import pkg.a\nclass C:\n import pkg.b\n", {"pkg.a", "pkg.b"}, set()), + ( + "if x:\n import pkg.a\ntry:\n import pkg.b\nexcept E:\n pass\n", + {"pkg.a", "pkg.b"}, + set(), + ), + ("def f():\n import pkg.a\n", set(), {"pkg.a"}), + ("async def f():\n import pkg.a\n", set(), {"pkg.a"}), + ("def f():\n if x:\n import pkg.a\n", set(), {"pkg.a"}), + ("class C:\n def m(self):\n import pkg.a\n", set(), {"pkg.a"}), + ("f = lambda: __import__('pkg.a')\n", set(), {"pkg.a"}), + ("g = (__import__('pkg.a') for _ in [0])\n", set(), {"pkg.a"}), + ("x: __import__('pkg.a')\n", set(), {"pkg.a"}), + ("def f(x: __import__('pkg.a')): pass\n", set(), {"pkg.a"}), + ("@__import__('pkg.a')\ndef f(): pass\n", set(), {"pkg.a"}), + ], +) +def test_imports_are_split_by_when_they_may_run(source, run, later): + (run_names, _), (later_names, _) = _imports_in(ast.parse(source), "pkg") + assert (run_names, later_names) == (run, later) + + +# Each needs the syntax of the Python 3 minor version given, and none parses +# before 3.12. +if sys.version_info >= (3, 12): + + @pytest.mark.parametrize( + "minor, source", + _on_this_python( + [ + (12, "class C[T: __import__('pkg.a')]:\n pass\n"), + (12, "type Alias = __import__('pkg.a')\n"), + (13, "def f[T = __import__('pkg.a')](): pass\n"), + ], + minor=0, + ), + ) + def test_type_parameters_and_aliases_may_run_later(minor, source): + (run_names, _), (later_names, _) = _imports_in(ast.parse(source), "pkg") + assert (run_names, later_names) == (set(), {"pkg.a"}) + + +def test_imports_in_name_star_bases_by_when_they_may_run(): + source = ( + "from .sub import *\n" + "__import__('pkg.top', fromlist=['*'])\n" + "def f():\n __import__('pkg.inner', fromlist='*')\n" + ) + (_, run_stars), (_, later_stars) = _imports_in(ast.parse(source), "pkg") + assert (run_stars, later_stars) == ({"pkg.sub", "pkg.top"}, {"pkg.inner"}) + + +def test_star_imports_see_all_of_a_module_whose_package_imports_the_importer(): + # Importing pkg.api starts pkg first, which runs user, whose star import + # then runs all of api: api's import of pkg starts nothing. + sources = { + "pkg": ("pkg", "from . import user\n"), + "pkg.api": ("pkg", "import pkg\nhash = str\n"), + "pkg.user": ("pkg", "from .api import *\nkey = hash('x')\n"), + } + modules = Modules( + { + module: (package, lambda source=source: source) + for module, (package, source) in sources.items() + } + ) + for module, (package, source) in sources.items(): + assert analyse(source, f"{module}.py", module, package, modules).findings == [] + + +@pytest.mark.parametrize( + "api", + [ + # An earlier __all__, which a module imported in a cycle sees. + "hash = str\n__all__ = ['hash']\nfrom . import user\n__all__ = []\n", + "hash = str\n__all__ = ['hash']\n__all__.remove('hash')\n", + "hash = str\n__all__ = ['hash']\n__all__.remove(name)\n", + "if flag:\n hash = str\n", + "hash = str\ndel hash\n", + "hash = str\n__all__ = names()\n", + ], +) +def test_star_imports_may_not_bind_some_names(api): + # So the importer looks further out, here to the builtin. + user = "from .api import *\nkey = hash('x')\n" + assert _package_kinds({"api": api, "user": user}) == ["builtin hash"] + + +@pytest.mark.parametrize( + "api", + [ + "hash = str\n", + "hash = str\n__all__ = ['hash']\n", + "from .other import hash\n", + # other doesn't import user. + "from . import other\nhash = str\n", + ], +) +def test_star_imports_bind_what_the_module_surely_exports(api): + user = "from .api import *\nkey = hash('x')\n" + sources = {"api": api, "other": "hash = str\n", "user": user} + assert _package_kinds(sources) == [] + + +def test_cyclic_star_imports_resolve_in_any_order(): + sources = { + "a": "from .b import *\nimport numpy as lib\n", + "b": "from .a import *\n", + "user": "from .b import *\nlib.random.uniform()\n", + } + results = [ + _package_findings(sources, order) + for order in (["a", "b", "user"], ["user", "b", "a"], ["b", "user", "a"]) + ] + assert results[0] == results[1] == results[2] + assert [kind for _, _, kind in results[0]["user"]] == ["random source"] + + +@pytest.mark.parametrize( + "sources", + [ + { + "a": "if flag:\n import numpy as lib\nelse:\n from .b import lib\n", + "b": "from .a import lib\n", + "other": "from .a import lib\nlib.random.uniform()\n", + "user": "from .b import lib\nlib.random.uniform()\n", + }, + # From via_a, a.lib leads to b.lib, then a.lib again, then b.lib, + # which is cut: so that inner a.lib has none of b's numpy. Keeping + # that partial answer would hide numpy from via_c, which reaches it + # only through a.lib, whenever via_a is analysed first. + { + "a": "from .b import lib\n", + "b": "if flag:\n from .a import lib\nelse:\n import numpy as lib\n", + "c": "from .a import lib\n", + "via_a": "from .a import lib\nlib.random.uniform()\n", + "via_c": "from .c import lib\nlib.random.uniform()\n", + }, + ], +) +def test_cyclic_named_imports_resolve_in_any_order(sources): + results = [ + _package_findings(sources, list(order)) for order in permutations(sources) + ] + assert all(result == results[0] for result in results) + for module, source in sources.items(): + if "lib.random" in source: + assert [kind for _, _, kind in results[0][module]] == ["random source"] + + +def test_model_api_passes_on_cores_numpy_and_its_own_helpers(): + modules = package_scan().modules + user = "policyengine_uk.variables.gov.dwp.tax_credits_current_year_income" + assert "numpy" in modules.star_binds("policyengine_uk.model_api", "np", user) + assert modules.star_binds_surely("policyengine_uk.model_api", "np", user) + for api in ("policyengine_uk.model_api", "policyengine_core.model_api"): + assert "numpy.random.uniform" in modules.expand(f"{api}.np.random.uniform") + assert "policyengine_uk.utils.benefit_unit.add_for_members" in modules.star_binds( + "policyengine_uk.model_api", "add_for_members", user + ) + + +def test_splitmix64_hash_and_its_uprating_caller_are_draw_helpers(): + helpers = package_scan().helpers + assert "policyengine_uk.utils.stochastic.splitmix64_uniform" in helpers + assert ( + "policyengine_uk.data.economic_assumptions.uprate_student_loan_plans" in helpers + ) + assert "policyengine_uk.build_metadata.get_data_build_fingerprint" not in helpers + + +def test_package_draws_nothing_outside_the_allowlists(): + violations, _ = check_allowlists(package_scan().counts, RATCHET, EXEMPT) + assert not violations, ( + "The model must not draw, sample, shuffle or hash entities to a " + "pseudo-random value while a simulation is built or run " + "(policyengine-core#500). Draw in the dataset build and store the " + "draw as an input variable; the formula then compares it with the " + "rule, so a rate stays reformable. Draw patterns found:\n" + + "\n".join(violations) + ) + + +def test_allowlist_entries_still_match(): + _, stale = check_allowlists(package_scan().counts, RATCHET, EXEMPT) + assert not stale, ( + "These entries allow more than the package now has. Lower or delete " + "them, and lower RATCHET_CEILING to match RATCHET:\n" + "\n".join(stale) + ) + + +def test_package_lookups_stay_within_the_depth_limit(): + exhausted = package_scan().modules.exhausted + assert not exhausted, ( + f"{len(exhausted)} name lookups nested deeper than MAX_RESOLUTION_DEPTH " + "and were cut, so what they found may depend on the order modules " + "were scanned in. Raise the limit. One cut: " + repr(next(iter(exhausted))) + ) + + +def test_ratchet_only_shrinks(): + total = sum(sum(entries.values()) for entries in RATCHET.values()) + assert total <= RATCHET_CEILING, ( + "RATCHET lists engine-time draws still to move into the data. Draw " + "a new stochastic input in the dataset build instead of adding it." + ) + + +def _is_offline(name: str, offline=OFFLINE_MODULES) -> bool: + return _matches(name, offline) + + +# An offline package the import check tests against; the package has none. +EXAMPLE_OFFLINE_MODULES = {"policyengine_uk.tools.offline"} + + +@pytest.mark.parametrize( + "source", + [ + "import policyengine_uk.tools.offline.generate_tests\n", + "from policyengine_uk.tools.offline import generate_tests\n", + "from .offline import generate_tests\n", + "import importlib\n" + "importlib.import_module('policyengine_uk.tools.offline.generate_tests')\n", + "from importlib import import_module as load\n" + "load('.offline', package='policyengine_uk.tools')\n", + "__import__('policyengine_uk.tools.offline')\n", + "import importlib\n" + "importlib.import_module(name='policyengine_uk.tools.offline')\n", + "import importlib\nimportlib.import_module('.offline', 'policyengine_uk.tools')\n", + "__import__('policyengine_uk.tools', fromlist=['offline'])\n", + "__import__('offline', globals(), locals(), [], 1)\n", + ], +) +def test_offline_import_check_sees_each_import_form(source): + imports = analyse(source, package="policyengine_uk.tools").imports + assert any(_is_offline(name, EXAMPLE_OFFLINE_MODULES) for _, name in imports) + + +@pytest.mark.parametrize( + "source, offline", + [ + ( + "from importlib import import_module\nimport_module('..offline', __name__)\n", + True, + ), + ("__import__('offline', {'__name__': __name__}, None, [], 1)\n", True), + # helper is a module, not a package: Python looks for helper.offline. + ( + "from importlib import import_module\nimport_module('.offline', __name__)\n", + False, + ), + ], +) +def test_offline_import_check_resolves_against_the_modules_name(source, offline): + imports = analyse( + source, + module="policyengine_uk.tools.helper", + package="policyengine_uk.tools", + ).imports + assert ( + any(_is_offline(name, EXAMPLE_OFFLINE_MODULES) for _, name in imports) + is offline + ) + + +def test_model_never_imports_offline_modules(): + importers = [ + f"{relative}:{line}" + for relative, module, _, _ in package_modules() + if not _is_offline(module) + for line, name in package_scan().imports[relative] + if _is_offline(name) + ] + assert not importers, ( + "Offline modules are exempt from the draw check only because the " + "model never imports them:\n" + "\n".join(importers) + ) diff --git a/policyengine_uk/tests/code_health/test_release_artifacts.py b/policyengine_uk/tests/code_health/test_release_artifacts.py index cce67f0d58..cfbdfd3061 100644 --- a/policyengine_uk/tests/code_health/test_release_artifacts.py +++ b/policyengine_uk/tests/code_health/test_release_artifacts.py @@ -26,6 +26,9 @@ index serves another file or other bytes. 7. The release workflow's PyPI checks read the manifest written before the publish step, which adds attestation files to ``dist`` (pe-uk#2461). +8. Hatchling's wheel and sdist builders select every file git tracks under + ``policyengine_uk/``, so ``check-dist`` finds none missing at release. + ``.gitignore`` decides what hatchling leaves out, whatever git tracks. """ import hashlib @@ -771,3 +774,26 @@ def run(name): assert "--dist" not in run(name), name # Written before the publish step adds anything to dist. assert names.index(written) < names.index("Publish to PyPI") + + +# Invariant 8: hatchling packages every tracked file. + + +def test_hatchling_packages_every_tracked_package_file(): + from hatchling.builders.sdist import SdistBuilder + from hatchling.builders.wheel import WheelBuilder + + tracked = set( + git(REPO_ROOT, "ls-files", "-z", "--", "policyengine_uk").split("\0") + ) - {""} + assert tracked + wheel = { + file.distribution_path + for file in WheelBuilder(str(REPO_ROOT)).recurse_included_files() + } + sdist = { + file.relative_path + for file in SdistBuilder(str(REPO_ROOT)).recurse_included_files() + } + assert sorted(tracked - wheel) == [] + assert sorted(tracked - sdist) == [] diff --git a/policyengine_uk/tests/conftest.py b/policyengine_uk/tests/conftest.py index d945afd3e5..9476822850 100644 --- a/policyengine_uk/tests/conftest.py +++ b/policyengine_uk/tests/conftest.py @@ -206,6 +206,17 @@ def cloned_uk_tax_benefit_system(): # slowest on CI, are instead dealt out by duration, longest first, each to the # shard with the least so far. A test missing from the table, or the table # missing, falls back to the hash. +# +# The tests of a file in SHARED_FIXTURE_FILES all run in one shard, because each +# shard that holds one of them builds that file's module fixture. In merge queue +# run 42212587066 the fixture in test_aggregate_impact_certified.py held one +# worker for 8 to 9 minutes at the start of five shards, which peaked at 14.2 to +# 15.3 GB on 16 GB runners, against 10.9 and 12.6 GB for the two without it. Runs +# 42218280596 and 42218280615 had those tests on all seven shards and lost eight +# of their 14 shard jobs at 15.4 to 15.7 GB. +SHARED_FIXTURE_FILES = frozenset( + {"policyengine_uk/tests/aggregate_impact/test_aggregate_impact_certified.py"} +) SHARD_DURATIONS = ( Path(importlib.util.find_spec("policyengine_uk").origin).parent / "tests" @@ -225,19 +236,36 @@ def read_shard_durations(path: Path = SHARD_DURATIONS) -> dict[str, float]: } -def balanced_shards(durations: Mapping[str, float], shards: int) -> dict[str, int]: - """Deal tests to shards, longest first, each to the least loaded shard. +def placement_unit(nodeid: str) -> str: + """What the deal and the hash place: a shared-fixture file, or else the test.""" + path = nodeid.split("::", 1)[0] + return path if path in SHARED_FIXTURE_FILES else nodeid + - Ties go to the lower node id and the lower shard, so every process gives - the same answer. +def deal_units(durations: Mapping[str, float], shards: int) -> dict[str, int]: + """Deal placement units to shards, longest first, each to the least loaded. + + A shared-fixture file weighs as much as its longest test, since the file's + long entries each measured the same fixture. Ties go to the lower name and + the lower shard, so every process gives the same answer. """ + weights: dict[str, float] = {} + for nodeid, seconds in durations.items(): + unit = placement_unit(nodeid) + weights[unit] = max(seconds, weights.get(unit, seconds)) load = [0.0] * shards - placed = {} - for nodeid, seconds in sorted(durations.items(), key=lambda kv: (-kv[1], kv[0])): + dealt = {} + for unit, seconds in sorted(weights.items(), key=lambda kv: (-kv[1], kv[0])): shard = min(range(shards), key=lambda k: (load[k], k)) load[shard] += seconds - placed[nodeid] = shard + 1 - return placed + dealt[unit] = shard + 1 + return dealt + + +def balanced_shards(durations: Mapping[str, float], shards: int) -> dict[str, int]: + """The shard the deal gives each test in ``durations``.""" + dealt = deal_units(durations, shards) + return {nodeid: dealt[placement_unit(nodeid)] for nodeid in durations} _BALANCED: dict[int, dict[str, int]] = {} @@ -250,10 +278,11 @@ def shard_of(nodeid: str, shards: int) -> int: runner and every pytest-xdist worker must give the same answer. """ if shards not in _BALANCED: - _BALANCED[shards] = balanced_shards(read_shard_durations(), shards) - if nodeid in _BALANCED[shards]: - return _BALANCED[shards][nodeid] - digest = hashlib.sha256(nodeid.encode("utf-8")).digest() + _BALANCED[shards] = deal_units(read_shard_durations(), shards) + unit = placement_unit(nodeid) + if unit in _BALANCED[shards]: + return _BALANCED[shards][unit] + digest = hashlib.sha256(unit.encode("utf-8")).digest() return int.from_bytes(digest[:8], "big") % shards + 1 diff --git a/policyengine_uk/tests/fixtures/council_tax_reduction_non_dependant_deductions.yaml b/policyengine_uk/tests/fixtures/council_tax_reduction_non_dependant_deductions.yaml new file mode 100644 index 0000000000..2aa4334b76 --- /dev/null +++ b/policyengine_uk/tests/fixtures/council_tax_reduction_non_dependant_deductions.yaml @@ -0,0 +1,185 @@ +# Council Tax Reduction non-dependant deductions as enacted, weekly, for the +# financial year starting 1 April of each year, from each year's amending +# instrument (source) and the point-in-time text of the scheme provision on +# legislation.gov.uk. Bands include their lower edge; the first amount is +# para (1)(b), the last para (1)(a). Wales: WSI 2012/3144 (2013-14), then WSI +# 2013/3029 Sch 1 para 3 and Sch 6 para 5 (identical scales). Scotland: SSI +# 2012/319 reg 48, SSI 2012/303 reg 67 and SSI 2021/249 reg 90 (identical). + +england_pensioners: + parameter: gov.local_authorities.england.council_tax_reduction.pensioners.non_dep_deduction.amount + years: + 2013: + thresholds: [0, 186.00, 322.00, 401.00] + amounts: [3.65, 7.25, 9.15, 10.95] + source: https://www.legislation.gov.uk/uksi/2012/3085/made + 2014: + thresholds: [0, 188.00, 326.00, 406.00] + amounts: [3.70, 7.45, 9.40, 11.25] + source: https://www.legislation.gov.uk/uksi/2013/3181/made + 2015: + thresholds: [0, 189.00, 328.00, 408.00] + amounts: [3.74, 7.52, 9.49, 11.36] + source: https://www.legislation.gov.uk/uksi/2014/3312/made + 2016: + thresholds: [0, 195.00, 338.00, 420.00] + amounts: [3.77, 7.58, 9.56, 11.45] + source: https://www.legislation.gov.uk/uksi/2015/2041/made + 2017: + thresholds: [0, 196.95, 341.40, 424.20] + amounts: [3.80, 7.65, 9.65, 11.55] + source: https://www.legislation.gov.uk/uksi/2016/1262/made + 2018: + thresholds: [0, 202.85, 351.65, 436.90] + amounts: [3.90, 7.90, 9.95, 11.90] + source: https://www.legislation.gov.uk/uksi/2017/1305/made + 2019: + thresholds: [0, 207.70, 360.10, 447.40] + amounts: [4.00, 8.10, 10.20, 12.20] + source: https://www.legislation.gov.uk/uksi/2018/1346/made + 2020: + thresholds: [0, 217.00, 377.00, 469.00] + amounts: [4.05, 8.25, 10.35, 12.40] + source: https://www.legislation.gov.uk/uksi/2020/23/made + 2021: + thresholds: [0, 217.00, 377.00, 469.00] + amounts: [4.05, 8.30, 10.40, 12.45] + source: https://www.legislation.gov.uk/uksi/2021/29/made + 2022: + thresholds: [0, 224.00, 389.00, 484.00] + amounts: [4.20, 8.55, 10.70, 12.85] + source: https://www.legislation.gov.uk/uksi/2022/25/made + 2023: + thresholds: [0, 236.00, 410.00, 511.00] + amounts: [4.60, 9.40, 11.80, 14.15] + source: https://www.legislation.gov.uk/uksi/2023/16/made + 2024: + thresholds: [0, 256.00, 445.00, 554.00] + amounts: [4.90, 10.05, 12.60, 15.10] + source: https://www.legislation.gov.uk/uksi/2024/29/made + 2025: + thresholds: [0, 266.00, 463.00, 577.00] + amounts: [5.00, 10.20, 12.80, 15.35] + source: https://www.legislation.gov.uk/uksi/2025/39/made + 2026: + thresholds: [0, 279.00, 485.00, 605.00] + amounts: [5.20, 10.60, 13.30, 15.95] + source: https://www.legislation.gov.uk/uksi/2026/27/made +wales: + parameter: gov.local_authorities.wales.council_tax_reduction.non_dep_deduction.amount + years: + 2013: + thresholds: [0, 186.00, 322.00, 401.00] + amounts: [3.65, 7.25, 9.15, 10.95] + source: https://www.legislation.gov.uk/wsi/2013/112/made + 2014: + thresholds: [0, 188.00, 326.00, 406.00] + amounts: [3.75, 7.50, 9.45, 11.30] + source: https://www.legislation.gov.uk/wsi/2014/66/made + 2015: + thresholds: [0, 189.00, 328.00, 408.00] + amounts: [3.90, 7.80, 9.85, 11.75] + source: https://www.legislation.gov.uk/wsi/2015/44/made + 2016: + thresholds: [0, 194.00, 338.00, 420.00] + amounts: [4.05, 8.10, 10.25, 12.25] + source: https://www.legislation.gov.uk/wsi/2016/50/made + 2017: + thresholds: [0, 200.00, 346.00, 430.00] + amounts: [4.20, 8.40, 10.60, 12.70] + source: https://www.legislation.gov.uk/wsi/2017/46/made + 2018: + thresholds: [0, 205.00, 355.00, 440.00] + amounts: [4.35, 8.70, 10.95, 13.10] + source: https://www.legislation.gov.uk/wsi/2018/14/made + 2019: + thresholds: [0, 210.00, 365.00, 450.00] + amounts: [4.55, 9.15, 11.50, 13.75] + source: https://www.legislation.gov.uk/wsi/2019/11/made + 2020: + thresholds: [0, 217.00, 377.00, 469.00] + amounts: [4.85, 9.75, 12.25, 14.65] + source: https://www.legislation.gov.uk/wsi/2020/16/made + 2021: + thresholds: [0, 217.00, 377.00, 469.00] + amounts: [5.10, 10.20, 12.85, 15.35] + source: https://www.legislation.gov.uk/wsi/2021/34/made + 2022: + thresholds: [0, 224.00, 389.00, 484.00] + amounts: [5.30, 10.60, 13.35, 15.95] + source: https://www.legislation.gov.uk/wsi/2022/51/made + 2023: + thresholds: [0, 236.00, 410.00, 511.00] + amounts: [5.45, 10.90, 13.70, 16.40] + source: https://www.legislation.gov.uk/wsi/2023/47/made + 2024: + thresholds: [0, 256.00, 445.00, 554.00] + amounts: [5.80, 11.55, 14.50, 17.35] + source: https://www.legislation.gov.uk/wsi/2024/56/made + 2025: + thresholds: [0, 266.00, 463.00, 577.00] + amounts: [6.25, 12.45, 15.65, 18.70] + source: https://www.legislation.gov.uk/wsi/2025/59/made + 2026: + thresholds: [0, 279.00, 485.00, 605.00] + amounts: [6.70, 13.35, 16.80, 20.05] + source: https://www.legislation.gov.uk/wsi/2026/13/made +scotland: + parameter: gov.local_authorities.scotland.council_tax_reduction.non_dep_deduction.amount + years: + 2013: + thresholds: [0, 186.00, 322.00, 401.00] + amounts: [3.65, 7.25, 9.15, 10.95] + source: https://www.legislation.gov.uk/ssi/2013/49/made + 2014: + thresholds: [0, 188.00, 326.00, 406.00] + amounts: [3.75, 7.50, 9.50, 11.35] + source: https://www.legislation.gov.uk/ssi/2014/35/made + 2015: + thresholds: [0, 189.00, 328.00, 408.00] + amounts: [3.85, 7.70, 9.80, 11.70] + source: https://www.legislation.gov.uk/ssi/2015/46/made + 2016: + thresholds: [0, 195.00, 338.00, 420.00] + amounts: [3.90, 7.75, 9.85, 11.80] + source: https://www.legislation.gov.uk/ssi/2016/81/made + 2017: + thresholds: [0, 200.00, 346.00, 430.00] + amounts: [3.95, 7.85, 9.95, 11.90] + source: https://www.legislation.gov.uk/ssi/2017/41/made + 2018: + thresholds: [0, 204.00, 354.00, 439.00] + amounts: [4.05, 8.10, 10.25, 12.25] + source: https://www.legislation.gov.uk/ssi/2018/69/made + 2019: + thresholds: [0, 209.00, 363.00, 451.00] + amounts: [4.15, 8.30, 10.50, 12.55] + source: https://www.legislation.gov.uk/ssi/2019/29/made + 2020: + thresholds: [0, 213.00, 370.00, 458.00] + amounts: [4.25, 8.45, 10.70, 12.80] + source: https://www.legislation.gov.uk/ssi/2020/25/made + 2021: + thresholds: [0, 213.00, 370.00, 458.00] + amounts: [4.30, 8.50, 10.80, 12.90] + source: https://www.legislation.gov.uk/ssi/2021/51/made + 2022: + thresholds: [0, 228.00, 396.00, 490.00] + amounts: [4.45, 8.80, 11.15, 13.30] + source: https://www.legislation.gov.uk/ssi/2022/52/made + 2023: + thresholds: [0, 241.00, 418.00, 517.00] + amounts: [4.90, 9.70, 12.30, 14.65] + source: https://www.legislation.gov.uk/ssi/2023/38/made + 2024: + thresholds: [0, 260.00, 451.00, 558.00] + amounts: [5.25, 10.35, 13.15, 15.65] + source: https://www.legislation.gov.uk/ssi/2024/35/made + 2025: + thresholds: [0, 273.00, 474.00, 586.00] + amounts: [5.35, 10.55, 13.40, 15.95] + source: https://www.legislation.gov.uk/ssi/2025/24/made + 2026: + thresholds: [0, 286.00, 497.00, 614.00] + amounts: [5.55, 10.95, 13.90, 16.55] + source: https://www.legislation.gov.uk/ssi/2026/56/made diff --git a/policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv b/policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv new file mode 100644 index 0000000000..59d0980666 --- /dev/null +++ b/policyengine_uk/tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv @@ -0,0 +1,526 @@ +region,kind,price,sdlt_2018,ltt_2018,sdlt_2019,ltt_2019,sdlt_2020,ltt_2020,sdlt_2022,ltt_2022,sdlt_2025,ltt_2025,sdlt_2026,ltt_2026,sdlt_2027,ltt_2027,sdlt_2028,ltt_2028,sdlt_2029,ltt_2029,sdlt_2030,ltt_2030 +LONDON,main,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 +LONDON,main,39999,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 +LONDON,main,40000,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 +LONDON,main,100000,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 +LONDON,main,125000,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 +LONDON,main,125001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +LONDON,main,150000,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0 +LONDON,main,150001,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0 +LONDON,main,180000,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0 +LONDON,main,225000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,main,250000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +LONDON,main,250001,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0 +LONDON,main,300000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,main,400000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +LONDON,main,425000,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0 +LONDON,main,450000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +LONDON,main,500000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +LONDON,main,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +LONDON,main,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +LONDON,main,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +LONDON,main,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +LONDON,main,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +LONDON,main,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +LONDON,main,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +LONDON,main,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +LONDON,first,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 +LONDON,first,39999,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 +LONDON,first,40000,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 +LONDON,first,100000,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 +LONDON,first,125000,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 +LONDON,first,125001,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 +LONDON,first,150000,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 +LONDON,first,150001,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 +LONDON,first,180000,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 +LONDON,first,225000,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 +LONDON,first,250000,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 +LONDON,first,250001,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 +LONDON,first,300000,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 +LONDON,first,400000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,first,425000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +LONDON,first,450000,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0 +LONDON,first,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +LONDON,first,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +LONDON,first,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +LONDON,first,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +LONDON,first,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +LONDON,first,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +LONDON,first,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +LONDON,first,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +LONDON,first,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +LONDON,additional,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 +LONDON,additional,39999,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 +LONDON,additional,40000,1200.0,0.0,1200.0,0.0,1200.0,0.0,1200.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,additional,100000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,additional,125000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +LONDON,additional,125001,3750.05,0.0,3750.05,0.0,3750.05,0.0,3750.05,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0 +LONDON,additional,150000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +LONDON,additional,150001,5000.05,0.0,5000.05,0.0,5000.05,0.0,5000.05,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0 +LONDON,additional,180000,6500.0,0.0,6500.0,0.0,6500.0,0.0,6500.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0 +LONDON,additional,225000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0 +LONDON,additional,250000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +LONDON,additional,250001,10000.08,0.0,10000.08,0.0,10000.08,0.0,10000.08,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0 +LONDON,additional,300000,14000.0,0.0,14000.0,0.0,14000.0,0.0,14000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +LONDON,additional,400000,22000.0,0.0,22000.0,0.0,22000.0,0.0,22000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +LONDON,additional,425000,24000.0,0.0,24000.0,0.0,24000.0,0.0,24000.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0 +LONDON,additional,450000,26000.0,0.0,26000.0,0.0,26000.0,0.0,26000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0 +LONDON,additional,500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +LONDON,additional,500001,30000.08,0.0,30000.08,0.0,30000.08,0.0,30000.08,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0 +LONDON,additional,625000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0 +LONDON,additional,750000,50000.0,0.0,50000.0,0.0,50000.0,0.0,50000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0 +LONDON,additional,925000,64000.0,0.0,64000.0,0.0,64000.0,0.0,64000.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0 +LONDON,additional,1000000,73750.0,0.0,73750.0,0.0,73750.0,0.0,73750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +LONDON,additional,1500000,138750.0,0.0,138750.0,0.0,138750.0,0.0,138750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0 +LONDON,additional,2000000,213750.0,0.0,213750.0,0.0,213750.0,0.0,213750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0 +LONDON,additional,6000000,813750.0,0.0,813750.0,0.0,813750.0,0.0,813750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0 +LONDON,non_residential,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 +LONDON,non_residential,39999,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 +LONDON,non_residential,40000,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 +LONDON,non_residential,100000,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 +LONDON,non_residential,125000,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 +LONDON,non_residential,125001,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 +LONDON,non_residential,150000,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 +LONDON,non_residential,150001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +LONDON,non_residential,180000,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0 +LONDON,non_residential,225000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +LONDON,non_residential,250000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,non_residential,250001,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0 +LONDON,non_residential,300000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +LONDON,non_residential,400000,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0 +LONDON,non_residential,425000,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0 +LONDON,non_residential,450000,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0 +LONDON,non_residential,500000,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0 +LONDON,non_residential,500001,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0 +LONDON,non_residential,625000,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0 +LONDON,non_residential,750000,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0 +LONDON,non_residential,925000,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0 +LONDON,non_residential,1000000,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0 +LONDON,non_residential,1500000,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0 +LONDON,non_residential,2000000,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0 +LONDON,non_residential,6000000,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0 +LONDON,residential_lease,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 +LONDON,residential_lease,39999,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 +LONDON,residential_lease,40000,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 +LONDON,residential_lease,100000,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 +LONDON,residential_lease,125000,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 +LONDON,residential_lease,125001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +LONDON,residential_lease,150000,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0 +LONDON,residential_lease,150001,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0 +LONDON,residential_lease,180000,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0 +LONDON,residential_lease,225000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +LONDON,residential_lease,250000,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0 +LONDON,residential_lease,250001,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0 +LONDON,residential_lease,300000,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0 +LONDON,residential_lease,400000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +LONDON,residential_lease,425000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +LONDON,residential_lease,450000,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0 +LONDON,residential_lease,500000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0 +LONDON,residential_lease,500001,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0 +LONDON,residential_lease,625000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,residential_lease,750000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +LONDON,residential_lease,925000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +LONDON,residential_lease,1000000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0 +LONDON,residential_lease,1500000,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0 +LONDON,residential_lease,2000000,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0 +LONDON,residential_lease,6000000,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0 +LONDON,non_residential_lease,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 +LONDON,non_residential_lease,39999,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 +LONDON,non_residential_lease,40000,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 +LONDON,non_residential_lease,100000,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 +LONDON,non_residential_lease,125000,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 +LONDON,non_residential_lease,125001,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 +LONDON,non_residential_lease,150000,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 +LONDON,non_residential_lease,150001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +LONDON,non_residential_lease,180000,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0 +LONDON,non_residential_lease,225000,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0 +LONDON,non_residential_lease,250000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +LONDON,non_residential_lease,250001,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0 +LONDON,non_residential_lease,300000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +LONDON,non_residential_lease,400000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +LONDON,non_residential_lease,425000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +LONDON,non_residential_lease,450000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +LONDON,non_residential_lease,500000,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0 +LONDON,non_residential_lease,500001,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0 +LONDON,non_residential_lease,625000,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0 +LONDON,non_residential_lease,750000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +LONDON,non_residential_lease,925000,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0 +LONDON,non_residential_lease,1000000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +LONDON,non_residential_lease,1500000,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0 +LONDON,non_residential_lease,2000000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +LONDON,non_residential_lease,6000000,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0 +LONDON,non_residential_lease_above_5m,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 +LONDON,non_residential_lease_above_5m,39999,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0 +LONDON,non_residential_lease_above_5m,40000,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0 +LONDON,non_residential_lease_above_5m,100000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +LONDON,non_residential_lease_above_5m,125000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +LONDON,non_residential_lease_above_5m,125001,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0 +LONDON,non_residential_lease_above_5m,150000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +LONDON,non_residential_lease_above_5m,150001,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0 +LONDON,non_residential_lease_above_5m,180000,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0 +LONDON,non_residential_lease_above_5m,225000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +LONDON,non_residential_lease_above_5m,250000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +LONDON,non_residential_lease_above_5m,250001,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0 +LONDON,non_residential_lease_above_5m,300000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +LONDON,non_residential_lease_above_5m,400000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +LONDON,non_residential_lease_above_5m,425000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +LONDON,non_residential_lease_above_5m,450000,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0 +LONDON,non_residential_lease_above_5m,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +LONDON,non_residential_lease_above_5m,500001,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0 +LONDON,non_residential_lease_above_5m,625000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +LONDON,non_residential_lease_above_5m,750000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +LONDON,non_residential_lease_above_5m,925000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +LONDON,non_residential_lease_above_5m,1000000,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +LONDON,non_residential_lease_above_5m,1500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +LONDON,non_residential_lease_above_5m,2000000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +LONDON,non_residential_lease_above_5m,6000000,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0 +WALES,main,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 +WALES,main,39999,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 +WALES,main,40000,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 +WALES,main,100000,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 +WALES,main,125000,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 +WALES,main,125001,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 +WALES,main,150000,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 +WALES,main,150001,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 +WALES,main,180000,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 +WALES,main,225000,0.0,1575.0,0.0,1575.0,0.0,1575.0,0.0,1575.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 +WALES,main,250000,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0 +WALES,main,250001,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06 +WALES,main,300000,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0 +WALES,main,400000,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0 +WALES,main,425000,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0 +WALES,main,450000,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0 +WALES,main,500000,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0 +WALES,main,500001,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07 +WALES,main,625000,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0 +WALES,main,750000,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0 +WALES,main,925000,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0 +WALES,main,1000000,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0 +WALES,main,1500000,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0 +WALES,main,2000000,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0 +WALES,main,6000000,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0 +WALES,first,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 +WALES,first,39999,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 +WALES,first,40000,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 +WALES,first,100000,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 +WALES,first,125000,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 +WALES,first,125001,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 +WALES,first,150000,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 +WALES,first,150001,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 +WALES,first,180000,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 +WALES,first,225000,0.0,1575.0,0.0,1575.0,0.0,1575.0,0.0,1575.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 +WALES,first,250000,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,2450.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0 +WALES,first,250001,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,2450.05,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06,0.0,1500.06 +WALES,first,300000,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4950.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0 +WALES,first,400000,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0,0.0,10500.0 +WALES,first,425000,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,11825.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0,0.0,12375.0 +WALES,first,450000,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,13700.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0,0.0,14250.0 +WALES,first,500000,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,17450.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0,0.0,18000.0 +WALES,first,500001,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,17450.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07,0.0,18000.07 +WALES,first,625000,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,26825.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0,0.0,27375.0 +WALES,first,750000,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0,0.0,36750.0 +WALES,first,925000,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,53700.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0,0.0,54250.0 +WALES,first,1000000,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61200.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0,0.0,61750.0 +WALES,first,1500000,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0,0.0,111750.0 +WALES,first,2000000,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171200.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0,0.0,171750.0 +WALES,first,6000000,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651200.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0,0.0,651750.0 +WALES,additional,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 +WALES,additional,39999,0.0,1199.97,0.0,1199.97,0.0,1199.97,0.0,1599.96,0.0,1999.95,0.0,1999.95,0.0,1999.95,0.0,1999.95,0.0,1999.95,0.0,1999.95 +WALES,additional,40000,0.0,1200.0,0.0,1200.0,0.0,1200.0,0.0,1600.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0 +WALES,additional,100000,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,4000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0 +WALES,additional,125000,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,5000.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0 +WALES,additional,125001,0.0,3750.03,0.0,3750.03,0.0,3750.03,0.0,5000.04,0.0,6250.05,0.0,6250.05,0.0,6250.05,0.0,6250.05,0.0,6250.05,0.0,6250.05 +WALES,additional,150000,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,6000.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0 +WALES,additional,150001,0.0,4500.03,0.0,4500.03,0.0,4500.03,0.0,6000.04,0.0,7500.05,0.0,7500.05,0.0,7500.05,0.0,7500.05,0.0,7500.05,0.0,7500.05 +WALES,additional,180000,0.0,5400.0,0.0,5400.0,0.0,5400.0,0.0,7200.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0 +WALES,additional,225000,0.0,8325.0,0.0,8325.0,0.0,8325.0,0.0,10575.0,0.0,12825.0,0.0,12825.0,0.0,12825.0,0.0,12825.0,0.0,12825.0,0.0,12825.0 +WALES,additional,250000,0.0,9950.0,0.0,9950.0,0.0,9950.0,0.0,12450.0,0.0,14950.0,0.0,14950.0,0.0,14950.0,0.0,14950.0,0.0,14950.0,0.0,14950.0 +WALES,additional,250001,0.0,9950.08,0.0,9950.08,0.0,9950.08,0.0,12450.09,0.0,14950.1,0.0,14950.1,0.0,14950.1,0.0,14950.1,0.0,14950.1,0.0,14950.1 +WALES,additional,300000,0.0,13950.0,0.0,13950.0,0.0,13950.0,0.0,16950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0,0.0,19950.0 +WALES,additional,400000,0.0,21950.0,0.0,21950.0,0.0,21950.0,0.0,25950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0,0.0,29950.0 +WALES,additional,425000,0.0,24575.0,0.0,24575.0,0.0,24575.0,0.0,28825.0,0.0,33075.0,0.0,33075.0,0.0,33075.0,0.0,33075.0,0.0,33075.0,0.0,33075.0 +WALES,additional,450000,0.0,27200.0,0.0,27200.0,0.0,27200.0,0.0,31700.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0,0.0,36200.0 +WALES,additional,500000,0.0,32450.0,0.0,32450.0,0.0,32450.0,0.0,37450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0,0.0,42450.0 +WALES,additional,500001,0.0,32450.11,0.0,32450.11,0.0,32450.11,0.0,37450.11,0.0,42450.12,0.0,42450.12,0.0,42450.12,0.0,42450.12,0.0,42450.12,0.0,42450.12 +WALES,additional,625000,0.0,45575.0,0.0,45575.0,0.0,45575.0,0.0,51825.0,0.0,58075.0,0.0,58075.0,0.0,58075.0,0.0,58075.0,0.0,58075.0,0.0,58075.0 +WALES,additional,750000,0.0,58700.0,0.0,58700.0,0.0,58700.0,0.0,66200.0,0.0,73700.0,0.0,73700.0,0.0,73700.0,0.0,73700.0,0.0,73700.0,0.0,73700.0 +WALES,additional,925000,0.0,81450.0,0.0,81450.0,0.0,81450.0,0.0,90700.0,0.0,99950.0,0.0,99950.0,0.0,99950.0,0.0,99950.0,0.0,99950.0,0.0,99950.0 +WALES,additional,1000000,0.0,91200.0,0.0,91200.0,0.0,91200.0,0.0,101200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0,0.0,111200.0 +WALES,additional,1500000,0.0,156200.0,0.0,156200.0,0.0,156200.0,0.0,171200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0,0.0,186200.0 +WALES,additional,2000000,0.0,231200.0,0.0,231200.0,0.0,231200.0,0.0,251200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0,0.0,271200.0 +WALES,additional,6000000,0.0,831200.0,0.0,831200.0,0.0,831200.0,0.0,891200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0,0.0,951200.0 +WALES,non_residential,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 +WALES,non_residential,39999,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 +WALES,non_residential,40000,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 +WALES,non_residential,100000,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 +WALES,non_residential,125000,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 +WALES,non_residential,125001,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 +WALES,non_residential,150000,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 +WALES,non_residential,150001,0.0,0.01,0.0,0.01,0.0,0.01,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 +WALES,non_residential,180000,0.0,300.0,0.0,300.0,0.0,300.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 +WALES,non_residential,225000,0.0,750.0,0.0,750.0,0.0,750.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 +WALES,non_residential,250000,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0 +WALES,non_residential,250001,0.0,1000.05,0.0,1000.05,0.0,1000.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05,0.0,250.05 +WALES,non_residential,300000,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0 +WALES,non_residential,400000,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0 +WALES,non_residential,425000,0.0,9750.0,0.0,9750.0,0.0,9750.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0 +WALES,non_residential,450000,0.0,11000.0,0.0,11000.0,0.0,11000.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0,0.0,10250.0 +WALES,non_residential,500000,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0 +WALES,non_residential,500001,0.0,13500.05,0.0,13500.05,0.0,13500.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05,0.0,12750.05 +WALES,non_residential,625000,0.0,19750.0,0.0,19750.0,0.0,19750.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0,0.0,19000.0 +WALES,non_residential,750000,0.0,26000.0,0.0,26000.0,0.0,26000.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0,0.0,25250.0 +WALES,non_residential,925000,0.0,34750.0,0.0,34750.0,0.0,34750.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0,0.0,34000.0 +WALES,non_residential,1000000,0.0,38500.0,0.0,38500.0,0.0,38500.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0,0.0,37750.0 +WALES,non_residential,1500000,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0,0.0,67750.0 +WALES,non_residential,2000000,0.0,98500.0,0.0,98500.0,0.0,98500.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0 +WALES,non_residential,6000000,0.0,338500.0,0.0,338500.0,0.0,338500.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0,0.0,337750.0 +WALES,residential_lease,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 +WALES,residential_lease,39999,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 +WALES,residential_lease,40000,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 +WALES,residential_lease,100000,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 +WALES,residential_lease,125000,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 +WALES,residential_lease,125001,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 +WALES,residential_lease,150000,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 +WALES,residential_lease,150001,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 +WALES,residential_lease,180000,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 +WALES,residential_lease,225000,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 +WALES,residential_lease,250000,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 +WALES,residential_lease,250001,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 +WALES,residential_lease,300000,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 +WALES,residential_lease,400000,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 +WALES,residential_lease,425000,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 +WALES,residential_lease,450000,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 +WALES,residential_lease,500000,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 +WALES,residential_lease,500001,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 +WALES,residential_lease,625000,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 +WALES,residential_lease,750000,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 +WALES,residential_lease,925000,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 +WALES,residential_lease,1000000,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 +WALES,residential_lease,1500000,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 +WALES,residential_lease,2000000,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 +WALES,residential_lease,6000000,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 +WALES,non_residential_lease,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 +WALES,non_residential_lease,39999,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 +WALES,non_residential_lease,40000,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 +WALES,non_residential_lease,100000,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 +WALES,non_residential_lease,125000,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 +WALES,non_residential_lease,125001,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 +WALES,non_residential_lease,150000,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 +WALES,non_residential_lease,150001,0.0,0.01,0.0,0.01,0.0,0.01,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 +WALES,non_residential_lease,180000,0.0,300.0,0.0,300.0,0.0,300.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 +WALES,non_residential_lease,225000,0.0,750.0,0.0,750.0,0.0,750.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 +WALES,non_residential_lease,250000,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0 +WALES,non_residential_lease,250001,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01 +WALES,non_residential_lease,300000,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0 +WALES,non_residential_lease,400000,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0 +WALES,non_residential_lease,425000,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0 +WALES,non_residential_lease,450000,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0,0.0,2250.0 +WALES,non_residential_lease,500000,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0 +WALES,non_residential_lease,500001,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01,0.0,2750.01 +WALES,non_residential_lease,625000,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0,0.0,4000.0 +WALES,non_residential_lease,750000,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0,0.0,5250.0 +WALES,non_residential_lease,925000,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0,0.0,7000.0 +WALES,non_residential_lease,1000000,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0 +WALES,non_residential_lease,1500000,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0,0.0,12750.0 +WALES,non_residential_lease,2000000,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0,0.0,17750.0 +WALES,non_residential_lease,6000000,0.0,98500.0,0.0,98500.0,0.0,98500.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0,0.0,97750.0 +WALES,non_residential_lease_above_5m,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 +WALES,non_residential_lease_above_5m,39999,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98 +WALES,non_residential_lease_above_5m,40000,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0 +WALES,non_residential_lease_above_5m,100000,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0 +WALES,non_residential_lease_above_5m,125000,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0 +WALES,non_residential_lease_above_5m,125001,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02 +WALES,non_residential_lease_above_5m,150000,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0 +WALES,non_residential_lease_above_5m,150001,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02 +WALES,non_residential_lease_above_5m,180000,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0 +WALES,non_residential_lease_above_5m,225000,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0 +WALES,non_residential_lease_above_5m,250000,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0 +WALES,non_residential_lease_above_5m,250001,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02 +WALES,non_residential_lease_above_5m,300000,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0 +WALES,non_residential_lease_above_5m,400000,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0 +WALES,non_residential_lease_above_5m,425000,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0 +WALES,non_residential_lease_above_5m,450000,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0 +WALES,non_residential_lease_above_5m,500000,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0 +WALES,non_residential_lease_above_5m,500001,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02 +WALES,non_residential_lease_above_5m,625000,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0 +WALES,non_residential_lease_above_5m,750000,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0 +WALES,non_residential_lease_above_5m,925000,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0 +WALES,non_residential_lease_above_5m,1000000,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0 +WALES,non_residential_lease_above_5m,1500000,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0 +WALES,non_residential_lease_above_5m,2000000,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0 +WALES,non_residential_lease_above_5m,6000000,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0 +NORTHERN_IRELAND,main,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 +NORTHERN_IRELAND,main,39999,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 +NORTHERN_IRELAND,main,40000,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 +NORTHERN_IRELAND,main,100000,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 +NORTHERN_IRELAND,main,125000,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 +NORTHERN_IRELAND,main,125001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +NORTHERN_IRELAND,main,150000,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0,500.0,0.0 +NORTHERN_IRELAND,main,150001,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0,500.02,0.0 +NORTHERN_IRELAND,main,180000,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0,1100.0,0.0 +NORTHERN_IRELAND,main,225000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,main,250000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +NORTHERN_IRELAND,main,250001,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0,2500.05,0.0 +NORTHERN_IRELAND,main,300000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,main,400000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +NORTHERN_IRELAND,main,425000,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0,11250.0,0.0 +NORTHERN_IRELAND,main,450000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +NORTHERN_IRELAND,main,500000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +NORTHERN_IRELAND,main,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +NORTHERN_IRELAND,main,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +NORTHERN_IRELAND,main,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +NORTHERN_IRELAND,main,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +NORTHERN_IRELAND,main,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +NORTHERN_IRELAND,main,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +NORTHERN_IRELAND,main,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +NORTHERN_IRELAND,main,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +NORTHERN_IRELAND,first,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 +NORTHERN_IRELAND,first,39999,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 +NORTHERN_IRELAND,first,40000,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 +NORTHERN_IRELAND,first,100000,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 +NORTHERN_IRELAND,first,125000,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 +NORTHERN_IRELAND,first,125001,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 +NORTHERN_IRELAND,first,150000,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 +NORTHERN_IRELAND,first,150001,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 +NORTHERN_IRELAND,first,180000,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 +NORTHERN_IRELAND,first,225000,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 +NORTHERN_IRELAND,first,250000,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 +NORTHERN_IRELAND,first,250001,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 +NORTHERN_IRELAND,first,300000,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 +NORTHERN_IRELAND,first,400000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,first,425000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +NORTHERN_IRELAND,first,450000,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0,7500.0,0.0 +NORTHERN_IRELAND,first,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +NORTHERN_IRELAND,first,500001,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0,15000.05,0.0 +NORTHERN_IRELAND,first,625000,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0,21250.0,0.0 +NORTHERN_IRELAND,first,750000,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0,27500.0,0.0 +NORTHERN_IRELAND,first,925000,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0,36250.0,0.0 +NORTHERN_IRELAND,first,1000000,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0,43750.0,0.0 +NORTHERN_IRELAND,first,1500000,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +NORTHERN_IRELAND,first,2000000,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0,153750.0,0.0 +NORTHERN_IRELAND,first,6000000,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0,633750.0,0.0 +NORTHERN_IRELAND,additional,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 +NORTHERN_IRELAND,additional,39999,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 +NORTHERN_IRELAND,additional,40000,1200.0,0.0,1200.0,0.0,1200.0,0.0,1200.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,additional,100000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,additional,125000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +NORTHERN_IRELAND,additional,125001,3750.05,0.0,3750.05,0.0,3750.05,0.0,3750.05,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0,6250.07,0.0 +NORTHERN_IRELAND,additional,150000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +NORTHERN_IRELAND,additional,150001,5000.05,0.0,5000.05,0.0,5000.05,0.0,5000.05,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0,8000.07,0.0 +NORTHERN_IRELAND,additional,180000,6500.0,0.0,6500.0,0.0,6500.0,0.0,6500.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0,10100.0,0.0 +NORTHERN_IRELAND,additional,225000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0,13250.0,0.0 +NORTHERN_IRELAND,additional,250000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +NORTHERN_IRELAND,additional,250001,10000.08,0.0,10000.08,0.0,10000.08,0.0,10000.08,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0,15000.1,0.0 +NORTHERN_IRELAND,additional,300000,14000.0,0.0,14000.0,0.0,14000.0,0.0,14000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +NORTHERN_IRELAND,additional,400000,22000.0,0.0,22000.0,0.0,22000.0,0.0,22000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +NORTHERN_IRELAND,additional,425000,24000.0,0.0,24000.0,0.0,24000.0,0.0,24000.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0,32500.0,0.0 +NORTHERN_IRELAND,additional,450000,26000.0,0.0,26000.0,0.0,26000.0,0.0,26000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0,35000.0,0.0 +NORTHERN_IRELAND,additional,500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +NORTHERN_IRELAND,additional,500001,30000.08,0.0,30000.08,0.0,30000.08,0.0,30000.08,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0,40000.1,0.0 +NORTHERN_IRELAND,additional,625000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0,52500.0,0.0 +NORTHERN_IRELAND,additional,750000,50000.0,0.0,50000.0,0.0,50000.0,0.0,50000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0,65000.0,0.0 +NORTHERN_IRELAND,additional,925000,64000.0,0.0,64000.0,0.0,64000.0,0.0,64000.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0,82500.0,0.0 +NORTHERN_IRELAND,additional,1000000,73750.0,0.0,73750.0,0.0,73750.0,0.0,73750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0,93750.0,0.0 +NORTHERN_IRELAND,additional,1500000,138750.0,0.0,138750.0,0.0,138750.0,0.0,138750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0,168750.0,0.0 +NORTHERN_IRELAND,additional,2000000,213750.0,0.0,213750.0,0.0,213750.0,0.0,213750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0,253750.0,0.0 +NORTHERN_IRELAND,additional,6000000,813750.0,0.0,813750.0,0.0,813750.0,0.0,813750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0,933750.0,0.0 +NORTHERN_IRELAND,non_residential,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 +NORTHERN_IRELAND,non_residential,39999,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 +NORTHERN_IRELAND,non_residential,40000,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 +NORTHERN_IRELAND,non_residential,100000,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 +NORTHERN_IRELAND,non_residential,125000,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 +NORTHERN_IRELAND,non_residential,125001,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 +NORTHERN_IRELAND,non_residential,150000,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 +NORTHERN_IRELAND,non_residential,150001,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0,0.02,0.0 +NORTHERN_IRELAND,non_residential,180000,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0,600.0,0.0 +NORTHERN_IRELAND,non_residential,225000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +NORTHERN_IRELAND,non_residential,250000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,non_residential,250001,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0,2000.05,0.0 +NORTHERN_IRELAND,non_residential,300000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +NORTHERN_IRELAND,non_residential,400000,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0,9500.0,0.0 +NORTHERN_IRELAND,non_residential,425000,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0,10750.0,0.0 +NORTHERN_IRELAND,non_residential,450000,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0,12000.0,0.0 +NORTHERN_IRELAND,non_residential,500000,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0,14500.0,0.0 +NORTHERN_IRELAND,non_residential,500001,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0,14500.05,0.0 +NORTHERN_IRELAND,non_residential,625000,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0,20750.0,0.0 +NORTHERN_IRELAND,non_residential,750000,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0,27000.0,0.0 +NORTHERN_IRELAND,non_residential,925000,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0,35750.0,0.0 +NORTHERN_IRELAND,non_residential,1000000,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0,39500.0,0.0 +NORTHERN_IRELAND,non_residential,1500000,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0,64500.0,0.0 +NORTHERN_IRELAND,non_residential,2000000,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0,89500.0,0.0 +NORTHERN_IRELAND,non_residential,6000000,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0,289500.0,0.0 +NORTHERN_IRELAND,residential_lease,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 +NORTHERN_IRELAND,residential_lease,39999,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 +NORTHERN_IRELAND,residential_lease,40000,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 +NORTHERN_IRELAND,residential_lease,100000,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 +NORTHERN_IRELAND,residential_lease,125000,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 +NORTHERN_IRELAND,residential_lease,125001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +NORTHERN_IRELAND,residential_lease,150000,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0,250.0,0.0 +NORTHERN_IRELAND,residential_lease,150001,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0,250.01,0.0 +NORTHERN_IRELAND,residential_lease,180000,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0,550.0,0.0 +NORTHERN_IRELAND,residential_lease,225000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +NORTHERN_IRELAND,residential_lease,250000,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0,1250.0,0.0 +NORTHERN_IRELAND,residential_lease,250001,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0,1250.01,0.0 +NORTHERN_IRELAND,residential_lease,300000,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0,1750.0,0.0 +NORTHERN_IRELAND,residential_lease,400000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +NORTHERN_IRELAND,residential_lease,425000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +NORTHERN_IRELAND,residential_lease,450000,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0,3250.0,0.0 +NORTHERN_IRELAND,residential_lease,500000,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0,3750.0,0.0 +NORTHERN_IRELAND,residential_lease,500001,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0,3750.01,0.0 +NORTHERN_IRELAND,residential_lease,625000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,residential_lease,750000,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0,6250.0,0.0 +NORTHERN_IRELAND,residential_lease,925000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +NORTHERN_IRELAND,residential_lease,1000000,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0,8750.0,0.0 +NORTHERN_IRELAND,residential_lease,1500000,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0,13750.0,0.0 +NORTHERN_IRELAND,residential_lease,2000000,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0,18750.0,0.0 +NORTHERN_IRELAND,residential_lease,6000000,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0,58750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,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 +NORTHERN_IRELAND,non_residential_lease,39999,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 +NORTHERN_IRELAND,non_residential_lease,40000,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 +NORTHERN_IRELAND,non_residential_lease,100000,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 +NORTHERN_IRELAND,non_residential_lease,125000,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 +NORTHERN_IRELAND,non_residential_lease,125001,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 +NORTHERN_IRELAND,non_residential_lease,150000,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 +NORTHERN_IRELAND,non_residential_lease,150001,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0,0.01,0.0 +NORTHERN_IRELAND,non_residential_lease,180000,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0,300.0,0.0 +NORTHERN_IRELAND,non_residential_lease,225000,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0,750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,250000,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0,1000.0,0.0 +NORTHERN_IRELAND,non_residential_lease,250001,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0,1000.01,0.0 +NORTHERN_IRELAND,non_residential_lease,300000,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0,1500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,400000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,425000,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0,2750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,450000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +NORTHERN_IRELAND,non_residential_lease,500000,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0,3500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,500001,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0,3500.01,0.0 +NORTHERN_IRELAND,non_residential_lease,625000,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0,4750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,750000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +NORTHERN_IRELAND,non_residential_lease,925000,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0,7750.0,0.0 +NORTHERN_IRELAND,non_residential_lease,1000000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,1500000,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0,13500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,2000000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +NORTHERN_IRELAND,non_residential_lease,6000000,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0,68500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,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 +NORTHERN_IRELAND,non_residential_lease_above_5m,39999,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0,799.98,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,40000,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0,800.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,100000,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0,2000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,125000,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0,2500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,125001,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0,2500.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,150000,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0,3000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,150001,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0,3000.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,180000,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0,3600.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,225000,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0,4500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,250000,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0,5000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,250001,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0,5000.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,300000,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0,6000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,400000,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0,8000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,425000,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0,8500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,450000,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0,9000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,500000,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0,10000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,500001,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0,10000.02,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,625000,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0,12500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,750000,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0,15000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,925000,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0,18500.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,1000000,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0,20000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,1500000,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0,30000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,2000000,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0,40000.0,0.0 +NORTHERN_IRELAND,non_residential_lease_above_5m,6000000,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0,120000.0,0.0 diff --git a/policyengine_uk/tests/income_support_reforms.py b/policyengine_uk/tests/income_support_reforms.py new file mode 100644 index 0000000000..1692439402 --- /dev/null +++ b/policyengine_uk/tests/income_support_reforms.py @@ -0,0 +1,10 @@ +"""Reforms that policy YAML tests apply by dotted path (the `reforms` key).""" + +from policyengine_core.reforms import Reform + + +class neutralise_income_support(Reform): + """A reform that removes Income Support by neutralising income_support.""" + + def apply(self): + self.neutralize_variable("income_support") diff --git a/policyengine_uk/tests/legacy_award_work_reference.py b/policyengine_uk/tests/legacy_award_work_reference.py index 9f9cb76332..f9f85076b6 100644 --- a/policyengine_uk/tests/legacy_award_work_reference.py +++ b/policyengine_uk/tests/legacy_award_work_reference.py @@ -25,6 +25,26 @@ against pay (reg 98(11)). - ESA partner (para 6(1)(f), reg 42(1)): 24 hours or more, unless a carer (reg 43(2)(c)). +- ESA age (WRA 2007 s.1(3)(c)): no claimant may have reached pensionable age, + their own State Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). A + caller that reads the model's pensionable age passes it as is_SP_age; + otherwise ages avoid 65 to 69. In the years tested (2025 and 2026) anyone + aged 64 or under was born after 5 October 1960, reaches pensionable age at + 66 or later, and so falls below it; anyone aged 70 or over was born before + 6 April 1960 and reached it at 66 or earlier. +- ESA and State Pension Credit (para 6(1)(c) and (d)): neither the claimant + nor the other member of the couple may be entitled to it. A caller that + knows passes whether the couple is entitled (on_pension_credit): it would + claim, and it would get Pension Credit with no income-related ESA in the + family. Otherwise no member reports Pension Credit or Housing Benefit, so + no mixed-age couple keeps the SI 2019/37 art 4 saving and the + exclusion of mixed-age couples is in force (SPCA 2002 s.4(1A)). Only a + couple every member of which has reached the qualifying age can then be + entitled, and from 2019-20 that is pensionable age for everyone (SPCA 2002 + s.1(6)), so each of them has reached pensionable age and has no claim + under s.1(3)(c), whether or not DWP pays the couple Pension Credit. A + member outside the couple keeps their own claim even when the couple has + an entitlement to Pension Credit. - JSA claimant (s.1(2)(e), reg 51(1)(a)): 16 hours or more, with no exception for carers doing unrelated paid work. The other member of the couple: 24 hours or more (s.3(1)(e), reg 51(1)(b)). In a joint-claim @@ -153,17 +173,40 @@ def esa_claimant_in_remunerative_work(adult, parameters): return not (within_lower_limit or permitted_work) +def reached_pensionable_age(adult): + """WRA 2007 s.1(3)(c): whether the adult has reached pensionable age.""" + if "is_SP_age" in adult: + return bool(adult["is_SP_age"]) + assert not 65 <= adult["age"] < 70, adult + return adult["age"] >= 70 + + +def couple_on_pension_credit(couple): + """Para 6(1)(c) and (d), for a couple with no Pension Credit or Housing + Benefit reports in 2025 or 2026: a couple every member of which has + reached the qualifying age, each of whom has also reached pensionable + age and so has no claim under s.1(3)(c), whether or not DWP pays the + couple Pension Credit.""" + assert not any( + adult.get("pension_credit_reported", 0) + or adult.get("housing_benefit_reported", 0) + for adult in couple + ), couple + return len(couple) > 0 and all(reached_pensionable_age(a) for a in couple) + + def legacy_benefits_closed(couple, others, parameters): """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. + notice/deadline model. The generators draw tax-credit reports only in + years with tax credits, in which they close nothing. Ages avoid the + changing Pension Credit qualifying-age boundary. """ dwp = parameters.gov.dwp members = couple + others - assert not any( + assert dwp.tax_credits.active or not any( member.get("child_tax_credit_reported", 0) or member.get("working_tax_credit_reported", 0) for member in members @@ -187,16 +230,21 @@ def esa_abolition_claim(couple, parameters): reports (esa_income_report_closed). The abolition reaches every remaining award on 1 July 2026 in England - (SI 2025/1148 art. 3A), the households' default country, and no member - here has the appointee saving, so it reaches any award reported in a - year with less than all of it payable. would_claim_uc_at_legacy_closure - keeps its default, true. The conditions are those of the other - working-age closures: no Pension Credit report, and not all pension age. + (SI 2025/1148 art. 3A), the households' default country, so it reaches + any award reported in a year with less than all of it payable. Where a + member who reports the award has an appointee saving + (esa_income_appointee_saving), DWP keeps the whole claim payable (art. + 3A(2)); like esa_income_payable_share, this function ignores a saving + held only by a member who reports nothing. + would_claim_uc_at_legacy_closure keeps its default, true. The conditions + are those of the other working-age closures: no Pension Credit report, + and not all pension age. """ payable = parameters.gov.dwp.ESA.income.awards_payable.great_britain - reports_award = any(m.get("esa_income_reported", 0) > 0 for m in couple) + reporters = [m for m in couple if m.get("esa_income_reported", 0) > 0] + saved = any(m.get("esa_income_appointee_saving", False) for m in reporters) on_pension_credit = any(m.get("pension_credit_reported", 0) > 0 for m in couple) - if not reports_award or payable >= 1 or on_pension_credit: + if not reporters or saved or payable >= 1 or on_pension_credit: return False assert all(not 65 <= adult["age"] < 70 for adult in couple), couple return not all(adult["age"] >= 70 for adult in couple) @@ -233,12 +281,25 @@ def candidates(couple, others, reported, parameters): ] -def esa_screen(couple, others, capital, parameters): +def esa_screen(couple, others, capital, parameters, on_pension_credit=None): """esa_income_eligible for a claimant and partner (couple) and other - members of the benefit unit outside the family (others).""" + members of the benefit unit outside the family (others). on_pension_credit + is whether the couple is entitled to State Pension Credit, if the caller + knows it.""" ESA = parameters.gov.dwp.ESA.income members = couple + others + def barred(i): + # WRA 2007 s.1(3)(c), for every claimant. + if reached_pensionable_age(members[i]): + return True + # Sch 1 para 6(1)(c) and (d), for the couple's claim only. + if i >= len(couple): + return False + if on_pension_credit is not None: + return bool(on_pension_credit) + return couple_on_pension_credit(couple) + def other_member_works(i): if i >= len(couple): return False @@ -250,7 +311,8 @@ def other_member_works(i): ) passes = any( - not esa_claimant_in_remunerative_work(members[i], parameters) + not barred(i) + and not esa_claimant_in_remunerative_work(members[i], parameters) and not other_member_works(i) for i in candidates(couple, others, "esa_income_reported", parameters) ) diff --git a/policyengine_uk/tests/microsimulation/reforms_config.yaml b/policyengine_uk/tests/microsimulation/reforms_config.yaml index 0d43f6b6fa..022450ec2b 100644 --- a/policyengine_uk/tests/microsimulation/reforms_config.yaml +++ b/policyengine_uk/tests/microsimulation/reforms_config.yaml @@ -159,7 +159,7 @@ reforms: data: 1.58.1 enhanced_frs_2024_25 model: 2.135.7 (merged 7717438bf, main 7ad002f54, 2026-10-18) - name: Raise VAT standard rate by 2pp - expected_impact: 27.6 + expected_impact: 27.1 tolerance: 1.5 parameters: gov.hmrc.vat.standard_rate: 0.22 @@ -194,6 +194,9 @@ reforms: - impact: 27.581 data: 1.58.1 enhanced_frs_2024_25 model: 2.135.7 (merged 7717438bf, main 7ad002f54, 2026-10-18) + - impact: 27.071 + data: 1.58.1 enhanced_frs_2024_25 + model: 2.172.1 (merged af0b602ca, main 1d4d020aa, 2026-11-03) - name: Raise additional rate by 3pp expected_impact: 3.7 parameters: diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml index 63eb07b0a2..0873c9466c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/carer_premium.yaml @@ -19,9 +19,10 @@ # partners are treated as caring for the same person unless both are entitled # with an award: a reported Carer's Allowance or Carer Support Payment award # (carers_allowance_reported carries both), or an entitlement that caring -# hours do not explain (fewer than 35 a week, or no claim), which was supplied -# directly. Most cases below with two premiums therefore give both partners a -# reported award. +# hours do not explain (fewer than 35 a week, care of someone who isn't +# severely disabled (cares_for_severely_disabled_person false), or no claim), +# which an input supplied directly. Most cases below with two premiums +# therefore give both partners a reported award. - name: Carer premium for a single claimant entitled to Carer's Allowance period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml index aaf0b00010..4b062a0214 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/child_qualifying_young_person.yaml @@ -92,6 +92,9 @@ age: 19 age_started_or_accepted_current_education_or_training: 18 current_education: POST_SECONDARY + # Set explicitly: a person left unset takes the date fact's value, + # which is true for anyone under 20 on 6 October. + is_before_universal_credit_qualifying_young_person_terminal_date: false under_terminal_date_age_limit: age: 18 current_education: POST_SECONDARY diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml index 79162f0948..e1f7891896 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_claimant_composition.yaml @@ -3,9 +3,9 @@ # HB Regulations 2006 reg 13D(2)(a)(i) and reg 2(1) "young individual" # (housing_benefit_LHA_category): # https://www.legislation.gov.uk/uksi/2006/213/regulation/13D -# The existing bedroom calculation is unchanged: B = 1 bedroom, C = 2; -# A is the shared accommodation rate, overriding the bedroom calculation. -# Where the schemes agree, each case asserts both categories. +# B = 1 bedroom, C = 2; A is the shared accommodation rate, overriding the +# bedroom calculation. Where the schemes agree, each case asserts both +# categories. - name: Single 17-year-old renter is not their own dependent child period: 2025 input: @@ -71,8 +71,11 @@ is_responsible_for_child_or_qualifying_young_person_for_universal_credit: false lha_renter_has_non_dependant: true is_lha_shared_accommodation_rate_specified_renter: false - # No UC QYP, but a non-dependant blocks A. Existing bedroom formula = 1: B. - LHA_category: B + # No UC QYP (UC Regs 2013 reg 5(3): training under a contract of + # employment does not count), but a non-dependant blocks A. The + # non-dependant is not a child, so has a bedroom (Sch 4 para 10(1)(c)): + # 1 + 1 = 2: C. + LHA_category: C # HB reg 13D(3)(a)-(b): a bedroom for the renter and one for the # 17-year-old, who is not a child (reg 2(1): under 16): 2 bedrooms, C. housing_benefit_LHA_category: C @@ -98,8 +101,9 @@ is_responsible_for_child_or_qualifying_young_person_for_universal_credit: true lha_renter_has_non_dependant: false is_lha_shared_accommodation_rate_specified_renter: false - # Responsibility for a UC QYP blocks A. Existing bedroom formula = 1: B. - LHA_category: B + # Responsibility for a UC QYP blocks A. The QYP has a bedroom (Sch 4 + # para 10(1)(b)): 1 + 1 = 2: C. + LHA_category: C # HB reg 13D(3)(a)-(b): a bedroom for the renter and one for the # 18-year-old, who is not a child (reg 2(1): under 16): 2 bedrooms, C. housing_benefit_LHA_category: C diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml new file mode 100644 index 0000000000..8d3ae56336 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/LHA_young_person_bedroom.yaml @@ -0,0 +1,514 @@ +# A member of the benefit unit aged 16 or over who is not the claimant or +# partner has their own bedroom in the size criteria, with the exceptions below. +# +# Universal Credit: UC Regs 2013 Sch 4 para 10(1)(b), "a qualifying young +# person for whom the renter or either joint renter is responsible"; anyone +# else aged 16 or over living with the renter is a non-dependant (para 9(2)) +# with a bedroom under para 10(1)(c), "a non-dependant who is not a child", +# except a qualifying young person no one is responsible for (para 9(2)(g)) +# and a member who heads the household and is not a qualifying young person +# the renter is responsible for, whom the formula treats as liable for the +# rent (para 9(2)(d)). +# A child is a person under 16 (WRA 2012 s.40). +# +# Housing Benefit: HB Regs 2006 and HB (SPC) Regs 2006 reg 13D(3)(b), "a +# person who is not a child". +# +# Rents of £200 a week (£10,400 a year) sit between the model's Maidstone +# one-bedroom (B, £173.08 a week) and two-bedroom (C, £208.85 a week) rates, +# so the bedroom decides whether the rent is met in full. + +- name: A qualifying young person has their own bedroom + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_household_head: true + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + # UC: the renter (para 10(1)(a)) and the qualifying young person (para + # 10(1)(b)): 2 bedrooms, category C. + LHA_allowed_bedrooms: 2 + LHA_category: C + # The rent is below the two-bedroom rate, so it is met in full. + uc_housing_costs_element: 10_400 + # HB: one bedroom each for the claimant and the young person, neither of + # whom is a child (reg 13D(3)(b)): 2 bedrooms, category C. + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +- name: A qualifying young person aged 19 has their own bedroom + period: 2026 + input: + people: + parent: + age: 50 + is_household_head: true + is_parent: true + young_person: + age: 19 + current_education: UPPER_SECONDARY + # UC Regs 2013 reg 5(1)(b) and 5(2): enrolled before 19, and before + # the 1 September after their 19th birthday. + age_started_or_accepted_current_education_or_training: 18 + is_before_universal_credit_qualifying_young_person_terminal_date: true + benunits: + family: + members: [parent, young_person] + households: + home: + members: [parent, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + LHA_allowed_bedrooms: 2 + LHA_category: C + uc_housing_costs_element: 10_400 + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +- name: A member aged 18 who is not a qualifying young person is a non-dependant with a bedroom + period: 2026 + input: + people: + parent: + age: 45 + is_household_head: true + is_parent: true + son: + age: 18 + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [parent, son] + households: + home: + members: [parent, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + # UC: not a qualifying young person, so a non-dependant (para 9(2)) who + # is not a child (para 10(1)(c)). + LHA_allowed_bedrooms: 2 + LHA_category: C + # HB: a person who is not a child (reg 13D(3)(b)). + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +- name: A qualifying young person no one is responsible for has no Universal Credit bedroom + period: 2026 + input: + people: + carer: + age: 40 + is_household_head: true + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + # UC Regs 2013 reg 4(6)(a): no one is responsible for a qualifying + # young person looked after by a local authority. + is_looked_after_by_local_authority: true + benunits: + family: + members: [carer, young_person] + households: + home: + members: [carer, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + # Not under para 10(1)(b), and not a non-dependant (para 9(2)(g)): the + # renter's bedroom only. + LHA_allowed_bedrooms: 1 + LHA_category: B + +- name: A qualifying young person cannot share with a younger sibling of the same sex + period: 2026 + input: + people: + parent_1: + age: 42 + is_household_head: true + is_parent: true + is_male: true + parent_2: + age: 40 + is_parent: true + is_male: false + young_person: + age: 17 + is_male: true + current_education: UPPER_SECONDARY + boy: + age: 12 + is_male: true + girl: + age: 8 + is_male: false + benunits: + family: + members: [parent_1, parent_2, young_person, boy, girl] + households: + home: + members: [parent_1, parent_2, young_person, boy, girl] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true, false, false, false] + # UC: the couple (para 10(1)(a)), the qualifying young person (b), and + # one bedroom each for the boy and the girl, who are of opposite sexes + # and one of whom is 10 or over ((d)-(f)): 4 bedrooms, category E. + LHA_allowed_bedrooms: 4 + LHA_category: E + # HB: the couple (reg 13D(3)(a)), the young person ((b)), and the two + # children ((e)): 4 bedrooms, the maximum category. + housing_benefit_LHA_allowed_bedrooms: 4 + housing_benefit_LHA_category: E + +- name: A partner aged 19 adds no bedroom + period: 2026 + input: + people: + claimant: + age: 24 + is_household_head: true + partner: + age: 19 + current_education: NOT_IN_EDUCATION + benunits: + couple: + members: [claimant, partner] + households: + home: + members: [claimant, partner] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true] + # The couple share one bedroom (UC para 10(1)(a); HB reg 13D(3)(a)). + LHA_allowed_bedrooms: 1 + LHA_category: B + housing_benefit_LHA_allowed_bedrooms: 1 + housing_benefit_LHA_category: B + +- name: A pensioner couple's grandchild in full-time education has their own Housing Benefit bedroom + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent_1: + age: 70 + is_household_head: true + grandparent_2: + age: 68 + grandchild: + age: 17 + current_education: UPPER_SECONDARY + benunits: + family: + members: [grandparent_1, grandparent_2, grandchild] + households: + home: + members: [grandparent_1, grandparent_2, grandchild] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true, false] + is_child_or_young_person_for_legacy_benefits: [false, false, true] + # HB (SPC) Regs 2006 reg 13D(3): the couple ((a)) and the young person + # ((b)): 2 bedrooms, category C. + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + # No income, so Housing Benefit meets the eligible rent: the rent, which + # is below the two-bedroom rate. + housing_benefit: 10_400 + +- name: A qualifying young person no one is responsible for in a non-dependant's family adds no Universal Credit bedroom + period: 2026 + input: + people: + renter: + age: 40 + is_household_head: true + carer: + age: 45 + is_parent: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_looked_after_by_local_authority: true + benunits: + renter_family: + members: [renter] + carer_family: + members: [carer, young_person] + households: + home: + members: [renter, carer, young_person] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_non_dependant_of_household_head: [false, true, true] + is_qualifying_young_person_for_universal_credit: [false, false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, false, false] + # The renter (para 10(1)(a)) and the carer, a non-dependant who is not a + # child ((c)). The young person is no one's responsibility (reg 4(6)), so + # is not a non-dependant (para 9(2)(g)): 2 bedrooms, category C. + LHA_allowed_bedrooms: [2, 1] + LHA_category: [C, B] + +- name: A non-dependant couple has two Universal Credit bedrooms but one Housing Benefit bedroom + period: 2026 + input: + people: + renter: + age: 40 + is_household_head: true + non_dependant_1: + age: 30 + non_dependant_2: + age: 28 + benunits: + renter_family: + members: [renter] + couple: + members: [non_dependant_1, non_dependant_2] + households: + home: + members: [renter, non_dependant_1, non_dependant_2] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_claimant_or_partner: [true, true, true] + # UC: the renter (para 10(1)(a)) and each non-dependant who is not a + # child ((c)): 3 bedrooms, category D. The couple's own category would be + # 1 bedroom, and a couple is not a specified renter (para 28): B. + LHA_allowed_bedrooms: [3, 1] + LHA_category: [D, B] + # HB: the claimant ((b)) and the occupier couple ((a)): 2 bedrooms, + # category C. An ordinary couple shares a bedroom; only a couple who + # cannot share has two ((za); DWP circular HB A3/2017). The couple pay no + # rent; their own category would be 1 bedroom: B. + housing_benefit_LHA_allowed_bedrooms: [2, 1] + housing_benefit_LHA_category: [C, B] + +- name: A member who heads the household but does not claim adds no Universal Credit bedroom + period: 2025 + input: + people: + mother: + age: 45 + is_parent: true + son: + age: 20 + current_education: NOT_IN_EDUCATION + is_household_head: true + benunits: + family: + members: [mother, son] + households: + home: + members: [mother, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_resolved_household_head: [false, true] + is_claimant_or_partner: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + # The formula reads the son, as household head, as the householder in + # whose name the home is rented. He does not claim, so he is not a + # renter (Sch 4 paras 1(2) and 9(1)(a)). Aged 20 and not in education, he + # is not a qualifying young person (para 9(1)(b)). Liable for his + # occupation, he is not a non-dependant (para 9(2)(d)). Only the renter's + # bedroom counts (para 10(1)(a)): 1 bedroom, category B. + LHA_allowed_bedrooms: 1 + LHA_category: B + +- name: A household head who is a qualifying young person the renter is responsible for has their own bedroom + period: 2025 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 45 + is_male: false + is_parent: true + daughter: + age: 17 + is_male: false + current_education: UPPER_SECONDARY + is_household_head: true + benunits: + family: + members: [mother, daughter] + households: + home: + members: [mother, daughter] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_resolved_household_head: [false, true] + is_claimant_or_partner: [true, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + # The daughter heads the household but is a qualifying young person her + # mother is responsible for, so she is in the extended benefit unit + # (Sch 4 para 9(1)(b)) whoever is liable for the rent. The renter + # (para 10(1)(a)) and the qualifying young person (para 10(1)(b)): + # 2 bedrooms, category C, and the rent is met in full. + LHA_allowed_bedrooms: 2 + LHA_category: C + uc_housing_costs_element: 10_400 + # HB: one bedroom each for the claimant and the daughter, an occupier + # who is not a child (reg 13D(3)(b)). + housing_benefit_LHA_allowed_bedrooms: 2 + housing_benefit_LHA_category: C + +# A non-dependant in the renter's benefit unit has a bedroom (para 10(1)(c)) +# and bears a housing cost contribution (para 13). The claim that bears the +# contribution has the bedroom, and coding the same adult in a benefit unit +# of their own changes no claim. +# +# 2026-27 amounts in the parameter files: UC standard allowance for a single +# claimant aged 25 or over £424.90 a month, £5,098.80 a year; housing cost +# contribution £96.55 a month, £1,158.60 a year, for a non-dependant aged 21 +# or over (paras 14(1), 16(2)(a)); Household Energy Payment £100 (council +# tax bands A to D in England; the default band is D); TV Licence £180. + +- name: An adult son in the renter's benefit unit has a bedroom and bears a housing cost contribution + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 45 + is_household_head: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [mother, son] + households: + home: + members: [mother, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, true] + # The renter and the son, a non-dependant who is not a child: 2 bedrooms, + # category C (£208.85 a week, £10,860.20 a year, above the rent). + LHA_allowed_bedrooms: 2 + LHA_category: C + # 96.55 x 12 = 1,158.60. + uc_non_dep_deductions: 1_158.60 + # 10,400 - 1,158.60 = 9,241.40. + uc_housing_costs_element: 9_241.40 + # 5,098.80 + 9,241.40 = 14,340.20, with no income to taper. + universal_credit: 14_340.20 + # 14,340.20 + 100 (Household Energy Payment) - 180 (TV Licence). + household_net_income: 14_260.20 + +- name: The same adult son in a benefit unit of his own, not claiming, leaves his mother's claim unchanged + period: 2026 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 45 + is_household_head: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + benunits: + family: + members: [mother] + son_unit: + members: [son] + would_claim_uc: false + households: + home: + members: [mother, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_non_dependant_of_household_head: [false, true] + # The renter and the son, now another family's non-dependant: 2 + # bedrooms, category C, as when he is in her benefit unit. + LHA_allowed_bedrooms: [2, 1] + LHA_category: [C, A] + uc_non_dep_deductions: [1_158.60, 0] + uc_housing_costs_element: [9_241.40, 0] + universal_credit: [14_340.20, 0] + household_net_income: 14_260.20 + +- name: A claiming joint tenant has the bedroom of the non-claiming head's adult son whose contribution it bears + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + # Over State Pension age, so not eligible for Universal Credit. + age: 80 + is_household_head: true + is_claimant_or_partner: true + son: + age: 30 + is_claimant_or_partner: false + sharer: + age: 40 + is_claimant_or_partner: true + benunits: + head_family: + members: [head, son] + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + households: + home: + members: [head, son, sharer] + rent: 12_000 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_eligible: [false, true] + uc_non_dependants_counted: [false, true] + # The head's family makes no Universal Credit claim, so the sharer's + # claim counts the son (para 9(2)(f)): the sharer bears his contribution + # (96.55 x 12 = 1,158.60) and has his bedroom (renter 1 + son 1 = 2). + uc_non_dep_deductions: [0, 1_158.60] + LHA_allowed_bedrooms: [2, 2] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml new file mode 100644 index 0000000000..1edd5322e9 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/benefit_unit_non_dependants.yaml @@ -0,0 +1,1363 @@ +# Non-dependants within the claimant's own benefit unit. +# +# A benefit unit can include someone aged 16 or over who is neither the +# claimant or partner nor a child or young person the claimant is responsible +# for, such as an adult son or a 19-year-old who has left education. In law +# that person is a non-dependant of the renter (UC Regs 2013 Sch 4 para +# 9(1)(c) and (2); HB Regs 2006 and HB (SPC) Regs 2006 reg 3; CTR SI +# 2012/2885 reg 9, WSI 2013/3029 reg 9, SSI 2021/249 reg 8), so their family's +# claim bears a deduction for them, subject to each scheme's exemptions. +# +# Amounts for 2026-27: +# - UC housing cost contribution £96.55 a month (Sch 4 para 14(1)), aged 21 +# or over (para 16(2)(a)): £1,158.60 a year. +# - HB non-dependant aged 18 or over not in remunerative work £20.40 a week +# (reg 74(1)(b)): £1,060.80 a year. In remunerative work with gross income +# of £300 a week: £64.35 (reg 74(2)(c)), £3,346.20 a year. The HB cases +# give part-time earners £50 a week for 10 hours, below remunerative work +# (16 hours, reg 6) and inside the lowest band. + +- name: UC, an adult son in the renter's benefit unit bears a housing cost contribution + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, true] + uc_individual_non_dep_deduction: [0, 1_158.60] + uc_non_dep_deductions: 1_158.60 + +- name: UC, no contribution for a non-dependant in the benefit unit under 21 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + # Left education: not a qualifying young person. + age: 19 + is_claimant_or_partner: false + is_qualifying_young_person_for_universal_credit: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, true] + uc_non_dep_deductions: 0 + +- name: UC, a qualifying young person the renter is responsible for is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 19 + is_claimant_or_partner: false + is_qualifying_young_person_for_universal_credit: true + benunits: + benunit: + members: [parent, daughter] + households: + household: + members: [parent, daughter] + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, false] + uc_non_dep_deductions: 0 + +# Sch 4 para 9(2)(g): a qualifying young person for whom no one in the +# renter's extended benefit unit is responsible is not a non-dependant. +- name: UC, a qualifying young person looked after by a local authority is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 50 + is_household_head: true + is_claimant_or_partner: true + young_person: + age: 17 + is_claimant_or_partner: false + is_qualifying_young_person_for_universal_credit: true + is_looked_after_by_local_authority: true + benunits: + benunit: + members: [carer, young_person] + households: + household: + members: [carer, young_person] + output: + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false] + uc_non_dep_deductions: 0 + +# Sch 4 para 15: no contribution for any non-dependant if the renter, or +# either joint renter of a couple, receives the PIP daily living component. +- name: UC, no contribution where the renter receives the PIP daily living component + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + pip_dl: 3_000 + son: + age: 25 + is_claimant_or_partner: false + aunt: + # A non-dependant in another family. + age: 30 + benunits: + benunit_1: + members: [parent, son] + benunit_2: + members: [aunt] + households: + household: + members: [parent, son, aunt] + output: + uc_non_dep_deductions_renter_exempt: [true, false] + uc_non_dep_deductions: [0, 0] + +- name: UC, no contribution where the renter's partner is blind + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent_1: + age: 52 + is_household_head: true + is_claimant_or_partner: true + parent_2: + age: 50 + is_claimant_or_partner: true + is_blind: true + son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent_1, parent_2, son] + households: + household: + members: [parent_1, parent_2, son] + output: + uc_non_dep_deductions: 0 + +# Para 15(1)(a) exempts "any renter who is a single person to whom +# sub-paragraph (2) applies": the renter's own disability, not a child's. +- name: UC, a disabled child in the renter's family does not exempt the renter + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + child: + age: 10 + is_claimant_or_partner: false + dla_sc_category: MIDDLE + benunits: + benunit: + members: [parent, son, child] + households: + household: + members: [parent, son, child] + output: + # A child under 16 is never a non-dependant (WRA 2012 s.40). + is_benefit_unit_non_dependant_for_universal_credit: [false, true, false] + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true, false] + uc_non_dep_deductions_renter_exempt: false + uc_non_dep_deductions: 1_158.60 + +# Sch 4 para 16(2)(d): no contribution for a non-dependant receiving +# Attendance Allowance. +- name: UC, no contribution for a non-dependant in the benefit unit receiving Attendance Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + attendance_allowance: 3_000 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + uc_non_dep_deduction_exempt: [false, true] + uc_non_dep_deductions: 0 + +# Pension Credit is the claimant's award: an adult son in a family on Pension +# Credit is not receiving it, so para 16(2)(b) does not exempt him. +- name: UC, a family's Pension Credit does not exempt the non-dependant in its benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + is_claimant_or_partner: true + son: + age: 40 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, son] + pension_credit: 2_000 + output: + uc_non_dep_deduction_exempt: [true, false] + +- name: UC, the head's family bears non-dependants in its own benefit unit and in other families + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + aunt: + age: 60 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [parent, son] + benunit_2: + members: [aunt] + households: + household: + members: [parent, son, aunt] + output: + uc_non_dep_deductions: [2_317.20, 0] + +# A non-dependant family's own adult member is a non-dependant of the +# household head, counted once, in the head's claim (Sch 4 para 9(2)(f)). The +# head is below State Pension age, so has a Universal Credit claim to count +# them in (uc_non_dependants_counted). +- name: UC, an adult in a non-dependant family is the household head's non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 60 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 35 + is_claimant_or_partner: true + grandson: + age: 23 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [head] + benunit_2: + members: [daughter, grandson] + households: + household: + members: [head, daughter, grandson] + output: + uc_non_dep_deductions: [2_317.20, 0] + +# A sharer of the rent bears the non-dependant in its own benefit unit; the +# household head's family does not (para 9(2)(f)). +- name: UC, a sharer of the rent bears the non-dependant in its own benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + sharer_son: + age: 26 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer, sharer_son] + liable_for_share_of_household_rent: true + households: + household: + members: [head, sharer, sharer_son] + rent: 12_000 + output: + uc_non_dep_deductions: [0, 1_158.60] + +- name: UC, the housing costs element nets off the contribution for an adult son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, son] + benunit_tenure_type: RENT_FROM_COUNCIL + benunit_rent: 6_000 + households: + household: + members: [parent, son] + output: + # £6,000 - £1,158.60. + uc_housing_costs_element: 4_841.40 + +- name: HB, an adult son in the claimant's benefit unit working part time + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 2_600 + weekly_hours: 10 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true] + housing_benefit_non_dep_deductions: 1_060.80 + +- name: HB, an adult daughter in the claimant's benefit unit working full time on £300 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 22 + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 37.5 + benunits: + benunit: + members: [parent, daughter] + households: + household: + members: [parent, daughter] + output: + housing_benefit_non_dep_deductions: 3_346.20 + +- name: HB, no deduction for a non-dependant in the benefit unit under 18 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 17 + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, true] + housing_benefit_non_dep_deductions: 0 + +# HB reg 19: a Child Benefit qualifying young person is in the claimant's +# family, not a non-dependant. +- name: HB, a young person in the claimant's family is not a non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 19 + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: true + benunits: + benunit: + members: [parent, daughter] + households: + household: + members: [parent, daughter] + output: + is_benefit_unit_non_dependant_for_legacy_benefits: [false, false] + housing_benefit_non_dep_deductions: 0 + +# A joint tenant's adult son normally resides with both joint tenants (reg +# 3(1) and (4)) and is in neither's family under reg 3(2)(a), so he is a +# non-dependant of each, apportioned by their shares of the rent (reg 74(5)). +- name: HB, joint tenants share the deduction for one tenant's adult son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 2_600 + weekly_hours: 10 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + share_of_household_rent: [0.5, 0.5] + housing_benefit_non_dep_deductions: [530.40, 530.40] + +# The same son coded as his own benefit unit gives the same deductions: the +# result does not depend on how the survey groups him. +- name: HB, joint tenants share the deduction for an adult son in his own benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: true + employment_income: 2_600 + weekly_hours: 10 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + benunit_3: + members: [son] + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + housing_benefit_non_dep_deductions: [530.40, 530.40, 0] + +# A lodger's adult son is the lodger's non-dependant, and the householder's +# too: reg 3(2)(e)(i) excludes only the person liable to pay the householder. +# The lodger is not a joint occupier with the householder, so reg 74(5) does +# not apportion him and each bears the whole deduction. +- name: HB, a lodger's adult son is a non-dependant of the lodger and of the householder + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 21 + is_claimant_or_partner: false + employment_income: 2_600 + weekly_hours: 10 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [head, lodger, lodger_son] + rent: 9_000 + output: + housing_benefit_non_dep_deductions: [1_060.80, 1_060.80] + +- name: CTR, the claimant's family bears the non-dependant in its benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deductions: 300 + +- name: CTR, no deduction for a non-dependant in the benefit unit under 18 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 17 + is_claimant_or_partner: false + is_child_or_qualifying_young_person_for_child_benefit: false + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false] + council_tax_reduction_non_dep_deductions: 0 + +# An applicant's adult son normally resides with both jointly liable people +# and is in neither's family (SI 2012/2885 reg 9(1), (2)(a)), so the deduction +# is apportioned equally between them (Sch 1 para 8(5)). +- name: CTR, jointly liable applicants share the deduction for one applicant's adult son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + council_tax_reduction_non_dep_deductions: [150, 150] + +# Para 8(5) still apportions a non-dependant from another family between the +# jointly liable people. +- name: CTR, jointly liable families share the deduction for another family's adult + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + friend: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + benunit_3: + members: [friend] + households: + household: + members: [head, sharer, friend] + rent: 12_000 + output: + council_tax_reduction_non_dep_deductions: [150, 150, 0] + +# SI 2012/2885 reg 9(2)(e) excludes only the person liable to pay the +# applicant, so a lodger's adult son who pays nothing himself is the +# applicant's non-dependant. +- name: CTR, a lodger's adult son is the applicant's non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 21 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [head, lodger, lodger_son] + rent: 9_000 + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] + council_tax_reduction_non_dep_deductions: [300, 0] + +# Only a family that claims bears deductions; the non-dependant's own family +# does not. +- name: CTR, a non-dependant family with an adult member bears no deductions itself + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + is_claimant_or_partner: true + daughter: + age: 45 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 200 + grandson: + age: 23 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 100 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [daughter, grandson] + households: + household: + members: [head, daughter, grandson] + output: + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true, true] + council_tax_reduction_non_dep_deductions: [300, 0] + +# With the Housing Benefit and Council Tax Reduction scales and exemptions as +# legislated (#2004): a non-dependant within the benefit unit is banded on +# their own income and exempt only on their own circumstances. + +# HB reg 74(1)(b): a non-dependant aged 18 or over not in remunerative work +# pays the lowest amount, £20.40 a week, whatever their income. +- name: HB, an adult son in the claimant's benefit unit with no income pays the lowest amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + output: + housing_benefit_non_dep_deductions: 1_060.80 + +# HB reg 74(7)(c); CTR SI 2012/2885 Sch 1 para 8(7)(c). +- name: HB and CTR, no deduction for a full-time student in the claimant's benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + is_household_head: true + is_claimant_or_partner: true + son: + age: 20 + is_claimant_or_partner: false + current_education: TERTIARY + benunits: + benunit: + members: [parent, son] + households: + household: + members: [parent, son] + country: ENGLAND + local_authority: MAIDSTONE + output: + housing_benefit_non_dep_deductions: 0 + council_tax_reduction_non_dep_deductions: 0 + +# HB reg 74(8)(a) exempts a non-dependant under 25 who is on Income Support. +# The family's Income Support is the parent's award, so the 22-year-old son is +# not on it and is not exempt. +- name: HB, a parent's Income Support does not exempt the adult son in their benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 22 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [parent, son] + income_support: 3_000 + households: + household: + members: [parent, son] + output: + housing_benefit_non_dep_deduction_exempt: [false, false] + housing_benefit_non_dep_deductions: 1_060.80 + +# HB reg 74(2)-(4): a non-dependant couple pays one deduction, the higher, on +# their joint income; their adult son is a non-dependant in his own right, on +# his own income. Father and son each earn £300 a week working full time, so +# the couple and the son each pay the £279-£365 band's £64.35 (not one £119.85 +# on a £600 family income): £128.70 a week, £6,692.40 a year. +- name: HB, a non-dependant couple and their adult son pay two deductions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 75 + is_household_head: true + is_claimant_or_partner: true + father: + age: 55 + is_claimant_or_partner: true + employment_income: 15_600 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + mother: + age: 52 + is_claimant_or_partner: true + current_education: NOT_IN_EDUCATION + son: + age: 23 + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + benunits: + head_unit: + members: [head] + family: + members: [father, mother, son] + would_claim_uc: false + households: + household: + members: [head, father, mother, son] + output: + household_benefits_individual_non_dep_deduction: [0, 3_346.20, 1_060.80, 3_346.20] + housing_benefit_non_dep_deductions: [6_692.40, 0] + +# SI 2012/2885 Sch 1 para 8(2)(c): £576.92 a week is in the £485-£605 band, +# £13.30 a week in 2026-27, £691.60 a year. +- name: CTR, England pensioner scheme charges the working son in the claimant's benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_household_head: true + is_claimant_or_partner: true + son: + age: 40 + is_claimant_or_partner: false + employment_income: 30_000 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [pensioner, son] + households: + household: + members: [pensioner, son] + country: ENGLAND + local_authority: MAIDSTONE + output: + council_tax_reduction_individual_non_dep_deduction: [0, 691.60] + council_tax_reduction_non_dep_deductions: 691.60 + +# SI 2012/2885 Sch 1 para 8(6): the exemption belongs to each applicant ("the +# applicant or his partner"). With two jointly liable applicants, one's +# blindness does not exempt the other's claim. +- name: CTR, a blind household head does not exempt a jointly liable sharer + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + is_blind: true + sharer: + age: 48 + is_claimant_or_partner: true + friend: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + benunit_3: + members: [friend] + households: + household: + members: [head, sharer, friend] + rent: 12_000 + country: SCOTLAND + output: + council_tax_reduction_applicant_has_non_dep_exemption: [true, false, false] + council_tax_reduction_non_dep_deductions: [0, 150, 0] + +- name: CTR, a blind sharer is exempt but the household head is not + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + is_blind: true + friend: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + benunit_3: + members: [friend] + households: + household: + members: [head, sharer, friend] + rent: 12_000 + country: SCOTLAND + output: + council_tax_reduction_applicant_has_non_dep_exemption: [false, true, false] + council_tax_reduction_non_dep_deductions: [150, 0, 0] + +# The sharer's adult son is a non-dependant of both jointly liable people. +# The blind sharer is exempt; the head is not and bears half (para 8(5)). +- name: CTR, a blind sharer is exempt for its own adult son but the head still bears half + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + is_blind: true + sharer_son: + age: 25 + is_claimant_or_partner: false + council_tax_reduction_individual_non_dep_deduction: 300 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer, sharer_son] + liable_for_share_of_household_rent: true + households: + household: + members: [head, sharer, sharer_son] + rent: 12_000 + country: SCOTLAND + output: + council_tax_reduction_non_dep_deductions: [150, 0] + +# The English working-age local schemes keep their own aggregation, which +# charges only other families' non-dependants. Widening the shared +# eligibility must not make a jointly liable sharer bear the head's own son. +- name: Local CTR (Newham), a jointly liable sharer does not bear the head's own in-unit son + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + sharer: + age: 48 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + would_claim_council_tax_reduction: true + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_council_tax_reduction: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + region: LONDON + local_authority: NEWHAM + tenure_type: RENT_PRIVATELY + council_tax: 1_800 + output: + council_tax_reduction_joint_liability_share: [0.5, 0.5] + newham_council_tax_reduction_non_dep_deductions: [0, 0] + newham_council_tax_reduction: [630, 630] + +# SI 2012/2885 reg 9(2)(a): the applicant's own family are not its +# non-dependants, so an individual deduction set for the applicant (here as +# an input) is never charged to the applicant's own claim. +- name: CTR, an applicant is never charged a deduction for itself + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + friend: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 200 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [friend] + households: + household: + members: [head, friend] + rent: 12_000 + output: + council_tax_reduction_claimant_benunit: [true, false] + council_tax_reduction_non_dep_deductions: [200, 0] + +# HB reg 74(5) apportions the sharer's son between the joint occupiers; reg +# 74(6) then removes the deduction from the blind sharer's claim only. The son +# earns £384.62 a week: £105.20 (reg 74(2)(d)) x 52 = £5,470.40, half each. +- name: HB, a blind sharer is exempt for its own adult son but the head still bears half + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + is_blind: true + sharer_son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer, sharer_son] + liable_for_share_of_household_rent: true + households: + household: + members: [head, sharer, sharer_son] + rent: 12_000 + country: SCOTLAND + output: + household_benefits_individual_non_dep_deduction: [0, 0, 5_470.40] + housing_benefit_non_dep_deductions: [2_735.20, 0] + +# A lodger on the PIP daily living component is exempt for its own son (reg +# 74(6)); the householder, who is not exempt, still bears him in full. +- name: HB, an exempt lodger is not charged for its own adult son but the householder is + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 45 + is_claimant_or_partner: true + pip_dl: 3_000 + rent_paid_as_lodger: 5_200 + lodger_son: + age: 25 + is_claimant_or_partner: false + employment_income: 20_000 + weekly_hours: 37.5 + benunits: + benunit_1: + members: [householder] + benunit_2: + members: [lodger, lodger_son] + households: + household: + members: [householder, lodger, lodger_son] + rent: 9_000 + country: SCOTLAND + output: + housing_benefit_non_dep_deductions: [5_470.40, 0] + +# A household head who is liable but is not her benefit unit's claimant or +# partner applies alone (council_tax_reduction_head_applies_alone). The young +# couple in her benefit unit are then outside her family and are her +# non-dependants (SI 2012/2885 reg 9(1), (2)(a)); as a couple they pay one +# deduction, the higher (Sch 1 para 8(3)). +- name: CTR, a head applying alone is charged once for the couple in her benefit unit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: true + is_claimant_or_partner: false + son: + age: 30 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 300 + partner: + age: 29 + is_claimant_or_partner: true + council_tax_reduction_individual_non_dep_deduction: 200 + benunits: + benunit: + members: [grandmother, son, partner] + households: + household: + members: [grandmother, son, partner] + rent: 12_000 + output: + council_tax_reduction_head_applies_alone: [true] + is_benefit_unit_non_dependant_for_council_tax_reduction: [false, true, true] + council_tax_reduction_non_dep_deductions: [300] + +# Sch 4 para 9(2)(f): an in-unit non-dependant counts in his own family's +# Universal Credit claim if it makes one. A pension-age head makes none, so the +# claim that counts the household's non-dependants, the jointly liable +# sharer's, counts him. +- name: UC, a non-claiming head's adult son counts in the sharer's claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + is_claimant_or_partner: true + son: + age: 30 + is_claimant_or_partner: false + sharer: + age: 40 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + uc_non_dependants_counted: [false, true] + uc_non_dep_deductions: [0, 1_158.60] + +# Sch 4 para 16(2)(b) exempts a non-dependant "in receipt of state pension +# credit". Pension Credit is the claimant's award, so a pensioner's adult son +# in her benefit unit is not exempt by it. +- name: UC, a pensioner non-dependant's Pension Credit does not exempt her adult son + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + pensioner: + age: 75 + is_claimant_or_partner: true + pensioner_son: + age: 35 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [head] + benunit_2: + members: [pensioner, pensioner_son] + households: + household: + members: [head, pensioner, pensioner_son] + rent: 12_000 + output: + uc_non_dep_receives_pension_credit: [false, true] + uc_non_dep_deduction_exempt: [false, true, false] + uc_non_dep_deductions: [1_158.60, 0] + +# The household head is the householder in whose name the accommodation is +# rented, so she is no one's non-dependant for UC (Sch 4 para 9(2)(d)) or HB +# (reg 3(2)(d)), even where her benefit unit's claimant and partner are the +# young couple. For CTR she applies alone and they are her non-dependants. +- name: UC and HB, a head who applies for CTR alone is not her benefit unit's non-dependant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 70 + is_household_head: true + is_claimant_or_partner: false + son: + age: 30 + is_claimant_or_partner: true + employment_income: 25_000 + weekly_hours: 37.5 + partner: + age: 29 + is_claimant_or_partner: true + benunits: + benunit: + members: [grandmother, son, partner] + households: + household: + members: [grandmother, son, partner] + rent: 12_000 + output: + is_benefit_unit_non_dependant_for_universal_credit: [false, false, false] + is_benefit_unit_non_dependant_for_legacy_benefits: [false, false, false] + is_benefit_unit_non_dependant_for_council_tax_reduction: [false, true, true] + uc_non_dep_deductions: [0] + housing_benefit_non_dep_deductions: [0] + +# Sch 4 para 9(2)(f): a family that makes no Universal Credit claim treats +# no one as its non-dependant, so a non-claiming lodger's adult son counts in +# the householder's claim. "Making a claim" is the model's would-claim flag. +- name: UC, a non-claiming lodger's adult son counts in the householder's claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 40 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 39 + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + lodger_son: + age: 25 + is_claimant_or_partner: false + benunits: + benunit_1: + members: [householder] + benunit_2: + members: [lodger, lodger_son] + would_claim_uc: false + households: + household: + members: [householder, lodger, lodger_son] + rent: 9_000 + output: + uc_non_dependants_counted: [true, false] + uc_non_dep_deductions: [1_158.60, 0] + +# A family bearing a share of the rent is a joint occupier, never a +# non-dependant, even where the rent is nil (HB reg 3(2)(d)). Its own adult son +# is still a non-dependant of both joint occupiers, apportioned by their +# shares (reg 74(5)). +- name: HB, a nil-rent sharer is not a non-dependant but its adult son is shared + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + sharer: + age: 48 + is_claimant_or_partner: true + household_benefits_individual_non_dep_deduction: 2_000 + sharer_son: + age: 25 + is_claimant_or_partner: false + household_benefits_individual_non_dep_deduction: 1_000 + benunits: + benunit_1: + members: [head] + benunit_2: + members: [sharer, sharer_son] + liable_for_share_of_household_rent: true + households: + household: + members: [head, sharer, sharer_son] + rent: 0 + output: + share_of_household_rent: [0.5, 0.5] + housing_benefit_non_dep_deductions: [500, 500] + +# Sch 4 para 9(2)(f): the head's family is flagged not to claim Universal +# Credit, so its adult son counts in the jointly liable sharer's claim. +- name: UC, a non-claiming head's adult son counts in a claiming sharer's claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + is_household_head: true + is_claimant_or_partner: true + employment_income: 150_000 + weekly_hours: 37.5 + son: + age: 25 + is_claimant_or_partner: false + sharer: + age: 45 + is_claimant_or_partner: true + benunits: + benunit_1: + members: [head, son] + would_claim_uc: false + benunit_2: + members: [sharer] + liable_for_share_of_household_rent: true + households: + household: + members: [head, son, sharer] + rent: 12_000 + output: + uc_non_dependants_counted: [false, true] + uc_non_dep_deductions: [0, 1_158.60] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml new file mode 100644 index 0000000000..5b31eb0678 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/child_tax_credit_school_leaver_routes.yaml @@ -0,0 +1,536 @@ +# Child Tax Credit qualifying young persons who have left education: Child +# Tax Credit Regulations 2002 reg. 4 (from the 16th birthday until the 31 +# August that next follows, with no education condition), reg. 5(3)(b) (20 +# weeks after leaving, under 18, while registered for work or training) and +# the second condition in reg. 5(4), which applies to both: not in +# remunerative work (24 hours a week or more, reg. 2(1)) after leaving, and +# not receiving a benefit in their own right. +# +# The child element was £3,455 in 2024-25, the last year of tax credits +# (gov/dwp/tax_credits/child_tax_credit/elements/child_element.yaml). +# +# The child element needs an existing claim that a Universal Credit claim has +# not ended (is_CTC_eligible): each case that reads it gives the claimant a +# reported award and the family no Universal Credit claim. + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4(1)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + is_child_or_qualifying_young_person_for_child_tax_credit: [false, true] + is_responsible_for_child_or_qualifying_young_person_for_child_tax_credit: true + CTC_child_element: 3_455 + +- name: Case 2, a 16-year-old who has left education and works 24 hours a week is not a qualifying young person (reg. 5(4)(a)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_in_remunerative_work_for_child_tax_credit: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + # Child Benefit reg. 4 has no remunerative work bar. + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 3, working 23 hours a week does not end the period. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 4, remunerative work does not matter for a 16-year-old still at school. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + pupil: + age: 16 + current_education: UPPER_SECONDARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, pupil] + would_claim_uc: false + output: + # Reg. 5(4)(a) applies to a person "having ceased" education or training. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, a 16-year-old apprentice still on a college course and working 30 hours a week is a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + would_claim_uc: false + output: + # Education "by virtue of his employment" is left out of the first + # condition (reg. 5(3)(a)(ii)), which reg. 4(2) lifts. The apprentice has + # not "ceased to receive full-time education" (reg. 5(4)(a), 5(5)), so + # the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 6, born 15 September 2007, the period runs to 31 August 2024. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 7, born 31 August 2007, reg. 4(1A) gives no period to the next 31 August. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20070831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 8, born 31 August 2008, the year in which they are a child until 30 August counts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + date_of_birth: 20080831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 9, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 5(4)(c)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 10, a 17-year-old who has left education is not a qualifying young person by default. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 11, a 17-year-old in the 20 weeks after leaving, registered for work or training, is a qualifying young person (reg. 5(3)(b)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 12, the 20 weeks end if the 17-year-old starts remunerative work. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 13, the 20 weeks are for under-18s only. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_tax_credit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 14, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + child_tax_credit_reported: 1 + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: true + is_child_or_qualifying_young_person_for_child_tax_credit: false + CTC_child_element: 0 + +- name: Case 15, a 16-year-old in full-time advanced education working 24 hours a week is a qualifying young person (reg. 4(2), 5(4)(a), 5(5)). + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 16 + date_of_birth: 20080101 + current_education: TERTIARY + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + # Advanced education fails the first condition, which reg. 4(2) lifts. + # It is still full-time education (reg. 5(5)), so the student has not + # "ceased" it and the remunerative work bar does not apply. + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 16, the same advanced student aged 17, outside the reg. 4 period, is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + date_of_birth: 20070101 + current_education: TERTIARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 17, a 16-year-old apprentice no longer on any course and working 30 hours a week is not a qualifying young person. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: NOT_IN_EDUCATION + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + would_claim_uc: false + output: + # Ceased full-time education and in remunerative work (reg. 5(4)(a)). + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +# Cases 18 to 20: the Income Support bar in reg. 5(4)(c), which reg. 4(2) +# keeps for the 16th-birthday period. 2018-19 rates: child element £2,780 and +# family element £545 a year (child_element.yaml, family_element.yaml); PIP +# daily living standard rate £57.30 a week; Carer's Allowance £64.60 a week; +# Child Benefit eldest rate £20.70 a week; colour TV licence £150.50. Cases +# 18 and 19 set the cost-of-living payment, which DWP first paid in 2022, to +# zero. + +- name: Case 18, a 16-year-old school leaver who receives Income Support in their own right is not a qualifying young person (reg. 4(2), reg. 5(4)(c)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + child_tax_credit_reported: 1 + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + # DWP first paid cost-of-living payments in 2022. + cost_of_living_support_payment: 0 + output: + # Born 15 January 2002: the reg. 4 period runs to 31 August 2018. + is_before_first_september_following_16th_birthday: [false, true] + # Not the claimant or partner, so their own report while Income Support + # is in payment. + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + # No child or qualifying young person, so no CTC. + child_tax_credit: 0 + # Child Benefit reg. 8(2)(a) ends their Child Benefit status too. + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; + # less TV licence 150.50: 6,188.30. The model calculates Income Support + # only for the claimant's family, so the 16-year-old's own 3,000 is in no + # income here. + household_net_income: 6_188.30 + +- name: Case 19, the same family without Income Support for the 16-year-old gets CTC until 31 August 2018 (reg. 4(1)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + child_tax_credit_reported: 1 + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + # DWP first paid cost-of-living payments in 2022. + cost_of_living_support_payment: 0 + output: + is_before_first_september_following_16th_birthday: [false, true] + is_on_income_support: [false, false] + is_qualifying_young_person_for_child_tax_credit: [false, true] + # No income: child element 2,780 + family element 545 = 3,325. + child_tax_credit: 3_325 + # 20.70 x 52. + child_benefit: 1_076.40 + # Case 18's 6,188.30 + Child Benefit 1,076.40 + 3,325 = 10,589.70. + household_net_income: 10_589.70 + +- name: Case 20, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 5(3)(a), 5(4)(c)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + child_tax_credit_reported: 1 + student: + age: 17 + date_of_birth: 20010115 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + # The first condition holds: full-time non-advanced education begun + # before 19. + meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit: [false, true] + is_before_first_september_following_16th_birthday: [false, false] + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + child_tax_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml index 93cff38dce..8beb6ffcf9 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_capital.yaml @@ -20,7 +20,9 @@ other_residential_property_value: 3_000 non_residential_property_value: 4_000 output: - esa_income_assessable_capital: 10_500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 9,500. + esa_income_assessable_capital: 9_550 - name: Income-related ESA has no tariff income at the lower capital threshold period: 2026 @@ -231,4 +233,144 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - esa_income_assessable_capital: 3_000 + # Savings at face value; shares less 10% for the expenses of sale + # (ESA Regs 2008 reg. 113(a)): 1,000 + 90% of 2,000. + esa_income_assessable_capital: 2_800 + +- name: Income-related ESA values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + esa_income_assessable_capital: 9_000 + +- name: Income-related ESA preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + esa_income_assessable_capital: [5_400, 10_800] + +- name: Income-related ESA reported claim path receives full overseas land and property capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + esa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + overseas_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + esa_income_assessable_capital: [16_200, 0] + +- name: Income-related ESA values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + esa_income_assessable_capital: 9_000 + +- name: Income-related ESA preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + esa_income_assessable_capital: [5_400, 10_800] + +- name: Income-related ESA reported claim path receives full unclassified other real estate capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + esa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + other_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + esa_income_assessable_capital: [16_200, 0] + +- name: Income-related ESA deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + esa_income_assessable_capital: 28_000 + +- name: Income-related ESA floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + esa_income_assessable_capital: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml new file mode 100644 index 0000000000..6987ebea0d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/esa_income/esa_income_pension_age.yaml @@ -0,0 +1,1027 @@ +# Income-related ESA and pensionable age or State Pension Credit. +# Welfare Reform Act 2007 s.1(3)(c): a claimant who has reached pensionable +# age is not entitled to ESA. Pensionable age is the person's own State +# Pension age (s.1(6); Pensions Act 1995 Sch 4 para 1). +# Sch 1 para 6(1)(c) and (d): a claimant gets the income-related allowance +# only if the claimant "is not entitled to state pension credit" and "is not a +# member of a couple the other member of which is entitled to ... state +# pension credit". The model reads the Pension Credit the couple would get +# with no income-related ESA in the family, B(0), and refuses the couple's +# claim where B(0) exceeds nil. B(e) is the Pension Credit the family would +# get with the award e. Where B(0) is nil, the model pays the couple's award +# and no Pension Credit, whatever B(e) (cases 18, 25 and 26). A mixed-age +# couple has no entitlement to State Pension Credit from 15 May 2019 (State +# Pension Credit Act 2002 s.4(1A)) unless it keeps the SI 2019/37 art 4 +# saving. +# Nobody here claims Universal Credit (would_claim_uc false), since a couple +# that claims it loses its income-related ESA anyway. No case has capital, so +# no tariff income: the model pays an award that passes at the amount +# reported. With no income, a family gets the guarantee credit's standard +# minimum guarantee for 52 weeks: in 2026-27, £238.00 for a single claimant and +# £363.25 for a couple (SPC Regs 2002 reg 6(1), as amended by the Social +# Security Benefits Up-rating Order 2026, SI 2026/148, art 29(2)), so +# 52 x 238.00 = 12_376 and 52 x 363.25 = 18_889. Nobody here in 2026 reached +# pensionable age before 6 April 2016, so no savings credit is due (SPCA 2002 +# s.3(1)). + +- name: Case 1, a single claimant aged 70 who reports income-related ESA gets Pension Credit and no ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # Born in 1956: pensionable age 66 (Pensions Act 1995 Sch 4 para 1 rule 6). + is_SP_age: true + # The claimant fails s.1(3)(c): 0, not the 5_000 reported. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: false + # 52 x 238.00. + pension_credit: 12_376 + +- name: Case 2, a couple aged 70 and 68 where the 68-year-old reports income-related ESA get Pension Credit and no ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 68 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Both born after 5 October 1954 and before 6 April 1960: pensionable + # age 66 (rule 6). + is_SP_age: [true, true] + meets_pension_credit_age_conditions: true + # The 68-year-old fails s.1(3)(c), and para 6(1)(c) and (d) too. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + # 52 x 363.25. + pension_credit: 18_889 + +- name: Case 3, a saved mixed-age couple aged 73 and 60 get Pension Credit and no ESA on the younger member's report. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 73 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + # The couple has had Pension Credit since before 15 May 2019. + pension_credit_reported: 9_000 + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # SI 2019/37 art 4(1): born 1 March 1953, the older member reached the + # qualifying age on 6 January 2016 (Pensions Act 1995 Sch 4 para 1 rule 3, + # table 1, via SPCA 2002 s.1(6)), so the couple could be entitled to + # Pension Credit on 14 May 2019, and it reports Pension Credit now, so + # it keeps Pension Credit despite s.4(1A). + has_mixed_age_couple_pension_credit_saving: true + # The 60-year-old has not reached pensionable age, so meets s.1(3)(c). + is_SP_age: [true, false] + meets_pension_credit_age_conditions: true + # Para 6(1)(d): B(0) = 52 x 363.25 = 18_889 is payable, so the other + # member of the couple is entitled to State Pension Credit and the + # 60-year-old gets no income-related ESA. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + # 52 x 363.25. He reached pensionable age at 65 on 1 March 2018 (rule + # 1), after 6 April 2016, so no savings credit is due (SPCA 2002 s.3(1)). + pension_credit: 18_889 + +- name: Case 4, the same mixed-age couple without the saving keep income-related ESA and get no Pension Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 73 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). + esa_income_appointee_saving: true + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Neither member reports Pension Credit or pension-age Housing Benefit, + # so the couple keeps no saving, and the 73-year-old has no entitlement + # to Pension Credit while the partner is under the qualifying age (SPCA + # 2002 s.4(1A)). + has_mixed_age_couple_pension_credit_saving: false + meets_pension_credit_age_conditions: false + pension_credit: 0 + # The 60-year-old meets both para 6(1)(d) and s.1(3)(c): the 5_000. + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [false, true] + +- name: Case 5, a saved mixed-age couple that does not claim Pension Credit keep income-related ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 73 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + pension_credit_reported: 9_000 + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). + esa_income_appointee_saving: true + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1, person2] + output: + has_mixed_age_couple_pension_credit_saving: true + meets_pension_credit_age_conditions: true + # Entitlement to Pension Credit needs a claim (Social Security + # Administration Act 1992 s.1), so the 60-year-old meets para 6(1)(d). + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: [false, true] + +- name: Case 6, a single claimant aged 60 keeps income-related ESA. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). + esa_income_appointee_saving: true + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + is_SP_age: false + meets_pension_credit_age_conditions: false + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 5_000 + is_on_income_related_esa: true + +- name: Case 7, a man aged 63 in 2016 over the Pension Credit qualifying age but under pensionable age gets no income-related ESA while he would claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # Born 1 March 1953, before 6 December 1953: pensionable age 65, on 1 + # March 2018 (Pensions Act 1995 Sch 4 para 1 rule 1), so he meets + # s.1(3)(c) in 2016-17. + is_SP_age: false + # The qualifying age for State Pension Credit is a woman's pensionable + # age for the same date of birth (SPCA 2002 s.1(6)): a woman born from 6 + # February to 5 March 1953 reached it on 6 January 2016 (rule 3, table 1). + has_attained_state_pension_credit_qualifying_age: true + meets_pension_credit_age_conditions: true + # Para 6(1)(c): with no income, B(0) is the minimum guarantee, 52 x + # 155.60 = 8_091.20 in 2016-17 (SPC Regs 2002 reg 6(1)(b)), so he is + # entitled to State Pension Credit. + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: false + pension_credit: 8_091.20 + +- name: Case 8, the same man in 2016 keeps income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + is_SP_age: false + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + is_on_income_related_esa: true + +- name: Case 9, a woman born on the same day reached pensionable age, so gets no income-related ESA in 2016 even without a Pension Credit claim. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: FEMALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + # Rule 3, table 1: pensionable age on 6 January 2016. + is_SP_age: true + # s.1(3)(c). + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: false + +- name: Case 10, before the mixed-age couple exclusion, a couple aged 70 and 60 that would claim Pension Credit get no ESA on the younger member's report. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # SPCA 2002 s.4(1A) came into force on 15 May 2019 (SI 2019/37 art 3), so + # in 2018-19 the 70-year-old can claim Pension Credit for the couple. + is_mixed_age_couple: true + meets_pension_credit_age_conditions: true + # Para 6(1)(d). + esa_income_eligible: false + esa_income: 0 + is_on_income_related_esa: [false, false] + +- name: Case 11, a member outside the couple aged 70 who reports income-related ESA gets none. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 45 + is_claimant_or_partner: true + person2: + age: 70 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # They claim in their own right, and fail s.1(3)(c) too. + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, false] + +- name: Case 12, a member outside the couple who claims income-related ESA in their own right keeps the award beside the couple's Pension Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + person2: + age: 40 + is_claimant_or_partner: false + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). + esa_income_appointee_saving: true + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # The 70-year-old is a single claimant of Pension Credit: 52 x 238.00. + meets_pension_credit_age_conditions: true + pension_credit: 12_376 + # The 40-year-old is not a member of the 70-year-old's couple, claims in + # their own right and keeps their award of 5_000 (para 6(1)(d)). + esa_income_eligible: true + esa_income: 5_000 + claimant_or_partner_esa_income: 0 + is_on_income_related_esa: [false, true] + +- name: Case 13, where both members of a mixed-age couple report income-related ESA, only the one under pensionable age can be its payee. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # The model applies this member's appointee saving to the couple's + # whole claim (esa_income_payable_share), as it does the claimant's + # below. + esa_income_appointee_saving: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). + esa_income_appointee_saving: true + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Born in 1956, the 70-year-old reached the qualifying age after 14 May + # 2019, so the couple keeps no saving (SI 2019/37 art 4(1)). + has_mixed_age_couple_pension_credit_saving: false + meets_pension_credit_age_conditions: false + # The 60-year-old can claim; the model makes the couple's two reports one + # award of 5_000 + 5_000 = 10_000. + esa_income_eligible: true + esa_income: 10_000 + claimant_or_partner_esa_income: 10_000 + # s.1(3)(c): the 70-year-old cannot be the claimant, so the award is not + # payable to them. + is_on_income_related_esa: [false, true] + +- name: Case 14, where only the older member of an unsaved mixed-age couple has the support component, the couple gets neither benefit cap exception. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # The model applies this member's appointee saving to the couple's + # whole claim (esa_income_payable_share), as it does the claimant's + # below. + esa_income_appointee_saving: true + esa_includes_support_component: true + person2: + age: 60 + is_claimant_or_partner: true + esa_income_reported: 5_000 + # DWP keeps this award in payment after the 1 July 2026 abolition + # under the claimant's appointee saving (SI 2025/1148 art. 3A(2)), + # so the model pays it for the whole of 2026-27 + # (esa_income_payable_share). + esa_income_appointee_saving: true + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + esa_income: 10_000 + # s.1(3)(c): only the 60-year-old can be the claimant, so the award is + # paid to them, without the support component. + is_on_income_related_esa: [false, true] + # HB Regs 2006 reg 75F(1) and UC Regs 2013 + # reg 83(1): the exception needs the award's support component, which + # belongs to the 70-year-old, who is not its claimant. + is_uc_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: false + +# Cases 15 to 19 take one case for each pairing of B(0) and B(e). Each is a +# man aged 63 in 2016, born 1 March 1953: over the qualifying age for State +# Pension Credit (from 6 January 2016) and under his pensionable age (65, +# on 1 March 2018), with support-group income-related ESA. In 2016-17 the +# minimum guarantee is £155.60 a week, 52 x 155.60 = 8_091.20 a year (SPC +# Regs 2002 reg 6(1)(b)), and DWP disregards £5 a week of a single claimant's +# earnings (Sch VI para 5), or £20 a week while the claimant is on employment +# and support allowance (para 4(1)(a)(vii)). Below 65 no savings +# credit is due (SPCA 2002 s.3(1)). His income stays under the personal +# allowance (£11,000) and his earnings under the National Insurance primary +# threshold (£155 a week), so he owes no income tax or primary Class 1 +# contributions. + +- name: Case 15, where DWP would pay Pension Credit with or without the award, the man gets the Pension Credit due on his income without the award and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 6_011.20 + esa_includes_support_component: true + # £50 a week for 10 hours. + employment_income: 2_600 + hours_worked: 520 + # £40 a week. + private_pension_income: 2_080 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): income 50 + 40 - 5 = 85 a week, so 155.60 - 85 = 70.60 a week. + # B(e): 50 + 40 - 20 = 70, so 85.60 a week. B(0) exceeds nil, so he fails + # para 6(1)(c) and gets B(0): 52 x 70.60. + esa_income_eligible: false + esa_income: 0 + pension_credit: 3_671.20 + pension_credit_earnings_disregard: 260 + +- name: Case 16, where DWP would pay no Pension Credit with or without the award, the man keeps income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 4_919.20 + esa_includes_support_component: true + # £115.50 a week for 15 hours. + employment_income: 6_006 + hours_worked: 780 + # £61 a week. + private_pension_income: 3_172 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # B(0): 115.50 + 61 - 5 = 171.50 a week, over 155.60. B(e): 115.50 + 61 + # - 20 = 156.50, still over it. He gets no Pension Credit either way, so + # meets para 6(1)(c) and keeps the award: 52 x 94.60 = 4_919.20. + pension_credit: 0 + esa_income_eligible: true + esa_income: 4_919.20 + is_on_income_related_esa: true + +- name: Case 17, the same man keeps the same income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 4_919.20 + esa_includes_support_component: true + employment_income: 6_006 + hours_worked: 780 + private_pension_income: 3_172 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + households: + household: + members: [person1] + output: + pension_credit: 0 + esa_income: 4_919.20 + +- name: Case 18, where DWP would pay Pension Credit only with the award, the man keeps income-related ESA and gets no Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + employment_income: 6_006 + hours_worked: 780 + # £50 a week. + private_pension_income: 2_600 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + output: + # In 2016-17 the minimum guarantee for a single claimant is £155.60 a week. + # B(0): 115.50 + 50 - 5 = 160.50 a week, over 155.60, so nil. B(e): + # 115.50 + 50 - 20 = 145.50, so 155.60 - 145.50 = 10.10 a week, or + # 52 x 10.10 = 525.20. B(0) is nil, so he meets para 6(1)(c) and the model + # pays the award, 52 x 105.60 = 5_491.20. + # The model pays no Pension Credit beside it. Parliament allows the award + # only while he "is not entitled to state pension credit" (WRA 2007 Sch 1 + # para 6(1)(c)), so with Pension Credit in payment his ESA would stop, and + # with it the £20 that DWP disregards for a claimant "in receipt of ... + # employment and support allowance" (SPC Regs 2002 Sch VI para + # 4(1)(a)(vii)). DWP keeps disregarding the £20 after the award ends only + # where (para 4(2)) "£20 was disregarded in respect of earnings taken into + # account in that award". He works 15 hours a week, under the 16-hour + # limit, and his £115.50 does not exceed the 2016-17 permitted work limit + # of £115.50: 16 hours at the £7.20 national living wage, £115.20, which + # esa_exempt_work_higher_earnings_limit rounds up to the next 50p, citing + # ESA Regs 2008 reg 45(9A) (from + # gov.dwp.ESA.exempt_work.higher_earnings_limit_minimum_wage_hours and + # gov.hmrc.minimum_wage.non_apprentice). So DWP disregards "any earnings + # derived from that work which do not exceed the limits" (ESA Regs 2008 + # Sch 7 para 5): all of them, not £20. The expected values agree with the + # reading under which "£20 was disregarded" means the £20 disregard of + # Sch 7 paras 6 and 7(1), as under para 5 DWP disregards "any earnings ... + # which do not exceed the limits", not a fixed £20. On that reading he + # fails para 4(2)(a), his Pension Credit would fall back to B(0), nil, and + # his ESA would come back, so ESA with no Pension Credit holds and Pension + # Credit with no ESA does not. On another reading, para 4(2)(a) holds + # wherever DWP disregarded at least £20 of earnings in the award, and he + # would then keep £525.20 of Pension Credit with no ESA while he stays in + # employment (para 4(2)(b)). + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + esa_exempt_work_higher_earnings_limit: 115.50 + pension_credit_earnings_disregard: 1_040 + pension_credit_entitlement: 525.20 + pension_credit: 0 + +- name: Case 19, a couple aged 66 and 58 to whom DWP would pay Pension Credit only without the award, since HMRC then applies working tax credit's income test, get Pension Credit and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + is_claimant_or_partner: true + # 20 hours a week. + employment_income: 10_000 + hours_worked: 1_040 + working_tax_credit_reported: 1 + person2: + age: 58 + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # Before 15 May 2019 the 66-year-old can claim Pension Credit for the + # couple. In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard + # is £10 a week (Sch VI para 5), £20 with the award. The 66-year-old has + # reached pensionable age, so pays no primary Class 1 contributions + # (SSCBA 1992 s.6(3)), and 10_000 is under the £11,000 personal + # allowance, so his earnings count in full. + # Working tax credit: the basic and couple elements, 1_960 + 2_010 = + # 3_970 (WTC (Entitlement and Maximum Rate) Regs 2002 Sch 2), which counts + # as Pension Credit income (SPCA 2002 s.15(1)(b)). + # B(e): with the award HMRC pays working tax credit without its income + # test (TCA 2002 s.7(2)), so working tax credit comes to 3_970, and + # 10_000 + 3_970 - 1_040 = 12_930, over 12_352.60: nil. DWP counts no + # working tax credit in the savings credit's qualifying income (SPC Regs + # 2002 reg 9(a); savings_credit_income): 10_000 - 1_040 = 8_960, 172.31 a + # week, under the £212.97 couple threshold: nil. + # B(0): the couple would get no Pension Credit on 3_970 either, so it has + # no passport, and HMRC applies the income test: 41% of income over £6,420 + # comes off, 3_970 - 0.41 x (10_000 - 6_420) = 2_502.20. Then 10_000 + + # 2_502.20 - 520 = 11_982.20, so 12_352.60 - 11_982.20 = 370.40. B(0) + # exceeds nil, so the younger member fails para 6(1)(d), and the couple + # gets B(0). + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + working_tax_credit: 2_502.20 + pension_credit: 370.40 + +- name: Case 20, in 2018 a couple aged 66 and 58 to whom DWP would pay no Pension Credit on their income keep support-group ESA and both benefit cap exceptions. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + date_of_birth: 19520701 + gender: MALE + is_claimant_or_partner: true + # £200 a week. + private_pension_income: 10_400 + person2: + age: 58 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 2_537.60 + esa_includes_support_component: true + # £124 a week for 15 hours. + employment_income: 6_448 + hours_worked: 780 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # In 2018-19 the couple minimum guarantee is £248.80 a week. B(0): 200 + + # 124 - 10 = 314 a week; B(e): 200 + 124 - 20 = 304. Both exceed it, so + # nil either way. (Each member's income stays under the £11,850 personal + # allowance, and £124 a week under the £162 National Insurance primary + # threshold, so neither member owes income tax or primary Class 1 + # contributions.) The younger member meets para 6(1)(d). + pension_credit: 0 + esa_income: 2_537.60 + claimant_or_partner_esa_income: 2_537.60 + is_on_income_related_esa: [false, true] + # HB Regs 2006 reg 75F(1) and UC Regs 2013 + # reg 83(1): the award with its support component. + is_uc_benefit_cap_exempt_specified_benefit: true + is_housing_benefit_benefit_cap_exempt_specified_benefit: true + # So the model applies no Universal Credit benefit cap. + is_uc_benefit_cap_exempt: true + +# Cases 21 to 24 supply Pension Credit, or a part of it, as an input. The model +# then reads the award it pays without the couple's income-related ESA, +# whatever the Pension Credit age conditions and the claim +# (would_claim_pc), since the caller, not the formulas, sets the award. The +# man of cases 7 and 8 meets the age conditions; a man aged 40, born 1 March +# 1976, does not. + +- name: Case 21, a man who reports income-related ESA and has £2,000 of Pension Credit as an input gets no ESA, though he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + pension_credit: 2_000 + households: + household: + members: [person1] + output: + # Para 6(1)(c): he has an entitlement to the £2,000 of Pension Credit. + pension_credit: 2_000 + esa_income_eligible: false + esa_income: 0 + claimant_or_partner_esa_income: 0 + +- name: Case 22, a man aged 40 whose Pension Credit entitlement is an input of £2,000 gets that Pension Credit and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + date_of_birth: 19760301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + pension_credit_entitlement: 2_000 + households: + household: + members: [person1] + output: + # He would claim, so pension_credit pays the entitlement, though he is + # under the qualifying age for State Pension Credit. + meets_pension_credit_age_conditions: false + pension_credit: 2_000 + esa_income_eligible: false + esa_income: 0 + +- name: Case 23, a man aged 40 whom an input makes eligible for Pension Credit, with no income, gets the minimum guarantee and no income-related ESA. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + date_of_birth: 19760301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + is_pension_credit_eligible: true + households: + household: + members: [person1] + output: + meets_pension_credit_age_conditions: false + # Without the award his income is nil, so his guarantee credit comes to + # the minimum guarantee, 52 x 155.60 = 8_091.20 (SPC Regs 2002 reg + # 6(1)(b)). Under 65 he gets no savings credit. + guarantee_credit: 8_091.20 + pension_credit: 8_091.20 + esa_income_eligible: false + esa_income: 0 + +- name: Case 24, the man of case 21 with £2,000 of guarantee credit as an input keeps income-related ESA if he would not claim Pension Credit. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_pc: false + guarantee_credit: 2_000 + households: + household: + members: [person1] + output: + # pension_credit pays the entitlement only on a claim, so he gets no + # Pension Credit, and the model pays the award. + pension_credit: 0 + esa_income_eligible: true + esa_income: 5_000 + +- name: Case 25, a couple in whose award DWP may disregard £20 of the partner's part-time earnings keeps income-related ESA and gets no Pension Credit where the couple gets Pension Credit only with the award. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + # £135 a week. + private_pension_income: 7_020 + person2: + age: 60 + date_of_birth: 19560301 + gender: FEMALE + is_claimant_or_partner: true + # £115.50 a week for 15 hours, under the 24 hours of WRA 2007 Sch 1 + # para 6(1)(f) (ESA Regs 2008 reg 42(1)). + employment_income: 6_006 + hours_worked: 780 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # The man reports the award and the woman has the earnings, so case 18's + # pension of £50 a week becomes £135 to land the couple in the same + # pairing. In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard is + # £10 a week (Sch VI para 5), £20 with the award (para 4(1)(a)(vii)). + # Income: 115.50 + 135 = 250.50 a week, 52 x 250.50 = 13_026. Neither + # member owes income tax or primary Class 1 contributions on £115.50 a + # week in 2016-17. + # B(0): 13_026 - 520 = 12_506, over 12_352.60, so nil. B(e): 13_026 - + # 1_040 = 11_986, so 12_352.60 - 11_986 = 366.60, or 52 x 7.05. Neither + # member has reached pensionable age, so no savings credit is due (SPCA + # 2002 s.3(1)). + # Here the law may differ from the model. DWP disregards £20 of earnings + # "in a case where the claimant's partner is in part-time employment and + # paragraph 6 does not apply" (ESA Regs 2008 Sch 7 para 7(4)). If DWP + # disregarded £20 of the woman's earnings in the man's award, DWP keeps + # disregarding the £20 (SPC Regs 2002 Sch VI para 4(2)) where "£20 was + # disregarded in respect of earnings taken into account in that award" + # and "the person whose earnings qualified for the disegard continues in + # employment after the termination of that award". Once the couple + # claimed, it could then keep £366.60 of Pension Credit with no ESA. The + # model holds no record of how an award started, and + # pension_credit_earnings_disregard does not apply para 4(2), so the model + # pays this couple the award and no Pension Credit, as in case 18. + # The same holds where DWP disregarded £20 of the partner's earnings + # because the claimant's permitted-work earnings fell short of the limit + # (Sch 7 para 6, case 26), or £20 of the claimant's own earnings (Sch 7 + # para 7(1) and (2)). + meets_pension_credit_age_conditions: true + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + pension_credit_earnings_disregard: 1_040 + pension_credit_entitlement: 366.60 + pension_credit: 0 + +- name: Case 26, a couple in whose award DWP may disregard £20 of the partner's earnings under the permitted-work shortfall keeps income-related ESA and gets no Pension Credit where the couple gets Pension Credit only with the award. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 63 + date_of_birth: 19530301 + gender: MALE + is_claimant_or_partner: true + esa_income_reported: 5_491.20 + esa_includes_support_component: true + # £90 a week for 15 hours, under the 16 hours and the £115.50 limit of + # permitted work (ESA Regs 2008 reg 45(4)). + employment_income: 4_680 + hours_worked: 780 + # £130 a week. + private_pension_income: 6_760 + person2: + age: 60 + date_of_birth: 19560301 + gender: FEMALE + is_claimant_or_partner: true + # £30 a week for 5 hours, under the 24 hours of WRA 2007 Sch 1 para + # 6(1)(f) (ESA Regs 2008 reg 42(1)). + employment_income: 1_560 + hours_worked: 260 + benunits: + benunit: + members: [person1, person2] + would_claim_uc: false + households: + household: + members: [person1, person2] + output: + # In 2016-17 the couple minimum guarantee is £237.55 a week, + # 52 x 237.55 = 12_352.60, and the couple's standard earnings disregard is + # £10 a week (Sch VI para 5), £20 with the award (para 4(1)(a)(vii)). + # Income: 90 + 130 + 30 = 250 a week, 52 x 250 = 13_000. Neither member + # owes primary Class 1 contributions on £90 or £30 a week in 2016-17. His + # £11,440 of earnings and pension exceeds the £11,000 personal allowance + # by £440. The model takes the couple to be married (is_married) and to + # claim the marriage allowance (would_claim_marriage_allowance), so it + # transfers £1,100 of her unused allowance to him (marriage_allowance, 10% + # of £11,000) and neither member owes income tax or contributions on the + # earnings. Without the transfer he would owe £88 and B(e) would come to + # 480.60. + # B(0): 13_000 - 520 = 12_480, over 12_352.60, so nil. B(e): 13_000 - + # 1_040 = 11_960, so 12_352.60 - 11_960 = 392.60, or 52 x 7.55. Neither + # member has reached pensionable age, so no savings credit is due (SPCA + # 2002 s.3(1)). + # Here the law may differ from the model. His £90 falls more than £20 + # short of 16 x £7.20 = £115.20, so DWP disregards the woman's earnings + # "to the extent that the claimant's earnings are less than ... 16 x + # National Minimum Wage, but only up to a maximum of £20" (ESA Regs 2008 + # Sch 7 para 6): £20 of her £30. DWP keeps disregarding the £20 (SPC Regs + # 2002 Sch VI para 4(2)) where "£20 was disregarded in respect of earnings + # taken into account in that award" and "the person whose earnings + # qualified for the disegard continues in employment after the termination + # of that award". Once the couple claimed, it could then keep £392.60 of + # Pension Credit with no ESA while she stays in work. The model holds no + # record of how an award started, and pension_credit_earnings_disregard + # does not apply para 4(2), so the model pays this couple the award and + # no Pension Credit, as in cases 18 and 25. + meets_pension_credit_age_conditions: true + esa_income_eligible: true + esa_income: 5_491.20 + claimant_or_partner_esa_income: 5_491.20 + esa_exempt_work_higher_earnings_limit: [115.50, 115.50] + pension_credit_employment_income_tax: 0 + pension_credit_earnings_disregard: 1_040 + pension_credit_income: 11_960 + pension_credit_entitlement: 392.60 + pension_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml index 86edb6a8d1..896eba2ba6 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/applicable_income/housing_benefit_applicable_income_childcare_element.yaml @@ -1534,8 +1534,9 @@ num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 -- name: Disabled 16-year-old outside education and training is not a relevant young person +- name: Disabled 16-year-old outside education and training is a relevant young person until the 31 August after their 16th birthday period: 2025 + absolute_error_margin: 0.01 input: people: claimant: @@ -1558,9 +1559,36 @@ housing_benefit_specified_or_temporary_accommodation_disregard: 0 working_tax_credit: 0 child_tax_credit: 0 + output: + # A 16-year-old who has left education is a young person until that 31 + # August (Child Benefit (General) Regulations 2006 reg. 4; HB reg 19(1)), + # in any year that starts before it. HB reg 28(6) then counts a disabled + # one: min(9,000, one-child cap * 52). + is_child_or_young_person_for_legacy_benefits: [false, true] + num_relevant_children_for_housing_benefit_childcare: 1 + housing_benefit_applicable_income_childcare_element: 9_000 + +- name: Disabled 16-year-old outside education and training is not a relevant young person after that 31 August + period: 2025 + input: + people: + claimant: + age: 40 + is_parent: true + childcare_expenses: 9_000 + young_person: + age: 16 + is_disabled_for_benefits: true + is_in_non_advanced_education: false + is_in_approved_training: false + is_before_first_september_following_16th_birthday: false + benunits: + benunit: + members: [claimant, young_person] output: # HB reg 28(6) requires a child of the family, including a qualifying # young person; the disability age extension does not create that status. + is_child_or_young_person_for_legacy_benefits: [false, false] num_relevant_children_for_housing_benefit_childcare: 0 housing_benefit_applicable_income_childcare_element: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml index d6eee69d34..372f0a50e8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/housing_benefit_capital.yaml @@ -89,7 +89,7 @@ - name: Housing Benefit counts direct land holdings as capital period: 2025 - absolute_error_margin: 0 + absolute_error_margin: 0.01 input: people: person: @@ -103,11 +103,36 @@ households: household: members: person - owned_land: 16_001 + # 90% of 17,780 is 16,002, over the 16,000 limit after the 10% + # deduction for the expenses of sale (HB Regs 2006 reg. 47(a)). + owned_land: 17_780 output: - housing_benefit_assessable_capital: 16_001 + housing_benefit_assessable_capital: 16_002 housing_benefit_eligible: false +- name: Housing Benefit values land less 10% for the expenses of sale + period: 2025 + 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 + # Land worth 16,001 is valued at 14,400.90 (HB Regs 2006 reg. 47(a)), + # within the limit. + owned_land: 16_001 + output: + housing_benefit_assessable_capital: 14_400.9 + housing_benefit_eligible: true + - name: Housing Benefit counts corporate wealth as a broad financial-capital proxy period: 2025 absolute_error_margin: 0 @@ -126,7 +151,9 @@ members: person corporate_wealth: 50_000 output: - housing_benefit_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (HB Regs 2006 + # reg. 47(a)). + housing_benefit_assessable_capital: 45_000 housing_benefit_eligible: false - name: Mixed benunits split household Housing Benefit capital by claimant and partner share @@ -277,3 +304,123 @@ output: housing_benefit_assessable_capital: 5_000 housing_benefit_eligible: true + +- name: Housing Benefit values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + housing_benefit_assessable_capital: 9_000 + +- name: Housing Benefit preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + housing_benefit_on_passporting_benefit: false + couple: + members: [second, partner] + housing_benefit_on_passporting_benefit: false + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + housing_benefit_assessable_capital: [5_400, 10_800] + +- name: Housing Benefit passport disregards overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: true + overseas_property_value: 18_000 + output: + housing_benefit_assessable_capital: 0 + +- name: Housing Benefit values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + housing_benefit_assessable_capital: 9_000 + +- name: Housing Benefit preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + housing_benefit_on_passporting_benefit: false + couple: + members: [second, partner] + housing_benefit_on_passporting_benefit: false + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + housing_benefit_assessable_capital: [5_400, 10_800] + +- name: Housing Benefit passport disregards unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: true + other_property_value: 18_000 + output: + housing_benefit_assessable_capital: 0 + +- name: Housing Benefit deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + housing_benefit_assessable_capital: 28_000 + +- name: Housing Benefit floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + housing_benefit_on_passporting_benefit: false + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + housing_benefit_assessable_capital: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.yaml index f3834f57b7..863c91d88a 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.yaml @@ -1,7 +1,323 @@ +# 2026-27 deductions (HB Regs 2006 reg 74(1)-(2); SI 2006/214 reg 55(1)-(2); +# up-rated by SI 2026/148 art 23(3)). In remunerative work (16 hours or more, +# reg 6(1)): under £192 a week 20.40; not less than £192 46.85; £279 64.35; +# £365 105.20; £485 119.85; £605 and above 131.45. Not in remunerative work: +# 20.40 (reg 74(1)(b)). Bands include their lower edge ("not less than"). +# Family benefits (UC, tax credits, child benefit), which count as gross +# income, are held at zero except where a case tests them. + +- name: In remunerative work with no income pays the lowest amount + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 0 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # reg 74(2)(a) and (1)(b) + household_benefits_individual_non_dep_deduction: 20.4 * 52 + +- name: Just below the second band + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 9983.48 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # 191.99 a week + household_benefits_individual_non_dep_deduction: 20.4 * 52 + +- name: Exactly 192 a week is in the band starting at 192 + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 9984 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 46.85 * 52 + +- name: Exactly 279 a week is in the band starting at 279 + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 14508 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 64.35 * 52 + +- name: Exactly 365 a week is in the band starting at 365 + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 18980 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 105.2 * 52 + +- name: Exactly 485 a week is in the band starting at 485 + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 25220 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 119.85 * 52 + +- name: Exactly 605 a week is in the band starting at 605 + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 31460 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 131.45 * 52 + +- name: Just below the top band + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 31459.48 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # 604.99 a week + household_benefits_individual_non_dep_deduction: 119.85 * 52 + +- name: Not in remunerative work pays the lowest amount whatever the income + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 40000 + weekly_hours: 0 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # reg 74(1)(b): the bands apply only in remunerative work + household_benefits_individual_non_dep_deduction: 20.4 * 52 + +- name: Just under 16 hours is not remunerative work + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 40000 + weekly_hours: 15.9 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 20.4 * 52 + +- name: 16 hours is remunerative work + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 40000 + weekly_hours: 16 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 131.45 * 52 + +- name: A loss counts as no income + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + self_employment_income: -5_000 + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 20.4 * 52 + +- name: A couple's joint income sets the band for the member in remunerative work + period: 2026 + absolute_error_margin: 0.01 + input: + people: + worker: + age: 30 + housing_benefit_non_dep_gross_income: 10_400 # 200 a week + weekly_hours: 35 + housing_benefit_individual_non_dep_deduction_eligible: true + partner: + age: 30 + housing_benefit_non_dep_gross_income: 10_400 + weekly_hours: 0 + housing_benefit_individual_non_dep_deduction_eligible: true + benunits: + couple: + members: [worker, partner] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + households: + household: + members: [worker, partner] + output: + # reg 74(4): joint income 400 a week is in the 365 band; the council would + # deduct 20.40 for the partner outside remunerative work (reg 74(1)(b)). + household_benefits_individual_non_dep_deduction: [5_470.40, 1_060.80] + +- name: A dependent child's income does not count towards the couple's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + housing_benefit_non_dep_gross_income: 9_360 # 180 a week + weekly_hours: 35 + housing_benefit_individual_non_dep_deduction_eligible: true + child: + age: 17 + housing_benefit_non_dep_gross_income: 5_200 + benunits: + family: + members: [parent, child] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + households: + household: + members: [parent, child] + output: + household_benefits_individual_non_dep_deduction: [1_060.80, 0] + +- name: Universal Credit counts towards gross income + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 40 + housing_benefit_non_dep_gross_income: 14_040 # 270 a week + weekly_hours: 16 + universal_credit_pre_benefit_cap: 1_040 # 20 a week + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # reg 74(2): 290 a week gross is in the 279 band (64.35). UC is not among + # the reg 74(9) disregards. + household_benefits_individual_non_dep_deduction: 64.35 * 52 + +- name: On income-related ESA, 16 hours is not remunerative work + period: 2026 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 40 + housing_benefit_non_dep_gross_income: 10_574.72 # 203.36 a week (supported permitted work) + weekly_hours: 16 + esa_income: 5_200 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # reg 6(6): the council treats a person on income-related ESA as outside + # remunerative work and deducts the reg 74(1)(b) amount. + household_benefits_individual_non_dep_deduction: 20.40 * 52 + +- name: A dependent young person in the family is banded on their own income, not the parent's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + housing_benefit_non_dep_gross_income: 9_360 # 180 a week + weekly_hours: 16 + housing_benefit_individual_non_dep_deduction_eligible: true + young_person: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 18 + housing_benefit_non_dep_gross_income: 5_200 # 100 a week + weekly_hours: 8 + housing_benefit_individual_non_dep_deduction_eligible: true + benunits: + family: + members: [parent, young_person] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + households: + household: + members: [parent, young_person] + output: + # reg 74(4) joins only a couple's income. The parent's 180 a week is + # under the 192 band (20.40); for the young person, under 16 hours, the council deducts 20.40 + # unless they are exempt (eligibility is an input here). + household_benefits_individual_non_dep_deduction: [1_060.80, 1_060.80] + # 2025-26 bands and deductions (HB Regs 2006 reg 74 as up-rated by SI 2025/295 # art 23(3); DWP Benefit and pension rates 2025 to 2026): under £183 a week -# 19.65; £183-265.99 45.15; £266-347.99 62.00; £348-462.99 101.35; -# £463-576.99 115.45; £577 and above 126.65. +# 19.65; £183 45.15; £266 62.00; £348 101.35; £463 115.45; £577 and above +# 126.65. Each band runs from "not less than" its lower edge (reg 74(2)). - name: The 2018 fiscal year uses the April non-dependant deduction in HB and CTR # SI 2018/281 arts 23(3)(b), 24(3)(b), and 1(2)(h): £15.25 from @@ -12,9 +328,18 @@ council_tax_reduction_individual_non_dep_deduction_eligible: true hours_worked: 832 # 16 hours a week employment_income: 5_200 # £100 a week, in both programmes' gross income + age: 30 + current_education: NOT_IN_EDUCATION + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + country: WALES output: household_benefits_individual_non_dep_deduction: 793 - council_tax_reduction_individual_non_dep_deduction: 793 + # Council Tax Reduction has its own scale: Wales's lowest amount, £4.35 a + # week from 1 April 2018 (WSI 2018/14), £4.20 before it. + council_tax_reduction_individual_non_dep_deduction: 4.35 * 52 - name: Reduced rent without LHA period: 2025 @@ -22,6 +347,10 @@ housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 # 16 hours a week housing_benefit_non_dep_gross_income: 5_200 # 100 weekly + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 output: household_benefits_individual_non_dep_deduction: 19.65 * 52 @@ -31,6 +360,10 @@ housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 # 16 hours a week housing_benefit_non_dep_gross_income: 30_056 # 578 weekly + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 output: household_benefits_individual_non_dep_deduction: 126.65 * 52 @@ -40,42 +373,116 @@ housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 # 16 hours a week housing_benefit_non_dep_gross_income: 18_148 # 349 weekly + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 output: household_benefits_individual_non_dep_deduction: 101.35 * 52 - name: Income just above the lower edge of the second band - # Passes whether band edges are read inclusively (reg 74(2)) or, as the - # formula still does, exclusively; see #2004 and #2007. period: 2025 input: housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 # 16 hours a week housing_benefit_non_dep_gross_income: 9_517 # just over 183 weekly, the lower edge of the second band + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 output: household_benefits_individual_non_dep_deduction: 45.15 * 52 -# Other investment income remains outside the gross-income base; extending -# that base is deferred to #2007. With no other income, this non-worker has -# no banding income, and the work gate leaves the scale's zero until #2007 -# models the exemptions with reg 74(1)(b)'s flat charge. - name: Other investment income does not band a non-worker period: 2025 input: housing_benefit_individual_non_dep_deduction_eligible: true other_investment_income: 28_912 # 556 weekly, no hours + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 output: - # The same as a non-dependant with no income. Banding the £556 would give - # 115.45 * 52 = 6,003.40. - household_benefits_individual_non_dep_deduction: 0 + # Not in remunerative work, so reg 74(1)(b)'s amount whatever the income: + # 19.65 * 52 = 1,021.80. Banding the £556 would give 115.45 * 52 = + # 6,003.40. + household_benefits_individual_non_dep_deduction: 19.65 * 52 -- name: Other investment income does not move a worker's band +- name: 2025-26 top band in remunerative work period: 2025 + absolute_error_margin: 0.01 input: housing_benefit_individual_non_dep_deduction_eligible: true - hours_worked: 1_950 # 37.5 hours a week - employment_income: 9_517 # just over 183 weekly - other_investment_income: 28_912 # 556 weekly + age: 30 + housing_benefit_non_dep_gross_income: 30_056 # 578 a week + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 126.65 * 52 + +- name: 2025-26 lowest amount out of remunerative work + period: 2025 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 5_200 # 100 a week + weekly_hours: 0 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 19.65 * 52 + +- name: 2025-26 second band in remunerative work + period: 2025 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 10_400 # 200 a week + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 output: - # Banded on the £183 of earnings: 45.15 * 52 = 2,347.80. Adding the £556 - # would give the top band, 126.65 * 52 = 6,585.80. household_benefits_individual_non_dep_deduction: 45.15 * 52 + +- name: 2025-26 middle band in remunerative work + period: 2025 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + housing_benefit_non_dep_gross_income: 20_800 # 400 a week + weekly_hours: 35 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + household_benefits_individual_non_dep_deduction: 101.35 * 52 + +- name: A worker's band counts their investment income + period: 2025 + absolute_error_margin: 0.01 + input: + housing_benefit_individual_non_dep_deduction_eligible: true + age: 30 + hours_worked: 1_950 # 37.5 hours a week + employment_income: 9_517 # just over 183 a week + other_investment_income: 28_912 # 556 a week + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + output: + # In remunerative work, banded on all gross income (reg 74(2)), 739.02 a + # week: the band from £577, 126.65 * 52. Banding the earnings alone would + # give 45.15 * 52 = 2,347.80. + household_benefits_individual_non_dep_deduction: 126.65 * 52 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml index 0307c37986..cf142dd2a3 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.yaml @@ -1,15 +1,21 @@ - name: Eligible period: 2025 input: - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true age: 18 + # Without this the age-based default puts an 18-year-old in tertiary + # education, and full-time students are exempt (reg 74(7)(c)). + current_education: NOT_IN_EDUCATION + # An 18-year-old with no income is otherwise entitled to UC without + # earned income, which exempts them (reg 74(8)(b)). + would_claim_uc: false output: housing_benefit_individual_non_dep_deduction_eligible: true - name: Age ineligible period: 2025 input: - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true age: 17 output: housing_benefit_individual_non_dep_deduction_eligible: false @@ -21,3 +27,33 @@ age: 18 output: housing_benefit_individual_non_dep_deduction_eligible: false + +# HB Regs 2006 reg 3: a member of the claimant's own benefit unit who is not +# the claimant, partner or a child or young person. Aged 25 or over, so the +# reg 74(8) exemptions for younger non-dependants on benefits do not apply. +- name: Non-dependant within the benefit unit + period: 2025 + input: + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_legacy_benefits: true + age: 30 + output: + housing_benefit_individual_non_dep_deduction_eligible: true + +- name: Non-dependant within the benefit unit, under 18 + period: 2025 + input: + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_legacy_benefits: true + age: 17 + output: + housing_benefit_individual_non_dep_deduction_eligible: false + +- name: Exempt non-dependant is ineligible + period: 2025 + input: + is_non_dependant_of_household_head: true + age: 20 + current_education: TERTIARY + output: + housing_benefit_individual_non_dep_deduction_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_chain.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_chain.yaml new file mode 100644 index 0000000000..f3f5835c1d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_chain.yaml @@ -0,0 +1,86 @@ +# Housing Benefit with a non-dependant, end to end through eligibility, +# exemption, banding and aggregation. Means-test inputs are fixed so the +# award is the eligible rent less the non-dependant deductions. + +- name: A zero-income full-time student does not reduce the claimant's HB + # A full-time student lives with the claimant; HB Regs 2006 + # reg 74(7)(c)-(d) exempts the student from a deduction. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + tenant: + age: 40 + student: + age: 20 + employment_status: STUDENT + current_education: TERTIARY + benunits: + tenant_unit: + members: [tenant] + benunit_is_rent_liable: true + housing_benefit_eligible: true + would_claim_housing_benefit: true + benunit_rent: 6_000 + housing_benefit_applicable_amount: 0 + housing_benefit_applicable_income: 0 + LHA_eligible: false + benefit_cap_reduction: 0 + student_unit: + members: [student] + benunit_is_rent_liable: false + housing_benefit_eligible: false + would_claim_housing_benefit: false + benunit_rent: 0 + housing_benefit_applicable_amount: 0 + housing_benefit_applicable_income: 0 + LHA_eligible: false + benefit_cap_reduction: 0 + households: + home: + members: [tenant, student] + output: + housing_benefit_non_dep_deduction_exempt: [false, true] + household_benefits_individual_non_dep_deduction: [0, 0] + housing_benefit_non_dep_deductions: [0, 0] + housing_benefit: [6_000, 0] + +- name: A zero-income non-student non-dependant pays the lowest deduction + # 2026 rather than 2025 so the amount is the same before and after + # PolicyEngine/policyengine-uk#1925 (which corrects the 2025-26 rates). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + tenant: + age: 40 + son: + age: 30 + current_education: NOT_IN_EDUCATION + benunits: + tenant_unit: + members: [tenant] + benunit_is_rent_liable: true + housing_benefit_eligible: true + would_claim_housing_benefit: true + benunit_rent: 6_000 + housing_benefit_applicable_amount: 0 + housing_benefit_applicable_income: 0 + LHA_eligible: false + benefit_cap_reduction: 0 + son_unit: + members: [son] + benunit_is_rent_liable: false + housing_benefit_eligible: false + would_claim_housing_benefit: false + benunit_rent: 0 + housing_benefit_applicable_amount: 0 + housing_benefit_applicable_income: 0 + LHA_eligible: false + benefit_cap_reduction: 0 + households: + home: + members: [tenant, son] + output: + # reg 74(1)(b): 20.40 a week in 2026-27. + housing_benefit_non_dep_deductions: [1_060.80, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_exempt.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_exempt.yaml new file mode 100644 index 0000000000..7412593b5b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_exempt.yaml @@ -0,0 +1,169 @@ +# HB Regs 2006 (SI 2006/213) reg 74(7)-(8) and (10); SI 2006/214 reg 55(7)-(9). + +- name: Full-time tertiary student is exempt + period: 2026 + input: + age: 20 + would_claim_uc: false + current_education: TERTIARY + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: Full-time student in non-advanced education is exempt + period: 2026 + input: + age: 18 + would_claim_uc: false + current_education: UPPER_SECONDARY + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: Higher education flag alone makes a student exempt + period: 2026 + input: + age: 30 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + in_HE: true + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: A worker not in education is not exempt + period: 2026 + input: + age: 30 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + employment_income: 20_000 + output: + housing_benefit_non_dep_deduction_exempt: false + +- name: State Pension Credit recipient is exempt at any age + period: 2026 + input: + age: 70 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + pension_credit: 1_000 + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: Income Support under 25 is exempt + period: 2026 + input: + age: 24 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + income_support: 1_000 + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: Income Support at 25 is not exempt + period: 2026 + input: + age: 25 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + income_support: 1_000 + output: + housing_benefit_non_dep_deduction_exempt: false + +- name: Income-based JSA under 25 is exempt + period: 2026 + input: + age: 24 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + jsa_income: 1_000 + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: Income-related ESA under 25 is not exempt without an assessment-phase input + period: 2026 + input: + age: 24 + would_claim_uc: false + current_education: NOT_IN_EDUCATION + esa_income: 1_000 + output: + # reg 74(8)(a) exempts only ESA(IR) without the support component and + # outside the work-related activity group; the model has no ESA group input. + housing_benefit_non_dep_deduction_exempt: false + +- name: Universal Credit without earned income under 25 is exempt + period: 2026 + input: + age: 24 + current_education: NOT_IN_EDUCATION + universal_credit_pre_benefit_cap: 1_000 + output: + housing_benefit_non_dep_deduction_exempt: true + +- name: Universal Credit with earned income under 25 is not exempt + period: 2026 + input: + age: 24 + current_education: NOT_IN_EDUCATION + universal_credit_pre_benefit_cap: 1_000 + employment_income: 2_000 + output: + housing_benefit_non_dep_deduction_exempt: false + +- name: Universal Credit without earned income at 25 is not exempt + period: 2026 + input: + age: 25 + current_education: NOT_IN_EDUCATION + universal_credit_pre_benefit_cap: 1_000 + output: + housing_benefit_non_dep_deduction_exempt: false + +- name: Another member's Universal Credit claim doesn't exempt a non-dependant under 25 + # The family's Universal Credit is the 40-year-old's own claim + # (is_uc_claimant), so the 22-year-old partner with no earnings is not + # entitled to it (HB Regs 2006 reg 74(8)(b); SI 2012/2885 Sch 1 para + # 8(8)(c)). The 40-year-old has earnings, so the limb exempts no one. + period: 2026 + input: + people: + claimant: + age: 70 + is_household_head: true + partner_a: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + is_uc_claimant: false + partner_b: + age: 30 + employment_income: 9_360 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + is_uc_claimant: false + other_adult: + age: 40 + employment_income: 1_040 + weekly_hours: 2 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + is_uc_claimant: true + benunits: + claimant_unit: + members: [claimant] + pension_credit: 0 + non_dependant_unit: + members: [partner_a, partner_b, other_adult] + universal_credit_pre_benefit_cap: 5_200 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, partner_a, partner_b, other_adult] + country: ENGLAND + local_authority: MAIDSTONE + output: + housing_benefit_non_dep_deduction_exempt: [false, false, false, false] + council_tax_reduction_non_dep_deduction_exempt: [false, false, false, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml index 015701c24d..88b1a2db3e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.yaml @@ -98,12 +98,80 @@ # Only person 3 is a non-dependant of the head's family: 3. housing_benefit_non_dep_deductions: [3, 0, 0] +- name: A sharer of a nil rent is not a non-dependant + period: 2025 + absolute_error_margin: 0 + input: + people: + person_1: + age: 40 + is_household_head: true + household_benefits_individual_non_dep_deduction: 0 + person_2: + age: 40 + household_benefits_individual_non_dep_deduction: 2 + benunits: + benunit_1: + members: [person_1] + housing_benefit_non_dep_deductions_claimant_exempt: false + benunit_2: + members: [person_2] + liable_for_share_of_household_rent: true + housing_benefit_non_dep_deductions_claimant_exempt: false + households: + household: + members: [person_1, person_2] + rent: 0 + output: + # Both families are liable for the rent, so each bears half. With a nil + # rent neither has any, but the sharer is still a joint occupier, not a + # non-dependant (HB Regs 2006 reg 3(2)(d)): no deduction for either. + share_of_household_rent: [0.5, 0.5] + housing_benefit_non_dep_deductions: [0, 0] + +- name: A family with any share of the rent is a joint occupier, however small + period: 2025 + absolute_error_margin: 0.001 + input: + people: + person_1: + is_household_head: true + household_benefits_individual_non_dep_deduction: 1 + person_2: + household_benefits_individual_non_dep_deduction: 2 + person_3: + household_benefits_individual_non_dep_deduction: 3 + benunits: + benunit_1: + members: [person_1] + share_of_household_rent: 0.995 + housing_benefit_non_dep_deductions_claimant_exempt: false + benunit_2: + members: [person_2] + # A nil rent leaves the family not liable for any, though it bears a + # share of the payments. + share_of_household_rent: 0.005 + benunit_is_rent_liable: false + housing_benefit_non_dep_deductions_claimant_exempt: false + benunit_3: + members: [person_3] + share_of_household_rent: 0 + benunit_is_rent_liable: false + housing_benefit_non_dep_deductions_claimant_exempt: false + households: + household: + members: [person_1, person_2, person_3] + output: + # HB Regs 2006 reg 3(2)(d): person 2 shares the payments, which makes + # them a joint occupier, not a non-dependant. The council deducts only + # person 3's 3, split by the shares: 3 x 0.995 and 3 x 0.005. + housing_benefit_non_dep_deductions: [2.985, 0.015, 0] + # The review case on #2184: an existing claimant in council housing with an # adult child whose only income is £28,912 of other investment income and who -# does no paid work. The deduction is the same as for an adult child with no -# income: the work gate leaves the scale's zero for a non-worker with no -# banding income until #2007 models the exemptions with reg 74(1)(b)'s flat -# charge. +# does no paid work. The bands apply only in remunerative work (HB Regs 2006 +# reg 74(2)), so the council deducts the reg 74(1)(b) amount for the adult child, +# as it would for one with no income. - name: A non-dependant's investment income does not raise the Housing Benefit deduction period: 2025 absolute_error_margin: 0.01 @@ -126,5 +194,160 @@ tenure_type: RENT_FROM_COUNCIL rent: 15_000 output: - # Banding the £556 would give 115.45 * 52 = 6,003.40. + # 19.65 * 52 = 1,021.80; banding the £556 would give 115.45 * 52 = 6,003.40. + housing_benefit_non_dep_deductions: [1_021.80, 0] + +- name: One deduction for a non-dependant couple, the higher amount + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + is_household_head: true + household_benefits_individual_non_dep_deduction: 0 + non_dep_1: + household_benefits_individual_non_dep_deduction: 1_060.80 + non_dep_2: + household_benefits_individual_non_dep_deduction: 5_470.40 + benunits: + claimant_unit: + members: [claimant] + couple: + members: [non_dep_1, non_dep_2] + households: + household: + members: [claimant, non_dep_1, non_dep_2] + output: + # HB Regs 2006 reg 74(3); the couple's own unit bears no deduction. + housing_benefit_non_dep_deductions: [5_470.40, 0] + +- name: No deductions where the claimant is exempt + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + is_household_head: true + household_benefits_individual_non_dep_deduction: 0 + non_dep: + household_benefits_individual_non_dep_deduction: 1_060.80 + benunits: + claimant_unit: + members: [claimant] + housing_benefit_non_dep_deductions_claimant_exempt: true + non_dep_unit: + members: [non_dep] + housing_benefit_non_dep_deductions_claimant_exempt: false + households: + household: + members: [claimant, non_dep] + output: + # HB Regs 2006 reg 74(6). housing_benefit_non_dep_deductions: [0, 0] + +- name: Universal Credit counts in the income of the claimant it assesses + # The non-dependant family's Universal Credit is the adult son's own claim + # (is_uc_claimant), so it counts in his gross income, not his parents' + # (HB Regs 2006 reg 74(2), (4)). The parents' joint income is the father's + # 180 a week, under the 192 band: 20.40, one deduction for the couple + # (reg 74(3)). The son works 2 hours, so the council deducts 20.40 for him (reg 74(1)(b)). + # (20.40 + 20.40) * 52 = 2,121.60. Counting the son's 100 a week of UC in + # the parents' income would give 280 a week and 64.35. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_household_head: true + father: + age: 50 + employment_income: 9_360 # 180 a week + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + is_uc_claimant: false + mother: + age: 48 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + is_uc_claimant: false + son: + age: 27 + employment_income: 1_040 # 20 a week + weekly_hours: 2 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + is_uc_claimant: true + benunits: + claimant_unit: + members: [claimant] + non_dependant_unit: + members: [father, mother, son] + universal_credit_pre_benefit_cap: 5_200 # 100 a week + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, father, mother, son] + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + output: + household_benefits_individual_non_dep_deduction: [0, 1_060.80, 1_060.80, 1_060.80] + housing_benefit_non_dep_deductions: [2_121.60, 0] + +- name: Another adult's own income-related ESA stays out of the couple's income + # The adult son claims income-related ESA in his own right, so it is his + # income, not his parents' (HB Regs 2006 reg 74(4)). The parents' joint + # income is the father's 180 a week, under the 192 band: 20.40, one + # deduction for the couple. The son does no work, so the council deducts 20.40 for him (reg + # 74(1)(b)). (20.40 + 20.40) * 52 = 2,121.60. Counting his 100 a week of + # ESA in the parents' income would give 280 a week and 64.35. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_household_head: true + father: + age: 50 + employment_income: 9_360 # 180 a week + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + mother: + age: 48 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + son: + age: 27 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + claimant_unit: + members: [claimant] + non_dependant_unit: + members: [father, mother, son] + esa_income: 5_200 # the son's own award, 100 a week + claimant_or_partner_esa_income: 0 + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, father, mother, son] + tenure_type: RENT_FROM_COUNCIL + rent: 10_000 + output: + household_benefits_individual_non_dep_deduction: [0, 1_060.80, 1_060.80, 1_060.80] + housing_benefit_non_dep_deductions: [2_121.60, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions_claimant_exempt.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions_claimant_exempt.yaml new file mode 100644 index 0000000000..ff959df9c7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions_claimant_exempt.yaml @@ -0,0 +1,95 @@ +# HB Regs 2006 (SI 2006/213) reg 74(6); SI 2006/214 reg 55(6). + +- name: Blind claimant + period: 2026 + input: + age: 40 + is_blind: true + output: + housing_benefit_non_dep_deductions_claimant_exempt: true + +- name: Claimant on Attendance Allowance + period: 2026 + input: + age: 70 + aa_category: LOWER + output: + housing_benefit_non_dep_deductions_claimant_exempt: true + +- name: Partner on the DLA care component + period: 2026 + input: + people: + claimant: + age: 40 + partner: + age: 38 + dla_sc_category: LOWER + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + housing_benefit_non_dep_deductions_claimant_exempt: true + +- name: Claimant on the PIP daily living component + period: 2026 + input: + age: 40 + pip_dl_category: STANDARD + output: + housing_benefit_non_dep_deductions_claimant_exempt: true + +- name: Partner on the PIP daily living component + period: 2026 + input: + people: + claimant: + age: 40 + partner: + age: 38 + pip_dl_category: STANDARD + benunits: + benunit: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + output: + housing_benefit_non_dep_deductions_claimant_exempt: true + +- name: Claimant on Armed Forces Independence Payment + period: 2026 + input: + age: 40 + armed_forces_independence_payment: 1_000 + output: + housing_benefit_non_dep_deductions_claimant_exempt: true + +- name: A child's DLA care component does not exempt the claimant + period: 2026 + input: + people: + claimant: + age: 40 + child: + age: 10 + dla_sc_category: HIGHER + benunits: + benunit: + members: [claimant, child] + households: + household: + members: [claimant, child] + output: + housing_benefit_non_dep_deductions_claimant_exempt: false + +- name: DLA mobility only does not exempt the claimant + period: 2026 + input: + age: 40 + dla_m_category: HIGHER + output: + housing_benefit_non_dep_deductions_claimant_exempt: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.yaml index a890f3a21e..1c8bfa4f8d 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.yaml @@ -4,8 +4,7 @@ # Sch 1 para 3 and Sch 6 para 5. Gross income includes statutory pay and # maintenance; the listed disability payments are disregarded. # Hand arithmetic: wages 9,000 + statutory pay 520 = 9,520, just above -# 183/week. The existing national consumers use the HB scale (45.15/week -# at this income in 2025); correcting CTR rates is separate (#2007). +# 183/week, in the HB band from £183 (45.15/week in 2025). # Annual wages and statutory pay can relate to different parts of the year; # leave days themselves are not remunerative work (HB reg 6(7)). @@ -602,3 +601,41 @@ miscellaneous_income: -300 output: housing_benefit_non_dep_gross_income: 1_000 + +- name: housing_benefit_non_dep_gross_income counts other investment income + # DWP tells councils to count earnings and any other income (HB guidance + # A5.542). + period: 2025 + input: + social_security_income: 0 + employment_income: 1_000 + other_investment_income: 2_000 + output: + housing_benefit_non_dep_gross_income: 3_000 + +- name: housing_benefit_non_dep_gross_income counts a self-employment or property loss as nil + # The model has profits, not the receipts DWP HB guidance A5.551 counts + # before business expenses. As an approximation, a loss counts as nil and + # does not reduce other income. + period: 2025 + input: + social_security_income: 0 + employment_income: 5_000 + self_employment_income: -3_000 + property_income: -1_000 + output: + housing_benefit_non_dep_gross_income: 5_000 + +- name: housing_benefit_non_dep_gross_income counts ISA interest and dividends + # DWP tells councils to count earnings and other income (HB guidance + # A5.542), including savings interest (A5.570). This case supplies an + # exempt-interest total, so the model counts that total once. + period: 2025 + input: + social_security_income: 0 + employment_income: 9_000 + individual_savings_account_interest_income: 500 + tax_free_savings_income: 20_000 + individual_savings_account_dividend_income: 30_000 + output: + housing_benefit_non_dep_gross_income: 59_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_remunerative_work.yaml index bb515785bf..f6bde9e1e8 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/housing_benefit_non_dep_remunerative_work.yaml @@ -1,9 +1,10 @@ # HB Regs 2006 reg 74(1)(b), (2) and reg 6(1), (6); pension-age # HB Regs 2006 reg 55(1)(b), (2) and reg 6(1), (6). -# A non-worker or 15-hour worker with banding income pays 20.40 * 52 = -# 1,060.80 in 2026-27. One with no banding income keeps the scale's zero: -# the gate only lowers a deduction until #2007 models the exemptions in -# reg 74(6), (7), (8) and (10) and reg 55(6)-(9) with the flat charge. +# For a non-worker or 15-hour worker, the council deducts 20.40 * 52 = 1,060.80 in 2026-27, +# whatever their income. The exemptions in reg 74(7), (8) and (10) and reg +# 55(7)-(9) act through eligibility, which most cases here take as an input. +# Family awards, which a single non-dependant's gross income would count, are +# held at zero. # A 16-hour worker's wages of 9,000 alone are below 192/week; adding # IIDB or child maintenance of 12,000 gives 403.85/week, so 105.20 * 52. @@ -12,6 +13,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -31,6 +40,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -50,6 +67,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -69,6 +94,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -88,6 +121,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -107,6 +148,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -126,6 +175,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -145,6 +202,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: ENGLAND housing_benefit_individual_non_dep_deduction_eligible: true @@ -164,6 +229,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 70 housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 0 @@ -175,10 +248,18 @@ output: household_benefits_individual_non_dep_deduction: 1060.8 -- name: housing_benefit keeps a non-worker with no banding income at zero (nil income) +- name: housing_benefit charges the flat amount to a non-worker with nil income period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 70 housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 0 @@ -188,30 +269,47 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - # Reg 74(1)(b) gives a non-exempt non-worker 1,060.80, but the zero is - # kept until #2007 models the exemptions alongside the flat charge. - household_benefits_individual_non_dep_deduction: 0 + # Reg 74(1)(b) gives a non-worker the flat amount whatever their income: + # 20.40 * 52. + household_benefits_individual_non_dep_deduction: 1060.8 - name: housing_benefit charges no new deduction to a non-dependant under 25 on Income Support # Reg 74(8)(a): no deduction for a non-dependant under 25 on Income Support. - # Income Support is not banding income, so the gate leaves the zero. + # The exemption makes them ineligible for a deduction. period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 22 - housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 0 social_security_income: 0 is_on_income_support: true is_on_income_based_jsa: false is_on_income_related_esa: false output: + housing_benefit_non_dep_deduction_exempt: true + housing_benefit_individual_non_dep_deduction_eligible: false household_benefits_individual_non_dep_deduction: 0 - name: housing_benefit excludes each income-related benefit recipient from remunerative work (Income Support) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 @@ -228,6 +326,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 @@ -244,6 +350,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 @@ -260,6 +374,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 housing_benefit_individual_non_dep_deduction_eligible: true hours_worked: 832 @@ -276,6 +398,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 gov.dwp.housing_benefit.non_dep_deduction.remunerative_work_hours: 20 age: 30 country: ENGLAND @@ -294,6 +424,14 @@ period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 gov.dwp.housing_benefit.non_dep_deduction.remunerative_work_hours: 20 age: 30 country: ENGLAND diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/non_dependant_deduction_review_cases.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/non_dependant_deduction_review_cases.yaml new file mode 100644 index 0000000000..e065b14ac8 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/non_dep_deduction/non_dependant_deduction_review_cases.yaml @@ -0,0 +1,1583 @@ +# End-to-end cases for the non-dependant deductions of Housing Benefit (HB Regs +# 2006 reg 74) and the England pensioner Council Tax Reduction scheme (SI +# 2012/2885 Sch 1 para 8), at 2026-27 amounts. +# The claimant family is the rent-liable family with the household's oldest +# member. Family benefits are held at zero except where a case tests them. + +- name: A dependent 18-year-old's PIP does not exempt the claimant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + dependant: + age: 18 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + pip_dl_category: STANDARD + lodger_son: + age: 40 + employment_income: 30_000 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant, dependant] + benunit_is_rent_liable: true + son_unit: + members: [lodger_son] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, dependant, lodger_son] + country: ENGLAND + local_authority: MAIDSTONE + output: + # reg 74(6) and para 8(6) protect the claimant or partner, not a young + # person in the family (reg 19; SSCBA s.142). The son's 576.92 a week is + # in the HB 485 band (119.85) and the CTR 485 band (13.30). + housing_benefit_non_dep_deductions_claimant_exempt: [false, false] + council_tax_reduction_applicant_has_non_dep_exemption: [false, false] + housing_benefit_non_dep_deductions: [6_232.20, 0] + council_tax_reduction_non_dep_deductions: [691.60, 0] + +- name: A 17-year-old claimant's PIP exempts their family + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 17 + current_education: NOT_IN_EDUCATION + pip_dl_category: STANDARD + sibling: + age: 16 + employment_income: 20_000 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + sibling_unit: + members: [sibling] + benunit_is_rent_liable: false + households: + household: + members: [claimant, sibling] + output: + # The benefit unit's head is the claimant whatever their age. + housing_benefit_non_dep_deductions_claimant_exempt: [true, false] + +- name: A parent non-dependant is banded on their own income, not their young person's + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + parent: + age: 45 + employment_income: 9_360 # 180 a week + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + young_person: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 18 + employment_income: 5_200 # 100 a week + weekly_hours: 8 + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + parent_unit: + members: [parent, young_person] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, parent, young_person] + country: ENGLAND + local_authority: MAIDSTONE + output: + # reg 74(4) and para 8(4) join a couple's income only. The parent's 180 a + # week is under both lowest bands; the young person is a full-time student + # (reg 74(7)(c); para 8(7)(c)). + housing_benefit_non_dep_deduction_exempt: [false, false, true] + housing_benefit_non_dep_deductions: [1_060.80, 0] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: A self-employed loss does not cancel wages for the Universal Credit exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + non_dependant: + age: 24 + employment_income: 5_200 + self_employment_income: -5_200 + weekly_hours: 8 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 1_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, non_dependant] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 57(2) step 3: a self-employed loss is nil, so the wages + # are earned income and neither exemption applies. Under 16 hours: the + # lowest amounts. + housing_benefit_non_dep_deduction_exempt: [false, false] + council_tax_reduction_non_dep_deduction_exempt: [false, false] + housing_benefit_non_dep_deductions: [1_060.80, 0] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Statutory maternity pay is earned income for the Universal Credit exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + non_dependant: + age: 22 + statutory_maternity_pay: 8000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 2000 + households: + household: + members: [claimant, non_dependant] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 55(4)(b): SMP is employed earnings, so the award is not + # calculated on no earned income. Not in remunerative work: the lowest amounts. + housing_benefit_non_dep_deduction_exempt: [false, false] + council_tax_reduction_non_dep_deduction_exempt: [false, false] + housing_benefit_non_dep_deductions: [1_060.80, 0] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Wages fully paid into a pension leave no earned income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + non_dependant: + age: 24 + employment_income: 6240 + employee_pension_contributions: 6240 + weekly_hours: 8 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, non_dependant] + country: ENGLAND + local_authority: MAIDSTONE + output: + # DWP deducts relievable pension contributions from employed earnings + # (UC Regs 2013 reg 55(5)(a)), leaving none (HB reg 74(8)(b); CTR para 8(8)(c)). + housing_benefit_non_dep_deduction_exempt: [false, true] + council_tax_reduction_non_dep_deduction_exempt: [false, true] + housing_benefit_non_dep_deductions: [0, 0] + council_tax_reduction_non_dep_deductions: [0, 0] + +- name: The minimum income floor counts as earned income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + non_dependant: + age: 24 + self_employment_income: 0 + uc_mif_applies: true + uc_minimum_income_floor: 20000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, non_dependant] + country: ENGLAND + local_authority: MAIDSTONE + output: + # DWP treats a person to whom the floor applies as having earned income + # equal to it (UC Regs 2013 reg 62(2)). + housing_benefit_non_dep_deduction_exempt: [false, false] + council_tax_reduction_non_dep_deduction_exempt: [false, false] + housing_benefit_non_dep_deductions: [1_060.80, 0] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Income-related ESA is payable to the recipient, not the working partner + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + recipient: + age: 45 + esa_income_reported: 2600 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + partner: + age: 40 + employment_income: 10920 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [recipient, partner] + benunit_is_rent_liable: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 2600 + pension_credit: 0 + would_claim_uc: false + # The couple doesn't move to Universal Credit when income-related ESA + # closes, so no award joins the band income. + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant, recipient, partner] + country: ENGLAND + local_authority: MAIDSTONE + output: + # HB Regs 2006 reg 2(3A): only the recipient is on ESA(IR), so only they are + # out of remunerative work (reg 6(6)) and exempt from CTR (para 8(8)(a)). The + # partner is banded on the couple's 260 a week (210 wages plus 50 ESA), in + # the HB 192 band (46.85) and under the CTR 279 band (5.20); one deduction + # for the couple, the higher. + housing_benefit_non_dep_deductions: [2_436.20, 0] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Gross income counts Universal Credit after the benefit cap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + non_dependant: + age: 40 + employment_income: 52 # 1 a week, so not exempt as UC without earnings + weekly_hours: 35 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 30000 + uc_benefit_cap: 20000 + households: + household: + members: [claimant, non_dependant] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 81: the cap reduces the award to 20,000; with 1 a week of + # wages, 385.62 a week, + # in the HB 365 band (105.20) and the CTR 279 band (10.60). Before the cap it + # would be 577.92 a week. + housing_benefit_non_dep_deductions: [5_470.40, 0] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: A young person for whom child benefit is paid is disregarded for CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + parent: + age: 45 + employment_income: 10400 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + young_person: + age: 19 + current_education: NOT_IN_EDUCATION + is_in_approved_training: true + age_started_or_accepted_current_education_or_training: 16 + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [parent, young_person] + benunit_is_rent_liable: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 1406.6 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + would_claim_uc: false + households: + household: + members: [claimant, parent, young_person] + country: ENGLAND + local_authority: MAIDSTONE + output: + # CTR para 8(8)(b) and LGFA 1992 Sch 1 para 3: the 19-year-old on approved + # training is disregarded, so only the parent is deducted: 227.05 a week with + # the child benefit, under 279 (5.20). HB has no such exemption: the parent + # (192 band, 46.85) plus the young person, not in remunerative work (20.40). + council_tax_reduction_non_dep_deduction_exempt: [false, false, true] + council_tax_reduction_non_dep_deductions: [270.40, 0] + housing_benefit_non_dep_deduction_exempt: [false, false, false] + housing_benefit_non_dep_deductions: [3_497.00, 0] + +# Partners are the SSCBA s.137 couple (is_claimant_or_partner), not whoever is +# not a child benefit child or qualifying young person. + +- name: A blind 16-year-old partner exempts a Welsh 19-year-old applicant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 19 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 16 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 16 + is_blind: true + is_claimant_or_partner: true + lodger: + age: 18 + employment_income: 30_000 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + applicant_unit: + members: [applicant, partner] + benunit_is_rent_liable: true + lodger_unit: + members: [lodger] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, partner, lodger] + country: WALES + local_authority: CARDIFF + output: + # HB reg 74(6)(a) and WSI 2013/3029 Sch 6 para 5(6)(a): the claimant's + # partner is blind, so no deduction for the lodger (576.92 a week, which + # would otherwise be 119.85 HB and 16.80 CTR a week). + housing_benefit_non_dep_deductions_claimant_exempt: [true, false] + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + housing_benefit_non_dep_deductions: [0, 0] + council_tax_reduction_non_dep_deductions: [0, 0] + +- name: A non-dependant couple with a partner at school is banded on joint income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_claimant_or_partner: true + older_partner: + age: 20 + employment_income: 9_360 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + younger_partner: + age: 18 + employment_income: 8_320 + weekly_hours: 10 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 16 + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + couple_unit: + members: [older_partner, younger_partner] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, older_partner, younger_partner] + country: ENGLAND + local_authority: MAIDSTONE + output: + # HB reg 74(3)-(4) and CTR Sch 1 para 8(3)-(4): one deduction for the + # couple, banded on their joint 340 a week because the older partner is in + # remunerative work: 64.35 HB (279 band) and 10.60 CTR (279 band). + housing_benefit_non_dep_deductions: [3_346.20, 0] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: A 17-year-old partner's PIP exempts a Merton applicant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 17 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 16 + pip_dl_category: STANDARD + is_claimant_or_partner: true + lodger: + age: 40 + employment_income: 30_000 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + applicant_unit: + members: [applicant, partner] + would_claim_uc: false + claims_all_entitled_benefits: true + lodger_unit: + members: [lodger] + would_claim_uc: false + households: + household: + members: [applicant, partner, lodger] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # Merton scheme para 30(6): no deduction where the applicant or partner + # gets the daily living component of PIP. + council_tax_reduction_applicant_has_non_dep_exemption: [true, false] + merton_council_tax_reduction_non_dep_deductions: [0, 0] + +# Gross and earned income, the Scottish working-age scheme, the benefit cap's +# childcare protection, and child benefit entitlement. + +- name: Statutory maternity pay counts towards a non-dependant couple's gross income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + worker: + age: 30 + employment_income: 13_520 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 28 + statutory_maternity_pay: 1_560 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + couple_unit: + members: [worker, partner] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, worker, partner] + country: ENGLAND + local_authority: MAIDSTONE + output: + # HB Regs 2006 reg 35(1)(i): statutory maternity pay is earnings. The + # couple's joint gross income is 260 wages plus 30 SMP, 290 a week (reg + # 74(4); CTR para 8(4)): the HB 279 band (64.35) and the CTR 279 band + # (10.60). Without the SMP, 260 would be a band lower in each. + housing_benefit_non_dep_deductions: [3_346.20, 0] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: A couple's income-related ESA counts towards their joint gross income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + recipient: + age: 45 + esa_income_reported: 5_200 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 40 + employment_income: 9_360 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + couple_unit: + members: [recipient, partner] + benunit_is_rent_liable: false + would_claim_uc: false + # The couple doesn't move to Universal Credit when income-related ESA + # closes, so no award joins the band income. + would_claim_uc_at_legacy_closure: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 5_200 + pension_credit: 0 + households: + household: + members: [claimant, recipient, partner] + country: ENGLAND + local_authority: MAIDSTONE + output: + # The partner works 16 hours and is banded on the couple's 180 wages plus + # 100 ESA(IR), 280 a week: the HB 279 band (64.35) and the CTR 279 band + # (10.60). The recipient is out of remunerative work (reg 6(6)) and exempt + # from CTR (para 8(8)(a)); one deduction for the couple, the higher. + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + housing_benefit_non_dep_deductions: [3_346.20, 0] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: Statutory paternity pay is earned income for the Universal Credit exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + lodger: + age: 24 + statutory_paternity_pay: 300 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + lodger_unit: + members: [lodger] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 2_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, lodger] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 55(4)(c): statutory paternity pay is employed earnings, + # so the award is not calculated on no earned income and neither + # exemption applies (HB reg 74(8)(b); CTR para 8(8)(c)). The lodger does no + # remunerative work, so the council deducts the lowest amounts, 20.40 and 5.20. + housing_benefit_non_dep_deduction_exempt: [false, false] + council_tax_reduction_non_dep_deduction_exempt: [false, false] + housing_benefit_non_dep_deductions: [1_060.80, 0] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Pension contributions are deducted from self-employed earnings in the start-up period + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + lodger: + age: 24 + self_employment_income: 6_000 + pension_contributions: 6_000 + uc_is_in_startup_period: true + weekly_hours: 10 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + lodger_unit: + members: [lodger] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 2_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, lodger] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 57(2), step 4: with no employed earnings, the 6,000 of + # relievable pension contributions comes off the 6,000 profit, leaving nil. + # The minimum income floor does not apply in the start-up period (reg + # 62(5)), so the award is on no earned income: exempt under HB reg + # 74(8)(b) (aged under 25) and CTR para 8(8)(c). + housing_benefit_non_dep_deduction_exempt: [false, true] + council_tax_reduction_non_dep_deduction_exempt: [false, true] + housing_benefit_non_dep_deductions: [0, 0] + council_tax_reduction_non_dep_deductions: [0, 0] + +- name: Scotland's working-age scheme exempts the partner of an Income Support claimant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + is_claimant_or_partner: true + recipient: + age: 35 + income_support_reported: 5_000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 34 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + applicant_unit: + members: [applicant] + benunit_is_rent_liable: true + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 0 + couple_unit: + members: [recipient, partner] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 5_000 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, recipient, partner] + country: SCOTLAND + local_authority: GLASGOW_CITY + output: + # SSI 2021/249 reg 90(8)(a) with reg 4(1): the partner "has a partner who + # is" on Income Support and is not on Universal Credit, so both are exempt + # from CTR. Housing Benefit has no such rule (reg 74(8)(a) is for the + # person on IS, and only under 25): both are out of remunerative work, and + # the council deducts one lowest amount for the couple, 20.40 a week. + council_tax_reduction_non_dep_deduction_exempt: [false, true, true] + council_tax_reduction_non_dep_deductions: [0, 0] + housing_benefit_non_dep_deductions: [1_060.80, 0] + +- name: Before April 2022 Scotland exempted only the Income Support claimant + period: 2021 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + is_claimant_or_partner: true + recipient: + age: 35 + income_support_reported: 5_000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 34 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + applicant_unit: + members: [applicant] + benunit_is_rent_liable: true + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 0 + couple_unit: + members: [recipient, partner] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 5_000 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, recipient, partner] + country: SCOTLAND + local_authority: GLASGOW_CITY + output: + # SSI 2012/303 reg 67(8)(a): only a non-dependant "who is on income + # support" is exempt. For the partner outside remunerative work, the council deducts the + # lowest 2021-22 amount, 4.30 a week. + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + council_tax_reduction_non_dep_deductions: [223.60, 0] + +- name: Scotland's pension-age scheme exempts only the Income Support claimant + period: 2026 + input: + people: + applicant: + age: 80 + is_claimant_or_partner: true + recipient: + age: 35 + income_support_reported: 5_000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 34 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + applicant_unit: + members: [applicant] + benunit_is_rent_liable: true + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 0 + couple_unit: + members: [recipient, partner] + benunit_is_rent_liable: false + would_claim_uc: false + income_support: 5_000 + pension_credit: 0 + households: + household: + members: [applicant, recipient, partner] + country: SCOTLAND + local_authority: GLASGOW_CITY + output: + # SSI 2012/319 reg 48(8)(a): a non-dependant "who is on income support". + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + +- name: The benefit cap does not reduce Universal Credit when childcare costs exceed the excess + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + guardian: + age: 20 + employment_income: 9_360 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + older_child: + age: 4 + is_claimant_or_partner: false + younger_child: + age: 2 + is_claimant_or_partner: false + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + guardian_unit: + members: [guardian, older_child, younger_child] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 22_000 + uc_childcare_element: 12_000 + child_benefit: 2_500 + uc_benefit_cap: 22_020 + child_tax_credit: 0 + working_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, guardian, older_child, younger_child] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 81(1)-(2): the excess over the cap is 2,480, less than + # the 12,000 childcare costs element, so the award is not reduced. Gross + # income is 180 wages plus 22,000 UC and 2,500 child benefit, 651.15 a + # week: the top bands, 131.45 HB and 15.95 CTR. + housing_benefit_non_dep_deductions: [6_835.40, 0] + council_tax_reduction_non_dep_deductions: [829.40, 0] + +- name: A young person no one claims child benefit for is not disregarded for CTR + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_claimant_or_partner: true + parent: + age: 45 + employment_income: 10_400 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + young_person: + age: 19 + current_education: NOT_IN_EDUCATION + is_in_approved_training: true + age_started_or_accepted_current_education_or_training: 16 + weekly_hours: 0 + is_claimant_or_partner: false + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [parent, young_person] + benunit_is_rent_liable: false + would_claim_child_benefit: false + child_tax_credit: 0 + working_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + would_claim_uc: false + households: + household: + members: [claimant, parent, young_person] + country: ENGLAND + local_authority: MAIDSTONE + output: + # LGFA 1992 Sch 1 para 3 needs another person to be "entitled to child + # benefit" for the young person, and entitlement needs a claim (SSAA 1992 + # s.1(1)). With no claim, the council deducts for both: the parent's 200 a week is + # under the CTR 279 band (5.20), and for the young person outside remunerative + # work, the council deducts the lowest amount (5.20). + council_tax_reduction_non_dep_deduction_exempt: [false, false, false] + council_tax_reduction_non_dep_deductions: [540.80, 0] + +- name: A non-dependant family's Universal Credit counts after the benefit cap + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + worker: + age: 20 + employment_income: 9_200 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 38 + maternity_allowance: 6_000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + child_1: + age: 16 + is_claimant_or_partner: false + child_2: + age: 15 + is_claimant_or_partner: false + child_3: + age: 13 + is_claimant_or_partner: false + child_4: + age: 11 + is_claimant_or_partner: false + child_5: + age: 0 + is_claimant_or_partner: false + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + family_unit: + members: [worker, partner, child_1, child_2, child_3, child_4, child_5] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 14_000 + uc_childcare_element: 0 + child_benefit: 5_129.80 + uc_benefit_cap: 22_020 + child_tax_credit: 0 + working_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, worker, partner, child_1, child_2, child_3, child_4, child_5] + country: ENGLAND + local_authority: MAIDSTONE + output: + # The Universal Credit cap counts 14,000 UC, 5,129.80 child benefit and the + # partner's 6,000 Maternity Allowance (Welfare Reform Act 2012 s.96(10)(i)): + # 25,129.80, which exceeds the 22,020 cap by 3,109.80, so DWP cuts UC to + # 10,890.20 (UC reg 81). Joint gross income comes to 9,200 wages + 6,000 + # Maternity Allowance + 10,890.20 UC + 5,129.80 child benefit = 600.38 a + # week: the 485 bands, 119.85 HB and 13.30 CTR a week, so the council + # deducts 6,232.20 HB and 691.60 CTR a year. + universal_credit: [0, 10_890.20] + housing_benefit_non_dep_deductions: [6_232.20, 0] + council_tax_reduction_non_dep_deductions: [691.60, 0] + +- name: A self-employment loss does not cancel a non-dependant's gross wages + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + lodger: + age: 30 + employment_income: 15_600 + self_employment_income: -10_400 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + lodger_unit: + members: [lodger] + benunit_is_rent_liable: false + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, lodger] + country: ENGLAND + local_authority: MAIDSTONE + output: + # HB reg 74(2) and SI 2006/214 reg 55(2): "normal gross weekly income". + # Taxable income nets the 10,400 loss against the 15,600 wages; gross + # income does not (DWP HB guidance A5.551), so the lodger is banded on + # 300 a week: the HB 279 band (64.35) and the CTR 279 band (10.60). + housing_benefit_non_dep_deductions: [3_346.20, 0] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: Scotland's partner rule does not cover a couple on Universal Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 50 + is_claimant_or_partner: true + recipient: + age: 35 + income_support_reported: 5_000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + partner: + age: 34 + employment_income: 5_200 + weekly_hours: 10 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + applicant_unit: + members: [applicant] + benunit_is_rent_liable: true + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 0 + couple_unit: + members: [recipient, partner] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 1_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 5_000 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, recipient, partner] + country: SCOTLAND + local_authority: GLASGOW_CITY + output: + # SSI 2021/249 reg 4(1): a qualifying income-related benefit claimant "is + # not on universal credit". Here the couple is, so the partner rule does + # not apply. The recipient is exempt as on UC without earned income (reg + # 90(8)(c)); the partner has wages, so is charged the lowest amount (5.55, + # under 16 hours), the couple's one deduction. + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + council_tax_reduction_non_dep_deductions: [288.60, 0] + +- name: A young person's own Maternity Allowance does not cap the family's Universal Credit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 80 + is_claimant_or_partner: true + is_household_head: true + worker: + age: 20 + employment_income: 7_000 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 16 + maternity_allowance: 6_000 + is_claimant_or_partner: false + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + family_unit: + members: [worker, young_person] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 14_000 + uc_childcare_element: 0 + child_benefit: 5_129.80 + uc_benefit_cap: 22_020 + child_tax_credit: 0 + working_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, worker, young_person] + country: ENGLAND + local_authority: MAIDSTONE + output: + # UC Regs 2013 reg 79(1): the cap counts the welfare benefits "to which a + # single person or couple is entitled". The young person's own 6,000 is + # not the claimant's, so the capped total is 19,129.80, under the cap, and + # UC stays 14,000. The worker is banded on 7,000 + 14,000 + 5,129.80 = + # 502.50 a week: the HB 485 band (119.85) and the CTR 485 band (13.30). + housing_benefit_non_dep_deductions: [6_232.20, 0] + council_tax_reduction_non_dep_deductions: [691.60, 0] + +# The Universal Credit exemption reads uc_individual_earned_income. + +- name: Employment expenses that cancel the wages leave no earned income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + current_education: NOT_IN_EDUCATION + non_dependant: + age: 24 + current_education: NOT_IN_EDUCATION + employment_income: 1_000 + employment_expenses: 1_000 + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 5000 + uc_benefit_cap: 100_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, non_dependant] + country: WALES + local_authority: CARDIFF + output: + # DWP subtracts allowable employment expenses from employed earnings + # (UC Regs 2013 reg 55(3)(a)), leaving none (HB reg 74(8)(b), (11); WSI + # 2013/3029 Sch 1 para 3(8)(c), (10)). + housing_benefit_non_dep_deduction_exempt: [false, true] + council_tax_reduction_non_dep_deduction_exempt: [false, true] + housing_benefit_non_dep_deductions: [0, 0] + council_tax_reduction_non_dep_deductions: [0, 0] + +- name: A trading loss that cancels the profit leaves no earned income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + current_education: NOT_IN_EDUCATION + non_dependant: + age: 24 + current_education: NOT_IN_EDUCATION + self_employment_income: 1_000 + trading_loss: 1_000 + uc_mif_applies: false + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 5000 + uc_benefit_cap: 100_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, non_dependant] + country: WALES + local_authority: CARDIFF + output: + # DWP adds the trades' profits and losses before applying the nil floor + # (UC Regs 2013 reg 57(2)), so the loss cancels the profit. The inputs + # turn off the minimum income floor. + housing_benefit_non_dep_deduction_exempt: [false, true] + council_tax_reduction_non_dep_deduction_exempt: [false, true] + housing_benefit_non_dep_deductions: [0, 0] + council_tax_reduction_non_dep_deductions: [0, 0] + +- name: A company's income attributed to its owner is earned income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + current_education: NOT_IN_EDUCATION + non_dependant: + age: 24 + current_education: NOT_IN_EDUCATION + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_trade: true + owned_company_income_share: 1_000 + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 5000 + uc_benefit_cap: 100_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, non_dependant] + country: WALES + local_authority: CARDIFF + output: + # DWP attributes the company's income to its owner as self-employed + # earnings (UC Regs 2013 reg 77(3)(b)), so the exemption doesn't apply. Outside + # remunerative work the councils deduct the lowest amounts: HB 20.40 and + # the Welsh pensioner scale's 6.70 a week. + housing_benefit_non_dep_deduction_exempt: [false, false] + council_tax_reduction_non_dep_deduction_exempt: [false, false] + housing_benefit_non_dep_deductions: [1_060.80, 0] + council_tax_reduction_non_dep_deductions: [348.40, 0] + +- name: The minimum income floor doesn't apply where a partner's earnings meet the couple threshold + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + current_education: NOT_IN_EDUCATION + non_dependant: + age: 24 + current_education: NOT_IN_EDUCATION + weekly_hours: 0 + employment_income: 0 + uc_mif_applies: true + uc_minimum_income_floor: 10_000 + partner: + age: 30 + current_education: NOT_IN_EDUCATION + weekly_hours: 15 + employment_income: 30_000 + uc_mif_applies: false + uc_minimum_income_floor: 10_000 + benunits: + claimant_unit: + members: [claimant] + benunit_is_rent_liable: true + non_dependant_unit: + members: [non_dependant, partner] + benunit_is_rent_liable: false + universal_credit_pre_benefit_cap: 5000 + uc_benefit_cap: 100_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, non_dependant, partner] + country: WALES + local_authority: CARDIFF + output: + # DWP applies the minimum income floor to a member of a couple only while + # the couple's earnings fall below the couple threshold (UC Regs 2013 reg + # 62(3)). The partner's earned income exceeds it, so the non-dependant + # has none, and the exemption applies. The Welsh working-age scheme + # deducts for each member of a Universal Credit couple (Sch 6 para 5(3)): + # nil for the exempt non-dependant and 6.70 a week for the partner, who + # works under 16 hours. + housing_benefit_non_dep_deduction_exempt: [false, true, false] + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 0, 348.40] + council_tax_reduction_non_dep_deductions: [348.40, 0] + +- name: A third adult's Universal Credit leaves a Welsh non-dependant couple one deduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 40 + is_household_head: true + is_claimant_or_partner: true + is_uc_claimant: false + current_education: NOT_IN_EDUCATION + first: + age: 35 + is_claimant_or_partner: true + is_uc_claimant: false + employment_income: 10_400 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + second: + age: 34 + is_claimant_or_partner: true + is_uc_claimant: false + employment_income: 5_200 + weekly_hours: 16 + current_education: NOT_IN_EDUCATION + third: + age: 25 + is_claimant_or_partner: false + is_uc_claimant: true + employment_income: 0 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + benunits: + applicant_unit: + members: [applicant] + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + non_dependant_unit: + members: [first, second, third] + universal_credit_pre_benefit_cap: 3_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + home: + members: [applicant, first, second, third] + country: WALES + local_authority: CARDIFF + rent: 6_000 + council_tax: 2_000 + tenure_type: RENT_PRIVATELY + savings: 0 + output: + # WSI 2013/3029 Sch 6 para 5(3): only the couple's own Universal Credit + # award takes it out of the one-deduction rule. Here the third adult + # claims alone, so the council bands the couple on 15,600 a year (300 a + # week) and makes one 13.35 deduction. It deducts nothing for the third + # adult, on Universal Credit with no earned income (para 5(8)(c)). + council_tax_reduction_individual_non_dep_deduction: [0, 694.20, 694.20, 0] + council_tax_reduction_non_dep_deductions: [694.20, 0] + +- name: A third adult's Universal Credit leaves the couple one deduction from each Welsh applicant + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + is_claimant_or_partner: true + is_uc_claimant: false + current_education: NOT_IN_EDUCATION + sharer: + age: 30 + is_claimant_or_partner: true + is_uc_claimant: false + current_education: NOT_IN_EDUCATION + first: + age: 35 + is_claimant_or_partner: true + is_uc_claimant: false + employment_income: 10_400 + hours_worked: 832 + current_education: NOT_IN_EDUCATION + second: + age: 34 + is_claimant_or_partner: true + is_uc_claimant: false + employment_income: 5_200 + hours_worked: 832 + current_education: NOT_IN_EDUCATION + third: + age: 25 + is_claimant_or_partner: false + is_uc_claimant: true + employment_income: 9_100 + hours_worked: 1_040 + current_education: NOT_IN_EDUCATION + benunits: + head_family: + members: [head] + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + non_dependant_family: + members: [first, second, third] + would_claim_uc: false + universal_credit_pre_benefit_cap: 3_000 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 520 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + home: + members: [head, sharer, first, second, third] + country: WALES + local_authority: CARDIFF + rent: 12_000 + council_tax: 2_000 + tenure_type: RENT_PRIVATELY + savings: 0 + output: + # The pensioner head and the working-age sharer each apply under their + # own Welsh scheme and share the deductions equally (Sch 1 para 3(5); Sch + # 6 para 5(5)). The council bands the couple on 15,600 + 520 = 16,120 a + # year (310 a week) and makes one deduction under either scheme, as the + # Universal Credit is the third adult's. It bands the third adult on + # 9,100 + 3,000 (232.69 a week) and deducts for them separately. + council_tax_reduction_non_dep_deductions: [521.30, 521.30, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml index a6a1b2bde1..88cc95616c 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/housing_benefit/pension_age_severe_disability_conditions.yaml @@ -1,8 +1,8 @@ # HB(SPC) Regs 2006 Sch 3 para 6, with the non-dependant definition in # reg 3: these cases assert final awards without a Guarantee Credit passport. -# The final HB and CTR awards include the model's current income rules, -# including a known defect outside this file: no non-dependant deduction -# exemption for a claimant on Attendance Allowance (#2007). The premium and +# The final HB and CTR awards include the model's current income rules. +# A claimant on Attendance Allowance has no non-dependant deductions (HB +# (SPC) Regs 2006 reg 55(6); SI 2012/2885 Sch 1 para 8(6)); the premium and # applicable-amount assertions do not depend on it. Each HB award is the # rent less 65% of income after tax, plus the savings credit where the award # is savings credit only (HB (SPC) Regs 2006 reg 27(4)(a)), above the @@ -129,9 +129,8 @@ guarantee_credit: [0, 0] has_non_dependant_for_severe_disability_premium: [true, false] pension_age_severe_disability_premium: [0, 0] - # The resident has no banding income, so the work gate leaves the - # scale's zero; reg 55(6) and Sch 1 para 8(6) exempt this claimant's - # non-dependants in any case (the AA exemption remains for #2007). + # Reg 55(6) and Sch 1 para 8(6) exempt this claimant's non-dependants: + # the claimant receives Attendance Allowance. housing_benefit: [3_898.70, 0] council_tax_benefit: [1_279.60, 0] @@ -175,9 +174,8 @@ # Savings credit only (908.46): HB income is Pension Credit income plus # the savings credit (HB (SPC) Regs 2006 reg 27(4)(a)), 0.65 x 908.46 = # 590.50 below the figure without it. - # Neither non-working resident has banding income, so the work gate - # leaves the scale's zero for both; the AA exemption (reg 55(6), Sch 1 - # para 8(6)) remains for #2007. + # The AA exemption (reg 55(6), Sch 1 para 8(6)) leaves no deduction for + # either resident. housing_benefit: [3_308.20, 0] council_tax_benefit: [1_097.91, 0] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml index 7173dd1ede..b22b47cd1b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/income_support/income_support_capital.yaml @@ -22,7 +22,9 @@ other_residential_property_value: 3000 non_residential_property_value: 4000 output: - income_support_assessable_capital: 10500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 9,500. + income_support_assessable_capital: 9550 - name: Income Support has no tariff income at the lower capital threshold period: 2021 @@ -231,4 +233,150 @@ corporate_wealth: 2000 private_pension_wealth: 150000 output: - income_support_assessable_capital: 3000 + # Savings at face value; shares less 10% for the expenses of sale + # (IS Regs 1987 reg. 49(a)): 1,000 + 90% of 2,000. + income_support_assessable_capital: 2800 + +- name: Income Support values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + income_support_assessable_capital: 9_000 + +- name: Income Support preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + would_claim_IS: false + couple: + members: [second, partner] + would_claim_IS: false + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + income_support_assessable_capital: [5_400, 10_800] + +- name: Income Support reported claim path receives full overseas land and property capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + would_claim_IS: true + other: + members: unrelated + would_claim_IS: false + households: + household: + members: [claimant, unrelated] + overseas_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + income_support_assessable_capital: [16_200, 0] + +- name: Income Support values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + income_support_assessable_capital: 9_000 + +- name: Income Support preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + would_claim_IS: false + couple: + members: [second, partner] + would_claim_IS: false + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + income_support_assessable_capital: [5_400, 10_800] + +- name: Income Support reported claim path receives full unclassified other real estate capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + would_claim_IS: true + other: + members: unrelated + would_claim_IS: false + households: + household: + members: [claimant, unrelated] + other_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + income_support_assessable_capital: [16_200, 0] + +- name: Income Support deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + income_support_assessable_capital: 28_000 + +- name: Income Support floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + income_support_assessable_capital: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml index 024036709e..748b2ef9ef 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/jsa_income/jsa_income_capital.yaml @@ -19,7 +19,9 @@ other_residential_property_value: 3_000 non_residential_property_value: 4_000 output: - jsa_income_assessable_capital: 10_500 + # Savings at face value; land, shares and property less 10% for the + # expenses of sale (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 9,500. + jsa_income_assessable_capital: 9_550 - name: Income-based JSA has no tariff income at the lower capital threshold period: 2025 @@ -184,4 +186,144 @@ corporate_wealth: 2_000 private_pension_wealth: 150_000 output: - jsa_income_assessable_capital: 3_000 + # Savings at face value; shares less 10% for the expenses of sale + # (JSA Regs 1996 reg. 111(a)): 1,000 + 90% of 2,000. + jsa_income_assessable_capital: 2_800 + +- name: Income-based JSA values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + jsa_income_assessable_capital: 9_000 + +- name: Income-based JSA preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + jsa_income_assessable_capital: [5_400, 10_800] + +- name: Income-based JSA reported claim path receives full overseas land and property capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + jsa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + overseas_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + jsa_income_assessable_capital: [16_200, 0] + +- name: Income-based JSA values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + jsa_income_assessable_capital: 9_000 + +- name: Income-based JSA preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + jsa_income_assessable_capital: [5_400, 10_800] + +- name: Income-based JSA reported claim path receives full unclassified other real estate capital + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 40 + jsa_income_reported: 1_000 + unrelated: + age: 40 + benunits: + claiming: + members: claimant + other: + members: unrelated + households: + household: + members: [claimant, unrelated] + other_property_value: 18_000 + output: + # Existing claim-path allocation gives the claimant all net household capital. + jsa_income_assessable_capital: [16_200, 0] + +- name: Income-based JSA deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + jsa_income_assessable_capital: 28_000 + +- name: Income-based JSA floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + jsa_income_assessable_capital: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml index 6add9f640f..0aee9af7f4 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/childcare_element/uc_childcare_work_condition_reg_32.yaml @@ -128,7 +128,8 @@ # allowance, so is a "severely disabled person" (SSCBA 1992 s. 70(2)). # Sara meets the Carer's Allowance conditions that reg. 30(1)(a) # imports, so "both Ben and Sara satisfy the paid work test" (ADM F7013). - # The model's is_carer_for_benefits reads only Sara's 40 care hours; it + # The model's is_carer_for_benefits reads Sara's 40 care hours and her + # cares_for_severely_disabled_person, left at its default of true; it # does not check her mother's attendance allowance. is_carer_for_benefits: [false, true, false, false] uc_childcare_work_condition: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml index be74f2340d..aad23c70e5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/company/uc_company_analogous_business.yaml @@ -242,7 +242,9 @@ uc_mif_applies: [false] uc_company_capital: [0] uc_company_holding_disregard: [0] - uc_assessable_capital: 25_000 + # Outside reg. 77 the holding is ordinary shares, valued at 90% for the + # expenses of sale (reg. 49(1)(a)). + uc_assessable_capital: 22_500 - name: An investor in a company with many shareholders is not treated as its owner # H4364: such a shareholder has no influence over the running of the @@ -270,7 +272,8 @@ output: uc_company_owner_treatment_applies: [false] uc_individual_earned_income: [0] - uc_assessable_capital: 12_000 + # Ordinary shares, valued at 90% for the expenses of sale (reg. 49(1)(a)). + uc_assessable_capital: 10_800 - name: A company carrying on neither a trade nor a property business is outside the rule # Reg. 77(1) needs a company "carrying on a trade or a property business". @@ -292,7 +295,8 @@ corporate_wealth: 10_000 output: uc_company_owner_treatment_applies: [false] - uc_assessable_capital: 10_000 + # Ordinary shares, valued at 90% for the expenses of sale (reg. 49(1)(a)). + uc_assessable_capital: 9_000 - name: Each partner's company is attributed to the couple's capital # Joint claimants' capital is combined against one limit (Welfare Reform @@ -483,7 +487,9 @@ savings: 10_000 corporate_wealth: 30_000 output: - household_uc_pool_company_holdings: 30_000 + # The 30,000 holding leaves the pool at the 90% at which corporate_wealth + # is counted (reg. 49(1)(a)). + household_uc_pool_company_holdings: 27_000 uc_assessable_capital: [5_000, 5_000] - name: A benunit that reports its capital has its own holding taken off what it reports @@ -507,7 +513,10 @@ household: members: person output: - uc_assessable_capital: 35_000 + # The complete override is a countable figure, so the holding inside it + # is counted at 90% (reg. 49(1)(a)) and comes off at that value: + # 50,000 - 27,000 + 15,000 of company capital. + uc_assessable_capital: 38_000 - name: A dependant's company earnings are not the claimant's earned income # Reg. 22(1)(b) deducts "the claimant's earned income (or, in the case of @@ -647,5 +656,7 @@ savings: 8_000 corporate_wealth: 12_000 output: - household_uc_pool_company_holdings: 12_000 + # The 12,000 holding leaves the pool at the 90% at which corporate_wealth + # is counted (reg. 49(1)(a)). + household_uc_pool_company_holdings: 10_800 uc_assessable_capital: [0, 8_000] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml index 9a9ca3cced..b25b8b51f5 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction.yaml @@ -6,6 +6,8 @@ person: age: 22 uc_non_dep_deduction_exempt: false + # A member of another family who is not liable for rent. + is_non_dependant_of_household_head: true benunits: benunit: members: person @@ -21,6 +23,7 @@ person: age: 22 uc_non_dep_deduction_exempt: true + is_non_dependant_of_household_head: true benunits: benunit: members: person @@ -36,6 +39,8 @@ person: age: 20 uc_non_dep_deduction_exempt: false + # A member of another family who is not liable for rent. + is_non_dependant_of_household_head: true benunits: benunit: members: person diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml index 11fe57ddce..99888fb21a 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.yaml @@ -4,7 +4,7 @@ input: age: 21 uc_non_dep_deduction_exempt: false - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true output: uc_individual_non_dep_deduction_eligible: true @@ -14,6 +14,40 @@ input: age: 20 uc_non_dep_deduction_exempt: false - benunit_is_rent_liable: false + is_non_dependant_of_household_head: true + output: + uc_individual_non_dep_deduction_eligible: false + +- name: Exempt + period: 2020 + absolute_error_margin: 0 + input: + age: 21 + uc_non_dep_deduction_exempt: true + is_non_dependant_of_household_head: true + output: + uc_individual_non_dep_deduction_eligible: false + +# UC Regs 2013 Sch 4 para 9(1)(c): a member of the renter's own benefit unit +# who is not the claimant, partner or a qualifying young person. +- name: Non-dependant within the benefit unit + period: 2020 + absolute_error_margin: 0 + input: + age: 25 + uc_non_dep_deduction_exempt: false + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_universal_credit: true + output: + uc_individual_non_dep_deduction_eligible: true + +- name: The renter is not their own non-dependant + period: 2020 + absolute_error_margin: 0 + input: + age: 25 + uc_non_dep_deduction_exempt: false + is_non_dependant_of_household_head: false + is_benefit_unit_non_dependant_for_universal_credit: false output: uc_individual_non_dep_deduction_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.yaml new file mode 100644 index 0000000000..b892ff5c13 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.yaml @@ -0,0 +1,319 @@ +- name: UC Sch 4 para 15, a partner whom reg 3(3) excludes from the claim brings no exemption by being blind + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 43 + is_blind: true + is_claimant_or_partner: true + # Reg 3(3)(e): subject to immigration control, so the claimant + # claims as a single person. + uc_is_ineligible_partner: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + family: + members: [claimant, partner, son] + households: + home: + members: [claimant, partner, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_single_or_joint_claimant: [true, false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false, true] + # The claimant, the partner who shares her room, and the son, a + # non-dependant who is not a child (Sch 4 para 10(1)(a), (c)): 2 + # bedrooms, category C, whose £208.85 weekly rate (£10,860.20 a year) + # exceeds the £10,400 rent, so the formula takes the rent. + LHA_allowed_bedrooms: 2 + LHA_category: C + # Para 1(2) makes the claimant the renter. The blind partner is no claimant, + # so para 15 gives no exemption. + uc_non_dep_deductions_renter_exempt: false + # The son, aged 22, owes 96.55 x 12 = 1,158.60 (para 13). + uc_non_dep_deductions: 1_158.60 + # 10,400.00 - 1,158.60 = 9,241.40. + uc_housing_costs_element: 9_241.40 + # Single rate aged 25 or over: 424.90 x 12 = 5,098.80. Under reg 3(3) + # the claimant claims as a single person. + uc_standard_allowance: 5_098.80 + # 5,098.80 + 9,241.40 = 14,340.20, with no income to taper. + universal_credit: 14_340.20 + # 14,340.20 + 100 (Household Energy Payment) - 90 (the TV Licence, which the model halves + # for a household with a blind member) = 14,350.20. + household_net_income: 14_350.20 + +- name: UC Sch 4 para 15, a blind single claimant whose partner reg 3(3) excludes is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_blind: true + is_household_head: true + is_claimant_or_partner: true + partner: + age: 43 + is_claimant_or_partner: true + # Reg 3(3)(e): subject to immigration control, so the claimant + # claims as a single person. + uc_is_ineligible_partner: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + family: + members: [claimant, partner, son] + households: + home: + members: [claimant, partner, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_single_or_joint_claimant: [true, false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false, true] + # The claimant, the partner who shares her room, and the son, a + # non-dependant who is not a child (Sch 4 para 10(1)(a), (c)): 2 + # bedrooms, category C, whose £208.85 weekly rate (£10,860.20 a year) + # exceeds the £10,400 rent, so the formula takes the rent. + LHA_allowed_bedrooms: 2 + LHA_category: C + # The renter is blind (para 15(1)(a), (2)(a)): no contribution for anyone. + uc_non_dep_deductions_renter_exempt: true + uc_non_dep_deductions: 0 + # The rent less no contribution: 10,400.00. + uc_housing_costs_element: 10_400.00 + # Single rate aged 25 or over: 424.90 x 12 = 5,098.80. Under reg 3(3) + # the claimant claims as a single person. + uc_standard_allowance: 5_098.80 + # 5,098.80 + 10,400.00 = 15,498.80, with no income to taper. + universal_credit: 15_498.80 + # 15,498.80 + 100 (Household Energy Payment) - 90 (the TV Licence, which the model halves + # for a household with a blind member) = 15,508.80. + household_net_income: 15_508.80 + +- name: UC Sch 4 para 15, a joint claim where the blind partner is a joint renter is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 43 + is_blind: true + is_claimant_or_partner: true + uc_is_ineligible_partner: false + son: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + family: + members: [claimant, partner, son] + households: + home: + members: [claimant, partner, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_single_or_joint_claimant: [true, true, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false, true] + # The claimant, the partner who shares her room, and the son, a + # non-dependant who is not a child (Sch 4 para 10(1)(a), (c)): 2 + # bedrooms, category C, whose £208.85 weekly rate (£10,860.20 a year) + # exceeds the £10,400 rent, so the formula takes the rent. + LHA_allowed_bedrooms: 2 + LHA_category: C + # Both members claim jointly, so the blind partner is a joint renter + # (para 15(1)(b), (2)(a)). + uc_non_dep_deductions_renter_exempt: true + uc_non_dep_deductions: 0 + # The rent less no contribution: 10,400.00. + uc_housing_costs_element: 10_400.00 + # Couple rate, both aged 25 or over: 666.97 x 12 = 8,003.64. + uc_standard_allowance: 8_003.64 + # 8,003.64 + 10,400.00 = 18,403.64, with no income to taper. + universal_credit: 18_403.64 + # 18,403.64 + 100 (Household Energy Payment) - 90 (the TV Licence, which the model halves + # for a household with a blind member) = 18,413.64. + household_net_income: 18_413.64 + +- name: UC Sch 4 paras 13 and 15, nobody blind, the son's contribution applies + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 43 + is_claimant_or_partner: true + # Reg 3(3)(e): subject to immigration control, so the claimant + # claims as a single person. + uc_is_ineligible_partner: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + family: + members: [claimant, partner, son] + households: + home: + members: [claimant, partner, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_single_or_joint_claimant: [true, false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false, true] + # The claimant, the partner who shares her room, and the son, a + # non-dependant who is not a child (Sch 4 para 10(1)(a), (c)): 2 + # bedrooms, category C, whose £208.85 weekly rate (£10,860.20 a year) + # exceeds the £10,400 rent, so the formula takes the rent. + LHA_allowed_bedrooms: 2 + LHA_category: C + # No claimant meets para 15(2). + uc_non_dep_deductions_renter_exempt: false + # The son, aged 22, owes 96.55 x 12 = 1,158.60 (para 13). + uc_non_dep_deductions: 1_158.60 + # 10,400.00 - 1,158.60 = 9,241.40. + uc_housing_costs_element: 9_241.40 + # Single rate aged 25 or over: 424.90 x 12 = 5,098.80. Under reg 3(3) + # the claimant claims as a single person. + uc_standard_allowance: 5_098.80 + # 5,098.80 + 9,241.40 = 14,340.20, with no income to taper. + universal_credit: 14_340.20 + # 14,340.20 + 100 (Household Energy Payment) - 180 (the TV Licence) = 14,260.20. + household_net_income: 14_260.20 + +- name: UC Sch 4 para 15(2)(c), a single claimant on armed forces independence payment is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + armed_forces_independence_payment: 5000 + attendance_allowance: 0 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 43 + is_claimant_or_partner: true + # Reg 3(3)(e): subject to immigration control, so the claimant + # claims as a single person. + uc_is_ineligible_partner: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + family: + members: [claimant, partner, son] + households: + home: + members: [claimant, partner, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_single_or_joint_claimant: [true, false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false, true] + # The claimant, the partner who shares her room, and the son, a + # non-dependant who is not a child (Sch 4 para 10(1)(a), (c)): 2 + # bedrooms, category C, whose £208.85 weekly rate (£10,860.20 a year) + # exceeds the £10,400 rent, so the formula takes the rent. + LHA_allowed_bedrooms: 2 + LHA_category: C + # Reg 2's attendance allowance includes armed forces independence payment + # (head (f)), so the claimant is in receipt of attendance allowance + # (para 15(2)(c)) without Attendance Allowance itself. + uc_non_dep_deductions_renter_exempt: true + uc_non_dep_deductions: 0 + # The rent less no contribution: 10,400.00. + uc_housing_costs_element: 10_400.00 + # Single rate aged 25 or over: 424.90 x 12 = 5,098.80. Under reg 3(3) + # the claimant claims as a single person. + uc_standard_allowance: 5_098.80 + # 5,098.80 + 10,400.00 = 15,498.80, with no income to taper; + # the formula takes no armed forces independence payment as unearned income. + universal_credit: 15_498.80 + # 15,498.80 + 100 (Household Energy Payment) - 180 (the TV Licence) = 15,418.80. + # household_benefits lists afcs but not armed_forces_independence_payment, + # so household net income leaves out the £5,000 payment. + household_net_income: 15_418.80 + +- name: UC Sch 4 para 15(2)(c), armed forces independence payment to a partner whom reg 3(3) excludes brings no exemption + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 45 + is_household_head: true + is_claimant_or_partner: true + partner: + age: 43 + armed_forces_independence_payment: 5000 + attendance_allowance: 0 + is_claimant_or_partner: true + # Reg 3(3)(e): subject to immigration control, so the claimant + # claims as a single person. + uc_is_ineligible_partner: true + son: + age: 22 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + family: + members: [claimant, partner, son] + households: + home: + members: [claimant, partner, son] + rent: 10_400 + tenure_type: RENT_PRIVATELY + brma: MAIDSTONE + output: + is_uc_single_or_joint_claimant: [true, false, false] + is_benefit_unit_non_dependant_for_universal_credit: [false, false, true] + # The claimant, the partner who shares her room, and the son, a + # non-dependant who is not a child (Sch 4 para 10(1)(a), (c)): 2 + # bedrooms, category C, whose £208.85 weekly rate (£10,860.20 a year) + # exceeds the £10,400 rent, so the formula takes the rent. + LHA_allowed_bedrooms: 2 + LHA_category: C + # The partner who receives it is no claimant, so para 15 gives no exemption. + uc_non_dep_deductions_renter_exempt: false + # The son, aged 22, owes 96.55 x 12 = 1,158.60 (para 13). + uc_non_dep_deductions: 1_158.60 + # 10,400.00 - 1,158.60 = 9,241.40. + uc_housing_costs_element: 9_241.40 + # Single rate aged 25 or over: 424.90 x 12 = 5,098.80. Under reg 3(3) + # the claimant claims as a single person. + uc_standard_allowance: 5_098.80 + # 5,098.80 + 9,241.40 = 14,340.20, with no income to taper; + # the formula takes no armed forces independence payment as unearned income. + universal_credit: 14_340.20 + # 14,340.20 + 100 (Household Energy Payment) - 180 (the TV Licence) = 14,260.20. + # household_benefits lists afcs but not armed_forces_independence_payment, + # so household net income leaves out the £5,000 payment. + household_net_income: 14_260.20 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 e753affdcf..5ba8c49947 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 @@ -411,7 +411,6 @@ - taxable_dividend_income - taxable_property_income - received_allowances_earned_income - - marriage_allowance - taxable_pension_income age: 40 employment_income: 20_000 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml index efa082474e..c1b2ab85ae 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/is_UC_eligible.yaml @@ -297,7 +297,9 @@ members: person corporate_wealth: 50_000 output: - uc_assessable_capital: 50_000 + # Market value less 10% for the expenses of sale (UC Regs 2013 + # reg. 49(1)(a)). + uc_assessable_capital: 45_000 is_uc_eligible: false - name: Main residence value is excluded from Universal Credit capital diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py index fe82f6aa46..bfc742c641 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/test_uc_capital_allocation.py @@ -1,8 +1,16 @@ -"""The financial proxy conserves residual capital after financial observations.""" +"""The financial proxy conserves residual capital after financial observations. + +Each unreported unit's share of the residual is its own amount, rounded to the +nearest penny with half a penny rounded up (UC Regs 2013 reg. 6(1)), so the +shares add up to the residual to within half a penny per unit: 1.00 shared +three ways is 0.33 each. +""" + +from decimal import ROUND_HALF_UP, Decimal import numpy as np import pytest -from hypothesis import HealthCheck, given, settings, strategies as st +from hypothesis import HealthCheck, example, given, settings, strategies as st from policyengine_core.simulations import Simulation from policyengine_uk.system import system @@ -29,6 +37,10 @@ def household_capital_situations(draw): max_size=3, ) ) + return build_situation(household_specs) + + +def build_situation(household_specs): people, benunits, households, expected = {}, {}, {}, [] for household_index, (capital, unit_specs) in enumerate(household_specs): household_members = [] @@ -56,7 +68,7 @@ def household_capital_situations(draw): if has_reported: reported_values[len(benunits)] = reported_capital else: - unreported_indices.append(len(benunits)) + unreported_indices.append((len(benunits), claimant_count)) benunits[unit_id] = { "members": members, "benunit_reported_capital": { @@ -78,6 +90,10 @@ def household_capital_situations(draw): return {"people": people, "benunits": benunits, "households": households}, expected +def nearest_penny(amount): + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + # Test conservation independently of machine-dependent generation timings. @settings( max_examples=30, @@ -85,6 +101,8 @@ def household_capital_situations(draw): suppress_health_check=[HealthCheck.too_slow], ) @given(household_capital_situations()) +# 1.00 of savings shared by three single claimants is 0.33 each, 0.99 in all. +@example(build_situation([(1, [(1, 0, False, 0)] * 3)])) def test_unreported_capital_shares_sum_to_each_households_residual(case): situation, expected = case # Reuse baseline rules; each example still gets fresh people and inputs. @@ -92,7 +110,16 @@ def test_unreported_capital_shares_sum_to_each_households_residual(case): assessed_capital = simulation.calculate("uc_assessable_capital", 2025) assert np.isfinite(assessed_capital).all() - for unreported_indices, reported_values, residual in expected: - assert assessed_capital[unreported_indices].sum() == pytest.approx(residual) + for unreported_units, reported_values, residual in expected: + claimants = sum(count for _, count in unreported_units) + for index, count in unreported_units: + share = Decimal(residual) * count / claimants + assert assessed_capital[index] == pytest.approx( + nearest_penny(share), abs=0.001 + ) + indices = [index for index, _ in unreported_units] + assert assessed_capital[indices].sum() == pytest.approx( + residual, abs=0.005 * len(indices) + 0.001 + ) for index, reported_capital in reported_values.items(): assert assessed_capital[index] == pytest.approx(reported_capital) diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml index 618ee78696..e2919f21fd 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_assessable_capital.yaml @@ -2,6 +2,9 @@ # UC Regulations 2013 reg.18(2) includes a single claimant's partner's capital. # https://www.legislation.gov.uk/ukpga/2012/5/section/5 # https://www.legislation.gov.uk/uksi/2013/376/regulation/18 +# Property expectations deduct 10% sale expenses, then secured debt, under +# https://www.legislation.gov.uk/uksi/2013/376/regulation/49 +# Capital above the limit fails https://www.legislation.gov.uk/uksi/2013/376/regulation/18 # Allocating unreported household capital by claimant/partner count is a # PolicyEngine convention, not a statutory allocation rule. @@ -163,3 +166,263 @@ household_uc_unreported_claimants: 0 # No claimant weights means no allocation: guarded zero instead of 0/0. uc_assessable_capital: 0 + +- name: Universal Credit values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + uc_assessable_capital: 9_000 + +- name: Universal Credit preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + uc_assessable_capital: [5_400, 10_800] + +- name: Universal Credit reported capital overrides overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + reporting: + members: first + uc_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The complete override consumes its property share; it is not redistributed. + uc_assessable_capital: [4_000, 10_800] + +- name: Universal Credit values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + uc_assessable_capital: 9_000 + +- name: Universal Credit preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + uc_assessable_capital: [5_400, 10_800] + +- name: Universal Credit reported capital overrides unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 40 + second: + age: 40 + partner: + age: 40 + benunits: + reporting: + members: first + uc_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The complete override consumes its property share; it is not redistributed. + uc_assessable_capital: [4_000, 10_800] + +- name: Universal Credit deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + uc_assessable_capital: 28_000 + +- name: Universal Credit floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + uc_assessable_capital: 0 + +- name: A financial report retains overseas and domestic property capital + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + benunit_reported_capital: 0 + overseas_property_value: 18_000 + other_residential_property_value: 10_000 + output: + # Regulation 49: 90% of both property stocks, independent of financial reports. + uc_property_capital: 25_200 + uc_assessable_capital: 25_200 + +- name: A financial report retains unclassified other and domestic property capital + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + benunit_reported_capital: 0 + other_property_value: 18_000 + non_residential_property_value: 10_000 + output: + # Regulation 49: 90% of both property stocks, independent of financial reports. + uc_property_capital: 25_200 + uc_assessable_capital: 25_200 + +- name: Overseas property exceeds the UC capital limit without a financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + output: + # UC regulation 49(1)-(2): market value less 10% sale expenses, no debt. + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A zero financial report preserves overseas property above the UC capital limit + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A complete UC override replaces overseas property and the financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + uc_reported_capital: 4_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 4_000 + is_uc_eligible: true + +- name: Unclassified other property exceeds the UC capital limit without a financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A zero financial report preserves unclassified other property above the UC capital limit + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + benunit_reported_capital: 0 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 18_000 + is_uc_eligible: false + +- name: A complete UC override replaces unclassified other property and the financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + other_property_value: 20_000 + benunit_reported_capital: 0 + uc_reported_capital: 4_000 + output: + uc_property_capital: 18_000 + uc_assessable_capital: 4_000 + is_uc_eligible: true + +- name: A financial report adds to all property with sale expenses and secured debt applied once + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + savings: 50_000 + benunit_reported_capital: 2_000 + overseas_property_value: 20_000 + other_property_value: 10_000 + other_residential_property_value: 20_000 + other_residential_property_secured_debt: 8_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 30_000 + output: + # 18,000 + 9,000 + (18,000 - 8,000) + max(0, 27,000 - 30,000). + uc_property_capital: 37_000 + uc_assessable_capital: 39_000 + is_uc_eligible: false diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml new file mode 100644 index 0000000000..8ede79769f --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_esa_income_abolition.yaml @@ -0,0 +1,175 @@ +# The Secretary of State appointed 1 July 2026 for the abolition of the +# remaining old style income-related ESA awards in Great Britain (SI +# 2025/1148 art. 3A(1)), apart from awards saved for an appointee (art. +# 3A(2)). The model pays each claim for the share of the fiscal year before +# that date (esa_income_payable_share, from gov.dwp.ESA.income.awards_payable): +# 1 up to 2025-26, 86/365 = 0.2356 in 2026-27 and 0 from 2027-28, or 1 for a +# saved claim. Nobody is entitled to a claim that pays nothing, so a young +# person's own income-related ESA report excludes them from the UC qualifying +# young person test (SI 2014/1230 reg. 28) only while the share exceeds 0. +# +# The first four cases use one household: a parent aged 45 and a 17-year-old on a +# non-advanced course in the parent's benefit unit, North East, owned +# outright, no income. The parent claims Universal Credit as a single +# claimant over 25 (standard_allowance SINGLE_OLD: £424.90 a month in +# 2026-27, £443.2099 projected for 2027-28) with the first child higher +# amount for a qualifying young person (£364.1952 projected for 2027-28): +# 2027 with the child element: 12 x (443.2099 + 364.1952) = 9,688.86 +# 2027 without the child element: 12 x 443.2099 = 5,318.52 +# 2026 without the child element: 12 x 424.90 = 5,098.80 + +- name: A young person's own income-related ESA report after the claim stops paying leaves the child element. + # The claim pays nothing in 2027-28, so nobody is entitled to it. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 0] + is_qualifying_young_person_for_universal_credit: [false, true] + universal_credit: 9_688.86 + +- name: A young person's own income-related ESA report saved for an appointee removes the child element. + # An award saved for an appointee stays payable (SI 2025/1148 art. 3A(2)). + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + esa_income_appointee_saving: true + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 1] + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_318.52 + +- name: A young person's own income-related ESA report in the year the claim stops paying removes the child element. + # The claim pays for 86 of the 365 days of 2026-27 in Great Britain. + period: 2026 + absolute_error_margin: 0.0001 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0.2356, 0.2356] + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own income-related ESA report after the claim stops paying leaves the child element while their legacy awards stay open. + # The young person also reports Pension Credit, so the model's migration + # proxy leaves their legacy awards open (person_legacy_benefits_closed is + # false), but the claim still pays nothing in 2027-28 + # (esa_income_payable_share 0) and nobody is entitled to it. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_payable_share: [0, 0] + person_legacy_benefits_closed: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + universal_credit: 9_688.86 + +- name: An 18-year-old claimant's own income-related ESA report after the claim stops paying leaves them receiving education while their legacy awards stay open. + # A single 18-year-old on a full-time non-advanced course with no + # maintenance funding reports their own income-related ESA and Pension + # Credit, and claims Universal Credit. The Pension Credit report leaves + # their legacy awards open (person_legacy_benefits_closed false), but the + # ESA claim pays nothing in 2027-28 (esa_income_payable_share 0), so on the + # first day of their Universal Credit award they aren't entitled to it + # either. They count as a qualifying young person for the education + # condition and receive education (UC Regs 2013 reg. 12(1A)), with no + # exception, so they get no Universal Credit. + period: 2027 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + is_claimant_or_partner: true + esa_income_reported: 3_000 + pension_credit_reported: 1_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + region: NORTH_EAST + output: + esa_income_payable_share: [0] + person_legacy_benefits_closed: [false] + is_qualifying_young_person_for_uc_education: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml new file mode 100644 index 0000000000..8966b13925 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_existing_benefits.yaml @@ -0,0 +1,448 @@ +# Under the Universal Credit (Transitional Provisions) Regulations 2014 +# reg. 28, DWP doesn't count a person entitled to an existing benefit (reg. +# 2(1)) as a UC qualifying young person: income-based JSA, income-related +# ESA, Income Support, Housing Benefit, Child Tax Credit and Working Tax +# Credit. The predicate reads the person's own reports and, for a claimant or +# partner, the couple's joint tax credit award (Tax Credits Act 2002 s.3(3)). +# It reads a report only in a year the model pays that benefit, only until +# the model's legacy closure, or for a claimant or partner the family's +# Universal Credit claim, ends the award on it, and only if no reform +# neutralises it. The model pays Income Support and income-based +# JSA until 31 March 2026 and tax credits until 5 April 2025, has no +# closure date for Housing Benefit, and pays an income-related ESA claim +# only while it stays payable (esa_income_payable_share). It also ends a +# person's legacy awards when its annual migration proxy closes them, and +# the predicate follows that closure +# (uc_qualifying_young_person_legacy_closure.yaml covers it). In these cases +# a young person's own report closes their legacy awards only where that +# benefit's year gate has already closed, so the closure decides no case. +# The predicate follows the family's Universal Credit claim only for a +# claimant's or partner's own award +# (uc_qualifying_young_person_universal_credit_claim.yaml covers it), so +# the cases that test a claimant's own Child Tax Credit or Housing Benefit +# report claim no Universal Credit (would_claim_uc). The first six cases, +# and the four after the seventh, share one household: a parent aged 45 and +# a 17-year-old on a non-advanced course, North East, owned outright, no +# income. + +- name: A parent's young person who reports their own Income Support attracts no child element. + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_801.68 + +- name: A parent's young person who reports their own Housing Benefit attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own income-related ESA attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A parent's young person who reports their own income-based JSA attracts no child element. + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_801.68 + +- name: A parent's young person who reports no award attracts the child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A parent's young person flagged as receiving benefits in their own right attracts no child element. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + receives_benefits_in_own_right: true + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: An 18-year-old claimant on a non-advanced course who reports Housing Benefit is not a qualifying young person. + # The claimant doesn't claim Universal Credit, so no claim ends their own + # Housing Benefit (uc_qualifying_young_person_universal_credit_claim.yaml + # covers a claim). They get no Universal Credit. + period: 2026 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false] + is_child_or_qualifying_young_person_for_universal_credit: [false] + uc_child_element: 0 + universal_credit: 0 + +- name: A young person's own Income Support report after Income Support stops paying leaves the child element. + # Income Support stops paying on 1 April 2026 (gov.dwp.income_support.active), so nobody is entitled to it in 2026. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-based JSA report after income-based JSA stops paying leaves the child element. + # Income-based JSA stops paying on 1 April 2026 (gov.dwp.JSA.income.active). + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own Child Tax Credit report while tax credits pay removes the child element. + period: 2024 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_721.40 + +- name: A young person's own Child Tax Credit report after tax credits stop paying leaves the child element. + # Tax credits stop paying on 6 April 2025 (gov.dwp.tax_credits.active). + period: 2025 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [false, true] + is_child_or_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Child Tax Credit or Working Tax Credit report excludes them. + # Each person forms their own benefit unit as its claimant, and none + # claims Universal Credit, so no claim ends their Child Tax Credit. + period: 2024 + input: + age: [17, 17, 17] + current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] + child_tax_credit_reported: [0.01, 0, 0] + working_tax_credit_reported: [0, 0.01, 0] + would_claim_uc: [false, false, false] + output: + is_qualifying_young_person_for_universal_credit: [false, false, true] + +- name: A parent's own reports of every existing benefit leave their young person a qualifying young person. + period: 2026 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + housing_benefit_reported: 3_000 + child_tax_credit_reported: 3_000 + working_tax_credit_reported: 3_000 + esa_income_reported: 3_000 + jsa_income_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_person] + output: + is_qualifying_young_person_for_universal_credit: [false, true] + +- name: A couple's Working Tax Credit award reported by one partner excludes both 18-year-old partners. + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + working_tax_credit_reported: 2_000 + partner: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + benunits: + benunit: + members: [claimant, partner] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: A couple's Child Tax Credit award does not exclude a dependant who reports none. + # The couple doesn't claim Universal Credit, so no claim ends its award. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 2_000 + partner: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, young_person] + would_claim_uc: false + output: + is_qualifying_young_person_for_universal_credit: [false, false, true] + +- name: A partner's own Housing Benefit report leaves an 18-year-old claimant a qualifying young person. + # The partner reports their own Housing Benefit, and the formula reads + # only tax credits across the couple. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + partner: + age: 30 + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + output: + is_qualifying_young_person_for_universal_credit: [true, false] + +- name: A dependant's own Child Tax Credit award leaves an 18-year-old claimant a qualifying young person. + # Tax Credits Act 2002 s.3(3)(b): the dependant claims as a single person, + # so their award is not the claimant's. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: UPPER_SECONDARY + is_claimant_or_partner: true + young_person: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [claimant, young_person] + output: + is_qualifying_young_person_for_universal_credit: [true, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml new file mode 100644 index 0000000000..450b158b04 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_legacy_closure.yaml @@ -0,0 +1,432 @@ +# The model ends a person's legacy awards once its annual migration proxy +# closes them, and the UC qualifying young person test (SI 2014/1230 reg. 28) +# follows that closure: nobody is entitled to an award the model has ended. +# The model ends a person's own Income Support, income-based JSA and +# income-related ESA when person_legacy_benefits_closed holds +# (income_support, jsa_income, esa_income_report_closed), and the family's +# Housing Benefit and tax credits when legacy_benefits_closed holds +# (housing_benefit_eligible, is_CTC_eligible, is_WTC_eligible). A +# dependant's own Income Support or income-based JSA report from 2026, or own +# tax credit report from 2025, closes the dependant's legacy awards +# (gov.dwp.income_support.active, gov.dwp.JSA.income.active and +# gov.dwp.tax_credits.active turn false on 1 April 2026, 1 April 2026 and +# 6 April 2025). The predicate excludes nobody on those reports, as each +# benefit's year gate has closed. +# +# Cases 1 to 6 and 10 use one household: a parent aged 45 and a +# 17-year-old on a non-advanced course in the parent's benefit unit, North +# East, owned outright, no income. Cases 7, 8 and 11 use the same family renting +# from the council at £6,000 a year and claiming no Universal Credit, so only +# the closure can end its Housing Benefit. Case 9 has a parent aged 70. In +# every case but 7, 8 and 11 the parent claims Universal Credit as a single +# claimant over 25 (universal_credit/standard_allowance/amount.yaml +# SINGLE_OLD: £400.14 a month in 2025-26, £424.90 in 2026-27) and gets the +# first child higher amount for a qualifying young person +# (elements/child/first/higher_amount.yaml: £339.00 in 2025-26, £351.88 in +# 2026-27). So: +# 2025 with the child element: 12 x (400.14 + 339.00) = 8,869.68 +# 2026 with the child element: 12 x (424.90 + 351.88) = 9,321.36 +# 2026 without the child element: 12 x 424.90 = 5,098.80 + +- name: A young person's own income-related ESA report alone removes the child element in 2026. + # No closure applies: the young person reports no closed benefit, and the + # claim pays for 86 of the 365 days of 2026-27 (esa_income_payable_share + # 0.2356), so the model pays 3,000 x 86 / 365 = 706.85 of income-related + # ESA (esa_income) and the young person is entitled to it. UC: 12 x 424.90 + # = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, false] + esa_income: 706.85 + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 5_098.80 + +- name: A young person's own income-related ESA report with their own Income Support report in 2026 leaves the child element. + # Income Support closed on 1 April 2026, so the young person's own report + # closes their legacy awards (person_legacy_benefits_closed) and the model + # pays no income-related ESA on their report (esa_income 0). Neither report + # excludes them. UC: 12 x (424.90 + 351.88) = 9,321.36; child element + # 12 x 351.88 = 4,222.56. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + income_support_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-related ESA report with their own income-based JSA report in 2026 leaves the child element. + # Income-based JSA closed on 1 April 2026, so the young person's own report + # closes their legacy awards and the model pays no income-related ESA on + # their report. UC: 12 x (424.90 + 351.88) = 9,321.36. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + jsa_income_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 9_321.36 + +- name: A young person's own income-related ESA report with their own Child Tax Credit report in 2025 leaves the child element. + # Tax credits closed on 6 April 2025, so the young person's own Child Tax + # Credit report closes their legacy awards and the model pays no + # income-related ESA on their report. UC: 12 x (400.14 + 339.00) = 8,869.68; + # child element 12 x 339.00 = 4,068.00. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + esa_income_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + esa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Income Support report with their own Child Tax Credit report in 2025 leaves the child element. + # The model pays Income Support in 2025, though never on a dependant's own + # report. The young person's own Child Tax Credit report closes their + # legacy awards (person_legacy_benefits_closed), and the predicate then + # drops their Income Support report too. UC: 12 x (400.14 + 339.00) = + # 8,869.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own income-based JSA report with their own Child Tax Credit report in 2025 leaves the child element. + # Income-based JSA still pays in 2025, but the young person's own Child Tax + # Credit report closes their legacy awards, so the model pays no + # income-based JSA on their report (jsa_income 0). UC: 8,869.68 as above. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + jsa_income_reported: 3_000 + child_tax_credit_reported: 1_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + jsa_income: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 8_869.68 + +- name: A young person's own Housing Benefit report after the parent's legacy awards close leaves the child element. + # The family rents from the council at £6,000 a year and claims no + # Universal Credit, so without the parent's report the model continues its + # Housing Benefit award at the full rent (6,000, no income). The parent's + # own Income Support report from 2026 closes the family's legacy awards + # (legacy_benefits_closed), and the family doesn't meet the Pension Credit + # age conditions, so the model ends that award (housing_benefit_eligible), + # the young person's report included, and the predicate drops the report. + # The young person meets reg. 5, so the family's UC child element comes to + # 12 x 351.88 = 4,222.56, though the family claims no Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + legacy_benefits_closed: true + claims_universal_credit: false + housing_benefit_eligible: false + housing_benefit: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_222.56 + universal_credit: 0 + +- name: A young person's own Housing Benefit report after the parent's tax credits close leaves the child element. + # The same renting family in 2025, which claims no Universal Credit. The + # parent's own Child Tax Credit report from 2025, after tax credits closed, + # closes the family's legacy awards, so the model ends its Housing Benefit + # award (6,000 without the report) and the predicate drops the young + # person's report. UC child element: 12 x 339.00 = 4,068. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + legacy_benefits_closed: true + claims_universal_credit: false + housing_benefit_eligible: false + housing_benefit: 0 + is_qualifying_young_person_for_universal_credit: [false, true] + uc_child_element: 4_068 + universal_credit: 0 + +- name: A young person's own Housing Benefit report still excludes them where the family meets the Pension Credit age conditions. + # A parent aged 70 reports their own Working Tax Credit in 2025, after tax + # credits closed, which closes the family's legacy awards. The family meets + # the Pension Credit age conditions, so the model keeps its Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to it + # (left_pension_route_at_legacy_closure). This family does, so the model + # pays it no Housing Benefit. The predicate can't read + # left_pension_route_at_legacy_closure, whose is_uc_entitled reads the + # predicate through the child element, so it keeps the Housing Benefit + # report. The young person is a dependant, so the family's claim doesn't + # end their own award either. UC: 12 x 400.14 = 4,801.68. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 70 + is_parent: true + is_claimant_or_partner: true + working_tax_credit_reported: 1_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + legacy_benefits_closed: true + meets_pension_credit_age_conditions: true + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 4_801.68 + +- name: A young person's own Income Support report from 2026 doesn't lift their Housing Benefit exclusion. + # The young person's own Income Support report from 2026, after Income + # Support closed, closes their own legacy awards (person_legacy_benefits_closed), + # but not the family's: legacy_benefits_closed reads only the claimant's + # and partner's reports. The model ends Housing Benefit on the family's + # closure, so the young person's Housing Benefit report still excludes + # them. UC: 12 x 424.90 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + income_support_reported: 3_000 + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + person_legacy_benefits_closed: [false, true] + legacy_benefits_closed: false + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own Housing Benefit report in temporary accommodation still excludes them after the parent's legacy awards close. + # The family of case 7 in temporary accommodation. The parent's own Income + # Support report closes the family's legacy awards, but the move off legacy + # benefits doesn't end a Housing Benefit award for specified or temporary + # accommodation (SI 2014/1230 reg. 46(2)). The model keeps the award + # (housing_benefit_eligible) and the predicate keeps the young person's + # report, so the family gets no child element. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + in_specified_or_temporary_accommodation: true + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + legacy_benefits_closed: true + claims_universal_credit: false + housing_benefit_eligible: true + is_qualifying_young_person_for_universal_credit: [false, false] + uc_child_element: 0 + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml new file mode 100644 index 0000000000..f348fa21c5 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/uc_qualifying_young_person_universal_credit_claim.yaml @@ -0,0 +1,427 @@ +# A claim for Universal Credit ends the claimant's own tax credits, Income +# Support and Housing Benefit, and either joint claimant's tax credits (SI +# 2014/1230 reg. 8(2) and (2A)), and the claimant's and partner's +# income-related ESA (SI 2013/983 art. 4). It ends nobody else's award. The +# model ends the family's continuing Housing Benefit award +# (housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible), and +# the claimant's and partner's income-related ESA (esa_income_report_closed), +# when the family claims (claims_universal_credit). Nobody is entitled to an +# award the claim has ended, so for a claimant or partner the UC qualifying +# young person test (SI 2014/1230 reg. 28) follows those three limbs. For a +# dependant it doesn't: a parent's claim leaves the dependant's own award. +# The model ends no Working Tax Credit, Income Support or income-based JSA +# award on the claim, so the predicate follows no claim limb for them. +# +# would_claim_uc decides the claim here. A case without a claim also sets +# would_claim_uc_at_legacy_closure false, so the family claims nothing when +# DWP moves it off its legacy benefits either. The reg. 5 variable +# reads universal_credit_reported and receives_benefits_in_own_right for reg. +# 5(5), not a calculated Universal Credit award, and these cases leave both +# at their defaults, so a claimant who claims Universal Credit still meets +# reg. 5. +# +# Rates (universal_credit/standard_allowance/amount.yaml, elements/child/): +# SINGLE_YOUNG £311.68 a month in 2024-25 and £338.58 in 2026-27; +# COUPLE_OLD £617.60 in 2024-25; the child amount for a child born after +# 5 April 2017 £287.92 in 2024-25 (elements/child/amount.yaml). +# Child Tax Credit child element £3,455 a year in 2024-25 +# (tax_credits/child_tax_credit/elements/child_element.yaml). + +- name: An 18-year-old claimant's own income-related ESA report leaves them a qualifying young person once they claim Universal Credit. + # The claimant claims Universal Credit, which ends their own income-related + # ESA (SI 2013/983 art. 4; esa_income_report_closed), so the model pays no + # income-related ESA on their report and they aren't entitled to it. UC: 12 + # x 338.58 = 4,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_report_closed: [true] + esa_income: 0 + is_qualifying_young_person_under_universal_credit_regulation_5: [true] + is_qualifying_young_person_for_universal_credit: [true] + universal_credit: 4_062.96 + +- name: An 18-year-old claimant's own income-related ESA report excludes them while they don't claim Universal Credit. + # Nothing ends the award: the claimant doesn't claim Universal Credit and + # reports no closed benefit, and the claim pays for 86 of the 365 days of + # 2026-27 (esa_income_payable_share 0.2356), so the model pays 3,000 x 86 / + # 365 = 706.85 of income-related ESA on their report and they're entitled + # to it. They get no Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + esa_income_report_closed: [false] + esa_income: 706.85 + is_qualifying_young_person_for_universal_credit: [false] + universal_credit: 0 + +- name: An 18-year-old claimant's own Housing Benefit report leaves them a qualifying young person once they claim Universal Credit. + # The claimant rents from the council at £6,000 a year and claims Universal + # Credit, which ends their own Housing Benefit (SI 2014/1230 reg. 8(2A); + # housing_benefit_eligible), so the model pays no Housing Benefit on their + # report. UC: 12 x 338.58 + 6,000 housing element = 10,062.96. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: true + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_eligible: false + housing_benefit: 0 + is_qualifying_young_person_for_universal_credit: [true] + universal_credit: 10_062.96 + +- name: An 18-year-old claimant's own Housing Benefit report excludes them while they don't claim Universal Credit. + # The claimant rents from the council at £6,000 a year, has no income and + # doesn't claim Universal Credit, so the model continues their Housing + # Benefit award at the full rent (6,000) and they're entitled to it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_eligible: true + housing_benefit: 6_000 + is_qualifying_young_person_for_universal_credit: [false] + universal_credit: 0 + +- name: An 18-year-old lone parent's own Child Tax Credit report leaves them a qualifying young person once they claim Universal Credit. + # The claimant claims Universal Credit, which ends their tax credits (SI + # 2014/1230 reg. 8(2); is_CTC_eligible), so the model pays no Child Tax + # Credit. UC: 12 x (311.68 + 287.92) = 7,195.20. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: true + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: false + child_tax_credit: 0 + is_qualifying_young_person_for_universal_credit: [true, false] + universal_credit: 7_195.20 + +- name: An 18-year-old lone parent's own Child Tax Credit report excludes them while they don't claim Universal Credit. + # Tax credits pay in 2024 and the claimant doesn't claim Universal Credit, + # so the model pays Child Tax Credit for the child born after 5 April 2017: + # the child element of 3,455, with no family element and no income to taper + # it. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: true + child_tax_credit: 3_455 + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 0 + +- name: An 18-year-old claimant whose partner reports the couple's Child Tax Credit stays a qualifying young person once the couple claims Universal Credit. + # The members of a couple claim tax credits jointly (Tax Credits Act 2002 + # s.3(3)), so the partner's report gives the claimant the couple's award. + # The couple's claim for Universal Credit ends a joint claimant's tax + # credits (SI 2014/1230 reg. 8(2); is_CTC_eligible), so nobody is entitled + # to the award. UC: 12 x (617.60 + 287.92) = 10,866.24. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + partner: + age: 45 + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: true + households: + household: + members: [claimant, partner, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: false + child_tax_credit: 0 + is_qualifying_young_person_for_universal_credit: [true, false, false] + universal_credit: 10_866.24 + +- name: An 18-year-old claimant whose partner reports the couple's Child Tax Credit isn't a qualifying young person while the couple doesn't claim Universal Credit. + # Tax credits pay in 2024 and the couple doesn't claim Universal Credit, so + # the model pays the couple's Child Tax Credit (child element 3,455) and the + # claimant shares the joint award. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + partner: + age: 45 + is_claimant_or_partner: true + child_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, partner, child] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [claimant, partner, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_CTC_eligible: true + child_tax_credit: 3_455 + is_qualifying_young_person_for_universal_credit: [false, false, false] + universal_credit: 0 + +- name: An 18-year-old lone parent's own Working Tax Credit report excludes them though they claim Universal Credit. + # Tax credits pay in 2024. The model ends Working Tax Credit when the + # family's legacy awards close, but not on its Universal Credit claim + # (is_WTC_eligible reads legacy_benefits_closed, not + # claims_universal_credit). The claimant works 16 hours a week and looks + # after a child, so they meet the model's lone parent hours test (16 hours + # from age 16), and the model keeps their award beside the claim. The + # predicate follows no claim limb for Working Tax Credit, so the + # claimant's own report still excludes them. + period: 2024 + input: + people: + claimant: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + weekly_hours: 16 + working_tax_credit_reported: 1_000 + child: + age: 1 + is_claimant_or_partner: false + benunits: + benunit: + members: [claimant, child] + would_claim_uc: true + households: + household: + members: [claimant, child] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: true + is_WTC_eligible: true + is_qualifying_young_person_under_universal_credit_regulation_5: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: An 18-year-old partner's own Housing Benefit report excludes them while their mixed-age couple keeps the Pension Credit route. + # A claimant aged 75 who reports Pension Credit and an 18-year-old partner + # on a non-advanced course rent from the council at £6,000 a year and claim + # Universal Credit. The couple keeps the mixed-age Pension Credit saving + # (has_mixed_age_couple_pension_credit_saving), so it meets the Pension + # Credit age conditions. The model keeps such a family's Housing Benefit + # while it claims, unless it claims at a legacy closure and is entitled to + # Universal Credit (left_pension_route_at_legacy_closure), and nothing + # closes this family's legacy awards. So the model pays the full rent + # (6,000, no income) as Housing Benefit, the partner stays entitled to it, + # and their own report excludes them. The predicate can't read + # left_pension_route_at_legacy_closure, so it keeps any Housing Benefit + # report while the family meets those conditions. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 75 + is_claimant_or_partner: true + pension_credit_reported: 1_000 + partner: + age: 18 + current_education: POST_SECONDARY + is_claimant_or_partner: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, partner] + would_claim_uc: true + households: + household: + members: [claimant, partner] + region: NORTH_EAST + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + meets_pension_credit_age_conditions: true + claims_universal_credit: true + housing_benefit_eligible: true + housing_benefit: 6_000 + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: A parent's claim for Universal Credit leaves their young person's own Housing Benefit report excluding them. + # A parent aged 45 and a 17-year-old on a non-advanced course, owned + # outright. The parent's claim ends the parent's own awards, not the young + # person's (SI 2014/1230 reg. 8(2A) names the claimant's award), so the + # young person's own report still excludes them. UC: 12 x 424.90 = 5,098.80 + # (SINGLE_OLD, 2026-27). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_claimant_or_partner: true + is_parent: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: true + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: true + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 5_098.80 + +- name: A young person's own Housing Benefit report excludes them while their parent doesn't claim Universal Credit. + # The same household without a claim: the young person's own report excludes + # them as with the claim, and the parent gets no Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_claimant_or_partner: true + is_parent: true + young_person: + age: 17 + current_education: POST_SECONDARY + is_claimant_or_partner: false + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [parent, young_person] + would_claim_uc: false + would_claim_uc_at_legacy_closure: false + households: + household: + members: [parent, young_person] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + claims_universal_credit: false + is_qualifying_young_person_for_universal_credit: [false, false] + universal_credit: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml new file mode 100644 index 0000000000..9b410fe5db --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_income_support.yaml @@ -0,0 +1,235 @@ +# Income Support in a young person's own right ends qualifying young person +# status for Pension Credit (SPC Regs 2002 reg. 4A(5)), Child Benefit (Child +# Benefit (General) Regulations 2006 reg. 8(2)(a)) and Child Tax Credit (CTC +# Regs 2002 reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) +# names Universal Credit, Employment and Support Allowance and Jobseeker's +# Allowance only, but the Secretary of State also excludes a person entitled to +# an existing benefit, Income Support among them (SI 2014/1230 regs. 2(1) and +# 28), so the report ends Universal Credit status too while Income Support pays. +# +# Pension Credit and Child Tax Credit read is_on_income_support: the benefit +# unit's calculated award, for whichever of the claimant and partner receives +# it, and the person's own report for any other member. Child Benefit reads +# receives_income_support_in_own_right, the person's own report, because the +# family's Income Support reads the Child Benefit test. +# +# Income Support stopped paying on 31 March 2026 +# (gov/dwp/income_support/active.yaml), so reported receipt counts to 2025-26. +# 2025-26 amounts: Child Benefit eldest child £26.05 a week; Pension Credit +# first-child addition £78.10 a week. CTC child element £3,455 in 2024-25; the +# element needs an existing claim that a Universal Credit claim has not ended +# (is_CTC_eligible), so the CTC cases give the parent a reported award and the +# family no Universal Credit claim. + +- name: Case 1, a 17-year-old at school with no Income Support is every programme's qualifying young person. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + # 78.10 * 52. + child_minimum_guarantee_addition: 4_061.20 + # 26.05 * 52. + child_benefit_entitlement: 1_354.60 + +- name: Case 2, reported Income Support of their own ends it for Pension Credit, Child Benefit and Universal Credit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + child_minimum_guarantee_addition: 0 + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + +- name: Case 3, the same for Child Tax Credit in 2024-25. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + is_on_income_support: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, false] + CTC_child_element: 0 + +- name: Case 4, without it the Child Tax Credit child element is paid. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + parent: + child_tax_credit_reported: 1 + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + output: + is_qualifying_young_person_for_child_tax_credit: [false, true] + CTC_child_element: 3_455 + +- name: Case 5, it ends the 16-year-old's status too, which has no education condition. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, school_leaver] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: Case 6, a report of Income Support after it stopped paying does not count. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, true] + +- name: Case 7, the Child Benefit input can be set directly; it does not decide Pension Credit or Child Tax Credit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + receives_income_support_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [grandparent, student] + output: + # No Income Support reported or calculated. + is_on_income_support: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + is_qualifying_young_person_for_child_tax_credit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, true] + +# Case 8, 2019-20 amounts: standard minimum guarantee £167.25 a week; severe +# disability addition £65.85 a week; PIP daily living standard rate £58.70 a +# week; Income Support personal allowance for a single person aged 18 to 24 +# £57.90 a week; winter fuel payment £200; colour TV licence £154.50. + +- name: Case 8, a student-carer on Income Support of their own is not the pensioner's qualifying young person, so the pensioner gets no severe disability addition. + period: 2019 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + pip_dl_category: STANDARD + state_pension: 5_000 + student: + # An orphan in full-time non-advanced education with an existing + # Income Support claim (IS Regs 1987 reg. 13(2)(c)), caring for the + # pensioner 35 hours a week without Carer's Allowance. + age: 18 + current_education: UPPER_SECONDARY + care_hours: 35 + would_claim_carers_allowance: false + income_support_reported: 3_000 + benunits: + pensioner_unit: + members: [pensioner] + would_claim_uc: false + student_unit: + members: [student] + would_claim_uc: false + households: + household: + members: [pensioner, student] + # DWP first paid cost-of-living payments in 2022. + cost_of_living_support_payment: 0 + output: + # The student claims Income Support for their own benefit unit: no + # income, so the award is the personal allowance, 57.90 x 52 = 3,010.80. + income_support: [0, 3_010.80] + is_on_income_support: [false, true] + # Reg. 4A(5): not a qualifying young person, so not disregarded under + # Schedule I para. 2(2)(f), and the pensioner does not live alone. + is_qualifying_young_person_for_pension_credit: [false, false] + severe_disability_minimum_guarantee_addition: [0, 0] + # 167.25 x 52 = 8,697, less the State Pension 5,000. + pension_credit: [3_697, 0] + # State Pension 5,000 + PIP 58.70 x 52 = 3,052.40 + Pension Credit 3,697 + # + Income Support 3,010.80 + winter fuel payment 200, less TV licence + # 154.50: 14,805.70. The cost-of-living payment is set to zero. + household_net_income: 14_805.70 diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml index 3fd69d83e7..2ca48da78e 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_reported_own_awards.yaml @@ -1,9 +1,22 @@ -# UC reg. 5(5) and SPC reg. 4A(5) exclude own awards from both QYP routes. -# The reports identify the recipient whether receives_benefits_in_own_right -# is omitted or explicitly false. SPC reg. 4A(5) also lists income support, -# which UC reg. 5(5) does not: is_on_income_support reads it, from the -# calculated award for a claimant or partner and from their own report for -# anyone else. +# The Secretary of State excludes a person receiving Universal Credit, +# contributory ESA or contribution-based JSA (UC reg. 5(5)), and defines ESA +# and JSA without their income-related and income-based forms (UC Regs reg. +# 2). DWP doesn't count a person entitled to income-related ESA, income-based +# JSA or Income Support as a UC qualifying young person (SI 2014/1230 reg. +# 28). The UC predicate reads a report only in a year the model pays that +# benefit (income-based JSA and Income Support until 31 March 2026), reads an +# income-related ESA, income-based JSA or Income Support report only until +# the person's legacy awards close (person_legacy_benefits_closed), and +# reads none that a reform neutralises. The model also ends a claimant's or +# partner's own income-related ESA when the family claims Universal Credit +# (esa_income_report_closed), so the cases that test a claimant's own +# income-related ESA report claim no Universal Credit (would_claim_uc). The +# Secretary of State excludes own awards +# from the Pension Credit routes, including income support (SPC reg. 4A(5)): +# is_on_income_support reads it, from the calculated award for a claimant or +# partner and from their own report for anyone else. The reports identify the +# recipient whether receives_benefits_in_own_right is omitted or explicitly +# false. - name: A 16-year-old reporting income-related ESA does not attract child additions for a parent aged 40. period: 2026 @@ -41,7 +54,8 @@ child_minimum_guarantee_addition: 0 - name: A 16-year-old reporting income-based JSA does not attract child additions for a parent aged 40. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: people: parent: @@ -216,7 +230,8 @@ child_minimum_guarantee_addition: 0 - name: A 16-year-old reporting income-based JSA does not attract child additions for a parent aged 70. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + period: 2025 input: people: parent: @@ -356,8 +371,12 @@ child_minimum_guarantee_addition: 0 - name: A reported award also excludes the education route. - period: 2026 + # Income-based JSA pays until 31 March 2026 (gov.dwp.JSA.income.active). + # Each person forms their own benefit unit as its claimant, and none claims + # Universal Credit, so no claim ends their income-related ESA. + period: 2025 input: + would_claim_uc: [false, false, false, false, false] age: [18, 18, 18, 18, 18] current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] universal_credit_reported: [5_000, 0, 0, 0, 0] @@ -370,8 +389,12 @@ is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] - name: An explicit false own-right flag does not erase a reported own ESA award. + # The person claims no Universal Credit, now or when DWP moves them off + # their legacy benefits, so no claim ends their award. period: 2026 input: + would_claim_uc: false + would_claim_uc_at_legacy_closure: false age: 16 current_education: NOT_IN_EDUCATION receives_benefits_in_own_right: false @@ -428,9 +451,10 @@ is_qualifying_young_person_for_pension_credit: false is_qualifying_young_person_for_child_benefit: true -- name: A 16-year-old reporting their own Income Support does not attract the Pension Credit child addition, but stays a UC young person. - # SPC reg. 4A(5) lists income support; UC reg. 5(5) does not. Income - # Support is in payment in 2025 (gov.dwp.income_support.active). +- name: A 16-year-old reporting their own Income Support counts as neither a Pension Credit nor a UC young person. + # SPC reg. 4A(5) lists income support; for UC, TP reg. 28 excludes a + # person entitled to it. Income Support is in payment in 2025 + # (gov.dwp.income_support.active). period: 2025 input: people: @@ -459,9 +483,9 @@ is_qualifying_young_person_for_pension_credit: [false, false] is_child_or_qualifying_young_person_for_pension_credit: [false, false] child_minimum_guarantee_addition: 0 - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] -- name: A member's own reported Income Support excludes the Pension Credit education route only. +- name: A member's own reported Income Support excludes the Pension Credit and UC education routes. period: 2025 input: people: @@ -484,11 +508,12 @@ output: is_on_income_support: [false, true] is_qualifying_young_person_for_pension_credit: [false, false] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] -- name: A member's Income Support report leaves the Pension Credit route available once Income Support stops paying. +- name: A member's Income Support report leaves the Pension Credit and UC routes available once Income Support stops paying. # Income Support stops paying in 2026 (gov.dwp.income_support.active), so a - # report then is no award in payment. + # report then is no award in payment for Pension Credit, and nobody is + # entitled to it for TP reg. 28. period: 2026 input: people: @@ -513,7 +538,7 @@ is_qualifying_young_person_for_pension_credit: [false, true] is_qualifying_young_person_for_universal_credit: [false, true] -- name: A claimant's calculated Income Support excludes them from the Pension Credit status. +- name: A claimant's calculated Income Support excludes them from the Pension Credit and UC statuses. period: 2025 input: age: 17 @@ -524,9 +549,9 @@ income_support: 3_790.80 is_on_income_support: true is_qualifying_young_person_for_pension_credit: false - is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_universal_credit: false -- name: A claimant's report without a calculated award leaves the Pension Credit status available. +- name: A claimant's report without a calculated award leaves the Pension Credit status available but not the UC one. # The claimant falls within no prescribed category, so the model calculates # no Income Support for them. period: 2025 @@ -538,7 +563,7 @@ income_support: 0 is_on_income_support: false is_qualifying_young_person_for_pension_credit: true - is_qualifying_young_person_for_universal_credit: true + is_qualifying_young_person_for_universal_credit: false - name: The partner of an Income Support claimant keeps the Pension Credit status. # A couple's award goes to the claimant: the partner who reports nothing is @@ -569,8 +594,11 @@ is_qualifying_young_person_for_pension_credit: [false, true] - name: A report of one penny of a listed award other than Income Support excludes the young person. + # Each person forms their own benefit unit as its claimant, and none claims + # Universal Credit, so no claim ends their income-related ESA. period: 2025 input: + would_claim_uc: [false, false, false, false, false] age: [16, 16, 16, 16, 16] current_education: [NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION, NOT_IN_EDUCATION] universal_credit_reported: [0.01, 0, 0, 0, 0] @@ -582,7 +610,7 @@ is_qualifying_young_person_for_universal_credit: [false, false, false, false, false] is_qualifying_young_person_for_pension_credit: [false, false, false, false, false] -- name: A member's report of one penny of Income Support excludes them from the Pension Credit status alone. +- name: A member's report of one penny of Income Support excludes them from the Pension Credit and UC statuses. period: 2025 input: people: @@ -605,4 +633,4 @@ output: is_on_income_support: [false, true] is_qualifying_young_person_for_pension_credit: [false, false] - is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml new file mode 100644 index 0000000000..2d0a30beb2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/qualifying_young_person_terminal_date.yaml @@ -0,0 +1,379 @@ +# A young person in non-advanced education or approved training is a +# qualifying young person "up to, but not including, the 1st September +# following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 +# reg. 4A(1)(b)). The model places that date from the date of birth and +# counts a year that starts (6 April) before it, so every 19-year-old counts +# unless an input says the date has passed. +# +# 2026-27 amounts: Universal Credit child element for a first child born +# before 6 April 2017, £351.88 a month +# (gov/dwp/universal_credit/elements/child/first/higher_amount.yaml); +# Pension Credit first-child addition, £81.07 a week +# (gov/dwp/pension_credit/guarantee_credit/child/first/addition.yaml). + +- name: Case 1, a 19-year-old at school is a Universal Credit and Pension Credit qualifying young person by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + # 351.88 * 12. + uc_individual_child_element: [0, 4_222.56] + is_uc_work_allowance_eligible: true + +- name: Case 2, the Universal Credit input ends Universal Credit status only. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + 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: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, true] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 3, the date fact set false ends both. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The parent, not set, takes the default, true: harmless, since every + # reader is behind the qualifying young person age limit. + is_before_universal_credit_qualifying_young_person_terminal_date: [true, false] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + +- name: Case 4, a pensioner gets the Pension Credit child addition for a 19-year-old at school. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, true] + # 81.07 * 52. + child_minimum_guarantee_addition: 4_215.64 + +- name: Case 5, no Pension Credit child addition once the 1 September after the 19th birthday has passed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 + +- name: Case 6, born 31 August 2007, the period ends on the 19th birthday, in the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + date_of_birth: 20070831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, true] + +- name: Case 7, born 15 September 2006, before the date until 31 August 2026 but aged 20 on 6 October. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060915 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 8, born 31 August 2006, the 1 September after the 19th birthday was in 2025. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060831 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + +- name: Case 9, a course begun at 19 does not qualify, whatever the date (reg. 5(2); reg. 4A(2)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 19 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 10, a 19-year-old outside education is not a qualifying young person, whatever the date. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + young_adult: + age: 19 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, young_adult] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 11, born 1 September 2006, the 1 September following the 19th birthday is in 2026, not the birthday itself. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 20 + date_of_birth: 20060901 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + # The 19th birthday is 1 September 2025; the 1 September that follows it + # is in 2026, inside the year. + is_before_first_september_following_19th_birthday: [false, true] + # The age limit, read on 6 October, excludes them. + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + +- name: Case 12, setting the date fact for one 19-year-old leaves another's status as the formula gives it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + first: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_first_september_following_19th_birthday: false + is_claimant_or_partner: false + second: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, first, second] + output: + # The second takes the default, true, which is the formula's value for + # anyone under 20 on 6 October. + is_before_first_september_following_19th_birthday: [true, false, true] + is_qualifying_young_person_for_universal_credit: [false, false, true] + is_qualifying_young_person_for_pension_credit: [false, false, true] + +- name: Case 13, a 19-year-old at school before the date who reports their own Universal Credit is no qualifying young person. + # UC reg. 5(5) and SPC reg. 4A(5) both list Universal Credit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + universal_credit_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_before_universal_credit_qualifying_young_person_terminal_date: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + uc_individual_child_element: [0, 0] + is_uc_work_allowance_eligible: false + +- name: Case 14, a pensioner gets no Pension Credit child addition for a 19-year-old at school before the date who reports their own income-related ESA. + # SPC reg. 4A(5) and UC reg. 5(5) both list employment and support allowance. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + esa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + # The pensioner, aged 70, is neither under 19 nor before the date. + meets_qualifying_young_person_terminal_date_condition_for_pension_credit: [false, true] + is_qualifying_young_person_for_pension_credit: [false, false] + is_qualifying_young_person_for_universal_credit: [false, false] + child_minimum_guarantee_addition: 0 + +- name: Case 15, a pensioner gets no Pension Credit child addition for a 19-year-old at school before the date who reports their own Income Support. + # SPC reg. 4A(5) lists income support. UC reg. 5(5) does not, but SI + # 2014/1230 reg. 28 excludes a person entitled to it. The model + # pays Income Support until 2026-04-01 (gov.dwp.income_support.active), so + # the case runs in 2025-26. The student is not a claimant or partner, so + # is_on_income_support reads their own report. Without it, the first-child + # addition would be £78.10 a week (2025-04-01) x 52 = £4,061.20. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + is_claimant_or_partner: true + student: + age: 19 + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + income_support_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [pensioner, student] + output: + is_before_first_september_following_19th_birthday: [false, true] + is_on_income_support: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_qualifying_young_person_for_pension_credit: [false, false] + child_minimum_guarantee_addition: 0 diff --git a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml index 973b2c42b2..75b461d549 100644 --- a/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/tax/income_tax/scottish_rates.yaml @@ -127,3 +127,572 @@ earned_taxable_income: 130000 earned_income_tax: 50034.35 income_tax: 50034.35 + +# tax_band for a Scottish taxpayer with savings or dividends (#2350). +# Parliament charges the Scottish rates only on income that is neither +# savings nor dividends (ITA 2007 s. 11A(1A), (4) and (5), and s. 13; +# S6M-16531 for 2025-26). +# Savings and dividends form the top slice (s. 16) and take the rates and +# bands they would bear outside Scotland (ss. 11D(6) and 13(5)). The model +# returns the higher band. In 2025-26 Parliament sets the personal allowance +# at £12,570, the basic rate limit at £37,700, the dividend allowance at £500 +# (8.75% ordinary, 33.75% upper) and the Personal Savings Allowance at +# £1,000, or £500 with higher-rate income (s. 12B). The Scottish Parliament +# sets the Scottish limits on taxable income at £2,827, £14,921, £31,092, +# £62,430 and £125,140 (19%, 20%, 21%, 42%, 45%, 48%). The Marriage +# Allowance test applies the rate conditions of s. 55B(2)(b) and the +# dividend counterfactual of s. 55B(2)(ba). Savings at the savings nil rate +# do not bar the Marriage Allowance. + +- name: Scottish 2025 pay of 40000 and dividends of 5000 reaches the intermediate band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 27,430 taxable, intermediate. 2,827 x 19% + 12,094 x 20% + # + 12,509 x 21% = 537.13 + 2,418.80 + 2,626.89 = 5,582.82. + # Dividends: 27,430 to 32,430, below 37,700: (5,000 - 500) x 8.75%. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_582.82 + dividend_income_tax: 393.75 + +- name: Scottish 2025 pay of 40000 and savings of 5000 reaches the intermediate band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay as above. Savings: 27,430 to 32,430, below 37,700, so the £1,000 + # allowance and (5,000 - 1,000) x 20% = 800. The model assigns no savings + # to the starting rate because other taxable income exceeds £5,000 + # (s. 12). + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_582.82 + savings_income_tax: 800 + +- name: England 2025 pay of 40000 and dividends of 5000 reaches the basic band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: ENGLAND + output: + # 32,430 of taxable income, below 37,700. 27,430 x 20% = 5,486. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_486 + dividend_income_tax: 393.75 + +- name: Scottish 2025 pay of 60000 and dividends of 20000 reaches the higher band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 60_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 47,430 taxable, higher, not advanced. 537.13 + 2,418.80 + # + 16,171 x 21% + 16,338 x 42% = 537.13 + 2,418.80 + 3,395.91 + # + 6,861.96 = 13,213.80. Dividends: 47,430 to 67,430, all above + # 37,700 and below 125,140: (20,000 - 500) x 33.75% = 6,581.25. No + # income reaches the advanced, top or dividend additional rate. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 13_213.80 + dividend_income_tax: 6_581.25 + +- name: Scottish 2025 pay of 60000 and savings of 20000 reaches the higher band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 60_000 + savings_interest_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay as above. Savings: 47,430 to 67,430, all at the higher rate with + # a £500 allowance: 19,500 x 40% = 7,800. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 13_213.80 + savings_income_tax: 7_800 + +- name: England 2025 pay of 60000 and dividends of 20000 reaches the higher band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 60_000 + dividend_income: 20_000 + households: + household: + members: [person] + country: ENGLAND + output: + # 37,700 x 20% + 9,730 x 40% = 7,540 + 3,892 = 11,432. + tax_band: HIGHER + earned_income_tax: 11_432 + dividend_income_tax: 6_581.25 + +- name: Scottish 2025 pay of 30000 and dividends of 25000 stays higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 30_000 + dividend_income: 25_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 17,430 taxable, intermediate. 537.13 + 2,418.80 + 2,509 x 21% + # = 3,482.82. Dividends: 17,430 to 42,430. 20,270 below 37,700, the + # first 500 at nil: 19,770 x 8.75% = 1,729.875; 4,730 above it at the + # upper rate: 4,730 x 33.75% = 1,596.375. Total 3,326.25. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 3_482.82 + dividend_income_tax: 3_326.25 + +- name: Scottish 2025 pay of 30000 and savings of 25000 stays higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 30_000 + savings_interest_income: 25_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Savings: 17,430 to 42,430 with a £500 allowance. 19,770 x 20% + # + 4,730 x 40% = 3,954 + 1,892 = 5,846. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 3_482.82 + savings_income_tax: 5_846 + +- name: Scottish 2025 income at both limits stays intermediate + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_608 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 31,092 taxable, exactly the intermediate rate's limit, so none + # at 42%: 537.13 + 2,418.80 + 16,171 x 21% = 6,351.84. Dividends: + # 31,092 to 37,700, exactly the basic rate limit, so all at the + # ordinary rate: (6,608 - 500) x 8.75% = 534.45. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 6_351.84 + dividend_income_tax: 534.45 + +- name: Scottish 2025 dividends one pound above the basic rate limit reach the higher band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_609 + households: + household: + members: [person] + country: SCOTLAND + output: + # As above, with £1 of dividends above 37,700 at the upper rate: + # 534.45 + 1 x 33.75% = 534.7875. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 6_351.84 + dividend_income_tax: 534.79 + +- name: Scottish 2025 Gift Aid raises the Scottish and basic rate limits + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 44_462 + dividend_income: 5_000 + gift_aid: 800 + households: + household: + members: [person] + country: SCOTLAND + output: + # The gift grosses up to 1,000, which raises the Scottish limits from + # the basic rate's upper limit and the basic rate limit (s. 414(2)(b)). + # Pay: 31,892 taxable, below 31,092 + 1,000. 537.13 + 13,094 x 20% + # + 15,971 x 21% = 537.13 + 2,618.80 + 3,353.91 = 6,509.84. + # Dividends: 31,892 to 36,892, below 38,700: 4,500 x 8.75% = 393.75. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 6_509.84 + dividend_income_tax: 393.75 + +- name: Scottish 2025 allowance beyond pay goes to dividends, which reach the basic band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 5_000 + dividend_income: 30_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # The allowance takes all 5,000 of pay first, then 7,570 of dividends, + # leaving 22,430 of dividends below 37,700 and nothing on the Scottish + # rates: (22,430 - 500) x 8.75% = 1,918.875. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + earned_income_tax: 0 + dividend_income_tax: 1_918.88 + +- name: Scottish 2025 allowance beyond pay goes to savings, which reach the basic band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 5_000 + savings_interest_income: 30_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # 22,430 of savings remain, with no other taxable income: 5,000 at the + # starting rate for savings (s. 12), 1,000 of allowance, then + # 16,430 x 20% = 3,286. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + earned_income_tax: 0 + savings_income_tax: 3_286 + +- name: Scottish 2025 starter-rate pay without savings or dividends reaches the starter band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 14_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # 1,430 taxable, all at 19% = 271.70. The person has no savings or + # dividends, so the basic rate limit does not apply. + tax_band: STARTER + earned_income_tax: 271.70 + +- name: Scottish 2025 pay equal to the personal allowance has no band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_570 + households: + household: + members: [person] + country: SCOTLAND + output: + tax_band: NONE + earned_income_tax: 0 + +- name: Scottish 2018-19 pay of 40000 and dividends of 5000 reaches the intermediate band + period: 2018 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # gov.scot, Scottish income tax 2018-2019: starter to £13,850, basic to + # £24,000, intermediate to £43,430 of income with an £11,850 allowance. + # Pay: 28,150 taxable, intermediate. 2,000 x 19% + 10,150 x 20% + # + 16,000 x 21% = 380 + 2,030 + 3,360 = 5,770. Dividends: 28,150 to + # 33,150, below the 34,500 basic rate limit, with a £2,000 allowance: + # 3,000 x 7.5% = 225. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_770 + dividend_income_tax: 225 + +- name: Scottish 2017-18 pay above 43000 reaches the higher band + period: 2017 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 44_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # gov.scot, Income tax rates set (2017): the Scottish higher rate starts + # at £43,000 of income, £31,500 above the £11,500 allowance. 32,500 + # taxable: 31,500 x 20% + 1,000 x 40% = 6,700. In 2017-18 Parliament + # allows the Scottish basic rate but not the Scottish higher rate + # (s. 55B(2)(b)). + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + earned_income_tax: 6_700 + +# The same rules in 2026-27, with the Scottish limits of S6M-20844: £3,967, +# £16,956, £31,092, £62,430 and £125,140 of taxable income. The personal +# allowance stays £12,570 and the basic rate limit £37,700. + +- name: Scottish 2026 pay of 40000 and dividends of 5000 reaches the intermediate band + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 27,430 taxable, intermediate. 3,967 x 19% + 12,989 x 20% + # + 10,474 x 21% = 753.73 + 2,597.80 + 2,199.54 = 5,551.07. + # Dividends: 27,430 to 32,430, below 37,700. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + earned_income_tax: 5_551.07 + +- name: Scottish 2026 pay of 60000 and savings of 20000 reaches the higher band + period: 2026 + input: + people: + person: + age: 40 + employment_income: 60_000 + savings_interest_income: 20_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 47,430 taxable, higher, below 62,430. Savings: 47,430 to 67,430, + # above 37,700 and below 125,140. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Scottish 2026 income at both limits stays intermediate + period: 2026 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_608 + households: + household: + members: [person] + country: SCOTLAND + output: + # Pay: 31,092 taxable, the intermediate rate's limit. Dividends: 31,092 + # to 37,700, the basic rate limit. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + +- name: Scottish 2026 dividends one pound above the basic rate limit reach the higher band + period: 2026 + input: + people: + person: + age: 40 + employment_income: 43_662 + dividend_income: 6_609 + households: + household: + members: [person] + country: SCOTLAND + output: + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + +- name: Scottish 2026 allowance beyond pay goes to dividends, which reach the basic band + period: 2026 + input: + people: + person: + age: 40 + employment_income: 5_000 + dividend_income: 30_000 + households: + household: + members: [person] + country: SCOTLAND + output: + # After allowances, the taxpayer has 22,430 of taxable dividends below + # 37,700 and pays no tax at Scottish rates. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + +# Boundaries the cases above leave open, 2025-26. + +- name: Scottish 2025 dividends at the Gift Aid limit stay basic + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + dividend_income: 50_271 + gift_aid: 0.8 + households: + household: + members: [person] + country: SCOTLAND + output: + # The 80p gift grosses up to 1 (s. 415) and raises the basic rate limit + # to 37,701 (s. 414(2)(b)). The allowance takes 12,570 of the dividends, + # which leaves 37,701, all within that limit: + # (37,701 - 500) x 8.75% = 3,255.0875. + tax_band: BASIC + meets_marriage_allowance_income_conditions: true + dividend_income_tax: 3_255.09 + +- name: Scottish 2025 savings at the higher rate limit stay higher + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + savings_interest_income: 125_140 + households: + household: + members: [person] + country: SCOTLAND + output: + # At income of 125,140 Parliament withdraws the whole personal allowance + # (s. 35), so the person pays tax on all 125,140, up to the 125,140 + # higher rate limit and not above it. With no other income, 5,000 bears + # the starting rate for savings (s. 12) and the higher-rate allowance + # covers 500 (s. 12B); the rest of the 37,700 bears 20% and the 87,440 + # above it 40%: + # 32,200 x 20% + 87,440 x 40% = 6,440 + 34,976 = 41,416. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + savings_income_tax: 41_416 + +- name: Scottish 2025 property income at the higher rate limit stays intermediate + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_570 + property_income: 31_092 + households: + household: + members: [person] + country: SCOTLAND + output: + # The model reads property_income as profit and, with no receipts given, + # takes a profit above the 1,000 property allowance to reflect the better + # of expenses and the allowance already, so it taxes all 31,092. Pay + # takes the personal allowance. Property income bears the Scottish rates + # (s. 11A): 2,827 x 19% + 12,094 x 20% + 16,171 x 21% = 537.13 + # + 2,418.80 + 3,395.91 = 6,351.84, with none above 31,092. + tax_band: INTERMEDIATE + meets_marriage_allowance_income_conditions: true + property_income_tax: 6_351.84 + +- name: Scottish 2025 property income one pound above the higher rate limit reaches the higher band + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + employment_income: 12_570 + property_income: 31_093 + households: + household: + members: [person] + country: SCOTLAND + output: + # As above with 1 at the Scottish higher rate: 6,351.84 + 0.42. + tax_band: HIGHER + meets_marriage_allowance_income_conditions: false + property_income_tax: 6_352.26 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_reduction_applied.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_reduction_applied.yaml index 49d6f3bb29..1daaf665f1 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_reduction_applied.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_reduction_applied.yaml @@ -13,7 +13,9 @@ # already limits the Housing Benefit reduction to the award less the reg 75 # minimum (50 pence a week). # -# The award before the cap and the capped total are entered. Annual limits +# Each case supplies the award before the cap and the capped total. With +# the total, each supplies the Housing Benefit in it, which Universal +# Credit's cap leaves out (UC reg 80(2A)). Annual limits # outside London (UC reg 80A(2)(b)(ii), HB reg 75CA(2)(b)(ii)): couple # 22,020; single without children 14,753. @@ -34,6 +36,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 100 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 households: household: @@ -63,6 +67,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 7_980 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 households: household: @@ -90,6 +96,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 10_000 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 households: household: @@ -117,6 +125,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 100 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 uc_childcare_element: 3_000 households: @@ -146,6 +156,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 100 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 uc_childcare_element: 9_000 households: @@ -174,6 +186,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 100 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 uc_deduction_rate: 0.25 households: @@ -206,6 +220,8 @@ benunit: members: [claimant, partner] universal_credit_pre_benefit_cap: 10_000 + # No rent, so the model calculates no Housing Benefit. + housing_benefit_pre_benefit_cap: 0 benefit_cap_welfare_benefits: 30_000 uc_deduction_rate: 0.25 households: 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 9327b3d862..75ad125eeb 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 @@ -821,13 +821,15 @@ members: [parent, sixteen] region: NORTH_EAST output: - is_child_or_young_person_for_legacy_benefits: [false, false] + # HB reg 19(1) requires a Child Benefit "qualifying young person". A + # 16-year-old who has left education is one until the 31 August after + # their 16th birthday (Child Benefit (General) Regulations 2006 reg. 4), + # so they are the family's young person for Housing Benefit too. + is_child_or_young_person_for_legacy_benefits: [false, true] # UC reg 5(1)(a): "up to, but not including, the 1st September following # their 16th birthday". Annual ages count the 16-year-old for the year. - # HB reg 19(1) requires a Child Benefit "qualifying young person"; - # this person is outside education and is not one in the model. is_uc_benefit_cap_exempt_specified_benefit: true - is_housing_benefit_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: true - name: A 19-year-old outside education is not a young person, so their DLA does not lift the cap period: 2025 @@ -1188,7 +1190,7 @@ 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 +- name: A 16-year-old out of education with armed forces independence payment lifts the Housing Benefit cap, not Universal Credit's period: 2025 input: people: @@ -1207,10 +1209,13 @@ members: [parent, sixteen] region: NORTH_EAST output: - # Not a Housing Benefit young person (not in education), and Universal - # Credit counts the payment for a claimant only (UC Regs 2013 reg 2). + # A Housing Benefit young person until the 31 August after the 16th + # birthday (Child Benefit (General) Regulations 2006 reg. 4), whose armed + # forces independence payment lifts the cap (HB Regs 2006 reg. 75F(1)). + # Universal Credit counts the payment for a claimant only (UC Regs 2013 + # reg 2). is_uc_benefit_cap_exempt_specified_benefit: false - is_housing_benefit_benefit_cap_exempt_specified_benefit: false + is_housing_benefit_benefit_cap_exempt_specified_benefit: true - name: A 16-year-old receiving benefits in their own right is not a young person, so their DLA does not lift the cap period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_welfare_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_welfare_benefits.yaml new file mode 100644 index 0000000000..61a1b2cfbb --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_welfare_benefits.yaml @@ -0,0 +1,61 @@ +# The welfare benefits the Universal Credit cap counts, with Housing Benefit +# at nil (UC Regs 2013 reg 80(2A)). + +- name: The Universal Credit cap leaves out Housing Benefit the family receives + period: 2025 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 30 + benunits: + family: + members: [adult] + universal_credit_pre_benefit_cap: 15_000 + housing_benefit_pre_benefit_cap: 10_000 + child_benefit: 5_000 + child_tax_credit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + uc_benefit_cap: 14_753 + uc_childcare_element: 0 + uc_deductions: 0 + households: + household: + members: [adult] + country: ENGLAND + output: + # The Housing Benefit cap counts all three awards (HB Regs 2006 reg + # 75C(2)(b)); the Universal Credit cap counts 15,000 + 5,000 = 20,000, + # 5,247 over the cap (reg 81). + benefit_cap_welfare_benefits: 30_000 + uc_benefit_cap_welfare_benefits: 20_000 + uc_benefit_cap_reduction: 5_247 + universal_credit: 9_753 + +- name: A supplied benefit cap total includes Housing Benefit, which the Universal Credit cap takes out + period: 2025 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 30 + benunits: + family: + members: [adult] + universal_credit_pre_benefit_cap: 20_000 + housing_benefit_pre_benefit_cap: 6_000 + benefit_cap_welfare_benefits: 26_000 + uc_benefit_cap: 14_753 + uc_childcare_element: 0 + uc_deductions: 0 + households: + household: + members: [adult] + country: ENGLAND + output: + # 26,000 - 6,000 = 20,000, 5,247 over the cap, so the award is 14,753. + uc_benefit_cap_welfare_benefits: 20_000 + uc_benefit_cap_reduction: 5_247 + universal_credit: 14_753 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 ddfdec6d7d..c6aa035b83 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 @@ -320,6 +320,7 @@ uc_benefit_cap: 22_020 uc_childcare_element: 9_000 universal_credit_pre_benefit_cap: 15_000 + housing_benefit_pre_benefit_cap: 0 output: # Excess 7,980 < childcare 9,000. uc_benefit_cap_reduction: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml new file mode 100644 index 0000000000..32701e2a25 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/capital_valuation.yaml @@ -0,0 +1,644 @@ +# Capital is valued at market value less 10% where a sale would incur +# expenses, and less any encumbrance secured on it: 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. A bank or building society account is +# valued at its balance (ADM H1604-H1607, H1675); land and property always have +# costs of sale (H1606) and shares are valued less 10% (H1665). +# Expected values are worked by hand from those provisions. + +- name: UC values a mortgaged holiday home as in ADM H1602 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 125,000 - 10% (12,500) - 100,500 mortgage = 12,000. + other_residential_property_value: 125_000 + other_residential_property_secured_debt: 100_500 + output: + uc_assessable_capital: 12_000 + is_uc_eligible: true + # 24 steps of 250 above 6,000 at 4.35 a month: 24 * 4.35 * 12. + uc_tariff_income: 1_252.8 + +- name: UC counts cash savings at face value + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_000 + output: + uc_assessable_capital: 15_000 + is_uc_eligible: true + +- name: UC shares valued less sale expenses bring capital within the limit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 5,000 + 90% of 12,000 = 15,800, within the 16,000 limit; at full + # value the household would hold 17,000. + savings: 5_000 + corporate_wealth: 12_000 + output: + 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 negative equity in a second home does not reduce savings + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The mortgage is deducted only from the property it is secured on + # (ADM H1615-H1616), so the property is valued at nil. + savings: 10_000 + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 150_000 + output: + uc_assessable_capital: 10_000 + is_uc_eligible: true + +- name: UC deducts sale expenses before the encumbrance on business premises + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # (90% of 40,000 - 20,000) + 90% of 5,000 = 16,000 + 4,500. + non_residential_property_value: 40_000 + non_residential_property_secured_debt: 20_000 + corporate_wealth: 5_000 + output: + uc_assessable_capital: 20_500 + is_uc_eligible: false + +- name: Housing Benefit values encumbered land less sale expenses + 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 + # 1,000 + (90% of 20,000 - 5,000) = 14,000 (HB Regs 2006 reg. 47). + savings: 1_000 + owned_land: 20_000 + owned_land_secured_debt: 5_000 + output: + housing_benefit_assessable_capital: 14_000 + # 32 steps of 250 above 6,000 at 1 a week: 32 * 52. + housing_benefit_tariff_income: 1_664 + housing_benefit_eligible: true + +- name: Income Support values encumbered land less sale expenses + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 20,000 - 5,000 = 13,000 (IS Regs 1987 reg. 49). + owned_land: 20_000 + owned_land_secured_debt: 5_000 + output: + income_support_assessable_capital: 13_000 + +- name: Income-based JSA values a mortgaged second home less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 2,000 + (90% of 30,000 - 25,000) = 4,000 (JSA Regs 1996 reg. 111). + savings: 2_000 + other_residential_property_value: 30_000 + other_residential_property_secured_debt: 25_000 + output: + jsa_income_assessable_capital: 4_000 + +- name: Income-related ESA values shares less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + benunits: + benunit: + members: person + households: + household: + members: person + # 3,000 + 90% of 10,000 = 12,000 (ESA Regs 2008 reg. 113). + savings: 3_000 + corporate_wealth: 10_000 + output: + esa_income_assessable_capital: 12_000 + +- name: Pension Credit values mortgaged business premises less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 2,000 + (90% of 30,000 - 10,000) = 19,000 (SPC Regs 2002 reg. 19). + savings: 2_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 10_000 + output: + pension_credit_assessable_capital: 19_000 + # 18 steps of 500 above 10,000 at 1 a week: 18 * 52. + pension_credit_deemed_income: 936 + +- name: UC floors each encumbered source at nil on its own + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The second home is in negative equity (90,000 - 150,000), so it is + # valued at nil; the business premises keep 90% of 10,000. Netting the + # debts across both would wrongly give nil. + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 150_000 + non_residential_property_value: 10_000 + output: + uc_assessable_capital: 9_000 + +- name: Housing Benefit values mortgaged second homes and premises less sale expenses + 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 + # 1,000 + (90% of 50,000 - 30,000) + max(0, 90% of 20,000 - 25,000) + # = 1,000 + 15,000 + 0 = 16,000, at the limit (HB Regs 2006 reg. 47). + savings: 1_000 + other_residential_property_value: 50_000 + other_residential_property_secured_debt: 30_000 + non_residential_property_value: 20_000 + non_residential_property_secured_debt: 25_000 + output: + housing_benefit_assessable_capital: 16_000 + # 40 steps of 250 above 6,000 at 1 a week: 40 * 52. + housing_benefit_tariff_income: 2_080 + housing_benefit_eligible: true + +- name: Pension-age Housing Benefit values encumbered land less sale expenses + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 200,000 - 50,000 = 130,000 (HB (SPC) Regs 2006 reg. 45). Its + # Pension Credit deemed income, 240 steps of 500 above 10,000 at 1 a + # week (12,480 a year), exceeds the 12,376 guarantee, so there is no + # guarantee credit to disregard the capital. + owned_land: 200_000 + owned_land_secured_debt: 50_000 + output: + pension_credit_assessable_capital: 130_000 + pension_credit_deemed_income: 12_480 + guarantee_credit: 0 + housing_benefit_assessable_capital: 130_000 + +- name: UC rounds the share valuation to the nearest penny, at the capital limit + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,666.67 is 15,000.003, rounded to the nearest penny (UC Regs + # 2013 reg. 6(1)): 15,000.00. With 1,000 of savings the capital is + # exactly the 16,000 limit, which does not exceed it (WRA 2012 s. + # 5(1)(a)). + savings: 1_000 + corporate_wealth: 16_666.67 + output: + uc_assessable_capital: 16_000 + is_uc_eligible: true + +- name: UC values property and shares less sale expenses to the penny + period: 2026 + absolute_error_margin: 0.005 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,001 is 14,400.90 and 90% of 1,000.10 is 900.09, exactly: + # 15,300.99. Float32 arithmetic would give 14,400.8994 and 900.0899. + other_residential_property_value: 16_001 + corporate_wealth: 1_000.10 + output: + uc_assessable_capital: 15_300.99 + is_uc_eligible: true + +- name: UC rounds half a penny up, so the capital is a penny over the limit + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # 90% of 16,666.65 is 14,999.985. UC Regs 2013 reg. 6(1) treats a + # fraction of half a penny or more as a penny: 14,999.99, so the capital + # is 1,000.02 + 14,999.99 = 16,000.01, over the 16,000 limit. Rounding + # down, as ADM H1665 does, would give exactly 16,000.00; the regulation + # governs the guidance. + savings: 1_000.02 + corporate_wealth: 16_666.65 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + +- name: Pension Credit values shares as in the ADM and DMG worked example + period: 2026 + absolute_error_margin: 0.001 + input: + people: + person: + age: 70 + benunits: + benunit: + members: person + households: + household: + members: person + # 250 shares at 4.1375 are 1,034.375 (ADM H1665; DMG 84763). Less 10% + # for the expenses of sale that is 930.9375, and the fraction of a + # penny is disregarded because counting it would not be to the + # claimant's advantage (SPC Regs 2002 reg. 24A): 930.93. + corporate_wealth: 1_034.375 + output: + pension_credit_assessable_capital: 930.93 + +- name: UC values the worked-example shares to the nearest penny + period: 2026 + absolute_error_margin: 0.001 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # The same 930.9375 is 930.94 to the nearest penny (UC Regs 2013 reg. + # 6(1)), where ADM H1665 gives 930.93. + corporate_wealth: 1_034.375 + output: + uc_assessable_capital: 930.94 + +- name: Income Support rounds a shared capital down to the penny + period: 2021 + absolute_error_margin: 0.001 + input: + people: + first: + age: 30 + is_claimant_or_partner: true + second: + age: 30 + is_claimant_or_partner: true + third: + age: 30 + is_claimant_or_partner: true + benunits: + first_unit: + members: [first] + would_claim_IS: false + second_unit: + members: [second] + would_claim_IS: false + third_unit: + members: [third] + would_claim_IS: false + households: + household: + members: [first, second, third] + # With no Income Support claim in the household, 10,000.01 of savings + # is shared by the three adults: 3,333.3366 each, rounded down at the + # last stage (IS Regs 1987 reg. 27): 3,333.33. + savings: 10_000.01 + output: + income_support_assessable_capital: [3_333.33, 3_333.33, 3_333.33] + +- name: Legacy benefits count 16,000.01 of savings in full, over the limit + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + benunits: + benunit: + members: person + households: + household: + members: person + # Stored in float32 as 16,000.0098, which is the penny 16,000.01: + # rounding down at the last stage (IS Regs 1987 reg. 27; JSA Regs 1996 + # reg. 92; ESA Regs 2008 reg. 3(a)) must not turn the storage error + # into a lost penny, which would put the capital at the 16,000 limit. + savings: 16_000.01 + output: + income_support_assessable_capital: 16_000.01 + jsa_income_assessable_capital: 16_000.01 + esa_income_assessable_capital: 16_000.01 + +- name: A Lifetime ISA counts at 75% of its balance to the penny + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + lifetime_isa_balance: 1_000.04 + benunits: + benunit: + members: person + households: + household: + members: person + output: + # 75% of 1,000.04 is 750.03, its surrender value below age 60. + lifetime_isa_countable_capital: 750.03 + income_support_assessable_capital: 750.03 + esa_income_assessable_capital: 750.03 + uc_assessable_capital: 750.03 + +- name: UC rounds a Lifetime ISA's half penny up, a penny over the limit + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # Below age 60 the surrender value is 75% of the balance: 0.195, half + # a penny, which float32 would store as 0.19499999. UC Regs 2013 reg. + # 6(1) treats it as a penny, so the capital is 15,999.81 + 0.20 = + # 16,000.01, over the 16,000 limit. The legacy benefits disregard it + # (IS Regs 1987 reg. 27; JSA Regs 1996 reg. 92; ESA Regs 2008 reg. + # 3(a)): 15,999.81 + 0.19 = 16,000.00. + lifetime_isa_balance: 0.26 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_999.81 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + income_support_assessable_capital: 16_000 + jsa_income_assessable_capital: 16_000 + esa_income_assessable_capital: 16_000 + +- name: Each partner's Lifetime ISA is valued to the penny on its own + period: 2026 + absolute_error_margin: 0.004 + input: + people: + first: + age: 30 + lifetime_isa_balance: 0.26 + second: + age: 30 + lifetime_isa_balance: 0.26 + benunits: + benunit: + members: [first, second] + households: + household: + members: [first, second] + # Each surrender value is 0.195, a calculation of its own: 0.20 to the + # nearest penny (UC Regs 2013 reg. 6(1)), so 15,999.62 + 0.40 = + # 16,000.02; 0.19 rounded down (IS Regs 1987 reg. 27; JSA Regs 1996 + # reg. 92; ESA Regs 2008 reg. 3(a)), so 15,999.62 + 0.38 = 16,000.00. + savings: 15_999.62 + output: + uc_assessable_capital: 16_000.02 + income_support_assessable_capital: 16_000 + jsa_income_assessable_capital: 16_000 + esa_income_assessable_capital: 16_000 + +- name: A Lifetime ISA value given as an input is counted as given + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # The balance's surrender value would be 0.195, but the value counted + # as capital is given directly, so every means test counts that 0.20: + # 15,999.81 + 0.20 = 16,000.01. + lifetime_isa_balance: 0.26 + lifetime_isa_countable_capital: 0.20 + benunits: + benunit: + members: person + households: + household: + members: person + savings: 15_999.81 + output: + uc_assessable_capital: 16_000.01 + is_uc_eligible: false + income_support_assessable_capital: 16_000.01 + jsa_income_assessable_capital: 16_000.01 + esa_income_assessable_capital: 16_000.01 + +- name: A Lifetime ISA input is preserved when its float32 value matches the formula + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 30 + # The formula gives 65,536.005. That and the explicit whole-penny + # value 65,536.01 both store as 65,536.0078125, so equality cannot + # tell a formula result from an input. Counting the input as given + # preserves 65,536.01 even under a programme that rounds down. + lifetime_isa_balance: 87_381.34 + lifetime_isa_countable_capital: 65_536.01 + benunits: + benunit: + members: person + housing_benefit_on_passporting_benefit: false + households: + household: + members: person + output: + uc_assessable_capital: 65_536.01 + housing_benefit_assessable_capital: 65_536.01 + income_support_assessable_capital: 65_536.01 + jsa_income_assessable_capital: 65_536.01 + esa_income_assessable_capital: 65_536.01 + +- name: Pension Credit preserves an explicit Lifetime ISA value + period: 2026 + absolute_error_margin: 0.004 + input: + people: + person: + age: 70 + lifetime_isa_balance: 0.26 + lifetime_isa_countable_capital: 0.20 + benunits: + benunit: + members: person + households: + household: + members: person + output: + pension_credit_assessable_capital: 0.20 + +- name: Every means test counts a cash ISA and savings at face value and a mortgaged second home less sale expenses and the mortgage + period: 2026 + absolute_error_margin: 0 + input: + people: + person: + age: 70 + cash_isa_balance: 4_000 + benunits: + benunit: + members: person + would_claim_uc: false + would_claim_IS: false + in_receipt_of_guarantee_credit: false + in_receipt_of_savings_credit_only: false + housing_benefit_on_passporting_benefit: false + households: + household: + members: person + savings: 3_000 + other_residential_property_value: 100_000 + other_residential_property_secured_debt: 60_000 + output: + # The formulas move the 4,000 cash ISA out of the household's cash_isa + # into its holder's own benefit unit at face value, count the 3,000 of + # savings at face value, and value the second home at 100,000 less 10% + # (10,000) less the 60,000 mortgage: 3,000 + 4,000 + 30,000 = 37,000. + cash_isa: 4_000 + cash_isa_countable_capital: 4_000 + uc_assessable_capital: 37_000 + housing_benefit_assessable_capital: 37_000 + income_support_assessable_capital: 37_000 + jsa_income_assessable_capital: 37_000 + esa_income_assessable_capital: 37_000 + pension_credit_assessable_capital: 37_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml new file mode 100644 index 0000000000..f79eee0ebf --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_cared_for_person.yaml @@ -0,0 +1,441 @@ +# Carer benefits for caring for a severely disabled person. +# SSCBA 1992 s.70(1): "A person shall be entitled to a carer’s allowance for +# any day on which he is engaged in caring for a severely disabled person". +# s.70(2) defines a "severely disabled person" by the benefit payable for them: +# attendance allowance, the disability living allowance care component at the +# highest or middle rate, the personal independence payment daily living +# component, armed forces independence payment, the Scottish disability +# payments it lists, or a payment that SI 1976/409 reg 3 prescribes. +# SSI 2023/302 reg 5(1): "Carer Support Payment may only be paid to an +# individual in respect of a period during which that individual provides +# regular and substantial care to a person to whom a qualifying disability +# benefit is normally payable". DWP includes the Universal Credit carer +# element (UC Regs 2013 reg 29) for a claimant with regular and substantial +# caring responsibilities for a severely disabled person, which a claimant +# has if "they satisfy the conditions for entitlement to a carer’s +# allowance", or would but for the earnings limit or residence in Scotland +# (reg 30(1)(a)), whether or not they claim the allowance (reg 30(2)). +# +# cares_for_severely_disabled_person records whether the person cared for +# gets such a benefit. It defaults to true. False removes the route through +# caring hours; a reported award (carers_allowance_reported) still counts. +# +# The model reads these parameters at 30 April of the simulation year and +# counts 52 weeks or 12 months in a year. For 2026: +# - Carer's Allowance (gov.dwp.carers_allowance.rate, from 2026-04-01): +# 86.45 a week, 86.45 * 52 = 4,495.40; +# - Carer Support Payment (gov.social_security_scotland.carer_support_payment +# .rate, from 2026-04-05): 86.45 a week, 86.45 * 52 = 4,495.40; +# - Scottish Carer Supplement (gov.social_security_scotland +# .carer_support_payment.supplement, from 2026-04-05): 11.70 a week, +# 11.70 * 52 = 608.40; +# - Universal Credit carer element (gov.dwp.universal_credit.elements.carer +# .amount, from 2026-04-01): 209.34 a month, 209.34 * 12 = 2,512.08; +# - Universal Credit standard allowance for a single claimant aged 25 or over +# (gov.dwp.universal_credit.standard_allowance.amount.SINGLE_OLD, from +# 2026-04-01): 424.90 a month, 424.90 * 12 = 5,098.80; +# - Pension Credit carer addition (gov.dwp.pension_credit.guarantee_credit +# .carer.addition, from 2026-04-01) and the legacy carer premium +# (gov.dwp.carer_premium.single, from 2026-04-01): 48.15 a week each, +# 48.15 * 52 = 2,503.80. +# The minimum caring hours are 35 a week for both benefits +# (gov.dwp.carers_allowance.min_hours and +# gov.social_security_scotland.carer_support_payment.min_hours), so 40 hours +# meet them. + +- name: England carer of a partner with no qualifying disability benefit gets no Carer's Allowance + # The carer cares 40 hours a week for their partner, who has no s.70(2) + # benefit, so the partner is not a severely disabled person and DWP pays + # no allowance (s.70(1)): 0 for both. Without a carer benefit or the hours + # route, the carer is not a carer for benefits either. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: ENGLAND + output: + carers_allowance: [0, 0] + is_entitled_to_carer_benefit: [false, false] + is_carer_for_benefits: [false, false] + +- name: England carer with the input left out gets Carer's Allowance on caring hours + # The model takes the person cared for to be severely disabled unless an + # input says otherwise: 40 hours meet the 35-hour minimum, so the carer gets + # the weekly rate all year, 86.45 * 52 = 4,495.40. The partner does not + # care: 0. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: ENGLAND + output: + carers_allowance: [4_495.40, 0] + is_entitled_to_carer_benefit: [true, false] + is_carer_for_benefits: [true, false] + +- name: Scotland carer of a partner with no qualifying disability benefit gets no Carer Support Payment + # Social Security Scotland pays Carer Support Payment only for care of a + # person to whom a qualifying disability benefit is normally payable (reg + # 5(1)), and the partner has none: 0. The model pays the supplement only + # with a payable Carer Support Payment: 0. From 2025 Carer Support Payment + # replaces Carer's Allowance in Scotland, so Carer's Allowance is 0 too. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: SCOTLAND + output: + carer_support_payment: [0, 0] + scottish_carer_supplement: [0, 0] + carers_allowance: [0, 0] + +- name: Scotland carer with the input left out gets Carer Support Payment and the supplement + # 40 hours meet the 35-hour minimum: 86.45 * 52 = 4,495.40 of Carer + # Support Payment and 11.70 * 52 = 608.40 of supplement. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: SCOTLAND + output: + carer_support_payment: [4_495.40, 0] + scottish_carer_supplement: [608.40, 0] + carers_allowance: [0, 0] + +- name: Universal Credit claimant caring for someone with no qualifying disability benefit has no carer element + # The claimant would not claim Carer's Allowance, so no allowance enters + # the award as income, and the carer element needs no claim (reg 30(2)). + # The person cared for is not severely disabled, so the claimant fails the + # Carer's Allowance conditions in reg 30(1)(a): no carer element. The award + # is the standard allowance alone, 424.90 * 12 = 5,098.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + would_claim_carers_allowance: false + cares_for_severely_disabled_person: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 0 + uc_carer_element: 0 + universal_credit: 5_098.80 + +- name: Universal Credit claimant with the input left out has the carer element + # The same claimant, with the person cared for taken to be severely + # disabled: the carer element, 209.34 * 12 = 2,512.08, and an award of + # 5,098.80 + 2,512.08 = 7,610.88. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + would_claim_carers_allowance: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 0 + uc_carer_element: 2_512.08 + universal_credit: 7_610.88 + +- name: A reported Carer's Allowance award counts whatever the input says + # The Department for Work and Pensions pays a reported award, so the input + # false leaves it: the carer gets the weekly rate all year, 86.45 * 52 = + # 4,495.40, not the reported amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + carers_allowance_reported: 4_000 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: ENGLAND + output: + carers_allowance: [4_495.40, 0] + is_entitled_to_carer_benefit: [true, false] + is_carer_for_benefits: [true, false] + +- name: A reported award in Scotland keeps Carer Support Payment whatever the input says + # carers_allowance_reported carries a reported Carer Support Payment award + # too, which Social Security Scotland pays: 86.45 * 52 = 4,495.40 and the + # supplement, 11.70 * 52 = 608.40. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 45 + care_hours: 40 + cares_for_severely_disabled_person: false + carers_allowance_reported: 4_000 + partner: + age: 50 + benunits: + benunit: + members: [carer, partner] + households: + household: + members: [carer, partner] + country: SCOTLAND + output: + carer_support_payment: [4_495.40, 0] + scottish_carer_supplement: [608.40, 0] + +- name: A reported award above the earnings limit keeps the carer element whatever the input says + # The claimant reports a Carer's Allowance award but earns 20,000 a year. + # Income tax takes 20% (gov.hmrc.income_tax.rates.uk) of 20,000 less the + # 12,570 personal allowance, 0.2 * 7,430 = 1,486, and employee National + # Insurance takes 8% (gov.hmrc.national_insurance.class_1.rates.employee + # .main) above the primary threshold of 241.73 a week (12,569.96 a year), + # 0.08 * 7,430.04 = 594.40. Net earnings come to (20,000 - 1,486 - 594.40) + # / 52 = 344.61 a week, above the 204 earnings limit + # (gov.dwp.carers_allowance.earnings_limit, from 2026-04-06), so the + # claimant is gainfully employed and DWP pays no allowance (s.70(1)(b)): + # 0. The reported award still counts as caring for a severely disabled + # person whatever the input says, and a claimant over the earnings limit + # still has the caring responsibilities the carer element needs (reg + # 30(1)(a)(i)), so the claimant is a carer for benefits and the element is + # 209.34 * 12 = 2,512.08. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 40 + care_hours: 40 + employment_income: 20_000 + cares_for_severely_disabled_person: false + carers_allowance_reported: 4_000 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + is_entitled_to_carer_benefit: false + is_carer_for_benefits: true + uc_carer_element: 2_512.08 + +- name: Couple who both care give the allowance only to the partner who cares for a severely disabled person + # Both care 40 hours a week. The elder, the benefit unit head, cares for + # someone with no qualifying disability benefit, so only the younger meets + # the caring condition and has the allowance, 86.45 * 52 = 4,495.40. With + # the input left out the elder would have the couple's one award. With one + # partner entitled, the couple restriction has no choice to make, whatever + # partners_care_for_same_severely_disabled_person says. One carer premium: + # 48.15 * 52 = 2,503.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 40 + care_hours: 40 + cares_for_severely_disabled_person: false + younger: + age: 38 + care_hours: 40 + benunits: + benunit: + members: [elder, younger] + would_claim_uc: false + households: + household: + members: [elder, younger] + country: ENGLAND + output: + is_claimant_or_partner: [true, true] + carers_allowance_pre_couple_limit: [0, 4_495.40] + carers_allowance: [0, 4_495.40] + is_entitled_to_carer_benefit: [false, true] + carer_premium: 2_503.80 + +- name: A supplied entitlement whose caring hours go to someone not severely disabled counts as a second award + # The elder has a reported award. The younger cares 40 hours a week for + # someone with no qualifying disability benefit, and an input supplies the + # younger's entitlement to a carer benefit. The hours can't explain that + # entitlement, so it counts as an award: two awards mean two people cared + # for, and the default is false. Each partner then has the carer premium, + # 2 * 48.15 * 52 = 5,007.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 40 + care_hours: 40 + carers_allowance_reported: 4_000 + is_entitled_to_carer_benefit: true + younger: + age: 38 + care_hours: 40 + cares_for_severely_disabled_person: false + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [elder, younger] + would_claim_uc: false + households: + household: + members: [elder, younger] + country: ENGLAND + output: + partners_care_for_same_severely_disabled_person: false + carer_premium: 5_007.60 + +- name: The same couple with the input left out has one carer premium + # The model's same-person default reads an entitlement that caring hours + # explain as no award, since hours can't show whom a carer cares for. Now + # the younger's 40 hours, for someone the model takes to be severely + # disabled, explain the younger's entitlement. One award leaves the + # default true, and caring for the same person caps the premium at one + # amount: 48.15 * 52 = 2,503.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 40 + care_hours: 40 + carers_allowance_reported: 4_000 + is_entitled_to_carer_benefit: true + younger: + age: 38 + care_hours: 40 + is_entitled_to_carer_benefit: true + benunits: + benunit: + members: [elder, younger] + would_claim_uc: false + households: + household: + members: [elder, younger] + country: ENGLAND + output: + partners_care_for_same_severely_disabled_person: true + carer_premium: 2_503.80 + +- name: Pension-age carer of someone with no qualifying disability benefit gets no Pension Credit carer addition + # A claimant aged 70 cares 40 hours a week for someone outside the + # household who has no qualifying disability benefit. With no entitlement + # to Carer's Allowance, the claimant has neither the Pension Credit carer + # addition nor the legacy carer premium: 0. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + care_hours: 40 + cares_for_severely_disabled_person: false + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 0 + is_entitled_to_carer_benefit: false + carer_minimum_guarantee_addition: 0 + carer_premium: 0 + +- name: Pension-age carer with the input left out gets the Pension Credit carer addition + # The same claimant, entitled to Carer's Allowance on caring hours: 86.45 + # * 52 = 4,495.40 of allowance, and the carer addition and the carer + # premium, 48.15 * 52 = 2,503.80 each. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + care_hours: 40 + benunits: + benunit: + members: [claimant] + households: + household: + members: [claimant] + country: ENGLAND + output: + carers_allowance: 4_495.40 + is_entitled_to_carer_benefit: true + carer_minimum_guarantee_addition: 2_503.80 + carer_premium: 2_503.80 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit.yaml index d33549c435..4c700e1fcb 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/carers_allowance_earnings_limit.yaml @@ -5,7 +5,8 @@ # are gross pay less income tax, primary Class 1 contributions and half of # pension contributions (Computation of Earnings Regulations 1996 reg. # 10(4)), and trading profit less a notional basic-rate tax and main-rate -# Class 4 (regs. 13(4) and 14). A fraction of a penny is disregarded (reg. 5). +# Class 4 (regs. 13(4) and 14). A fraction of a penny counts in the +# claimant's favour (reg. 5): disregarded in earnings, a penny in a deduction. # 2026-27: personal allowance and NI primary threshold £12,570, employee # Class 1 main rate 8%, Class 4 lower profits limit £12,570 at 6%, basic rate # 20%. Carer's Allowance £86.45 a week. @@ -321,3 +322,302 @@ carers_allowance_earnings: 10_518.20 is_gainfully_employed_for_carers_allowance: false carers_allowance_pre_overlap: 86.45 * 52 + +- name: Case 21, in 2024-25 a penny a year short of £151.01 a week is within the limit. + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 7_852.51 + care_hours: 40 + country: ENGLAND + output: + # 7,852.51 / 52 = £151.0098, which counts as £151.00 (reg. 5). + carers_allowance_earnings: 7_852.51 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 81.90 * 52 + +- name: Case 22, in 2024-25 a whole penny over £151 is over the limit. + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 7_852.52 + care_hours: 40 + country: ENGLAND + output: + # 7,852.52 / 52 = £151.01. Float32 holds 7,852.52 as 7,852.52001953125; + # counting pence in float32 used to give £151.00 (#2541). + carers_allowance_earnings: 7_852.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 23, in 2024-25 the half penny of half an odd-pence pension contribution counts as a penny off earnings. + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 7_852.52 + employee_pension_contributions: 0.01 + care_hours: 40 + country: ENGLAND + output: + # Half of 0.01 is 0.005. Counting the half penny of the deduction as a + # penny favours the claimant (reg. 5), so earnings are 7,852.51, or + # £151.0098 a week, which counts as £151.00. + carers_allowance_earnings: 7_852.51 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 81.90 * 52 + +- name: Case 24, in 2024-25 a penny more pay with the same contribution is over the limit. + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 7_852.53 + employee_pension_contributions: 0.01 + care_hours: 40 + country: ENGLAND + output: + # 7,852.53 less 0.01 (the half penny counted as a penny) is 7,852.52, or + # £151.01 a week. + carers_allowance_earnings: 7_852.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 25, a fraction of a penny in each self-employed deduction counts as a penny. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + self_employment_income: 14_000.01 + personal_pension_contributions: 6_039.38 + care_hours: 40 + country: ENGLAND + output: + # Notional tax 20% x 1,430.01 = 286.002 and Class 4 6% x 1,430.01 = + # 85.8006 count as 286.01 and 85.81 (reg. 5), and half the premium is + # 3,019.69: 14,000.01 - 286.01 - 85.81 - 3,019.69 = 10,608.50, or £204.00 + # a week, within the limit. Unrounded, earnings are 10,608.5174; the + # nearest half penny of that is 10,608.52, £204.01 a week, which would + # have ended entitlement. + carers_allowance_earnings: 10_608.50 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: Case 26, in 2026-27 a whole penny of Class 1 counts as that penny, not two. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 14_764.46 + employee_pension_contributions: 7_960.76 + care_hours: 40 + country: ENGLAND + output: + # Class 1 is 8% x (14,764.46 - 52 x 241.73) = 175.56 exactly; the model's + # float32 sum of twelve months holds it as 175.5600128, which rounded up + # would be 175.57. Pay less the contribution is 6,803.70, under the + # personal allowance, so no PAYE, and half the contribution is 3,980.38: + # 14,764.46 - 175.56 - 3,980.38 = 10,608.52, or £204.01 a week, over the + # limit. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 27, in 2025-26 the same Class 1 of £175.56 leaves a penny a week over £196. + period: 2025 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 14_764.46 + employee_pension_contributions: 8_792.76 + care_hours: 40 + country: ENGLAND + output: + # 14,764.46 - 175.56 - 4,396.38 = 10,192.52, or £196.01 a week. + carers_allowance_earnings: 10_192.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 28, in 2024-25 Class 1 of £43.80 leaves a penny a week over £151. + period: 2024 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 13_117.46 + employee_pension_contributions: 10_442.27 + care_hours: 40 + country: ENGLAND + output: + # Class 1 is 8% x (13,117.46 - 12,569.96) = 43.80, stored as 43.800003. + # Half the contribution is 5,221.135, which counts as 5,221.14 (reg. 5): + # 13,117.46 - 43.80 - 5,221.14 = 7,852.52, or £151.01 a week. + carers_allowance_earnings: 7_852.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 29, pension contributions entered as a total still come off earnings. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 11_000 + pension_contributions: 1_000 + care_hours: 40 + country: ENGLAND + output: + # 11,000 - 1,000 / 2 = 10,500, or £201.92 a week, within the limit. + carers_allowance_earnings: 10_500 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: Case 30, in 2026-27 £12,570.50 of profit with a £3,923.70 premium is a penny a week over the limit. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + self_employment_income: 12_570.50 + personal_pension_contributions: 3_923.70 + care_hours: 40 + country: ENGLAND + output: + # Notional tax 20% x 0.50 = 0.10 and Class 4 6% x 0.50 = 0.03: + # 12,570.50 - 0.10 - 0.03 - 1,961.85 = 10,608.52, £204.01 a week. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 31, a penny more profit can leave a penny less in earnings, as reg. 5 intends. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + self_employment_income: 12_570.51 + personal_pension_contributions: 3_923.70 + care_hours: 40 + country: ENGLAND + output: + # Notional tax 0.102 and Class 4 0.0306 each count as a penny more (0.11 + # and 0.04), so the extra penny of profit adds two pennies of deductions: + # 12,570.51 - 0.11 - 0.04 - 1,961.85 = 10,608.51, £204.00 a week, within. + carers_allowance_earnings: 10_608.51 + is_gainfully_employed_for_carers_allowance: false + carers_allowance: 86.45 * 52 + +- name: Case 32, two pension programmes are summed in whole pence, not as float32 adds them. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 12_229.84 + employee_pension_contributions: 1_807.49 + personal_pension_contributions: 1_435.15 + care_hours: 40 + country: ENGLAND + output: + # The contributions total 3,242.64, which pension_contributions holds as + # 3,242.64013671875: more than half a float32 step out, so read alone it + # would be 3,242.6401367 and half of it would count as 1,621.33. Summed + # in whole pence, half is 1,621.32. Pay is under the primary threshold, + # and pay less the employee contribution under the personal allowance: + # 12,229.84 - 1,621.32 = 10,608.52, or £204.01 a week, over the limit. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 33, Class 1 entered as nil is deducted as nil, not as the formula would work it. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 12_569.97 + employee_pension_contributions: 3_922.90 + ni_class_1_employee: 0 + care_hours: 40 + country: ENGLAND + output: + # The model's formula would charge 8% of the penny over its £12,569.96 + # primary threshold, 0.0008, counted as a penny. Entered as nil, nil is + # what was deducted (reg. 10(4)(a)): 12,569.97 - 3,922.90 / 2 = 10,608.52, + # or £204.01 a week, over the limit. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 34, a pension total the model sums from two inputs is read in whole pence. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income_before_lsr: 10_608.96 + salary_sacrifice_returned_to_income: 0.20 + employee_pension_contributions_reported: 1.08 + care_hours: 40 + country: ENGLAND + output: + # Pay is 10,608.96 + 0.20 = 10,609.16; employee contributions are + # 1.08 + 0.20 = 1.28, which float32 adds to 1.28000009, so that half of + # it read alone would count as 0.65. Summed in whole pence, half is 0.64: + # 10,609.16 - 0.64 = 10,608.52, or £204.01 a week, over the limit. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 35, pay the model sums from two inputs is read in whole pence. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income_before_lsr: 10_276.42 + salary_sacrifice_returned_to_income: 1_415.69 + employee_pension_contributions_reported: 751.49 + care_hours: 40 + country: ENGLAND + output: + # Pay is 10,276.42 + 1,415.69 = 11,692.11, which float32 adds to + # 11,692.109375: more than half a float32 step out, so read alone it would + # count as 11,692.10. Contributions are 751.49 + 1,415.69 = 2,167.18, half + # 1,083.59. Pay is under the primary threshold and pay less contributions + # under the personal allowance: 11,692.11 - 1,083.59 = 10,608.52, or + # £204.01 a week, over the limit. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 36, pay and pension both summed by the model are read in whole pence. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income_before_lsr: 10_794.69 + salary_sacrifice_returned_to_income: 1_435.15 + employee_pension_contributions_reported: 1_807.49 + care_hours: 40 + country: ENGLAND + output: + # Pay is 10,794.69 + 1,435.15 = 12,229.84 (float32: 12,229.8408203125) + # and contributions 1,807.49 + 1,435.15 = 3,242.64 (3,242.64013671875), + # each more than half a float32 step out of its pence. Summed in whole + # pence: 12,229.84 - 3,242.64 / 2 = 10,608.52, or £204.01 a week, over. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 + +- name: Case 37, Class 1 entered for the year in whole pence is deducted in whole pence. + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + employment_income: 10_609.72 + ni_class_1_employee: 1.20 + care_hours: 40 + country: ENGLAND + output: + # The model divides the year's 1.20 among the months in float32 and + # sums them to 1.2000002, which rounded up would deduct 1.21. The Class + # 1 deducted is the 1.20 entered (reg. 10(4)(a)): 10,609.72 - 1.20 = + # 10,608.52, or £204.01 a week, over the limit. + carers_allowance_earnings: 10_608.52 + is_gainfully_employed_for_carers_allowance: true + carers_allowance: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/enhanced_disability_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/enhanced_disability_premium.yaml new file mode 100644 index 0000000000..7535951f7a --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/enhanced_disability_premium.yaml @@ -0,0 +1,557 @@ +# Legacy enhanced disability premium for a claimant or partner. +# Conditions: HB Regs 2006 Sch 3 para 15(1)(b)-(d); IS Regs 1987 Sch 2 para +# 13A(1), (1ZA); ESA Regs 2008 Sch 4 para 7(1)(b)-(d); JSA Regs 1996 Sch 1 +# para 15A(1), (1ZA); HB Regs (NI) 2006 Sch 4 para 15(1)(aa)-(c). +# Amounts (HB Regs 2006 Sch 3 Part 4, enhanced disability premium row (9), +# 2026-27): £22.00 for a person who is not a member of a couple, £31.40 where +# a member of a couple meets the condition. +# 2026-27 weekly rates: DLA care highest £114.60, middle £76.70; PIP daily +# living enhanced £114.60, standard £76.70 (gov.dwp parameters). +# The model counts an award only with a positive amount, and counts an amount +# entered without its category at the top rate when it reaches +# 52 × (top weekly rate − £1): (114.60 - 1) * 52 = 5,907.20 in 2026-27. + +- name: Case 1, enhanced PIP daily living qualifies a single claimant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: ENGLAND + output: + # 114.60 * 52 = 5,959.20 (HB para 15(1)(c); IS para 13A(1ZA)(c)). + pip_dl: 5_959.20 + receives_enhanced_disability_premium_qualifying_benefit: true + num_enhanced_disabled_claimants_or_partners_for_legacy_benefits: 1 + # 22.00 * 52 = 1,144.00. + enhanced_disability_premium: 1_144 + +- name: Case 2, the highest DLA care rate qualifies a single claimant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + dla_sc_category: HIGHER + households: + household: + members: [person1] + country: ENGLAND + output: + # 114.60 * 52 = 5,959.20 (HB para 15(1)(b); IS para 13A(1ZA)(b)). + dla_sc: 5_959.20 + receives_enhanced_disability_premium_qualifying_benefit: true + enhanced_disability_premium: 1_144 + +- name: Case 3, standard PIP daily living does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: STANDARD + households: + household: + members: [person1] + country: ENGLAND + output: + # 76.70 * 52 = 3,988.40; claimants qualify under para 15(1)(c) only at + # the enhanced rate. + pip_dl: 3_988.40 + receives_enhanced_disability_premium_qualifying_benefit: false + enhanced_disability_premium: 0 + +- name: Case 4, the middle DLA care rate does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + dla_sc_category: MIDDLE + households: + household: + members: [person1] + country: ENGLAND + output: + # 76.70 * 52 = 3,988.40; claimants qualify under para 15(1)(b) only at + # the highest rate. + dla_sc: 3_988.40 + receives_enhanced_disability_premium_qualifying_benefit: false + enhanced_disability_premium: 0 + +- name: Case 5, armed forces independence payment qualifies a single claimant. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + output: + # HB para 15(1)(d); IS para 13A(1ZA)(a): any payment of AFIP. + receives_enhanced_disability_premium_qualifying_benefit: true + enhanced_disability_premium: 1_144 + +- name: Case 6, attendance allowance at the higher rate does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + aa_category: HIGHER + households: + household: + members: [person1] + country: ENGLAND + output: + # Claimants do not qualify through attendance allowance under HB para + # 15(1), IS para 13A(1ZA), ESA para 7(1) or JSA para 15A(1ZA). + receives_enhanced_disability_premium_qualifying_benefit: false + enhanced_disability_premium: 0 + +- name: Case 7, a single claimant over the State Pension Credit qualifying age gets no premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + dla_sc_category: HIGHER + households: + household: + members: [person1] + country: ENGLAND + output: + has_attained_state_pension_credit_qualifying_age: true + receives_enhanced_disability_premium_qualifying_benefit: true + # HB para 15(1)(b): "who has not attained the qualifying age for state + # pension credit". + num_enhanced_disabled_claimants_or_partners_for_legacy_benefits: 0 + enhanced_disability_premium: 0 + +- name: Case 8, a couple where one member qualifies gets the couple rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + person2: + age: 31 + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + receives_enhanced_disability_premium_qualifying_benefit: [true, false] + num_enhanced_disabled_claimants_or_partners_for_legacy_benefits: 1 + # 31.40 * 52 = 1,632.80. + enhanced_disability_premium: 1_632.80 + +- name: Case 9, a couple where both members qualify still gets one couple rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + person2: + age: 31 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + num_enhanced_disabled_claimants_or_partners_for_legacy_benefits: 2 + # Housing Benefit pays £31.40 under row (9)(c) "where the claimant is a + # member of a couple and the conditions ... are satisfied in respect of a + # member of that couple": 31.40 * 52 = 1,632.80, not two single rates + # (2 * 1,144.00). + enhanced_disability_premium: 1_632.80 + +- name: Case 10, a couple whose only qualifying member is over the qualifying age gets no premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + is_couple: true + has_attained_state_pension_credit_qualifying_age: [false, true] + # IS para 13A(1)(b): "the claimant's partner, if any, who has not + # attained the qualifying age for state pension credit". + num_enhanced_disabled_claimants_or_partners_for_legacy_benefits: 0 + enhanced_disability_premium: 0 + +- name: Case 11, a couple with a qualifying member under the qualifying age gets the couple rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + pip_dl_category: ENHANCED + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + households: + household: + members: [person1, person2] + country: ENGLAND + output: + is_couple: true + num_enhanced_disabled_claimants_or_partners_for_legacy_benefits: 1 + enhanced_disability_premium: 1_632.80 + +- name: Case 12, Northern Ireland reads enhanced PIP daily living from 2017. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: NORTHERN_IRELAND + output: + # Claimants in Northern Ireland qualify through PIP from 20 June 2016 + # under S.R. 2016/228; the model reads the list at 30 April, so it counts + # no PIP in Northern Ireland in 2016. + receives_enhanced_disability_premium_qualifying_benefit: false + enhanced_disability_premium: 0 + +- name: Case 13, enhanced PIP daily living qualifies in Northern Ireland in 2017. + period: 2017 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: NORTHERN_IRELAND + output: + receives_enhanced_disability_premium_qualifying_benefit: true + # 2017-18 single rate £15.90: 15.90 * 52 = 826.80. + enhanced_disability_premium: 826.80 + +- name: Case 14, Northern Ireland already counts armed forces independence payment in 2016. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: NORTHERN_IRELAND + output: + # Claimants in Northern Ireland qualify through AFIP from 24 December 2013 + # under S.I. 2013/3021. 2016-17 single rate £15.75: 15.75 * 52 = 819.00. + receives_enhanced_disability_premium_qualifying_benefit: true + enhanced_disability_premium: 819 + +- name: Case 15, Great Britain counts enhanced PIP daily living in 2016. + period: 2016 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: WALES + output: + # Claimants in Great Britain qualify through PIP from 8 April 2013 under + # S.I. 2013/388. + receives_enhanced_disability_premium_qualifying_benefit: true + enhanced_disability_premium: 819 + +- name: Case 16, Housing Benefit premiums include the premium for enhanced PIP daily living. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: ENGLAND + output: + housing_benefit_pension_age_regulations_apply: false + enhanced_disability_premium: 1_144 + # The severe disability premium list + # (gov.dwp.disability_premia.severe_qualifying_benefits) also includes + # enhanced PIP daily living; the claimant lives alone and nobody receives + # a carer benefit for caring for them: 86.05 * 52 = 4,474.60. + severe_disability_premium: 4_474.60 + # 1,144.00 + 4,474.60 = 5,618.60. + benefits_premiums: 5_618.60 + +- name: Case 17, Housing Benefit for an over-age partner in the working-age regulations has no premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + jsa_income: 3_000 + households: + household: + members: [person1, person2] + country: ENGLAND + output: + is_couple: true + # Because the couple receives income-based Jobseeker's Allowance, the + # model applies the working-age regulations + # (housing_benefit_pension_age_regulations_apply), under whose para + # 15(1)(b) a family member who has attained the qualifying age does not + # qualify. + housing_benefit_pension_age_regulations_apply: false + enhanced_disability_premium: 0 + # Only one partner receives a severe disability premium qualifying + # benefit and the other is not blind, so the model pays no severe + # disability premium either (severe_disability_premium). + benefits_premiums: 0 + +- name: Case 18, the Income Support applicable amount includes the premium for enhanced PIP daily living. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: ENGLAND + output: + # Personal allowance for a single claimant aged 25 or over, £95.55 + # (2026-27 DWP rates): 95.55 * 52 = 4,968.60. Premiums 1,144.00 + + # 4,474.60 = 5,618.60. Total 10,587.20. + income_support_applicable_amount: 10_587.20 + +- name: Case 19, a DLA care amount at the highest rate without its category qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + dla_sc: 5_959.20 + households: + household: + members: [person1] + country: ENGLAND + output: + receives_highest_dla_sc: false + # 5,959.20 reaches 5,907.20. + has_highest_dla_sc_award: true + receives_enhanced_disability_premium_qualifying_benefit: true + enhanced_disability_premium: 1_144 + +- name: Case 20, a PIP daily living amount at the enhanced rate without its category qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl: 5_959.20 + households: + household: + members: [person1] + country: ENGLAND + output: + receives_enhanced_pip_dl: false + has_enhanced_pip_dl_award: true + enhanced_disability_premium: 1_144 + +- name: Case 21, an amount within £1 a week of the highest DLA care rate qualifies. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + # £113.65 a week: 113.65 * 52 = 5,909.80, above 5,907.20. + dla_sc: 5_909.80 + households: + household: + members: [person1] + country: ENGLAND + output: + has_highest_dla_sc_award: true + enhanced_disability_premium: 1_144 + +- name: Case 22, an amount more than £1 a week below the highest DLA care rate does not qualify. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + # £113.50 a week: 113.50 * 52 = 5,902.00, below 5,907.20. + dla_sc: 5_902 + households: + household: + members: [person1] + country: ENGLAND + output: + has_highest_dla_sc_award: false + enhanced_disability_premium: 0 + +- name: Case 23, the highest DLA care category with an amount of zero is no award. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + dla_sc_category: HIGHER + dla_sc: 0 + households: + household: + members: [person1] + country: ENGLAND + output: + receives_highest_dla_sc: true + has_highest_dla_sc_award: false + receives_enhanced_disability_premium_qualifying_benefit: false + enhanced_disability_premium: 0 + +- name: Case 24, the enhanced PIP daily living category with an amount of zero is no award. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + pip_dl: 0 + households: + household: + members: [person1] + country: ENGLAND + output: + has_enhanced_pip_dl_award: false + enhanced_disability_premium: 0 + +- name: Case 25, the highest DLA care category qualifies with an amount below the rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + dla_sc_category: HIGHER + dla_sc: 1_000 + households: + household: + members: [person1] + country: ENGLAND + output: + # The model takes the rate from the category and the award from the + # positive amount. + has_highest_dla_sc_award: true + enhanced_disability_premium: 1_144 + +- name: Case 26, a child with the highest DLA care category and an amount of zero gets no child premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child: + age: 10 + dla_sc_category: HIGHER + dla_sc: 0 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + output: + receives_enhanced_disability_premium_qualifying_benefit: [false, false] + # With no DLA amount, the model pays neither the disabled-child nor the + # enhanced rate. + housing_benefit_child_disability_premiums: 0 + +- name: Case 27, a child with a highest-rate DLA care amount and no category gets both child premiums. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + child: + age: 10 + dla_sc: 5_959.20 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + output: + receives_enhanced_disability_premium_qualifying_benefit: [false, true] + # HB Sch 3 Part 4: disabled child £84.46 plus enhanced child £33.99, + # 118.45 * 52 = 6,159.40. + housing_benefit_child_disability_premiums: 6_159.40 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml index a7109607b0..decbf3de23 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/boarders_and_lodgers_non_dependants.yaml @@ -76,7 +76,9 @@ # Counted home-letting income: one lodger family at working age, # (100 - 20) x 52 = 4,160 a year. The applicable amount, 95.55 x 52 = # 4,968.60, exceeds it, so Housing Benefit is the eligible rent less the - # deduction: 10,400 - 3,346.20 = 7,053.80. + # deduction: 10,400 - 3,346.20 = 7,053.80. The son normally resides with the + # lodger too (reg 3(1)), so the lodger's family shows the same deduction, + # with no award here for it to reduce. period: 2026 absolute_error_margin: 0.01 input: @@ -116,20 +118,23 @@ benunit_is_liable_for_household_rent: [true, false] is_non_dependant_of_household_head: [false, false, true] housing_benefit_individual_non_dep_deduction_eligible: [false, false, true] - housing_benefit_non_dep_deductions: [3_346.20, 0] + housing_benefit_non_dep_deductions: [3_346.20, 3_346.20] legacy_benefits_home_letting_income: [4_160, 0] housing_benefit_applicable_income: [4_160, 0] housing_benefit: [7_053.80, 0] - name: A lodger's non-paying adult son is the householder's non-dependant for Universal Credit and CTR - # The same household, with the household head claiming Universal Credit. + # The same household, with the household head claiming Universal Credit + # and the lodger's family making no claim of its own. # The son is a non-dependant (Sch 4 para 9(2)(d) excludes only a person # liable to pay on a commercial basis), aged 21 or over and not exempt, so # the housing cost contribution is 96.55 a month in 2026-27 # (elements/housing/non_dep_deduction/amount.yaml): 96.55 x 12 = 1,158.60. # The housing element is 10,400 - 1,158.60 = 9,241.40. Council tax - # reduction takes the same non-dependant flag and, in the model, the - # Housing Benefit scale: 64.35 x 52 = 3,346.20. + # reduction takes the same non-dependant flag and Scotland's own scale (SSI + # 2021/249 reg 90): 300 a week lies in the band from 286, whose 2026-27 + # deduction is 10.95 a week (scotland/council_tax_reduction/non_dep_deduction/ + # amount.yaml): 10.95 x 52 = 569.40. period: 2026 absolute_error_margin: 0.01 input: @@ -155,6 +160,7 @@ would_claim_uc: true lodger_unit: members: [lodger, son] + would_claim_uc: false households: household: members: [householder, lodger, son] @@ -166,7 +172,52 @@ uc_non_dep_deductions: [1_158.60, 0] uc_housing_costs_element: [9_241.40, 5_200] council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true] - council_tax_reduction_non_dep_deductions: [3_346.20, 0] + council_tax_reduction_non_dep_deductions: [569.40, 0] + +- name: A claiming lodger's non-paying adult son counts in the lodger's own Universal Credit claim + # The same household, with the lodger's family claiming too. The son lives + # with both renters, and only one claim treats him as a non-dependant (Sch 4 + # para 9(2)(f)). The model gives him to his own family's claim, which has his + # bedroom and bears the contribution of 1,158.60. The lodger's housing + # element comes to 5,200 - 1,158.60 = 4,041.40, and the head's stays 10,400. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + householder: + age: 60 + is_household_head: true + is_claimant_or_partner: true + lodger: + age: 50 + is_household_head: false + is_claimant_or_partner: true + rent_paid_as_lodger: 5_200 + son: + age: 30 + is_household_head: false + is_claimant_or_partner: false + employment_income: 15_600 + weekly_hours: 20 + benunits: + householder_unit: + members: [householder] + would_claim_uc: true + lodger_unit: + members: [lodger, son] + would_claim_uc: true + households: + household: + members: [householder, lodger, son] + rent: 10_400 + tenure_type: RENT_FROM_COUNCIL + region: SCOTLAND + output: + is_uc_non_dependant_of_household_head: [false, false, false] + uc_individual_non_dep_deduction_eligible: [false, false, true] + uc_non_dep_deductions: [0, 1_158.60] + uc_housing_costs_element: [10_400, 4_041.40] + LHA_allowed_bedrooms: [1, 2] - name: A working-age lodger couple where one pays is one family for the disregard and neither is a non-dependant # England, 2026-27. One partner pays 6,240 a year (120 a week); the other @@ -331,17 +382,17 @@ is_non_dependant_of_household_head: [false, false] rent_from_boarders_and_lodgers: [0, 0] -- name: A lodger's son keeps the Pension Credit exemption from the UC housing cost contribution +- name: A lodger's Pension Credit does not exempt the lodger's son from the UC housing cost contribution # England, 2026-27. The household head (40) claims Universal Credit. The # lodger (75) pays 5,200 a year and has a State Pension of 6,000 a year; # her son (40), in her benefit unit, pays nothing and earns 15,600 over 20 - # hours. He is a non-dependant, but no housing cost contribution applies - # where the non-dependant's family receives Pension Credit (UC Regs 2013 - # Sch 4 para 16). Her guarantee credit is the 238 a week standard minimum - # guarantee less her income: 238 x 52 - 6,000 = 6,376 (her son's earnings - # are not hers). uc_non_dep_receives_pension_credit computes it with her - # family's UC housing element at nil; a family over the qualifying age - # claims no UC, so that is her actual award. + # hours. He is a non-dependant, and UC Regs 2013 Sch 4 para 16(2)(b) exempts + # only a non-dependant who receives Pension Credit. The award is hers: the + # 238 a week standard minimum guarantee less her income, 238 x 52 - 6,000 = + # 6,376 (her son's earnings are not hers). uc_non_dep_receives_pension_credit + # computes it with her family's UC housing element at nil; a family over the + # qualifying age claims no UC, so that is her actual award, and the head's + # claim counts him: 96.55 x 12 = 1,158.60. period: 2026 absolute_error_margin: 0.01 input: @@ -378,8 +429,8 @@ is_non_dependant_of_household_head: [false, false, true] pension_credit: [0, 6_376] uc_non_dep_receives_pension_credit: [false, true] - uc_individual_non_dep_deduction_eligible: [false, false, false] - uc_non_dep_deductions: [0, 0] + uc_individual_non_dep_deduction_eligible: [false, false, true] + uc_non_dep_deductions: [1_158.60, 0] # Universal Credit's extended benefit unit (UC Regs 2013 Sch 4 paras 8 to # 10). Under para 9(2)(g), DWP leaves out of the renter's non-dependants "a @@ -462,9 +513,11 @@ # unrelated lodger (50) paying 5,200 a year, and the lodger's son (30), # who pays nothing and earns 15,600 over 20 hours a week. The formula # applies para 9(2)(g) only to a child or qualifying young person. The - # son, at 30, is neither, so he stays the head's non-dependant for - # Universal Credit too. The council keeps his Housing Benefit deduction - # of 64.35 x 52 = 3,346.20 and pays 10,400 - 3,346.20 = 7,053.80. + # son, at 30, is neither, and the lodger's family makes no claim of its + # own, so he stays the head's non-dependant for Universal Credit too. The + # council keeps his Housing Benefit deduction of 64.35 x 52 = 3,346.20 and + # pays 10,400 - 3,346.20 = 7,053.80. The lodger's family shows the same + # deduction, with no award here for it to reduce. period: 2026 absolute_error_margin: 0.01 input: @@ -491,6 +544,7 @@ would_claim_uc: false lodger_unit: members: [lodger, son] + would_claim_uc: false households: household: members: [householder, lodger, son] @@ -500,7 +554,7 @@ output: is_non_dependant_of_household_head: [false, false, true] is_uc_non_dependant_of_household_head: [false, false, true] - housing_benefit_non_dep_deductions: [3_346.20, 0] + housing_benefit_non_dep_deductions: [3_346.20, 3_346.20] housing_benefit: [7_053.80, 0] - name: A non-paying daughter's qualifying young person stays the householder's non-dependant for Universal Credit @@ -563,7 +617,8 @@ # rents privately for 18,000 a year, earns 15,000 and claims Universal # Credit. An unrelated lodger (50) pays 5,200 a year; the lodger's son # (30), in the lodger's benefit unit, pays nothing and earns 15,600 over - # 20 hours a week. The son is the head's non-dependant, so the model does + # 20 hours a week. The lodger's family makes no claim of its own, so the + # son is the head's non-dependant, and the model does # not treat the head, single and under 35, as a specified renter: the # head gets two bedrooms, whose rate of 18,600 exceeds the rent. The son # brings a housing cost contribution of 96.55 x 12 = 1,158.60, so the @@ -596,6 +651,7 @@ would_claim_uc: true lodger_unit: members: [lodger, son] + would_claim_uc: false households: household: members: [householder, lodger, son] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml index 272d0661ef..67fa51506e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_from_capital.yaml @@ -2094,7 +2094,7 @@ # (Sch 1 para 37): £1 a week for each £500, or part, over £10,000: # 4,000 / 500 = 8; 8 x 52 = 416. Tax on her pension: (13,000 - 12,570) # x 20% = 86. Income 13,000 - 86 + 416 = 13,330. CTR 2,000 - 20% x - # (13,330 - 13,312) = 1,996.40. + # (13,330 - 13,312) = 1,996.40 before the non-dependant deduction. period: 2026 absolute_error_margin: 0.01 input: @@ -2131,7 +2131,15 @@ council_tax_reduction_tariff_income: [416] council_tax_reduction_applicable_income: [13_330] council_tax_reduction_applicable_amount: [13_312] - council_tax_reduction: 1_996.40 + # The couple in her benefit unit are the head's non-dependants (SI + # 2012/2885 reg 9(2)(a)). The model exempts the member to whom DWP pays + # the Pension Credit (is_on_pension_credit) and not the other, so the + # council deducts England's lowest pensioner amount, 5.20 x 52 = 270.40, + # for the couple, neither of whom works. + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + council_tax_reduction_non_dep_deductions: [270.40] + # 1,996.40 - 270.40 = 1,726. + council_tax_reduction: 1_726 - name: A household head applying alone keeps her tariff income when the couple she lives with gets savings credit only # As the guarantee credit case, but the couple are 80 and 78 and the elder @@ -2140,7 +2148,7 @@ # keeps her tariff income. The head, aged 90, reached State Pension age # before 1 April 2021, so her allowance is 256 x 52 = 13,312. Tariff # 8 x 52 = 416; tax (13,000 - 12,570) x 20% = 86; income 13,000 - 86 + 416 - # = 13,330. CTR 2,000 - 20% x (13,330 - 13,312) = 1,996.40. + # = 13,330. CTR 2,000 - 20% x (13,330 - 13,312) = 1,996.40 before the non-dependant deduction. period: 2026 absolute_error_margin: 0.01 input: @@ -2179,4 +2187,12 @@ council_tax_reduction_tariff_income: [416] council_tax_reduction_applicable_income: [13_330] council_tax_reduction_applicable_amount: [13_312] - council_tax_reduction: 1_996.40 + # The couple in her benefit unit are the head's non-dependants (SI + # 2012/2885 reg 9(2)(a)). The model exempts the member to whom DWP pays + # the Pension Credit (is_on_pension_credit) and not the other, so the + # council deducts England's lowest pensioner amount, 5.20 x 52 = 270.40, + # for the couple, neither of whom works. + council_tax_reduction_non_dep_deduction_exempt: [false, true, false] + council_tax_reduction_non_dep_deductions: [270.40] + # 1,996.40 - 270.40 = 1,726. + council_tax_reduction: 1_726 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml index 9a50ebbd02..6940037519 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/legacy_means_tests/income_tax_caused_by_earnings.yaml @@ -103,14 +103,17 @@ # A parent aged 35 with one child, pay 40,000 and interest 30,000. Child # Benefit 27.05 x 52 = 1,406.60 (1,406 in whole pounds). Adjusted net income # 70,000 is half-way through the 60,000 to 80,000 taper: charge 50% x 1,406 - # = 703 (ITEPA 2003 s.681C). Pay: (40,000 - 12,570) x 20% = 5,486. Interest, - # higher-rate (savings allowance 500): 500 at nil, 37,700 - 27,430 - 500 = - # 9,770 at 20% = 1,954, 30,000 - 10,270 = 19,730 at 40% = 7,892; 9,846. - # Income tax before the winter fuel payment charge: 5,486 + 9,846 + 703 = - # 16,035. Without the pay: adjusted net income 30,000, no charge; interest - # 17,430 taxable, 5,000 at the starting rate and 1,000 savings allowance at - # nil, 11,430 at 20% = 2,286. Tax deducted: 16,035 - 2,286 = 13,749, which - # includes the whole 703 charge. + # = 703 (ITEPA 2003 s.681C). The least tax sets 2,300 of the personal + # allowance against the pay and 10,270 against the interest (ITA 2007 + # s.25(2)). Pay: 37,700 at 20% = 7,540. Interest, higher-rate (savings + # allowance 500): 19,730 above the basic rate limit, 500 at nil and 19,230 + # at 40% = 7,692. Income tax before the winter fuel payment charge: 7,540 + + # 7,692 + 703 = 15,935. Setting the whole allowance against the pay leaves + # the 500 at nil in the basic rate band and charges 100 more. Without the + # pay: adjusted net income 30,000, no charge; interest 17,430 taxable, 5,000 + # at the starting rate and 1,000 savings allowance at nil, 11,430 at 20% = + # 2,286. Tax deducted: 15,935 - 2,286 = 13,649, which includes the whole 703 + # charge. period: 2026 absolute_error_margin: 0.01 input: @@ -130,10 +133,10 @@ region: NORTH_EAST output: CB_HITC: [703, 0] - income_tax_before_winter_fuel_payment_charge: [16_035, 0] - legacy_means_test_income_tax: [5_486, 0] + income_tax_before_winter_fuel_payment_charge: [15_935, 0] + legacy_means_test_income_tax: [7_540, 0] legacy_means_test_uncounted_income_tax_without_earnings: [2_286, 0] - legacy_means_test_deducted_income_tax: [13_749, 0] + legacy_means_test_deducted_income_tax: [13_649, 0] - name: Only the part of the High Income Child Benefit Charge that the pay adds is deducted # A parent aged 35 with one child, pay 10,000 and interest 65,000. With the diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml index cf16632edc..5e4f8db1ae 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_deemed_income.yaml @@ -78,9 +78,11 @@ households: household: members: person - owned_land: 10_001 + # 90% of 11,120 is 10,008 after the 10% deduction for the expenses + # of sale (SPC Regs 2002 reg. 19(a)): one 500 step above 10,000. + owned_land: 11_120 output: - pension_credit_assessable_capital: 10_001 + pension_credit_assessable_capital: 10_008 pension_credit_deemed_income: 52 - name: Pension Credit does not dilute pensioner capital across unrelated adult benunits. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml index 2131ed247c..8450543a61 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_earnings_disregard.yaml @@ -337,12 +337,25 @@ 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 + # 2025-26 Scottish schedule on taxable income: 19% up to 2,827, 20% to + # 14,921, 21% to 31,092. The partner's unused personal allowance gives the + # claimant a Marriage Allowance of 1,260 (10% of 12,570, rounded up to + # 10), which comes off as a tax reduction of 20% x 1,260 = 252, the + # Scottish basic rate (ITA 2007 s. 55B(1) and (3)). + # With earnings: 12,000 + 16,000 + 1,040 - 12,570 = 16,470 taxable; + # 2,827 x 19% + 12,094 x 20% + 1,549 x 21% = 537.13 + 2,418.80 + 325.29 + # = 3,281.22, less 252 = 3,029.22. + # Without earnings (next case): 15,430 taxable; 537.13 + 2,418.80 + + # 509 x 21% = 3,062.82, less 252 = 2,810.82. + # The taxable income exceeds 14,921 even without the earnings, so all + # 1,040 of the earnings bear 21%, adding 3,029.22 - 2,810.82 = 218.40 of + # tax. The claimant pays no Class 1 contributions above State Pension age, + # so net earnings come to 1,040 - 218.40 = 821.60, and the 20 pound + # disregard (attendance allowance), up to 1,040 a year, covers all 821.60. + # Income: 29,040 - 3,029.22 - 821.60 = 25,189.18, as with no earnings. + income_tax: [3_029.22, 0] + pension_credit_earnings_disregard: 821.60 + pension_credit_income: 25_189.18 - name: The same Scottish couple with no earnings has the same Pension Credit income. period: 2025 @@ -365,9 +378,13 @@ members: [claimant, partner] country: SCOTLAND output: - income_tax: [2_805.73, 0] + # 28,000 - 12,570 = 15,430 taxable: 2,827 x 19% + 12,094 x 20% + 509 x + # 21% = 3,062.82, less the 252 Marriage Allowance reduction (20% x 1,260) + # = 2,810.82. Income: 12,000 + 16,000 - 2,810.82 = 25,189.18; attendance + # allowance does not count. + income_tax: [2_810.82, 0] pension_credit_earnings_disregard: 0 - pension_credit_income: 25_194.27 + pension_credit_income: 25_189.18 - 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 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 6b32d58624..d0b49c7cf7 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 @@ -65,10 +65,11 @@ - name: Case 4, rent from a let property is not Pension Credit income; the property counts through deemed income. # SPC Regs 2002 Sch IV para 18 disregards any actual income from capital, so - # the £10,476 of rent does not count. Deemed income (reg 15(6)): capital - # 55,825 - 10,000 = 45,825, ceil(45,825 / 500) = 92, so £92 a week, 4,784 a - # year. Guarantee credit = (238 - 92) x 52 = 7,592. Savings credit is nil, as - # qualifying income (£92 a week) is below the savings credit threshold. + # the £10,476 of rent does not count. Capital: 455 + 90% of 55,370 (reg. + # 19(a)) = 50,288. Deemed income (reg 15(6)): 50,288 - 10,000 = 40,288, + # ceil(40,288 / 500) = 81, so £81 a week, 4,212 a year. Guarantee credit = + # (238 - 81) x 52 = 8,164. Savings credit is nil, as qualifying income (£81 a + # week) falls below the savings credit threshold. period: 2026 absolute_error_margin: 0.01 input: @@ -85,12 +86,12 @@ other_residential_property_value: 55_370 savings: 455 output: - pension_credit_assessable_capital: 55_825 - pension_credit_deemed_income: 4_784 - pension_credit_income: 4_784 - guarantee_credit: 7_592 + pension_credit_assessable_capital: 50_288 + pension_credit_deemed_income: 4_212 + pension_credit_income: 4_212 + guarantee_credit: 8_164 savings_credit: 0 - pension_credit: 7_592 + pension_credit: 8_164 - name: Case 5, 2026 Pension Credit uses tariff income with the published guarantee rate. period: 2026 @@ -110,12 +111,14 @@ savings: 87_285 corporate_wealth: 12_650 output: - pension_credit_assessable_capital: 99_935 - pension_credit_deemed_income: 9_360 - pension_credit_income: 9_360 + # 87,285 + 90% of 12,650 (SPC Regs 2002 reg. 19(a)). Deemed income is + # 178 steps of 500 above 10,000 at 1 a week: 178 * 52 = 9,256. + pension_credit_assessable_capital: 98_670 + pension_credit_deemed_income: 9_256 + pension_credit_income: 9_256 standard_minimum_guarantee: 12_376 - guarantee_credit: 3_016 - pension_credit: 3_016 + guarantee_credit: 3_120 + pension_credit: 3_120 - name: Pension Credit capital disregards private pension wealth period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml index 1ed36adf50..a7929272dd 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/pension_credit/pension_credit_reported_capital.yaml @@ -1,6 +1,8 @@ # Expected values are hand-computed from SPC Regs 2002 reg 15(6): deemed income # is 1 pound a week for every 500 pounds, or part of 500 pounds, of capital # above 10,000 pounds, times 52 weeks a year. +# Property expectations deduct 10% sale expenses followed by secured debt: +# https://www.legislation.gov.uk/uksi/2002/1792/regulation/19 - name: With no reported capital (the default, -1), household capital is the proxy. period: 2025 @@ -174,3 +176,330 @@ pension_credit_income: 9_000 # minimum guarantee 218.15 a week (2024-25, SI 2024/242) x 52 - 9,000 guarantee_credit: 218.15 * 52 - 9_000 + +- name: Pension Credit values overseas land and property less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + pension_credit_assessable_capital: 9_000 + +- name: Pension Credit preserves household allocation for overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Pension Credit reported capital overrides overseas land and property + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + pension_credit_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + # The override replaces its own proxy; the other unit keeps its two-thirds share. + pension_credit_assessable_capital: [4_000, 10_800] + +- name: Pension Credit values unclassified other real estate less sale expenses + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 10_000 + output: + # 10,000 gross less 10% sale expenses; no identified secured debt. + pension_credit_assessable_capital: 9_000 + +- name: Pension Credit preserves household allocation for unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + single: + members: first + couple: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The 16,200 net household value is shared by one and two adults. + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Pension Credit reported capital overrides unclassified other real estate + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + pension_credit_reported_capital: 4_000 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + other_property_value: 18_000 + output: + # The override replaces its own proxy; the other unit keeps its two-thirds share. + pension_credit_assessable_capital: [4_000, 10_800] + +- name: Pension Credit deducts sale expenses before combined secured debt for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 80_000 + output: + # max(0, 90% of 120,000 - 80,000); no separate buy-to-let input. + pension_credit_assessable_capital: 28_000 + +- name: Pension Credit floors combined property equity at zero for second homes and buy-to-let + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + # Second homes of 50,000 and buy-to-let of 70,000 share one component. + other_residential_property_value: 120_000 + other_residential_property_secured_debt: 120_000 + output: + # max(0, 90% of 120,000 - 120,000); no separate buy-to-let input. + pension_credit_assessable_capital: 0 + +- name: Pension Credit counts overseas property with no financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit retains overseas property with a zero financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit counts other property with no financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 20_000 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit retains other property with a zero financial report + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 20_000 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 18_000 + +- name: Pension Credit adds property to the financial report while replacing financial proxies + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + savings: 50_000 + corporate_wealth: 30_000 + lifetime_isa_countable_capital: 5_000 + overseas_property_value: 20_000 + other_property_value: 10_000 + benunit_reported_capital: 6_000 + output: + # Financial proxies are replaced; each property category is valued once. + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 33_000 + +- name: A complete Pension Credit override still replaces new property and financial reports + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 20_000 + other_property_value: 20_000 + benunit_reported_capital: 6_000 + pension_credit_reported_capital: 0 + output: + pension_credit_assessable_capital: 0 + +- name: Pension Credit financial reports retain domestic property with expenses and debt deducted once + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 2_000 + owned_land: 10_000 + owned_land_secured_debt: 3_000 + other_residential_property_value: 20_000 + other_residential_property_secured_debt: 8_000 + non_residential_property_value: 30_000 + non_residential_property_secured_debt: 7_000 + output: + # 2,000 + (9,000 - 3,000) + (18,000 - 8,000) + (27,000 - 7,000). + pension_credit_assessable_capital: 38_000 + +- name: Pension Credit financial reports preserve the pension-age property allocation + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 75 + second: + age: 75 + partner: + age: 75 + benunits: + reporting: + members: first + benunit_reported_capital: 0 + unreported: + members: [second, partner] + households: + household: + members: [first, second, partner] + overseas_property_value: 18_000 + output: + pension_credit_reported_capital: [-1, -1] + pension_credit_assessable_capital: [5_400, 10_800] + +- name: Savings-credit-only Housing Benefit inherits retained overseas property + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 0 + overseas_property_value: 20_000 + housing_benefit_pension_age_regulations_apply: true + in_receipt_of_savings_credit_only: true + in_receipt_of_guarantee_credit: false + housing_benefit_on_passporting_benefit: false + output: + pension_credit_assessable_capital: 18_000 + housing_benefit_assessable_capital: 18_000 + +- name: Savings-credit-only Housing Benefit inherits retained other property + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + benunit_reported_capital: 0 + other_property_value: 20_000 + housing_benefit_pension_age_regulations_apply: true + in_receipt_of_savings_credit_only: true + in_receipt_of_guarantee_credit: false + housing_benefit_on_passporting_benefit: false + output: + pension_credit_assessable_capital: 18_000 + housing_benefit_assessable_capital: 18_000 + +- name: Pension Credit values large overseas property without an intermediate reported total + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 145635.68 + output: + # Stored gross 145635.6875 less 10%, rounded down, is 131072.11. + # Final float32 storage gives 131072.109375. + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: A zero financial report cannot reduce large overseas property through extra float32 rounding + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + overseas_property_value: 145635.68 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: Pension Credit values large other property without an intermediate reported total + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 145635.68 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 + +- name: A zero financial report cannot reduce large other property through extra float32 rounding + period: 2026 + absolute_error_margin: 0 + input: + age: 75 + other_property_value: 145635.68 + benunit_reported_capital: 0 + output: + pension_credit_reported_capital: -1 + pension_credit_assessable_capital: 131072.109375 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml index 2b88881ea0..a282d3c84d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/person_isa_capital.yaml @@ -8,7 +8,12 @@ # reg 88(2); ESA reg 83(2)). The formula subtracts the identified ISA balances # from the household's capital, then allocates the remainder by claimant and # partner counts, between pension-age adults for Pension Credit, or wholly to -# a unit on the IS, JSA or ESA claim path. +# a unit on the IS, JSA or ESA claim path. Each programme values the pool's +# share_like_capital, and each stocks and shares ISA balance the formula moves +# out of it, at market value less 10% for the expenses of sale (UC Regs 2013 +# reg 49(1)(a); 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), and counts savings and +# cash ISAs at face value. - name: Case 1, a lodger's cash ISA stays out of the main benefit unit's capital. period: 2025 @@ -70,9 +75,9 @@ # The formula subtracts the child's 6,000 from savings 9,000 plus # share_like_capital 6,000 and allocates the remaining 9,000 by claimant # counts: 6,000 to the parents and 3,000 to the child's unit, which adds - # the 6,000 ISA. - uc_assessable_capital: [6_000, 9_000] - housing_benefit_assessable_capital: [6_000, 9_000] + # the 6,000 ISA less 10%, 5,400. + uc_assessable_capital: [6_000, 8_400] + housing_benefit_assessable_capital: [6_000, 8_400] - name: Case 3, an adult child's ISA inside corporate wealth stays out of the parents' capital. period: 2025 @@ -101,10 +106,11 @@ output: share_like_capital: 9_000 # The formula subtracts the child's 6,000 ISA from corporate wealth - # 9,000 and allocates the remaining 3,000: 2,000 to the parents and 1,000 - # to the child's unit. - uc_assessable_capital: [2_000, 7_000] - housing_benefit_assessable_capital: [2_000, 7_000] + # 9,000, values the remaining 3,000 less 10% at 2,700 and allocates it: + # 1,800 to the parents and 900 to the child's unit, which adds the ISA + # less 10%, 5,400. + uc_assessable_capital: [1_800, 6_300] + housing_benefit_assessable_capital: [1_800, 6_300] - name: Case 4, a parent's Income Support claim does not count an adult child's cash ISA. period: 2025 @@ -180,7 +186,9 @@ members: [parent, adult_child] savings: 2_000 output: - esa_income_assessable_capital: [2_000, 5_000] + # The parent's claiming unit takes the 2,000 of savings; the child's unit + # counts the ISA less 10%, 4,500. + esa_income_assessable_capital: [2_000, 4_500] - name: Case 7, a pensioner couple's Pension Credit counts their own ISAs and not a lodger's. period: 2025 @@ -209,8 +217,8 @@ # The formula subtracts all three balances from savings 4,000, cash_isa # 9,000 and share_like_capital 2,000, and allocates the remaining 4,000 # between the two pension-age adults. The couple's unit adds their own - # 3,000 and 2,000. - pension_credit_assessable_capital: [9_000, 0] + # 3,000 cash ISA and the 2,000 stocks and shares ISA less 10%, 1,800. + pension_credit_assessable_capital: [8_800, 0] - name: Case 8, a dependent child's junior ISA counts in no benefit unit. period: 2025 @@ -268,9 +276,10 @@ output: # Joint claimants' combined capital (WRA 2012 s 5(2)(a)): the formula # allocates savings of 6,000 by claimant counts, 4,000 to the couple and - # 2,000 to the lodger, and the couple's unit adds their 4,000 and 3,000. - uc_assessable_capital: [11_000, 2_000] - housing_benefit_assessable_capital: [11_000, 2_000] + # 2,000 to the lodger, and the couple's unit adds their 4,000 cash ISA + # and the 3,000 stocks and shares ISA less 10%, 2,700. + uc_assessable_capital: [10_700, 2_000] + housing_benefit_assessable_capital: [10_700, 2_000] - name: Case 10, a household total above the balances keeps the rest in the pool. period: 2025 @@ -378,13 +387,16 @@ household: members: [tenant, lodger] output: - # Pooling this ISA would give each claimant 17,000, above reg 18's - # 16,000 limit. The formula attributes it to its holder. - uc_assessable_capital: [0, 34_000] + # The ISA less 10%, 30,600, exceeds reg 18's 16,000 limit. Pooling it + # would give each claimant 15,300, within the limit. The formula + # attributes it to its holder. + uc_assessable_capital: [0, 30_600] # Cases 14 to 17: where balances exceed a household total, the model counts # exactly the total, so a holder at a capital limit or a tariff threshold -# stays on its boundary. +# stays on its boundary. In Cases 14 and 15 the stocks and shares total, less +# 10% and rounded to the nearest penny (UC Regs 2013 reg 6(1)), comes to the +# boundary: 90% of 17,777.78 is 16,000.002 and 90% of 6,666.67 is 6,000.003. # The model stores amounts in float32 (spacing 0.0009765625 at 16,000), and # absolute_error_margin 0 fails on one unit in the last place. @@ -398,7 +410,7 @@ cash_isa_balance: 16_790 stocks_holder: age: 30 - stocks_and_shares_isa_balance: 16_790 + stocks_and_shares_isa_balance: 18_567 benunits: cash_unit: members: cash_holder @@ -414,10 +426,10 @@ cash_isa: 16_000 stocks_home: members: stocks_holder - stocks_and_shares_isa: 16_000 + stocks_and_shares_isa: 17_777.78 output: cash_isa_countable_capital: [16_000, 0] - stocks_and_shares_isa_countable_capital: [0, 16_000] + stocks_and_shares_isa_countable_capital: [0, 17_777.78] # WRA 2012 s 5(1)(a) requires capital not greater than the amount reg # 18(1)(a) prescribes, 16,000, so capital of exactly 16,000 keeps # entitlement. Reg 72(1) deems 4.35 a month for each 250 above 6,000: @@ -452,10 +464,10 @@ cash_isa: 6_000 stocks_home: members: stocks_holder - stocks_and_shares_isa: 6_000 + stocks_and_shares_isa: 6_666.67 output: cash_isa_countable_capital: [6_000, 0] - stocks_and_shares_isa_countable_capital: [0, 6_000] + stocks_and_shares_isa_countable_capital: [0, 6_666.67] # Reg 72(1) deems no income from capital of 6,000 or less. uc_assessable_capital: [6_000, 6_000] uc_tariff_income: [0, 0] @@ -566,7 +578,8 @@ share_like_capital: 100 output: stocks_and_shares_isa_countable_capital: 100 - uc_assessable_capital: 100 + # UC counts the 100 less 10%. + uc_assessable_capital: 90 - name: Case 19, a child's ISA counts in no unit when the parent reports the family's capital. period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml index 95951f1d60..f522fcb351 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_property_capital.yaml @@ -121,5 +121,160 @@ output: household_uc_reported_financial_capital: 10_000 household_uc_pool_company_holdings: 0 - uc_assessable_capital: [0, 20_000] + # corporate_wealth is valued at 90% (reg. 49(1)(a)), and the holding + # inside the countable override comes off at that value: the owner keeps + # 10,000 - 9,000 = 1,000, and the pool is 20,000 + 9,000 - 10,000 = + # 19,000 for the other claimant. + uc_assessable_capital: [1_000, 19_000] universal_credit: [5_098.80, 0] + +- name: A recorded financial total consumes its owner's company holding at its counted value + period: 2026 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + benunit_reported_capital: 10_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 16_500 + corporate_wealth: 10_000 + output: + # The pool counts the 10,000 of shares at 90% (reg. 49(1)(a)): 16,500 + + # 9,000 = 25,500. The recorded total holds the holding at full value, so + # its holding part is consumed at the counted 9,000, not 10,000: the + # report consumes 10,000 - 1,000 = 9,000. The owner's own assessment takes + # the full holding off the report (10,000 - 10,000 = 0), and the other + # claimant keeps all 16,500 of savings, over the 16,000 limit. The total + # reconciles: 0 + 16,500 + the 9,000 disregarded holding = 25,500. + household_uc_reported_financial_capital: 9_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 16_500] + universal_credit: [5_098.80, 0] + +- name: A capital of exactly 16,000.00 after a company holding leaves the pool is within the limit + period: 2026 + absolute_error_margin: 0 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000.01 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + uc_reported_capital: 0 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 7_000.01 + corporate_wealth: 20_000 + output: + # The pool is 7,000.01 + 90% of 20,000 = 25,000.01. The owner's holding + # leaves it at 90% of 10,000.01 = 9,000.009, which is 9,000.01 to the + # nearest penny (UC Regs 2013 reg. 6(1)), so the other claimant has + # 16,000.00, which does not exceed the 16,000 limit. Unrounded, the + # 16,000.001 would fail it. + household_uc_reported_financial_capital: 9_000.01 + uc_assessable_capital: [0, 16_000] + is_uc_eligible: [true, true] + +- name: A capital pool shared three ways is rounded to the nearest penny at the last stage + period: 2026 + absolute_error_margin: 0 + input: + people: + first: + age: 30 + is_claimant_or_partner: true + second: + age: 30 + is_claimant_or_partner: true + third: + age: 30 + is_claimant_or_partner: true + benunits: + first_unit: + members: [first] + would_claim_uc: true + second_unit: + members: [second] + would_claim_uc: true + third_unit: + members: [third] + would_claim_uc: true + households: + household: + members: [first, second, third] + savings: 48_000.01 + output: + # A third of 48,000.01 is 16,000.0033, which is 16,000.00 to the nearest + # penny (reg. 6(1)), within the limit for each unit. + uc_assessable_capital: [16_000, 16_000, 16_000] + is_uc_eligible: [true, true, true] + +- name: A recorded financial total below its owner's company holding still consumes the holding + period: 2026 + input: + people: + owner: + age: 30 + is_claimant_or_partner: true + stands_as_sole_owner_or_partner_of_company: true + owned_company_carries_on_property_business: true + owned_company_holding_value: 10_000 + owned_company_capital: 0 + other_claimant: + age: 30 + is_claimant_or_partner: true + benunits: + reporting: + members: [owner] + would_claim_uc: true + benunit_reported_capital: 4_000 + missing: + members: [other_claimant] + would_claim_uc: true + households: + household: + members: [owner, other_claimant] + savings: 10_000 + corporate_wealth: 10_000 + output: + # All 10,000 of shares are the owner's holding, which the pool counts at + # 9,000 (reg. 49(1)(a)). The recorded 4,000 is below the holding, so it + # has no part beyond it and consumes the holding at its counted 9,000, + # as an override would. The other claimant keeps only the 10,000 of + # savings: none of the holding is left for them to count. The owner's + # own assessment takes the full holding off the report: max(0, 4,000 - + # 10,000) = 0. + household_uc_reported_financial_capital: 9_000 + household_uc_pool_company_holdings: 0 + uc_assessable_capital: [0, 10_000] diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml index bc1ed54e69..35386bd3d5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_receiving_education.yaml @@ -815,6 +815,13 @@ # Housing Benefit (reg. 2(1)), doesn't count as a qualifying young person # for the Universal Credit Regulations. With no maintenance funding, reg. # 12(2) doesn't apply either, so the claimant doesn't receive education. + # The claimant claims Universal Credit, which ends their own Housing + # Benefit two weeks after the award starts (reg. 8(2A)). + # is_qualifying_young_person_for_universal_credit follows that claim limb + # and reads reg. 5(5) only from reported Universal Credit, so it gives true + # for this claimant. The condition reads the status on the first day of the + # award, when the Housing Benefit still runs + # (is_qualifying_young_person_for_uc_education). period: 2026 input: people: @@ -886,17 +893,21 @@ members: [person1, person2] region: NORTH_EAST output: - is_qualifying_young_person_for_universal_credit: [true, true] + is_qualifying_young_person_for_universal_credit: [false, false] is_qualifying_young_person_for_uc_education: [false, false] uc_receiving_education: [false, false] - name: Case 34, the 18-year-old of case 32 claims when DWP moves them off legacy benefits and keeps the award. # The claimant reports Housing Benefit, so under SI 2014/1230 reg. 28 they - # don't count as a qualifying young person, and they receive education + # don't count as a qualifying young person for the condition + # (is_qualifying_young_person_for_uc_education), and they receive education # under reg. 12(2)(b), not reg. 12(1A). They fail the condition on the # migration day "because the person is undertaking a full-time course (see # regulation 12(2) and 13 ...)", so under reg. 60(1) it doesn't apply to - # them while they continue the course. + # them while they continue the course. Their claim ends their own Housing + # Benefit, so is_qualifying_young_person_for_universal_credit, which + # follows that claim limb and reads reg. 5(5) only from reported + # Universal Credit, gives true. period: 2026 input: people: @@ -1256,9 +1267,150 @@ output: legacy_benefits_closed: true claims_uc_at_legacy_closure: true - is_qualifying_young_person_for_uc_education: [false] + is_qualifying_young_person_for_uc_education: [true] + is_qualifying_young_person_on_uc_migration_day: [false] uc_receiving_education: [true] uc_is_excepted_from_education_condition: [true] meets_uc_education_condition: [true] is_uc_eligible: true universal_credit: 4_062.96 + +- name: Case 43, the 18-year-old of case 31 in Northern Ireland, whose family rents, keeps the ordinary route. + # SR 2016/226 reg. 6(2A): a claim ends a rent rebate or allowance two weeks + # after the award starts, so on the first day the claimant keeps an existing + # benefit and reg. 29 keeps them out of the definition, as in case 31. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: RENT_PRIVATELY + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 44, the same 18-year-old in Northern Ireland, whose report covers only a rate rebate, receives education. + # SR 2016/226 reg. 6(2): the claim ends a rate rebate the day before the award + # starts. On the first day the claimant has no existing benefit, so they count + # as a qualifying young person and receive education (Universal Credit + # Regulations (Northern Ireland) 2016 reg. 12(1)). No exception applies, so they + # get no Universal Credit. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + housing_benefit_report_is_rate_rebate_only: true + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [true] + uc_receiving_education: [true] + is_uc_eligible: false + universal_credit: 0 + +- name: Case 45, the same 18-year-old in England in a home the family owns keeps the ordinary route. + # SI 2014/1230 reg. 8(2A) runs on an award of Housing Benefit whatever its + # form, so reg. 28 keeps the claimant out of the definition on the first day. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTH_EAST + tenure_type: OWNED_OUTRIGHT + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 46, the 18-year-old of case 31 in temporary accommodation stays outside the definition all year. + # SI 2014/1230 reg. 8(3): a claim doesn't end a Housing Benefit award for + # specified or temporary accommodation, so reg. 28 keeps the claimant out of + # the definition after the first day as well, and they take the ordinary + # route, as in case 31. + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + in_specified_or_temporary_accommodation: true + households: + household: + members: [person1] + region: NORTH_EAST + tenure_type: RENT_PRIVATELY + output: + is_qualifying_young_person_for_universal_credit: [false] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + +- name: Case 47, the same 18-year-old in a caravan the family owns in Northern Ireland, with a rent allowance, keeps the ordinary route. + # Housing Benefit Regulations (Northern Ireland) 2006 reg. 13(1)(f) and (g): a + # rent rebate or allowance covers mooring charges for a houseboat and site + # payments for a caravan or mobile home, so an owner can get one. SR 2016/226 + # reg. 6(2A) runs it on for two weeks, so on the first day reg. 29 keeps the + # claimant out of the definition, as in case 43. They get the standard allowance + # for a single person under 25: 12 x 338.58 (SR 2026/54 reg. 3(3)(a)). + period: 2026 + input: + people: + person1: + age: 18 + current_education: POST_SECONDARY + is_in_full_time_education: true + housing_benefit_reported: 6_000 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + region: NORTHERN_IRELAND + tenure_type: OWNED_OUTRIGHT + accommodation_type: MOBILE + output: + is_qualifying_young_person_for_universal_credit: [true] + is_qualifying_young_person_for_uc_education: [false] + uc_receiving_education: [false] + is_uc_eligible: true + universal_credit: 4_062.96 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml new file mode 100644 index 0000000000..571728fc4d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/child_benefit/child_benefit_school_leaver_routes.yaml @@ -0,0 +1,794 @@ +# The Child Benefit qualifying young person conditions for a young person who +# has left education: Child Benefit (General) Regulations 2006 reg. 4 (until +# the 31 August after the 16th birthday), reg. 5 (the 20-week extension +# period for a 16- or 17-year-old), reg. 7 (until the terminal date) and +# reg. 7(3) (no Child Benefit by virtue of reg. 7 in a week of remunerative +# work). Reg. 2(2): a qualifying young person until the last applicable +# condition ceases to be met. Reg. 8: not while receiving a benefit in their +# own right, on any route. Cases 17, 18, 27 and 28 cover reported Universal +# Credit, ESA and JSA awards (reg. 8(2)(b), (e) and (f)), and Cases 19 to 26 +# Income Support (reg. 8(2)(a)). +# +# Child Benefit in 2026-27: £27.05 a week for the eldest child and £17.90 for +# each other child (gov/hmrc/child_benefit/amount). Housing Benefit and the +# other legacy schemes take their young person from the same definition +# (SSCBA 1992 s.142; HB Regs 2006 reg. 19). + +- name: Case 1, a 16-year-old who has left education is a qualifying young person for the year (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + # 27.05 * 52. + child_benefit_entitlement: 1_406.60 + # The legacy schemes' young person follows Child Benefit. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + +- name: Case 2, a 16-year-old apprentice is a qualifying young person under reg. 4, which has no education condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + apprentice: + age: 16 + current_education: POST_SECONDARY + is_apprentice: true + weekly_hours: 30 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, apprentice] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + # Reg. 7(3) bars only Child Benefit payable through reg. 7. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 3, born 15 September 2009, the 31 August after the 16th birthday is in 2026-27 (reg. 4). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090915 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 4, born 31 August 2009, reg. 4 ended on 1 September 2025, before the year. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + date_of_birth: 20090831 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + is_child_or_young_person_for_legacy_benefits: [false, false] + is_lone_parent: false + +- name: Case 5, a 17-year-old and an 18-year-old who have left education are not qualifying young persons by default. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + seventeen: + age: 17 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + eighteen: + age: 18 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, seventeen, eighteen] + output: + is_qualifying_young_person_for_child_benefit: [false, false, false] + child_benefit_entitlement: 0 + +- name: Case 6, a 16-year-old receiving a benefit in their own right is not a qualifying young person (reg. 8). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + receives_benefits_in_own_right: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 7, a 17-year-old in an extension period is a qualifying young person (reg. 5). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 8, the extension period needs the young person not to be in remunerative work (reg. 5(2)(c)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 17 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + weekly_hours: 24 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 9, the extension period is for under-18s only (reg. 5(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 10, an 18-year-old before the terminal date is a qualifying young person (reg. 7). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 23 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_in_remunerative_work_for_child_benefit: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + is_lone_parent: true + +- name: Case 11, in remunerative work a young person before the terminal date stays a qualifying young person but brings no Child Benefit (reg. 7(3)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 24 + employment_income: 20_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + would_claim_uc: false + households: + household: + members: [parent, school_leaver] + country: ENGLAND + output: + is_qualifying_young_person_for_child_benefit: [false, true] + is_child_or_qualifying_young_person_for_child_benefit: [false, true] + is_child_benefit_payable_for_person: [false, false] + child_benefit_entitlement: 0 + # Still a young person for Housing Benefit and the other legacy schemes. + is_child_or_young_person_for_legacy_benefits: [false, true] + is_lone_parent: true + # Their £20,000 of earnings is not the parent's (HB Regs 2006 reg 25(3), + # IS Regs 1987 reg 23(2), CTR applicant-and-partner rules; #2387). The + # parent has no income of their own. + housing_benefit_applicable_income: 0 + income_support_applicable_income: 0 + council_tax_reduction_applicable_income: 0 + +- name: Case 12, reg. 7(3) does not stop Child Benefit payable through another condition. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 40 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + # Reg. 4 also applies at 16, and has no remunerative work bar. + is_child_benefit_payable_for_person: [false, true] + child_benefit_entitlement: 1_406.60 + +- name: Case 13, a working school leaver before the terminal date does not take the eldest-child rate from a younger child. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 18 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + weekly_hours: 30 + is_claimant_or_partner: false + child: + age: 10 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver, child] + output: + child_benefit_child_index: [-1, -1, 1] + # One child at the eldest rate: 27.05 * 52. + child_benefit_entitlement: 1_406.60 + +- name: Case 14, the terminal date condition ends at 20. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 20 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 15, a 16-year-old at school and a 16-year-old who has left are both qualifying young persons, so two rates. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + at_school: + age: 16 + current_education: UPPER_SECONDARY + is_claimant_or_partner: false + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, at_school, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true, true] + # (27.05 + 17.90) * 52. + child_benefit_entitlement: 2_337.40 + +- name: Case 16, a lone 16-year-old who has left education is not their own qualifying young person. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: true + benunits: + benunit: + members: [school_leaver] + output: + is_qualifying_young_person_for_child_benefit: true + is_child_or_qualifying_young_person_for_child_benefit: false + child_benefit_entitlement: 0 + +- name: Case 17, a 16-year-old who reports their own income-related ESA is not a qualifying young person (reg. 8(2)(e)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + esa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + receives_benefits_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit_entitlement: 0 + is_lone_parent: false + +- name: Case 18, a 16-year-old who reports their own income-based JSA is not a qualifying young person (reg. 8(2)(b)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + jsa_income_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + receives_benefits_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit_entitlement: 0 + is_lone_parent: false + +# Cases 19 to 26: Income Support in the young person's own right (reg. +# 8(2)(a)). receives_income_support_in_own_right reads the person's own +# reported Income Support while DWP pays Income Support +# (gov.dwp.income_support.active) and no reform neutralises income_support. +# A year's period reads that parameter's value on 30 April, so it is true +# for 2025-26 and false from 2026-27 (DWP made the last payments on 31 March +# 2026). +# +# 2018-19 rates: Child Benefit £20.70 a week for the eldest child and £13.70 +# for each other child; PIP daily living standard rate £57.30; Carer's +# Allowance £64.60; Income Support personal allowance for a lone parent aged +# 18 or over £73.10; colour TV licence £150.50. The 2018 cases set the +# cost-of-living payment, which DWP first paid in 2022, to zero. In Cases 19 +# to 22, 24 and 25 a parent aged 40 on PIP daily living lives with a young +# person who has left education and cares for them 35 hours a week. No +# family claims tax credits or Universal Credit, so Child Benefit is the only +# award the young person's status changes. + +- name: Case 19, a 16-year-old school leaver who receives Income Support in their own right is not a qualifying young person (reg. 4, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + # Born 15 January 2002: the reg. 4 period runs to 31 August 2018. + is_before_first_september_following_16th_birthday: [false, true] + is_on_income_support: [false, true] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + is_child_or_young_person_for_legacy_benefits: [false, false] + child_benefit: 0 + # PIP 57.30 x 52 = 2,979.60; Carer's Allowance 64.60 x 52 = 3,359.20; + # less TV licence 150.50: 6,188.30. The model calculates Income Support + # only for the claimant's family, so the 16-year-old's own 3,000 is in no + # income here. + household_net_income: 6_188.30 + +- name: Case 20, the same family without the 16-year-old's Income Support gets Child Benefit for them. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + # 20.70 x 52. + child_benefit: 1_076.40 + # Case 19's 6,188.30 + 1,076.40 = 7,264.70. + household_net_income: 7_264.70 + +- name: Case 21, a 17-year-old in an extension period who receives Income Support in their own right is not a qualifying young person (reg. 5, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 17 + date_of_birth: 20010115 + current_education: NOT_IN_EDUCATION + is_in_child_benefit_extension_period: true + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + # Born 15 January 2001: the reg. 4 period ended on 31 August 2017. + is_before_first_september_following_16th_birthday: [false, false] + meets_qualifying_young_person_extension_period_condition_for_child_benefit: [false, true] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # As Case 19: 2,979.60 + 3,359.20 - 150.50 = 6,188.30. + household_net_income: 6_188.30 + +- name: Case 22, an 18-year-old before the terminal date who receives Income Support in their own right is not a qualifying young person (reg. 7, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 18 + date_of_birth: 20000115 + current_education: NOT_IN_EDUCATION + is_before_child_benefit_terminal_date: true + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + is_before_first_september_following_16th_birthday: [false, false] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # As Case 19: 2,979.60 + 3,359.20 - 150.50 = 6,188.30. + household_net_income: 6_188.30 + +- name: Case 23, a 17-year-old at school who receives Income Support in their own right is not a qualifying young person (reg. 3, reg. 8(2)(a)). + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + student: + age: 17 + date_of_birth: 20010115 + current_education: UPPER_SECONDARY + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + would_claim_uc: false + households: + household: + members: [parent, student] + cost_of_living_support_payment: 0 + output: + # Full-time non-advanced education begun before 19 (reg. 3). + meets_qualifying_young_person_education_or_training_condition_for_child_benefit: [false, true] + meets_qualifying_young_person_entry_condition_for_child_benefit: [false, true] + receives_income_support_in_own_right: [false, true] + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit: 0 + # PIP 57.30 x 52 = 2,979.60, less TV licence 150.50: 2,829.10. + household_net_income: 2_829.10 + +- name: Case 24, under a reform that neutralises income_support, Case 19's report does not end the status. + period: 2018 + absolute_error_margin: 0.01 + reforms: + - policyengine_uk.tests.income_support_reforms.neutralise_income_support + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + cost_of_living_support_payment: 0 + output: + is_on_income_support: [false, false] + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + # 20.70 x 52. + child_benefit: 1_076.40 + # As Case 20: 6,188.30 + 1,076.40 = 7,264.70. + household_net_income: 7_264.70 + +- name: Case 25, in 2026-27, after DWP stopped paying Income Support, a report of it does not end the status. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + pip_dl_category: STANDARD + carer: + age: 16 + date_of_birth: 20100115 + current_education: NOT_IN_EDUCATION + care_hours: 35 + income_support_reported: 3_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, carer] + would_claim_uc: false + households: + household: + members: [parent, carer] + output: + # Born 15 January 2010: the reg. 4 period runs to 31 August 2026. + is_before_first_september_following_16th_birthday: [false, true] + receives_income_support_in_own_right: [false, false] + is_qualifying_young_person_for_child_benefit: [false, true] + # 27.05 x 52. + child_benefit: 1_406.60 + # PIP 76.70 x 52 = 3,988.40; Carer's Allowance 86.45 x 52 = 4,495.40; + # Child Benefit 1,406.60; Household Energy Payment 100; less TV licence + # 180: 9,810.40. + household_net_income: 9_810.40 + +- name: Case 26, a 16-year-old school leaver whose lone parent claims Income Support for the family, and who has none of their own, stays a qualifying young person. + period: 2018 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + income_support_reported: 3_000 + child: + age: 4 + is_claimant_or_partner: false + school_leaver: + age: 16 + date_of_birth: 20020115 + current_education: NOT_IN_EDUCATION + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, child, school_leaver] + would_claim_uc: false + # The family takes up no free childcare, so the 4-year-old's + # universal hours add nothing to household income. + would_claim_universal_childcare: false + households: + household: + members: [parent, child, school_leaver] + cost_of_living_support_payment: 0 + output: + is_on_income_support: [true, false, false] + receives_income_support_in_own_right: [true, false, false] + is_qualifying_young_person_for_child_benefit: [false, false, true] + is_child_or_young_person_for_legacy_benefits: [false, true, true] + # A lone parent of a child under 5 with no income: the personal + # allowance, 73.10 x 52 = 3,801.20. + income_support: 3_801.20 + # (20.70 + 13.70) x 52 = 1,788.80. + child_benefit: 1_788.80 + # 3,801.20 + 1,788.80, less TV licence 150.50: 5,439.50. + household_net_income: 5_439.50 + +- name: Case 27, a 17-year-old at school who reports their own contributory ESA is not a qualifying young person (reg. 3 with reg. 8(2)(e)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + student: + age: 17 + current_education: UPPER_SECONDARY + esa_contrib_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, student] + output: + is_qualifying_young_person_for_child_benefit: [false, false] + child_benefit_entitlement: 0 + +- name: Case 28, a 16-year-old school leaver who reports contribution-based JSA stays a qualifying young person (reg. 8(2)(b) lists income-based JSA only). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + is_parent: true + is_claimant_or_partner: true + school_leaver: + age: 16 + current_education: NOT_IN_EDUCATION + jsa_contrib_reported: 5_000 + is_claimant_or_partner: false + benunits: + benunit: + members: [parent, school_leaver] + output: + is_qualifying_young_person_for_child_benefit: [false, true] + # 27.05 x 52 = 1,406.60. + child_benefit_entitlement: 1_406.60 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml new file mode 100644 index 0000000000..fc82157caa --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.yaml @@ -0,0 +1,376 @@ +# The Marriage Allowance as a tax reduction at Step 6 (ITA 2007 s. 55A(2)): +# the appropriate percentage of the transferable amount (s. 55B(1)), the basic +# rate or, for a Scottish taxpayer, the Scottish basic rate (s. 55B(3)), +# deducted only so far as Step 5 tax remains after earlier reductions +# (s. 29(2) and (3)). +# +# Worked by hand from the parameter files. 2025-26: personal allowance +# £12,570, transferable amount 10% x 12,570 = 1,257 rounded up to £1,260 +# (s. 55B(4)(b) and (5)), basic rate 20% on the first £37,700, dividend +# allowance £500 and dividend ordinary rate 8.75%, Personal Savings Allowance +# £1,000 for a basic-rate taxpayer, starting rate limit for savings £5,000. +# Scottish rates for 2025-26 (S6M-16531): 19% up to £2,827, 20% to £14,921, +# 21% to £31,092, 42% above. Employee NI: 8% above £12,570. Each household +# also pays the £174.50 colour TV licence (gov.dcms.bbc.tv_licence.colour). + +- name: A Scottish intermediate-rate recipient with dividends gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 40_000 + dividend_income: 5_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + # The spouse's £5,000 leaves 12,570 - 5,000 = 7,570 of allowance unused, + # so the earner gets the full £1,260. + marriage_allowance: [1_260, 0] + marriage_allowance_appropriate_percentage: [0.2, 0.2] + # 20% x 1,260 = 252. + marriage_allowance_tax_reduction: [252, 0] + # Non-savings taxable income 40,000 - 12,570 = 27,430: + # 2,827 x 19% + (14,921 - 2,827) x 20% + (27,430 - 14,921) x 21% + # = 537.13 + 2,418.80 + 2,626.89 = 5,582.82. + # Dividends: 27,430 + 5,000 = 32,430 stays within £37,700 (ss. 11D(6) and + # 13(5)), so (5,000 - 500) x 8.75% = 393.75. Step 5: 5,976.57. + # Step 6: 5,976.57 - 252 = 5,724.57. + income_tax: [5_724.57, 0] + # 50,000 - 5,724.57 - (40,000 - 12,570) x 8% - 174.50 + # = 50,000 - 5,724.57 - 2,194.40 - 174.50 = 41,906.53. + household_net_income: 41_906.53 + +- name: A Scottish intermediate-rate recipient with savings gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 40_000 + savings_interest_income: 5_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + # Non-savings tax 5,582.82 as above. Taxable income 32,430 stays within the + # rest-of-UK basic rate limit, so the Personal Savings Allowance is £1,000 + # (s. 12B); non-savings income of 27,430 leaves no starting-rate band. + # Savings: (5,000 - 1,000) x 20% = 800. Step 5: 6,382.82. + # Step 6: 6,382.82 - 252 = 6,130.82. + income_tax: [6_130.82, 0] + # 50,000 - 6,130.82 - 2,194.40 - 174.50 = 41,500.28. + household_net_income: 41_500.28 + +- name: A Scottish recipient with pay only gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 40_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + marriage_allowance_tax_reduction: [252, 0] + # 5,582.82 - 252 = 5,330.82. + income_tax: [5_330.82, 0] + # Universal Credit deducts the tax on earnings after the Step 6 + # reductions: 5,582.82 - 252 = 5,330.82. + uc_income_tax_on_earnings: [5_330.82, 0] + +- name: A Scottish starter-rate recipient gets 20% of the transferable amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 14_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + households: + household: + members: [earner, spouse] + region: SCOTLAND + output: + marriage_allowance_tax_reduction: [252, 0] + # (14,000 - 12,570) x 19% = 271.70; 271.70 - 252 = 19.70. + income_tax: [19.70, 0] + +- name: A recipient's starting rate for savings does not grow with the Marriage Allowance + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 17_570 + savings_interest_income: 3_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance_tax_reduction: [252, 0] + # Non-savings taxable income 17,570 - 12,570 = 5,000 uses up the £5,000 + # starting rate limit for savings (s. 12), so no savings bear the starting + # rate. Pay: 5,000 x 20% = 1,000. Savings: (3,000 - 1,000) x 20% = 400. + # Step 5: 1,400. Step 6: 1,400 - 252 = 1,148. + income_tax: [1_148, 0] + +- name: A recipient whose tax comes only from dividends gets the full reduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 12_570 + dividend_income: 5_000 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance: [1_260, 0] + marriage_allowance_tax_reduction: [252, 0] + # 2026-27: the personal allowance of £12,570 covers the pay. Dividends: + # (5,000 - 500) x 10.75% = 483.75. Step 6: 483.75 - 252 = 231.75. + income_tax: [231.75, 0] + +- name: A basic-rate recipient in England keeps the same tax under the reduction + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 30_000 + spouse: + age: 40 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance_tax_reduction: [252, 0] + # (30,000 - 12,570) x 20% - 1,260 x 20% = 3,486 - 252 = 3,234, as when the + # model took 1,260 off the 20% band. + income_tax: [3_234, 0] + uc_income_tax_on_earnings: [3_234, 0] + +- name: The reduction stops at the recipient's Step 5 tax + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 13_500 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance: [1_260, 0] + # Step 5: (13,500 - 12,570) x 20% = 186, less than 252 (s. 29(2)). + marriage_allowance_tax_reduction: [186, 0] + income_tax: [0, 0] + +- name: The reduction takes only the tax that earlier reductions leave + period: 2025 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 14_000 + other_tax_credits: 100 + spouse: + age: 40 + employment_income: 5_000 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + # Step 5: (14,000 - 12,570) x 20% = 286. The earlier reductions take 100, + # leaving 186 (s. 29(3)), less than 252. + marriage_allowance_tax_reduction: [186, 0] + income_tax_reductions: [286, 0] + income_tax: [0, 0] + +# The appropriate percentage (s. 55B(3)) for a Scottish and an English +# taxpayer. The model's Scottish basic rate is 20% in each year; the Scottish +# rates of s. 11A start in 2016-17, so in 2015-16 both take the basic rate. + +- name: Appropriate percentage in 2015-16 + period: 2015 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: Appropriate percentage in 2017-18 + period: 2017 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: Appropriate percentage in 2018-19 + period: 2018 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: Appropriate percentage in 2025-26 + period: 2025 + input: + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.2, 0.2] + +- name: A reformed Scottish basic rate sets a Scottish taxpayer's appropriate percentage + period: 2025 + input: + gov.hmrc.income_tax.rates.scotland.rates[1].rate: 0.22 + people: + scot: + age: 40 + english: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + english_household: + members: [english] + region: LONDON + output: + marriage_allowance_appropriate_percentage: [0.22, 0.2] + +- name: Before 2016-17 a Scottish taxpayer's appropriate percentage stays the basic rate + period: 2015 + input: + gov.hmrc.income_tax.rates.scotland.rates[1].rate: 0.22 + people: + scot: + age: 40 + households: + scottish_household: + members: [scot] + region: SCOTLAND + output: + marriage_allowance_appropriate_percentage: 0.2 + +- name: A reformed basic rate sets the reduction outside Scotland + period: 2025 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.rates.uk[0].rate: 0.21 + people: + earner: + age: 40 + employment_income: 30_000 + spouse: + age: 40 + benunits: + benunit: + members: [earner, spouse] + is_married: true + output: + marriage_allowance_appropriate_percentage: [0.21, 0.21] + # 21% x 1,260 = 264.60. + marriage_allowance_tax_reduction: [264.60, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml index 064d9679fc..981bdc61be 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.yaml @@ -1,41 +1,366 @@ -- name: Basic rate band is eligible for Marriage Allowance +# ITA 2007 s. 55B(2)(b) and (ba): the person receiving the allowance may be +# liable only at the basic rates, the Scottish starter and intermediate rates +# (from 2018-19, SI 2018/459 art. 6(2)), the dividend ordinary rate and the nil +# and starting rates for savings, and dividends count as if the dividend nil +# rate (s. 13A) did not exist. From 2021-22 to 2026-27 Parliament sets the +# personal allowance at £12,570 and the basic rate limit at £37,700, and the +# Scottish Parliament starts the Scottish higher rate at £31,092 of taxable +# income (S6M-16531 for 2025-26, S6M-20844 for 2026-27). In 2026-27 Parliament +# sets the dividend allowance at £500 and the Personal Savings Allowance at +# £1,000, or £500 with income above the basic rate limit (s. 12B). + +- name: Basic-rate earner meets the Marriage Allowance income conditions period: 2021 input: - tax_band: BASIC + # 30,000 - 12,570 = 17,430, all at the basic rate. + employment_income: 30_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: true -- name: Higher rate band is ineligible for Marriage Allowance +- name: Higher-rate earner fails the Marriage Allowance income conditions period: 2021 input: - tax_band: HIGHER + # 60,000 - 12,570 = 47,430. The model taxes 9,730 at the higher rate. + employment_income: 60_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: false -- name: Additional rate band is ineligible for Marriage Allowance +- name: Additional-rate earner fails the Marriage Allowance income conditions period: 2021 input: - tax_band: ADDITIONAL + # The personal allowance falls to zero at 125,140 of income, and 10,000 + # of the income exceeds the 150,000 higher rate limit. + employment_income: 160_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: false -- name: Below-PA earner (band NONE) is eligible for Marriage Allowance (regression for #395) +- name: Earner below the personal allowance meets the conditions (regression for #395) period: 2021 input: - tax_band: NONE + employment_income: 10_000 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: true -- name: Scottish starter rate band is eligible for Marriage Allowance +- name: Scottish starter-rate earner meets the Marriage Allowance income conditions period: 2021 input: - tax_band: STARTER + # 430 of taxable income, all at the Scottish starter rate. + employment_income: 13_000 + region: SCOTLAND output: meets_marriage_allowance_income_conditions: true -- name: Scottish intermediate rate band is eligible for Marriage Allowance +- name: Scottish intermediate-rate earner meets the Marriage Allowance income conditions period: 2021 input: - tax_band: INTERMEDIATE + # 27,430 of taxable income, below the Scottish higher rate at 31,092. + employment_income: 40_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: Under a raised Scottish higher rate, nil-rate savings meet the conditions + period: 2025 + input: + # The Scottish higher rate moves to 37,702 of taxable income. Pay leaves + # 50,270 - 12,570 = 37,700, all below it. The 1 of interest takes taxable + # income to 37,701, above the basic rate limit, so Parliament sets the + # Personal Savings Allowance at 500 (s. 12B) and charges the pound at the + # savings nil rate (s. 12A), which it allows for the Marriage Allowance + # (s. 55B(2)(b)). The model bands the person HIGHER on that pound alone. + gov.hmrc.income_tax.rates.scotland.rates[3].threshold: 37_702 + employment_income: 50_270 + savings_interest_income: 1 + region: SCOTLAND + output: + tax_band: HIGHER + savings_income_tax: 0 + meets_marriage_allowance_income_conditions: true + +- name: Savings covered by the savings nil rate above the basic rate limit meet the conditions + period: 2026 + input: + # 50,070 - 12,570 = 37,500 of pay, then 500 of interest to 38,000. Income + # above the basic rate limit sets the Personal Savings Allowance at 500 + # (s. 12B), which covers all 500 at the savings nil rate (s. 12A). + employment_income: 50_070 + savings_interest_income: 500 + region: NORTH_EAST + output: + tax_band: HIGHER + savings_income_tax: 0 + meets_marriage_allowance_income_conditions: true + +- name: One pound of savings at the savings higher rate fails the conditions + period: 2026 + input: + # 50,270 - 12,570 = 37,700 of pay, at the basic rate limit, then 501 of + # interest. The higher-rate allowance covers 500 at the savings nil rate + # (s. 12A) and the last pound bears the savings higher rate, 40p. With pay + # at the limit, no other share of the allowance lowers the tax (s. 25(2)). + employment_income: 50_270 + savings_interest_income: 501 + region: NORTH_EAST + absolute_error_margin: 0.01 + output: + savings_income_tax: 0.4 + meets_marriage_allowance_income_conditions: false + +- name: Savings at a raised starting rate limit and the savings nil rate meet the conditions + period: 2026 + input: + # A reform raises the starting rate limit for savings to 37,500. Pay uses + # the personal allowance. Parliament charges 37,500 of interest at the + # starting rate (s. 12) and the 500 above that limit at the savings nil + # rate (s. 12A), and permits both for the Marriage Allowance + # (s. 55B(2)(b)). + gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 37_500 + employment_income: 12_570 + savings_interest_income: 38_000 + region: NORTH_EAST + output: + savings_starter_rate_income: 37_500 + savings_allowance: 500 + savings_income_tax: 0 + meets_marriage_allowance_income_conditions: true + +- name: Under a starting rate limit above the basic rate limit, savings past the basic rate limit fail the conditions + period: 2026 + input: + # A reform raises the starting rate limit for savings to 40,000. Pay uses + # the personal allowance. Parliament charges the starting rate only on + # income that would otherwise bear the basic rate (s. 12(1)(b)), so it + # covers 37,700 of the 38,000 of interest, and the savings nil rate only on + # savings above the 40,000 limit (s. 12A). The interest stops below that + # limit, so the other 300 bears the savings higher rate, which Parliament + # forbids for the Marriage Allowance (s. 55B(2)(b)). The model's savings + # liability charges these 300 at the savings nil rate. + gov.hmrc.income_tax.rates.savings_starter_rate.allowance: 40_000 + employment_income: 12_570 + savings_interest_income: 38_000 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: Taxable income at the basic rate limit meets the conditions + period: 2026 + input: + # 50,270 - 12,570 = 37,700: Parliament charges income up to the basic rate + # limit at the basic rate (s. 10(2)). + employment_income: 50_270 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the basic rate limit fails the conditions + period: 2026 + input: + # 37,701 of taxable income: 1 at the higher rate (s. 10(3)). + employment_income: 50_271 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: Scottish taxable income at the start of the higher rate meets the conditions + period: 2026 + input: + # 43,662 - 12,570 = 31,092: the Scottish Parliament charges the + # intermediate rate on income up to 31,092 and the higher rate only above + # it (S6M-20844). + employment_income: 43_662 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound into the Scottish higher rate fails the conditions + period: 2026 + input: + employment_income: 43_663 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: Dividends covered by the dividend allowance above the basic rate limit fail the conditions + period: 2026 + input: + # 37,500 of pay, then 500 of dividends, all within the 500 dividend + # allowance (s. 13A). Without s. 13A, 300 of them would bear the dividend + # upper rate (37,500 + 500 = 38,000), so s. 55B(2)(ba) fails, though the + # person pays no dividend tax. + employment_income: 50_070 + dividend_income: 500 + region: NORTH_EAST + output: + dividend_income_tax: 0 + meets_marriage_allowance_income_conditions: false + +- name: Dividends that reach the basic rate limit exactly meet the conditions + period: 2026 + input: + # 37,500 + 200 = 37,700: none would bear the dividend upper rate. + employment_income: 50_070 + dividend_income: 200 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: Gift Aid raises the basic rate limit for the conditions + period: 2026 + input: + # A 1,600 gift grosses up to 2,000 (s. 415), which raises the basic rate + # limit to 39,700 (s. 414(2)(b)). 52,270 - 12,570 = 39,700. + employment_income: 52_270 + gift_aid: 1_600 + region: NORTH_EAST output: meets_marriage_allowance_income_conditions: true + +- name: One pound above the Gift Aid limit fails the conditions + period: 2026 + input: + employment_income: 52_271 + gift_aid: 1_600 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: Gift Aid raises the start of the Scottish higher rate for the conditions + period: 2026 + input: + # The 2,000 grossed-up gift moves the Scottish higher rate to 33,092 + # (s. 414(2)(b)). 45,662 - 12,570 = 33,092. + employment_income: 45_662 + gift_aid: 1_600 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: One pound above the Scottish Gift Aid limit fails the conditions + period: 2026 + input: + employment_income: 45_663 + gift_aid: 1_600 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: In 2015-16 the basic rate limit bounds a Scottish taxpayer's income + period: 2015 + input: + # The Scottish rates of s. 11A start in 2016-17 (SI 2015/2000 art. 3), and + # in 2015-16 Parliament allows the basic rate (s. 55B(2)(b)). + # 42,200 - 10,600 = 31,600, below the basic rate limit, though above the + # 31,500 the model reads from its 2017-18 Scottish scale. + employment_income: 42_200 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2016-17 the basic rate limit bounds a Scottish taxpayer's income + period: 2016 + input: + # The Treasury added the Scottish basic rate to s. 55B(2)(b) (SI 2015/1810 + # art. 14(3)), and in 2016-17 Parliament charges it on non-savings income + # that would otherwise bear the basic rate (s. 11A(1)). + # 42,700 - 11,000 = 31,700, below the basic rate limit. + employment_income: 42_700 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2017-18 the Scottish higher rate fails the conditions + period: 2017 + input: + # In 2017-18 Parliament allows only the Scottish basic rate (s. 55B(2)(b)). + # With an 11,500 allowance, the Scottish higher rate starts at 31,500 of + # taxable income: 43,001 - 11,500 = 31,501. + employment_income: 43_001 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: false + +- name: In 2017-18 Scottish income up to the higher rate meets the conditions + period: 2017 + input: + # 43,000 - 11,500 = 31,500, all at the Scottish basic rate. + employment_income: 43_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2017-18 the same income outside Scotland meets the conditions + period: 2017 + input: + # 31,501 of taxable income, below the 33,500 basic rate limit. + employment_income: 43_001 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2018-19 the Scottish intermediate rate meets the conditions + period: 2018 + input: + # The Treasury adds the Scottish starter and intermediate rates to + # s. 55B(2)(b) (SI 2018/459 art. 6(2)). 40,000 - 11,850 = 28,150, at the + # intermediate rate. + employment_income: 40_000 + region: SCOTLAND + output: + meets_marriage_allowance_income_conditions: true + +- name: In 2015-16 dividends above the basic rate limit fail the conditions + period: 2015 + input: + # 42,600 - 10,600 = 32,000 of pay, at the model's basic rate limit, then 1 + # of dividends. Parliament charges the dividend upper rate on dividends + # that would otherwise bear the higher rate (s. 13(2)), and the higher rate + # starts at the basic rate limit (s. 10(3)). In 2015-16 Parliament permits + # only the dividend ordinary rate among the dividend rates (s. 55B(2)(b)). + # The model's dividend scale starts the upper rate at 37,500 that year. + employment_income: 42_600 + dividend_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: At the statutory 2015-16 basic rate limit a pound of dividends fails the conditions + period: 2015 + input: + # A reform sets the basic rate limit to the statutory 31,785 (s. 10(5) for + # 2015-16). 42,385 - 10,600 = 31,785 of pay, then 1 of dividends, which + # Parliament charges at the dividend upper rate (s. 13(2)). + gov.hmrc.income_tax.rates.uk[1].threshold: 31_785 + employment_income: 42_385 + dividend_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: In 2015-16 savings above the basic rate limit fail the conditions + period: 2015 + input: + # 32,000 of pay, then 1 of interest. Parliament gave the savings nil rate + # effect from 2016-17 (FA 2016 s. 4(17)), so in 2015-16 the pound bears the + # higher rate, which it forbids for the Marriage Allowance (s. 55B(2)(b)). + # The model applies its Personal Savings Allowance in 2015-16 too. + employment_income: 42_600 + savings_interest_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false + +- name: At the statutory 2015-16 basic rate limit a pound of savings fails the conditions + period: 2015 + input: + # As above with 1 of interest, which bears the higher rate in a year + # before the savings nil rate. + gov.hmrc.income_tax.rates.uk[1].threshold: 31_785 + employment_income: 42_385 + savings_interest_income: 1 + region: NORTH_EAST + output: + meets_marriage_allowance_income_conditions: false diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml new file mode 100644 index 0000000000..93084b4b88 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/least_tax_allocation.yaml @@ -0,0 +1,406 @@ +# ITA 2007 s. 25(2): "At Steps 2 and 3, deduct the reliefs and allowances in +# the way which will result in the greatest reduction in the taxpayer's +# liability to income tax." From 2027-28, the taxpayer deducts them from +# income other than property, savings and dividend income first (s. 25(3A), +# inserted by Finance Act 2026 s. 6). Savings sit above non-savings income and dividends above savings +# (s. 16), and from 2027-28 property income sits just below savings (s. 16A). +# 2026-27 and 2027-28: Personal Allowance £12,570, tapered away by £1 for each +# £2 of adjusted net income above £100,000 (s. 35); basic rate limit £37,700 +# and higher rate limit £125,140; starting rate limit for savings £5,000 +# (s. 12); personal savings allowance £1,000 for a basic rate taxpayer and £500 +# with higher rate income (s. 12B); dividend allowance £500 (s. 13A); dividend +# rates 10.75%, 35.75% and 39.35%. The model's 2027-28 property basic rate is +# 24% (Budget 2026, paragraph 6.246). + +- name: Case 1, dividends of £20,000 take the whole Personal Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + dividend_income: 20_000 + output: + received_allowances_dividend_income: 12_570 + # (20,000 - 12,570 - 500) x 10.75%. + income_tax: 744.975 + +- name: Case 2, savings in the starting rate band leave the Personal Allowance to dividends + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 4_000 + dividend_income: 20_000 + output: + # The £4,000 of savings is the lowest part of Step 3 income, within the + # £5,000 starting rate band, so it bears 0% (s. 12). Set against + # it, the allowance would save nothing; set against dividends it saves + # 10.75%. So all £12,570 comes off dividends, and the tax is as in case 1: + # (20,000 - 12,570 - 500) x 10.75%. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 12_570 + savings_income_tax: 0 + income_tax: 744.975 + +- name: Case 3, the taxpayer sets a trading loss against dividends rather than savings at the starting rate + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 4_000 + dividend_income: 200_000 + trading_loss: 1_000 + output: + # Adjusted net income of £203,000 removes the Personal Allowance. The + # £1,000 of relief (s. 64) comes off dividends: the savings stay at 0% + # in the starting rate band, and the dividends start £1,000 higher, at + # £4,000. Dividends of £199,000 run from £4,000 to £203,000: + # £500 nil rate, (37,700 - 4,500) x 10.75% = 3,569, + # (125,140 - 37,700) x 35.75% = 31,259.80 and + # (203,000 - 125,140) x 39.35% = 30,637.91, so 65,466.71. Set against + # savings, £1,000 more dividends would fall in the ordinary rate band: + # £107.50 more. + personal_allowance: 0 + trade_loss_relief_against_general_income: 1_000 + trade_loss_relief_against_savings_and_dividends: 1_000 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 1_000 + savings_income_tax: 0 + income_tax: 65_466.71 + +- name: Case 4, in 2027-28 the taxpayer sets a trading loss against property income rather than dividends + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + property_income: 20_000 + dividend_income: 200_000 + trading_loss: 1_000 + output: + # No other income, so the taxpayer chooses between property and dividends + # (s. 25(2)); s. 25(3A) changes nothing. Property income sits below dividends (s. 16A). + # Against property, the £1,000 of relief saves 24% on property income in + # the basic rate band, and moves £1,000 of dividends down into the + # ordinary rate band at 10.75%: a net saving of £132.50 over setting it + # against dividends, which would leave Step 3 income and the dividends' + # top of £219,000 unchanged. Property: 19,000 x 24% = 4,560. Dividends of + # £200,000 from £19,000 to £219,000: £500 nil rate, + # (37,700 - 19,500) x 10.75% = 1,956.50, 87,440 x 35.75% = 31,259.80 and + # (219,000 - 125,140) x 39.35% = 36,933.91, so 70,150.21. + personal_allowance: 0 + trade_loss_relief_against_savings_and_dividends: 0 + received_allowances_dividend_income: 0 + property_income_tax: 4_560 + dividend_income_tax: 70_150.21 + income_tax: 74_710.21 + +- name: Case 5, a Scottish taxpayer's savings and dividends follow the UK rates + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 3_000 + savings_interest_income: 4_000 + dividend_income: 20_000 + region: SCOTLAND + output: + # Scottish rates apply only to non-savings income (s. 11A). The pay + # takes £3,000 of the allowance, saving the 19% starter rate. The + # savings stay whole at 0% in the starting rate band (s. 12) and the + # dividends take the other £9,570. Dividends of £10,430 from £4,000: + # (10,430 - 500) x 10.75% on the UK bands (s. 13(5)) = 1,067.475. + # Setting the allowance against savings before dividends would leave + # £14,430 of dividends: £430 more tax. + received_allowances_earned_income: 3_000 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 9_570 + earned_income_tax: 0 + savings_income_tax: 0 + income_tax: 1_067.475 + +- name: Case 6, a Scottish higher rate taxpayer keeps the allowance against pay + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 10_000 + region: SCOTLAND + output: + # Pay of £37,430 after the allowance bears the Scottish rates: + # 3,967 x 19% + 12,989 x 20% + 14,136 x 21% + 6,338 x 42% = 8,982.05. + # Savings run from £37,430 to £47,430. Step 3 income would be higher + # rate income outside Scotland, so the savings allowance is £500 + # (s. 12B(8)(b)(iv)), and the other £9,500 lies above the UK basic rate + # limit, at 40% (s. 11D(6)): 3,800. Moving £270 of the allowance to + # savings, as in case 7, would tax £270 more pay at 42% to take £270 of + # savings out of the 40% band: £5.40 more. + received_allowances_earned_income: 12_570 + received_allowances_savings_income: 0 + earned_income_tax: 8_982.05 + savings_income_tax: 3_800 + income_tax: 12_782.05 + +- name: Case 7, savings across the basic rate limit take part of the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 10_000 + output: + # With all £12,570 against pay, pay of £37,430 bears 20% and the + # £500 savings allowance covers savings from £37,430 to £37,930, all but + # £230 of it below the basic rate limit, where savings would bear + # 20% anyway. Setting £270 against savings instead puts the pay at + # £37,700 and the savings allowance above the limit, where it saves 40%: + # pay 37,700 x 20% = 7,540; savings (47,430 - 38,200) x 40% = 3,692. + # The order pay, savings, dividends charges 7,486 + 3,800 = 11,286. + received_allowances_earned_income: 12_300 + received_allowances_savings_income: 270 + earned_income_tax: 7_540 + savings_income_tax: 3_692 + income_tax: 11_232 + +- name: Case 8, dividends across the basic rate limit take part of the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + dividend_income: 20_000 + output: + # As in case 7, the dividend allowance saves most above the basic rate + # limit. Pay 37,700 x 20% = 7,540; dividends of £19,730 from £37,700: + # £500 nil rate and (57,430 - 38,200) x 35.75% = 6,874.725. The order + # pay, savings, dividends charges 7,486 + 19,500 x 35.75% = 14,457.25. + received_allowances_earned_income: 12_300 + received_allowances_dividend_income: 270 + dividend_income_tax: 6_874.725 + income_tax: 14_414.725 + +- name: Case 9, a person with only savings sets the whole allowance against them + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + savings_interest_income: 20_000 + output: + # Step 3 income of £7,430: £5,000 at the starting rate, £1,000 within + # the savings allowance and (7,430 - 6,000) x 20% = 286. + received_allowances_savings_income: 12_570 + income_tax: 286 + +- name: Case 10, a person with only property income in 2027-28 sets the whole allowance against it + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + property_income: 30_000 + output: + # (30,000 - 12,570) x 24%. + received_allowances_earned_income: 12_570 + property_income_tax: 4_183.2 + income_tax: 4_183.2 + +- name: Case 11, from 2027-28 the allowance comes off pay before dividends + period: 2027 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + dividend_income: 20_000 + output: + # The taxpayer deducts the allowance from income other than property, + # savings and dividend income first (s. 25(3A)), so all £12,570 comes off + # the pay, unlike case 8. Pay 37,430 x 20% = 7,486. Dividends from £37,430: £500 + # at the nil rate and (57,430 - 37,930) x 35.75% = 6,971.25. + received_allowances_earned_income: 12_570 + received_allowances_dividend_income: 0 + dividend_income_tax: 6_971.25 + income_tax: 14_457.25 + +- name: Case 12, pay stops at the basic rate limit with nil-rate savings above it + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 50_000 + savings_interest_income: 300 + dividend_income: 20_000 + output: + # As in case 8, £270 of the allowance comes off dividends, which sit + # above the basic rate limit at 35.75%, rather than off pay at 20%, until + # the pay reaches the £37,700 limit; past it, pay would pay 40%. The + # £300 of savings sits between the pay and the dividends, within the + # £500 savings allowance of a higher rate taxpayer (s. 12B), so no + # allowance comes off it. Pay 37,700 x 20% = 7,540. Dividends of £19,730 + # from £38,000: £500 at the nil rate and (57,730 - 38,500) x 35.75% = + # 6,874.725. All the allowance against pay first would charge 7,486 + + # (57,730 - 38,230) x 35.75% = 14,457.25. + received_allowances_earned_income: 12_300 + received_allowances_savings_income: 0 + received_allowances_dividend_income: 270 + savings_income_tax: 0 + earned_income_tax: 7_540 + dividend_income_tax: 6_874.725 + income_tax: 14_414.725 + +- name: Case 13, the share leaves out the Marriage Allowance a recipient receives + period: 2026 + absolute_error_margin: 0.01 + input: + people: + recipient: + age: 40 + employment_income: 5_000 + property_income: 15_000 + savings_interest_income: 15_000 + partner: + age: 40 + benunit: + members: [recipient, partner] + is_married: true + output: + # The model gives the recipient a Marriage Allowance of £1,260 from the + # partner's unused Personal Allowance: 10% of £12,570, rounded up to a + # multiple of £10 (s. 55B(4) and (5)). Parliament gives it as a tax + # reduction (s. 55B(1)), which the taxpayer deducts at Step 6 of s. 23, + # not as a deduction at Step 3, so the model leaves it out of the share. Pay, property income + # and the savings above the £1,000 savings allowance all bear 20%, and the + # pay and property income leave no starting rate band (s. 12), so no share + # lowers the Step 5 tax, and the model takes the Personal Allowance off the + # pay and then the property income. Property 7,430 x 20% = 1,486; savings + # of £15,000 from £7,430: £1,000 at the savings nil rate and + # 14,000 x 20% = 2,800. + marriage_allowance: [1_260, 0] + received_allowances_earned_income: [12_570, 0] + received_allowances_savings_income: [0, 0] + income_tax_pre_charges: [4_286, 0] + +- name: Case 14, net pay pension relief stays on pay + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + employee_pension_contributions: 10_000 + savings_interest_income: 30_000 + output: + # The model takes employee contributions as a net pay arrangement, which + # the employer deducts from employment income (FA 2004 s. 193(2)). The + # taxpayer can't set that relief against savings, so it stays on the + # pay, and the pay left for the share is £30,000, as if the person had + # pay of £30,000 and no pension. Adjusted net income of £60,000 leaves the + # full £12,570 allowance and a £500 savings allowance (s. 12B). Against + # pay: pay 17,430 x 20% = 3,486; savings from £17,430 to £47,430: £500 at + # the nil rate, (37,700 - 17,930) x 20% = 3,954 and 9,730 x 40% = 3,892. + # Every share keeps the savings allowance below the £37,700 limit, where + # it saves 20%, so no share lowers the tax of 11,332. Sharing the pension + # relief too would put the pay at the limit and charge £100 less. + pension_contributions_relief: 10_000 + received_allowances_earned_income: 22_570 + received_allowances_savings_income: 0 + earned_taxable_income: 17_430 + earned_income_tax: 3_486 + savings_income_tax: 7_846 + income_tax: 11_332 + +- name: Case 15, relief at source on personal pension contributions stays on non-savings income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + personal_pension_contributions: 10_000 + savings_interest_income: 30_000 + output: + # The model takes personal contributions as relief at source. Parliament + # deducts nothing from income: the payer keeps basic rate tax on the + # contribution (FA 2004 s. 192(1)) and, on a claim, the basic and higher + # rate limits rise by the contribution (s. 192(4)). Under the law the + # liability is £13,332 and the payer keeps £2,000; the model deducts the + # relief from non-savings income instead, which gives the same net + # £11,332, as in case 14. + pension_contributions_relief: 10_000 + received_allowances_earned_income: 22_570 + received_allowances_savings_income: 0 + income_tax: 11_332 + +- name: Case 16, trading loss relief set against savings leaves the pay in earned income + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + employment_income: 40_000 + savings_interest_income: 30_000 + trading_loss: 10_000 + output: + # Net income of £60,000 leaves the full allowance and a £500 savings + # allowance. The £10,000 of relief (s. 64) and the £12,570 allowance + # share £22,570. The taxpayer sets £2,300 of the relief against pay, which + # leaves pay at the £37,700 limit, and the other £7,700 and the whole + # allowance against savings (s. 25(2)): savings of £9,730 from £37,700, + # £500 at the nil rate above the limit, where it saves 40%. Pay + # 37,700 x 20% = 7,540; savings 9,230 x 40% = 3,692. The relief against + # pay first charges 11,332. net_income deducts all the relief, so earned + # taxable income adds back the £7,700 set against savings. + trade_loss_relief_against_general_income: 10_000 + trade_loss_relief_against_savings_and_dividends: 7_700 + received_allowances_earned_income: 0 + received_allowances_savings_income: 20_270 + earned_taxable_income: 37_700 + non_savings_income_after_allowances: 37_700 + earned_income_tax: 7_540 + savings_income_tax: 3_692 + income_tax: 11_232 + +- name: Case 17, under a reform that exempts pensions the search stops pay at the exemption + period: 2026 + absolute_error_margin: 0.01 + input: + gov.hmrc.income_tax.earned_taxable_income_exclusions: + - taxable_savings_interest_income + - taxable_dividend_income + - taxable_property_income + - received_allowances_earned_income + - marriage_allowance + - taxable_pension_income + age: 40 + employment_income: 5_000 + private_pension_income: 10_000 + savings_interest_income: 30_000 + output: + # The reform takes the £10,000 pension out of earned income, so the + # model taxes non-savings income only above it. Net income of £45,000 + # leaves the full allowance and a £1,000 savings allowance. With £5,000 of + # the allowance against pay and pension, the £10,000 left is all pension, + # earned income is nil, and the savings start at nil: £22,430 of savings, + # £5,000 at the starting rate (s. 12), £1,000 at the savings nil rate + # (s. 12A) and 16,430 x 20% = 3,286. Setting more against savings makes + # each pound of pay taxable at 20% and shrinks the starting rate band by + # a pound; setting less leaves a pound more of savings at 20%. All the + # allowance against non-savings income first charges + # (30,000 - 6,000) x 20% = 4,800. + received_allowances_earned_income: 5_000 + received_allowances_savings_income: 7_570 + earned_taxable_income: 0 + savings_income_tax: 3_286 + income_tax: 3_286 + +- name: Case 18, trading loss relief past the non-savings income leaves none of it + period: 2026 + absolute_error_margin: 0 + input: + age: 40 + self_employment_income: 30_016.63 + savings_interest_income: 27_285.95 + dividend_income: 74_747.51 + trading_loss: 33_973.11 + output: + # The £33,973.11 of relief exceeds the £30,016.63 of trading income, so + # no non-savings income is left, and the rest of the relief and the + # allowances come off savings and dividends. The model computes the non-savings income left from the + # share's own arithmetic, so float32 rounding in net income plus the + # relief added back leaves nothing above nil. + non_savings_income_after_allowances: 0 + earned_taxable_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml index bc8ca3381e..eba9397fb6 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.yaml @@ -1,59 +1,81 @@ -- name: Dividend income less than remaining allowance +# The allowances and trade loss relief go to non-savings, savings and dividend +# income in the way that gives the least tax (ITA 2007 s. 25(2)). 2024-25: +# Personal Allowance £12,570, starting rate limit for savings £5,000 (s. 12), +# dividend allowance £500 (s. 13A). + +- name: Dividend income less than the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 8_000 - received_allowances_savings_income: 2_000 + taxable_employment_income: 8_000 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 2_000 output: + # The £12,000 of income is all within the allowances. received_allowances_dividend_income: 2_000 -- name: Dividend income equal to remaining allowance +- name: Dividend income equal to the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 - received_allowances_savings_income: 570 + taxable_employment_income: 10_000 + taxable_savings_interest_income: 570 taxable_dividend_income: 2_000 output: received_allowances_dividend_income: 2_000 -- name: Dividend income greater than remaining allowance +- name: Dividend income greater than the allowances left period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_000 - received_allowances_savings_income: 1_000 + taxable_employment_income: 11_000 + taxable_savings_interest_income: 1_000 taxable_dividend_income: 1_000 output: + # The pay takes £11,000. Of the £430 of income left, savings would be + # in the starting rate band and dividends within the dividend + # allowance, both taxed at 0%, so the allowances keep the order + # savings then dividends: £1,000 and £570. received_allowances_dividend_income: 570 -- name: No remaining allowance +- name: Allowances go to dividends beyond the dividend allowance period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_570 - received_allowances_savings_income: 2_000 + taxable_employment_income: 10_570 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - received_allowances_dividend_income: 0 + # After the pay, £1,000 of income remains. Left as £1,000 of dividends, + # £500 would bear 8.75% (s. 13A nil rate on the first £500). Left + # as £500 of savings, in the starting rate band, and £500 of dividends, + # within the nil rate, it all bears 0%. + received_allowances_savings_income: 1_500 + received_allowances_dividend_income: 500 + dividend_income_tax: 0 - name: Negative taxable dividend income period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 - received_allowances_savings_income: 1_000 + taxable_employment_income: 10_000 + taxable_savings_interest_income: 1_000 taxable_dividend_income: -500 output: received_allowances_dividend_income: 0 -- name: Negative remaining allowance +- name: Allowances keep the dividends left within the dividend allowance period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_000 - received_allowances_savings_income: 2_000 + taxable_employment_income: 11_000 + taxable_savings_interest_income: 2_000 taxable_dividend_income: 1_000 output: - received_allowances_dividend_income: 0 \ No newline at end of file + # £1,430 remains after the pay. Up to £500 of dividends bear 0% + # (s. 13A) and savings in the starting rate band 0% (s. 12), so £930 + # of savings and £500 of dividends remain: £1,070 of the allowances + # go to savings and £500 to dividends. + received_allowances_savings_income: 1_070 + received_allowances_dividend_income: 500 + dividend_income_tax: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml index 6acebb4035..55def4eade 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.yaml @@ -32,10 +32,12 @@ period: 2024 input: allowances: 12_570 - net_income: 20_000 + taxable_employment_income: 5_000 taxable_savings_interest_income: 8_000 taxable_dividend_income: 7_000 output: + # The pay would bear 20%, so it takes £5,000 of the allowances + # (ITA 2007 s. 25(2)). received_allowances_earned_income: 5_000 - name: Earned income only, no savings or dividends diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml index ff181a5172..92c707d367 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.yaml @@ -1,53 +1,66 @@ -- name: Savings income less than remaining allowance +# The allowances and trade loss relief go to non-savings, savings and dividend +# income in the way that gives the least tax (ITA 2007 s. 25(2)). 2024-25: +# Personal Allowance £12,570, starting rate limit for savings £5,000 (s. 12), +# personal savings allowance £1,000 for a basic rate taxpayer (s. 12B). + +- name: Savings income less than the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 + taxable_employment_income: 10_000 taxable_savings_interest_income: 2_000 output: + # The £12,000 of income is all within the allowances. received_allowances_savings_income: 2_000 -- name: Savings income equal to remaining allowance +- name: Savings income equal to the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_570 + taxable_employment_income: 10_570 taxable_savings_interest_income: 2_000 output: received_allowances_savings_income: 2_000 -- name: Savings income greater than remaining allowance +- name: Savings income greater than the allowances left after earnings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 11_570 + taxable_employment_income: 11_570 taxable_savings_interest_income: 3_000 output: + # The pay takes £11,570, which would otherwise bear 20%. The + # £2,000 of savings left falls within the £5,000 starting rate band, so + # it bears 0% whichever £1,000 of the allowances it takes. received_allowances_savings_income: 1_000 -- name: No remaining allowance +- name: No allowances go to savings that the starting rate leaves untaxed period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 12_570 + taxable_employment_income: 12_570 taxable_savings_interest_income: 1_000 output: + # Against the savings the allowances would save nothing: the £1,000 is + # in the starting rate band. Against the pay they save 20%. received_allowances_savings_income: 0 - name: Negative taxable savings interest income period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 10_000 + taxable_employment_income: 10_000 taxable_savings_interest_income: -500 output: received_allowances_savings_income: 0 -- name: Negative remaining allowance +- name: Earnings above the allowances leave none for savings period: 2024 input: allowances: 12_570 - received_allowances_earned_income: 13_000 + taxable_employment_income: 13_000 taxable_savings_interest_income: 1_000 output: - received_allowances_savings_income: 0 \ No newline at end of file + # £430 of pay bears 20% and the savings above it the starting + # rate of 0%, so the allowances are worth most against the pay. + received_allowances_savings_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml index e6384e97ee..05e7557487 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/gift_aid.yaml @@ -92,11 +92,15 @@ dividend_income: [10_000, 10_000] gift_aid: [0, 800] output: - earned_taxable_income: [37_430, 37_430] - # The £500 dividend allowance still occupies the band. The extension - # moves £770 of taxed dividends from 35.75% to 10.75%: £192.50 relief. - dividend_income_tax: [3_396.25, 3_203.75] - income_tax: [10_882.25, 10_689.75] + # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic + # rate limit, £37,700, or £38,700 with the £1,000 grossed-up gift, and the + # rest of it comes off dividends, so the £500 dividend allowance covers + # dividends that would otherwise bear 35.75%. Dividends left: + # 9,730 and 8,730; less £500 at the nil rate, at 35.75%. The extension + # keeps £1,000 more pay at 20% in place of upper-rate dividends: £157.50. + earned_taxable_income: [37_700, 38_700] + dividend_income_tax: [3_299.725, 2_942.225] + income_tax: [10_839.725, 10_682.225] - name: Gift Aid extends UK limits across savings and dividend slices period: 2026 @@ -109,12 +113,18 @@ dividend_income: [5_000, 5_000] gift_aid: [0, 1_600] output: - # £2,000 gross extension moves £800 taxed savings down 20 percentage - # points and £700 taxed dividends down 25 percentage points. + # ITA 2007 s. 25(2): the Personal Allowance keeps the pay up to the basic + # rate limit, £37,700, or £39,700 with the £2,000 grossed-up gift, and + # the rest comes off savings above it, taxed at 40%, keeping the £500 + # savings allowance in use. Without the gift: £800 of savings, £500 at + # the nil rate and £300 at 40% = 120; all £5,000 of dividends, £500 at the + # nil rate and £4,500 at 35.75% = 1,608.75; pay 7,540. With it: £500 of + # savings at the nil rate; £3,300 of dividends, (3,300 - 500) x 35.75% = + # 1,001; pay 39,700 x 20% = 7,940. savings_allowance: [500, 500] - savings_income_tax: [460, 300] - dividend_income_tax: [1_608.75, 1_433.75] - income_tax: [9_368.75, 9_033.75] + savings_income_tax: [120, 0] + dividend_income_tax: [1_608.75, 1_001] + income_tax: [9_268.75, 8_941] - name: Gift Aid extends UK savings and dividend limits for Scottish taxpayers period: 2026 @@ -461,8 +471,12 @@ members: [control] region: NORTH_WEST output: - # Both still have higher-rate income, so a £500 savings allowance. Without - # the gift, £9,770 at 20% and £9,730 at 40%; the gift moves £1,000 from - # 40% to 20%: £200. + # Both still have higher-rate income, so a £500 savings allowance. ITA + # 2007 s. 25(2): the Personal Allowance keeps the property income up to + # the basic rate limit, £38,700 with the £1,000 grossed-up gift and + # £37,700 without, and the rest comes off savings, so the savings + # allowance covers savings taxed at 40% rather than 20%. Savings left: + # 8,730 and 9,730, less £500 at the nil rate, at 40%. The gift moves + # £1,000 of income from 40% to 20%: £200. savings_allowance: [500, 500] - savings_income_tax: [5_646, 5_846] + savings_income_tax: [3_292, 3_692] diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml index 5a7f5ad0fd..221c5b036a 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/property_income_band_base.yaml @@ -39,9 +39,15 @@ members: [person] country: ENGLAND output: - # £500 savings allowance, then 9,770 at 20% (1,954) to the basic rate - # limit and 9,730 at 40% (3,892). - savings_income_tax: 5_846 + # Under ITA 2007 s. 25(2) the model sets £2,300 of the Personal Allowance + # against the property income, leaving it at the basic rate limit, + # £37,700, and the other £10,270 against savings, so the £500 savings + # allowance covers savings taxed at 40% rather than 20%. Savings left: + # 9,730, less £500 at the nil rate (s. 12A), at 40%: 3,692. Property + # income 37,700 at 20%: 7,540. The old order, with all the allowance + # against property income, charged 5,486 and 5,846: £100 more. + savings_income_tax: 3_692 + income_tax: 7_540 + 3_692 - name: Case 3, pay and property income together form the band base. period: 2026 @@ -119,7 +125,7 @@ taxed_income: 47_430 personal_pension_contributions_tax: 4_000 -- name: Case 7, Marriage Allowance still comes off the band base (pinned; #1947). +- name: Case 7, Marriage Allowance leaves the band base unchanged. period: 2026 absolute_error_margin: 0.01 input: @@ -134,8 +140,10 @@ members: [person] country: ENGLAND output: - # earned_taxable_income subtracts Marriage Allowance, which the law gives - # as a tax reduction (ITA 2007 s. 55B), so the base is 30,000 - 12,570 - - # 1,260 = 16,170 rather than 17,430. Pinned here until #1947 is fixed. - non_savings_taxable_income: 16_170 + # Parliament gives the Marriage Allowance as a tax reduction at Step 6 + # (ITA 2007 ss. 55A(2) and 55B(1)), so it leaves the base at + # 30,000 - 12,570 = 17,430. + non_savings_taxable_income: 17_430 + # Step 5: 17,430 x 20% = 3,486. Step 6: 3,486 - 20% x 1,260 = 3,234. + income_tax: 3_234 non_savings_income_after_allowances: 17_430 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml index a7365d8d5e..90776b41bc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_general_income.yaml @@ -110,7 +110,7 @@ # (40,000 - 12,570 - 500) x 10.75%: the tax on £40,000 of dividends. income_tax: 2_894.98 -- name: Relief takes non-savings income first, then savings, then dividends +- name: Relief and allowances leave savings that the starting rate and savings allowance cover period: 2026 absolute_error_margin: 0.01 input: @@ -121,13 +121,16 @@ output: trade_loss_relief_against_general_income: 20_000 trade_loss_relief_against_savings_and_dividends: 15_000 - # The £15,000 of relief left after the pay, with the Personal - # Allowance, takes all £10,000 of savings and £17,570 of dividends. + # ITA 2007 s. 25(2) sets the £32,570 of relief and Personal Allowance + # against income in the way that gives the least tax. The pay takes + # £5,000. Of Step 3 income of £12,430, savings up to £6,000 bear + # 0%: £5,000 at the starting rate (s. 12) and £1,000 within the savings + # allowance (s. 12A). So savings take £4,000 and dividends £23,570. received_allowances_earned_income: 0 - received_allowances_savings_income: 10_000 - received_allowances_dividend_income: 17_570 - # £25,000 of dividends is left: (25,000 - 12,570 - 500) x 10.75%. - income_tax: 1_282.48 + received_allowances_savings_income: 4_000 + received_allowances_dividend_income: 23_570 + # £6,430 of dividends remains: (6,430 - 500) x 10.75%. + income_tax: 637.475 - name: Adjusted total income is total income after employment expenses period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml new file mode 100644 index 0000000000..9ce78c0e74 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/stamp_duty/stamp_duty_land_tax_commencement.yaml @@ -0,0 +1,426 @@ +# Purchases from issue #2801 in model years 2015 to 2018. Annual parameters +# sample 30 April, so model year 2015 reads the law on 30 April 2015, and so on. +# Commencement: Finance Act 2018 s. 41(8) (first-time buyers' relief, from 22 +# November 2017); Finance Act 2016 s. 128(5) (higher rates for additional +# dwellings, from 1 April 2016); Finance Act 2016 s. 127(12) (non-residential +# rates and the 2% lease rent band, from 17 March 2016). Before those dates: +# Finance Act 2003 s. 55(1B) Table A and s. 55(2) Table B, and Sch 5 para 2(3) +# Table B, each as at 30 April 2015. HMRC charges SDLT on land in Wales until +# 31 March 2018 (s. 48(1)(a) as at 30 April 2017). Parliament ended SDLT in +# Wales from 1 April 2018 (Wales Act 2014 s. 16(2), (4); SI 2018/214 art. +# 2(a)), and the Welsh Revenue Authority charges Land Transaction Tax from then +# (LTTADT(W)A 2017 s. 2; WSI 2018/34 art. 3); the Welsh Ministers set LTT's +# rates in WSI 2018/128. + +- name: First-time buyer, £300,000, model year 2015, pays the main rates + # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). + period: 2015 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 5_000 + +- name: First-time buyer, £450,000, model year 2015, pays the main rates + # 2,500 + 5% x 200,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 12_500 + +- name: First-time buyer, £300,000, model year 2016, pays the main rates + # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). + period: 2016 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 5_000 + +- name: First-time buyer, £450,000, model year 2016, pays the main rates + # 2,500 + 5% x 200,000 under s. 55(1B) Table A. + period: 2016 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 12_500 + +- name: First-time buyer, £300,000, model year 2017, pays the main rates + # 2% x 125,000 + 5% x 50,000 under s. 55(1B) Table A; Sch 6ZA starts on 22 November 2017 (FA 2018 s. 41(8)). + period: 2017 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 5_000 + +- name: First-time buyer, £450,000, model year 2017, pays the main rates + # 2,500 + 5% x 200,000 under s. 55(1B) Table A. + period: 2017 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 12_500 + +- name: First-time buyer, £200,000, model year 2016, pays the main rates + # 2% x 75,000 under s. 55(1B) Table A. + period: 2016 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 200_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 1_500 + +- name: First-time buyer, £300,000, model year 2018, gets the relief + # Sch 6ZA para 4: 0% to £300,000. + period: 2018 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 300_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 0 + +- name: First-time buyer, £450,000, model year 2018, gets the relief + # Sch 6ZA para 4: 5% x 150,000. + period: 2018 + absolute_error_margin: 0.01 + input: + main_residential_property_purchased: 450_000 + main_residential_property_purchased_is_first_home: true + output: + stamp_duty_land_tax: 7_500 + +- name: Additional dwelling, £300,000, model year 2015, pays the main rates + # Sch 4ZA starts on 1 April 2016 (FA 2016 s. 128(5)); before then s. 55(1B) Table A applies. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 5_000 + +- name: Additional dwelling, £100,000, model year 2015, pays the main rates + # 0% on the first £125,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 0 + +- name: Additional dwelling, £1,000,000, model year 2015, pays the main rates + # 2,500 + 33,750 + 10% x 75,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 43_750 + +- name: Additional dwelling, £1,600,000, model year 2015, pays the main rates + # 2,500 + 33,750 + 57,500 + 12% x 100,000 under s. 55(1B) Table A. + period: 2015 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 1_600_000 + output: + stamp_duty_land_tax: 105_750 + +- name: Additional dwelling, £300,000, model year 2016, pays the higher rates + # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). + period: 2016 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + +- name: Additional dwelling, £100,000, model year 2016, pays the higher rates + # 3% x 100,000 under Sch 4ZA para 1(2). + period: 2016 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 3_000 + +- name: Additional dwelling, £300,000, model year 2017, pays the higher rates + # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). + period: 2017 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + +- name: Additional dwelling, £100,000, model year 2017, pays the higher rates + # 3% x 100,000 under Sch 4ZA para 1(2). + period: 2017 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 3_000 + +- name: Additional dwelling, £300,000, model year 2018, pays the higher rates + # 3% x 125,000 + 5% x 125,000 + 8% x 50,000 under Sch 4ZA para 1(2). + period: 2018 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + +- name: Additional dwelling, £100,000, model year 2018, pays the higher rates + # 3% x 100,000 under Sch 4ZA para 1(2). + period: 2018 + absolute_error_margin: 0.01 + input: + additional_residential_property_purchased: 100_000 + output: + stamp_duty_land_tax: 3_000 + +- name: Non-residential purchase, £1,000,000, model year 2015, pays one percentage of the whole price + # 4% x 1,000,000 under s. 55(2) Table B, before 17 March 2016 (FA 2016 s. 127(12)). + period: 2015 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 40_000 + +- name: Non-residential purchase, £250,000, model year 2015, pays the lower band's percentage of the whole price + # 1% x 250,000: under s. 55(2) Table B a price of £250,000 is "More than + # £150,000 but not more than £250,000", before 17 March 2016 (FA 2016 s. 127(12)). + period: 2015 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 250_000 + output: + stamp_duty_land_tax: 2_500 + +- name: Non-residential purchase, £1,000,000, model year 2016, pays the sliced rates + # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2016 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 39_500 + +- name: Non-residential purchase, £1,000,000, model year 2017, pays the sliced rates + # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2017 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 39_500 + +- name: Non-residential purchase, £1,000,000, model year 2018, pays the sliced rates + # 2% x 100,000 + 5% x 750,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2018 + absolute_error_margin: 0.01 + input: + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 39_500 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2015, pays 1% + # 1% x 1,000,000 under Sch 5 para 2(3) Table B, which had no band above 1% before 17 March 2016. + period: 2015 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 10_000 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2016, pays 2% + # 2% x 1,000,000 on rent above £5,000,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2016 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 20_000 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2017, pays 2% + # 2% x 1,000,000 on rent above £5,000,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2017 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 20_000 + +- name: Non-residential lease, £1,000,000 of rent above £5,000,000 cumulative, model year 2018, pays 2% + # 2% x 1,000,000 on rent above £5,000,000, from 17 March 2016 (FA 2016 s. 127(12)). + period: 2018 + absolute_error_margin: 0.01 + input: + non_residential_rent: 1_000_000 + cumulative_non_residential_rent: 5_000_000 + output: + stamp_duty_land_tax: 20_000 + +- name: Wales, main home, £300,000, model year 2015, pays SDLT + # FA 2003 s. 48(1)(a) charges SDLT on land in Wales until 1 April 2018; 2% x 125,000 + 5% x 50,000. + period: 2015 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, main home, £300,000, model year 2016, pays SDLT + # FA 2003 s. 48(1)(a) charges SDLT on land in Wales until 1 April 2018; 2% x 125,000 + 5% x 50,000. + period: 2016 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, main home, £300,000, model year 2017, pays SDLT + # FA 2003 s. 48(1)(a) charges SDLT on land in Wales until 1 April 2018; 2% x 125,000 + 5% x 50,000. + period: 2017 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, main home, £300,000, model year 2018, pays LTT + # WSI 2018/128 from 1 April 2018: 3.5% x 70,000 + 5% x 50,000. + period: 2018 + absolute_error_margin: 0.01 + input: + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: false + ltt_liable: true + stamp_duty_land_tax: 0 + land_transaction_tax: 4_950 + +- name: Wales, additional dwelling, £300,000, model year 2015, pays SDLT at the main rates + period: 2015 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: Wales, additional dwelling, £300,000, model year 2016, pays SDLT at the higher rates + period: 2016 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + land_transaction_tax: 0 + +- name: Wales, additional dwelling, £300,000, model year 2017, pays SDLT at the higher rates + period: 2017 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 14_000 + land_transaction_tax: 0 + +- name: Wales, additional dwelling, £300,000, model year 2018, pays LTT + # LTT higher rates in gov.wra.land_transaction_tax.residential.higher_rate from 1 April 2018: 3% x 180,000 + 6.5% x 70,000 + 8% x 50,000. + period: 2018 + absolute_error_margin: 0.01 + input: + country: WALES + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 0 + land_transaction_tax: 13_950 + +- name: A reform can charge a 2015 non-residential purchase on slices + # 1% x 100,000 + 3% x 250,000 + 4% x 500,000. + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price: false + non_residential_property_purchased: 1_000_000 + output: + stamp_duty_land_tax: 28_500 + +- name: A reform can keep SDLT in Wales after 2018 + period: 2019 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.applies_in_wales: true + country: WALES + main_residential_property_purchased: 300_000 + output: + sdlt_liable: true + ltt_liable: false + stamp_duty_land_tax: 5_000 + land_transaction_tax: 0 + +- name: A 2015 reform to the main rates reaches a buyer of an additional dwelling + # 3% x 125,000 + 5% x 50,000, the reformed main rates. + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.residential.purchase.main.subsequent[1].rate: 0.03 + additional_residential_property_purchased: 300_000 + output: + stamp_duty_land_tax: 6_250 + +- name: A 2015 reform with a positive first non-residential threshold charges nothing below it + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.non_residential.purchase[0].threshold: 10_000 + gov.hmrc.stamp_duty.non_residential.purchase[0].rate: 0.005 + non_residential_property_purchased: 5_000 + output: + stamp_duty_land_tax: 0 + +- name: A 2015 reform with a positive first non-residential threshold charges its rate above it + # 0.5% x 20,000 on the whole price. + period: 2015 + absolute_error_margin: 0.01 + input: + gov.hmrc.stamp_duty.non_residential.purchase[0].threshold: 10_000 + gov.hmrc.stamp_duty.non_residential.purchase[0].rate: 0.005 + non_residential_property_purchased: 20_000 + output: + stamp_duty_land_tax: 100 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml new file mode 100644 index 0000000000..b5864a09ac --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/student_loans/plan_5_repayment_start.yaml @@ -0,0 +1,70 @@ +# SI 2009/470 reg. 15(2BA), inserted by SI 2022/1335 reg. 15(9): "a borrower +# is not required to repay any part of a plan 5 loan on or before 5th April +# 2026". Reg. 15(2BB) omits (2BA) on 6 April 2026. +# +# The model reads year Y's parameters at 30 April of Y, so 2025 reads the law +# for 2025-26 (no Plan 5 repayment due) and 2026 reads 2026-27 (due). +# Repayment income here equals employment income: there is no other income, +# deduction or benefit in kind. + +- name: A Plan 5 borrower on £40,000 owes nothing in 2024-25 + period: 2024 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies, so nil. + student_loan_repayment: 0 + +- name: A Plan 5 borrower on £40,000 owes nothing in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies, so nil. + student_loan_repayment: 0 + +- name: A Plan 5 borrower on £40,000 repays 9% above £25,000 in 2026-27 + period: 2026 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: PLAN_5 + output: + # 0.09 x (40,000 - 25,000) = 0.09 x 15,000 = 1,350 + student_loan_repayment: 1_350 + +- name: A Plan 5 borrower on £200,000 owes nothing in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 200_000 + student_loan_plan: PLAN_5 + output: + # Reg. 15(2BA) applies however far income exceeds the £25,000 threshold. + student_loan_repayment: 0 + +- name: A Plan 2 borrower on £60,000 repays 9% above £28,470 in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 60_000 + student_loan_plan: PLAN_2 + output: + # Plan 2 threshold from 6 April 2025: £28,470. + # 0.09 x (60,000 - 28,470) = 0.09 x 31,530 = 2,837.70 + student_loan_repayment: 2_837.70 + +- name: A postgraduate borrower on £40,000 repays 6% above £21,000 in 2025-26 + period: 2025 + input: + age: 25 + employment_income: 40_000 + student_loan_plan: POSTGRADUATE + output: + # Postgraduate threshold £21,000, rate 6%. + # 0.06 x (40,000 - 21,000) = 0.06 x 19,000 = 1,140 + student_loan_repayment: 1_140 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml index fcb5a62706..a8b479db56 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/vat/vat_grossing.yaml @@ -5,11 +5,24 @@ full_rate_vat_consumption: 10_000 reduced_rate_vat_consumption: 1_000 output: - # (10,000 x 20% + 1,000 x 5%) / (0.67 x 0.7) = 2,050 / 0.469 = 4,371.00, - # less private school fees' official share of 2025-26 VAT receipts - # (0.008368), which private_school_vat counts separately. - vat: 4_334.43 - baseline_vat: 4_334.43 + # (10,000 x 20% + 1,000 x 5%) / (0.69 x 0.7) = 2,050 / 0.483 = 4,244.31, + # times one minus private school fees' official share of 2025-26 VAT + # receipts (0.008368), which private_school_vat counts separately: + # 2,050 / 0.483 x (1 - 0.008368) = 4,208.79. + vat: 4_208.79 + baseline_vat: 4_208.79 + +- name: VAT in 2024 is grossed up by the same product as later years + period: 2024 + absolute_error_margin: 0.01 + input: + full_rate_vat_consumption: 10_000 + reduced_rate_vat_consumption: 1_000 + output: + # The deprecated combined factor has one undated value, so 2024 takes the + # two factors' product too: 2,050 / 0.483 + vat: 4_244.31 + baseline_vat: 4_244.31 - name: With both factors at one, VAT is the statutory liability on recorded consumption period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml index 42ee19cbed..3073fabe9e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/applicant_or_partner.yaml @@ -222,12 +222,15 @@ 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] + # rate, and the enhanced disability premium (22.00), which Scottish + # councils pay for attendance allowance from 1 April 2023 (SSI 2021/249 + # Sch 1 para 14(1)(g)): (44.85 + 86.05 + 22.00) * 52. The young + # mother's disability does not give the grandmother a premium, and at 17 + # neither parent counts as a non-dependant who bars the severe disability + # premium. + council_tax_reduction_premiums: [7_950.80] # Single aged 25 or over, 95.55 * 52 = 4,968.60, plus the premiums. - council_tax_reduction_applicable_amount: [11_775.40] + council_tax_reduction_applicable_amount: [12_919.40] - name: Adult parents in a lone-applying head's benefit unit are her non-dependants and bar her severe disability premium period: 2026 @@ -268,9 +271,11 @@ # 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] + # The disability premium and the enhanced disability premium for her + # attendance allowance (SSI 2021/249 Sch 1 para 14(1)(g)) remain: + # (44.85 + 22.00) * 52. + council_tax_reduction_premiums: [3_476.20] + council_tax_reduction_applicable_amount: [8_444.80] - name: A blind grandmother head who applies alone has no non-dependant deduction in Merton period: 2026 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 b323679c75..fa457103a8 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 @@ -250,13 +250,14 @@ council_tax_reduction_claimant_benunit: [true, false] # The adult child's own scheme (Maidstone's working-age one) is not # simulated, but the household's bill is: the head's pensioner claim - # covers all of it (the non-working child has no banding income, so the - # work gate leaves the scale's zero). The child's report cannot be added - # on top. + # covers all of it, less the non-dependant deduction for the adult child, + # who is not in remunerative work, not a student and has no award. The + # council deducts the lowest amount (SI 2012/2885 Sch 1 para 8(1)(b): 5.20 * 52 = + # 270.40). The child's report cannot be added on top. council_tax_reduction_scheme_supported: [true, false] council_tax_reduction_household_has_simulated_claim: true - council_tax_benefit: [1_800, 0] - council_tax_reduction: 1_800 + council_tax_benefit: [1_529.60, 0] + council_tax_reduction: 1_529.60 - name: Where no claim is simulated, every family keeps its reported reduction period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml index 720ebe1016..dd3a9e2b63 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/claimant_household_head.yaml @@ -718,9 +718,8 @@ # student shares liability with a non-student, the non-student is solely # liable (LGFA 1992 s.75(4)), so the co-tenant claims on the whole bill # and the head is the co-tenant's non-dependant. No deduction is made for - # a full-time student non-dependant (reg 90(7)(c)). That exemption - # remains for #2007; the model's national deduction is nil here because - # the head has no banding income, which the work gate leaves at zero. + # a full-time student non-dependant (reg 90(7)(c)), so the co-tenant's + # reduction covers the whole bill. council_tax_reduction_liable_person: [true, true] council_tax_reduction_claimant_benunit: [false, true] council_tax_reduction_joint_liability_share: [0.5, 1] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_capital_valuation.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_capital_valuation.yaml new file mode 100644 index 0000000000..05431b2f90 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_capital_valuation.yaml @@ -0,0 +1,262 @@ +# Outside the Pension Credit, Universal Credit and passported routes, each +# scheme calculates capital at its current market or surrender value less, +# where there would be expenses attributable to the sale, 10 per cent, and +# less the amount of any encumbrance secured on it (SI 2012/2885 Sch 1 para +# 32; SI 2012/2886 Sch para 65; WSI 2013/3029 Sch 1 para 26 and Sch 6 para +# 28; SSI 2012/319 reg 42; SSI 2012/303 reg 46; SSI 2021/249 reg 70(1)). +# Expected values are worked by hand from those provisions. + +- name: The household route values mortgaged land less sale expenses and the mortgage + period: 2026 + absolute_error_margin: 0 + input: + people: + applicant: + age: 40 + benunits: + benunit: + members: applicant + would_claim_uc: false + households: + household: + members: applicant + country: ENGLAND + local_authority: MAIDSTONE + # 50,000 less 10% (5,000) less the 20,000 secured on it = 25,000. + owned_land: 50_000 + owned_land_secured_debt: 20_000 + output: + council_tax_reduction_assessable_capital: 25_000 + +# Each scheme calculates capital outside the United Kingdom at its current +# market or surrender value in that country, or, where that country prohibits +# transferring that value to the United Kingdom, at the price it would realise +# if sold in the United Kingdom to a willing buyer, less, where there would be +# expenses attributable to sale, 10 per cent and the amount of any +# encumbrances secured on it (SI 2012/2885 Sch 1 para 33; SI 2012/2886 Sch +# para 66; WSI 2013/3029 Sch 1 para 27 and Sch 6 para 29; SSI 2012/319 reg +# 43; SSI 2012/303 reg 47; SSI 2021/249 reg 70(2) with reg 70(1)). Other real +# estate, whose location the data don't record, falls under one rule or the +# other, and both take off 10%. The data hold no secured debt for either, so +# the household route counts each at 90% of its value. Each case puts a +# working-age applicant without Universal Credit and a pensioner without +# Pension Credit in households of their own. + +- name: England's schemes count overseas property less 10% outside the Pension Credit and Universal Credit routes + period: 2026 + absolute_error_margin: 0 + input: + people: + worker: + age: 40 + pensioner: + age: 70 + benunits: + worker_unit: + members: worker + would_claim_uc: false + pensioner_unit: + members: pensioner + would_claim_pc: false + households: + worker_home: + members: worker + country: ENGLAND + local_authority: MAIDSTONE + overseas_property_value: 100_000 + pensioner_home: + members: pensioner + country: ENGLAND + local_authority: MAIDSTONE + overseas_property_value: 100_000 + output: + council_tax_reduction_pensioner: [false, true] + # 100,000 less 10% (10,000) = 90,000. + council_tax_reduction_assessable_capital: [90_000, 90_000] + +- name: England's schemes count other real estate less 10% outside the Pension Credit and Universal Credit routes + period: 2026 + absolute_error_margin: 0 + input: + people: + worker: + age: 40 + pensioner: + age: 70 + benunits: + worker_unit: + members: worker + would_claim_uc: false + pensioner_unit: + members: pensioner + would_claim_pc: false + households: + worker_home: + members: worker + country: ENGLAND + local_authority: MAIDSTONE + other_property_value: 50_000 + pensioner_home: + members: pensioner + country: ENGLAND + local_authority: MAIDSTONE + other_property_value: 50_000 + output: + council_tax_reduction_pensioner: [false, true] + # 50,000 less 10% (5,000) = 45,000. + council_tax_reduction_assessable_capital: [45_000, 45_000] + +- name: Wales's schemes count overseas property less 10% outside the Pension Credit and Universal Credit routes + period: 2026 + absolute_error_margin: 0 + input: + people: + worker: + age: 40 + pensioner: + age: 70 + benunits: + worker_unit: + members: worker + would_claim_uc: false + pensioner_unit: + members: pensioner + would_claim_pc: false + households: + worker_home: + members: worker + country: WALES + local_authority: CARDIFF + overseas_property_value: 100_000 + pensioner_home: + members: pensioner + country: WALES + local_authority: CARDIFF + overseas_property_value: 100_000 + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_assessable_capital: [90_000, 90_000] + +- name: Wales's schemes count other real estate less 10% outside the Pension Credit and Universal Credit routes + period: 2026 + absolute_error_margin: 0 + input: + people: + worker: + age: 40 + pensioner: + age: 70 + benunits: + worker_unit: + members: worker + would_claim_uc: false + pensioner_unit: + members: pensioner + would_claim_pc: false + households: + worker_home: + members: worker + country: WALES + local_authority: CARDIFF + other_property_value: 50_000 + pensioner_home: + members: pensioner + country: WALES + local_authority: CARDIFF + other_property_value: 50_000 + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_assessable_capital: [45_000, 45_000] + +- name: Scotland's schemes count overseas property less 10% outside the Pension Credit and Universal Credit routes + period: 2026 + absolute_error_margin: 0 + input: + people: + worker: + age: 40 + pensioner: + age: 70 + benunits: + worker_unit: + members: worker + would_claim_uc: false + pensioner_unit: + members: pensioner + would_claim_pc: false + households: + worker_home: + members: worker + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + overseas_property_value: 100_000 + pensioner_home: + members: pensioner + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + overseas_property_value: 100_000 + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_assessable_capital: [90_000, 90_000] + +- name: Scotland's schemes count other real estate less 10% outside the Pension Credit and Universal Credit routes + period: 2026 + absolute_error_margin: 0 + input: + people: + worker: + age: 40 + pensioner: + age: 70 + benunits: + worker_unit: + members: worker + would_claim_uc: false + pensioner_unit: + members: pensioner + would_claim_pc: false + households: + worker_home: + members: worker + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + other_property_value: 50_000 + pensioner_home: + members: pensioner + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + other_property_value: 50_000 + output: + council_tax_reduction_pensioner: [false, true] + council_tax_reduction_assessable_capital: [45_000, 45_000] + +- name: A working-age applicant with a Universal Credit award has overseas and other real estate counted through Universal Credit's capital + # SI 2012/2886 Sch para 37(6): with an award of Universal Credit, the + # authority uses the Secretary of State's calculation of capital. + # uc_assessable_capital counts the overseas property and other real estate + # less 10% (UC Regs 2013 reg 49(1)): 9,000 + 4,500 = 13,500. Universal + # Credit counts no land, where the household route would add the 1,000 of + # land less 10%, 900, and give 14,400. Capital of 13,500 leaves the + # applicant within the 16,000 limit with an award. + period: 2026 + absolute_error_margin: 0 + input: + people: + applicant: + age: 40 + benunits: + benunit: + members: applicant + would_claim_uc: true + households: + household: + members: applicant + country: ENGLAND + local_authority: MAIDSTONE + overseas_property_value: 10_000 + other_property_value: 5_000 + owned_land: 1_000 + output: + uc_assessable_capital: 13_500 + council_tax_reduction_universal_credit_award: true + council_tax_reduction_assessable_capital: 13_500 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml new file mode 100644 index 0000000000..2d5b93dc14 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.yaml @@ -0,0 +1,376 @@ +# Scottish councils include the carer premium in the working-age council tax +# reduction where the applicant or partner has regular and substantial caring +# responsibilities for a severely disabled person (SSI 2021/249 Sch 1 para +# 5(2)), and the Scottish Ministers count among those responsibilities "an +# award of universal credit which includes the carer element described in +# regulation 29 of the 2013 Regulations" (para 6(1)(c)). No other scheme's +# carer premium has that limb: under the Housing Benefit, English and Welsh +# schedules, DWP and councils test entitlement to Carer's Allowance or Carer +# Support Payment (HB Regs 2006 Sch 3 para 17; WSI 2013/3029 Sch 7 para 14), +# as Scottish councils did for persons under pension age before 1 April 2022 +# (SSI 2012/303 Sch 1 para 14). From 1 April 2022 to 31 March +# 2025 an "award of universal credit" included "a universal credit award with +# a value of zero" (reg 4, as amended by SSI 2022/52 reg 3); from 1 April 2025 +# it "means an award of universal credit made under Part 1 of the 2012 Act" +# (SSI 2025/24 reg 8), and an income that tapers the award to nil leaves no +# award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). An income +# that tapers the amount payable below one penny leaves no award to have a +# value of zero in any year: DWP makes no award or the award ends (UC Claims +# and Payments Regs 2013 reg 32A(1)), and the Scottish Government's policy +# note to SSI 2025/24 confines the earlier words to an award a sanction +# reduces to zero. So the model needs an award of at least one penny in every +# year from 2022-23. +# +# Amounts: carer premium £48.15 a week from 1 April 2026 (SSI 2021/249 Sch 1 +# para 5(1), substituted by SSI 2026/56), £46.40 in 2025-26, £45.60 in +# 2024-25 and £38.85 in 2022-23 (para 17, as substituted by SSI 2022/52 reg +# 25(c)(ii); para 5(1) kept £37.70 until SSI 2023/38 amended both from +# 1 April 2023); personal allowance for a single person aged 25 or over +# £95.55 (2026-27) and £77.00 (2022-23); Universal Credit carer element +# £209.34 a month (2026-27), £201.68 (2025-26), £198.31 (2024-25) and £168.81 +# (2022-23), standard allowance £424.90, £400.14, £393.45 and £334.91. + +- name: The issue's Scottish carer aged 40 with 35 hours and no Carer's Allowance claim gets the carer premium + # The award includes the carer element, 209.34 x 12 = 2,512.08, so the + # council includes the carer premium (para 6(1)(c)) and the applicable + # amount comes to (95.55 + 48.15) x 52 = 7,472.40, not 95.55 x 52 = + # 4,968.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 209.34 * 12 + universal_credit: (424.90 + 209.34) * 12 + receives_uc_carer_element: true + is_entitled_to_carer_benefit: false + carer_premium: 0 + council_tax_reduction_carer_premium: 48.15 * 52 + council_tax_reduction_applicable_amount: (95.55 + 48.15) * 52 + +- name: The Scottish carer premium for a carer element starts with the 2021 Regulations on 1 April 2022 + # The model reads model year 2022 (2022-23) at 30 April 2022. Carer element + # 168.81 x 12 = 2,025.72; carer premium 38.85 x 52 = 2,020.20; applicable + # amount (77.00 + 38.85) x 52. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 168.81 * 12 + council_tax_reduction_carer_premium: 38.85 * 52 + council_tax_reduction_applicable_amount: (77.00 + 38.85) * 52 + +- name: Before 1 April 2022 the Scottish scheme for persons under pension age tested Carer's Allowance only + # Under SSI 2012/303 Sch 1 para 14, councils included the premium for an + # applicant or partner "entitled to a carer's allowance". The model reads + # model year 2021 (2021-22) at 30 April 2021, so the carer element, 163.73 + # x 12 = 1,964.76, brings no premium. + period: 2021 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 163.73 * 12 + council_tax_reduction_carer_premium: 0 + +- name: The same carer in England gets no carer premium for the carer element + # In England the model takes the working-age premiums from the Housing + # Benefit schedule, whose carer premium needs entitlement to Carer's + # Allowance (HB Regs 2006 Sch 3 para 17(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: ENGLAND + output: + uc_carer_element: 209.34 * 12 + receives_uc_carer_element: true + council_tax_reduction_carer_premium: 0 + council_tax_reduction_applicable_amount: 95.55 * 52 + +- name: The same carer in Wales gets no carer premium for the carer element + # Welsh councils include the premium for an applicant or partner "entitled + # to a carer's allowance" (WSI 2013/3029 Sch 7 para 14(1)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: WALES + output: + uc_carer_element: 209.34 * 12 + receives_uc_carer_element: true + council_tax_reduction_carer_premium: 0 + council_tax_reduction_applicable_amount: 95.55 * 52 + +- name: A Scottish carer whose income tapers the award to nil has no award and no carer premium + # With £20,000 of earnings the award comes to nil, which in 2026 leaves no + # award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), and para + # 6(1)(c) does not apply, though the maximum amount still includes the + # carer element. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 209.34 * 12 + universal_credit: 0 + receives_uc_carer_element: false + council_tax_reduction_carer_premium: 0 + +- name: In 2024 a Scottish carer whose earnings taper the award to nil has no award and no carer premium + # The same carer in 2024-25. The maximum amount comes to (393.45 + 198.31) + # x 12 = 7,101.12, and the taper, 0.55 x 17,942.66 = 9,868.46 on £20,000 + # of earnings less income tax and National Insurance, takes more, so the + # award comes to nil. Until 31 March 2025 an "award of universal credit" + # included "a universal credit award with a value of zero" (reg 4), but an + # income that fails the financial condition leaves no award to have that + # value (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), so para + # 6(1)(c) does not apply: no carer premium, where the model would + # otherwise include 45.60 x 52 = 2,371.20. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 198.31 * 12 + uc_maximum_amount: 7_101.12 + uc_earned_income: 17_942.66 + universal_credit: 0 + receives_uc_carer_element: false + is_entitled_to_carer_benefit: false + carer_premium: 0 + council_tax_reduction_carer_premium: 0 + +- name: From 1 April 2025 the same Scottish carer's nil award leaves no award + # The same carer in 2025-26. The maximum amount comes to (400.14 + 201.68) + # x 12 = 7,221.84, and the taper again takes more, so the award comes to + # nil. From 1 April 2025 reg 4 defines an award of universal credit as one + # "made under Part 1 of the 2012 Act" (SSI 2025/24 reg 8), and an income + # that tapers it to nil leaves no award, so para 6(1)(c) does not apply: no + # carer premium. Neither of reg 10A's nil awards (a sanction, or a reclaim + # under the UC Claims and Payments Regs 2013 reg 32A) arises. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + uc_carer_element: 201.68 * 12 + universal_credit: 0 + receives_uc_carer_element: false + is_entitled_to_carer_benefit: false + council_tax_reduction_carer_premium: 0 + +- name: A Scottish carer with both Carer Support Payment and the carer element gets one premium + # The applicant has regular and substantial caring responsibilities under + # both para 6(1)(a) and (c), and councils include the premium once for an + # applicant with those responsibilities (para 5(2)): 48.15 x 52 = + # 2,503.80, not twice that. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + receives_carer_benefit: true + receives_uc_carer_element: true + carer_premium: 48.15 * 52 + council_tax_reduction_carer_premium: 48.15 * 52 + +- name: Scottish partners caring for the same person get one premium, whichever holds the carer element + # Both care 35 hours. The younger receives Carer Support Payment and the + # joint award includes the carer element for the elder (UC Regs 2013 reg + # 29(3)). Councils include one premium under para 5(3) and (4), in respect + # of the one whose award includes the carer element: 48.15 x 52 = + # 2,503.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + elder: + age: 45 + care_hours: 35 + would_claim_carers_allowance: false + younger: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + benunits: + couple: + members: [elder, younger] + households: + household: + members: [elder, younger] + country: SCOTLAND + region: SCOTLAND + output: + partners_care_for_same_severely_disabled_person: true + receives_carer_benefit: [false, true] + receives_uc_carer_element: [true, false] + council_tax_reduction_carer_premium: 48.15 * 52 + +- name: A Scottish pensioner's schedule has no carer element limb + # Under Scotland's pension-age scheme, councils test entitlement to Carer's + # Allowance or Carer Support Payment (SSI 2012/319 Sch 1 para 10(1)), and + # the working-age variable keeps the shared premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 70 + care_hours: 35 + would_claim_carers_allowance: false + benunits: + carer_unit: + members: [carer] + households: + household: + members: [carer] + country: SCOTLAND + region: SCOTLAND + output: + council_tax_reduction_pensioner: true + council_tax_reduction_carer_premium: 0 + council_tax_reduction_premiums: 0 + +- name: A dependant's caring does not raise the Scottish carer premium + # Councils include the premium where "an applicant or the applicant's + # partner" has caring responsibilities (para 5(2)). The son receives Carer + # Support Payment for caring for his grandmother, but councils count only + # the applicant and partner, so his parent's family gets no carer premium + # (48.15 x 52 = 2,503.80 if his caring counted). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + son: + age: 17 + care_hours: 35 + would_claim_carers_allowance: true + grandmother: + age: 80 + aa_category: HIGHER + benunits: + family: + members: [parent, son] + grandmother_unit: + members: [grandmother] + households: + household: + members: [parent, son, grandmother] + country: SCOTLAND + region: SCOTLAND + output: + is_claimant_or_partner: [true, false, true] + receives_carer_benefit: [false, true, false] + carer_premium: [0, 0] + council_tax_reduction_carer_premium: [0, 0] 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 45a9f2ec1f..ced91b4985 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 @@ -10,13 +10,14 @@ # Neither counts the Scottish Carer Supplement, paid with CSP from 15 March # 2026: reg 57(1) does not list it and reg 27(1)(j)(xxib) excepts it. In law # the supplement's tax does not reach Council Tax Reduction either: at working -# age SSI 2021/249 deducts tax only from earnings, and at pension age SSI -# 2012/319 reg 31(12) disregards the tax payable on income taken into -# account, which the supplement is not. The pension-age route deducts all -# income tax, so there the taxable supplement reaches the model's Council Tax -# Reduction income through income tax and in no other way. Scotland's -# working-age route deducts only the tax on earnings and counts unearned -# income gross (reg 57), so the supplement's tax does not reach it. +# age the Scottish Ministers let the council deduct tax only from earnings (SSI +# 2021/249), and at pension age the council disregards the tax payable on +# income taken into account (SSI 2012/319 reg 31(12)), which the supplement is +# not. The model deducts only the tax each scheme allows +# (council_tax_reduction_tax_deduction). At working age that is the tax +# earnings add, and at pension age the tax on the counted income with the +# supplement above it, so without earnings the supplement does not reach the +# model's Council Tax Reduction income. # Neither instrument disregards CSP above the CA amount. # # Weekly rates. 2026: CSP and CA 86.45, Scottish Carer Supplement 11.70, @@ -260,7 +261,7 @@ # jumps to 2,000 when the carer moves to Carer Support Payment in 2025. council_tax_reduction: 2_000 - 0.2 * (7_000 + 81.90 * 52 - (90.50 + 45.60) * 52) -- name: Pension-age Council Tax Reduction counts Carer Support Payment, and the supplement only through tax +- name: Pension-age Council Tax Reduction counts Carer Support Payment and deducts no tax on the supplement period: 2026 absolute_error_margin: 0.01 input: @@ -287,13 +288,19 @@ # first 3,967 at the Scottish starter rate of 19% and 2,566.80 at 20%. # The supplement adds 608.40 x 20% = 121.68 of that tax. income_tax: 3_967 * 0.19 + 2_566.80 * 0.20 - # 14,000 + 4,495.40 - 1,267.09: the supplement is not added. - council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_267.09 + # Under SSI 2012/319 reg 31(12), the council disregards only the tax on + # income taken into account. The supplement is not, so the formula leaves + # out its tax, as the highest part of income: 14,000 + 4,495.40 - 12,570 = + # 5,925.40 taxable, 3,967 x 19% = 753.73 and 1,958.40 x 20% = 391.68, so + # 1,145.41. + council_tax_reduction_tax_deduction: 3_967 * 0.19 + 1_958.40 * 0.20 + # 14,000 + 4,495.40 - 1,145.41: neither the supplement nor its tax counts. + council_tax_reduction_applicable_income: 14_000 + 86.45 * 52 - 1_145.41 council_tax_reduction_applicable_amount: (256 + 48.15) * 52 # Income is above the minimum guarantee of (238 + 48.15) x 52 = 14,879.80. guarantee_credit: 0 - # 2,000 - 0.2 x (17,228.31 - 15,815.80) - council_tax_reduction: 1_717.50 + # 2,000 - 0.2 x (17,349.99 - 15,815.80) + council_tax_reduction: 1_693.16 - name: A pension-age couple's Council Tax Reduction counts the partner's Carer Support Payment period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml index 9005d13a41..8fa02cd473 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_devolved_working_age.yaml @@ -457,6 +457,43 @@ council_tax_reduction_working_age_applicable_income: 17_827 simulated_council_tax_reduction_benunit: 397.80 +- name: Wales, a Step 6 reduction larger than the tax on earnings comes off the tax on the pension + # Taxable income 13,000 + 3,000 - 12,570 = 3,430, so the tax before Step 6 is + # 686, and the lowest slice, the earnings, bears 430 x 20% = 86 of it. The + # partner's 1,260 of Marriage Allowance takes 252 off at Step 6, earnings + # first, so the tax on earnings is nil and the pension bears the other 434. + # Income: 13,000 - 34.40 Class 1 - 520 + 3,000 - 434 = 15,011.60; applicable + # amount 159.85 x 52 = 8,312.20; award 1,800 - 0.2 x 6,699.40 = 460.12. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 13_000 + private_pension_income: 3_000 + weekly_hours: 20 + council_tax_benefit_reported: 100 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + region: WALES + country: WALES + council_tax: 1_800 + savings: 0 + output: + marriage_allowance: [1_260, 0] + uc_income_tax_on_earnings: [0, 0] + council_tax_reduction_working_age_unearned_income_tax: 434 + council_tax_reduction_working_age_applicable_income: 15_011.60 + simulated_council_tax_reduction_benunit: 460.12 + - name: Wales, the £17.10 needs the disabled member of a couple to be the one in work # Sch 8 para 18(2)(b)(v): the disabled claimant does not work, so only the # £20 disability disregard applies (para 4), 1,040 a year. diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.yaml index c63c7d59df..d96e582366 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.yaml @@ -1,31 +1,73 @@ -# Income bands apply only in remunerative work (SI 2012/2885 Sch 1 para -# 8(1)-(2)). This scoped gate retains the existing HB scale and the exclusion -# of other investment income from gross income, and leaves a non-worker with -# no banding income at the scale's zero; national CTR rates, the exemptions -# and any extension of the income base remain for #2007. +# The national Council Tax Reduction non-dependant deduction bands normal +# gross weekly income, including investment income, but only for a +# non-dependant in remunerative work (SI 2012/2885 Sch 1 para 8(1)-(2)); the council +# deducts the lowest amount for one outside remunerative work whatever their income. The +# England pensioner scheme's 2025-26 scale (SI 2025/39): under £266 a week +# 5.00; £266 10.20; £463 12.80; £577 and above 15.35. -- name: Other investment income does not band a non-worker for CTR +- name: Investment income does not band a non-worker for CTR period: 2025 + absolute_error_margin: 0.01 input: - council_tax_reduction_individual_non_dep_deduction_eligible: true - other_investment_income: 28_912 # 556 weekly, no hours + people: + claimant: + age: 70 + is_household_head: true + adult_child: + age: 30 + other_investment_income: 28_912 # 556 a week, no hours + current_education: NOT_IN_EDUCATION + benunits: + claimant_family: + members: [claimant] + adult_child_family: + members: [adult_child] + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 + households: + household: + members: [claimant, adult_child] + council_tax: 2_000 + country: ENGLAND + local_authority: MAIDSTONE output: - # The same as a non-dependant with no income. Banding the £556 would give - # 115.45 * 52 = 6,003.40. - council_tax_reduction_individual_non_dep_deduction: 0 + # Not in remunerative work: the lowest amount, 5.00 * 52, as for a + # non-dependant with no income. Banding the £556 would give 12.80 * 52. + council_tax_reduction_individual_non_dep_deduction: [0, 260] -- name: Other investment income does not move a worker's CTR band +- name: A worker's CTR band counts their investment income period: 2025 + absolute_error_margin: 0.01 input: - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 1_950 # 37.5 hours a week - employment_income: 9_517 # just over 183 weekly - other_investment_income: 28_912 # 556 weekly + people: + claimant: + age: 70 + is_household_head: true + adult_child: + age: 30 + hours_worked: 1_950 # 37.5 hours a week + employment_income: 9_517 # just over 183 a week + other_investment_income: 28_912 # 556 a week + current_education: NOT_IN_EDUCATION + benunits: + claimant_family: + members: [claimant] + adult_child_family: + members: [adult_child] + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 + households: + household: + members: [claimant, adult_child] + council_tax: 2_000 + country: ENGLAND + local_authority: MAIDSTONE output: - # Banded on the £183 of earnings: 45.15 * 52 = 2,347.80. - council_tax_reduction_individual_non_dep_deduction: 45.15 * 52 + # In remunerative work, banded on all gross income, 739.02 a week: the + # band from £577, 15.35 * 52. + council_tax_reduction_individual_non_dep_deduction: [0, 798.20] -- name: A pensioner's CTR is not cut for a non-dependant's investment income +- name: A pensioner's CTR is cut by the lowest amount for a non-working non-dependant with investment income period: 2025 absolute_error_margin: 0.01 input: @@ -36,11 +78,14 @@ adult_child: age: 30 other_investment_income: 28_912 # 556 a week, no hours + current_education: NOT_IN_EDUCATION benunits: claimant_family: members: [claimant] adult_child_family: members: [adult_child] + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 households: household: members: [claimant, adult_child] @@ -48,6 +93,7 @@ rent: 15_000 council_tax: 2_000 country: ENGLAND + local_authority: MAIDSTONE output: - # Banding the £556 would give 115.45 * 52 = 6,003.40. - council_tax_reduction_non_dep_deductions: [0, 0] + # 5.00 * 52; banding the £556 would give 12.80 * 52 = 665.60. + council_tax_reduction_non_dep_deductions: [260, 0] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.yaml new file mode 100644 index 0000000000..71c342a701 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.yaml @@ -0,0 +1,1284 @@ +# National Council Tax Reduction schemes' own non-dependant deductions: +# England pensioners SI 2012/2885 Sch 1 para 8; Wales WSI 2013/3029 Sch 1 +# para 3 and Sch 6 para 5; Scotland SSI 2012/319 reg 48 and SSI 2021/249 +# reg 90. 2026-27 weekly sums, with bands including their lower edge: +# England: under 279 5.20; 279 10.60; 485 13.30; 605 and above 15.95. +# Wales: under 279 6.70; 279 13.35; 485 16.80; 605 and above 20.05. +# Scotland: under 286 5.55; 286 10.95; 497 13.90; 614 and above 16.55. +# The council deducts the lowest amount for a non-dependant outside remunerative work (under 16 hours). +# The applicant is the benefit unit with the household's oldest member, so +# working-age applicants here are 60. + +- name: England in remunerative work with no income pays the lowest amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: England just below 279 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 14507.48 + age: 40 + current_education: NOT_IN_EDUCATION + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # 278.99 a week + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: England exactly 279 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 14508 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: England exactly 485 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 25220 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 691.60] + council_tax_reduction_non_dep_deductions: [691.60, 0] + +- name: England just below 605 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 31459.48 + age: 40 + current_education: NOT_IN_EDUCATION + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # 604.99 a week + council_tax_reduction_individual_non_dep_deduction: [0, 691.60] + council_tax_reduction_non_dep_deductions: [691.60, 0] + +- name: England exactly 605 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 31460 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 829.40] + council_tax_reduction_non_dep_deductions: [829.40, 0] + +- name: England not in remunerative work pays the lowest amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 40000 + weekly_hours: 15.9 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: England 2025-26 top amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 30004 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 798.20] + council_tax_reduction_non_dep_deductions: [798.20, 0] + +- name: Scotland pensioner claimant, below 286 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 14871.48 + age: 40 + current_education: NOT_IN_EDUCATION + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + # 285.99 a week + council_tax_reduction_individual_non_dep_deduction: [0, 288.60] + council_tax_reduction_non_dep_deductions: [288.60, 0] + +- name: Scotland pensioner claimant, exactly 286 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 14872 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 569.40] + council_tax_reduction_non_dep_deductions: [569.40, 0] + +- name: Scotland working-age claimant, exactly 497 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 25844 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 722.80] + council_tax_reduction_non_dep_deductions: [722.80, 0] + +- name: Scotland working-age claimant, exactly 614 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 31928 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 860.60] + council_tax_reduction_non_dep_deductions: [860.60, 0] + +- name: Scotland 2025-26 lowest amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 40000 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + +- name: Wales pensioner claimant, not in remunerative work + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 40000 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 348.40] + council_tax_reduction_non_dep_deductions: [348.40, 0] + +- name: Wales pensioner claimant, exactly 279 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 14508 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 694.20] + council_tax_reduction_non_dep_deductions: [694.20, 0] + +- name: Wales working-age claimant, exactly 485 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 25220 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 873.60] + council_tax_reduction_non_dep_deductions: [873.60, 0] + +- name: Wales working-age claimant, exactly 605 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 31460 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 1_042.60] + council_tax_reduction_non_dep_deductions: [1_042.60, 0] + +- name: Wales 2025-26 lowest amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 325.00] + council_tax_reduction_non_dep_deductions: [325.00, 0] + +- name: English working-age households have no national deduction + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 36400 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # Unsupported English working-age local schemes keep reported CTB. + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: A full-time student is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 20 + current_education: TERTIARY + council_tax_reduction_non_dep_gross_income: 20000 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # SI 2012/2885 Sch 1 para 8(7)(c) + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: A 40-year-old on Income Support is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 1000 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # para 8(8)(a) + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: A 40-year-old on income-based JSA is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 1000 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: A 40-year-old on income-related ESA is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 1000 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: A non-dependant on State Pension Credit is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 90 + son: + age: 70 + council_tax_reduction_non_dep_gross_income: 9000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 1000 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # The claimant is strictly older, so the son is an eligible non-dependant + # and the zero comes from the SPC exemption (para 8(8)(a)), not from the + # son also counting as the applicant. + council_tax_reduction_individual_non_dep_deduction_eligible: [false, true] + council_tax_reduction_non_dep_deduction_exempt: [false, true] + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: The same non-dependant without State Pension Credit pays the lowest amount + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 90 + son: + age: 70 + council_tax_reduction_non_dep_gross_income: 9000 + weekly_hours: 0 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_non_dep_deduction_exempt: [false, false] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Universal Credit without earned income is exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # para 8(8)(c) + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: Universal Credit with earned income is not exempt + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + employment_income: 2000 + weekly_hours: 10 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] + council_tax_reduction_non_dep_deductions: [270.40, 0] + +- name: Scotland Income Support exemption applies at working age too + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 1000 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + # SSI 2021/249 reg 90(8)(a) + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +# The UC limb starts in 2015-16 in England (SI 2014/3312) and Wales (WSI +# 2015/44). The model no longer has parameters for several other inputs in +# 2014, so the year before is not simulated here; Scotland's later start +# (2016-17) is tested in 2015-16 below. +- name: "England: Universal Credit exemption from 2015-16" + period: 2015 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # inserted by SI 2014/3312 + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: "Wales: Universal Credit exemption from 2015-16" + period: 2015 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + # added by WSI 2015/44 + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: "Scotland: no Universal Credit exemption in 2015-16" + period: 2015 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_individual_non_dep_deduction: [0, 200.20] + council_tax_reduction_non_dep_deductions: [200.20, 0] + +- name: "Scotland: Universal Credit exemption from 2016-17" + period: 2016 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + age: 40 + current_education: NOT_IN_EDUCATION + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son] + country: SCOTLAND + local_authority: GLASGOW_CITY + council_tax: 2_000 + savings: 0 + output: + # inserted by SSI 2016/81 + council_tax_reduction_individual_non_dep_deduction: [0, 0.00] + council_tax_reduction_non_dep_deductions: [0.00, 0] + +- name: No deduction where the applicant is blind + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + is_blind: True + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 36400 + weekly_hours: 35 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # para 8(6)(a); the son's own amount is 15.95 a week, but none is deducted. + council_tax_reduction_individual_non_dep_deduction: [0, 829.40] + council_tax_reduction_non_dep_deductions: [0, 0] + +- name: "England: one deduction for a non-dependant couple, on their joint income" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + age: 40 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 15600 + weekly_hours: 35 + partner: + age: 38 + current_education: NOT_IN_EDUCATION + council_tax_reduction_non_dep_gross_income: 0 + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son, partner] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [claimant, son, partner] + country: ENGLAND + local_authority: MAIDSTONE + council_tax: 2_000 + savings: 0 + output: + # para 8(3)-(4): joint 300 a week is in the 279 band (10.60); for the partner + # outside remunerative work the council would deduct 5.20; it deducts only the higher. + council_tax_reduction_individual_non_dep_deduction: [0, 551.20, 270.40] + council_tax_reduction_non_dep_deductions: [551.20, 0] + +- name: "Wales working age: both members of a couple on Universal Credit are deducted" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 60 + son: + employment_income: 15600 + age: 40 + current_education: NOT_IN_EDUCATION + weekly_hours: 35 + partner: + age: 38 + current_education: NOT_IN_EDUCATION + employment_income: 1000 + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son, partner] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son, partner] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + # WSI 2013/3029 Sch 6 para 5(3): one deduction per couple "other than where + # there is an award of universal credit". Joint 319.23 a week: 13.35 and 6.70. + council_tax_reduction_individual_non_dep_deduction: [0, 694.20, 348.40] + council_tax_reduction_non_dep_deductions: [1_042.60, 0] + +- name: "Wales pensioner: one deduction for a couple on Universal Credit" + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 70 + son: + employment_income: 15600 + age: 40 + current_education: NOT_IN_EDUCATION + weekly_hours: 35 + partner: + age: 38 + current_education: NOT_IN_EDUCATION + employment_income: 1000 + weekly_hours: 0 + benunits: + claimant_unit: + members: [claimant] + son_unit: + members: [son, partner] + would_claim_uc: false + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + universal_credit_pre_benefit_cap: 1000 + households: + household: + members: [claimant, son, partner] + country: WALES + local_authority: CARDIFF + council_tax: 2_000 + savings: 0 + output: + # Sch 1 para 3(3) has no Universal Credit exception. + council_tax_reduction_individual_non_dep_deduction: [0, 694.20, 348.40] + council_tax_reduction_non_dep_deductions: [694.20, 0] + +- name: A Pension Credit pensioner living with a working son (issue 1934) + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + state_pension: 9_000 + son: + age: 40 + employment_income: 30_000 + weekly_hours: 37.5 + current_education: NOT_IN_EDUCATION + benunits: + benunit: + members: [pensioner] + claims_all_entitled_benefits: true + son_benunit: + members: [son] + would_claim_uc: false + households: + household: + members: [pensioner, son] + country: ENGLAND + local_authority: MAIDSTONE + tenure_type: OWNED_OUTRIGHT + council_tax: 2_000 + savings: 0 + output: + in_receipt_of_guarantee_credit: [true, false] + # 30,000 / 52 = 576.92 a week, in the 485 band: 13.30 a week, 691.60 a + # year. On the Housing Benefit scale it was 119.85 a week, which took the + # whole 2,000. + council_tax_reduction_non_dep_deductions: [691.60, 0] + council_tax_reduction: 1_308.40 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions_mixed_schemes.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions_mixed_schemes.yaml new file mode 100644 index 0000000000..f2f1382d1d --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions_mixed_schemes.yaml @@ -0,0 +1,249 @@ +# Jointly liable applicants under different national schemes. Each applicant's +# scheme follows its own family (SI 2012/2885 reg 3; council_tax_reduction_ +# pensioner), and a non-dependant of two jointly liable people is apportioned +# equally between them (Sch 1 para 8(5); WSI 2013/3029 Sch 1 para 3(5) and +# Sch 6 para 5(5); SSI 2021/249 reg 90(5)). 2026-27 scales: England +# pensioners 279 band 10.60; Wales 279 band 13.35; Scotland lowest 5.55, 286 +# band 10.95. + +- name: England, working-age head and pension-age sharer, deduction from the sharer's pensioner claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + is_household_head: true + current_education: NOT_IN_EDUCATION + sharer: + age: 70 + current_education: NOT_IN_EDUCATION + non_dependant: + age: 25 + employment_income: 15_600 # 300 a week + hours_worked: 1_040 # 20 a week + current_education: NOT_IN_EDUCATION + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + non_dependant_family: + members: [non_dependant] + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 + households: + home: + members: [head, sharer, non_dependant] + country: ENGLAND + local_authority: MAIDSTONE + rent: 12_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + savings: 0 + output: + # The pension-age sharer's claim bears half of 10.60 * 52 = 551.20. The + # working-age head is in Maidstone's local scheme, so no national + # deduction; the person-level variable reports the head's scheme. + council_tax_reduction_non_dep_deductions: [0, 275.60, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 0, 0] + +- name: Wales, pension-age head and working-age sharer with a non-dependant UC couple + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + current_education: NOT_IN_EDUCATION + first_partner: + age: 30 + employment_income: 10_400 + hours_worked: 832 # 16 a week + current_education: NOT_IN_EDUCATION + second_partner: + age: 30 + employment_income: 5_200 + hours_worked: 832 + current_education: NOT_IN_EDUCATION + sharer: + age: 30 + current_education: NOT_IN_EDUCATION + benunits: + head_family: + members: [head] + would_claim_uc: false + couple_family: + members: [first_partner, second_partner] + universal_credit_pre_benefit_cap: 3_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, first_partner, second_partner, sharer] + country: WALES + rent: 12_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + savings: 0 + output: + # The couple's joint 357.69 a week is in the 279 band (13.35 * 52 = + # 694.20). The pension-age head's claim takes one deduction for the couple + # (Sch 1 para 3(3)); the working-age sharer's takes one for each member + # of a couple with a UC award (Sch 6 para 5(3)). Each bears half. + council_tax_reduction_non_dep_deductions: [347.10, 0, 694.20] + +- name: Wales, working-age head and pension-age sharer with a non-dependant UC couple + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + is_household_head: true + current_education: NOT_IN_EDUCATION + first_partner: + age: 30 + employment_income: 10_400 + hours_worked: 832 + current_education: NOT_IN_EDUCATION + second_partner: + age: 30 + employment_income: 5_200 + hours_worked: 832 + current_education: NOT_IN_EDUCATION + sharer: + age: 70 + current_education: NOT_IN_EDUCATION + benunits: + head_family: + members: [head] + would_claim_uc: false + couple_family: + members: [first_partner, second_partner] + universal_credit_pre_benefit_cap: 3_000 + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + households: + home: + members: [head, first_partner, second_partner, sharer] + country: WALES + rent: 12_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + savings: 0 + output: + council_tax_reduction_non_dep_deductions: [694.20, 0, 347.10] + +- name: Scotland, working-age head and pension-age sharer, the partner rule only for the working-age claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_household_head: true + current_education: NOT_IN_EDUCATION + sharer: + age: 70 + current_education: NOT_IN_EDUCATION + recipient: + age: 35 + esa_income_reported: 5_200 + current_education: NOT_IN_EDUCATION + worker: + age: 35 + employment_income: 15_600 + hours_worked: 1_560 # 30 a week + current_education: NOT_IN_EDUCATION + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + couple_family: + members: [recipient, worker] + esa_income: 5_200 + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 + households: + home: + members: [head, sharer, recipient, worker] + country: SCOTLAND + rent: 12_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + savings: 0 + output: + # Working-age head (SSI 2021/249 reg 90(8)(a), reg 4(1)): the worker is + # the partner of an income-related ESA claimant not on UC, so no + # deduction. Pension-age sharer (SSI 2012/319 reg 48): only the recipient + # is exempt; the worker is banded on the couple's 400 a week, 10.95 * 52 + # = 569.40, half each. The person-level flags report the head's scheme, + # so the worker is exempt there. The 70-year-old sharer is on Pension + # Credit, so their own flag is true too; it matters only for a + # non-dependant. + council_tax_reduction_non_dep_deductions: [0, 284.70, 0] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true, true] + council_tax_reduction_non_dep_deduction_exempt: [false, true, true, true] + +- name: Scotland, pension-age head and working-age sharer, the partner rule only for the working-age claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 70 + is_household_head: true + current_education: NOT_IN_EDUCATION + sharer: + age: 40 + current_education: NOT_IN_EDUCATION + recipient: + age: 35 + esa_income_reported: 5_200 + current_education: NOT_IN_EDUCATION + worker: + age: 35 + employment_income: 15_600 + hours_worked: 1_560 + current_education: NOT_IN_EDUCATION + benunits: + head_family: + members: [head] + would_claim_uc: false + sharer_family: + members: [sharer] + liable_for_share_of_household_rent: true + would_claim_uc: false + couple_family: + members: [recipient, worker] + esa_income: 5_200 + would_claim_uc: false + universal_credit_pre_benefit_cap: 0 + households: + home: + members: [head, sharer, recipient, worker] + country: SCOTLAND + rent: 12_000 + tenure_type: RENT_PRIVATELY + council_tax: 2_000 + savings: 0 + output: + # Under the pension-age head's scheme, only the ESA recipient is exempt; + # the 70-year-old head's own flag is true because they are on Pension + # Credit. + council_tax_reduction_non_dep_deductions: [284.70, 0, 0] + council_tax_reduction_individual_non_dep_deduction_eligible: [false, false, true, true] + council_tax_reduction_non_dep_deduction_exempt: [true, false, true, false] diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.yaml index ea70a23fdd..064cdf59f2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.yaml @@ -4,8 +4,10 @@ # Sch 1 para 3 and Sch 6 para 5. Gross income includes statutory pay and # maintenance; the listed disability payments are disregarded. # Hand arithmetic: wages 9,000 + statutory pay 520 = 9,520, just above -# 183/week. The existing national consumers use the HB scale (45.15/week -# at this income in 2025); correcting CTR rates is separate (#2007). +# 183/week. Each national scheme bands it on its own 2025-26 scale, below +# the first band's lower edge, so the council deducts the scheme's lowest +# amount for the non-dependant: England 5.00 (SI 2025/39), Scotland 5.35 (SSI 2025/24) and Wales +# 6.25 (WSI 2025/59) a week. # Annual wages and statutory pay can relate to different parts of the year; # leave days themselves are not remunerative work (HB reg 6(7)). @@ -50,9 +52,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 260] + council_tax_reduction_non_dep_deductions: [260, 0] + simulated_council_tax_reduction_benunit: [3_740, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_maternity_pay with ENGLAND claimant aged 70 period: 2025 @@ -95,9 +97,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 260] + council_tax_reduction_non_dep_deductions: [260, 0] + simulated_council_tax_reduction_benunit: [3_740, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_paternity_pay with ENGLAND claimant aged 70 period: 2025 @@ -140,9 +142,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 260] + council_tax_reduction_non_dep_deductions: [260, 0] + simulated_council_tax_reduction_benunit: [3_740, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_sick_pay with SCOTLAND claimant aged 40 period: 2025 @@ -185,9 +187,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + simulated_council_tax_reduction_benunit: [3_721.80, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_maternity_pay with SCOTLAND claimant aged 40 period: 2025 @@ -230,9 +232,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + simulated_council_tax_reduction_benunit: [3_721.80, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_paternity_pay with SCOTLAND claimant aged 40 period: 2025 @@ -275,9 +277,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + simulated_council_tax_reduction_benunit: [3_721.80, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_sick_pay with SCOTLAND claimant aged 70 period: 2025 @@ -320,9 +322,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + simulated_council_tax_reduction_benunit: [3_721.80, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_maternity_pay with SCOTLAND claimant aged 70 period: 2025 @@ -365,9 +367,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + simulated_council_tax_reduction_benunit: [3_721.80, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_paternity_pay with SCOTLAND claimant aged 70 period: 2025 @@ -410,9 +412,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 278.20] + council_tax_reduction_non_dep_deductions: [278.20, 0] + simulated_council_tax_reduction_benunit: [3_721.80, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_sick_pay with WALES claimant aged 40 period: 2025 @@ -455,9 +457,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 325] + council_tax_reduction_non_dep_deductions: [325, 0] + simulated_council_tax_reduction_benunit: [3_675, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_maternity_pay with WALES claimant aged 40 period: 2025 @@ -500,9 +502,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 325] + council_tax_reduction_non_dep_deductions: [325, 0] + simulated_council_tax_reduction_benunit: [3_675, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_paternity_pay with WALES claimant aged 40 period: 2025 @@ -545,9 +547,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 325] + council_tax_reduction_non_dep_deductions: [325, 0] + simulated_council_tax_reduction_benunit: [3_675, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_sick_pay with WALES claimant aged 70 period: 2025 @@ -590,9 +592,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 325] + council_tax_reduction_non_dep_deductions: [325, 0] + simulated_council_tax_reduction_benunit: [3_675, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_maternity_pay with WALES claimant aged 70 period: 2025 @@ -635,9 +637,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 325] + council_tax_reduction_non_dep_deductions: [325, 0] + simulated_council_tax_reduction_benunit: [3_675, 0] - name: council_tax_reduction_non_dep_gross_income includes statutory_paternity_pay with WALES claimant aged 70 period: 2025 @@ -680,9 +682,9 @@ council_tax_reduction_maximum_eligible_liability: 4_000 output: council_tax_reduction_non_dep_gross_income: [0, 9_520] - council_tax_reduction_individual_non_dep_deduction: [0, 2_347.80] - council_tax_reduction_non_dep_deductions: [2_347.80, 0] - simulated_council_tax_reduction_benunit: [1_652.20, 0] + council_tax_reduction_individual_non_dep_deduction: [0, 325] + council_tax_reduction_non_dep_deductions: [325, 0] + simulated_council_tax_reduction_benunit: [3_675, 0] - name: council_tax_reduction_non_dep_gross_income counts tax-exempt maintenance and periodic benefits period: 2025 @@ -719,3 +721,41 @@ miscellaneous_income: -300 output: council_tax_reduction_non_dep_gross_income: 1_000 + +- name: council_tax_reduction_non_dep_gross_income counts other investment income + # DWP tells councils to count earnings and any other income (HB guidance + # A5.542). + period: 2025 + input: + social_security_income: 0 + employment_income: 1_000 + other_investment_income: 2_000 + output: + council_tax_reduction_non_dep_gross_income: 3_000 + +- name: council_tax_reduction_non_dep_gross_income counts a self-employment or property loss as nil + # The model has profits, not the receipts DWP HB guidance A5.551 counts + # before business expenses. As an approximation, a loss counts as nil and + # does not reduce other income. + period: 2025 + input: + social_security_income: 0 + employment_income: 5_000 + self_employment_income: -3_000 + property_income: -1_000 + output: + council_tax_reduction_non_dep_gross_income: 5_000 + +- name: council_tax_reduction_non_dep_gross_income counts ISA interest and dividends + # DWP tells councils to count earnings and other income (HB guidance + # A5.542), including savings interest (A5.570). This case supplies an + # exempt-interest total, so the model counts that total once. + period: 2025 + input: + social_security_income: 0 + employment_income: 9_000 + individual_savings_account_interest_income: 500 + tax_free_savings_income: 20_000 + individual_savings_account_dividend_income: 30_000 + output: + council_tax_reduction_non_dep_gross_income: 59_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_remunerative_work.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_remunerative_work.yaml index 4c9caa26cc..c3dd3a9753 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_remunerative_work.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_remunerative_work.yaml @@ -1,171 +1,348 @@ # England SI 2012/2885 Sch 1 para 8 and reg 10; Scotland SSI 2021/249 # reg 90 and reg 10, SSI 2012/319 reg 48 and reg 6; Wales WSI 2013/3029 # Sch 1 para 3, Sch 6 para 5 and reg 10: income banding requires remunerative -# work. This scoped fix retains the consumer's inherited HB scale: the -# 2026-27 flat amount is 20.40/week and 403.85/week of income gives 105.20. -# Actual national CTR flat amounts are England 5.20, Scotland 5.55, Wales -# 6.70; their scales remain for #2007. These are work-gate regressions. A -# non-worker with no banding income keeps the scale's zero: the gate only -# lowers a deduction until #2007 models the exemptions in paragraphs (6) to -# (8) with the flat charge. +# work. Under each scheme's 2026-27 scale, the council deducts the lowest amount +# for a non-dependant outside remunerative work (England 5.20, SI 2026/27; Scotland 5.55, SSI +# 2026/56; Wales 6.70, WSI 2026/13), and 403.85/week of income in work falls +# in the band from £279 in England (10.60) and Wales (13.35) and from £286 in +# Scotland (10.95). The cases hold the family's awards, which a single +# non-dependant's gross income would count, at zero. English cases give the household a +# pension-age applicant, since England's national scheme covers pension-age +# applicants only, and the model works out each family's scheme. - name: ENGLAND council_tax_reduction bands IIDB only from 16 weekly hours (0 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 0 - employment_income: 0 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 0 + employment_income: 0 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 0 - council_tax_reduction_non_dep_gross_income: 12000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 0] + council_tax_reduction_non_dep_gross_income: [0, 12000] + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] - name: ENGLAND council_tax_reduction bands IIDB only from 16 weekly hours (15 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 780 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 780 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 15 - council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 15] + council_tax_reduction_non_dep_gross_income: [0, 21000] + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] - name: ENGLAND council_tax_reduction bands IIDB only from 16 weekly hours (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 16 - council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 16] + council_tax_reduction_non_dep_gross_income: [0, 21000] + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] - name: ENGLAND council_tax_reduction bands IIDB only from 16 weekly hours (37.5 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 1950 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 1950 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 37.5 - council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 37.5] + council_tax_reduction_non_dep_gross_income: [0, 21000] + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] - name: ENGLAND council_tax_reduction bands child maintenance only from 16 weekly hours (0 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 0 - employment_income: 0 - maintenance_income: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 0 + employment_income: 0 + maintenance_income: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 0 - council_tax_reduction_non_dep_gross_income: 12000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 0] + council_tax_reduction_non_dep_gross_income: [0, 12000] + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] - name: ENGLAND council_tax_reduction bands child maintenance only from 16 weekly hours (15 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 780 - employment_income: 9000 - maintenance_income: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 780 + employment_income: 9000 + maintenance_income: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 15 - council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 15] + council_tax_reduction_non_dep_gross_income: [0, 21000] + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] - name: ENGLAND council_tax_reduction bands child maintenance only from 16 weekly hours (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - maintenance_income: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + maintenance_income: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 16 - council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 16] + council_tax_reduction_non_dep_gross_income: [0, 21000] + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] - name: ENGLAND council_tax_reduction bands child maintenance only from 16 weekly hours (37.5 weekly hours) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 1950 - employment_income: 9000 - maintenance_income: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 1950 + employment_income: 9000 + maintenance_income: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - weekly_hours: 37.5 - council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_pensioner: [true, false] + weekly_hours: [0, 37.5] + council_tax_reduction_non_dep_gross_income: [0, 21000] + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] - name: SCOTLAND council_tax_reduction bands IIDB only from 16 weekly hours (0 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -179,12 +356,20 @@ output: weekly_hours: 0 council_tax_reduction_non_dep_gross_income: 12000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 288.60 - name: SCOTLAND council_tax_reduction bands IIDB only from 16 weekly hours (15 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -198,12 +383,20 @@ output: weekly_hours: 15 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 288.60 - name: SCOTLAND council_tax_reduction bands IIDB only from 16 weekly hours (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -217,12 +410,20 @@ output: weekly_hours: 16 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 569.40 - name: SCOTLAND council_tax_reduction bands IIDB only from 16 weekly hours (37.5 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -236,12 +437,20 @@ output: weekly_hours: 37.5 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 569.40 - name: SCOTLAND council_tax_reduction bands child maintenance only from 16 weekly hours (0 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -255,12 +464,20 @@ output: weekly_hours: 0 council_tax_reduction_non_dep_gross_income: 12000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 288.60 - name: SCOTLAND council_tax_reduction bands child maintenance only from 16 weekly hours (15 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -274,12 +491,20 @@ output: weekly_hours: 15 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 288.60 - name: SCOTLAND council_tax_reduction bands child maintenance only from 16 weekly hours (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -293,12 +518,20 @@ output: weekly_hours: 16 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 569.40 - name: SCOTLAND council_tax_reduction bands child maintenance only from 16 weekly hours (37.5 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: SCOTLAND council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -312,12 +545,20 @@ output: weekly_hours: 37.5 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 569.40 - name: WALES council_tax_reduction bands IIDB only from 16 weekly hours (0 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -331,12 +572,20 @@ output: weekly_hours: 0 council_tax_reduction_non_dep_gross_income: 12000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 348.40 - name: WALES council_tax_reduction bands IIDB only from 16 weekly hours (15 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -350,12 +599,20 @@ output: weekly_hours: 15 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 348.40 - name: WALES council_tax_reduction bands IIDB only from 16 weekly hours (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -369,12 +626,20 @@ output: weekly_hours: 16 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 694.20 - name: WALES council_tax_reduction bands IIDB only from 16 weekly hours (37.5 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -388,12 +653,20 @@ output: weekly_hours: 37.5 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 694.20 - name: WALES council_tax_reduction bands child maintenance only from 16 weekly hours (0 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -407,12 +680,20 @@ output: weekly_hours: 0 council_tax_reduction_non_dep_gross_income: 12000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 348.40 - name: WALES council_tax_reduction bands child maintenance only from 16 weekly hours (15 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -426,12 +707,20 @@ output: weekly_hours: 15 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 348.40 - name: WALES council_tax_reduction bands child maintenance only from 16 weekly hours (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -445,12 +734,20 @@ output: weekly_hours: 16 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 694.20 - name: WALES council_tax_reduction bands child maintenance only from 16 weekly hours (37.5 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 age: 30 country: WALES council_tax_reduction_individual_non_dep_deduction_eligible: true @@ -464,12 +761,21 @@ output: weekly_hours: 37.5 council_tax_reduction_non_dep_gross_income: 21000 - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 694.20 - name: council_tax_reduction charges the flat amount on a non-worker's pension or nil income (pension income) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + council_tax_reduction_pensioner: true age: 70 council_tax_reduction_individual_non_dep_deduction_eligible: true hours_worked: 0 @@ -479,12 +785,21 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 270.40 -- name: council_tax_reduction keeps a non-worker with no banding income at zero (nil income) +- name: council_tax_reduction charges the flat amount to a non-worker with nil income period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + council_tax_reduction_pensioner: true age: 70 council_tax_reduction_individual_non_dep_deduction_eligible: true hours_worked: 0 @@ -494,150 +809,291 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - # Paragraph (1)(b) gives a non-exempt non-worker the flat amount, but the - # zero is kept until #2007 models the exemptions alongside the flat charge. - council_tax_reduction_individual_non_dep_deduction: 0 + # Paragraph (1)(b) gives a non-worker the flat amount whatever their + # income, here 5.20 * 52; Pension Credit, which would exempt them, is held + # at zero. + council_tax_reduction_individual_non_dep_deduction: 270.40 - name: council_tax_reduction charges no new deduction to a non-dependant on Universal Credit without earnings # SI 2012/2885 Sch 1 para 8(8)(c): no deduction for a non-dependant - # entitled to Universal Credit with no earned income. Universal Credit is - # not banding income, so the gate leaves the zero. + # entitled to Universal Credit with no earned income. The applicant is of + # pension age, so England's national scheme applies. period: 2026 absolute_error_margin: 0.01 input: people: + applicant: + age: 70 + social_security_income: 0 non_dependant: age: 30 - council_tax_reduction_individual_non_dep_deduction_eligible: true hours_worked: 0 social_security_income: 0 is_on_income_support: false is_on_income_based_jsa: false is_on_income_related_esa: false benunits: + applicant_unit: + members: [applicant] non_dependant_unit: members: [non_dependant] - universal_credit: 6_000 + universal_credit_pre_benefit_cap: 6_000 households: household: - members: [non_dependant] + members: [applicant, non_dependant] country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: [0] + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 0] - name: council_tax_reduction excludes each income-related benefit recipient from remunerative work (Income Support) - # The recipient is not in remunerative work, so the flat amount applies - # rather than a band. SI 2012/2885 Sch 1 para 8(8)(a) exempts this - # non-dependant altogether; that exemption remains for #2007. + # The recipient is not in remunerative work (SI 2012/2885 reg 10(6)), and + # Sch 1 para 8(8)(a) exempts them altogether: no deduction. period: 2026 absolute_error_margin: 0.01 input: - age: 30 - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: true - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: true + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 0] - name: council_tax_reduction excludes each income-related benefit recipient from remunerative work (income-based JSA) - # The recipient is not in remunerative work, so the flat amount applies - # rather than a band. SI 2012/2885 Sch 1 para 8(8)(a) exempts this - # non-dependant altogether; that exemption remains for #2007. + # The recipient is not in remunerative work (SI 2012/2885 reg 10(6)), and + # Sch 1 para 8(8)(a) exempts them altogether: no deduction. period: 2026 absolute_error_margin: 0.01 input: - age: 30 - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: true - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: true + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 0] - name: council_tax_reduction excludes each income-related benefit recipient from remunerative work (income-related ESA) - # The recipient is not in remunerative work, so the flat amount applies - # rather than a band. SI 2012/2885 Sch 1 para 8(8)(a) exempts this - # non-dependant altogether; that exemption remains for #2007. + # The recipient is not in remunerative work (SI 2012/2885 reg 10(6)), and + # Sch 1 para 8(8)(a) exempts them altogether: no deduction. period: 2026 absolute_error_margin: 0.01 input: - age: 30 - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: true + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: true + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 0] - name: council_tax_reduction excludes each income-related benefit recipient from remunerative work (no excluded benefit) period: 2026 absolute_error_margin: 0.01 input: - age: 30 - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - iidb: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + iidb: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] - name: ENGLAND council_tax_reduction reads its remunerative work hours parameter (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: gov.local_authorities.england.council_tax_reduction.pensioners.non_dep_deduction.remunerative_work_hours: 20 - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 832 - employment_income: 9000 - maintenance_income: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 832 + employment_income: 9000 + maintenance_income: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 270.40] - name: ENGLAND council_tax_reduction reads its remunerative work hours parameter (20 weekly hours) period: 2026 absolute_error_margin: 0.01 input: gov.local_authorities.england.council_tax_reduction.pensioners.non_dep_deduction.remunerative_work_hours: 20 - age: 30 - country: ENGLAND - council_tax_reduction_individual_non_dep_deduction_eligible: true - hours_worked: 1040 - employment_income: 9000 - maintenance_income: 12000 - social_security_income: 0 - is_on_income_support: false - is_on_income_based_jsa: false - is_on_income_related_esa: false + people: + applicant: + age: 70 + social_security_income: 0 + non_dependant: + age: 30 + hours_worked: 1040 + employment_income: 9000 + maintenance_income: 12000 + social_security_income: 0 + is_on_income_support: false + is_on_income_based_jsa: false + is_on_income_related_esa: false + benunits: + applicant_unit: + members: [applicant] + non_dependant_unit: + members: [non_dependant] + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 + households: + household: + members: [applicant, non_dependant] + country: ENGLAND output: - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_pensioner: [true, false] + council_tax_reduction_individual_non_dep_deduction: [0, 551.20] - name: SCOTLAND council_tax_reduction reads its remunerative work hours parameter (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 gov.local_authorities.scotland.council_tax_reduction.non_dep_deduction.remunerative_work_hours: 20 age: 30 country: SCOTLAND @@ -650,12 +1106,20 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 288.60 - name: SCOTLAND council_tax_reduction reads its remunerative work hours parameter (20 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 gov.local_authorities.scotland.council_tax_reduction.non_dep_deduction.remunerative_work_hours: 20 age: 30 country: SCOTLAND @@ -668,12 +1132,20 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 569.40 - name: WALES council_tax_reduction reads its remunerative work hours parameter (16 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 gov.local_authorities.wales.council_tax_reduction.non_dep_deduction.remunerative_work_hours: 20 age: 30 country: WALES @@ -686,12 +1158,20 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - council_tax_reduction_individual_non_dep_deduction: 1060.8 + council_tax_reduction_individual_non_dep_deduction: 348.40 - name: WALES council_tax_reduction reads its remunerative work hours parameter (20 weekly hours) period: 2026 absolute_error_margin: 0.01 input: + universal_credit_pre_benefit_cap: 0 + child_tax_credit: 0 + working_tax_credit: 0 + child_benefit: 0 + income_support: 0 + jsa_income: 0 + esa_income: 0 + pension_credit: 0 gov.local_authorities.wales.council_tax_reduction.non_dep_deduction.remunerative_work_hours: 20 age: 30 country: WALES @@ -704,4 +1184,4 @@ is_on_income_based_jsa: false is_on_income_related_esa: false output: - council_tax_reduction_individual_non_dep_deduction: 5470.4 + council_tax_reduction_individual_non_dep_deduction: 694.20 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 21eda800bf..52cc7e13fe 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 @@ -251,14 +251,12 @@ guarantee_credit: [3_376, 0] in_receipt_of_guarantee_credit: [true, false] council_tax_reduction_applicable_income: [0, 5_000] - # The national schemes' non-dependant deduction currently uses Housing - # Benefit amounts (20.40 a week here), not the CTR schedules' own amounts - # and exemptions (SI 2012/2885 Sch 1 para 8); this pins that the deduction - # still applies on guarantee credit, not its size. - council_tax_reduction_non_dep_deductions: [1_060.80, 0] - # 2,000 - 1,060.80 - simulated_council_tax_reduction_benunit: [939.20, 0] - council_tax_reduction: 939.20 + # SI 2012/2885 Sch 1 para 8(1)(b): the son has no hours, so the council deducts + # the 2026-27 lowest amount, 5.20 a week, for him outside remunerative work. + council_tax_reduction_non_dep_deductions: [270.40, 0] + # 2,000 - 270.40 + simulated_council_tax_reduction_benunit: [1_729.60, 0] + council_tax_reduction: 1_729.60 - name: Mixed-age couple is not in receipt of guarantee credit, so the capital limit applies period: 2026 @@ -476,12 +474,16 @@ savings: 10_000 other_residential_property_value: 7_000 output: - savings_credit: 267.42 + # Capital 10,000 + 90% of 7,000 = 16,300 (SPC Regs 2002 reg. 19(a)), so + # deemed income is 13 steps x 52 = 676 and Pension Credit income is + # 13,314 + 676 = 13,990. Savings credit: 60% of (12,376 - 208.07 x 52) + # = 933.82, less 40% of (13,990 - 12,376) = 645.60, leaves 288.22. + savings_credit: 288.22 in_receipt_of_savings_credit_only: true # Pension Credit counts the second property as capital; household # savings alone (10,000) are under the limit. Before: CTR 1,999.60. - pension_credit_assessable_capital: 17_000 - council_tax_reduction_assessable_capital: 17_000 + pension_credit_assessable_capital: 16_300 + council_tax_reduction_assessable_capital: 16_300 council_tax_reduction: 0 - name: The same pensioner not claiming Pension Credit is tested on savings and the second property @@ -675,20 +677,21 @@ savings: 0 corporate_wealth: 20_000 output: - # (12,000 + 1,500 - 12,570) x 0.2 = 186. (20,000 - 10,000) / 500 = 20 - # steps of £1 a week; 20 x 52 = 1,040 of deemed income. - pension_credit_assessable_capital: 20_000 - pension_credit_deemed_income: 1_040 - # 13,500 - 186 + 1,040 = 14,354, above the minimum guarantee of 12,376. - pension_credit_income: 14_354 + # (12,000 + 1,500 - 12,570) x 0.2 = 186. Shares are valued at 90% for the + # expenses of sale (SPC Regs 2002 reg. 19(a)): 18,000. (18,000 - 10,000) / + # 500 = 16 steps of £1 a week; 16 x 52 = 832 of deemed income. + pension_credit_assessable_capital: 18_000 + pension_credit_deemed_income: 832 + # 13,500 - 186 + 832 = 14,146, above the minimum guarantee of 12,376. + pension_credit_income: 14_146 guarantee_credit: 0 - # 933.816 - 0.4 x (14,354 - 12,376) = 933.816 - 791.20 = 142.616. - savings_credit: 142.62 + # 933.816 - 0.4 x (14,146 - 12,376) = 933.816 - 708 = 225.816. + savings_credit: 225.82 in_receipt_of_savings_credit_only: true - # The Pension Credit capital is 20,000, over 16,000, with nil savings in + # The Pension Credit capital is 18,000, over 16,000, with nil savings in # the household. The same pensioner with 20,000 of private_pension_wealth # keeps CTR in the next case. - council_tax_reduction_assessable_capital: 20_000 + council_tax_reduction_assessable_capital: 18_000 council_tax_reduction: 0 - name: Savings credit only, private_pension_wealth is not Pension Credit capital, so it does not take the pensioner over the CTR capital limit diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml index ea63047128..e66ef7b8b4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_prescribed_benefits.yaml @@ -570,10 +570,15 @@ # 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 + # Applying alone, she has the mother and father, a couple outside her + # family, as her non-dependants (SI 2012/2885 reg 9(1), (2)(a)). + # Neither works, so each takes the lowest pensioner amount, £5.20 a + # week, and the couple pays one deduction, the higher (Sch 1 para 8): + # 5.20 x 52 = 270.40. + council_tax_reduction_non_dep_deductions: 270.40 + # 1,800 - 0.2 x (14,980.16 - 13,312.00) - 270.40 + # = 1,800 - 333.632 - 270.40 = 1,195.968. + council_tax_benefit: 1_195.968 - name: A guarantee credit recipient with IIDB has nil Council Tax Reduction income period: 2026 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml new file mode 100644 index 0000000000..b8a4099170 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.yaml @@ -0,0 +1,941 @@ +# Income tax and National Insurance deducted from Council Tax Reduction income. +# - Scotland, working age (SSI 2021/249): in reg 50(3)(a), the Scottish +# Ministers provide that "Earnings do not include— (a) any amount deducted +# from them by way of income tax or primary Class 1 contributions under the +# 1992 Act". They set the same rule with Universal Credit (regs 49(6)(b) and +# 51(2) step 3) and gave no tax deduction for unearned income (regs 57 to +# 65). Without Universal Credit, in reg 51(9) they apply SSI 2012/303 regs 37 +# and 38, under which the council prices the tax and contributions on +# self-employed profit notionally: the basic rate on chargeable income less +# only the personal allowance, and Class 4 at the main rate between the +# lower and upper limits. +# - Pension age: in SSI 2012/319 reg 31(12), the Scottish Ministers provide +# that "In the case of any income taken into account for the purpose of +# calculating a person's income, any amount payable by way of tax must be +# disregarded"; the Secretary of State (SI 2012/2885 Sch 1 para 17(13)) and +# the Welsh Ministers (WSI 2013/3029 Sch 1 para 11(13)) make the same rule. +# Under reg 33(2)(a), para 19(2)(a) and para 13(2)(a), the council deducts +# the income tax and Class 1 deducted from earnings, and under reg 37, para +# 30 and para 24 it prices self-employed profit notionally, as at working +# age. +# No provision says how to split one income tax liability. The model follows +# the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 +# (option (a)): the tax on earnings is the extra tax the earnings cause, with +# the earnings as the top slice of income and self-employed profit beneath +# them. In the pensioner schemes the tax on the other counted income is the +# tax it bears with the uncounted income above it. +# +# Scotland's and Wales's working-age awards take their own route +# (council_tax_reduction_devolved_working_age), which takes the Universal Credit +# split of the tax (uc_income_tax_on_earnings), with earnings as the lowest +# slice, and doesn't read this deduction. The deduction still feeds +# council_tax_reduction_applicable_income, and the cases below assert both. +# +# 2026-27 rates. Personal allowance 12,570. Scottish bands on taxable non- +# savings income: 19% to 3,967, 20% to 16,956, 21% to 31,092, 42% to 62,430. +# UK basic rate 20% to 37,700, higher rate 40%. Savings: starting rate band +# 5,000 less taxable non-savings income; personal savings allowance 1,000 for +# a basic-rate taxpayer and 500 for a higher-rate one. Class 1 primary +# contributions 8% above 241.73 a week (12,569.96 a year). Class 4 6% from +# 12,570 to 50,270. Each family is a single adult with no Pension Credit or +# Universal Credit unless stated. + +- name: Scotland's working-age scheme deducts the tax that pay adds to a pension + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + private_pension_income: 10_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Tax with the pay: 30,000 - 12,570 = 17,430 taxable; 3,967 x 19% = 753.73, + # 12,989 x 20% = 2,597.80 and 474 x 21% = 99.54, so 3,451.07. Tax without + # the pay: 10,000 is below the personal allowance, so nil. The pay adds + # 3,451.07. Class 1: (20,000 - 12,569.96) x 8% = 594.40. + council_tax_reduction_tax_deduction: 4_045.47 + # 20,000 + 10,000 - 4,045.47. + council_tax_reduction_applicable_income: 25_954.53 + +- name: Scotland's working-age scheme deducts the savings tax that pay adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + savings_interest_income: 60_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # With the pay: non-savings 17,430 taxable, 3,451.07 as above. Savings + # 60,000 sit above 17,430 in the UK bands: no starting rate; 500 personal + # savings allowance; 37,700 - 17,430 - 500 = 19,770 x 20% = 3,954; and + # 60,000 - 20,270 = 39,730 x 40% = 15,892. Total 23,297.07. + # Without the pay: 60,000 - 12,570 = 47,430 taxable savings; 5,000 at the + # starting rate and 500 allowance at nil, 32,200 x 20% = 6,440 and + # 9,730 x 40% = 3,892, so 10,332. The pay adds 12,965.07. + # Class 1: (30,000 - 12,569.96) x 8% = 1,394.40. + council_tax_reduction_tax_deduction: 14_359.47 + # The scheme counts the pay alone: 30,000 - 14,359.47. + council_tax_reduction_applicable_income: 15_640.53 + +- name: Scotland's working-age scheme deducts the tax that pay adds to property income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + property_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # With the pay: 50,000 - 12,570 = 37,430 taxable; 753.73 + 2,597.80 + + # 14,136 x 21% = 2,968.56 + 6,338 x 42% = 2,661.96, so 8,982.05. Without + # the pay: 7,430 taxable; 753.73 + 3,463 x 20% = 692.60, so 1,446.33. The + # pay adds 7,535.72. Class 1 1,394.40. + council_tax_reduction_tax_deduction: 8_930.12 + # The scheme counts the pay but not the rent from property, which the + # model treats as income from capital: 30,000 - 8,930.12. + council_tax_reduction_applicable_income: 21_069.88 + +- name: Scotland's working-age scheme keeps benefits in kind beneath the pay + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 30_000 + company_car_benefit: 15_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Reg 50(3)(e): a payment in kind is not earnings, so the car benefit stays + # in both terms. With the pay: 45,000 - 12,570 = 32,430 taxable; 753.73 + + # 2,597.80 + 2,968.56 + 1,338 x 42% = 561.96, so 6,882.05. Without the pay: + # 15,000 - 12,570 = 2,430 x 19% = 461.70. The pay adds 6,420.35. Class 1 + # 1,394.40. + council_tax_reduction_tax_deduction: 7_814.75 + # 30,000 - 7,814.75: the car benefit is not counted. + council_tax_reduction_applicable_income: 22_185.25 + +- name: Scotland's working-age scheme prices tax on a 15,000 profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 15_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/303 reg 38: (15,000 - 12,570) x 20% = 486 and Class 4 + # (15,000 - 12,570) x 6% = 145.80. That exceeds the 461.70 + 145.80 the + # person pays. + council_tax_reduction_tax_deduction: 631.80 + # 15,000 - 631.80. + council_tax_reduction_applicable_income: 14_368.20 + +- name: Scotland's working-age scheme prices tax on a 60,000 profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 60_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # (60,000 - 12,570) x 20% = 9,486, and Class 4 only between the limits: + # (50,270 - 12,570) x 6% = 2,262. + council_tax_reduction_tax_deduction: 11_748 + # 60,000 - 11,748. + council_tax_reduction_applicable_income: 48_252 + +- name: Scotland's working-age scheme with Universal Credit deducts the tax that profit adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 35 + self_employment_income: 15_000 + savings_interest_income: 20_000 + child_1: + age: 5 + child_2: + age: 3 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + universal_credit: 7_190.20 + # Reg 51(2) step 3 deducts the tax and contributions paid. With the + # profit: non-savings 2,430 x 19% = 461.70; savings 20,000 over a 2,570 + # starting rate band and a 1,000 allowance, 16,430 x 20% = 3,286; total + # 3,747.70. Without it: 7,430 taxable savings; 5,000 starting rate, 1,000 + # allowance, 1,430 x 20% = 286. The profit adds 3,461.70. Class 4 + # charged: 145.80. + council_tax_reduction_tax_deduction: 3_607.50 + # 15,000 - 3,607.50, plus Universal Credit 7,190.20 and Child Benefit + # (27.05 + 17.90) x 52 = 2,337.40. The interest is not counted. + council_tax_reduction_applicable_income: 20_920.10 + +- name: Scotland's working-age scheme counted Class 2 in the notional deduction before 2024-25 + period: 2023 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + self_employment_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # 2023-24: SSI 2012/303 reg 38 as it stood on 5 April 2024. Income tax + # (20,000 - 12,570) x 20% = 1,486; Class 4 (20,000 - 12,570) x 9% = + # 668.70; Class 2, as the profit exceeds the 12,570 lower profits + # threshold, 3.45 x 52 weeks = 179.40. + council_tax_reduction_tax_deduction: 2_334.10 + # 20,000 - 2,334.10. + council_tax_reduction_applicable_income: 17_665.90 + +- name: Scotland's working-age scheme deducts no voluntary Class 3 contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + ni_class_3: 900 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # Tax on the pay: 7,430 taxable; 753.73 + 692.60 = 1,446.33. Class 1: + # 594.40. Class 3 is voluntary, not a primary Class 1 contribution + # deducted from earnings, so the 900 does not come off. + council_tax_reduction_tax_deduction: 2_040.73 + # 20,000 - 2,040.73. + council_tax_reduction_applicable_income: 17_959.27 + +- name: England's pensioner scheme deducts no tax on savings interest + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_308 + savings_interest_income: 6_559 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The scheme counts the pension, not the interest. Tax on the pension + # with the interest above it: (20,308 - 12,570) x 20% = 1,547.60. + council_tax_reduction_tax_deduction: 1_547.60 + # 20,308 - 1,547.60. + council_tax_reduction_applicable_income: 18_760.40 + +- name: England's pensioner scheme deducts no tax on rent from a property the pensioner doesn't live in + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + property_income: 10_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The pensioner lets out a flat they don't live in, for a profit of + # 10,000. The scheme counts the pension, not the rent, which the model + # treats as income from capital; the case gives the flat no value, so the + # model counts no capital and no tariff income. Income tax: 30,000 - + # 12,570 = 17,430 x 20% = 3,486. Tax on the pension with the rent above + # it: (20,000 - 12,570) x 20% = 1,486. + income_tax_before_winter_fuel_payment_charge: 3_486 + council_tax_reduction_tax_deduction: 1_486 + # 20,000 - 1,486. + council_tax_reduction_applicable_income: 18_514 + # A single pensioner who reached State Pension age after 1 April 2021 + # gets 238 a week, 12,376 a year: 2,000 - 20% x (18,514 - 12,376) = + # 772.40. + council_tax_reduction_applicable_amount: 12_376 + council_tax_reduction: 772.40 + +- name: England's pensioner scheme deducts the savings tax that pay adds + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + employment_income: 20_000 + savings_interest_income: 20_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # With the pay: 7,430 x 20% = 1,486 on the pay; savings 20,000 with no + # starting rate, a 1,000 allowance and 19,000 x 20% = 3,800; total 5,286. + # Without the pay: 7,430 taxable savings; 5,000 starting rate, 1,000 + # allowance, 1,430 x 20% = 286. The pay adds 5,000. The scheme counts no + # other taxed income, and the person pays no Class 1 above pension age. + council_tax_reduction_tax_deduction: 5_000 + # 20,000 - 5,000 - the 5 a week earnings disregard (260). + council_tax_reduction_applicable_income: 14_740 + +- name: England's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # SI 2012/2885 Sch 1 para 30: (60,000 - 12,570) x 20% = 9,486, not the + # 11,432 the profit bears. No Class 4 is payable above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + +- name: Wales's pensioner scheme deducts no tax on dividends + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + dividend_income: 10_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + region: WALES + council_tax: 2_000 + output: + # The scheme counts the pension, not the dividends. Tax on the pension: + # (20,000 - 12,570) x 20% = 1,486. + council_tax_reduction_tax_deduction: 1_486 + # 20,000 - 1,486. + council_tax_reduction_applicable_income: 18_514 + +- name: Wales's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: WALES + region: WALES + council_tax: 2_000 + output: + # WSI 2013/3029 Sch 1 para 24: (60,000 - 12,570) x 20% = 9,486, not the + # 11,432 the profit bears. No Class 4 is payable above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + +- name: Scotland's pensioner scheme deducts no tax on the Scottish Carer Supplement + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 15_000 + # 11.70 a week for 52 weeks. + scottish_carer_supplement: 608.40 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # The Scottish pensioner scheme does not count the Scottish Carer + # Supplement as income (SSI 2012/319 reg 27(1)(j)(xxib)), so its tax, as + # the highest part of income, does not come off. Tax on the pension: + # (15,000 - 12,570) x 19% = 461.70. + council_tax_reduction_tax_deduction: 461.70 + # 15,000 - 461.70. + council_tax_reduction_applicable_income: 14_538.30 + +- name: Scotland's pensioner scheme prices tax on a profit notionally + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + self_employment_income: 60_000 + benunits: + benunit: + members: [pensioner] + households: + household: + members: [pensioner] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # SSI 2012/319 reg 37: (60,000 - 12,570) x 20% = 9,486, not the 13,182.05 + # in Scottish bands the profit bears. No Class 4 above pension age. + council_tax_reduction_tax_deduction: 9_486 + # 60,000 - 9,486 - 260. + council_tax_reduction_applicable_income: 50_254 + +- name: England's pensioner scheme deducts no High Income Child Benefit Charge + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Tax on the pension: 70,000 - 12,570 = 57,430 taxable; 37,700 x 20% = + # 7,540 and 19,730 x 40% = 7,892, so 15,432. Child Benefit 27.05 x 52 = + # 1,406.60, 1,406 in whole pounds; the charge takes (70,000 - 60,000) / + # 200 = 50% of it, 703, so income tax before the winter fuel payment + # charge comes to 16,135. The scheme does not count Child Benefit, and + # through the charge HMRC recovers it, so the formula treats the charge as + # not tax payable on income taken into account. + income_tax_before_winter_fuel_payment_charge: [16_135, 0] + council_tax_reduction_tax_deduction: 15_432 + # 70,000 - 15,432; the scheme does not count Child Benefit. + council_tax_reduction_applicable_income: 54_568 + +- name: England's pensioner scheme deducts the same tax without a Child Benefit claim + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: false + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # No Child Benefit, so no charge: (70,000 - 12,570) taxed as above, 15,432. + income_tax_before_winter_fuel_payment_charge: [15_432, 0] + council_tax_reduction_tax_deduction: 15_432 + # 70,000 - 15,432. + council_tax_reduction_applicable_income: 54_568 + +- name: England's pensioner scheme deducts the rise in the Child Benefit charge that pay causes + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + employment_income: 5_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # With the pay: 75,000 - 12,570 = 62,430 taxable; 7,540 + 24,730 x 40% = + # 9,892, so 17,432; the charge takes (75,000 - 60,000) / 200 = 75% of + # 1,406, 1,054 in whole pounds; T = 18,486. Without the pay: 15,432 and + # a charge of 703, T(E) = 16,135. T - T(E) = 2,351: the 2,000 of tax on + # the pay and the 351 rise in the charge. T(E, S, U) leaves out the charge + # the pensioner would pay without the pay: 15,432. No Class 1 above + # pension age. 2,351 + 15,432 = 17,783. + income_tax_before_winter_fuel_payment_charge: [18_486, 0] + council_tax_reduction_tax_deduction: 17_783 + # 75,000 - 17,783 - the 25 a week lone parent's earnings disregard (1,300). + council_tax_reduction_applicable_income: 55_917 + +- name: England's pensioner scheme deducts a Child Benefit charge that pay causes in full + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 50_000 + employment_income: 30_000 + child: + age: 10 + benunits: + benunit: + members: [pensioner, child] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [pensioner, child] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Without the pay: (50,000 - 12,570) x 20% = 7,486, and adjusted net + # income 50,000 brings no charge. With it: 80,000 - 12,570 = 67,430 + # taxable; the pay adds 270 x 20% = 54 and 29,730 x 40% = 11,892, so + # 11,946; the charge takes (80,000 - 60,000) / 200 = 100% of 1,406. T = + # 7,486 + 11,946 + 1,406 = 20,838; T(E) = 7,486; T(E, S, U) = 7,486. The + # deduction comes to the whole liability. + income_tax_before_winter_fuel_payment_charge: [20_838, 0] + council_tax_reduction_tax_deduction: 20_838 + # 80,000 - 20,838 - 1,300. + council_tax_reduction_applicable_income: 57_862 + +- name: England's pensioner scheme deducts no pension annual allowance charge + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 70_000 + personal_pension_contributions: 70_000 + benunits: + benunit: + members: [pensioner] + would_claim_uc: false + households: + household: + members: [pensioner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # The pensioner has no earnings, so the model gives relief on 3,600 of + # the contributions: 70,000 - 3,600 - 12,570 = 53,830 taxable; 37,700 x + # 20% = 7,540 and 16,130 x 40% = 6,452, so 13,992. The contributions + # exceed the 60,000 annual allowance by 10,000, and the model charges that + # amount at the higher rate, 40%, so the charge comes to 4,000 and income + # tax before the winter fuel payment charge to 17,992. + personal_pension_contributions_tax: 4_000 + income_tax_before_winter_fuel_payment_charge: 17_992 + # The charge falls on the contributions, not on income the scheme + # counts, so the formula leaves it out of T(E, S, U): the deduction comes + # to the 13,992 of tax on the pension. + council_tax_reduction_tax_deduction: 13_992 + +- name: England's pensioner scheme deducts a working-age partner's Class 1 contributions + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 70 + private_pension_income: 20_000 + partner: + age: 60 + employment_income: 20_000 + benunits: + benunit: + members: [pensioner, partner] + would_claim_uc: false + households: + household: + members: [pensioner, partner] + country: ENGLAND + region: NORTH_WEST + council_tax: 2_000 + output: + # Under para 19(2)(a), the council deducts the income tax and primary + # Class 1 deducted from the partner's pay. T: the pension bears (20,000 - + # 12,570) x 20% = 1,486 and the pay the same 1,486, so 2,972. Without the + # pay the partner has no income, and the model transfers 1,260 of + # Marriage Allowance to the pensioner, whose tax falls to (20,000 - + # 12,570 - 1,260) x 20% = 1,234: T(E) = 1,234, so the pay adds 2,972 - + # 1,234 = 1,738. T(E, S, U) = 1,234. Class 1 (20,000 - 12,569.96) x 8% = + # 594.40. 1,738 + 1,234 + 594.40 = 3,566.40. + council_tax_reduction_tax_deduction: 3_566.40 + # 40,000 - 3,566.40 - the 10 a week couple's earnings disregard (520). + council_tax_reduction_applicable_income: 35_913.60 + +- name: Scotland's pensioner scheme allows only the personal relief against a profit + period: 2026 + absolute_error_margin: 0.01 + input: + people: + older: + age: 70 + self_employment_income: 30_000 + younger: + age: 68 + private_pension_income: 10_000 + benunits: + benunit: + members: [older, younger] + households: + household: + members: [older, younger] + country: SCOTLAND + region: SCOTLAND + council_tax: 2_000 + output: + # The younger partner's pension falls within the personal allowance, so + # the model transfers 1,260 of Marriage Allowance to the older partner. + marriage_allowance: [1_260, 0] + # The Scottish Ministers allow "only the personal relief" (SSI 2012/319 + # reg 37(1)), so the Marriage Allowance does not come off: (30,000 - + # 12,570) x 20% = 3,486. No Class 4 above pension age. T(E, S, U): + # without the profit the older partner has no income and the pension + # bears no tax, so nil. No employed earnings. + council_tax_reduction_tax_deduction: 3_486 + # 40,000 - 3,486 - the 10 a week couple's earnings disregard (520). + council_tax_reduction_applicable_income: 35_994 + +- name: Wales's working-age scheme deducts the tax the legacy means tests deduct + period: 2026 + absolute_error_margin: 0.01 + input: + people: + adult: + age: 40 + employment_income: 20_000 + savings_interest_income: 20_000 + benunits: + benunit: + members: [adult] + would_claim_uc: false + households: + household: + members: [adult] + country: WALES + region: WALES + council_tax: 2_000 + output: + # The formula leaves this scheme as the legacy means tests have it: the + # income tax less the tax the interest would bear without the pay + # (legacy_means_test_deducted_income_tax), and National Insurance. + # With the pay: non-savings 20,000 - 12,570 = 7,430 taxable, x 20% = + # 1,486; the interest above it gets no starting rate, a 1,000 personal + # savings allowance and 19,000 x 20% = 3,800, so 5,286. Without the pay: + # 20,000 - 12,570 = 7,430 of taxable interest, 5,000 at the starting rate + # and 1,000 allowance at nil, 1,430 x 20% = 286. 5,286 - 286 = 5,000. + # Class 1: (20,000 - 12,569.96) x 8% = 594.40. + legacy_means_test_deducted_income_tax: 5_000 + council_tax_reduction_tax_deduction: 5_594.40 + # The scheme counts the pay but not the interest: 20,000 - 5,594.40. + council_tax_reduction_applicable_income: 14_405.60 + +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,661 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 43_661 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable non-savings income 43,661 - 12,570 = 31,091, within the + # intermediate band, so the earner takes the partner's 1,260 of Marriage + # Allowance, which takes 20% of it, 252, off the tax at Step 6. 3,967 x + # 19% = 753.73, 12,989 x 20% = 2,597.80 and 14,135 x 21% = 2,968.35, so + # 6,319.88 less 252 = 6,067.88. Without the pay, nil. Class 1 (43,661 - + # 12,569.96) x 8% = 2,487.28. 6,067.88 + 2,487.28 = 8,555.16. + marriage_allowance: [1_260, 0] + council_tax_reduction_tax_deduction: 8_555.16 + # 43,661 - 8,555.16. + council_tax_reduction_applicable_income: 35_105.84 + # The working-age award takes its own route. Pay is the only income, so the + # tax and Class 1 match, and the route disregards 10 a week (520) of a + # couple's earnings: 43,661 - 8,555.16 - 520. + council_tax_reduction_working_age_applicable_income: 34_585.84 + # 15,000 - 20% x (34,585.84 - the couple's allowance 150.15 x 52 = + # 7,807.80). + council_tax_reduction: 9_644.39 + +- name: Scotland's working-age scheme passes the Marriage Allowance cliff through at pay of 43,663 + period: 2026 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 40 + employment_income: 43_663 + partner: + age: 40 + benunits: + benunit: + members: [earner, partner] + would_claim_uc: false + households: + household: + members: [earner, partner] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 31,093 exceeds the Scottish intermediate limit of 31,092, so + # the earner pays 1 pound at the higher rate and gets no Marriage + # Allowance: 753.73 + 2,597.80 + 14,136 x 21% = 2,968.56 + 1 x 42% = + # 0.42, so 6,320.51. Class 1 (43,663 - 12,569.96) x 8% = 2,487.44. + # 6,320.51 + 2,487.44 = 8,807.95: two pounds of pay raise the deduction + # by 252.79. + marriage_allowance: [0, 0] + council_tax_reduction_tax_deduction: 8_807.95 + # 43,663 - 8,807.95. + council_tax_reduction_applicable_income: 34_855.05 + # 43,663 - 8,807.95 - 520. + council_tax_reduction_working_age_applicable_income: 34_335.05 + # 15,000 - 20% x (34,335.05 - 7,807.80): the reduction rises by 50.16. + council_tax_reduction: 9_694.55 + +- name: Scotland's shared deduction passes a Child Benefit charge step through at pay of 64,999, and the working-age award leaves it out + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 64_999 + child_1: + age: 4 + child_2: + age: 7 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 64,999 - 12,570 = 52,429: 3,967 x 19% = 753.73, 12,989 x 20% = + # 2,597.80, 14,136 x 21% = 2,968.56 and 21,337 x 42% = 8,961.54, so + # 15,281.63. Child Benefit (27.05 + 17.90) x 52 = 2,337.40, 2,337 in whole + # pounds. Adjusted net income exceeds 60,000 by 4,999, or 24 whole steps + # of 200, so the charge takes 24% of 2,337 = 560.88, 560 in whole pounds. + # T = 15,841.63. Without the pay, nil. Class 1: 37,700 x 8% = 3,016 up to + # the upper earnings limit of 966.73 a week (50,269.96 a year), and + # (64,999 - 50,269.96) x 2% = 294.58, so 3,310.58. 15,841.63 + 3,310.58 = + # 19,152.21. + CB_HITC: [560, 0, 0] + council_tax_reduction_tax_deduction: 19_152.21 + # The model counts the pay and the Child Benefit: 64,999 + 2,337.40 - + # 19,152.21. + council_tax_reduction_applicable_income: 48_184.19 + # The working-age award takes its own route, and the Universal Credit split + # of the tax leaves the charge out: 64,999 - 15,281.63 - 3,310.58 - 25 a + # week of a lone parent's earnings (1,300). The route counts no Child + # Benefit. + council_tax_reduction_working_age_applicable_income: 45_106.79 + # (95.55 lone parent + 2 x 109.85 child premium) x 52. + council_tax_reduction_working_age_applicable_amount: 16_393 + # 15,000 - 20% x (45,106.79 - 16,393). + council_tax_reduction: 9_257.24 + +- name: Scotland's shared deduction passes a Child Benefit charge step through at pay of 65,000, and the working-age award leaves it out + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 65_000 + child_1: + age: 4 + child_2: + age: 7 + benunits: + benunit: + members: [parent, child_1, child_2] + would_claim_uc: false + would_claim_child_benefit: true + households: + household: + members: [parent, child_1, child_2] + country: SCOTLAND + region: SCOTLAND + council_tax: 15_000 + output: + # Taxable 52,430: one more pound at 42%, so 15,282.05. Adjusted net income + # exceeds 60,000 by 5,000, or 25 steps of 200, so the charge takes 25% of + # 2,337 = 584.25, 584 in whole pounds. T = 15,866.05. Class 1 3,016 + + # (65,000 - 50,269.96) x 2% = 294.60, so 3,310.60. 15,866.05 + 3,310.60 = + # 19,176.65: a pound of pay raises the deduction by 24.44, and the charge + # gives 24 of that. + CB_HITC: [584, 0, 0] + council_tax_reduction_tax_deduction: 19_176.65 + # 65,000 + 2,337.40 - 19,176.65. + council_tax_reduction_applicable_income: 48_160.75 + # 65,000 - 15,282.05 - 3,310.60 - 1,300. + council_tax_reduction_working_age_applicable_income: 45_107.35 + # 15,000 - 20% x (45,107.35 - 16,393): a pound of pay lowers the award by + # 0.11, with no step from the charge. + council_tax_reduction: 9_257.13 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml index dc509f3b49..6a37e3b102 100644 --- a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/council_tax_reduction_young_person.yaml @@ -1,6 +1,6 @@ # Each Council Tax Reduction scheme's own "young person". Scotland's -# working-age scheme, from 1 April 2022, uses the Universal Credit qualifying -# young person (SSI 2021/249 reg. 6(1); UC Regs 2013 reg. 5). England, Wales +# working-age scheme, from 1 April 2022, uses the qualifying young person of +# UC Regs 2013 reg. 5 (SSI 2021/249 reg. 6(1)). England, Wales # and Scotland's pension-age scheme use the Child Benefit qualifying young # person (SI 2012/2885 reg. 2; WSI 2013/3029 reg. 2; SSI 2012/319 reg. 4), as # did Scotland's working-age scheme until 31 March 2022 (SSI 2012/303 reg. 4). @@ -353,3 +353,103 @@ is_council_tax_reduction_applicant_or_partner: [true, false, false] is_child_or_young_person_for_council_tax_reduction: [true, false, false] council_tax_reduction_applicable_income: 10_000 + +- name: Case 11, Scotland working age, a 19-year-old's own Housing Benefit leaves them a young person. + # The Secretary of State excludes a person entitled to an existing benefit + # from the qualifying young person only for the purposes of the UC + # Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28). The Scottish + # Ministers define the young person by UC Regs reg. 5 (SSI 2021/249 reg. + # 6(1)), so the 19-year-old stays in the family. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 22 + is_claimant_or_partner: true + student: + age: 19 + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + housing_benefit_reported: 3_000 + benunits: + benunit: + members: [claimant, student] + would_claim_uc: false + households: + household: + members: [claimant, student] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + output: + is_qualifying_young_person_under_universal_credit_regulation_5: [false, true] + is_qualifying_young_person_for_universal_credit: [false, false] + is_child_or_young_person_for_council_tax_reduction: [false, true] + is_council_tax_reduction_lone_parent: true + # Lone parent aged 18 or over: 95.55 * 52. + council_tax_reduction_applicable_amount: 4_968.60 + +- name: Case 12, Scotland working age, a 17-year-old's own Income Support, income-based JSA or income-related ESA leaves them a young person, and contribution-based JSA does not. + period: 2025 + input: + age: [17, 17, 17, 17] + current_education: [UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY, UPPER_SECONDARY] + is_claimant_or_partner: [false, false, false, false] + income_support_reported: [3_000, 0, 0, 0] + jsa_income_reported: [0, 3_000, 0, 0] + esa_income_reported: [0, 0, 3_000, 0] + jsa_contrib_reported: [0, 0, 0, 3_000] + country: [SCOTLAND, SCOTLAND, SCOTLAND, SCOTLAND] + output: + is_qualifying_young_person_for_universal_credit: [false, false, false, false] + # The Secretary of State excludes a person receiving contribution-based + # JSA (reg. 5(5)), so only that report removes the young person. + is_qualifying_young_person_under_universal_credit_regulation_5: [true, true, true, false] + is_child_or_young_person_for_council_tax_reduction: [true, true, true, false] + +- name: Case 13, Scotland working age, a 19-year-old's own income-related ESA leaves the earning applicant a lone parent and raises the award. + # The Scottish Ministers define the young person by UC Regs reg. 5 (SSI + # 2021/249 reg. 6(1)), so the 19-year-old stays in the family and the + # 22-year-old applicant is a lone parent: 95.55 * 52 = 4,968.60. The + # 19-year-old's own income-related ESA ends the family's Child Benefit (Child + # Benefit (General) Regs 2006 reg. 8(2)(e)), so applicable income is the + # applicant's earnings, 5,000. The shared formula would withdraw 20% of the + # excess, 0.2 * (5,000 - 4,968.60) = 6.28, but Scotland's working-age route + # sets the award: (95.55 + 109.85) * 52 = 10,680.80 exceeds the income, 5,000 + # less 25 a week = 3,700, so the award is the full 1,800. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + claimant: + age: 22 + is_claimant_or_partner: true + employment_income: 5_000 + student: + age: 19 + is_claimant_or_partner: false + current_education: UPPER_SECONDARY + age_started_or_accepted_current_education_or_training: 17 + is_before_universal_credit_qualifying_young_person_terminal_date: true + esa_income_reported: 3_000 + benunits: + benunit: + members: [claimant, student] + would_claim_uc: false + would_claim_council_tax_reduction: true + households: + household: + members: [claimant, student] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + output: + is_child_or_young_person_for_council_tax_reduction: [false, true] + is_council_tax_reduction_lone_parent: true + child_benefit: 0 + council_tax_reduction_applicable_amount: 4_968.60 + council_tax_reduction_applicable_income: 5_000 + council_tax_reduction: 1_800 diff --git a/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/enhanced_disability_premium.yaml b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/enhanced_disability_premium.yaml new file mode 100644 index 0000000000..1365bcf75e --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/local_authorities/council_tax_reduction/enhanced_disability_premium.yaml @@ -0,0 +1,1028 @@ +# Enhanced disability premium in working-age Council Tax Reduction. +# Welsh councils count DLA care highest, PIP daily living enhanced or AFIP, +# for an applicant or family member "who has not attained the qualifying age +# for state pension credit" (CTR (Prescribed Requirements) (Wales) Regs 2013 +# Sch 7 para 12(1)). Scottish councils count the same benefits for the +# applicant or partner (CTR (Scotland) Regs 2021 Sch 1 para 14(1)), with no +# age limit from 20 June 2022 (S.S.I. 2022/161), and attendance allowance at +# either rate from 1 April 2023 (para 14(1)(g), S.S.I. 2023/38). Merton and +# Kingston upon Thames count DLA care highest and PIP daily living at either +# rate and omit AFIP (Merton's Local Council Tax Support Scheme Rules 2026 and +# Kingston upon Thames's Council Tax Reduction Scheme 2026 - 2027, Sch 3 para +# 12(1)). Newham counts DLA care highest, PIP daily living at either rate and +# AFIP (Newham's Council Tax Reduction Scheme 2026, Sch 3 para 12(1)). In +# Westminster the model applies the Default Scheme's list (S.I. 2012/2886 Sch +# 3 para 12(1)): DLA care highest and PIP daily living at either rate, without +# AFIP. The four London councils keep the qualifying age for State Pension +# Credit as the age limit. The model applies Kingston's list in 2013-14 to +# 2017-18 and from 2023-24. It cites no complete Kingston scheme text for +# 2018-19 to 2022-23, so for those years it assumes the Housing Benefit list. +# The other councils' lists apply in every model year. The model reads each +# year's rules at 30 April. +# Unless a case sets `is_disabled_for_benefits`, it keeps its default, false, +# and the model pays no disability premium, although the schemes give one to +# a claimant under the qualifying age who receives these awards (for example +# HB Regs 2006 Sch 3 paras 12(a) and 13(1)(a)(i); Merton's rules Sch 3 paras +# 9(a) and 10(1)(a)(i)). In those cases the totals from +# `council_tax_reduction_premiums` down follow that default, not the schemes. +# 2026-27 amounts: enhanced disability premium £22.00 single, £31.40 couple; +# severe disability premium £86.05 single. + +- name: Case 1, Welsh working-age CTR premiums include the premium for enhanced PIP daily living. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + pip_dl_category: ENHANCED + households: + household: + members: [person1] + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_pensioner: false + # 22.00 * 52 = 1,144.00. + council_tax_reduction_enhanced_disability_premium: 1_144 + # Plus the severe disability premium, 86.05 * 52 = 4,474.60: 5,618.60. + council_tax_reduction_premiums: 5_618.60 + +- name: Case 2, Scottish working-age CTR counts attendance allowance at either rate from 2023. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + aa_category: LOWER + households: + household: + members: [person1] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_pensioner: false + # The model's legacy premium does not count attendance allowance. + enhanced_disability_premium: 0 + # Para 14(1)(g): 22.00 * 52 = 1,144.00. + meets_council_tax_reduction_enhanced_disability_premium_condition: true + council_tax_reduction_enhanced_disability_premium: 1_144 + # Severe disability premium 4,474.60 + 1,144.00 = 5,618.60. + council_tax_reduction_premiums: 5_618.60 + +- name: Case 3, Scottish working-age CTR does not count attendance allowance in 2022. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + aa_category: LOWER + households: + household: + members: [person1] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + meets_council_tax_reduction_enhanced_disability_premium_condition: false + council_tax_reduction_enhanced_disability_premium: 0 + # The model pays only the severe disability premium, at the 2022-23 + # single rate £69.40: 69.40 * 52 = 3,608.80. + council_tax_reduction_premiums: 3_608.80 + +- name: Case 4, Scottish working-age CTR pays the couple rate for an over-age partner from 2023. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + jsa_income: 3_000 + households: + household: + members: [person1, person2] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + is_couple: true + # Because the couple receives income-based Jobseeker's Allowance, the + # model applies the working-age scheme. + council_tax_reduction_pensioner: false + # The model's legacy premium applies the age limit. + enhanced_disability_premium: 0 + # Scottish councils apply no age limit under para 14(1)(b): 31.40 * 52 = + # 1,632.80. + council_tax_reduction_enhanced_disability_premium: 1_632.80 + # The model pays no other premium, because only one partner has a severe + # disability premium qualifying benefit. + council_tax_reduction_premiums: 1_632.80 + +- name: Case 5, Scottish working-age CTR applied the age limit in 2022. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + jsa_income: 3_000 + households: + household: + members: [person1, person2] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + is_couple: true + council_tax_reduction_pensioner: false + # Para 14(1)(b) as in force on 30 April 2022: "the applicant's partner, + # who has not attained pensionable age". + council_tax_reduction_enhanced_disability_premium: 0 + council_tax_reduction_premiums: 0 + +- name: Case 6, Welsh working-age CTR keeps the age limit for an over-age partner. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + jsa_income: 3_000 + households: + household: + members: [person1, person2] + country: WALES + local_authority: CARDIFF + output: + is_couple: true + council_tax_reduction_pensioner: false + # Sch 7 para 12(1)(b): "who has not attained the qualifying age for state + # pension credit". + council_tax_reduction_enhanced_disability_premium: 0 + council_tax_reduction_premiums: 0 + +- name: Case 7, a household head who applies alone gets the premium for enhanced PIP daily living. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + is_household_head: true + pip_dl_category: ENHANCED + person2: + age: 17 + is_parent: true + is_household_head: false + person3: + age: 17 + is_parent: true + is_household_head: false + person4: + age: 0 + is_household_head: false + benunits: + benunit: + members: [person1, person2, person3, person4] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3, person4] + country: WALES + local_authority: CARDIFF + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + council_tax_reduction_pensioner: false + meets_council_tax_reduction_enhanced_disability_premium_condition: [true, false, false, false] + # The head's own single-rate premium 1,144.00, plus the severe disability + # premium 4,474.60 (the other residents are under 18): 5,618.60. + council_tax_reduction_premiums: 5_618.60 + +- name: Case 8, a Scottish household head who applies alone and receives attendance allowance gets the premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + is_household_head: true + aa_category: LOWER + person2: + age: 17 + is_parent: true + is_household_head: false + person3: + age: 17 + is_parent: true + is_household_head: false + person4: + age: 0 + is_household_head: false + benunits: + benunit: + members: [person1, person2, person3, person4] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3, person4] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + council_tax_reduction_pensioner: false + # Para 14(1)(g) 1,144.00 plus the severe disability premium 4,474.60: + # 5,618.60. + council_tax_reduction_premiums: 5_618.60 + +- name: Case 9, AFIP adds no enhanced disability premium to a Merton reduction. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + private_pension_income: 12_000 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + households: + household: + members: [person1] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + output: + # The model's legacy premium still counts AFIP for Housing Benefit and + # Income Support (HB para 15(1)(d)). + enhanced_disability_premium: 1_144 + # Merton omits AFIP from Sch 3 para 12(1). + meets_council_tax_reduction_enhanced_disability_premium_condition: false + council_tax_reduction_enhanced_disability_premium: 0 + # AFIP counts toward the severe disability premium: 86.05 * 52 = + # 4,474.60. + council_tax_reduction_premiums: 4_474.60 + # Personal allowance 95.55 * 52 = 4,968.60, plus 4,474.60: 9,443.20. + council_tax_reduction_applicable_amount: 9_443.20 + # 1,800 - 20% of (12,000 - 9,443.20) = 1,800 - 511.36 = 1,288.64. + simulated_council_tax_reduction_benunit: 1_288.64 + +- name: Case 10, AFIP meets Newham's condition but not Merton's, Kingston upon Thames's or Westminster's. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + merton: + age: 30 + armed_forces_independence_payment: 2_000 + kingston: + age: 30 + armed_forces_independence_payment: 2_000 + newham: + age: 30 + armed_forces_independence_payment: 2_000 + westminster: + age: 30 + armed_forces_independence_payment: 2_000 + benunits: + merton: + members: [merton] + kingston: + members: [kingston] + newham: + members: [newham] + westminster: + members: [westminster] + households: + merton: + members: [merton] + country: ENGLAND + local_authority: MERTON + kingston: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + newham: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + westminster: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + output: + meets_council_tax_reduction_enhanced_disability_premium_condition: [false, false, true, false] + # 22.00 * 52 = 1,144.00. + council_tax_reduction_enhanced_disability_premium: [0, 0, 1_144, 0] + enhanced_disability_premium: [1_144, 1_144, 1_144, 1_144] + +- name: Case 11, standard PIP daily living meets the Merton, Kingston upon Thames, Newham and Westminster conditions. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + merton: + age: 30 + pip_dl_category: STANDARD + kingston: + age: 30 + pip_dl_category: STANDARD + newham: + age: 30 + pip_dl_category: STANDARD + westminster: + age: 30 + pip_dl_category: STANDARD + benunits: + merton: + members: [merton] + kingston: + members: [kingston] + newham: + members: [newham] + westminster: + members: [westminster] + households: + merton: + members: [merton] + country: ENGLAND + local_authority: MERTON + kingston: + members: [kingston] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + newham: + members: [newham] + country: ENGLAND + local_authority: NEWHAM + westminster: + members: [westminster] + country: ENGLAND + local_authority: WESTMINSTER + output: + meets_council_tax_reduction_enhanced_disability_premium_condition: [true, true, true, true] + council_tax_reduction_enhanced_disability_premium: [1_144, 1_144, 1_144, 1_144] + # Under HB para 15(1)(c), claimants qualify only at the enhanced rate. + enhanced_disability_premium: [0, 0, 0, 0] + +- name: Case 12, the Merton and Newham schemes keep the age limit for an over-age partner. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + merton1: + age: 60 + merton2: + age: 70 + pip_dl_category: STANDARD + newham1: + age: 60 + newham2: + age: 70 + armed_forces_independence_payment: 2_000 + benunits: + merton: + members: [merton1, merton2] + jsa_income: 3_000 + newham: + members: [newham1, newham2] + jsa_income: 3_000 + households: + merton: + members: [merton1, merton2] + country: ENGLAND + local_authority: MERTON + newham: + members: [newham1, newham2] + country: ENGLAND + local_authority: NEWHAM + output: + is_couple: [true, true] + # Because each couple receives income-based Jobseeker's Allowance, the + # model applies the working-age scheme. + council_tax_reduction_pensioner: [false, false] + # Para 12(1)(c) and (p): "who has not attained the qualifying age for + # state pension credit". + meets_council_tax_reduction_enhanced_disability_premium_condition: [false, false, false, false] + council_tax_reduction_enhanced_disability_premium: [0, 0] + +- name: Case 13, Merton's own list counts standard PIP daily living in 2022. + # Merton's scheme rules for 2022-23, Sch 3 para 12(1)(c), count the PIP + # daily living component at either rate. The case pins the 2022-23 energy + # and cost of living payments at zero, so household net income moves only + # with the reduction. 2022-23 rates: PIP daily living standard 61.85, + # personal allowance (25 or over) 77.00, severe disability premium 69.40, + # disability premium 36.20, enhanced disability premium 17.75. + period: 2022 + absolute_error_margin: 0.01 + # The claimant receives PIP, so the case sets is_disabled_for_benefits for + # the disability premium (HB Regs 2006 Sch 3 paras 12(a) and 13(1)(a)(i); + # Merton's rules Sch 3 paras 9(a) and 10(1)(a)(i)). A pension of 12,500, + # below the 12,570 personal allowance, keeps the reduction on the taper. + input: + people: + person1: + age: 30 + pip_dl_category: STANDARD + is_disabled_for_benefits: true + private_pension_income: 12_500 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + households: + household: + members: [person1] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + output: + # The legacy premium counts the enhanced rate only. + enhanced_disability_premium: 0 + meets_council_tax_reduction_enhanced_disability_premium_condition: true + # Merton's list counts the standard PIP daily living rate: 17.75 * 52 = 923.00. + council_tax_reduction_enhanced_disability_premium: 923 + # Severe disability premium 69.40 * 52 = 3,608.80, disability premium + # 36.20 * 52 = 1,882.40 and the enhanced disability premium 923.00: 6,414.20. + council_tax_reduction_premiums: 6_414.20 + # Personal allowance 77.00 * 52 = 4,004.00, plus 6,414.20: 10,418.20. + council_tax_reduction_applicable_amount: 10_418.20 + # 1,800 - 20% of (12,500 - 10,418.20) = 1,800 - 416.36 = 1,383.64. + council_tax_reduction: 1_383.64 + # Pension 12,500 + PIP 61.85 * 52 = 3,216.20 + reduction 1,383.64 - + # council tax 1,800 = 15,299.84. + household_net_income: 15_299.84 + +- name: Case 14, Merton's own list counts standard PIP daily living in 2023. + # Merton's Local Council Tax Support Scheme Rules 2023, Sch 3 para 12(1)(c): + # PIP daily living "payable at either rate". The case pins the 2023-24 energy + # and cost of living payments at zero. 2023-24 rates: PIP daily living + # standard 68.10, personal allowance (25 or over) 84.80, severe disability + # premium 76.40, disability premium 39.85, enhanced disability premium + # 19.55. + period: 2023 + absolute_error_margin: 0.01 + # The claimant receives PIP, so the case sets is_disabled_for_benefits for + # the disability premium (HB Regs 2006 Sch 3 paras 12(a) and 13(1)(a)(i); + # Merton's rules Sch 3 paras 9(a) and 10(1)(a)(i)). A pension of 12,500, + # below the 12,570 personal allowance, keeps the reduction on the taper. + input: + people: + person1: + age: 30 + pip_dl_category: STANDARD + is_disabled_for_benefits: true + private_pension_income: 12_500 + benunits: + benunit: + members: [person1] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + households: + household: + members: [person1] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + output: + # The legacy premium still counts the enhanced rate only. + enhanced_disability_premium: 0 + meets_council_tax_reduction_enhanced_disability_premium_condition: true + # 19.55 * 52 = 1,016.60. + council_tax_reduction_enhanced_disability_premium: 1_016.60 + # Severe disability premium 76.40 * 52 = 3,972.80, disability premium + # 39.85 * 52 = 2,072.20 and the enhanced disability premium 1,016.60: + # 7,061.60. + council_tax_reduction_premiums: 7_061.60 + # Personal allowance 84.80 * 52 = 4,409.60, plus 7,061.60: 11,471.20. + council_tax_reduction_applicable_amount: 11_471.20 + # 1,800 - 20% of (12,500 - 11,471.20) = 1,800 - 205.76 = 1,594.24. + council_tax_reduction: 1_594.24 + # Pension 12,500 + PIP 68.10 * 52 = 3,541.20 + reduction 1,594.24 - + # council tax 1,800 = 15,835.44. + household_net_income: 15_835.44 + +- name: Case 15, Merton's own list counts standard PIP daily living and omits AFIP in 2015. + # Merton's scheme rules for 2015-16, Sch 3 para 12(1)(b), (c), count the DLA + # care component at the highest rate and the PIP daily living component at + # either rate, and name no armed forces payment. 2015-16 rates: PIP daily + # living standard 55.10, personal allowance (25 or over) 73.10, severe + # disability premium 61.85, disability premium 32.25, enhanced disability + # premium 15.75. Each claimant receives PIP or AFIP, so the case sets + # is_disabled_for_benefits for the disability premium. A pension of 10,500, + # below the 10,600 personal allowance, keeps each reduction on the taper. + # The case pins the cost of living and energy payments at zero. + period: 2015 + absolute_error_margin: 0.01 + input: + people: + pip: + age: 30 + pip_dl_category: STANDARD + is_disabled_for_benefits: true + private_pension_income: 10_500 + afip: + age: 30 + armed_forces_independence_payment: 2_000 + is_disabled_for_benefits: true + private_pension_income: 10_500 + benunits: + pip: + members: [pip] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + afip: + members: [afip] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + households: + pip: + members: [pip] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + afip: + members: [afip] + country: ENGLAND + local_authority: MERTON + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + output: + # The legacy premium counts AFIP (HB para 15(1)(d)) but not the standard + # PIP rate: 15.75 * 52 = 819.00. + enhanced_disability_premium: [0, 819] + meets_council_tax_reduction_enhanced_disability_premium_condition: [true, false] + council_tax_reduction_enhanced_disability_premium: [819, 0] + # Severe disability premium 61.85 * 52 = 3,216.20 and disability premium + # 32.25 * 52 = 1,677.00: 4,893.20, plus 819.00 for PIP: 5,712.20. + council_tax_reduction_premiums: [5_712.20, 4_893.20] + # Personal allowance 73.10 * 52 = 3,801.20: 9,513.40 and 8,694.40. + council_tax_reduction_applicable_amount: [9_513.40, 8_694.40] + # 1,800 - 20% of (10,500 - 9,513.40) = 1,800 - 197.32 = 1,602.68; + # 1,800 - 20% of (10,500 - 8,694.40) = 1,800 - 361.12 = 1,438.88. + council_tax_reduction: [1_602.68, 1_438.88] + # Pension 10,500 + PIP 55.10 * 52 = 2,865.20 + 1,602.68 - 1,800 = + # 13,167.88. The model leaves AFIP out of household benefits + # (HOUSEHOLD_BENEFIT_VARIABLES), so 10,500 + 1,438.88 - 1,800 = 10,138.88. + household_net_income: [13_167.88, 10_138.88] + +- name: Case 16, Westminster's list counts standard PIP daily living and omits AFIP in 2015. + # The model applies the Default Scheme's list (S.I. 2012/2886 Sch 3 para + # 12(1)(b), (c)) in Westminster. 2015-16 rates as in Case 15; Westminster's + # maximum reduction (100%) and withdrawal rate (20%) match Merton's. + period: 2015 + absolute_error_margin: 0.01 + input: + people: + pip: + age: 30 + pip_dl_category: STANDARD + is_disabled_for_benefits: true + private_pension_income: 10_500 + afip: + age: 30 + armed_forces_independence_payment: 2_000 + is_disabled_for_benefits: true + private_pension_income: 10_500 + benunits: + pip: + members: [pip] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + afip: + members: [afip] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + households: + pip: + members: [pip] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + afip: + members: [afip] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + output: + # 15.75 * 52 = 819.00. + enhanced_disability_premium: [0, 819] + meets_council_tax_reduction_enhanced_disability_premium_condition: [true, false] + council_tax_reduction_enhanced_disability_premium: [819, 0] + # 3,216.20 + 1,677.00 = 4,893.20, plus 819.00 for PIP: 5,712.20. + council_tax_reduction_premiums: [5_712.20, 4_893.20] + # Plus the personal allowance 3,801.20: 9,513.40 and 8,694.40. + council_tax_reduction_applicable_amount: [9_513.40, 8_694.40] + # 1,800 - 20% of (10,500 - 9,513.40) = 1,602.68; + # 1,800 - 20% of (10,500 - 8,694.40) = 1,438.88. + council_tax_reduction: [1_602.68, 1_438.88] + # 10,500 + 2,865.20 + 1,602.68 - 1,800 = 13,167.88; + # 10,500 + 1,438.88 - 1,800 = 10,138.88, without AFIP as in Case 15. + household_net_income: [13_167.88, 10_138.88] + +- name: Case 17, Westminster's list counts standard PIP daily living and omits AFIP in 2026. + # 2026-27 rates: PIP daily living standard 76.70, personal allowance (under + # 25) 75.65, severe disability premium 86.05, disability premium 44.85, + # enhanced disability premium 22.00. Each claimant, aged 24, takes the lower + # personal allowance, which keeps each applicable amount below a pension of + # 12,500, under the 12,570 personal allowance, and each reduction on the + # taper. + # The case pins the 2026-27 household energy payment and the earlier + # payments at zero. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pip: + age: 24 + pip_dl_category: STANDARD + is_disabled_for_benefits: true + private_pension_income: 12_500 + afip: + age: 24 + armed_forces_independence_payment: 2_000 + is_disabled_for_benefits: true + private_pension_income: 12_500 + benunits: + pip: + members: [pip] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + afip: + members: [afip] + would_claim_uc: false + would_claim_IS: false + would_claim_pc: false + claims_all_entitled_benefits: true + households: + pip: + members: [pip] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + household_energy_payment: 0 + afip: + members: [afip] + country: ENGLAND + local_authority: WESTMINSTER + council_tax: 1_800 + savings: 0 + tv_licence: 0 + energy_bills_rebate: 0 + cost_of_living_support_payment: 0 + household_energy_payment: 0 + output: + # 22.00 * 52 = 1,144.00. + enhanced_disability_premium: [0, 1_144] + meets_council_tax_reduction_enhanced_disability_premium_condition: [true, false] + council_tax_reduction_enhanced_disability_premium: [1_144, 0] + # Severe disability premium 86.05 * 52 = 4,474.60 and disability premium + # 44.85 * 52 = 2,332.20: 6,806.80, plus 1,144.00 for PIP: 7,950.80. + council_tax_reduction_premiums: [7_950.80, 6_806.80] + # Personal allowance 75.65 * 52 = 3,933.80: 11,884.60 and 10,740.60. + council_tax_reduction_applicable_amount: [11_884.60, 10_740.60] + # 1,800 - 20% of (12,500 - 11,884.60) = 1,800 - 123.08 = 1,676.92; + # 1,800 - 20% of (12,500 - 10,740.60) = 1,800 - 351.88 = 1,448.12. + council_tax_reduction: [1_676.92, 1_448.12] + # Pension 12,500 + PIP 76.70 * 52 = 3,988.40 + 1,676.92 - 1,800 = + # 16,365.32; pension 12,500 + 1,448.12 - 1,800 = 12,148.12, without AFIP + # as in Case 15. + household_net_income: [16_365.32, 12_148.12] + +- name: Case 18, in Kingston the model assumes the Housing Benefit condition and counts AFIP in 2020. + period: 2020 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # Kingston's list does not apply; the GB Housing Benefit list counts AFIP. + meets_council_tax_reduction_enhanced_disability_premium_condition: true + +- name: Case 19, in Kingston the model assumes the Housing Benefit condition and counts AFIP in 2022. + period: 2022 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # Kingston's list does not apply; the GB Housing Benefit list counts AFIP. + meets_council_tax_reduction_enhanced_disability_premium_condition: true + +- name: Case 20, Kingston's own list omits AFIP in 2017. + period: 2017 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # Kingston's list applies and counts only highest DLA care or PIP daily living. + meets_council_tax_reduction_enhanced_disability_premium_condition: false + +- name: Case 21, Kingston's own list omits AFIP in 2024. + period: 2024 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # Kingston's list applies and counts only highest DLA care or PIP daily living. + meets_council_tax_reduction_enhanced_disability_premium_condition: false + +- name: Case 22, in Kingston the model assumes the Housing Benefit condition and counts highest DLA care in 2020. + period: 2020 + input: + people: + person1: + age: 30 + dla_sc_category: HIGHER + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # The GB Housing Benefit list counts has_highest_dla_sc_award. + meets_council_tax_reduction_enhanced_disability_premium_condition: true + +- name: Case 23, Merton's own list still omits AFIP in 2020. + period: 2020 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: MERTON + output: + # Merton's list still counts only highest DLA care or PIP daily living. + meets_council_tax_reduction_enhanced_disability_premium_condition: false + +- name: Case 24, in Kingston the model assumes the Housing Benefit condition from 2018 and counts AFIP. + period: 2018 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # The switch is false from 1 April 2018; the GB Housing Benefit list counts AFIP. + meets_council_tax_reduction_enhanced_disability_premium_condition: true + +- name: Case 25, Kingston's own list omits AFIP again from 2023. + period: 2023 + input: + people: + person1: + age: 30 + armed_forces_independence_payment: 2_000 + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # The switch is true again from 1 April 2023; Kingston's list omits AFIP. + meets_council_tax_reduction_enhanced_disability_premium_condition: false + +- name: Case 26, in Kingston the model assumes the Housing Benefit condition from 2018 and omits standard PIP daily living. + period: 2018 + input: + people: + person1: + age: 30 + pip_dl_category: STANDARD + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # The GB Housing Benefit list counts only the enhanced PIP daily living rate. + meets_council_tax_reduction_enhanced_disability_premium_condition: false + +- name: Case 27, Kingston's own list counts standard PIP daily living again from 2023. + period: 2023 + input: + people: + person1: + age: 30 + pip_dl_category: STANDARD + households: + household: + members: [person1] + country: ENGLAND + local_authority: KINGSTON_UPON_THAMES + output: + # Kingston's list counts the PIP daily living component at either rate. + meets_council_tax_reduction_enhanced_disability_premium_condition: true + +- name: Case 28, Scotland's working-age route counts attendance allowance at either rate. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + aa_category: LOWER + households: + household: + members: [person1] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_devolved_working_age: true + # (22.00 + the severe disability premium 86.05) * 52 = 5,618.60. + council_tax_reduction_working_age_adult_premiums: 5_618.60 + # Plus the single allowance, 95.55 * 52 = 4,968.60. + council_tax_reduction_working_age_applicable_amount: 10_587.20 + +- name: Case 29, Scotland's working-age route pays the couple rate for an over-age partner. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 60 + person2: + age: 70 + dla_sc_category: HIGHER + benunits: + benunit: + members: [person1, person2] + jsa_income: 3_000 + households: + household: + members: [person1, person2] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + output: + council_tax_reduction_devolved_working_age: true + # 31.40 * 52. + council_tax_reduction_working_age_adult_premiums: 1_632.80 + +- name: Case 30, Wales's working-age route doesn't count attendance allowance for the premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 30 + aa_category: LOWER + benunits: + benunit: + members: [person1] + # A Universal Credit award in Wales takes the route to the maximum amount. + would_claim_uc: false + households: + household: + members: [person1] + country: WALES + local_authority: CARDIFF + output: + council_tax_reduction_devolved_working_age: true + meets_council_tax_reduction_enhanced_disability_premium_condition: false + # The severe disability premium alone, 86.05 * 52 = 4,474.60. + council_tax_reduction_working_age_adult_premiums: 4_474.60 + # Plus the single allowance, 101.85 * 52 = 5,296.20. + council_tax_reduction_working_age_applicable_amount: 9_770.80 + +- name: Case 31, Scotland's working-age route counts attendance allowance for a head who applies alone. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + is_household_head: true + aa_category: LOWER + person2: + age: 17 + is_parent: true + is_household_head: false + person3: + age: 17 + is_parent: true + is_household_head: false + person4: + age: 0 + is_household_head: false + benunits: + benunit: + members: [person1, person2, person3, person4] + claims_all_entitled_benefits: true + would_claim_uc: false + households: + household: + members: [person1, person2, person3, person4] + country: SCOTLAND + local_authority: CITY_OF_EDINBURGH + council_tax: 1_800 + savings: 0 + output: + council_tax_reduction_head_applies_alone: true + council_tax_reduction_devolved_working_age: true + # The head's own conditions: (22.00 + 86.05) * 52. + council_tax_reduction_working_age_adult_premiums: 5_618.60 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 index cd84156576..c0f75df3e3 100644 --- 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 @@ -1,14 +1,18 @@ # 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. +# Enhanced PIP daily living also qualifies for the enhanced disability premium, +# 22.00*52=1144 (HB Regs 2006 Sch3 para15(1)(c); IS Regs 1987 Sch2 +# para13A(1ZA)(c); SSI2021/249 Sch1 para14(1)(f)). # Pension12570:no tax; PIP disregarded; Child Benefit claim declined. -# CTR=2000-20%*(12570-allowance):479.72 under the shared formula. Where the -# student is a UC qualifying young person, Scotland's working-age route -# (council_tax_reduction_devolved_working_age) makes the applicant a lone -# parent with a child premium: (95.55 + 109.85 + 86.05 severe disability -# premium) * 52 = 15,155.40 (SSI 2026/56 reg 14), above the 12,570 income, -# so the award is the full 2,000. The shared council_tax_reduction_applicable_ -# amount (9,443.20) has no child amounts and no longer sets the award. +# CTR=2000-20%*(12570-6112.60):708.52 under the shared formula and Scotland's +# working-age route alike. Where the student is a UC qualifying young person, +# that route (council_tax_reduction_devolved_working_age) makes the applicant +# a lone parent with a child premium: (95.55 + 109.85 + 86.05 severe disability +# premium + 22.00 enhanced disability premium) * 52 = 16,299.40 (SSI 2026/56 +# reg 14), above the 12,570 income, so the award is the full 2,000. The shared +# council_tax_reduction_applicable_amount (10,587.20) has no child amounts and +# no longer sets the award. # 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. @@ -54,21 +58,25 @@ council_tax: 2000 savings: 0 output: - simulated_council_tax_reduction_benunit: 479.72 + # 2000 - 20% * (12570 - 6112.60) = 708.52. + simulated_council_tax_reduction_benunit: 708.52 is_council_tax_reduction_lone_parent: false - council_tax_reduction_working_age_applicable_amount: 4968.6 + # 4968.60 + 1144. + council_tax_reduction_working_age_applicable_amount: 6112.6 disability_premium: 0 - enhanced_disability_premium: 0 + enhanced_disability_premium: 1144 severe_disability_premium: 4474.6 council_tax_reduction_pensioner: false council_tax_reduction_applicable_income: 12570 - council_tax_reduction_applicable_amount: 4968.6 + # 4968.60 + 1144 (enhanced disability premium only). + council_tax_reduction_applicable_amount: 6112.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 + # 4474.60 + 1144 = 5618.60; IS 4968.60 + 5618.60 = 10587.20. + working_age_benefits_premiums: 5618.6 + benefits_premiums: 5618.6 + income_support_applicable_amount: 10587.2 - name: Before terminal date both definitions retain Scottish CTR premium period: 2026 absolute_error_margin: 0.01 @@ -110,21 +118,22 @@ simulated_council_tax_reduction_benunit: 2000 is_council_tax_reduction_lone_parent: true council_tax_reduction_working_age_child_amounts: 109.85 * 52 - council_tax_reduction_working_age_adult_premiums: 4474.6 - council_tax_reduction_working_age_applicable_amount: (95.55 + 109.85 + 86.05) * 52 + council_tax_reduction_working_age_adult_premiums: (86.05 + 22.00) * 52 + council_tax_reduction_working_age_applicable_amount: (95.55 + 109.85 + 86.05 + 22.00) * 52 council_tax_reduction_working_age_applicable_income: 12570 disability_premium: 0 - enhanced_disability_premium: 0 + enhanced_disability_premium: 1144 severe_disability_premium: 4474.6 council_tax_reduction_pensioner: false council_tax_reduction_applicable_income: 12570 - council_tax_reduction_applicable_amount: 9443.2 + # 4968.60 + 4474.60 + 1144 = 10587.20. + council_tax_reduction_applicable_amount: 10587.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 + working_age_benefits_premiums: 5618.6 + benefits_premiums: 5618.6 + income_support_applicable_amount: 10587.2 - name: 'Supplied-status contract: UC young person outside CB family restores Scottish CTR premium' period: 2026 @@ -168,18 +177,21 @@ simulated_council_tax_reduction_benunit: 2000 is_council_tax_reduction_lone_parent: true council_tax_reduction_working_age_child_amounts: 109.85 * 52 - council_tax_reduction_working_age_adult_premiums: 4474.6 - council_tax_reduction_working_age_applicable_amount: (95.55 + 109.85 + 86.05) * 52 + council_tax_reduction_working_age_adult_premiums: (86.05 + 22.00) * 52 + council_tax_reduction_working_age_applicable_amount: (95.55 + 109.85 + 86.05 + 22.00) * 52 council_tax_reduction_working_age_applicable_income: 12570 disability_premium: 0 - enhanced_disability_premium: 0 + enhanced_disability_premium: 1144 severe_disability_premium: 0 council_tax_reduction_pensioner: false council_tax_reduction_applicable_income: 12570 - council_tax_reduction_applicable_amount: 9443.2 + # 4968.60 + 4474.60 + 1144 = 10587.20. + council_tax_reduction_applicable_amount: 10587.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 + # The non-dependant bars only the severe premium: 1144; IS 4968.60 + + # 1144 = 6112.60. + working_age_benefits_premiums: 1144 + benefits_premiums: 1144 + income_support_applicable_amount: 6112.6 diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml index 3e9ab4f08f..e851f2b5db 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/carer_support_payment_earnings_limit.yaml @@ -441,3 +441,33 @@ output: carers_allowance_earnings: 6146.6 carer_support_payment_pre_overlap: 0 + +- name: Each self-employed deduction rounded up a penny leaves earnings within the limit + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + self_employment_income: 12576.76 + personal_pension_contributions: 3932.96 + care_hours: 40 + country: SCOTLAND + output: + # Sch2para4: notional tax 20% x 6.76 = 1.352 and Class 4 6% x 6.76 = + # 0.4056 count as 1.36 and 0.41; half the premium is 1,966.48: + # 12,576.76 - 1.36 - 0.41 - 1,966.48 = 10,608.51, or 204.00 a week. + carers_allowance_earnings: 10608.51 + carer_support_payment_pre_overlap: 4495.4 + +- name: A penny more profit leaves earnings a penny a week over the limit + period: 2026 + absolute_error_margin: 0.001 + input: + age: 45 + self_employment_income: 12576.77 + personal_pension_contributions: 3932.96 + care_hours: 40 + country: SCOTLAND + output: + # 12,576.77 - 1.36 - 0.41 - 1,966.48 = 10,608.52, or 204.01 a week. + carers_allowance_earnings: 10608.52 + carer_support_payment_pre_overlap: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml index 967e7ef79c..27fbacac25 100644 --- a/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/social_security_scotland/scottish_carer_supplement.yaml @@ -56,3 +56,167 @@ output: scottish_carer_supplement: 0 carer_support_payment: 0 + +# Award weeks. SSI 2023/302 reg 14A: "An individual is entitled to Scottish +# Carer Supplement for each award week in which Carer Support Payment is +# payable to the individual." In reg 2, "award week" means "a period of 7 +# days beginning on a Sunday and ending on a Saturday". The Scottish Ministers +# set the weekly rates in reg 16(1) and (1A): Carer Support Payment 86.45 and +# Scottish Carer Supplement 11.70. Under reg 42(1) and (5), a carer who moves +# to another part of the United Kingdom keeps Carer Support for 13 weeks from +# the day they stop being ordinarily resident in Scotland (reg 42(4)). Under +# reg 42(1A), where the cared-for person dies in that time, the carer keeps it +# for 12 award weeks after the first day of the award week following the one +# in which they died, up to 25 weeks and 6 days in all. The move cases below +# put the move on a Sunday, so they count 13 and 25 complete award weeks, each +# from a Sunday. A move on any other day spreads the 13 weeks over 14 award +# weeks. The formula counts the weeks as the larger of +# carer_support_payment_pre_overlap and carer_support_payment over the weekly +# Carer Support Payment rate, up to 52, and pays the weekly supplement rate for +# each where carer_support_payment is positive, and nothing where it is £0. +# +# The count differs from the law in these cases, among others; the variable's +# documentation names each: +# - an overlapping benefit or earnings that vary within the year; +# - a supplied part-year payment for a person the model finds eligible in +# Scotland; +# - a supplied payment that an overlapping benefit has reduced, with no amount +# before the reduction; +# - the disable_simulated_benefits reform; +# - a supplied part-year amount before the reduction, which the model nets +# against an overlapping benefit paid all year: 1,123.85 for 13 award weeks +# and 2,000 a year of State Pension net to 0, where Social Security +# Scotland nets them week by week and pays 623.85 of Carer Support +# Payment and 152.10 of supplement; +# - part weeks, where Social Security Scotland pays a whole weekly supplement +# for each award week in which Carer Support Payment is payable. + +- name: Carer in England with 13 weeks of Carer Support Payment gets 13 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # Reg 42(1), (4) and (5): a move on a Sunday gives 13 complete award + # weeks, each from a Sunday, 13 x 86.45 = 1,123.85. + carer_support_payment: 1123.85 + output: + carer_support_payment_pre_overlap: 0 + # 1,123.85 / 86.45 = 13 award weeks; 13 x 11.70 = 152.10. + scottish_carer_supplement: 152.10 + +- name: Carer in England whose cared-for person dies in week 13 after the move gets 25 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # Reg 42(1A): the move falls on a Sunday and the cared-for person dies in + # award week 13. The carer keeps Carer Support for the 12 award weeks from + # the start of award week 14, 25 complete award weeks in all, each from a + # Sunday. 25 x 86.45 = 2,161.25. + carer_support_payment: 2161.25 + output: + carer_support_payment_pre_overlap: 0 + # 2,161.25 / 86.45 = 25 award weeks; 25 x 11.70 = 292.50. + scottish_carer_supplement: 292.50 + +- name: One week of Carer Support Payment gives one week of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + carer_support_payment: 86.45 + output: + # 86.45 / 86.45 = 1 award week; 1 x 11.70 = 11.70. + scottish_carer_supplement: 11.70 + +- name: Carer Support Payment worth more than 52 weeks gives 52 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # 60 x 86.45 = 5,187. + carer_support_payment: 5187 + output: + # The model counts 52 award weeks in a year: 52 x 11.70 = 608.40. + scottish_carer_supplement: 608.40 + +- name: Carer in Scotland with no caring inputs and 13 weeks of Carer Support Payment gets 13 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + country: SCOTLAND + # No caring hours and no reported Carer's Allowance, so the inputs give + # no entitlement before the overlapping-benefit reduction. + carer_support_payment: 1123.85 + output: + carer_support_payment_pre_overlap: 0 + # 13 x 11.70 = 152.10. + scottish_carer_supplement: 152.10 + +- name: Carer in Scotland the model finds eligible keeps 52 weeks of supplement with a supplied part-year payment + period: 2026 + absolute_error_margin: 0.01 + input: + age: 35 + care_hours: 40 + country: SCOTLAND + carer_support_payment: 1123.85 + output: + # Age 35 and 40 hours of care a week describe a carer entitled all year: + # 52 x 86.45 = 4,495.40 before the overlapping-benefit reduction. + carer_support_payment_pre_overlap: 4495.40 + # The formula counts 52 award weeks from the larger amount: 52 x 11.70 = + # 608.40. For a carer with 13 weeks of Carer Support Payment, Social + # Security Scotland would pay 13 x 11.70 = 152.10; see the variable's + # documentation. + scottish_carer_supplement: 608.40 + +- name: Carer whose State Pension reduces Carer Support Payment and leaves some keeps 52 weeks of supplement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + state_pension: 2_000 + care_hours: 35 + country: SCOTLAND + output: + carer_support_payment_pre_overlap: 4495.40 + # Reg 16(2)(a): 2,000 a year, 38.46 a week, falls short of the 86.45 + # weekly rate, so Carer Support Payment stays payable in every award + # week: 4,495.40 - 2,000 = 2,495.40. + carer_support_payment: 2495.40 + # Reg 14A: 52 x 11.70 = 608.40. + scottish_carer_supplement: 608.40 + +- name: Carer with a supplied payment that State Pension reduced keeps 52 weeks of supplement with the amount before the reduction + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + country: SCOTLAND + # A carer entitled all year, 52 x 86.45 = 4,495.40, whose State Pension + # of 2,000 a year leaves 2,495.40, with both amounts supplied. + carer_support_payment_pre_overlap: 4495.40 + carer_support_payment: 2495.40 + output: + # Reg 16(2)(a) leaves a payment in every award week: 52 x 11.70 = 608.40. + scottish_carer_supplement: 608.40 + +- name: Carer in England with 13 weeks of Carer Support Payment gets 13 weeks of supplement in 2027 + period: 2027 + absolute_error_margin: 0.01 + input: + age: 35 + country: ENGLAND + # 13 complete award weeks from a Sunday move. 13 x 89.50 = 1,163.50, at + # the model's projected 2027 rates: Carer Support Payment 89.50 and + # Scottish Carer Supplement 12.10. + carer_support_payment: 1163.50 + output: + # 1,163.50 / 89.50 = 13 award weeks; 13 x 12.10 = 157.30. + scottish_carer_supplement: 157.30 diff --git a/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml new file mode 100644 index 0000000000..6e62b31464 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/demographic/uc_carer_element_carer_test.yaml @@ -0,0 +1,505 @@ +# DWP and councils count an award of Universal Credit which includes the +# carer element as a carer benefit in the carer condition of the legacy +# severe disability premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b); +# HB(SPC) Regs 2006 Sch 3 para 6(2)) and of the Pension Credit severe +# disability addition (SPC Regs 2002 Sch I para 1(1)(a)(iii) and (b), with +# para 1(4)): they withhold the premium or addition when a carer "has an +# award of universal credit which includes the carer element in respect of +# caring for" the claimant. DWP includes the carer element for regular and +# substantial caring for a severely disabled person, whether or not the carer +# claims Carer's Allowance (UC Regs 2013 regs 29 and 30(2)). +# +# Amounts for 2026-27 (DWP benefit and pension rates 2026 to 2027; the Social +# Security Benefits Up-rating Order 2026, SI 2026/148): severe disability +# premium and addition £86.05 a week, couple premium £172.10, Carer's Allowance +# £86.45; Universal Credit standard allowance £424.90 a month (single, 25 or +# over) and £666.97 (joint, either 25 or over), carer element £209.34 a month. + +- name: A blind carer's Universal Credit carer element bars the premium of the person they care for + # The council ignores the carer, a blind non-dependant, under the + # non-dependant condition (para 14(4)(b)), and the carer's award bars the + # premium instead. Carer element 209.34 x 12 = 2,512.08; award (424.90 + + # 209.34) x 12 = 7,610.88. Without the award, the council would include the + # premium, 86.05 x 52 = 4,474.60. + period: 2026 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + disabled_unit: + members: [disabled] + would_claim_uc: false + carer_unit: + members: [carer] + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + uc_carer_element: [0, 2_512.08] + universal_credit: [0, 7_610.88] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: [0, 0] + +- name: A partner whose joint award includes the carer element leaves a couple who both qualify the single rate + # The joint award includes a carer element in respect of caring for only + # one of the couple, so the council includes one single rate, 86.05 x 52 = + # 4,474.60, and not the double rate, 172.10 x 52 = 8,949.20, that it + # includes where no one cares for either (para 14(2)(b)(iii)). Joint award + # (666.97 + 209.34) x 12 = 10,515.72. + period: 2026 + input: + people: + claimant: + age: 45 + pip_dl_category: STANDARD + partner: + age: 40 + pip_dl_category: STANDARD + care_hours: 35 + would_claim_carers_allowance: false + benunits: + couple: + members: [claimant, partner] + households: + household: + members: [claimant, partner] + country: ENGLAND + output: + uc_carer_element: 2_512.08 + universal_credit: 10_515.72 + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: 4_474.60 + +- name: A son's Universal Credit carer element in another benefit unit bars a pensioner's severe disability addition + # DWP ignores the blind son as a resident (SPC Regs 2002 Sch I para + # 2(2)(b)), and his award with the carer element bars the addition (para + # 1(1)(a)(iii) and (4)). Without the award, DWP would include the addition, + # 86.05 x 52 = 4,474.60, and the council the pension-age Housing Benefit + # premium at the same amount (HB(SPC) Regs 2006 Sch 3 para 6(2)(a)(iii)). + period: 2026 + input: + people: + pensioner: + age: 75 + aa_category: HIGHER + son: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + pensioner_unit: + members: [pensioner] + son_unit: + members: [son] + households: + household: + members: [pensioner, son] + country: ENGLAND + output: + universal_credit: [0, 7_610.88] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_minimum_guarantee_addition: [0, 0] + pension_age_severe_disability_premium: [0, 0] + +- name: A carer on both Carer's Allowance and Universal Credit counts once + # The carer receives Carer's Allowance, 86.45 x 52 = 4,495.40, and a + # Universal Credit award with the carer element, (424.90 + 209.34) x 12 - + # 4,495.40 = 3,115.48. They care for one person (SSCBA 1992 s.70(7) and + # (7ZA), (7A)), so the couple who both qualify keep one rate of the + # addition, 86.05 x 52 = 4,474.60 (SPC Regs 2002 reg 6(5)), as with the + # allowance alone. + period: 2026 + input: + people: + husband: + age: 78 + aa_category: HIGHER + wife: + age: 76 + aa_category: HIGHER + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: true + benunits: + couple: + members: [husband, wife] + carer_unit: + members: [carer] + households: + household: + members: [husband, wife, carer] + country: ENGLAND + output: + carers_allowance: [0, 0, 4_495.40] + universal_credit: [0, 3_115.48] + receives_carer_benefit: [false, false, true] + has_uc_award_with_carer_element: [false, false, true] + is_cared_for_by_carer_benefit_recipient: [true, false, false] + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + +- name: A partner's carer element adds no second carer where the other partner receives the allowance for the same person + # Both partners care 35 hours; the younger claims Carer's Allowance and the + # joint award includes the carer element for the elder (UC Regs 2013 reg + # 29(3); the model takes the elder). Partners caring for the same severely + # disabled person have one relevant entitlement between them (SSCBA 1992 + # s.70(7ZA) and (7A)), so they care for one person, the disabled child, and + # not also the grandmother in another benefit unit. + period: 2026 + input: + people: + elder: + age: 45 + care_hours: 35 + would_claim_carers_allowance: false + younger: + age: 40 + care_hours: 35 + would_claim_carers_allowance: true + child: + age: 8 + dla_sc_category: MIDDLE + grandmother: + age: 80 + aa_category: HIGHER + benunits: + family: + members: [elder, younger, child] + grandmother_unit: + members: [grandmother] + households: + household: + members: [elder, younger, child, grandmother] + country: ENGLAND + output: + partners_care_for_same_severely_disabled_person: [true, true] + receives_carer_benefit: [false, true, false, false] + has_uc_award_with_carer_element: [true, false, false, false] + is_cared_for_by_carer_benefit_recipient: [false, false, true, false] + +- name: A carer whose income tapers the award to nil has no award and does not bar the premium + # The carer's maximum amount comes to (424.90 + 209.34) x 12 = 7,610.88. + # Earnings of £25,000 less income tax (25,000 - 12,570) x 0.20 = 2,486.00 + # and National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave 21,519.60, + # and with no work allowance the taper takes 0.55 x 21,519.60 = 11,835.78. + # That exceeds the maximum amount, so the award comes to nil, which leaves + # no award (Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17). No one + # has an award in respect of caring for the disabled person, who keeps the + # premium, 86.05 x 52 = 4,474.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 25_000 + benunits: + disabled_unit: + members: [disabled] + would_claim_uc: false + carer_unit: + members: [carer] + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + uc_earned_income: [0, 21_519.60] + universal_credit_pre_benefit_cap: [0, 0] + universal_credit: [0, 0] + receives_uc_carer_element: [false, false] + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_premium: [4_474.60, 0] + +- name: A renting carer's housing costs element keeps an award that bars the premium + # The carer of the case above, earning £25,000, rents from the council at + # £6,000 a year. The housing costs element raises the maximum amount to + # 7,610.88 + 6,000 = 13,610.88, and the taper of 11,835.78 leaves an award + # of 1,775.10. The disabled person, on the PIP daily living component, + # brings no non-dependant deduction (UC Regs 2013 Sch 4 para 16(2)(e)). + # The award includes the carer element and bars the premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 25_000 + is_household_head: true + disabled: + age: 50 + pip_dl_category: STANDARD + benunits: + carer_unit: + members: [carer] + disabled_unit: + members: [disabled] + would_claim_uc: false + households: + household: + members: [carer, disabled] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + uc_housing_costs_element: [6_000, 0] + universal_credit: [1_775.10, 0] + universal_credit_pre_benefit_cap: [1_775.10, 0] + has_uc_award_with_carer_element: [true, false] + is_cared_for_by_carer_benefit_recipient: [false, true] + severe_disability_premium: [0, 0] + +- name: A son whose income tapers the award to nil does not bar a pensioner's severe disability addition + # The son of the third case earns £20,000: less income tax 1,486.00 and + # National Insurance 594.40, the taper takes 0.55 x 17,919.60 = 9,855.78, + # more than his maximum amount, 7,610.88. He has no award, so the addition + # and the pension-age Housing Benefit premium stay at 86.05 x 52 = + # 4,474.60 (SPC Regs 2002 Sch I para 1(1)(a)(iii); HB(SPC) Regs 2006 Sch 3 + # para 6(2)(a)(iii)). + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 75 + aa_category: HIGHER + son: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + pensioner_unit: + members: [pensioner] + son_unit: + members: [son] + households: + household: + members: [pensioner, son] + country: ENGLAND + output: + uc_earned_income: [0, 17_919.60] + universal_credit: [0, 0] + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_minimum_guarantee_addition: [4_474.60, 0] + pension_age_severe_disability_premium: [4_474.60, 0] + +- name: The carer test reads a stored award as the award, even without a claim + # The carer of the first case earns £20,000, which tapers the award the + # model would work out to nil, and neither family claims. The case supplies + # Universal Credit instead: nil for the disabled person's family and £100 for + # the carer's. A stored award reads no other variable, so the carer test + # reads it (receives_uc_carer_element): the carer's award includes the + # carer element and bars the premium. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 20_000 + benunits: + disabled_unit: + members: [disabled] + claims_universal_credit: false + universal_credit: 0 + carer_unit: + members: [carer] + claims_universal_credit: false + universal_credit: 100 + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + receives_uc_carer_element: [false, true] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_premium: [0, 0] + +- name: Without a Universal Credit claim no one has a carer element award + # The same carer as in the first case, in a family that does not claim + # Universal Credit and does not claim Carer's Allowance: no one has an award + # in respect of caring for the disabled person, who keeps the premium, + # 86.05 x 52 = 4,474.60. + period: 2026 + input: + people: + disabled: + age: 50 + pip_dl_category: STANDARD + carer: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + disabled_unit: + members: [disabled] + would_claim_uc: false + carer_unit: + members: [carer] + would_claim_uc: false + households: + household: + members: [disabled, carer] + country: ENGLAND + output: + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_premium: [4_474.60, 0] + +- name: A blind carer whose award comes to nil with a small housing costs element does not bar a pensioner's addition + # The household head, aged 35 and blind, has a limited capability for work + # determination, cares 35 hours a week for a pensioner aged 75 in another + # benefit unit, claims no Carer's Allowance, earns £25,000 and pays the + # council £600 a year in rent. The pensioner receives Attendance Allowance + # at the higher rate, 114.60 x 52 = 5,959.20, and £10,000 of State Pension. + # + # The carer's Universal Credit. Income tax (25,000 - 12,570) x 0.20 = + # 2,486.00 and National Insurance (25,000 - 12,570) x 0.08 = 994.40 leave + # earned income of 21,519.60. The award includes the carer element: the + # carer has no limited capability for work-related activity (UC Regs 2013 + # reg 29(4)). The pensioner brings no non-dependant deduction (Sch 4 para + # 16(2)(d)). The maximum amount comes to (424.90 + 209.34) x 12 + 600 = + # 8,210.88. With a housing costs element, DWP applies the lower work + # allowance, 427 x 12 = 5,124.00 (reg 22(2)(b)), and the taper takes + # 0.55 x (21,519.60 - 5,124.00) = 9,017.58, more than the maximum amount. + # The model caps the reduction at the maximum amount, so the award comes + # to nil and the carer has no award (Welfare Reform Act 2012 s.5(1)(b); + # reg 17). + # + # The pensioner's addition. She receives Attendance Allowance (SPC Regs + # 2002 Sch I para 1(1)(a)(i)); DWP ignores the blind carer as a resident + # (para 2(2)(b)); and no one has an award including the carer element in + # respect of caring for her (para 1(1)(a)(iii) and (4)). So DWP includes + # the addition, 86.05 x 52 = 4,474.60. + # + # Pension Credit. The minimum guarantee comes to 238.00 x 52 + 4,474.60 = + # 16,850.60. Her income for Pension Credit is the State Pension, 10,000 + # (the model counts no Attendance Allowance), so guarantee credit comes to + # 16,850.60 - 10,000 = 6,850.60. Her qualifying income, 10,000 / 52 = + # 192.31 a week, falls short of the savings credit threshold of 208.07, so + # savings credit comes to nil. + # + # Household net income. Earnings 25,000, plus State Pension 10,000, + # Attendance Allowance 5,959.20, Pension Credit 6,850.60, Winter Fuel + # Payment 200 (aged under 80, lower rate) and the Household Energy Payment + # 100 (England, the model's default band D), less income tax 2,486.00 and + # National Insurance 994.40: 44,629.40. Were the carer counted, the + # addition would fall to nil and Pension Credit to 16,850.60 - 4,474.60 - + # 10,000 = 2,376.00, and household net income to 40,154.80. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + carer: + age: 35 + is_blind: true + uc_limited_capability_for_work: true + care_hours: 35 + would_claim_carers_allowance: false + employment_income: 25_000 + is_household_head: true + pensioner: + age: 75 + aa_category: HIGHER + state_pension: 10_000 + benunits: + carer_unit: + members: [carer] + pensioner_unit: + members: [pensioner] + households: + household: + members: [carer, pensioner] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 600 + output: + uc_earned_income_before_work_allowance: [21_519.60, 0] + uc_earned_income: [16_395.60, 0] + uc_maximum_amount: [8_210.88, 0] + uc_work_allowance: [5_124, 0] + uc_income_reduction: [8_210.88, 0] + universal_credit_pre_benefit_cap: [0, 0] + has_uc_award_with_carer_element: [false, false] + is_cared_for_by_carer_benefit_recipient: [false, false] + severe_disability_minimum_guarantee_addition: [0, 4_474.60] + minimum_guarantee: [12_376, 16_850.60] + savings_credit: [0, 0] + pension_credit: [0, 6_850.60] + household_net_income: 44_629.40 + +- name: In Northern Ireland a son's award including the carer element bars a pensioner's addition + # SR 2016/236 reg 31(7) inserted the Universal Credit words into SPC Regs + # (NI) 2003 Sch 1 para 1(1)(a)(iii), with para 1(4) defining the award by + # reference to UC Regs (NI) 2016 reg 30. The model reads the same + # parameters in Northern Ireland: Universal Credit standard allowance + # 424.90 and carer element 209.34 a month (gov.dwp.universal_credit), and + # the addition 86.05 a week + # (gov.dwp.pension_credit.guarantee_credit.severe_disability.addition). + # The pensioner, aged 75, receives Attendance Allowance at the higher rate. + # Her blind son, aged 40, cares 35 hours a week, claims no Carer's + # Allowance and has no income, so his award comes to (424.90 + 209.34) x + # 12 = 7,610.88 and includes the carer element, 209.34 x 12 = 2,512.08. + # DWP ignores him as a resident (para 2(2)(b)), but his award bars the + # addition, which would otherwise come to 86.05 x 52 = 4,474.60. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + pensioner: + age: 75 + aa_category: HIGHER + son: + age: 40 + is_blind: true + care_hours: 35 + would_claim_carers_allowance: false + benunits: + pensioner_unit: + members: [pensioner] + son_unit: + members: [son] + households: + household: + members: [pensioner, son] + country: NORTHERN_IRELAND + output: + uc_carer_element: [0, 2_512.08] + universal_credit_pre_benefit_cap: [0, 7_610.88] + has_uc_award_with_carer_element: [false, true] + is_cared_for_by_carer_benefit_recipient: [true, false] + severe_disability_minimum_guarantee_addition: [0, 0] diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml index 819ebc010a..b6a709ce7a 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/benunit_reported_capital.yaml @@ -3,8 +3,8 @@ # 2013 reg.18) and Pension Credit (SPCA 2002 s.5) assess the claimant's and # partner's capital together. UC replaces financial proxies with this # observation and counts property separately; uc_reported_capital remains a -# separate complete override. Pension Credit continues to use the observation -# in place of its whole capital proxy unless its own variable is set. +# separate complete override. Pension Credit also retains separately valued +# property unless its own complete override is set directly. # Tariff income: UC Regulations 2013 reg.72, 4.35 pounds a month for each 250 # pounds or part above 6,000. @@ -24,7 +24,7 @@ savings: 50_000 output: uc_reported_capital: -1 - pension_credit_reported_capital: 2_000 + pension_credit_reported_capital: -1 uc_assessable_capital: 2_000 is_uc_eligible: true @@ -65,7 +65,7 @@ savings: 50_000 output: uc_assessable_capital: 3_000 - pension_credit_reported_capital: 20_000 + pension_credit_reported_capital: -1 - name: Recorded capital sets UC tariff income. period: 2025 diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml index 2747220122..55c27a49aa 100644 --- a/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml +++ b/policyengine_uk/tests/policy/baseline/household/wealth/household_land_value.yaml @@ -48,7 +48,7 @@ output: household_land_value: 104000 -- name: Zero property wealth still counts owned land +- name: Zero domestic building value still counts owned land period: 2023 absolute_error_margin: 0 input: @@ -87,3 +87,80 @@ owned_land: 0 output: household_land_value: 88000 + +- name: UK land-only holdings enter property wealth and household land once + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + owned_land: 25000 + output: + property_wealth: 25000 + household_land_value: 25000 + total_wealth: 25000 + +- name: Overseas-only holdings have worldwide wealth but no UK land or household LVT + period: 2026 + absolute_error_margin: 0 + input: + gov.contrib.ubi_center.land_value_tax.rate: 0 + gov.contrib.ubi_center.land_value_tax.household_rate: 0.02 + gov.contrib.ubi_center.land_value_tax.corporate_rate: 0 + region: LONDON + overseas_property_value: 30000 + output: + property_wealth: 30000 + total_wealth: 30000 + household_land_value: 0 + LVT: 0 + +- name: Mixed London holdings exclude overseas property and count direct UK land once + period: 2026 + absolute_error_margin: 0.01 + input: + gov.contrib.ubi_center.land_value_tax.rate: 0 + gov.contrib.ubi_center.land_value_tax.household_rate: 0.02 + gov.contrib.ubi_center.land_value_tax.corporate_rate: 0 + region: LONDON + main_residence_value: 100000 + owned_land: 20000 + overseas_property_value: 30000 + output: + property_wealth: 150000 + total_wealth: 150000 + household_land_value: 105000 + LVT: 2100 + +- name: Unclassified other real estate uses the regional land approximation + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + other_property_value: 20000 + output: + property_wealth: 20000 + total_wealth: 20000 + household_land_value: 17000 + +- name: Large overseas holdings cannot reduce directly owned UK land through float32 cancellation + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + owned_land: 20000.50 + overseas_property_value: 8388608 + output: + household_land_value: 20000.50 + +- name: A saved worldwide aggregate does not replace the domestic land components + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_wealth: 900000 + main_residence_value: 100000 + owned_land: 20000 + overseas_property_value: 30000 + output: + property_wealth: 900000 + household_land_value: 105000 diff --git a/policyengine_uk/tests/policy/baseline/household/wealth/property_wealth_components.yaml b/policyengine_uk/tests/policy/baseline/household/wealth/property_wealth_components.yaml new file mode 100644 index 0000000000..cec2f04739 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/household/wealth/property_wealth_components.yaml @@ -0,0 +1,165 @@ +# Second homes and buy-to-let are separate WAS source categories but share +# the single saved other_residential_property_value input after data #501. +- name: Main residence contributes once to residential property and total wealth + period: 2026 + absolute_error_margin: 0 + input: + main_residence_value: 100000 + output: + residential_property_value: 100000 + property_wealth: 100000 + total_wealth: 100000 + +- name: Second-home-only source is carried by the other-residential component + period: 2026 + absolute_error_margin: 0 + input: + other_residential_property_value: 40000 + output: + residential_property_value: 40000 + property_wealth: 40000 + total_wealth: 40000 + +- name: Buy-to-let-only source is carried once by the other-residential component + period: 2026 + absolute_error_margin: 0 + input: + other_residential_property_value: 60000 + output: + residential_property_value: 60000 + property_wealth: 60000 + total_wealth: 60000 + +- name: Second homes and buy-to-let use one combined other-residential component + period: 2026 + absolute_error_margin: 0 + input: + other_residential_property_value: 100000 + output: + residential_property_value: 100000 + property_wealth: 100000 + total_wealth: 100000 + +- name: UK non-residential buildings contribute once to worldwide property wealth + period: 2026 + absolute_error_margin: 0 + input: + non_residential_property_value: 70000 + output: + residential_property_value: 0 + property_wealth: 70000 + total_wealth: 70000 + +- name: Direct UK land contributes once to worldwide property wealth + period: 2026 + absolute_error_margin: 0 + input: + owned_land: 20000 + output: + residential_property_value: 0 + property_wealth: 20000 + total_wealth: 20000 + +- name: Overseas land and buildings contribute once to worldwide property wealth + period: 2026 + absolute_error_margin: 0 + input: + overseas_property_value: 30000 + output: + residential_property_value: 0 + property_wealth: 30000 + total_wealth: 30000 + +- name: Other real estate contributes once without residential classification + period: 2026 + absolute_error_margin: 0 + input: + other_property_value: 50000 + output: + residential_property_value: 0 + property_wealth: 50000 + total_wealth: 50000 + +- name: All six saved property components and other wealth categories count once + period: 2026 + absolute_error_margin: 0 + input: + main_residence_value: 100000 + other_residential_property_value: 100000 + non_residential_property_value: 70000 + owned_land: 20000 + overseas_property_value: 30000 + other_property_value: 50000 + corporate_wealth: 11000 + private_pension_wealth: 13000 + savings: 17000 + output: + residential_property_value: 200000 + property_wealth: 370000 + total_wealth: 411000 + +- name: Overseas property never creates a UK dwelling or non-residential purchase + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + overseas_property_value: 500000 + output: + main_residential_property_purchased: 0 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + stamp_duty_land_tax: 0 + expected_sdlt: 0 + +- name: Unclassified other property never creates a UK property purchase + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + other_property_value: 500000 + output: + main_residential_property_purchased: 0 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + stamp_duty_land_tax: 0 + expected_sdlt: 0 + +- name: Second-home and buy-to-let stocks do not imply additional purchases + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + other_residential_property_value: 400000 + non_residential_property_value: 500000 + output: + residential_property_value: 400000 + additional_residential_property_purchased: 0 + non_residential_property_purchased: 0 + stamp_duty_land_tax: 0 + expected_sdlt: 0 + +# From 1 April 2025 a £300,000 additional-dwelling transaction pays +# 5% x £125,000 + 7% x £125,000 + 10% x £50,000 = £20,000. +# Finance Act 2025 sections 50-51 and Finance Act 2003 Schedule 4ZA. +- name: An explicit additional-dwelling transaction enters SDLT once with overseas and other stocks + period: 2026 + absolute_error_margin: 0 + input: + region: LONDON + property_purchased: true + other_residential_property_value: 400000 + overseas_property_value: 500000 + other_property_value: 500000 + additional_residential_property_purchased: 300000 + output: + residential_property_value: 400000 + property_wealth: 1400000 + main_residential_property_purchased: 0 + additional_residential_property_purchased: 300000 + non_residential_property_purchased: 0 + sdlt_on_residential_property_transactions: 20000 + stamp_duty_land_tax: 20000 + expected_sdlt: 20000 diff --git a/policyengine_uk/tests/policy/integration/income.yaml b/policyengine_uk/tests/policy/integration/income.yaml index 391dcf83fb..eb2c734fde 100644 --- a/policyengine_uk/tests/policy/integration/income.yaml +++ b/policyengine_uk/tests/policy/integration/income.yaml @@ -20,12 +20,22 @@ employment_income: 40_000 savings_interest_income: 15_000 output: - basic_rate_savings_income_pre_starter: 10_000 + # ITA 2007 s. 25(2): of the £12,500 Personal Allowance, £2,500 comes off + # the pay and £10,000 off the savings. The pay left is £37,500, the basic + # rate limit, so the £500 savings allowance (s. 12B(3)) covers savings + # that would otherwise bear 40% rather than 20%: £100 less tax + # than the allowance all against the pay. Savings of £5,000 run from + # £37,500 to £42,500: £500 at the nil rate and £4,500 at 40%, so the tax + # is 7,500 + 1,800 = 9,300. Setting more against the savings leaves it at + # 9,300; setting £500 more against the pay would bring the savings + # allowance below the limit and charge 9,400. + received_allowances_savings_income: 10_000 + basic_rate_savings_income_pre_starter: 0 savings_starter_rate_income: 0 - basic_rate_savings_income: 9_500 + basic_rate_savings_income: 0 savings_allowance: 500 - higher_rate_savings_income: 5_000 - taxed_savings_income: 14_500 + higher_rate_savings_income: 4_500 + taxed_savings_income: 4_500 - name: Dividends taxed at the marginal rates period: 2020 @@ -55,7 +65,7 @@ dividend_income_tax: 178.75 income_tax: 7_884.75 -- name: Personal allowance caps savings starter rate before dividends. +- name: Personal allowance goes to dividends rather than savings at the starting rate. period: 2027 absolute_error_margin: 0.01 input: @@ -63,14 +73,20 @@ savings_interest_income: 5_000 dividend_income: 5_000 output: - received_allowances_savings_income: 2_570 - basic_rate_savings_income_pre_starter: 2_430 - savings_starter_rate_income: 2_430 + # The pay takes £10,000 of the allowance first (ITA 2007 s. 25(3A)). The + # £5,000 of savings is all in the starting rate band (s. 12), so the + # other £2,570 saves tax only against dividends (s. 25(2)). Dividends of + # £2,430: (2,430 - 500) x 10.75% = 207.475. Setting the £2,570 against + # savings first would leave £5,000 of dividends and charge 483.75. + received_allowances_savings_income: 0 + received_allowances_dividend_income: 2_570 + basic_rate_savings_income_pre_starter: 5_000 + savings_starter_rate_income: 5_000 savings_allowance: 1_000 savings_income_tax: 0 - taxed_dividend_income: 4_500 - dividend_income_tax: 483.75 - income_tax: 483.75 + taxed_dividend_income: 1_930 + dividend_income_tax: 207.475 + income_tax: 207.475 - name: Starter-rate savings applies before personal savings allowance. period: 2027 diff --git a/policyengine_uk/tests/shard_durations.toml b/policyengine_uk/tests/shard_durations.toml index 5e04dd9d37..1185519663 100644 --- a/policyengine_uk/tests/shard_durations.toml +++ b/policyengine_uk/tests/shard_durations.toml @@ -7,7 +7,10 @@ # all five Python shards of nine runs on 24 October 2026, each test's mean # over the runs: merge queue runs 40050881130, 40059344467, 40063244949, # 40067189795, 40071060766, 40079467297, 40083761750 and 40087883891, and -# pull request run 40095846881. A slow test not here is placed by the hash. +# pull request run 40095846881. The entries for +# test_non_dependant_deduction_properties.py come from pull request run +# 41294689067 alone, below 20 seconds included. A slow test not here is +# placed by the hash. # test_ci_test_shards.py fails if an entry names a test that no longer # exists, so a pull request that renames or removes a listed test should # rename or delete its line here. Regenerate when a shard drifts well above @@ -167,6 +170,7 @@ "policyengine_uk/tests/test_uc_state_pension_properties.py::test_uc_falls_pound_for_pound_in_state_pension" = 96 "policyengine_uk/tests/test_uc_work_related_groups_properties.py::test_qualifying_age_route_moves_only_claimants_past_it" = 96 "policyengine_uk/tests/test_uc_earned_income_before_work_allowance_properties.py::test_more_pay_never_turns_the_childcare_criterion_on" = 96 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_non_dependant_deduction_invariants" = 96 "policyengine_uk/tests/test_legacy_award_work_properties.py::test_screens_match_a_family_by_family_reading" = 95 "policyengine_uk/tests/test_dated_reform_routes.py::test_a_schedule_of_two_changes_matches_the_oracle" = 95 "policyengine_uk/tests/test_uc_state_pension_properties.py::test_state_pension_counts_like_private_pension" = 95 @@ -262,6 +266,7 @@ "policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py::test_no_op_without_sharers_boarders_or_lodgers" = 72 "policyengine_uk/tests/test_state_pension_age.py::test_status_matches_statute_for_every_birth_date[2017]" = 72 "policyengine_uk/tests/test_pawhp_household_income_properties.py::test_switching_pawhp_off_lowers_household_income_by_pawhp" = 72 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_capped_universal_credit_matches_the_model" = 72 "policyengine_uk/tests/test_process_time_reforms.py::test_a_triple_lock_rate_set_by_reform_uprates_the_state_pension[via_parameter_changes-generated-bare-year]" = 71 "policyengine_uk/tests/test_state_pension_age.py::test_status_matches_statute_for_every_birth_date[2019]" = 71 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_basic_rate_rise_gives_responses_of_the_implied_sign" = 71 @@ -279,13 +284,12 @@ "policyengine_uk/tests/test_fuel_duty_schedule.py::test_annual_override_applies_at_full_strength_and_only_to_its_year" = 68 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_claimant_or_partner_awards_match_reference" = 66 "policyengine_uk/tests/test_council_tax_reduction_prescribed_benefits_properties.py::test_each_benefit_counts_pound_for_pound_in_every_scheme" = 66 -"policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py::test_net_earnings_never_fall_as_pay_rises" = 66 "policyengine_uk/tests/test_uc_statutory_pay_properties.py::test_a_dependants_statutory_pay_is_not_the_claimants_earned_income" = 66 "policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py::test_lha_categories" = 66 "policyengine_uk/tests/test_trading_loss_properties.py::test_zero_profit_trade_loss_cannot_raise_tax_or_reduce_programme_award[tax_credits-2024]" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_awards_never_change_the_readers_for_young_singles" = 65 "policyengine_uk/tests/test_carer_overlapping_benefits_properties.py::test_claiming_a_carer_benefit_reduced_to_nil_increases_pension_credit" = 65 -"policyengine_uk/tests/test_property_income_band_base_properties.py::test_more_property_income_never_lowers_savings_or_dividend_tax" = 65 +"policyengine_uk/tests/test_property_income_band_base_properties.py::test_more_property_income_never_lowers_income_tax" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_claimant_or_partner_awards_bounded_by_benefit_unit_awards" = 65 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_no_policy_change_gives_no_response" = 65 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_awards_never_change_the_readers_for_working_parents" = 64 @@ -372,6 +376,7 @@ "policyengine_uk/tests/test_uc_statutory_pay_properties.py::test_uc_does_not_increase_as_statutory_paternity_pay_rises" = 48 "policyengine_uk/tests/test_salary_sacrifice_ni_only.py::test_explicit_actual_return_matches_actual_pay_and_member_inputs[personal_allowance]" = 48 "policyengine_uk/tests/test_income_support_eligibility_properties.py::test_more_work_or_jsa_never_makes_a_family_eligible" = 48 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_earned_income_exemption_reads_universal_credit" = 48 "policyengine_uk/tests/test_housing_benefit_earnings_disregard_properties.py::test_disregard_is_non_decreasing_in_earnings" = 47 "policyengine_uk/tests/test_benefit_cap_uc_and_hb_properties.py::test_a_family_with_both_awards_is_capped_once_through_universal_credit" = 47 "policyengine_uk/tests/test_legacy_award_readers_properties.py::test_other_members_esa_never_changes_non_dependant_exemption" = 47 @@ -450,7 +455,7 @@ "policyengine_uk/tests/test_housing_benefit_guarantee_credit_passport_properties.py::test_receipt_means_a_paid_guarantee_credit_and_passports" = 39 "policyengine_uk/tests/test_labour_supply_response_baseline.py::test_each_response_is_measured_before_the_other" = 39 "policyengine_uk/tests/test_pension_annual_allowance_taper.py::test_allowance_bounds_and_statutory_two_gate_reference" = 39 -"policyengine_uk/tests/test_trading_loss_properties.py::test_relief_taxes_like_income_cut_in_section_25_order" = 38 +"policyengine_uk/tests/test_trading_loss_properties.py::test_relief_never_taxes_more_than_income_cut_in_the_old_order" = 38 "policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py::test_ni_excess_never_falls_with_sacrifice_or_pay" = 38 "policyengine_uk/tests/test_statutory_pension_edges.py::test_trade_losses_do_not_leak_nics_reclassification_into_aa[DC-2030]" = 38 "policyengine_uk/tests/test_severe_disability_afcs_properties.py::test_severe_disability_matches_the_tax_credit_condition" = 38 @@ -579,6 +584,10 @@ "policyengine_uk/tests/test_disable_simulated_benefits_winter_heating.py::test_a_household_that_moves_is_paid_under_each_years_scheme[LONDON-SCOTLAND]" = 20 "policyengine_uk/tests/test_capital_gains_responses.py::test_revenue_falls_short_of_the_static_estimate" = 20 "policyengine_uk/tests/test_capital_gains_response_default.py::test_employer_cost_branches_hold_the_realisation_response" = 20 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_band_edges_are_inclusive[WALES]" = 15 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_band_edges_are_inclusive[SCOTLAND]" = 12 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_band_edges_are_inclusive[HOUSING_BENEFIT]" = 10 +"policyengine_uk/tests/test_non_dependant_deduction_properties.py::test_band_edges_are_inclusive[ENGLAND]" = 9 # test_person_isa_capital.py (#2729): local timings on 1 November 2026, one # test at a time at load 40 to 90, and the threshold and first Pension Credit # nodes in one run of the file at load 90 to 165, until CI logs replace them. 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 38363d852a..88bca73799 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 @@ -38,10 +38,10 @@ schemes' size criteria to give the same number of bedrooms. The old category read the Universal Credit count (``LHA_allowed_bedrooms``); #2006 gave Housing Benefit its own (HB Regs 2006 reg. 13D(3), - ``housing_benefit_LHA_allowed_bedrooms``), which counts a young person - in the family whom the Universal Credit count leaves out, such as one - who is not a qualifying young person for Universal Credit. This change - leaves both counts as main has them. + ``housing_benefit_LHA_allowed_bedrooms``), which can differ from the + Universal Credit count: it gives a bedroom to a qualifying young person + a local authority looks after, whom UC Regs 2013 Sch 4 para 9(2)(g) + leaves out, and pools the children of other occupiers' families. 4. Reductions. 0 <= each scheme's reduction <= the excess of the welfare benefits over that scheme's cap; the UC reduction is the excess, with Housing Benefit counted as nil (reg. 80(2A)), less the childcare costs @@ -892,13 +892,12 @@ def test_examples_reach_the_cases(): assert v["is_uc_benefit_cap_single_claimant_rate"][lone_parent] assert not v["is_housing_benefit_benefit_cap_single_claimant_rate"][lone_parent] assert not v["reference_is_benefit_cap_single_claimant_rate"][lone_parent] - # HB's size criteria give the young person a bedroom (reg. 13D(3)(b)), - # so the LHA comparisons leave this family out (invariant 3). + # Both schemes' size criteria give the young person a bedroom: HB as a + # person who is not a child (reg. 13D(3)(b)), UC as a non-dependant who + # is not a child (Sch 4 paras 9(2) and 10(1)(c)). assert v["housing_benefit_LHA_category"][lone_parent] == "C" - assert ( - v["LHA_allowed_bedrooms"][lone_parent] - != v["housing_benefit_LHA_allowed_bedrooms"][lone_parent] - ) + assert v["LHA_allowed_bedrooms"][lone_parent] == 2 + assert v["housing_benefit_LHA_allowed_bedrooms"][lone_parent] == 2 # AFIP: excepted from both shared rates, and lifts both caps, which the # old lists did not do. assert v["LHA_category"][afip] == v["housing_benefit_LHA_category"][afip] == "B" 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 076d8ffa2c..9a91c9c0c4 100644 --- a/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_specified_benefits_properties.py @@ -31,9 +31,12 @@ Each scheme applies its own exceptions. UC reg 83(1)(f) names a "child or qualifying young person"; HB reg 75F(1)(e) names a "child or young person". UC includes a 16-year-old school leaver (reg 5(1)(a)) who does not receive -benefits in their own right (reg 5(5)) and is not looked after (reg 4(6)). -HB young persons follow Child Benefit (reg 19), so only the education-based -young-person role counts for Housing Benefit below. UC reg 83(1)(c) names +benefits in their own right (reg 5(5); SI 2014/1230 reg. 28) and is not +looked after (reg 4(6)). +HB young persons follow Child Benefit (reg 19), which includes a 16-year-old +school leaver until the 31 August after their 16th birthday (Child Benefit +(General) Regulations 2006 reg 4), counted for the year as the model does, +unless they receive benefits in their own right (reg 8). UC reg 83(1)(c) names "a claimant" for attendance allowance, which includes armed forces independence payment (reg 2); HB reg 75F(1)(ea) also names "a young person" for it. Only UC reg 83(1)(a), (j) names the LCWRA and carer elements; only @@ -86,6 +89,7 @@ from policyengine_uk import Simulation from policyengine_uk.model_api import BenUnit, Variable from policyengine_uk.scenarios.uc_reform import REBALANCING_YEARS +from policyengine_uk.tax_benefit_system import system as baseline_system from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input YEAR = 2025 @@ -277,9 +281,29 @@ def own_esa(m): return m["esa_contrib_reported"] > 0 or m["esa_income_reported"] > 0 -def own_benefits(m): - # UC reg 5(5): a personal report identifies an award in the person's own right. - return any(m.get(report, 0) > 0 for report in OWN_RIGHT_REPORTS) +def own_benefits(m, year=YEAR): + # UC reg. 5(5) (UC, contributory ESA, contribution-based JSA) and SI + # 2014/1230 reg. 28 (income-related ESA, and income-based JSA while it + # pays, as in 2025): a personal report identifies an award in the + # person's own right. The Secretary of State ties reg. 28's exclusion to + # entitlement, so the oracle counts an income-based JSA report only in a + # year the model pays it. + dwp = baseline_system.parameters(f"{year}-01-01").gov.dwp + reports = [ + report + for report in OWN_RIGHT_REPORTS + if report != "jsa_income_reported" or dwp.JSA.income.active + ] + return any(m.get(report, 0) > 0 for report in reports) + + +def child_benefit_own_benefits(m): + # Child Benefit (General) Regulations 2006 reg 8(2)(b), (e), (f). + return any( + m.get(report, 0) > 0 + for report in OWN_RIGHT_REPORTS + if report != "jsa_contrib_reported" + ) def reference_specified_benefit(members, benunit_inputs, scheme): @@ -288,19 +312,25 @@ def reference_specified_benefit(members, benunit_inputs, scheme): claimants = [m for m in members if m["_role"] == "claimant"] # UC reg 83 names a "qualifying young person" (reg 5), which includes a # 16-year-old school leaver (reg 5(1)(a)); HB reg 75F names a "young - # person" (reg 19), a Child Benefit qualifying young person. The - # school-leaver role here has no Child Benefit education or terminal-date - # circumstance, so it is not one. - young_roles = ("young_person", "sixteen") if scheme == "uc" else ("young_person",) + # person" (reg 19), a Child Benefit qualifying young person, which + # includes one too (Child Benefit (General) Regulations 2006 reg 4). + # Neither counts one receiving benefits in their own right. + young_roles = ("young_person", "sixteen") # The UC predicate supplements its qualifying-young-person test with a # separate age-16 approximation, which reads only the explicit own-right - # flag. Reported awards remove older UC young persons, but do not change - # that approximation or the Housing Benefit test. + # flag, so reported awards remove only older UC young persons. The Child + # Benefit test, which Housing Benefit follows, removes anyone who reports + # their own Universal Credit, ESA or income-based JSA (Child Benefit + # (General) Regulations 2006 reg 8(2)(b), (e), (f)). young_persons = [ m for m in members if m["_role"] in young_roles - and (scheme != "uc" or m["age"] == 16 or not own_benefits(m)) + and ( + (m["age"] == 16 or not own_benefits(m)) + if scheme == "uc" + else not child_benefit_own_benefits(m) + ) ] children = [m for m in members if m["_role"] == "child"] # No capital, so the couple's income-related award is their reports, @@ -406,18 +436,17 @@ def reference_cap(members, scheme): # Each scheme's rate: a single claimant (UC reg 80A(2)(c)) or a claimant # who neither has a partner nor is a lone parent (HB regs 2(1), 75CA(2)), # with no child or young person. The family-rate helper counts any - # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit's - # school-leaver terminal dates, which annual ages cannot place); it does + # 16-year-old for the year (UC reg 5(1)(a); for HB, Child Benefit reg 4 + # and its school-leaver terminal dates, which annual ages cannot place); it does # not yet apply reg 5(5) to one receiving benefits in their own right. - # Older UC young persons cease to count when they report their own award; - # Housing Benefit continues to read the existing Child Benefit test. + # Older young persons cease to count when they report their own award: + # for UC any award (reg 5(5)), for Housing Benefit the awards the Child + # Benefit test reads (Child Benefit (General) Regulations 2006 reg 8). claimants = sum(m["_role"] == "claimant" for m in members) + reports = own_benefits if scheme == "uc" else child_benefit_own_benefits dependants = any( m["_role"] in ("child", "sixteen", "sixteen_own_right") - or ( - m["_role"] == "young_person" - and (scheme != "uc" or m["age"] == 16 or not own_benefits(m)) - ) + or (m["_role"] == "young_person" and (m["age"] == 16 or not reports(m))) for m in members ) return SINGLE_CAP if claimants == 1 and not dependants else FAMILY_CAP @@ -615,8 +644,8 @@ def test_each_head_alone_matches_regulations(mode): assert not mismatches, mismatches # Both outcomes occur under each scheme. assert exempt.any() and not exempt.all() - # The schemes differ: UC's elements and 16-year-old school leaver, HB's - # young person's armed forces independence payment. + # The schemes differ: UC's elements, HB's young person's armed forces + # independence payment. assert (exemptions["uc"] != exemptions["hb"]).any() # Every role meets every circumstance. assert len(units) == len(SINGLE_HEAD_ROLES) * len(CIRCUMSTANCES) diff --git a/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py new file mode 100644 index 0000000000..5f0f4d0f23 --- /dev/null +++ b/policyengine_uk/tests/test_benefit_unit_non_dependant_properties.py @@ -0,0 +1,595 @@ +"""Property-based tests for non-dependants within a benefit unit. + +A benefit unit can include members aged 16 or over who are neither the +claimant or partner nor a qualifying young person: in law they are +non-dependants of the renter (UC Regs 2013 Sch 4 para 9; HB Regs 2006 reg 3; +CTR SI 2012/2885 reg 9 and equivalents). Invariants, for any generated +population of households: + +1. Oracle: each family's Universal Credit, Housing Benefit and Council Tax + Reduction non-dependant deductions, and who is eligible for an individual + deduction, equal an independent calculation from the generated household + structure: who is a non-dependant, of whom, how a non-dependant couple + pays one deduction (the higher), and how a non-dependant of several joint + occupiers is apportioned (by rent or liability share, whichever family + the non-dependant is in). +2. Conservation: every Housing Benefit non-dependant deduction (a couple's + counted once) is borne in full across the joint occupiers, and once more + by a boarder or lodger for a non-dependant in its own benefit unit, + unless a claimant is exempt; each Council Tax Reduction claiming family + bears every non-dependant, under its own scheme, in proportion to its + joint-liability share + unless the applicant is exempt; every Universal Credit contribution is + borne by exactly one family unless that family's renter is exempt. +3. Exemptions: a family whose renter is exempt (UC Sch 4 para 15; HB reg + 74(6); CTR Sch 1 para 8(6)) has no deductions; no deduction is charged for + a non-dependant under 21 (UC) or under 18 (HB, CTR), or for a qualifying + young person. +4. No-op: in households without non-dependants inside a benefit unit, the + deductions equal the previous formulas, which charged only members of + other families. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_core.periods import period as make_period + +from policyengine_uk import Simulation +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._national_non_dependants import ( + household_head_family_is_pensioner, + individual_non_dep_deduction, +) + +# Situation inputs without a period key are set for 2025, so the properties +# are checked in 2025, when the contribution was £93.02 a month. +YEAR = 2025 +UC_CONTRIBUTION = 93.02 * 12 +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Members of a benefit unit who are not the claimant or partner. +# dependant: a qualifying young person the claimant is responsible for; +# looked_after: a qualifying young person placed by a local authority, for +# whom no one in the family is responsible; +# non_dependant: aged 16 or over and not a qualifying young person. +KINDS = ["dependant", "looked_after", "non_dependant"] +ROLES = ["sharer", "lodger", "non_dependant"] + + +@st.composite +def extra_member(draw): + kind = draw(st.sampled_from(KINDS)) + age = draw(st.integers(16, 19) if kind != "non_dependant" else st.integers(16, 60)) + return dict( + kind=kind, + age=age, + earnings=draw(st.sampled_from([0, 2_600, 15_600, 30_000])), + hours=draw(st.sampled_from([0, 10, 37.5])), + attendance_allowance=draw(st.booleans()), + ) + + +@st.composite +def family(draw, role): + # The household head's family has the household's oldest members, so it + # is unambiguously the head's (and the council tax claimant's) family. + adult_age = st.integers(61, 90) if role == "head" else st.integers(25, 59) + return dict( + role=role, + adult_ages=draw(st.lists(adult_age, min_size=1, max_size=2)), + extras=draw(st.lists(extra_member(), min_size=0, max_size=2)), + child=draw(st.booleans()), + renter_pip=draw(st.booleans()), + # Take-up: a family flagged not to claim Universal Credit passes its + # in-unit non-dependants to the claim that counts the household's. + would_claim_uc=draw(st.booleans()), + payment=draw(st.floats(1_000, 10_000, allow_nan=False)), + ) + + +@st.composite +def household(draw): + head = draw(family("head")) + others = [ + draw(family(draw(st.sampled_from(ROLES)))) + for _ in range(draw(st.integers(0, 2))) + ] + return dict( + families=[head] + others, + rent=draw(st.floats(3_000, 20_000, allow_nan=False)), + tenure=draw(st.sampled_from(["RENT_FROM_COUNCIL", "RENT_PRIVATELY"])), + ) + + +population = st.lists(household(), min_size=1, max_size=5) + + +def build(population, convert=None): + """One situation for the whole population, and a record per person and + per family for the oracle. convert maps extra members' kinds to others.""" + people, benunits, homes = {}, {}, {} + person_rows, family_rows = [], [] + for h, house in enumerate(population): + members = [] + for f, fam in enumerate(house["families"]): + name = f"h{h}_f{f}" + ids = [] + head_family = f == 0 + # The head's family has the household's oldest member. + for i, age in enumerate(fam["adult_ages"]): + pid = f"{name}_adult_{i}" + pip = fam["renter_pip"] and i == 0 + people[pid] = { + "age": age, + "is_claimant_or_partner": True, + "is_household_head": head_family and i == 0, + "pip_dl": 3_000.0 if pip else 0.0, + } + ids.append(pid) + person_rows.append( + dict( + household=h, + family=len(family_rows), + kind="adult", + age=age, + disability_benefit=pip, + ) + ) + for i, extra in enumerate(fam["extras"]): + kind = (convert or {}).get(extra["kind"], extra["kind"]) + pid = f"{name}_extra_{i}" + young = kind != "non_dependant" + people[pid] = { + "age": min(extra["age"], 19) if young else extra["age"], + "is_claimant_or_partner": False, + "is_household_head": False, + "is_qualifying_young_person_for_universal_credit": young, + "is_child_or_qualifying_young_person_for_child_benefit": young, + "is_looked_after_by_local_authority": kind == "looked_after", + "employment_income": float(extra["earnings"]), + "weekly_hours": float(extra["hours"]), + "attendance_allowance": ( + 3_000.0 if extra["attendance_allowance"] else 0.0 + ), + } + ids.append(pid) + person_rows.append( + dict( + household=h, + family=len(family_rows), + kind=kind, + age=people[pid]["age"], + disability_benefit=extra["attendance_allowance"], + ) + ) + if fam["child"]: + pid = f"{name}_child" + # Inputs set for some members are inputs for all, so the + # child's Child Benefit status is stated, not left to default. + people[pid] = { + "age": 8, + "is_claimant_or_partner": False, + "is_household_head": False, + "is_child_or_qualifying_young_person_for_child_benefit": True, + } + ids.append(pid) + person_rows.append( + dict( + household=h, + family=len(family_rows), + kind="child", + age=8, + disability_benefit=False, + ) + ) + benunit = {"members": ids, "would_claim_uc": fam["would_claim_uc"]} + if fam["role"] == "sharer": + benunit["liable_for_share_of_household_rent"] = True + if fam["role"] == "lodger": + people[ids[0]]["rent_paid_as_lodger"] = fam["payment"] + benunits[name] = benunit + members.extend(ids) + family_rows.append( + dict( + household=h, + role=fam["role"], + renter_pip=fam["renter_pip"], + n_liable=len(fam["adult_ages"]), + ) + ) + homes[f"h{h}"] = { + "members": members, + "rent": house["rent"], + "tenure_type": house["tenure"], + "brma": "MAIDSTONE", + } + situation = {"people": people, "benunits": benunits, "households": homes} + return situation, person_rows, family_rows + + +def calc(sim, variable, map_to=None): + return np.asarray(sim.calculate(variable, YEAR, map_to=map_to), dtype=float) + + +def counted_once_per_couple(person_rows, amounts): + """A couple pays one deduction, the higher (HB reg 74(3); CTR Sch 1 para + 8(3)): keep the larger amount of each family's adults (its claimant and + partner) and zero the other; anyone else counts in full.""" + counted = np.array(amounts, dtype=float) + adults = {} + for p, row in enumerate(person_rows): + if row["kind"] == "adult": + adults.setdefault(row["family"], []).append(p) + for members in adults.values(): + keep = max(members, key=lambda p: (counted[p], -p)) + for p in members: + if p != keep: + counted[p] = 0.0 + return counted + + +def oracle( + person_rows, + family_rows, + uc, + hb, + ctr, + pension_credit, + claims, + ctr_share, + uc_counted, + uc_own_claim, + ctr_other, + ctr_own_scheme_is_heads, +): + """Expected deductions per family from the generated structure. + + uc, hb and ctr are each person's individual deduction from the model; + the oracle decides independently who is whose non-dependant. Which + Universal Credit claim counts the household's non-dependants + (uc_non_dependants_counted), whether a family makes its own claim, the + CTR claim flags and the joint-liability shares are read from the model. + ctr is each person's deduction under the scheme of the household head's + family; ctr_other under the other scheme, which a claiming family on that + scheme bears (SI 2012/2885 reg 3). + """ + n = len(family_rows) + exp_uc, exp_hb, exp_ctr = np.zeros(n), np.zeros(n), np.zeros(n) + expected_uc_individual = np.zeros(len(person_rows)) + hb = counted_once_per_couple(person_rows, hb) + ctr = counted_once_per_couple(person_rows, ctr) + ctr_other = counted_once_per_couple(person_rows, ctr_other) + households = sorted({f["household"] for f in family_rows}) + for h in households: + fams = [i for i, f in enumerate(family_rows) if f["household"] == h] + # Sch 4 para 9(2)(f): one claim counts the household's non-dependants. + counted = [i for i in fams if uc_counted[i]] + counted_family = counted[0] if counted else None + # Families liable for the household's rent: the head's and sharers'. + liable = [i for i in fams if family_rows[i]["role"] in ("head", "sharer")] + total_liable = sum(family_rows[i]["n_liable"] for i in liable) + share = { + i: (family_rows[i]["n_liable"] / total_liable if i in liable else 0.0) + for i in fams + } + for p, row in enumerate(person_rows): + if row["household"] != h: + continue + f = row["family"] + role = family_rows[f]["role"] + other_family = role == "non_dependant" + own_family = row["kind"] == "non_dependant" + # Only a lodger and partner are liable to pay the householder, so + # for HB and CTR the lodger's qualifying young person is a + # non-dependant of the joint occupiers too. UC leaves them out + # (Sch 4 para 9(2)(g)). + lodgers_young_person = role == "lodger" and row["kind"] in ( + "dependant", + "looked_after", + ) + # UC: a member of a family not liable for rent counts in the + # claim that counts the household's non-dependants; a non-QYP + # member aged 16+ of any other family counts in their own family's + # claim if it makes one, otherwise in that same counted claim. + # Para 16: Attendance Allowance or PIP daily living, or Pension + # Credit, which only a claimant or partner receives. + uc_exempt = row["disability_benefit"] or ( + row["kind"] == "adult" and pension_credit[f] > 0 + ) + uc_non_dependant = other_family or own_family + if uc_non_dependant and row["age"] >= 21 and not uc_exempt: + expected_uc_individual[p] = UC_CONTRIBUTION + own_claim = own_family and not other_family and uc_own_claim[f] + bearer = f if own_claim else counted_family + if ( + uc_non_dependant + and bearer is not None + and not family_rows[bearer]["renter_pip"] + ): + exp_uc[bearer] += uc[p] + # HB: every non-dependant, in any family, is pooled across the + # joint occupiers by rent share; a boarder or lodger, who is not + # a joint occupier, also bears its own in full. + if other_family or own_family or lodgers_young_person: + for i in fams: + exp_hb[i] += share[i] * hb[p] + if own_family and role == "lodger": + exp_hb[f] += hb[p] + # CTR: every non-dependant, a lodger's included, is pooled across + # the claiming families by joint-liability share, except that a + # family's own claimant or partner is not its non-dependant. + if other_family or own_family or lodgers_young_person: + for i in fams: + if i == f and not own_family: + continue + amount = ctr[p] if ctr_own_scheme_is_heads[i] else ctr_other[p] + exp_ctr[i] += claims[i] * ctr_share[i] * amount + # A claimant or partner on PIP daily living exempts their own family + # from all HB and CTR deductions (reg 74(6); Sch 1 para 8(6)). + for i in fams: + if family_rows[i]["renter_pip"]: + exp_hb[i] = 0.0 + exp_ctr[i] = 0.0 + return exp_uc, exp_hb, exp_ctr, expected_uc_individual + + +def ctr_by_scheme(sim, person_rows, family_rows): + """Each person's CTR deduction under the other scheme (pensioner or + working-age) from the household head's family, and whether each family is + on the head's scheme. Computed with the aggregator's own helper, since a + jointly liable applicant on the other scheme has its deductions + recomputed under that scheme.""" + person = sim.populations["person"] + period = make_period(YEAR) + head_pensioner = np.asarray(household_head_family_is_pensioner(person, period)) + eligible = ( + calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") > 0 + ) + other = np.asarray( + individual_non_dep_deduction( + person, period, sim.tax_benefit_system.parameters, ~head_pensioner + ), + dtype=float, + ) + first_member = {} + for p, row in enumerate(person_rows): + first_member.setdefault(row["family"], p) + family_head_pensioner = np.array( + [head_pensioner[first_member[f]] for f in range(len(family_rows))] + ) + family_pensioner = calc(sim, "council_tax_reduction_pensioner") > 0 + return dict( + ctr_other=eligible * other, + ctr_own_scheme_is_heads=family_pensioner == family_head_pensioner, + ) + + +def pool_under_own_scheme(person_rows, family_rows, v, mask=None): + """For each family, the household's CTR deductions (a couple's once) + under that family's own scheme, over the members in mask (everyone by + default).""" + mask = np.ones(len(person_rows), dtype=bool) if mask is None else mask + person_household = np.array([row["household"] for row in person_rows]) + family_household = np.array([row["household"] for row in family_rows]) + pools = {} + for scheme, amounts in (("head", v["ctr"]), ("other", v["ctr_other"])): + counted = counted_once_per_couple(person_rows, amounts) * mask + by_household = np.bincount( + person_household, weights=counted, minlength=family_household.max() + 1 + ) + pools[scheme] = by_household[family_household] + return np.where(v["ctr_own_scheme_is_heads"], pools["head"], pools["other"]) + + +def run(population, convert=None): + situation, person_rows, family_rows = build(population, convert) + sim = Simulation(situation=situation) + values = dict( + uc=calc(sim, "uc_individual_non_dep_deduction"), + hb=calc(sim, "household_benefits_individual_non_dep_deduction"), + ctr=calc(sim, "council_tax_reduction_individual_non_dep_deduction"), + pension_credit=calc(sim, "pension_credit"), + claims=calc(sim, "council_tax_reduction_claimant_benunit"), + ctr_share=calc(sim, "council_tax_reduction_joint_liability_share"), + uc_counted=calc(sim, "uc_non_dependants_counted") > 0, + **ctr_by_scheme(sim, person_rows, family_rows), + uc_own_claim=( + (calc(sim, "benunit_is_rent_liable") > 0) + & (calc(sim, "is_uc_eligible") > 0) + & (calc(sim, "claims_universal_credit") > 0) + ), + ) + return sim, person_rows, family_rows, values + + +@PROPERTY_SETTINGS +@given(population) +def test_deductions_match_the_oracle(population): + sim, person_rows, family_rows, v = run(population) + exp_uc, exp_hb, exp_ctr, exp_uc_individual = oracle(person_rows, family_rows, **v) + # 1. Oracle. The members each scheme classes as in-unit non-dependants + # are exactly the generated non-QYP members. + kinds = np.array([row["kind"] for row in person_rows]) + for in_unit_variable in ( + "is_benefit_unit_non_dependant_for_universal_credit", + "is_benefit_unit_non_dependant_for_legacy_benefits", + "is_benefit_unit_non_dependant_for_council_tax_reduction", + ): + assert np.array_equal( + calc(sim, in_unit_variable) > 0, kinds == "non_dependant" + ), in_unit_variable + assert np.allclose(v["uc"], exp_uc_individual, atol=0.01) + assert np.allclose(calc(sim, "uc_non_dep_deductions"), exp_uc, atol=0.01) + assert np.allclose( + calc(sim, "housing_benefit_non_dep_deductions"), exp_hb, atol=0.01 + ) + assert np.allclose( + calc(sim, "council_tax_reduction_non_dep_deductions"), exp_ctr, atol=0.01 + ) + + # HB and CTR eligibility: a non-dependant aged 18 or over, either in a + # family not liable for rent or within their own benefit unit. + def eligible(own_family_roles): + return np.array( + [ + row["age"] >= 18 + and ( + family_rows[row["family"]]["role"] == "non_dependant" + or ( + row["kind"] == "non_dependant" + and family_rows[row["family"]]["role"] in own_family_roles + ) + # Only a lodger and partner are liable to pay the householder. + or ( + family_rows[row["family"]]["role"] == "lodger" + and row["kind"] in ("dependant", "looked_after") + ) + ) + for row in person_rows + ] + ) + + # Any family's own non-dependants, a boarder's or lodger's included. HB + # eligibility also excludes an exempt non-dependant (reg 74(7)-(10)); CTR + # applies its exemptions in the amount. + everyone = eligible(("head", "sharer", "lodger", "non_dependant")) + hb_exempt = calc(sim, "housing_benefit_non_dep_deduction_exempt") > 0 + assert np.array_equal( + calc(sim, "housing_benefit_individual_non_dep_deduction_eligible") > 0, + everyone & ~hb_exempt, + ) + assert np.array_equal( + calc(sim, "council_tax_reduction_individual_non_dep_deduction_eligible") > 0, + everyone, + ) + + +@PROPERTY_SETTINGS +@given(population) +def test_conservation_and_exemptions(population): + sim, person_rows, family_rows, v = run(population) + ages = np.array([row["age"] for row in person_rows]) + kinds = np.array([row["kind"] for row in person_rows]) + # 2. Conservation. HB: each deduction, a couple's once, is borne in full + # across the joint occupiers, and a lodger's own non-dependant once more + # by the lodger, where no claimant is exempt. + hb_counted = counted_once_per_couple(person_rows, v["hb"]) + hb_families = sim.map_result( + calc(sim, "housing_benefit_non_dep_deductions"), "benunit", "household" + ) + lodgers_own = np.array( + [ + row["kind"] == "non_dependant" + and family_rows[row["family"]]["role"] == "lodger" + for row in person_rows + ] + ) + hb_people = sim.map_result(hb_counted * (1 + lodgers_own), "person", "household") + hb_exempt = ( + sim.map_result( + calc(sim, "housing_benefit_non_dep_deductions_claimant_exempt"), + "benunit", + "household", + ) + > 0 + ) + assert np.allclose(hb_families[~hb_exempt], hb_people[~hb_exempt], atol=0.01) + assert np.all(hb_families <= hb_people + 0.01) + # CTR: every non-dependant is borne by each claiming family, under its + # own scheme, in proportion to its joint-liability share (which follows + # the regulations' per-person wording, so shares need not sum to one); + # none where the applicant is exempt. This holds on the generator's + # domain (rent > 0, claimants liable for it), where no claiming family's + # own claimant or partner has a deduction. + ctr_families = calc(sim, "council_tax_reduction_non_dep_deductions") + expected_ctr = ( + v["claims"] + * v["ctr_share"] + * pool_under_own_scheme(person_rows, family_rows, v) + ) + ctr_exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") > 0 + assert np.allclose(ctr_families[~ctr_exempt], expected_ctr[~ctr_exempt], atol=0.01) + assert np.all(ctr_families[ctr_exempt] == 0) + # UC: each contribution is borne once, except where the bearer is exempt. + uc_families = sim.map_result( + calc(sim, "uc_non_dep_deductions"), "benunit", "household" + ) + uc_people = sim.map_result(v["uc"], "person", "household") + assert np.all(uc_families <= uc_people + 0.01) + # 3. Exemptions. + renter_exempt = calc(sim, "uc_non_dep_deductions_renter_exempt") > 0 + assert np.all(calc(sim, "uc_non_dep_deductions")[renter_exempt] == 0) + hb_claimant_exempt = ( + calc(sim, "housing_benefit_non_dep_deductions_claimant_exempt") > 0 + ) + assert np.all( + calc(sim, "housing_benefit_non_dep_deductions")[hb_claimant_exempt] == 0 + ) + assert np.all(v["uc"][ages < 21] == 0) + assert np.all(v["hb"][ages < 18] == 0) + assert np.all(v["ctr"][ages < 18] == 0) + # Nobody in a family liable for rent is charged except its own + # non-dependants. In a lodger's family, only the lodger and partner are + # liable. + not_non_dependant = kinds != "non_dependant" + + def in_liable_family(row): + role = family_rows[row["family"]]["role"] + return role != "non_dependant" and (role != "lodger" or row["kind"] == "adult") + + liable_family = np.array([in_liable_family(row) for row in person_rows]) + for amounts in (v["uc"], v["hb"], v["ctr"]): + assert np.all(amounts[not_non_dependant & liable_family] == 0) + + +@PROPERTY_SETTINGS +@given(population) +def test_no_op_without_non_dependants_in_a_benefit_unit(population): + every_kind_dependant = {kind: "dependant" for kind in KINDS} + sim, person_rows, family_rows, v = run(population, every_kind_dependant) + # 4. The previous formulas: only members of other families (a couple's + # higher amount once), charged to the claim that counts them (UC), by rent share + # (HB) or by claim share (CTR), with the claimants' exemptions. + person_household = np.array([row["household"] for row in person_rows]) + family_household = np.array([row["household"] for row in family_rows]) + other = calc(sim, "is_non_dependant_of_household_head") > 0 + + def other_families(amounts): + by_household = np.bincount( + person_household, + weights=amounts * other, + minlength=family_household.max() + 1, + ) + return by_household[family_household] + + renter_exempt = calc(sim, "uc_non_dep_deductions_renter_exempt") > 0 + assert np.allclose( + calc(sim, "uc_non_dep_deductions"), + np.where(renter_exempt, 0, v["uc_counted"] * other_families(v["uc"])), + atol=0.01, + ) + hb_claimant_exempt = ( + calc(sim, "housing_benefit_non_dep_deductions_claimant_exempt") > 0 + ) + hb_counted = counted_once_per_couple(person_rows, v["hb"]) + assert np.allclose( + calc(sim, "housing_benefit_non_dep_deductions"), + np.where( + hb_claimant_exempt, + 0, + calc(sim, "share_of_household_rent") * other_families(hb_counted), + ), + atol=0.01, + ) + ctr_exempt = calc(sim, "council_tax_reduction_applicant_has_non_dep_exemption") > 0 + pool = pool_under_own_scheme(person_rows, family_rows, v, mask=other) + assert np.allclose( + calc(sim, "council_tax_reduction_non_dep_deductions"), + np.where(ctr_exempt, 0, v["claims"] * v["ctr_share"] * pool), + atol=0.01, + ) diff --git a/policyengine_uk/tests/test_benunit_reported_capital_properties.py b/policyengine_uk/tests/test_benunit_reported_capital_properties.py index b1884bf6eb..724dcde71f 100644 --- a/policyengine_uk/tests/test_benunit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_benunit_reported_capital_properties.py @@ -2,14 +2,14 @@ A dataset records financial capital (the benefit unit's savings and investments, -1 when nothing is recorded). Universal Credit replaces only -financial proxies with that observation; `uc_reported_capital` is a separate -complete override and defaults to -1. Pension Credit continues to derive -`pension_credit_reported_capital` from the observation. +financial proxies with that observation. `uc_reported_capital` and +`pension_credit_reported_capital` are separate complete overrides and both +default to -1; Pension Credit also assesses the financial input separately. Invariants, over generated households of one or two benefit units: -1. Derivation: Pension Credit's reported capital equals the financial input, - -1 included. UC's complete override remains -1 without an explicit value. +1. Separation: both programmes' complete overrides remain -1 without an + explicit value, including when the financial input is supplied. 2. Differential: without property or person-level capital, a financial observation gives the same assessment as explicitly setting that unit's complete override too. Each unit consumes the same amount from the financial @@ -27,12 +27,13 @@ 7. Periods: the input set for one year is uprated into later years. A programme variable set directly applies only to the years it is set, as for any formula variable. Later years use the financial input, when present, - while the complete UC override returns to -1. + while both complete programme overrides return to -1. """ from pathlib import Path import numpy as np +import pytest import yaml from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -42,6 +43,8 @@ from policyengine_uk import Microsimulation, Simulation from policyengine_uk.data import UKSingleYearDataset +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2025 SETTINGS = settings( max_examples=15, @@ -157,11 +160,11 @@ def test_financial_capital_matches_complete_override_without_nonfinancial_assets a, b = outputs(via_input), outputs(via_programmes) for i, unit in enumerate(units): assert a["uc_reported_capital"][i] == -1 - assert a["pension_credit_reported_capital"][i] == unit["recorded"] + assert a["pension_credit_reported_capital"][i] == -1 if unit["recorded"] >= 0: assert a["uc_assessable_capital"][i] == unit["recorded"] for v in OUTPUTS: - if v == "uc_reported_capital": + if v in PROGRAMME_VARIABLES: continue np.testing.assert_allclose(a[v], b[v], rtol=0, atol=1e-6, err_msg=v) for v in ["uc_assessable_capital", "pension_credit_assessable_capital"]: @@ -178,7 +181,7 @@ def test_programme_variable_set_directly_wins_for_that_programme(case, base, uc) inputs[0]["uc_reported_capital"] = uc sim = simulate(units, household, inputs) assert sim.calculate("uc_reported_capital", YEAR)[0] == uc - assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == base + assert sim.calculate("pension_credit_reported_capital", YEAR)[0] == -1 assert sim.calculate("uc_assessable_capital", YEAR)[0] == uc @@ -274,13 +277,13 @@ def test_stored_financial_observations_and_explicit_overrides_project_like_savin / sim.calculate("savings", 2025).values ) assert np.all(growth > 1) - projected_names = ["pension_credit_reported_capital"] - if "uc_reported_capital" in columns: - projected_names.append("uc_reported_capital") - else: + projected_names = list(columns) + for name in PROGRAMME_VARIABLES: + if name in columns: + continue for year in [2025, 2027]: np.testing.assert_array_equal( - sim.calculate("uc_reported_capital", year).values, + sim.calculate(name, year).values, [-1, -1, -1], ) for name in projected_names: @@ -303,10 +306,10 @@ def test_stored_financial_observations_and_explicit_overrides_project_like_savin ) -def _single_adult(benunit_inputs): +def _single_adult(benunit_inputs, age=30): return Simulation( situation={ - "people": {"p": {"age": {"2025": 30}}}, + "people": {"p": {"age": {"2025": age}}}, "benunits": {"b": {"members": ["p"], **benunit_inputs}}, "households": {"h": {"members": ["p"], "savings": {"2025": 50_000}}}, } @@ -314,14 +317,17 @@ def _single_adult(benunit_inputs): def test_input_set_once_is_uprated_into_later_years(): - sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}) + sim = _single_adult({"benunit_reported_capital": {"2025": 10_000}}, age=75) index = sim.tax_benefit_system.parameters.get_child( "gov.economic_assumptions.indices.obr.per_capita.gdp" ) expected = 10_000 * index("2026-01-01") / index("2025-01-01") assert sim.calculate("uc_reported_capital", 2026)[0] == -1 + assert sim.calculate("pension_credit_reported_capital", 2026)[0] == -1 np.testing.assert_allclose( - sim.calculate("pension_credit_reported_capital", 2026)[0], expected, rtol=1e-6 + sim.calculate("pension_credit_assessable_capital", 2026)[0], + expected, + rtol=1e-6, ) np.testing.assert_allclose( sim.calculate("uc_assessable_capital", 2026)[0], expected, rtol=1e-6 diff --git a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py index 68b38ea818..b3b34f628c 100644 --- a/policyengine_uk/tests/test_boarder_lodger_rent_properties.py +++ b/policyengine_uk/tests/test_boarder_lodger_rent_properties.py @@ -48,9 +48,9 @@ head's benefit unit who pays, or who is a claimant or partner in a benefit unit where a claimant or partner pays. 10. Moving a non-paying adult who is not a claimant or partner out of the - payer's benefit unit into one of their own leaves the household head's - Housing Benefit and council tax reduction non-dependant deductions - unchanged. + payer's benefit unit into one of their own that claims no Universal Credit + leaves the household head's Housing Benefit and council tax reduction + non-dependant deductions unchanged. 11. Adding such an adult never lowers the household head's Housing Benefit non-dependant deductions, and never raises the head's Housing Benefit unless it raises the head's bedroom count under the size criteria. @@ -156,6 +156,8 @@ def situation(h, year, scale=1.0, as_kind=None): people[name] = person members.append(name) benunits[f"payer_{j}"] = {"members": members} + if any(attrs.get("forgoes_universal_credit") for attrs in unit): + benunits[f"payer_{j}"]["would_claim_uc"] = {year: False} names += members return { "people": people, @@ -461,7 +463,14 @@ def households_with_a_non_paying_adult(draw): def with_adult(h, adult, own_unit): h = dict(h, payers=[list(unit) for unit in h["payers"]]) if own_unit: - h["payers"].append([dict(adult, is_claimant_or_partner=True)]) + # A family's own Universal Credit award with no earned income exempts + # an adult from the national schemes' deductions (SI 2012/2885 Sch 1 + # para 8(8)(c) and equivalents), and one under 25 from Housing Benefit's + # (HB Regs 2006 reg 74(8)). The model reads no award for an adult in + # another family's unit, so this family doesn't claim. + h["payers"].append( + [dict(adult, is_claimant_or_partner=True, forgoes_universal_credit=True)] + ) else: h["payers"][0].append(adult) return h diff --git a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py index 09c62063dd..4833c90e2f 100644 --- a/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py +++ b/policyengine_uk/tests/test_budget_2026_savings_property_rates_properties.py @@ -4,7 +4,8 @@ 6 April 2027 (paragraphs 6.246 and 6.247), on top of Budget 2025's 22%, 42% and 47%. Each test compares current law with the Budget 2025 rates for the same generated people, and checks identities that hold for every input -whose property allowance election is the same under both sets of rates: +whose property allowance election and allowance order stay the same under +both sets of rates: - rUK property tax rises by exactly 2% of taxable property income, since every pound of it sits in one of the three rate bands; @@ -23,6 +24,16 @@ property tax and the relief both move (test_higher_relief_can_make_giving_up_the_property_allowance_worthwhile). The model makes that choice only where it lowers the person's income tax. + +The order of the allowances can change too. A Scottish taxpayer's property +income stays on the Scottish rates while the savings rates rise, so the +allowance can save more against savings, and then both taxes move +(test_higher_savings_rates_can_move_the_allowance_onto_savings). For everyone, +whatever the share, income tax rises by at most 2% of the banded savings and +taxable property income under the Budget 2025 rates: the Budget 2025 share +costs at most that much more at the higher rates, and the model takes a share +that costs no more than it +(test_income_tax_rises_by_at_most_two_points_of_banded_income). """ import numpy as np @@ -56,10 +67,14 @@ st.one_of(st.floats(min_value=0, max_value=20_000), amount), # savings ) cases = st.lists(case, min_size=CASES, max_size=CASES) -# Review round 2's counterexample: the fourth person (Scotland) gives up the -# property allowance at the higher rates. +# The fourth person, a Scottish taxpayer, gives up the property allowance at +# the higher rates. ELECTION_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES ELECTION_CHANGE[3] = (20_000.0, 500.0, 0.87, 0.0) +# The fourth person, a Scottish taxpayer, sets the allowance against savings at +# the higher rates. +ORDER_CHANGE = [(0.0, 0.0, 0.0, 0.0)] * CASES +ORDER_CHANGE[3] = (0.0, 58_000.0, 0.0, 24_000.0) def situation(): @@ -88,7 +103,12 @@ def sims(): ) -VARIABLES = [ +ALLOWANCE_ORDER = [ + "received_allowances_earned_income", + "received_allowances_savings_income", + "received_allowances_dividend_income", +] +VARIABLES = ALLOWANCE_ORDER + [ "pays_scottish_income_tax", "uses_property_allowance", "taxable_property_income_after_allowances", @@ -101,6 +121,12 @@ def sims(): "property_finance_cost_relief", "income_tax", ] +BANDED = [ + "basic_rate_savings_income", + "higher_rate_savings_income", + "add_rate_savings_income", + "taxable_property_income_after_allowances", +] def run(sims, drawn): @@ -116,9 +142,13 @@ def run(sims, drawn): return results -def same_election(law, before): - """People whose property allowance election the rates leave unchanged.""" - return law["uses_property_allowance"] == before["uses_property_allowance"] +def same_choices(law, before): + """People whose property allowance election and allowance order the rates + leave unchanged.""" + same = law["uses_property_allowance"] == before["uses_property_allowance"] + for name in ALLOWANCE_ORDER: + same &= np.abs(law[name] - before[name]) <= tolerance(law[name]) + return same def tolerance(*values): @@ -138,7 +168,7 @@ def tolerance(*values): @example(ELECTION_CHANGE) def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): law, before = run(sims, drawn) - ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + ruk = ~law["pays_scottish_income_tax"].astype(bool) & same_choices(law, before) rise = law["property_income_tax"] - before["property_income_tax"] expected = RISE * law["taxable_property_income_after_allowances"] tol = tolerance(law["property_income_tax"], expected) @@ -148,9 +178,10 @@ def test_ruk_property_tax_rises_by_two_points_of_taxable_property(sims, drawn): @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): law, before = run(sims, drawn) - scottish = law["pays_scottish_income_tax"].astype(bool) & same_election(law, before) + scottish = law["pays_scottish_income_tax"].astype(bool) & same_choices(law, before) rise = law["property_income_tax"] - before["property_income_tax"] tol = tolerance(law["property_income_tax"]) assert (np.abs(rise)[scottish] <= tol[scottish]).all() @@ -159,6 +190,7 @@ def test_scottish_property_tax_ignores_the_property_rates(sims, drawn): @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): law, before = run(sims, drawn) banded = ( @@ -168,13 +200,31 @@ def test_savings_tax_rises_by_two_points_in_every_country(sims, drawn): ) rise = law["savings_income_tax"] - before["savings_income_tax"] tol = tolerance(law["savings_income_tax"], banded) - same = same_election(law, before) + same = same_choices(law, before) assert (np.abs(rise - RISE * banded) <= tol)[same].all() @SETTINGS @given(cases) @example(ELECTION_CHANGE) +@example(ORDER_CHANGE) +def test_income_tax_rises_by_at_most_two_points_of_banded_income(sims, drawn): + """Under the Budget 2025 share and property allowance election, the Budget + 2026 rates add 2% of the savings in the rate bands and, outside Scotland, + of the taxable property income, and the finance cost relief never falls. + The model's share and election give no more tax than that one (ITA 2007 + s. 25(2)), so the bound holds for everyone, including people whose share + the rates move.""" + law, before = run(sims, drawn) + bound = before["income_tax"] + RISE * sum(before[name] for name in BANDED) + tol = tolerance(law["income_tax"], bound) + assert (law["income_tax"] <= bound + tol).all() + + +@SETTINGS +@given(cases) +@example(ELECTION_CHANGE) +@example(ORDER_CHANGE) def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): law, before = run(sims, drawn) rise = law["property_finance_cost_relief"] - before["property_finance_cost_relief"] @@ -183,7 +233,36 @@ def test_finance_cost_relief_rises_by_at_most_two_points(sims, drawn): assert (rise >= -tol).all() full_before = 0.22 * before["property_finance_costs_relieved"] uncapped = np.abs(before["property_finance_cost_relief"] - full_before) <= tol - assert (rise <= cap + tol)[uncapped & same_election(law, before)].all() + assert (rise <= cap + tol)[uncapped & same_choices(law, before)].all() + + +def test_higher_savings_rates_can_move_the_allowance_onto_savings(sims): + """Property income of £58,000 and savings of £24,000 for a Scottish + taxpayer. Under the Budget 2025 rates the allowance saves 42% against + either income, and the old order sets it against property income. Under + the Budget 2026 rates savings pay 44% and this property income still pays + the Scottish 42%, so ITA 2007 s.25(2) sets the £12,570 against savings. + + Savings tax goes from 42% of £23,500 to 44% of £10,930, after the £500 + savings allowance. Property tax rises by 42% of £12,570. Income tax rises + by £218.60, against £470 had the allowance stayed on property income: the + move saves 2% of £12,570, £251.40.""" + law, before = run(sims, ORDER_CHANGE) + person = ORDER_CHANGE.index((0.0, 58_000.0, 0.0, 24_000.0)) + assert law["pays_scottish_income_tax"][person] + assert before["received_allowances_earned_income"][person] == 12_570 + assert before["received_allowances_savings_income"][person] == 0 + assert law["received_allowances_earned_income"][person] == 0 + assert law["received_allowances_savings_income"][person] == 12_570 + assert before["savings_income_tax"][person] == pytest.approx(9_870, abs=0.01) + assert law["savings_income_tax"][person] == pytest.approx(4_809.2, abs=0.01) + assert law["property_income_tax"][person] - before["property_income_tax"][ + person + ] == pytest.approx(5_279.4, abs=0.01) + assert law["income_tax"][person] - before["income_tax"][person] == pytest.approx( + 218.6, abs=0.01 + ) + assert not same_choices(law, before)[person] def test_capped_finance_cost_relief_can_rise_by_more_than_two_points(): diff --git a/policyengine_uk/tests/test_capital_share_components.py b/policyengine_uk/tests/test_capital_share_components.py index 608aa1e94d..04407d88e2 100644 --- a/policyengine_uk/tests/test_capital_share_components.py +++ b/policyengine_uk/tests/test_capital_share_components.py @@ -58,12 +58,15 @@ def _parameter(path): return _node(path)(f"{YEAR}-01-01") +SALE_LISTS = [ + path.rsplit(".", 1)[0] + ".sale_expenses.sources" for path in SOURCE_LISTS.values() +] ORIGINAL_LISTS = { path: [ "corporate_wealth" if source == "share_like_capital" else source for source in _parameter(path) ] - for path in SOURCE_LISTS.values() + for path in [*SOURCE_LISTS.values(), *SALE_LISTS] } ORIGINAL_REFORM = { path: {f"{YEAR}-01-01.{YEAR + 4}-12-31": sources} @@ -114,13 +117,14 @@ def test_lists_name_share_like_capital_only(variable): assert "share_like_capital" in sources assert "corporate_wealth" not in sources assert not set(COMPONENTS) & set(sources) - # Where a programme lists the sources whose sale incurs expenses, it names - # the same share-like variable, or shares would silently lose the - # deduction. + # Every programme values shares less 10% for the expenses of sale (UC + # Regs 2013 reg. 49(1)(a) and the legacy equivalents), so its list of + # those sources names the same share-like variable, or shares would + # silently lose the deduction. node = _node(SOURCE_LISTS[variable].rsplit(".", 1)[0]) - if "sale_expenses" in node.children: - sale = list(node.sale_expenses.sources(f"{YEAR}-01-01")) - assert not {"corporate_wealth", *COMPONENTS} & set(sale) + sale = list(node.sale_expenses.sources(f"{YEAR}-01-01")) + assert "share_like_capital" in sale + assert not {"corporate_wealth", *COMPONENTS} & set(sale) @SETTINGS @@ -244,7 +248,9 @@ def test_components_alone_are_counted(): ] got = _results(households) assert got["share_like_capital"][0] == 12_000 - assert got["uc_assessable_capital"][0] == 13_000 + # Savings in full and shares less 10% for the expenses of sale (UC Regs + # 2013 reg. 49(1)(a)): 1,000 + 90% of 12,000. + assert got["uc_assessable_capital"][0] == 11_800 def test_a_disregarded_company_holding_leaves_the_capital_that_counts_it(): @@ -281,5 +287,7 @@ def test_a_disregarded_company_holding_leaves_the_capital_that_counts_it(): } sim = Simulation(situation=situation) assert sim.calculate("household_uc_company_holding_pool_scale", YEAR)[0] == 1 - assert sim.calculate("household_uc_pool_company_holdings", YEAR)[0] == 30_000 + # The pool counts the shares at 90% (reg. 49(1)(a)), so the holding + # leaves it at that value. + 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 new file mode 100644 index 0000000000..50c4554ef1 --- /dev/null +++ b/policyengine_uk/tests/test_capital_valuation_properties.py @@ -0,0 +1,639 @@ +"""Property-based tests for how the means tests value capital. + +Universal Credit and each legacy means test calculate capital in the United +Kingdom at its current market or surrender value less 10% where there would +be expenses attributable to sale, and less any encumbrance secured on it (UC +Regs 2013 reg. 49(1); HB Regs 2006 reg. 47; HB (SPC) Regs 2006 reg. 45; IS +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). + +Invariants, for any generated population of single-adult households: + +1. Non-increasing: every programme's assessable capital is at most the same + household's capital with the deduction switched off (rate 0, no debt). +2. Cash only: a household holding only savings and a cash ISA has the same + capital with the deduction on or off, equal to the two balances. +3. Differential: capital equals a direct implementation of the regulation, + savings + sum over sources of max(0, 90% of value - secured debt), where + the test itself fixes which assets take the 10% (land, property and + shares, not cash), so a wrong parameter list fails it. +4. Monotone: a higher sale-expense rate, or more secured debt, never raises + capital. +5. Isolation: debt secured on one source never takes capital below the value + of the household's unencumbered sources, and capital is never negative. + (Isolation between two encumbered sources is pinned by the differential and + by a YAML case.) +6. Pennies: 90% of any whole-penny value stored as a float32 input is the + exact figure rounded down (the legacy benefits) or to the nearest penny + (Universal Credit and Housing Benefit), checked in integer pence up to + 131,071.99, the largest value float32 holds to the penny. +7. Never rounds the wrong way: for any stored value, whole penny or not, the + result is within a penny below 90% of the exact stored value when rounding + down, and within half a penny of it when rounding to the nearest penny. + The same holds for a float32 value widened to float64, as a benefit unit's + maximum over its household's values is. +8. Whole pennies through the model: every programme counts whole-penny + savings, shares and Lifetime ISA balances to the penny: savings in full, + shares at 90% and a Lifetime ISA at 75% below its charge-free age, each + rounded by the programme's rule, and a Lifetime ISA in full from that age. +""" + +from fractions import Fraction +from functools import reduce + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, 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 Person, Variable +from policyengine_uk.model_api import YEAR as YEAR_PERIOD +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Assessable capital variable -> parameter node holding its sources and +# sale-expense rule. Pension Credit counts only pension-age adults; Housing +# Benefit is checked at both ages, disregarding all capital for pension-age +# Guarantee Credit recipients (HB (SPC) Regs 2006 reg. 26); the others are +# checked on working-age adults. +PROGRAMMES = { + "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", +} +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). +SALE_EXPENSE_ASSETS = { + "owned_land", + "other_residential_property_value", + "non_residential_property_value", + "overseas_property_value", + "other_property_value", + "share_like_capital", +} +LEGAL_RATE = 0.1 +ASSETS = [ + "savings", + "cash_isa", + "owned_land", + "corporate_wealth", + "other_residential_property_value", + "non_residential_property_value", + "overseas_property_value", + "other_property_value", +] +# Ordinary savings and a cash ISA are both deposit balances, counted in full. +CASH_ASSETS = ("savings", "cash_isa") +# The capital sources count shares as share_like_capital, which is the +# corporate_wealth these households are given. +INPUT_OF = {"share_like_capital": "corporate_wealth"} + + +def asset(unit, source): + return unit["assets"][INPUT_OF.get(source, source)] + + +DEBTS = { + "owned_land": "owned_land_secured_debt", + "other_residential_property_value": "other_residential_property_secured_debt", + "non_residential_property_value": "non_residential_property_secured_debt", +} +amount = st.one_of(st.just(0.0), st.floats(0, 400_000, allow_nan=False)) + + +@st.composite +def households(draw, cash_only=False): + assets = {asset: draw(amount) for asset in ASSETS} + debts = {debt: draw(amount) for debt in DEBTS.values()} + if cash_only: + assets = {asset: 0.0 for asset in ASSETS} | { + cash: assets[cash] for cash in CASH_ASSETS + } + debts = {debt: 0.0 for debt in debts} + # About half the units do not claim Universal Credit, so the Housing + # Benefit passport cannot remove every working-age unit from the + # differential below. + return dict( + age=draw(st.sampled_from([30, 70])), + claims_uc=draw(st.booleans()), + assets=assets, + debts=debts, + ) + + +def situation(units, debt_scale=1.0): + people, benunits, homes = {}, {}, {} + for i, unit in enumerate(units): + people[f"p{i}"] = {"age": {YEAR: unit["age"]}} + benunits[f"b{i}"] = { + "members": [f"p{i}"], + "would_claim_uc": {YEAR: unit["claims_uc"]}, + } + homes[f"h{i}"] = { + "members": [f"p{i}"], + **{k: {YEAR: v} for k, v in unit["assets"].items()}, + **{k: {YEAR: v * debt_scale} for k, v in unit["debts"].items()}, + } + return {"people": people, "benunits": benunits, "households": homes} + + +def rate_reform(rate): + return { + f"{node}.sale_expenses.rate": {str(YEAR): rate} for node in PROGRAMMES.values() + } + + +def calculate(units, rate=None, debt_scale=1.0): + sim = Simulation( + situation=situation(units, debt_scale), + reform=None if rate is None else rate_reform(rate), + ) + values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + values["guarantee_credit"] = np.asarray(sim.calculate("guarantee_credit", YEAR)) + values["hb_passported"] = np.asarray( + sim.calculate("housing_benefit_on_passporting_benefit", YEAR) + ) + return sim, values + + +def parameter_node(sim, path): + return reduce(getattr, path.split("."), sim.tax_benefit_system.parameters(YEAR)) + + +def applies(variable, unit, guarantee_credit=0.0, hb_passported=False): + if variable == "housing_benefit_assessable_capital": + # A family on a passporting benefit has nil HB capital + # (housing_benefit_on_passporting_benefit, on main since #1927). + if hb_passported: + return False + return unit["age"] < 67 or guarantee_credit <= 0 + return (unit["age"] >= 67) == (variable in PENSION_AGE_ONLY) + + +def expected_capital(unit, node, variable=None): + """Reg. 49(1) applied source by source, independently of the model.""" + total = 0.0 + if variable == "uc_assessable_capital": + # Universal Credit values countable property on its own path + # (property_sources), outside the financial sources. + for source in [ + "other_residential_property_value", + "non_residential_property_value", + "overseas_property_value", + "other_property_value", + ]: + total += max( + 0.0, + asset(unit, source) * (1 - LEGAL_RATE) + - unit["debts"].get(DEBTS.get(source), 0.0), + ) + for source in node.sources: + value = asset(unit, source) + if source in SALE_EXPENSE_ASSETS: + value *= 1 - LEGAL_RATE + if source in DEBTS: + value = max(0.0, value - unit["debts"][DEBTS[source]]) + total += value + return total + + +def close(a, b): + return abs(a - b) <= 1 + 1e-6 * abs(b) + + +def check_valuation(units): + """Assert the valuation invariants; count which Housing Benefit branches ran.""" + sim, valued = calculate(units) + _, full = calculate(units, rate=0.0, debt_scale=0.0) + hb_checked = {"passported_nil": 0, "working_age_differential": 0} + for variable, path in PROGRAMMES.items(): + node = parameter_node(sim, path) + assert node.sale_expenses.rate == LEGAL_RATE + assert "savings" in node.sources + counted = set(node.sources) + assert set(node.sale_expenses.sources) & counted == ( + SALE_EXPENSE_ASSETS & counted + ), variable + hb = variable == "housing_benefit_assessable_capital" + for i, unit in enumerate(units): + gc = valued["guarantee_credit"][i] + assert valued[variable][i] >= 0, (variable, unit) + assert valued[variable][i] <= full[variable][i] + 0.01, (variable, unit) + if hb and valued["hb_passported"][i]: + assert valued[variable][i] == 0, unit + hb_checked["passported_nil"] += 1 + if hb and unit["age"] >= 67 and gc > 0: + assert valued[variable][i] == 0, unit + if applies(variable, unit, gc, valued["hb_passported"][i]): + expected = expected_capital(unit, node, variable) + assert close(valued[variable][i], expected), (variable, unit) + if hb and unit["age"] < 67 and expected > 0: + hb_checked["working_age_differential"] += 1 + # Debt secured on property never reaches the other assets. + floor = sum( + asset(unit, s) * (1 - LEGAL_RATE * (s in SALE_EXPENSE_ASSETS)) + for s in node.sources + if s not in DEBTS + ) + assert valued[variable][i] >= floor - 1, (variable, unit) + return hb_checked + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=20, max_size=200)) +def test_valuation_matches_the_regulation_and_never_adds_capital(units): + check_valuation(units) + + +def test_housing_benefit_branches_are_both_exercised(): + """Fixed units that a passport cannot hide: one claims UC with £5,000 of + savings (UC passports it to nil HB capital), one declines UC and holds a + mortgaged second home (its HB capital is checked against reg 47).""" + no_debt = {debt: 0.0 for debt in DEBTS.values()} + units = [ + dict( + age=30, + claims_uc=True, + assets={asset: 0.0 for asset in ASSETS} | {"savings": 5_000.0}, + debts=no_debt, + ), + dict( + age=30, + claims_uc=False, + assets={asset: 0.0 for asset in ASSETS} + | {"savings": 2_000.0, "other_residential_property_value": 100_000.0}, + debts=no_debt | {"other_residential_property_secured_debt": 60_000.0}, + ), + ] + hb_checked = check_valuation(units) + assert hb_checked["passported_nil"] >= 1 + assert hb_checked["working_age_differential"] >= 1 + + +@PROPERTY_SETTINGS +@given(st.lists(households(cash_only=True), min_size=20, max_size=200)) +def test_cash_only_households_have_no_deduction(units): + _, valued = calculate(units) + _, full = calculate(units, rate=0.0) + for variable in PROGRAMMES: + assert np.allclose(valued[variable], full[variable]), variable + for i, unit in enumerate(units): + if applies( + variable, + unit, + valued["guarantee_credit"][i], + valued["hb_passported"][i], + ): + cash = sum(unit["assets"][asset] for asset in CASH_ASSETS) + assert close(valued[variable][i], cash), variable + + +@PROPERTY_SETTINGS +@given( + st.lists(households(), min_size=20, max_size=200), + st.floats(0, 0.5), + st.floats(0, 0.5), + st.floats(0, 2), +) +def test_capital_is_non_increasing_in_rate_and_secured_debt(units, r1, r2, scale): + low, high = sorted([r1, r2]) + _, at_low = calculate(units, rate=low) + _, at_high = calculate(units, rate=high) + _, more_debt = calculate(units, rate=low, debt_scale=1 + scale) + for variable in PROGRAMMES: + assert np.all(at_high[variable] <= at_low[variable] + 0.01), variable + assert np.all(more_debt[variable] <= at_low[variable] + 0.01), variable + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given(st.lists(st.integers(0, 13_107_199), min_size=1, max_size=1_000)) +# 16,001 is 14,400.8994 after a float32 multiplication; 1,000.10 is stored as +# 1,000.0999756; 90% of 2.30 is 206.99999999999997 pence in float64; 90% of +# 16,666.65 is 14,999.985, a half penny. +@example([1_600_100, 100_010, 230, 1_666_665]) +def test_sale_expenses_round_to_the_penny(pence): + stored = np.asarray(pence, dtype=np.int64).astype(np.float32) / np.float32(100) + # A benefit unit's maximum over its household's float32 values is float64. + for value in (stored, stored.astype(np.float64)): + assert np.round(exact_amount(value) * 100).astype(np.int64).tolist() == pence + down = less_sale_expenses(value, LEGAL_RATE, True) + nearest = less_sale_expenses(value, LEGAL_RATE, False) + assert down.dtype == nearest.dtype == np.float64 + assert np.round(down * 100).astype(np.int64).tolist() == [ + 9 * amount // 10 for amount in pence + ] + assert np.round(nearest * 100).astype(np.int64).tolist() == [ + (9 * amount + 5) // 10 for amount in pence + ] + + +def test_share_valuation_matches_the_adm_worked_example(): + # ADM H1665 and DMG 84763: 250 shares at 4.1375 are 1,034.375, and less + # 10% for the expenses of sale that is 930.9375: 930.93 rounded down, as + # the legacy benefits do, or 930.94 to the nearest penny, as Universal + # Credit does (UC Regs 2013 reg. 6(1)). + for stored in (1034.375, np.float32(1034.375)): + assert less_sale_expenses(stored, LEGAL_RATE, True) == 930.93 + assert less_sale_expenses(stored, LEGAL_RATE, False) == 930.94 + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + st.lists( + st.floats(0, 1_000_000, allow_nan=False, width=32), min_size=1, max_size=500 + ) +) +@example([1034.375, 10352.055, 0.005, 999_999.875, 140_000.015625]) +def test_sale_expenses_never_round_the_wrong_way(values): + stored = np.asarray(values, dtype=np.float32) + exact = exact_amount(stored) + assert np.array_equal(exact, exact_amount(stored.astype(np.float64))) + # From 131,072 float32 cannot hold every penny, so no penny is identified + # and the value is kept as stored. The result is then within a penny of + # the stored value's figure, not of every penny it could have been stored + # from: 140,000.01 and 140,000.02 are both stored as 140,000.015625, and + # 90% of that rounds down to 126,000.01, a penny above 140,000.01's. + large = stored >= 131_072 + assert np.array_equal(exact[large], stored[large].astype(np.float64)) + # Storage error is removed only within half a unit of the stored value's + # own precision, so a value is never moved to a penny it was not stored + # from. + assert np.all( + np.abs(exact - stored.astype(np.float64)) + <= np.spacing(np.abs(stored)).astype(np.float64) / 2 + ) + down = less_sale_expenses(stored, LEGAL_RATE, True) + nearest = less_sale_expenses(stored, LEGAL_RATE, False) + for value, low, near in zip(exact.tolist(), down.tolist(), nearest.tolist()): + # Exact rational arithmetic on the value the helper works from, with + # the helper's millionth-of-a-penny allowance for binary noise. + target = Fraction(value) * Fraction(9, 10) + noise = Fraction(1, 100_000_000) + assert target - Fraction(1, 100) - noise < Fraction(low) <= target + noise + assert abs(Fraction(near) - target) <= Fraction(1, 200) + noise + + +ROUNDS_DOWN = { + "uc_assessable_capital": False, + "housing_benefit_assessable_capital": False, + "income_support_assessable_capital": True, + "jsa_income_assessable_capital": True, + "esa_income_assessable_capital": True, + "pension_credit_assessable_capital": True, +} + + +def whole_penny_situation(rows): + people, benunits, homes = {}, {}, {} + for i, (savings, shares, lisa, age) in enumerate(rows): + people[f"p{i}"] = { + "age": {YEAR: age}, + "lifetime_isa_balance": {YEAR: lisa / 100}, + } + benunits[f"b{i}"] = {"members": [f"p{i}"]} + homes[f"h{i}"] = { + "members": [f"p{i}"], + "savings": {YEAR: savings / 100}, + "corporate_wealth": {YEAR: shares / 100}, + } + return {"people": people, "benunits": benunits, "households": homes} + + +@PROPERTY_SETTINGS +@given( + st.lists( + st.tuples( + st.integers(0, 6_000_000), + st.integers(0, 6_000_000), + st.integers(0, 1_500_000), + st.sampled_from([30, 70]), + ), + min_size=20, + max_size=100, + ) +) +# 16,000.01 of savings is over every 16,000 limit; 1,000.10 and 12,345.67 +# lose a penny if float32 storage error reaches a floor; a Lifetime ISA of +# 0.24 or 1,000.04 counts 0.18 or 750.03 under 60. 75% of a 0.26 balance is +# 0.195 and of 1,000.30 is 750.225, half pennies, which float32 stores just +# below: with 15,999.81 or 15,249.77 of savings the capital is 16,000.01 or +# 16,000.00 to the nearest penny, and 16,000.00 or 15,999.99 rounded down. +@example( + [ + (1_600_001, 0, 0, 30), + (100_010, 0, 0, 30), + (1_234_567, 0, 0, 70), + (159_992, 1_600_010, 0, 30), + (0, 0, 24, 30), + (0, 0, 100_004, 30), + (1_599_981, 0, 26, 30), + (1_524_977, 0, 100_030, 30), + ] + * 3 +) +def test_whole_pennies_are_counted_to_the_penny(rows): + sim = Simulation(situation=whole_penny_situation(rows)) + values = {v: np.asarray(sim.calculate(v, YEAR), dtype=float) for v in PROGRAMMES} + gc = np.asarray(sim.calculate("guarantee_credit", YEAR)) + passported = np.asarray( + sim.calculate("housing_benefit_on_passporting_benefit", YEAR) + ) + savings_credit_only = np.asarray( + sim.calculate("in_receipt_of_savings_credit_only", YEAR) + ) + for variable, round_down in ROUNDS_DOWN.items(): + for i, (savings, shares, lisa, age) in enumerate(rows): + if not applies(variable, {"age": age}, gc[i], passported[i]): + continue + # Housing Benefit takes Pension Credit's capital for a savings + # credit only award (HB (SPC) Regs 2006 reg. 27(6)). + down = round_down or ( + variable == "housing_benefit_assessable_capital" + and savings_credit_only[i] + ) + share_pence = (9 * shares + (0 if down else 5)) // 10 + lisa_pence = lisa if age >= 60 else (3 * lisa + (0 if down else 2)) // 4 + expected = (savings + share_pence + lisa_pence) / 100 + # The model stores float32, which holds every penny below 131,072. + assert abs(values[variable][i] - expected) < 0.004, ( + variable, + rows[i], + values[variable][i], + expected, + ) + + +def test_lifetime_isa_reformed_value_is_kept_as_stored(): + # This reform's value and the baseline's 0.195 are the same float32, + # but the reform deliberately values the ISA below the half penny. + class lifetime_isa_countable_capital(Variable): + value_type = float + entity = Person + definition_period = YEAR_PERIOD + + def formula(person, period, parameters): + return person.filled_array(0.19499999) + + class ReplaceLifetimeISA(Reform): + def apply(self): + self.update_variable(lifetime_isa_countable_capital) + + sim = Simulation( + situation=whole_penny_situation([(0, 0, 26, 30)]), + reform=ReplaceLifetimeISA, + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + + +def test_lifetime_isa_baseline_formula_uses_reformed_parameters(): + sim = Simulation( + situation=whole_penny_situation([(0, 0, 26, 30)]), + reform={"gov.hmrc.isa.lifetime.withdrawal_charge": {str(YEAR): 0.5}}, + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.13) < 0.004 + + +@pytest.mark.parametrize("via_holder", [False, True]) +def test_lifetime_isa_input_set_after_construction_is_preserved(via_holder): + sim = Simulation(situation=whole_penny_situation([(0, 0, 26, 30)])) + # Cache the formula first: a later equal-valued input still has input + # provenance, including when the caller uses Holder.set_input directly. + sim.calculate("lifetime_isa_countable_capital", YEAR) + if via_holder: + from policyengine_core import periods + + sim.get_holder("lifetime_isa_countable_capital").set_input( + periods.period(YEAR), np.array([0.195]) + ) + else: + sim.set_input("lifetime_isa_countable_capital", YEAR, np.array([0.195])) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + + +def test_lifetime_isa_input_for_another_period_does_not_replace_the_formula(): + situation = whole_penny_situation([(0, 0, 26, 30)]) + situation["people"]["p0"]["lifetime_isa_countable_capital"] = {YEAR - 1: 0.195} + sim = Simulation(situation=situation) + # The previous year's supplied value cannot suppress this year's exact + # baseline valuation of 75% of 0.26, rounded nearest to 0.20. + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 0.20) < 0.004 + + +@pytest.mark.parametrize("inherited", [False, True]) +def test_lifetime_isa_input_is_preserved_on_its_visible_branch(inherited): + situation = whole_penny_situation([(0, 0, 26, 30)]) + if inherited: + situation["people"]["p0"]["lifetime_isa_countable_capital"] = {YEAR: 0.195} + sim = Simulation(situation=situation) + branch = sim.get_branch("entered_lisa") + if not inherited: + branch.set_input("lifetime_isa_countable_capital", YEAR, np.array([0.195])) + assert abs(branch.calculate("uc_assessable_capital", YEAR)[0] - 0.19) < 0.004 + expected_main = 0.19 if inherited else 0.20 + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - expected_main) < 0.004 + + +def lisa_mask_reform(): + """A reform that keeps the baseline LISA formula and only adds a + ``defined_for`` mask, so the formula identity check still passes.""" + + class lisa_is_counted(Variable): + value_type = bool + entity = Person + definition_period = YEAR_PERIOD + default_value = False + label = "Lifetime ISA counted as capital" + + class lifetime_isa_countable_capital(Variable): + defined_for = "lisa_is_counted" + + class MaskLifetimeISA(Reform): + def apply(self): + self.add_variable(lisa_is_counted) + self.update_variable(lifetime_isa_countable_capital) + + return MaskLifetimeISA + + +def test_lifetime_isa_exact_valuation_respects_a_defined_for_mask(): + # Two units hold 0.26 each. Only p1's ISA is counted: Core stores 0 for + # p0, so p0 must have no capital, and p1 must be valued as without the + # mask (0.195, to the penny under each programme's rule). + situation = whole_penny_situation([(0, 0, 26, 30), (0, 0, 26, 30)]) + unmasked = Simulation(situation=situation) + masked = Simulation(situation=situation, reform=lisa_mask_reform()) + # The reform adds the mask, so the shared situation can't supply it. + masked.set_input("lisa_is_counted", YEAR, np.array([False, True])) + assert list(masked.calculate("lifetime_isa_countable_capital", YEAR)) == [ + 0, + pytest.approx(0.195), + ] + assert abs(unmasked.calculate("uc_assessable_capital", YEAR)[0] - 0.20) < 0.004 + for variable in PROGRAMMES: + before = np.asarray(unmasked.calculate(variable, YEAR), dtype=float) + after = np.asarray(masked.calculate(variable, YEAR), dtype=float) + assert abs(after[0]) < 0.004, (variable, after) + assert abs(after[1] - before[1]) < 0.004, (variable, before, after) + + +def test_a_lifetime_isa_masked_for_everyone_runs_no_formula(): + # Core runs no formula when the mask applies to nobody, so a dependency + # only applicable people need is never calculated. A balance formula + # that raises shows whether the exact valuation calculates it anyway. + class lifetime_isa_balance(Variable): + def formula(person, period, parameters): + raise AssertionError("A masked Lifetime ISA balance was calculated") + + mask = lisa_mask_reform() + + class MaskWithUnavailableBalance(Reform): + def apply(self): + mask.apply(self) + self.update_variable(lifetime_isa_balance) + + without_lisa = Simulation(situation=whole_penny_situation([(1_599_981, 0, 0, 30)])) + situation = whole_penny_situation([(1_599_981, 0, 0, 30)]) + del situation["people"]["p0"]["lifetime_isa_balance"] + sim = Simulation(situation=situation, reform=MaskWithUnavailableBalance) + assert list(sim.calculate("lifetime_isa_countable_capital", YEAR)) == [0] + for variable in PROGRAMMES: + before = without_lisa.calculate(variable, YEAR)[0] + after = sim.calculate(variable, YEAR)[0] + assert abs(after - before) < 0.004, (variable, before, after) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 15_999.81) < 0.004 + + +def test_a_masked_lifetime_isa_leaves_universal_credit_eligibility(): + # 15,999.81 of savings is within the 16,000 limit. A counted 0.26 ISA + # (0.195, rounded to 0.20) would take it to 16,000.01; masked, it adds + # nothing. + sim = Simulation( + situation=whole_penny_situation([(1_599_981, 0, 26, 30)]), + reform=lisa_mask_reform(), + ) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 15_999.81) < 0.004 + assert sim.calculate("is_uc_eligible", YEAR)[0] + sim = Simulation( + situation=whole_penny_situation([(1_599_981, 0, 26, 30)]), + reform=lisa_mask_reform(), + ) + sim.set_input("lisa_is_counted", YEAR, np.array([True])) + assert abs(sim.calculate("uc_assessable_capital", YEAR)[0] - 16_000.01) < 0.004 + assert not sim.calculate("is_uc_eligible", YEAR)[0] 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 146a82091e..8adc07dc19 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_limit_properties.py @@ -8,34 +8,160 @@ (Computation of Earnings Regulations 1996 regs. 10(4), 13 and 14; reg. 14(2)(a), which deducted Class 2, was omitted from 6 April 2024). +A fraction of a penny counts in the claimant's favour (reg. 5): it is +disregarded in pay, profit and net earnings, and counts as a penny in each +deduction. So the half penny of half an odd-pence pension contribution (reg. +10(4)(b)) counts as a penny off earnings, as does the fraction of a penny in +a notional tax or Class 4 deduction. (PAYE and Class 1 are in law the amounts +actually deducted; the model's figures for them are rounded up the same way.) +carers_allowance_earnings applies that to each step, in float64 pence from the +whole pence each amount was entered as (stored_pence), so net earnings are +whole pence despite float32 storage. The test then reads back the whole +pence, disregarding any fraction in an amount entered directly, and compares +the whole pence a week, after dividing by 52 (regs. 8(1)(b)(iii) and +11(1)(a)) with the fraction disregarded, with the limit. _over_limit restates +the test in exact rational arithmetic. + Invariants, for a carer in England in 2026 caring 35 hours or more, with pay, trading profit, employee pension contributions and unearned income drawn freely: 1. Bounds: 0 <= carers_allowance_earnings <= pay + max(0, profit). -2. Differential: the test is passed exactly when - 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. +2. Differential: the test is passed exactly when _over_limit is false for the + stored carers_allowance_earnings, and the allowance is the full rate then + and nil otherwise. +3. Monotonic in steps of 2p: raising pay or profit by 2p or more never lowers + net earnings, so a person over the limit stays over it. The deductions + that rise with pay (PAYE and Class 1) or with profit (notional tax and + Class 4) have marginal rates adding to at most a half, and each is rounded + up, so a rise of R pence adds less than R / 2 + 2 pence to them, which is + at most R when R >= 2. A rise of 1p can lower net earnings by 1p, when it + takes both deductions to their next penny at once. That is reg. 5 working + as intended, not an error (YAML Cases 30 and 31 pin one such pair). 4. Unearned income is not earnings: savings interest and pension income leave carers_allowance_earnings unchanged. + +Invariants of the test itself, for every limit in every year from 2015 to +the parameter horizon, with carers_allowance_earnings entered directly: + +5. Whole pence are judged exactly: annual earnings of A pence are over the + limit L exactly when A // 52 > 100 x L, that is A >= 52 x (100 x L + 1), + whatever float32 does to A. So 52 x L + £0.51 a year is within the limit + and 52 x L + £0.52 (a penny a year more, and a penny a week over) is over + it. +6. Exact arithmetic: for any stored amount, whole pence or not, the formula + agrees with _over_limit. +7. Monotonic in earnings: more earnings never take a person from over the + limit to within it. + +And through the model, with pay or profit and a pension contribution entered +in whole pence: + +8. Half of a pension contribution comes off pay, its half penny counting as a + penny (regs. 10(4)(b) and 5): in each year up to the latest limit's first, + pay of P pence below the personal allowance and the primary threshold, + with a contribution of C pence, is over the limit L exactly when + (P - ceil(C / 2)) // 52 > 100 x L. +9. Each deduction counts a fraction of a penny as a penny (reg. 5): with tax + and Class 1 or Class 4 to pay, from 2024-25 (Class 2 nil, Class 1 at 8%), + net earnings are P less each of the tax, the contributions and half the + pension contribution, rounded up to a whole penny, exactly. Class 1 is 8% + of P less 52 x £241.73, worked exactly here, not read from the model's + float32 ni_class_1_employee, which can lie a hundredth of a penny from it. +10. A pension total entered directly as pension_contributions comes off + earnings as the same amount entered as a programme does. + +And for the amounts the deductions start from: + +11. Class 1 is the amount the model calculates: where it calculated + ni_class_1_employee with its own formula, as it does in every year from + 2015 to the horizon, including under a parameter reform or with a + variable a reform puts in place of a part but calculates the same way, + the deduction is that formula worked exactly. Where Class 1 (for the + year or month by month) or its primary part was supplied as an input, + or a reform neutralized, abolished, replaced, ended or re-dated the + formula, or changed how it reads liability or NI income, the deduction + is the amount calculated or entered, as the amount deducted (reg. + 10(4)(a)), even within a fraction of a penny of what the formula would + give. Class 1 entered in whole pence, for the year or by month, is + deducted to the penny, though float32 sums the months to more or less. +12. Sums the model forms are read in whole pence: pay, NI income, and + employee and all pension contributions, which the model adds from other + amounts in float32, are the exact sums of their whole-penny parts, and + net earnings follow exact arithmetic on them. Only sums the simulation + formed are summed again: a total entered directly is read as entered, + even when it is the very float32 its parts would sum to. Whether the + simulation's cache policy keeps a sum, or Class 1, changes nothing. +13. An amount calculated by a formula in place of a sum is read as stored + (policyengine-core allows no formula beside a sum). """ +import datetime +import math +import types +from fractions import Fraction +from pathlib import Path + import numpy as np import pytest +import yaml from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st +import policyengine_uk from policyengine_uk import Simulation +from policyengine_uk.model_api import GBP, MONTH, YEAR, Person, Reform, Variable, add +from policyengine_core.parameters import ParameterNode +from policyengine_uk.system import system from policyengine_uk.utils.class_2 import class_2_contribution_weeks +from policyengine_uk.variables.gov.hmrc.national_insurance.class_1.ni_class_1_employee_primary import ( + ni_class_1_employee_primary, +) LIMIT = 204 RATE = 86.45 WEEKS = 52 +LIMIT_FILE = ( + Path(policyengine_uk.__file__).parent + / "parameters/gov/dwp/carers_allowance/earnings_limit.yaml" +) + + +LIMIT_VALUES = sorted( + (date, entry["value"]) + for date, entry in yaml.safe_load(LIMIT_FILE.read_text())["values"].items() +) +# The last year the parameter tree lists a value for. +HORIZON = max( + int(value.instant_str[:4]) + for value in system.parameters.gov.dwp.carers_allowance.earnings_limit.values_list +) + + +def _limits_by_year(last_year): + """Each year from the first limit in earnings_limit.yaml to last_year, + with the limit for it: up to the year of the file's last value, the value + in force on 30 April, after that year's April up-rating; after that, the + model's, which may project the limit beyond the last legislated one.""" + limits = [] + for year in range(LIMIT_VALUES[0][0].year, last_year + 1): + if year <= LIMIT_VALUES[-1][0].year: + in_force = [ + v for date, v in LIMIT_VALUES if date <= datetime.date(year, 4, 30) + ] + limit = in_force[-1] + else: + limit = system.parameters( + f"{year}-01-01" + ).gov.dwp.carers_allowance.earnings_limit + limits.append((year, limit)) + return limits + + +# Every year from the first limit to the horizon. +LIMITS_BY_YEAR = _limits_by_year(HORIZON) +# The same, up to the year the last legislated limit took effect. +SET_YEARS = _limits_by_year(LIMIT_VALUES[-1][0].year) def _person(pay, profit, pension, savings=0.0, private_pension=0.0): @@ -57,8 +183,8 @@ 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(). + # Keep each stored value as the formula reads it (float32 for amounts). + # Tolerance checks convert with float(), which is exact. return { name: np.asarray(sim.calculate(name, 2026))[0] for name in ( @@ -69,13 +195,27 @@ 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 +def _stored_pence(value): + """A stored float32 amount in exact pence: the whole pence it lies within + half a float32 step of, or else its exact value.""" + pence = Fraction(float(value)) * 100 + whole = round(pence) + if abs(pence - whole) <= Fraction(float(np.spacing(np.float32(value)))) * 50: + return Fraction(whole) + return pence + + +def _over_limit(earnings, limit=LIMIT): + # The formula's rule in exact rational arithmetic, on the stored value: + # the whole pence a year it lies within half a float32 step of, or else + # its pence with the fraction disregarded, then whole pence a week, the + # fraction disregarded (reg. 5), compared with the limit. £10,608.52 a + # year, stored as 10,608.51953125, is 1,060,852 pence a year and 20,401 + # pence (£204.01) a week, a penny over £204. 10,608.517578125 (the + # float32 just below it) is more than half a step from any whole penny, + # so 1,060,851 pence and £204.00 a week, within £204. + annual = math.floor(_stored_pence(earnings)) + return annual // WEEKS > Fraction(str(limit)) * 100 money = st.floats(min_value=0, max_value=60_000, allow_nan=False) @@ -91,8 +231,8 @@ def _over_limit(earnings): @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. +# One penny a week over it (10,608.52 / 52 = 204.01), stored just below +# £10,608.52 as 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. @@ -113,30 +253,538 @@ 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.""" + (£204.00 a week in exact arithmetic), and the formula recovers the + £10,608.52, the only whole pence within half a float32 step; 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 +def _carers(variable, amounts_by_year, reform=None): + """One carer in England per amount, aged 45 and caring 40 hours a week, + with ``variable`` set to that amount in each year.""" + years = list(amounts_by_year) + count = len(amounts_by_year[years[0]]) + people = { + f"carer_{i}": { + "age": {year: 45 for year in years}, + "care_hours": {year: 40 for year in years}, + variable: {year: amounts_by_year[year][i] for year in years}, + } + for i in range(count) + } + return Simulation( + situation={ + "people": people, + "benunits": {f"b{i}": {"members": [f"carer_{i}"]} for i in range(count)}, + "households": { + f"h{i}": { + "members": [f"carer_{i}"], + "country": {year: "ENGLAND" for year in years}, + } + for i in range(count) + }, + }, + reform=reform, + ) + + +# A year's earnings at each limit, a penny a year short of £0.01 a week over +# it, and £0.01 a week over it: 52 x L + £0.51 is L + £0.0098 a week, which +# reg. 5 takes down to L. +PENNY_STEPS = ((0.0, False), (0.51, False), (0.52, True)) + + +def _penny_steps_misjudged(variable, limits_by_year): + """The penny steps the model misjudges, with the Budget 2026 earnings + taper switched off, so that earnings over the limit end entitlement in + every year. test_carers_allowance_weekly_earnings_above_limit_properties + tests the same steps under the taper.""" + amounts = { + year: [WEEKS * limit + step for step, _ in PENNY_STEPS] + for year, limit in limits_by_year + } + without_taper = { + "gov.dwp.carers_allowance.earnings_taper.applies": { + str(year): 0 for year in amounts + } + } + simulation = _carers(variable, amounts, without_taper) + misjudged = [] + for year, limit in limits_by_year: + assert ( + system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit + == limit + ) + earnings = simulation.calculate("carers_allowance_earnings", year) + gainful = simulation.calculate( + "is_gainfully_employed_for_carers_allowance", year + ) + allowance = simulation.calculate("carers_allowance", year) + for (step, over), amount, stored, judged, paid in zip( + PENNY_STEPS, amounts[year], earnings, gainful, allowance + ): + # Earnings are the amount entered, as float32 holds it. + assert stored == np.float32(amount), (year, amount, stored) + if bool(judged) != over or (paid == 0) != over: + misjudged.append((year, limit, amount, bool(judged), float(paid))) + return misjudged + + +def test_a_penny_a_week_over_each_limit_is_over_it_in_every_year(): + """Reg. 8(1), after reg. 5, for every limit in earnings_limit.yaml in every + year it applies, and the model's limit in each later year to the horizon, + with carers_allowance_earnings entered directly. Before #2541 the formula + counted pence in float32 and treated 52 x L + £0.52 as within the 2020-21 + to 2024-25 limits (£128, £132, £139 and £151).""" + assert {limit for _, limit in SET_YEARS} == {v for _, v in LIMIT_VALUES} + assert _penny_steps_misjudged("carers_allowance_earnings", LIMITS_BY_YEAR) == [] + + +def test_pay_a_penny_a_week_over_each_limit_ends_entitlement(): + """The same steps entered as pay, in each year up to the latest limit's + first. 52 x L + £0.52 is below the personal allowance and the primary + threshold in each of these years, so earnings are the pay itself, as the + test checks. This is #2541's reproducer.""" + assert _penny_steps_misjudged("employment_income", SET_YEARS) == [] + + +def _pay_less_pension(year, pairs): + """Pay and an employee pension contribution for one carer each.""" + people = { + f"carer_{i}": { + "age": {year: 45}, + "care_hours": {year: 40}, + "employment_income": {year: pay}, + "employee_pension_contributions": {year: contribution}, + } + for i, (pay, contribution) in enumerate(pairs) + } + simulation = Simulation( + situation={ + "people": people, + "benunits": { + f"b{i}": {"members": [f"carer_{i}"]} for i in range(len(pairs)) + }, + "households": { + f"h{i}": {"members": [f"carer_{i}"], "country": {year: "ENGLAND"}} + for i in range(len(pairs)) + }, + } + ) + earnings = simulation.calculate("carers_allowance_earnings", year) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + return earnings, gainful + + +# Pay and an odd-pence pension contribution: (extra pay over 52 x L, the +# contribution, over the limit). Half of the contribution comes off pay +# (reg. 10(4)(b)), and its half penny counts as a penny (reg. 5), so the +# first three leave 52 x L + £0.51 and the last two 52 x L + £0.52. +ODD_PENCE_CONTRIBUTIONS = ( + (0.52, 0.01, False), + (0.53, 0.03, False), + (48.00, 94.97, False), + (0.53, 0.01, True), + (0.54, 0.03, True), +) + + +def test_half_an_odd_pence_pension_contribution_counts_as_a_penny_off(): + """Regs. 10(4)(b) and 5, for every limit in earnings_limit.yaml in each + year up to the latest limit's first. Pay of 52 x L + £0.52 less half of a + 1p contribution is 52 x L + £0.515, and reg. 5 counts the half penny + deducted as a penny, leaving 52 x L + £0.51, within the limit. Float32 + would hold £7,852.515 as 7,852.51513671875, just above it, so earnings + taken to the nearest penny after the deduction would be over.""" + misjudged = [] + for year, limit in SET_YEARS: + pairs = [(WEEKS * limit + extra, c) for extra, c, _ in ODD_PENCE_CONTRIBUTIONS] + earnings, gainful = _pay_less_pension(year, pairs) + for (pay, contribution), (_, _, over), stored, judged in zip( + pairs, ODD_PENCE_CONTRIBUTIONS, earnings, gainful + ): + # Earnings are pay less half of the contribution rounded up to a + # whole penny, as float32 holds them. + net = round(pay * 100) - math.ceil(round(contribution * 100) / 2) + assert stored == np.float32(net / 100), ( + year, + pay, + contribution, + float(stored), + ) + if bool(judged) != over: + misjudged.append((year, limit, pay, contribution, bool(judged))) + assert misjudged == [] + + +@st.composite +def pay_and_contributions_around_a_limit(draw): + year, limit = draw(st.sampled_from(SET_YEARS)) + # The least pence of earnings a year that is over the limit. + over = WEEKS * (limit * 100 + 1) + pairs = [] + for _ in range(draw(st.integers(1, 30))): + contribution = draw(st.integers(0, 20_000)) + # Pay within a few pence of the boundary after the deduction, or + # anywhere within £500 a year of the limit. Either way pay is below + # the personal allowance and the primary threshold, so no tax or NI. + boundary = over + math.ceil(contribution / 2) + near = st.integers(boundary - 3, boundary + 3) + anywhere = st.integers( + WEEKS * limit * 100 - 50_000, WEEKS * limit * 100 + 50_000 + ) + pairs.append((draw(st.one_of(near, anywhere)), contribution)) + return year, limit, pairs + + @settings( - max_examples=8, + max_examples=10, deadline=None, derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) -@given(pay=money, rise=st.floats(min_value=1, max_value=20_000)) -def test_net_earnings_never_fall_as_pay_rises(pay, rise): - lower = _person(pay, 0, 0) - higher = _person(pay + rise, 0, 0) - assert ( - float(higher["carers_allowance_earnings"]) - >= float(lower["carers_allowance_earnings"]) - 0.01 +@given(case=pay_and_contributions_around_a_limit()) +# Odd-pence contributions on either side of the boundary in 2024-25. +@example(case=(2024, 151, [(785_252, 1), (785_253, 1), (785_253, 3), (785_254, 3)])) +def test_pay_less_half_a_pension_contribution_is_judged_as_the_law_says(case): + """Invariant 8.""" + year, limit, pairs = case + _, gainful = _pay_less_pension(year, [(p / 100, c / 100) for p, c in pairs]) + for (pay, contribution), judged in zip(pairs, gainful): + net = pay - math.ceil(contribution / 2) + assert bool(judged) == (net // WEEKS > limit * 100), (year, pay, contribution) + + +def _with_fractions(route, year): + """Whole-pence pay or profit P and pension contributions C (odd and even + pence) whose net earnings, deductions unrounded, lie from 4p below to 2p + above the threshold B = 52 x (100 L + 1) pence a year. Returns B and + (P, C) pairs in pence. Trading: profit above the personal allowance and + the lower profits limit, so notional basic-rate tax and Class 4 at the + main rate. Employee: pay above the primary threshold, less a contribution + that leaves it below the personal allowance, so Class 1 and no PAYE (an + employee paying PAYE is over every limit, each below the personal + allowance).""" + hmrc = system.parameters(f"{year}-01-01").gov.hmrc + limit = system.parameters(f"{year}-01-01").gov.dwp.carers_allowance.earnings_limit + threshold = WEEKS * (round(limit * 100) + 1) + pa = Fraction(str(hmrc.income_tax.allowances.personal_allowance.amount)) * 100 + class_4 = hmrc.national_insurance.class_4 + lpl = Fraction(str(class_4.thresholds.lower_profits_limit)) * 100 + basic = Fraction(str(hmrc.income_tax.rates.uk.rates[0])) + main = Fraction(str(class_4.rates.main)) + # Class 1 at 8% of pay above £12,570, to choose pay near the threshold. + pt, class_1 = 1_257_000, Fraction(8, 100) + if route == "trading": + keep = 1 - basic - main + first = 2 * (keep * max(pa, lpl) + basic * pa + main * lpl - threshold) + offset = basic * pa + main * lpl + else: + keep = 1 - class_1 + first = 2 * (pt - threshold) + offset = class_1 * pt + pairs = [] + for contribution in range( + math.ceil(first) + 1_001, math.ceil(first) + 401_000, 9_973 + ): + const = offset - Fraction(contribution, 2) + low = math.ceil((threshold - 4 - const) / keep) + high = math.floor((threshold + 2 - const) / keep) + pairs += [(p, contribution) for p in range(low, high + 1)] + return threshold, pairs + + +# Known boundary pairs, in pence. A trader within the limit whom a +# nearest-half-penny rule on the total would put over it (YAML Case 25). The +# employees' Class 1 is a whole number of pence (£175.56, £235.38, £1.14, +# £1.26, £2.28) that the model's float32 sum of twelve months holds more than +# half a step out, so reading it with stored_pence and rounding up would +# deduct a penny too much and put each of them, exactly a penny a week over, +# within the limit. +KNOWN_PAIRS = { + ("trading", 2026): [(1_400_001, 603_938)], + ("employee", 2024): [(1_476_446, 1_347_276), (1_551_221, 1_484_862)], + ("employee", 2026): [ + (1_476_446, 796_076), + (1_551_221, 933_662), + (1_258_421, 394_910), + (1_258_571, 395_186), + (1_259_846, 397_532), + ], +} + + +@pytest.mark.parametrize("route", ["trading", "employee"]) +@pytest.mark.parametrize("year", [2024, 2026]) +def test_each_deduction_counts_a_fraction_of_a_penny_as_a_penny(route, year): + """Invariant 9, through the model: net earnings are P less each + deduction rounded up to a whole penny (reg. 5), exactly, and the person + is over the limit exactly when that reaches the threshold. Class 1 is + worked exactly from its rate and threshold. Rounding only the total, or + not at all, misjudges some of these pairs, and so does rounding up the + model's float32 Class 1 (KNOWN_PAIRS).""" + threshold, pairs = _with_fractions(route, year) + known_pairs = KNOWN_PAIRS.get((route, year), []) + pairs += known_pairs + gross, contribution = ( + ("self_employment_income", "personal_pension_contributions") + if route == "trading" + else ("employment_income", "employee_pension_contributions") + ) + people = { + f"carer_{i}": { + "age": {year: 45}, + "care_hours": {year: 40}, + gross: {year: p / 100}, + contribution: {year: c / 100}, + } + for i, (p, c) in enumerate(pairs) + } + simulation = Simulation( + situation={ + "people": people, + "benunits": { + f"b{i}": {"members": [f"carer_{i}"]} for i in range(len(pairs)) + }, + "households": { + f"h{i}": {"members": [f"carer_{i}"], "country": {year: "ENGLAND"}} + for i in range(len(pairs)) + }, + } + ) + earnings = simulation.calculate("carers_allowance_earnings", year) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + class_1 = simulation.calculate("ni_class_1_employee", year) + hmrc = system.parameters(f"{year}-01-01").gov.hmrc + pa = Fraction(str(hmrc.income_tax.allowances.personal_allowance.amount)) * 100 + lpl = ( + Fraction(str(hmrc.national_insurance.class_4.thresholds.lower_profits_limit)) + * 100 + ) + basic = Fraction(str(hmrc.income_tax.rates.uk.rates[0])) + main = Fraction(str(hmrc.national_insurance.class_4.rates.main)) + # Class 1 at 8% above 52 weeks of £241.73 (the model's £12,569.96), as + # employee_scale.yaml sets for both years. + employee_scale = hmrc.national_insurance.class_1.employee_scale + assert employee_scale.components[1].rate == 0.08 + assert employee_scale.components[1].threshold == 241.73 + class_1_rate, primary_threshold = Fraction(8, 100), WEEKS * 24_173 + fractions = 0 + for (p, c), stored, judged, ni in zip(pairs, earnings, gainful, class_1): + if route == "trading": + assert max(pa, lpl) <= p and ni == 0, (p, c) + deductions = [basic * (p - pa), main * (p - lpl)] + else: + assert p - c < pa and ni > 0, (p, c) + deductions = [class_1_rate * (p - primary_threshold)] + deductions.append(Fraction(c, 2)) + net = p - sum(math.ceil(d) for d in deductions) + fractions += any(d.denominator > 1 for d in deductions) + assert stored == np.float32(net / 100), (p, c, float(stored), net) + assert bool(judged) == (net >= threshold), (p, c, net) + # Most pairs carry a fraction of a penny in some deduction. + assert fractions > len(pairs) // 2 + if route == "employee": + # Each known pair is exactly the threshold: a penny a week over. + for (p, c), judged in zip( + known_pairs, gainful[len(pairs) - len(known_pairs) :] + ): + net = ( + p + - math.ceil(class_1_rate * (p - primary_threshold)) + - math.ceil(Fraction(c, 2)) + ) + assert net == threshold and judged, (p, c, net) + if route == "trading" and year == 2026: + # YAML Case 25: £14,000.01 of profit and a £6,039.38 premium. Tax £286.002 and + # Class 4 £85.8006 round up to £286.01 and £85.81, and half the + # premium is £3,019.69, leaving £10,608.50: £204.00 a week, within the + # limit, so the full £4,495.40 a year is paid. + assert float(earnings[-1]) == 10_608.5 and not gainful[-1] + allowance = simulation.calculate("carers_allowance", year)[-1] + assert abs(float(allowance) - RATE * WEEKS) < 0.01 + + +def test_a_limit_in_pounds_and_pence_is_read_exactly(): + """A reformed limit of £204.10 is 20,410 pence, not the 20,409.999... + that 204.1 x 100 gives in floating point: £204.10 a week is within it and + £204.11 is over it.""" + reform = { + "gov.dwp.carers_allowance.earnings_limit": {"2026-01-01.2026-12-31": 204.1} + } + amounts = [WEEKS * 204.1, WEEKS * 204.1 + 0.51, WEEKS * 204.1 + 0.52] + simulation = _carers("carers_allowance_earnings", {2026: amounts}, reform) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", 2026) + assert list(gainful) == [False, False, True] + + +@st.composite +def whole_pence_around_a_limit(draw): + year, limit = draw(st.sampled_from(LIMITS_BY_YEAR)) + # The least number of pence a year that is over the limit. + over = WEEKS * (limit * 100 + 1) + near = st.integers(over - 3 * WEEKS, over + 3 * WEEKS) + # Up to £131,071.99, below which float32 holds every whole-pence amount + # to within half a penny. + anywhere = st.integers(0, 2**17 * 100 - 1) + pence = draw(st.lists(st.one_of(near, anywhere), min_size=1, max_size=40)) + return year, limit, pence + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(case=whole_pence_around_a_limit()) +# Either side of the boundary in 2020-21 and 2024-25, where float32 pence +# misjudged the penny over (#2541). +@example(case=(2020, 128, [665_651, 665_652])) +@example(case=(2022, 132, [686_451, 686_452])) +@example(case=(2023, 139, [722_851, 722_852])) +@example(case=(2024, 151, [785_251, 785_252])) +def test_whole_pence_are_judged_exactly(case): + """Invariant 5.""" + year, limit, pence = case + simulation = _carers("carers_allowance_earnings", {year: [p / 100 for p in pence]}) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + for amount, judged in zip(pence, gainful): + assert bool(judged) == (amount // WEEKS > limit * 100), (year, amount) + + +def _float32_either_side(amount): + nearest = np.float32(amount) + below = np.nextafter(nearest, np.float32(-np.inf)) + above = np.nextafter(nearest, np.float32(np.inf)) + return [float(below), float(nearest), float(above)] + + +# The stored amounts either side of 52L + £0.52, the least whole pence a year +# over the limit: the float32 nearest it is those pence, and the one below it +# is more than half a step away, so a fraction of a penny short. +EDGES = {limit: _float32_either_side(WEEKS * limit + 0.52) for limit in (139, 204)} + + +@st.composite +def amounts_around_a_limit(draw): + year, limit = draw(st.sampled_from(LIMITS_BY_YEAR)) + over = WEEKS * limit + 0.52 + near = st.floats(over - 0.05, over + 0.05) + anywhere = st.floats(0, 100_000, allow_nan=False) + amounts = draw(st.lists(st.one_of(near, anywhere), min_size=2, max_size=40)) + return year, limit, amounts + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(case=amounts_around_a_limit()) +# 2022-23: £6,864.51 (within the £132 limit) and £6,864.52 (a penny a week +# over it), with amounts a tenth of a penny off them. +@example(case=(2022, 132, [6_864.509, 6_864.51, 6_864.511, 6_864.52, 6_864.521])) +# The stored amounts either side of 52L + £0.52, for a limit #2541 affected +# and one it did not. +@example(case=(2023, 139, EDGES[139])) +@example(case=(2026, 204, EDGES[204])) +# 2026-27: 10,608.515625 and 10,608.517578125, a fraction of a penny short of +# £10,608.52 (the second is the earnings of YAML Case 25's trader under a +# nearest-half-penny rule on the total), and 10,608.5205078125, just over it. +@example(case=(2026, 204, [10_608.515625, 10_608.517578125, 10_608.5205078125])) +def test_the_formula_matches_exact_arithmetic_and_rises_with_earnings(case): + """Invariants 6 and 7, for amounts with any fraction of a penny.""" + year, limit, amounts = case + simulation = _carers("carers_allowance_earnings", {year: amounts}) + stored = simulation.calculate("carers_allowance_earnings", year) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + for value, judged in zip(stored, gainful): + assert bool(judged) == _over_limit(value, limit), (year, float(value)) + in_order = gainful[np.argsort(stored, kind="stable")].astype(int) + assert np.all(np.diff(in_order) >= 0), (year, amounts) + + +def _net_pence(variable, pence, other=None): + """carers_allowance_earnings in whole pence in 2026, one carer in England + per amount of ``variable`` (in pence), with ``other`` amounts as well.""" + amounts = {2026: [p / 100 for p in pence]} + simulation = _carers(variable, amounts) + for name, value in (other or {}).items(): + simulation.set_input(name, 2026, np.full(len(pence), value)) + earnings = simulation.calculate("carers_allowance_earnings", 2026) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", 2026) + return np.round(earnings.astype(np.float64) * 100).astype(int), gainful + + +# Every penny across the points where a deduction starts or changes rate in +# 2026-27: the personal allowance and Class 1 primary threshold and lower +# profits limit (£12,570), the higher rate, upper earnings limit and upper +# profits limit (£50,270) and the additional rate (£125,140); and, for +# profit, the boundary pair of YAML Cases 30 and 31. +MONOTONIC_RANGES = { + "employment_income": [ + (1_255_000, 1_259_000), + (5_025_000, 5_029_000), + (12_512_000, 12_516_000), + ], + "self_employment_income": [(1_255_000, 1_259_000), (5_025_000, 5_029_000)], +} + + +@pytest.mark.parametrize("variable", list(MONOTONIC_RANGES)) +def test_net_earnings_never_fall_for_a_rise_of_two_pence_or_more(variable): + """Invariant 3, for every penny in each range: net earnings at P + R are + at least those at P for every R >= 2p, and a 1p rise lowers them by at + most 1p. Some 1p rises do, as reg. 5 intends.""" + one_penny_falls = 0 + for start, end in MONOTONIC_RANGES[variable]: + pence = list(range(start, end)) + net, _ = _net_pence(variable, pence) + # The least net earnings at any P + R, R >= 2, for each P. + later_minimum = np.minimum.accumulate(net[::-1])[::-1] + assert np.all(later_minimum[2:] >= net[:-2]), (variable, start) + steps = np.diff(net) + assert steps.min() >= -1, (variable, start) + one_penny_falls += int(np.sum(steps == -1)) + if variable == "self_employment_income": + assert one_penny_falls > 0 + + +def test_a_penny_more_profit_can_lower_net_earnings_by_a_penny(): + """The intended exception to invariant 3, with a premium of £3,923.70: + £12,570.50 of profit takes notional tax of 10p and Class 4 of 3p, leaving + £10,608.52, a penny a week over the limit; £12,570.51 takes 10.2p and + 3.06p, which count as 11p and 4p (reg. 5), leaving £10,608.51, within + it. Pinned also as YAML Cases 30 and 31.""" + net, gainful = _net_pence( + "self_employment_income", + [1_257_050, 1_257_051], + {"personal_pension_contributions": 3_923.70}, ) - if lower["is_gainfully_employed_for_carers_allowance"]: - assert higher["is_gainfully_employed_for_carers_allowance"] + assert list(net) == [1_060_852, 1_060_851] + assert list(gainful) == [True, False] + + +@pytest.mark.parametrize( + "pay, total", + [(1_100_000, 100_000), (1_100_103, 99_201), (785_253, 1), (1_060_853, 1)], +) +def test_a_pension_total_entered_directly_counts_like_a_programme(pay, total): + """Invariant 10: pension_contributions entered as a total comes off + earnings, half of it with the half penny counted as a penny, exactly as + the same amount entered as personal_pension_contributions (which, like the + total, leaves PAYE alone).""" + results = [] + for name in ("pension_contributions", "personal_pension_contributions"): + net, gainful = _net_pence("employment_income", [pay], {name: total / 100}) + results.append((int(net[0]), bool(gainful[0]))) + assert results[0] == results[1] + if pay < 1_257_000: + assert results[0][0] == pay - math.ceil(total / 2) @settings( @@ -206,3 +854,605 @@ def test_trading_profit_takes_the_basic_rate_in_every_year(year, country): assert simulation.calculate("carers_allowance_earnings", year)[0] == pytest.approx( expected, abs=0.01 ) + + +def _earnings_of(rows, reform=None, year=2026, uncached=()): + """Net earnings in whole pence, and whether each is over the limit, for + one carer in England per dict in ``rows``: inputs by variable, each an + amount for ``year`` or a dict of amounts by period. The simulation keeps + no value of the variables named in ``uncached``.""" + people = {} + for i, row in enumerate(rows): + person = {"age": {year: 45}, "care_hours": {year: 40}} + for name, value in row.items(): + person[name] = value if isinstance(value, dict) else {year: value} + people[f"carer_{i}"] = person + simulation = Simulation( + situation={ + "people": people, + "benunits": { + f"b{i}": {"members": [f"carer_{i}"]} for i in range(len(rows)) + }, + "households": { + f"h{i}": {"members": [f"carer_{i}"], "country": {year: "ENGLAND"}} + for i in range(len(rows)) + }, + }, + reform=reform, + ) + if uncached: + simulation.opt_out_cache = True + simulation.tax_benefit_system.cache_blacklist = list(uncached) + earnings = simulation.calculate("carers_allowance_earnings", year) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + return [int(round(float(e) * 100)) for e in earnings], [bool(g) for g in gainful] + + +# The 2026-27 threshold: 52 x (£204 + 1p) a year, a penny a week over. +THRESHOLD_2026 = WEEKS * (LIMIT * 100 + 1) +MONTHS_2026 = [f"2026-{month:02d}" for month in range(1, 13)] + + +def _class_1(pence): + """Class 1 at 8% of pay above the model's £12,569.96 in 2026, exactly.""" + return Fraction(8, 100) * max(0, pence - WEEKS * 24_173) + + +@pytest.mark.parametrize( + "class_1_input", + [ + {"ni_class_1_employee": 0}, + {"ni_class_1_employee": {month: 0 for month in MONTHS_2026}}, + {"ni_class_1_employee_primary": {month: 0 for month in MONTHS_2026}}, + ], + ids=["year", "months", "primary part"], +) +def test_class_1_supplied_as_an_input_is_deducted_as_supplied(class_1_input): + """Invariant 11, for an input. £12,569.97 of pay is a penny above the + model's primary threshold, so its formula gives Class 1 of 0.08p, which + counts as a penny. Entered as nil, nil is deducted (reg. 10(4)(a)): with + a £3,922.90 contribution, £12,569.97 less £1,961.45 leaves £10,608.52, a + penny a week over the limit.""" + pay, contribution = 1_256_997, 392_290 + row = { + "employment_income": pay / 100, + "employee_pension_contributions": contribution / 100, + } + # Separate simulations: an input set for one person is set for each. + calculated, calculated_over = _earnings_of([row]) + supplied, supplied_over = _earnings_of([{**row, **class_1_input}]) + assert calculated == [pay - math.ceil(_class_1(pay)) - contribution // 2] + assert supplied == [pay - contribution // 2] + assert calculated + supplied == [1_060_851, 1_060_852] + assert calculated_over + supplied_over == [False, True] + + +def test_class_1_supplied_near_its_formula_is_deducted_as_supplied(): + """Invariant 11: £594.40 of Class 1 entered with £20,000 of pay, within a + third of a penny of what the formula gives (£594.4032), is the amount + deducted, not the formula's rounded up.""" + pay = 2_000_000 + paye = math.ceil(Fraction(20, 100) * (pay - 1_257_000)) + calculated, _ = _earnings_of([{"employment_income": pay / 100}]) + supplied, _ = _earnings_of( + [{"employment_income": pay / 100, "ni_class_1_employee": 594.40}] + ) + assert calculated == [pay - paye - math.ceil(_class_1(pay))] + assert supplied == [pay - paye - 59_440] + + +@pytest.mark.parametrize( + "class_1_input", + [ + {"ni_class_1_employee": 1.20}, + {"ni_class_1_employee": {month: 0.10 for month in MONTHS_2026}}, + ], + ids=["year", "months"], +) +def test_class_1_entered_in_whole_pence_is_deducted_in_whole_pence(class_1_input): + """Invariant 11: £1.20 of Class 1, entered for the + year or as 10p a month, is £1.20 deducted. policyengine-core divides the + year's £1.20 among the months, and either way sums twelve float32 months + to 1.2000001669, which rounded up would deduct £1.21: £10,609.72 of pay + leaves £10,608.52, a penny a week over the limit, not £10,608.51.""" + net, over = _earnings_of([{"employment_income": 10_609.72, **class_1_input}]) + assert net == [1_060_852] and over == [True] + + +@pytest.mark.parametrize("entry", ["year", "months"]) +def test_whole_pence_of_class_1_entered_are_deducted_exactly(entry): + """Invariant 11: Class 1 entered in whole pence, for the year (which + policyengine-core divides among the months in float32) or month by + month, is deducted to the penny. With pay below the personal allowance, + so no PAYE, net earnings are the pay less the Class 1 entered, exactly, + for 400 carers with pay and Class 1 drawn at random.""" + rng = np.random.default_rng(2541) + pays = rng.integers(0, 1_257_000, 400) + pays[:4] = [1_060_972, 1_060_972, 1_119_992, 1_257_000 - 1] + if entry == "year": + class_1 = [rng.integers(0, pay + 1, 1) for pay in pays] + class_1[:4] = [[120], [121], [59_440], [1_257_000 - 1]] + else: + class_1 = [rng.integers(0, pay // 12 + 1, 12) for pay in pays] + class_1[:4] = [[10] * 12, [10] * 11 + [11], [4_953] * 11 + [4_957], [0] * 12] + rows = [ + { + "employment_income": pay / 100, + "ni_class_1_employee": ( + pence[0] / 100 + if entry == "year" + else {month: p / 100 for month, p in zip(MONTHS_2026, pence)} + ), + } + for pay, pence in zip(pays, class_1) + ] + net, _ = _earnings_of(rows) + expected = [int(pay) - int(sum(pence)) for pay, pence in zip(pays, class_1)] + wrong = [(row, n, e) for row, n, e in zip(rows, net, expected) if n != e] + assert not wrong, wrong[:5] + + +class _NoClass1(Reform): + def apply(self): + self.neutralize_variable("ni_class_1_employee") + + +class _NoPrimaryClass1(Reform): + """The primary part replaced by a formula giving nil.""" + + def apply(self): + class ni_class_1_employee_primary(Variable): + label = "NI Class 1 employee-side primary contributions" + entity = Person + definition_period = MONTH + value_type = float + unit = GBP + + def formula(person, period, parameters): + return person("ni_class_1_income", period) * 0 + + self.replace_variable(ni_class_1_employee_primary) + + +def _primary_class_1_reform(formula_name="formula", **attributes): + """A reform replacing the primary part with a variable calculated by the + model's own formula, named ``formula_name`` (giving the date it applies + from), with ``attributes`` (a date it ends, say).""" + variable = type( + "ni_class_1_employee_primary", + (Variable,), + { + "label": ni_class_1_employee_primary.label, + "entity": Person, + "definition_period": MONTH, + "value_type": float, + "unit": GBP, + formula_name: ni_class_1_employee_primary.formula, + **attributes, + }, + ) + + class reform(Reform): + def apply(self): + self.replace_variable(variable) + + return reform + + +# The model's primary formula, run with 53 weeks in a year: the same code +# reading another value of a global it names. +_PRIMARY_IN_53_WEEKS = types.FunctionType( + ni_class_1_employee_primary.formula.__code__, + {**ni_class_1_employee_primary.formula.__globals__, "WEEKS_IN_YEAR": 53}, +) + + +class _AbolishedPrimaryClass1(Reform): + """The primary part abolished by a gov.abolitions parameter, which + policyengine-core reads in place of calculating it.""" + + def apply(self): + def abolish(parameters): + parameters.gov.add_child( + "abolitions", + ParameterNode( + "gov.abolitions", + data={ + "ni_class_1_employee_primary": {"values": {"2015-01-01": True}} + }, + ), + ) + return parameters + + self.modify_parameters(abolish) + + +class _NoLiabilityInDecember(Reform): + """Liability month by month, ending in December.""" + + def apply(self): + class ni_liable(Variable): + label = "age-liable for primary Class 1 and Class 2 NI" + entity = Person + definition_period = MONTH + value_type = bool + + def formula(person, period, parameters): + return ( + person("over_16", period) + & ~person("is_SP_age", period) + & (period.start.month < 12) + ) + + self.replace_variable(ni_liable) + + +class _NoNIIncomeInDecember(Reform): + """NI income month by month, nil in December.""" + + def apply(self): + class ni_class_1_income(Variable): + label = "NI Class 1 income" + entity = Person + definition_period = MONTH + value_type = float + unit = GBP + + def formula(person, period, parameters): + return person("employment_income", period) * (period.start.month < 12) + + self.replace_variable(ni_class_1_income) + + +@pytest.mark.parametrize( + "reform, net", + [ + (_NoClass1, 1_060_852), + (_NoPrimaryClass1, 1_060_852), + (_primary_class_1_reform(end="2025-12-31"), 1_060_852), + (_primary_class_1_reform("formula_2027_01_01"), 1_060_852), + (_AbolishedPrimaryClass1, 1_060_852), + (_primary_class_1_reform(value_type=int), 1_060_852), + (_primary_class_1_reform(formula=_PRIMARY_IN_53_WEEKS), 1_060_852), + (_NoLiabilityInDecember, 1_060_851), + (_NoNIIncomeInDecember, 1_060_851), + (_primary_class_1_reform(), 1_060_851), + ], + ids=[ + "neutralized", + "nil formula", + "ended", + "from 2027", + "abolished", + "whole pounds", + "53 weeks", + "no liability in December", + "no NI income in December", + "the model's formula", + ], +) +def test_class_1_under_a_reform_of_its_formula_is_the_amount_calculated(reform, net): + """Invariant 11, for a reform (its dates and abolition). + At £12,569.97 of pay, a penny above the primary threshold, the model's + own formula gives Class 1 of 0.08p, which counts as a penny, so with a + £3,922.90 contribution £10,608.51 is left, within the limit. With Class + 1 neutralized, its primary part replaced by a nil formula, ended before + 2026, applying only from 2027 or abolished, there is none to deduct, and + £10,608.52 is a penny a week over: the formula is not worked in place of + the amount calculated. So it is with the primary part held in whole + pounds (0.08p is nil), or its formula's code run with 53 weeks in a + year (raising the threshold above this pay). With liability or NI income + month by month and ending in December, the model's Class 1 is eleven + months of it, still a penny, though the year's liability (December's) or + a twelfth of the year's NI income would give none. The model's own + formula, in a variable a reform puts in its place, is still worked + exactly.""" + row = {"employment_income": 12_569.97, "employee_pension_contributions": 3_922.90} + result, over = _earnings_of([row], reform=reform) + assert result == [net] and over == [net == THRESHOLD_2026] + + +@pytest.mark.parametrize( + "reform, rate", + [ + # The upper earnings limit moved far above this pay: Class 1 is still + # £175.56, which the model's float32 sum holds as 175.5600128. + ( + { + "gov.hmrc.national_insurance.class_1.employee_scale[2].threshold": { + "2026-01-01.2100-12-31": 1_000 + } + }, + Fraction(8, 100), + ), + # A main rate of 10%: £219.45 exactly. + ( + { + "gov.hmrc.national_insurance.class_1.employee_scale[1].rate": { + "2026-01-01.2100-12-31": 0.1 + } + }, + Fraction(10, 100), + ), + ], + ids=["upper earnings limit", "main rate"], +) +def test_class_1_under_a_parameter_reform_is_worked_exactly(reform, rate): + """Invariant 11: a parameter reform changes the formula's parameters, not + the formula, so Class 1 is still worked exactly, with them.""" + pay = 1_476_446 + paye = math.ceil(Fraction(20, 100) * (pay - 1_257_000)) + class_1 = math.ceil(rate * (pay - WEEKS * 24_173)) + net, _ = _earnings_of([{"employment_income": pay / 100}], reform=reform) + assert net == [pay - paye - class_1] + + +class _NIIncomeFormula(Reform): + """ni_class_1_income calculated by a formula rather than as a sum.""" + + def apply(self): + class ni_class_1_income(Variable): + label = "NI Class 1 income" + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + + def formula(person, period, parameters): + return person("employment_income", period) + + self.replace_variable(ni_class_1_income) + + +def test_a_formula_in_place_of_a_sum_is_read_as_stored(): + """Invariant 13: with ni_class_1_income calculated by a + formula giving the same amount, £14,764.46 of pay still leaves + £14,764.46 less PAYE of £438.90 and Class 1 of £175.56.""" + net, _ = _earnings_of([{"employment_income": 14_764.46}], reform=_NIIncomeFormula) + assert net == [1_415_000] + + +def test_a_formula_cannot_sit_beside_a_sum(): + """Invariant 13 relies on this: policyengine-core refuses a variable with + both a formula and adds, so a variable calculated by a formula lists no + amounts to add, and amount_pence reads it as stored.""" + + class ni_class_1_income(Variable): + label = "NI Class 1 income" + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + adds = list(system.variables["ni_class_1_income"].adds) + + def formula(person, period, parameters): + return add(person, period, ["employment_income"]) + + with pytest.raises(ValueError, match="mixes computation modes"): + ni_class_1_income() + + +def test_the_model_calculates_class_1_with_its_own_formula_in_every_year(): + """Invariant 11, for the baseline: in every year from 2015 to the + horizon, no reform the model applies by default changes how Class 1 is + calculated, so the deduction is its formula worked exactly. The formula + is restated here in exact arithmetic, month by month, from the same + parameters: the main rate between the primary threshold and the upper + earnings limit and the additional rate above it, weekly thresholds as + 52 / 12 of a week a month. NI income is the pay entered plus the + haircut the model calculates from 2029 (salary sacrifice cap costs + spread over all pay), read as stored.""" + from policyengine_core.periods import MONTH as month_unit + from policyengine_core.periods import period as make_period + + from policyengine_uk.variables.gov.dwp.carers_allowance_earnings import ( + _class_1_calculated, + _primary_class_1, + ) + + pays = [1_476_446, 6_000_000, 15_000_017] + for year in range(2015, HORIZON + 1): + simulation = _carers("employment_income", {year: [p / 100 for p in pays]}) + person = simulation.populations["person"] + period = make_period(year) + simulation.calculate("ni_class_1_employee", year) + assert _class_1_calculated(person, period), year + worked = _primary_class_1( + person, period, simulation.tax_benefit_system.parameters + ) + haircuts = simulation.calculate("salary_sacrifice_broad_base_haircut", year) + for pay, haircut, value in zip(pays, haircuts, worked): + income = pay + Fraction(float(haircut)) * 100 + exact = Fraction(0) + for month in period.get_subperiods(month_unit): + scale = simulation.tax_benefit_system.parameters( + month + ).gov.hmrc.national_insurance.class_1.employee_scale + monthly = income / 12 + pt, uel = ( + Fraction(str(scale.components[i].threshold)) * 100 * WEEKS / 12 + for i in (1, 2) + ) + main, additional = ( + Fraction(str(scale.components[i].rate)) for i in (1, 2) + ) + exact += main * (min(max(monthly, pt), uel) - pt) + additional * max( + monthly - uel, 0 + ) + # Within 10^-5p: a haircut within half a float32 step of a whole + # penny is read as that penny, which moves Class 1 by far less. + assert abs(Fraction(float(value)) - exact) < Fraction(1, 10**5), ( + year, + pay, + float(value), + float(exact), + ) + + +def test_an_entered_total_is_read_as_entered_even_as_its_parts_float32_sum(): + """Invariant 12: only sums the simulation formed are summed again. A + pension total entered as £3,242.6401, beside programmes of £1,807.49 and + £1,435.15, is stored as 3,242.64013671875, the very float32 the + programmes sum to (CA Case 32). Summed, half would be £1,621.32; entered, + it is read as stored, a fraction of a penny over £3,242.64, so half + counts as £1,621.33 and £12,229.84 of pay leaves £10,608.51, within.""" + row = { + "employment_income": 12_229.84, + "employee_pension_contributions": 1_807.49, + "personal_pension_contributions": 1_435.15, + } + summed_net, summed_over = _earnings_of([row]) + entered_net, entered_over = _earnings_of( + [{**row, "pension_contributions": 3_242.6401}] + ) + assert np.float32(3_242.6401) == np.float32(1_807.49) + np.float32(1_435.15) + assert summed_net + entered_net == [1_222_984 - 162_132, 1_222_984 - 162_133] + assert summed_over + entered_over == [True, False] + + +@pytest.mark.parametrize( + "row, uncached, net", + [ + ( + { + "employment_income_before_lsr": 10_608.96, + "salary_sacrifice_returned_to_income": 0.20, + "employee_pension_contributions_reported": 1.08, + }, + ["employee_pension_contributions"], + 1_060_852, + ), + ( + { + "employment_income": 12_569.97, + "employee_pension_contributions": 3_922.90, + }, + [ + "ni_class_1_employee", + "ni_class_1_employee_primary", + "ni_class_1_employee_additional", + ], + 1_060_851, + ), + ], + ids=["employee pension contributions", "Class 1"], +) +def test_what_the_simulation_keeps_does_not_change_earnings(row, uncached, net): + """Invariants 11 and 12: a simulation whose cache policy + keeps no value of a sum, or of Class 1, still calculated them, so reads + them as one that keeps them does. Employee contributions of £1.08 and + 20p of salary sacrifice returned to pay are £1.28, though float32 sums + them to 1.2800001, so half of them is 64p off £10,609.16 of pay, leaving + £10,608.52, over (CA Case 34); and Class 1 at £12,569.97 is still worked + exactly, 0.08p counting as a penny.""" + kept = _earnings_of([row]) + not_kept = _earnings_of([row], uncached=uncached) + assert kept == not_kept == ([net], [net == THRESHOLD_2026]) + + +def _natural_rows(rng, count, regime): + """Inputs the model sums, in whole pence, chosen so that net earnings, in + exact arithmetic on the model's own definitions, lie within 3p of the + 2026-27 threshold: pay before labour supply responses a, salary sacrifice + returned to pay b (also a member contribution), reported employee + contributions r, personal contributions q and statutory sick pay s. The + model counts statutory sick pay in NI income but not in pay, so these + rows test its sums, not how it treats statutory sick pay. + + - "no NI": below the primary threshold, so no PAYE or Class 1. + - "Class 1": Class 1 on a + b + s, with no PAYE while pay less member + contributions stays below the personal allowance. + - "PAYE": pay less member contributions x pence over the personal + allowance, with PAYE of 20% of x and Class 1. x is mostly a multiple of + 5p, so that the tax is whole pence and member contributions read a + fraction of a penny low would add a penny; q puts net earnings on the + target. + """ + rows, nets = [], [] + while len(rows) < count: + b, s = int(rng.integers(0, 200_000)), int(rng.integers(0, 50_000)) + r, q = int(rng.integers(0, 300_000)), int(rng.integers(0, 100_000)) + target = THRESHOLD_2026 + int(rng.integers(-3, 4)) + paye = 0 + if regime == "PAYE": + x = int(rng.integers(0, 300_000)) + if rng.random() < 0.8: + x -= x % 5 + pay = 1_257_000 + r + b + x + paye = math.ceil(Fraction(20, 100) * x) + # Half the contributions that leaves net earnings at the target. + half = pay - paye - math.ceil(_class_1(pay + s)) - target + q = 2 * half - r - b + if q < 0: + continue + # Pay that leaves net earnings at the target, given the deductions. + half = math.ceil(Fraction(r + b + q, 2)) + if regime == "no NI": + pay = target + half + elif regime == "Class 1": + # Net = pay - ceil(8% of (pay + s - PT)) - half, with no PAYE + # while pay less member contributions stays below the allowance. + pay = math.ceil( + (target + half - Fraction(8, 100) * (WEEKS * 24_173 - s)) + / Fraction(92, 100) + ) + a = pay - b + if a <= 0: + continue + employee = r + b + if regime == "Class 1" and not ( + pay + s > WEEKS * 24_173 and pay - employee < 1_257_000 + ): + continue + if regime == "no NI" and (pay + s > WEEKS * 24_173 or pay > 1_257_000): + continue + net = pay - paye - math.ceil(_class_1(pay + s)) - half + rows.append( + { + "employment_income_before_lsr": a / 100, + "salary_sacrifice_returned_to_income": b / 100, + "employee_pension_contributions_reported": r / 100, + "personal_pension_contributions": q / 100, + "statutory_sick_pay": s / 100, + } + ) + nets.append(net) + return rows, nets + + +@pytest.mark.parametrize("regime", ["no NI", "Class 1", "PAYE"]) +def test_sums_the_model_forms_are_read_in_whole_pence(regime): + """Invariant 12: pay (employment_income, which adds pay before labour + supply responses and returned salary sacrifice), NI income (which adds + statutory sick pay), employee pension contributions (reported plus + returned salary sacrifice, which reduce the pay PAYE is charged on) and + pension_contributions (the programmes) are the exact sums of their + whole-penny parts, however float32 rounds the model's sums, and the limit + test follows net earnings exactly, with no NI, with Class 1, and with + PAYE and Class 1.""" + rows, nets = _natural_rows(np.random.default_rng(2543), 600, regime) + if regime == "no NI": + # Returned pay and contributions of 20p, and of £1,435.15. + rows += [ + { + "employment_income_before_lsr": 10_608.96, + "salary_sacrifice_returned_to_income": 0.20, + "employee_pension_contributions_reported": 1.08, + }, + { + "employment_income_before_lsr": 10_794.69, + "salary_sacrifice_returned_to_income": 1_435.15, + "employee_pension_contributions_reported": 1_807.49, + }, + # Pay of £11,692.11, which float32 holds as 11,692.109375. + { + "employment_income_before_lsr": 10_276.42, + "salary_sacrifice_returned_to_income": 1_415.69, + "employee_pension_contributions_reported": 751.49, + }, + ] + nets += [THRESHOLD_2026] * 3 + net, gainful = _earnings_of(rows) + wrong = [(row, n, e) for row, n, e in zip(rows, net, nets) if n != e] + assert not wrong, wrong[:3] + assert gainful == [n >= THRESHOLD_2026 for n in nets] diff --git a/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py index be9c21dea3..8cad4d2ff6 100644 --- a/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py +++ b/policyengine_uk/tests/test_carers_allowance_pension_contribution_deduction_rate_properties.py @@ -14,20 +14,26 @@ 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). + falls by exactly the person's contributions x (r - 0.5), give or take the + penny that rounding each deduction up to a whole penny (reg. 5) can add. 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. + entitlement. The pence are counted as the formula counts them: the stored + (float32) annual earnings are read as the whole pence they lie within + half a float32 step of, or else with the fraction disregarded, and spread + over 52 weeks in whole pence. 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 math +from fractions import Fraction + import numpy as np from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st @@ -45,7 +51,8 @@ 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. +# the exact difference by more than a penny; reg. 5's rounding of the pension +# deduction adds up to a penny more. TOLERANCE = 0.05 PROPERTY_SETTINGS = settings( @@ -111,21 +118,27 @@ def situation(people, year): 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. + # Keep each stored value as the formula reads it: the rounding check below + # must start from the same float32 amount. 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. + # Reg. 5, as is_gainfully_employed_for_carers_allowance applies it, in + # exact rational arithmetic: the whole pence a year the stored amount lies + # within half a float32 step of, or else its pence with the fraction + # disregarded, then whole pence a week, the fraction disregarded. + # 10,608.52 a year, stored as 10,608.51953125, is 1,060,852 pence and + # 20,401 pence (204.01) a week. + pence = Fraction(float(earnings)) * 100 + whole = round(pence) + half_step = Fraction(float(np.spacing(np.float32(earnings)))) * 50 + annual = whole if abs(pence - whole) <= half_step else math.floor(pence) + return annual // 52 > Fraction(str(limit)) * 100 + + +# A carer one penny a week over the 2026-27 limit (10,608.52 / 52 = 204.01), +# stored just below 10,608.52 as described in over_limit. PENNY_OVER = dict( country="ENGLAND", age=45, diff --git a/policyengine_uk/tests/test_carers_allowance_weekly_earnings_above_limit_properties.py b/policyengine_uk/tests/test_carers_allowance_weekly_earnings_above_limit_properties.py new file mode 100644 index 0000000000..dd19b7eb86 --- /dev/null +++ b/policyengine_uk/tests/test_carers_allowance_weekly_earnings_above_limit_properties.py @@ -0,0 +1,217 @@ +"""Weekly earnings above the Carer's Allowance earnings limit, in exact pence. + +carers_allowance_weekly_earnings_above_limit spreads net earnings over 52 +weeks (Computation of Earnings Regulations 1996 regs. 8(1)(b)(iii) and +11(1)(a)), disregards the fraction of a penny that leaves (reg. 5), and gives +how far the whole pence a week exceed the limit (ICA Regulations 1976 reg. +8(1)). Two things read it: the gainful-employment test, which asks whether +there is any excess, and the Budget 2026 earnings taper, which takes +taper.rate (100%) of the excess off the weekly rate. So the amount of the +excess matters as well as its sign. + +Annual earnings of A whole pence, against a limit of L pounds and a weekly +rate of R pounds, have an excess of E = max(0, A // 52 - 100 x L) pence a +week. Under the taper (applied by reform in each year from 2020-21, so every +limit since then is tested with it), for a carer in England caring 40 hours: + +1. The excess is exact: E pence, recovered exactly from its float32 value, + and the person is gainfully employed exactly when E > 0. +2. The award is the rate less the excess: 52 x max(0, 100 x R - E) pence a + year, to within a float32 step of the annual award. (The rates after the + last legislated one are the model's uprated projections, which need not + be whole pence.) +3. It never pays more than the full rate, nor does the award before the + couple and overlap restrictions. +4. It never goes negative, nor does the award before those restrictions. +5. It falls by at most £1 for each £1 of weekly earnings above the limit: + comparing any two carers, the weekly award differs by no more than their + excesses do. +6. At or under the limit it is the full rate. +""" + +import math + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.pence import stored_pence + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +WEEKS = 52 +FIRST_YEAR = 2020 +# The last year the parameter tree lists a limit for, including any years +# the model projects after the last legislated limit. +HORIZON = max( + int(value.instant_str[:4]) + for value in system.parameters.gov.dwp.carers_allowance.earnings_limit.values_list +) +YEARS = list(range(FIRST_YEAR, HORIZON + 1)) +# The taper in every year, so that it reads each limit since 2020-21. +WITH_TAPER = { + "gov.dwp.carers_allowance.earnings_taper.applies": {str(year): 1 for year in YEARS} +} + + +def _limit_and_rate_pence(year): + """The limit, in whole pence, and the weekly rate, in pence, which a + projected year may give with a fraction of a penny.""" + ca = system.parameters(f"{year}-04-30").gov.dwp.carers_allowance + return round(ca.earnings_limit * 100), ca.rate * 100 + + +def _within_a_step(award, expected): + """Whether each annual ``award`` (pence) is ``expected`` (pence) to + within a float32 step of the annual amount.""" + expected = np.asarray(expected, dtype=np.float64) + step = np.spacing(np.float32(np.max(expected) / 100 + 1)).astype(float) * 100 + return np.abs(np.asarray(award) - expected) <= step + + +def _simulation(year, count): + people = { + f"carer_{i}": {"age": {year: 45}, "care_hours": {year: 40}} + for i in range(count) + } + return Simulation( + situation={ + "people": people, + "benunits": {f"b{i}": {"members": [f"carer_{i}"]} for i in range(count)}, + "households": { + f"h{i}": { + "members": [f"carer_{i}"], + "region": {year: "NORTH_WEST"}, + } + for i in range(count) + }, + }, + reform=WITH_TAPER, + ) + + +_SIMULATIONS = {} + + +def _run(year, annual_pence): + """For carers with annual net earnings of ``annual_pence``: the excess + and the award, each in exact pence (weekly and annual), whether each is + gainfully employed, and the annual award before the couple and overlap + restrictions, in pence.""" + key = (year, len(annual_pence)) + if key not in _SIMULATIONS: + _SIMULATIONS[key] = _simulation(year, len(annual_pence)) + simulation = _SIMULATIONS[key] + simulation.set_input( + "carers_allowance_earnings", + year, + np.array([p / 100 for p in annual_pence]), + ) + simulation.reset_calculations() + excess = stored_pence( + simulation.calculate("carers_allowance_weekly_earnings_above_limit", year) + ) + gainful = simulation.calculate("is_gainfully_employed_for_carers_allowance", year) + award = simulation.calculate("carers_allowance", year).astype(np.float64) * 100 + pre_couple = ( + simulation.calculate("carers_allowance_pre_couple_limit", year).astype( + np.float64 + ) + * 100 + ) + return excess, gainful, award, pre_couple + + +def _expected(year, annual_pence): + limit, rate = _limit_and_rate_pence(year) + excess = np.array([max(0, p // WEEKS - limit) for p in annual_pence]) + award = WEEKS * np.maximum(0, rate - excess) + return excess, award + + +# A carer exactly at the limit, and 1p, 2p and £1 a week over it. +STEPS = (0, 1, 2, 100) + + +@pytest.mark.parametrize("year", YEARS) +def test_a_penny_two_pence_and_a_pound_over_each_limit_taper_exactly(year): + """Invariants 1, 2 and 6 at each limit from 2020-21 to the horizon: 52 x L + + 52 x s pence a year (s pence a week over the limit), and 51p a year + more, which reg. 5 disregards.""" + limit, rate = _limit_and_rate_pence(year) + annual = [WEEKS * (limit + step) + extra for step in STEPS for extra in (0, 51)] + excess, gainful, award, _ = _run(year, annual) + expected_excess = [step for step in STEPS for _ in (0, 51)] + assert list(excess) == expected_excess, (year, limit) + assert list(gainful) == [e > 0 for e in expected_excess], (year, limit) + assert _within_a_step(award, [WEEKS * (rate - e) for e in expected_excess]).all(), ( + year, + limit, + list(award), + ) + + +@st.composite +def whole_pence_around_a_limit(draw): + year = draw(st.sampled_from(YEARS)) + limit, rate = _limit_and_rate_pence(year) + # The least number of pence a year that is over the limit, and the least + # at which the taper leaves nothing. + over = WEEKS * (limit + 1) + nil = WEEKS * (limit + math.ceil(rate)) + near = st.one_of( + st.integers(over - 3 * WEEKS, over + 3 * WEEKS), + st.integers(nil - 3 * WEEKS, nil + 3 * WEEKS), + ) + # Up to £131,071.99, below which float32 holds every whole-pence amount + # to within half a penny. + anywhere = st.integers(0, 2**17 * 100 - 1) + pence = draw(st.lists(st.one_of(near, anywhere), min_size=40, max_size=40)) + return year, pence + + +@settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(case=whole_pence_around_a_limit()) +# A penny a week over each limit #2541 affected, and a penny short of it. +@example(case=(2020, [WEEKS * 12_801 - 1, WEEKS * 12_801] * 20)) +@example(case=(2022, [WEEKS * 13_201 - 1, WEEKS * 13_201] * 20)) +@example(case=(2023, [WEEKS * 13_901 - 1, WEEKS * 13_901] * 20)) +@example(case=(2024, [WEEKS * 15_101 - 1, WEEKS * 15_101] * 20)) +def test_the_taper_follows_exact_pence(case): + """Invariants 1 to 6 for whole-pence earnings anywhere, and near the + points where the excess starts and where the taper leaves nothing.""" + year, pence = case + _, rate = _limit_and_rate_pence(year) + excess, gainful, award, pre_couple = _run(year, pence) + expected_excess, expected_award = _expected(year, pence) + # 1 and 2: exact. + assert list(excess) == list(expected_excess), year + assert list(gainful) == list(expected_excess > 0), year + assert _within_a_step(award, expected_award).all(), year + # A float32 step of the annual award, in pence a week. + slack = np.spacing(np.float32(WEEKS * rate / 100)).astype(float) * 100 / WEEKS + # 3 and 4: within the full rate and never negative, both for the award + # and for the taper's own award before the couple and overlap + # restrictions, which other variables read. + assert (award <= WEEKS * rate + WEEKS * slack).all(), year + assert (award >= 0).all(), year + assert (pre_couple <= WEEKS * rate + WEEKS * slack).all(), year + assert (pre_couple >= 0).all(), year + # 5: between any two carers, the weekly award falls by no more than the + # excess rises, and the award a week is at least the rate less the excess. + weekly = award / WEEKS + order = np.argsort(excess, kind="stable") + fall = -np.diff(weekly[order]) + rise = np.diff(excess[order]) + assert (fall <= rise + 2 * slack).all(), year + assert (rate - weekly <= excess + slack).all(), year + # 6: the full rate at or under the limit. + assert _within_a_step(award[excess == 0], WEEKS * rate).all(), year diff --git a/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py new file mode 100644 index 0000000000..2a5ba00304 --- /dev/null +++ b/policyengine_uk/tests/test_cares_for_severely_disabled_person_properties.py @@ -0,0 +1,508 @@ +"""Carer benefits pay on caring hours only for care of a severely disabled +person. + +The Department for Work and Pensions pays Carer's Allowance only for caring +for a "severely disabled person" (SSCBA 1992 s.70(1)), defined by the benefit +payable for them (s.70(2)). Social Security Scotland pays Carer Support +Payment only for care of a person "to whom a qualifying disability benefit is +normally payable" (SSI 2023/302 reg 5(1)). DWP includes the Universal Credit +carer element (UC Regs 2013 reg 29) for a claimant who meets the Carer's +Allowance conditions, or would but for the earnings limit or living in +Scotland (reg 30(1)(a)). The input cares_for_severely_disabled_person records +whether the person cared for gets such a benefit. It defaults to true, and +the model reads it only on the route through caring hours: a reported award +(carers_allowance_reported) still counts. + +Invariants, for benefit units of one or two adults and up to one qualifying +young person, each with drawn caring hours, reported award, take-up, +earnings and overlapping benefit, in each country, in 2024 (Scotland still on +Carer's Allowance), 2026, 2030 (the year the Carer's Allowance earnings +taper starts) and 2031, where no input supplies a carer benefit amount or +entitlement: + +1. leaving the input out gives exactly the values that setting it true + gives, for carers_allowance, carer_support_payment, is_carer_for_benefits, + is_entitled_to_carer_benefit, uc_carer_element, + partners_care_for_same_severely_disabled_person and + carer_minimum_guarantee_addition; +2. setting one person's input false never raises that person's + carers_allowance or carer_support_payment and never turns that person's + is_carer_for_benefits or is_entitled_to_carer_benefit on; it never raises + the benefit unit's uc_carer_element or carer_minimum_guarantee_addition, + and never turns partners_care_for_same_severely_disabled_person on; +3. before the taper, setting every person's input false never raises any + person's carers_allowance or carer_support_payment, or the benefit unit's + amounts, and never turns any of those flags on; +4. a person whose input is false and who has no reported award gets no + Carer's Allowance or Carer Support Payment, before or after the + overlapping-benefit adjustment, and is neither entitled to a carer benefit + nor a carer for benefits; +5. setting the input false for the people with a reported award leaves every + person's carers_allowance and carer_support_payment as they were, and + every person's is_carer_for_benefits too, except for a reported recipient + who would not claim, whose route through caring hours the input closes. + +And for couples where an input supplies each partner's entitlement to a +carer benefit, true or false: + +6. partners_care_for_same_severely_disabled_person is false exactly where + both partners have an entitlement and an award: a reported award, or an + entitlement that caring hours for a severely disabled person don't explain + (fewer than the minimum hours, the input false, or no claim). Leaving the + input out gives what setting it true gives, and setting inputs false never + turns the flag on. + +Only one of two partners who care for the same person can have the award +(SSCBA 1992 s.70(7ZA); SSI 2023/302 reg 5(3)). So when one partner's input +goes false, the model can give the award to the other partner, whose amount +and entitlement then rise, as the law has it; the second invariant therefore +compares only the person whose input changes, and the benefit unit. Under +the earnings taper the partners' awards can differ, and the model gives the +award to the partner with the larger one: setting both inputs false can then +give the other partner back a reported award, so the third invariant holds +only before the taper. Two fixed tests at the end run both cases. + +The first five invariants need households where no input supplies a carer +benefit entitlement or amount. Without one, the input can't change +partners_care_for_same_severely_disabled_person, so the sixth invariant +supplies the entitlements. A supplied entitlement that caring hours no longer +explain counts as a second award: it can turn that flag false and give the +couple a second carer premium or Universal Credit carer element, as the YAML +case "A supplied entitlement whose caring hours go to someone not severely +disabled counts as a second award" shows. Batching needs the same care: +supplying a value for one person makes it an input for every person in the +simulation, so the others take its default, not the formula. The sixth +invariant's households supply the entitlement for every person. + +Tests 1, 2, 3, 5 and 6 build each benefit unit more than once in one +simulation, in separate households: as drawn, and with the input changed. +Test 4 builds each unit once, with each member's input false or left out. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +INPUT = "cares_for_severely_disabled_person" +YEARS = [2024, 2026, 2030, 2031] +YEARS_BEFORE_TAPER = [2024, 2026] +COUNTRIES = ["ENGLAND", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +AWARD = 4_000 +# Annual amounts are float32; a change smaller than half a penny is not one. +TOLERANCE = 0.005 + +PERSON_AMOUNTS = ["carers_allowance", "carer_support_payment"] +PERSON_FLAGS = ["is_carer_for_benefits", "is_entitled_to_carer_benefit"] +BENUNIT_AMOUNTS = ["uc_carer_element", "carer_minimum_guarantee_addition"] +BENUNIT_FLAGS = ["partners_care_for_same_severely_disabled_person"] +PRE_OVERLAP = ["carers_allowance_pre_overlap", "carer_support_payment_pre_overlap"] + + +@st.composite +def carer_inputs(draw, earns=True): + # The draws lean towards a claim within the earnings limit, so that most + # carers who care the hours have an award the input can take away. + return { + # Either side of the 35-hour minimum both benefits use. + "care_hours": draw(st.sampled_from([0, 20, 34, 35, 40, 40, 60])), + "carers_allowance_reported": draw(st.sampled_from([0, 0, AWARD])), + "would_claim_carers_allowance": draw(st.sampled_from([True, True, False])), + # From 2026: below the earnings limit, above it by less than the rate + # (13,500 exceeds it by 50.60 a week in 2026 and 21.70 in 2031, so the + # taper pays some allowance), and far above it. The lower 2024 limit + # puts 8,000 and 13,500 above it, by 2.84 and 103.60 a week. + "employment_income": ( + draw(st.sampled_from([0, 0, 0, 8_000, 13_500, 30_000])) if earns else 0 + ), + # Above the carer rates, so an overlapping benefit can reduce the + # payment to nil and leave the entitlement. + "incapacity_benefit_reported": draw(st.sampled_from([0, 0, 6_000])), + } + + +@st.composite +def benefit_units(draw): + """One benefit unit in its own household: its members, the country, one + member whose input a test changes, and for each member the input false + or left out (None).""" + has_young_person = draw(st.booleans()) + young_person_age = draw(st.integers(16, 19)) + head_age = draw(st.integers(young_person_age + 16 if has_young_person else 18, 85)) + members = [{"age": head_age, "is_parent": has_young_person, **draw(carer_inputs())}] + if draw(st.booleans()): + # Within 15 years of the head in both directions when either could be + # under 20, and under 20 years older otherwise, so the model reads + # neither as the other's child. + oldest_partner = min(85, head_age + 19) if head_age >= 20 else head_age + 15 + youngest_partner = max(18, head_age - 15) + if has_young_person: + youngest_partner = max(youngest_partner, young_person_age + 16) + members.append( + { + "age": draw(st.integers(youngest_partner, oldest_partner)), + "is_parent": has_young_person, + **draw(carer_inputs()), + } + ) + adults = len(members) + if has_young_person: + # A qualifying young person: 16 to 19 in non-advanced education. + members.append( + { + "age": young_person_age, + "current_education": "UPPER_SECONDARY", + **draw(carer_inputs(earns=False)), + } + ) + return { + "members": members, + "adults": adults, + "country": draw(st.sampled_from(COUNTRIES)), + "chosen": draw(st.integers(0, len(members) - 1)), + "inputs": [draw(st.sampled_from([False, None])) for _ in members], + } + + +def with_input(unit, values): + """The unit with the input set to each member's value, or left out where + the value is None.""" + members = [ + member if value is None else {**member, INPUT: value} + for member, value in zip(unit["members"], values) + ] + return {**unit, "members": members} + + +@st.composite +def couples_with_supplied_entitlements(draw): + """A couple in its own household, where an input supplies each partner's + entitlement to a carer benefit, true or false, and each partner's input + is false or left out (None).""" + head_age = draw(st.integers(18, 85)) + oldest_partner = min(85, head_age + 19) if head_age >= 20 else head_age + 15 + ages = [head_age, draw(st.integers(max(18, head_age - 15), oldest_partner))] + members = [ + { + "age": age, + "is_parent": False, + "is_entitled_to_carer_benefit": draw(st.booleans()), + **draw(carer_inputs()), + } + for age in ages + ] + return { + "members": members, + "adults": 2, + "country": draw(st.sampled_from(COUNTRIES)), + "chosen": draw(st.integers(0, 1)), + "inputs": [draw(st.sampled_from([False, None])) for _ in members], + } + + +def simulate(units, year): + people, benunits, households = {}, {}, {} + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit["members"]): + name = f"p{i}_{j}" + people[name] = {key: {year: value} for key, value in inputs.items()} + names.append(name) + benunits[f"b{i}"] = {"members": names} + households[f"h{i}"] = {"members": names, "country": {year: unit["country"]}} + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + offsets = np.cumsum([0] + [len(unit["members"]) for unit in units]) + return sim, offsets + + +def calculate(units, year, variables): + sim, offsets = simulate(units, year) + values = {name: sim.calculate(name, year) for name in variables} + return values, offsets + + +SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +UNITS = st.lists(benefit_units(), min_size=1, max_size=6) + +ADULT = { + "is_parent": False, + "carers_allowance_reported": 0, + "would_claim_carers_allowance": True, + "employment_income": 0, + "incapacity_benefit_reported": 0, +} +# Both partners care 40 hours a week. The elder, the benefit unit head, has the +# couple's one award; with the elder's input false the younger has it. +COUPLE = { + "members": [ + {**ADULT, "age": 40, "care_hours": 40}, + {**ADULT, "age": 38, "care_hours": 40}, + ], + "adults": 2, + "country": "ENGLAND", + "chosen": 0, + "inputs": [False, None], +} +# The same couple, aged 70 and 60, where an overlapping benefit takes the +# elder's award to nil: with the elder's input false, the younger is paid. +OVERLAPPED_COUPLE = { + **COUPLE, + "members": [ + {**ADULT, "age": 70, "care_hours": 40, "incapacity_benefit_reported": 6_000}, + {**ADULT, "age": 60, "care_hours": 40}, + ], +} +# Under the taper: the elder's reported award, tapered by earnings, loses to +# the younger's larger award on caring hours. With both inputs false the elder +# has it back; with only the elder's false, nothing changes. +TAPERED_COUPLE = { + **COUPLE, + "members": [ + { + **ADULT, + "age": 40, + "care_hours": 40, + "carers_allowance_reported": AWARD, + "employment_income": 13_500, + }, + {**ADULT, "age": 38, "care_hours": 40}, + ], + "inputs": [False, False], +} +SCOTTISH_COUPLE = {**COUPLE, "country": "SCOTLAND"} +# The elder has a reported award; the younger cares 40 hours a week for +# someone not severely disabled. Each has a supplied entitlement, so the +# younger's counts as a second award once the input says the hours don't +# explain it. +SUPPLIED_COUPLE = { + **COUPLE, + "members": [ + { + **ADULT, + "age": 40, + "care_hours": 40, + "carers_allowance_reported": AWARD, + "is_entitled_to_carer_benefit": True, + }, + {**ADULT, "age": 38, "care_hours": 40, "is_entitled_to_carer_benefit": True}, + ], + "chosen": 1, + "inputs": [None, False], +} + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[COUPLE, SCOTTISH_COUPLE], year=2026) +def test_input_left_out_matches_input_true(drawn, year): + variables = PERSON_AMOUNTS + PERSON_FLAGS + ["is_claimant_or_partner"] + benunit_variables = BENUNIT_AMOUNTS + BENUNIT_FLAGS + true = [with_input(unit, [True] * len(unit["members"])) for unit in drawn] + values, offsets = calculate(drawn + true, year, variables + benunit_variables) + k = len(drawn) + n = offsets[k] + for i, unit in enumerate(drawn): + # The generator's construction, checked against the model: the + # adults are the claimant and partner, the young person is not. + flags = values["is_claimant_or_partner"][offsets[i] : offsets[i + 1]] + adults = unit["adults"] + assert flags[:adults].all() and not flags[adults:].any(), unit + for name in variables: + assert np.array_equal(values[name][:n], values[name][n:]), name + for name in benunit_variables: + assert np.array_equal(values[name][:k], values[name][k:]), name + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[COUPLE, OVERLAPPED_COUPLE, SCOTTISH_COUPLE], year=2026) +@example(drawn=[TAPERED_COUPLE], year=2031) +def test_one_persons_input_false_never_raises_their_award(drawn, year): + changed = [ + with_input( + unit, + [ + False if j == unit["chosen"] else None + for j in range(len(unit["members"])) + ], + ) + for unit in drawn + ] + variables = PERSON_AMOUNTS + PERSON_FLAGS + BENUNIT_AMOUNTS + BENUNIT_FLAGS + values, offsets = calculate(drawn + changed, year, variables) + k = len(drawn) + for i, unit in enumerate(drawn): + before = offsets[i] + unit["chosen"] + after = offsets[k + i] + unit["chosen"] + for name in PERSON_AMOUNTS: + assert values[name][after] <= values[name][before] + TOLERANCE, (name, unit) + for name in PERSON_FLAGS: + assert values[name][before] or not values[name][after], (name, unit) + for name in BENUNIT_AMOUNTS: + assert values[name][k + i] <= values[name][i] + TOLERANCE, (name, unit) + for name in BENUNIT_FLAGS: + assert values[name][i] or not values[name][k + i], (name, unit) + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS_BEFORE_TAPER)) +@example(drawn=[COUPLE, OVERLAPPED_COUPLE, SCOTTISH_COUPLE], year=2026) +def test_everyones_input_false_never_raises_an_award_before_the_taper(drawn, year): + false = [with_input(unit, [False] * len(unit["members"])) for unit in drawn] + variables = PERSON_AMOUNTS + PERSON_FLAGS + BENUNIT_AMOUNTS + BENUNIT_FLAGS + values, offsets = calculate(drawn + false, year, variables) + k = len(drawn) + n = offsets[k] + for name in PERSON_AMOUNTS: + assert (values[name][n:] <= values[name][:n] + TOLERANCE).all(), name + for name in PERSON_FLAGS: + assert not (values[name][n:] & ~values[name][:n]).any(), name + for name in BENUNIT_AMOUNTS: + assert (values[name][k:] <= values[name][:k] + TOLERANCE).all(), name + for name in BENUNIT_FLAGS: + assert not (values[name][k:] & ~values[name][:k]).any(), name + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[COUPLE, TAPERED_COUPLE, SCOTTISH_COUPLE], year=2031) +def test_input_false_without_a_reported_award_pays_no_carer_benefit(drawn, year): + units = [with_input(unit, unit["inputs"]) for unit in drawn] + variables = PERSON_AMOUNTS + PRE_OVERLAP + PERSON_FLAGS + values, offsets = calculate(units, year, variables) + for i, unit in enumerate(drawn): + for j, (member, value) in enumerate(zip(unit["members"], unit["inputs"])): + if value is False and member["carers_allowance_reported"] == 0: + for name in PERSON_AMOUNTS + PRE_OVERLAP: + assert values[name][offsets[i] + j] == 0, (name, unit) + for name in PERSON_FLAGS: + assert not values[name][offsets[i] + j], (name, unit) + + +@SETTINGS +@given(UNITS, st.sampled_from(YEARS)) +@example(drawn=[TAPERED_COUPLE], year=2031) +@example(drawn=[{**COUPLE, "members": TAPERED_COUPLE["members"]}], year=2026) +def test_reported_award_pays_the_same_whatever_the_input(drawn, year): + toggled = [ + with_input( + unit, + [ + False if member["carers_allowance_reported"] > 0 else None + for member in unit["members"] + ], + ) + for unit in drawn + ] + variables = PERSON_AMOUNTS + ["is_carer_for_benefits"] + values, offsets = calculate(drawn + toggled, year, variables) + n = offsets[len(drawn)] + for name in PERSON_AMOUNTS: + assert np.array_equal(values[name][:n], values[name][n:]), name + # A reported award the person would claim keeps them a carer for benefits + # whatever the input says, as the reported limb of is_carer_for_benefits + # has it; a reported recipient who would not claim relies on the hours. + claims = np.array( + [ + member["carers_allowance_reported"] == 0 + or member["would_claim_carers_allowance"] + for unit in drawn + for member in unit["members"] + ] + ) + carer = values["is_carer_for_benefits"] + assert np.array_equal(carer[:n][claims], carer[n:][claims]) + + +@SETTINGS +@given( + st.lists(couples_with_supplied_entitlements(), min_size=1, max_size=6), + st.sampled_from(YEARS), +) +@example(drawn=[SUPPLIED_COUPLE, {**SUPPLIED_COUPLE, "country": "SCOTLAND"}], year=2026) +def test_a_supplied_entitlement_counts_as_an_award_unless_hours_explain_it(drawn, year): + true = [with_input(unit, [True, True]) for unit in drawn] + changed = [with_input(unit, unit["inputs"]) for unit in drawn] + sim, offsets = simulate(drawn + true + changed, year) + same = sim.calculate("partners_care_for_same_severely_disabled_person", year) + claimant_or_partner = sim.calculate("is_claimant_or_partner", year) + gov = sim.tax_benefit_system.parameters(year).gov + k = len(drawn) + assert claimant_or_partner.all() + for i, unit in enumerate(drawn): + assert same[k + i] == same[i], unit + assert same[i] or not same[2 * k + i], unit + # The minimum hours of the benefit that applies: Carer Support + # Payment's in Scotland from 2025, Carer's Allowance's otherwise. + if unit["country"] == "SCOTLAND" and year >= 2025: + min_hours = gov.social_security_scotland.carer_support_payment.min_hours + else: + min_hours = gov.dwp.carers_allowance.min_hours + awards = 0 + for member, value in zip(unit["members"], unit["inputs"]): + explained = ( + member["care_hours"] >= min_hours + and value is not False + and member["would_claim_carers_allowance"] + ) + reported = member["carers_allowance_reported"] > 0 + awards += member["is_entitled_to_carer_benefit"] and ( + reported or not explained + ) + assert same[2 * k + i] == (awards < 2), unit + + +def _couple_values(units, year, variables): + values, _ = calculate(units, year, variables) + return {name: [round(float(x), 2) for x in values[name]] for name in variables} + + +def test_award_passes_to_the_partner_who_cares_for_a_severely_disabled_person(): + # The cases that the second invariant leaves out, run as they are. With + # the elder's input false the younger's allowance rises from nil to the + # full rate, 86.45 * 52 = 4,495.40: one partner's hours no longer count, + # so the other is the only one caring for a severely disabled person. + names = ["carers_allowance", "is_entitled_to_carer_benefit"] + default = _couple_values([COUPLE], 2026, names) + changed = _couple_values([with_input(COUPLE, COUPLE["inputs"])], 2026, names) + assert default == { + "carers_allowance": [4_495.4, 0], + "is_entitled_to_carer_benefit": [1, 0], + } + assert changed == { + "carers_allowance": [0, 4_495.4], + "is_entitled_to_carer_benefit": [0, 1], + } + # Where an overlapping benefit takes the elder's award to nil, the couple + # had nothing and now has the younger's full allowance. + overlapped = _couple_values([OVERLAPPED_COUPLE], 2026, names) + changed = _couple_values( + [with_input(OVERLAPPED_COUPLE, OVERLAPPED_COUPLE["inputs"])], 2026, names + ) + assert overlapped["carers_allowance"] == [0, 0] + assert changed["carers_allowance"] == [0, 4_495.4] + + +def test_under_the_taper_both_inputs_false_can_give_back_a_reported_award(): + # The case that the third invariant leaves out. In 2031 the elder's + # tapered award falls short of the younger's award on caring hours, so + # the model gives the younger the couple's award. With both inputs false + # the younger has none, and the elder keeps the reported award. + names = ["carers_allowance_pre_couple_limit", "carers_allowance"] + default = _couple_values([TAPERED_COUPLE], 2031, names) + both_false = _couple_values( + [with_input(TAPERED_COUPLE, TAPERED_COUPLE["inputs"])], 2031, names + ) + elder_award, younger_award = default["carers_allowance_pre_couple_limit"] + assert 0 < elder_award < younger_award + assert default["carers_allowance"] == [0, younger_award] + assert both_false["carers_allowance"] == [elder_award, 0] diff --git a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py index 34431e6fab..06b28c60ab 100644 --- a/policyengine_uk/tests/test_child_and_adult_definitions_properties.py +++ b/policyengine_uk/tests/test_child_and_adult_definitions_properties.py @@ -50,7 +50,9 @@ 19 and without own-right benefits, that route alone makes a UC or PC qualifying young person whatever the education inputs; without it the education route is unchanged. Own-right benefits exclude both routes. UC - and PC agree below 19, where only UC's terminal date differs. + and PC agree below 19, where only UC's terminal date differs. Child + Benefit reg 4 and CTC reg 4 (until the 31 August after the 16th birthday) + count for the same years. 6. Supplied claimant/partner role vectors, valid or not, are returned unchanged on any benefit unit. Widening an unflagged member's gap below the claimant, with the claimant, flags, other members and HBAI adulthood @@ -61,11 +63,21 @@ Scope: these are tests of the ENCODED statutory limbs, not full entitlement. Education status proxies full-time non-advanced study: hours, employment -contracts, approved providers and interruptions are unobserved. CB/CTC leaver -extensions are omitted. UC/PC's route to the 1 September after the 16th -birthday, which has no education condition, counts for a year that starts -before that date (invariant 5). UC's terminal date is an input; PC's September -terminal date is absent. +contracts, approved providers and interruptions are unobserved. Child +Benefit's extension period and terminal date (regs 5 and 7) and CTC's 20 +weeks after leaving (reg 5(3)(b)) are inputs, false here, with their own +tests in test_child_benefit_school_leaver_routes_properties.py and +test_child_tax_credit_school_leaver_routes_properties.py. The route to the 1 +September after the 16th birthday (UC, PC) or the 31 August before it (Child +Benefit reg 4, CTC reg 4), which has no education condition, counts for a +year that starts before that date (invariant 5). Nobody here is in +remunerative work, which ends CTC's route. The tests here set UC's +terminal-date input, false unless a test varies it; PC's terminal date +follows the 19th-birthday date fact, which is true for everyone under 20 when +only an age is given +(test_qualifying_young_person_terminal_date_properties.py covers both dates). +Nobody here reports Income Support, which SPC reg 4A(5), Child Benefit reg 8 +and CTC reg 5(4)(c) list and UC reg 5(5) does not. Own-benefit receipt collapses different programme-specific benefit lists. UC/CTC/PC blanket looked-after exclusions omit statutory exceptions. HBAI's fallback assumes dependence at 16--17 regardless of study, requires an @@ -201,29 +213,33 @@ def statutory_child(age): def in_route_to_first_september_after_16th_birthday(p): - # UC reg 5(1)(a) and SPC reg 4A(1)(a) have no education condition. With no - # date of birth, a whole age on 6 October places the birthday on 6 April, - # so the year (from 6 April) starts before the 1 September following the - # 16th birthday exactly when that age is under 17. + # UC reg 5(1)(a), SPC reg 4A(1)(a) and Child Benefit reg 4 have no + # education condition. With no date of birth, a whole age on 6 October + # places the birthday on 6 April, so the year (from 6 April) starts before + # the 1 September following the 16th birthday exactly when that age is + # under 17. Child Benefit's 31 August is the day before. return p.get("is_before_first_september_following_16th_birthday", p["age"] < 17) def qualifying_young_person(p, programme): - # CB and CTC: education/training routes only (see omissions in module - # docstring). UC and PC: also the route with no education condition. + # Every programme: the education/training route, and the route with no + # education condition. # CB: https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + # CB at 16: https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + # CTC at 16: https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 # CB own-benefit restriction: https://www.legislation.gov.uk/uksi/2006/223/regulation/8 # UC: https://www.legislation.gov.uk/uksi/2013/376/regulation/5 # CTC: https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 # PC: https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: return False - if programme in { - "universal_credit", - "pension_credit", - } and in_route_to_first_september_after_16th_birthday(p): - return True - if not (non_advanced_education(p) or p["is_in_approved_training"]): + studying = non_advanced_education(p) or p["is_in_approved_training"] + if in_route_to_first_september_after_16th_birthday(p): + # CTC reg 4 lifts only the first condition, for a person who has left + # education; one still in it is tested as a student below. + if programme != "child_tax_credit" or not studying: + return True + if not studying: return False if ( p["age"] >= 19 diff --git a/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..6d4dcc2747 --- /dev/null +++ b/policyengine_uk/tests/test_child_benefit_school_leaver_routes_properties.py @@ -0,0 +1,433 @@ +"""Child Benefit qualifying young persons who have left education. + +Child Benefit (General) Regulations 2006: a person aged 16 to 19 is a +qualifying young person while any of regs 3 to 7 applies (reg. 2(2)) and +they are not receiving a benefit in their own right (reg. 8). + +- Reg. 3: non-advanced education or approved training, begun before 19. +- Reg. 4: under 17 and left education, until the 31 August next following + the 16th birthday (the 1 September for a 16th birthday on 31 August). +- Reg. 5: under 18, in an extension period, not in remunerative work. +- Reg. 7: until the terminal date after leaving; reg. 7(3) pays no Child + Benefit "by virtue of this regulation" in a week of remunerative work (24 + hours or more, reg. 1(3)). + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education is a qualifying young person for a year + exactly when they are 16 to 19 on 6 October and the year starts (6 April) + on or before the reg. 4 end date, for every date of birth. +2. Over every combination of age, education, training, own-right benefits, + the three leaver facts and hours: the status equals the union of the four + conditions within ages 16 to 19 and without own-right benefits. So each + condition ends at its age limit, own-right benefits exclude every + condition, and with the three leaver facts off the status is reg. 3's + alone, as before. +3. Switching a leaver fact on never removes the status. +4. Child Benefit is payable for the benefit unit's child or qualifying young + person unless they qualify through reg. 7 alone and are in remunerative + work. They stay a qualifying young person, and a legacy young person. +5. Child Benefit entitlement is the eldest payable member at the eldest + rate and each other at the additional rate, for 52 weeks. +6. Reg. 8(2)(a): in a year when DWP pays Income Support, a person who + reports Income Support of their own is never a qualifying young person, + on any route, whoever else in the family claims it. +7. Without a report, setting receives_income_support_in_own_right false as + an input leaves the status unchanged; with it set false, a report + changes nothing, so the status is the one without the report. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +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.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_benefit_extension_period" +TERMINAL = "is_before_child_benefit_terminal_date" +QYP = "is_qualifying_young_person_for_child_benefit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + TERMINAL: False, + "is_claimant_or_partner": False, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def regulation_4_end_date(born): + # Reg. 4(2): "the 31st August next following the person's 16th birthday"; + # reg. 4(3): for a 16th birthday on 31 August, "the 1st September + # immediately following". A 29 February birthday is before 31 August + # whichever day it is kept on in other years. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 9, 1) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + # "Has not passed": the year, from 6 April, reaches the period if it + # starts on or before the end date. + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= regulation_4_end_date(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2006 to 2011: ages 14 to 20 on 6 October 2026, + # with the 31 August and 1 September edges in each year. + start, end = date(2006, 1, 1), date(2012, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + # Not vacuous, and the edges fall where the regulation puts them. + by_birth = dict(zip(births, expected)) + assert by_birth[date(2010, 6, 1)] and not by_birth[date(2009, 6, 1)] + # 16 on 31 August 2025: the period ended on 1 September 2025. + assert not by_birth[date(2009, 8, 31)] + # 16 on 1 September 2025: the period runs to 31 August 2026. + assert by_birth[date(2009, 9, 1)] + # 16 on 6 October 2026 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2010, 10, 6)] and not by_birth[date(2010, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +def regulation_3(p): + # Education status stands in for full-time non-advanced education. + # https://www.legislation.gov.uk/uksi/2006/223/regulation/3 + studying = ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return studying and begun_before_19 + + +def remunerative_work(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/1 + return p["weekly_hours"] >= 24 + + +def regulation_5(p): + # https://www.legislation.gov.uk/uksi/2006/223/regulation/5 + return p["age"] < 18 and p[EXTENSION] and not remunerative_work(p) + + +def qualifying_young_person(p): + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + return regulation_3(p) or p[ROUTE] or regulation_5(p) or p[TERMINAL] + + +def payable(p): + # Reg. 7(3) bars Child Benefit payable through reg. 7 only. + if p["is_claimant_or_partner"]: + return False + if p["age"] < 16: + return True + if not qualifying_young_person(p): + return False + through_another = regulation_3(p) or p[ROUTE] or regulation_5(p) + return through_another or not remunerative_work(p) + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + **{ROUTE: route, EXTENSION: extension, TERMINAL: terminal}, + ) + for age, education, training, own_right, hours in product( + range(14, 22), EDUCATIONS, (False, True), (False, True), (0, 23, 24) + ) + for route, extension, terminal in product((False, True), repeat=3) +] + + +def test_status_is_the_union_of_the_four_conditions(): + # 8 ages * every education value * 2 * 2 * 3 hours, each with the 8 + # combinations of the three leaver facts (adjacent rows). + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[ + np.array([p["receives_benefits_in_own_right"] for p in GRID]) + ].any() + assert status.any() + # With the leaver facts off, reg. 3 alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION] or p[TERMINAL]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and regulation_3(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: within each block of 8, a row with more facts on never + # loses the status. Rows are ordered (route, extension, terminal). + blocks = status.reshape(-1, 8) + for less, more in ( + (0, 1), + (0, 2), + (0, 4), + (1, 3), + (1, 5), + (2, 3), + (2, 6), + (4, 5), + (4, 6), + (3, 7), + (5, 7), + (6, 7), + ): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4. + np.testing.assert_array_equal( + sim.calculate("is_child_benefit_payable_for_person", YEAR)[1::2], + [payable(p) for p in GRID], + ) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_benefit", YEAR + )[1::2] + legacy = sim.calculate("is_child_or_young_person_for_legacy_benefits", YEAR)[1::2] + np.testing.assert_array_equal(family, (age < 16) | status) + np.testing.assert_array_equal(legacy, family) + barred = family & ~np.array([payable(p) for p in GRID]) + # Not vacuous: some young persons are barred, all of them 16 or over, + # before the terminal date and working 24 hours. + assert barred.any() + assert all( + p[TERMINAL] and p["weekly_hours"] == 24 and p["age"] >= 16 + for p, b in zip(GRID, barred) + if b + ) + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans(), TERMINAL: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + return [dict(p, is_claimant_or_partner=True) for p in claimants] + others + + +@SETTINGS +@given(st.lists(benefit_units(), min_size=1, max_size=6), st.sampled_from([2024, 2026])) +def test_child_benefit_entitlement_counts_payable_members(units, year): + # Invariant 5, from the inputs and the weekly rates alone. + sim = simulate(units, year) + rates = sim.tax_benefit_system.parameters(f"{year}-06-01").gov.hmrc.child_benefit + entitlement = sim.calculate("child_benefit_entitlement", year) + for unit, actual in zip(units, entitlement): + number = sum(payable(p) for p in unit) + weekly = ( + 0 + if number == 0 + else rates.amount.eldest + (number - 1) * rates.amount.additional + ) + np.testing.assert_allclose(actual, weekly * 52, rtol=1e-6) + + +# --- Invariants 6 and 7: Income Support in the young person's own right --- + +INCOME_SUPPORT = "receives_income_support_in_own_right" +OWN_REPORTS = ( + "universal_credit_reported", + "esa_income_reported", + "esa_contrib_reported", + "jsa_income_reported", + "jsa_contrib_reported", +) + + +def family_member(age, **inputs): + defaults = { + "income_support_reported": 0, + "care_hours": 0, + ROUTE: False, + **{report: 0 for report in OWN_REPORTS}, + } + return person(age, **{**defaults, **inputs}) + + +young_or_child = st.builds( + family_member, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + is_apprentice=st.booleans(), + receives_benefits_in_own_right=st.sampled_from([False, False, False, True]), + weekly_hours=st.sampled_from([0, 23, 24, 40]), + care_hours=st.sampled_from([0, 35]), + income_support_reported=st.sampled_from([0, 0, 1, 3_000]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans(), TERMINAL: st.booleans()}, + **{report: st.sampled_from([0, 0, 0, 2_000]) for report in OWN_REPORTS}, +) +# A parent who can claim Income Support for the family: a carer, or a lone +# parent of a child under 5 when the draw gives them one. +parent = st.builds( + family_member, + st.integers(25, 60), + is_claimant_or_partner=st.just(True), + care_hours=st.sampled_from([0, 35]), + income_support_reported=st.sampled_from([0, 3_000]), +) + + +@st.composite +def families(draw): + if draw(st.booleans()): + parents = draw(st.lists(parent, min_size=1, max_size=2)) + return parents + draw(st.lists(young_or_child, min_size=1, max_size=4)) + # A young person who claims for their own benefit unit. + young = draw(young_or_child.filter(lambda p: p["age"] >= 16)) + others = draw(st.lists(young_or_child, max_size=2)) + return [dict(young, is_claimant_or_partner=True)] + others + + +def case_19_family(): + # child_benefit_school_leaver_routes.yaml Case 19: a 16-year-old school + # leaver, in the reg. 4 period, who cares for a parent and reports their + # own Income Support. + return [ + family_member(40, is_claimant_or_partner=True), + family_member( + 16, care_hours=35, income_support_reported=3_000, **{ROUTE: True} + ), + ] + + +@SETTINGS +@given( + st.lists(families(), min_size=1, max_size=6), st.sampled_from([2016, 2019, 2024]) +) +def test_own_income_support_ends_the_status_on_every_route(units, year): + # Every draw also holds Case 19's family, so there is a status to lose. + units = units + [case_19_family()] + reported = np.array( + [p["income_support_reported"] > 0 for unit in units for p in unit] + ) + sim = simulate(units, year) + # The family's Income Support reads this test through the legacy young + # person; calculating it first shows the test does not read it back. + sim.calculate("income_support", year) + status = sim.calculate(QYP, year) + # Invariant 6, in 2016, 2019 and 2024, when DWP paid Income Support. + assert not status[reported].any() + released = simulate( + [[dict(p, **{INCOME_SUPPORT: False}) for p in unit] for unit in units], year + ).calculate(QYP, year) + unreported = simulate( + [[dict(p, income_support_reported=0) for p in unit] for unit in units], year + ).calculate(QYP, year) + # Invariant 7. + np.testing.assert_array_equal(released[~reported], status[~reported]) + np.testing.assert_array_equal(released, unreported) + # Case 19's 16-year-old has the status without the report, and loses it + # to the report alone. + assert released[-1] and not status[-1] diff --git a/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py new file mode 100644 index 0000000000..5796d5c8e0 --- /dev/null +++ b/policyengine_uk/tests/test_child_tax_credit_school_leaver_routes_properties.py @@ -0,0 +1,430 @@ +"""Child Tax Credit qualifying young persons who have left education. + +Child Tax Credit Regulations 2002: + +- Reg. 5(3)(a), (ab), (3A): the first condition is full-time non-advanced + education or approved training, begun before 19. +- Reg. 4(1)-(2): from the 16th birthday until the 31 August that next + follows, the first condition is not required. Reg. 4(1A): a 16th birthday + on 31 August gets no such period. +- Reg. 5(3)(b): under 18, the 20 weeks after leaving while registered for + work or training satisfy the first condition. +- Reg. 5(4), the second condition, always applies: a person who has ceased + full-time education or approved training must not be in remunerative work + (24 hours a week or more, reg. 2(1)), and no one may be receiving a benefit + in their own right. Full-time education at any level counts (reg. 5(5)), so + an advanced student has not ceased, nor has an apprentice still on a + course: the employment exclusion belongs to the first condition. + +Invariants: + +1. Reg. 4, against calendar arithmetic that shares no code with the model: + a person outside education, not working, is a qualifying young person for + a year exactly when they are 16 to 19 on 6 October and some of the year, + from 6 April, falls on or before the last day they are a child or in the + reg. 4 period. For a 16th birthday on 31 August that day is 30 August. +2. Over every combination of age, education, training, apprenticeship, + own-right benefits, the age the course began, the two leaver facts and + hours: the status is the education condition; or the reg. 4 period, + unless the person has ceased education or training and is in remunerative + work; or the 20 weeks for a person under 18 who has ceased and is not in + remunerative work; within ages 16 to 19 and without own-right benefits. + So remunerative work ends both routes for a person who has ceased and + never a student's status, an advanced student keeps the reg. 4 period + whatever their hours, the 20 weeks end at 18 and never reach a student + whose course began at 19, and with the leaver facts off the status is the + education condition's alone, as before. +3. Switching a leaver fact on never removes the status. +4. The benefit unit's child or qualifying young person is a child or + qualifying young person who is not its claimant or partner. +5. Reg. 5(4)(c), which reg. 4(2) keeps for the reg. 4 period: a member who + is not the claimant or partner and reports Income Support of their own + while it is in payment is never a qualifying young person, on any route; + once Income Support has stopped paying (from April 2026) or a reform + neutralises it, the report does not change the status. +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +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.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2024 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +ROUTE = "is_before_first_september_following_16th_birthday" +EXTENSION = "is_in_child_tax_credit_extension_period" +QYP = "is_qualifying_young_person_for_child_tax_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "is_looked_after_by_local_authority": False, + "age_started_or_accepted_current_education_or_training": 17, + "weekly_hours": 0, + EXTENSION: False, + "is_claimant_or_partner": False, + "income_support_reported": 0, + **inputs, + } + + +def simulate(units, year=YEAR): + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for i, unit in enumerate(units): + names = [] + for j, inputs in enumerate(unit): + name = f"p{i}_{j}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def last_day_as_child_or_in_regulation_4_period(born): + # Reg. 4(1): "until 31st August which next follows" the 16th birthday. + # Reg. 4(1A): a 16th birthday on 31 August starts no such period, so the + # last day covered is the day before it, the last day under 16. + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/4 + if (born.month, born.day) == (8, 31): + return date(born.year + 16, 8, 30) + return date(born.year + 16 + ((born.month, born.day) > (8, 31)), 8, 31) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_regulation_4_matches_calendar(births, year): + units = [ + [ + person(40, is_claimant_or_partner=True, date_of_birth=0), + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")), + ] + for born in births + ] + expected = [ + 16 <= age_on_6_october(born, year) < 20 + and date(year, 4, 6) <= last_day_as_child_or_in_regulation_4_period(born) + for born in births + ] + actual = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal(actual, expected) + return expected + + +def test_regulation_4_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 14 to 20 on 6 October 2024. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = assert_regulation_4_matches_calendar(births, YEAR) + by_birth = dict(zip(births, expected)) + assert by_birth[date(2008, 6, 1)] and not by_birth[date(2007, 6, 1)] + # 16 on 31 August 2023: reg. 4(1A) gives no period to 31 August 2024. + assert not by_birth[date(2007, 8, 31)] + # 16 on 31 August 2024: a child until 30 August 2024, in the year. + assert by_birth[date(2008, 8, 31)] + # 16 on 1 September 2023: the period runs to 31 August 2024. + assert by_birth[date(2007, 9, 1)] + # 16 on 6 October 2024 is 16 for the year; 16 on 7 October is still 15. + assert by_birth[date(2008, 10, 6)] and not by_birth[date(2008, 10, 7)] + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + # From 2015: the status reads is_on_income_support, and Income Support + # reads Pension Credit's mixed-age couple date, which has no value + # before 2015 (child_tax_credit fails there already). + st.integers(2015, 2031), +) +def test_regulation_4_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_regulation_4_matches_calendar(births, year) + + +INCOME_SUPPORT_IN_PAYMENT = {2018: True, 2024: True, 2026: False} + + +def qualifying_young_person_given_income_support(p, year): + # Reg. 5(4)(c): a member who is not the claimant or partner receives + # Income Support on their own report while Income Support is paid. + own_income_support = ( + INCOME_SUPPORT_IN_PAYMENT[year] + and not p["is_claimant_or_partner"] + and p["income_support_reported"] > 0 + ) + return qualifying_young_person(p) and not own_income_support + + +def test_income_support_ends_every_route(): + # Invariant 5 on every grid row that qualifies without Income Support. + qualifying = [p for p in GRID if qualifying_young_person(p)] + reporting = [dict(p, income_support_reported=3_000) for p in qualifying] + routes = { + "education": [first_condition_through_education(p) for p in qualifying], + "regulation 4": [ + p[ROUTE] and not first_condition_through_education(p) for p in qualifying + ], + "20 weeks": [ + p[EXTENSION] and not p[ROUTE] and not first_condition_through_education(p) + for p in qualifying + ], + } + assert all(any(rows) for rows in routes.values()) + for year in INCOME_SUPPORT_IN_PAYMENT: + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] + for p in reporting + ] + status = simulate(units, year).calculate(QYP, year)[1::2] + np.testing.assert_array_equal( + status, + [qualifying_young_person_given_income_support(p, year) for p in reporting], + ) + if INCOME_SUPPORT_IN_PAYMENT[year]: + assert not status.any(), year + else: + assert status.all(), year + + +def test_income_support_does_not_end_the_status_once_neutralised(): + # Invariant 5: a reform that removes Income Support leaves no award. + rows = [ + person(16, income_support_reported=3_000, **{ROUTE: True}), + person( + 17, + current_education="UPPER_SECONDARY", + income_support_reported=3_000, + **{ROUTE: False}, + ), + person(17, income_support_reported=3_000, **{ROUTE: False, EXTENSION: True}), + ] + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in rows + ] + assert not simulate(units, 2018).calculate(QYP, 2018)[1::2].any() + removed = simulate(units, 2018) + removed.tax_benefit_system.neutralize_variable("income_support") + removed.delete_arrays("income_support") + assert removed.calculate(QYP, 2018)[1::2].all() + + +def receiving_education_or_training(p): + # Education status stands in for full-time non-advanced education that is + # not by virtue of employment (reg. 5(3)(a)), or approved training ((ab)). + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def has_ceased_education_or_training(p): + # Reg. 5(4)(a), (5): full-time education at any level, an apprentice's + # course included, or approved training, has not ceased. + in_full_time_education = p["current_education"] != "NOT_IN_EDUCATION" + return not (in_full_time_education or p["is_in_approved_training"]) + + +def first_condition_through_education(p): + # Reg. 5(3A): begun before 19. + begun_before_19 = ( + p["age"] < 19 or p["age_started_or_accepted_current_education_or_training"] < 19 + ) + return receiving_education_or_training(p) and begun_before_19 + + +def qualifying_young_person(p): + # https://www.legislation.gov.uk/uksi/2002/2007/regulation/5 + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if first_condition_through_education(p): + return True + ceased = has_ceased_education_or_training(p) + # Reg. 2(1): remunerative work is 24 hours a week or more. + working = p["weekly_hours"] >= 24 + # Reg. 4(2): the period needs only the second condition. + if p[ROUTE] and not (ceased and working): + return True + # Reg. 5(3)(b): under 18, having ceased, and reg. 5(4)(a). + return p["age"] < 18 and p[EXTENSION] and ceased and not working + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + weekly_hours=hours, + age_started_or_accepted_current_education_or_training=entry_age, + **{ROUTE: route, EXTENSION: extension}, + ) + for age, education, training, apprentice, own_right, hours, entry_age in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (0, 23, 24), + # A course begun at 19 fails reg. 5(3A). Such a student has not left + # education, so neither leaver fact reaches them. + (17, 19), + ) + for route, extension in product((False, True), repeat=2) +] + + +def test_status_is_the_first_condition_or_a_leaver_fact_under_the_second(): + units = [ + [person(45, is_claimant_or_partner=True, **{ROUTE: False}), p] for p in GRID + ] + sim = simulate(units) + status = sim.calculate(QYP, YEAR)[1::2] + # Invariant 2. + np.testing.assert_array_equal(status, [qualifying_young_person(p) for p in GRID]) + age = np.array([p["age"] for p in GRID]) + hours = np.array([p["weekly_hours"] for p in GRID]) + student = np.array([receiving_education_or_training(p) for p in GRID]) + ceased = np.array([has_ceased_education_or_training(p) for p in GRID]) + route = np.array([p[ROUTE] for p in GRID]) + own_right = np.array([p["receives_benefits_in_own_right"] for p in GRID]) + assert not status[(age < 16) | (age >= 20)].any() + assert not status[own_right].any() + # A student whose course began at 19 does not qualify through the 20 + # weeks, which need them to have ceased. (The grid sets the reg. 4 fact + # directly; from a date of birth it is never true at 19.) + late_student = np.array( + [ + receiving_education_or_training(p) + and p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + and not p[ROUTE] + for p in GRID + ] + ) + assert late_student.any() and not status[late_student].any() + # Remunerative work ends the status of a person who has ceased education + # or training, and not a student's. + assert not status[ceased & (hours >= 24)].any() + assert status[student & (hours >= 24)].any() + # An advanced student in the reg. 4 period qualifies whatever their hours. + advanced = np.array([p["current_education"] == "TERTIARY" for p in GRID]) + in_age = (age >= 16) & (age < 20) & ~own_right + advanced_in_route = advanced & ~ceased & route & in_age + assert advanced_in_route.any() and status[advanced_in_route].all() + assert (advanced_in_route & (hours >= 24)).any() + # The 20 weeks alone do not reach an 18-year-old. + alone = np.array([p[EXTENSION] and not p[ROUTE] for p in GRID]) + assert not status[alone & ~student & (age >= 18)].any() + assert status[alone & ~student & (age == 17)].any() + # With the leaver facts off, the education condition alone, as before. + off = np.array([not (p[ROUTE] or p[EXTENSION]) for p in GRID]) + np.testing.assert_array_equal( + status[off], + [ + 16 <= p["age"] < 20 + and not p["receives_benefits_in_own_right"] + and first_condition_through_education(p) + for p, o in zip(GRID, off) + if o + ], + ) + # Invariant 3: rows are ordered (route, extension) within blocks of 4. + blocks = status.reshape(-1, 4) + for less, more in ((0, 1), (0, 2), (1, 3), (2, 3)): + assert (blocks[:, more] >= blocks[:, less]).all(), (less, more) + # Invariant 4, for members who are not the claimant. + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", YEAR + ) + np.testing.assert_array_equal(family[1::2], (age < 16) | status) + assert not family[0::2].any() + + +member = st.builds( + person, + st.integers(0, 21), + current_education=st.sampled_from(EDUCATIONS), + is_in_approved_training=st.booleans(), + receives_benefits_in_own_right=st.booleans(), + is_looked_after_by_local_authority=st.booleans(), + weekly_hours=st.sampled_from([0, 10, 23, 24, 40]), + income_support_reported=st.sampled_from([0, 0, 3_000]), + **{ROUTE: st.booleans(), EXTENSION: st.booleans()}, +) + + +@st.composite +def benefit_units(draw): + claimants = draw(st.lists(member, min_size=1, max_size=2)) + others = draw(st.lists(member, max_size=4)) + # Claimants report no Income Support, so the family claims none and the + # model calculates no award; another member's report is their own claim. + return [ + dict(p, is_claimant_or_partner=True, income_support_reported=0) + for p in claimants + ] + others + + +@SETTINGS +@given( + st.lists(benefit_units(), min_size=1, max_size=6), + st.sampled_from(sorted(INCOME_SUPPORT_IN_PAYMENT)), +) +def test_family_members_in_any_benefit_unit(units, year): + # Invariants 2, 4 and 5 on benefit units of any shape, where a claimant or + # partner can be a qualifying young person by status. + sim = simulate(units, year) + people = [p for unit in units for p in unit] + status = sim.calculate(QYP, year) + expected_status = [ + qualifying_young_person_given_income_support(p, year) for p in people + ] + np.testing.assert_array_equal(status, expected_status) + family = sim.calculate( + "is_child_or_qualifying_young_person_for_child_tax_credit", year + ) + expected = [ + (p["age"] < 16 or qualifying_young_person_given_income_support(p, year)) + and not p["is_claimant_or_partner"] + and not p["is_looked_after_by_local_authority"] + for p in people + ] + np.testing.assert_array_equal(family, expected) diff --git a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py index 13ef3b526b..368a05afd9 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_applicant_properties.py @@ -73,13 +73,16 @@ ] 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. +# disability, the enhanced PIP daily living component (an enhanced disability +# premium qualifying benefit), Attendance Allowance (a severe disability +# premium qualifying benefit, which also exempts her from non-dependant +# deductions, and an enhanced disability premium one for Scottish councils +# from 2023), 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(), + pip_dl_category=st.sampled_from(["NONE", "NONE", "ENHANCED"]), attendance_allowance=st.sampled_from([0, 0, 4_000]), carers_allowance=st.sampled_from([0, 0, 4_000]), is_blind=st.booleans(), diff --git a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py index b27915d2ef..7bf05bee59 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_carer_income_properties.py @@ -13,44 +13,48 @@ Neither counts the Scottish Carer Supplement, paid with Carer Support Payment from 15 March 2026: reg 57(1) is a closed list that leaves it out, and reg 27(1)(j)(xxib) excepts it. Nor does the tax on the supplement count: at -working age SSI 2021/249 deducts income tax only from earnings (Part 6 -Chapter 3), and at pension age SSI 2012/319 reg 31(12) disregards the tax -payable on income taken into account, which the supplement is not. So in law -the supplement does not touch Council Tax Reduction at all. The model deducts -all of a family's income tax from its Council Tax Reduction income, an -existing approximation, so there the supplement, which is taxable (SI -2026/93), reaches that income through the tax charged on it and in no other -way. The tax deducted is the liability before the winter fuel payment charge -(ITEPA 2003 s. 681I), which recovers a winter payment rather than taxing the -income assessed; "income tax" below means that liability. +working age the Scottish Ministers let the council deduct income tax only from +earnings (SSI 2021/249 Part 6 Chapter 3), and at pension age the council +disregards the tax payable on income taken into account (SSI 2012/319 reg +31(12)), which the supplement is not. The supplement is taxable (SI 2026/93). +The model deducts the tax and National Insurance each scheme allows +(council_tax_reduction_tax_deduction). At working age that includes the extra +tax earnings cause, with the earnings as the top slice of income (the choice +settled for Pension Credit on PolicyEngine/policyengine-uk#2019, option (a)), +so the supplement beneath the earnings can raise the tax the earnings add. At +pension age the formula deducts the tax on the counted income with the +supplement above it. Invariants, for single people and couples in Scotland in 2026 in which one member is a carer, all of working age and taking no Universal Credit or all over State Pension age: 1. Counts the component: claiming Carer Support Payment raises Council Tax - Reduction income, before income tax and National Insurance and apart from - the other benefits it counts, by exactly the Carer Support Payment + Reduction income, before the income tax and National Insurance it deducts + and apart from the other benefits it counts, by exactly the Carer Support Payment component (86.45 a week), not by the component plus the supplement, and by that amount before any overlapping-benefit reduction (see below). The reduction itself never rises, and where both awards are partial it falls by 20% of the rise in income less the rise in the applicable amount (the carer premium comes with the claim). -2. Invariant to the supplement: setting the Scottish Carer Supplement to zero - leaves Carer Support Payment and the other benefits counted in Council - Tax Reduction income (Child Benefit, the income-related benefits, - Universal Credit and tax credits) unchanged, and changes that income only - through income tax: income plus income tax is unchanged. The supplement - never lowers the reduction, and where both awards are partial it raises - it by 20% of the tax on the supplement. +2. Invariant to the supplement, apart from the tax earnings add: setting the + Scottish Carer Supplement to zero leaves Carer Support Payment and the + other benefits counted in Council Tax Reduction income (Child Benefit, the + income-related benefits, Universal Credit and tax credits) unchanged. + Under the general rules it changes Council Tax Reduction income only by + the change in the deduction for income tax and National Insurance, and in + a family with no earnings it changes neither that income nor the + reduction. The supplement never lowers the reduction. 3. Differential: Council Tax Reduction and Housing Benefit assess the same carer income. Where Guarantee Credit does not passport Housing Benefit, Housing Benefit applicable income, before its disregard, childcare element and tariff income, equals Council Tax Reduction income less the part of Carer Support Payment that an overlapping benefit removes, which Housing - Benefit counts after the reduction (HB (SPC) Regs reg 29(4)(g)). This holds in - Scotland, where the carer's benefit is Carer Support Payment, and in - England and Wales, where it is Carer's Allowance. + Benefit counts after the reduction (HB (SPC) Regs reg 29(4)(g)), and less + the income tax and National Insurance that Housing Benefit deducts and + Council Tax Reduction does not. This holds in Scotland, where the carer's + benefit is Carer Support Payment, and in England and Wales, where it is + Carer's Allowance. Invariants 1 to 3 concern Council Tax Reduction income under the general rules. A pension-age family in receipt of a guarantee credit has its whole income @@ -128,9 +132,11 @@ str(YEAR) ).gov.local_authorities.scotland.council_tax_reduction SCOTTISH_CARER_PREMIUM = _SCOTTISH_CTR.working_age.adult_premiums.carer_premium * WEEKS -# The income tax Council Tax Reduction income deducts: the liability before -# the winter fuel payment charge. +# The income tax Housing Benefit deducts: the liability before the winter fuel +# payment charge. INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +# The income tax and National Insurance Council Tax Reduction income deducts. +CTR_TAX = "council_tax_reduction_tax_deduction" PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -158,6 +164,7 @@ ] BENUNIT_VARIABLES = [ "council_tax_reduction_applicable_income", + CTR_TAX, "council_tax_reduction_applicable_amount", "council_tax_benefit", "guarantee_credit", @@ -297,8 +304,7 @@ def grid(families, reform=None): # from the other benefits it counts. g["income_before_tax"] = ( g["council_tax_reduction_applicable_income"] - + g[INCOME_TAX] - + g["national_insurance"] + + g[CTR_TAX] - g["other_counted_benefits"] ) # Cells whose Council Tax Reduction income follows the general rules, not @@ -481,33 +487,32 @@ def test_council_tax_reduction_income_ignores_the_scottish_carer_supplement( without[variable][i][same_earnings], atol=0.01, ), (fam, variable) - # Invariant 2 on the shared formula: both runs on it, and only where - # the zero floor on income does not bind in either run and both runs - # follow the general rules. + # Invariant 2 on the shared formula: both runs on it and following the + # general rules. shared = g["shared"][i] & without["shared"][i] income = g["council_tax_reduction_applicable_income"][i] income_without = without["council_tax_reduction_applicable_income"][i] general = g["general_rules"][i] & without["general_rules"][i] & shared - compared = (income > 0) & (income_without > 0) & general - assert compared.any() or not shared.any(), fam + assert general.any() or not shared.any(), fam + # The supplement reaches the income only through the deduction. assert np.allclose( - (income + g[INCOME_TAX][i])[compared], - (income_without + without[INCOME_TAX][i])[compared], - atol=0.05, - ), fam + (income + g[CTR_TAX][i])[general], + (income_without + without[CTR_TAX][i])[general], + atol=0.01, + ), (fam, income[general], income_without[general]) + has_earnings = any(a.get("employment_income", 0.0) > 0 for a in fam["adults"]) award = g["council_tax_benefit"][i] award_without = without["council_tax_benefit"][i] - # The supplement can only add tax, which lowers income, so it never - # lowers the award, even on the Pension Credit routes (whose income - # follows invariant 4). + # The supplement never lowers the award, even on the Pension Credit + # routes (whose income follows invariant 4). assert np.all(award >= award_without - 0.01), fam - both_partial = partial(g, i) & partial(without, i) & general - tax_on_supplement = g[INCOME_TAX][i] - without[INCOME_TAX][i] - assert np.allclose( - (award - award_without)[both_partial], - WITHDRAWAL_RATE * tax_on_supplement[both_partial], - atol=0.05, - ), fam + if not has_earnings: + assert np.allclose(income[general], income_without[general], atol=0.01), ( + fam, + income[general], + income_without[general], + ) + assert np.allclose(award[general], award_without[general], atol=0.01), fam # Without a claim there is no supplement, so nothing moves. assert np.allclose( income[:, 1][shared[:, 1]], income_without[:, 1][shared[:, 1]], atol=0.01 @@ -547,7 +552,12 @@ def test_council_tax_reduction_and_housing_benefit_assess_the_same_carer_income( + g["housing_benefit_applicable_income_childcare_element"][i] - g["housing_benefit_tariff_income"][i] ) - ctr_less_overlap = ctr_income - removed_by_overlap + # Housing Benefit deducts the whole liability; Council Tax Reduction + # the tax and National Insurance its scheme allows. + tax_only_hb_deducts = ( + g[INCOME_TAX][i] + g["national_insurance"][i] - g[CTR_TAX][i] + ) + ctr_less_overlap = ctr_income - removed_by_overlap - tax_only_hb_deducts assert np.allclose( hb_before_adjustments[compared], ctr_less_overlap[compared], atol=0.05 ), (fam, hb_before_adjustments[compared], ctr_less_overlap[compared]) diff --git a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py index 37e6168269..67556a7aaf 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_claimant_properties.py @@ -33,10 +33,13 @@ 3. Simulated reductions: no family outside the claimants gets one, and a household's total never exceeds its council tax. (Reported reductions, used where a scheme is not modelled, are outside this test.) -4. Non-dependants: no claimant or partner of a claimant family is a - non-dependant (the applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: no - member of a sharer family is one, and every adult outside the claimant - families, the sharer families and boarders' and lodgers' families is one. +4. Non-dependants: no applicant or partner of a claimant family + (is_council_tax_reduction_applicant_or_partner) is a non-dependant (the + applicant's family, SI 2012/2885 reg 9(2)(a)). Pin: no member of a sharer + family is one except a member outside its applicant's family + (is_benefit_unit_non_dependant_for_council_tax_reduction), and every + adult outside the claimant families, the sharer families and boarders' + and lodgers' families is one. 5. Differential: where the input flags exactly one member of a household, or no household in the simulation has the input (so the flag's formula picks the eldest), the family holding the head is the one holding the @@ -318,8 +321,10 @@ def test_claimant_invariants(population): person_sharer = sharer[facts["benunit"]] lodger = calc(sim, "pays_rent_to_householder") adult = age >= 18 - assert not np.any(non_dep & person_claimant & claimant_or_partner) - assert not np.any(non_dep & person_sharer) + applicant_or_partner = calc(sim, "is_council_tax_reduction_applicant_or_partner") + in_unit = calc(sim, "is_benefit_unit_non_dependant_for_council_tax_reduction") + assert not np.any(non_dep & person_claimant & applicant_or_partner) + assert not np.any(non_dep & person_sharer & ~in_unit) assert np.all(non_dep[adult & ~person_claimant & ~person_sharer & ~lodger]) # 5. Differential with the person-level household head flag. Once anyone in diff --git a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py index 6a28465c01..fb5982d1e4 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_devolved_working_age_properties.py @@ -33,6 +33,11 @@ disregard: the £17.10 additional disregard needs earnings that cover the charges, and from April 2022 only employed earnings qualify, so the disregard never changes for a family with no employed earnings. +7. For a family whose taxed income is pay and a private pension, the tax the + route takes on earnings (uc_income_tax_on_earnings) and the tax it takes on + counted unearned income (council_tax_reduction_working_age_unearned_income_tax) + add to the family's income tax, so a Step 6 tax reduction neither disappears + nor counts twice. Each example puts every family and its variants in one simulation, so the formulas also run on arrays with several benefit units and households. @@ -177,6 +182,54 @@ def test_income_related_esa_passports_to_the_full_liability(units): assert np.allclose(award, liability, atol=0.01) +@SETTINGS +@given( + st.lists( + st.tuples( + st.sampled_from(REGIONS), + st.booleans(), + st.integers(0, 60_000), + st.integers(0, 40_000), + ), + min_size=1, + max_size=8, + ) +) +def test_tax_on_earnings_and_on_a_pension_add_to_the_income_tax(rows): + # Invariant 7. A partner with no income gives a basic-rate earner Marriage + # Allowance, a Step 6 tax reduction. + units = [] + for region, couple, pay, pension in rows: + people = [ + { + "age": 40, + "employment_income": pay, + "private_pension_income": pension, + "council_tax_benefit_reported": 100, + } + ] + if couple: + people.append({"age": 40}) + units.append( + { + "region": region, + "people": people, + "would_claim_uc": False, + "rent": 0, + "council_tax": 1_800, + "savings": 0, + "esa_income": 0, + } + ) + sim = Simulation(situation=situation(units)) + income_tax = sim.calculate("income_tax", YEAR, map_to="benunit") + on_earnings = sim.calculate("uc_income_tax_on_earnings", YEAR, map_to="benunit") + on_unearned = sim.calculate( + "council_tax_reduction_working_age_unearned_income_tax", YEAR + ) + np.testing.assert_allclose(on_earnings + on_unearned, income_tax, atol=0.01) + + def welsh_universal_credit_family(earnings, rent, statutory_maternity_pay=0): return { "region": "WALES", 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 6a5bebcbaa..9e3e6f5935 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 @@ -342,12 +342,16 @@ def situation( "esa_income", "universal_credit", ] -# The means tests deduct the income tax before the winter fuel payment charge -# (ITEPA 2003 s.681I), less the tax on income they do not take into account -# due without earnings (legacy_means_test_deducted_income_tax): the charge -# recovers a payment rather than taxing the income they assess. A pensioner -# over £35,000 of total income is charged it. -MAIN_DEDUCTIONS = ["legacy_means_test_deducted_income_tax", "national_insurance"] +# The income tax and National Insurance each scheme allows +# (council_tax_reduction_tax_deduction), a benefit unit total for the +# applicant and partner. It starts from income tax before the winter fuel +# payment charge (ITEPA 2003 s.681I), because the charge recovers a payment +# rather than taxing the income the schemes assess; a pensioner over £35,000 +# of total income pays it. The deduction's own tests +# (test_council_tax_reduction_tax_deduction_properties.py and +# council_tax_reduction_tax_deduction.yaml) check it against the law; this file +# checks the routes around it. +MAIN_DEDUCTIONS = ["council_tax_reduction_tax_deduction"] # The pensioner earnings disregards in 2026, written out from the law, not # read from the parameters: £25 a week for a lone parent, £10 with a partner, # £5 otherwise, plus £17.10 (SI 2012/2885 Sch 4 paras 2, 8 and 10; WSI @@ -430,7 +434,7 @@ def benunit_total(names): benunit_total(["child_benefit"]), 0, ) - - benunit_total(MAIN_DEDUCTIONS) + - sum(np.asarray(sim.calculate(v, YEAR))[claimants] for v in MAIN_DEDUCTIONS) - 0.5 * benunit_total(["pension_contributions"]) - pensioner_earnings_disregard(sim, claimants), ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py b/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py index 15c9896e4c..fcfd1a8dff 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_pensioner_children_tariff_properties.py @@ -362,7 +362,12 @@ def test_northern_ireland_gets_no_new_amounts(): (14_071.2, 17_170, 1_380.24), (9_412, 9_592.34, 1_963.93), (3_801.2, 16_080.24, 889.22), - (5_972.2, 24_674.64, 460.78), + # The Scottish couple on 25,000 of pay. The Marriage Allowance comes + # off the tax at Step 6, so taxable income stays 12,500 and its last + # 350 reach the 21% band from 12,150: 380 + 2,030 + 73.50 - 250 = + # 2,233.50 of tax. With the allowance off taxable income the tax was + # 2,230, so income falls by 3.50 and the award rises by 20% of that. + (5_972.2, 24_671.14, 461.48), (3_801.2, 9_371.84, 937.87), (9_412, 12_000, 1_482.4), (14_071.2, 17_150, 1_384.24), 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 index 4334138476..f2e0d4279e 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py +++ b/policyengine_uk/tests/test_council_tax_reduction_severe_disability_family.py @@ -1,10 +1,11 @@ """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. +young person under UC Regs 2013 reg. 5 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. @@ -31,7 +32,9 @@ def input_grid(rows, country="SCOTLAND", year=2026, applicant_age=45): "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"], + # SSI 2021/249 reg. 6(1) reads UC Regs reg. 5, without SI + # 2014/1230 reg. 28. + "is_qualifying_young_person_under_universal_credit_regulation_5": 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", diff --git a/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py new file mode 100644 index 0000000000..113ddfce0d --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_tax_deduction_properties.py @@ -0,0 +1,731 @@ +"""Income tax and National Insurance deducted from Council Tax Reduction income. + +In Scotland's working-age scheme (the Council Tax Reduction (Scotland) +Regulations 2021, SSI 2021/249), the Scottish Ministers let the council take +income tax and primary Class 1 contributions off earnings only (regs 49(6)(b), +50(3)(a) and 51(2)), and give no tax deduction against unearned income (regs 57 +to 65). Without Universal Credit, in reg 51(9) they apply the notional tax and +contributions of SSI 2012/303 reg 38 to self-employed profit. In the pensioner +schemes, the council disregards tax only on income taken into account and on +earnings: SSI 2012/319 regs 31(12), 33(2)(a) and 37 in Scotland, SI 2012/2885 +Sch 1 paras 17(13), 19(2)(a) and 30 in England, and WSI 2013/3029 Sch 1 paras +11(13), 13(2)(a) and 24 in Wales. + +No provision says how to split one income tax liability. The model follows +the choice settled for Pension Credit on PolicyEngine/policyengine-uk#2019 +(option (a)): the tax on earnings is the extra tax the earnings cause, with +the earnings as the top slice of income. council_tax_reduction_tax_deduction +applies these rules, and council_tax_reduction_applicable_income deducts it. +In the English and Welsh working-age schemes, the formula deducts what the +legacy means tests deduct (legacy_means_test_deducted_income_tax). + +Each test builds households by hand in 2026, states its invariant in its +docstring, and records the scheme of each household it draws as a Hypothesis +event, so that `--hypothesis-show-statistics` counts the examples per scheme. +""" + +import numpy as np +from hypothesis import HealthCheck, event, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +REGIONS = {"ENGLAND": "NORTH_WEST", "WALES": "WALES", "SCOTLAND": "SCOTLAND"} +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DEDUCTION = "council_tax_reduction_tax_deduction" +INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +NO_SCOTTISH_CARER_SUPPLEMENT = { + "gov.social_security_scotland.carer_support_payment.supplement": { + "2026-01-01.2100-12-31": 0 + } +} +# Taxed income the pensioner schemes do not count, as inputs. +UNCOUNTED = [ + "savings_interest_income", + "other_investment_income", + "dividend_income", + "miscellaneous_income", + "company_car_benefit", +] +# The same with the profits of a property business, which the strategies below +# draw on their own. +PENSIONER_UNCOUNTED = UNCOUNTED + ["property_income"] +# Taxed income other than earnings that Scotland's working-age scheme counts +# or treats as capital. +UNEARNED = [ + "private_pension_income", + "property_income", + "savings_interest_income", + "dividend_income", + "miscellaneous_income", +] + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False) + + +def maybe(high): + return st.one_of(st.just(0.0), money(high)) + + +def simulate(households, reform=None): + """One simulation of households. Each household is a dict with country, + benunits (a list of lists of person input dicts; the first benunit is + the Council Tax Reduction applicant's) and, optionally, uc, + child_benefit (whether the applicant's benefit unit claims Child + Benefit) and council_tax (2,000 where the household gives none).""" + situation = {"people": {}, "benunits": {}, "households": {}} + for h, household in enumerate(households): + names = [] + for b, adults in enumerate(household["benunits"]): + members = [] + for p, inputs in enumerate(adults): + name = f"p{h}_{b}_{p}" + situation["people"][name] = {k: {YEAR: v} for k, v in inputs.items()} + members.append(name) + situation["benunits"][f"b{h}_{b}"] = { + "members": members, + "would_claim_uc": {YEAR: household.get("uc", False)}, + } + if b == 0 and "child_benefit" in household: + situation["benunits"][f"b{h}_{b}"]["would_claim_child_benefit"] = { + YEAR: household["child_benefit"] + } + names += members + country = household["country"] + situation["households"][f"h{h}"] = { + "members": names, + "country": {YEAR: country}, + "region": {YEAR: REGIONS[country]}, + "council_tax": {YEAR: household.get("council_tax", 2_000.0)}, + "savings": {YEAR: 0.0}, + } + return Simulation(situation=situation, reform=reform) + + +def benunit_values(simulation, variable): + return np.asarray(simulation.calculate(variable, YEAR)) + + +def applicant_total(simulation, variable): + """The Council Tax Reduction applicant's and partner's total, by benunit.""" + values = np.asarray(simulation.calculate(variable, YEAR)) + members = np.asarray( + simulation.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + ) + return np.asarray( + simulation.map_result(values * members, "person", "benunit", how="sum") + ) + + +def scheme_of(country, pensioner, uc=False): + if pensioner: + return f"{country.lower()} pensioner" + if country == "SCOTLAND": + return "scotland working age " + ("with UC" if uc else "without UC") + return f"{country.lower()} working age" + + +@st.composite +def adult(draw, pensioner, earnings=True, self_employment=True): + person = dict( + age=draw(st.integers(67, 90)) if pensioner else draw(st.integers(25, 60)), + employment_income=draw(maybe(90_000)) if earnings else 0.0, + self_employment_income=draw(maybe(90_000)) if self_employment else 0.0, + private_pension_income=draw(maybe(60_000)), + property_income=draw(maybe(30_000)), + **{v: draw(maybe(40_000)) for v in UNCOUNTED}, + ) + if pensioner: + person["state_pension"] = draw(money(12_000)) + return person + + +@st.composite +def household(draw, countries=tuple(REGIONS), pensioner=None, **kwargs): + country = draw(st.sampled_from(countries)) + is_pensioner = draw(st.booleans()) if pensioner is None else pensioner + adults = draw(st.lists(adult(is_pensioner, **kwargs), min_size=1, max_size=2)) + h = dict(country=country, benunits=[adults], pensioner=is_pensioner) + if country == "SCOTLAND" and not is_pensioner and draw(st.booleans()): + # A Universal Credit claim with two children, and earnings, profit, + # pension and property income low enough for an award. Savings + # interest, dividends and the other uncounted income stay as drawn: + # Universal Credit does not count them. + h["uc"] = True + for a in adults: + for variable in ["employment_income", "self_employment_income"]: + a[variable] = min(a[variable], 10_000.0) + a["private_pension_income"] = 0.0 + a["property_income"] = 0.0 + adults += [dict(age=4), dict(age=7)] + return h + + +def grown_ups(people): + """Indices of the adults among a benefit unit's members.""" + return [i for i, p in enumerate(people) if p["age"] >= 18] + + +def schemes(simulation, households): + pensioner = benunit_values(simulation, "council_tax_reduction_pensioner") + universal_credit = benunit_values(simulation, "universal_credit") > 0 + labels = [] + for i, h in enumerate(households): + assert pensioner[i] == h["pensioner"], h + labels.append(scheme_of(h["country"], pensioner[i], universal_credit[i])) + event(labels[-1]) + return labels + + +@PROPERTY_SETTINGS +@given( + st.lists( + household(countries=("SCOTLAND",), pensioner=False, self_employment=False), + min_size=1, + max_size=4, + ) +) +def test_scottish_working_age_deduction_matches_pension_credit_earnings_tax( + households, +): + """Invariant 1. At working age in Scotland without self-employment income, + the deduction less primary Class 1 contributions equals the income tax on + employed earnings that Pension Credit computes + (pension_credit_employment_income_tax), wherever the Council Tax + Reduction applicant and partner are the Pension Credit claimant and + partner.""" + simulation = simulate(households) + schemes(simulation, households) + same_people = np.asarray( + simulation.calculate("is_council_tax_reduction_applicant_or_partner", YEAR) + ) == np.asarray(simulation.calculate("is_claimant_or_partner", YEAR)) + assert same_people.all() + deduction = benunit_values(simulation, DEDUCTION) + class_1 = applicant_total(simulation, "ni_class_1_employee") + pension_credit = benunit_values(simulation, "pension_credit_employment_income_tax") + assert np.allclose(deduction - class_1, pension_credit, atol=0.01), ( + households, + deduction, + class_1, + pension_credit, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(household(pensioner=True, earnings=False), min_size=1, max_size=4), + st.sampled_from(PENSIONER_UNCOUNTED), + money(60_000), + st.booleans(), +) +def test_pensioner_deduction_ignores_uncounted_income_without_earnings( + households, uncounted, change, partner +): + """Invariant 2. In every pensioner scheme, with no employed earnings, the + deduction does not change when any uncounted income changes, for either + member of a couple. The notional amount on self-employed profit reads the + personal reliefs to which the person is entitled, which the uncounted + income can taper, so the test compares the deduction less that amount.""" + changed = [] + for h in households: + adults = [dict(a) for a in h["benunits"][0]] + who = grown_ups(adults)[-1] if partner else 0 + adults[who][uncounted] = adults[who][uncounted] + change + changed.append({**h, "benunits": [adults]}) + before = simulate(households) + after = simulate(changed) + schemes(before, households) + + def without_notional(simulation): + notional = np.where( + np.asarray([h["country"] == "SCOTLAND" for h in households], dtype=bool), + applicant_total( + simulation, "council_tax_reduction_scotland_self_employment_deductions" + ), + applicant_total(simulation, "pension_credit_self_employment_deductions"), + ) + return benunit_values(simulation, DEDUCTION) - notional + + assert np.allclose(without_notional(before), without_notional(after), atol=0.01), ( + households, + uncounted, + change, + without_notional(before), + without_notional(after), + ) + + +@st.composite +def pensioner_parent_household(draw): + """A pensioner family with one or two children and no employed earnings, + each adult drawing a private pension of up to 120,000, so that the + applicant's adjusted net income often exceeds the 60,000 at which the High + Income Child Benefit Charge starts.""" + h = draw(household(pensioner=True, earnings=False)) + adults = h["benunits"][0] + for a in adults: + a["private_pension_income"] = draw(money(120_000)) + children = [dict(age=draw(st.integers(0, 15)))] + if draw(st.booleans()): + children.append(dict(age=draw(st.integers(0, 15)))) + return {**h, "benunits": [adults + children]} + + +@PROPERTY_SETTINGS +@given(st.lists(pensioner_parent_household(), min_size=1, max_size=4)) +def test_pensioner_deduction_ignores_a_child_benefit_claim(households): + """Invariant 2, for Child Benefit. In every pensioner scheme, with no + employed earnings, the deduction does not change with a claim for Child + Benefit. The pensioner schemes do not count Child Benefit, and through the + High Income Child Benefit Charge HMRC recovers it, so the formula treats + the charge as not tax payable on income taken into account. The test + compares two simulations of the same families, one claiming Child Benefit + and one not.""" + claiming = [{**h, "child_benefit": True} for h in households] + not_claiming = [{**h, "child_benefit": False} for h in households] + with_claim = simulate(claiming) + without_claim = simulate(not_claiming) + schemes(with_claim, households) + charge = applicant_total(with_claim, "CB_HITC") + for charged in charge > 0: + event("High Income Child Benefit Charge" if charged else "no charge") + assert np.all(benunit_values(with_claim, "child_benefit") > 0) + assert np.all(benunit_values(without_claim, "child_benefit") == 0) + assert np.allclose( + benunit_values(with_claim, DEDUCTION), + benunit_values(without_claim, DEDUCTION), + atol=0.01, + ), ( + households, + charge, + benunit_values(with_claim, DEDUCTION), + benunit_values(without_claim, DEDUCTION), + ) + + +@st.composite +def any_household(draw): + return draw(household()) + + +@PROPERTY_SETTINGS +@given(st.lists(any_household(), min_size=1, max_size=4)) +def test_deduction_bounds(households): + """Invariant 3. The deduction never falls below zero. Without + self-employment income it never exceeds the applicant's and partner's + income tax before the winter fuel payment charge plus National Insurance. + With it, the notional amount on profit can take the deduction above that + liability, but never above the liability plus the notional amount.""" + simulation = simulate(households) + schemes(simulation, households) + deduction = benunit_values(simulation, DEDUCTION) + whole = applicant_total(simulation, INCOME_TAX) + applicant_total( + simulation, "national_insurance" + ) + profit = applicant_total(simulation, "self_employment_income") + notional = np.maximum( + applicant_total( + simulation, "council_tax_reduction_scotland_self_employment_deductions" + ), + applicant_total(simulation, "pension_credit_self_employment_deductions"), + ) + assert np.all(deduction >= 0), (households, deduction) + assert np.all((deduction <= whole + 0.01)[profit == 0]), ( + households, + deduction, + whole, + ) + assert np.all(deduction <= whole + notional + 0.01), ( + households, + deduction, + whole, + notional, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household( + countries=("SCOTLAND",), + pensioner=False, + earnings=False, + self_employment=False, + ), + min_size=1, + max_size=4, + ) +) +def test_scottish_working_age_deducts_nothing_without_earnings(households): + """Invariant 4. At working age in Scotland, with no earnings and no + profit, the deduction is nil, however much other taxed income there is.""" + simulation = simulate(households) + schemes(simulation, households) + deduction = benunit_values(simulation, DEDUCTION) + assert np.all(deduction == 0), (households, deduction) + + +@st.composite +def household_with_council_tax(draw, **kwargs): + """A household from household(), with council tax of up to 30,000, so that + families keep a reduction across the whole range of pay.""" + return {**draw(household(**kwargs)), "council_tax": draw(money(30_000))} + + +# The Hypothesis event invariant 5 records for each cliff or step it holds. +CROSSES = { + "marriage_allowance": "crosses the Marriage Allowance cliff", + "savings_allowance": "crosses a personal savings allowance cliff", + "CB_HITC": "crosses a High Income Child Benefit Charge step", + "personal_allowance": "crosses a personal allowance step", +} +# Invariant 5 runs these three families every time. A lone parent with two +# children whose pay of 10,000 rises by 1 takes adjusted net income from +# 64,999.50 to 65,000.50, across a step of the High Income Child Benefit +# Charge (560 to 584). +LONE_PARENT_AT_A_CHARGE_STEP = dict( + country="SCOTLAND", + pensioner=False, + council_tax=2_000.0, + benunits=[ + [ + dict( + age=40, + employment_income=10_000.0, + savings_interest_income=40_000.0, + other_investment_income=14_999.50, + ), + dict(age=4), + dict(age=7), + ] + ], +) +# A single person with pay of 90,000 and savings interest of 20,001.75, whose +# pay rises by 0.50, takes adjusted net income across 110,002, where the +# tapered personal allowance falls from 7,570 to 7,569. +SINGLE_PERSON_AT_A_PERSONAL_ALLOWANCE_STEP = dict( + country="SCOTLAND", + pensioner=False, + council_tax=30_000.0, + benunits=[ + [dict(age=40, employment_income=90_000.0, savings_interest_income=20_001.75)] + ], +) +# A single person whose pay rises from 13,000 to 14,000, within the Scottish +# starter band: the deduction rises by 190 of income tax and 80 of Class 1. +SINGLE_PERSON_IN_THE_STARTER_BAND = dict( + country="SCOTLAND", + pensioner=False, + council_tax=2_000.0, + benunits=[[dict(age=40, employment_income=13_000.0)]], +) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household_with_council_tax(countries=("SCOTLAND",), pensioner=False), + min_size=1, + max_size=4, + ), + money(20_000), + st.booleans(), +) +@example(households=[LONE_PARENT_AT_A_CHARGE_STEP], rise=1.0, partner=False) +@example( + households=[SINGLE_PERSON_AT_A_PERSONAL_ALLOWANCE_STEP], rise=0.5, partner=False +) +@example(households=[SINGLE_PERSON_IN_THE_STARTER_BAND], rise=1_000.0, partner=False) +def test_scottish_working_age_reduction_never_rises_with_pay(households, rise, partner): + """Invariant 5. At working age in Scotland without Universal Credit, + raising either member's employment income never raises Council Tax + Reduction once the test holds the income tax system's cliffs and steps + still. Option (a) passes each of them through to the deduction, and each + can raise the tax the earnings add by more than the rise in pay: + - Marriage Allowance: an earner who enters the higher band loses the + allowance, which the model takes off the tax at Step 6. A Scottish + couple, one partner with no income and council tax of 15,000, whose pay + rises from 43,661 to 43,663 loses 1,260 of it: the deduction rises by + 252.79 and the reduction by 50.16 (the YAML cases pin both). + - The personal savings allowance: it falls from 1,000 to 500 as a person + enters the higher band, and to nil in the additional band, so a pound + of pay can bring 500 of savings income into tax at 40%, adding 200. + - The High Income Child Benefit Charge: the model charges a whole + percentage point of the Child Benefit for each whole 200 of adjusted + net income above 60,000, so a pound of pay that crosses a step raises + the charge by a percent of the Child Benefit, 23 or 24 pounds for two + children. + - The personal allowance: the model rounds the tapered allowance up to a + whole pound, so a rise in pay of less than 2 can lower the allowance by + a pound, and the tax then rises on the rise plus that pound. + The test holds marriage_allowance, savings_allowance, CB_HITC and + personal_allowance at their values before the rise in two more + simulations, one of the families before the rise and one of the raised + families, and asserts, for every family, that the reduction in the second + does not exceed the reduction in the first. Because + income_tax_with_inputs_zeroed keeps stored inputs on its branches, the + branch without the pay then reads the same held values on both sides. The + test draws council tax of up to 30,000, and always runs three families: + a lone parent who crosses a step of the charge, a single person who + crosses a step of the personal allowance, and a single person in the + starter band.""" + raised = [] + for h in households: + adults = [dict(a) for a in h["benunits"][0]] + who = grown_ups(adults)[-1] if partner else 0 + adults[who]["employment_income"] += rise + raised.append({**h, "uc": False, "benunits": [adults]}) + # The invariant is for families without Universal Credit. + households = [{**h, "uc": False} for h in households] + before = simulate(households) + after = simulate(raised) + # income_tax_with_inputs_zeroed keeps stored inputs on its branches, so + # hold the cliffs and steps on both sides. + held_before = simulate(households) + held = simulate(raised) + cliffs = [ + "marriage_allowance", + "savings_allowance", + "CB_HITC", + "personal_allowance", + ] + for variable in cliffs: + values = np.asarray(before.calculate(variable, YEAR)) + held_before.set_input(variable, YEAR, values) + held.set_input(variable, YEAR, values) + schemes(before, households) + reduction_held_before = np.asarray( + held_before.calculate("council_tax_reduction", YEAR) + ) + reduction_held = np.asarray(held.calculate("council_tax_reduction", YEAR)) + # The model moves each of these in steps, so any change crosses one. + crossed_any = np.zeros(len(households), dtype=bool) + for variable in cliffs: + crossed = ~np.isclose( + applicant_total(before, variable), applicant_total(after, variable) + ) + crossed_any |= crossed + if crossed.any(): + event(CROSSES[variable]) + if not crossed_any.all(): + event("crosses no cliff or step") + assert np.all(reduction_held <= reduction_held_before + 0.01), ( + households, + rise, + partner, + reduction_held_before, + reduction_held, + ) + + +@PROPERTY_SETTINGS +@given( + st.lists(any_household(), min_size=1, max_size=3), + adult(pensioner=False), + adult(pensioner=False), +) +def test_non_dependant_income_never_changes_the_deduction( + households, non_dependant, other_income +): + """Invariant 6. A non-dependant's income never changes the deduction: + adding a working-age adult with their own benefit unit to the household, + and changing that adult's income, leaves the applicant's deduction where + it was.""" + with_non_dependant = [ + {**h, "benunits": h["benunits"] + [[non_dependant]]} for h in households + ] + changed = [{**h, "benunits": h["benunits"] + [[other_income]]} for h in households] + alone = simulate(households) + schemes(alone, households) + first = simulate(with_non_dependant) + second = simulate(changed) + # The applicant's benunit is the first in each household. + applicant = np.arange(len(households)) * 2 + deduction = benunit_values(alone, DEDUCTION) + for simulation in (first, second): + assert np.allclose( + benunit_values(simulation, DEDUCTION)[applicant], deduction, atol=0.01 + ), (households, non_dependant, other_income) + + +CASES = [ + dict( + country="SCOTLAND", + benunits=[[dict(age=40, employment_income=30_000.0, dividend_income=9_000.0)]], + ), + dict( + country="SCOTLAND", benunits=[[dict(age=40, self_employment_income=15_000.0)]] + ), + dict( + country="SCOTLAND", + uc=True, + benunits=[ + [ + dict(age=35, self_employment_income=15_000.0), + dict(age=5), + dict(age=3), + ] + ], + ), + dict( + country="SCOTLAND", benunits=[[dict(age=70, private_pension_income=15_000.0)]] + ), + dict( + country="ENGLAND", + benunits=[[dict(age=70, employment_income=20_000.0)]], + ), + dict( + country="WALES", + benunits=[[dict(age=70, self_employment_income=60_000.0)]], + ), + dict( + country="WALES", + benunits=[[dict(age=40, employment_income=20_000.0)]], + ), +] + + +def test_council_tax_reduction_is_the_same_whichever_variable_is_calculated_first(): + """Invariant 7. Council Tax Reduction and its tax deduction come out the + same whether the deduction or the reduction is calculated first.""" + variables = [ + DEDUCTION, + "council_tax_reduction_applicable_income", + "council_tax_reduction", + ] + for case in CASES: + applicant = case["benunits"][0][0] + inputs = { + **case, + "benunits": [ + [{**applicant, "savings_interest_income": 6_559.0}] + + case["benunits"][0][1:] + ], + } + results = [] + for order in (variables, variables[::-1]): + simulation = simulate([inputs]) + results.append( + {v: float(simulation.calculate(v, YEAR).sum()) for v in order} + ) + assert results[0] == results[1], (case, results) + + +UNCOUNTED_CASES = [ + ("savings_interest_income", 10_000.0), + ("other_investment_income", 10_000.0), + ("dividend_income", 10_000.0), + ("miscellaneous_income", 10_000.0), + ("company_car_benefit", 10_000.0), + ("property_income", 10_000.0), +] + + +def test_branch_takes_each_uncounted_income_out_of_income_tax(): + """The pensioner schemes' tax on other counted income zeroes each income + they do not count, so it equals the income tax of the same person without + that income. This covers the Scottish Carer Supplement, which the model + computes from the carer's award rather than reading as an input.""" + base = dict(age=70, private_pension_income=30_000.0) + with_income = [ + dict(country="ENGLAND", benunits=[[{**base, variable: amount}]]) + for variable, amount in UNCOUNTED_CASES + ] + without_income = [dict(country="ENGLAND", benunits=[[base]])] + branch = np.asarray( + simulate(with_income).calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + ) + ) + expected = float(simulate(without_income).calculate(INCOME_TAX, YEAR)[0]) + assert np.allclose(branch, expected, atol=0.01), (branch, expected) + tax_with_income = np.asarray(simulate(with_income).calculate(INCOME_TAX, YEAR)) + assert np.all(tax_with_income > expected + 1), tax_with_income + + # A landlord's finance costs earn a 20% tax reduction on the rent. The + # branch drops the reduction with the rent, so it gives the same tax with + # or without the costs. + landlords = simulate( + [ + dict( + country="ENGLAND", + benunits=[ + [ + { + **base, + "property_income": 10_000.0, + "property_finance_costs": costs, + } + ] + ], + ) + for costs in (0.0, 5_000.0) + ] + ) + landlord_tax = np.asarray(landlords.calculate(INCOME_TAX, YEAR)) + assert np.isclose(landlord_tax[0] - landlord_tax[1], 1_000, atol=0.01) + assert np.allclose( + landlords.calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + ), + expected, + atol=0.01, + ) + + carer = dict( + age=70, + private_pension_income=14_000.0, + care_hours=35, + would_claim_carers_allowance=True, + ) + carer_household = [dict(country="SCOTLAND", benunits=[[carer]])] + with_supplement = simulate(carer_household) + without_supplement = simulate(carer_household, reform=NO_SCOTTISH_CARER_SUPPLEMENT) + assert with_supplement.calculate("scottish_carer_supplement", YEAR)[0] > 0 + assert np.isclose( + with_supplement.calculate( + "council_tax_reduction_income_tax_on_other_counted_income", YEAR + )[0], + without_supplement.calculate(INCOME_TAX, YEAR)[0], + atol=0.01, + ) + assert ( + with_supplement.calculate(INCOME_TAX, YEAR)[0] + > without_supplement.calculate(INCOME_TAX, YEAR)[0] + 1 + ) + + +@PROPERTY_SETTINGS +@given( + st.lists( + household(countries=("ENGLAND", "WALES"), pensioner=False), + min_size=1, + max_size=4, + ) +) +def test_other_schemes_deduct_the_legacy_means_test_measure(households): + """Invariant 8. In the English and Welsh working-age schemes, the + deduction equals what the legacy means tests deduct: the applicant's and + partner's legacy_means_test_deducted_income_tax, with the sum floored at + nil, plus their National Insurance. So these schemes take no tax on the + interest, dividends or rent from property that they do not count, beyond + the extra tax that earnings cause on it.""" + simulation = simulate(households) + schemes(simulation, households) + expected = np.maximum( + 0, applicant_total(simulation, "legacy_means_test_deducted_income_tax") + ) + applicant_total(simulation, "national_insurance") + deduction = benunit_values(simulation, DEDUCTION) + assert np.allclose(deduction, expected, atol=0.01), ( + households, + deduction, + expected, + ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py index 1bf2efbf8f..07f0378bf4 100644 --- a/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py +++ b/policyengine_uk/tests/test_council_tax_reduction_young_person_properties.py @@ -1,8 +1,11 @@ """Each Council Tax Reduction scheme's own young person. -Scotland's working-age scheme, from 1 April 2022, defines a young person as a -Universal Credit qualifying young person (Council Tax Reduction (Scotland) -Regulations 2021 reg. 6(1); UC Regs 2013 reg. 5). England, Wales and +Scotland's working-age scheme, from 1 April 2022, defines a young person by +the qualifying young person of UC Regs 2013 reg. 5 (Council Tax Reduction +(Scotland) Regulations 2021 reg. 6(1)). The Secretary of State excludes a +person entitled to an existing benefit only for the purposes of the UC +Regulations and WRA 2012 Part 1 (SI 2014/1230 reg. 28), so that exclusion +stays out. England, Wales and Scotland's pension-age scheme use the Child Benefit qualifying young person (SI 2012/2885 reg. 2; WSI 2013/3029 reg. 2; SSI 2012/319 reg. 4), as Scotland's working-age scheme did until 31 March 2022 (SSI 2012/303 reg. 4). Every scheme @@ -158,7 +161,9 @@ def assert_scheme_selection(units, year): universal_credit = ( ( under_16 - | sim.calculate("is_qualifying_young_person_for_universal_credit", year) + | sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ) ) & ~looked_after & ~claimant_or_partner @@ -255,7 +260,9 @@ def test_scheme_selection_over_every_combination(): # Not vacuous: some of those lone claimants are qualifying young # persons by status, in Scotland's working-age scheme too. sim = simulate(alone, year) - status = sim.calculate("is_qualifying_young_person_for_universal_credit", year) + status = sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ) scottish_alone = np.array( [follows_universal_credit(unit, year) for unit in alone] ) @@ -280,7 +287,7 @@ def test_schemes_differ_only_where_the_two_definitions_differ(units): sim = simulate(variants, year) flag = sim.calculate("is_child_or_young_person_for_council_tax_reduction", year) agree = sim.calculate( - "is_qualifying_young_person_for_universal_credit", year + "is_qualifying_young_person_under_universal_credit_regulation_5", year ) == sim.calculate("is_qualifying_young_person_for_child_benefit", year) offset = 0 for unit in units: diff --git a/policyengine_uk/tests/test_disable_simulated_benefits.py b/policyengine_uk/tests/test_disable_simulated_benefits.py index 31875f8ea3..292a35cc03 100644 --- a/policyengine_uk/tests/test_disable_simulated_benefits.py +++ b/policyengine_uk/tests/test_disable_simulated_benefits.py @@ -838,3 +838,134 @@ def test_no_person_is_moved_off_their_legacy_benefits(reformed, year): # 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) + + +# A parent aged 45 and a 17-year-old on a non-advanced course in each family; +# the 17-year-old reports £3,000 of one existing benefit (SI 2014/1230 reg. +# 2(1)) in the dataset year. +YOUNG_PERSON_REPORTS = { + "income_support_reported": "income_support", + "jsa_income_reported": "jsa_income", + "esa_income_reported": "esa_income", + "housing_benefit_reported": "housing_benefit", + "child_tax_credit_reported": "child_tax_credit", + "working_tax_credit_reported": "working_tax_credit", +} + + +def young_person_situation(): + people, benunits, households = {}, {}, {} + for report in YOUNG_PERSON_REPORTS: + parent, young = f"{report}_parent", f"{report}_young" + people[parent] = dict(adult(45, True), is_parent=every_year(True)) + people[young] = { + "age": every_year(17), + "is_claimant_or_partner": every_year(False), + "current_education": every_year("POST_SECONDARY"), + report: {DATASET_YEAR: 3_000}, + } + benunits[report] = {"members": [parent, young]} + households[report] = {"members": [parent, young]} + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.mark.parametrize("year", YEARS) +def test_a_young_person_on_a_benefit_the_reform_pays_is_no_qualifying_young_person( + year, +): + # The reform pays each benefit at its dataset-year report after the + # model's closure dates (income-based JSA from 2026, tax credits from + # 2025), so the young person stays entitled to it, and the Secretary of + # State excludes them from the UC qualifying young person test (reg. 28), + # in each of the reform's years. Income + # Support is the claimant's family's award, so the model pays none on a + # dependant's own report, with the reform or without it; the predicate + # reads that report in the years the model pays Income Support to anyone. + reformed = Simulation(situation=young_person_situation(), scenario=REFORM) + unreformed = Simulation(situation=young_person_situation()) + predicate = "is_qualifying_young_person_for_universal_credit" + paid = [ + float(reformed.calculate(benefit, year)[i]) + for i, benefit in enumerate(YOUNG_PERSON_REPORTS.values()) + ] + assert paid == [0, 3_000, 3_000, 3_000, 3_000, 3_000] + assert reformed.calculate(predicate, year)[1::2].tolist() == [False] * 6 + # Without the reform, the model stops paying tax credits from 2025 and + # Income Support and income-based JSA from 2026, and those reports + # exclude nobody. + tax_credits_closed = year >= 2025 + legacy_closed = year >= 2026 + assert unreformed.calculate(predicate, year)[1::2].tolist() == [ + legacy_closed, + legacy_closed, + year >= 2027, + False, + tax_credits_closed, + tax_credits_closed, + ] + + +# An 18-year-old claimant on a non-advanced course, a lone parent of a child +# aged 1, renting from the council at £6,000 a year, who reports £3,000 of +# their own award in the dataset year. The family claims Universal Credit +# (would_claim_uc defaults to true). +CLAIMANT_REPORTS = { + "esa_income_reported": "esa_income", + "housing_benefit_reported": "housing_benefit", + "child_tax_credit_reported": "child_tax_credit", +} + + +def claimant_situation(): + people, benunits, households = {}, {}, {} + for report in CLAIMANT_REPORTS: + claimant, child = f"{report}_claimant", f"{report}_child" + people[claimant] = { + "age": every_year(18), + "is_claimant_or_partner": every_year(True), + "current_education": every_year("POST_SECONDARY"), + report: {DATASET_YEAR: 3_000}, + } + people[child] = { + "age": every_year(1), + "is_claimant_or_partner": every_year(False), + } + benunits[report] = {"members": [claimant, child]} + households[report] = { + "members": [claimant, child], + "region": every_year("NORTH_EAST"), + "tenure_type": every_year("RENT_FROM_COUNCIL"), + "rent": every_year(6_000), + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("index, report", list(enumerate(CLAIMANT_REPORTS))) +def test_a_claimant_on_a_benefit_the_reform_pays_beside_universal_credit_is_no_qualifying_young_person( + year, index, report +): + # A claim for Universal Credit ends the claimant's own income-related + # ESA, Housing Benefit and Child Tax Credit (SI 2014/1230 reg. 8(2) and + # (2A); SI 2013/983 art. 4), and without the reform the model ends each + # award on the claim, so the predicate doesn't read the report (the + # model's year gate closes the Child Tax Credit report from 2025 too). + # In its years, the dataset year and the nine after it, the reform pays + # each report as reported, claim or none, so the claimant stays entitled + # to it and the predicate excludes them. + reformed = Simulation(situation=claimant_situation(), scenario=REFORM) + unreformed = Simulation(situation=claimant_situation()) + predicate = "is_qualifying_young_person_for_universal_credit" + benefit = CLAIMANT_REPORTS[report] + person = 2 * index + + def paid(simulation): + amounts = simulation.calculate(benefit, year) + return float(amounts[person] if benefit == "esa_income" else amounts[index]) + + assert reformed.calculate("claims_universal_credit", year)[index] + assert paid(reformed) == 3_000 + assert not reformed.calculate(predicate, year)[person] + assert unreformed.calculate("claims_universal_credit", year)[index] + assert paid(unreformed) == 0 + assert unreformed.calculate(predicate, year)[person] diff --git a/policyengine_uk/tests/test_enhanced_disability_premium_properties.py b/policyengine_uk/tests/test_enhanced_disability_premium_properties.py new file mode 100644 index 0000000000..04c54b12f5 --- /dev/null +++ b/policyengine_uk/tests/test_enhanced_disability_premium_properties.py @@ -0,0 +1,543 @@ +"""Property-based tests for the legacy enhanced disability premium. + +A claimant or partner meets the premium's condition (HB Regs 2006 Sch 3 para +15(1)(b)-(d); IS Regs 1987 Sch 2 para 13A; HB Regs (NI) 2006 Sch 4 para 15) +with an award of the highest-rate DLA care component, the enhanced-rate PIP +daily living component or armed forces independence payment, while under the +qualifying age for State Pension Credit. Claimants in Northern Ireland qualify +through AFIP from 24 December 2013 and through PIP from 20 June 2016, which +the model reads at 30 April, so from 2017. Housing Benefit pays the single +rate to a person who is not a member of a couple, and one couple rate where +either or both members meet the condition (HB Regs 2006 Sch 3 Part 4). + +The model counts an award only with a positive amount of the benefit, and +takes its rate from the award category or from an amount that reaches +52 × (top weekly rate − £1). + +Scottish councils count attendance allowance at either rate from 1 April 2023 +(SSI 2021/249 Sch 1 para 14), and the Scottish Ministers removed the age limit +from 20 June 2022, which the model reads from 2023. Merton and Kingston upon +Thames (Sch 3 para 12) count the PIP daily living component at either rate +and omit AFIP; Newham counts the PIP daily living component at either rate +and AFIP. In Westminster the model applies the Default Scheme's list (S.I. +2012/2886 Sch 3 para 12), which counts the PIP daily living component at +either rate and omits AFIP. Each council's list starts on or before 1 April +2013 (COUNCIL_LISTS) and applies in every model year, except that for +Kingston upon Thames the model assumes the Housing Benefit condition in +2018-19 to 2022-23, for which it cites no complete scheme text. The model +also applies the Housing Benefit condition in Wales and every other scheme. + +The test generates single people, couples and lone parents in the places and +age range below, with the listed awards, and adds a family in each council +with its own list to every example. It supplies each person's entered DLA +care and PIP daily living amount, or computes an amount from their category. +It checks these invariants in every year from 2015 to 2030: + +1. The test computes the statutory amount from the award inputs and the + parameter rates. enhanced_disability_premium equals that amount, and + council_tax_reduction_enhanced_disability_premium equals the amount under + the list the model applies to the scheme in that year. +2. The model pays no premium to a family in which no claimant or partner has + a qualifying award. +3. Giving a claimant or partner a qualifying benefit on top of their awards + never lowers either premium. +4. Each premium equals nil, the single rate or the couple rate, and never + exceeds two single rates; the couple rate never exceeds two single rates + in any year. +5. The Council Tax Reduction premium equals the legacy premium in Wales, + Northern Ireland, Oxford and the default English council, in Scotland + before 2023, and in Kingston upon Thames in 2018-19 to 2022-23. It never + falls below the legacy premium in Scotland from 2023, or where a council's + own list counts AFIP (Newham's). Where a council's own list omits AFIP + (Merton's, Kingston upon Thames's and Westminster's), it never falls + below the legacy premium unless that premium rests on AFIP alone, and then + the model pays none unless an under-age claimant or partner has a PIP + daily living award. +6. For a child or young person in Great Britain, the qualifying flag matches + the test's own calculation. The model pays the child's enhanced rate in the + Housing Benefit child premiums (housing_benefit_child_disability_premiums) + when the flag holds and pays none when it does not. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import CountryTaxBenefitSystem, Simulation + +PROPERTY_SETTINGS = settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +WEEKS_IN_YEAR = 52 +# The test allows this weekly tolerance below the top rate +# (policyengine-uk-data's CATEGORY_THRESHOLD_WEEKLY_TOLERANCE). +TOLERANCE = 1 +# Each example builds one simulation with inputs for every year from 2015 to +# 2030, so every example tests both sides of the Northern Ireland PIP date +# (2017), the Scottish changes (2023) and Kingston upon Thames's Housing +# Benefit years (2018 to 2022). Every council's own list starts in 2013-14, +# before the model's first year, so the test checks each list in every year +# the model applies it. +YEARS = list(range(2015, 2031)) +# (country, local authority): None keeps the default local authority. +PLACES = [ + ("ENGLAND", None), + ("ENGLAND", "MERTON"), + ("ENGLAND", "KINGSTON_UPON_THAMES"), + ("ENGLAND", "NEWHAM"), + ("ENGLAND", "WESTMINSTER"), + ("ENGLAND", "OXFORD"), + ("WALES", "CARDIFF"), + ("SCOTLAND", "CITY_OF_EDINBURGH"), + ("NORTHERN_IRELAND", None), +] +# The generator assigns the award inputs below. The reported amounts of 7,000 +# and 3,000 respectively exceed and fall below each tested year's threshold, +# which rises from (82.30 - 1) * 52 = 4,227.60 in 2015 to 6,596.16 at the +# model's uprated 2030 rates (test_entered_amounts_straddle_the_threshold). +AWARDS = { + "dla_care_lowest": {"dla_sc_category": "LOWER"}, + "dla_care_middle": {"dla_sc_category": "MIDDLE"}, + "dla_care_highest": {"dla_sc_category": "HIGHER"}, + "dla_care_top_amount_only": {"dla_sc": 7_000}, + "dla_care_low_amount_only": {"dla_sc": 3_000}, + "dla_care_highest_zero_amount": {"dla_sc_category": "HIGHER", "dla_sc": 0}, + "dla_care_highest_part_amount": {"dla_sc_category": "HIGHER", "dla_sc": 1_000}, + "pip_daily_living_standard": {"pip_dl_category": "STANDARD"}, + "pip_daily_living_enhanced": {"pip_dl_category": "ENHANCED"}, + "pip_daily_living_top_amount_only": {"pip_dl": 7_000}, + "pip_daily_living_low_amount_only": {"pip_dl": 3_000}, + "pip_daily_living_enhanced_zero_amount": { + "pip_dl_category": "ENHANCED", + "pip_dl": 0, + }, + "afip": {"armed_forces_independence_payment": 2_000}, + "attendance_allowance_lower": {"aa_category": "LOWER"}, + "attendance_allowance_higher": {"aa_category": "HIGHER"}, +} +QUALIFYING_AWARDS = ["dla_care_highest", "pip_daily_living_enhanced", "afip"] +ADULT_AGES = [20, 30, 45, 60, 64, 66, 67, 70, 85] +# Each council's own list (Sch 3 para 12 of its scheme), by the scheme year +# it starts: "dla_highest" is the DLA care component at the highest rate, +# "any_pip" the PIP daily living component at either rate and "afip" armed +# forces independence payment. None marks years where the model applies the +# Housing Benefit condition instead. +COUNCIL_LISTS = { + # The 2013 rules. + "MERTON": {2013: ("dla_highest", "any_pip")}, + # The Council adopted the Default Scheme for 2013-14 and kept its list + # through 2017-18, and the council prints the same list in its texts from + # 2023-24. The model cites no complete scheme text for 2018-19 to + # 2022-23, so it assumes the Housing Benefit condition in those years. + "KINGSTON_UPON_THAMES": { + 2013: ("dla_highest", "any_pip"), + 2018: None, + 2023: ("dla_highest", "any_pip"), + }, + # The 2013 scheme, and the 2014 scheme with AFIP. + "NEWHAM": { + 2013: ("dla_highest", "any_pip"), + 2014: ("dla_highest", "any_pip", "afip"), + }, + # The Default Scheme: the council has approved the same scheme since + # 2013-14, and its Cabinet says the council's scheme mirrors it. + "WESTMINSTER": {2013: ("dla_highest", "any_pip")}, +} + +system = CountryTaxBenefitSystem() + + +def award_inputs(awards): + """Return the model inputs for a tuple of awards, letting a later award's + input replace an earlier one's.""" + inputs = {} + for award in awards: + inputs.update(AWARDS[award]) + return inputs + + +def amount(inputs, benefit, year): + """Return the entered annual amount of DLA care (dla_sc) or PIP daily + living (pip_dl), or compute it from the award category.""" + if benefit in inputs: + return inputs[benefit] + dwp = system.parameters(year).gov.dwp + rates = dwp.dla.self_care if benefit == "dla_sc" else dwp.pip.daily_living + category = inputs.get(f"{benefit}_category", "NONE") + if category == "NONE": + return 0 + return getattr(rates, category.lower()) * WEEKS_IN_YEAR + + +def top_rate_award(inputs, benefit, year): + """Check for an award at the highest DLA care or enhanced PIP daily living + rate: a positive amount and either the top category or an amount within + the tolerance of the top rate.""" + dwp = system.parameters(year).gov.dwp + if benefit == "dla_sc": + top_category, top_rate = "HIGHER", dwp.dla.self_care.higher + else: + top_category, top_rate = "ENHANCED", dwp.pip.daily_living.enhanced + annual = amount(inputs, benefit, year) + top = inputs.get(f"{benefit}_category") == top_category or ( + annual >= (top_rate - TOLERANCE) * WEEKS_IN_YEAR + ) + return annual > 0 and top + + +def afip(inputs): + return inputs.get("armed_forces_independence_payment", 0) > 0 + + +def qualifies(inputs, country, year): + """Check for the legacy premium's qualifying awards under the + Regulations.""" + if top_rate_award(inputs, "dla_sc", year) or afip(inputs): + return True + if top_rate_award(inputs, "pip_dl", year): + return country != "NORTHERN_IRELAND" or year >= 2017 + return False + + +def council_list(council, year): + """Return the awards on a council's own list in a year, or None where + the model applies the Housing Benefit condition.""" + starts = [start for start in COUNCIL_LISTS.get(council, {}) if start <= year] + return COUNCIL_LISTS[council][max(starts)] if starts else None + + +def qualifies_for_scheme(inputs, place, year): + """Check for the awards on the list the model applies to the scheme in + that year, before the scheme's age limit.""" + country, council = place + if country == "SCOTLAND": + # SSI 2021/249 Sch 1 para 14(1), with head (g) from 2023. + attendance_allowance = inputs.get("aa_category") in ("LOWER", "HIGHER") + return qualifies(inputs, country, year) or ( + attendance_allowance and year >= 2023 + ) + listed = council_list(council, year) + if listed is None: + # The Housing Benefit condition. + return qualifies(inputs, country, year) + held = dict( + dla_highest=top_rate_award(inputs, "dla_sc", year), + any_pip=amount(inputs, "pip_dl", year) > 0, + afip=afip(inputs), + ) + return any(held[award] for award in listed) + + +def scheme_age_limit(place, year): + return place[0] != "SCOTLAND" or year < 2023 + + +awards = st.lists(st.sampled_from(sorted(AWARDS)), max_size=2).map(tuple) +adult = st.fixed_dictionaries(dict(age=st.sampled_from(ADULT_AGES), awards=awards)) +family = st.fixed_dictionaries( + dict( + place=st.sampled_from(PLACES), + shape=st.sampled_from(["single", "couple", "lone_parent"]), + adults=st.lists(adult, min_size=2, max_size=2), + upgrade_member=st.integers(0, 1), + upgrade_award=st.sampled_from(QUALIFYING_AWARDS), + ) +) +# A family in each council with its own list, drawn for every example. +council_families = st.tuples( + *( + family.map(lambda f, place=place: dict(f, place=place)) + for place in PLACES + if place[1] in COUNCIL_LISTS + ) +) +child = st.fixed_dictionaries( + dict( + age=st.sampled_from([4, 10, 16, 17]), + awards=awards, + country=st.sampled_from(["ENGLAND", "WALES", "SCOTLAND"]), + ) +) + + +def adults_of(f): + return f["adults"][: 2 if f["shape"] == "couple" else 1] + + +def upgraded(f): + """Add a qualifying benefit to one claimant's or partner's awards and + return the family.""" + adults = [dict(a) for a in f["adults"]] + j = f["upgrade_member"] % len(adults_of(f)) + adults[j]["awards"] = adults[j]["awards"] + (f["upgrade_award"],) + return dict(f, adults=adults) + + +def every_year(inputs): + return {k: {year: v for year in YEARS} for k, v in inputs.items()} + + +def person_inputs(age, awards, **extra): + """Return a person's inputs for every year. A situation that supplies an + amount for one person supplies the default, zero, for everyone else, as + it does for categories (utils/pip.py). So the test gives every person + both amounts: the amount entered, or the rate for the award category, + which the amount formulas would give.""" + inputs = award_inputs(awards) + person = every_year(dict(age=age, **extra, **inputs)) + for benefit in ("dla_sc", "pip_dl"): + person[benefit] = {year: amount(inputs, benefit, year) for year in YEARS} + return person + + +def household_inputs(country, council): + inputs = dict(country=country) + if council is not None: + inputs["local_authority"] = council + return every_year(inputs) + + +def build(families, children): + """Build one household and one benefit unit per family, and one per child + with a parent aged 35. The test marks claimants and partners as such, so + the model never reads an age gap as making one the other's child.""" + people, benunits, households = {}, {}, {} + for i, f in enumerate(families): + members = [] + for j, a in enumerate(adults_of(f)): + name = f"f{i}_adult{j}" + people[name] = person_inputs( + a["age"], a["awards"], is_claimant_or_partner=True + ) + members.append(name) + if f["shape"] == "lone_parent": + name = f"f{i}_child" + people[name] = person_inputs(8, (), is_claimant_or_partner=False) + members.append(name) + benunits[f"f{i}"] = {"members": members} + households[f"f{i}"] = dict(members=members, **household_inputs(*f["place"])) + for i, c in enumerate(children): + members = [f"c{i}_parent", f"c{i}_child"] + people[members[0]] = person_inputs(35, ()) + people[members[1]] = person_inputs(c["age"], c["awards"]) + benunits[f"c{i}"] = {"members": members} + households[f"c{i}"] = dict( + members=members, **household_inputs(c["country"], None) + ) + sim = Simulation( + situation=dict(people=people, benunits=benunits, households=households) + ) + person_ids = list(sim.populations["person"].ids) + benunit_ids = list(sim.populations["benunit"].ids) + return sim, person_ids.index, benunit_ids.index + + +def oracle(f, year, attained): + """Compute the two annual premiums for one family from the Regulations + and COUNCIL_LISTS, with the model's Housing Benefit assumption for + Kingston upon Thames in 2018 to 2022. attained[j] holds when adult j has + attained the qualifying age for State Pension Credit.""" + p = system.parameters(year).gov.dwp.disability_premia + rate = p.enhanced_couple if f["shape"] == "couple" else p.enhanced_single + country = f["place"][0] + inputs = [award_inputs(a["awards"]) for a in adults_of(f)] + legacy = any( + qualifies(x, country, year) and not attained[j] for j, x in enumerate(inputs) + ) + age_limit = scheme_age_limit(f["place"], year) + scheme = any( + qualifies_for_scheme(x, f["place"], year) and not (age_limit and attained[j]) + for j, x in enumerate(inputs) + ) + return rate * WEEKS_IN_YEAR * legacy, rate * WEEKS_IN_YEAR * scheme + + +def check_scheme_against_legacy(f, year, attained, edp, ctr): + """Check invariant 5: each scheme's premium against the legacy + premium.""" + country, council = f["place"] + listed = council_list(council, year) + if country == "SCOTLAND": + if year < 2023: + assert np.isclose(ctr, edp, atol=0.01) + else: + assert ctr >= edp - 0.01 + elif listed is None: + assert np.isclose(ctr, edp, atol=0.01) + elif "afip" in listed: + assert ctr >= edp - 0.01 + else: + under_age = [ + award_inputs(a["awards"]) + for j, a in enumerate(adults_of(f)) + if not attained[j] + ] + rests_on_afip = edp > 0 and not any( + top_rate_award(x, "dla_sc", year) or top_rate_award(x, "pip_dl", year) + for x in under_age + ) + if not rests_on_afip: + assert ctr >= edp - 0.01 + elif not any(amount(x, "pip_dl", year) > 0 for x in under_age): + # The list omits AFIP. + assert ctr == 0 + + +def check_families(sim, person, benunit, families, year): + n = len(families) // 2 + edp = sim.calculate("enhanced_disability_premium", year) + ctr = sim.calculate("council_tax_reduction_enhanced_disability_premium", year) + attained = sim.calculate("has_attained_state_pension_credit_qualifying_age", year) + is_couple = sim.calculate("is_couple", year) + p = system.parameters(year).gov.dwp.disability_premia + single_annual = p.enhanced_single * WEEKS_IN_YEAR + couple_annual = p.enhanced_couple * WEEKS_IN_YEAR + family_edp, family_ctr = [], [] + for i, f in enumerate(families): + b = benunit(f"f{i}") + member_attained = [ + bool(attained[person(f"f{i}_adult{j}")]) for j in range(len(adults_of(f))) + ] + assert bool(is_couple[b]) == (f["shape"] == "couple") + legacy, scheme = oracle(f, year, member_attained) + family_edp.append(edp[b]) + family_ctr.append(ctr[b]) + # 1. Oracle. + assert np.isclose(edp[b], legacy, atol=0.01), (f, year, edp[b], legacy) + assert np.isclose(ctr[b], scheme, atol=0.01), (f, year, ctr[b], scheme) + # 2. No qualifying award, no premium. + inputs = [award_inputs(a["awards"]) for a in adults_of(f)] + if not any(qualifies(x, f["place"][0], year) for x in inputs): + assert edp[b] == 0 + if not any(qualifies_for_scheme(x, f["place"], year) for x in inputs): + assert ctr[b] == 0 + # 4. Bounds. + for annual in (edp[b], ctr[b]): + assert any( + np.isclose(annual, x, atol=0.01) + for x in (0, single_annual, couple_annual) + ) + assert annual <= 2 * single_annual + 0.01 + check_scheme_against_legacy(f, year, member_attained, edp[b], ctr[b]) + # 3. Monotone: the test pairs each family in the first half with its twin + # in the second half, which has a qualifying benefit added. + assert all(t >= s - 0.01 for s, t in zip(family_edp[:n], family_edp[n:])) + assert all(t >= s - 0.01 for s, t in zip(family_ctr[:n], family_ctr[n:])) + + +def check_children(sim, person, benunit, children, year): + # 6. The Housing Benefit child premiums' enhanced rate. + p = system.parameters(year).gov.dwp.housing_benefit.premiums + premiums = sim.calculate("housing_benefit_child_disability_premiums", year) + qualifying = sim.calculate( + "receives_enhanced_disability_premium_qualifying_benefit", year + ) + young = sim.calculate("is_child_or_young_person_for_legacy_benefits", year) + for i, c in enumerate(children): + k = person(f"c{i}_child") + if not young[k]: + continue + inputs = award_inputs(c["awards"]) + assert bool(qualifying[k]) == qualifies(inputs, c["country"], year), (c, year) + # A child qualifies for the disabled child rate with a positive DLA or + # PIP amount, or AFIP. + disabled = ( + amount(inputs, "dla_sc", year) > 0 + or amount(inputs, "pip_dl", year) > 0 + or afip(inputs) + ) + weekly = premiums[benunit(f"c{i}")] / WEEKS_IN_YEAR + enhanced_weekly = weekly - p.disabled_child * disabled + assert np.isclose( + enhanced_weekly, p.enhanced_disabled_child * qualifying[k], atol=0.01 + ), (c, year, weekly) + + +@PROPERTY_SETTINGS +@given( + families=st.lists(family, min_size=1, max_size=30), + council_families=council_families, + children=st.lists(child, max_size=15), +) +def test_enhanced_disability_premium_invariants(families, council_families, children): + families = families + list(council_families) + everyone = families + [upgraded(f) for f in families] + sim, person, benunit = build(everyone, children) + for year in YEARS: + check_families(sim, person, benunit, everyone, year) + check_children(sim, person, benunit, children, year) + + +def test_couple_rate_never_exceeds_two_single_rates(): + for year in range(2015, 2031): + p = system.parameters(year).gov.dwp.disability_premia + assert p.enhanced_couple <= 2 * p.enhanced_single, year + + +def test_entered_amounts_straddle_the_threshold(): + """In every tested year the entered top amount reaches the top-rate + threshold and the entered low amount falls below it.""" + for year in YEARS: + for benefit, award in (("dla_sc", "dla_care"), ("pip_dl", "pip_daily_living")): + top = AWARDS[f"{award}_top_amount_only"] + low = AWARDS[f"{award}_low_amount_only"] + assert top_rate_award(top, benefit, year), (award, year) + assert not top_rate_award(low, benefit, year), (award, year) + + +class NeutraliseDlaCareAndPipDailyLiving(Reform): + def apply(self): + self.neutralize_variable("dla_sc") + self.neutralize_variable("pip_dl") + + +def test_neutralised_awards_pay_no_enhanced_premium(): + """A reform that neutralises DLA care and PIP daily living leaves their + categories in place but sets both amounts to zero, so no award remains. + The model then pays neither adult premium nor the child's disability + premiums.""" + year = 2026 + people = dict( + dla=dict(age=30, dla_sc_category="HIGHER"), + pip=dict(age=30, pip_dl_category="ENHANCED"), + parent=dict(age=35), + child=dict(age=10, dla_sc_category="HIGHER"), + ) + groups = dict(dla=["dla"], pip=["pip"], family=["parent", "child"]) + situation = dict( + people={name: every_year(inputs) for name, inputs in people.items()}, + benunits={name: {"members": members} for name, members in groups.items()}, + households={ + name: dict(members=members, **household_inputs("ENGLAND", "MERTON")) + for name, members in groups.items() + }, + ) + p = system.parameters(year).gov.dwp + single_annual = p.disability_premia.enhanced_single * WEEKS_IN_YEAR + child_annual = ( + p.housing_benefit.premiums.disabled_child + + p.housing_benefit.premiums.enhanced_disabled_child + ) * WEEKS_IN_YEAR + for reform, adult_premium, child_premium, flag in ( + (None, single_annual, child_annual, True), + (NeutraliseDlaCareAndPipDailyLiving, 0, 0, False), + ): + sim = Simulation(situation=situation, reform=reform) + assert sim.calculate( + "receives_enhanced_disability_premium_qualifying_benefit", year + ).tolist() == [flag, flag, False, flag] + for variable in ( + "enhanced_disability_premium", + "council_tax_reduction_enhanced_disability_premium", + ): + assert np.allclose( + sim.calculate(variable, year), [adult_premium, adult_premium, 0] + ), (reform, variable) + assert np.allclose( + sim.calculate("housing_benefit_child_disability_premiums", year), + [0, 0, child_premium], + ), reform diff --git a/policyengine_uk/tests/test_esa_income_pension_age_properties.py b/policyengine_uk/tests/test_esa_income_pension_age_properties.py new file mode 100644 index 0000000000..9331151624 --- /dev/null +++ b/policyengine_uk/tests/test_esa_income_pension_age_properties.py @@ -0,0 +1,1279 @@ +"""The model refuses a couple income-related ESA only where the couple would +get State Pension Credit without it. + +Parliament bars ESA for a claimant who has reached pensionable age, their own +State Pension age (Welfare Reform Act 2007 s.1(3)(c) and s.1(6)), and bars the +income-related allowance where the claimant, or the other member of the +couple, has an entitlement to State Pension Credit (Sch 1 para 6(1)(c) and +(d)). The model's Pension Credit reads the couple's award, so the model reads +the Pension Credit the couple would get with no income-related ESA in the +family, B(0), and refuses the couple's claim where B(0) exceeds nil. + +Each example builds a second, separate simulation of the same people with no +income-related ESA reported. Its pension_credit gives B(0) without a branch. +Against it, for every family drawn here: + +- where B(0) is nil, whether the couple would claim Pension Credit changes + neither the couple's income-related ESA (claimant_or_partner_esa_income) + nor the family's (esa_income); +- where B(0) exceeds nil, the couple would claim it and has an open report, + the couple's income-related ESA is nil and the family's Pension Credit is + B(0); +- claiming Pension Credit never raises income-related ESA; +- esa_income_eligible equals the family-by-family reading of the law in + legacy_award_work_reference (esa_screen), given the model's pensionable age + (is_SP_age) and B(0); +- nobody who has reached pensionable age is on income-related ESA + (is_on_income_related_esa), and a family whose every report comes from such + a member gets none; +- no family has both Pension Credit and the couple's income-related ESA. + +DWP disregards £20 a week of earnings while the claimant or partner +receives ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)), so some families get +Pension Credit only with the award. The model pays such a family the award +and no Pension Credit (pension_credit), and the test counts them. + +The years are 2016, when a man born before 6 December 1953 could be over the +qualifying age for State Pension Credit but under his pensionable age of 65 +(Pensions Act 1995 Sch 4 para 1 rule 1), 2018, before the exclusion of +mixed-age couples (SPCA 2002 s.4(1A), from 15 May 2019), and 2026, when only +a mixed-age couple that keeps the SI 2019/37 art 4 saving +(has_mixed_age_couple_pension_credit_saving, drawn) can get Pension Credit. +The Secretary of State appointed 1 July 2026 for the abolition of +income-related ESA to reach every remaining award (SI 2025/1148 art. 3A(1)), +and under the claimant's appointee saving DWP keeps the award in payment +(art. 3A(2)), so every adult drawn in 2026 has the saving +(esa_income_appointee_saving) and each award stays payable for the whole year; +test_esa_income_abolition_properties.py tests unsaved awards. Adults have +earnings, pensions and household savings, so some families pass Pension +Credit's means test and others fail it, and in 2016 and 2018 some report +working tax credit, which HMRC pays without its income test to a family on +the award (TCA 2002 s.7(2)). A member outside the couple claims in their own +right and keeps that claim while below pensionable age, whatever the couple's +Pension Credit. + +Each of would_claim_uc, claims_all_entitled_benefits and the mixed-age saving +would otherwise read the ESA report, so the generator enters them, and no one +reports Housing Benefit, whose saving route also reads it. The test asserts +that the two simulations agree on the Pension Credit age conditions. Roles are +given explicitly (is_claimant_or_partner). Each example builds many families +in one simulation, in separate benefit units, with the take-up copies in the +same simulation. Simulations take most of the time here, so each example +builds two and the file builds few. + +A differential test runs the drawn families through a reform that calculates +the model's Pension Credit by a formula of its own, so the screen reads +Pension Credit for every couple whose claim passes the other tests, and +requires the same results as the model, which reads it only for a couple +that meets the age conditions and would claim. + +Separate tests count the branches the model opens and the variables the +screen reads: a population with no couple that could be on Pension Credit +opens none, a branch never outlives the calculation, the tax credit passport +branch inside it reads the couple's award as nil, and the screen reads no +Pension Credit condition without a couple's claim that passes the other +tests. Others enter Pension Credit, or a part of it, as an input, or reform +it: an entered award, a reform that pays every family, a frozen award, a +reform that changes the default or coverage of a Pension Credit variable and +a reform that gives take-up a formula each make the model read the award for +every couple, and a parameter reform keeps the age and take-up conditions. +One test changes each attribute of each Pension Credit variable in turn, and +requires the model to drop the age and take-up conditions for every change to +what Core calculates the variable from. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, event, example, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import period as period_ +from policyengine_core.reforms import Reform +from policyengine_core.simulations import Simulation as CoreSimulation + +from policyengine_uk import Simulation +from policyengine_uk.model_api import GBP, YEAR, BenUnit, Variable +from policyengine_uk.tests.legacy_award_work_reference import esa_screen +from policyengine_uk.variables.gov.dwp.esa_income_eligible import ( + NO_INCOME_RELATED_ESA_BRANCH, + PENSION_CREDIT_VARIABLES, + pension_credit_follows_model_formulas, +) +from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( + guarantee_credit as model_guarantee_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.is_pension_credit_eligible import ( + is_pension_credit_eligible as model_eligibility, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( + pension_credit as model_pension_credit, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = [2016, 2018, 2026] +# Either side of the State Pension Credit qualifying age and of pensionable +# age in each year drawn, including men aged 62 to 64 in 2016. +AGES = [30, 45, 58, 60, 62, 63, 64, 65, 66, 67, 68, 70, 75, 82] +# 0, 10, 15 and 24 hours a week. +HOURS = [0, 0, 520, 780, 1_248] +# £50, £100 and about £192 a week. +EARNINGS = [0, 0, 2_600, 5_200, 10_000] +# £40, £61 and £200 a week. +PENSIONS = [0, 0, 2_080, 3_172, 10_400] +SAVINGS = [0, 0, 8_000, 14_000, 40_000] + + +@st.composite +def adults(draw, outside=False, tax_credits=False, saved=False): + adult = { + "age": draw(st.sampled_from(AGES)), + "months_since_last_birthday": draw(st.sampled_from([0, 3, 8, 11])), + "gender": draw(st.sampled_from(["MALE", "FEMALE"])), + "esa_income_reported": draw(st.sampled_from([0, 2_500, 5_000])), + "esa_includes_support_component": draw(st.booleans()), + "hours_worked": draw(st.sampled_from(HOURS)), + "employment_income": draw(st.sampled_from(EARNINGS)), + "private_pension_income": draw(st.sampled_from(PENSIONS)), + } + if saved: + adult["esa_income_appointee_saving"] = True + if outside: + adult["current_education"] = "NOT_IN_EDUCATION" + elif tax_credits: + adult["working_tax_credit_reported"] = draw(st.sampled_from([0, 0, 0, 1])) + return adult + + +@st.composite +def families(draw, tax_credits, saved): + """A claimant, an optional partner, an optional member outside the couple, + the couple's saving and the household's savings. Members of the couple + report working tax credit only in a year with tax credits. With saved, + every adult has the appointee saving for income-related ESA.""" + couple = [draw(adults(tax_credits=tax_credits, saved=saved))] + if draw(st.booleans()): + couple.append(draw(adults(tax_credits=tax_credits, saved=saved))) + others = ( + [draw(adults(outside=True, saved=saved))] + if draw(st.integers(0, 3)) == 0 + else [] + ) + saving = draw(st.booleans()) + return couple, others, saving, draw(st.sampled_from(SAVINGS)) + + +@st.composite +def examples(draw): + year = draw(st.sampled_from(YEARS)) + # Tax credits ended on 5 April 2025. + tax_credits = year < 2025 + # The abolition of income-related ESA reaches unsaved awards from 1 July + # 2026 (SI 2025/1148 art. 3A(1)), so every adult drawn in 2026 has the + # appointee saving, under which DWP keeps the award payable all year + # (art. 3A(2)). + saved = year >= 2026 + units = draw(st.lists(families(tax_credits, saved), min_size=4, max_size=12)) + # In 2016, add case 18's man and case 26's couple, who get Pension Credit + # only with the award. + if year == 2016: + units = units + [ROWS[2], COUPLE_ROW] + return units, year + + +def situation(units, year, would_claim_pc, reports=True): + """Each family once for each take-up in would_claim_pc. Without reports, + nobody reports income-related ESA.""" + people, benunits, households = {}, {}, {} + copies = [(unit, claims) for claims in would_claim_pc for unit in units] + for i, ((couple, others, saving, savings), claims) in enumerate(copies): + names = [] + members = [(m, True) for m in couple] + [(m, False) for m in others] + for j, (inputs, claimant_or_partner) in enumerate(members): + name = f"p{i}_{j}" + values = dict(inputs, is_claimant_or_partner=claimant_or_partner) + if not reports: + values.pop("esa_income_reported") + people[name] = {k: {year: v} for k, v in values.items()} + names.append(name) + benunits[f"b{i}"] = { + "members": names, + "would_claim_uc": {year: False}, + "claims_all_entitled_benefits": {year: False}, + "has_mixed_age_couple_pension_credit_saving": {year: saving}, + "would_claim_pc": {year: claims}, + } + households[f"h{i}"] = {"members": names, "savings": {year: savings}} + return {"people": people, "benunits": benunits, "households": households} + + +def couple_reports(sim, year): + """Whether the couple has an open report of income-related ESA.""" + open_report = (sim.calculate("esa_income_reported", year) > 0) & ~sim.calculate( + "esa_income_report_closed", year + ) + couple = sim.calculate("is_claimant_or_partner", year) + return sim.populations["benunit"].sum(open_report & couple) > 0 + + +# Each pairing of B(0) and B(e) in 2016, for a man aged 63 and six months, whom +# the model takes to be born on 6 April 1953, with the earnings, pensions and +# awards of esa_income_pension_age.yaml cases 15, 16 and 18; the couple of case +# 19, whose working tax credit decides the pairing; and a member outside the +# couple reporting beside a pensioner on Pension Credit. +MAN = { + "age": 63, + "months_since_last_birthday": 6, + "gender": "MALE", + "esa_includes_support_component": True, + "working_tax_credit_reported": 0, +} +ROWS = [ + ( + [ + dict( + MAN, + esa_income_reported=reported, + employment_income=earnings, + hours_worked=hours, + private_pension_income=pension, + ) + ], + [], + False, + 0, + ) + for reported, earnings, hours, pension in [ + (6_011.20, 2_600, 520, 2_080), + (4_919.20, 6_006, 780, 3_172), + (5_491.20, 6_006, 780, 2_600), + ] +] + [ + ( + [ + { + "age": 66, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 1_040, + "employment_income": 10_000, + "private_pension_income": 0, + "working_tax_credit_reported": 1, + }, + { + "age": 58, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 3_000, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, + ), + ( + [ + { + "age": 70, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + } + ], + [ + { + "age": 40, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 3_000, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 0, + "current_education": "NOT_IN_EDUCATION", + } + ], + False, + 0, + ), +] + +# Case 26's couple: the man of case 18 earning £90 a week in 15 hours with a +# £130 weekly pension, and a woman aged 60 earning £30 a week in 5 hours. +COUPLE_ROW = ( + [ + dict( + MAN, + esa_income_reported=5_491.20, + employment_income=4_680, + hours_worked=780, + private_pension_income=6_760, + ), + { + "age": 60, + "months_since_last_birthday": 0, + "gender": "FEMALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 260, + "employment_income": 1_560, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, +) + +# Two couples in 2018, a year with tax credits and before the exclusion of +# mixed-age couples. Case 19's couple (ROWS[3]) gets Pension Credit with or +# without the award that year. In case 20's couple, a man aged 66 has a £200 +# weekly pension and a man aged 58 reports support-group income-related ESA +# and earns £124 a week in 15 hours, and the couple gets no Pension Credit +# with or without the award. +ROWS_2018 = [ + ROWS[3], + ( + [ + { + "age": 66, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 0, + "esa_includes_support_component": False, + "hours_worked": 0, + "employment_income": 0, + "private_pension_income": 10_400, + "working_tax_credit_reported": 0, + }, + { + "age": 58, + "months_since_last_birthday": 0, + "gender": "MALE", + "esa_income_reported": 2_537.60, + "esa_includes_support_component": True, + "hours_worked": 780, + "employment_income": 6_448, + "private_pension_income": 0, + "working_tax_credit_reported": 0, + }, + ], + [], + False, + 0, + ), +] + + +@settings( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(examples()) +@example((ROWS, 2016)) +@example((ROWS_2018, 2018)) +def test_couple_esa_follows_pension_credit_worked_out_without_it(drawn): + units, year = drawn + n = len(units) + event(f"year {year}") + sim = Simulation(situation=situation(units, year, [True, False])) + oracle = Simulation(situation=situation(units, year, [True], reports=False)) + route = sim.calculate("meets_pension_credit_age_conditions", year) + # The oracle's Pension Credit reads the same age conditions. + assert ( + route[:n] == oracle.calculate("meets_pension_credit_age_conditions", year) + ).all() + assert (route[:n] == route[n:]).all() + without_award = oracle.calculate("pension_credit", year) + pension_credit = sim.calculate("pension_credit", year) + couple_esa = sim.calculate("claimant_or_partner_esa_income", year) + esa = sim.calculate("esa_income", year) + reports = couple_reports(sim, year) + claims, declines = slice(0, n), slice(n, 2 * n) + + pays = without_award > 0 + barred = pays & reports[claims] + refused = ~pays + for label, mask in [ + ("Pension Credit pays without the award", barred), + ("Pension Credit refuses a couple with a report", refused & reports[claims]), + ( + "Pension Credit pays only with the award", + refused + & (couple_esa[claims] > 0) + & (sim.calculate("pension_credit_entitlement", year)[claims] > 0), + ), + ]: + if mask.any(): + event(label) + + # Sch 1 para 6(1)(c) and (d): where the family would get Pension Credit + # without the award, the couple's award is nil and the family gets that + # Pension Credit. + assert (couple_esa[claims][barred] == 0).all(), units + assert np.allclose( + pension_credit[claims][barred], without_award[barred], atol=0.01 + ), units + # Where the family would get no Pension Credit without the award, take-up + # changes no award. + assert np.allclose( + couple_esa[claims][refused], couple_esa[declines][refused], atol=0.01 + ), units + assert np.allclose(esa[claims][refused], esa[declines][refused], atol=0.01), units + # Claiming Pension Credit never raises income-related ESA. + assert (esa[claims] <= esa[declines] + 0.01).all(), units + # Sch 1 para 6(1)(c) and (d): no family has both the couple's award and + # Pension Credit, whether or not it would claim. + assert not ((couple_esa > 0) & (pension_credit > 0)).any(), units + if units == ROWS: + # The third row is case 18: B(0) nil and B(e) 525.20, so the man + # keeps his award and gets no Pension Credit. + assert couple_esa[2] == pytest.approx(5_491.20, abs=0.01) + assert sim.calculate("pension_credit_entitlement", year)[2] == pytest.approx( + 525.20, abs=0.01 + ) + assert pension_credit[2] == 0 + # The fifth row is case 12's pairing: the member outside the couple + # keeps their 3,000 beside the couple's minimum guarantee, 52 x + # 155.60 = 8,091.20, whether or not the couple would claim. + assert esa[[4, n + 4]] == pytest.approx([3_000, 3_000], abs=0.01) + assert couple_esa[4] == 0 + assert pension_credit[4] == pytest.approx(8_091.20, abs=0.01) + + # The screen, family by family. + eligible = sim.calculate("esa_income_eligible", year) + pensionable_age = sim.calculate("is_SP_age", year) + on_esa = sim.calculate("is_on_income_related_esa", year) + capital = sim.calculate("esa_income_assessable_capital", year) + parameters = sim.tax_benefit_system.parameters(year) + # s.1(3)(c): the award is not payable to anyone over pensionable age. + assert not (pensionable_age & on_esa).any(), units + start = 0 + for i, (unit, claimed) in enumerate( + [(unit, True) for unit in units] + [(unit, False) for unit in units] + ): + couple, others, _, _ = unit + members = [ + dict(member, is_SP_age=pensionable_age[start + j]) + for j, member in enumerate(couple + others) + ] + on_pension_credit = claimed and route[i % n] and pays[i % n] + expected = esa_screen( + members[: len(couple)], + members[len(couple) :], + capital[i], + parameters, + on_pension_credit=on_pension_credit, + ) + assert eligible[i] == expected, (year, claimed, unit) + reporters = [m["esa_income_reported"] > 0 for m in members] + if any(reporters) and all( + m["is_SP_age"] for m, r in zip(members, reporters) if r + ): + assert esa[i] == 0, (year, unit) + start += len(members) + + +# The appointee saving for income-related ESA, under which DWP keeps the award +# in payment after the abolition of 1 July 2026 (SI 2025/1148 art. 3A(2)). +SAVED = {"esa_income_appointee_saving": True} + + +class BranchLog: + """Records each branch Core creates, with the simulation it branches.""" + + def __init__(self, monkeypatch): + self.created = [] + get_branch = CoreSimulation.get_branch + + def logged(simulation, name="branch", clone_system=False): + new = name not in simulation.branches and name != simulation.branch_name + branch = get_branch(simulation, name, clone_system) + if new: + self.created.append((simulation, branch)) + return branch + + monkeypatch.setattr(CoreSimulation, "get_branch", logged) + + def bar_branches(self): + return [ + (parent, branch) + for parent, branch in self.created + if branch.branch_name.startswith(NO_INCOME_RELATED_ESA_BRANCH) + ] + + +def household(members, year, **benunit): + names = [f"p{j}" for j in range(len(members))] + return { + "people": { + name: {k: {year: v} for k, v in inputs.items()} + for name, inputs in zip(names, members) + }, + "benunits": { + "b": { + "members": names, + "would_claim_uc": {year: False}, + **{k: {year: v} for k, v in benunit.items()}, + } + }, + "households": {"h": {"members": names}}, + } + + +def combine(*situations): + combined = {"people": {}, "benunits": {}, "households": {}} + for i, part in enumerate(situations): + for entity, units in part.items(): + for name, unit in units.items(): + unit = dict(unit) + if "members" in unit: + unit["members"] = [f"{m}_{i}" for m in unit["members"]] + combined[entity][f"{name}_{i}"] = unit + return combined + + +def test_no_branch_opens_without_a_couple_that_could_be_on_pension_credit( + monkeypatch, +): + """In 2026 no couple here has a claim that passes the other tests and + meets the Pension Credit age conditions: a working-age claimant reporting + the award, a couple aged 70 and 60 without the saving (case 4's kind), a + pensioner without a report, a member outside the couple reporting beside + a pensioner, and a pensioner reporting the award, who has reached + pensionable age and so has no claim whatever the couple's Pension + Credit. Each reporter has the appointee saving (SI 2025/1148 art. 3A(2)), + so the model keeps each report open for the whole of 2026-27 + (esa_income_payable_share is 1).""" + log = BranchLog(monkeypatch) + year = 2026 + adult = {"is_claimant_or_partner": True} + reports = {"esa_income_reported": 5_000, **SAVED} + sim = Simulation( + situation=combine( + household([dict(adult, age=45, **reports)], year), + household( + [dict(adult, age=70), dict(adult, age=60, **reports)], + year, + ), + household([dict(adult, age=75)], year), + household( + [ + dict(adult, age=70), + { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + **reports, + }, + ], + year, + ), + household([dict(adult, age=75, **reports)], year), + ) + ) + assert sim.calculate("esa_income", year).tolist() == [5_000, 5_000, 0, 5_000, 0] + assert sim.calculate("meets_pension_credit_age_conditions", year)[-1] + sim.calculate("household_net_income", year) + assert log.bar_branches() == [] + + +def test_the_branch_closes_and_its_passport_branch_reads_no_award(monkeypatch): + """In 2016 the couple of esa_income_pension_age.yaml case 19 meets the + Pension Credit age conditions with an open report, so the model opens the + branch, and its working tax credit opens the passport branch inside it + (pension_credit_with_passported_tax_credits). That branch reads the + award as nil, set on its parent, and no branch outlives the calculation.""" + log = BranchLog(monkeypatch) + year = 2016 + sim = Simulation( + situation=household( + [ + { + "age": 66, + "is_claimant_or_partner": True, + "employment_income": 10_000, + "hours_worked": 1_040, + "working_tax_credit_reported": 1, + }, + { + "age": 58, + "is_claimant_or_partner": True, + "esa_income_reported": 3_000, + }, + ], + year, + ) + ) + assert sim.calculate("esa_income", year).tolist() == [0] + assert sim.calculate("pension_credit", year) == pytest.approx([370.40], abs=0.01) + bar_branches = log.bar_branches() + assert bar_branches + passports = [ + branch + for parent, branch in log.created + if any(parent is bar for _, bar in bar_branches) + and branch.branch_name.startswith("tax_credits_pension_credit_passport") + ] + assert passports + for passport in passports: + for variable in ["esa_income", "claimant_or_partner_esa_income"]: + assert passport.calculate(variable, year).tolist() == [0], variable + for parent, branch in bar_branches: + assert all(other is not branch for other in parent.branches.values()) + assert not [ + name for name in sim.branches if name.startswith(NO_INCOME_RELATED_ESA_BRANCH) + ] + + +# The man of esa_income_pension_age.yaml cases 7 and 8, born 1 March 1953: in +# 2016 he meets the Pension Credit age conditions, under his pensionable age. +# A man aged 40 does not meet them. +MAN_63 = { + "age": 63, + "date_of_birth": 19530301, + "gender": "MALE", + "is_claimant_or_partner": True, + "esa_income_reported": 5_000, +} +MAN_40 = dict(MAN_63, age=40, date_of_birth=19760301) +ADULT = {"is_claimant_or_partner": True} + + +def no_branch_opens_beside(log): + return log.bar_branches() == [] + + +def test_an_entered_pension_credit_award_bars_the_award_whatever_the_age_and_take_up(): + """Three men in 2016 each have £2,000 of Pension Credit as an input: the + man aged 63 who would claim Pension Credit, the same man who would not, + and a man aged 40. Each has an entitlement to that Pension Credit (Sch 1 + para 6(1)(c)), so none gets income-related ESA (esa_income_pension_age.yaml + case 21).""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year, would_claim_pc=True, pension_credit=2_000), + household([MAN_63], year, would_claim_pc=False, pension_credit=2_000), + household([MAN_40], year, pension_credit=2_000), + ) + ) + assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + True, + True, + False, + ] + assert sim.calculate("pension_credit", year).tolist() == [2_000] * 3 + assert sim.calculate("esa_income", year).tolist() == [0] * 3 + assert sim.calculate("claimant_or_partner_esa_income", year).tolist() == [0] * 3 + + +class flat_pension_credit(Reform): + """Pension Credit of £2,000 for every benefit unit.""" + + def apply(self): + class pension_credit(Variable): + label = "Pension Credit" + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return benunit.filled_array(2_000) + + self.update_variable(pension_credit) + + +def test_a_reform_that_pays_pension_credit_to_all_bars_the_award(monkeypatch): + """Under a reform that pays every benefit unit £2,000 of Pension Credit, + the man aged 63 who would claim it, the same man who would not and a man + aged 40 each have an entitlement to it, so none gets income-related ESA, + and the model reads that Pension Credit for all three.""" + log = BranchLog(monkeypatch) + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year), + household([MAN_63], year, would_claim_pc=False), + household([MAN_40], year), + ), + reform=flat_pension_credit, + ) + assert sim.calculate("pension_credit", year).tolist() == [2_000] * 3 + assert sim.calculate("esa_income", year).tolist() == [0] * 3 + assert log.bar_branches() + assert not pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + + +class pension_credit_by_another_formula(Reform): + """The model's Pension Credit, calculated by a formula of the reform's.""" + + def apply(self): + class pension_credit(Variable): + label = "Pension Credit" + value_type = float + entity = BenUnit + definition_period = YEAR + unit = GBP + + def formula(benunit, period, parameters): + return model_pension_credit.formula(benunit, period, parameters) + + self.update_variable(pension_credit) + + +OUTPUTS = [ + "esa_income", + "claimant_or_partner_esa_income", + "esa_income_eligible", + "pension_credit", + "working_tax_credit", + "household_net_income", +] + + +@settings( + max_examples=2, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(examples()) +@example((ROWS, 2016)) +@example((ROWS_2018, 2018)) +def test_reading_pension_credit_for_every_couple_matches_the_model(drawn): + """Differential: a reform that calculates the model's Pension Credit by a + formula of its own makes the screen read Pension Credit without the award + for every couple whose claim passes the other tests. It must give every + family the same ESA, Pension Credit and income as the model, which reads it + only for a couple that meets the age conditions and would claim.""" + units, year = drawn + event(f"year {year}") + people = situation(units, year, [True, False]) + model = Simulation(situation=people) + reformed = Simulation(situation=people, reform=pension_credit_by_another_formula) + parameters = model.tax_benefit_system.parameters + assert pension_credit_follows_model_formulas( + model.populations["benunit"], period_(year), parameters + ) + assert not pension_credit_follows_model_formulas( + reformed.populations["benunit"], period_(year), parameters + ) + for variable in OUTPUTS: + assert np.allclose( + model.calculate(variable, year), + reformed.calculate(variable, year), + atol=0.01, + ), (variable, units) + + +def test_a_parameter_reform_keeps_the_age_and_take_up_conditions(monkeypatch): + """A reform that lifts the exclusion of mixed-age couples in 2026 keeps + the model's formulas, and meets_pension_credit_age_conditions reads its + parameters. A couple aged 70 and 60 without the saving then meets the + age conditions, so the model reads its Pension Credit and refuses the + younger member's award. With only a working-age claimant, no couple meets + them, and the model opens no branch, under the reform or without it. Each + reporter has the appointee saving (SI 2025/1148 art. 3A(2)), so the model + keeps each report open for the whole of 2026-27 (esa_income_payable_share + is 1).""" + log = BranchLog(monkeypatch) + year = 2026 + reform = { + "gov.dwp.pension_credit.mixed_age_couples.excluded": { + f"{year}-01-01.{year}-12-31": False + } + } + working_age = household( + [dict(ADULT, age=45, esa_income_reported=5_000, **SAVED)], year + ) + mixed = household( + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000, **SAVED)], + year, + has_mixed_age_couple_pension_credit_saving=False, + ) + + alone = Simulation(situation=combine(working_age), reform=reform) + assert pension_credit_follows_model_formulas( + alone.populations["benunit"], period_(year), alone.tax_benefit_system.parameters + ) + assert alone.calculate("esa_income", year).tolist() == [5_000] + assert no_branch_opens_beside(log) + + baseline = Simulation(situation=combine(working_age, mixed)) + assert baseline.calculate("esa_income", year).tolist() == [5_000, 5_000] + assert baseline.calculate("pension_credit", year).tolist() == [0, 0] + assert no_branch_opens_beside(log) + + lifted = Simulation(situation=combine(working_age, mixed), reform=reform) + assert follows_model_formulas(lifted, year) + assert lifted.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + False, + True, + ] + # The couple's minimum guarantee for 52 weeks in 2026-27: 52 x 363.25. + assert lifted.calculate("pension_credit", year) == pytest.approx( + [0, 18_889], abs=0.01 + ) + assert lifted.calculate("esa_income", year).tolist() == [5_000, 0] + assert log.bar_branches() + + +def test_a_frozen_pension_credit_bars_the_award_whatever_the_reform_age_conditions(): + """In 2018 a couple aged 70 and 60 meets the Pension Credit age + conditions, so gets Pension Credit and no income-related ESA. A reform + that brings the exclusion of mixed-age couples forward to 2018 and freezes + Pension Credit at the baseline's award (gov.contrib.freeze_pension_credit) + still pays that award, though the couple no longer meets the reform's age + conditions, so the couple still gets no income-related ESA.""" + year = 2018 + reform = { + "gov.dwp.pension_credit.mixed_age_couples.excluded": { + f"{year}-01-01.{year}-12-31": True + }, + "gov.contrib.freeze_pension_credit": {f"{year}-01-01.{year}-12-31": True}, + } + sim = Simulation( + situation=household( + [dict(ADULT, age=70), dict(ADULT, age=60, esa_income_reported=5_000)], + year, + has_mixed_age_couple_pension_credit_saving=False, + ), + reform=reform, + ) + assert not sim.calculate("meets_pension_credit_age_conditions", year)[0] + assert sim.baseline.calculate("esa_income", year).tolist() == [0] + # The couple's minimum guarantee for 52 weeks in 2018-19: 52 x 248.80. + frozen = sim.baseline.calculate("pension_credit", year) + assert frozen == pytest.approx([12_937.60], abs=0.01) + assert sim.calculate("pension_credit", year) == pytest.approx(frozen, abs=0.01) + assert not pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + assert sim.calculate("esa_income", year).tolist() == [0] + + +def test_each_entered_part_of_pension_credit_drops_the_age_and_take_up_conditions(): + """An input for pension_credit, its entitlement, its eligibility or either + credit can pay Pension Credit outside the age and take-up conditions, so + each makes the model read Pension Credit for every couple.""" + year = 2016 + sim = Simulation(situation=household([MAN_40], year)) + parameters = sim.tax_benefit_system.parameters + assert pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), parameters + ) + for variable, value in [ + ("pension_credit", 2_000), + ("pension_credit_entitlement", 2_000), + ("is_pension_credit_eligible", True), + ("guarantee_credit", 2_000), + ("savings_credit", 2_000), + ]: + branch = sim.get_branch(f"entered_{variable}") + try: + branch.set_input(variable, year, np.array([value])) + assert not pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year), parameters + ), variable + # An input for another year leaves this year to the formulas. + assert pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year + 1), parameters + ), variable + finally: + sim.branches.pop(f"entered_{variable}", None) + assert pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), parameters + ) + + +def follows_model_formulas(sim, year): + return pension_credit_follows_model_formulas( + sim.populations["benunit"], period_(year), sim.tax_benefit_system.parameters + ) + + +class entitlement_with_a_default(Reform): + """The model's Pension Credit entitlement, which gives £2,000 to a family + outside its defined_for.""" + + def apply(self): + class pension_credit_entitlement(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + default_value = 2_000 + defined_for = "is_pension_credit_eligible" + adds = ["guarantee_credit", "savings_credit"] + + self.update_variable(pension_credit_entitlement) + + +class payment_with_a_default(Reform): + """The model's Pension Credit formula for a family eligible for Pension + Credit, and £2,000 for any other.""" + + def apply(self): + class pension_credit(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + default_value = 2_000 + defined_for = "is_pension_credit_eligible" + formula = model_pension_credit.formula + + self.update_variable(pension_credit) + + +class eligibility_with_a_default(Reform): + """The model's eligibility formula for a family that meets the age + conditions, and eligibility for any other.""" + + def apply(self): + class is_pension_credit_eligible(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + default_value = True + defined_for = "meets_pension_credit_age_conditions" + formula = model_eligibility.formula + + self.update_variable(is_pension_credit_eligible) + + +class guarantee_credit_for_every_family_it_tests(Reform): + """The model's guarantee credit and entitlement for every family with + income within the minimum guarantee (is_guarantee_credit_eligible), not + only one eligible for Pension Credit. Only their coverage changes.""" + + def apply(self): + class guarantee_credit(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + defined_for = "is_guarantee_credit_eligible" + formula = model_guarantee_credit.formula + + class pension_credit_entitlement(Variable): + value_type = float + entity = BenUnit + definition_period = YEAR + defined_for = "is_guarantee_credit_eligible" + adds = ["guarantee_credit", "savings_credit"] + + self.update_variable(guarantee_credit) + self.update_variable(pension_credit_entitlement) + + +class model_pension_credit_registered_again(Reform): + """The model's own five Pension Credit variables, registered again.""" + + def apply(self): + for variable in PENSION_CREDIT_VARIABLES: + self.update_variable(variable) + + +@pytest.mark.parametrize( + "reform, follows, pension_credit_at_40, esa", + [ + (entitlement_with_a_default, False, 2_000, [5_000, 0, 0]), + ( + (entitlement_with_a_default, pension_credit_by_another_formula), + False, + 2_000, + [5_000, 0, 0], + ), + (payment_with_a_default, False, 2_000, [5_000, 0, 0]), + (eligibility_with_a_default, False, 8_091.20, [5_000, 0, 0]), + (guarantee_credit_for_every_family_it_tests, False, 8_091.20, [5_000, 0, 0]), + (model_pension_credit_registered_again, True, 0, [5_000, 5_000, 0]), + ], + ids=[ + "entitlement_default", + "entitlement_default_another_formula", + "payment_default", + "eligibility_default", + "guarantee_credit_coverage", + "model_definitions_again", + ], +) +def test_a_reform_that_pays_outside_the_age_conditions_bars_the_award( + reform, follows, pension_credit_at_40, esa +): + """In 2016 the man aged 63 who would not claim Pension Credit gets none + and keeps his £5,000 award, and the man aged 63 who would claim gets the + £8,091.20 minimum guarantee (52 x £155.60) and no award. The man aged 40 + does not meet the age conditions. Under each reform but the last, the + model pays him £2,000 of Pension Credit or the minimum guarantee outside + the definition's defined_for, so he has an entitlement to it (Sch 1 para + 6(1)(c)) and gets no award. The model's own definitions, registered + again, keep the shortcut, and he keeps his £5,000.""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year, would_claim_pc=False), + household([MAN_40], year), + household([MAN_63], year), + ), + reform=reform, + ) + assert sim.calculate("esa_income", year).tolist() == esa + assert sim.calculate("pension_credit", year) == pytest.approx( + [0, pension_credit_at_40, 8_091.20], abs=0.01 + ) + assert sim.calculate("meets_pension_credit_age_conditions", year).tolist() == [ + True, + False, + True, + ] + assert follows_model_formulas(sim, year) is follows + + +class take_up_without_income_related_esa(Reform): + """A family claims Pension Credit while it gets no income-related ESA.""" + + def apply(self): + class would_claim_pc(Variable): + value_type = bool + entity = BenUnit + definition_period = YEAR + + def formula(benunit, period, parameters): + return benunit("esa_income", period) == 0 + + self.update_variable(would_claim_pc) + + +@pytest.mark.parametrize( + "reform", + [ + take_up_without_income_related_esa, + (take_up_without_income_related_esa, pension_credit_by_another_formula), + ], + ids=["take_up_formula", "take_up_formula_another_formula"], +) +def test_a_take_up_formula_makes_the_model_read_pension_credit_for_every_couple( + reform, +): + """Take-up that reads the family's ESA would close a cycle if the screen + read take-up in the simulation itself, so the model reads Pension Credit + without the award for every couple. Without the award both men aged 63 + in 2016 would claim and get the £8,091.20 minimum guarantee, so neither + gets the award, and both then claim. The man aged 40 gets no Pension + Credit, so he keeps his £5,000 and does not claim.""" + year = 2016 + sim = Simulation( + situation=combine( + household([MAN_63], year), + household([MAN_40], year), + household([MAN_63], year), + ), + reform=reform, + ) + assert sim.calculate("esa_income", year).tolist() == [0, 5_000, 0] + assert sim.calculate("pension_credit", year) == pytest.approx( + [8_091.20, 0, 8_091.20], abs=0.01 + ) + assert sim.calculate("would_claim_pc", year).tolist() == [True, False, True] + assert not follows_model_formulas(sim, year) + + +def wrapped(formula): + def wrapper(entity, period, parameters): + return formula(entity, period, parameters) + + return wrapper + + +def changes(variable): + """For each attribute a reform can change, the attribute, a new value and + whether the change can alter what Core calculates.""" + formula = wrapped(variable.get_formula() or model_pension_credit.formula) + default = True if variable.value_type is bool else 2_000.0 + return [ + ("default_value", default, True), + ("defined_for", "is_guarantee_credit_eligible", True), + ("is_neutralized", True, True), + ("formulas", {"0001-01-01": formula}, True), + ("adds", ["guarantee_credit"], True), + ("definition_period", "month", True), + ("end", period_(2010).start.date, True), + ("label", "Changed", False), + ("documentation", "Changed.", False), + ("reference", ("https://www.legislation.gov.uk/ukpga/2002/16",), False), + ("unit", "currency-USD", False), + ("metadata", {"changed": True}, False), + ] + + +# The five Pension Credit variables, named apart from the model's own list. +PENSION_CREDIT_NAMES = [ + "pension_credit", + "pension_credit_entitlement", + "is_pension_credit_eligible", + "guarantee_credit", + "savings_credit", +] + + +def test_every_change_to_a_pension_credit_definition_drops_the_shortcut(): + """Exhaustive over the five Pension Credit variables and the attributes a + reform can change: any change to what Core calculates a variable from + drops the age and take-up conditions, and a change to its description + alone keeps them. A take-up formula drops them too, and a take-up input or + default keeps them, since the screen reads take-up itself.""" + year = 2016 + sim = Simulation(situation=household([MAN_40], year)) + system = sim.tax_benefit_system + assert follows_model_formulas(sim, year) + for name in PENSION_CREDIT_NAMES: + variable = system.variables[name] + for attribute, value, calculates in changes(variable): + changed = variable.clone() + if attribute == "formulas": + value = type(changed.formulas)(value) + setattr(changed, attribute, value) + system.variables[name] = changed + try: + assert follows_model_formulas(sim, year) is not calculates, ( + name, + attribute, + ) + finally: + system.variables[name] = variable + take_up = system.variables["would_claim_pc"] + for attribute, value, follows in [ + ("formulas", {"0001-01-01": lambda benunit, period, parameters: True}, False), + ("default_value", False, True), + ]: + changed = take_up.clone() + if attribute == "formulas": + value = type(changed.formulas)(value) + setattr(changed, attribute, value) + system.variables["would_claim_pc"] = changed + try: + assert follows_model_formulas(sim, year) is follows, attribute + finally: + system.variables["would_claim_pc"] = take_up + branch = sim.get_branch("entered_take_up") + try: + branch.set_input("would_claim_pc", year, np.array([False])) + assert pension_credit_follows_model_formulas( + branch.populations["benunit"], period_(year), system.parameters + ) + finally: + sim.branches.pop("entered_take_up", None) + assert follows_model_formulas(sim, year) + + +class ScreenReads: + """Records each variable the income-related ESA screen reads, with the + branch and year it reads in. A branch can change the family: the one that + works out Pension Credit without employment income gives a claimant in + paid work no earnings, so their claim can pass the work test there. So + can a later year: the model also runs the screen for 2027 to 2032 here, + and the situation enters earnings and hours for 2026 only.""" + + def __init__(self, monkeypatch): + self.reads = [] + calculate = CoreSimulation.calculate + + def logged(simulation, variable_name, *args, **kwargs): + stack = simulation.tracer.stack + if stack and stack[-1]["name"] == "esa_income_eligible": + frame = stack[-1] + self.reads.append( + (simulation.branch_name, str(frame["period"]), variable_name) + ) + return calculate(simulation, variable_name, *args, **kwargs) + + monkeypatch.setattr(CoreSimulation, "calculate", logged) + + def variables(self, year): + """What the screen read in the simulation itself, for year.""" + return { + variable + for branch, period, variable in self.reads + if branch == "default" and period == str(year) + } + + +PENSION_CREDIT_CONDITIONS = {"meets_pension_credit_age_conditions", "would_claim_pc"} + + +def test_the_screen_reads_no_pension_credit_condition_without_a_couple_claim( + monkeypatch, +): + """In 2026 no couple here has a claim that passes the age, work and + capital tests: a pensioner reporting the award, a claimant aged 45 in + remunerative work, and a member outside the couple aged 40 reporting + beside a pensioner, whose own claim passes. The screen then reads neither + the Pension Credit age conditions nor the claim. A claimant aged 45 out of + work passes, and the screen reads both. Each reporter has the appointee + saving (SI 2025/1148 art. 3A(2)), so the model keeps each report open for + the whole of 2026-27 (esa_income_payable_share is 1).""" + reads = ScreenReads(monkeypatch) + year = 2026 + no_couple_claim = [ + household([dict(ADULT, age=75, esa_income_reported=5_000, **SAVED)], year), + household( + [ + dict( + ADULT, + age=45, + esa_income_reported=5_000, + employment_income=15_000, + hours_worked=1_560, + **SAVED, + ) + ], + year, + ), + household( + [ + dict(ADULT, age=70), + { + "age": 40, + "is_claimant_or_partner": False, + "current_education": "NOT_IN_EDUCATION", + "esa_income_reported": 5_000, + **SAVED, + }, + ], + year, + ), + ] + sim = Simulation(situation=combine(*no_couple_claim)) + assert sim.calculate("esa_income_eligible", year).tolist() == [False, False, True] + assert "esa_income_assessable_capital" in reads.variables(year) + assert not PENSION_CREDIT_CONDITIONS & reads.variables(year), reads.reads + + reads.reads.clear() + out_of_work = household( + [dict(ADULT, age=45, esa_income_reported=5_000, **SAVED)], year + ) + sim = Simulation(situation=combine(*no_couple_claim, out_of_work)) + assert sim.calculate("esa_income_eligible", year).tolist() == [ + False, + False, + True, + True, + ] + assert PENSION_CREDIT_CONDITIONS <= reads.variables(year) diff --git a/policyengine_uk/tests/test_gift_aid.py b/policyengine_uk/tests/test_gift_aid.py index 34d7bb182c..91b4d18244 100644 --- a/policyengine_uk/tests/test_gift_aid.py +++ b/policyengine_uk/tests/test_gift_aid.py @@ -299,23 +299,33 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): "personal_allowance": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, "allowances": [12_570, 12_570, 0, 12_570, 12_570, 12_070] * 2, "unused_personal_allowance": [0, 0, 0, 0, 0, 0] * 2, - "earned_taxable_income": [17_430, 47_430, 150_000, 17_430, 0, 88_930] * 2, - "received_allowances_earned_income": [12_570, 12_570, 0, 12_570, 10_000, 12_070] - * 2, - "received_allowances_savings_income": [0, 0, 0, 0, 2570, 0] * 2, + # The fourth row outside Scotland sets £1,300 of the Personal + # Allowance against pay and £11,270 against savings (ITA 2007 + # s. 25(2), #2106): pay and property reach the £37,700 basic rate + # limit, and the £500 savings allowance covers savings that would + # bear 40%, so its tax falls by £100. In Scotland the pay above + # £31,092 would bear 42%, so the model keeps the old order. + "earned_taxable_income": [17_430, 47_430, 150_000, 28_700, 0, 88_930] + + [17_430, 47_430, 150_000, 17_430, 0, 88_930], + "received_allowances_earned_income": [12_570, 12_570, 0, 1_300, 10_000, 12_070] + + [12_570, 12_570, 0, 12_570, 10_000, 12_070], + "received_allowances_savings_income": [0, 0, 0, 11_270, 2570, 0] + + [0, 0, 0, 0, 2570, 0], "received_allowances_dividend_income": [0, 0, 0, 0, 0, 0] * 2, - "basic_rate_earned_income": [17_430, 37_700, 37_700, 17_430, 0, 37_700] * 2, + "basic_rate_earned_income": [17_430, 37_700, 37_700, 28_700, 0, 37_700] + + [17_430, 37_700, 37_700, 17_430, 0, 37_700], "higher_rate_earned_income": [0, 9730, 87_440, 0, 0, 51_230] * 2, "add_rate_earned_income": [0, 0, 24_860, 0, 0, 0] * 2, - "basic_rate_savings_income": [0, 0, 0, 10_770, 11_430, 0] * 2, - "higher_rate_savings_income": [0, 0, 0, 730, 0, 0] * 2, + "basic_rate_savings_income": [0, 0, 0, 0, 11_430, 0] + + [0, 0, 0, 10_770, 11_430, 0], + "higher_rate_savings_income": [0, 0, 0, 230, 0, 0] + [0, 0, 0, 730, 0, 0], "add_rate_savings_income": [0, 0, 0, 0, 0, 0] * 2, "savings_allowance": [1000, 500, 0, 500, 1000, 500] * 2, "earned_income_tax": [ 3486, 11_432, 53_703, - 3486, + 5740, 0, 28_032, 3451.070068359375, @@ -325,14 +335,14 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): 0, 31407.05078125, ], - "savings_income_tax": [0, 0, 0, 2446, 2286, 0] * 2, + "savings_income_tax": [0, 0, 0, 92, 2286, 0] + [0, 0, 0, 2446, 2286, 0], "dividend_income_tax": [0, 0, 0, 6256.25, 1021.25, 0] * 2, "property_income_tax": [0, 0, 0, 1800, 0, 0, 0, 0, 0, 1890, 0, 0], "income_tax": [ 3486, 11_432, 53_703, - 13988.25, + 13888.25, 3307.25, 28_032, 3451.070068359375, @@ -371,7 +381,12 @@ def test_zero_gift_preserves_baseline_outputs_bit_for_bit(): "HIGHER", "ADDITIONAL", "HIGHER", - "INTERMEDIATE", + # The model allocates the allowance to all 10,000 of property + # income, so no income bears a Scottish rate (ITA 2007 s. 11A). The + # remaining 27,430 of savings and dividends falls below the 37,700 + # UK basic rate limit (ss. 11D(6) and 13(5)), so tax_band returns + # BASIC (#2350). + "BASIC", "ADDITIONAL", ] diff --git a/policyengine_uk/tests/test_household_head_agreement_properties.py b/policyengine_uk/tests/test_household_head_agreement_properties.py index a59afeefe9..061b96e9c7 100644 --- a/policyengine_uk/tests/test_household_head_agreement_properties.py +++ b/policyengine_uk/tests/test_household_head_agreement_properties.py @@ -28,13 +28,17 @@ if and only if their family is neither the head's nor liable for rent. - Universal Credit non-dependants: at most one family in a household counts them (uc_non_dependants_counted; UC Regs 2013 Sch 4 para - 9(2)(f)). Pin: deductions fall only on that family's claim, and where - the head's family is liable for the rent and claims Universal Credit, - it is the head's. Only families with a share of the rent have Housing - Benefit non-dependant deductions. + 9(2)(f)). Pin: other families' non-dependants fall only on that + family's claim, and a family's own in-unit non-dependants on its own + claim; where the head's family is liable for the rent and claims + Universal Credit, the counting claim is the head's. Only families with + a share of the rent have Housing Benefit non-dependant deductions, + except a boarder or lodger for a non-dependant in its own benefit unit. - Council Tax Reduction: only the head's family and sharers claim; where the head is 18 or over, the head's family claims and no one in it is a - non-dependant. + non-dependant except a member who is outside the applicant's family + (is_benefit_unit_non_dependant_for_council_tax_reduction; SI 2012/2885 + reg 9(2)(a)). - A family paying the householder as boarders or lodgers is never the head's, and the maintenance loan never has the head living with parents. 4. Metamorphic: every value the simulation computes, other than the @@ -412,7 +416,11 @@ def test_every_programme_reads_one_head(population): # counts the household's non-dependants: the head's family's wherever it # is liable for the rent and claims. counted = calc(sim, "uc_non_dependants_counted") - assert np.all(counted[calc(sim, "uc_non_dep_deductions") > 0]) + # A family's own in-unit non-dependants count in its own claim. + has_uc_in_unit = any_in_benunit( + calc(sim, "is_benefit_unit_non_dependant_for_universal_credit"), facts + ) + assert np.all((counted | has_uc_in_unit)[calc(sim, "uc_non_dep_deductions") > 0]) household_of_benunit = np.zeros(head_family.size, dtype=int) household_of_benunit[benunit_of] = facts["household"] assert np.all(sum_in_household(counted, household_of_benunit) <= 1) @@ -426,7 +434,16 @@ def test_every_programme_reads_one_head(population): in_household = head_claims_uc[household_of_benunit] assert np.array_equal(counted[in_household], head_family[in_household]) # Housing Benefit non-dependant deductions. - assert np.all(calc(sim, "housing_benefit_non_dep_deductions")[share == 0] == 0) + # A boarder or lodger bears a non-dependant in its own benefit unit. + has_legacy_in_unit = any_in_benunit( + calc(sim, "is_benefit_unit_non_dependant_for_legacy_benefits"), facts + ) + assert np.all( + calc(sim, "housing_benefit_non_dep_deductions")[ + (share == 0) & ~has_legacy_in_unit + ] + == 0 + ) # Council Tax Reduction. claimant = calc(sim, "council_tax_reduction_claimant_benunit") assert not np.any(claimant & ~head_family & ~sharer) @@ -435,7 +452,8 @@ def test_every_programme_reads_one_head(population): ctr_non_dependant = calc( sim, "council_tax_reduction_individual_non_dep_deduction_eligible" ) - assert not np.any(ctr_non_dependant & adult_head_family[benunit_of]) + ctr_in_unit = calc(sim, "is_benefit_unit_non_dependant_for_council_tax_reduction") + assert not np.any(ctr_non_dependant & adult_head_family[benunit_of] & ~ctr_in_unit) # Boarders and lodgers; the maintenance loan. assert not np.any(calc(sim, "pays_rent_to_householder") & person_head_family) arrangement = calc(sim, "maintenance_loan_living_arrangement") diff --git a/policyengine_uk/tests/test_housing_benefit_non_dep_history.py b/policyengine_uk/tests/test_housing_benefit_non_dep_history.py index 47d4e369b0..0c45be6875 100644 --- a/policyengine_uk/tests/test_housing_benefit_non_dep_history.py +++ b/policyengine_uk/tests/test_housing_benefit_non_dep_history.py @@ -42,7 +42,7 @@ def test_all_historical_scale_cells(year, thresholds, amounts): # SI 2015/457 art 18(3); SI 2016/242 reg 5; SI 2017/260 art 22(4); # SI 2018/281 arts 1(2)(h), 23(3) and 24(3). - # Test the parameter itself, not the separate #2007 eligibility/banding fix. + # Test the parameter itself, not eligibility or banding. scale = load_parameter_file(str(PATH), "hb_non_dep_scale")(f"{year}-04-30") assert scale.thresholds == thresholds assert scale.amounts == amounts diff --git a/policyengine_uk/tests/test_income_support_eligibility_properties.py b/policyengine_uk/tests/test_income_support_eligibility_properties.py index cb3319414a..3eac1ea7dc 100644 --- a/policyengine_uk/tests/test_income_support_eligibility_properties.py +++ b/policyengine_uk/tests/test_income_support_eligibility_properties.py @@ -58,7 +58,12 @@ model's reported or proxy inputs; it reads the qualifying age (has_attained_state_pension_credit_qualifying_age) and the model's proxy for being on Pension Credit (meets_pension_credit_age_conditions and -would_claim_pc) from the model; and the means test is out of scope. No +would_claim_pc) from the model, and, for the income-related ESA screen, +pensionable age (is_SP_age; WRA 2007 s.1(3)(c)) and whether the model pays +the couple Pension Credit (Sch 1 para 6(1)(c) and (d)): no one drawn here has +earnings, and in 2025 no one has tax credits, so the model's pension_credit is the same +with or without the couple's income-related ESA; and the means test is out of +scope. No claimant or partner drawn here reports Pension Credit or Housing Benefit (only some added adults do), so in 2025 no family with a claimant or partner under the qualifying age meets the Pension Credit age conditions. The @@ -211,8 +216,10 @@ def families(draw): def excluded_members(draw): """An adult who is neither claimant, partner nor in the family. - They are not in education, so a 16 to 19 year old is not a qualifying - young person. Most are primed with what barred or opened the claim when + They are not in education and past the 31 August following their 16th + birthday (Child Benefit (General) Regulations 2006 reg. 4), so a 16 to 19 + year old is not a qualifying young person. Most are primed with what + barred or opened the claim when every member counted: over state pension age, income-related ESA, an Income Support report, or caring. Some are primed with what the s.124(1)(g) proxy reads for a couple: over the qualifying age, with @@ -221,6 +228,7 @@ def excluded_members(draw): adult = { **draw(adult_inputs(min_age=16)), "current_education": "NOT_IN_EDUCATION", + "is_before_first_september_following_16th_birthday": False, } primed = draw( st.sampled_from(["random", "elderly", "esa", "award", "carer", "pension"]) @@ -396,8 +404,14 @@ def reference_eligibility( attained_qualifying_age, on_pension_credit, parameters, + entitled_to_pension_credit=False, ): - """The model's Income Support gate, read family by family.""" + """The model's Income Support gate, read family by family. + + on_pension_credit is the model's proxy for s.124(1)(g); + entitled_to_pension_credit records whether the model pays the couple + Pension Credit, which the income-related ESA screen reads (WRA 2007 Sch 1 + para 6(1)(c) and (d)).""" IS = parameters.gov.dwp.income_support WORK = IS.eligibility.remunerative_work JSA = parameters.gov.dwp.JSA.income @@ -448,7 +462,13 @@ def is_claimant(i): sum(a["esa_income_reported"] for a in adults), capital, parameters.gov.dwp.ESA.income.capital, - ) and esa_screen(adults, [], capital, parameters) + ) and esa_screen( + adults, + [], + capital, + parameters, + on_pension_credit=entitled_to_pension_credit, + ) if jsa_income is not None: # Within half a penny of zero is no award. income_based_jsa = jsa_income > 0.005 @@ -511,9 +531,23 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): on_pension_credit = sim.calculate( "meets_pension_credit_age_conditions", YEAR ) & sim.calculate("would_claim_pc", YEAR) + # Entitlement for the ESA screen: the couple would get Pension Credit. + # Without earnings or tax credits, DWP would pay the same Pension Credit + # with the couple's award as without. + entitled = on_pension_credit & (sim.calculate("pension_credit", YEAR) > 0) + pensionable_age = sim.calculate("is_SP_age", YEAR) parameters = sim.tax_benefit_system.parameters(YEAR) start = 0 for i, (adults, dependants, capital, extra) in enumerate(units): + # The ESA screen reads each member's pensionable age from the model. + adults = [ + dict(adult, is_SP_age=pensionable_age[start + j]) + for j, adult in enumerate(adults) + ] + if extra is not None: + extra = dict( + extra, is_SP_age=pensionable_age[start + len(adults) + len(dependants)] + ) # An esa_income or jsa_income the reported amounts explain is read # through them, like a calculated one. entered_esa = entered_jsa = None @@ -529,7 +563,13 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): reported_total, capital, parameters, - screen=esa_screen(adults, others, capital, parameters), + screen=esa_screen( + adults, + others, + capital, + parameters, + on_pension_credit=entitled[i], + ), ): entered_esa = esa_income[i] if jsa_income is not None: @@ -554,6 +594,7 @@ def test_is_eligibility_matches_a_family_by_family_reading(drawn, data): attained_qualifying_age[start : start + len(adults)], on_pension_credit[i], parameters, + entitled_to_pension_credit=entitled[i], ) assert eligible[i] == expected, units[i] # The docstring's claim: no family drawn here with a claimant or diff --git a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py index ff2edaeab4..05f1fd1794 100644 --- a/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py +++ b/policyengine_uk/tests/test_income_support_gate_reads_claimant_or_partner_awards.py @@ -62,6 +62,9 @@ def reference_report_awards(benunit, period, award): "pension_credit_reported", "esa_income_reported", "jsa_income_reported", + # The ESA reference reads the model's pensionable age (WRA 2007 + # s.1(3)(c)). + "is_SP_age", ] arrays = {name: person(name, period) for name in names} roles = person("is_claimant_or_partner", period) @@ -76,6 +79,29 @@ def reference_report_awards(benunit, period, award): is_esa = award == "esa_income" rules = parameters.gov.dwp.ESA.income if is_esa else parameters.gov.dwp.JSA.income screen = reference.esa_screen if is_esa else reference.jsa_screen + if is_esa: + # Sch 1 para 6(1)(c) and (d): the couple would claim State Pension + # Credit and DWP would pay it. No one here has earnings, and in 2025 no + # one has tax credits, so DWP would pay the same Pension Credit with + # the couple's award as without. An outside member keeps their own + # claim whatever the couple's Pension Credit. + on_pension_credit = ( + benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + & (benunit("pension_credit", period) > 0) + ) + + def passes(members, unit, couple): + if not is_esa: + return screen(members, [], capitals[unit], parameters) + return screen( + members, + [], + capitals[unit], + parameters, + on_pension_credit=couple and on_pension_credit[unit], + ) + full, scoped = [], [] for unit in range(benunit.count): ids = np.flatnonzero(benunit.members_entity_id == unit) @@ -92,13 +118,13 @@ def reference_report_awards(benunit, period, award): active = is_esa or parameters.gov.dwp.JSA.income.active couple_amount = ( max(0, sum(member[f"{award}_reported"] for member in couple) - tariff) - if active and screen(couple, [], capital, parameters) + if active and passes(couple, unit, couple=True) else 0 ) outside_amount = sum( max(0, member[f"{award}_reported"] - tariff) for member in others - if active and screen([member], [], capital, parameters) + if active and passes([member], unit, couple=False) ) full.append(couple_amount + outside_amount) scoped.append(couple_amount) diff --git a/policyengine_uk/tests/test_income_tax_allocation.py b/policyengine_uk/tests/test_income_tax_allocation.py new file mode 100644 index 0000000000..d4d2b64896 --- /dev/null +++ b/policyengine_uk/tests/test_income_tax_allocation.py @@ -0,0 +1,737 @@ +"""Property tests for setting Income Tax reliefs and allowances against income +in the way that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, +s. 25(3A); #2106). + +The invariants, for every person without Marriage Allowance, on the Step 5 +tax (income_tax_pre_charges), the only tax the share changes: + +1. Least tax: no share of the deductions between the components s. 25 lets + them come off gives less Step 5 tax than the model's. A brute-force oracle + forces each share on a grid through the model's own variables. +2. The tax never exceeds what the order non-savings, savings, dividends + charges, which is the order the model used before #2106. +3. The deductions set against the components add up to the deductions used, + min(relief + allowances, income), and none exceeds its component. +4. With one kind of income there is nothing to share, so the tax and the + deductions are the old order's. +5. The search's own tax function agrees with income_tax_pre_charges on every + share (a differential test of two implementations of Step 5). +6. The vertex search finds the least value of that function, checked against + a dense grid. +7. Fast path: for everyone the tests in _old_order_is_least let through, the + result equals the full search's, bit for bit, and in current law those + tests let through everyone with one kind of income. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.utils.income_tax_allocation import ( + _build, + _context, + _least_tax_share, + _old_order_is_least, + _shares, + default_order_deductions, + least_tax_deductions, + old_order, + step_5_tax, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = (2026, 2027) +PEOPLE = 8 +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] +INCOMES = ( + "employment_income", + "savings_interest_income", + "dividend_income", + "property_income", + "trading_loss", + "gift_aid", +) +ALLOCATION = ( + "received_allowances_earned_income", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "trade_loss_relief_against_savings_and_dividends", +) +VARIABLES = ALLOCATION + ( + "net_income_before_trade_loss_relief", + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "trade_loss_relief_against_general_income", + "allowances", + "income_tax_pre_charges", +) +# Hand-built people for the brute-force oracle: (year, region, incomes). +HAND_BUILT = [ + (2026, "LONDON", dict(savings_interest_income=4_000, dividend_income=20_000)), + (2026, "LONDON", dict(employment_income=50_000, savings_interest_income=10_000)), + (2026, "LONDON", dict(employment_income=50_000, dividend_income=20_000)), + ( + 2026, + "LONDON", + dict( + savings_interest_income=4_000, dividend_income=200_000, trading_loss=1_000 + ), + ), + ( + 2027, + "LONDON", + dict(property_income=20_000, dividend_income=200_000, trading_loss=1_000), + ), + ( + 2026, + "SCOTLAND", + dict( + employment_income=3_000, + savings_interest_income=4_000, + dividend_income=20_000, + ), + ), + ( + 2027, + "LONDON", + dict( + employment_income=9_000, + property_income=30_000, + savings_interest_income=6_000, + dividend_income=15_000, + ), + ), + ( + 2026, + "WALES", + dict( + employment_income=40_000, + savings_interest_income=3_000, + dividend_income=8_000, + gift_aid=2_000, + ), + ), +] +GRID = 21 + +amount = st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=0, max_value=150_000), + st.sampled_from([4_000.0, 6_000.0, 12_570.0, 37_700.0, 50_270.0, 125_140.0]), +) +person = st.tuples(*([amount] * 5), st.floats(min_value=0, max_value=5_000)) +people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) +year = st.sampled_from(YEARS) +SETTINGS = settings( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def situation(count, year, regions): + return { + "people": {f"p{i}": {"age": {year: 40}} for i in range(count)}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(count)}, + "households": { + f"h{i}": {"members": [f"p{i}"], "region": {year: regions[i]}} + for i in range(count) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + """Simulations built once and reused: one for the model's own share and + one with the share forced, for each year and set of regions.""" + cache = {} + + def get(year, regions, forced=False): + key = (year, tuple(regions), forced) + if key not in cache: + cache[key] = Simulation(situation=situation(len(regions), year, regions)) + return cache[key] + + return get + + +def run(sim, year, incomes, forced=None): + count = sim.populations["person"].count + for name in INCOMES: + values = np.asarray(incomes.get(name, 0.0), dtype=float) + sim.set_input(name, year, np.broadcast_to(values, (count,)).copy()) + for name, values in (forced or {}).items(): + sim.set_input(name, year, np.asarray(values, dtype=float)) + sim.reset_calculations() + return sim + + +def results(sim, year): + return {name: sim.calculate(name, year).astype(float) for name in VARIABLES} + + +def parameters_of(sim): + return lambda period: sim.tax_benefit_system.parameters(str(period)) + + +def draw(drawn): + return {name: np.array(values) for name, values in zip(INCOMES, zip(*drawn))} + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 2e-7 * sum(np.abs(v) for v in values) + + +def default_order(sim, year): + default = default_order_deductions(sim.populations["person"], year) + return { + "received_allowances_savings_income": default.savings, + "received_allowances_dividend_income": default.dividends, + } + + +@SETTINGS +@given(year, people) +@example( + 2026, + [(0.0, 4_000.0, 20_000.0, 0.0, 0.0, 0.0), (50_000.0, 10_000.0, 0.0, 0.0, 0.0, 0.0)] + + [(0.0,) * 6] * (PEOPLE - 2), +) +def test_least_tax_never_exceeds_default_order_and_adds_up(sims, year, drawn): + incomes = draw(drawn) + regions = [REGIONS[i % len(REGIONS)] for i in range(PEOPLE)] + law_sim = run(sims(year, regions), year, incomes) + law = results(law_sim, year) + forced = run(sims(year, regions, True), year, incomes, default_order(law_sim, year)) + default = results(forced, year) + + # Invariant 2: never more tax than the order non-savings, savings, dividends. + # The share changes only the Step 5 tax: the Step 6 reductions and Step 7 + # charges do not depend on it. + tax, old_tax = law["income_tax_pre_charges"], default["income_tax_pre_charges"] + assert np.all(tax <= old_tax + tolerance(old_tax)), (tax, old_tax) + + # Invariant 3: the deductions add up and none exceeds its component. + savings = np.maximum(0, law["taxable_savings_interest_income"]) + dividends = np.maximum(0, law["taxable_dividend_income"]) + non_savings = np.maximum( + 0, law["net_income_before_trade_loss_relief"] - savings - dividends + ) + relief = law["trade_loss_relief_against_general_income"] + used = np.minimum(relief + law["allowances"], non_savings + savings + dividends) + to_savings = law["received_allowances_savings_income"] + to_dividends = law["received_allowances_dividend_income"] + relief_left = law["trade_loss_relief_against_savings_and_dividends"] + to_non_savings = law["received_allowances_earned_income"] + relief - relief_left + margin = tolerance(used) + assert np.all(np.abs(to_non_savings + to_savings + to_dividends - used) <= margin) + for share, component in [ + (to_non_savings, non_savings), + (to_savings, savings), + (to_dividends, dividends), + ]: + assert np.all(share >= -margin) and np.all(share <= component + margin) + assert np.all(relief_left <= relief + margin) + assert np.all(relief_left <= to_savings + to_dividends + margin) + + # Invariant 4: one kind of income, nothing to share. + kinds = (non_savings > 0).astype(int) + (savings > 0) + (dividends > 0) + single = kinds <= 1 + np.testing.assert_allclose(tax[single], old_tax[single], atol=0.01) + for name in ALLOCATION: + np.testing.assert_allclose(law[name][single], default[name][single], atol=0.01) + + +@pytest.mark.parametrize( + "year, region, incomes", + HAND_BUILT, + ids=[ + f"{year}-{region}-" + "-".join(incomes) for year, region, incomes in HAND_BUILT + ], +) +def test_tax_equals_brute_force_grid_minimum(sims, year, region, incomes): + law_sim = run(sims(year, [region]), year, incomes) + law = results(law_sim, year) + c = _context(law_sim.populations["person"], year, parameters_of(law_sim)) + savings, dividends = c.savings[0, 0], c.dividends[0, 0] + used, non_savings = c.used[0, 0], c.non_savings[0, 0] + # From 2027-28 s. 25(3A) sets the deductions against other income first. + at_least_non_savings = used - c.shared[0, 0] if c.other_first else 0 + to_savings, to_dividends = ( + grid.ravel() + for grid in np.meshgrid( + np.linspace(0, min(savings, used), GRID), + np.linspace(0, min(dividends, used), GRID), + ) + ) + to_non_savings = used - to_savings - to_dividends + feasible = (to_non_savings >= at_least_non_savings - 1e-6) & ( + to_non_savings <= non_savings + 1e-6 + ) + forced = run( + sims(year, [region] * GRID**2, True), + year, + incomes, + { + "received_allowances_savings_income": to_savings, + "received_allowances_dividend_income": to_dividends, + }, + ) + grid_tax = forced.calculate("income_tax_pre_charges", year).astype(float) + model_tax = law["income_tax_pre_charges"][0] + least = grid_tax[feasible].min() + step = min(savings, used) / (GRID - 1) + min(dividends, used) / (GRID - 1) + # Invariant 1: the model's share gives the least tax; the grid comes within its + # step of it, since a pound moved between components changes the tax by + # less than a pound. + assert model_tax <= least + tolerance(model_tax) + assert model_tax >= least - step - tolerance(model_tax) + # Invariant 5: the search's tax function equals the model's Step 5 tax. + forced_c = _context(forced.populations["person"], year, parameters_of(forced)) + x = (forced_c.step_3_income - forced_c.savings - forced_c.dividends)[:, 0] + x = x + to_savings + to_dividends + s = savings - to_savings + v = dividends - to_dividends + own = step_5_tax(forced_c, x[:, None], s[:, None], v[:, None])[:, 0] + np.testing.assert_allclose( + own[feasible], grid_tax[feasible], atol=tolerance(grid_tax).max() + ) + + +@SETTINGS +@given(year, people) +def test_vertex_search_equals_dense_grid(sims, year, drawn): + incomes = draw(drawn) + regions = [REGIONS[i % len(REGIONS)] for i in range(PEOPLE)] + sim = run(sims(year, regions), year, incomes) + c = _context(sim.populations["person"], year, parameters_of(sim)) + x, s, v = _least_tax_share(c) + found = step_5_tax(c, x, s, v)[:, 0] + # Invariant 6: a 101 x 101 grid over the first component and savings + # left, projected onto the feasible shares. + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + fractions = np.linspace(0, 1, 101) + grid_x = (low + (high - low) * fractions[None, :]).repeat(101, axis=1) + grid_s = np.tile(c.savings * fractions[None, :], 101) + grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) + grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) + step = ((high - low) / 100 + c.savings / 100)[:, 0] + # The search may take a share within TIE (1e-6) of the least, for its + # tie-break. + assert np.all(found <= grid + 1e-5) + assert np.all(found >= grid - 2 * step - 1e-5) + + +# The fast path, checked on many synthetic people at once without a simulation: +# the inputs the search reads, drawn freely, and the old order computed from +# them as the model does, in float32. +SYNTHETIC_YEARS = tuple(range(2016, 2031)) +SYNTHETIC_PEOPLE = 2_000 +LIMITS = [500, 1_000, 5_000, 6_000, 12_570, 17_570, 37_700, 50_270, 100_000, 125_140] +# Curated people at the edges of each test: other non-savings income, property, +# savings, dividends, trade loss relief, allowances beyond the Personal +# Allowance, an exclusion from earned income, grossed-up Gift Aid, Scottish, +# and personal savings allowance. +EDGES = [ + # Pay and savings across the basic rate limit: the search moves £270. + (50_000, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + # Pay and dividends across the basic rate limit. + (50_000, 0, 0, 20_000, 0, 0, 0, 0, False, 500), + # Pay that ends exactly at the basic rate limit, and a pound below it. + (50_270, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + (50_269, 0, 10_000, 0, 0, 0, 0, 0, False, 500), + # Savings and dividends with no other income. + (0, 0, 4_000, 20_000, 0, 0, 0, 0, False, 1_000), + # An exclusion from earned income moves the savings below the basic rate + # limit. + (51_000, 0, 10_000, 0, 0, 0, 1_260, 0, False, 500), + (49_000, 0, 0, 20_000, 0, 0, 1_260, 0, False, 500), + # Savings exactly at the savings allowance, dividends at the allowance. + (30_000, 0, 1_000, 500, 0, 0, 0, 0, False, 1_000), + (30_000, 0, 1_001, 501, 0, 0, 0, 0, False, 1_000), + # Deductions that just fill the first component. + (12_570, 0, 3_000, 0, 0, 0, 0, 0, False, 1_000), + (12_570, 0, 3_000, 2_000, 0, 0, 0, 0, False, 1_000), + # A pensioner whose savings fill the starting rate band. + (14_000, 0, 8_000, 0, 0, 0, 0, 0, False, 1_000), + # Scottish pay in the starter, basic and intermediate bands. + (14_000, 0, 7_000, 0, 0, 0, 0, 0, True, 1_000), + (25_000, 0, 2_000, 3_000, 0, 0, 0, 0, True, 1_000), + (40_000, 0, 0, 5_000, 0, 0, 0, 0, True, 500), + (43_000, 0, 6_000, 0, 0, 0, 0, 0, True, 500), + # Property with other income, savings and dividends. + (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, False, 500), + (9_000, 30_000, 6_000, 15_000, 0, 0, 0, 0, True, 500), + # Scottish property income on 42% beside savings on 44% from 2027-28. + (0, 58_000, 24_000, 0, 0, 0, 0, 0, True, 500), + (0, 20_000, 0, 200_000, 1_000, 0, 0, 0, False, 0), + (45_000, 20_000, 3_000, 0, 0, 0, 1_260, 0, False, 500), + # Gift Aid raises the limits. + (40_000, 0, 3_000, 8_000, 0, 0, 0, 2_000, False, 1_000), + (52_000, 0, 9_000, 0, 0, 0, 0, 2_000, False, 500), + # Pension relief and a trading loss. + (60_000, 0, 9_000, 4_000, 3_000, 10_000, 0, 0, False, 500), + (110_000, 0, 20_000, 30_000, 0, 0, 0, 0, False, 0), + # A gift that lifts the UK limits above the £10 million extra bracket, so + # the thresholds no longer ascend. + (15_050_070, 0, 0, 10_000, 0, 12_570, 0, 15_000_000, False, 0), +] + + +@pytest.fixture(scope="module") +def system(): + import policyengine_uk.simulation as uk_simulation + + return uk_simulation.CountryTaxBenefitSystem() + + +def synthetic_people(seed, count=SYNTHETIC_PEOPLE, mix="free", exclusions=False): + """Inputs for ``least_tax_deductions`` and the old order's deductions. + + "free" draws every amount from a wide mixture with values at the limits; + "realistic" gives most people pay alone and some savings or dividends. + With ``exclusions``, half the people get an exclusion from earned income + of up to £20,000 in place of the Marriage Allowance's £1,260.""" + rng = np.random.default_rng(seed) + + def amount(zero, scale): + rest = 1 - zero + kind = rng.choice(4, count, p=[zero, 0.3 * rest, 0.4 * rest, 0.3 * rest]) + limits = rng.choice(LIMITS, count) + rng.uniform(-300, 300, count) + values = np.select( + [kind == 0, kind == 1, kind == 2], + [0, rng.uniform(0, 2_000, count), rng.uniform(0, scale, count)], + limits, + ) + return np.maximum(0, values) + + if mix == "free": + other = amount(0.2, 200_000) + property_income = amount(0.6, 60_000) + savings = amount(0.4, 150_000) + dividends = amount(0.5, 250_000) + else: + other = rng.lognormal(10.2, 0.6, count) + property_income = np.where( + rng.random(count) < 0.05, rng.uniform(0, 20_000, count), 0 + ) + savings = np.where(rng.random(count) < 0.25, rng.lognormal(5, 1.5, count), 0) + dividends = np.where(rng.random(count) < 0.08, rng.lognormal(7, 1.5, count), 0) + income = other + property_income + savings + dividends + relief = np.where( + rng.random(count) < 0.1, np.minimum(rng.uniform(0, 30_000, count), income), 0 + ) + extra = np.where(rng.random(count) < 0.2, rng.uniform(0, 15_000, count), 0) + married = rng.random(count) < 0.1 + gift = np.where(rng.random(count) < 0.1, rng.uniform(0, 10_000, count), 0) + scottish = rng.random(count) < 0.3 + savings_allowance = rng.choice([0.0, 500.0, 1_000.0], count) + pension = np.where(rng.random(count) < 0.2, rng.uniform(0, 20_000, count), 0) + exclusion_draw = np.where(rng.random(count) < 0.5, rng.uniform(0, 20_000, count), 0) + rows = np.array(EDGES, dtype=float) if mix == "free" else np.empty((0, 10)) + other, property_income, savings, dividends, relief, extra, marriage, gift = ( + np.concatenate([column, rows[:, i]]) + for i, column in enumerate( + [ + other, + property_income, + savings, + dividends, + relief, + extra, + married * 1_260.0, + gift, + ] + ) + ) + scottish = np.concatenate([scottish, rows[:, 8] > 0]) + savings_allowance = np.concatenate([savings_allowance, rows[:, 9]]) + pension = np.concatenate([pension, np.zeros(len(rows))]) + if exclusions: + # A reform's exclusion from earned income, such as an exempt pension. + marriage = np.concatenate([exclusion_draw, rows[:, 6]]) + f32 = np.float32 + before_relief = (other + property_income + savings + dividends).astype(f32) + relief = relief.astype(f32) + net_income = np.maximum(0, before_relief - relief) + personal_allowance = np.maximum(0, 12_570 - np.maximum(0, net_income - 100_000) / 2) + allowances = (personal_allowance + extra + pension).astype(f32) + savings, dividends = savings.astype(f32), dividends.astype(f32) + default = old_order( + allowances, savings, dividends, net_income, relief, before_relief + ) + inputs = dict( + savings=savings, + dividends=dividends, + relief=relief, + net_income=net_income, + property=property_income.astype(f32), + allowances=allowances, + pension=pension.astype(f32), + earned_exclusions=marriage.astype(f32), + gift=gift.astype(f32), + scottish=scottish, + savings_allowance=savings_allowance.astype(f32), + ) + inputs = { + name: values if name == "scottish" else values.astype(np.float64) + for name, values in inputs.items() + } + return inputs, default + + +def assert_fast_path_equals_search(income_tax, inputs, default): + fast = least_tax_deductions(inputs, income_tax, default.savings, default.dividends) + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + for fast_part, full_part in zip(fast, full): + mismatch = np.flatnonzero(fast_part != full_part) + assert not len(mismatch), { + name: values[mismatch[:5]] for name, values in inputs.items() + } + + +@settings( + max_examples=30, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.sampled_from(SYNTHETIC_YEARS), st.integers(0, 2**32 - 1)) +@example(2025, 0) +@example(2026, 0) +@example(2027, 0) +@example(2028, 1) +def test_fast_path_equals_full_search(system, year, seed): + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + inputs, default = synthetic_people(seed) + # Invariant 7: the people the tests let through get the search's result. + assert_fast_path_equals_search(income_tax, inputs, default) + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_fast_path_lets_most_realistic_people_through(system, year): + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + inputs, default = synthetic_people(2106, count=20_000, mix="realistic") + c = _build(inputs, income_tax) + through = _old_order_is_least( + c, default.savings[:, None], default.dividends[:, None] + )[:, 0] + # With one kind of income nothing can move (invariant 4), so all of + # those go through; and few of the rest need the search. + one_kind = (inputs["savings"] == 0) & (inputs["dividends"] == 0) + assert through[one_kind].all() + assert (~through).mean() < 0.1, (~through).mean() + + +# Rates that fall with income break the arguments of tests 2 and 3, so the +# tests must then send everyone with something to share to the search. +FALLING_RATES = { + "gov.hmrc.income_tax.rates.scotland.rates[3].rate": 0.05, + "gov.hmrc.income_tax.rates.uk[1].rate": 0.1, + "gov.hmrc.income_tax.rates.dividends[1].rate": 0.05, +} + + +@pytest.mark.parametrize("year", [2026, 2027]) +def test_fast_path_equals_full_search_when_rates_fall(system, year): + parameters = system.parameters + old = {} + try: + for path, value in FALLING_RATES.items(): + parameter = parameters.get_child(path) + old[path] = parameter(str(year)) + parameter.update(period=str(year), value=value) + system.reset_parameter_caches() + income_tax = parameters(str(year)).gov.hmrc.income_tax + for seed in range(3): + inputs, default = synthetic_people(seed) + assert_fast_path_equals_search(income_tax, inputs, default) + finally: + for path, value in old.items(): + parameters.get_child(path).update(period=str(year), value=value) + system.reset_parameter_caches() + + +def vertex_search_and_grid(income_tax, inputs, points=101): + """The search's least Step 5 tax and a dense grid's least, per person, + with the grid's step.""" + c = _build(inputs, income_tax) + x, s, v = _least_tax_share(c) + found = step_5_tax(c, x, s, v)[:, 0] + total = c.step_3_income + low = np.maximum(0, total - c.savings - c.dividends) + high = np.minimum(c.first, total) + fractions = np.linspace(0, 1, points) + grid_x = (low + (high - low) * fractions[None, :]).repeat(points, axis=1) + grid_s = np.tile(c.savings * fractions[None, :], points) + grid_x, grid_s, grid_v = _shares(c, grid_x, grid_x + grid_s) + grid = step_5_tax(c, grid_x, grid_s, grid_v).min(axis=1) + step = ((high - low) / (points - 1) + c.savings / (points - 1))[:, 0] + return found, grid, step + + +@pytest.mark.parametrize("year", [2018, 2026, 2027]) +def test_vertex_search_equals_dense_grid_with_exclusions(system, year): + """Invariant 6 on synthetic people with exclusions from earned income of + up to £20,000 beside property income, as a reform that exempts pensions + gives. Before 2027-28 the exclusion splits the first component into three + pieces, each with its own lines.""" + income_tax = system.parameters(str(year)).gov.hmrc.income_tax + for seed in range(2): + inputs, _ = synthetic_people(seed, count=300, exclusions=True) + found, grid, step = vertex_search_and_grid(income_tax, inputs) + worst = np.argmax(found - grid) + assert np.all(found <= grid + 1e-5), { + name: values[worst] for name, values in inputs.items() + } | {"found": found[worst], "grid": grid[worst]} + assert np.all(found >= grid - 2 * step - 1e-5) + + +def one_person(**amounts): + """``least_tax_deductions`` inputs for one person, and the old order's + deductions, computed as the model computes them from net income and net + income before trade loss relief.""" + values = dict( + savings=0.0, + dividends=0.0, + relief=0.0, + net_income=0.0, + property=0.0, + allowances=12_570.0, + pension=0.0, + earned_exclusions=0.0, + gift=0.0, + savings_allowance=1_000.0, + ) + before_relief = amounts.pop("before_relief", None) + scottish = amounts.pop("scottish", False) + values.update(amounts) + if before_relief is None: + before_relief = values["net_income"] + values["relief"] + inputs = { + name: np.array([value], dtype=np.float64) for name, value in values.items() + } + inputs["scottish"] = np.array([scottish]) + f32 = np.float32 + default = old_order( + *( + np.array([value], dtype=f32) + for value in ( + values["allowances"], + values["savings"], + values["dividends"], + values["net_income"], + values["relief"], + before_relief, + ) + ) + ) + return inputs, default + + +def tax_saved(income_tax, inputs, default): + """The Step 5 tax under the old order less the tax under the full search's + share, in the search's own tax function.""" + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + c = _build(inputs, income_tax) + + def tax(to_savings, to_dividends): + s = c.savings - np.asarray(to_savings, dtype=float)[:, None] + v = c.dividends - np.asarray(to_dividends, dtype=float)[:, None] + return step_5_tax(c, c.step_3_income - s - v, s, v)[0, 0] + + return tax(default.savings, default.dividends) - tax(*full) + + +@pytest.mark.parametrize( + "amounts, saving", + [ + # Savings of £1,000 the only income in net income before the relief, + # with £1,000 of trade loss relief, and a supplied adjusted net income + # that puts net income at £30,000. The old order sets the relief + # against the savings and leaves £17,430 of non-savings income at + # 20%; setting it against non-savings income instead leaves £16,430, + # and the £1,000 savings allowance covers the savings: £200 less. + ( + dict(savings=1_000, relief=1_000, net_income=30_000, before_relief=1_000), + 200, + ), + # Savings of £300 and £300 of relief, with net income supplied at + # £13,070: the old order leaves £500 of non-savings income at 20%, the + # search £200, with the savings in the savings allowance: £60 less. + (dict(savings=300, relief=300, net_income=13_070, before_relief=300), 60), + ], + ids=["relief-against-savings", "all-savings-used"], +) +def test_fast_path_follows_a_supplied_net_income(system, amounts, saving): + """Where a supplied adjusted net income sets net income apart from net + income before trade loss relief less the relief, the old order can set + relief against savings while non-savings income remains. The fast path + must send those people to the search.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(**amounts) + assert tax_saved(income_tax, inputs, default) == pytest.approx(saving, abs=1e-6) + assert_fast_path_equals_search(income_tax, inputs, default) + + +def test_fast_path_reads_the_scottish_starter_rate(system): + """A reform that lifts the top of the Scottish starter band to £9,000, + above the £5,000 starting rate limit for savings. A Scottish taxpayer with + £18,570 of pay and £20,000 of savings: the old order leaves £6,000 of pay + at the 19% starter rate, and the savings above it, past the starting rate + band, bear 20%. Moving £3,000 of the allowance to savings takes the pay to + £9,000 and saves 1% of £3,000, £30. The fast path must read the Scottish + rate on the pay, not the UK basic rate.""" + parameters = system.parameters + path = "gov.hmrc.income_tax.rates.scotland.rates[1].threshold" + parameter = parameters.get_child(path) + before = parameter("2026") + try: + parameter.update(period="2026", value=9_000) + system.reset_parameter_caches() + income_tax = parameters("2026").gov.hmrc.income_tax + inputs, default = one_person(savings=20_000, net_income=38_570, scottish=True) + assert tax_saved(income_tax, inputs, default) == pytest.approx(30, abs=1e-6) + assert_fast_path_equals_search(income_tax, inputs, default) + finally: + parameter.update(period="2026", value=before) + system.reset_parameter_caches() + + +def test_pension_relief_stays_on_non_savings_income(system): + """Pay of £40,000 with £10,000 of pension relief and £30,000 of savings in + 2026-27. The relief stays on the pay: the employer deducts net pay + contributions from employment income (FA 2004 s. 193(2)), and for relief + at source Parliament raises the basic and higher rate limits instead + (s. 192(4)). So only the £12,570 allowance moves, and every share keeps + the savings allowance below the basic rate limit: the old order stands. + Sharing the pension relief would put the pay at the limit and save + £100.""" + income_tax = system.parameters("2026").gov.hmrc.income_tax + inputs, default = one_person( + savings=30_000, + net_income=70_000, + allowances=22_570, + pension=10_000, + savings_allowance=500, + ) + full = least_tax_deductions( + inputs, income_tax, default.savings, default.dividends, search_all=True + ) + assert full[0][0] == 0 + assert tax_saved(income_tax, inputs, default) == 0 + shared = dict(inputs, pension=np.array([0.0])) + assert tax_saved(income_tax, shared, default) == pytest.approx(100, abs=1e-6) diff --git a/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py b/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py index 6cc429a92b..343d504245 100644 --- a/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py +++ b/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py @@ -449,10 +449,13 @@ def test_the_step_lists_are_disjoint_and_hold_the_modelled_provisions(): @pytest.mark.parametrize("year", [2026, 2027, 2028, 2029, 2030]) -def test_step_6_puts_the_pension_tax_allowance_after_every_other_reduction(sim, year): +def test_step_6_lists_the_pension_and_marriage_allowances_last(sim, year): income_tax = sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax reductions = list(income_tax.step_6_tax_reductions) - assert reductions[-1] == "pension_tax_allowance" + assert reductions[-2:] == [ + "pension_tax_allowance", + "marriage_allowance_tax_reduction", + ] for index, reduction in enumerate(reductions): assert earlier_tax_reductions(income_tax, reduction) == reductions[:index] diff --git a/policyengine_uk/tests/test_input_records.py b/policyengine_uk/tests/test_input_records.py index a29f1d61fb..24b795e4fd 100644 --- a/policyengine_uk/tests/test_input_records.py +++ b/policyengine_uk/tests/test_input_records.py @@ -12,12 +12,20 @@ """ import numpy as np +import pandas as pd import pytest from policyengine_core.experimental.memory_config import MemoryConfig from policyengine_core.periods import period +from policyengine_core.reforms import Reform -from policyengine_uk import Simulation -from policyengine_uk.utils.supplied_inputs import set_model_value, supplied_input +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.utils.supplied_inputs import ( + is_model_value, + set_model_value, + stored_supplied_value, + supplied_input, +) pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -176,3 +184,99 @@ def test_rebuilt_populations_forget_the_inputs_they_replace(): holder.put_in_cache(np.array([123.0]), period(2026)) person = sim.populations["person"] assert supplied_input(person, "adjusted_net_income", period(2026)) is None + + +@pytest.mark.parametrize("model", [False, True], ids=["supplied", "model"]) +@pytest.mark.parametrize( + "subsampled", + [ + pytest.param(False, id="direct"), + pytest.param( + True, + id="subsampled", + marks=pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="needs policyengine-core holder resync", + ), + ), + ], +) +def test_restoring_reform_can_replace_a_neutralized_input(model, subsampled): + """A write after restoration replaces the hidden value and its ownership.""" + if subsampled: + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + "employment_income": [30_000.0], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "household_weight": [1.0], + "region": ["LONDON"], + "tenure_type": ["OWNED_OUTRIGHT"], + "rent": [0.0], + "council_tax": [0.0], + } + ), + fiscal_year=2025, + ) + simulation = Microsimulation(dataset=dataset) + else: + simulation = Simulation(situation={"age": 40, "employment_income": 30_000}) + name = "adjusted_net_income" + input_period = period(2025) + original_variable = simulation.tax_benefit_system.get_variable(name).__class__ + if model: + set_model_value(simulation, name, input_period, [10_000]) + else: + simulation.set_input(name, input_period, [10_000]) + + class NeutralizeIncome(Reform): + def apply(self): + self.neutralize_variable(name) + + class RestoreAndReplaceIncome(Reform): + def apply(self): + self.replace_variable(original_variable) + assert not self.get_variable(name).is_neutralized + assert self.get_variable(name).__class__ is original_variable + self.simulation.set_input(name, input_period, [20_000]) + stored = stored_supplied_value( + self.simulation.get_holder(name), + input_period, + self.simulation.branch_name, + ) + np.testing.assert_array_equal(stored, [20_000]) + np.testing.assert_array_equal( + self.simulation.calculate(name, input_period), [20_000] + ) + assert not is_model_value(self.simulation, name, input_period, stored) + np.testing.assert_array_equal( + supplied_input( + self.simulation.populations["person"], name, input_period + ), + [20_000], + ) + + simulation.apply_reform(NeutralizeIncome) + if subsampled: + simulation.subsample(n=1, seed=0, time_period=2025) + simulation.apply_reform(RestoreAndReplaceIncome) + + stored = stored_supplied_value( + simulation.get_holder(name), input_period, simulation.branch_name + ) + np.testing.assert_array_equal(stored, [20_000]) + np.testing.assert_array_equal(simulation.calculate(name, input_period), [20_000]) + assert not is_model_value(simulation, name, input_period, stored) + np.testing.assert_array_equal( + supplied_input(simulation.populations["person"], name, input_period), [20_000] + ) diff --git a/policyengine_uk/tests/test_isa_balances.py b/policyengine_uk/tests/test_isa_balances.py index a38b6cc2f4..2468bef265 100644 --- a/policyengine_uk/tests/test_isa_balances.py +++ b/policyengine_uk/tests/test_isa_balances.py @@ -59,10 +59,16 @@ def test_stored_household_totals_without_person_balances_project_with_savings(): shares = simulation.calculate("stocks_and_shares_isa", 2027).values[0] assert cash == pytest.approx(3_000.0 * growth) assert shares == pytest.approx(2_000.0 * growth) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) all reach the capital pool. + # Savings and the cash ISA reach the capital pool at their value. The + # stocks-and-shares ISA reaches it through share_like_capital, which + # uc_assessable_capital values less the sale-expense rate whether + # share_like_capital holds shares or, as here, only the ISA. + rate = simulation.tax_benefit_system.parameters( + "2027-04-06" + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate capital = simulation.calculate("uc_assessable_capital", 2027).values[0] - assert capital == pytest.approx(6_000.0 * growth, rel=1e-5) + expected = (1_000.0 + 3_000.0 + 2_000.0 * (1 - rate)) * growth + assert capital == pytest.approx(expected, rel=1e-5) def test_household_totals_sum_the_person_balances_in_every_year(): diff --git a/policyengine_uk/tests/test_legacy_award_readers_properties.py b/policyengine_uk/tests/test_legacy_award_readers_properties.py index 602a71ac26..cf3666cb9c 100644 --- a/policyengine_uk/tests/test_legacy_award_readers_properties.py +++ b/policyengine_uk/tests/test_legacy_award_readers_properties.py @@ -63,8 +63,9 @@ (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), - 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; + 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; - 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 @@ -690,7 +691,9 @@ def test_other_members_awards_never_change_the_readers_for_young_singles(drawn): @pytest.mark.parametrize("report", ["esa_income_reported", "jsa_income_reported"]) def test_sixteen_year_old_own_award_leaves_young_single_readers_unchanged(report): # A natural route-(a) case: no route or own-right Boolean is supplied. - # UC reg 5(5) and SPC reg 4A(5) still exclude the member's own award. + # The Secretary of State excludes the member's own income-related ESA or + # income-based JSA (paying in 2025) from the UC test (SI 2014/1230 reg. + # 28) and from the Pension Credit test (SPC reg. 4A(5)). family = ( [ { diff --git a/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py b/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py index 0b0353e418..d5e37b1d90 100644 --- a/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py +++ b/policyengine_uk/tests/test_legacy_means_test_income_from_capital_properties.py @@ -27,9 +27,11 @@ personal allowance can leave rent or dividends taxable. Where someone has earnings, each means-test income changes by exactly minus the change in the tax that test deducts (legacy_means_test_deducted_income_tax, summed - over the members it counts) and in its earnings disregard, which is - capped at net earnings, unless floored or on a route that does not use - the test's own income rules. The awards are compared wherever none of + over the members it counts; for council tax reduction, + council_tax_reduction_tax_deduction, which applies each scheme's own + rule) and in its earnings disregard, which never exceeds net earnings, + unless a floor binds or the family takes a route that does not use the + test's own income rules. The test compares the awards wherever none of those taxes or disregards moves. 2. Bounds: the counted home-letting income is between nil and the rent, and equals the rent less £20 a week, floored at nil. @@ -59,10 +61,16 @@ savings rise while the family stays on the same route (pensioner, UC award, passport, guarantee credit, savings credit only). 6. Differential: for every generated family without self-employment income, - Income Support, Housing Benefit and the council tax reduction line deduct, - for each person they count, the same income tax as Pension Credit's tax - line: the pre-charge income tax less - legacy_means_test_uncounted_income_tax_without_earnings. + Income Support and Housing Benefit deduct, for each person they count, the + same income tax as Pension Credit's tax line: the pre-charge income tax + less legacy_means_test_uncounted_income_tax_without_earnings. The council + tax reduction line deducts council_tax_reduction_tax_deduction, which + deducts that tax only in the schemes it leaves to the legacy means tests + (the English and Welsh working-age schemes and outside Great Britain). + In Scotland's working-age scheme and Great Britain's pensioner schemes it + applies those schemes' own rules + (test_council_tax_reduction_tax_deduction_properties.py), so there the + line does not read Pension Credit's tax. 7. Applicant: where the household head does not apply alone for council tax reduction, council_tax_reduction_home_letting_income equals legacy_benefits_home_letting_income; where the head applies alone, her @@ -266,6 +274,13 @@ def calculate_with_taxes(units, year, variables, **kwargs): for income, members in TEST_MEMBERS.items(): mask = np.asarray(sim.calculate(members, year), dtype=float) out[f"tax:{income}"] = sim.map_result(tax * mask, "person", "benunit") + # The council tax reduction line deducts each scheme's own measure, a + # benefit unit total for the applicant and partner, which equals the + # shared deduction only in the schemes it leaves to the legacy means + # tests. + out["tax:council_tax_reduction_applicable_income"] = np.asarray( + sim.calculate("council_tax_reduction_tax_deduction", year), dtype=float + ) earnings = sum( np.abs(np.asarray(sim.calculate(v, year), dtype=float)) for v in EARNINGS ) @@ -487,12 +502,17 @@ def test_deducted_tax_without_earnings_is_the_step_4_split(units, year): @PROPERTY_SETTINGS @given(populations, st.sampled_from(YEARS)) def test_the_four_tests_deduct_the_same_income_tax(units, year): - """Income Support, Housing Benefit and the council tax reduction line - deduct, for each person they count, the income tax Pension Credit's tax - line deducts. Each test's income is recomputed with the person's income - tax raised by one pound and compared with the original, so the check - reads each formula rather than the shared variable. The families have no - self-employment income, where Pension Credit's notional tax applies.""" + """Income Support and Housing Benefit deduct, for each person they count, + the income tax Pension Credit's tax line deducts, and so does the council + tax reduction line in the schemes council_tax_reduction_tax_deduction + leaves to the legacy means tests (the English and Welsh working-age + schemes and outside Great Britain). In Scotland's working-age scheme and + Great Britain's pensioner schemes, the line applies those schemes' own + rules and does not respond to that tax. The test recomputes each test's + income with the person's income tax raised by one pound and compares it + with the original, so the check reads each formula rather than the + shared variable. The families have no self-employment income, where + Pension Credit's notional tax applies.""" sim = Simulation(situation=situation(units, year)) people = sim.populations["person"].count pension_credit_tax = np.asarray( @@ -549,6 +569,15 @@ def test_the_four_tests_deduct_the_same_income_tax(units, year): ) == 0 ) + # Each family is one benefit unit in its own household. + region = np.array([unit["region"] for unit in units]) + pensioner = np.asarray( + sim.calculate("council_tax_reduction_pensioner", year) + ).astype(bool) + own_scheme_rules = (region != "NORTHERN_IRELAND") & ( + pensioner | (region == "SCOTLAND") + ) + counted = np.where(own_scheme_rules, 0.0, counted) checked = unfloored & own_rules # The earnings disregard is capped at net earnings, so it can fall # with them. diff --git a/policyengine_uk/tests/test_lha_bedroom_young_people_properties.py b/policyengine_uk/tests/test_lha_bedroom_young_people_properties.py new file mode 100644 index 0000000000..4dc83270b4 --- /dev/null +++ b/policyengine_uk/tests/test_lha_bedroom_young_people_properties.py @@ -0,0 +1,630 @@ +"""Property-based tests for the bedrooms of benefit-unit members aged 16 or +over who are not the claimant or partner (UC Regs 2013 Sch 4 paras 9-10; HB +Regs 2006 and HB (SPC) Regs 2006 reg 13D(3)). + +Invariants, for any generated population of households: + +1. Monotonicity: adding a person aged 16 to 19 to any family never lowers + any family's Universal Credit or Housing Benefit bedrooms, whether the + claimant and partner are supplied or inferred. +2. Exact effect: when the added person is not the claimant or partner, their + family gains one Housing Benefit bedroom, and one Universal Credit bedroom + unless they are a qualifying young person no one is responsible for (para + 9(2)(g)) or they head the household and are not a qualifying young person + (the formula treats the household head as liable for the rent, so such + a member is neither a renter nor a non-dependant: paras 1(2), 9(1)(a) and + 9(2)(d)). If they join a non-dependant's family, the claim that counts the + household's non-dependants (uc_non_dependants_counted) gains one Universal + Credit bedroom unless they are a qualifying young person no one is + responsible for (para 9(2)(g) applies to the whole extended benefit unit), + and every family liable for the rent gains one Housing Benefit bedroom, + since they are an occupier of each such claim (reg 13D(12)). If they join + a boarder's or lodger's family, every family liable for the rent gains one + Housing Benefit bedroom. If they are an in-unit non-dependant (invariant + 4) of a family that makes no Universal Credit claim of its own, the claim + that counts the household's non-dependants gains one Universal Credit + bedroom too, as it bears their contribution. No other family's bedrooms + change. +3. Reference: every family's bedrooms equal an independent count of the size + criteria. Universal Credit: one for the renter or couple; one for each + other member aged 16 or over, except a qualifying young person no one is + responsible for and a household head who is not a qualifying young + person; for the counting claim, one for each person aged 16 or + over in a non-dependant's family, with the same exception, and one for + each in-unit non-dependant of a family that makes no claim; and the + fewest rooms that hold the family's own children. Housing Benefit, for a + family liable for the rent: one for the claimant or couple; one for each + other member aged 16 or over; for each non-dependant's, boarder's or + lodger's family, one for its couple or single adult (reg 13D(3)(a), (b)) + and one for each other member aged 16 or over; and the fewest rooms that + hold the children of all those occupiers together. Any other family's + Housing Benefit claim counts only its own members. The fewest rooms hold + children under 16 two to a room, where only children of the same sex or + two children under 10 may share, found by brute force. Which claim counts + the non-dependants, and which families are liable for the rent, are read + from the model; they are tested elsewhere. Universal Credit does not yet + count other families' children (a separate change). +4. In-unit non-dependants: every member of a benefit unit aged 16 or over + who is neither the claimant nor partner nor a Universal Credit qualifying + young person nor the household head (the householder in whose name the + accommodation is rented, whom the formula reads as liable for the rent, + so that para 9(2)(d) excludes them) is a non-dependant (para 9(2)) with + a bedroom (para 10(1)(c)). So their family has one more Universal Credit + bedroom with them than without them, and making them qualifying young + persons the renter is responsible for (para 10(1)(b)) leaves every family's + Universal Credit bedrooms unchanged, except that the claim counting the + household's non-dependants loses the bedroom of one whose own family + makes no claim and is not that claim (para 9(2)(g)). The model's + is_benefit_unit_non_dependant_for_universal_credit, which the formula + reads, marks exactly these members. +5. Rooms and contributions: nobody owes a housing cost contribution without + being a non-dependant (para 13). Adding an adult aged 16 to 64, not in + education, to any family: if they are an in-unit non-dependant, they owe + a contribution unless aged under 21 or receiving Attendance Allowance + (para 16(2)); the claim that bears it (their own family's if it makes a + claim, otherwise the one that counts the household's non-dependants) + gains exactly that contribution, unless its renter is exempt (para 15), + and one bedroom; no other family's contributions change. +6. Coding: moving such an adult aged 20 or over, not claiming, between a + family and a benefit unit of their own leaves every claiming family's + Universal Credit bedrooms, contributions and award unchanged. Someone + younger can be a qualifying young person, and their family's Child Benefit + and child element then depend on them. +""" + +from itertools import combinations + +import numpy as np +from hypothesis import HealthCheck, assume, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2025 # Situation inputs without a period are set for 2025. +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +ROLES = ["sharer", "boarder", "lodger", "non_dependant"] +EDUCATION = ["NOT_IN_EDUCATION", "UPPER_SECONDARY", "TERTIARY"] + +child = st.tuples(st.integers(0, 15), st.booleans()) # (age, is_male) + + +@st.composite +def family(draw, role): + return dict( + role=role, + adults=draw(st.lists(st.integers(20, 85), min_size=1, max_size=2)), + children=draw(st.lists(child, min_size=0, max_size=4)), + ) + + +@st.composite +def household(draw): + others = draw( + st.lists(st.sampled_from(ROLES).flatmap(family), min_size=0, max_size=2) + ) + return [draw(family("head"))] + others + + +@st.composite +def young_person(draw): + return dict( + age=draw(st.integers(16, 19)), + is_male=draw(st.booleans()), + current_education=draw(st.sampled_from(EDUCATION)), + age_started_or_accepted_current_education_or_training=draw(st.integers(14, 19)), + is_before_universal_credit_qualifying_young_person_terminal_date=draw( + st.booleans() + ), + is_looked_after_by_local_authority=draw(st.booleans()), + receives_benefits_in_own_right=draw(st.booleans()), + ) + + +@st.composite +def scenario(draw): + population = draw(st.lists(household(), min_size=1, max_size=4)) + h = draw(st.integers(0, len(population) - 1)) + f = draw(st.integers(0, len(population[h]) - 1)) + person = draw(young_person()) + # On some draws the person added to the head's family heads the household + # in place of its first adult, whose claimant role does not change. + if f == 0: + person["is_household_head"] = draw(st.booleans()) + return population, (h, f), person + + +def build(population, added=None, supply_roles=True): + """One situation for the population, optionally with a person added to + family ``added[0]`` = (household, family) with attributes ``added[1]``. + + Returns the situation and, per benefit unit, its (household, family). + An added person with ``is_household_head`` heads the household in place + of the head family's first adult.""" + people, benunits, homes, keys = {}, {}, {}, [] + for h, families in enumerate(population): + members = [] + for f, fam in enumerate(families): + name = f"h{h}_f{f}" + ids = [] + for i, age in enumerate(fam["adults"]): + pid = f"{name}_adult_{i}" + people[pid] = {"age": age, "is_household_head": False} + if supply_roles: + people[pid]["is_claimant_or_partner"] = True + elif fam["children"]: + people[pid]["is_parent"] = True + ids.append(pid) + for i, (age, male) in enumerate(fam["children"]): + pid = f"{name}_child_{i}" + people[pid] = {"age": age, "is_male": male, "is_household_head": False} + if supply_roles: + people[pid]["is_claimant_or_partner"] = False + ids.append(pid) + if added is not None and added[0] == (h, f): + pid = f"{name}_added" + people[pid] = { + **added[1], + "is_household_head": added[1].get("is_household_head", False), + } + if supply_roles: + people[pid]["is_claimant_or_partner"] = False + ids.append(pid) + extra = {} + if fam["role"] == "sharer": + extra["liable_for_share_of_household_rent"] = True + if fam["role"] in ("boarder", "lodger"): + people[ids[0]][f"rent_paid_as_{fam['role']}"] = 5_000 + added_head = ( + added is not None + and added[0] == (h, f) + and added[1].get("is_household_head", False) + ) + if fam["role"] == "head" and not added_head: + people[ids[0]]["is_household_head"] = True + benunits[name] = {"members": ids, **extra} + members.extend(ids) + keys.append((h, f)) + homes[f"h{h}"] = { + "members": members, + "rent": 12_000, + "tenure_type": "RENT_PRIVATELY", + "brma": "MAIDSTONE", + } + situation = {"people": people, "benunits": benunits, "households": homes} + return situation, keys + + +def bedrooms(situation): + sim = Simulation(situation=situation) + uc = np.asarray(sim.calculate("LHA_allowed_bedrooms", YEAR)) + hb = np.asarray(sim.calculate("housing_benefit_LHA_allowed_bedrooms", YEAR)) + return sim, uc, hb + + +def claim_roles(sim): + """Per benefit unit: whether it counts the household's non-dependants + for Universal Credit, and whether it is liable for the household's + rent.""" + counted = np.asarray(sim.calculate("uc_non_dependants_counted", YEAR)) + liable = ( + np.asarray( + sim.calculate("is_liable_for_household_rent", YEAR, map_to="benunit") + ) + > 0 + ) + return counted.astype(bool), liable + + +def own_claims(sim): + """Per benefit unit: whether it makes its own Universal Credit claim as a + renter, the test uc_non_dep_deductions applies to an in-unit + non-dependant's family.""" + return ( + np.asarray(sim.calculate("benunit_is_rent_liable", YEAR)) + & np.asarray(sim.calculate("is_uc_eligible", YEAR)) + & np.asarray(sim.calculate("claims_universal_credit", YEAR)) + ) + + +def has_own_uc_bedroom(sim, situation): + """Each person aged 16 or over who is not the claimant or partner: whether + they have a Universal Credit bedroom of their own. A qualifying young + person the renter is responsible for has one (para 10(1)(b)); one no one + is responsible for has none (para 9(2)(g)). Anyone else has one as a + non-dependant (para 10(1)(c)), unless they head the household, whom the + formula treats as liable for the rent (para 9(2)(d)).""" + qualifying = np.asarray( + sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR) + ) + responsible = np.asarray( + sim.calculate("is_child_or_qualifying_young_person_for_universal_credit", YEAR) + ) + head = np.asarray(sim.calculate("is_resolved_household_head", YEAR)) + return dict(zip(situation["people"], responsible | (~qualifying & ~head))) + + +def fewest_rooms_for_children(children): + """Brute force: the fewest rooms holding the children two to a room, + where a pair must be of the same sex or both under 10.""" + + def can_share(a, b): + return a[1] == b[1] or (a[0] < 10 and b[0] < 10) + + def best(remaining): + if not remaining: + return 0 + first, rest = remaining[0], remaining[1:] + rooms = 1 + best(rest) + for j, other in enumerate(rest): + if can_share(first, other): + rooms = min(rooms, 1 + best(rest[:j] + rest[j + 1 :])) + return rooms + + return best(list(children)) + + +@PROPERTY_SETTINGS +@given(scenario(), st.booleans()) +def test_adding_a_person_aged_16_to_19_never_lowers_bedrooms(case, supply_roles): + population, target, person = case + before, keys = build(population, supply_roles=supply_roles) + after, keys_after = build(population, (target, person), supply_roles) + assert keys == keys_after + _, uc_before, hb_before = bedrooms(before) + _, uc_after, hb_after = bedrooms(after) + # 1. Monotonicity. + assert np.all(uc_after >= uc_before) + assert np.all(hb_after >= hb_before) + + +@PROPERTY_SETTINGS +@given(scenario()) +# A qualifying young person a local authority looks after, added to the +# head's family: no Universal Credit bedroom (para 9(2)(g)). +@example( + ( + [[dict(role="head", adults=[40], children=[])]], + (0, 0), + dict( + age=17, + is_male=True, + current_education="UPPER_SECONDARY", + age_started_or_accepted_current_education_or_training=16, + is_before_universal_credit_qualifying_young_person_terminal_date=True, + is_looked_after_by_local_authority=True, + receives_benefits_in_own_right=False, + ), + ) +) +# The same young person in a non-dependant's family: no Universal Credit +# bedroom in the renter's claim either (para 9(2)(g)). +@example( + ( + [ + [ + dict(role="head", adults=[40], children=[]), + dict(role="non_dependant", adults=[45], children=[]), + ] + ], + (0, 1), + dict( + age=17, + is_male=True, + current_education="UPPER_SECONDARY", + age_started_or_accepted_current_education_or_training=16, + is_before_universal_credit_qualifying_young_person_terminal_date=True, + is_looked_after_by_local_authority=True, + receives_benefits_in_own_right=False, + ), + ) +) +def test_exact_effect_of_adding_a_person_aged_16_to_19(case): + population, target, person = case + before, keys = build(population) + after, _ = build(population, (target, person)) + sim_before, uc_before, hb_before = bedrooms(before) + sim, uc_after, hb_after = bedrooms(after) + counted, liable = claim_roles(sim) + # Adding a dependant changes neither who counts the non-dependants nor + # who is liable for the rent. + counted_before, liable_before = claim_roles(sim_before) + assert np.array_equal(counted, counted_before) + assert np.array_equal(liable, liable_before) + gains = has_own_uc_bedroom(sim, after)[f"h{target[0]}_f{target[1]}_added"] + names = list(after["people"]) + marked = in_unit_non_dependants(sim)[ + names.index(f"h{target[0]}_f{target[1]}_added") + ] + # 2. Exact effect. + expected_uc = np.zeros(len(keys)) + expected_hb = np.zeros(len(keys)) + t = keys.index(target) + expected_uc[t] += 1 if gains else 0 + expected_hb[t] += 1 + role = population[target[0]][target[1]]["role"] + same_household = np.array([h == target[0] for h, _ in keys]) + passed_on = ( + role != "non_dependant" and marked and not own_claims(sim)[t] and not counted[t] + ) + if (role == "non_dependant" and gains) or passed_on: + expected_uc[same_household & counted] += 1 + if role in ("non_dependant", "boarder", "lodger"): + expected_hb[same_household & liable] += 1 + assert np.array_equal(uc_after - uc_before, expected_uc) + assert np.array_equal(hb_after - hb_before, expected_hb) + + +@PROPERTY_SETTINGS +@given(scenario()) +def test_bedrooms_match_an_independent_count_of_the_size_criteria(case): + population, target, person = case + situation, keys = build(population, (target, person)) + sim, uc, hb = bedrooms(situation) + counted, liable = claim_roles(sim) + gains = has_own_uc_bedroom(sim, situation) + own_claim = own_claims(sim) + names = list(situation["people"]) + marked = in_unit_non_dependants(sim) + occupier_roles = ("non_dependant", "boarder", "lodger") + # 3. Reference count. + for b, (h, f) in enumerate(keys): + families = population[h] + fam = families[f] + added = (h, f) == target + own_uc = int(added and gains[f"h{h}_f{f}_added"]) + own_hb = int(added) + outside_uc = outside_hb = 0 + hb_children = list(fam["children"]) + for g, other in enumerate(families): + if g == f: + continue + joins = target == (h, g) + if counted[b] and other["role"] == "non_dependant": + # UC: each non-dependant who is not a child (adults are + # supplied as claimant or partner). + outside_uc += len(other["adults"]) + outside_uc += int(joins and gains[f"h{h}_f{g}_added"]) + elif counted[b] and joins and not own_claim[keys.index((h, g))]: + # UC: an in-unit non-dependant of a family that makes no + # claim of its own counts in this claim (para 9(2)(f)). + outside_uc += int(marked[names.index(f"h{h}_f{g}_added")]) + if liable[b] and other["role"] in occupier_roles: + # HB: the occupier family's couple ((a)) or single adult + # ((b)), the added person ((b)) and its children. + outside_hb += 1 + int(joins) + hb_children += other["children"] + uc_children = fewest_rooms_for_children(fam["children"]) + assert uc[b] == 1 + own_uc + outside_uc + uc_children + assert hb[b] == 1 + own_hb + outside_hb + fewest_rooms_for_children(hb_children) + + +def in_unit_non_dependants(sim): + """Each person: aged 16 or over, neither the claimant nor partner, not a + Universal Credit qualifying young person, and not the household head + (UC Regs 2013 Sch 4 para 9(2), with (d) for the head, the householder + in whose name the accommodation is rented). Checks that the model's + is_benefit_unit_non_dependant_for_universal_credit marks the same + people.""" + age = np.asarray(sim.calculate("age", YEAR)) + claimant_or_partner = np.asarray(sim.calculate("is_claimant_or_partner", YEAR)) + qualifying = np.asarray( + sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR) + ) + household_head = np.asarray(sim.calculate("is_resolved_household_head", YEAR)) + marked = (age >= 16) & ~claimant_or_partner & ~qualifying & ~household_head + model = np.asarray( + sim.calculate("is_benefit_unit_non_dependant_for_universal_credit", YEAR) + ) + assert np.array_equal(marked, model) + return marked + + +@PROPERTY_SETTINGS +@given(scenario()) +def test_every_in_unit_non_dependant_has_a_bedroom(case): + population, target, person = case + situation, keys = build(population, (target, person)) + sim, uc, _ = bedrooms(situation) + marked = in_unit_non_dependants(sim) + names = list(situation["people"]) + # 4. A marked member has a bedroom of their own: without them, their + # family has one fewer, and so does the claim counting the household's + # non-dependants if they are in a non-dependant's family, or in a family + # that neither makes its own claim nor is that claim. Only the added + # person can be marked: the generated adults are claimants or partners + # and the generated children are under 16. + if marked[names.index(f"h{target[0]}_f{target[1]}_added")]: + without, _ = build(population) + _, uc_without, _ = bedrooms(without) + expected = np.zeros(len(keys)) + expected[keys.index(target)] = 1 + role = population[target[0]][target[1]]["role"] + counted, _ = claim_roles(sim) + t = keys.index(target) + if role == "non_dependant" or not (own_claims(sim)[t] or counted[t]): + same_household = np.array([h == target[0] for h, _ in keys]) + expected[same_household & counted] += 1 + assert np.array_equal(uc - uc_without, expected) + # Making every marked member a qualifying young person the renter is + # responsible for leaves every family's bedrooms unchanged, except that + # the claim counting the household's non-dependants loses the bedroom of + # one whose own family makes no claim and is not that claim: another + # family's qualifying young person is not that renter's non-dependant + # (para 9(2)(g)). + expected_change = np.zeros(len(keys)) + if marked[names.index(f"h{target[0]}_f{target[1]}_added")]: + counted, _ = claim_roles(sim) + t = keys.index(target) + role = population[target[0]][target[1]]["role"] + if role != "non_dependant" and not (own_claims(sim)[t] or counted[t]): + same_household = np.array([h == target[0] for h, _ in keys]) + expected_change[same_household & counted] = -1 + as_dependants = { + **situation, + "people": { + name: ( + { + **attributes, + "is_qualifying_young_person_for_universal_credit": True, + "is_looked_after_by_local_authority": False, + } + if marked[i] + else attributes + ) + for i, (name, attributes) in enumerate(situation["people"].items()) + }, + } + sim_as_dependants, uc_as_dependants, _ = bedrooms(as_dependants) + assert not in_unit_non_dependants(sim_as_dependants).any() + assert np.array_equal(uc_as_dependants - uc, expected_change) + + +@st.composite +def adult_member(draw): + """A member aged 16 or over who is not in education, with or without + Attendance Allowance (an exemption under UC Regs 2013 Sch 4 para 16(2)). + Outside education, UC reg 5(1)(a) makes them a qualifying young person + only before the 1 September after their 16th birthday.""" + return dict( + age=draw(st.integers(16, 64)), + is_male=draw(st.booleans()), + current_education="NOT_IN_EDUCATION", + is_looked_after_by_local_authority=False, + attendance_allowance=draw(st.sampled_from([0, 5_000])), + ) + + +@st.composite +def adult_scenario(draw): + population = draw(st.lists(household(), min_size=1, max_size=3)) + h = draw(st.integers(0, len(population) - 1)) + f = draw(st.integers(0, len(population[h]) - 1)) + # Whether each family would claim Universal Credit, so that the claim + # bearing a non-dependant is sometimes not their own family's. + would_claim = [[draw(st.booleans()) for _ in families] for families in population] + return population, (h, f), draw(adult_member()), would_claim + + +def with_claim_flags(situation, would_claim): + for name, unit in situation["benunits"].items(): + if name.startswith("h") and "_f" in name: + h, f = name[1:].split("_f") + unit["would_claim_uc"] = would_claim[int(h)][int(f)] + return situation + + +def in_own_benefit_unit(population, target, person, would_claim): + """The population with ``person`` in a benefit unit of their own, in the + household of family ``target``, not claiming Universal Credit. Invariant 6 + covers people aged 20 or over.""" + assume(person["age"] >= 20) + situation, keys = build(population) + situation = with_claim_flags(situation, would_claim) + h = target[0] + situation["people"]["moved"] = { + **person, + "is_household_head": False, + "is_claimant_or_partner": True, + } + situation["benunits"]["moved"] = {"members": ["moved"], "would_claim_uc": False} + situation["households"][f"h{h}"]["members"].append("moved") + return situation, keys + + +def claim_and_contributions(situation): + sim = Simulation(situation=situation) + + def calc(variable): + return np.asarray(sim.calculate(variable, YEAR)) + + own_claim = ( + calc("benunit_is_rent_liable") + & calc("is_uc_eligible") + & calc("claims_universal_credit") + ) + return sim, calc, own_claim + + +@PROPERTY_SETTINGS +@given(adult_scenario()) +def test_every_in_unit_non_dependant_with_a_room_owes_a_contribution(case): + population, target, person, would_claim = case + before, keys = build(population) + after, _ = build(population, (target, person)) + before = with_claim_flags(before, would_claim) + after = with_claim_flags(after, would_claim) + sim_before, calc_before, own_claim_before = claim_and_contributions(before) + sim, calc, own_claim = claim_and_contributions(after) + counted = calc("uc_non_dependants_counted") + assert np.array_equal(own_claim, own_claim_before) + assert np.array_equal(counted, calc_before("uc_non_dependants_counted")) + # 5. Nobody owes a contribution without being a non-dependant (Sch 4 + # para 9(2) and para 13). + owes = calc("uc_individual_non_dep_deduction") > 0 + non_dependant = calc("is_benefit_unit_non_dependant_for_universal_credit") | calc( + "is_non_dependant_of_household_head" + ) + assert not np.any(owes & ~non_dependant) + names = list(after["people"]) + i = names.index(f"h{target[0]}_f{target[1]}_added") + in_unit = calc("is_benefit_unit_non_dependant_for_universal_credit")[i] + head = calc("is_resolved_household_head")[i] + # The added member is never a claimant. Outside education, they count as + # a qualifying young person only before the 1 September after their 16th + # birthday (UC reg 5(1)(a)), which the formula reads. + qualifying = calc("is_qualifying_young_person_for_universal_credit")[i] + assert in_unit == (person["age"] >= 16 and not head and not qualifying) + if not in_unit: + return + # An in-unit non-dependant owes a contribution unless aged under 21 or + # receiving Attendance Allowance (para 16(2)(a), (2)(c)). + p = sim.tax_benefit_system.parameters( + YEAR + ).gov.dwp.universal_credit.elements.housing.non_dep_deduction + exempt = person["age"] < p.age_threshold or person["attendance_allowance"] > 0 + assert owes[i] == (not exempt) + # The claim that bears them: their own family's if it makes one, + # otherwise the one counting the household's non-dependants. + b = keys.index(target) + role = population[target[0]][target[1]]["role"] + same_household = np.array([h == target[0] for h, _ in keys]) + if own_claim[b] and role != "non_dependant": + bearer = np.arange(len(keys)) == b + else: + bearer = same_household & counted.astype(bool) + renter_exempt = calc("uc_non_dep_deductions_renter_exempt") + expected = bearer * (not exempt) * ~renter_exempt * p.amount * 12 + delta = calc("uc_non_dep_deductions") - calc_before("uc_non_dep_deductions") + assert np.allclose(delta, expected) + # The claim that bears the contribution also has their bedroom. + rooms = calc("LHA_allowed_bedrooms") - calc_before("LHA_allowed_bedrooms") + assert np.all(rooms[bearer] == 1) + + +@PROPERTY_SETTINGS +@given(adult_scenario()) +def test_moving_a_non_claiming_adult_out_of_the_benefit_unit_changes_no_claim(case): + population, target, person, would_claim = case + inside, keys = build(population, (target, person)) + inside = with_claim_flags(inside, would_claim) + outside, _ = in_own_benefit_unit(population, target, person, would_claim) + _, calc_in, own_claim_in = claim_and_contributions(inside) + _, calc_out, own_claim_out = claim_and_contributions(outside) + n = len(keys) + # The household head's family keeps its claim either way. + assert np.array_equal(own_claim_in, own_claim_out[:n]) + # 6. Every family making a claim keeps its bedrooms, contribution and + # award whether the adult is coded in a benefit unit or in their own. + for variable in ( + "LHA_allowed_bedrooms", + "uc_non_dep_deductions", + "universal_credit", + ): + a = calc_in(variable)[own_claim_in] + b = calc_out(variable)[:n][own_claim_in] + assert np.allclose(a, b), variable diff --git a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py index caeacd7ce2..6c305411ee 100644 --- a/policyengine_uk/tests/test_marriage_allowance_cps_switch.py +++ b/policyengine_uk/tests/test_marriage_allowance_cps_switch.py @@ -24,12 +24,13 @@ one has a Marriage Allowance, and income tax is what it is at current law when no one claims the allowance. 3. With any statutory rate and the CPS parameters at their defaults, no one - in the higher or additional band meets the income conditions or has the - allowance, and no one has more than the statutory share of the personal - allowance, rounded up to the next £10. + liable above the basic rates (above the Scottish intermediate rate for a + Scottish taxpayer) meets the income conditions or has the allowance, + everyone else meets the conditions, and no one has more than the statutory + share of the personal allowance, rounded up to the next £10. 4. The allowance never falls when the statutory rate rises. -5. Switched on by its own parameters, the reform behaves as it did before - this change (regression values computed before the fix). +5. Switched on by its own parameters, the reform gives the allowance to the + higher-rate earner too, worth the basic rate of the allowance. """ import functools @@ -69,6 +70,12 @@ # (marriage_allowance/rounding_increment.yaml). PERSONAL_ALLOWANCE = 12_570 ROUNDING_INCREMENT = 10 +# 2025-26 taxable income above which the model charges non-savings income at +# rates that bar the allowance: the basic rate limit (ITA 2007 s. 10), and for +# a Scottish taxpayer the start of the Scottish higher rate, £43,662 of income +# less the personal allowance (Scottish Rate Resolution 2025). +BASIC_RATE_LIMIT = 37_700 +SCOTTISH_HIGHER_RATE_START = 31_092 REFORM_VARIABLE = "meets_expanded_ma_conditions" # Only the reform adds it. @@ -161,19 +168,19 @@ def test_a_higher_statutory_rate_raises_only_the_statutory_allowance(route): assert outcome(simulation) == ([2_520, 0, 0, 0], [2_982, 11_432]) -# Invariant 5. Each expectation was computed on main before this change -# (ecd531776); the reform's own parameters switched it on there too. The -# expanded allowance reaches the higher-rate earner. The model takes the -# allowance off taxable income, so it is worth 40% to them: 11,432 - 1,260 x -# 40% = 10,928 at 10%, and 11,432 - 2,520 x 40% = 10,424 at 20%. +# Invariant 5, by hand. The expanded allowance reaches the higher-rate earner. +# Parliament gives the allowance as a tax reduction of the basic rate on the +# amount (ITA 2007 s. 55B(1) and (3)), whatever the recipient's top rate: +# 11,432 - 1,260 x 20% = 11,180 at 10%, and 11,432 - 2,520 x 20% = 10,928 at +# 20%. OWN_SWITCHES = { "remove_income_condition": ( change((REMOVE_INCOME_CONDITION, True)), - ([1_260, 0, 1_260, 0], [3_234, 10_928]), + ([1_260, 0, 1_260, 0], [3_234, 11_180]), ), "cps_rate": ( change((CPS_RATE, 0.2)), - ([2_520, 0, 2_520, 0], [2_982, 10_424]), + ([2_520, 0, 2_520, 0], [2_982, 10_928]), ), } @@ -192,7 +199,7 @@ def test_a_cps_rate_switches_the_reform_on_whatever_the_statutory_rate(): reform = change((STATUTORY_RATE, 0.2), (CPS_RATE, 0.2)) simulation = Simulation(situation=ISSUE_COUPLES, reform=reform) assert REFORM_VARIABLE in simulation.tax_benefit_system.variables - assert outcome(simulation) == ([2_520, 0, 2_520, 0], [2_982, 10_424]) + assert outcome(simulation) == ([2_520, 0, 2_520, 0], [2_982, 10_928]) CHILD_CONDITION_COUPLES = couples( @@ -281,8 +288,8 @@ def test_the_reform_switches_on_from_its_own_parameters_only( # parameters, reused: each example writes its own inputs. COUPLES = 20 -# Every fourth couple lives in Scotland, whose starter and intermediate bands -# are eligible bands. +# Every fourth couple lives in Scotland, where Parliament allows the allowance +# at the Scottish starter and intermediate rates (ITA 2007 s. 55B(2)(b)). REGIONS = ["SCOTLAND", "LONDON", "NORTH_EAST", "WALES"] @@ -368,7 +375,6 @@ def run(simulation, drawn, anyone_claims=True): simulation.set_input("is_married", YEAR, married) simulation.set_input("would_claim_marriage_allowance", YEAR, claims) simulation.reset_calculations() - p = simulation.tax_benefit_system.parameters(YEAR) return dict( allowance=simulation.calculate("marriage_allowance", YEAR), meets_income_conditions=simulation.calculate( @@ -376,8 +382,15 @@ def run(simulation, drawn, anyone_claims=True): ), # Simulation.calculate returns an enum's names. band=np.asarray(simulation.calculate("tax_band", YEAR), dtype=str), - eligible_bands=p.gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands, income_tax=simulation.calculate("income_tax", YEAR), + # The earners have only employment income, so they have no savings or + # dividend income. + taxable=np.maximum( + simulation.calculate("employment_income", YEAR) + - simulation.calculate("personal_allowance", YEAR), + 0, + ), + scottish=simulation.calculate("pays_scottish_income_tax", YEAR), ) @@ -408,11 +421,14 @@ def test_any_statutory_rate_keeps_the_statutory_conditions_and_cap( simulation = simulation_for((STATUTORY_RATE, rate_permille / 1_000)) assert REFORM_VARIABLE not in simulation.tax_benefit_system.variables r = run(simulation, drawn) - outside = ~np.isin(r["band"], r["eligible_bands"]) - higher = np.isin(r["band"], ["HIGHER", "ADDITIONAL"]) - assert outside[higher].all() - assert not r["meets_income_conditions"][outside].any() - assert (r["allowance"][outside] == 0).all() + # Liable above those rates on any of their income, however little (ITA + # 2007 s. 55B(2)(b)). Income at the limit bears the lower rate (s. 10(2)). + limit = np.where(r["scottish"], SCOTTISH_HIGHER_RATE_START, BASIC_RATE_LIMIT) + above = r["taxable"] > limit + assert np.isin(r["band"], ["HIGHER", "ADDITIONAL"])[above].all() + assert not r["meets_income_conditions"][above].any() + assert r["meets_income_conditions"][~above].all() + assert (r["allowance"][above] == 0).all() assert (r["allowance"] >= 0).all() assert (r["allowance"] <= statutory_cap(rate_permille)).all() diff --git a/policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py b/policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py new file mode 100644 index 0000000000..045ef63b27 --- /dev/null +++ b/policyengine_uk/tests/test_marriage_allowance_tax_reduction_properties.py @@ -0,0 +1,194 @@ +"""The Marriage Allowance gives its recipient a tax reduction at Step 6. + +Parliament gives the recipient a tax reduction of the appropriate percentage +of the transferable amount (ITA 2007 s. 55B(1)) at Step 6 (s. 55A(2)): the +basic rate, or for a Scottish taxpayer the Scottish basic rate (s. 55B(3)). +The reduction comes off only so far as Step 5 tax remains after the +reductions already deducted (s. 29(2) and (3)). + +Invariants, for generated married couples in England, Scotland, Wales and +Northern Ireland, with pay, pensions, savings and dividends drawn freely: + +1. For every person, income_tax equals the income tax with + would_claim_marriage_allowance false, less the smaller of the appropriate + percentage of marriage_allowance and the Step 5 tax the earlier reductions + leave. +2. The reduction does not depend on the recipient's marginal rate: under the + CPS expanded reform, which gives the allowance to recipients in every band, + a recipient with enough tax gets exactly the appropriate percentage of the + allowance, and income tax falls by that amount. +3. The election does not move the rate test: + meets_marriage_allowance_income_conditions gives the same result with + would_claim_marriage_allowance true and false. +4. The reduction lies between zero and the appropriate percentage of the + allowance, and the Step 6 reductions together never exceed the Step 5 tax. +""" + +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.reforms.cps.marriage_tax_reforms import expanded_ma_reform + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +TOLERANCE = 0.01 +COUPLES = 8 +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTHERN_IRELAND"] +YEARS = [2016, 2017, 2018, 2021, 2025, 2026] + +small = st.integers(min_value=0, max_value=20_000) +person_strategy = st.tuples( + st.integers(min_value=0, max_value=160_000), # employment income + st.integers(min_value=0, max_value=30_000), # private pension income + small, # savings interest + small, # dividends +) +couple_strategy = st.tuples(person_strategy, person_strategy, st.sampled_from(REGIONS)) +couples_strategy = st.lists(couple_strategy, min_size=COUPLES, max_size=COUPLES) + +VARIABLES = [ + "income_tax", + "income_tax_pre_charges", + "income_tax_reductions", + "marriage_allowance", + "marriage_allowance_tax_reduction", + "marriage_allowance_appropriate_percentage", + "meets_marriage_allowance_income_conditions", +] + + +def situation(couples, year, would_claim): + people, benunits, households = {}, {}, {} + for i, (first, second, region) in enumerate(couples): + members = [f"first_{i}", f"second_{i}"] + for name, (pay, pension, savings, dividends) in zip(members, (first, second)): + people[name] = { + "age": {year: 45}, + "marital_status": {year: "MARRIED"}, + "employment_income": {year: pay}, + "private_pension_income": {year: pension}, + "savings_interest_income": {year: savings}, + "dividend_income": {year: dividends}, + "would_claim_marriage_allowance": {year: would_claim}, + } + benunits[f"benunit_{i}"] = {"members": members, "is_married": {year: True}} + households[f"household_{i}"] = { + "members": members, + "region": {year: region}, + } + return {"people": people, "benunits": benunits, "households": households} + + +def calculate(couples, year, would_claim, reform=None): + simulation = Simulation( + situation=situation(couples, year, would_claim), reform=reform + ) + values = {name: np.asarray(simulation.calculate(name, year)) for name in VARIABLES} + step_6 = simulation.tax_benefit_system.parameters( + year + ).gov.hmrc.income_tax.step_6_tax_reductions + earlier = list(step_6)[: list(step_6).index("marriage_allowance_tax_reduction")] + values["earlier_reductions"] = sum( + np.asarray(simulation.calculate(name, year)) for name in earlier + ) + values["step_6_reductions"] = sum( + np.asarray(simulation.calculate(name, year)) for name in step_6 + ) + return values + + +SETTINGS = settings( + max_examples=12, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@SETTINGS +@given(couples=couples_strategy, year=st.sampled_from(YEARS)) +def test_reduction_takes_the_appropriate_percentage_off_step_5_tax(couples, year): + claimed = calculate(couples, year, would_claim=True) + unclaimed = calculate(couples, year, would_claim=False) + rate = claimed["marriage_allowance_appropriate_percentage"] + allowance = claimed["marriage_allowance"] + reduction = claimed["marriage_allowance_tax_reduction"] + tax_left = np.maximum( + 0, unclaimed["income_tax_pre_charges"] - unclaimed["earlier_reductions"] + ) + expected = np.minimum(rate * allowance, tax_left) + # Invariant 1. + np.testing.assert_allclose( + claimed["income_tax"], unclaimed["income_tax"] - expected, atol=TOLERANCE + ) + np.testing.assert_allclose(reduction, expected, atol=TOLERANCE) + # Invariant 3. + np.testing.assert_array_equal( + claimed["meets_marriage_allowance_income_conditions"], + unclaimed["meets_marriage_allowance_income_conditions"], + ) + # Invariant 4. + assert (reduction >= -TOLERANCE).all() + assert (reduction <= rate * allowance + TOLERANCE).all() + step_5 = np.maximum(0, claimed["income_tax_pre_charges"]) + assert (claimed["step_6_reductions"] <= step_5 + TOLERANCE).all() + assert (claimed["income_tax_reductions"] <= step_5 + TOLERANCE).all() + # The allowance never changes the Step 5 tax. + np.testing.assert_allclose( + claimed["income_tax_pre_charges"], + unclaimed["income_tax_pre_charges"], + atol=TOLERANCE, + ) + + +@SETTINGS +@given(couples=couples_strategy, year=st.sampled_from([2018, 2021, 2025, 2026])) +def test_reduction_does_not_depend_on_the_marginal_rate(couples, year): + # Invariant 2. The CPS expanded reform, with no child condition, gives the + # allowance to married recipients in every band. + claimed = calculate(couples, year, would_claim=True, reform=expanded_ma_reform) + unclaimed = calculate(couples, year, would_claim=False, reform=expanded_ma_reform) + rate = claimed["marriage_allowance_appropriate_percentage"] + allowance = claimed["marriage_allowance"] + full = rate * allowance + enough_tax = ( + unclaimed["income_tax_pre_charges"] - unclaimed["earlier_reductions"] + >= full + TOLERANCE + ) + np.testing.assert_allclose( + claimed["marriage_allowance_tax_reduction"][enough_tax], + full[enough_tax], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + unclaimed["income_tax"][enough_tax] - claimed["income_tax"][enough_tax], + full[enough_tax], + atol=TOLERANCE, + ) + # The appropriate percentage is the basic rate or the Scottish basic rate, + # 20% in each of these years, whatever the recipient's income. + np.testing.assert_allclose(rate, 0.2) + + +def test_higher_rate_recipients_under_the_expanded_reform_get_20_percent(): + # 2025-26, England: pay of 60,000 and 150,000 put the recipients in the + # higher and additional rate bands. The CPS expanded reform gives each the + # 1,260 allowance, and the reduction is 20% x 1,260 = 252, not 40% or 45% + # of it. + couples = [ + ((60_000, 0, 0, 0), (0, 0, 0, 0), "LONDON"), + ((150_000, 0, 0, 0), (0, 0, 0, 0), "LONDON"), + ((60_000, 0, 0, 0), (0, 0, 0, 0), "SCOTLAND"), + ] + claimed = calculate(couples, 2025, would_claim=True, reform=expanded_ma_reform) + unclaimed = calculate(couples, 2025, would_claim=False, reform=expanded_ma_reform) + np.testing.assert_allclose( + claimed["marriage_allowance"], [1_260, 0, 1_260, 0, 1_260, 0] + ) + np.testing.assert_allclose( + unclaimed["income_tax"] - claimed["income_tax"], + [252, 0, 252, 0, 252, 0], + atol=TOLERANCE, + ) diff --git a/policyengine_uk/tests/test_non_dependant_cycle_contracts.py b/policyengine_uk/tests/test_non_dependant_cycle_contracts.py new file mode 100644 index 0000000000..cf158ec4d7 --- /dev/null +++ b/policyengine_uk/tests/test_non_dependant_cycle_contracts.py @@ -0,0 +1,550 @@ +"""Universal Credit and Housing Benefit when the inputs supply cap totals. + +Housing Benefit's non-dependant deductions read universal_credit, so no +baseline formula on Universal Credit's route may calculate Housing Benefit. +The Universal Credit cap counts Housing Benefit as nil (UC Regs 2013 reg +80(2A)): its basket, uc_benefit_cap_welfare_benefits, adds up the other +welfare benefits itself. Inputs that supply benefit_cap_welfare_benefits, a +total that includes Housing Benefit, also supply housing_benefit_pre_benefit_cap, +which the basket subtracts, or supply the basket itself. + +The contract holds for every population: either the inputs leave +benefit_cap_welfare_benefits to the model, or they supply +housing_benefit_pre_benefit_cap or uc_benefit_cap_welfare_benefits beside it; +otherwise the basket raises ValueError, whether or not any household holds a +non-dependant. A value a scenario stores as the model's own (set_model_value) +supplies neither input. Under the contract, each household's Universal Credit +and Housing Benefit equal its results alone. A reform that makes Universal +Credit read calculated Housing Benefit adds a cycle, and the engine raises +CycleError. + +The tests use two independent households: a single Universal Credit +claimant with £20,000 before a £14,753 cap and no rent; and a 70-year-old +paying £6,000 rent with a 40-year-old non-dependant who earns £9,000 at 20 +hours and claims no Universal Credit. Alone, the first gets £14,753 of +Universal Credit. For the second's non-dependant the council deducts +£19.65 x 52 = £1,021.80 (HB Regs 2006 regs 70 and 74(2)(a), 2025-26), +leaving £4,978.20 of Housing Benefit. +""" + +import copy + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.errors import CycleError +from policyengine_core.reforms import Reform +from policyengine_core.simulations import Simulation + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.utils.supplied_inputs import set_model_value +from policyengine_uk.variables.gov.dwp.benefit_cap_welfare_benefits import ( + benefit_cap_welfare_benefits as CombinedTotal, +) +from policyengine_uk.variables.gov.dwp.universal_credit.benefit_cap.uc_benefit_cap_welfare_benefits import ( + uc_benefit_cap_welfare_benefits as Basket, +) +from policyengine_uk.variables.gov.dwp.universal_credit.universal_credit_pre_benefit_cap import ( + universal_credit_pre_benefit_cap as PreCap, +) + +YEAR = 2025 +UC_MIXED = [14_753.0, 0.0, 0.0] +HB_MIXED = [0.0, 4_978.20, 0.0] +FIRST_REQUESTS = [ + "universal_credit", + "housing_benefit", + "housing_benefit_non_dep_deductions", +] +NO_AWARDS = dict( + child_benefit=0.0, + child_tax_credit=0.0, + working_tax_credit=0.0, + income_support=0.0, + jsa_income=0.0, + esa_income=0.0, + pension_credit=0.0, + uc_deductions=0.0, + uc_deduction_rate=0.0, + uc_childcare_element=0.0, +) + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +def situation(which="mixed", year=YEAR, full_uc=False): + """The claimant's household ("single"), the non-dependant's + ("nondep"), or both ("mixed"), with no cap total supplied.""" + people, units, households = {}, {}, {} + if which in ("single", "mixed"): + people["uc"] = dict( + age=40, + current_education="NOT_IN_EDUCATION", + employment_income=1_000.0, + weekly_hours=20.0, + ) + units["uc"] = dict( + members=["uc"], + **NO_AWARDS, + would_claim_uc=True, + uc_benefit_cap=14_753.0, + benunit_is_rent_liable=False, + ) + if not full_uc: + units["uc"]["universal_credit_pre_benefit_cap"] = 20_000.0 + households["single"] = dict( + members=["uc"], + country="ENGLAND", + local_authority="MAIDSTONE", + rent=0.0, + tenure_type="RENT_PRIVATELY", + ) + if which in ("nondep", "mixed"): + people["applicant"] = dict(age=70, current_education="NOT_IN_EDUCATION") + people["nondep"] = dict( + age=40, + current_education="NOT_IN_EDUCATION", + employment_income=9_000.0, + weekly_hours=20.0, + ) + for name in ["applicant", "nondep"]: + units[name] = dict( + members=[name], + **NO_AWARDS, + would_claim_uc=False, + uc_benefit_cap=14_753.0, + benunit_is_rent_liable=name == "applicant", + ) + if not full_uc: + units[name]["universal_credit_pre_benefit_cap"] = 0.0 + households["nondep"] = dict( + members=["applicant", "nondep"], + country="ENGLAND", + local_authority="MAIDSTONE", + rent=6_000.0, + tenure_type="RENT_PRIVATELY", + ) + return keyed(dict(people=people, benunits=units, households=households), year) + + +def keyed(data, year): + """Key every input by year.""" + for entities in data.values(): + for entity in entities.values(): + for name, value in entity.items(): + if name != "members" and not isinstance(value, dict): + entity[name] = {year: value} + return data + + +def supply(data, variable, values, year=YEAR): + """Supply ``variable`` for every benefit unit, from ``values`` by unit + name (nil for a unit it leaves out).""" + for name, unit in data["benunits"].items(): + unit[variable] = {year: float(values.get(name, 0.0))} + return data + + +def make(system, data, year=YEAR, trace=False): + return Simulation( + tax_benefit_system=system, + situation=copy.deepcopy(data), + default_input_period=year, + default_calculation_period=year, + trace=trace, + ) + + +def with_totals(which, **inputs): + """The population with the combined total its Universal Credit and + Housing Benefit imply supplied, and any other inputs.""" + data = situation(which) + supply(data, "benefit_cap_welfare_benefits", {"uc": 20_000.0}) + for variable, values in inputs.items(): + supply(data, variable, values) + return data + + +def traced_names(simulation): + return {key.split("<")[0] for key in simulation.tracer.get_serialized_flat_trace()} + + +@pytest.mark.parametrize("first", FIRST_REQUESTS) +def test_a_supplied_universal_credit_basket_keeps_households_independent(system, first): + """With the combined total and Universal Credit's basket supplied, each + household gets its results alone, whatever the first request.""" + basket = {"uc": 20_000.0} + alone = [ + make(system, with_totals(which, uc_benefit_cap_welfare_benefits=basket)) + for which in ["single", "nondep"] + ] + mixed = make(system, with_totals("mixed", uc_benefit_cap_welfare_benefits=basket)) + mixed.calculate(first, YEAR) + for name, expected in [ + ("universal_credit", UC_MIXED), + ("housing_benefit", HB_MIXED), + ]: + result = mixed.calculate(name, YEAR) + np.testing.assert_allclose(result, expected, atol=0.01, err_msg=name) + np.testing.assert_allclose( + result, + np.concatenate([sim.calculate(name, YEAR) for sim in alone]), + atol=0.01, + err_msg=name, + ) + np.testing.assert_allclose( + mixed.calculate("housing_benefit_non_dep_deductions", YEAR), + [0.0, 1_021.80, 0.0], + atol=0.01, + ) + + +@pytest.mark.parametrize("first", FIRST_REQUESTS) +def test_a_supplied_housing_benefit_gives_its_arithmetic(system, first): + """With the combined total and Housing Benefit supplied, the basket is + their difference, and Universal Credit and Housing Benefit follow from + it. The claimant's total of £26,000 includes £6,000 of Housing Benefit + (as in specified accommodation): the basket counts £20,000, £5,247 over + the cap, so the award comes to £14,753, and HB reg 75F(1)(g) leaves the + Housing Benefit uncapped. The applicant's total is their £4,978.20 of + Housing Benefit, and pension age puts them outside the cap.""" + data = situation("mixed") + supply( + data, + "benefit_cap_welfare_benefits", + {"uc": 26_000.0, "applicant": 4_978.20}, + ) + supply( + data, + "housing_benefit_pre_benefit_cap", + {"uc": 6_000.0, "applicant": 4_978.20}, + ) + sim = make(system, data) + sim.calculate(first, YEAR) + np.testing.assert_allclose( + sim.calculate("uc_benefit_cap_welfare_benefits", YEAR), + [20_000.0, 0.0, 0.0], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("universal_credit", YEAR), UC_MIXED, atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("housing_benefit", YEAR), [6_000.0, 4_978.20, 0.0], atol=0.01 + ) + # The deduction still reads the non-dependant's wages and Universal + # Credit; the supplied Housing Benefit does not read the deduction. + np.testing.assert_allclose( + sim.calculate("housing_benefit_non_dep_deductions", YEAR), + [0.0, 1_021.80, 0.0], + atol=0.01, + ) + + +@pytest.mark.parametrize("which", ["mixed", "single"]) +def test_a_supplied_total_alone_raises(system, which): + """A supplied combined total without housing_benefit_pre_benefit_cap or + uc_benefit_cap_welfare_benefits raises ValueError, with or without a + non-dependant in the population, and the message names both inputs.""" + sim = make(system, with_totals(which)) + with pytest.raises(ValueError) as error: + sim.calculate("universal_credit", YEAR) + message = str(error.value) + assert "housing_benefit_pre_benefit_cap" in message + assert "uc_benefit_cap_welfare_benefits" in message + assert "benefit_cap_welfare_benefits" in message + + +def test_a_reformed_total_leaves_universal_credit_on_its_basket(system): + """A reform that gives benefit_cap_welfare_benefits a new formula changes + the Housing Benefit cap's total, and Universal Credit's basket still adds + up the benefits itself.""" + + def formula(benunit, period, parameters): + return CombinedTotal.formula(benunit, period, parameters) + 1_000 + + class ReformedTotal(Reform): + def apply(self): + self.update_variable( + type( + "benefit_cap_welfare_benefits", + (CombinedTotal,), + dict(formula=formula), + ) + ) + + baseline = make(system, situation("mixed")) + reformed = make(ReformedTotal(system), situation("mixed")) + np.testing.assert_allclose( + reformed.calculate("benefit_cap_welfare_benefits", YEAR), + baseline.calculate("benefit_cap_welfare_benefits", YEAR) + 1_000, + atol=0.01, + ) + for name in ["uc_benefit_cap_welfare_benefits", "universal_credit"]: + np.testing.assert_allclose( + reformed.calculate(name, YEAR), + baseline.calculate(name, YEAR), + atol=0.01, + err_msg=name, + ) + np.testing.assert_allclose( + reformed.calculate("universal_credit", YEAR), UC_MIXED, atol=0.01 + ) + + +def test_model_values_supply_neither_input(system): + """set_model_value stores a value as the model's own, so it supplies + neither the combined total nor Housing Benefit. A model value of the + total leaves Universal Credit's basket adding up the benefits itself; a + supplied total with a model value of Housing Benefit still raises.""" + baseline = make(system, situation("mixed")) + basket = baseline.calculate("uc_benefit_cap_welfare_benefits", YEAR) + stored_total = make(system, situation("mixed")) + set_model_value( + stored_total, + "benefit_cap_welfare_benefits", + YEAR, + np.array([30_000.0, 0.0, 0.0]), + ) + np.testing.assert_allclose( + stored_total.calculate("uc_benefit_cap_welfare_benefits", YEAR), + basket, + atol=0.01, + ) + np.testing.assert_allclose( + stored_total.calculate("universal_credit", YEAR), UC_MIXED, atol=0.01 + ) + stored_housing_benefit = make(system, with_totals("mixed")) + set_model_value( + stored_housing_benefit, + "housing_benefit_pre_benefit_cap", + YEAR, + np.array([0.0, 4_978.20, 0.0]), + ) + with pytest.raises(ValueError, match="housing_benefit_pre_benefit_cap"): + stored_housing_benefit.calculate("universal_credit", YEAR) + + +UC_HOUSEHOLD = st.fixed_dictionaries( + dict( + kind=st.just("uc"), + universal_credit_pre_benefit_cap=st.floats(0, 40_000), + uc_benefit_cap=st.sampled_from([14_753.0, 22_020.0]), + child_benefit=st.sampled_from([0.0, 1_500.0]), + employment_income=st.floats(0, 20_000), + ) +) +NON_DEPENDANT_HOUSEHOLD = st.fixed_dictionaries( + dict( + kind=st.just("nondep"), + rent=st.sampled_from([3_000.0, 6_000.0]), + employment_income=st.floats(0, 30_000), + weekly_hours=st.sampled_from([0.0, 20.0, 35.0]), + universal_credit_pre_benefit_cap=st.sampled_from([0.0, 5_000.0, 15_000.0]), + ) +) + + +def generated_population(households): + """Each generated household, with its Universal Credit basket and the + combined total it implies supplied for every benefit unit.""" + people, units, homes, basket = {}, {}, {}, {} + for h, household in enumerate(households): + if household["kind"] == "uc": + claimant = f"uc_{h}" + people[claimant] = dict( + age=40, + current_education="NOT_IN_EDUCATION", + employment_income=household["employment_income"], + weekly_hours=20.0, + ) + units[claimant] = dict( + members=[claimant], + **{**NO_AWARDS, "child_benefit": household["child_benefit"]}, + would_claim_uc=True, + uc_benefit_cap=household["uc_benefit_cap"], + benunit_is_rent_liable=False, + universal_credit_pre_benefit_cap=household[ + "universal_credit_pre_benefit_cap" + ], + ) + basket[claimant] = ( + household["universal_credit_pre_benefit_cap"] + + household["child_benefit"] + ) + homes[f"home_{h}"] = dict( + members=[claimant], + country="ENGLAND", + local_authority="MAIDSTONE", + rent=0.0, + tenure_type="RENT_PRIVATELY", + ) + else: + applicant, non_dependant = f"applicant_{h}", f"nondep_{h}" + on_uc = household["universal_credit_pre_benefit_cap"] > 0 + people[applicant] = dict(age=70, current_education="NOT_IN_EDUCATION") + people[non_dependant] = dict( + age=40, + current_education="NOT_IN_EDUCATION", + employment_income=household["employment_income"], + weekly_hours=household["weekly_hours"], + ) + units[applicant] = dict( + members=[applicant], + **NO_AWARDS, + would_claim_uc=False, + uc_benefit_cap=14_753.0, + benunit_is_rent_liable=True, + universal_credit_pre_benefit_cap=0.0, + ) + units[non_dependant] = dict( + members=[non_dependant], + **NO_AWARDS, + would_claim_uc=on_uc, + uc_benefit_cap=14_753.0, + benunit_is_rent_liable=False, + universal_credit_pre_benefit_cap=household[ + "universal_credit_pre_benefit_cap" + ], + ) + basket[non_dependant] = household["universal_credit_pre_benefit_cap"] + homes[f"home_{h}"] = dict( + members=[applicant, non_dependant], + country="ENGLAND", + local_authority="MAIDSTONE", + rent=household["rent"], + tenure_type="RENT_PRIVATELY", + ) + data = keyed(dict(people=people, benunits=units, households=homes), YEAR) + # The supplied units hold no Housing Benefit in the cap: the claimants + # pay no rent, and the applicants have reached pension age. + supply(data, "uc_benefit_cap_welfare_benefits", basket) + supply(data, "benefit_cap_welfare_benefits", basket) + return data + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + households=st.lists( + st.one_of(UC_HOUSEHOLD, NON_DEPENDANT_HOUSEHOLD), min_size=1, max_size=3 + ) +) +def test_each_household_gets_its_results_alone(system, households): + """For any mix of independent households, with Universal Credit's basket + supplied, each household's Universal Credit, Housing Benefit and + non-dependant deductions equal its results alone.""" + mixed = make(system, generated_population(households)) + alone = [ + make(system, generated_population([household])) for household in households + ] + for name in [ + "universal_credit", + "housing_benefit", + "housing_benefit_non_dep_deductions", + ]: + np.testing.assert_allclose( + mixed.calculate(name, YEAR), + np.concatenate([sim.calculate(name, YEAR) for sim in alone]), + atol=0.01, + err_msg=name, + ) + + +def test_computed_universal_credit_and_local_schemes(system): + """With nothing supplied, Universal Credit's traced calculation reads no + Housing Benefit variable but the report of the award, and the national and + local Council Tax Reduction schemes then read Universal Credit.""" + # Each local scheme's parameters start in 2026-27. + year = 2026 + template = situation("mixed", year=year, full_uc=True) + template["people"]["uc"]["housing_benefit_reported"] = {year: 6_000.0} + data = {group: {} for group in template} + for authority in [ + "MERTON", + "KINGSTON_UPON_THAMES", + "NEWHAM", + "WESTMINSTER", + "OXFORD", + ]: + prefix = authority.lower() + "_" + for group, entities in template.items(): + for name, entity in entities.items(): + copied = copy.deepcopy(entity) + if "members" in copied: + copied["members"] = [ + prefix + member for member in copied["members"] + ] + if group == "benunits": + # A reported Housing Benefit award otherwise makes + # claims_all_entitled_benefits false, so this family + # would not claim CTR by default. + copied["would_claim_council_tax_reduction"] = {year: True} + if group == "households": + copied["local_authority"] = {year: authority} + copied["council_tax"] = {year: 2_000.0} + data[group][prefix + name] = copied + sim = make(system, data, year=year, trace=True) + universal_credit = sim.calculate("universal_credit", year) + names = traced_names(sim) + assert universal_credit[0] > 0 + assert "uc_maximum_amount" in names + assert "housing_benefit_reported" in names + assert not { + name + for name in names + if name.startswith("housing_benefit") + and not name.startswith("housing_benefit_report") + } + for name in [ + "housing_benefit", + "council_tax_reduction", + "merton_council_tax_reduction", + "kingston_upon_thames_council_tax_reduction", + "newham_council_tax_reduction", + "westminster_council_tax_reduction", + "oxford_council_tax_reduction", + ]: + values = sim.calculate(name, year) + if name.endswith("_council_tax_reduction"): + assert values.max() > 0, (name, values) + + +@pytest.mark.parametrize( + "variable,parent", + [ + ("uc_benefit_cap_welfare_benefits", Basket), + ("universal_credit_pre_benefit_cap", PreCap), + ], +) +def test_a_reform_reading_calculated_housing_benefit_raises_cycle_error( + system, variable, parent +): + """A reform that adds calculated Housing Benefit to Universal Credit's + basket or award makes Universal Credit and the deductions depend on each + other, and the engine raises CycleError.""" + + def formula(benunit, period, parameters): + return parent.formula(benunit, period, parameters) + benunit( + "housing_benefit", period + ) + + replacement = type(variable, (parent,), dict(formula=formula)) + + class CyclicReform(Reform): + def apply(self): + self.update_variable(replacement) + + data = situation("mixed") + for unit in data["benunits"].values(): + unit.pop(variable, None) + with pytest.raises(CycleError): + make(CyclicReform(system), data).calculate("housing_benefit", YEAR) diff --git a/policyengine_uk/tests/test_non_dependant_deduction_properties.py b/policyengine_uk/tests/test_non_dependant_deduction_properties.py new file mode 100644 index 0000000000..5a172b2bad --- /dev/null +++ b/policyengine_uk/tests/test_non_dependant_deduction_properties.py @@ -0,0 +1,1374 @@ +"""Property-based and exhaustive tests for the Housing Benefit and national +Council Tax Reduction non-dependant deductions. + +Law: +- Housing Benefit: SI 2006/213 reg 74 (working age) and SI 2006/214 reg 55 + (pension age). +- Council Tax Reduction: SI 2012/2885 Sch 1 para 8 (England, pensioners); + WSI 2013/3029 Sch 1 para 3 and Sch 6 para 5 (Wales); SSI 2012/319 reg 48 and + SSI 2021/249 reg 90 (Scotland). + +Invariants, for any generated population of households, each a claimant family +and a non-dependant family of one or two adults: + +1. An exempt non-dependant's individual deduction is 0, for Housing Benefit and + for Council Tax Reduction. +2. Bounds: for an eligible non-dependant who is not exempt, the council + deducts between the scale's lowest and highest weekly amounts. In English + working-age households (local schemes) the national CTR deduction is 0. +3. For a non-dependant outside remunerative work (under 16 hours or, for Housing + Benefit, on IS, JSA(IB) or ESA(IR): HB reg 6(6)), the council deducts the lowest + amount whatever their income. +4. If the claimant or partner is blind or receives a qualifying disability + benefit (read from the generated inputs, and checked against the model's + flags), the claimant family's deductions are 0. +5. Aggregation: a family's deductions are the sum, over the other families in + the household, of their claimant and partner's higher amount (both amounts + for a Welsh working-age applicant's non-dependant couple whose own + Universal Credit award assesses either member; another member's award as + a single claimant leaves the couple one deduction) plus each other + member's, recomputed here from the individual amounts, times the family's + share of the rent (Housing Benefit, reg 74(5)) or of the council tax + liability (CTR, Sch 1 para 8(5)). +6. Monotonicity (metamorphic): raising any non-dependant's income never lowers + an individual deduction or a family's deductions. +7. Exhaustive: in every year from 2015 (2019 for Housing Benefit) to 2030, + including uprated years, an income at a band's lower edge gets that band's + amount and the next lower representable income gets the band below's. +8. Differential: each CTR scale's parameter equals the enacted sums in + tests/fixtures/council_tax_reduction_non_dependant_deductions.yaml. +9. Differential: each exemption flag equals a reading of the provisions + straight from the generated inputs. Housing Benefit: a full-time student, + a State Pension Credit recipient, or under 25 and on Income Support, + income-based JSA or Universal Credit without earned income. CTR (any age): + a full-time student, on IS, JSA(IB), ESA(IR) or SPC, or, in a national + scheme, on Universal Credit without earned income, or an adult for whom + someone else is entitled to child benefit, which needs a claim (LGFA 1992 + Sch 1 para 3). In Scotland's working-age scheme from April 2022, also + anyone whose partner is on IS, JSA(IB) or ESA(IR) and who is not on + Universal Credit (SSI 2021/249 reg 90(8)(a), reg 4(1)). IS, JSA(IB), + ESA(IR) and SPC count only for the person they are payable to, read from + the model's is_on_* status (HB Regs 2006 reg 2(1), (3), (3A)); where the + claimant or partner reports the award, or no one does, that is the + reporter or the benefit unit's head, which the test checks. Universal + Credit counts for the claimants it assesses: the members the generated + is_uc_claimant flags name (by default the claimant and partner), the two + eldest where the inputs flag more, which the test checks. Earned income is + Universal Credit's own calculation (invariant 13). +10. (In test_rent_of_sharers_boarders_and_lodgers_properties.py.) +11. Banding: for a non-dependant in remunerative work who is neither exempt nor + on IS, JSA(IB) or ESA(IR), the council deducts the amount of the band whose lower + edge is the highest not above their normal gross weekly income, computed here + from the generated inputs: earnings, statutory sick, maternity and paternity + pay, self-employed profit with a loss as nil, and other investment income; + for the claimant or partner of the non-dependant's family, the couple's + sum plus the family's IS, JSA(IB), ESA(IR) and Pension Credit, and its + Universal Credit where the award assesses either of them. The council + bands another member who claims Universal Credit alone on their own + income and that award. The test skips incomes within a penny a week of an + edge, since the inputs hold float32 values. +12. The Universal Credit cap counts the Housing Benefit cap's welfare + benefits other than Housing Benefit (UC Regs 2013 reg 80(2A)), and no + baseline formula on universal_credit's route calculates Housing Benefit: + its traced calculation reads no Housing Benefit variable but the reported + award. Inputs that supply benefit_cap_welfare_benefits, a total that + includes Housing Benefit, also supply housing_benefit_pre_benefit_cap or + uc_benefit_cap_welfare_benefits, or the basket raises ValueError. A + non-dependant family's gross income counts its award after the cap and + before debt deductions: universal_credit plus uc_deductions, which equals + the award before the cap less the cap reduction applied, under current law + and under an 85% protected minimum floor; the floor never lowers an award. + Under a reform that scales universal_credit, each household's deductions + equal those of the same household with the reformed award supplied, and + the Housing Benefit deduction is the band of wages plus that award, so it + moves when the award crosses a band. + (test_non_dependant_cycle_contracts.py checks the supplied-total contract + over mixed populations, and that a reform making Universal Credit read + calculated Housing Benefit raises CycleError.) +13. Differential: the Universal Credit exemption's earned-income test agrees + with Universal Credit's own calculation (uc_individual_earned_income) + across employment expenses, trading losses, company income attributed to + its owner, pension contributions and the minimum income floor of single + claimants and couples; and it agrees with the inputs where they settle it + without the tax and National Insurance calculation. +""" + +from functools import lru_cache +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.model_api import YEAR, BenUnit, Variable + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Local authorities without a modelled local scheme. +COUNTRIES = { + "ENGLAND": "MAIDSTONE", + "WALES": "CARDIFF", + "SCOTLAND": "GLASGOW_CITY", +} +CTR_SCALES = { + "ENGLAND": "england.council_tax_reduction.pensioners.non_dep_deduction", + "WALES": "wales.council_tax_reduction.non_dep_deduction", + "SCOTLAND": "scotland.council_tax_reduction.non_dep_deduction", +} +FIXTURE = ( + Path(__file__).parent + / "fixtures" + / "council_tax_reduction_non_dependant_deductions.yaml" +) +WEEKS = 52 +CLAIMANT_DISABILITY = [ + None, + None, + None, + "is_blind", + "attendance_allowance", + "dla_sc", + "pip_dl", + "armed_forces_independence_payment", +] +BENEFITS = [ + "income_support", + "jsa_income", + "esa_income", + "pension_credit", + "universal_credit_pre_benefit_cap", +] +REPORTED = { + "income_support": "income_support_reported", + "jsa_income": "jsa_income_reported", + "esa_income": "esa_income_reported", + "pension_credit": "pension_credit_reported", +} +IS_ON = { + "income_support": "is_on_income_support", + "jsa_income": "is_on_income_based_jsa", + "esa_income": "is_on_income_related_esa", + "pension_credit": "is_on_pension_credit", +} +STATUTORY_PAY = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", +] +# Family benefits counted in gross income, held at zero so the oracle knows +# each family's gross income. +OTHER_FAMILY_BENEFITS = ["child_tax_credit", "working_tax_credit", "child_benefit"] +SYSTEM = CountryTaxBenefitSystem() + + +def for_years(situation, years): + """Key every input by year: unkeyed inputs apply only to the default + period.""" + for group in situation.values(): + for entity in group.values(): + for key, value in entity.items(): + if key != "members" and not isinstance(value, dict): + entity[key] = {str(year): value for year in years} + return situation + + +@st.composite +def households(draw): + # The CTR applicant is the family with the household's oldest member, so + # claimants are older than their non-dependants. State Pension age is at + # most 67 in 2025-30. + claimant_age = draw(st.one_of(st.integers(50, 64), st.integers(68, 95))) + non_dependants = [ + dict( + age=draw(st.integers(18, claimant_age - 1)), + education=draw( + st.sampled_from( + ["NOT_IN_EDUCATION"] * 4 + ["TERTIARY", "UPPER_SECONDARY"] + ) + ), + earnings=draw(st.one_of(st.just(0.0), st.floats(0, 60_000))), + other_income=draw(st.one_of(st.just(0.0), st.floats(0, 30_000))), + self_employment=draw(st.one_of(st.just(0.0), st.floats(-20_000, 20_000))), + statutory_pay={ + name: draw(st.one_of(st.just(0.0), st.floats(0, 10_000))) + for name in STATUTORY_PAY + }, + pension_contributions=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), + startup_period=draw(st.booleans()), + hours=draw(st.sampled_from([0.0, 10.0, 15.9, 16.0, 35.0, 45.0])), + income_rise=draw(st.one_of(st.just(0.0), st.floats(0, 40_000))), + approved_training=False, + ) + for _ in range(draw(st.integers(1, 2))) + ] + # A young person aged 16-19 in the non-dependant family: in full-time + # education (a student) or on approved training (not a student). Whether + # they are the family's claimant or partner is read from the model's + # is_claimant_or_partner. + if draw(st.booleans()): + training = draw(st.booleans()) + non_dependants.append( + dict( + age=draw(st.integers(16, 19)), + education="NOT_IN_EDUCATION" if training else "UPPER_SECONDARY", + earnings=draw(st.one_of(st.just(0.0), st.floats(0, 20_000))), + other_income=0.0, + self_employment=0.0, + statutory_pay=dict.fromkeys(STATUTORY_PAY, 0.0), + pension_contributions=0.0, + startup_period=False, + hours=draw(st.sampled_from([0.0, 8.0, 16.0, 35.0])), + income_rise=draw(st.one_of(st.just(0.0), st.floats(0, 10_000))), + approved_training=training, + ) + ) + return dict( + country=draw(st.sampled_from(sorted(COUNTRIES))), + claimant_age=claimant_age, + claimant_disability=draw(st.sampled_from(CLAIMANT_DISABILITY)), + benefits={ + benefit: draw(st.sampled_from([0.0, 0.0, 0.0, 1_000.0])) + for benefit in BENEFITS + }, + # Which adult non-dependant reports IS, JSA(IB), ESA(IR) and SPC, if + # anyone does. + reporter=draw(st.sampled_from([None, 0, 1])), + # is_uc_claimant for the non-dependants, by position, where the test + # enters it (explicit_uc_claimants). A flag on the young person alone + # makes them a single claimant beside the couple. + uc_claimants=draw(st.lists(st.booleans(), min_size=3, max_size=3)), + claims_child_benefit=draw(st.sampled_from([True, True, False])), + non_dependants=non_dependants, + ) + + +def build(population, year, raise_income=False, explicit_uc_claimants=False): + """The generated population as a simulation. With explicit_uc_claimants, + the inputs name each family's UC claimants: is_uc_claimant is True for + each claimant and the generated flag for each non-dependant. (An input for some people stores + the default, False, for the rest.) Otherwise the model's default applies: + the claimant and partner of each family.""" + people, benunits, households_, persons = {}, {}, {}, [] + for h, household in enumerate(population): + claimant = f"claimant_{h}" + flag = household["claimant_disability"] + people[claimant] = dict( + age=household["claimant_age"], + current_education="NOT_IN_EDUCATION", + employment_income=0.0, + self_employment_income=0.0, + is_in_approved_training=False, + age_started_or_accepted_current_education_or_training=1_000, + weekly_hours=0.0, + is_blind=flag == "is_blind", + **{ + name: 1_000.0 if flag == name else 0.0 + for name in CLAIMANT_DISABILITY[3:] + if name != "is_blind" + }, + ) + if explicit_uc_claimants: + people[claimant]["is_uc_claimant"] = True + persons.append(dict(household=h, benunit=2 * h, non_dependant=None)) + members = [] + for n, nd in enumerate(household["non_dependants"]): + name = f"non_dependant_{h}_{n}" + other = nd["other_income"] + (nd["income_rise"] if raise_income else 0) + reports = household.get("reporter") == n + people[name] = dict( + age=nd["age"], + current_education=nd["education"], + employment_income=nd["earnings"], + other_investment_income=other, + self_employment_income=nd["self_employment"], + **nd.get("statutory_pay", dict.fromkeys(STATUTORY_PAY, 0.0)), + pension_contributions=nd.get("pension_contributions", 0.0), + uc_is_in_startup_period=nd.get("startup_period", False), + **{ + reported: household["benefits"][benefit] if reports else 0.0 + for benefit, reported in REPORTED.items() + }, + # Held at zero so the oracle knows each person's gross income. + state_pension=0.0, + carers_allowance=0.0, + is_in_approved_training=nd["approved_training"], + # Young people started at 16; adults' start ages don't matter. + age_started_or_accepted_current_education_or_training=16, + weekly_hours=nd["hours"], + is_blind=False, + **{ + benefit: 0.0 + for benefit in CLAIMANT_DISABILITY[3:] + if benefit != "is_blind" + }, + ) + if explicit_uc_claimants: + people[name]["is_uc_claimant"] = household["uc_claimants"][n] + members.append(name) + persons.append( + dict(household=h, benunit=2 * h + 1, non_dependant=nd, position=n) + ) + benunits[f"claimant_unit_{h}"] = dict( + members=[claimant], + benunit_is_rent_liable=True, + **{benefit: 0.0 for benefit in BENEFITS + OTHER_FAMILY_BENEFITS}, + ) + benunits[f"non_dependant_unit_{h}"] = dict( + members=members, + benunit_is_rent_liable=False, + would_claim_child_benefit=household.get("claims_child_benefit", True), + **household["benefits"], + **{benefit: 0.0 for benefit in OTHER_FAMILY_BENEFITS}, + ) + households_[f"household_{h}"] = dict( + members=[claimant, *members], + country=household["country"], + local_authority=COUNTRIES[household["country"]], + ) + situation = dict(people=people, benunits=benunits, households=households_) + return Simulation(situation=for_years(situation, [year])), persons + + +def calculate(sim, year): + names = dict( + person=[ + "housing_benefit_non_dep_deduction_exempt", + "housing_benefit_individual_non_dep_deduction_eligible", + "household_benefits_individual_non_dep_deduction", + "council_tax_reduction_non_dep_deduction_exempt", + "council_tax_reduction_individual_non_dep_deduction_eligible", + "council_tax_reduction_individual_non_dep_deduction", + "is_benunit_head", + "is_claimant_or_partner", + "is_uc_assessed_claimant", + "is_qualifying_young_person_for_child_benefit", + "uc_individual_earned_income", + "is_on_income_support", + "is_on_income_based_jsa", + "is_on_income_related_esa", + "is_on_pension_credit", + ], + benunit=[ + "housing_benefit_non_dep_deductions", + "housing_benefit_non_dep_deductions_claimant_exempt", + "council_tax_reduction_non_dep_deductions", + "universal_credit_pre_benefit_cap", + "council_tax_reduction_applicant_has_non_dep_exemption", + "council_tax_reduction_pensioner", + "council_tax_reduction_joint_liability_share", + "share_of_household_rent", + ], + ) + return { + name: np.asarray(sim.calculate(name, year)) + for entity in names.values() + for name in entity + } + + +def aggregate(individual, persons, couple, benunit_count, each_member): + """Recompute family deductions from individual amounts (invariant 5): the + claimant and partner's higher amount (both where each_member), plus each + other member's.""" + couple_amounts, others = {}, {} + for i, person in enumerate(persons): + target = couple_amounts if couple[i] else others + target.setdefault(person["benunit"], []).append(individual[i]) + own = np.array( + [ + (sum if each_member[b] else max)(couple_amounts.get(b, [0.0])) + + sum(others.get(b, [])) + for b in range(benunit_count) + ] + ) + household_of = {p["benunit"]: p["household"] for p in persons} + totals = {} + for b in range(benunit_count): + totals[household_of[b]] = totals.get(household_of[b], 0.0) + own[b] + return np.array([totals[household_of[b]] - own[b] for b in range(benunit_count)]) + + +@PROPERTY_SETTINGS +@given( + population=st.lists(households(), min_size=1, max_size=12), + year=st.integers(2025, 2030), + explicit_uc_claimants=st.booleans(), +) +@example( + # Rare under random draws: a Welsh working-age applicant's non-dependant + # couple on Universal Credit, both with earnings (Sch 6 para 5(3)); and + # the Housing Benefit under-25 boundary on Income Support (reg 74(8)). + population=[ + dict( + country="WALES", + claimant_age=60, + claimant_disability=None, + benefits={ + **dict.fromkeys(BENEFITS, 0.0), + "universal_credit_pre_benefit_cap": 1_000.0, + }, + non_dependants=[ + dict( + age=30, + education="NOT_IN_EDUCATION", + earnings=15_600.0, + other_income=0.0, + hours=35.0, + income_rise=0.0, + self_employment=0.0, + approved_training=False, + ), + dict( + age=28, + education="NOT_IN_EDUCATION", + earnings=1_000.0, + other_income=0.0, + hours=0.0, + income_rise=5_000.0, + self_employment=0.0, + approved_training=False, + ), + ], + ), + *[ + dict( + country="ENGLAND", + claimant_age=70, + claimant_disability=None, + benefits={**dict.fromkeys(BENEFITS, 0.0), "income_support": 1_000.0}, + non_dependants=[ + dict( + age=age, + education="NOT_IN_EDUCATION", + earnings=0.0, + other_income=0.0, + hours=0.0, + income_rise=0.0, + self_employment=0.0, + approved_training=False, + ) + ], + ) + for age in [24, 25] + ], + ], + year=2026, + explicit_uc_claimants=False, +) +def test_non_dependant_deduction_invariants(population, year, explicit_uc_claimants): + sim, persons = build(population, year, explicit_uc_claimants=explicit_uc_claimants) + r = calculate(sim, year) + p = SYSTEM.parameters(str(year)) + hb = p.gov.dwp.housing_benefit.non_dep_deduction + hb_lowest, hb_top = hb.amount.amounts[0] * WEEKS, hb.amount.amounts[-1] * WEEKS + # Each applicant's scheme follows its own family; the applicant is each + # household's claimant family (benefit units 0, 2, 4, ...). + applicant_pensioner = r["council_tax_reduction_pensioner"].astype(bool)[0::2] + pension_age = np.repeat( + applicant_pensioner, + [1 + len(h["non_dependants"]) for h in population], + ) + # Claimant or partner of each person's own family (SSCBA s.137 couple). + couple = r["is_claimant_or_partner"].astype(bool) + members_of = {} + for i, person in enumerate(persons): + members_of.setdefault(person["benunit"], []).append(i) + # Universal Credit's claimants: the members is_uc_claimant flags (by + # default the claimant and partner), the two eldest where inputs flag + # more (WRA 2012 ss. 2(1), 40), in the order entered at equal ages. + assessed = r["is_uc_assessed_claimant"].astype(bool) + for members in members_of.values(): + household = population[persons[members[0]]["household"]] + flags = household.get("uc_claimants") + + def flagged(j): + if not explicit_uc_claimants: + return bool(couple[j]) + if persons[j]["non_dependant"] is None: + return True + return flags[persons[j]["position"]] + + def age(j): + nd = persons[j]["non_dependant"] + return household["claimant_age"] if nd is None else nd["age"] + + eldest = sorted(filter(flagged, members), key=lambda j: -age(j))[:2] + assert sorted(j for j in members if assessed[j]) == sorted(eldest) + # Whether a family's Universal Credit is the couple's own: it assesses + # the claimant or partner. + couple_claims_universal_credit = { + b: any(couple[j] and assessed[j] for j in members) + for b, members in members_of.items() + } + + def payee_of(i, benefit): + """The award's payee: the member who reports it, or the head.""" + person = persons[i] + household = population[person["household"]] + reporter = household.get("reporter") + reported = ( + person["non_dependant"] is not None + and reporter is not None + and reporter < len(household["non_dependants"]) + and household["benefits"][benefit] > 0 + ) + if reported: + return person["position"] == reporter + return bool(r["is_benunit_head"][i]) + + f32 = np.float32 + + def own_gross(j): + """A person's own gross income from the generated inputs (invariant + 11): a self-employment loss counts as nil.""" + nd_j = persons[j]["non_dependant"] + if nd_j is None: + return 0.0 + return float( + f32(nd_j["earnings"]) + + sum( + f32(nd_j.get("statutory_pay", {}).get(name, 0.0)) + for name in STATUTORY_PAY + ) + + max(f32(0), f32(nd_j["self_employment"])) + + f32(nd_j["other_income"]) + ) + + def band_amount(scale, weekly): + """The amount of the band whose lower edge is the highest not above + weekly income, or None within a penny of an edge.""" + if any(abs(weekly - t) < 0.01 for t in scale.thresholds[1:]): + return None + index = max(k for k, t in enumerate(scale.thresholds) if t <= weekly) + return scale.amounts[index] * WEEKS + + for i, person in enumerate(persons): + household = population[person["household"]] + hb_deduction = r["household_benefits_individual_non_dep_deduction"][i] + ctr_deduction = r["council_tax_reduction_individual_non_dep_deduction"][i] + # 1. Exempt => 0. + if r["housing_benefit_non_dep_deduction_exempt"][i]: + assert hb_deduction == 0 + if r["council_tax_reduction_non_dep_deduction_exempt"][i]: + assert ctr_deduction == 0 + nd = person["non_dependant"] + if nd is None: + continue + # 9. The exemptions, read straight from the inputs. + # IS, JSA(IB), ESA(IR) and SPC count for their payee (HB reg 2(1), + # (3)-(3A)): the model's is_on_* status. Where the reporter, if any, is + # the claimant or partner, that is the reporter, or the benefit unit's + # head where none reports. Universal Credit counts for the claimants + # it assesses. + on = {b: bool(r[IS_ON[b]][i]) for b in REPORTED} + reporter = household.get("reporter") + reporter_index = ( + None + if reporter is None or reporter >= len(household["non_dependants"]) + else members_of[person["benunit"]][reporter] + ) + # Pension Credit's payee must also have reached the qualifying age + # (SPCA 2002 s.1(2)(b)), which payee_of doesn't model. + if reporter_index is None or couple[reporter_index]: + for b in ["income_support", "jsa_income", "esa_income"]: + assert on[b] == (payee_of(i, b) and household["benefits"][b] > 0), ( + b, + i, + ) + on["universal_credit_pre_benefit_cap"] = ( + bool(assessed[i]) + and household["benefits"]["universal_credit_pre_benefit_cap"] > 0 + ) + student = nd["education"] != "NOT_IN_EDUCATION" + # Earned income is Universal Credit's own calculation (HB reg 74(11), + # CTR Sch 1 para 8(11A) and equivalents: UC Regs 2013 reg 52), which + # invariant 13 checks against the inputs. + earned = r["uc_individual_earned_income"][i] > 0 + uc_without_earnings = on["universal_credit_pre_benefit_cap"] and not earned + hb_exempt = ( + student + or on["pension_credit"] + or ( + nd["age"] < 25 + and (on["income_support"] or on["jsa_income"] or uc_without_earnings) + ) + ) + assert bool(r["housing_benefit_non_dep_deduction_exempt"][i]) == hb_exempt + national = household["country"] != "ENGLAND" or pension_age[i] + legacy = ["income_support", "jsa_income", "esa_income"] + on_legacy = any(on[b] for b in legacy) + # Scotland's working-age scheme (SSI 2021/249, from April 2022): a + # non-dependant who is, or whose partner is, on IS, JSA(IB) or ESA(IR) + # and who is not on Universal Credit. + if household["country"] == "SCOTLAND" and not pension_age[i]: + couple_on_legacy = bool(couple[i]) and any( + couple[j] and any(r[IS_ON[b]][j] for b in legacy) + for j in members_of[person["benunit"]] + ) + qualifying = (on_legacy or couple_on_legacy) and not on[ + "universal_credit_pre_benefit_cap" + ] + else: + qualifying = on_legacy + ctr_exempt = ( + student + or qualifying + or on["pension_credit"] + or (national and uc_without_earnings) + # LGFA 1992 Sch 1 para 3: an adult in someone else's family for + # whom child benefit is claimed. + or ( + nd["age"] >= 18 + and bool(r["is_qualifying_young_person_for_child_benefit"][i]) + and not couple[i] + and household.get("claims_child_benefit", True) + ) + ) + assert ( + bool(r["council_tax_reduction_non_dep_deduction_exempt"][i]) == ctr_exempt + ) + + # 2-3. Housing Benefit bounds and the not-in-work amount. + if ( + r["housing_benefit_individual_non_dep_deduction_eligible"][i] + and not r["housing_benefit_non_dep_deduction_exempt"][i] + ): + assert hb_lowest - 0.01 <= hb_deduction <= hb_top + 0.01 + # Reg 6(6): on IS, JSA(IB) or ESA(IR) is not remunerative work. + if ( + nd["hours"] < hb.remunerative_work_hours + or on["income_support"] + or on["jsa_income"] + or on["esa_income"] + ): + assert hb_deduction == pytest.approx(hb_lowest, abs=0.01) + # 2-3. Council Tax Reduction bounds and the not-in-work amount. + country = household["country"] + if not national: + assert ctr_deduction == 0 + elif ( + r["council_tax_reduction_individual_non_dep_deduction_eligible"][i] + and not r["council_tax_reduction_non_dep_deduction_exempt"][i] + ): + scale = p.gov.local_authorities + for part in CTR_SCALES[country].split("."): + scale = getattr(scale, part) + lowest = scale.amount.amounts[0] * WEEKS + top = scale.amount.amounts[-1] * WEEKS + assert lowest - 0.01 <= ctr_deduction <= top + 0.01 + if nd["hours"] < scale.remunerative_work_hours: + assert ctr_deduction == pytest.approx(lowest, abs=0.01) + + # 11. Banding on gross income computed from the inputs. Income-based + # JSA and income-related ESA count only where the claimant or partner + # reports them, or no one does. + couple_awards = ( + ["jsa_income", "esa_income"] + if reporter_index is None or couple[reporter_index] + else [] + ) + family_awards = sum( + household["benefits"][b] + for b in ["income_support", "pension_credit", *couple_awards] + ) + # Universal Credit is the income of the claimants it assesses: the + # couple's, or a single claimant's beside it. + universal_credit = household["benefits"]["universal_credit_pre_benefit_cap"] + couple_claims = couple_claims_universal_credit[person["benunit"]] + if couple[i]: + weekly = ( + sum(own_gross(j) for j in members_of[person["benunit"]] if couple[j]) + + family_awards + + (universal_credit if couple_claims else 0.0) + ) / WEEKS + else: + weekly = ( + own_gross(i) + + (universal_credit if assessed[i] and not couple_claims else 0.0) + ) / WEEKS + if ( + r["housing_benefit_individual_non_dep_deduction_eligible"][i] + and nd["hours"] >= hb.remunerative_work_hours + and not (on["income_support"] or on["jsa_income"] or on["esa_income"]) + ): + expected = band_amount(hb.amount, weekly) + if expected is not None: + assert hb_deduction == pytest.approx(expected, abs=0.01), (i, weekly) + if ( + national + and r["council_tax_reduction_individual_non_dep_deduction_eligible"][i] + and not r["council_tax_reduction_non_dep_deduction_exempt"][i] + and nd["hours"] >= scale.remunerative_work_hours + and not qualifying + ): + expected = band_amount(scale.amount, weekly) + if expected is not None: + assert ctr_deduction == pytest.approx(expected, abs=0.01), (i, weekly) + + benunit_count = 2 * len(population) + # 4-5. Housing Benefit aggregation and the claimant exemption. + expected_hb = aggregate( + r["household_benefits_individual_non_dep_deduction"], + persons, + couple, + benunit_count, + [False] * benunit_count, + ) + # 4. The claimant exemption, from the generated disability inputs: only + # the claimant family's claimant can be blind or on a qualifying benefit. + disabled = np.array([h["claimant_disability"] is not None for h in population]) + claimant_exempt = np.repeat(disabled, 2) & (np.arange(benunit_count) % 2 == 0) + np.testing.assert_array_equal( + r["housing_benefit_non_dep_deductions_claimant_exempt"].astype(bool), + claimant_exempt, + ) + expected_hb = np.where( + claimant_exempt, 0, expected_hb * r["share_of_household_rent"] + ) + np.testing.assert_allclose( + r["housing_benefit_non_dep_deductions"], expected_hb, atol=0.01 + ) + # 4-5. Council Tax Reduction aggregation and the applicant exemption. + welsh_working_age = [ + population[b // 2]["country"] == "WALES" + and not applicant_pensioner[b // 2] + and couple_claims_universal_credit[b] + and r["universal_credit_pre_benefit_cap"][b] > 0 + for b in range(benunit_count) + ] + expected_ctr = aggregate( + r["council_tax_reduction_individual_non_dep_deduction"], + persons, + couple, + benunit_count, + welsh_working_age, + ) + # The applicant is the claimant family (its member is the oldest). + np.testing.assert_array_equal( + r["council_tax_reduction_applicant_has_non_dep_exemption"].astype(bool), + claimant_exempt, + ) + expected_ctr = np.where( + claimant_exempt, + 0, + expected_ctr * r["council_tax_reduction_joint_liability_share"], + ) + np.testing.assert_allclose( + r["council_tax_reduction_non_dep_deductions"], expected_ctr, atol=0.01 + ) + + # 6. Raising non-dependants' incomes never lowers a deduction. + raised, _ = build( + population, + year, + raise_income=True, + explicit_uc_claimants=explicit_uc_claimants, + ) + s = calculate(raised, year) + for name in [ + "household_benefits_individual_non_dep_deduction", + "council_tax_reduction_individual_non_dep_deduction", + "housing_benefit_non_dep_deductions", + "council_tax_reduction_non_dep_deductions", + ]: + assert np.all(s[name] >= r[name] - 1e-3), name + + +def first_income_at_or_above(threshold): + """The smallest float32 annual income (the input's storage type) whose + weekly value, computed as the model does (the benefit-unit sum returns + float64, then divided by 52), is at least the threshold.""" + + def weekly(annual): + return float(np.float32(annual)) / WEEKS + + income = np.float32(threshold * WEEKS) + while weekly(income) < threshold: + income = np.nextafter(income, np.float32(np.inf)) + while weekly(np.nextafter(income, np.float32(-np.inf))) >= threshold: + income = np.nextafter(income, np.float32(-np.inf)) + return income + + +SCALES = [ + ("HOUSING_BENEFIT", range(2019, 2031)), + ("ENGLAND", range(2015, 2031)), + ("WALES", range(2015, 2031)), + ("SCOTLAND", range(2015, 2031)), +] + + +@pytest.mark.parametrize("scale_name, years", SCALES, ids=[s for s, _ in SCALES]) +def test_band_edges_are_inclusive(scale_name, years): + """Invariant 7: every lower edge, every year, both schemes.""" + country = "ENGLAND" if scale_name == "HOUSING_BENEFIT" else scale_name + edges_by_year = {} + for year in years: + p = SYSTEM.parameters(str(year)) + if scale_name == "HOUSING_BENEFIT": + scale = p.gov.dwp.housing_benefit.non_dep_deduction.amount + else: + scale = p.gov.local_authorities + for part in CTR_SCALES[scale_name].split("."): + scale = getattr(scale, part) + scale = scale.amount + edges_by_year[year] = list( + zip(scale.thresholds[1:], scale.amounts[1:], scale.amounts[:-1]) + ) + count = max(len(edges) for edges in edges_by_year.values()) + people, benunits, households_ = {}, {}, {} + for k in range(2 * count): + claimant, non_dependant = f"claimant_{k}", f"non_dependant_{k}" + incomes = {} + for year, edges in edges_by_year.items(): + threshold, _, _ = edges[k // 2] + at_edge = first_income_at_or_above(threshold) + incomes[str(year)] = float( + at_edge if k % 2 == 0 else np.nextafter(at_edge, np.float32(-np.inf)) + ) + people[claimant] = dict(age=80, current_education="NOT_IN_EDUCATION") + people[non_dependant] = dict( + age=40, + current_education="NOT_IN_EDUCATION", + weekly_hours={str(y): 35.0 for y in years}, + housing_benefit_non_dep_gross_income=incomes, + council_tax_reduction_non_dep_gross_income=incomes, + ) + benunits[f"claimant_unit_{k}"] = dict( + members=[claimant], + benunit_is_rent_liable={str(y): True for y in years}, + **{b: {str(y): 0.0 for y in years} for b in BENEFITS}, + ) + benunits[f"non_dependant_unit_{k}"] = dict( + members=[non_dependant], + benunit_is_rent_liable={str(y): False for y in years}, + **{b: {str(y): 0.0 for y in years} for b in BENEFITS}, + ) + households_[f"household_{k}"] = dict( + members=[claimant, non_dependant], + country=country, + local_authority=COUNTRIES[country], + ) + sim = Simulation( + situation=for_years( + dict(people=people, benunits=benunits, households=households_), years + ) + ) + variable = ( + "household_benefits_individual_non_dep_deduction" + if scale_name == "HOUSING_BENEFIT" + else "council_tax_reduction_individual_non_dep_deduction" + ) + for year, edges in edges_by_year.items(): + deductions = np.asarray(sim.calculate(variable, year))[1::2] / WEEKS + for k, (threshold, amount, below) in enumerate(edges): + assert deductions[2 * k] == pytest.approx(amount, abs=1e-4), ( + year, + threshold, + ) + assert deductions[2 * k + 1] == pytest.approx(below, abs=1e-4), ( + year, + threshold, + ) + + +def test_ctr_scales_match_enacted_sums(): + """Invariant 8: the parameters reproduce the enacted table.""" + fixture = yaml.safe_load(FIXTURE.read_text()) + for scheme, entry in fixture.items(): + for year, row in entry["years"].items(): + # PolicyEngine UK reads parameters at 30 April from 2015; earlier + # years are read at that date directly. + instant = str(year) if year >= 2015 else f"{year}-04-30" + node = SYSTEM.parameters(instant) + for part in entry["parameter"].split("."): + node = getattr(node, part) + assert list(node.thresholds) == pytest.approx(row["thresholds"]), ( + scheme, + year, + ) + assert list(node.amounts) == pytest.approx(row["amounts"]), (scheme, year) + + +def capped_universal_credit_families(families, year, **extra): + """One household per family: the generated awards and debt deduction + rate, no Housing Benefit and no Maternity Allowance.""" + people, benunits, households_ = {}, {}, {} + for f, family in enumerate(families): + members = [f"adult_{f}_{k}" for k in range(2 if family["couple"] else 1)] + for k, name in enumerate(members): + people[name] = dict( + age=30 - k, + jsa_contrib=family["jsa_contrib"] if k == 0 else 0.0, + esa_contrib=family["esa_contrib"] if k == 0 else 0.0, + maternity_allowance=0.0, + ) + rate = family.get("uc_deduction_rate", 0.0) + benunits[f"family_{f}"] = dict( + members=members, + **{ + key: family[key] + for key in [ + "universal_credit_pre_benefit_cap", + "child_benefit", + "child_tax_credit", + "uc_benefit_cap", + "uc_childcare_element", + ] + }, + income_support=0.0, + jsa_income=0.0, + esa_income=0.0, + housing_benefit_pre_benefit_cap=0.0, + uc_deduction_rate=rate, + **(dict() if rate else dict(uc_deductions=0.0)), + **extra, + ) + households_[f"household_{f}"] = dict(members=members, country="ENGLAND") + return for_years( + dict(people=people, benunits=benunits, households=households_), [year] + ) + + +def capped_universal_credit(sim, year): + from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + universal_credit_award, + ) + + return universal_credit_award(sim.populations["benunit"], year) + + +def core_simulation(situation, year, system=SYSTEM, trace=False): + """A simulation on a tax-benefit system built once for the module.""" + from policyengine_core.simulations import Simulation as CoreSimulation + + return CoreSimulation( + tax_benefit_system=system, + situation=situation, + default_input_period=year, + default_calculation_period=year, + trace=trace, + ) + + +CAPPED_FAMILY = st.fixed_dictionaries( + dict( + couple=st.booleans(), + universal_credit_pre_benefit_cap=st.one_of(st.just(0.0), st.floats(0, 40_000)), + child_benefit=st.sampled_from([0.0, 1_500.0, 5_000.0]), + child_tax_credit=st.sampled_from([0.0, 3_000.0]), + uc_benefit_cap=st.sampled_from([14_753.0, 22_020.0, 25_323.0]), + uc_childcare_element=st.sampled_from([0.0, 2_000.0, 12_000.0]), + jsa_contrib=st.sampled_from([0.0, 4_000.0]), + esa_contrib=st.sampled_from([0.0, 5_000.0]), + uc_deduction_rate=st.sampled_from([0.0, 0.05, 0.15]), + ) +) +ONE_CAPPED_FAMILY = dict( + couple=False, + universal_credit_pre_benefit_cap=20_000.0, + child_benefit=0.0, + child_tax_credit=0.0, + uc_benefit_cap=14_753.0, + uc_childcare_element=0.0, + jsa_contrib=0.0, + esa_contrib=0.0, +) + + +PROTECTED_FLOOR = "gov.dwp.universal_credit.deductions.protected_floor" + + +@PROPERTY_SETTINGS +@given( + families=st.lists(CAPPED_FAMILY, min_size=1, max_size=8), + year=st.integers(2025, 2030), +) +@example( + # Under the 85% floor the 15% debt deduction comes first and the cap + # reduction goes, so the award before deductions is the full 20,000. + families=[ONE_CAPPED_FAMILY, dict(ONE_CAPPED_FAMILY, uc_deduction_rate=0.15)], + year=2025, +) +def test_capped_universal_credit_matches_the_model(families, year): + """Invariant 12: the Universal Credit cap counts the welfare benefits + other than Housing Benefit, and a non-dependant family's gross income + counts its award after the cap and before debt deductions, under current + law and under an 85% protected minimum floor, which never lowers an + award.""" + + def check(): + sim = core_simulation(capped_universal_credit_families(families, year), year) + # UC Regs 2013 reg 80(2A), with no Housing Benefit to take out. + np.testing.assert_array_equal( + sim.calculate("uc_benefit_cap_welfare_benefits", year), + sim.calculate("benefit_cap_welfare_benefits", year) + - sim.calculate("housing_benefit_pre_benefit_cap", year), + ) + np.testing.assert_allclose( + capped_universal_credit(sim, year), + np.maximum( + sim.calculate("universal_credit_pre_benefit_cap", year) + - sim.calculate("uc_benefit_cap_reduction_after_floor", year), + 0, + ), + atol=0.01, + ) + return np.asarray(sim.calculate("universal_credit", year)) + + current_law = check() + floor = SYSTEM.parameters.get_child(PROTECTED_FLOOR) + floor.update(period=str(year), value=0.85) + SYSTEM.reset_parameter_caches() + try: + floored = check() + finally: + floor.update(period=str(year), value=0.0) + SYSTEM.reset_parameter_caches() + assert np.all(floored >= current_law - 0.01) + + +class uc_award_scale(Variable): + value_type = float + entity = BenUnit + label = "Scale on the Universal Credit award, for a test reform" + definition_period = YEAR + default_value = 1.0 + + +@lru_cache(maxsize=None) +def scaled_award_system(): + """Current law, with universal_credit replaced by uc_award_scale times + current law's formula. The replacement still reads the cap reduction.""" + from policyengine_core.reforms import Reform + + from policyengine_uk.variables.gov.dwp.universal_credit.universal_credit import ( + universal_credit as current_law, + ) + + def formula(benunit, period, parameters): + return benunit("uc_award_scale", period) * current_law.formula( + benunit, period, parameters + ) + + class ScaledAward(Reform): + def apply(self): + self.add_variable(uc_award_scale) + self.update_variable( + type("universal_credit", (current_law,), dict(formula=formula)) + ) + + return ScaledAward(SYSTEM) + + +@pytest.fixture(scope="module", autouse=True) +def release_scaled_award_system(): + # The system also keeps its last simulation alive. Clear the cache after + # this module's last test, so the system doesn't stay in memory for the + # rest of the test process. + yield + scaled_award_system.cache_clear() + + +AWARD_YEAR = 2025 +AWARD_HOUSEHOLD = st.fixed_dictionaries( + dict( + employment_income=st.floats(0, 20_000), + universal_credit_pre_benefit_cap=st.floats(0, 25_000), + scale=st.floats(0, 1), + ) +) +# 4,000 of wages plus the 14,753 capped award come to 360.63 a week, in the +# 348 band; a tenth less award gives 332.26 a week, in the 266 band. +BAND_CROSSING = dict( + employment_income=4_000.0, universal_credit_pre_benefit_cap=15_000.0, scale=0.9 +) + + +def award_households(households, awards=None, scaled=True): + """Each generated household twice, with the generated scale and then + with none: a 70-year-old claimant paying 6,000 rent, and a 40-year-old + non-dependant working 20 hours who claims Universal Credit under a 14,753 + cap. awards supplies each family's universal_credit; scaled=False leaves + out the scale, for current law.""" + people, benunits, households_ = {}, {}, {} + no_awards = {benefit: 0.0 for benefit in BENEFITS + OTHER_FAMILY_BENEFITS} + for copy, with_scale in enumerate([True, False]): + for h, household in enumerate(households): + claimant, non_dependant = f"claimant_{copy}_{h}", f"non_dep_{copy}_{h}" + people[claimant] = dict(age=70, current_education="NOT_IN_EDUCATION") + people[non_dependant] = dict( + age=40, + current_education="NOT_IN_EDUCATION", + employment_income=household["employment_income"], + weekly_hours=20.0, + ) + benunits[f"claimant_unit_{copy}_{h}"] = dict( + members=[claimant], benunit_is_rent_liable=True, **no_awards + ) + benunits[f"non_dep_unit_{copy}_{h}"] = dict( + members=[non_dependant], + benunit_is_rent_liable=False, + would_claim_uc=True, + uc_benefit_cap=14_753.0, + uc_childcare_element=0.0, + **{ + **no_awards, + "universal_credit_pre_benefit_cap": household[ + "universal_credit_pre_benefit_cap" + ], + }, + ) + if awards is None and scaled: + benunits[f"non_dep_unit_{copy}_{h}"]["uc_award_scale"] = ( + household["scale"] if with_scale else 1.0 + ) + households_[f"household_{copy}_{h}"] = dict( + members=[claimant, non_dependant], + country="ENGLAND", + local_authority="MAIDSTONE", + rent=6_000.0, + tenure_type="RENT_PRIVATELY", + ) + if awards is not None: + for name, award in zip(benunits, awards): + benunits[name]["universal_credit"] = float(award) + return for_years( + dict(people=people, benunits=benunits, households=households_), [AWARD_YEAR] + ) + + +def test_universal_credit_reads_no_calculated_housing_benefit(): + """Invariant 12: Universal Credit's traced calculation reads no Housing + Benefit variable but the reported award, so the deductions can read + universal_credit. Inputs that supply benefit_cap_welfare_benefits, a + total that includes Housing Benefit, also supply + housing_benefit_pre_benefit_cap or uc_benefit_cap_welfare_benefits, or + the basket raises ValueError. Where they supply + housing_benefit_pre_benefit_cap, Universal Credit reads that input + without calculating Housing Benefit.""" + year = 2025 + situation = award_households([BAND_CROSSING], scaled=False) + sim = core_simulation(situation, year, trace=True) + sim.calculate("universal_credit", year) + read = {key.split("<")[0] for key in sim.tracer.get_serialized_flat_trace()} + assert "universal_credit" in read + assert not [ + name + for name in read + if name.startswith("housing_benefit") and name != "housing_benefit_reported" + ], read + for unit in situation["benunits"].values(): + unit["benefit_cap_welfare_benefits"] = { + str(year): unit["universal_credit_pre_benefit_cap"][str(year)] + } + with pytest.raises(ValueError, match="housing_benefit_pre_benefit_cap"): + core_simulation(situation, year).calculate("housing_benefit", year) + for unit in situation["benunits"].values(): + unit["housing_benefit_pre_benefit_cap"] = {str(year): 0.0} + sim = core_simulation(situation, year, trace=True) + sim.calculate("universal_credit", year) + read = {key.split("<")[0] for key in sim.tracer.get_serialized_flat_trace()} + assert not [ + name + for name in read + if name.startswith("housing_benefit") + and name not in ("housing_benefit_reported", "housing_benefit_pre_benefit_cap") + ], read + + +@PROPERTY_SETTINGS +@given(households=st.lists(AWARD_HOUSEHOLD, min_size=1, max_size=3)) +@example(households=[BAND_CROSSING]) +def test_deduction_follows_a_reformed_award(households): + """Invariant 12: under a reform that scales the Universal Credit award, + each household's Housing Benefit and CTR deductions equal those of the + same household with the reformed award supplied, and the Housing Benefit + deduction is the band of the non-dependant's wages plus that award, so + an award that crosses a band moves the deduction.""" + year = AWARD_YEAR + reformed = core_simulation( + award_households(households), year, system=scaled_award_system() + ) + awards = np.asarray(reformed.calculate("universal_credit", year)) + supplied = core_simulation(award_households(households, awards), year) + for name in [ + "housing_benefit_non_dep_deductions", + "council_tax_reduction_non_dep_deductions", + "housing_benefit", + ]: + np.testing.assert_allclose( + reformed.calculate(name, year), + supplied.calculate(name, year), + atol=0.01, + err_msg=name, + ) + # Reg 74(1)(a), (2): the non-dependant works 20 hours and has no other + # income, so the deduction is the band of wages plus the award. + scale = SYSTEM.parameters(str(year)).gov.dwp.housing_benefit.non_dep_deduction + deductions = np.asarray( + reformed.calculate("housing_benefit_non_dep_deductions", year) + ) + wages = [h["employment_income"] for h in households] * 2 + for i, (wage, award) in enumerate(zip(wages, awards[1::2])): + weekly = (wage + award) / WEEKS + if min(abs(weekly - edge) for edge in scale.amount.thresholds) < 0.01: + continue + expected = scale.amount.calc(np.array([weekly]))[0] * WEEKS + assert deductions[2 * i] == pytest.approx(expected, abs=0.01), (i, weekly) + if households == [BAND_CROSSING]: + assert deductions[0] < deductions[2], deductions + + +# Earned-income cases for invariant 13. Each sets the person's earnings +# inputs; "settled" is what the inputs alone say, where they say it without +# the tax and National Insurance calculation: no earnings left after +# expenses, pension contributions and trading losses (False), or a small +# positive amount no deduction can remove (True). +EARNINGS = { + "none": (dict(), False), + "wages": (dict(employment_income=1_000.0), True), + "wages_cancelled_by_expenses": ( + dict(employment_income=1_000.0, employment_expenses=1_000.0), + False, + ), + "wages_partly_cancelled_by_expenses": ( + dict(employment_income=1_000.0, employment_expenses=500.0), + True, + ), + "wages_into_pension": ( + dict(employment_income=1_000.0, employee_pension_contributions=1_000.0), + None, + ), + "profit": (dict(self_employment_income=1_000.0), None), + "profit_cancelled_by_loss": ( + dict(self_employment_income=1_000.0, trading_loss=1_000.0), + None, + ), + "loss": (dict(self_employment_income=-500.0), None), + "company_income": ( + dict( + stands_as_sole_owner_or_partner_of_company=True, + owned_company_carries_on_trade=True, + owned_company_income_share=1_000.0, + ), + True, + ), + "company_income_without_owner_treatment": ( + dict(owned_company_carries_on_trade=True, owned_company_income_share=1_000.0), + False, + ), + "high_wages": (dict(employment_income=30_000.0), True), +} + + +@PROPERTY_SETTINGS +@given( + families=st.lists( + st.lists( + st.fixed_dictionaries( + dict( + age=st.integers(18, 89), + earnings=st.sampled_from(sorted(EARNINGS)), + # None leaves the minimum income floor to the model. + floor_applies=st.sampled_from([None, None, True, False]), + ) + ), + min_size=1, + max_size=2, + ), + min_size=1, + max_size=6, + ), + year=st.integers(2025, 2030), +) +@example( + # A couple's floor: the partner's earnings meet the couple threshold, so + # the floor doesn't apply to the other member (UC Regs 2013 reg 62(3)). + families=[ + [ + dict(age=24, earnings="none", floor_applies=True), + dict(age=30, earnings="high_wages", floor_applies=False), + ], + [dict(age=24, earnings="none", floor_applies=True)], + ], + year=2026, +) +def test_earned_income_exemption_reads_universal_credit(families, year): + """Invariant 13: compare the Universal Credit exemption's earned-income test + with Universal Credit's own calculation (HB Regs 2006 reg 74(8)(b), (11); WSI + 2013/3029 Sch 1 para 3(8)(c), (10)).""" + people, benunits, households_, cases = {}, {}, {}, [] + for f, family in enumerate(families): + claimant = f"claimant_{f}" + people[claimant] = dict(age=90, current_education="NOT_IN_EDUCATION") + cases.append(None) + members = [] + for k, member in enumerate(family): + name = f"member_{f}_{k}" + inputs, settled = EARNINGS[member["earnings"]] + people[name] = dict( + age=member["age"], + current_education="NOT_IN_EDUCATION", + weekly_hours=0.0, + **inputs, + ) + if member["floor_applies"] is not None: + people[name]["uc_mif_applies"] = member["floor_applies"] + people[name]["uc_minimum_income_floor"] = 10_000.0 + members.append(name) + cases.append((member, settled)) + no_awards = {benefit: 0.0 for benefit in BENEFITS + OTHER_FAMILY_BENEFITS} + benunits[f"claimant_unit_{f}"] = dict( + members=[claimant], benunit_is_rent_liable=True, **no_awards + ) + benunits[f"family_{f}"] = dict( + members=members, + benunit_is_rent_liable=False, + **{**no_awards, "universal_credit_pre_benefit_cap": 1_000.0}, + ) + households_[f"household_{f}"] = dict( + members=[claimant, *members], + country="WALES", + local_authority=COUNTRIES["WALES"], + ) + sim = Simulation( + situation=for_years( + dict(people=people, benunits=benunits, households=households_), [year] + ) + ) + earned = np.asarray(sim.calculate("uc_individual_earned_income", year)) > 0 + assessed = np.asarray(sim.calculate("is_uc_assessed_claimant", year)) + floor = np.asarray(sim.calculate("uc_mif_applies", year)) + hb_exempt = np.asarray( + sim.calculate("housing_benefit_non_dep_deduction_exempt", year) + ) + ctr_exempt = np.asarray( + sim.calculate("council_tax_reduction_non_dep_deduction_exempt", year) + ) + for i, case in enumerate(cases): + if case is None: + continue + member, settled = case + # For each claimant the award assesses, compare the exemption with + # Universal Credit's earned-income calculation (the model pairs + # claimant and partner, and may leave a second adult outside the + # couple): the Welsh pensioner scheme at any age, Housing Benefit + # under 25. + on_universal_credit = bool(assessed[i]) + uc_without_earnings = on_universal_credit and not earned[i] + assert bool(ctr_exempt[i]) == uc_without_earnings, (i, member) + assert bool(hb_exempt[i]) == (member["age"] < 25 and uc_without_earnings), ( + i, + member, + ) + # Where the inputs settle it, and the floor doesn't apply. + if settled is not None and not floor[i]: + assert bool(earned[i]) == settled, (i, member) diff --git a/policyengine_uk/tests/test_non_dependant_population_consistent_total.py b/policyengine_uk/tests/test_non_dependant_population_consistent_total.py new file mode 100644 index 0000000000..074d84ad24 --- /dev/null +++ b/policyengine_uk/tests/test_non_dependant_population_consistent_total.py @@ -0,0 +1,104 @@ +"""Supplied cap totals that agree with each household's own calculation. + +Each household's combined total and Housing Benefit come from its own +simulation with nothing supplied. Supplying both for a population of those +households gives every household the results it gets alone, whatever the +first request asks for. Supplying the totals without the Housing Benefit +raises ValueError (test_non_dependant_cycle_contracts.py states the +contract). +""" + +import numpy as np +import pytest + +from policyengine_uk import CountryTaxBenefitSystem +from policyengine_uk.tests.test_non_dependant_cycle_contracts import ( + FIRST_REQUESTS, + YEAR, + make, + situation, + supply, +) + +HOUSEHOLDS = ["single", "nondep"] + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +@pytest.fixture(scope="module") +def standalone(system): + """Each household's own totals and results, with nothing supplied.""" + results = {} + for which in HOUSEHOLDS: + sim = make(system, situation(which)) + units = list(situation(which)["benunits"]) + results[which] = { + name: dict(zip(units, sim.calculate(name, YEAR).tolist())) + for name in [ + "benefit_cap_welfare_benefits", + "housing_benefit_pre_benefit_cap", + "universal_credit", + "housing_benefit", + ] + } + return results + + +def consistent(standalone, *variables): + """The mixed population with ``variables`` supplied at each household's + own values.""" + data = situation("mixed") + for variable in variables: + values = {} + for which in HOUSEHOLDS: + values.update(standalone[which][variable]) + supply(data, variable, values) + return data + + +def expected(standalone, name): + return [value for which in HOUSEHOLDS for value in standalone[which][name].values()] + + +def test_each_household_alone(standalone): + # The claimant pays no rent, so the total is the £20,000 of Universal + # Credit; the applicant's total is their own Housing Benefit, before a + # cap that pension age keeps away. + assert expected(standalone, "benefit_cap_welfare_benefits") == pytest.approx( + [20_000.0, 4_978.20, 0.0], abs=0.01 + ) + assert expected(standalone, "housing_benefit_pre_benefit_cap") == pytest.approx( + [0.0, 4_978.20, 0.0], abs=0.01 + ) + assert expected(standalone, "universal_credit") == pytest.approx( + [14_753.0, 0.0, 0.0], abs=0.01 + ) + + +@pytest.mark.parametrize("first", FIRST_REQUESTS) +def test_consistent_totals_keep_households_independent(system, standalone, first): + sim = make( + system, + consistent( + standalone, + "benefit_cap_welfare_benefits", + "housing_benefit_pre_benefit_cap", + ), + ) + sim.calculate(first, YEAR) + for name in ["universal_credit", "housing_benefit"]: + np.testing.assert_allclose( + sim.calculate(name, YEAR), + expected(standalone, name), + atol=0.01, + err_msg=name, + ) + + +def test_consistent_totals_without_housing_benefit_raise(system, standalone): + sim = make(system, consistent(standalone, "benefit_cap_welfare_benefits")) + with pytest.raises(ValueError, match="housing_benefit_pre_benefit_cap"): + sim.calculate("housing_benefit", YEAR) diff --git a/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py b/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py index e829efd5ef..ab7872153d 100644 --- a/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py +++ b/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py @@ -1,7 +1,8 @@ """Spousal maintenance is gross income for non-dependant deductions. -HB Regs 2006 reg 74(2), (9), pension-age HB reg 55(2), (10), and -SI 2012/2885 Sch 1 para 8(2), (9) do not disregard spousal maintenance. +HB Regs 2006 reg 74(2), (9), pension-age HB reg 55(2), (10), SI 2012/2885 +Sch 1 para 8(2), (9) and WSI 2013/3029 Sch 6 para 5(2), (9) do not disregard +spousal maintenance. It remains exempt from income tax under ITTOIA 2005 s 727. """ @@ -13,17 +14,18 @@ YEAR = 2026 -@pytest.fixture(scope="module") -def non_dependants(): +CASES = [ + ("earnings_only", 7_800, 0), + ("maintenance_100", 7_800, 5_200), + ("maintenance_150", 7_800, 7_800), + ("same_gross_earnings", 13_000, 0), +] + + +def build(country, local_authority): # Separate benefit units isolate each non-dependant's own gross income. - cases = [ - ("earnings_only", 7_800, 0), - ("maintenance_100", 7_800, 5_200), - ("maintenance_150", 7_800, 7_800), - ("same_gross_earnings", 13_000, 0), - ] people, benunits, households = {}, {}, {} - for name, earnings, maintenance in cases: + for name, earnings, maintenance in CASES: people[name] = { "age": {YEAR: 30}, "weekly_hours": {YEAR: 16}, @@ -35,18 +37,30 @@ def non_dependants(): benunits[name] = { "members": [name], "universal_credit": {YEAR: 0}, + "universal_credit_pre_benefit_cap": {YEAR: 0}, "pension_credit": {YEAR: 0}, } households[name] = { "members": [name], - "country": {YEAR: "ENGLAND"}, - "local_authority": {YEAR: "MERTON"}, + "country": {YEAR: country}, + "local_authority": {YEAR: local_authority}, } return Simulation( situation={"people": people, "benunits": benunits, "households": households} ) +@pytest.fixture(scope="module") +def non_dependants(): + return build("ENGLAND", "MERTON") + + +@pytest.fixture(scope="module") +def welsh_non_dependants(): + # Wales's national scheme covers working-age applicants too. + return build("WALES", "CARDIFF") + + def values(simulation, variable): return np.asarray(simulation.calculate(variable, YEAR), dtype=float) @@ -62,16 +76,19 @@ def test_housing_benefit_non_dependant_band_counts_spousal_maintenance(non_depen ) -def test_national_ctr_non_dependant_band_counts_spousal_maintenance(non_dependants): - # SI 2012/2885 Sch 1 para 8(2), (9): £150 earnings plus £100 - # maintenance and £250 earnings have the same normal gross weekly income. - # Compare bands without endorsing the pre-existing shared HB rate scale - # in the national CTR formula (#2007). - deductions = values( - non_dependants, "council_tax_reduction_individual_non_dep_deduction" +def test_national_ctr_non_dependant_band_counts_spousal_maintenance( + welsh_non_dependants, +): + # WSI 2013/3029 Sch 6 para 5(2), (9), as up-rated by WSI 2026/13: £150 + # earnings plus £100 maintenance and £250 earnings both fall below £279 + # (£6.70 a week); £150 plus £150 = £300 falls in £279–484.99 (£13.35). + np.testing.assert_allclose( + values( + welsh_non_dependants, "council_tax_reduction_individual_non_dep_deduction" + ), + [348.40, 348.40, 694.20, 348.40], + atol=0.01, ) - assert deductions[1] == pytest.approx(deductions[3], abs=0.01) - assert deductions[1] > deductions[0] def test_local_ctr_non_dependant_band_counts_spousal_maintenance(non_dependants): diff --git a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py index 61c0fe3d0a..346cf198e9 100644 --- a/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py +++ b/policyengine_uk/tests/test_pension_credit_reported_capital_properties.py @@ -11,9 +11,11 @@ 1. Override identity: a pension-age unit with reported capital R >= 0 has assessable capital R, whatever the household holds. 2. Default differential: with nothing reported, assessable capital equals the - household proxy recomputed here from the inputs (household sources times - the unit's share of the household's pension-age adults, plus person-level - sources of the claimant or partner), the formula before this change. + household proxy recomputed here from the inputs (household sources, valued + under SPC Regs 2002 reg. 19 with savings at face value and the others at + 90%, times the unit's share of the household's pension-age adults, plus + person-level sources of the claimant or partner), the formula before this + change. 3. Locality: reporting capital for one unit never changes another unit's assessable capital. The strategy for this property always gives the other unit someone over State Pension age, nothing recorded and household capital, @@ -116,7 +118,11 @@ def proxy(people, units, household, index): "non_residential_property_value", "corporate_wealth", ] - household_capital = sum(household.get(s, 0) for s in sources) + # SPC Regs 2002 reg. 19(a): sources whose sale would incur expenses (land, + # property, shares) are valued at 90%; savings at face value. + household_capital = sum( + household.get(s, 0) * (1 if s == "savings" else 0.9) for s in sources + ) sp_age = {name: p["age"] >= 66 for name, p in people.items()} unit = units[index]["members"] unit_sp = sum(sp_age[n] for n in unit) diff --git a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py index 18e2464fd6..7f31244d41 100644 --- a/policyengine_uk/tests/test_pension_credit_young_person_income_support.py +++ b/policyengine_uk/tests/test_pension_credit_young_person_income_support.py @@ -3,8 +3,10 @@ A 16-year-old outside education, in a 70-year-old's benefit unit, reports their own Income Support in 2025, when it is in payment. They are not the claimant's qualifying young person, so the claimant gets no child addition, -until a reform removes Income Support. UC reg. 5(5) does not list income -support, so they stay a UC qualifying young person throughout. +until a reform removes Income Support. DWP doesn't count them as a UC +qualifying young person while they're entitled to Income Support (SI +2014/1230 reg. 28), so the reform that removes Income Support restores both +statuses. A young carer who claims Income Support in their own benefit unit follows the calculated award. While they receive it they are no qualifying young @@ -188,10 +190,12 @@ def test_own_income_support_ends_the_young_person_until_a_reform_removes_it(): ).tolist() == [False, True] assert kept.calculate("child_minimum_guarantee_addition", YEAR).tolist() == [0] assert removed.calculate("child_minimum_guarantee_addition", YEAR)[0] > 0 - for sim in (kept, removed): - assert sim.calculate( - "is_qualifying_young_person_for_universal_credit", YEAR - ).tolist() == [False, True] + assert kept.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [False, False] + assert removed.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [False, True] @pytest.mark.usefixtures("cloned_uk_tax_benefit_system") diff --git a/policyengine_uk/tests/test_person_isa_capital.py b/policyengine_uk/tests/test_person_isa_capital.py index 9b105fd5b4..5ab33ba7e3 100644 --- a/policyengine_uk/tests/test_person_isa_capital.py +++ b/policyengine_uk/tests/test_person_isa_capital.py @@ -17,7 +17,9 @@ plus the ISAs of members no unit counts (dependants, and for Pension Credit holders outside a pension-age unit), equals savings, cash_isa and share_like_capital together, up to float32 rounding of the remainder's - shares. Where balances exceed a total, the holdings sum to it exactly, so + shares and each programme's penny rounding. Each programme values + share_like_capital, and each stocks and shares ISA it moves out of it, at + its sale-expense share (90%), and savings and cash ISAs at face value. Where balances exceed a total, the holdings sum to it exactly, so that source leaves nothing in the remainder: an adult with no ISA counts exactly the capital they count without the other members' ISAs. 2. Where the balances' stored sum exceeds the total, the holdings sum to @@ -393,10 +395,11 @@ def test_household_only_data_keep_todays_capital(): simulation.calculate(name, year), before.calculate(name, year) ), (name, year) # The first household's pool: savings 3,000, cash_isa 4,000 and - # corporate_wealth 5,000 (which holds the 2,000 ISA), shared by three - # claimants, two of them the couple. + # corporate_wealth 5,000 (which holds the 2,000 ISA) less 10%, 4,500, + # shared by three claimants, two of them the couple. UC rounds each to + # the nearest penny. uc = simulation.calculate("uc_assessable_capital", YEAR) - assert list(uc[:2]) == [8_000, 4_000] + assert list(uc[:2]) == pytest.approx([7_666.67, 3_833.33], abs=0.001) def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): @@ -430,12 +433,15 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): uc = simulation.calculate("uc_assessable_capital", year) hb = simulation.calculate("housing_benefit_assessable_capital", year) for capital in (uc, hb): + # UC and Housing Benefit round each unit's capital to the nearest + # penny, so the household's two units can come to a penny over: + # savings of 1,268.69 in 2032 give each 634.345, which rounds up. by_household = np.bincount(benunit_household, weights=capital) - assert by_household == pytest.approx(holdings, rel=1e-6), year + assert by_household == pytest.approx(holdings, rel=1e-6, abs=0.011), year # The lodger counts their own balance on top of a third of the pool. pool = holdings[0] - counted[2] - assert uc[1] == pytest.approx(pool / 3 + counted[2], rel=1e-6), year - assert uc[0] == pytest.approx(2 * pool / 3, rel=1e-6), year + assert uc[1] == pytest.approx(pool / 3 + counted[2], rel=1e-6, abs=0.006), year + assert uc[0] == pytest.approx(2 * pool / 3, rel=1e-6, abs=0.006), year # #2781 keeps the stored 15,000, so the formula allocates savings of 4,000 # and the unexplained 5,000 as 6,000 and 3,000. The second household's # pool holds only its savings. @@ -486,10 +492,11 @@ def test_both_forms_in_one_dataset_add_up_to_the_stored_total(): ] -def _household_capital(simulation, year): - return sum( - simulation.calculate(name, year) - for name in ("savings", "cash_isa", "share_like_capital") +def _household_capital(simulation, year, share=1): + return ( + simulation.calculate("savings", year) + + simulation.calculate("cash_isa", year) + + share * simulation.calculate("share_like_capital", year) ) @@ -515,28 +522,33 @@ def test_person_balances_without_household_totals_count_with_their_holders(): simulation.calculate("share_like_capital", year), simulation.calculate("corporate_wealth", year), ) - holdings = _household_capital(simulation, year) + holdings = _household_capital( + simulation, year, _share_counted("uc_assessable_capital") + ) uc = simulation.calculate("uc_assessable_capital", year) assert np.bincount(benunit_household, weights=uc) == pytest.approx( holdings, rel=1e-6 ) - # Savings 7,000 and 2,000 of non-ISA shares make the first pool, 3,000 for - # each claimant, and each unit adds its members' 5,000 and 6,000 of ISAs. - # The second pool, savings 12,000 and 3,000 of trusts, gives the parents - # 10,000 and their son 5,000 in UC, and the pension-age parents all - # 15,000 in Pension Credit. - expected_uc = [11_000, 9_000, 14_000, 11_000] - assert list(simulation.calculate("uc_assessable_capital", YEAR)) == expected_uc + # Savings 7,000 and 2,000 of non-ISA shares less 10%, 1,800, make the + # first pool, 2,933.33 for each claimant. The couple adds a 3,000 cash ISA + # and a 2,000 stocks and shares ISA less 10% (1,800), and the other unit a + # 5,000 cash ISA and a 1,000 one less 10% (900). The second pool, savings + # 12,000 and 3,000 of trusts less 10% (2,700), gives the parents 9,800 + # and their son 4,900 in UC, to which they add 4,000 and 5,400 of ISAs; + # the pension-age parents count all 14,700 and their 4,000 in Pension + # Credit. UC rounds to the nearest penny, and the legacy programmes + # round down. + expected_uc = [10_666.67, 8_833.33, 13_800, 10_300] + assert list(simulation.calculate("uc_assessable_capital", YEAR)) == pytest.approx( + expected_uc, abs=0.001 + ) for name in CAPITAL: if name != "pension_credit_assessable_capital": capital = simulation.calculate(name, YEAR) - assert list(capital[:2]) == expected_uc[:2], name - assert list(simulation.calculate("pension_credit_assessable_capital", YEAR)) == [ - 0, - 0, - 19_000, - 0, - ] + assert list(capital[:2]) == pytest.approx(expected_uc[:2], abs=0.011), name + assert list( + simulation.calculate("pension_credit_assessable_capital", YEAR) + ) == pytest.approx([0, 0, 18_700, 0], abs=0.001) # One benefit unit each, with a child: the adults' cash and stocks and shares @@ -714,10 +726,34 @@ def _check_counted_isas(simulation): return counted -def _holdings(simulation): - return sum( - simulation.calculate(name, YEAR) - for name in ("savings", "cash_isa", "share_like_capital") +def _share_counted(variable): + """The share of share_like_capital's market value ``variable`` counts. + + The programme counts share_like_capital, and each stocks and shares ISA + balance it moves out of it, less its sale-expense rate where its + ``sale_expenses.sources`` lists share_like_capital. + """ + capital = _node(CAPITAL[variable]) + date = f"{YEAR}-01-01" + if "share_like_capital" in capital.sale_expenses.sources(date): + return 1 - capital.sale_expenses.rate(date) + return 1 + + +# The share of each ISA person source's value a programme counts, given the +# share it counts of share_like_capital. +def _isa_shares(share): + return { + "cash_isa_countable_capital": 1, + "stocks_and_shares_isa_countable_capital": share, + } + + +def _holdings(simulation, share=1): + return ( + simulation.calculate("savings", YEAR) + + simulation.calculate("cash_isa", YEAR) + + share * simulation.calculate("share_like_capital", YEAR) ) @@ -732,7 +768,6 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): simulation = Simulation(situation=_situation(units, assets)) counted = _check_counted_isas(simulation) dependants = ~simulation.calculate("is_claimant_or_partner", YEAR) - uncounted = sum((amount * dependants).sum() for amount in counted.values()) for name in ( "uc_assessable_capital", "housing_benefit_assessable_capital", @@ -740,15 +775,24 @@ def test_a_pound_counts_once_in_working_age_means_tests(units, holdings): "jsa_income_assessable_capital", "esa_income_assessable_capital", ): + share = _share_counted(name) + weights = _isa_shares(share) + uncounted = sum( + (weights[source] * amount * dependants).sum() + for source, amount in counted.items() + ) capital = simulation.calculate(name, YEAR) - # Float32 shares of the remainder round to within a few pence. + # Float32 shares of the remainder, and each valuation's penny + # rounding, come to within a few pence. assert capital.sum() + uncounted == pytest.approx( - _holdings(simulation)[0], rel=1e-5, abs=0.05 + _holdings(simulation, share)[0], rel=1e-5, abs=0.05 ), name # Each unit counts at least its own claimant's and partner's ISAs. own = sum( - simulation.map_result(amount * ~dependants, "person", "benunit", how="sum") - for amount in counted.values() + simulation.map_result( + weights[source] * amount * ~dependants, "person", "benunit", how="sum" + ) + for source, amount in counted.items() ) assert (capital >= own * (1 - 1e-6) - 0.01).all(), name @@ -857,22 +901,26 @@ def test_a_pound_counts_once_in_pension_credit(households): > 0 ) counts = simulation.calculate("is_claimant_or_partner", YEAR) & in_pension_unit + share = _share_counted("pension_credit_assessable_capital") + weights = _isa_shares(share) uncounted = sum( - _by_household(simulation, "person", amount * ~counts) - for amount in counted.values() + _by_household(simulation, "person", weights[source] * amount * ~counts) + for source, amount in counted.items() ) capital = simulation.calculate("pension_credit_assessable_capital", YEAR) - # Float32 shares of the remainder round to within a few pence. + # Float32 shares of the remainder, and each valuation's penny rounding, + # come to within a few pence. assert _by_household(simulation, "benunit", capital) + uncounted == pytest.approx( - _holdings(simulation), rel=1e-5, abs=0.05 + _holdings(simulation, share), rel=1e-5, abs=0.05 ) if households is STORED_TOTALS: # The last household's ISA holdings sum exactly to its totals, so its # pool keeps savings of 1,000 and the 4,500 of share_like_capital - # beyond its 2,000 of ISAs: 2,750 for each pension-age adult. - own = sum(counted.values()) + # beyond its 2,000 of ISAs, which Pension Credit values less 10% at + # 4,050: 2,525 for each pension-age adult. + own = sum(weights[source] * amount for source, amount in counted.items()) assert list(capital[-3:]) == pytest.approx( - [2_750 + own[-3], 2_750 + own[-2], 0], rel=1e-6 + [2_525 + own[-3], 2_525 + own[-2], 0], rel=1e-6, abs=0.01 ) @@ -1027,26 +1075,28 @@ def test_reforms_to_either_list_keep_each_isa_with_its_household_source(): def capital(name): return list(simulation.calculate(name, YEAR)) - assert capital("uc_assessable_capital") == [1_000, 15_000] - assert capital("housing_benefit_assessable_capital") == [1_000, 15_000] + # The lodger's unit counts the 10,000 cash ISA and the 4,000 stocks and + # shares ISA less 10% (3,600), and half the 2,000 of savings. + assert capital("uc_assessable_capital") == [1_000, 14_600] + assert capital("housing_benefit_assessable_capital") == [1_000, 14_600] sources = list(_node(uc_path + ".sources")(f"{YEAR}-01-01")) # Dropping cash_isa from UC's sources: savings 2,000 shared, and the - # lodger keeps their 4,000 stocks and shares ISA. Dropping + # lodger keeps their stocks and shares ISA less 10%. Dropping # share_like_capital: the 10,000 cash ISA stays theirs. for dropped, expected in ( - ("cash_isa", [1_000, 5_000]), + ("cash_isa", [1_000, 4_600]), ("share_like_capital", [1_000, 11_000]), ): kept = [name for name in sources if name != dropped] with _law(simulation, uc_path + ".sources", kept): assert capital("uc_assessable_capital") == expected, dropped # Emptying UC's person_sources returns both ISAs to the pool: savings - # 2,000 and the ISAs, 16,000 in all, shared by two claimants. Housing - # Benefit keeps its own lists. + # 2,000, the cash ISA and the stocks and shares ISA less 10%, 15,600 in + # all, shared by two claimants. Housing Benefit keeps its own lists. with _law(simulation, uc_path + ".person_sources", []): - assert capital("uc_assessable_capital") == [8_000, 8_000] - assert capital("housing_benefit_assessable_capital") == [1_000, 15_000] - assert capital("uc_assessable_capital") == [1_000, 15_000] + assert capital("uc_assessable_capital") == [7_800, 7_800] + assert capital("housing_benefit_assessable_capital") == [1_000, 14_600] + assert capital("uc_assessable_capital") == [1_000, 14_600] @pytest.mark.parametrize("variable", CAPITAL) diff --git a/policyengine_uk/tests/test_plan_5_repayment_start_properties.py b/policyengine_uk/tests/test_plan_5_repayment_start_properties.py new file mode 100644 index 0000000000..63d803320d --- /dev/null +++ b/policyengine_uk/tests/test_plan_5_repayment_start_properties.py @@ -0,0 +1,171 @@ +"""Plan 5 student loan repayments start on 6 April 2026. + +SI 2009/470 reg. 15(2BA), inserted by SI 2022/1335 reg. 15(9): "Despite +paragraphs (2), (2A) and (2B), a borrower is not required to repay any part of +a plan 5 loan on or before 5th April 2026." Reg. 15(2BB) omits (2BA) on 6 April +2026. The model reads year Y's parameters at 30 April of Y, so the years 2015 +to 2025 carry no Plan 5 liability and 2026 onwards do. + +Each property draws borrowers on every plan, batched into one Simulation per +example, and compares the model with ``reference_repayment``: the formula +without a Plan 5 start date, which charges the plan's rate on repayment income +above the plan's threshold, capped by a positive balance. + +Invariants: + +1. P1: a Plan 5 borrower repays nothing in each year from 2015 to 2025, for + any income and any balance. +2. P2: from 2026, every borrower's repayment equals the reference. +3. P3: a borrower on any plan other than Plan 5 repays the reference amount in + every year from 2015. +4. P4: a repayment never falls below nil and never exceeds a positive balance. +""" + +from unittest.mock import patch + +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.tax_benefit_system import system + +_PROPERTY_SYSTEM = system.clone() +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +PLANS = ("NONE", "PLAN_1", "PLAN_2", "PLAN_4", "PLAN_5", "POSTGRADUATE") +THRESHOLD_KEYS = { + "PLAN_1": "plan_1", + "PLAN_2": "plan_2", + "PLAN_4": "plan_4", + "PLAN_5": "plan_5", + "POSTGRADUATE": "postgraduate", +} +FIRST_YEAR = 2015 +PLAN_5_FIRST_YEAR = 2026 +LAST_YEAR = 2035 + +borrowers = st.lists( + st.tuples( + st.sampled_from(PLANS), + st.integers(0, 300_000), + # Zero means no balance is known, so no cap applies. + st.one_of(st.just(0), st.integers(1, 100_000)), + ), + min_size=1, + max_size=8, +) +plan_5_borrowers = st.lists( + st.tuples( + st.just("PLAN_5"), + st.integers(0, 1_000_000), + st.one_of(st.just(0), st.integers(1, 200_000)), + ), + min_size=1, + max_size=8, +) + + +def simulate(people, year): + """One Simulation with each (plan, employment income, balance) its own + household.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (plan, income, balance) in enumerate(people): + name = f"p{i}" + situation["people"][name] = { + "age": {year: 30}, + "employment_income": {year: income}, + "student_loan_plan": {year: plan}, + "student_loan_balance": {year: balance}, + } + situation["benunits"][f"b{i}"] = {"members": [name]} + situation["households"][f"h{i}"] = {"members": [name]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + return Simulation(situation=situation) + + +def reference_repayment(sim, people, year): + """Repayments from the plan's rate and threshold alone, with no Plan 5 + start date.""" + p = sim.tax_benefit_system.parameters(str(year)).gov.hmrc.student_loans + net_income = sim.calculate("net_income", year) + benefits = np.minimum( + sim.calculate("taxable_employment_income", year), + sim.calculate("benefits_in_kind", year), + ) + income = np.maximum(net_income - benefits, 0) + out = [] + for (plan, _, balance), person_income in zip(people, income): + if plan == "NONE": + out.append(0.0) + continue + threshold = p.thresholds[THRESHOLD_KEYS[plan]] + rate = ( + p.postgraduate_repayment_rate + if plan == "POSTGRADUATE" + else p.repayment_rate + ) + repayment = rate * max(0.0, person_income - threshold) + out.append(min(repayment, balance) if balance > 0 else repayment) + return np.array(out) + + +@pytest.fixture(scope="module", autouse=True) +def _release_simulation(): + yield + _PROPERTY_SYSTEM.simulation = None + + +@PROPERTY_SETTINGS +@given( + people=plan_5_borrowers, + year=st.integers(FIRST_YEAR, PLAN_5_FIRST_YEAR - 1), +) +def test_p1_plan_5_borrowers_repay_nothing_before_2026_27(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + assert np.all(repayment == 0) + + +@PROPERTY_SETTINGS +@given(people=borrowers, year=st.integers(PLAN_5_FIRST_YEAR, LAST_YEAR)) +def test_p2_every_plan_follows_threshold_and_rate_from_2026_27(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + np.testing.assert_allclose( + repayment, reference_repayment(sim, people, year), rtol=1e-6, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given( + people=borrowers.map( + lambda people: [person for person in people if person[0] != "PLAN_5"] + ).filter(bool), + year=st.integers(FIRST_YEAR, LAST_YEAR), +) +def test_p3_other_plans_unchanged_in_every_year(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + np.testing.assert_allclose( + repayment, reference_repayment(sim, people, year), rtol=1e-6, atol=0.01 + ) + + +@PROPERTY_SETTINGS +@given(people=borrowers, year=st.integers(FIRST_YEAR, LAST_YEAR)) +def test_p4_repayment_is_bounded_by_nil_and_balance(people, year): + sim = simulate(people, year) + repayment = sim.calculate("student_loan_repayment", year) + balance = np.array([person[2] for person in people], dtype=float) + assert np.all(repayment >= 0) + capped = balance > 0 + assert np.all(repayment[capped] <= balance[capped] + 1e-6) diff --git a/policyengine_uk/tests/test_price_base_year.py b/policyengine_uk/tests/test_price_base_year.py new file mode 100644 index 0000000000..22cf880c63 --- /dev/null +++ b/policyengine_uk/tests/test_price_base_year.py @@ -0,0 +1,242 @@ +"""Real incomes use the prices of gov.simulation.price_base_year (#2875). + +``inflation_adjustment`` divides ``consumer_price_index`` on 1 January of the +base year by the index in the period. ``inflation_adjustment_ahc`` divides one +index on 1 January of the base year by the same index in the period: it uses +``consumer_price_index_ahc`` where that index has a value on both dates, and +``consumer_price_index`` on both dates otherwise. No formula reads the clock, +so a simulation gives the same answers whatever year it runs in. + +Invariants: + +1. Identity: each adjustment equals 1 when the period equals the base year, + for every base year from 2009, where the CPI index starts. For + ``inflation_adjustment_ahc`` that includes base years before 2022. +2. Positivity: each adjustment stays positive wherever the indices have + values. +3. Rebasing: for any two base years, the ratio of the two adjustments does + not depend on the period. It holds for ``inflation_adjustment`` from 2009 + and for ``inflation_adjustment_ahc`` across periods and base years from + 2022. +4. The four real income variables equal their nominal income times + ``inflation_adjustment``. + +The property tests set the base year on a fresh simulation's own parameter +tree, which is faster than a reform; ``test_tree_update_matches_reform`` +checks that the two routes agree. +""" + +import functools + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.periods import instant + +from policyengine_uk import Simulation +from policyengine_uk.system import system + +INDICES = system.parameters.gov.economic_assumptions.indices.obr +CPI = INDICES.consumer_price_index +CPI_AHC = INDICES.consumer_price_index_ahc +BASE_YEAR = "gov.simulation.price_base_year" +DEFAULT_BASE_YEAR = 2026 + +SITUATION = { + "people": { + "adult": {"age": {"2026": 40}, "employment_income": {"2026": 30_000}}, + }, + "benunits": {"benunit": {"members": ["adult"]}}, + "households": {"household": {"members": ["adult"]}}, +} + +# Base years the property tests draw from: the first CPI year, years before +# and within the stretch where the AHC index reads 1, the default, a forecast +# year and a year after the last index value. +BASE_YEARS = [2009, 2014, 2018, 2022, 2026, 2031, 2080] +PERIODS = st.integers(min_value=2009, max_value=2080) + +PROPERTY_SETTINGS = settings( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) + +REAL_INCOMES = { + "real_household_net_income": "household_net_income", + "real_household_net_income_ahc": "household_net_income_ahc", + "real_hbai_household_net_income": "hbai_household_net_income", + "real_hbai_household_net_income_ahc": "hbai_household_net_income_ahc", +} + + +def cpi(year): + return CPI(f"{year}-01-01") + + +@functools.lru_cache(maxsize=None) +def simulation_at_base_year(base_year): + """A fresh simulation whose own parameter tree prices to ``base_year``.""" + sim = Simulation(situation=SITUATION) + sim.tax_benefit_system.parameters.get_child(BASE_YEAR).update( + start=instant("2000-01-01"), stop=instant("2100-12-31"), value=base_year + ) + sim.tax_benefit_system._parameters_at_instant_cache.clear() + return sim + + +@functools.lru_cache(maxsize=None) +def baseline_simulation(): + return Simulation(situation=SITUATION) + + +@pytest.fixture(scope="module", autouse=True) +def release_simulations(): + yield + simulation_at_base_year.cache_clear() + baseline_simulation.cache_clear() + + +def adjustment(variable, base_year, period): + sim = simulation_at_base_year(base_year) + return float(sim.calculate(variable, period)[0]) + + +# Example tests. + + +def test_default_base_year(): + parameter = system.parameters.get_child(BASE_YEAR) + for year in (2009, 2015, 2026, 2027, 2050, 2100): + assert parameter(f"{year}-01-01") == DEFAULT_BASE_YEAR + + +@pytest.mark.parametrize( + "period", [2009, 2014, 2015, 2021, 2022, 2026, 2027, 2030, 2074, 2080] +) +def test_inflation_adjustment_against_cpi(period): + expected = cpi(DEFAULT_BASE_YEAR) / cpi(period) + actual = baseline_simulation().calculate("inflation_adjustment", period) + assert actual[0] == pytest.approx(expected, rel=1e-6) + + +def test_ahc_adjustment_uses_one_index(): + sim = baseline_simulation() + # Both dates have an AHC index value. + for period in (2022, 2027, 2030): + expected = CPI_AHC("2026-01-01") / CPI_AHC(f"{period}-01-01") + actual = sim.calculate("inflation_adjustment_ahc", period)[0] + assert actual == pytest.approx(expected, rel=1e-6) + # The AHC index has no value in 2012, so CPI prices both dates. + assert CPI_AHC("2012-01-01") is None + actual = sim.calculate("inflation_adjustment_ahc", 2012)[0] + assert actual == pytest.approx(cpi(2026) / cpi(2012), rel=1e-6) + + +def test_reform_changes_the_base_year(): + # A reform can pass the year as a float. + reformed = Simulation( + situation=SITUATION, + reform={BASE_YEAR: {"2000-01-01.2100-12-31": 2030.0}}, + ) + baseline = baseline_simulation() + adjustment_2026 = reformed.calculate("inflation_adjustment", 2026)[0] + assert adjustment_2026 == pytest.approx(cpi(2030) / cpi(2026), rel=1e-6) + assert reformed.calculate("inflation_adjustment", 2030)[0] == pytest.approx(1) + assert reformed.calculate("inflation_adjustment_ahc", 2030)[0] == pytest.approx(1) + assert reformed.calculate("real_household_net_income", 2026)[0] == pytest.approx( + baseline.calculate("household_net_income", 2026)[0] * cpi(2030) / cpi(2026), + rel=1e-5, + ) + assert baseline.calculate("inflation_adjustment", 2026)[0] == pytest.approx(1) + + +def test_tree_update_matches_reform(): + reformed = Simulation( + situation=SITUATION, + reform={BASE_YEAR: {"2000-01-01.2100-12-31": 2031}}, + ) + for variable in ("inflation_adjustment", "inflation_adjustment_ahc"): + for period in (2012, 2026, 2040): + assert adjustment(variable, 2031, period) == pytest.approx( + reformed.calculate(variable, period)[0], rel=1e-7 + ) + + +def test_labels_name_no_year(): + for name in ( + "inflation_adjustment", + "inflation_adjustment_ahc", + "real_household_net_income", + "real_household_net_income_ahc", + ): + label = system.variables[name].label + assert "base-year" in label + assert not any(char.isdigit() for char in label), label + assert ( + system.variables["real_household_net_income"].label + != system.variables["real_household_net_income_ahc"].label + ) + + +# Property tests. + + +@PROPERTY_SETTINGS +@given(base_year=st.sampled_from(BASE_YEARS)) +def test_identity_in_the_base_year(base_year): + for variable in ("inflation_adjustment", "inflation_adjustment_ahc"): + assert adjustment(variable, base_year, base_year) == pytest.approx(1) + + +@PROPERTY_SETTINGS +@given(base_year=st.sampled_from(BASE_YEARS), period=PERIODS) +def test_positive(base_year, period): + for variable in ("inflation_adjustment", "inflation_adjustment_ahc"): + value = adjustment(variable, base_year, period) + assert np.isfinite(value) and value > 0 + + +@PROPERTY_SETTINGS +@given( + base_years=st.lists(st.sampled_from(BASE_YEARS), min_size=2, max_size=2), + periods=st.lists(PERIODS, min_size=2, max_size=2), +) +def test_rebasing_does_not_depend_on_the_period(base_years, periods): + (b1, b2), (p1, p2) = base_years, periods + variable = "inflation_adjustment" + ratio_1 = adjustment(variable, b1, p1) / adjustment(variable, b2, p1) + ratio_2 = adjustment(variable, b1, p2) / adjustment(variable, b2, p2) + assert ratio_1 == pytest.approx(ratio_2, rel=1e-6) + assert ratio_1 == pytest.approx(cpi(b1) / cpi(b2), rel=1e-6) + + +@PROPERTY_SETTINGS +@given( + base_years=st.lists( + st.sampled_from([b for b in BASE_YEARS if b >= 2022]), min_size=2, max_size=2 + ), + periods=st.lists(st.integers(2022, 2080), min_size=2, max_size=2), +) +def test_ahc_rebasing_does_not_depend_on_the_period(base_years, periods): + (b1, b2), (p1, p2) = base_years, periods + variable = "inflation_adjustment_ahc" + ratio_1 = adjustment(variable, b1, p1) / adjustment(variable, b2, p1) + ratio_2 = adjustment(variable, b1, p2) / adjustment(variable, b2, p2) + assert ratio_1 == pytest.approx(ratio_2, rel=1e-6) + + +@settings(PROPERTY_SETTINGS, max_examples=8) +@given( + base_year=st.sampled_from(BASE_YEARS), + period=st.sampled_from([2024, 2026, 2028]), +) +def test_real_income_is_nominal_times_adjustment(base_year, period): + sim = simulation_at_base_year(base_year) + multiplier = sim.calculate("inflation_adjustment", period)[0] + for real, nominal in REAL_INCOMES.items(): + assert sim.calculate(real, period)[0] == pytest.approx( + sim.calculate(nominal, period)[0] * multiplier, rel=1e-5, abs=0.01 + ) diff --git a/policyengine_uk/tests/test_private_school_vat_receipts.py b/policyengine_uk/tests/test_private_school_vat_receipts.py index 50af153c2f..cf3ab9fe77 100644 --- a/policyengine_uk/tests/test_private_school_vat_receipts.py +++ b/policyengine_uk/tests/test_private_school_vat_receipts.py @@ -37,7 +37,7 @@ LEGACY = "gov.simulation.microdata_vat_coverage" FEES = "gov.simulation.private_school_vat.private_school_fees" BASIS = "gov.simulation.private_school_vat.private_school_vat_basis" -FACTOR = 0.67 * 0.7 +FACTOR = 0.69 * 0.7 # HMRC's OBR-certified costing of VAT on private school fees (Autumn Budget # 2024 Table 5.1, £m) and OBR cash-basis VAT receipts (October 2026 EFO # detailed forecast tables, receipts, sheet 4.8, row 13, cells D13:H13, £m), @@ -190,13 +190,13 @@ def test_vat_reads_neither_attendance_nor_fees(): def test_published_microcosm_receipts_check_is_unchanged(): - # Rounded aggregate inputs published in docs/engineering/vat-coverage.md, - # with energy bills split out: the standard-rated base and the energy-bill - # VAT, which is added after grossing. Model year 2024 has no school charge - # and no share to reallocate. - sim = simulation(year=2024, full=396.15e9, reduced=0, energy_vat=1.99e9) + # Rounded aggregate inputs that docs/engineering/vat-coverage.md publishes + # for Microcosm a6299504, with energy bills split out: the standard-rated + # base, and the energy-bill VAT, which vat adds after grossing. Model year + # 2024 has no school charge and no share to reallocate. + sim = simulation(year=2024, full=407.40e9, reduced=0, energy_vat=2.33e9) combined = amount(sim, "vat", 2024) + amount(sim, "private_school_vat", 2024) - expected = 0.2 * 396.15e9 / FACTOR + 1.99e9 + expected = 0.2 * 407.40e9 / FACTOR + 2.33e9 assert combined == pytest.approx(expected) assert combined / 170.994e9 == pytest.approx(1, rel=0.001) diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index 6b2ff922c0..6c937c5666 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -37,6 +37,8 @@ ) from policyengine_uk.system import system +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + BASE_YEAR = 2024 GROWTH = parameters.gov.economic_assumptions.yoy_growth # The fixture declares the OBR savings income projection, under which the @@ -114,9 +116,9 @@ def _dataset(amount=1_000.0, base_year=BASE_YEAR): input set to ``amount`` (or one amount per household), plus rent and council tax. - Recorded Pension Credit capital is twice the benefit unit's recorded - capital, which its formula reads, so the two tell apart a stored value - from a calculated one.""" + Recorded complete Pension Credit capital is twice the benefit unit's + financial report, distinguishing the stored complete override from both + the financial observation and the default no-override sentinel.""" n = len(HOUSEHOLDS) ids = np.arange(1, n + 1) amounts = np.broadcast_to(np.asarray(amount, dtype=float), (n,)).copy() @@ -390,11 +392,11 @@ def test_a_year_past_the_horizon_is_served_from_the_last_projected_year(sim): one with an uprating attribute is scaled by that index from the last projected year, and any other is held at its last projected value.""" last, after = PROJECTION_END_YEAR, PROJECTION_END_YEAR + 1 - # The formula reads benunit_reported_capital, uprated from the last - # projected year, in place of the stored value, which was twice it. + # The complete Pension Credit override expires after the stored horizon. + # Its default is -1; the financial observation remains separately indexed. assert system.variables["pension_credit_reported_capital"].formulas assert sim.calculate("pension_credit_reported_capital", after).values == ( - pytest.approx(sim.calculate("benunit_reported_capital", after).values) + pytest.approx(np.full(len(HOUSEHOLDS), -1)) ) assert sim.calculate("pension_credit_reported_capital", last).values == ( pytest.approx(2 * sim.calculate("benunit_reported_capital", last).values) @@ -420,7 +422,7 @@ def test_legacy_reported_capital_expires_only_after_the_horizon(projected): """Legacy survey files store programme capital without the shared input. Projection preserves that observation through the supported horizon. - Afterward the programme formula uses the absent shared input's sentinel, + Afterward the complete-override formula returns its default sentinel, matching direct-situation semantics rather than carrying the observation. """ dataset = _dataset() diff --git a/policyengine_uk/tests/test_property_income_band_base_properties.py b/policyengine_uk/tests/test_property_income_band_base_properties.py index 7d5730f867..b489636a0d 100644 --- a/policyengine_uk/tests/test_property_income_band_base_properties.py +++ b/policyengine_uk/tests/test_property_income_band_base_properties.py @@ -10,20 +10,29 @@ amount leaves savings and dividend tax unchanged, in England and in Scotland. (The taxable amount, because the £1,000 property allowance can relieve small property income in full.) -2. Accounting: with no Marriage Allowance, non_savings_taxable_income equals - adjusted net income less savings, dividends and allowances, floored at nil - (earned_taxable_income also subtracts Marriage Allowance; #1947). -3. Monotonic: more property income never lowers savings or dividend tax. +2. Accounting: non_savings_taxable_income equals adjusted net income less the + allowances and the savings and dividends left after the allowances set + against them, floored at nil. The Marriage Allowance does not enter it: the + model gives the allowance as a tax reduction at Step 6 (ITA 2007 s. 55B(1)). + Under s. 25(2) the model can set allowances against savings or dividends + while non-savings income remains, so the savings and dividends left, not + their full amounts, sit above it. +3. Monotonic: more property income never lowers the income tax. Savings or + dividend tax alone can fall, because under s. 25(2) the model can move + allowances from property income to savings as property income rises. (Main passes this too; the differential and the higher-rate check below are what catch the bug.) """ import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + YEAR = 2026 PEOPLE = 10 amount = st.integers(min_value=0, max_value=200_000) @@ -41,6 +50,9 @@ "taxable_savings_interest_income", "taxable_dividend_income", "allowances", + "received_allowances_savings_income", + "received_allowances_dividend_income", + "income_tax", ] @@ -100,8 +112,11 @@ def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): expected = np.maximum( 0, v["adjusted_net_income"] - - v["taxable_savings_interest_income"] - - v["taxable_dividend_income"] + - ( + v["taxable_savings_interest_income"] + - v["received_allowances_savings_income"] + ) + - (v["taxable_dividend_income"] - v["received_allowances_dividend_income"]) - v["allowances"], ) np.testing.assert_allclose(v["non_savings_taxable_income"], expected, atol=0.01) @@ -109,19 +124,41 @@ def test_non_savings_taxable_income_is_the_rest_after_allowances(rows): @SETTINGS @given(rows=rows_strategy, rise=st.integers(min_value=1, max_value=100_000)) -def test_more_property_income_never_lowers_savings_or_dividend_tax(rows, rise): +def test_more_property_income_never_lowers_income_tax(rows, rise): before = _calculate(rows, "ENGLAND") after = _calculate( [(pay, prop + rise, s, d) for pay, prop, s, d in rows], "ENGLAND" ) - for name in ["savings_income_tax", "dividend_income_tax"]: - assert np.all(after[name] >= before[name] - 0.01) + assert np.all(after["income_tax"] >= before["income_tax"] - 0.01) def test_savings_above_property_income_past_the_basic_rate_limit_pay_the_higher_rate(): - """£40,000 of property income leaves £10,270 of the basic-rate band, so - some of £20,000 of savings must pay 40%: savings tax exceeds 20% of - everything above the £500 savings allowance (£3,900). Main banded the - savings as if the property income were not there and charged £2,900.""" + """£40,000 of property income and £20,000 of savings. Under ITA 2007 + s. 25(2) the model sets £2,300 of the Personal Allowance against the + property income, which leaves it at the £37,700 basic rate limit, and the + other £10,270 against savings. All the savings left, £9,730, sit above the + limit: £500 at the nil rate (s. 12A) and £9,230 at 40%, £3,692. Main once + banded the savings as if the property income were not there and charged + £2,900.""" v = _calculate([(0, 40_000, 20_000, 0)] + [(0, 0, 0, 0)] * (PEOPLE - 1), "ENGLAND") - assert v["savings_income_tax"][0] > (20_000 - 500) * 0.20 + np.testing.assert_allclose(v["savings_income_tax"][0], 9_230 * 0.40, atol=0.01) + np.testing.assert_allclose(v["income_tax"][0], 37_700 * 0.20 + 3_692, atol=0.01) + # Non-savings taxable income comes to 37,700, not 60,000 - 20,000 - 12,570 + # = 27,430: invariant 2 subtracts only the savings left. + np.testing.assert_allclose(v["non_savings_taxable_income"][0], 37_700, atol=0.01) + + +def test_more_property_income_can_lower_savings_tax(): + """More property income can lower savings tax while income tax rises. + With £30,000 of property income and £20,000 of savings, taxable income of + £37,430 stays within the basic rate band, so the savings allowance comes + to £1,000 (s. 12B(4)) and no share changes the tax: £19,000 of savings at + 20%, £3,800. £10,000 more property income gives the £3,692 of the test + above: savings tax falls by £108 while income tax rises from £7,286 to + £11,232.""" + before = _calculate([(0, 30_000, 20_000, 0)] * PEOPLE, "ENGLAND") + after = _calculate([(0, 40_000, 20_000, 0)] * PEOPLE, "ENGLAND") + np.testing.assert_allclose(before["savings_income_tax"], 3_800, atol=0.01) + np.testing.assert_allclose(after["savings_income_tax"], 3_692, atol=0.01) + np.testing.assert_allclose(before["income_tax"], 17_430 * 0.20 + 3_800, atol=0.01) + np.testing.assert_allclose(after["income_tax"], 7_540 + 3_692, atol=0.01) diff --git a/policyengine_uk/tests/test_property_wealth_components.py b/policyengine_uk/tests/test_property_wealth_components.py new file mode 100644 index 0000000000..1cd00e4d77 --- /dev/null +++ b/policyengine_uk/tests/test_property_wealth_components.py @@ -0,0 +1,675 @@ +"""Synthetic accounting and projection contracts for the WAS property components. + +WAS has seven gross terms, but second homes and buy-to-let are saved together +by policyengine-uk-data. These tests exercise that six-input model contract; +licensed-source reconciliation and release persistence belong to the data repo. +The formula properties use fresh Core simulation holders with one shared +model; uprating, datasets, monthly stocks, LVT and purchases exercise the UK +simulation wrapper separately. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import yaml +from hypothesis import example, given, settings +from hypothesis import strategies as st +from policyengine_core.simulations import Simulation as CoreSimulation + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +BASE_YEAR = 2024 +GDP_INDEX = "gov.economic_assumptions.indices.obr.per_capita.gdp" +GDP_GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" +PROPERTY_INPUTS = ( + "main_residence_value", + "other_residential_property_value", + "non_residential_property_value", + "owned_land", + "overseas_property_value", + "other_property_value", +) +OTHER_WEALTH_INPUTS = ("corporate_wealth", "private_pension_wealth", "savings") +CAPITAL_PATHS = ( + "gov.dwp.universal_credit.means_test.capital", + "gov.dwp.housing_benefit.means_test.capital", + "gov.dwp.income_support.means_test.capital", + "gov.dwp.JSA.income.capital", + "gov.dwp.ESA.income.capital", + "gov.dwp.pension_credit.income.capital", +) +SOURCE_INPUTS = ( + ("main_residence", "main_residence_value"), + ("second_homes", "other_residential_property_value"), + ("buy_to_let", "other_residential_property_value"), + ("other_uk_buildings", "non_residential_property_value"), + ("uk_land", "owned_land"), + ("overseas_land_and_property", "overseas_property_value"), + ("other_real_estate", "other_property_value"), +) +REGIONS = ( + "LONDON", + "SOUTH_EAST", + "EAST_OF_ENGLAND", + "SOUTH_WEST", + "WEST_MIDLANDS", + "EAST_MIDLANDS", + "NORTH_WEST", + "YORKSHIRE", + "SCOTLAND", + "WALES", + "NORTH_EAST", + "NORTHERN_IRELAND", + "UNKNOWN", +) +PROPERTY_SETTINGS = settings(max_examples=15, deadline=None, derandomize=True) +# Finite non-negative stocks at float32 precision, as held by the model. +MONEY = st.floats( + min_value=0, + max_value=10_000_000, + allow_nan=False, + allow_infinity=False, + width=32, +) +COMPONENT_VALUES = dict( + zip(PROPERTY_INPUTS, (60_000, 25_000, 15_000, 20_000, 30_000, 0)) +) + + +@pytest.fixture(scope="module") +def _property_metadata_system(cloned_uk_tax_benefit_system): + # The dependency installs the clone factory before this read-only fixture. + import policyengine_uk.simulation as simulation_module + + return simulation_module.CountryTaxBenefitSystem() + + +def _saved_components(source_values): + """The upstream release contract combines second homes and buy-to-let.""" + components = dict.fromkeys(PROPERTY_INPUTS, 0.0) + for source, variable in SOURCE_INPUTS: + components[variable] += source_values.get(source, 0.0) + return components + + +def _situation(assets, region="LONDON"): + return { + "people": {"person": {"age": {BASE_YEAR: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {BASE_YEAR: region}, + **{variable: {BASE_YEAR: value} for variable, value in assets.items()}, + } + }, + } + + +def _simulation(assets, *, region="LONDON", reform=None): + return Simulation(situation=_situation(assets, region), reform=reform) + + +@pytest.fixture(scope="module") +def _formula_simulation(_property_metadata_system): + """Fresh holders per example; unchanged production formula definitions.""" + + def build(assets, *, region="LONDON"): + return CoreSimulation( + tax_benefit_system=_property_metadata_system, + situation=_situation(assets, region), + ) + + return build + + +def _value(simulation, variable, period=BASE_YEAR): + return float(simulation.calculate(variable, period)[0]) + + +def _property_sum(simulation, period=BASE_YEAR): + return sum(_value(simulation, variable, period) for variable in PROPERTY_INPUTS) + + +def _component_dataset(components, *, stored_total=None): + """Use the actual single-year loader and its YAML uprating registrations.""" + household = { + "household_id": [1], + "region": ["LONDON"], + "tenure_type": ["OWNED_OUTRIGHT"], + "council_tax": [0.0], + "rent": [0.0], + **{variable: [float(value)] for variable, value in components.items()}, + } + if stored_total is not None: + household["property_wealth"] = [stored_total] + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame(household), + fiscal_year=BASE_YEAR, + ) + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +def test_new_property_inputs_register_gdp_uprating_for_situations_and_datasets( + variable, _property_metadata_system +): + model_variable = _property_metadata_system.variables[variable] + assert model_variable.uprating == GDP_INDEX + assert model_variable.entity.key == "household" + assert model_variable.default_value == 0 + registrations_path = Path(__file__).parents[1] / "data" / "uprating_indices.yaml" + registrations = yaml.safe_load(registrations_path.read_text()) + assert registrations[GDP_GROWTH].count(variable) == 1 + assert sum(names.count(variable) for names in registrations.values()) == 1 + + +@pytest.mark.parametrize("capital_path", CAPITAL_PATHS) +@pytest.mark.parametrize("year", [2024, 2026]) +def test_property_capital_lists_preserve_each_source_once( + capital_path, year, _property_metadata_system +): + parameters = _property_metadata_system.parameters + sources = list(parameters.get_child(f"{capital_path}.sources")(year)) + sale_sources = list( + parameters.get_child(f"{capital_path}.sale_expenses.sources")(year) + ) + for names in (sources, sale_sources): + for variable in ("overseas_property_value", "other_property_value"): + assert names.count(variable) == ( + 0 if "universal_credit" in capital_path else 1 + ) + assert "main_residence_value" not in names + assert "private_pension_wealth" not in names + assert names.count("share_like_capital") == 1 + assert "corporate_wealth" not in names + assert "buy_to_let_value" not in names + # Preserve UC's existing omission; the aggregate change does not decide it. + if "universal_credit" in capital_path: + assert "owned_land" not in sources + property_sources = list( + parameters.get_child(f"{capital_path}.property_sources")(year) + ) + for variable in ("uc_overseas_property_capital", "uc_other_property_capital"): + assert property_sources.count(variable) == 1 + else: + assert sources.count("owned_land") == 1 + + +PARAMETERS_DIR = Path(__file__).parents[1] / "parameters" +PROPERTY_SOURCE_FILES = sorted(PARAMETERS_DIR.rglob("property_sources.yaml")) +# Universal Credit's uc_property_capital formula reads each listed variable +# directly. Every other list goes to valued_capital(..., sources=...), which +# skips a name missing from the programme's capital.sources. +DIRECTLY_READ_PROPERTY_SOURCES = {"gov.dwp.universal_credit.means_test.capital"} + + +def _dated_lists(path): + values = yaml.safe_load(path.read_text())["values"] + return {str(start): names for start, names in values.items()} + + +@pytest.mark.parametrize( + "path", + PROPERTY_SOURCE_FILES, + ids=lambda path: ".".join(path.relative_to(PARAMETERS_DIR).parent.parts), +) +def test_property_source_lists_name_configured_sources(path, _property_metadata_system): + node = ".".join(path.relative_to(PARAMETERS_DIR).parent.parts) + property_lists = _dated_lists(path) + assert property_lists + if node in DIRECTLY_READ_PROPERTY_SOURCES: + variables = _property_metadata_system.variables + for names in property_lists.values(): + assert len(names) == len(set(names)) + unknown = [name for name in names if name not in variables] + assert not unknown, f"{path}: {unknown} are not variables" + return + parameters = _property_metadata_system.parameters + first = min(property_lists) + dates = set(property_lists) | set(_dated_lists(path.with_name("sources.yaml"))) + for date in sorted(d for d in dates if d >= first): + names = list(parameters.get_child(f"{node}.property_sources")(date)) + configured = list(parameters.get_child(f"{node}.sources")(date)) + assert len(names) == len(set(names)) + missing = [name for name in names if name not in configured] + assert not missing, f"{path} at {date}: {missing} missing from sources" + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +@PROPERTY_SETTINGS +@given( + property_pence=st.integers(min_value=0, max_value=2_000_000), + report_pence=st.integers(min_value=0, max_value=2_000_000), + holding_pence=st.integers(min_value=0, max_value=2_000_000), +) +@example(property_pence=2_000_000, report_pence=0, holding_pence=0) +def test_financial_reports_preserve_separately_valued_property( + _property_metadata_system, variable, property_pence, report_pence, holding_pence +): + # The only financial proxy is an entirely disregarded company holding. + # A financial-only report adds to the same independently valued property, + # net of that holding, even when the report is zero. + holding = holding_pence / 100 + situation = _situation( + {variable: property_pence / 100, "corporate_wealth": holding} + ) + situation["people"]["person"]["uc_company_holding_disregard"] = {BASE_YEAR: holding} + unreported = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + baseline = _value(unreported, "uc_assessable_capital") + property_capital = _value(unreported, "uc_property_capital") + report = report_pence / 100 + situation["benunits"]["benunit"]["benunit_reported_capital"] = {BASE_YEAR: report} + reported = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + counted = _value(reported, "uc_assessable_capital") + + assert counted >= baseline + assert baseline == property_capital + assert _value(reported, "uc_property_capital") == property_capital + # Recover the penny from float32 storage before adding the observed report; + # compare the exact value after the final float32 holder conversion. + net_report = max(0, report - holding) + assert counted == float( + np.float32(round(net_report + round(property_capital, 2), 2)) + ) + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +@PROPERTY_SETTINGS +@given( + value=MONEY, + savings=MONEY, + region=st.sampled_from(("LONDON", "WALES", "SCOTLAND")), + age=st.sampled_from((40, 70)), +) +@example(value=100_000, savings=0, region="SCOTLAND", age=40) +def test_council_tax_reduction_household_route_counts_each_input_less_sale_expenses( + _property_metadata_system, variable, value, savings, region, age +): + # An applicant on neither Universal Credit nor Pension Credit takes council + # tax reduction's household route in England, Wales and Scotland. Each + # scheme takes 10% off land and property inside or outside the United + # Kingdom where there would be expenses of sale, and the data hold no + # secured debt for either input, so each pound adds 90p to capital. + def capital(amount): + situation = _situation({"savings": savings, variable: amount}, region) + situation["people"]["person"]["age"] = {BASE_YEAR: age} + situation["benunits"]["benunit"]["would_claim_uc"] = {BASE_YEAR: False} + situation["benunits"]["benunit"]["would_claim_pc"] = {BASE_YEAR: False} + simulation = CoreSimulation( + tax_benefit_system=_property_metadata_system, situation=situation + ) + return _value(simulation, "council_tax_reduction_assessable_capital") + + without = capital(0) + counted = capital(value) + # The formula sums in float32, so allow its rounding of the total. + tolerance = 1e-6 * (savings + value) + 0.01 + assert without == pytest.approx(savings, abs=tolerance) + assert counted >= without + assert counted - without == pytest.approx(0.9 * value, abs=tolerance) + + +@pytest.mark.parametrize("source,variable", SOURCE_INPUTS) +def test_each_was_source_enters_gross_and_total_property_once(source, variable): + components = _saved_components({source: 100_000}) + sim = _simulation(components) + + assert _value(sim, variable) == 100_000 + assert _value(sim, "property_wealth") == 100_000 + assert _value(sim, "total_wealth") == 100_000 + residential = 100_000 if variable in PROPERTY_INPUTS[:2] else 0 + assert _value(sim, "residential_property_value") == residential + + +def test_seven_source_terms_reconcile_to_six_saved_components(): + # This is a synthetic mapping contract, not reconciliation of licensed WAS. + source_values = dict(zip((source for source, _ in SOURCE_INPUTS), range(1, 8))) + components = _saved_components(source_values) + sim = _simulation(components) + + assert len(components) == 6 + assert components["other_residential_property_value"] == 2 + 3 + assert _value(sim, "residential_property_value") == 1 + 2 + 3 + assert _value(sim, "property_wealth") == sum(source_values.values()) + assert _value(sim, "total_wealth") == sum(source_values.values()) + + +def test_london_mixed_property_has_150000_gross_and_105000_uk_land(): + sim = _simulation(COMPONENT_VALUES) + + assert _value(sim, "property_wealth") == 150_000 + assert _value(sim, "total_wealth") == 150_000 + assert _value(sim, "household_land_value") == 105_000 + + +@PROPERTY_SETTINGS +@given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS))) +@example((60_000, 25_000, 15_000, 20_000, 30_000, 40_000)) +def test_property_wealth_equals_sum_of_saved_inputs(_formula_simulation, values): + sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values))) + + assert _value(sim, "property_wealth") == pytest.approx( + _property_sum(sim), rel=2e-6, abs=0.01 + ) + + +@PROPERTY_SETTINGS +@given(st.tuples(*(MONEY for _ in PROPERTY_INPUTS)), st.sampled_from(REGIONS)) +@example((0, 0, 0, 20_000, 0, 0), "LONDON") +@example((0, 0, 0, 20_000.50, 8_388_608, 0), "LONDON") +def test_household_land_value_is_at_least_direct_uk_land( + _formula_simulation, values, region +): + sim = _formula_simulation(dict(zip(PROPERTY_INPUTS, values)), region=region) + direct_land = _value(sim, "owned_land") + land = _value(sim, "household_land_value") + # A nonnegative domestic base cannot reduce the directly owned land. + assert land >= direct_land + + +@PROPERTY_SETTINGS +@given(MONEY, st.sampled_from(REGIONS)) +@example(30_000, "LONDON") +def test_overseas_only_households_have_zero_uk_land(_formula_simulation, value, region): + sim = _formula_simulation({"overseas_property_value": value}, region=region) + + assert _value(sim, "property_wealth") == _value(sim, "overseas_property_value") + assert _value(sim, "household_land_value") == 0 + + +@PROPERTY_SETTINGS +@given(MONEY, st.sampled_from(REGIONS)) +@example(20_000, "LONDON") +def test_land_only_households_count_direct_uk_land_once( + _formula_simulation, value, region +): + sim = _formula_simulation({"owned_land": value}, region=region) + direct_land = _value(sim, "owned_land") + + assert _value(sim, "property_wealth") == direct_land + assert _value(sim, "household_land_value") == direct_land + assert _value(sim, "total_wealth") == direct_land + + +@PROPERTY_SETTINGS +@given(st.tuples(*(MONEY for _ in (*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS)))) +@example((60_000, 25_000, 15_000, 20_000, 30_000, 40_000, 10_000, 5_000, 1_000)) +def test_total_wealth_includes_every_asset_category_once(_formula_simulation, values): + sim = _formula_simulation( + dict(zip((*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS), values)) + ) + expected = sum( + _value(sim, variable) for variable in (*PROPERTY_INPUTS, *OTHER_WEALTH_INPUTS) + ) + + assert _value(sim, "total_wealth") == pytest.approx(expected, rel=2e-6, abs=0.01) + + +def test_overseas_only_household_has_no_household_lvt(): + reform = { + "gov.contrib.ubi_center.land_value_tax.household_rate": { + "2024-01-01.2026-12-31": 0.01 + } + } + overseas = _simulation({"overseas_property_value": 100_000}, reform=reform) + domestic = _simulation(COMPONENT_VALUES, reform=reform) + + assert _value(overseas, "LVT") == 0 + assert _value(domestic, "LVT") == pytest.approx(1_050) + + +@pytest.mark.parametrize( + "second_homes,buy_to_let", [(100_000, 0), (0, 200_000), (100_000, 200_000)] +) +def test_second_homes_and_buy_to_let_use_one_explicit_domestic_purchase( + second_homes, buy_to_let +): + components = _saved_components( + {"second_homes": second_homes, "buy_to_let": buy_to_let} + ) + stocks_only = _simulation({**components, "property_purchased": True}) + assert _value(stocks_only, "additional_residential_property_purchased") == 0 + assert _value(stocks_only, "sdlt_on_transactions") == 0 + combined = second_homes + buy_to_let + sim = _simulation( + {**components, "additional_residential_property_purchased": combined} + ) + + assert _value(sim, "residential_property_value") == combined + assert _value(sim, "property_wealth") == combined + assert _value(sim, "total_wealth") == combined + assert _value(sim, "additional_residential_property_purchased") == combined + stamp_duty = sim.tax_benefit_system.parameters(BASE_YEAR).gov.hmrc.stamp_duty + assert _value(sim, "sdlt_on_transactions") == pytest.approx( + stamp_duty.residential.purchase.additional.rate.calc(np.array([combined]))[0] + ) + + +@pytest.mark.parametrize( + "variable", ["overseas_property_value", "other_property_value"] +) +def test_overseas_and_unclassified_property_do_not_enter_uk_purchases_or_sdlt(variable): + sim = _simulation({variable: 500_000, "property_purchased": True}) + + for purchase in ( + "main_residential_property_purchased", + "additional_residential_property_purchased", + "non_residential_property_purchased", + ): + assert _value(sim, purchase) == 0 + assert _value(sim, "sdlt_on_transactions") == 0 + assert _value(sim, "stamp_duty_land_tax") == 0 + + +def _assert_projected_component_accounting(sim, assets, year): + parameters = sim.tax_benefit_system.parameters + projected = {} + for variable, amount in assets.items(): + index_path = sim.tax_benefit_system.variables[variable].uprating + index = parameters.get_child(index_path) + expected = amount * index(year) / index(BASE_YEAR) + projected[variable] = _value(sim, variable, year) + # Dataset GDP projections compound the growth rates; cumulative indices + # round their levels. Residential inputs retain current main's HPI route. + assert projected[variable] == pytest.approx(expected, rel=1e-4, abs=0.02) + assert _value(sim, variable, f"{year}-01") == projected[variable] + total = sum(projected.values()) + assert _value(sim, "property_wealth", year) == pytest.approx(total, rel=2e-6) + assert _value(sim, "total_wealth", year) == pytest.approx(total, rel=2e-6) + assert _value(sim, "property_wealth", f"{year}-01") == _value( + sim, "property_wealth", year + ) + land = ( + total - projected["owned_land"] - projected["overseas_property_value"] + ) * 0.85 + projected["owned_land"] + assert _value(sim, "household_land_value", year) == pytest.approx(land, rel=2e-6) + assert _value(sim, "household_land_value", f"{year}-01") == _value( + sim, "household_land_value", year + ) + + +@pytest.mark.parametrize("year", [2024, 2026]) +def test_situation_uprating_preserves_accounting_and_monthly_stocks(year): + assets = {**COMPONENT_VALUES, "other_property_value": 40_000} + sim = _simulation(assets) + + _assert_projected_component_accounting(sim, assets, year) + + +@pytest.mark.parametrize("year", [2024, 2026]) +def test_component_only_dataset_derives_uprated_property_wealth(year): + assets = {**COMPONENT_VALUES, "other_property_value": 40_000} + dataset = _component_dataset(assets) + assert "property_wealth" not in dataset.household.columns + sim = Simulation(dataset=dataset) + + _assert_projected_component_accounting(sim, assets, year) + + +def test_legacy_stored_total_overrides_formula_until_removed_by_data_release(): + # Existing releases are deliberately not migrated by the model. uk-data#543 + # must stop persisting this column, including inherited copies of it. + legacy = _component_dataset(COMPONENT_VALUES, stored_total=1) + legacy_sim = Simulation(dataset=legacy) + assert _value(legacy_sim, "property_wealth") == 1 + assert _value(legacy_sim, "property_wealth", 2026) == 1 + + components_only = legacy.copy() + components_only.household = components_only.household.drop( + columns="property_wealth" + ) + sim = Simulation(dataset=components_only) + assert _value(sim, "property_wealth") == 150_000 + _assert_projected_component_accounting(sim, COMPONENT_VALUES, 2026) + + +# These outputs come from a run of these situations on main at 2da212ab, which +# has neither overseas_property_value nor other_property_value. Row 8 pins +# wealth, land and Universal Credit capital; row 9 pins wealth and land only. +# Unlike main at 2da212ab, the Housing Benefit, JSA, ESA and Income Support +# formulas here take sale expenses off other residential and non-residential +# property and off share_like_capital, which holds row 9's corporate_wealth, +# and the Universal Credit formula takes them off share_like_capital. These +# formulas therefore change the Housing Benefit, JSA, ESA and Income Support +# capital of both rows, and row 9's Universal Credit capital, independently +# of the new inputs. Rows 8 and 9 stop before Pension Credit capital, which +# the formula sets to nil while no member has reached the qualifying age. +WEALTH_AND_LAND = ("property_wealth", "total_wealth", "household_land_value") +CAPITAL = ( + "uc_assessable_capital", + "housing_benefit_assessable_capital", + "pension_credit_assessable_capital", + "jsa_income_assessable_capital", + "esa_income_assessable_capital", + "income_support_assessable_capital", +) +ZERO_INPUT_GRID = ( + ( + (40,), + "LONDON", + {"savings": 5_000, "main_residence_value": 200_000}, + None, + (200_000, 205_000, 170_000, 5_000, 0, 0, 5_000, 5_000, 5_000), + ), + ( + (40,), + "NORTH_EAST", + {"savings": 17_000}, + None, + (0, 17_000, 0, 17_000, 17_000, 0, 17_000, 17_000, 17_000), + ), + ( + (30, 32), + "WALES", + {"savings": 9_000, "main_residence_value": 150_000}, + None, + (150_000, 159_000, 70_500, 9_000, 0, 0, 9_000, 9_000, 9_000), + ), + ( + (70,), + "SCOTLAND", + {"savings": 12_000, "main_residence_value": 300_000}, + None, + (300_000, 312_000, 132_000, 12_000, 0, 12_000, 12_000, 12_000, 12_000), + ), + ( + (72, 69), + "SOUTH_WEST", + {"savings": 30_000}, + None, + (0, 30_000, 0, 30_000, 0, 30_000, 30_000, 30_000, 30_000), + ), + ( + (40,), + "LONDON", + {"savings": 5_000}, + 2_000, + (0, 5_000, 0, 2_000, 0, 0, 5_000, 5_000, 5_000), + ), + ( + (70,), + "YORKSHIRE", + {"savings": 8_000}, + 4_000, + (0, 8_000, 0, 4_000, 0, 4_000, 8_000, 8_000, 8_000), + ), + ( + (45,), + "LONDON", + { + "main_residence_value": 400_000, + "other_residential_property_value": 150_000, + "non_residential_property_value": 80_000, + }, + None, + (630_000, 630_000, 535_500, 207_000), + ), + ( + (45,), + "EAST_MIDLANDS", + { + "main_residence_value": 220_000, + "non_residential_property_value": 35_000, + "corporate_wealth": 10_000, + "private_pension_wealth": 90_000, + }, + None, + (255_000, 355_000, 122_400), + ), +) + + +def _grid_situation(ages, region, assets, reported, year=2026): + people = {f"person_{i}": {"age": {year: age}} for i, age in enumerate(ages)} + benunit = {"members": list(people)} + if reported is not None: + benunit["benunit_reported_capital"] = {year: reported} + return { + "people": people, + "benunits": {"benunit": benunit}, + "households": { + "household": { + "members": list(people), + "region": {year: region}, + **{variable: {year: value} for variable, value in assets.items()}, + } + }, + } + + +@pytest.mark.parametrize("explicit_zero", [False, True]) +@pytest.mark.parametrize("ages,region,assets,reported,expected", ZERO_INPUT_GRID) +def test_new_inputs_at_zero_reproduce_main( + ages, region, assets, reported, expected, explicit_zero +): + if explicit_zero: + assets = {**assets, "overseas_property_value": 0, "other_property_value": 0} + sim = Simulation(situation=_grid_situation(ages, region, assets, reported)) + outputs = (*WEALTH_AND_LAND, *CAPITAL)[: len(expected)] + + actual = tuple(_value(sim, variable, 2026) for variable in outputs) + assert actual == pytest.approx(expected, abs=0.01) diff --git a/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py new file mode 100644 index 0000000000..06480f73a0 --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_income_support_properties.py @@ -0,0 +1,385 @@ +"""Income Support in a young person's own right and qualifying young person +status. + +A person receiving Income Support is not a qualifying young person for +Pension Credit (SPC Regs 2002 reg. 4A(5)), Child Benefit (Child Benefit +(General) Regulations 2006 reg. 8(2)(a)) or Child Tax Credit (CTC Regs 2002 +reg. 5(4)(c)). Universal Credit's list (UC Regs 2013 reg. 5(5)) does not +include Income Support, but the Secretary of State also excludes a person +entitled to an existing benefit, Income Support among them (SI 2014/1230 regs. +2(1) and 28). + +Pension Credit and Child Tax Credit read is_on_income_support: the benefit +unit's calculated award, for whichever of the claimant and partner receives +it, and the person's own report for any other member while Income Support is +in payment and not neutralised. +Child Benefit reads receives_income_support_in_own_right, the person's own +report on the same terms, because the family's Income Support reads the +Child Benefit test and the calculated award would make a cycle. + +Invariants: + +1. Over every combination of age, education, training, the other own-right + benefits and the 16th-birthday fact, for a member who is not the claimant + or partner: reporting Income Support of their own while it is in payment + ends the Pension Credit, Child Benefit, Child Tax Credit and Universal + Credit statuses; without a report each status is what it is when the + report is left alone; and Pension Credit and Universal Credit agree + without it. +2. By default receives_income_support_in_own_right is the person's own + reported Income Support while Income Support is in payment (to 2025-26) + and no reform neutralises it, and false otherwise. +3. Under a reform that neutralises income_support, no Pension Credit, Child + Tax Credit or Child Benefit status depends on reported Income Support. + Under a reform that replaces income_support's formula with one returning + zero, no Pension Credit or Child Tax Credit status of a claimant or + partner depends on it; another member's report and the Child Benefit + reader still exclude, as their documentation says, because only setting + gov.dwp.income_support.active to false, neutralising Income Support, or + setting the report to zero, removes a report. +4. Reported Income Support ends Universal Credit status for anyone while it + is in payment, in the baseline and under the reform that replaces its + formula. Under the reform that neutralises it, no Universal Credit status + depends on it. +5. Setting a young person's reported Income Support to zero never lowers + their household's Pension Credit. +""" + +from itertools import product +from unittest.mock import patch + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import YEAR as YEAR_PERIOD +from policyengine_uk.model_api import BenUnit, Variable +from policyengine_uk.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2025 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +INCOME_SUPPORT = "receives_income_support_in_own_right" +EXCLUDING = ("pension_credit", "child_benefit", "child_tax_credit", "universal_credit") +# Income Support paid its last awards on 31 March 2026. +IN_PAYMENT = {2016: True, 2019: True, 2024: True, 2025: True, 2026: False} +SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +class income_support(Variable): + """A reform's Income Support formula that pays nothing.""" + + value_type = float + entity = BenUnit + label = "Income Support" + definition_period = YEAR_PERIOD + + def formula(benunit, period, parameters): + return benunit.empty_array() + + +def apply(sim, reform): + if reform == "zero formula": + sim.tax_benefit_system.update_variable(income_support) + elif reform == "neutralised": + sim.tax_benefit_system.neutralize_variable("income_support") + return sim + + +def simulate(households, year=YEAR, reform=None): + """households: a list of households, each a list of benefit units, each a + list of people's inputs. Every person gives the same input names.""" + situation = {"people": {}, "benunits": {}, "households": {}} + people = [] + for h, units in enumerate(households): + household_members = [] + for b, unit in enumerate(units): + names = [] + for p, inputs in enumerate(unit): + name = f"p{h}_{b}_{p}" + situation["people"][name] = {} + people.append(inputs) + names.append(name) + situation["benunits"][f"b{h}_{b}"] = {"members": names} + household_members += names + situation["households"][f"h{h}"] = {"members": household_members} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + # No family claims Universal Credit, so legacy awards, Child Tax Credit + # included, continue. + benunits = len(situation["benunits"]) + sim.set_input("would_claim_uc", year, np.zeros(benunits, dtype=bool)) + sim.input_variables = list(variables) + ["would_claim_uc"] + sim.reset_calculations() + return apply(sim, reform) + + +def status(sim, programme, year=YEAR): + return sim.calculate(f"is_qualifying_young_person_for_{programme}", year) + + +def member(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "receives_benefits_in_own_right": False, + "age_started_or_accepted_current_education_or_training": 17, + "is_claimant_or_partner": False, + "income_support_reported": 0, + "state_pension": 0, + "pip_dl_category": "NONE", + "care_hours": 0, + "would_claim_carers_allowance": False, + "child_tax_credit_reported": 0, + **inputs, + } + + +# --- Invariants 1 and 2 --- + +BASE = [ + dict( + current_education=education, + is_in_approved_training=training, + receives_benefits_in_own_right=own_right, + **{SIXTEEN: sixteen}, + ) + for education, training, own_right, sixteen in product( + EDUCATIONS, (False, True), (False, True), (False, True) + ) +] +GRID = [dict(row, age=age) for age, row in product(range(14, 22), BASE)] + + +def as_members(rows, **inputs): + # Each young person sits in the benefit unit of a 70-year-old claimant, + # so they are not the claimant or partner. + claimant = member(70, is_claimant_or_partner=True, **{SIXTEEN: False}) + return [[[claimant, member(**{SIXTEEN: False, **row, **inputs})]] for row in rows] + + +def test_income_support_ends_all_four_statuses(): + untouched = simulate(as_members(GRID)) + reporting = simulate(as_members(GRID, income_support_reported=3_000)) + neutralised = simulate( + as_members(GRID, income_support_reported=3_000), reform="neutralised" + ) + for programme in EXCLUDING: + np.testing.assert_array_equal( + status(neutralised, programme)[1::2], + status(untouched, programme)[1::2], + err_msg=programme, + ) + for programme in EXCLUDING: + before = status(untouched, programme)[1::2] + # Not vacuous: there is a status to lose. + assert before.any(), programme + assert not status(reporting, programme)[1::2].any(), programme + np.testing.assert_array_equal( + status(untouched, "pension_credit")[1::2], + status(untouched, "universal_credit")[1::2], + ) + + +@SETTINGS +@given( + st.lists( + st.tuples(st.integers(0, 60), st.sampled_from([0, 0, 1, 2_500, 9_000])), + min_size=1, + max_size=40, + ), + st.sampled_from([2021, 2024, 2025, 2026, 2028]), +) +def test_default_follows_reported_income_support_while_it_is_paid(rows, year): + # Invariant 2. + households = [ + [[member(age, income_support_reported=amount)]] for age, amount in rows + ] + sim = simulate(households, year) + expected = [year <= 2025 and amount > 0 for _, amount in rows] + np.testing.assert_array_equal(sim.calculate(INCOME_SUPPORT, year), expected) + neutralised = simulate(households, year, reform="neutralised") + assert not neutralised.calculate(INCOME_SUPPORT, year).any() + + +# --- Invariants 3 to 5, on generated households --- + + +@st.composite +def household(draw): + young = member( + draw(st.integers(15, 20)), + current_education=draw(st.sampled_from(EDUCATIONS)), + is_in_approved_training=draw(st.booleans()), + income_support_reported=draw(st.sampled_from([0, 3_000])), + care_hours=draw(st.sampled_from([0, 35])), + ) + shape = draw(st.sampled_from(["pensioner's unit", "own unit", "parent's unit"])) + if shape == "parent's unit": + # A Child Tax Credit claimant and their young person. + parent = member( + draw(st.integers(35, 55)), + is_claimant_or_partner=True, + child_tax_credit_reported=1, + ) + return [[parent, young]] + pensioner = member( + draw(st.integers(66, 90)), + is_claimant_or_partner=True, + state_pension=draw(st.sampled_from([0, 5_000, 9_000])), + pip_dl_category=draw(st.sampled_from(["NONE", "STANDARD", "ENHANCED"])), + ) + if shape == "own unit": + # The young person claims for their own benefit unit, so the model + # calculates their Income Support. + return [[pensioner], [dict(young, is_claimant_or_partner=True)]] + return [[pensioner, young]] + + +def without_reports(households): + return [ + [[dict(p, income_support_reported=0) for p in unit] for unit in units] + for units in households + ] + + +def flat(households, key): + return np.array([p[key] for units in households for unit in units for p in unit]) + + +GENERATED = dict( + households=st.lists(household(), min_size=1, max_size=8), + year=st.sampled_from([2016, 2019, 2024]), +) + + +@SETTINGS +@given(**GENERATED, reform=st.sampled_from(["zero formula", "neutralised"])) +def test_reforms_that_remove_income_support_end_the_exclusions( + households, year, reform +): + # Invariants 3 and 4. + reformed = simulate(households, year, reform) + unreported = simulate(without_reports(households), year, reform) + reported = flat(households, "income_support_reported") > 0 + claimant = flat(households, "is_claimant_or_partner") + # A report counts where it is the reader's evidence: for Child Benefit + # always, and for Pension Credit and Child Tax Credit for anyone who is + # not the claimant or partner; and only until a reform neutralises it. + report_stands = reported & IN_PAYMENT[year] & (reform != "neutralised") + for programme in ("pension_credit", "child_tax_credit"): + np.testing.assert_array_equal( + status(reformed, programme, year), + status(unreported, programme, year) & ~(report_stands & ~claimant), + err_msg=programme, + ) + np.testing.assert_array_equal( + status(reformed, "child_benefit", year), + status(unreported, "child_benefit", year) & ~report_stands, + ) + # SI 2014/1230 reg. 28 reads the report for anyone, and only neutralising + # Income Support removes it. + np.testing.assert_array_equal( + status(reformed, "universal_credit", year), + status(unreported, "universal_credit", year) & ~report_stands, + ) + assert not reformed.calculate("income_support", year).any() + + +@SETTINGS +@given(**GENERATED) +def test_universal_credit_and_pension_credit_without_reported_income_support( + households, year +): + # Invariants 4 and 5 in the baseline. Every generated year pays Income + # Support. + reported = simulate(households, year) + unreported = simulate(without_reports(households), year) + np.testing.assert_array_equal( + status(reported, "universal_credit", year), + status(unreported, "universal_credit", year) + & ~(flat(households, "income_support_reported") > 0), + ) + gained = unreported.calculate("pension_credit", year, map_to="household") + kept = reported.calculate("pension_credit", year, map_to="household") + assert (gained >= kept - 0.01).all(), (gained, kept) + + +# --- The review's household: a pensioner and a student-carer --- + + +def pensioner_and_student_carer(): + # 2019: a pensioner aged 70 on PIP daily living with £5,000 of State + # Pension, and an unrelated 18-year-old in their own benefit unit: an + # orphan in full-time non-advanced education with an existing Income + # Support claim (IS Regs 1987 reg. 13(2)(c)), caring for the pensioner 35 + # hours a week without Carer's Allowance. + pensioner = member( + 70, + is_claimant_or_partner=True, + state_pension=5_000, + pip_dl_category="STANDARD", + ) + student = member( + 18, + is_claimant_or_partner=True, + current_education="UPPER_SECONDARY", + income_support_reported=3_000, + care_hours=35, + ) + return [[[pensioner], [student]]] + + +def test_a_reform_paying_no_income_support_restores_the_severe_disability_addition(): + # With the student's Income Support in payment, reg. 4A(5) excludes them, + # the pensioner does not live alone, and Pension Credit is the standard + # minimum guarantee 167.25 x 52 = 8,697 less the State Pension: 3,697. + # A reform that pays no Income Support leaves the student a qualifying + # young person, disregarded under Schedule I para. 2(2)(f), so the + # pensioner gets the severe disability addition 65.85 x 52 = 3,424.20 as + # well: 7,121.20. Household net income: State Pension 5,000 + PIP 58.70 x + # 52 = 3,052.40 + Pension Credit 7,121.20 + winter fuel payment 200, less + # TV licence 154.50: 15,219.10, before the cost-of-living payments. + year = 2019 + for reform in ("zero formula", "neutralised"): + sim = simulate(pensioner_and_student_carer(), year, reform) + np.testing.assert_array_equal(status(sim, "pension_credit", year), [0, 1]) + np.testing.assert_allclose( + sim.calculate("severe_disability_minimum_guarantee_addition", year), + [3_424.20, 0], + atol=0.01, + ) + np.testing.assert_allclose( + sim.calculate("pension_credit", year), [7_121.20, 0], atol=0.01 + ) + np.testing.assert_allclose( + sim.calculate("household_net_income", year) + - sim.calculate("cost_of_living_support_payment", year), + [15_219.10], + atol=0.01, + ) + # Child Benefit reads the report: of these two reforms, only neutralising + # Income Support ends its exclusion. + zero = simulate(pensioner_and_student_carer(), year, "zero formula") + assert zero.calculate(INCOME_SUPPORT, year)[1] + assert not status(zero, "child_benefit", year)[1] + neutralised = simulate(pensioner_and_student_carer(), year, "neutralised") + assert status(neutralised, "child_benefit", year)[1] diff --git a/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py new file mode 100644 index 0000000000..7a74728df3 --- /dev/null +++ b/policyengine_uk/tests/test_qualifying_young_person_terminal_date_properties.py @@ -0,0 +1,441 @@ +"""The 1 September following the 19th birthday, for Universal Credit and +Pension Credit qualifying young persons. + +A young person enrolled on, or accepted for, non-advanced education or +approved training is a qualifying young person "up to, but not including, +the 1st September following their 19th birthday" (UC Regs 2013 reg. 5(1)(b); +SPC Regs 2002 reg. 4A(1)(b)). + +Invariants: + +1. is_before_first_september_following_19th_birthday is true exactly when + the year starts (6 April) before that 1 September, for every date of + birth, against calendar arithmetic that shares no code with the model. +2. Universal Credit's own terminal-date variable follows that fact unless it + is set, and setting it changes Universal Credit only. +3. Over every combination of age, education, training, apprenticeship, + own-right Universal Credit, ESA or JSA, entry age and the two date facts, + for people not on Income Support: Universal Credit and Pension Credit + agree, and both equal the regulation's conditions. SPC reg. 4A(5) lists + Income Support and SI 2014/1230 reg. 28 excludes a person entitled to it, + so a young person on their own Income Support who otherwise qualifies + loses both statuses, on either route. +4. With the 19th-birthday fact on, both statuses are the earlier definition + without a terminal date, so nothing changes below 19 or for a 19-year-old + before the date; switching the fact on never removes the status; and it + matters only for a 19-year-old in education or training. +5. The formula gives true for everyone under 20 on 6 October, whether the + date of birth or only the age is given, and true is each terminal-date + variable's default. So a situation that sets one of them for some people + leaves everyone else's status as it would be with nothing set. That holds + for one variable at a time: a programme variable set for some people gives + everyone else its default, not their date fact, so a situation that also + sets the date fact must set the programme variable for every person whose + status matters (the crossed test). +""" + +from datetime import date, timedelta +from itertools import product +from unittest.mock import patch + +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.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +YEAR = 2026 +_PROPERTY_SYSTEM = system.clone() +EDUCATIONS = tuple(item.name for item in EducationType) +SIXTEEN = "is_before_first_september_following_16th_birthday" +NINETEEN = "is_before_first_september_following_19th_birthday" +UC_TERMINAL = "is_before_universal_credit_qualifying_young_person_terminal_date" +UC = "is_qualifying_young_person_for_universal_credit" +PC = "is_qualifying_young_person_for_pension_credit" +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def person(age, **inputs): + return { + "age": age, + "current_education": "NOT_IN_EDUCATION", + "is_in_approved_training": False, + "is_apprentice": False, + "receives_benefits_in_own_right": False, + "age_started_or_accepted_current_education_or_training": 17, + **inputs, + } + + +def simulate(people, year=YEAR): + """Each person alone in a benefit unit: the status tests are personal.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i in range(len(people)): + situation["people"][f"p{i}"] = {} + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + variables = people[0].keys() + assert all(p.keys() == variables for p in people) + for variable in variables: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.input_variables = list(variables) + sim.reset_calculations() + return sim + + +def first_september_following_19th_birthday(born): + # A 19th birthday before 1 September is followed by 1 September of the + # same year, and one on or after it by the next year's. A 29 February + # birthday is before 1 September whichever day it is kept on. + # https://www.legislation.gov.uk/uksi/2013/376/regulation/5 + # https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A + return date(born.year + 19 + ((born.month, born.day) >= (9, 1)), 9, 1) + + +def age_on_6_october(born, year): + return year - born.year - ((10, 6) < (born.month, born.day)) + + +def assert_fact_matches_calendar(births, year): + people = [ + person(age_on_6_october(born, year), date_of_birth=int(f"{born:%Y%m%d}")) + for born in births + ] + expected = [ + date(year, 4, 6) < first_september_following_19th_birthday(born) + for born in births + ] + sim = simulate(people, year) + # Invariant 1. + np.testing.assert_array_equal(sim.calculate(NINETEEN, year), expected) + # Invariant 2: Universal Credit's variable follows it when not set. + np.testing.assert_array_equal(sim.calculate(UC_TERMINAL, year), expected) + return expected + + +def test_fact_for_every_date_of_birth(): + # Every date of birth from 2004 to 2009: ages 16 to 22 on 6 October 2026. + start, end = date(2004, 1, 1), date(2010, 1, 1) + births = [start + timedelta(days=d) for d in range((end - start).days)] + expected = dict(zip(births, assert_fact_matches_calendar(births, YEAR))) + # 19 on 31 August 2026: the period ends that day, in the year. + assert expected[date(2007, 8, 31)] + # 19 on 31 August 2025: it ended before the year. + assert not expected[date(2006, 8, 31)] + # 19 on 1 September 2025: it runs to 31 August 2026. + assert expected[date(2006, 9, 1)] + # Everyone aged 19 on 6 October 2026 is before the date. + assert all( + before + for born, before in expected.items() + if age_on_6_october(born, YEAR) == 19 + ) + + +@SETTINGS +@given( + st.lists(st.dates(date(1990, 1, 1), date(2031, 12, 31)), min_size=1, max_size=30), + st.integers(2013, 2031), +) +def test_fact_matches_calendar_arithmetic_in_any_year(births, year): + births = [born for born in births if age_on_6_october(born, year) >= 0] + assume(births) + assert_fact_matches_calendar(births, year) + + +def studying(p): + # Education status stands in for a non-advanced course of more than 12 + # hours a week; reg. 5(3) and reg. 4A(3) exclude a contract of employment. + return ( + p["current_education"] not in {"NOT_IN_EDUCATION", "TERTIARY"} + and not p["is_apprentice"] + ) or p["is_in_approved_training"] + + +def qualifying_young_person(p, terminal_date=True): + # Reg. 5(1), (2), (5); reg. 4A(1), (2), (5). + if not 16 <= p["age"] < 20 or p["receives_benefits_in_own_right"]: + return False + if p[SIXTEEN]: + return True + if not studying(p): + return False + if ( + p["age"] >= 19 + and p["age_started_or_accepted_current_education_or_training"] >= 19 + ): + return False + return p["age"] < 19 or p[NINETEEN] or not terminal_date + + +GRID = [ + person( + age, + current_education=education, + is_in_approved_training=training, + is_apprentice=apprentice, + receives_benefits_in_own_right=own_right, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + for age, education, training, apprentice, own_right, entry_age, sixteen in product( + range(14, 22), + EDUCATIONS, + (False, True), + (False, True), + (False, True), + (17, 19, 1000), + (False, True), + ) + for nineteen in (False, True) +] + + +def test_universal_credit_and_pension_credit_agree_on_every_combination(): + sim = simulate(GRID) + # The population invariant 3 covers: Income Support stops paying before + # 2026-27 (gov.dwp.income_support.active), so nobody here is on it. + assert not sim.calculate("is_on_income_support", YEAR).any() + uc, pc = sim.calculate(UC, YEAR), sim.calculate(PC, YEAR) + expected = np.array([qualifying_young_person(p) for p in GRID]) + # Invariant 3. + np.testing.assert_array_equal(uc, expected) + np.testing.assert_array_equal(pc, expected) + # Invariant 4. Rows are adjacent pairs: the 19th-birthday fact off, on. + off, on = expected[0::2], expected[1::2] + assert (on >= off).all() + np.testing.assert_array_equal( + on, [qualifying_young_person(p, terminal_date=False) for p in GRID[1::2]] + ) + changed = on != off + assert changed.any() + assert all( + p["age"] == 19 and studying(p) and not p[SIXTEEN] + for p, c in zip(GRID[1::2], changed) + if c + ) + + +def test_own_income_support_ends_both_statuses(): + # Invariant 3 for Income Support, in 2025-26, while it pays. A + # pensioner aged 70 with one member aged 16 to 19, over education, entry + # age, both date facts and the member's own reported Income Support. + year = 2025 + rows = list( + product( + range(16, 20), + ("NOT_IN_EDUCATION", "UPPER_SECONDARY"), + (17, 19), + (False, True), + (False, True), + (0, 5_000), + ) + ) + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, education, entry_age, sixteen, nineteen, report) in enumerate(rows): + members = [f"pensioner{i}", f"young{i}"] + situation["people"][members[0]] = { + "age": {year: 70}, + "is_parent": {year: True}, + "is_benunit_head": {year: True}, + } + situation["people"][members[1]] = { + "age": {year: age}, + "is_benunit_head": {year: False}, + "current_education": {year: education}, + "age_started_or_accepted_current_education_or_training": {year: entry_age}, + SIXTEEN: {year: sixteen}, + NINETEEN: {year: nineteen}, + "income_support_reported": {year: report}, + } + situation["benunits"][f"b{i}"] = {"members": members} + situation["households"][f"h{i}"] = {"members": members} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + young = slice(1, None, 2) + own_income_support = np.array([row[-1] > 0 for row in rows]) + np.testing.assert_array_equal( + sim.calculate("is_on_income_support", year)[young], own_income_support + ) + expected = np.array( + [ + qualifying_young_person( + person( + age, + current_education=education, + age_started_or_accepted_current_education_or_training=entry_age, + **{SIXTEEN: sixteen, NINETEEN: nineteen}, + ) + ) + for age, education, entry_age, sixteen, nineteen, _ in rows + ] + ) + np.testing.assert_array_equal( + sim.calculate(UC, year)[young], expected & ~own_income_support + ) + np.testing.assert_array_equal( + sim.calculate(PC, year)[young], expected & ~own_income_support + ) + # Not vacuous: own Income Support removes both statuses from a 19-year-old + # before the date. + assert any( + e and o and row[0] == 19 and row[4] + for e, o, row in zip(expected, own_income_support, rows) + ) + + +def test_universal_credit_input_overrides_the_fact_for_universal_credit_only(): + # Invariant 2: a 19-year-old at school, with each programme's input set + # against the fact. + rows = list(product((False, True), repeat=2)) + people = [ + person( + 19, + current_education="UPPER_SECONDARY", + **{SIXTEEN: False, NINETEEN: nineteen, UC_TERMINAL: uc_terminal}, + ) + for nineteen, uc_terminal in rows + ] + sim = simulate(people) + np.testing.assert_array_equal(sim.calculate(UC, YEAR), [u for _, u in rows]) + np.testing.assert_array_equal(sim.calculate(PC, YEAR), [n for n, _ in rows]) + + +DEFAULTED = ( + NINETEEN, + UC_TERMINAL, + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", +) + + +def test_formula_is_true_under_20_and_is_each_default(): + # Invariant 5, from age and months alone: every age to 25 and every + # month since the last birthday, in three years. + rows = list(product(range(26), range(12))) + for year in (2022, 2026, 2030): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, (age, months) in enumerate(rows): + situation["people"][f"p{i}"] = { + "age": {year: age}, + "months_since_last_birthday": {year: months}, + } + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = {"members": [f"p{i}"]} + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + under_20 = np.array([age < 20 for age, _ in rows]) + for variable in DEFAULTED: + values = sim.calculate(variable, year) + assert values[under_20].all(), (variable, year) + assert sim.tax_benefit_system.variables[variable].default_value is True + # Not vacuous: the fact is false for some people of 20 or more. + assert not sim.calculate(NINETEEN, year)[~under_20].all() + + +def test_setting_one_person_leaves_the_others_status(): + # Invariant 5 in a household: two 19-year-olds at school, course begun + # at 17. Each terminal-date variable is set false for the first only; + # the second stays a qualifying young person for both programmes. + for variable in DEFAULTED: + people = { + "parent": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + sim = Simulation(situation=situation) + uc = sim.calculate(UC, YEAR) + pc = sim.calculate(PC, YEAR) + # The second student is a qualifying young person for both. + assert uc[2] and pc[2], variable + # The first loses the status the variable governs. + if variable == UC_TERMINAL: + assert not uc[1] and pc[1] + elif variable == NINETEEN: + assert not uc[1] and not pc[1] + else: + assert uc[1] and not pc[1] + + +def test_crossed_settings_need_the_programme_variable_for_everyone(): + # Invariant 5's qualification. Two 19-year-olds at school: the first has + # the date fact set false, the second the programme variable set false. + # Set for the second alone, the programme variable gives the first its + # default (true), so the first becomes eligible again for that + # programme; set for both, both are excluded, as the documentation says. + def household(programme_variable, both): + people = { + "pensioner": {"age": {YEAR: 70}, "is_claimant_or_partner": {YEAR: True}}, + **{ + name: { + "age": {YEAR: 19}, + "current_education": {YEAR: "UPPER_SECONDARY"}, + "age_started_or_accepted_current_education_or_training": {YEAR: 17}, + "is_claimant_or_partner": {YEAR: False}, + } + for name in ("first", "second") + }, + } + people["first"][NINETEEN] = {YEAR: False} + people["second"][programme_variable] = {YEAR: False} + if both: + people["first"][programme_variable] = {YEAR: False} + situation = { + "people": people, + "benunits": {"b": {"members": list(people)}}, + "households": {"h": {"members": list(people)}}, + } + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_PROPERTY_SYSTEM.clone, + ): + return Simulation(situation=situation) + + for variable, status in ( + (UC_TERMINAL, UC), + ( + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", + PC, + ), + ): + partial = household(variable, both=False).calculate(status, YEAR) + # The documented hazard: the first student takes the default. + assert partial[1] and not partial[2], variable + full = household(variable, both=True).calculate(status, YEAR) + assert not full[1] and not full[2], variable diff --git a/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py new file mode 100644 index 0000000000..661341fbcf --- /dev/null +++ b/policyengine_uk/tests/test_removed_marriage_allowance_eligible_bands_parameter.py @@ -0,0 +1,56 @@ +"""Saved policies naming the Marriage Allowance eligible bands get directions. + +The model applies the rate conditions of ITA 2007 s. 55B(2)(b) and (ba) to +income directly, so nothing reads +``gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands`` (#2350). +""" + +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.parameters import RemovedParameterNode +from policyengine_uk.utils.scenario import Scenario + +REMOVED = "gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands" +SITUATION = {"people": {"person": {"age": {2025: 40}}}} +# The parameter held a list of band names, as a saved policy would. +SAVED = {REMOVED: {"2025": ["NONE", "STARTER", "BASIC"]}} + + +@pytest.fixture(scope="module", autouse=True) +def reuse_country_model(): + with pytest.MonkeyPatch.context() as patch: + patch.setattr( + "policyengine_uk.simulation.CountryTaxBenefitSystem", system.clone + ) + yield + + +@pytest.fixture(scope="module") +def baseline(): + return Simulation(situation=SITUATION) + + +@pytest.mark.parametrize("entry_point", ["core_reform", "scenario_reform"]) +def test_removed_eligible_bands_give_directions(baseline, entry_point): + simulation = baseline.clone() + with pytest.raises(ValueError) as error: + if entry_point == "core_reform": + Reform.from_dict(SAVED)(simulation.tax_benefit_system) + else: + Scenario.from_reform(SAVED).apply(simulation) + message = str(error.value) + assert f"{REMOVED} has been removed" in message + assert "s. 55B(2)(b)" in message + assert "gov.hmrc.income_tax.rates.uk[1].threshold" in message + assert "gov.hmrc.income_tax.rates.scotland.rates[3].threshold" in message + + +def test_removed_eligible_bands_stay_discoverable(baseline): + marriage_allowance = baseline.tax_benefit_system.parameters.gov.hmrc.income_tax.allowances.marriage_allowance + alias = marriage_allowance.children["eligible_bands"] + assert isinstance(alias, RemovedParameterNode) + assert alias.metadata["removed"] is True + assert "s. 55B(2)(b)" in alias.description diff --git a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py index 2bf3482266..820a5d381f 100644 --- a/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py +++ b/policyengine_uk/tests/test_rent_of_sharers_boarders_and_lodgers_properties.py @@ -19,10 +19,16 @@ most one family per household counts the household's non-dependants for Universal Credit, it is liable for the household's rent, and only it has Universal Credit deductions; Housing Benefit deductions over the families - liable for the rent sum to the household's non-dependants' deductions; a + liable for the rent sum to the household's non-dependant deductions (one + per couple, the higher: HB Regs 2006 reg 74(3)) times the share of the + rent borne by families whose claimant or partner is not exempt (reg + 74(6)), and never exceed the sum of individual deductions, with a sharer + never a non-dependant, even where the rent is nil (reg 3(2)(d)); a boarder's or lodger's family has no Universal Credit or Housing Benefit deductions; no sharer, boarder or lodger is a Council Tax Reduction non-dependant. + (The generated families have no non-dependants within their own + benefit unit, which test_benefit_unit_non_dependant_properties covers.) 5. Category: the Universal Credit LHA category does not depend on the household input is_shared_accommodation; the Housing Benefit category is the shared rate for any family entitled to one bedroom that lacks @@ -294,12 +300,31 @@ def test_non_dependants(population): assert np.all(per_household(sim, uc) <= total + 0.01) # Housing Benefit: apportioned over the families liable for the rent. hb = calc(sim, "housing_benefit_non_dep_deductions") - hb_individual = ( - calc(sim, "household_benefits_individual_non_dep_deduction") * non_dependant + # A sharer's family is a joint occupier, never a non-dependant (HB Regs + # 2006 reg 3(2)(d)), even where the rent is nil and so the family is not + # benunit_is_rent_liable. + sharer = sim.map_result((roles == "sharer").astype(float), "benunit", "person") > 0 + hb_individual = calc(sim, "household_benefits_individual_non_dep_deduction") * ( + non_dependant & ~sharer ) hb_total = sim.map_result(hb_individual, "person", "household") assert hb_total.max() > 0 - assert np.allclose(per_household(sim, hb), hb_total, rtol=1e-5, atol=0.01) + # The council deducts once for each non-dependant couple, the higher amount + # (HB Regs 2006 reg 74(3)), and once for each other member. The pool is + # apportioned over the families liable for the rent, and none falls on a + # family whose claimant or partner is exempt (reg 74(6)). + families = sim.populations["benunit"] + couple = calc(sim, "is_claimant_or_partner").astype(bool) + family_amount = families.max(hb_individual * couple) + families.sum( + hb_individual * ~couple + ) + pool = per_household(sim, family_amount) + exempt = calc(sim, "housing_benefit_non_dep_deductions_claimant_exempt").astype( + bool + ) + borne = per_household(sim, calc(sim, "share_of_household_rent") * ~exempt) + assert np.allclose(per_household(sim, hb), pool * borne, rtol=1e-5, atol=0.01) + assert np.all(per_household(sim, hb) <= hb_total + 0.01) assert np.all(hb[payer] == 0) assert np.all(uc[payer] == 0) # Council Tax Reduction: sharers, boarders and lodgers are not diff --git a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py index 7197148782..f075c56cc0 100644 --- a/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py +++ b/policyengine_uk/tests/test_savings_credit_only_receipt_properties.py @@ -185,7 +185,13 @@ def family(ages, **overrides): # recipient; and a working-age family. KNOWN_FAMILIES = [ family([90], state_pension=12_480.0), - family([90], state_pension=12_480.0, savings=12_000.0, other_property=4_400.0), + family( + [90], + state_pension=12_480.0, + savings=12_000.0, + other_property=4_400.0, + shares=500.0, + ), family([90], state_pension=12_480.0, savings=12_000.0, rent=6_000.0), family([90], state_pension=12_480.0, would_claim_pc=False), family([70], state_pension=9_000.0), @@ -234,6 +240,7 @@ def situation(units, would_claim_pc=None): "council_tax": {YEAR: unit["council_tax"]}, "savings": {YEAR: unit["savings"]}, "other_residential_property_value": {YEAR: unit["other_property"]}, + "corporate_wealth": {YEAR: unit.get("shares", 0.0)}, } return {"people": people, "benunits": benunits, "households": households} @@ -394,11 +401,13 @@ def test_known_families_detect_main(): ) assert not np.any(fix[FLAG]) # Main keeps the Pension Credit assessment. The owner with capital is - # tested on 16,400 (savings 12,000 and property 4,400) against the - # 16,000 limit and gets no council tax reduction, where the scheme's - # own rules count the property less 10% for the expenses of sale, - # 12,000 + 3,960 = 15,960, and give 2,000. The tenant has no Housing - # Benefit tariff income, where + # tested on 16,410 (savings 12,000, property 4,400 less 10% for the + # expenses of sale, 3,960, and shares 500 less 10%, 450; SPC Regs 2002 + # reg. 19(a)) against the 16,000 limit and gets no council tax + # reduction, where the scheme's own household route counts the + # savings and the property less 10%, 12,000 + 3,960 = 15,960, leaves + # out shares (council tax reduction capital sources), and gives 2,000. + # The tenant has no Housing Benefit tariff income, where # Housing Benefit's own rules count 4 a week on savings of 12,000 # (208). Its Housing Benefit income is the same on both routes: with # no savings credit paid, the Pension Credit assessment is the State @@ -411,7 +420,7 @@ def test_known_families_detect_main(): "housing_benefit_tariff_income", ] capital = "council_tax_reduction_assessable_capital" - assert main[capital][OWNER_WITH_CAPITAL] == 16_400 + assert main[capital][OWNER_WITH_CAPITAL] == 16_410 assert fix[capital][OWNER_WITH_CAPITAL] == 15_960 reduction = "simulated_council_tax_reduction_benunit" assert main[reduction][OWNER_WITH_CAPITAL] == 0 @@ -421,7 +430,7 @@ def test_known_families_detect_main(): assert assessed_differently(fix, not_claiming, known) == [] -def single_pensioner(savings=0.0, other_property=0.0): +def single_pensioner(savings=0.0, other_property=0.0, shares=0.0): # Single, aged 90, State Pension 12,480 (240 a week), owning in Maidstone # with council tax of 2,000. return { @@ -441,6 +450,7 @@ def single_pensioner(savings=0.0, other_property=0.0): "council_tax": {YEAR: 2_000}, "savings": {YEAR: savings}, "other_residential_property_value": {YEAR: other_property}, + "corporate_wealth": {YEAR: shares}, } }, } @@ -501,24 +511,28 @@ def read(simulation, variable): def test_stopping_pension_credit_payments_ends_the_pension_credit_capital_test(): - # Savings of 12,000 and other property worth 4,400. The Pension Credit - # assessment of capital counts both (16,400), above the 16,000 limit, so - # the savings-credit-only award leaves no council tax reduction. Deemed - # income is 1 a week for each 500, or part of 500, over 10,000: 6,400 / - # 500 = 12.8, so 13 a week (SPC Regs 2002 reg 15(6)). Income is 240.00 + + # Savings of 12,000, other property worth 4,400 and shares worth 500. The + # Pension Credit assessment of capital counts all three, the property and + # the shares less 10% for the expenses of sale (SPC Regs 2002 reg. 19(a)): + # 12,000 + 3,960 + 450 = 16,410, above the 16,000 limit, so the + # savings-credit-only award leaves no council tax reduction. Deemed + # income is 1 a week for each 500, or part of 500, over 10,000: 6,410 / + # 500 = 12.82, so 13 a week (SPC Regs 2002 reg 15(6)). Income is 240.00 + # 13.00 = 253.00 a week, so the savings credit is 0.6 x (238.00 - 208.07) # - 0.4 x (253.00 - 238.00) = 17.958 - 6.00 = 11.958 a week (621.82). - data = single_pensioner(savings=12_000.0, other_property=4_400.0) + data = single_pensioner(savings=12_000.0, other_property=4_400.0, shares=500.0) unreformed = Simulation(situation=data) assert abs(unreformed.calculate("savings_credit", YEAR)[0] - 621.82) < 0.01 assert unreformed.calculate(FLAG, YEAR).tolist() == [True] capital = "council_tax_reduction_assessable_capital" - assert unreformed.calculate(capital, YEAR)[0] == 16_400 + assert unreformed.calculate(capital, YEAR)[0] == 16_410 assert unreformed.calculate("council_tax_reduction", YEAR)[0] == 0 # With Pension Credit not paid the scheme's own capital rule applies: the # savings and the property less 10% for the expenses of sale (SI # 2012/2885 Sch 1 para 32(a)), 12,000 + 3,960 = 15,960, within the limit. + # The household route leaves the shares out (council tax reduction + # capital sources). # Tariff income is 1 a week for each 500, or part of 500, over 10,000 # (Sch 1 para 37): 5,960 / 500 = 11.92, so 12 a week, 624 a year. Income # 12,480 + 624 = 13,104 is below the applicable amount of 256.00 a week diff --git a/policyengine_uk/tests/test_scenario_baseline_parameters.py b/policyengine_uk/tests/test_scenario_baseline_parameters.py index 1e272b0810..b637a3d285 100644 --- a/policyengine_uk/tests/test_scenario_baseline_parameters.py +++ b/policyengine_uk/tests/test_scenario_baseline_parameters.py @@ -51,8 +51,8 @@ def _vat_change(sim): 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). + # 22% raises VAT by a tenth, whatever the grossing factor (about 411 at + # the shipped factors). 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 diff --git a/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py b/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py new file mode 100644 index 0000000000..b03cfc4caf --- /dev/null +++ b/policyengine_uk/tests/test_scottish_carer_supplement_award_weeks_properties.py @@ -0,0 +1,454 @@ +"""The Scottish Carer Supplement for each award week in which Carer Support +Payment is payable. + +Regulation 14A of the Carer's Assistance (Carer Support Payment) (Scotland) +Regulations 2023 (SSI 2023/302): "An individual is entitled to Scottish Carer +Supplement for each award week in which Carer Support Payment is payable to +the individual." The Scottish Ministers set the weekly rates of the two +components in regulation 16(1) and (1A). The model holds annual amounts, so +scottish_carer_supplement counts the award weeks as + + min(52, max(carer_support_payment_pre_overlap, carer_support_payment) / rate) + +where carer_support_payment is positive, and pays the weekly supplement rate +for each of them. It measures the amount against 52 weekly rates as the engine +stores them, so a full year's entitlement counts exactly 52 weeks. + +Invariants, for single people aged 14 to 90 in each UK country, in each year +from 2024 to 2030: + +1. Bounds: the supplement lies between 0 and 52 weekly supplement rates, as + the engine stores that amount. +2. The supplement is positive exactly where carer_support_payment is + positive, in years whose supplement rate is positive. +3. Differential: the supplement equals the rule rebuilt here from the drawn + inputs, with the rates read from the parameters. +4. Monotonic: raising a supplied carer_support_payment never lowers the + supplement. +5. Whole weeks: a supplied payment of k weekly rates, for k from 1 to 52, + gives k weekly supplements, and a payment of 52 weekly rates or more + gives 52. +6. Full year where the model computes the payment and the amount before the + overlapping-benefit reduction: with neither carer_support_payment nor + carer_support_payment_pre_overlap supplied, the supplement equals 52 + weekly supplement rates, as the engine stores that amount, where + carer_support_payment is positive, and nothing elsewhere. +7. A reform that sets the weekly Carer Support Payment rate to zero leaves no + supplement and raises no floating-point error. + +Each example puts every drawn person in one simulation, with the same inputs +in each year, and checks every year. The reference rebuilds the entitlement +before the overlapping-benefit reduction as 52 weekly rates for a carer in +Scotland, from 2025, who reaches the minimum age, cares for the minimum hours +or reports Carer's Allowance, and would claim; the drawn people have no +earnings. A State Pension supplied directly overlaps in full. The engine +stores amounts in float32, so the reference rounds the amounts it compares to +float32. The differential and whole-week comparisons allow a tenth of a penny +for the rounding, which comes to under £0.0001 at these sizes; the other +invariants hold exactly. + +A last test checks gov.contrib.policyengine.disable_simulated_benefits, which +makes a Scottish carer's reported Carer's Allowance their +carer_support_payment and leaves the amount before the reduction to the model. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.scenario import Scenario + +CSP = "gov.social_security_scotland.carer_support_payment" +WEEKS = 52 +# carer_support_payment_pre_couple_limit pays Carer Support Payment from 2025. +CSP_FROM = 2025 +YEARS = [2024, 2025, 2026, 2027, 2028, 2029, 2030] +# The years with a positive supplement rate. +SUPPLEMENT_YEARS = [2026, 2027, 2028, 2029, 2030] +COUNTRIES = ["ENGLAND", "WALES", "NORTHERN_IRELAND", "SCOTLAND"] +TOLERANCE = 0.001 +# A State Pension equal to a year of Carer Support Payment in each year: under +# reg 16(2)(b), the payment given is then £0. +YEAR_OF_CSP = "a year of Carer Support Payment" +PROPERTY_SETTINGS = settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def money(high): + return st.floats(0, high, allow_nan=False, allow_infinity=False).map( + lambda x: round(x, 2) + ) + + +# Up to about 72 weeks at the 2030 rate, small amounts of a week or two, and +# none. +PAYMENT = st.one_of(st.just(0.0), money(200), money(7_000)) +# Below the 2025 to 2030 annual rates (4,331.60 to 5,018), above them, and +# equal to them. +STATE_PENSION = st.one_of( + st.just(0.0), money(4_300), money(12_000), st.just(YEAR_OF_CSP) +) + + +def parameters(year): + return system.parameters(year).gov.social_security_scotland.carer_support_payment + + +def stored(amount): + """The amount as the engine stores it.""" + return float(np.float32(amount)) + + +@st.composite +def batches(draw, supplied): + """Single people aged 14 to 90, each in their own household, about half in + Scotland. + + An input overrides its variable's formula for the whole population, so a + batch supplies carer_support_payment for everyone (supplied) or for no + one, and supplies carer_support_payment_pre_overlap for everyone or for no + one. + """ + give_pre_overlap = draw(st.sampled_from([False, False, True])) + people = [] + for _ in range(draw(st.integers(2, 16))): + person = { + "country": draw(st.sampled_from(["SCOTLAND"] * 3 + COUNTRIES[:3])), + "age": draw(st.integers(14, 90)), + "care_hours": draw(st.sampled_from([0, 20, 34, 35, 40, 60])), + "carers_allowance_reported": draw(st.sampled_from([0, 0, 0, 3_000])), + "would_claim_carers_allowance": draw(st.sampled_from([True, True, False])), + } + if supplied: + person["carer_support_payment"] = draw(PAYMENT) + else: + person["state_pension"] = draw(STATE_PENSION) + if give_pre_overlap: + person["carer_support_payment_pre_overlap"] = draw(PAYMENT) + people.append(person) + return people + + +def inputs(person, year): + """The person's inputs in the year, with the sentinels replaced.""" + values = {key: value for key, value in person.items() if key != "country"} + if values.get("state_pension") == YEAR_OF_CSP: + values["state_pension"] = WEEKS * parameters(year).rate + if "weeks_paid" in values: + weeks_paid = values.pop("weeks_paid") + values["carer_support_payment"] = weeks_paid * parameters(year).rate + return values + + +def build(people, years=YEARS, reform=None): + """One simulation holding every person, with inputs in each year.""" + situation = {"people": {}, "benunits": {}, "households": {}} + for i, person in enumerate(people): + by_year = {year: inputs(person, year) for year in years} + situation["people"][f"p{i}"] = { + name: {year: by_year[year][name] for year in years} + for name in by_year[years[0]] + } + situation["benunits"][f"b{i}"] = {"members": [f"p{i}"]} + situation["households"][f"h{i}"] = { + "members": [f"p{i}"], + "country": {year: person["country"] for year in years}, + } + return Simulation(situation=situation, reform=reform) + + +def calc(sim, variable, year): + return np.asarray(sim.calculate(variable, year), dtype=float) + + +def scheme(sim, year): + parameters = sim.tax_benefit_system.parameters(year) + return parameters.gov.social_security_scotland.carer_support_payment + + +def rates(sim, year): + csp = scheme(sim, year) + return float(csp.rate), float(csp.supplement) + + +def reference(sim, year, people): + """The supplement rebuilt from the drawn inputs and the parameters.""" + csp = scheme(sim, year) + rate, supplement = rates(sim, year) + expected = [] + for person in people: + values = inputs(person, year) + if "carer_support_payment_pre_overlap" in values: + pre_overlap = stored(values["carer_support_payment_pre_overlap"]) + else: + eligible = ( + person["country"] == "SCOTLAND" + and year >= CSP_FROM + and person["age"] >= csp.min_age + and ( + person["care_hours"] >= csp.min_hours + or person["carers_allowance_reported"] > 0 + ) + and person["would_claim_carers_allowance"] + ) + pre_overlap = stored(WEEKS * rate) if eligible else 0.0 + if "carer_support_payment" in values: + payable = stored(values["carer_support_payment"]) + else: + # Reg 16(2): the overlapping benefit reduces the payment, to £0 + # where the benefit equals or exceeds the payment. + payable = max(pre_overlap - stored(values["state_pension"]), 0.0) + weeks = min(WEEKS, max(pre_overlap, payable) / rate) if rate > 0 else 0.0 + expected.append(supplement * weeks if payable > 0 else 0.0) + return np.array(expected) + + +def check_bounds_and_payment(sim, year): + """Invariants 1 and 2.""" + supplement = calc(sim, "scottish_carer_supplement", year) + payable = calc(sim, "carer_support_payment", year) + rate, weekly_supplement = rates(sim, year) + assert (supplement >= 0).all(), year + assert (supplement <= np.float32(WEEKS * weekly_supplement)).all(), year + if weekly_supplement > 0 and rate > 0: + np.testing.assert_array_equal(supplement > 0, payable > 0, err_msg=str(year)) + else: + np.testing.assert_array_equal(supplement, 0, err_msg=str(year)) + return supplement + + +def carer(country="ENGLAND", **values): + """A 35-year-old with no caring inputs, who would claim.""" + return { + "country": country, + "age": 35, + "care_hours": 0, + "carers_allowance_reported": 0, + "would_claim_carers_allowance": True, + **values, + } + + +def england(payment): + return carer(carer_support_payment=payment) + + +def scottish_carer(payment, care_hours=40): + return carer("SCOTLAND", care_hours=care_hours, carer_support_payment=payment) + + +def pensioner(state_pension, country="SCOTLAND"): + return { + **carer(country, care_hours=35, state_pension=state_pension), + "age": 70, + } + + +@PROPERTY_SETTINGS +@given(people=batches(supplied=True)) +# 13 and 25 complete award weeks of the 2026 rate after a Sunday move to +# England (reg 42), one week, 60 weeks, a carer in Scotland with no caring +# inputs, and one the model finds eligible. +@example( + people=[ + england(1_123.85), + england(2_161.25), + england(86.45), + england(5_187), + scottish_carer(1_123.85, care_hours=0), + scottish_carer(1_123.85), + scottish_carer(0), + ] +) +# Both amounts supplied: a payment that State Pension reduced, none, and one +# week with no entitlement before the reduction. +@example( + people=[ + carer( + "SCOTLAND", + carer_support_payment_pre_overlap=4_495.40, + carer_support_payment=2_495.40, + ), + carer(carer_support_payment_pre_overlap=1_123.85, carer_support_payment=0), + carer(carer_support_payment_pre_overlap=0, carer_support_payment=86.45), + ] +) +def test_supplied_payment_bounds_and_reference(people): + sim = build(people) + for year in YEARS: + supplement = check_bounds_and_payment(sim, year) + # 3. Differential. + np.testing.assert_allclose( + supplement, + reference(sim, year, people), + atol=TOLERANCE, + err_msg=str(year), + ) + + +@st.composite +def raised_batches(draw): + people = draw(batches(supplied=True)) + raised = [ + {**person, "carer_support_payment": person["carer_support_payment"] + extra} + for person, extra in zip(people, [draw(PAYMENT) for _ in people]) + ] + return people, raised + + +@PROPERTY_SETTINGS +@given(batch=raised_batches()) +def test_supplement_is_monotonic_in_a_supplied_payment(batch): + people, raised = batch + # One simulation: the drawn people, then the same people paid more. + sim = build(people + raised) + for year in YEARS: + supplement = calc(sim, "scottish_carer_supplement", year) + low, high = supplement[: len(people)], supplement[len(people) :] + # 4. Exactly, since the formula rounds monotonically at each step. + assert (high >= low).all(), year + + +def test_whole_weeks(): + """5. Each payment is a number of the year's weekly rates.""" + weeks = list(range(1, WEEKS + 1)) + beyond = [52, 52.001, 53, 60, 104, 1_000] + # Carers in Scotland with no caring inputs have no entitlement before the + # overlapping-benefit reduction either. + people = [ + carer(COUNTRIES[i % len(COUNTRIES)], weeks_paid=k) + for i, k in enumerate(weeks + beyond) + ] + sim = build(people, SUPPLEMENT_YEARS) + for year in SUPPLEMENT_YEARS: + _, weekly_supplement = rates(sim, year) + expected = [k * weekly_supplement for k in weeks] + expected += [WEEKS * weekly_supplement] * len(beyond) + np.testing.assert_allclose( + calc(sim, "scottish_carer_supplement", year), + expected, + atol=TOLERANCE, + err_msg=str(year), + ) + + +@PROPERTY_SETTINGS +@given(people=batches(supplied=False)) +# State Pension that reduces the payment and leaves some, that takes it to +# £0, and that equals it (reg 16(2)(b)); a carer outside Scotland. +@example( + people=[ + pensioner(2_000), + pensioner(6_000), + pensioner(YEAR_OF_CSP), + pensioner(0), + pensioner(0, country="ENGLAND"), + ] +) +def test_computed_payment_bounds_and_reference(people): + sim = build(people) + for year in YEARS: + supplement = check_bounds_and_payment(sim, year) + # 3. Differential. + np.testing.assert_allclose( + supplement, + reference(sim, year, people), + atol=TOLERANCE, + err_msg=str(year), + ) + if "carer_support_payment_pre_overlap" in people[0]: + continue + # 6. 52 weekly supplements, as the engine stores them, wherever the + # model pays Carer Support Payment. + payable = calc(sim, "carer_support_payment", year) + _, weekly_supplement = rates(sim, year) + full_year = (payable > 0) * weekly_supplement * WEEKS + np.testing.assert_array_equal( + supplement, full_year.astype(np.float32), err_msg=str(year) + ) + + +@pytest.mark.parametrize("supplied", [True, False]) +def test_zero_rate_gives_no_supplement(supplied): + """7. The formula guards the division by the weekly rate.""" + zero_rate = {f"{CSP}.rate": {"2024-01-01.2100-12-31": 0}} + if supplied: + people = [england(1_123.85), scottish_carer(1_123.85), england(0)] + else: + people = [pensioner(0), pensioner(2_000), pensioner(0, country="ENGLAND")] + sim = build(people, SUPPLEMENT_YEARS, reform=zero_rate) + for year in SUPPLEMENT_YEARS: + assert rates(sim, year)[0] == 0 + # Calculate the inputs to the formula first, so that only the formula + # runs with floating-point errors raised. + calc(sim, "carer_support_payment", year) + calc(sim, "carer_support_payment_pre_overlap", year) + with np.errstate(all="raise"): + supplement = calc(sim, "scottish_carer_supplement", year) + np.testing.assert_array_equal(supplement, 0, err_msg=str(year)) + + +REFORM_ON = Scenario( + applied_before_data_load=True, + parameter_changes={"gov.contrib.policyengine.disable_simulated_benefits": True}, +) + + +def test_disable_simulated_benefits_pays_the_weeks_the_report_implies(): + """Two carers in Scotland, aged 40 and caring 40 hours a week, each report + £4,000 of Carer's Allowance in 2025. The reform makes each report their + carer_support_payment in every year, without uprating it. The model finds + the carer who would claim eligible, so they keep 52 weeks; it finds the + one who would not claim ineligible, so they get the weeks the report + implies at each year's weekly rate.""" + years = range(2025, 2028) + people = { + name: { + "age": {year: 40 for year in years}, + "care_hours": {year: 40 for year in years}, + "would_claim_carers_allowance": {year: claims for year in years}, + "carers_allowance_reported": {2025: 4_000}, + } + for name, claims in [("claims", True), ("would_not_claim", False)] + } + situation = { + "people": people, + "benunits": {f"{name}_unit": {"members": [name]} for name in people}, + "households": { + f"{name}_home": { + "members": [name], + "region": {year: "SCOTLAND" for year in years}, + } + for name in people + }, + } + sim = Simulation(situation=situation, scenario=REFORM_ON) + expected = { + # 52 x 86.45 = 4,495.40 before the reduction. 52 x 11.70 = 608.40; + # 4,000 / 86.45 = 46.27 weeks, x 11.70 = 541.35. + 2026: ([4_495.40, 0], [608.40, 4_000 / 86.45 * 11.70]), + # 52 x 89.50 = 4,654 before the reduction. 52 x 12.10 = 629.20; + # 4,000 / 89.50 = 44.69 weeks, x 12.10 = 540.78. + 2027: ([4_654, 0], [629.20, 4_000 / 89.50 * 12.10]), + } + for year, (pre_overlap, supplement) in expected.items(): + np.testing.assert_allclose(calc(sim, "carer_support_payment", year), 4_000) + np.testing.assert_allclose( + calc(sim, "carer_support_payment_pre_overlap", year), + pre_overlap, + atol=TOLERANCE, + err_msg=str(year), + ) + np.testing.assert_allclose( + calc(sim, "scottish_carer_supplement", year), + supplement, + atol=TOLERANCE, + err_msg=str(year), + ) diff --git a/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py b/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py index e91d6d3be2..c1c8cd1248 100644 --- a/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py +++ b/policyengine_uk/tests/test_sdlt_dated_schedule_properties.py @@ -1,7 +1,8 @@ """Property-based tests for the dated residential SDLT schedules (issue #2245). The reference below restates the legislation's own tables, for effective -dates from 15 March 2018: +dates from 4 December 2014, the date from which s55(1B) Table A has effect +(Stamp Duty Land Tax Act 2015 s1(3), s2(2)): - Finance Act 2003 s55(1B) Table A (main rates); Sch4ZA para 1(2) (higher rates for additional dwellings, the main rates plus 3 points, or 5 from @@ -9,6 +10,10 @@ from £40,000; Sch6ZA paras 1(3) and 4 (first-time buyers' relief: 0% to £300,000 and 5% above, on prices up to £500,000); Sch5 para 2(3) Table A (lease rent: 1% of net present value above £125,000). +- Finance Act 2016 s128(5): Sch4ZA's higher rates from 1 April 2016. Before + then a buyer of an additional dwelling paid the main rates at any price. +- Finance Act 2018 s41(8): Sch6ZA's relief from 22 November 2017. Before then + a first-time buyer paid the main rates. - SDLT (Temporary Relief) Act 2020 s1, from 8 July 2020 to 30 June 2021 (the end as amended by Finance Act 2021 s87(2)(a)): 0% to £500,000 on the main and lease rent tables, and no first-time buyers' relief (s1(5)). @@ -22,12 +27,12 @@ 1. Differential: the raw dated parameters give the reference tax for a non-first purchase, a first-time buyer, an additional dwelling and a - lease. -2. Annual sampling: each model year from 2018 to 2040 reads every residential - SDLT parameter at 30 April of that year. -3. Through stamp_duty_land_tax, in every model year from 2018 to 2040, each - buyer's tax equals the reference at 30 April, and in every model year - from 2015 to 2040 it never falls as the price or the lease rent rises. + lease, on any date and on each day a schedule changes and the day before. +2. Annual sampling: each model year from 2015 to 2040 reads every residential + SDLT parameter in force at 30 April of that year. +3. Through stamp_duty_land_tax, in every model year from 2015 to 2040, each + buyer's tax equals the reference at 30 April and never falls as the price + or the lease rent rises. 4. At any effective date, no buyer's tax falls as the price or the lease rent rises. 5. Omitted relief: from 8 July 2020 to 30 June 2021, whatever the main and @@ -67,8 +72,11 @@ FIRST_TIME_BUYER = (300_000, 500_000) FIRST_TIME_BUYER_2022_25 = (425_000, 625_000) # First effective date, main rates, first-time buyers' relief (nil band, -# maximum price) or None, higher-rates surcharge, lease rent nil band. +# maximum price) or None, higher-rates surcharge or None, lease rent nil band. TIMELINE = ( + (date(2014, 12, 4), STANDARD, None, None, 125_000), + (date(2016, 4, 1), STANDARD, None, 0.03, 125_000), + (date(2017, 11, 22), STANDARD, FIRST_TIME_BUYER, 0.03, 125_000), (date(2018, 3, 15), STANDARD, FIRST_TIME_BUYER, 0.03, 125_000), (date(2020, 7, 8), NIL_500K, None, 0.03, 500_000), (date(2021, 7, 1), NIL_250K, FIRST_TIME_BUYER, 0.03, 250_000), @@ -103,7 +111,8 @@ def reference_tax(effective_date, price, buyer): nil_band, maximum = first_time_buyer if price <= maximum: return _marginal(((0, 0), (nil_band, 0.05)), price) - if buyer == "additional" and price >= HIGHER_RATES_MINIMUM: + higher_rates_apply = surcharge is not None + if buyer == "additional" and higher_rates_apply and price >= HIGHER_RATES_MINIMUM: higher = tuple((start, rate + surcharge) for start, rate in main) return _marginal(higher, price) return _marginal(main, price) @@ -116,17 +125,20 @@ def reference_lease_tax(effective_date, net_present_value): def _model_tax(purchase, price, buyer): # The schedule sdlt_on_residential_property_transactions applies to each - # kind of purchase. - if buyer == "first": + # kind of purchase. Before a relief or surcharge starts, its schedule + # holds no brackets on the raw dated parameters, and the formula reads + # the main rates instead. + main_rates_tax = purchase.main.subsequent.calc(price) + if buyer == "first" and purchase.main.first.max > 0: return np.where( price <= purchase.main.first.max, purchase.main.first.rate.calc(price), - purchase.main.subsequent.calc(price), + main_rates_tax, ) - if buyer == "additional": + if buyer == "additional" and purchase.additional.higher_rates_apply: chargeable = np.where(price < purchase.additional.min, 0, price) return purchase.additional.rate.calc(chargeable) - return purchase.main.subsequent.calc(price) + return main_rates_tax RAW = ParameterNode( @@ -166,6 +178,46 @@ def test_dated_parameters_match_the_statutory_tables(effective_date, price_list) ) +CHANGE_DAYS = sorted( + {d + timedelta(days=offset) for d in CHANGE_DATES for offset in (-1, 0)} +) +BOUNDARY_PRICES = sorted( + {0.0, 0.01, 2_000_000.0} + | {float(b + offset) for b in PRICE_BOUNDARIES for offset in (-1, 0, 1)} +) + + +def _check_statutory_tables(effective_date): + residential = RAW(effective_date.isoformat()) + price = np.array(BOUNDARY_PRICES) + for buyer in BUYERS: + expected = [reference_tax(effective_date, p, buyer) for p in BOUNDARY_PRICES] + np.testing.assert_allclose( + _model_tax(residential.purchase, price, buyer), + expected, + rtol=1e-12, + atol=1e-6, + err_msg=f"{buyer} on {effective_date}", + ) + expected = [reference_lease_tax(effective_date, v) for v in BOUNDARY_PRICES] + np.testing.assert_allclose( + residential.rent.calc(price), expected, rtol=1e-12, atol=1e-6 + ) + + +@pytest.mark.parametrize("effective_date", CHANGE_DAYS, ids=str) +def test_each_change_day_and_its_eve_match_the_statutory_tables(effective_date): + _check_statutory_tables(effective_date) + + +def test_table_a_starts_on_4_december_2014(): + # Stamp Duty Land Tax Act 2015 s2(2): s55(1B) Table A has effect from 4 + # December 2014. The day before, the main rates hold other bands. + _check_statutory_tables(FIRST_DATE) + eve = RAW((FIRST_DATE - timedelta(days=1)).isoformat()).purchase.main.subsequent + assert tuple(zip(eve.thresholds, eve.rates)) != STANDARD + + @PROPERTY_SETTINGS @given(effective_date=dates, value_list=st.lists(prices, min_size=1, max_size=8)) def test_dated_lease_rent_matches_the_statutory_table(effective_date, value_list): @@ -225,6 +277,7 @@ def test_omitted_relief_ignores_buyer_status_under_any_rates( _change_rates_over_initial_relief(main.subsequent, main_rates) _change_rates_over_initial_relief(main.first.rate, first_time_buyer_rates) purchase = residential(effective_date.isoformat()).purchase + assert purchase.main.first.max == 0 assert list(purchase.main.subsequent.rates) == main_rates assert list(purchase.main.first.rate.rates) == first_time_buyer_rates price = np.array(price_list, dtype=float) @@ -240,13 +293,19 @@ def _scale(scale): def _read(residential): + # Before a relief or surcharge starts, the model year reads backdated + # values that the formula never uses, so only schedules in force count. purchase = residential.purchase + first, additional = purchase.main.first, purchase.additional + relief = (_scale(first.rate),) if first.max > 0 else () + higher_rates = bool(additional.higher_rates_apply) + surcharge = (_scale(additional.rate), additional.min) if higher_rates else () return ( _scale(purchase.main.subsequent), - _scale(purchase.main.first.rate), - purchase.main.first.max, - _scale(purchase.additional.rate), - purchase.additional.min, + first.max, + relief, + higher_rates, + surcharge, _scale(residential.rent), ) diff --git a/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py new file mode 100644 index 0000000000..8717310ef5 --- /dev/null +++ b/policyengine_uk/tests/test_sdlt_statutory_commencement_properties.py @@ -0,0 +1,489 @@ +"""Property-based tests for the dates SDLT's 2016 and 2017 changes start on +(issue #2801). + +Parliament set these starts: + +- Finance Act 2018 s41(8): first-time buyers' relief (Sch 6ZA) from 22 + November 2017. +- Finance Act 2016 s128(5): the higher rates for additional dwellings (Sch + 4ZA) from 1 April 2016. Before then a buyer of an additional dwelling paid + the main rates in Finance Act 2003 s55(1B) Table A. +- Finance Act 2016 s127(12): the sliced non-residential rates and the 2% lease + rent band above £5,000,000 from 17 March 2016. Before then HMRC charged a + non-residential purchase "the percentage of the chargeable consideration for + the transaction determined in accordance with Table B" (s55(2) as at 30 + April 2015): 0% up to £150,000, 1% above £150,000 up to £250,000, 3% above + £250,000 up to £500,000 and 4% above £500,000. + +HMRC charged SDLT on land in Wales until 31 March 2018 (s48(1)(a) as at 30 +April 2017). Parliament ended SDLT in Wales from 1 April 2018 (Wales Act 2014 +s16(2), (4); SI 2018/214 art 2(a)), and the Welsh Revenue Authority charges +Land Transaction Tax from then (LTTADT(W)A 2017 s2; WSI 2018/34 art 3); the +Welsh Ministers set LTT's rates in WSI 2018/128. Each model year reads the +parameters in force on 30 April. + +Invariants, for any generated households or dates: + +1. In every model year from 2015 to 2030, a purchase in Wales is liable to + exactly one of SDLT and LTT, and the other tax is zero: SDLT up to model + year 2017, LTT from 2018. +2. In model year 2015, a non-residential purchase priced at or around a Table + B threshold pays the percentage for its band times the whole price. +3. No transaction tax is ever negative. +4. In model years 2018 to 2030, apart from 2021, 2023 and 2024, SDLT and LTT + on a grid of purchases equal the values `main` (2da212abc) gave, pinned in + tests/fixtures/sdlt_ltt_main_grid_2018_2030.csv. +5. On any date from 1 January 2015, the raw dated parameters hold the + statutory switches and tables: SDLT applies in Wales before 1 April 2018; + HMRC charges a non-residential purchase on the whole price at Table B's + rates before 17 March 2016 and on slices at 0%, 2% and 5% from then; the + non-residential lease rent table adds a 2% band above £5,000,000 from 17 + March 2016. +6. Before 1 April 2016 a buyer of an additional dwelling pays what a buyer of + a main home pays, at every price, on the raw dated parameters and in model + year 2015. +7. Before 22 November 2017 a first-time buyer pays what any other buyer pays, + at every price, on the raw dated parameters and in model years 2015 to + 2017. +8. In every model year from 2015 to 2030, SDLT on a non-residential purchase + never falls as the price rises, on the whole-price path and the sliced + path. +""" + +from datetime import date, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_core.parameters import ParameterNode + +import policyengine_uk +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=15, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +YEARS = list(range(2015, 2031)) +LTT_FIRST_YEAR = 2018 +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND", "NORTHERN_IRELAND"] +TAXES = [ + "stamp_duty_land_tax", + "sdlt_on_residential_property_transactions", + "sdlt_on_non_residential_property_transactions", + "sdlt_on_residential_property_rent", + "sdlt_on_non_residential_property_rent", + "land_transaction_tax", + "land_and_buildings_transaction_tax", +] +# Table B in s55(2) as at 30 April 2015: each band covers prices that exceed +# its threshold and do not exceed the next one. +TABLE_B = ((0, 0.0), (150_000, 0.01), (250_000, 0.03), (500_000, 0.04)) +TABLE_B_YEAR = 2015 +GRID_PATH = Path(__file__).parent / "fixtures" / "sdlt_ltt_main_grid_2018_2030.csv" + +purchase_strategy = st.fixed_dictionaries( + { + "main_residential_property_purchased": st.integers(0, 3_000_000), + "main_residential_property_purchased_is_first_home": st.booleans(), + "additional_residential_property_purchased": st.integers(0, 3_000_000), + "non_residential_property_purchased": st.integers(0, 3_000_000), + "rent": st.integers(0, 2_000_000), + "cumulative_residential_rent": st.integers(0, 2_000_000), + "non_residential_rent": st.integers(0, 8_000_000), + "cumulative_non_residential_rent": st.integers(0, 8_000_000), + } +) + + +def _situation(households, years): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, household in enumerate(households): + person = f"p{i}" + situation["people"][person] = {"age": {year: 40 for year in years}} + situation["benunits"][f"b{i}"] = {"members": [person]} + situation["households"][f"h{i}"] = { + "members": [person], + **{ + key: {year: value for year in years} for key, value in household.items() + }, + } + return situation + + +def _calculate(simulation, variable, year): + return np.array(simulation.calculate(variable, year), dtype=float) + + +@PROPERTY_SETTINGS +@given(st.lists(purchase_strategy, min_size=1, max_size=6)) +@example( + [ + { + "main_residential_property_purchased": 300_000, + "main_residential_property_purchased_is_first_home": False, + "additional_residential_property_purchased": 300_000, + "non_residential_property_purchased": 1_000_000, + "rent": 0, + "cumulative_residential_rent": 0, + "non_residential_rent": 0, + "cumulative_non_residential_rent": 0, + } + ] +) +def test_wales_pays_exactly_one_of_sdlt_and_ltt(purchases): + households = [{"region": "WALES", **purchase} for purchase in purchases] + simulation = Simulation(situation=_situation(households, YEARS)) + for year in YEARS: + sdlt_liable = np.array(simulation.calculate("sdlt_liable", year), dtype=bool) + ltt_liable = np.array(simulation.calculate("ltt_liable", year), dtype=bool) + assert np.all(sdlt_liable != ltt_liable), year + assert np.all(ltt_liable == (year >= LTT_FIRST_YEAR)), year + sdlt = _calculate(simulation, "stamp_duty_land_tax", year) + ltt = _calculate(simulation, "land_transaction_tax", year) + np.testing.assert_array_equal(sdlt[~sdlt_liable], 0, err_msg=str(year)) + np.testing.assert_array_equal(ltt[~ltt_liable], 0, err_msg=str(year)) + # A £1,000,000 purchase in any of these forms pays the liable tax. + large = np.array( + [ + max( + p["main_residential_property_purchased"], + p["additional_residential_property_purchased"], + p["non_residential_property_purchased"], + ) + >= 1_000_000 + for p in purchases + ] + ) + assert np.all(np.where(sdlt_liable, sdlt, ltt)[large] > 0), year + + +def _table_b_tax(price): + rate = [rate for threshold, rate in TABLE_B if price > threshold or threshold == 0] + return rate[-1] * price + + +threshold_prices = st.sampled_from( + [ + threshold + offset + for threshold, _ in TABLE_B + for offset in (-100, -1, -0.5, 0, 0.5, 1, 100) + if threshold + offset >= 0 + ] +) +table_b_prices = st.one_of(threshold_prices, st.integers(0, 3_000_000)) + + +@PROPERTY_SETTINGS +@given(st.lists(table_b_prices, min_size=1, max_size=40)) +@example([150_000, 150_000.5, 250_000, 250_000.5, 500_000, 500_000.5, 1_000_000]) +def test_table_b_charges_the_whole_price_in_2015(prices): + # Prices are whole pounds or halves, which float32 stores exactly. + households = [ + {"region": "LONDON", "non_residential_property_purchased": price} + for price in prices + ] + simulation = Simulation(situation=_situation(households, [TABLE_B_YEAR])) + tax = _calculate(simulation, "stamp_duty_land_tax", TABLE_B_YEAR) + expected = [_table_b_tax(price) for price in prices] + np.testing.assert_allclose(tax, expected, rtol=1e-6, atol=0.01) + + +def _whole_price_tax(table, price): + # The rate for the highest band whose threshold the price exceeds, on the + # whole price, or nothing when the price exceeds no threshold. + rates = [rate for threshold, rate in table if price > threshold] + return rates[-1] * price if rates else 0.0 + + +NON_RESIDENTIAL_FIRST_BAND = "gov.hmrc.stamp_duty.non_residential.purchase[0]" + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@example(10_000, 0.005, [0, 5_000, 9_999.5, 10_000, 10_000.5, 20_000, 150_000.5]) +@given( + st.integers(1, 149_999), + st.sampled_from([0.001, 0.005, 0.0075]), + st.lists( + st.one_of(threshold_prices, st.integers(0, 3_000_000)), min_size=1, max_size=20 + ), +) +def test_reformed_first_threshold_charges_the_whole_price_above_it( + threshold, rate, prices +): + # A reform gives Table B's first band a positive threshold and rate in + # model year 2015. A price that exceeds no threshold pays nothing, and a + # price above the first threshold pays its band's rate on the whole price. + table = ((threshold, rate),) + TABLE_B[1:] + prices = prices + [threshold, threshold + 0.5, threshold + 1] + households = [ + {"region": "LONDON", "non_residential_property_purchased": price} + for price in prices + ] + period = f"{TABLE_B_YEAR}-01-01.{TABLE_B_YEAR}-12-31" + reform = { + f"{NON_RESIDENTIAL_FIRST_BAND}.threshold": {period: threshold}, + f"{NON_RESIDENTIAL_FIRST_BAND}.rate": {period: rate}, + } + simulation = Simulation( + situation=_situation(households, [TABLE_B_YEAR]), reform=reform + ) + tax = _calculate(simulation, "stamp_duty_land_tax", TABLE_B_YEAR) + expected = [_whole_price_tax(table, price) for price in prices] + np.testing.assert_allclose(tax, expected, rtol=1e-6, atol=0.01, err_msg=str(table)) + + +@settings(PROPERTY_SETTINGS, max_examples=6) +@given( + st.lists( + st.tuples(st.sampled_from(REGIONS), purchase_strategy), + min_size=4, + max_size=12, + ) +) +def test_transaction_taxes_are_never_negative(households): + households = [{"region": region, **purchase} for region, purchase in households] + simulation = Simulation(situation=_situation(households, YEARS)) + for year in YEARS: + for tax in TAXES: + assert np.all(_calculate(simulation, tax, year) >= 0), (tax, year) + + +GRID_INPUTS = { + "main": ("main_residential_property_purchased",), + "first": ("main_residential_property_purchased",), + "additional": ("additional_residential_property_purchased",), + "non_residential": ("non_residential_property_purchased",), + "residential_lease": ("rent",), + "non_residential_lease": ("non_residential_rent",), + "non_residential_lease_above_5m": ("non_residential_rent",), +} +ZERO_INPUTS = ( + "main_residential_property_purchased", + "additional_residential_property_purchased", + "non_residential_property_purchased", + "rent", + "cumulative_residential_rent", + "non_residential_rent", + "cumulative_non_residential_rent", +) + + +def _grid_household(row): + household = {variable: 0 for variable in ZERO_INPUTS} + household["region"] = row.region + household["main_residential_property_purchased_is_first_home"] = row.kind == "first" + for variable in GRID_INPUTS[row.kind]: + household[variable] = row.price + if row.kind == "non_residential_lease_above_5m": + household["cumulative_non_residential_rent"] = 5_000_000 + return household + + +@pytest.fixture(scope="module") +def pinned_grid(): + grid = pd.read_csv(GRID_PATH) + years = sorted({int(column.split("_")[-1]) for column in grid.columns[3:]}) + households = [_grid_household(row) for row in grid.itertuples()] + return grid, years, Simulation(situation=_situation(households, years)) + + +def test_pinned_grid_leaves_out_2021_2023_and_2024(pinned_grid): + _, years, _ = pinned_grid + assert years == [y for y in range(2018, 2031) if y not in (2021, 2023, 2024)] + + +@pytest.mark.parametrize( + "year", [y for y in range(2018, 2031) if y not in (2021, 2023, 2024)] +) +def test_model_years_from_2018_match_main(pinned_grid, year): + grid, _, simulation = pinned_grid + for tax, column in ( + ("stamp_duty_land_tax", "sdlt"), + ("land_transaction_tax", "ltt"), + ): + np.testing.assert_allclose( + _calculate(simulation, tax, year), + grid[f"{column}_{year}"], + rtol=0, + atol=0.01, + err_msg=f"{tax} {year}", + ) + + +# Invariant 5: the raw dated switches and tables. + +RAW = ParameterNode( + "stamp_duty", + directory_path=str( + Path(policyengine_uk.__file__).parent / "parameters/gov/hmrc/stamp_duty" + ), +) +WALES_SDLT_ENDS = date(2018, 4, 1) # SI 2018/214 art 2(a) +SLICED_RATES_START = date(2016, 3, 17) # FA 2016 s127(12) +HIGHER_RATES_START = date(2016, 4, 1) # FA 2016 s128(5) +RELIEF_START = date(2017, 11, 22) # FA 2018 s41(8) +# FA 2003 s55(2) Table B as amended by FA 2016 s127, and Sch 5 para 2(3) +# Table B before and after it. +SLICED_RATES = ((0, 0.0), (150_000, 0.02), (250_000, 0.05)) +RENT_BEFORE = ((0, 0.0), (150_000, 0.01)) +RENT_FROM = ((0, 0.0), (150_000, 0.01), (5_000_000, 0.02)) +STATUTORY_DATES = (WALES_SDLT_ENDS, SLICED_RATES_START, HIGHER_RATES_START) +STATUTORY_DATES += (RELIEF_START,) +RAW_DAYS = sorted({d + timedelta(days=k) for d in STATUTORY_DATES for k in (-1, 0)}) +raw_dates = st.one_of( + st.dates(min_value=date(2015, 1, 1), max_value=date(2035, 12, 31)), + st.sampled_from(RAW_DAYS), +) + + +def _bands(scale): + return tuple(zip(map(float, scale.thresholds), map(float, scale.rates))) + + +def _check_raw_switches(effective_date): + stamp_duty = RAW(effective_date.isoformat()) + non_residential = stamp_duty.non_residential + before_sliced_rates = effective_date < SLICED_RATES_START + assert bool(stamp_duty.applies_in_wales) == (effective_date < WALES_SDLT_ENDS) + assert bool(non_residential.purchase_charged_on_whole_price) == before_sliced_rates + assert _bands(non_residential.purchase) == ( + TABLE_B if before_sliced_rates else SLICED_RATES + ) + assert _bands(non_residential.rent) == ( + RENT_BEFORE if before_sliced_rates else RENT_FROM + ) + purchase = stamp_duty.residential.purchase + assert bool(purchase.additional.higher_rates_apply) == ( + effective_date >= HIGHER_RATES_START + ) + # first.max holds 0 from 8 July 2020 to 30 June 2021, which + # test_sdlt_dated_schedule_properties.py checks in + # test_omitted_relief_ignores_buyer_status_under_any_rates. + if effective_date < date(2020, 7, 8): + assert (purchase.main.first.max > 0) == (effective_date >= RELIEF_START) + + +@pytest.mark.parametrize("effective_date", RAW_DAYS, ids=str) +def test_raw_switches_change_on_their_statutory_dates(effective_date): + _check_raw_switches(effective_date) + + +@settings(PROPERTY_SETTINGS, max_examples=60) +@given(raw_dates) +def test_raw_switches_hold_on_any_date(effective_date): + _check_raw_switches(effective_date) + + +# Invariants 6 and 7: before a surcharge or relief starts, the buyer pays the +# main rates. + +residential_prices = st.one_of( + st.integers(0, 300_000_000).map(lambda pence: pence / 100), + st.sampled_from( + [ + b + k + for b in (40_000, 125_000, 250_000, 300_000, 500_000, 925_000, 1_500_000) + for k in (-1, 0, 1) + ] + ), +) + + +def _residential_tax(purchase, price, buyer): + # The schedule sdlt_on_residential_property_transactions applies. + main_rates_tax = purchase.main.subsequent.calc(price) + if buyer == "first" and purchase.main.first.max > 0: + return np.where( + price <= purchase.main.first.max, + purchase.main.first.rate.calc(price), + main_rates_tax, + ) + if buyer == "additional" and purchase.additional.higher_rates_apply: + chargeable = np.where(price < purchase.additional.min, 0, price) + return purchase.additional.rate.calc(chargeable) + return main_rates_tax + + +@settings(PROPERTY_SETTINGS, max_examples=60) +@example(HIGHER_RATES_START - timedelta(days=1), [40_000, 300_000, 1_000_000]) +@example(RELIEF_START - timedelta(days=1), [200_000, 300_000, 500_000]) +@given( + st.dates(min_value=date(2014, 12, 4), max_value=RELIEF_START - timedelta(days=1)), + st.lists(residential_prices, min_size=1, max_size=8), +) +def test_raw_buyers_pay_the_main_rates_before_their_schedules_start( + effective_date, price_list +): + purchase = RAW(effective_date.isoformat()).residential.purchase + price = np.array(price_list, dtype=float) + main = _residential_tax(purchase, price, "main") + np.testing.assert_array_equal(_residential_tax(purchase, price, "first"), main) + if effective_date < HIGHER_RATES_START: + np.testing.assert_array_equal( + _residential_tax(purchase, price, "additional"), main + ) + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@example([0, 40_000, 125_000, 200_000, 300_000, 500_000, 1_000_000, 1_600_000]) +@given(st.lists(residential_prices, min_size=1, max_size=10)) +def test_model_years_before_the_schedules_charge_the_main_rates(price_list): + years = [2015, 2016, 2017] + households = [] + for price in price_list: + for buyer in ("main", "first", "additional"): + households.append( + { + "region": "LONDON", + "main_residential_property_purchased": ( + 0 if buyer == "additional" else price + ), + "main_residential_property_purchased_is_first_home": ( + buyer == "first" + ), + "additional_residential_property_purchased": ( + price if buyer == "additional" else 0 + ), + } + ) + simulation = Simulation(situation=_situation(households, years)) + for year in years: + tax = _calculate(simulation, "stamp_duty_land_tax", year).reshape(-1, 3) + main, first, additional = tax.T + np.testing.assert_array_equal(first, main, err_msg=str(year)) + if year == 2015: + np.testing.assert_array_equal(additional, main, err_msg=str(year)) + + +# Invariant 8: non-residential SDLT never falls as the price rises. + +non_residential_prices = st.one_of( + st.integers(0, 300_000_000).map(lambda pence: pence / 100), + st.sampled_from( + [b + k for b, _ in TABLE_B for k in (-1, -0.5, 0, 0.5, 1) if b + k >= 0] + ), +) + + +@settings(PROPERTY_SETTINGS, max_examples=10) +@example([0, 150_000, 150_000.5, 250_000, 250_000.5, 500_000, 500_000.5]) +@given(st.lists(non_residential_prices, min_size=2, max_size=20)) +def test_non_residential_tax_never_falls_as_the_price_rises(price_list): + prices = sorted(price_list) + households = [ + {"region": "LONDON", "non_residential_property_purchased": price} + for price in prices + ] + simulation = Simulation(situation=_situation(households, YEARS)) + for year in YEARS: + tax = _calculate( + simulation, "sdlt_on_non_residential_property_transactions", year + ) + assert np.all(np.diff(tax) >= -1e-6), (year, prices, tax) diff --git a/policyengine_uk/tests/test_state_pension_reported_weeks.py b/policyengine_uk/tests/test_state_pension_reported_weeks.py new file mode 100644 index 0000000000..ff770b5e2f --- /dev/null +++ b/policyengine_uk/tests/test_state_pension_reported_weeks.py @@ -0,0 +1,215 @@ +"""The State Pension a survey reports, read back at 52 weeks a year. + +basic_state_pension, new_state_pension and additional_state_pension take +state_pension_reported less any age addition +(state_pension_reported_excluding_age_addition), divide it by +policyengine-core's 52 weeks a year and split the weekly amount at the data +year's full rate for the type the person has in the simulated year. A dataset +must therefore annualise the State Pension at 52 weeks. At 365.25 / 7, which +policyengine-uk-data used until policyengine-uk-data#644, every full-rate +pensioner read 0.34% above the full rate and carried an add-on they do not +get, which put people whose only income is the full new State Pension on the +Pension Tax Allowance's taper, paying tax. + +These cases build simulations from 2024-25 data projected forward, as survey +data is (ages held fixed, at ages whose type stays the same in every year), and +check the route from a reported weekly amount to Income Tax: + +- a pensioner reporting the data year's full rate has no add-on in any + year. The new State Pension reporter is 70 and reports the full new rate. + The basic State Pension reporter is 85. With ages held fixed, the model + gives a woman aged 78 or over the basic type in every year to 2030-31. At + 85 a basic State Pension recipient is over 80, so DWP pays her the 25p a + week age addition with the full basic rate. She reports both, and the model + pays the full rate as basic State Pension and the 25p, never uprated, as the + age addition; +- Steph, whose only income is the full new State Pension, pays no Income Tax + in 2027-28 to 2029-30, as HM Treasury's explainer says, and the same as when + her State Pension is given directly; +- near the full new rate, no one reporting at most the full rate pays Income + Tax, and above it the tax never exceeds 8/15 of the add-on (20% on it plus a + third of it off the allowance). + +policyengine-core holds amounts as float32, so £11,502.40 reads back about a +thousandth of a penny above £221.20 a week (0.0012p, or 0.063p a year); +comparisons allow a penny. +""" + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Microsimulation, Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.system import system + +DATA_YEAR = 2024 +PTA_YEARS = [2027, 2028, 2029] +WEEKS_IN_YEAR = 52 +# Ages held through the projection: reached State Pension age in 2020 (new +# State Pension) and in 2005 (basic State Pension). +AGE = {"NEW": 70, "BASIC": 85} +PENNY = 0.01 + + +def survey_simulation(weekly, pension_types) -> Microsimulation: + """2024-25 data with one pensioner per household, reporting `weekly` a + week of State Pension annualised at 52 weeks, and nothing else.""" + ids = np.arange(len(weekly)) + person = pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": [AGE[t] for t in pension_types], + "gender": "FEMALE", + "state_pension_reported": np.asarray(weekly) * WEEKS_IN_YEAR, + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": np.ones(len(ids)), + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ) + return Microsimulation( + dataset=UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": ids}), + household=household, + fiscal_year=DATA_YEAR, + ) + ) + + +def calculate(sim, variable, year) -> np.ndarray: + # A Microsimulation returns a weighted series, a Simulation an array. + values = sim.calculate(variable, year) + return np.asarray(getattr(values, "values", values)) + + +def full_rates(year) -> dict: + p = system.parameters.gov.dwp.state_pension + return { + "NEW": p.new_state_pension.amount(year), + "BASIC": p.basic_state_pension.amount(year), + } + + +# The weekly age addition in the data year. DWP pays it to a basic State +# Pension recipient aged 80 or over and never uprates it. +AGE_ADDITION = system.parameters.gov.dwp.state_pension.age_addition.amount(DATA_YEAR) + + +@pytest.fixture(scope="module") +def full_rate_reporters(): + """A new State Pension reporter at the full new rate, and a basic State + Pension reporter, aged 85, at the full basic rate plus the age addition.""" + types = ["NEW", "BASIC"] + rates = full_rates(DATA_YEAR) + weekly = [rates["NEW"], rates["BASIC"] + AGE_ADDITION] + return survey_simulation(weekly, types), types + + +@pytest.mark.parametrize("year", range(DATA_YEAR, 2031)) +def test_full_rate_reporters_have_no_add_on_in_any_year(full_rate_reporters, year): + sim, types = full_rate_reporters + rates = full_rates(year) + + assert calculate(sim, "state_pension_type", year).astype(str).tolist() == types + assert np.all(calculate(sim, "additional_state_pension", year) < PENNY) + assert calculate(sim, "new_state_pension", year)[0] == pytest.approx( + WEEKS_IN_YEAR * rates["NEW"], abs=PENNY + ) + assert calculate(sim, "basic_state_pension", year)[1] == pytest.approx( + WEEKS_IN_YEAR * rates["BASIC"], abs=PENNY + ) + # The data year's 25p in every year, so not uprated, and none on the new + # State Pension. + assert calculate(sim, "state_pension_age_addition", year).tolist() == ( + pytest.approx([0, WEEKS_IN_YEAR * AGE_ADDITION], abs=PENNY) + ) + + +@pytest.mark.parametrize("year", PTA_YEARS) +def test_steph_pays_no_income_tax_from_survey_data_or_given_directly( + full_rate_reporters, year +): + """Steph's only income is the full new State Pension: £13,036.40 in + 2027-28, 52 weeks at £250.70. The Pension Tax Allowance removes all her + tax, whether her State Pension comes from a survey's weekly amount or is + given directly.""" + sim, _ = full_rate_reporters + threshold = WEEKS_IN_YEAR * full_rates(year)["NEW"] + personal_allowance = system.parameters( + f"{year}-04-30" + ).gov.hmrc.income_tax.allowances.personal_allowance.amount + tax_before_allowance = 0.2 * (threshold - personal_allowance) + + direct = Simulation( + situation={ + "people": { + "steph": { + "age": {year: AGE["NEW"]}, + "state_pension": {year: threshold}, + } + }, + "benunits": {"benunit": {"members": ["steph"]}}, + "households": { + "household": {"members": ["steph"], "region": {year: "LONDON"}} + }, + } + ) + + from_survey = { + v: calculate(sim, v, year)[0] + for v in ("state_pension", "income_tax", "pension_tax_allowance") + } + given_directly = {v: calculate(direct, v, year)[0] for v in from_survey} + + assert from_survey["state_pension"] == pytest.approx(threshold, abs=PENNY) + assert from_survey["income_tax"] == pytest.approx(0, abs=PENNY) + # Not vacuous: without the allowance she would pay basic-rate tax. + assert tax_before_allowance > 90 + assert from_survey["pension_tax_allowance"] == pytest.approx( + tax_before_allowance, abs=PENNY + ) + for v in from_survey: + assert from_survey[v] == pytest.approx(given_directly[v], abs=PENNY), v + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + offsets=st.lists( + st.one_of( + st.floats(min_value=-5, max_value=5, allow_nan=False), + st.sampled_from([0.0, -0.01, 0.01]), + ), + min_size=1, + max_size=40, + ) +) +def test_near_the_full_rate_only_the_add_on_is_taxed(offsets): + """New State Pension reporters within £5 a week of the full rate, with no + other income, in each year of the allowance.""" + offsets = np.asarray(offsets) + rates = full_rates(DATA_YEAR) + sim = survey_simulation(rates["NEW"] + offsets, ["NEW"] * len(offsets)) + at_or_below = offsets <= 0 + for year in PTA_YEARS: + add_on = calculate(sim, "additional_state_pension", year) + tax = calculate(sim, "income_tax", year) + assert np.all(add_on[at_or_below] < PENNY), year + assert np.all(tax[at_or_below] < PENNY), year + assert np.all(tax <= 8 / 15 * add_on + PENNY), year diff --git a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py index 807ee24603..8cd9c33b3c 100644 --- a/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py +++ b/policyengine_uk/tests/test_statutory_paternity_pay_income_tax_properties.py @@ -28,7 +28,7 @@ - ITA 2007 s. 12B cuts the Personal Savings Allowance from £1,000 to £500 once the recipient has higher-rate income, and to nil with additional-rate income, so one pound across the basic rate limit can - cost £100 of tax on savings. + cost £100 of tax. - ITEPA 2003 s. 681C(3)(b) rounds the charge's percentage down to a whole number, so one pound across a £200 step adds 1% of the year's Child Benefit at once. @@ -429,17 +429,23 @@ def rise(results, variable): def test_a_pound_of_statutory_paternity_pay_can_halve_the_savings_allowance(): - # Wages of £48,270 and £2,000 of savings interest give taxable income of - # 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the - # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound of - # statutory paternity pay gives higher-rate income and a £500 allowance. - # Non-savings income goes from £35,700 to £35,701 (20p more). Savings tax + # Wages of £48,270 and £2,000 of savings interest give taxable income + # of 50,270 - 12,570 = £37,700, exactly the basic rate limit, so the + # Personal Savings Allowance is £1,000 (ITA 2007 s. 12B). A pound + # of statutory paternity pay gives higher-rate income and a £500 + # allowance. Under the order non-savings, savings, dividends, savings tax # goes from 20% of 1,000 = £200 to 20% of 1,499 plus 40% of 1 = £300.20. + # Setting £1,500 of the Personal Allowance against savings instead leaves + # the last pound within the £500 savings allowance. Pay of 37,201 then + # bears 20%, so income tax rises from £7,340 to £7,440.20, not £7,440.40 + # (ITA 2007 s. 25(2)). That pound would bear 40% but for the savings nil + # rate, so it still counts as higher-rate income (s. 12B(8)(b)(ii)), and + # the allowance stays at £500. results = Results([single_earner(48_270.0, savings=2_000.0)]) assert results.recipient("savings_allowance", 0, LOW) == 1_000 assert results.recipient("savings_allowance", 0, HIGH) == 500 assert ( - abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.4) + abs(rise(results, "income_tax_before_winter_fuel_payment_charge") - 100.2) < 0.01 ) diff --git a/policyengine_uk/tests/test_stock_capital_periods.py b/policyengine_uk/tests/test_stock_capital_periods.py index 3ad2e0226f..eead7689d9 100644 --- a/policyengine_uk/tests/test_stock_capital_periods.py +++ b/policyengine_uk/tests/test_stock_capital_periods.py @@ -15,6 +15,7 @@ def test_uc_capital_stocks_are_not_prorated_in_monthly_calculations(): "members": ["person"], "savings": {"2026": 12_000}, "other_residential_property_value": {"2026": 12_000}, + "other_residential_property_secured_debt": {"2026": 5_000}, "corporate_wealth": {"2026": 12_000}, } }, @@ -23,7 +24,14 @@ def test_uc_capital_stocks_are_not_prorated_in_monthly_calculations(): assert simulation.calculate("savings", "2026-01")[0] == 12_000 assert simulation.calculate("corporate_wealth", "2026-01")[0] == 12_000 - assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 34_800 + assert ( + simulation.calculate("other_residential_property_secured_debt", "2026-01")[0] + == 5_000 + ) + # Savings at face value; property and shares less 10% for the expenses + # of sale, and the property less its mortgage (UC Regs 2013 reg. 49(1)): + # 12,000 + (10,800 - 5,000) + 10,800. + assert simulation.calculate("uc_assessable_capital", "2026-01")[0] == 28_600 assert not simulation.calculate("is_uc_eligible", "2026-01")[0] @@ -43,7 +51,9 @@ def test_pension_credit_capital_stocks_are_not_prorated_monthly(): simulation = Simulation(situation=situation) assert simulation.calculate("savings", "2026-01")[0] == 12_000 + # Land and shares less 10% for the expenses of sale (SPC Regs 2002 + # reg. 19(a)): 12,000 + 2 * 10,800. assert ( simulation.calculate("pension_credit_assessable_capital", "2026-01")[0] - == 36_000 + == 33_600 ) diff --git a/policyengine_uk/tests/test_stored_household_totals.py b/policyengine_uk/tests/test_stored_household_totals.py index 1088176bda..3b0f5f2099 100644 --- a/policyengine_uk/tests/test_stored_household_totals.py +++ b/policyengine_uk/tests/test_stored_household_totals.py @@ -216,10 +216,26 @@ def test_stored_household_totals_grow_past_the_projection_horizon(): assert totals[year][0] == pytest.approx( totals[PROJECTION_END_YEAR][0] * growth, rel=1e-6 ) - # Savings, the cash ISA and the stocks-and-shares ISA (through - # share_like_capital) make up the capital, each grown alike. + # Savings, the cash ISA and the stocks-and-shares ISA make up the capital. + # Savings and the cash ISA count at their value. The stocks-and-shares ISA + # reaches the capital through share_like_capital, which uc_assessable_capital + # values less the sale-expense rate (Universal Credit Regulations 2013 + # reg. 49(1)(a)). The parts grow alike, so capital keeps the same ratio to + # savings in every year: 5.8 for this household. + cash_isa = _values(simulation, "cash_isa") + shares_isa = _values(simulation, "stocks_and_shares_isa") + ratio = capital[DATA_YEAR][0] / savings[DATA_YEAR][0] for year in YEARS: - assert capital[year][0] == pytest.approx(6 * savings[year][0], rel=1e-5) + rate = simulation.tax_benefit_system.parameters( + period(year).start + ).gov.dwp.universal_credit.means_test.capital.sale_expenses.rate + expected = ( + savings[year][0] + cash_isa[year][0] + shares_isa[year][0] * (1 - rate) + ) + assert capital[year][0] == pytest.approx(expected, rel=1e-5), year + face_value = savings[year][0] + cash_isa[year][0] + shares_isa[year][0] + assert capital[year][0] < face_value - 0.01, year + assert capital[year][0] == pytest.approx(ratio * savings[year][0], rel=1e-5) @pytest.mark.parametrize( diff --git a/policyengine_uk/tests/test_stored_pence.py b/policyengine_uk/tests/test_stored_pence.py new file mode 100644 index 0000000000..13b57aeeb8 --- /dev/null +++ b/policyengine_uk/tests/test_stored_pence.py @@ -0,0 +1,324 @@ +"""stored_pence: an amount of money held in float32, in pence. + +Invariants: + +1. Whole pence round-trip: every whole number of pence below £131,072, + stored in float32, comes back as exactly that number (exhaustive). +2. Bounded: the result is never more than half a float32 step from the + stored amount, so it moves an amount only to the whole pence it is held + as. +3. Fractions kept: a stored amount more than half a step from every whole + penny comes back exactly, to be rounded as the rule requires. +4. Monotonic: a larger stored amount never gives fewer pence. + +summed: a float32 sum of float32 amounts, in pence. + +5. Rounding bounded: a float32 sum of up to eight float32 amounts of either + sign, nested in sums of sums and added one at a time as policyengine-core + adds them, lies within summed's tolerance of the exact sum of the stored + amounts, so summed returns that sum. +6. Sums recovered: such a sum of whole-pence amounts below £131,072 each + comes back as exactly the sum of their pence, including sums that + stored_pence alone would not recover. +7. Not a sum: a total further from the parts' sum than that rounding, as when + it is entered directly, comes back as stored_pence(total). For amounts + under £10,000 in all, any total a whole penny or more from their sum is. + +months_summed: a monthly flow summed over a year, in pence. + +8. A year's amount recovered: every whole number of pence below £32,768, + entered for a year and divided among its twelve months in float32 as + policyengine-core divides it, comes back as exactly that number + (exhaustive). Below £32,768 the months' rounding and the year's is under + half a penny, so no other whole penny lies within it. +9. Months recovered: twelve whole-pence monthly amounts below £131,072 in + all come back as exactly the sum of their pence. +10. Bounded: the result is the exact sum of the months as stored, or a whole + penny within a float32 step of each month and of the year of it. + +Zero amounts: + +11. Nothing underflows: stored_pence, summed and months_summed read zero + amounts, whose bound is zero, as 0 pence without raising a warning when + numpy warns on every floating-point error and warnings are errors, and + summed and months_summed give the same Pence under that state as under + numpy's default one. +""" + +import warnings +from contextlib import contextmanager +from fractions import Fraction +from functools import reduce + +import numpy as np +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_uk.utils.pence import ( + Pence, + months_summed, + stored, + stored_pence, + summed, +) + +# Below 2^17 pounds a float32 step is under a penny. +LIMIT_PENCE = 2**17 * 100 + + +def test_every_whole_pence_amount_below_2_to_the_17_pounds_round_trips(): + for start in range(0, LIMIT_PENCE, 2_000_000): + pence = np.arange(start, min(start + 2_000_000, LIMIT_PENCE)) + stored = (pence / 100).astype(np.float32) + recovered = stored_pence(stored) + assert recovered.dtype == np.float64 + wrong = np.nonzero(recovered != pence)[0] + assert len(wrong) == 0, pence[wrong[:5]] + + +float32_amounts = st.floats( + min_value=0, max_value=2.0**17, width=32, allow_nan=False, allow_infinity=False +) + + +@settings(max_examples=300, deadline=None, derandomize=True) +@given(amount=float32_amounts) +# £10,608.52 is stored as 10,608.51953125; the float32 below it is a +# fraction of a penny short of £10,608.52. +@example(amount=10_608.51953125) +@example(amount=10_608.517578125) +def test_moves_an_amount_at_most_half_a_step_and_keeps_fractions(amount): + stored = np.float32(amount) + exact = Fraction(float(stored)) * 100 + half_step = Fraction(float(np.spacing(stored))) * 50 + result = Fraction(float(stored_pence(stored))) + assert abs(result - exact) <= half_step + whole = round(exact) + if abs(exact - whole) > half_step: + assert result == exact + else: + assert result == whole + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given(amounts=st.lists(float32_amounts, min_size=2, max_size=50)) +@example(amounts=[10_608.517578125, 10_608.51953125, 10_608.5205078125]) +def test_never_gives_fewer_pence_for_more_money(amounts): + stored = np.sort(np.array(amounts, dtype=np.float32)) + assert np.all(np.diff(stored_pence(stored)) >= 0) + + +def _float32_sum(tree): + """A tree of float32 amounts (a list is a sum of its items), added one at + a time in float32 as policyengine-core adds them, and read with summed, + each amount as its exact value: (the float32 total, the Pence).""" + if not isinstance(tree, list): + amount = np.float32(tree) + return amount, Pence( + np.float64(amount) * 100, np.float64(abs(amount)), np.float64(1) + ) + totals, parts = zip(*(_float32_sum(item) for item in tree)) + total = reduce(np.add, totals) + return total, summed(total, parts) + + +def _exact(tree): + """The exact sum of a tree's float32 amounts.""" + if isinstance(tree, list): + return sum((_exact(item) for item in tree), Fraction(0)) + return Fraction(float(np.float32(tree))) + + +def _leaves(tree): + return sum(_leaves(item) for item in tree) if isinstance(tree, list) else 1 + + +# Float32 amounts of either sign below £131,072 on a grid of 2^-20, so that +# float64 sums of up to eight of them, in pence, are exact. +grid_amounts = st.integers(-(2**37) + 1, 2**37 - 1).map( + lambda units: float(np.float32(units / 2**20)) +) +sums_of_amounts = st.recursive( + grid_amounts, + lambda items: st.lists(items, min_size=1, max_size=4), + max_leaves=8, +).filter(lambda tree: isinstance(tree, list)) + + +@settings(max_examples=500, deadline=None, derandomize=True) +@given(tree=sums_of_amounts) +# Cancellation: the amounts are far larger than the total. +@example(tree=[65_536.01, -65_536.0, 0.01]) +@example(tree=[[131_071.99, 0.01], [-131_071.99, [-0.01, 0.03]]]) +@example(tree=[[1.0, 2.0**-20, 2.0**-20], [-1.0, 2.0**-20]]) +def test_float32_sums_lie_within_the_tolerance_of_the_exact_sum(tree): + total, read = _float32_sum(tree) + # Every level was accepted as a sum, so the exact sum is returned. + assert read.count == _leaves(tree), (tree, float(total)) + assert Fraction(float(read.pence)) == _exact(tree) * 100, (tree, float(total)) + + +def test_sums_of_whole_pence_are_recovered(): + rng = np.random.default_rng(2541) + missed_by_stored_pence = 0 + for shape in ([1], [2], [5], [2, 3], [1, 4, 2]): + # Whole-pence amounts of either sign below £131,072, in groups of the + # sizes ``shape`` lists, each group summed and the groups' sums added. + groups = [ + [rng.integers(1 - LIMIT_PENCE, LIMIT_PENCE, 200_000) for _ in range(size)] + for size in shape + ] + group_totals, group_sums = [], [] + for group in groups: + amounts = [(pence / 100).astype(np.float32) for pence in group] + group_total = reduce(np.add, amounts) + group_totals.append(group_total) + group_sums.append(summed(group_total, [stored(a) for a in amounts])) + total = reduce(np.add, group_totals) + exact = sum(sum(group) for group in groups) + assert np.array_equal(summed(total, group_sums).pence, exact), shape + missed_by_stored_pence += int(np.sum(stored_pence(total) != exact)) + # Summing in float32 leaves some totals too far from their pence for + # stored_pence alone, which is why the parts are summed again. + assert missed_by_stored_pence > 0 + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + parts=st.lists(st.integers(0, 199_999), min_size=1, max_size=5), + entered=st.integers(0, 999_999), +) +# £1,000 entered as a pension total, with no programme amounts. +@example(parts=[0, 0], entered=100_000) +# A penny from the sum of the amounts. +@example(parts=[180_749, 143_515], entered=324_265) +def test_a_total_that_is_not_the_sum_is_read_as_stored(parts, entered): + """Amounts under £2,000, five at most: the rounding of their sum is under + half a penny, so a total a whole penny or more from it is not the sum.""" + amounts = [np.float32(p / 100) for p in parts] + read = [stored(amount) for amount in amounts] + assert summed(np.float32(entered / 100), read).pence == entered + assert summed(reduce(np.add, amounts), read).pence == sum(parts) + + +def _months(amounts): + """Twelve float32 monthly amounts, summed one month at a time in float32 + as policyengine-core sums a flow over a year, and read with + months_summed.""" + return months_summed(reduce(np.add, amounts), [stored(m) for m in amounts]) + + +def test_a_years_whole_pence_divided_among_its_months_are_recovered(): + for start in range(0, 32_768 * 100, 1_000_000): + pence = np.arange(start, min(start + 1_000_000, 32_768 * 100)) + # policyengine-core's set_input_divide_by_period: the year's float32 + # amount divided by twelve in float32, for each month. + month = (pence / 100).astype(np.float32) / 12 + assert month.dtype == np.float32 + recovered = _months([month] * 12).pence + wrong = np.nonzero(recovered != pence)[0] + assert len(wrong) == 0, pence[wrong[:5]] + + +def test_whole_pence_entered_for_each_month_are_recovered(): + rng = np.random.default_rng(2541) + pence = rng.integers(0, LIMIT_PENCE // 12, (12, 200_000)) + recovered = _months([(p / 100).astype(np.float32) for p in pence]).pence + assert np.array_equal(recovered, pence.sum(axis=0)) + + +# Float32 monthly amounts below £131,072 in all, on a grid of 2^-20 so that +# float64 sums of twelve of them, in pence, are exact. +monthly_amounts = st.integers(0, 2**37 // 12).map( + lambda units: float(np.float32(units / 2**20)) +) + + +def _step(amount): + """The float32 step of the next float32 above ``amount``, in pounds.""" + return Fraction( + float(np.spacing(np.nextafter(np.float32(amount), np.float32(np.inf)))) + ) + + +@settings(max_examples=300, deadline=None, derandomize=True) +@given(months=st.lists(monthly_amounts, min_size=12, max_size=12)) +# Twelve 10p months, which float32 sums to 1.2000002, more than half a step +# from £1.20. +@example(months=[0.1] * 12) +def test_moves_the_months_sum_only_to_a_whole_penny_within_their_rounding(months): + amounts = [np.float32(m) for m in months] + read = _months(amounts) + exact = sum(Fraction(float(a)) for a in amounts) * 100 + result = Fraction(float(read.pence)) + if result != exact: + steps = sum(_step(a) for a in amounts) + _step(sum(float(a) for a in amounts)) + assert result.denominator == 1 and abs(result - exact) <= steps * 100, ( + months, + float(result), + float(exact), + ) + + +@contextmanager +def _strict(): + """numpy warns on every floating-point error, and every warning raises.""" + with warnings.catch_warnings(): + warnings.simplefilter("error") + with np.errstate(all="warn"): + yield + + +# numpy's default error state, under which underflow passes silently. +NUMPY_DEFAULT = dict(divide="warn", over="warn", under="ignore", invalid="warn") + + +def test_zero_amounts_read_as_zero_pence_without_a_warning(): + zero = np.zeros(3, dtype=np.float32) + with _strict(): + pence = stored_pence(zero) + read = stored(zero) + total = summed(zero, [read, read]) + months = months_summed(zero, [read] * 12) + assert np.array_equal(pence, np.zeros(3)) + for result, count in ((read, 1), (total, 2), (months, 12)): + assert np.array_equal(result.pence, np.zeros(3)) + assert np.array_equal(result.bound, np.zeros(3)) + assert np.array_equal(result.count, np.full(3, count)) + + +def _same(a, b): + return all(np.array_equal(x, y) for x, y in zip(a, b)) + + +# Whole pence of either sign up to £131,072, with zero drawn often. +whole_pence = st.one_of(st.just(0), st.integers(-LIMIT_PENCE, LIMIT_PENCE)) + + +@settings(max_examples=300, deadline=None, derandomize=True) +@given( + parts=st.lists(whole_pence, min_size=1, max_size=5), + months=st.lists(whole_pence, min_size=12, max_size=12), +) +@example(parts=[0, 0], months=[0] * 12) +@example(parts=[1, -1], months=[0] * 11 + [1]) +def test_zero_bounds_read_the_same_under_a_strict_error_state(parts, months): + """summed and months_summed give the same Pence whether numpy warns on + underflow or ignores it, and raise nothing when it warns, for float32 + amounts in whole pence from -£131,072 to £131,072, including zeros whose + bound is zero.""" + parts = [np.float32(p / 100) for p in parts] + months = [np.float32(m / 100) for m in months] + + def read(): + return ( + summed(reduce(np.add, parts), [stored(p) for p in parts]), + months_summed(reduce(np.add, months), [stored(m) for m in months]), + ) + + with np.errstate(**NUMPY_DEFAULT): + default = read() + with _strict(): + strict = read() + for a, b in zip(default, strict): + assert _same(a, b), (a, b) diff --git a/policyengine_uk/tests/test_subsample.py b/policyengine_uk/tests/test_subsample.py new file mode 100644 index 0000000000..2417c08aed --- /dev/null +++ b/policyengine_uk/tests/test_subsample.py @@ -0,0 +1,1443 @@ +"""``Simulation.subsample`` keeps a weighted sample of a dataset's households. + +Before, the method was inherited from policyengine-core and raised +``TypeError``: it rebuilds through ``build_from_dataset()`` with no arguments, +and the UK builder needs the dataset. The UK method subsamples the +simulation's populations, its baseline and its dataset together. + +Invariants, for every dataset, sample size ``n``, seed, sampling period and +``quantize_weights``: + +1. Size: between 1 and ``n`` households are kept, each with a positive weight + in the sampling period. +2. Weight totals: the total household weight of every year is unchanged, to + float32 precision, and so is the total of every other supplied weight over + its own entity. +3. Quantized weights: with ``quantize_weights`` (the default), a household's + weight is its number of draws times the year's total over the draws of + households with a positive weight that year: a whole multiple of + total / ``n`` in the sampling year, and in any year where every drawn + household has a positive weight, with the multiples summing to ``n``. + Without it, a household's weight is proportional to its old weight times a + whole number of draws. Either way a zero weight stays zero, in every year. +4. Links: a kept household or benefit unit has exactly the people it had, a + kept person keeps their household and benefit unit, and no other record is + kept. This holds for identifiers that 32-bit floats cannot tell apart, and + whatever the stored ``person_household_id`` and ``person_benunit_id`` + inputs hold. +5. Inputs: every supplied value other than a weight is unchanged for the kept + records, in every period, and the subsample supplies the same variables + and periods as before. Calculated values are not kept. +6. Determinism: the same seed keeps the same households with the same weights. +7. Baseline: the baseline is replaced with a copy that holds the same + households and weights and keeps its own policy. The baseline held before + is unchanged, so subsampling a clone leaves the source simulation and its + baseline as they were. +8. Dataset (differential): ``dataset`` holds the same records as the + populations, from the rows of the dataset the simulation was built from, + with weights rescaled in the same way. For a simulation whose inputs are + all in its dataset, a simulation built from that dataset gives the same + results as the subsampled simulation. Intended: an input the dataset does + not hold stays out of it, as it was out of the dataset before + subsampling. That is an input set with ``set_input`` after the build, or + one for a part of a year, which a DataFrame can give and a dataset of + whole years cannot hold. +9. Unbiasedness: over many seeds, a household's mean quantized weight in the + sampling period is its old weight. Intended exceptions: in another period, + the mean is the household's share of the sampling period's total times + that period's total, which is its old weight only if its share is the same + in both periods; and unquantized weights keep the total exactly, so they + are a ratio estimate, whose mean is off by a few per cent in a small + sample and closes on the old weight as the sample grows. +10. Trace and inputs: the tracer starts again, and a supplied input stored on + disk is kept, not a value calculated later and cached over it in memory. +11. Model values and origins: a value the model stored as its own + (``set_model_value``, as the Universal Credit health element reform does + for every simulation) stays the model's, not supplied, and the record of + where each capital gains input came from is unchanged. + +The properties are generated for datasets of one year, which the model +projects, and of several years whose weights change differently from +household to household. + +Intended exceptions: a simulation built from a situation, one given no +household weights or a negative one, a branch, and a baseline holding other +households raise ``ValueError`` and are left unchanged. +""" + +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.data_storage import OnDiskStorage +from policyengine_core.periods import period as period_ +from policyengine_core.reforms import Reform +from policyengine_core.tracers import FullTracer + +from policyengine_uk import CountryTaxBenefitSystem, Microsimulation, Simulation +from policyengine_uk.data import UKMultiYearDataset, UKSingleYearDataset +from policyengine_uk.data.isa_income import LEGACY_CONTRACT, simulation_contract +from policyengine_uk.data.savings_income_projection import ( + OBR_SAVINGS_INCOME_2022_2025, +) +from policyengine_uk.utils.subsample import ( + WEIGHT_VARIABLES, + draw_household_counts, + rescale_weights, + subsample_dataset, +) +from policyengine_uk.utils.supplied_inputs import ( + is_model_value, + set_model_value, + stored_supplied_value, + supplied_input, + supplied_input_arrays, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEARS = range(2025, 2031) +# Neighbouring identifiers of the size the UK datasets use. As 32-bit floats, +# identifiers this large are exact only at multiples of 64. +FIRST_HOUSEHOLD_ID = 900_000_001 +FIRST_BENUNIT_ID = 906_000_001 +FIRST_PERSON_ID = 967_000_001 +PERSONAL_ALLOWANCE = "gov.hmrc.income_tax.allowances.personal_allowance.amount" +REGIONS = ["LONDON", "WALES", "SCOTLAND", "NORTH_EAST"] + +# Six households: (weight, people in each benefit unit). +HOUSEHOLDS = [ + (100.0, [1, 1]), + (200.0, [1]), + (300.0, [2, 2]), + (400.0, [1]), + (500.0, [1, 1]), + (600.0, [2]), +] + + +def dataset(households=HOUSEHOLDS, year=2025, weights=True) -> UKSingleYearDataset: + """A dataset of ``households``, each a weight and the number of people in + each of its benefit units.""" + people, benunits, rows = [], [], [] + for index, (weight, units) in enumerate(households): + household_id = FIRST_HOUSEHOLD_ID + index + rows.append( + { + "household_id": household_id, + "household_weight": weight, + "region": REGIONS[index % len(REGIONS)], + "rent": 0.0, + "tenure_type": "OWNED_OUTRIGHT", + "council_tax": 1_000.0 + index, + } + ) + for size in units: + benunit_id = FIRST_BENUNIT_ID + len(benunits) + benunits.append({"benunit_id": benunit_id}) + for _ in range(size): + people.append( + { + "person_id": FIRST_PERSON_ID + len(people), + "person_benunit_id": benunit_id, + "person_household_id": household_id, + "age": 25 + 3 * len(people), + "employment_income": 9_000.0 * (1 + len(people) % 7), + } + ) + household = pd.DataFrame(rows) + if not weights: + household = household.drop(columns="household_weight") + return UKSingleYearDataset( + person=pd.DataFrame(people), + benunit=pd.DataFrame(benunits), + household=household, + fiscal_year=year, + ) + + +def values(simulation, variable, period) -> np.ndarray: + return np.asarray(simulation.calculate(variable, period)) + + +def ids(simulation, entity) -> list: + return np.asarray(simulation.populations[entity].ids).tolist() + + +def members(simulation, entity) -> dict: + """The people in each ``entity``, by its identifier, from the populations.""" + population = simulation.populations[entity] + groups = np.asarray(population.ids)[population.members_entity_id] + members_by_group = {group: set() for group in ids(simulation, entity)} + for person, group in zip(ids(simulation, "person"), groups.tolist()): + members_by_group[group].add(person) + return members_by_group + + +def by_id(simulation, variable, period) -> dict: + entity = simulation.tax_benefit_system.get_variable(variable).entity.key + return dict(zip(ids(simulation, entity), values(simulation, variable, period))) + + +def weights_by_id(simulation, period) -> dict: + return by_id(simulation, "household_weight", period) + + +def supplied(simulation) -> set: + """The variables and periods the simulation holds supplied values for.""" + return { + (name, str(period)) for name, period, _ in supplied_input_arrays(simulation) + } + + +# The reported failure. + + +def test_subsample_runs_on_a_dataset_simulation(): + simulation = Microsimulation(dataset=dataset()) + total = values(simulation, "household_weight", 2026).sum() + + result = simulation.subsample(n=4, seed=0, time_period=2026) + + assert result is simulation + assert 1 <= len(ids(simulation, "household")) <= 4 + assert values(simulation, "household_weight", 2026).sum() == pytest.approx( + total, rel=1e-5 + ) + assert np.isfinite(values(simulation, "household_net_income", 2026)).all() + + +def test_subsample_is_the_uk_method_not_policyengine_cores(): + """policyengine-core's method ends in ``build_from_dataset()``, which the + UK builder cannot answer, so the UK classes must not inherit it.""" + assert "subsample" in vars(Simulation) + assert Microsimulation.subsample is Simulation.subsample + + +# Links. + + +def test_identifiers_beyond_float32_precision_keep_their_households(): + simulation = Microsimulation(dataset=dataset()) + households = members(simulation, "household") + benunits = members(simulation, "benunit") + + simulation.subsample(n=5, seed=3, time_period=2025) + + kept = ids(simulation, "household") + assert 1 < len(kept) <= 5 + assert members(simulation, "household") == { + household: households[household] for household in kept + } + kept_people = set(ids(simulation, "person")) + assert members(simulation, "benunit") == { + benunit: people for benunit, people in benunits.items() if people <= kept_people + } + assert kept_people == set().union(*(households[household] for household in kept)) + assert values(simulation, "household_id", 2025).tolist() == kept + + +def test_memberships_come_from_the_populations_not_the_stored_identifiers(): + simulation = Microsimulation(dataset=dataset()) + households = members(simulation, "household") + benunits = members(simulation, "benunit") + people = len(ids(simulation, "person")) + for variable in ("person_household_id", "person_benunit_id"): + simulation.set_input(variable, 2025, np.zeros(people)) + + simulation.subsample(n=5, seed=3) + + for entity, before in (("household", households), ("benunit", benunits)): + assert members(simulation, entity) == { + group: before[group] for group in ids(simulation, entity) + } + # Intended: the stored values are supplied inputs, so they are kept. + assert set(values(simulation, "person_household_id", 2025).tolist()) == {0} + + +def test_zero_weight_households_are_never_kept(): + households = [(0.0, [1]), (50.0, [2]), (0.0, [1, 1]), (70.0, [1])] + for seed in range(5): + simulation = Microsimulation(dataset=dataset(households)) + simulation.subsample(n=6, seed=seed) + assert set(ids(simulation, "household")) <= { + FIRST_HOUSEHOLD_ID + 1, + FIRST_HOUSEHOLD_ID + 3, + } + + +# Weights. + + +def test_quantized_weights_count_draws_and_keep_every_years_total(): + simulation = Microsimulation(dataset=dataset()) + totals = { + year: values(simulation, "household_weight", year).sum() for year in YEARS + } + + simulation.subsample(n=7, seed=1, time_period=2027) + + draws = None + for year in YEARS: + weights = values(simulation, "household_weight", year) + assert weights.sum() == pytest.approx(totals[year], rel=1e-5) + multiples = weights * 7 / totals[year] + assert np.abs(multiples - multiples.round()).max() < 1e-3 + assert multiples.round().sum() == 7 + # The same draws set the weights of every year. + assert draws is None or multiples.round().tolist() == draws + draws = multiples.round().tolist() + + +def test_unquantized_weights_scale_the_old_weights_by_draws(): + simulation = Microsimulation(dataset=dataset()) + before = weights_by_id(simulation, 2025) + total = sum(before.values()) + + simulation.subsample(n=5, seed=2, quantize_weights=False) + + after = weights_by_id(simulation, 2025) + assert sum(after.values()) == pytest.approx(total, rel=1e-5) + ratios = np.array([after[household] / before[household] for household in after]) + draws = ratios * 5 / ratios.sum() + assert np.abs(draws - draws.round()).max() < 1e-3 + assert draws.round().sum() == 5 + + +def test_other_supplied_weights_keep_their_totals_over_their_own_entity(): + data = dataset() + data.household["original_weight"] = [7.0, 5.0, 3.0, 2.0, 1.0, 9.0] + data.person["raw_person_weight"] = np.arange(1.0, len(data.person) + 1) + simulation = Microsimulation(dataset=data) + benunit_weight = values(simulation, "benunit_weight", 2025) + simulation.set_input("benunit_weight", 2025, benunit_weight * 2) + totals = { + (name, year): values(simulation, name, year).sum() + for name in ("original_weight", "raw_person_weight") + for year in YEARS + } + totals["benunit_weight", 2025] = benunit_weight.sum() * 2 + + simulation.subsample(n=4, seed=5) + + for (name, year), total in totals.items(): + assert values(simulation, name, year).sum() == pytest.approx(total, rel=1e-5) + table = getattr( + simulation.dataset[year], + simulation.tax_benefit_system.get_variable(name).entity.key, + ) + if name in table: + assert table[name].sum() == pytest.approx(total, rel=1e-5) + + +def test_every_weight_variable_is_listed(): + """A new sampling weight must be rescaled by a subsample, so it has to be + listed. A variable that only ends in "weight" must be left out on purpose: + household_bus_fare_age_weight shares out bus fare spending by age.""" + named = { + name for name in CountryTaxBenefitSystem().variables if name.endswith("weight") + } + assert named == WEIGHT_VARIABLES | {"household_bus_fare_age_weight"} + + +# Inputs. + + +def test_supplied_values_are_kept_and_calculated_values_are_not(): + simulation = Microsimulation(dataset=dataset()) + hours = np.arange(1.0, len(ids(simulation, "person")) + 1) + simulation.set_input("hours_worked", 2027, hours) + energy = np.arange(10.0, 10.0 + len(ids(simulation, "household"))) + simulation.set_input("monthly_domestic_energy_consumption", "2026-03", energy) + hours_by_person = dict(zip(ids(simulation, "person"), hours)) + energy_by_household = dict(zip(ids(simulation, "household"), energy)) + ages = {year: by_id(simulation, "age", year) for year in YEARS} + regions = by_id(simulation, "region", 2025) + inputs = supplied(simulation) + # Calculate results before subsampling; they must not become inputs. + for variable in ("income_tax", "household_net_income", "universal_credit"): + simulation.calculate(variable, 2026) + simulation.calculate("age", 2035) + income_tax = by_id(simulation, "income_tax", 2026) + + simulation.subsample(n=4, seed=4) + + assert supplied(simulation) == inputs + for variable in ("income_tax", "household_net_income", "universal_credit"): + assert simulation.get_holder(variable).get_known_periods() == [] + assert period_(2035) not in simulation.get_holder("age").get_known_periods() + people = ids(simulation, "person") + households = ids(simulation, "household") + # A result calculated before is calculated again, on the sample. + assert by_id(simulation, "income_tax", 2026) == { + person: income_tax[person] for person in people + } + assert by_id(simulation, "hours_worked", 2027) == { + person: hours_by_person[person] for person in people + } + assert by_id(simulation, "monthly_domestic_energy_consumption", "2026-03") == { + household: energy_by_household[household] for household in households + } + for year in YEARS: + assert by_id(simulation, "age", year) == { + person: ages[year][person] for person in people + } + assert by_id(simulation, "region", 2025) == { + household: regions[household] for household in households + } + + +def test_a_calculated_value_that_replaced_an_input_is_not_kept(): + """The record of an input outlives a calculated value stored over it. + The subsample must not keep the record: the next calculation would fill + the period again, and the record would pass the result off as supplied.""" + simulation = Microsimulation(dataset=dataset()) + people = len(ids(simulation, "person")) + simulation.set_input("hours_worked", 2027, np.full(people, 1_000.0)) + simulation.get_holder("hours_worked").put_in_cache( + np.full(people, 500.0), period_(2027) + ) + assert ("hours_worked", "2027") not in supplied(simulation) + + simulation.subsample(n=4, seed=4) + simulation.calculate("hours_worked", 2027) + + assert ("hours_worked", "2027") not in supplied(simulation) + assert "2027" not in simulation.to_input_dict().get("hours_worked", {}) + assert not any( + name == "hours_worked" and str(period) == "2027" + for name, _, period in simulation._user_input_keys + ) + + +def input_on_disk_under_a_cached_value(simulation, folder): + """Store household_net_income for 2025 as an input on disk, then cache a + calculated value over it in memory. Returns the input.""" + period = period_(2025) + holder = simulation.get_holder("household_net_income") + holder._disk_storage = OnDiskStorage(str(folder), preserve_storage_dir=True) + holder._on_disk_storable = True + # A memory limit no simulation can stay under sends the input to disk. + simulation.memory_config = SimpleNamespace(max_memory_occupation_pc=-1) + supplied_value = np.full(len(HOUSEHOLDS), 111.0) + simulation.set_input("household_net_income", period, supplied_value) + holder._on_disk_storable = False + simulation.memory_config = None + holder.put_in_cache(np.full(len(HOUSEHOLDS), 999.0), period) + return supplied_value + + +def test_an_input_on_disk_is_kept_not_a_value_cached_over_it(tmp_path): + simulation = Microsimulation(dataset=dataset()) + supplied_value = input_on_disk_under_a_cached_value(simulation, tmp_path) + holder = simulation.get_holder("household_net_income") + assert holder._memory_storage.get(period_(2025)).tolist() == [999.0] * 6 + + assert stored_supplied_value(holder, period_(2025), "default").tolist() == ( + supplied_value.tolist() + ) + simulation.subsample(n=2, seed=1) + assert set(values(simulation, "household_net_income", 2025).tolist()) == {111.0} + + +def test_supplied_values_match_policyengine_cores_recorded_inputs(tmp_path): + """Differential: policyengine-core 3.33 reads a recorded input from its + tier in ``Simulation._get_recorded_input_array``; earlier versions have + no such method, and ``stored_supplied_value`` follows the same rule.""" + simulation = Microsimulation(dataset=dataset()) + if not hasattr(simulation, "_get_recorded_input_array"): + pytest.skip("policyengine-core reads recorded inputs from 3.33") + input_on_disk_under_a_cached_value(simulation, tmp_path) + people = len(ids(simulation, "person")) + simulation.set_input("hours_worked", 2027, np.full(people, 1_000.0)) + simulation.get_holder("hours_worked").put_in_cache( + np.full(people, 500.0), period_(2027) + ) + simulation.calculate("income_tax", 2026) + + keys = sorted(simulation._user_input_keys, key=lambda key: (key[0], str(key[2]))) + assert keys + for variable, branch, period in keys: + ours = stored_supplied_value(simulation.get_holder(variable), period, branch) + cores = simulation._get_recorded_input_array(variable, period) + if cores is None: + assert ours is None, (variable, period) + else: + assert np.asarray(ours).tolist() == np.asarray(cores).tolist(), ( + variable, + period, + ) + + +def test_a_trace_starts_again_on_the_sample(): + simulation = Microsimulation(dataset=dataset(), trace=True) + simulation.calculate("household_net_income", 2025) + # A baseline traced in its simulation stays traced in it. + simulation.baseline.tracer = simulation.tracer + + simulation.subsample(n=1, seed=1) + simulation.calculate("household_net_income", 2025) + + assert isinstance(simulation.tracer, FullTracer) + assert simulation.baseline.tracer is simulation.tracer + flat = simulation.tracer.get_serialized_flat_trace() + assert len(flat["household_net_income<2025, (default)>"]["value"]) == 1 + + +def test_a_model_value_stays_the_models(): + """Formulas ask whether a value was supplied (``supplied_input``). A value + the model stored as its own must not read as supplied after the rebuild, + while a supplied one still does.""" + simulation = Microsimulation(dataset=dataset()) + benunits = ids(simulation, "benunit") + model = np.arange(1.0, len(benunits) + 1) * 100 + set_model_value(simulation, "uc_LCWRA_element", 2026, model) + simulation.set_input("uc_carer_element", 2026, np.full(len(benunits), 7.0)) + model_by_benunit = dict(zip(benunits, model)) + # The Universal Credit health element reform stores the model's own + # values for other years in every simulation. + model_periods = { + str(period) + for name, period, values in supplied_input_arrays(simulation) + if is_model_value(simulation, name, period, values) + } + assert {"2026", "2027"} <= model_periods + + simulation.subsample(n=3, seed=1) + + benunit = simulation.populations["benunit"] + kept = ids(simulation, "benunit") + assert supplied_input(benunit, "uc_LCWRA_element", period_(2026)) is None + assert by_id(simulation, "uc_LCWRA_element", 2026) == { + unit: model_by_benunit[unit] for unit in kept + } + assert supplied_input(benunit, "uc_carer_element", period_(2026)).tolist() == ( + [7.0] * len(kept) + ) + for held in (simulation, simulation.baseline): + assert { + str(period) + for name, period, values in supplied_input_arrays(held) + if is_model_value(held, name, period, values) + } >= model_periods - {"2026"} + + +@pytest.mark.parametrize("model", [False, True], ids=["supplied", "model"]) +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="needs policyengine-core holder resync", +) +def test_neutralized_inputs_and_model_values_survive_subsampling(model): + """Neutralisation hides stored inputs without discarding them or their owner.""" + data = dataset([(100.0, [1]), (100.0, [1])]) + data.person["employment_income"] = 30_000.0 + simulation = Microsimulation(dataset=data) + name = "adjusted_net_income" + period = period_(2025) + original_variable = simulation.tax_benefit_system.get_variable(name).__class__ + original_values = np.array([10_000.0, 11_000.0]) + if model: + set_model_value(simulation, name, period, original_values) + else: + simulation.set_input(name, period, original_values) + original_by_person = dict(zip(ids(simulation, "person"), original_values)) + + class NeutralizeIncome(Reform): + def apply(self): + self.neutralize_variable(name) + + class RestoreIncome(Reform): + def apply(self): + self.replace_variable(original_variable) + + simulation.apply_reform(NeutralizeIncome) + expected_results = { + variable: by_id(simulation, variable, period) + for variable in ("net_income", "income_tax") + } + if not model: + assert all( + value == 0 + for result in expected_results.values() + for value in result.values() + ) + + simulation.subsample(n=1, seed=0, time_period=2025) + + kept = ids(simulation, "person") + expected_input = [original_by_person[person] for person in kept] + assert (name, str(period)) in supplied(simulation) + stored = stored_supplied_value( + simulation.get_holder(name), period, simulation.branch_name + ) + np.testing.assert_array_equal(stored, expected_input) + assert is_model_value(simulation, name, period, stored) is model + assert simulation.tax_benefit_system.get_variable(name).is_neutralized + np.testing.assert_array_equal(values(simulation, name, period), np.zeros(len(kept))) + person = simulation.populations["person"] + if model: + assert supplied_input(person, name, period) is None + else: + np.testing.assert_array_equal( + supplied_input(person, name, period), np.zeros(len(kept)) + ) + for variable, expected in expected_results.items(): + assert by_id(simulation, variable, period) == { + person: expected[person] for person in kept + } + + simulation.apply_reform(RestoreIncome) + + np.testing.assert_array_equal(values(simulation, name, period), expected_input) + assert is_model_value(simulation, name, period, np.asarray(expected_input)) is model + if model: + assert supplied_input(person, name, period) is None + else: + np.testing.assert_array_equal( + supplied_input(person, name, period), expected_input + ) + + +def test_where_capital_gains_inputs_came_from_is_kept(): + data = dataset() + data.person["capital_gains"] = np.arange(len(data.person)) * 1_000.0 + simulation = Microsimulation(dataset=data) + full = simulation.dataset + before = simulation.capital_gains_projection_metadata["application"] + statuses = {record["status"] for record in before["by_period"].values()} + assert statuses == {"supplied", "projected"} + + simulation.subsample(n=3, seed=1) + + assert simulation.capital_gains_projection_metadata["application"] == before + assert ( + simulation.dataset._cgt_extension_provenance == full._cgt_extension_provenance + ) + # Differential: a simulation built from the subsampled dataset reports + # the same origins as one built from the full dataset. + rebuilt = Microsimulation(dataset=simulation.dataset) + assert ( + rebuilt.capital_gains_projection_metadata["application"] + == Microsimulation(dataset=full).capital_gains_projection_metadata[ + "application" + ] + ) + + +def test_a_neutralised_capital_gains_input_still_reads_as_its_default(): + data = dataset() + data.person["capital_gains"] = np.arange(len(data.person)) * 1_000.0 + simulation = Microsimulation(dataset=data) + + class NeutraliseGains(Reform): + def apply(self): + self.neutralize_variable("capital_gains_before_response") + + simulation.apply_reform(NeutraliseGains) + simulation.calculate("capital_gains_before_response", 2026) + before = simulation.capital_gains_projection_metadata["application"] + by_variable = before["by_variable"]["capital_gains_before_response"] + assert by_variable["2026"]["status"] == "default" + + simulation.subsample(n=3, seed=1) + + assert simulation.capital_gains_projection_metadata["application"] == before + + +def test_how_the_dataset_says_its_amounts_grow_is_kept(): + """The dataset's ISA income contract, savings income series and energy + price basis describe every household, so the sample keeps them.""" + data = dataset() + data = UKSingleYearDataset( + person=data.person.assign( + savings_interest_income=np.arange(len(data.person)) * 500.0, + individual_savings_account_interest_income=100.0, + ), + benunit=data.benunit, + household=data.household, + fiscal_year=2025, + isa_income_contract=dict(LEGACY_CONTRACT), + savings_income_projection=OBR_SAVINGS_INCOME_2022_2025, + energy_price_basis={"period_type": "fiscal_year", "period_value": 2025}, + ) + full = Microsimulation(dataset=data) + simulation = Microsimulation(dataset=data) + full_dataset = simulation.dataset + years = full_dataset.years + contracts = {year: simulation_contract(simulation, year) for year in years} + + simulation.subsample(n=4, seed=0, time_period=2026) + + for each in (simulation, simulation.baseline): + assert {year: simulation_contract(each, year) for year in years} == contracts + assert each._savings_income_projection == OBR_SAVINGS_INCOME_2022_2025 + sample = simulation.dataset + for year in years: + for name in ( + "isa_income_contract", + "savings_income_projection", + "energy_price_basis", + ): + assert getattr(sample[year], name) == getattr(full_dataset[year], name) + # Under the legacy contract the model projects savings interest past the + # dataset's last year from gross interest. + expected = by_id(full, "savings_interest_income", 2035) + for record, value in by_id(simulation, "savings_interest_income", 2035).items(): + assert value == pytest.approx(expected[record]) + + +def test_results_match_the_same_households_in_the_full_simulation(): + """Subsampling changes which households are held and their weights, + not what the model calculates for a household.""" + full = Microsimulation(dataset=dataset()) + sample = Microsimulation(dataset=dataset()) + sample.subsample(n=4, seed=6, time_period=2026) + + for variable in ("income_tax", "household_net_income", "council_tax"): + expected = by_id(full, variable, 2026) + for record, value in by_id(sample, variable, 2026).items(): + assert value == pytest.approx(expected[record]) + + +# Determinism. + + +def test_the_same_seed_keeps_the_same_households(): + samples = [] + for seed in (0, 0, "text seed", "text seed", None, None): + simulation = Microsimulation(dataset=dataset()) + simulation.subsample(n=3, seed=seed, time_period=2026) + samples.append(weights_by_id(simulation, 2026)) + + assert samples[0] == samples[1] + assert samples[2] == samples[3] + assert samples[4] == samples[5] + + +def test_different_seeds_keep_different_households(): + weights = np.array([weight for weight, _ in HOUSEHOLDS]) + draws = {tuple(draw_household_counts(weights, 2, seed)) for seed in range(12)} + assert len(draws) > 1 + + +def test_frac_sets_the_number_of_draws(): + by_frac = Microsimulation(dataset=dataset()) + by_frac.subsample(frac=0.5, seed=8) + by_n = Microsimulation(dataset=dataset()) + by_n.subsample(n=3, seed=8) + + assert weights_by_id(by_frac, 2025) == weights_by_id(by_n, 2025) + + +def test_a_period_before_the_data_samples_by_the_first_weights(): + """The model has no weights before the data's first year: it would give + every household the default weight of one.""" + early = Microsimulation(dataset=dataset()) + assert set(values(early, "household_weight", 2020).tolist()) == {1.0} + early.subsample(n=3, seed=9, time_period=2020) + first = Microsimulation(dataset=dataset()) + first.subsample(n=3, seed=9, time_period=2025) + + assert weights_by_id(early, 2025) == weights_by_id(first, 2025) + + +# Baseline. + + +def test_a_reform_and_its_baseline_keep_the_same_households(): + reform = {PERSONAL_ALLOWANCE: 0} + simulation = Microsimulation(dataset=dataset(), reform=reform) + + simulation.subsample(n=4, seed=0, time_period=2026) + + baseline = simulation.baseline + assert 1 <= len(ids(simulation, "household")) <= 4 + for held in (baseline, baseline.baseline): + assert ids(held, "household") == ids(simulation, "household") + assert ids(held, "person") == ids(simulation, "person") + assert weights_by_id(held, 2026) == weights_by_id(simulation, 2026) + # Each simulation keeps its own policy, on the sample. + sample = simulation.dataset + reformed = Microsimulation(dataset=sample, reform=reform) + unreformed = Microsimulation(dataset=sample) + for held, expected in ((simulation, reformed), (baseline, unreformed)): + assert values(held, "income_tax", 2026).tolist() == pytest.approx( + values(expected, "income_tax", 2026).tolist() + ) + assert ( + values(simulation, "income_tax", 2026).sum() + > values(baseline, "income_tax", 2026).sum() + ) + + +@pytest.mark.parametrize("reform", [None, {PERSONAL_ALLOWANCE: 0}]) +def test_subsampling_a_clone_leaves_the_source_and_its_baseline_alone(reform): + """policyengine-core's ``clone`` shares a separate baseline between the + source and the clone, so the clone must not subsample it in place.""" + source = Microsimulation(dataset=dataset(), reform=reform) + baselines = [] + held = source + while getattr(held, "baseline", None) is not None: + held = held.baseline + baselines.append((held, held.dataset, weights_by_id(held, 2026))) + clone = source.clone() + assert clone.baseline is source.baseline + + clone.subsample(n=2, seed=1) + + assert len(ids(clone, "household")) <= 2 + assert ids(clone.baseline, "household") == ids(clone, "household") + assert len(ids(source, "household")) == len(HOUSEHOLDS) + held = source + for baseline, data, weights in baselines: + assert held.baseline is baseline + assert len(ids(baseline, "household")) == len(HOUSEHOLDS) + assert baseline.dataset is data + assert weights_by_id(baseline, 2026) == weights + held = baseline + # The source can still be subsampled on its own. + source.subsample(n=3, seed=2) + assert ids(source.baseline, "household") == ids(source, "household") + + +def test_the_baseline_held_before_is_left_as_it_was(): + simulation = Microsimulation(dataset=dataset()) + before = simulation.baseline + + simulation.subsample(n=2, seed=1) + + assert simulation.baseline is not before + assert len(ids(before, "household")) == len(HOUSEHOLDS) + assert ids(simulation.baseline, "household") == ids(simulation, "household") + + +def test_a_baseline_copy_does_not_copy_the_baselines_arrays(monkeypatch): + """The copy of a baseline rebuilds its arrays at once, so copying the + baseline's cached arrays first would only double its memory.""" + from policyengine_core.data_storage import InMemoryStorage + + from policyengine_uk.utils import subsample as module + + if module._branch_clone is None: + pytest.skip("this policyengine-core cannot share a clone's arrays") + simulation = Microsimulation(dataset=dataset(), reform={PERSONAL_ALLOWANCE: 0}) + simulation.baseline.calculate("household_net_income", 2026) + calls = [] + clone = InMemoryStorage.clone + + def recording_clone(self, share_arrays=False): + calls.append(share_arrays) + return clone(self, share_arrays=share_arrays) + + monkeypatch.setattr(InMemoryStorage, "clone", recording_clone) + + simulation.subsample(n=3, seed=1) + + assert calls + assert all(calls) + + +def test_a_baseline_holding_other_households_is_refused_and_nothing_changes(): + simulation = Microsimulation(dataset=dataset()) + simulation.baseline.subsample(n=2, seed=1) + households = ids(simulation, "household") + data = simulation.dataset + + with pytest.raises(ValueError, match="same households"): + simulation.subsample(n=3, seed=0) + + assert ids(simulation, "household") == households + assert simulation.dataset is data + + +# Dataset. + + +def test_the_dataset_holds_the_sample(): + simulation = Microsimulation(dataset=dataset()) + full = simulation.dataset + full_households = len(full[2025].household) + + simulation.subsample(n=4, seed=0, time_period=2026) + + sample = simulation.dataset + assert isinstance(sample, UKMultiYearDataset) + assert sample is not full + assert sample.years == full.years + assert simulation.baseline.dataset is sample + # The dataset the simulation was built from is not changed. + assert len(full[2025].household) == full_households + for year in sample.years: + tables = sample[year] + assert tables.household.household_id.tolist() == ids(simulation, "household") + assert tables.benunit.benunit_id.tolist() == ids(simulation, "benunit") + assert tables.person.person_id.tolist() == ids(simulation, "person") + assert tables.household.household_weight.tolist() == pytest.approx( + values(simulation, "household_weight", year).tolist(), rel=1e-5 + ) + assert tables.household.region.tolist() == list( + values(simulation, "region", year) + ) + + +def test_inputs_set_after_the_build_stay_out_of_the_dataset(): + """Intended: ``dataset`` is the subsample of the dataset the simulation + was built from, which held no values set afterwards either. So a later + input stays in the simulation only, and a weight set on the simulation + alone is rescaled to its own total there, while the baseline and the + dataset keep the dataset's weights.""" + simulation = Microsimulation(dataset=dataset()) + people = len(ids(simulation, "person")) + simulation.set_input("hours_worked", 2025, np.full(people, 1_234.0)) + simulation.set_input("household_weight", 2025, np.ones(len(HOUSEHOLDS))) + hours_in_data = Microsimulation(dataset=simulation.dataset).calculate( + "hours_worked", 2025 + ) + + simulation.subsample(n=2, seed=1) + + rebuilt = Microsimulation(dataset=simulation.dataset) + assert set(values(simulation, "hours_worked", 2025).tolist()) == {1_234.0} + assert set(values(rebuilt, "hours_worked", 2025).tolist()) <= set( + np.asarray(hours_in_data).tolist() + ) + assert values(simulation, "household_weight", 2025).sum() == pytest.approx( + len(HOUSEHOLDS) + ) + total = sum(weight for weight, _ in HOUSEHOLDS) + for held in (simulation.baseline, rebuilt): + assert values(held, "household_weight", 2025).sum() == pytest.approx(total) + + +def test_an_input_for_part_of_a_year_stays_out_of_the_dataset(): + """Intended: a DataFrame can give a monthly input, which the simulation + keeps and a dataset of whole years cannot hold, so a simulation built + from the subsampled dataset does not have it.""" + frame = pd.DataFrame( + { + "person_id__2025": [1, 2], + "household_id__2025": [1, 2], + "household_weight__2025": [1.0, 1.0], + "ebr_energy_bills_credit_eligible__2025-01": [False, False], + } + ) + simulation = Microsimulation(dataset=frame) + + simulation.subsample(n=1, seed=1) + + monthly = ("ebr_energy_bills_credit_eligible", "2025-01") + assert monthly in supplied(simulation) + assert values(simulation, *monthly).tolist() == [False] + assert monthly not in supplied(Microsimulation(dataset=simulation.dataset)) + + +def test_subsampling_a_single_year_dataset_keeps_its_type(): + data = dataset() + counts = np.array([0, 2, 0, 1, 0, 0]) + household_ids = data.household.household_id.values + + sample = subsample_dataset(data, household_ids, counts) + + assert isinstance(sample, UKSingleYearDataset) + assert sample.time_period == data.time_period + assert sample.household.household_id.tolist() == household_ids[counts > 0].tolist() + assert sample.household.household_weight.tolist() == pytest.approx([1_400.0, 700.0]) + assert len(data.household) == 6 + + +def test_a_simulation_built_from_a_dataframe_subsamples_its_dataset(): + households = np.arange(4) + frame = pd.DataFrame( + { + "person_id__2025": FIRST_PERSON_ID + households, + "person_benunit_id__2025": FIRST_BENUNIT_ID + households, + "person_household_id__2025": FIRST_HOUSEHOLD_ID + households, + "benunit_id__2025": FIRST_BENUNIT_ID + households, + "household_id__2025": FIRST_HOUSEHOLD_ID + households, + "household_weight__2025": [10.0, 20.0, 30.0, 40.0], + "age__2025": [30, 40, 50, 60], + } + ) + simulation = Microsimulation(dataset=frame) + ages = by_id(simulation, "age", 2025) + + simulation.subsample(n=3, seed=1) + + kept = ids(simulation, "household") + assert 1 <= len(kept) <= 3 + assert ids(simulation.baseline, "household") == kept + assert sum(weights_by_id(simulation, 2025).values()) == pytest.approx(100.0) + assert by_id(simulation, "age", 2025) == { + person: ages[person] for person in ids(simulation, "person") + } + assert isinstance(simulation.dataset, UKMultiYearDataset) + tables = simulation.dataset[2025] + assert tables.household.household_id.tolist() == kept + assert tables.household.household_weight.sum() == pytest.approx(100.0) + + +# Refusals. + + +def test_subsample_needs_n_or_frac(): + simulation = Microsimulation(dataset=dataset()) + with pytest.raises(ValueError, match="n or frac"): + simulation.subsample(seed=0) + + +@pytest.mark.parametrize("n", [0, -3]) +def test_subsample_needs_at_least_one_draw(n): + simulation = Microsimulation(dataset=dataset()) + with pytest.raises(ValueError, match="at least one household"): + simulation.subsample(n=n) + assert len(ids(simulation, "household")) == len(HOUSEHOLDS) + + +def test_a_simulation_without_weights_is_refused(): + simulation = Microsimulation(dataset=dataset(weights=False)) + with pytest.raises(ValueError, match="household_weight"): + simulation.subsample(n=2) + + +def test_a_negative_household_weight_is_refused_and_nothing_changes(): + simulation = Microsimulation(dataset=dataset([(1.0, [1]), (-2.0, [1])])) + weights = weights_by_id(simulation, 2025) + + with pytest.raises(ValueError, match="negative"): + simulation.subsample(n=1, seed=0) + + assert weights_by_id(simulation, 2025) == weights + + +def test_another_negative_weight_is_refused(): + data = dataset() + data.household["original_weight"] = [1.0, 2.0, 3.0, -4.0, 5.0, 6.0] + simulation = Microsimulation(dataset=data) + households = ids(simulation, "household") + + with pytest.raises(ValueError, match="original_weight for 2025.*negative"): + simulation.subsample(n=3) + + assert ids(simulation, "household") == households + + +def test_a_simulation_with_no_positive_weight_is_refused(): + households = [(0.0, [1]), (0.0, [2])] + simulation = Microsimulation(dataset=dataset(households)) + with pytest.raises(ValueError, match="positive weight"): + simulation.subsample(n=2) + + +def test_a_situation_is_refused(): + simulation = Simulation(situation={"age": 30}) + with pytest.raises(ValueError, match="built from a dataset"): + simulation.subsample(n=1) + + +def test_a_branch_is_refused(): + simulation = Microsimulation(dataset=dataset()) + branch = simulation.get_branch("other") + with pytest.raises(ValueError, match="branches"): + branch.subsample(n=2) + + +def test_branches_are_made_again_on_the_sample(): + simulation = Microsimulation(dataset=dataset()) + simulation.get_branch("other").calculate("income_tax", 2026) + + simulation.subsample(n=3, seed=0) + + assert simulation.branches == {} + branch = simulation.get_branch("other") + assert ids(branch, "household") == ids(simulation, "household") + assert len(branch.calculate("income_tax", 2026)) == len(ids(simulation, "person")) + + +def test_a_benefit_unit_across_households_is_refused(): + data = dataset() + # Move the second person of the third household's first benefit unit to + # the fourth household. + data.person.loc[3, "person_household_id"] = FIRST_HOUSEHOLD_ID + 3 + simulation = Microsimulation(dataset=data) + with pytest.raises(ValueError, match="more than one household"): + simulation.subsample(n=3) + + +def test_a_total_that_cannot_be_kept_names_the_weight(): + simulation = Microsimulation(dataset=dataset()) + simulation.set_input("original_weight", 2026, [np.inf, 1.0, 1.0, 1.0, 1.0, 1.0]) + households = ids(simulation, "household") + + with pytest.raises(ValueError, match="original_weight for 2026.*finite total"): + simulation.subsample(n=3) + + assert ids(simulation, "household") == households + + +@pytest.mark.parametrize("quantize", [True, False]) +def test_a_weight_held_only_by_records_not_drawn_is_refused(quantize): + """The third household holds all of the original weight and is never + drawn, and the drawn ones stand for no one, so no sample keeps that + total.""" + data = dataset([(100.0, [1]), (200.0, [2]), (0.0, [1])]) + data.household["original_weight"] = [0.0, 0.0, 9.0] + simulation = Microsimulation(dataset=data) + + with pytest.raises(ValueError, match="original_weight for 2025.*no weight"): + simulation.subsample(n=2, quantize_weights=quantize) + + +def test_a_zero_weight_stays_zero_in_every_year(): + """A household drawn by its positive weight in one year keeps a zero + weight in a year where it has one, and the year's total goes to the + drawn households that stand for someone that year.""" + years = [] + for year, weights in ((2025, [100.0, 200.0, 300.0]), (2026, [0.0, 200.0, 300.0])): + years.append(dataset([(weight, [1]) for weight in weights], year=year)) + simulation = Microsimulation(dataset=UKMultiYearDataset(datasets=years)) + + simulation.subsample(n=6, seed=0, time_period=2025) + + first = FIRST_HOUSEHOLD_ID + kept = ids(simulation, "household") + assert first in kept + weights_2026 = weights_by_id(simulation, 2026) + assert weights_2026[first] == 0 + assert sum(weights_2026.values()) == pytest.approx(500.0) + assert sum(weights_by_id(simulation, 2025).values()) == pytest.approx(600.0) + assert rescale_weights(np.array([0.0, 100, 100]), np.array([1, 1, 0])).tolist() == ( + [0.0, 200.0, 0.0] + ) + + +# Properties of the draw and the weights. + +DRAW_SETTINGS = settings(max_examples=200, deadline=None, derandomize=True) +weight = st.one_of( + st.just(0.0), + st.floats(min_value=1e-3, max_value=1e6, allow_nan=False, allow_infinity=False), +) +weight_lists = st.lists(weight, min_size=1, max_size=40).filter( + lambda weights: max(weights) > 0 +) +seeds = st.one_of(st.integers(), st.text(max_size=8)) + + +@DRAW_SETTINGS +@given( + weights=weight_lists, + n=st.integers(1, 60), + seed=seeds, + quantize=st.booleans(), +) +def test_draws_sum_to_n_and_skip_weightless_households(weights, n, seed, quantize): + weights = np.array(weights) + + counts = draw_household_counts(weights, n, seed, quantize) + + assert counts.shape == weights.shape + assert counts.sum() == n + assert (counts >= 0).all() + assert (counts[weights <= 0] == 0).all() + assert 1 <= (counts > 0).sum() <= n + assert counts.tolist() == draw_household_counts(weights, n, seed, quantize).tolist() + + +@DRAW_SETTINGS +@given( + weights=weight_lists, + n=st.integers(1, 60), + seed=seeds, + quantize=st.booleans(), +) +def test_rescaled_weights_keep_the_total(weights, n, seed, quantize): + weights = np.array(weights) + counts = draw_household_counts(weights, n, seed, quantize) + + rescaled = rescale_weights(weights, counts, quantize) + + assert rescaled.sum() == pytest.approx(weights.sum(), rel=1e-9) + assert (rescaled[counts == 0] == 0).all() + assert (rescaled[counts > 0] > 0).all() + drawn = counts > 0 + shares = rescaled[drawn] / ( + counts[drawn] if quantize else weights[drawn] * counts[drawn] + ) + assert shares == pytest.approx(np.full(drawn.sum(), shares[0]), rel=1e-9) + + +@DRAW_SETTINGS +@given( + weights=weight_lists, + counts=st.data(), + quantize=st.booleans(), +) +def test_a_second_weight_keeps_its_own_total(weights, counts, quantize): + """A weight is rescaled by draws made from another weight, as when a + year's weights are rescaled by a sample drawn in another year.""" + weights = np.array(weights) + counts = np.array( + counts.draw( + st.lists(st.integers(0, 5), min_size=len(weights), max_size=len(weights)) + ) + ) + drawn_weight = (counts * (weights > 0) if quantize else weights * counts).sum() + if weights.sum() > 0 and drawn_weight == 0: + with pytest.raises(ValueError, match="no weight"): + rescale_weights(weights, counts, quantize) + return + + rescaled = rescale_weights(weights, counts, quantize) + + assert rescaled.sum() == pytest.approx(weights.sum(), rel=1e-9) + assert (rescaled[counts == 0] == 0).all() + + +def test_negative_sampling_weights_are_refused_before_drawing(): + """A negative weight would otherwise just never be drawn, and the total + it lowers would be shared out among the drawn households.""" + with pytest.raises(ValueError, match="must not be negative to sample"): + draw_household_counts(np.array([1.0, -2.0, 3.0]), 2, 0) + + +def test_weights_without_a_total_are_refused_or_stay_zero(): + counts = np.array([1, 2]) + assert rescale_weights(np.zeros(2), counts).tolist() == [0.0, 0.0] + for weights in ([np.nan, 1.0], [np.inf, 1.0]): + with pytest.raises(ValueError, match="finite total"): + rescale_weights(np.array(weights), counts) + with pytest.raises(ValueError, match="finite"): + draw_household_counts(np.array([np.inf, 1.0]), 2, 0) + + +def mean_new_weights(weights, n, seeds, quantize): + return np.mean( + [ + rescale_weights( + weights, draw_household_counts(weights, n, seed, quantize), quantize + ) + for seed in range(seeds) + ], + axis=0, + ) + + +def test_a_households_mean_quantized_weight_is_its_old_weight(): + weights = np.array([1.0, 2.0, 3.0, 4.0, 0.0, 10.0, 30.0]) + + mean = mean_new_weights(weights, n=40, seeds=3_000, quantize=True) + + assert mean == pytest.approx(weights, rel=0.03) + + +def test_unquantized_weights_are_a_ratio_estimate(): + """Intended: keeping the total exactly makes the unquantized weights a + ratio estimate, so a household's mean new weight is off by a few per cent + in a small sample and closes on its old weight as the sample grows.""" + weights = np.array([1.0, 2.0, 3.0, 4.0, 0.0, 10.0, 30.0]) + positive = weights > 0 + + def error(n): + mean = mean_new_weights(weights, n=n, seeds=1_500, quantize=False) + return np.abs(mean[positive] / weights[positive] - 1).max() + + assert 0.04 < error(40) < 0.10 + assert error(400) < 0.02 + + +def test_another_periods_mean_weight_follows_the_sampling_periods_shares(): + """Intended: households are drawn by their weights in the sampling period + only, so in another period a household's mean new weight is its share of + the sampling period's total times that period's total. That is its old + weight there only if its share is the same in both periods.""" + sampling = np.array([1.0, 2.0, 3.0, 4.0, 10.0]) + other = np.array([5.0, 1.0, 3.0, 4.0, 2.0]) + expected = sampling / sampling.sum() * other.sum() + mean = np.mean( + [ + rescale_weights(other, draw_household_counts(sampling, 40, seed)) + for seed in range(3_000) + ], + axis=0, + ) + assert mean == pytest.approx(expected, rel=0.03) + assert not np.allclose(mean, other, rtol=0.2) + + +# Properties of a subsampled simulation. + +MAX_HOUSEHOLDS = 7 +SIMULATION_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +household_specs = st.lists( + st.tuples( + st.one_of(st.just(0.0), st.floats(min_value=0.5, max_value=5_000.0)), + st.lists(st.integers(1, 3), min_size=1, max_size=2), + ), + min_size=2, + max_size=MAX_HOUSEHOLDS, +).filter(lambda households: any(weight > 0 for weight, _ in households)) + + +MULTI_YEARS = (2025, 2026, 2027) + + +def multi_year_dataset(households, factors) -> UKMultiYearDataset: + """A dataset of ``households`` for each of MULTI_YEARS, with each + household's weight multiplied by its own factor in each year.""" + return UKMultiYearDataset( + datasets=[ + dataset( + [ + (weight * factor, units) + for (weight, units), factor in zip(households, year_factors) + ], + year=year, + ) + for year, year_factors in zip(MULTI_YEARS, factors) + ] + ) + + +FACTORS = st.lists( + st.lists( + st.sampled_from([0.5, 0.8, 1.0, 1.25, 2.0]), + min_size=MAX_HOUSEHOLDS, + max_size=MAX_HOUSEHOLDS, + ), + min_size=len(MULTI_YEARS), + max_size=len(MULTI_YEARS), +) +# Households whose shares of the total weight change from year to year. +CHANGING_SHARES = dict( + households=[(100.0, [1]), (300.0, [2]), (0.0, [1]), (50.0, [1, 2])], + n=6, + seed=11, + multi_year=True, + factors=[[1.0] * MAX_HOUSEHOLDS, [2.0, 0.5, 1.0, 1.25, 1, 1, 1], [0.5] + [2.0] * 6], + year_index=1, +) + + +@SIMULATION_SETTINGS +@given( + households=household_specs, + n=st.integers(1, 10), + seed=st.integers(0, 10_000), + quantize=st.booleans(), + multi_year=st.booleans(), + factors=FACTORS, + year_index=st.integers(0, len(YEARS) - 1), +) +@example(quantize=True, **CHANGING_SHARES) +@example(quantize=False, **CHANGING_SHARES) +def test_subsample_properties( + households, n, seed, quantize, multi_year, factors, year_index +): + if multi_year: + factors = [year_factors[: len(households)] for year_factors in factors] + years = list(MULTI_YEARS) + else: + years = list(YEARS) + year = years[year_index % len(years)] + + def build(): + if multi_year: + return Microsimulation(dataset=multi_year_dataset(households, factors)) + return Microsimulation(dataset=dataset(households)) + + def subsample(): + return build().subsample( + n=n, seed=seed, time_period=year, quantize_weights=quantize + ) + + full = build() + sample = subsample() + kept = ids(sample, "household") + people = ids(sample, "person") + + # 1. Size. + assert 1 <= len(kept) <= n + full_weights = {period: weights_by_id(full, period) for period in years} + assert all(full_weights[year][household] > 0 for household in kept) + + draws_by_period = {} + for period in years: + weights = weights_by_id(sample, period) + total = sum(full_weights[period].values()) + # 2. Weight totals. + assert sum(weights.values()) == pytest.approx(total, rel=1e-5) + # 3. Quantized weights: whole numbers of draws, of the old weight + # when the weights are not quantized. + shares = np.array( + [ + weights[household] + / (1 if quantize else full_weights[period][household]) + for household in kept + ] + ) + draws = shares * n / shares.sum() + assert np.abs(draws - draws.round()).max() < 1e-2 + assert draws.round().sum() == n + draws_by_period[period] = draws.round().tolist() + # The same draws set the weights of every period. + assert len({tuple(draws) for draws in draws_by_period.values()}) == 1 + + # 4. Links. + for entity in ("household", "benunit"): + full_members = members(full, entity) + sample_members = members(sample, entity) + assert all(sample_members.values()) + assert sample_members == { + group: full_members[group] for group in sample_members + } + assert set(people) == set().union(*members(sample, "household").values()) + assert set(people) == set().union(*members(sample, "benunit").values()) + assert len(set(people)) == len(people) + + # 5. Inputs. + assert supplied(sample) == supplied(full) + for period in years: + for variable in ("age", "employment_income", "region", "council_tax"): + expected = by_id(full, variable, period) + assert by_id(sample, variable, period) == { + record: expected[record] + for record in ids( + sample, sample.tax_benefit_system.get_variable(variable).entity.key + ) + } + + # 6. Determinism. + again = subsample() + assert ids(again, "person") == people + for period in years: + assert weights_by_id(again, period) == weights_by_id(sample, period) + + # 7. Baseline. + assert ids(sample.baseline, "household") == kept + assert ids(sample.baseline, "person") == people + assert weights_by_id(sample.baseline, year) == weights_by_id(sample, year) + + # 8. Dataset, and a simulation built from it. + rebuilt = Microsimulation(dataset=sample.dataset) + for entity in ("person", "benunit", "household"): + assert ids(rebuilt, entity) == ids(sample, entity) + assert getattr(sample.dataset[year], entity)[f"{entity}_id"].tolist() == ids( + sample, entity + ) + for period in years: + assert list(weights_by_id(rebuilt, period).values()) == pytest.approx( + list(weights_by_id(sample, period).values()), rel=1e-5 + ) + for variable in ("household_net_income", "income_tax", "universal_credit"): + assert values(rebuilt, variable, year).tolist() == pytest.approx( + values(sample, variable, year).tolist() + ) diff --git a/policyengine_uk/tests/test_supplied_inputs.py b/policyengine_uk/tests/test_supplied_inputs.py new file mode 100644 index 0000000000..d06f4b8201 --- /dev/null +++ b/policyengine_uk/tests/test_supplied_inputs.py @@ -0,0 +1,471 @@ +"""Only a supplied value that is still stored counts as a supplied input. + +``supplied_input`` and ``supplied_input_periods`` tell formulas whether a value +was given to the simulation as an input (``set_input``) rather than filled in +by the engine. policyengine-core keeps its record of an input +(``Simulation._user_input_keys``) after a value is cached over the input, so +the helpers read a supplied value only from a storage tier (memory or disk) +that still holds it (#2307). Before, they counted a value cached over an input +as supplied, and read an input stored on disk from memory when a value was +cached over it there. + +Invariants, for every sequence of inputs set, values cached (calculated or +not), arrays deleted and calculations run, on a branch, on the branch it was +made from and on the default branch, in memory or on disk: + +1. Survival (model): ``supplied_input`` returns the input on the nearest of + those branches that still stores one for the period, reading memory before + disk; otherwise None. An input stays stored until it is deleted, or + another input or a value is cached over it in the same tier. A value + cached in memory over an input on disk leaves the input. A value the + engine calculates never replaces an input. +2. Agreement: ``supplied_input`` returns a value for a period exactly when + ``supplied_input_periods`` lists the period, and the list is sorted + earliest first. +3. Differential: for every recorded input, the helpers count it as supplied + exactly when policyengine-core's ``Holder._stores_user_input`` does. On + policyengine-core 3.33.1 and later, ``supplied_input`` returns what + ``Simulation._get_recorded_input_array`` returns, and from 3.33.2 + ``supplied_input_periods`` lists the periods + ``Simulation._get_set_input_periods`` does. +4. Calculation: calculating, on the default branch and on a branch, leaves + every supplied value of every variable in ``SUPPLIED_INPUT_VARIABLES`` + supplied and unchanged. No calculation caches a value over a supplied + input, so in normal use the helpers gave the right answer before the fix + too. + +Intended: a neutralised variable whose input is still stored reads as its +default, as it does through the engine. +""" + +from contextlib import contextmanager, nullcontext +from types import SimpleNamespace + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.data_storage import OnDiskStorage +from policyengine_core.enums import Enum +from policyengine_core.periods import period as period_ +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.utils.supplied_inputs import ( + SUPPLIED_INPUT_VARIABLES, + stored_supplied_value, + supplied_input, + supplied_input_periods, +) + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def single_person(person=None): + return Simulation( + situation={ + "people": {"person": {"age": {2025: 40}, **(person or {})}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + ) + + +@contextmanager +def under_memory_pressure(holder, folder): + """Store what ``holder`` stores next on disk, unless it already holds a + value for that period in memory, as a simulation over its memory limit + does.""" + if holder._disk_storage is None: + holder._disk_storage = OnDiskStorage(str(folder), preserve_storage_dir=True) + holder._on_disk_storable = True + # A memory limit no simulation can stay under. + holder.simulation.memory_config = SimpleNamespace(max_memory_occupation_pc=-1) + try: + yield + finally: + holder._on_disk_storable = False + holder.simulation.memory_config = None + + +def read(population, variable, year): + value = supplied_input(population, variable, period_(year)) + return None if value is None else value.tolist() + + +def recorded_input_array(simulation, variable, year): + """policyengine-core's own reading of the supplied value, from 3.33.1.""" + value = simulation._get_recorded_input_array(variable, period_(year)) + return None if value is None else np.asarray(value).tolist() + + +def stored_period(sim, variable, year): + """The period a test stores ``variable`` for: ``year``, or its first month + for a monthly variable.""" + definition = sim.tax_benefit_system.get_variable(variable) + return period_(f"{year}-01" if definition.definition_period == "month" else year) + + +def value_of_type(sim, variable, number): + """A one-person value of ``variable``'s type, built from ``number``: the + number itself, True for a boolean, or one of an enum's values.""" + definition = sim.tax_benefit_system.get_variable(variable) + if definition.value_type is Enum: + names = [item.name for item in definition.possible_values] + return definition.possible_values.encode( + np.array([names[int(number) % len(names)]]) + ) + if definition.value_type is bool: + return np.array([bool(number)]) + return np.array([number], dtype=definition.dtype) + + +@pytest.mark.parametrize("variable", sorted(SUPPLIED_INPUT_VARIABLES)) +def test_a_value_cached_over_an_input_is_not_supplied(variable): + sim = single_person() + holder = sim.get_holder(variable) + population = holder.population + period = stored_period(sim, variable, 2025) + supplied = value_of_type(sim, variable, 1) + cached = value_of_type(sim, variable, 0) + sim.set_input(variable, period, supplied) + assert supplied_input(population, variable, period).tolist() == supplied.tolist() + + holder.put_in_cache(cached, period) + + assert holder.get_array(period).tolist() == cached.tolist() + assert supplied_input(population, variable, period) is None + assert supplied_input_periods(population, variable) == [] + assert not holder._stores_user_input(period, "default") + if hasattr(sim, "_get_recorded_input_array"): + assert sim._get_recorded_input_array(variable, period) is None + + +def test_other_years_inputs_are_still_supplied(): + sim = single_person({"trading_loss": {2024: 100.0, 2025: 200.0, 2026: 300.0}}) + holder = sim.get_holder("trading_loss") + holder.put_in_cache(np.array([999.0]), period_(2025)) + population = holder.population + assert [read(population, "trading_loss", year) for year in (2024, 2025, 2026)] == [ + [100.0], + None, + [300.0], + ] + assert supplied_input_periods(population, "trading_loss") == [ + period_(2024), + period_(2026), + ] + + +def test_an_input_on_disk_under_a_value_cached_in_memory_is_supplied(tmp_path): + sim = single_person() + holder = sim.get_holder("trading_loss") + with under_memory_pressure(holder, tmp_path): + sim.set_input("trading_loss", 2025, [100.0]) + assert holder._disk_storage.has(period_(2025), "default") + assert not holder._memory_storage.has(period_(2025), "default") + + holder.put_in_cache(np.array([999.0]), period_(2025)) + + # The engine reads the cached value, but the input is still stored. + assert holder.get_array(period_(2025)).tolist() == [999.0] + population = holder.population + assert read(population, "trading_loss", 2025) == [100.0] + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + assert holder._stores_user_input(period_(2025), "default") + if hasattr(sim, "_get_recorded_input_array"): + assert recorded_input_array(sim, "trading_loss", 2025) == [100.0] + + +def test_a_value_cached_over_an_input_on_disk_is_not_supplied(tmp_path): + sim = single_person() + holder = sim.get_holder("trading_loss") + with under_memory_pressure(holder, tmp_path): + sim.set_input("trading_loss", 2025, [100.0]) + holder.put_in_cache(np.array([999.0]), period_(2025)) + assert holder._disk_storage.get(period_(2025)).tolist() == [999.0] + assert not holder._memory_storage.has(period_(2025), "default") + + population = holder.population + assert read(population, "trading_loss", 2025) is None + assert supplied_input_periods(population, "trading_loss") == [] + assert not holder._stores_user_input(period_(2025), "default") + + +def test_a_calculated_value_is_never_stored_over_an_input(tmp_path): + """policyengine-core stores a calculated (derived) value over nothing the + engine reads as an input, in memory or on disk.""" + sim = single_person() + holder = sim.get_holder("trading_loss") + sim.set_input("trading_loss", 2025, [100.0]) + with under_memory_pressure(holder, tmp_path): + sim.set_input("trading_loss", 2026, [200.0]) + holder.put_in_cache(np.array([999.0]), period_(2026), derived=True) + holder.put_in_cache(np.array([999.0]), period_(2025), derived=True) + holder.put_in_cache(np.array([999.0]), period_(2026), derived=True) + assert holder.get_array(period_(2025)).tolist() == [100.0] + assert holder.get_array(period_(2026)).tolist() == [200.0] + population = holder.population + assert read(population, "trading_loss", 2025) == [100.0] + assert read(population, "trading_loss", 2026) == [200.0] + + +def test_the_nearest_branch_still_storing_an_input_is_read(): + sim = single_person({"trading_loss": {2025: 100.0}}) + branch = sim.get_branch("reform") + population = branch.get_variable_population("trading_loss") + # The branch reads the input of the simulation it was made from. + assert read(population, "trading_loss", 2025) == [100.0] + + branch.set_input("trading_loss", 2025, [200.0]) + assert read(population, "trading_loss", 2025) == [200.0] + # The simulation the branch was made from does not see the branch's input. + assert read(sim.get_variable_population("trading_loss"), "trading_loss", 2025) == [ + 100.0 + ] + + holder = branch.get_holder("trading_loss") + holder.put_in_cache(np.array([999.0]), period_(2025), "reform") + # The branch's input is gone, so the input it was made from is read, as + # policyengine-core reads it. + assert read(population, "trading_loss", 2025) == [100.0] + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + if hasattr(branch, "_get_recorded_input_array"): + assert recorded_input_array(branch, "trading_loss", 2025) == [100.0] + + holder.put_in_cache(np.array([998.0]), period_(2025), "default") + assert read(population, "trading_loss", 2025) is None + assert supplied_input_periods(population, "trading_loss") == [] + + +class neutralize_trading_loss(Reform): + def apply(self): + self.neutralize_variable("trading_loss") + + +def test_a_neutralised_input_reads_as_its_default_only_while_it_is_stored(): + sim = single_person({"trading_loss": {2025: 100.0, 2026: 200.0}}) + sim.apply_reform(neutralize_trading_loss) + holder = sim.get_holder("trading_loss") + holder.put_in_cache(np.array([999.0]), period_(2026)) + population = holder.population + assert read(population, "trading_loss", 2025) == [0.0] + assert read(population, "trading_loss", 2026) is None + assert supplied_input_periods(population, "trading_loss") == [period_(2025)] + + +CALCULATED = [ + "household_net_income", + "income_tax", + "ni_class_4", + "ni_class_4_losses_carried_forward", + "ni_class_4_trading_loss", + "ni_class_4_losses_brought_forward", + "trading_loss", + "person_bus_fare_spending", + "bus_fare_spending", + "local_bus_trips", +] + + +def assert_supplied(sim, expected): + for variable in SUPPLIED_INPUT_VARIABLES: + population = sim.get_variable_population(variable) + stored = sorted( + (period for name, period in expected if name == variable), + key=lambda period: period.start, + ) + assert supplied_input_periods(population, variable) == stored, variable + for period in stored: + assert ( + supplied_input(population, variable, period).tolist() + == expected[variable, period] + ), (variable, period) + for variable, branch_name, period in sim._user_input_keys: + assert sim.get_holder(variable)._stores_user_input(period, branch_name) + + +def test_calculating_leaves_every_supplied_value_supplied(): + situation = { + "self_employment_income": {year: 30_000 for year in range(2024, 2028)}, + "employment_income": {year: 10_000 for year in range(2024, 2028)}, + } + sim = single_person(situation) + # The situation's registered inputs, then a value of every other + # registered variable for 2025 and 2026. + expected = { + (variable, period_(year)): [float(value)] + for variable, values in situation.items() + if variable in SUPPLIED_INPUT_VARIABLES + for year, value in values.items() + } + for year in (2025, 2026): + for number, variable in enumerate(sorted(SUPPLIED_INPUT_VARIABLES), 1): + if variable in situation: + continue + period = stored_period(sim, variable, year) + value = value_of_type(sim, variable, number + year) + sim.set_input(variable, period, value) + expected[variable, period] = value.tolist() + branch = sim.get_branch("reform") + branch.set_input("trading_loss", 2027, [123.0]) + branch_expected = {**expected, ("trading_loss", period_(2027)): [123.0]} + + for simulation in (sim, branch): + for year in (2024, 2025, 2026, 2027, 2028): + for variable in CALCULATED: + simulation.calculate(variable, year) + + assert_supplied(sim, expected) + assert_supplied(branch, branch_expected) + + +BRANCHES = ("child", "parent", "default") # nearest first +YEARS = (2024, 2025, 2026) +step = st.one_of( + # Set an input, on disk if under memory pressure. + st.tuples( + st.just("set"), st.sampled_from(BRANCHES), st.sampled_from(YEARS), st.booleans() + ), + # Cache a value, calculated (derived) or not, on disk if under pressure. + st.tuples( + st.just("cache"), + st.sampled_from(BRANCHES), + st.sampled_from(YEARS), + st.booleans(), + st.booleans(), + ), + st.tuples(st.just("delete"), st.sampled_from(BRANCHES), st.sampled_from(YEARS)), + st.tuples( + st.just("calculate"), + st.sampled_from(["trading_loss", "ni_class_4_losses_brought_forward"]), + st.sampled_from(YEARS + (2027,)), + ), +) + +_templates = [] + + +def branch_chain(): + """A branch ("child") of a branch ("parent") of a simulation.""" + if not _templates: + _templates.append(single_person()) + root = _templates[0].clone(clone_tax_benefit_system=False) + return root.get_branch("parent").get_branch("child") + + +def tier_holding(holder, period, branch_name, value): + """The tier that holds ``value`` for ``period`` on ``branch_name``.""" + for tier, storage in ( + ("memory", holder._memory_storage), + ("disk", holder._disk_storage), + ): + if ( + storage is not None + and storage.has(period, branch_name) + and storage.get(period, branch_name).tolist() == [value] + ): + return tier + return None + + +def expected_supplied(stored, year): + """Invariant 1: the input on the nearest branch, memory before disk.""" + for branch_name in BRANCHES: + for tier in ("memory", "disk"): + if (branch_name, year, tier) in stored: + return [stored[branch_name, year, tier]] + return None + + +@settings( + max_examples=100, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(steps=st.lists(step, max_size=10)) +# A value cached over the input in the same tier (#2307). +@example(steps=[("set", "child", 2025, False), ("cache", "child", 2025, False, False)]) +# An input on disk under a value cached in memory. +@example(steps=[("set", "child", 2025, True), ("cache", "child", 2025, False, False)]) +# A value cached over the child's input leaves the parent's. +@example( + steps=[ + ("set", "parent", 2025, False), + ("set", "child", 2025, True), + ("cache", "child", 2025, False, True), + ] +) +# An input in memory over an earlier input on disk is read, as the engine +# reads it. +@example(steps=[("set", "child", 2025, True), ("set", "child", 2025, False)]) +# From policyengine-core 3.33, the first input drops the cached value, so it +# and the second input go to disk (found by Hypothesis). +@example( + steps=[ + ("cache", "child", 2024, False, False), + ("set", "child", 2024, True), + ("set", "child", 2024, True), + ] +) +def test_only_inputs_still_stored_are_supplied(tmp_path_factory, steps): + child = branch_chain() + holder = child.get_holder("trading_loss") + population = holder.population + folder = tmp_path_factory.mktemp("disk") + # The surviving inputs, by (branch, year, tier). + stored = {} + for index, (kind, *args) in enumerate(steps): + if kind in ("set", "cache"): + branch_name, year, *flags = args + pressure = flags[-1] + period = period_(year) + # Every value written is new, so the tier holding it shows where + # core stored it. (Under pressure, core stores on disk unless + # memory holds a value for the period; from 3.33, setting an input + # first drops cached values that may depend on it.) + value = (1.0 if kind == "set" else 10_000.0) + index + with under_memory_pressure(holder, folder) if pressure else nullcontext(): + if kind == "set": + holder.set_input(period, np.array([value]), branch_name) + else: + derived = flags[0] + holder.put_in_cache(np.array([value]), period, branch_name, derived) + tier = tier_holding(holder, period, branch_name, value) + if kind == "set": + assert tier is not None + stored[branch_name, year, tier] = value + elif tier is not None: + # A calculated value never replaces an input. + assert not (derived and (branch_name, year, tier) in stored) + stored.pop((branch_name, year, tier), None) + elif kind == "delete": + branch_name, year = args + holder.delete_arrays(period_(year), branch_name) + for tier in ("memory", "disk"): + stored.pop((branch_name, year, tier), None) + else: + variable, year = args + child.calculate(variable, year) + + supplied_years = [] + for year in YEARS: + expected = expected_supplied(stored, year) + assert read(population, "trading_loss", year) == expected, (year, stored) + if expected is not None: + supplied_years.append(year) + if hasattr(child, "_get_recorded_input_array"): + assert recorded_input_array(child, "trading_loss", year) == expected + # Invariant 2. + periods = supplied_input_periods(population, "trading_loss") + assert periods == [period_(year) for year in supplied_years] + if hasattr(child, "_get_set_input_periods"): + assert set(child._get_set_input_periods("trading_loss")) == set(periods) + # Invariant 3, for every recorded input of the variable. + for variable, branch_name, period in child._user_input_keys: + if variable == "trading_loss": + ours = stored_supplied_value(holder, period, branch_name) + assert (ours is not None) == holder._stores_user_input( + period, branch_name + ), (branch_name, period) diff --git a/policyengine_uk/tests/test_tax_band_scottish_properties.py b/policyengine_uk/tests/test_tax_band_scottish_properties.py new file mode 100644 index 0000000000..e8e7dcae64 --- /dev/null +++ b/policyengine_uk/tests/test_tax_band_scottish_properties.py @@ -0,0 +1,445 @@ +"""Property tests for a Scottish taxpayer's tax_band and the Marriage +Allowance income conditions (#2350). + +tax_band returns the highest band in which a person has taxable income. For a +Scottish taxpayer the liability charges income other than savings and +dividends at the Scottish rates (earned_income_tax and property_income_tax, +ITA 2007 s. 11A) and savings and dividends at the rest-of-UK savings and +dividend rates (savings_income_tax and dividend_income_tax, ss. 11D(6) and +13(5)). These tests read the band off that liability: they raise the rates of +one band at a time by RISE and see which bands' rates each person pays. + +These tests use single adults, so the model grants them no Marriage +Allowance. We set the starting-rate amount, Personal Savings Allowance and +dividend allowance to zero, so the model taxes every taxable pound at a band's +positive rate, and for every single Scottish taxpayer: + +- a person in a band pays tax at that band's rates on some income and at no + higher band's rates, and a person with no band pays no tax; +- the Marriage Allowance income conditions hold exactly when the person pays + nothing at the higher or additional rates (s. 55B(2)(b)). + +With the baseline nil-rate amounts, the model keeps nil-rate income in its +underlying band, and no one pays tax at the higher or additional rates above +their band. + +The Marriage Allowance income conditions do not read the band. With the +baseline nil-rate amounts and with reformed limits and amounts, for every +single person in England, and in Scotland from 2017-18, they hold exactly when +the liability charges no income at a rate s. 55B(2)(b) forbids and, with the +dividend allowance removed (s. 55B(2)(ba)), none at the dividend upper or +additional rate. So savings at the savings nil rate or the starting rate for +savings never stop the allowance. The reforms start the dividend upper rate at +the basic rate limit, where Parliament starts it (s. 13(2)), and hold the +starting rate limit for savings at or below the basic rate limit. In that +range the model's savings liability places the savings nil rate where +Parliament does (s. 12A). + +Before #2350 the band compared all taxable income with the Scottish limits. +Against that formula, which ran from 2019-20, the band never rises, except +from STARTER to BASIC where savings or dividends sit below the Scottish +starter limit: they sit in the rest-of-UK basic band (where the model charges +the starting rate for savings, the dividend nil rate or the dividend ordinary +rate), not at the Scottish starter rate. +""" + +from contextlib import contextmanager + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +# Whole-pound limits and allowances, with Scottish schedules of three (2017-18), +# five (2018-19 to 2023-24) and six rates (from 2024-25). +YEARS = list(range(2017, 2027)) +PEOPLE = 16 +# Raising a band's rates by 100 percentage points measures the income the +# model taxes at those rates. These inputs use whole pounds, and the model +# rounds the tapered personal allowance up to a pound. SOME allows for float32 +# rounding. +RISE = 1.0 +SOME = 0.25 +BANDS = ["NONE", "STARTER", "BASIC", "INTERMEDIATE", "HIGHER", "ADDITIONAL"] +RANK = {band: rank for rank, band in enumerate(BANDS)} +SCOTTISH = "gov.hmrc.income_tax.rates.scotland.rates[{}].rate" +UK = "gov.hmrc.income_tax.rates.uk[{}].rate" +SAVINGS = "gov.hmrc.income_tax.rates.savings.{}" +DIVIDENDS = "gov.hmrc.income_tax.rates.dividends[{}].rate" +# The rates charged in each band: the Scottish rates on non-savings income, +# the savings rates and the dividend rates. ADDITIONAL covers the Scottish +# advanced and top rates. +BAND_RATES = { + "STARTER": [SCOTTISH.format(0)], + "BASIC": [SCOTTISH.format(1), SAVINGS.format("basic"), DIVIDENDS.format(0)], + "INTERMEDIATE": [SCOTTISH.format(2)], + "HIGHER": [SCOTTISH.format(3), SAVINGS.format("higher"), DIVIDENDS.format(1)], + "ADDITIONAL": [ + SCOTTISH.format(4), + SCOTTISH.format(5), + SAVINGS.format("additional"), + DIVIDENDS.format(2), + DIVIDENDS.format(3), + ], +} +# The starting rate for savings, the Personal Savings Allowance and the +# dividend allowance. +NIL_RATE_AMOUNTS = [ + "gov.hmrc.income_tax.rates.savings_starter_rate.allowance", + "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic", + "gov.hmrc.income_tax.allowances.personal_savings_allowance.higher", + "gov.hmrc.income_tax.allowances.personal_savings_allowance.additional", + "gov.hmrc.income_tax.allowances.dividend_allowance", +] +LIABILITY = [ + "earned_income_tax", + "property_income_tax", + "savings_income_tax", + "dividend_income_tax", +] +INCOMES = [ + "employment_income", + "property_income", + "savings_interest_income", + "dividend_income", + "gift_aid", +] + +pounds = st.integers(min_value=0, max_value=60_000) +# Pay at the 2025-26 Scottish and rest-of-UK limits, the taper and the top. +pay = st.one_of( + st.integers(min_value=0, max_value=160_000), + st.sampled_from([12_570, 15_397, 27_491, 43_662, 50_270, 75_000, 100_001]), +) +person = st.tuples( + pay, + st.one_of(st.just(0), pounds), # property income + st.one_of(st.just(0), pounds), # savings interest + st.one_of(st.just(0), pounds), # dividends + # Gift Aid in multiples of £4 grosses up to whole pounds at 20%. + st.one_of( + st.just(0), st.integers(min_value=0, max_value=1_000).map(lambda n: 4 * n) + ), +) +people = st.lists(person, min_size=PEOPLE, max_size=PEOPLE) +year = st.sampled_from(YEARS) + +# The issue's cases, 2025-26: pay and dividends of 40,000 and 5,000, 60,000 and +# 20,000, and 30,000 and 25,000; the same with savings; both limits at once. +ISSUE = [ + (40_000, 0, 0, 5_000, 0), + (60_000, 0, 0, 20_000, 0), + (30_000, 0, 0, 25_000, 0), + (40_000, 0, 5_000, 0, 0), + (60_000, 0, 20_000, 0, 0), + (30_000, 0, 25_000, 0, 0), + (43_662, 0, 0, 6_608, 0), + (5_000, 0, 1_000, 1_000, 0), +] +ISSUE += [(0, 0, 0, 0, 0)] * (PEOPLE - len(ISSUE)) + + +def situation(): + return { + "people": {f"person_{i}": {"age": {YEARS[0]: 40}} for i in range(PEOPLE)}, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "country": {year: "SCOTLAND" for year in YEARS}, + } + for i in range(PEOPLE) + }, + } + + +@pytest.fixture(scope="module") +def sim(): + return Simulation(situation=situation()) + + +def set_people(sim, drawn, period): + for variable, values in zip(INCOMES, zip(*drawn)): + sim.set_input(variable, period, np.array(values, dtype=float)) + sim.reset_calculations() + + +def set_law(sim, period, values): + """Set parameter values for the year on the simulation's own system.""" + parameters = sim.tax_benefit_system.parameters + for path, value in values.items(): + parameters.get_child(path).update(period=str(period), value=value) + sim.tax_benefit_system.reset_parameter_caches() + sim.reset_calculations() + + +@contextmanager +def law(sim, period, values): + parameters = sim.tax_benefit_system.parameters + before = {path: parameters.get_child(path)(str(period)) for path in values} + set_law(sim, period, values) + try: + yield + finally: + set_law(sim, period, before) + + +def liability(sim, period): + return sum(sim.calculate(v, period).astype(float) for v in LIABILITY) + + +def charged_at(sim, period, paths): + """Measure the income the model taxes at these rates by raising only + these rates by RISE and dividing the tax increase by RISE.""" + parameters = sim.tax_benefit_system.parameters + before = liability(sim, period) + rates = {path: parameters.get_child(path)(str(period)) for path in paths} + # Skip Scottish rates that Parliament had not set that year. + raised = {path: rate + RISE for path, rate in rates.items() if rate is not None} + with law(sim, period, raised): + return (liability(sim, period) - before) / RISE + + +def charged(sim, period, bands): + """The income charged at each band's rates.""" + return {band: charged_at(sim, period, BAND_RATES[band]) for band in bands} + + +def band_of(sim, period): + return np.asarray(sim.calculate("tax_band", period), dtype=str) + + +SETTINGS = settings( + max_examples=8, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(people, year) +@example(ISSUE, 2025) +def test_band_is_the_highest_rate_charged_without_nil_rates(sim, drawn, period): + set_people(sim, drawn, period) + with law(sim, period, {path: 0 for path in NIL_RATE_AMOUNTS}): + band = band_of(sim, period) + meets = np.asarray( + sim.calculate("meets_marriage_allowance_income_conditions", period) + ) + income_at = charged(sim, period, BAND_RATES) + for i, b in enumerate(band): + case = f"{drawn[i]} in {period}: {b}, {[income_at[x][i] for x in income_at]}" + if b == "NONE": + assert all(abs(income_at[x][i]) < SOME for x in income_at), case + continue + assert income_at[b][i] > SOME, case + for above in BANDS[RANK[b] + 1 :]: + assert abs(income_at[above][i]) < SOME, case + above_intermediate = (income_at["HIGHER"] > SOME) | (income_at["ADDITIONAL"] > SOME) + np.testing.assert_array_equal(meets, ~above_intermediate, err_msg=str(drawn)) + + +@SETTINGS +@given(people, year) +@example(ISSUE, 2025) +def test_no_one_pays_higher_rates_above_their_band(sim, drawn, period): + set_people(sim, drawn, period) + band = band_of(sim, period) + tax = liability(sim, period) + # Counts by marginal rate use these bands. + income_at = charged(sim, period, ["HIGHER", "ADDITIONAL"]) + for i, b in enumerate(band): + case = f"{drawn[i]} in {period}: {b}, {[income_at[x][i] for x in income_at]}" + if b == "NONE": + assert abs(tax[i]) < SOME, case + for above in income_at: + if RANK[above] > RANK[b]: + assert abs(income_at[above][i]) < SOME, case + + +def band_before_2350(sim, period): + """tax_band's Scottish formula from 2019-20 before #2350: all taxable + income against the Scottish limits, the next band from each limit on.""" + scale = sim.tax_benefit_system.parameters( + str(period) + ).gov.hmrc.income_tax.rates.scotland.rates + income = sim.calculate("net_income", period) - sim.calculate("allowances", period) + gift = sim.calculate("gift_aid_grossed_up", period) + return np.select( + [ + income < threshold + (gift if index >= 2 else 0) + for index, threshold in enumerate(scale.thresholds[:5]) + ], + BANDS[:5], + default="ADDITIONAL", + ) + + +@SETTINGS +@given(people, st.sampled_from(YEARS[2:])) +@example(ISSUE, 2025) +def test_band_never_rises_except_from_starter_to_basic(sim, drawn, period): + set_people(sim, drawn, period) + band = band_of(sim, period) + before = band_before_2350(sim, period) + taxable = sim.calculate("net_income", period) - sim.calculate("allowances", period) + non_savings = sim.calculate("non_savings_income_after_allowances", period) + starter_limit = sim.tax_benefit_system.parameters( + str(period) + ).gov.hmrc.income_tax.rates.scotland.rates.thresholds[1] + for i in range(PEOPLE): + if RANK[band[i]] <= RANK[before[i]]: + continue + # Savings or dividends below the starter limit sit in the basic band. + case = f"{drawn[i]} in {period}: {before[i]} to {band[i]}" + assert (before[i], band[i]) == ("STARTER", "BASIC"), case + assert non_savings[i] < taxable[i] < starter_limit, case + + +def test_savings_allowance_does_not_read_the_band(): + # Parliament sets the Personal Savings Allowance on the rest-of-UK bands + # for everyone (ITA 2007 s. 12B(8)), so savings_allowance (#2347) never + # reads tax_band: forcing the band changes nothing. + sim = Simulation(situation=situation()) + set_people(sim, ISSUE, 2025) + allowance = sim.calculate("savings_allowance", 2025) + np.testing.assert_array_equal(allowance[:6], [1_000, 500, 500, 1_000, 500, 500]) + for band in BANDS: + sim.set_input("tax_band", 2025, np.array([band] * PEOPLE)) + sim.reset_calculations() + np.testing.assert_array_equal( + sim.calculate("savings_allowance", 2025), allowance + ) + + +# The Marriage Allowance income conditions against the liability, with the +# baseline nil-rate amounts and with reformed limits and amounts: people +# alternate between Scotland and England, from 2016-17, the first year of the +# savings and dividend nil rates. Before 2017-18 the model charges a Scottish +# taxpayer's non-savings income on its 2017-18 Scottish scale, where Parliament +# charged it on the rest-of-UK limits (s. 11A(1)). The Marriage Allowance test +# uses the basic rate limit there, as Parliament did, so this test leaves those +# people out. +MA_YEARS = list(range(2016, 2027)) +MA_COUNTRIES = ["SCOTLAND", "ENGLAND"] +FIRST_SCOTTISH_LIMITS_YEAR = 2017 +# The rates Parliament forbids in s. 55B(2)(b), other than the dividend rates: +# the Scottish higher, advanced and top rates, the higher and additional rates, +# and the savings higher and additional rates. Each person pays at most one of +# the Scottish and rest-of-UK schedules on non-savings income. +FORBIDDEN = ( + [SCOTTISH.format(i) for i in (3, 4, 5)] + + [UK.format(i) for i in (1, 2, 3)] + + [SAVINGS.format("higher"), SAVINGS.format("additional")] +) +DIVIDEND_UPPER = [DIVIDENDS.format(i) for i in (1, 2, 3)] +DIVIDEND_ALLOWANCE = "gov.hmrc.income_tax.allowances.dividend_allowance" +BASIC_RATE_LIMIT = "gov.hmrc.income_tax.rates.uk[1].threshold" +DIVIDEND_UPPER_START = "gov.hmrc.income_tax.rates.dividends[1].threshold" +STARTING_RATE_LIMIT = "gov.hmrc.income_tax.rates.savings_starter_rate.allowance" +# Rate limits and nil-rate amounts a reform might change, each drawn or left +# at the law in force. +LIMITS = { + "gov.hmrc.income_tax.rates.scotland.rates[3].threshold": st.integers( + 20_000, 50_000 + ), + BASIC_RATE_LIMIT: st.integers(25_000, 50_000), + STARTING_RATE_LIMIT: st.integers(0, 60_000), + "gov.hmrc.income_tax.allowances.personal_savings_allowance.basic": st.integers( + 0, 10_000 + ), + "gov.hmrc.income_tax.allowances.personal_savings_allowance.higher": st.integers( + 0, 10_000 + ), + DIVIDEND_ALLOWANCE: st.integers(0, 10_000), +} +limits = st.fixed_dictionaries({}, optional=LIMITS) + +# The Scottish witness from the #2350 review: pay that leaves 37,700 of +# taxable income and 1 of interest, under a Scottish higher rate moved to +# 37,702. Then, in England, 500 of interest above 37,500 of taxable pay, all +# covered by the higher-rate Personal Savings Allowance, and pay at the basic +# rate limit. +NIL_RATE = [(0, 0, 0, 0, 0)] * PEOPLE +NIL_RATE[0] = (50_270, 0, 1, 0, 0) +NIL_RATE[1] = (50_070, 0, 500, 0, 0) +NIL_RATE[3] = (50_270, 0, 0, 0, 0) +WITNESS_LIMITS = {"gov.hmrc.income_tax.rates.scotland.rates[3].threshold": 37_702} + + +@pytest.fixture(scope="module") +def mixed_sim(): + situation = { + "people": {f"person_{i}": {"age": {MA_YEARS[0]: 40}} for i in range(PEOPLE)}, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "country": { + year: MA_COUNTRIES[i % len(MA_COUNTRIES)] for year in MA_YEARS + }, + } + for i in range(PEOPLE) + }, + } + return Simulation(situation=situation) + + +@SETTINGS +@given(people, st.sampled_from(MA_YEARS), limits) +@example(NIL_RATE, 2025, WITNESS_LIMITS) +@example(NIL_RATE, 2025, {}) +def test_marriage_allowance_follows_the_liability_with_nil_rates( + mixed_sim, drawn, period, changes +): + sim = mixed_sim + set_people(sim, drawn, period) + parameters = sim.tax_benefit_system.parameters + # Skip Scottish rates that Parliament had not set that year. + changes = { + path: value + for path, value in changes.items() + if parameters.get_child(path)(str(period)) is not None + } + basic_rate_limit = changes.get( + BASIC_RATE_LIMIT, parameters.get_child(BASIC_RATE_LIMIT)(str(period)) + ) + # Parliament starts the dividend upper rate at the basic rate limit + # (ss. 10(3) and 13(2)), so a reformed limit moves the model's dividend + # scale too. + changes[DIVIDEND_UPPER_START] = basic_rate_limit + # The model's savings liability starts the savings nil rate where the + # starting rate for savings ends. That matches where Parliament places it + # (s. 12A) only while the starting rate limit stays at or below the basic + # rate limit. + if STARTING_RATE_LIMIT in changes: + changes[STARTING_RATE_LIMIT] = min( + changes[STARTING_RATE_LIMIT], basic_rate_limit + ) + with law(sim, period, changes): + meets = np.asarray( + sim.calculate("meets_marriage_allowance_income_conditions", period) + ) + forbidden = charged_at(sim, period, FORBIDDEN) + with law(sim, period, {DIVIDEND_ALLOWANCE: 0}): + dividends_above_basic = charged_at(sim, period, DIVIDEND_UPPER) + checked = np.array( + [ + period >= FIRST_SCOTTISH_LIMITS_YEAR + or MA_COUNTRIES[i % len(MA_COUNTRIES)] != "SCOTLAND" + for i in range(PEOPLE) + ] + ) + np.testing.assert_array_equal( + meets[checked], + ((forbidden < SOME) & (dividends_above_basic < SOME))[checked], + err_msg=f"{drawn} in {period} with {changes}", + ) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index 7bc8837dd7..f1e2d25f51 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -13,12 +13,15 @@ is that less the pension contributions given relief once (own contributions up to the greater of £3,600 and pay plus profits, none from age 75). 2. Differential: a person's Income Tax before the winter fuel payment - charge with a loss equals the same tax of the same person without the - loss and with their income cut by the relief, taken from non-savings - income first, then savings, then dividends: the model's fixed allowance - order (s.25(2) asks for the order giving the greatest reduction; #2106). - The charge itself can differ between the two: it tests total income, - which the relief, at Step 2, does not lower but the cut does. + charge with a loss never exceeds the same tax of the same person without + the loss and with their income cut by the relief, taken from non-savings + income first, then savings, then dividends. The model sets the relief and + allowances together where they give the least tax (s. 25(2)), and the + cut, with the allowances set against it, is one share the model weighs. + Where the person has no savings or dividend income, every share is that + cut, so the two taxes agree. The charge itself can differ between the + two: it tests total income, which the relief, at Step 2, does not lower + but the cut does. 3. Income Tax before the winter fuel payment charge never rises with the loss and falls by at most the relief (for people without savings income: the personal savings allowance steps up by £500 when someone stops being @@ -48,7 +51,11 @@ and self-employed earnings is held alongside them, 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. + entitlement to those reliefs without offsetting PC earnings. Scotland's + and Wales's working-age Council Tax Reduction takes a Universal Credit + claimant's earnings from the same assessment (SSI 2021/249 regs 49 and + 51(2); WSI 2013/3029 Sch 6 para 9), so the comparison holds that figure + at its with-loss value too. 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. @@ -317,8 +324,10 @@ def portfolio_holders(draw): def reduced(person): - """The person's income cut by the relief, in the s.25 order the model - uses, computed outside the model.""" + """The person's income cut by the relief in the order non-savings, + savings, dividends, which this test computes outside the model. The model + sets the relief and allowances where they give the least tax (ITA 2007 + s. 25(2)), so it charges no more tax than on this cut.""" pay = person["employment_income"] pension = person["private_pension_income"] savings = person["savings_interest_income"] @@ -385,7 +394,7 @@ def reduced(person): st.lists(portfolio_holders(), min_size=8, max_size=16), st.lists(st.sampled_from(REGIONS), min_size=16, max_size=16), ) -def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): +def test_relief_never_taxes_more_than_income_cut_in_the_old_order(people, regions): twins = [reduced(p)[0] for p in people] # The winter fuel payment charge tests total income, before Step 2 # reliefs (ITEPA 2003 s.681I(1)(b), ITA 2007 s.23), so a relief and an @@ -399,9 +408,15 @@ def test_relief_taxes_like_income_cut_in_section_25_order(people, regions): assert abs(a["trade_loss_relief_against_general_income"][i] - relief) <= ( tolerance(relief) ), person - assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + assert a[tax][i] - b[tax][i] <= 0.5 + tolerance( a[tax][i], person["employment_income"] ), (person, a[tax][i], b[tax][i]) + # With no savings or dividend income, the relief and allowances can + # come only off non-savings income, so every share is the cut. + if person["savings_interest_income"] == 0 and person["dividend_income"] == 0: + assert abs(a[tax][i] - b[tax][i]) <= 0.5 + tolerance( + a[tax][i], person["employment_income"] + ), (person, a[tax][i], b[tax][i]) # A pensioner with a tiny Pension Credit award: Pension Credit income @@ -996,11 +1011,18 @@ def values(sim, variable): # 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. + # Council Tax Reduction reads income tax on branches that zero earnings, + # so holding the person's tax would also hold it on those branches. Hold + # the scheme's deduction from the with-loss side instead. pc_tax = { v: values(with_loss, v) for v in [ "pension_credit_employment_income_tax", "pension_credit_self_employment_income_tax", + "council_tax_reduction_tax_deduction", + # The Welsh working-age route disregards the tax on pensions and + # benefits, which it reads from earned_income_tax, not a held tax. + "council_tax_reduction_working_age_unearned_income_tax", ] } loss = values(with_loss, "trading_loss") @@ -1019,6 +1041,21 @@ def values(sim, variable): v: values(with_loss, v) for v in ["uc_income_tax_on_trade", "uc_national_insurance_on_trade"] } + # Scotland's and Wales's working-age schemes take a Universal Credit + # claimant's earnings from the Universal Credit assessment, so the loss + # reaches them through that figure as well as the tax. Hold it as the + # award is held. Without Universal Credit their earnings take the trade's + # part of the tax off the total (_pay_and_miscellaneous_tax). + uc_assessment = { + **uc_trade_deductions, + **{ + v: values(with_loss, v) + for v in [ + "uc_individual_earned_income", + "uc_individual_earned_income_before_mif", + ] + }, + } def held(k, **extra): return { @@ -1033,7 +1070,10 @@ def held(k, **extra): no_loss = Simulation( situation=family_situation( units, - {name: held(k) for k, name in enumerate(names)}, + { + name: held(k, **{v: float(uc_assessment[v][k]) for v in uc_assessment}) + for k, name in enumerate(names) + }, { b: { "universal_credit": float(uc[k]), diff --git a/policyengine_uk/tests/test_uc_carer_element_award_properties.py b/policyengine_uk/tests/test_uc_carer_element_award_properties.py new file mode 100644 index 0000000000..af81bb545c --- /dev/null +++ b/policyengine_uk/tests/test_uc_carer_element_award_properties.py @@ -0,0 +1,902 @@ +"""Universal Credit carer-element awards as a carer benefit. + +HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b) (with the IS, JSA, ESA, +pension-age Housing Benefit and council tax reduction schedules) and SPC Regs +2002 Sch I para 1(1) and (4) withhold the severe disability premium and the +Pension Credit severe disability addition where someone "has an award of +universal credit which includes the carer element in respect of caring for" +the claimant or partner. Scotland's working-age council tax reduction (SSI +2021/249 Sch 1 paras 5 and 6(1)(c), from 1 April 2022) pays the carer premium +to an applicant or partner with such an award. + +The severe disability carer test (has_uc_award_with_carer_element) reads the +award before the benefit cap, universal_credit_pre_benefit_cap, since the cap +reads Pension Credit, or the award itself where the simulation stores it. +Scotland's carer premium reads the positive award (receives_uc_carer_element) +in every year. Each example simulates the households as drawn, and again with +has_uc_award_with_carer_element supplied as false for everyone, which leaves +receives_carer_benefit as the attribution's only source of carers. +carer_premium gives the Scottish premium's counterpart without the limb. + +Invariants, on generated households under baseline law: + +1. No change without a carer element: where no one has + has_uc_award_with_carer_element or receives_uc_carer_element, the + attribution, the severe disability premiums and the addition equal their + values without the limb, and the Scottish premium equals carer_premium, + exactly. +2. Never raises: no carer element raises the severe disability premium (the + shared, council tax reduction and pension-age premiums) or the Pension + Credit addition. +3. Never lowers, and only in Scotland: council_tax_reduction_carer_premium + never falls below carer_premium, and differs from it only for a Scottish + working-age family. +4. Bounds: the Scottish premium comes to a whole number of weekly amounts, at most + the number of claimants and partners, and at most one where partners care + for the same person; a person with both an allowance and a carer element + counts once. +5. The carer test is the award: has_uc_award_with_carer_element equals + receives_uc_carer_element for every person, and + universal_credit_pre_benefit_cap comes to more than nil wherever + universal_credit does, and for every family whose award includes the + carer element in respect of someone, exactly where universal_credit does. + The equality also holds where the simulation stores the award. +6. Attribution: the attribution makes no one their own carer, everyone it + counts as cared for meets Carer's Allowance's severe disability test, and + no household has more people cared for than carers (carer benefit + recipients and carer element holders). +7. No cycle: every reader calculates on one simulation, whichever the test + requests first, and also where the simulation stores the award. +8. The cut: the non-dependant deduction's Pension Credit exemption reads the + non-dependant family's Pension Credit in a branch with every housing costs + element set to nil, where the carer test reads the award without housing + costs. test_the_non_dependant_branch_reads_the_award_without_housing_costs + pins that reading in three request orders, for a household where the + branch's result changes no deduction, and + test_the_branch_decides_a_blind_partners_exemption pins a household where + the branch's result decides one member's exemption, and the deduction once + the renter's para 15 exemption is supplied as false. + +A reform that makes is_uc_eligible read Pension Credit would cycle through the +attribution, which reads eligibility; these tests do not cover reforms. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEAR = 2026 +WEEKS = 52 +CARER_PREMIUM = 48.15 +PROPERTY_SETTINGS = settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +SEVERE_READERS = ( + "severe_disability_premium", + "severe_disability_premium_before_non_dependant_test", + "council_tax_reduction_severe_disability_premium", + "pension_age_severe_disability_premium", + "severe_disability_minimum_guarantee_addition", +) +CARER_READERS = ( + "carer_premium", + "council_tax_reduction_carer_premium", + "council_tax_reduction_premiums", +) +BENUNIT_READERS = SEVERE_READERS + CARER_READERS +PERSON_READERS = ( + "is_cared_for_by_carer_benefit_recipient", + "has_uc_award_with_carer_element", + "receives_uc_carer_element", +) +# Every reader of the attribution or the carer element award, and the +# programmes that read them, for the cycle check. +ALL_READERS = BENUNIT_READERS + ( + "is_cared_for_by_carer_benefit_recipient", + "has_uc_award_with_carer_element", + "receives_uc_carer_element", + "universal_credit_pre_benefit_cap", + "universal_credit", + "pension_credit", + "housing_benefit", + "income_support", + "council_tax_reduction", + "household_net_income", +) + + +DISABILITY = { + "none": {}, + "pip_standard": {"pip_dl_category": "STANDARD"}, + "aa_higher": {"aa_category": "HIGHER"}, + "dla_middle": {"dla_sc_category": "MIDDLE"}, +} + + +@st.composite +def adult(draw, age): + return dict( + age=draw(age), + disability=draw(st.sampled_from(["none", "none", "pip_standard", "aa_higher"])), + blind=draw(st.sampled_from([False, False, True])), + # Draw the qualifying hours with or without a Carer's Allowance claim: + # DWP includes the Universal Credit carer element without a claim and + # above the allowance's earnings limit (UC Regs 2013 reg 30(1)(a)(i) + # and (2)). + care_hours=draw(st.sampled_from([0, 35, 35])), + would_claim_carers_allowance=draw(st.booleans()), + earnings=draw(st.sampled_from([0, 0, 8_000, 25_000])), + ) + + +@st.composite +def benefit_unit(draw): + # Working-age or pension-age adults, partners within ten years of each + # other, so no adult reads as the other's child. + eldest = draw(st.integers(20, 80)) + ages = st.integers(max(18, eldest - 10), eldest) + adults = [draw(adult(st.just(eldest)))] + adults += draw(st.lists(adult(ages), max_size=1)) + children = draw( + st.lists( + st.fixed_dictionaries( + dict( + age=st.integers(0, 15), + disability=st.sampled_from(["none", "dla_middle"]), + ) + ), + max_size=1, + ) + ) + return dict(adults=adults, children=children) + + +@st.composite +def households(draw): + # DWP includes housing costs for renters and deducts an amount for each + # non-dependant in another benefit unit. + tenure, rent = draw( + st.sampled_from( + [ + ("OWNED_OUTRIGHT", 0), + ("RENT_PRIVATELY", 6_000), + ("RENT_PRIVATELY", 12_000), + ("RENT_FROM_COUNCIL", 6_000), + ] + ) + ) + return dict( + country=draw(st.sampled_from(["ENGLAND", "SCOTLAND", "WALES"])), + tenure=tenure, + rent=rent, + units=draw(st.lists(benefit_unit(), min_size=1, max_size=3)), + ) + + +def situation(population, without_limb=()): + """One situation; households in without_limb have no carer element award.""" + people, benunits, hh = {}, {}, {} + for h, household in enumerate(population): + members = [] + for u, unit in enumerate(household["units"]): + unit_members = [] + for a, p in enumerate(unit["adults"]): + name = f"h{h}_u{u}_a{a}" + people[name] = { + "age": {YEAR: p["age"]}, + "is_blind": {YEAR: p["blind"]}, + "care_hours": {YEAR: p["care_hours"]}, + "would_claim_carers_allowance": { + YEAR: p["would_claim_carers_allowance"] + }, + "employment_income": {YEAR: p["earnings"]}, + "is_claimant_or_partner": {YEAR: True}, + **{k: {YEAR: v} for k, v in DISABILITY[p["disability"]].items()}, + } + if h in without_limb: + people[name]["has_uc_award_with_carer_element"] = {YEAR: False} + unit_members.append(name) + for c, p in enumerate(unit["children"]): + name = f"h{h}_u{u}_c{c}" + people[name] = { + "age": {YEAR: p["age"]}, + "is_claimant_or_partner": {YEAR: False}, + **{k: {YEAR: v} for k, v in DISABILITY[p["disability"]].items()}, + } + unit_members.append(name) + benunits[f"h{h}_u{u}"] = {"members": unit_members} + members += unit_members + hh[f"h{h}"] = {"members": members, "country": {YEAR: household["country"]}} + if "tenure" in household: + hh[f"h{h}"]["tenure_type"] = {YEAR: household["tenure"]} + hh[f"h{h}"]["rent"] = {YEAR: household["rent"]} + return {"people": people, "benunits": benunits, "households": hh} + + +def with_and_without_limb(population, variables): + """Each variable for the households as drawn, and without the limb. + + Without the limb, a second simulation supplies + has_uc_award_with_carer_element as false for everyone: a variable supplied + for some people takes its default, not its formula, for the rest. + """ + sim = Simulation(situation=situation(population)) + sim_without = Simulation( + situation=situation(population, without_limb=range(len(population))) + ) + return sim, { + v: ( + np.asarray(sim.calculate(v, YEAR)), + np.asarray(sim_without.calculate(v, YEAR)), + ) + for v in variables + } + + +def household_index(sim, entity): + """The household each benefit unit or person belongs to, by position.""" + population = sim.populations[entity] + names = population.ids + return np.array([int(str(name).split("_")[0][1:]) for name in names]) + + +def single(age=40, **inputs): + return dict( + dict( + age=age, + disability="none", + blind=False, + care_hours=0, + would_claim_carers_allowance=False, + earnings=0, + ), + **inputs, + ) + + +EXAMPLES = [ + # A blind carer's Universal Credit carer element, in another benefit + # unit, bars the disabled person's premium. + dict( + country="ENGLAND", + units=[ + dict(adults=[single(50, disability="pip_standard")], children=[]), + dict(adults=[single(40, blind=True, care_hours=35)], children=[]), + ], + ), + # The same carer earning enough to taper the award to nil. + dict( + country="ENGLAND", + units=[ + dict(adults=[single(50, disability="pip_standard")], children=[]), + dict( + adults=[single(40, blind=True, care_hours=35, earnings=25_000)], + children=[], + ), + ], + ), + # The Scottish carer of the issue, aged 40 with 35 hours and no claim. + dict(country="SCOTLAND", units=[dict(adults=[single(care_hours=35)], children=[])]), + # A Scottish couple, one claiming the allowance, both caring. + dict( + country="SCOTLAND", + units=[ + dict( + adults=[ + single(45, care_hours=35), + single(40, care_hours=35, would_claim_carers_allowance=True), + ], + children=[dict(age=6, disability="dla_middle")], + ) + ], + ), + # A pensioner whose son, on a qualifying benefit, cares for her. + dict( + country="WALES", + units=[ + dict(adults=[single(75, disability="aa_higher")], children=[]), + dict( + adults=[single(40, disability="pip_standard", care_hours=35)], + children=[], + ), + ], + ), +] + + +def test_issue_households_read_the_carer_element_award(): + # In #2120 the blind carer's award includes the carer element, 209.34 x + # 12, so the disabled person has no severe disability premium (86.05 x 52 + # without it). In #2124 the Scottish carer's applicable amount includes + # the carer premium, (95.55 + 48.15) x 52, not 95.55 x 52. + sim = Simulation(situation=situation(EXAMPLES[:1] + EXAMPLES[2:3])) + severe = sim.calculate("severe_disability_premium", YEAR) + carer_element = sim.calculate("uc_carer_element", YEAR) + applicable = sim.calculate("council_tax_reduction_applicable_amount", YEAR) + # Benefit units: the disabled person, the blind carer, the Scottish carer. + assert carer_element == pytest.approx([0, 209.34 * 12, 209.34 * 12]) + assert severe[0] == 0 + assert applicable[2] == pytest.approx((95.55 + 48.15) * WEEKS) + + +@PROPERTY_SETTINGS +@given(st.lists(households(), min_size=1, max_size=4)) +@example(EXAMPLES) +def test_the_carer_element_limb_moves_only_its_readers_and_only_one_way(population): + variables = SEVERE_READERS + ( + "carer_premium", + "council_tax_reduction_carer_premium", + "council_tax_reduction_pensioner", + "partners_care_for_same_severely_disabled_person", + "is_cared_for_by_carer_benefit_recipient", + "is_severely_disabled_for_carers_allowance", + "has_uc_award_with_carer_element", + "receives_uc_carer_element", + "receives_carer_benefit", + "is_claimant_or_partner", + "universal_credit_pre_benefit_cap", + "universal_credit", + "uc_carer_element", + ) + sim, values = with_and_without_limb(population, variables) + drawn = {v: pair[0] for v, pair in values.items()} + without = {v: pair[1] for v, pair in values.items()} + n_benunits = len(drawn["carer_premium"]) + benunit_household = household_index(sim, "benunit") + person_household = household_index(sim, "person") + + # 5. The carer test is the award: the award before the cap is positive + # wherever the final award is, and exactly where it is for a family whose + # award includes the carer element, so the two tests agree. + award = drawn["universal_credit_pre_benefit_cap"] + final = drawn["universal_credit"] + assert not ((final > 0) & ~(award > 0)).any() + carer_family = drawn["uc_carer_element"] > 0 + assert ((award > 0) == (final > 0))[carer_family].all() + has_award = drawn["has_uc_award_with_carer_element"] + positive_award = drawn["receives_uc_carer_element"] + assert (has_award == positive_award).all() + + # 1. No change without a carer element, household by household. + touched = list(set(person_household[has_award | positive_award])) + untouched_units = ~np.isin(benunit_household, touched) + untouched_people = ~np.isin(person_household, touched) + for v in SEVERE_READERS: + assert (drawn[v][untouched_units] == without[v][untouched_units]).all(), v + cared_for = drawn["is_cared_for_by_carer_benefit_recipient"] + cared_for_without = without["is_cared_for_by_carer_benefit_recipient"] + assert (cared_for[untouched_people] == cared_for_without[untouched_people]).all() + ctr = drawn["council_tax_reduction_carer_premium"] + shared = drawn["carer_premium"] + assert (ctr[untouched_units] == shared[untouched_units]).all() + + # 2. A carer element never raises the severe disability premium or the + # addition, and never removes anyone from the cared-for set. + for v in SEVERE_READERS: + assert (drawn[v] <= without[v]).all(), v + assert not (cared_for_without & ~cared_for).any() + + # 3. The Scottish premium never lowers the carer premium and moves it + # only in Scotland at working age. + assert (ctr >= shared).all() + countries = sim.calculate("country", YEAR) + if hasattr(countries, "decode_to_str"): + countries = countries.decode_to_str() + country = dict(zip(sim.populations["household"].ids, np.asarray(countries))) + scottish = np.array([country[f"h{h}"] == "SCOTLAND" for h in benunit_household]) + scottish_working_age = scottish & ~drawn["council_tax_reduction_pensioner"] + assert (ctr[~scottish_working_age] == shared[~scottish_working_age]).all() + + # 4. Whole weekly amounts, at most one per claimant or partner, one where + # partners care for the same person. + weekly = ctr / (CARER_PREMIUM * WEEKS) + assert np.allclose(weekly, np.round(weekly)) + benunit_position = { + name: i for i, name in enumerate(sim.populations["benunit"].ids) + } + person_benunit = [ + benunit_position[name.rsplit("_", 1)[0]] + for name in sim.populations["person"].ids + ] + claimants = np.bincount( + person_benunit, weights=drawn["is_claimant_or_partner"], minlength=n_benunits + ) + assert (np.round(weekly) <= claimants).all() + same_person = drawn["partners_care_for_same_severely_disabled_person"] + assert (np.round(weekly[same_person]) <= 1).all() + + # 6. Everyone cared for meets Carer's Allowance's severe disability test; no + # household has more people cared for than carers, and the attribution + # never counts a lone carer as cared for. + assert not (cared_for & ~drawn["is_severely_disabled_for_carers_allowance"]).any() + carer = drawn["receives_carer_benefit"] | has_award + for h in np.unique(person_household): + in_h = person_household == h + assert cared_for[in_h].sum() <= carer[in_h].sum() + if carer[in_h].sum() == 1: + assert not (cared_for & carer)[in_h].any() + + +READER_ORDERS = st.permutations(ALL_READERS) + + +@settings( + max_examples=3, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(households(), min_size=1, max_size=3), READER_ORDERS) +@example(EXAMPLES, ALL_READERS) +@example(EXAMPLES, tuple(reversed(ALL_READERS))) +def test_every_reader_calculates_on_one_simulation_in_any_order(population, order): + # A CycleError, or the recursion it guards, would fail this test. + sim = Simulation(situation=situation(population)) + for variable in order: + values = np.asarray(sim.calculate(variable, YEAR)) + assert np.isfinite(values.astype(float)).all(), variable + + +def stored_award_situation(stored=True): + """The issue's blind carer on £20,000, which tapers the award the model + works out to nil, with neither family claiming; the situation supplies + the award instead: nil for the disabled person's family, £100 for the carer's.""" + sim_situation = situation( + [ + dict( + country="ENGLAND", + units=[ + dict(adults=[single(50, disability="pip_standard")], children=[]), + dict( + adults=[single(40, blind=True, care_hours=35, earnings=20_000)], + children=[], + ), + ], + ) + ] + ) + for unit, award in (("h0_u0", 0), ("h0_u1", 100)): + sim_situation["benunits"][unit]["claims_universal_credit"] = {YEAR: False} + if stored: + sim_situation["benunits"][unit]["universal_credit"] = {YEAR: award} + return sim_situation + + +@pytest.mark.parametrize("order", [ALL_READERS, tuple(reversed(ALL_READERS))]) +def test_a_stored_award_is_the_award_and_reads_no_cycle(order): + # A stored award reads no other variable, so the carer test reads it: the + # carer's £100 award includes the carer element and bars the disabled + # person's premium, 86.05 x 52 = 4,474.60 without it. Every reader then + # calculates on the one simulation (invariants 5 and 7). + sim = Simulation(situation=stored_award_situation()) + for variable in order: + values = np.asarray(sim.calculate(variable, YEAR)) + assert np.isfinite(values.astype(float)).all(), variable + has_award = sim.calculate("has_uc_award_with_carer_element", YEAR) + receives = sim.calculate("receives_uc_carer_element", YEAR) + assert receives.tolist() == [False, True] + assert has_award.tolist() == receives.tolist() + assert sim.calculate("is_cared_for_by_carer_benefit_recipient", YEAR).tolist() == [ + True, + False, + ] + assert sim.calculate("severe_disability_premium", YEAR).tolist() == [0, 0] + # Without the stored award neither family claims, so no one has an award. + unstored = Simulation(situation=stored_award_situation(stored=False)) + assert not unstored.calculate("has_uc_award_with_carer_element", YEAR).any() + assert unstored.calculate("severe_disability_premium", YEAR)[0] == pytest.approx( + 86.05 * WEEKS + ) + + +def test_a_reform_that_abolishes_universal_credit_leaves_no_carer_element_award(): + # A neutralized award reads no other variable and comes to nil, so no one + # has an award, and the issue's blind carer does not bar the premium. + from policyengine_core.reforms import Reform + + class AbolishUniversalCredit(Reform): + def apply(self): + self.neutralize_variable("universal_credit") + + sim = Simulation(situation=situation(EXAMPLES[:1]), reform=AbolishUniversalCredit) + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() + assert sim.calculate("severe_disability_premium", YEAR)[0] == pytest.approx( + 86.05 * WEEKS + ) + + +def require(condition, message): + # Raise RuntimeError when a premise fails, so a failure names the premise + # apart from the premium assertion. + if not condition: + raise RuntimeError(message) + + +def non_dependant_situation(): + """A carer (40) who rents jointly with the disabled person (50) she cares + for, each liable for half of £12,000, and a blind non-dependant (30) + earning £20,000. The carer earns £27,000.""" + people = { + "carer": { + "age": {YEAR: 40}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 27_000}, + "is_household_head": {YEAR: True}, + }, + "disabled": {"age": {YEAR: 50}, "pip_dl_category": {YEAR: "STANDARD"}}, + "non_dependant": { + "age": {YEAR: 30}, + "is_blind": {YEAR: True}, + "employment_income": {YEAR: 20_000}, + }, + } + benunits = { + "carer_unit": {"members": ["carer"], "share_of_household_rent": {YEAR: 0.5}}, + "disabled_unit": { + "members": ["disabled"], + "would_claim_uc": {YEAR: False}, + "liable_for_share_of_household_rent": {YEAR: True}, + "share_of_household_rent": {YEAR: 0.5}, + "severe_disability_premium_joint_occupier_exclusion_applies": {YEAR: True}, + }, + "non_dependant_unit": { + "members": ["non_dependant"], + "would_claim_uc": {YEAR: False}, + }, + } + household = { + "members": ["carer", "disabled", "non_dependant"], + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: "RENT_PRIVATELY"}, + "rent": {YEAR: 12_000}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +def tax_free_childcare_situation(): + """A blind carer (35) earning £60,000 with a child (3) in £12,000 of + childcare, and the disabled person (60) she cares for in another family + of the household.""" + people = { + "carer": { + "age": {YEAR: 35}, + "is_blind": {YEAR: True}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 60_000}, + }, + "child": {"age": {YEAR: 3}, "childcare_expenses": {YEAR: 12_000}}, + "disabled": {"age": {YEAR: 60}, "pip_dl_category": {YEAR: "STANDARD"}}, + } + benunits = { + "carer_unit": {"members": ["carer", "child"]}, + "disabled_unit": {"members": ["disabled"], "would_claim_uc": {YEAR: False}}, + } + household = { + "members": ["carer", "child", "disabled"], + "country": {YEAR: "ENGLAND"}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +def test_a_non_dependant_deduction_that_takes_the_award_to_nil_leaves_no_award(): + # DWP's deduction for the non-dependant (UC Regs 2013 Sch 4 paras 13 and + # 16; blindness brings no exemption, and para 15 does not exempt the + # carer as a renter) takes the carer's award to nil, so she has no award + # (Welfare Reform Act 2012 s.5(1)(b); reg 17). DWP does not count the + # disabled person, a joint tenant, as her non-dependant (para 9(2)(d)), + # the council does not count her as his (HB Regs 2006 reg 3(2)(d)), and a + # blind non-dependant does not bar the premium (Sch 3 para 14(4)). So he + # keeps the premium in law. + sim = Simulation(situation=non_dependant_situation()) + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + deductions = sim.calculate("uc_non_dep_deductions", YEAR) + without_deduction = non_dependant_situation() + without_deduction["benunits"]["carer_unit"]["uc_non_dep_deductions"] = {YEAR: 0} + award_without = Simulation(situation=without_deduction).calculate( + "universal_credit_pre_benefit_cap", YEAR + ) + require(award[0] == 0 and deductions[0] > 0, "the carer's award is not nil") + require(award_without[0] > 0, "the deduction does not take it to nil") + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() + severe = sim.calculate("severe_disability_premium", YEAR) + assert severe[1] == pytest.approx(86.05 * WEEKS) + + +def test_a_family_that_takes_tax_free_childcare_instead_has_no_award(): + # The family gets more from the Tax-Free Childcare top-up than from the + # award, so it takes no award (forgoes_universal_credit_for_tax_free_ + # childcare). No one then has an award in respect of caring for the + # disabled person, so he keeps the premium in law. + sim = Simulation(situation=tax_free_childcare_situation()) + forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + maximum = sim.calculate("uc_maximum_amount", YEAR) + reduction = sim.calculate("uc_income_reduction", YEAR) + require(forgoes[0] and award[0] == 0, "the family claims") + require(maximum[0] > reduction[0], "the award would come to nil anyway") + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() + severe = sim.calculate("severe_disability_premium", YEAR) + assert severe[1] == pytest.approx(86.05 * WEEKS) + + +def small_housing_situation(): + """A blind carer (35) earning £25,000, whom DWP has determined to have + limited capability for work, rents from the council at £600 a year; the + disabled person (60) she cares for lives in another family of the + household.""" + people = { + "carer": { + "age": {YEAR: 35}, + "is_blind": {YEAR: True}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 25_000}, + "uc_limited_capability_for_work": {YEAR: True}, + "is_household_head": {YEAR: True}, + }, + "disabled": {"age": {YEAR: 60}, "pip_dl_category": {YEAR: "STANDARD"}}, + } + benunits = { + "carer_unit": {"members": ["carer"]}, + "disabled_unit": {"members": ["disabled"], "would_claim_uc": {YEAR: False}}, + } + household = { + "members": ["carer", "disabled"], + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: 600}, + } + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +def test_small_housing_costs_with_a_work_allowance_leave_no_award(): + # DWP has determined that the carer has limited capability for work, so + # her award has a work allowance (UC Regs 2013 reg 22(1)(b)), and she + # gets the carer element with it: DWP gives the LCWRA element in its + # place only for limited capability for work and work-related activity + # (reg 29(4)), and ignores the allowance's earnings limit and the missing + # claim (reg 30(1)(a)(i) and (2)). Her award has a housing costs element of + # £600, so DWP applies the lower work allowance, £427 a month (reg + # 22(2)(b)). The taper then exceeds the maximum amount, and the award + # comes to nil (Welfare Reform Act 2012 s.5(1)(b); reg 17), although an + # award with no housing costs element and the higher work allowance, + # £710 a month, would leave £461.10. The council + # ignores the blind carer as a resident (HB Regs 2006 Sch 3 para + # 14(4)(b)), and with no award she does not bar the premium (para + # 14(2)(a)(iii)). So the disabled person keeps the premium in law. + sim = Simulation(situation=small_housing_situation()) + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + housing = sim.calculate("uc_housing_costs_element", YEAR) + allowance = sim.calculate("uc_work_allowance", YEAR) + deductions = sim.calculate("uc_non_dep_deductions", YEAR) + forgoes = sim.calculate("forgoes_universal_credit_for_tax_free_childcare", YEAR) + lcwra = sim.calculate("uc_LCWRA_element", YEAR) + carer_element = sim.calculate("uc_carer_element", YEAR) + require(award[0] == 0, "the carer's award is not nil") + require(housing[0] > 0 and allowance[0] == 427 * 12, "no lower work allowance") + require(deductions[0] == 0 and not forgoes[0], "another route takes it to nil") + require(lcwra[0] == 0 and carer_element[0] > 0, "the award has no carer element") + assert not sim.calculate("has_uc_award_with_carer_element", YEAR).any() + severe = sim.calculate("severe_disability_premium", YEAR) + assert severe[1] == pytest.approx(86.05 * WEEKS) + + +def veteran_situation( + pension_credit=None, veteran_exempt=None, blind_partner=False, renter_exempt=None +): + """A blind carer (40) earning £20,000 rents from the council at £6,000 a + year as household head and cares 35 hours a week for a veteran (70) in + another family of the household, who receives armed forces independence + payment and £13,000 of State Pension. The carer claims alone, so UC Regs + 2013 Sch 4 para 15(1)(a) and (2)(a) exempt the carer, a blind single + renter, from every non-dependant deduction. pension_credit, if given, + supplies the veteran family's uc_non_dep_receives_pension_credit, which + the model otherwise reads from a branch. veteran_exempt, if given, + supplies the veteran's uc_non_dep_deduction_exempt. renter_exempt, if + given, supplies the carer family's uc_non_dep_deductions_renter_exempt. + blind_partner adds the veteran's blind partner (68), who receives no + disability benefit, and raises the veteran's State Pension to £21,000.""" + people = { + "carer": { + "age": {YEAR: 40}, + "is_blind": {YEAR: True}, + "care_hours": {YEAR: 35}, + "would_claim_carers_allowance": {YEAR: False}, + "employment_income": {YEAR: 20_000}, + "is_household_head": {YEAR: True}, + }, + "veteran": { + "age": {YEAR: 70}, + "armed_forces_independence_payment": {YEAR: 150 * WEEKS}, + "state_pension": {YEAR: 13_000}, + }, + } + benunits = { + "carer_unit": {"members": ["carer"]}, + "veteran_unit": {"members": ["veteran"], "would_claim_uc": {YEAR: False}}, + } + if pension_credit is not None: + benunits["veteran_unit"]["uc_non_dep_receives_pension_credit"] = { + YEAR: pension_credit + } + if veteran_exempt is not None: + people["veteran"]["uc_non_dep_deduction_exempt"] = {YEAR: veteran_exempt} + if renter_exempt is not None: + benunits["carer_unit"]["uc_non_dep_deductions_renter_exempt"] = { + YEAR: renter_exempt + } + household = { + "members": ["carer", "veteran"], + "country": {YEAR: "ENGLAND"}, + "tenure_type": {YEAR: "RENT_FROM_COUNCIL"}, + "rent": {YEAR: 6_000}, + } + if blind_partner: + people["veteran"]["state_pension"] = {YEAR: 21_000} + people["partner"] = {"age": {YEAR: 68}, "is_blind": {YEAR: True}} + benunits["veteran_unit"]["members"].append("partner") + household["members"].append("partner") + return {"people": people, "benunits": benunits, "households": {"h": household}} + + +@pytest.mark.parametrize( + "first", ["universal_credit", "pension_credit", "has_uc_award_with_carer_element"] +) +def test_the_non_dependant_branch_reads_the_award_without_housing_costs(first): + # The carer's award: income tax 1,486.00 and National Insurance 594.40 + # leave earned income of 17,919.60, and the taper, 0.55 x 17,919.60 = + # 9,855.78, leaves (424.90 + 209.34) x 12 + 6,000 - 9,855.78 = 3,755.10 + # with no non-dependant deduction. The award includes the carer element, + # so the veteran, whom DWP and the model treat as severely disabled + # through armed forces independence payment, gets no severe disability + # addition. The veteran pays income tax of 86.00 and keeps 12,914.00 of + # State Pension, which exceeds the standard minimum guarantee of 238.00 + # x 52 = 12,376.00, so the veteran gets no Pension Credit. + # + # uc_non_dep_receives_pension_credit reads the veteran's Pension Credit + # in a branch with every housing costs element set to nil. There the + # carer's award, 7,610.88 - 9,855.78, comes to nil, the veteran gets the + # addition (DWP ignores a blind resident, SPC Regs 2002 Sch I para + # 2(2)(b)) and Pension Credit of (238.00 + 86.05) x 52 - 12,914.00 = + # 3,936.60, so the branch returns true. + # + # In law DWP deducts nothing for this household, on two grounds. UC Regs + # 2013 Sch 4 para 15(1)(a) and (2)(a) exempt the carer, a blind single + # renter, from every deduction. Para 16(2)(d) exempts the veteran, who + # receives "attendance allowance" under head (f) of reg 2's definition, + # armed forces independence payment. The model holds both grounds + # (uc_non_dep_deductions_renter_exempt and + # uc_receives_attendance_allowance), so the model's award and the law's + # agree at 3,755.10, and the branch's result changes neither. This test + # pins the model's figures, whichever variable the simulation requests + # first. + sim = Simulation(situation=veteran_situation()) + sim.calculate(first, YEAR) + assert sim.calculate("has_uc_award_with_carer_element", YEAR).tolist() == [ + True, + False, + ] + assert sim.calculate("pension_credit", YEAR).tolist() == [0, 0] + assert sim.calculate("uc_non_dep_receives_pension_credit", YEAR).tolist() == [ + False, + True, + ] + assert sim.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + assert award[0] == pytest.approx(3_755.10, abs=0.01) + # The branch's result set to its value in law, false, since the veteran + # gets no Pension Credit: the model still exempts the veteran through + # armed forces independence payment and deducts nothing. + law = Simulation(situation=veteran_situation(pension_credit=False)) + assert law.calculate("uc_non_dep_deduction_exempt", YEAR).tolist() == [ + False, + True, + ] + assert law.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + law_award = law.calculate("universal_credit_pre_benefit_cap", YEAR) + assert law_award[0] == pytest.approx(3_755.10, abs=0.01) + assert law.calculate("has_uc_award_with_carer_element", YEAR)[0] + assert law.calculate("pension_credit", YEAR)[1] == 0 + # Para 15 removes the deduction on its own: with the veteran's exemption + # supplied as false, the model still deducts nothing. + veteran_liable = Simulation(situation=veteran_situation(veteran_exempt=False)) + assert veteran_liable.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + # A counterfactual that neither the law nor the model gives this + # household: with both exemptions supplied as false, the model deducts + # 96.55 x 12 = 1,158.60 and leaves an award of 3,755.10 - 1,158.60 = + # 2,596.50. The award still includes the carer element, so even a + # deduction would leave the carer test and the veteran's nil Pension + # Credit unchanged. + counterfactual = Simulation( + situation=veteran_situation(veteran_exempt=False, renter_exempt=False) + ) + assert counterfactual.calculate("uc_non_dep_deductions", YEAR)[0] == pytest.approx( + 96.55 * 12 + ) + counterfactual_award = counterfactual.calculate( + "universal_credit_pre_benefit_cap", YEAR + ) + assert counterfactual_award[0] == pytest.approx(2_596.50, abs=0.01) + assert counterfactual.calculate("has_uc_award_with_carer_element", YEAR)[0] + assert counterfactual.calculate("pension_credit", YEAR)[1] == 0 + + +def test_the_branch_decides_a_blind_partners_exemption(): + # The veteran of the test above, now with £21,000 of State Pension and a + # blind partner (68) who receives no disability benefit. Para 16(2) + # exempts the veteran through armed forces independence payment but + # lists no blindness, so only the family's Pension Credit can exempt the + # partner, and the branch's result decides the partner's exemption. + # + # The veteran pays income tax of (21,000 - 12,570) x 0.20 - 1,260 x 0.20 + # = 1,434.00, with the partner's marriage allowance, and the couple keep + # 19,566.00, which exceeds the couple's standard minimum guarantee of + # 363.25 x 52 = 18,889.00, so the couple get no Pension Credit. In the + # branch, the carer's award comes to nil, and SPC Regs 2002 Sch I para + # 1(1)(c) gives the couple the addition (the veteran receives armed + # forces independence payment, the partner is blind, and DWP ignores the + # blind carer as a resident under para 2(2)(b)): Pension Credit comes to + # (363.25 + 86.05) x 52 - 19,566.00 = 3,797.60, the branch returns true, + # and the model exempts the partner and deducts nothing. + # + # In law DWP deducts nothing either, because para 15(1)(a) and (2)(a) + # exempt the carer, a blind single renter, from every deduction; the + # carer's award then comes to 3,755.10, includes the carer element and + # leaves the couple without the addition or Pension Credit. The model + # holds that exemption too, so with the branch's result supplied at its + # value in law, false, it still deducts nothing. With the renter's + # exemption supplied as false as well, it deducts 96.55 x 12 = 1,158.60 + # for the partner and leaves an award of 2,596.50. + sim = Simulation(situation=veteran_situation(blind_partner=True)) + assert sim.calculate("has_uc_award_with_carer_element", YEAR).tolist() == [ + True, + False, + False, + ] + assert sim.calculate("pension_credit", YEAR).tolist() == [0, 0] + assert sim.calculate("uc_non_dep_receives_pension_credit", YEAR).tolist() == [ + False, + True, + ] + assert sim.calculate("uc_non_dep_deduction_exempt", YEAR).tolist() == [ + False, + True, + True, + ] + assert sim.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + award = sim.calculate("universal_credit_pre_benefit_cap", YEAR) + assert award[0] == pytest.approx(3_755.10, abs=0.01) + branch = Simulation(situation=veteran_situation(blind_partner=True)) + branch.set_input("uc_housing_costs_element", YEAR, [0, 0]) + assert branch.calculate("pension_credit", YEAR)[1] == pytest.approx( + 3_797.60, abs=0.01 + ) + flag_false = Simulation( + situation=veteran_situation(pension_credit=False, blind_partner=True) + ) + assert flag_false.calculate("uc_non_dep_deduction_exempt", YEAR).tolist() == [ + False, + True, + False, + ] + assert flag_false.calculate("uc_non_dep_deductions", YEAR)[0] == 0 + no_para_15 = Simulation( + situation=veteran_situation( + pension_credit=False, blind_partner=True, renter_exempt=False + ) + ) + assert no_para_15.calculate("uc_non_dep_deductions", YEAR)[0] == pytest.approx( + 96.55 * 12 + ) + no_para_15_award = no_para_15.calculate("universal_credit_pre_benefit_cap", YEAR) + assert no_para_15_award[0] == pytest.approx(2_596.50, abs=0.01) + assert no_para_15.calculate("has_uc_award_with_carer_element", YEAR)[0] diff --git a/policyengine_uk/tests/test_uc_company_override_properties.py b/policyengine_uk/tests/test_uc_company_override_properties.py index 735847e6b8..1fac837bf1 100644 --- a/policyengine_uk/tests/test_uc_company_override_properties.py +++ b/policyengine_uk/tests/test_uc_company_override_properties.py @@ -20,6 +20,9 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 +# corporate_wealth is valued at 90% for the expenses of sale (UC Regs 2013 +# reg. 49(1)(a)); disregarded holdings inside it leave the pool at that value. +SHARE_VALUE = 0.9 LAYOUTS = { "one-owner": ((10_000,), (), ()), "two-owners-one-unit": ((4_000, 6_000), (), ()), @@ -53,7 +56,7 @@ def _company_person(holding=0, dependant=False): def _grid(layout): - """Combine 960 independent three-unit households in one simulation.""" + """Combine 1,280 independent three-unit households in one simulation.""" people, units, households, cases = {}, {}, {}, [] first_holdings, dependant_holdings, second_holdings = LAYOUTS[layout] claimant_holdings = (sum(first_holdings), sum(second_holdings), 0) @@ -67,7 +70,7 @@ def _grid(layout): ) in enumerate( product( (0, 4_000, 10_000, 20_000), - (None, -1, 10_000), + (None, -1, 4_000, 10_000), (0, 4_000, 10_000, 20_000), (6_000, 20_000), OTHER_REPORTS, @@ -132,14 +135,15 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units for row, (specs, adults, holdings, dependants, corporate, savings) in zip( assessed, cases ): - pool = corporate + savings + # The pool counts corporate_wealth at 90% (reg. 49(1)(a)), and holdings + # inside it leave the pool at that value. + pool = SHARE_VALUE * corporate + savings missing = [ "override" not in spec and spec.get("financial", -1) < 0 for spec in specs ] explicit = [ spec.get("override", max(0, spec.get("financial", -1))) for spec in specs ] - explicit_consumption = sum(explicit) # Invalid holdings are bounded per person, before grouping into units. # Allocate one budget by those ownership shares; never recap a route. bounded_holdings = [ @@ -150,8 +154,22 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units total_holdings = sum(bounded_holdings) + bounded_dependants budget = min(corporate, total_holdings) scale = budget / total_holdings if total_holdings else 0 - effective_holdings = [holding * scale for holding in bounded_holdings] - extra_holding_draw = bounded_dependants * scale + sum( + market_holdings = [holding * scale for holding in bounded_holdings] + effective_holdings = [SHARE_VALUE * holding for holding in market_holdings] + # A recorded financial total holds its owners' holdings at full value; + # the pool counts them at 90% (reg. 49(1)(a)), so the report consumes + # its part beyond the holdings in full and the holdings at 90%. Like an + # override, a report below the holdings still consumes them: a report + # of 4,000 against a 10,000 holding consumes 9,000. + explicit_consumption = sum( + spec["override"] + if "override" in spec + else max(0, spec["financial"] - market) + SHARE_VALUE * market + if spec.get("financial", -1) >= 0 + else 0 + for spec, market in zip(specs, market_holdings) + ) + extra_holding_draw = SHARE_VALUE * bounded_dependants * scale + sum( holding if absent else max(0, holding - spec["override"]) @@ -181,9 +199,20 @@ def test_company_overrides_conserve_capital_and_keep_holdings_out_of_other_units # Account for what regulation 77 actually removes from reporting # owners, without scaling that substitution. This identity also # covers inconsistent observations and exhausted residual pools. + # A complete override is a countable figure, so the holding comes off + # it at 90%. A recorded financial total is used unvalued, so the owner + # loses the holding in full while the pool gives up only its 90% + # value: what the report removes from the pool beyond the owner's own + # remaining capital is its consumption less that capital. owner_substitution = sum( - min(holding, reported) - for holding, reported, absent in zip(holdings, explicit, missing) + min(SHARE_VALUE * holding, reported) + if "override" in spec + else max(0, reported - market) + + SHARE_VALUE * market + - max(0, reported - holding) + for holding, market, reported, absent, spec in zip( + holdings, market_holdings, explicit, missing, specs + ) if not absent ) assert row.sum() + owner_substitution + extra_holding_draw == pytest.approx( @@ -303,7 +332,8 @@ def test_regrouping_holdings_between_holders_and_units_preserves_residual_pool() savings, ) ) - expected.append(savings + max(0, corporate - total)) + # Shares left after the holdings count at 90% (reg. 49(1)(a)). + expected.append(savings + SHARE_VALUE * max(0, corporate - total)) situation, residual_rows = _pool_property_situation(cases) capital = np.asarray( Simulation(situation=situation).calculate("uc_assessable_capital", YEAR) diff --git a/policyengine_uk/tests/test_uc_company_owner_properties.py b/policyengine_uk/tests/test_uc_company_owner_properties.py index beafcec75b..8f383dd15d 100644 --- a/policyengine_uk/tests/test_uc_company_owner_properties.py +++ b/policyengine_uk/tests/test_uc_company_owner_properties.py @@ -27,8 +27,9 @@ not report their capital, the household's capital less every owner's holding (up to its corporate wealth) is shared between the units in proportion to their claimants, and each unit adds its own claimants' - company capital; so the units' capital sums to that pool plus the owners' - company capital. + company capital. Each valuation, owner's company capital and unit's final + capital is rounded to the nearest penny, and the household total sums the + rounded units (reg. 6(1)). 4. Earnings identity: earned income before the floor is pay less the person's own income tax and NI on the employment, plus the trading total floored at zero, less their relievable pension contributions, floored at @@ -66,13 +67,22 @@ holding, whatever the generated examples reach. """ +from decimal import Decimal, ROUND_HALF_UP + import numpy as np +import pytest from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation +from policyengine_uk.utils.capital_valuation import exact_amount + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") YEAR = 2026 +# corporate_wealth (shares) is valued at 90% for the expenses of sale (UC Regs +# 2013 reg. 49(1)(a)), so a disregarded holding inside it comes off at 90%. +SHARE_VALUE = 0.9 PROPERTY_SETTINGS = settings( max_examples=10, deadline=None, @@ -121,6 +131,20 @@ def close(a, b): return abs(a - b) <= 1e-2 + 1e-6 * abs(b) +def stored_amount(value): + """Use the same float32 input precision as the simulation.""" + return float(exact_amount(np.float32(value))) + + +def penny(value): + # UC Regs 2013 reg. 6(1): half a penny is rounded up, for every amount + # calculated (https://www.legislation.gov.uk/uksi/2013/376/regulation/6). + # Remove binary arithmetic noise below a millionth of a penny before + # applying the legal rule, independently of the model rounding helper. + amount = Decimal(str(value)).quantize(Decimal("0.00000001"), rounding=ROUND_HALF_UP) + return float(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)) + + @st.composite def companies(draw): company_capital = draw(st.one_of(st.just(0.0), st.floats(0, 12_000))) @@ -265,17 +289,19 @@ def applies(company): def company_capital_of(company): - """Capital treated as possessed under reg. 77(2), net of 77(3)(a).""" + """Reg. 77(2) capital, net of 77(3)(a), rounded per person (reg. 6(1)).""" engaged = ( company["is_engaged_in_owned_company_trade"] or company["owned_company_is_main_employment"] ) disregarded = ( - company["owned_company_trade_assets"] + stored_amount(company["owned_company_trade_assets"]) if company["owned_company_carries_on_trade"] and engaged else 0.0 ) - return max(0.0, company["owned_company_capital"] - disregarded) + return penny( + max(0.0, stored_amount(company["owned_company_capital"]) - disregarded) + ) def pinned_company(**facts): @@ -488,7 +514,7 @@ def test_capital_and_earnings_identities(unit_list): assert close(earned, max(before, floor)) else: assert close(earned, before) - disregarded = min(holdings, unit["corporate_wealth"]) + disregarded = SHARE_VALUE * min(holdings, unit["corporate_wealth"]) expected = base["uc_assessable_capital"][i] - disregarded + company_capital assert close(got["uc_assessable_capital"][i], expected) @@ -598,9 +624,7 @@ def test_property_company_gives_no_earnings(unit_list, share): np.testing.assert_allclose(a, b, rtol=0, atol=1e-6, err_msg=v) -@PROPERTY_SETTINGS -@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) -@example( +PINNED_ALLOCATIONS = [ [ ( pinned_unit( @@ -610,9 +634,36 @@ def test_property_company_gives_no_earnings(unit_list, share): ), pinned_unit(pinned_adult()), ) - ] -) -def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): + ], + [ + ( + pinned_unit( + pinned_adult(), + pinned_adult(), + corporate_wealth=6392.862111833667, + ), + pinned_unit( + pinned_adult( + pinned_company(owned_company_holding_value=6336.697665836761) + ), + pinned_adult(), + ), + ) + ], + [ + ( + pinned_unit( + pinned_adult(pinned_company(owned_company_holding_value=0.40)), + corporate_wealth=0.65, + ), + pinned_unit(pinned_adult(pinned_company(owned_company_holding_value=0.40))), + ) + ], +] + + +def _allocation_outputs(pairs): + """Return UC capital and the oracle's unit and household totals.""" # Each household holds two benefit units: the first unit's adults and the # second's, with the first unit's savings and corporate wealth. No unit # reports its own capital, so every owner's holding leaves the household @@ -621,7 +672,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs people, benunits, households = {}, {}, {} expected_units, expected_households = [], [] for h, (first, second) in enumerate(pairs): - names, holdings, unit_company_capital = [], 0.0, [] + names, holdings, unit_company_capital = [], [], [] for b, unit in enumerate((first, second)): members, company_capital = [], 0.0 for j, adult in enumerate(unit["adults"]): @@ -634,7 +685,7 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs **{k: {YEAR: v} for k, v in c.items()}, } if applies(c): - holdings += c["owned_company_holding_value"] + holdings.append(c["owned_company_holding_value"]) company_capital += company_capital_of(c) members.append(name) benunits[f"h{h}_b{b}"] = {"members": members} @@ -645,17 +696,61 @@ def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs "savings": {YEAR: first["savings"]}, "corporate_wealth": {YEAR: first["corporate_wealth"]}, } - disregarded = min(holdings, first["corporate_wealth"]) - pool = first["savings"] + first["corporate_wealth"] - disregarded + shares = stored_amount(first["corporate_wealth"]) + # The holdings budget uses raw stored inputs and stores its scale as + # float32. Apply that scale to the recovered monetary amounts before + # valuation; an ideal min(total holdings, shares) loses this rounding + # boundary when holdings exceed the budget. + raw_shares = np.float32(first["corporate_wealth"]) + raw_holdings = sum( + float(np.clip(np.float32(holding), np.float32(0), raw_shares)) + for holding in holdings + ) + scale = float( + np.float32(min(1.0, float(raw_shares) / raw_holdings)) + if raw_holdings > 0 + else np.float32(1.0) + ) + disregarded = sum( + min(stored_amount(holding), shares) * scale for holding in holdings + ) + # Reg. 49(1)(a) values the shares and the removed holdings separately; + # reg. 6(1) rounds each valuation, then each unit's final capital. + pool = max( + 0.0, + stored_amount(first["savings"]) + + penny(SHARE_VALUE * shares) + - penny(SHARE_VALUE * disregarded), + ) claimants = [len(first["adults"]), len(second["adults"])] for n, company_capital in zip(claimants, unit_company_capital): - expected_units.append(pool * n / sum(claimants) + company_capital) - expected_households.append(pool + sum(unit_company_capital)) + expected_units.append(penny(pool * n / sum(claimants) + company_capital)) + # Allocation rounding belongs to each benefit unit. Summing the + # unrounded household pool would lose up to half a penny per unit. + expected_households.append(sum(expected_units[-2:])) sim = Simulation( situation={"people": people, "benunits": benunits, "households": households} ) capital = np.asarray(sim.calculate("uc_assessable_capital", YEAR)) + return capital, expected_units, expected_households + + +@PROPERTY_SETTINGS +@given(st.lists(st.tuples(units(), units()), min_size=1, max_size=3)) +@example(PINNED_ALLOCATIONS[0]) +@example(PINNED_ALLOCATIONS[1]) +@example(PINNED_ALLOCATIONS[2]) +def test_household_capital_is_allocated_and_conserved_across_benefit_units(pairs): + capital, expected_units, expected_households = _allocation_outputs(pairs) assert (capital >= 0).all() + if pairs in PINNED_ALLOCATIONS: + # Each pinned unit's capital equals the penny-rounded oracle exactly + # once the test converts the oracle to float32, so a missing final + # rounding or a lost penny fails. The household totals are sums of + # these exact unit values, so the test checks no separate total here. + assert capital.dtype == np.float32 + assert capital.tolist() == np.float32(expected_units).tolist() + return for got, want in zip(capital, expected_units): assert close(got, want) per_household = capital.reshape(-1, 2).sum(axis=1) diff --git a/policyengine_uk/tests/test_uc_income_tax_properties.py b/policyengine_uk/tests/test_uc_income_tax_properties.py new file mode 100644 index 0000000000..97f4b05284 --- /dev/null +++ b/policyengine_uk/tests/test_uc_income_tax_properties.py @@ -0,0 +1,142 @@ +"""Property tests for the income tax that Universal Credit deducts from +earnings (uc_income_tax_on_earnings and uc_income_tax_on_trade). + +The helper takes earnings as the lowest slice of non-savings income, after the +allowances set against non-savings income first: the model follows PAYE +coding, which sets them against the job. The least-tax share of the reliefs +and allowances (ITA 2007 s. 25(2)) can move the allowances onto savings or +dividends to lower the year's tax, but the slice follows the order +non-savings, savings, dividends, so tax on other income never comes off +earnings: + +- adding savings or dividend income never raises either slice, for every + person whose allowances it leaves unchanged (more income can taper the + Personal Allowance, which raises the tax on earnings for a reason of its + own). +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +PEOPLE = 8 +YEARS = (2025, 2026, 2027, 2028) +REGIONS = ["LONDON", "SCOTLAND", "WALES", "NORTH_EAST"] +INCOMES = ( + "employment_income", + "self_employment_income", + "property_income", + "savings_interest_income", + "dividend_income", + "trading_loss", +) +SLICES = ("uc_income_tax_on_earnings", "uc_income_tax_on_trade") + +amount = st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=20_000), + st.floats(min_value=0, max_value=80_000), + st.sampled_from([5_000.0, 12_570.0, 37_406.0, 37_700.0, 50_270.0]), +) +person = st.tuples(*([amount] * len(INCOMES))) +extra = st.tuples(amount, amount) # savings and dividends added +people = st.lists(st.tuples(person, extra), min_size=PEOPLE, max_size=PEOPLE) +# Self-employment income of £37,406: £14,407 of savings would move the whole +# Personal Allowance onto the savings under the least-tax share. +WITNESS = [((0.0, 37_406.0, 0.0, 0.0, 0.0, 0.0), (14_407.0, 0.0))] + [ + ((0.0,) * len(INCOMES), (0.0, 0.0)) +] * (PEOPLE - 1) + + +def situation(year): + return { + "people": {f"p{i}": {"age": {year: 40}} for i in range(PEOPLE)}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(PEOPLE)}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "region": {year: REGIONS[i % len(REGIONS)]}, + } + for i in range(PEOPLE) + }, + } + + +@pytest.fixture(scope="module") +def sims(): + cache = {} + + def get(year): + if year not in cache: + cache[year] = Simulation(situation=situation(year)) + return cache[year] + + return get + + +def run(sim, year, incomes): + for name, values in incomes.items(): + sim.set_input(name, year, np.asarray(values, dtype=float)) + sim.reset_calculations() + return { + name: sim.calculate(name, year).astype(float) + for name in SLICES + ("allowances", "income_tax") + } + + +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.sampled_from(YEARS), people) +@example(2025, WITNESS) +@example(2026, WITNESS) +def test_more_savings_or_dividends_never_raise_tax_on_earnings(sims, year, drawn): + base_people, added = zip(*drawn) + incomes = { + name: np.array(values) for name, values in zip(INCOMES, zip(*base_people)) + } + savings, dividends = (np.array(values) for values in zip(*added)) + sim = sims(year) + before = run(sim, year, incomes) + more = dict(incomes) + more["savings_interest_income"] = incomes["savings_interest_income"] + savings + more["dividend_income"] = incomes["dividend_income"] + dividends + after = run(sim, year, more) + same_allowances = np.abs(after["allowances"] - before["allowances"]) <= 0.01 + for name in SLICES: + rise = after[name] - before[name] + tolerance = 0.01 + 1e-6 * np.abs(before[name]) + assert (rise <= tolerance)[same_allowances].all(), ( + name, + rise[same_allowances].max(), + ) + + +def test_savings_leave_the_tax_on_self_employment_unchanged(sims): + """Self-employment income of £37,406, alone and with £14,407 of savings, + in 2026-27. With the savings, the least-tax share sets the whole Personal + Allowance against them, and income tax falls from £8,057.20 under the + order non-savings, savings, dividends to £8,016. The tax on the trade + stays at (37,406 - 12,570) x 20% = £4,967.20, as without the savings.""" + year = 2026 + sim = sims(year) + zeros = np.zeros(PEOPLE) + incomes = {name: zeros.copy() for name in INCOMES} + incomes["self_employment_income"][0] = 37_406 + alone = run(sim, year, incomes) + incomes["savings_interest_income"][0] = 14_407 + with_savings = run(sim, year, incomes) + assert alone["uc_income_tax_on_trade"][0] == pytest.approx(4_967.2, abs=0.01) + assert with_savings["uc_income_tax_on_trade"][0] == pytest.approx(4_967.2, abs=0.01) + assert with_savings["uc_income_tax_on_earnings"][0] == pytest.approx( + 4_967.2, abs=0.01 + ) + assert with_savings["income_tax"][0] == pytest.approx(8_016, abs=0.01) diff --git a/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py b/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py index 009f508dd2..f0429f2895 100644 --- a/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py +++ b/policyengine_uk/tests/test_uc_lodger_child_extended_benefit_unit_properties.py @@ -23,8 +23,7 @@ person as the head's Universal Credit non-dependant. The generator keeps everyone in the lodger units under State Pension age, so -no lodger family receives Pension Credit, which would exempt the unit's -non-paying adult from the housing cost contribution. +no lodger family receives Pension Credit. """ import numpy as np diff --git a/policyengine_uk/tests/test_uc_property_capital.py b/policyengine_uk/tests/test_uc_property_capital.py index 02435651c5..bf697457b3 100644 --- a/policyengine_uk/tests/test_uc_property_capital.py +++ b/policyengine_uk/tests/test_uc_property_capital.py @@ -285,7 +285,10 @@ def test_missing_financial_and_spi_style_inputs_retain_the_financial_proxy(finan }, ) situation["people"]["p0_0"]["lifetime_isa_balance"] = {YEAR: 4_000} - assert _values(Simulation(situation=situation))[0] == 18_000 + # 4,000 of savings + 90% of 2,000 of shares and of 10,000 of property + # (reg. 49(1)(a)) + 75% of the 4,000 Lifetime ISA: 4,000 + 1,800 + 9,000 + # + 3,000. + assert _values(Simulation(situation=situation))[0] == 17_800 @pytest.mark.parametrize("financial", [-1, 0, 6_000]) @@ -559,16 +562,21 @@ def test_complete_override_company_holding_cannot_leak_into_missing_unit_pool( # owner's holding. It consumes at least that holding from the pool once, # including when zero or smaller than the holding, and supersedes the # financial observation. + # The holding sits in corporate_wealth, which counts at 90% (reg. + # 49(1)(a)), so it is 9,000 of countable capital and the pool is 6,000 + + # 9,000 = 15,000. + holding = 0.9 * 10_000 assert _values(simulation, "household_uc_pool_company_holdings")[0] == 0 - assert _values(simulation, "household_uc_reported_financial_capital")[0] == max( - complete_override, 10_000 - ) + assert _values(simulation, "household_uc_reported_financial_capital")[ + 0 + ] == pytest.approx(max(complete_override, holding)) np.testing.assert_allclose( _values(simulation), [ - max(0, complete_override - 10_000), - max(0, 16_000 - max(complete_override, 10_000)), + max(0, complete_override - holding), + max(0, 6_000 + holding - max(complete_override, holding)), ], + atol=0.01, ) @@ -595,5 +603,8 @@ def test_owner_override_consumes_company_holding_once_in_twenty_thousand_savings # £30k household pool less the owner's £10k override leaves £20k. The # override includes the holding; removing it again would leave £10k and # incorrectly award the other unit £4,263.60 Universal Credit in 2026. - np.testing.assert_allclose(_values(simulation, year=year), [0, 20_000]) + # With corporate_wealth counted at 90% (reg. 49(1)(a)) the pool is 20,000 + + # 9,000 = 29,000; the override consumes 10,000, leaving 19,000, and the + # owner keeps 10,000 less the 9,000 holding. + np.testing.assert_allclose(_values(simulation, year=year), [1_000, 19_000]) assert _values(simulation, "universal_credit", year=year)[1] == 0 diff --git a/policyengine_uk/tests/test_uc_property_capital_mock.py b/policyengine_uk/tests/test_uc_property_capital_mock.py index 6f32099a88..a712f62d80 100644 --- a/policyengine_uk/tests/test_uc_property_capital_mock.py +++ b/policyengine_uk/tests/test_uc_property_capital_mock.py @@ -192,10 +192,13 @@ def test_mock_owner_override_consumes_its_company_holding_once(tmp_path): ), ) - # The owner's override already contains the holding. The other unit keeps - # the £20k residual and is ineligible for UC after the H5 input round trip. + # The owner's override already contains the holding, counted at the 90% + # at which corporate_wealth is valued (reg. 49(1)(a)): the pool is £20k + + # £9k, the override consumes £10k, and the other unit keeps the £19k + # residual and is ineligible for UC after the H5 input round trip; the + # owner keeps £10k less the £9k holding. np.testing.assert_allclose( - simulation.calculate("uc_assessable_capital", year), [0, 20_000] + simulation.calculate("uc_assessable_capital", year), [1_000, 19_000] ) assert simulation.calculate("universal_credit", year)[1] == 0 @@ -363,11 +366,17 @@ def test_mock_property_valuation_uses_the_assessment_year(tmp_path): assert simulation.calculate("uc_assessable_capital", 2026)[0] == 24_000 -@pytest.mark.parametrize("reported, expected", [(6_000, 6_000), (0, 0), (-1, 56_000)]) -def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( - tmp_path, reported, expected +# Pension Credit retains property at 90% alongside financial-only reports +# (SPC Regs 2002 reg. 19(a)): the report plus 45,000, or 6,000 + 45,000 +# when no financial capital is reported. +@pytest.mark.parametrize( + "reported, expected_report, expected", + [(6_000, -1, 51_000), (0, -1, 45_000), (-1, -1, 51_000)], +) +def test_mock_pension_credit_financial_reports_preserve_property( + tmp_path, reported, expected_report, expected ): - """Changing UC's financial boundary does not change Pension Credit.""" + """A financial observation excludes the separately imputed property.""" simulation = _load_mock( tmp_path, _mock_year( @@ -382,7 +391,10 @@ def test_mock_pension_credit_keeps_its_existing_reported_capital_contract( ) assert simulation.calculate("uc_assessable_capital", YEAR)[0] == 0 - assert simulation.calculate("pension_credit_reported_capital", YEAR)[0] == reported + assert ( + simulation.calculate("pension_credit_reported_capital", YEAR)[0] + == expected_report + ) assert ( simulation.calculate("pension_credit_assessable_capital", YEAR)[0] == expected ) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py new file mode 100644 index 0000000000..1561c2270d --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_existing_benefits_properties.py @@ -0,0 +1,570 @@ +"""Properties of the UC qualifying young person test under SI 2014/1230 reg. 28. + +DWP doesn't count a person entitled to an existing benefit (SI 2014/1230 +reg. 2(1)) as a UC qualifying young person (reg. 28). The predicate reads the +person's own reports of income-based JSA, income-related ESA, Income Support, +Housing Benefit, Child Tax Credit and Working Tax Credit and, for a claimant +or partner, the couple's joint tax credit award (Tax Credits Act 2002 +s.3(3)). It reads a report only in a year the model pays that benefit and +until the model's legacy closure, or for a claimant or partner the family's +Universal Credit claim, ends the award on it, and only if no reform +neutralises it; these tests run without a reform, and +test_uc_qualifying_young_person_neutralised_benefits.py covers reforms. The +model stops paying a benefit when its year gate closes, and ends a person's +legacy awards when its annual migration proxy closes them: their own Income +Support, income-based JSA and income-related ESA when +person_legacy_benefits_closed holds, the family's tax credits when +legacy_benefits_closed holds, and its Housing Benefit when +legacy_benefits_closed holds and the family doesn't meet the Pension Credit +age conditions. A claim for Universal Credit ends the claimant's and +partner's own awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4), +and the model ends the family's continuing Housing Benefit award and its +Child Tax Credit, and the claimant's and partner's income-related ESA, when +the family claims (claims_universal_credit). The oracle reads those closure +variables from the model, and each family draws would_claim_uc. These tests +check that: + +- any positive own report of an existing benefit whose year gate, legacy + closure and, for a claimant or partner, claim limb leave it open makes the + predicate false; +- with every such report at zero, the predicate equals the UC Regs 2013 + reg. 5 test less the person's reported UC, ESA and JSA awards; +- compared with the same family without the claimant's and partner's + reports, those reports change a dependant's predicate only where the + proxy closes the family's legacy awards on them, and then only to lift an + exclusion; +- a person whose legacy awards have closed is never excluded through their + own income-related ESA report, nor through a Housing Benefit report once + the family's awards close outside the Pension Credit age conditions; +- a parent's Universal Credit never rises when one existing benefit report + of their young person turns positive and the young person reports no + other; +- the predicate equals the reg. 5 test less reg. 28's exclusions, exactly; +- turning would_claim_uc on never changes the predicate of anyone who isn't + the claimant or partner; +- turning would_claim_uc on never turns a claimant's or partner's predicate + from true to false. +""" + +from unittest.mock import patch + +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.tax_benefit_system import system +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) + +EDUCATIONS = tuple(item.name for item in EducationType) +NON_ADVANCED = ("UPPER_SECONDARY", "POST_SECONDARY") +YEARS = (2024, 2025, 2026) +EXISTING_BENEFITS = ( + "jsa_income_reported", + "esa_income_reported", + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +) +TAX_CREDITS = ("child_tax_credit_reported", "working_tax_credit_reported") +# The Secretary of State defines ESA and JSA without their income-related and +# income-based forms (UC Regs reg. 2), so reg. 5(5) reaches only these. +REG_5_5_REPORTS = ( + "universal_credit_reported", + "esa_contrib_reported", + "jsa_contrib_reported", +) +# The existing benefits the model pays in each year: tax credits until 5 April +# 2025 (gov.dwp.tax_credits.active), Income Support and income-based JSA until +# 31 March 2026 (gov.dwp.income_support.active, gov.dwp.JSA.income.active). +PAYING = { + 2024: set(EXISTING_BENEFITS), + 2025: set(EXISTING_BENEFITS) - set(TAX_CREDITS), + 2026: {"esa_income_reported", "housing_benefit_reported"}, +} +SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +amount = st.floats(0.01, 20_000) + + +@st.composite +def sometimes(draw): + """A positive amount one time in three, otherwise zero.""" + return draw(amount) if draw(st.sampled_from((False, False, True))) else 0.0 + + +@st.composite +def member( + draw, + ages, + training, + educations=EDUCATIONS, + own_right=(False, True), + reg_5_5_reports=True, + one_existing_benefit=False, +): + """A person who usually reports no UC, ESA or JSA award under reg. 5(5). + + Reg. 5(5) excludes anyone with such a report, so drawing them rarely + leaves people for reg. 28 to exclude; with reg_5_5_reports false, the + person reports none. Each existing benefit report is positive one time in + three; with one_existing_benefit true, the person reports at most one, + and none one time in three. + """ + reg_5_5_report = ( + draw(st.sampled_from((None,) * 4 + REG_5_5_REPORTS)) + if reg_5_5_reports + else None + ) + return { + "age": draw(ages), + "current_education": draw(st.sampled_from(educations)), + "is_in_approved_training": draw(training), + "receives_benefits_in_own_right": draw( + st.sampled_from((False,) * 3 + own_right[1:]) + ), + **{ + name: draw(amount) if name == reg_5_5_report else 0.0 + for name in REG_5_5_REPORTS + }, + **existing_benefit_reports(draw, one_existing_benefit), + } + + +def existing_benefit_reports(draw, one_existing_benefit): + if not one_existing_benefit: + return {name: draw(sometimes()) for name in EXISTING_BENEFITS} + report = draw(st.sampled_from((None,) * 3 + EXISTING_BENEFITS)) + return {name: draw(amount) if name == report else 0.0 for name in EXISTING_BENEFITS} + + +young_member = member(st.integers(15, 20), st.booleans()) +# Claimants and partners aged 16 to 19 can be qualifying young people. +adult = member(st.one_of(st.integers(16, 19), st.integers(20, 70)), st.just(False)) +# Someone who meets reg. 5 unless reg. 28 excludes them: 16 to 18, on a +# non-advanced course, not flagged as receiving benefits in their own right +# and reporting no UC, ESA or JSA award under reg. 5(5). A 19-year-old needs +# the entry and terminal-date inputs (reg. 5(1)(b) and (2)), which these +# draws don't set. They report at most one existing benefit, so reg. 28 +# often leaves them in the test. +qualifying = member( + st.integers(16, 18), + st.just(False), + NON_ADVANCED, + own_right=(False,), + reg_5_5_reports=False, + one_existing_benefit=True, +) +INPUTS = ( + ( + "age", + "current_education", + "is_in_approved_training", + "receives_benefits_in_own_right", + "is_claimant_or_partner", + "is_parent", + ) + + REG_5_5_REPORTS + + EXISTING_BENEFITS +) + + +UNITS = 4 + + +@st.composite +def families(draw): + """A claimant, then a partner or a dependant, then two dependants.""" + couple = draw(st.booleans()) + claimant = dict( + draw(adult), + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) + second = draw(adult if couple else young_member) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict(draw(young_member), is_claimant_or_partner=False, is_parent=False) + for _ in range(2) + ] + return [claimant, second] + dependants + + +@st.composite +def qualifying_families(draw): + """Families where reg. 28 alone decides most members' status. + + Each slot is drawn from qualifying, which meets reg. 5, or from the + general strategies. Each slot meets reg. 5 in about three draws in + five. A partner is otherwise an older adult whose + own reports reg. 28 must not pass to the claimant, except for the + couple's tax credit award. + """ + couple = draw(st.booleans()) + claimant = draw(st.one_of(qualifying, qualifying, adult)) + claimant = dict( + claimant, + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) + second = draw(st.one_of(qualifying, qualifying, adult if couple else young_member)) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict( + draw(st.one_of(qualifying, qualifying, young_member)), + is_claimant_or_partner=False, + is_parent=False, + ) + for _ in range(2) + ] + return [claimant, second] + dependants + + +# Someone who meets reg. 5 unless reg. 28 excludes them and reports each +# existing benefit one time in three, so a report that closes their legacy +# awards often sits beside an income-related ESA or Housing Benefit report. +reporting_qualifying = member( + st.integers(16, 18), + st.just(False), + NON_ADVANCED, + own_right=(False,), + reg_5_5_reports=False, +) + + +@st.composite +def closure_families(draw): + """A claimant and a second member who meet reg. 5 one time in two, and + two dependants who always do, each reporting any existing benefit.""" + couple = draw(st.booleans()) + claimant = draw(st.one_of(reporting_qualifying, adult)) + claimant = dict( + claimant, + is_claimant_or_partner=True, + is_parent=True, + would_claim_uc=draw(st.booleans()), + ) + second = draw(st.one_of(reporting_qualifying, adult if couple else young_member)) + second = dict(second, is_claimant_or_partner=couple, is_parent=couple) + dependants = [ + dict(draw(reporting_qualifying), is_claimant_or_partner=False, is_parent=False) + for _ in range(2) + ] + return [claimant, second] + dependants + + +units_strategy = st.lists(families(), min_size=UNITS, max_size=UNITS) +closure_units_strategy = st.lists(closure_families(), min_size=UNITS, max_size=UNITS) +qualifying_units_strategy = st.lists( + qualifying_families(), min_size=UNITS, max_size=UNITS +) +_CACHE = {} + + +@pytest.fixture(autouse=True, scope="module") +def _release_simulations(): + """Drop the cloned system and cached simulations when the module ends.""" + yield + _CACHE.clear() + + +def simulate(units, year): + """Set the inputs on one cached simulation per shape and year. Each + family claims Universal Credit (would_claim_uc) as its first member's + draw says, or by default.""" + shape = (tuple(len(members) for members in units), year) + if shape not in _CACHE: + situation = {"people": {}, "benunits": {}, "households": {}} + for i, members in enumerate(units): + names = [f"p{i}_{j}" for j in range(len(members))] + for name in names: + situation["people"][name] = {} + situation["benunits"][f"b{i}"] = {"members": names} + situation["households"][f"h{i}"] = {"members": names} + if "system" not in _CACHE: + _CACHE["system"] = system.clone() + with patch( + "policyengine_uk.simulation.CountryTaxBenefitSystem", + side_effect=_CACHE["system"].clone, + ): + _CACHE[shape] = Simulation(situation=situation) + sim = _CACHE[shape] + people = flatten(units) + for variable in INPUTS: + sim.set_input(variable, year, [p[variable] for p in people]) + sim.set_input( + "would_claim_uc", + year, + [members[0].get("would_claim_uc", True) for members in units], + ) + sim.input_variables = list(INPUTS) + ["would_claim_uc"] + sim.reset_calculations() + return sim + + +def flatten(units): + return [p for members in units for p in members] + + +def claiming(units, claims): + """The same families, each with would_claim_uc set to claims.""" + return [ + [dict(members[0], would_claim_uc=claims)] + members[1:] for members in units + ] + + +def with_zero(units, names, who): + return [ + [dict(p, **{n: 0.0 for n in names}) if who(p) else p for p in members] + for members in units + ] + + +def predicate(sim, year): + return sim.calculate("is_qualifying_young_person_for_universal_credit", year).copy() + + +def reg_5_test(sim, year): + """UC Regs 2013 reg. 5 less the person's reported UC, ESA and JSA awards.""" + + def get(name): + return sim.calculate(name, year) + + routes = get("is_before_first_september_following_16th_birthday") | ( + get( + "meets_qualifying_young_person_education_or_training_condition_for_universal_credit" + ) + & get("meets_qualifying_young_person_entry_condition_for_universal_credit") + & get( + "meets_qualifying_young_person_terminal_date_condition_for_universal_credit" + ) + ) + excluded = get("receives_benefits_in_own_right") + for name in REG_5_5_REPORTS: + excluded = excluded | (get(name) > 0) + return ( + get("meets_qualifying_young_person_age_condition_for_universal_credit") + & routes + & ~excluded + ) + + +def open_awards(sim, year, claim_limbs=True): + """For each report, whether the model's legacy closure and, with + claim_limbs, the family's Universal Credit claim leave the award on it + open, person by person. The claim ends the claimant's and partner's own + income-related ESA, Housing Benefit outside the Pension Credit age + conditions and Child Tax Credit, and nobody else's.""" + own_open = ~sim.calculate("person_legacy_benefits_closed", year) + family_closed = sim.calculate("legacy_benefits_closed", year, map_to="person") + pension_age = sim.calculate( + "meets_pension_credit_age_conditions", year, map_to="person" + ) + claim_ends = np.zeros_like(own_open) + if claim_limbs: + claim_ends = sim.calculate( + "claims_universal_credit", year, map_to="person" + ) & sim.calculate("is_claimant_or_partner", year) + return { + "income_support_reported": own_open, + "jsa_income_reported": own_open, + "esa_income_reported": own_open & ~claim_ends, + "housing_benefit_reported": pension_age | (~family_closed & ~claim_ends), + "child_tax_credit_reported": ~family_closed & ~claim_ends, + "working_tax_credit_reported": ~family_closed, + } + + +def reg_28_exclusions(units, year, sim): + """Who reg. 28 excludes: own reports of benefits the model pays on them + that year, and, for a claimant or partner, the couple's tax credit + award.""" + paying = PAYING[year] + award_open = open_awards(sim, year) + people = flatten(units) + own = np.array( + [ + any(p[n] > 0 and award_open[n][i] for n in paying) + for i, p in enumerate(people) + ], + dtype=bool, + ) + couple_tax_credits = np.zeros(len(people), dtype=bool) + start = 0 + for members in units: + indices = range(start, start + len(members)) + award = any( + people[j]["is_claimant_or_partner"] + and people[j][n] > 0 + and award_open[n][j] + for j in indices + for n in paying.intersection(TAX_CREDITS) + ) + for j in indices: + couple_tax_credits[j] = people[j]["is_claimant_or_partner"] and award + start += len(members) + return own | couple_tax_credits + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_any_own_existing_benefit_report_excludes_the_person(units, year): + sim = simulate(units, year) + assert not np.any(predicate(sim, year) & reg_28_exclusions(units, year, sim)) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_zero_existing_benefit_reports_leave_the_reg_5_test(units, year): + zeroed = with_zero(units, EXISTING_BENEFITS, lambda p: True) + sim = simulate(zeroed, year) + assert predicate(sim, year).tolist() == reg_5_test(sim, year).tolist() + assert ( + reg_5_test(sim, year).tolist() + == sim.calculate( + "is_qualifying_young_person_under_universal_credit_regulation_5", year + ).tolist() + ) + # With reports present, reg. 28 only removes people from the test. + sim = simulate(units, year) + assert not np.any(predicate(sim, year) & ~reg_5_test(sim, year)) + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_a_claimants_reports_change_a_dependant_only_by_closing_the_family(units, year): + sim = simulate(units, year) + reported = predicate(sim, year) + family_closed = sim.calculate( + "legacy_benefits_closed", year, map_to="person" + ).copy() + cleared = predicate( + simulate( + with_zero(units, EXISTING_BENEFITS, lambda p: p["is_claimant_or_partner"]), + year, + ), + year, + ) + dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) + # Where the couple's reports leave the family's legacy awards open, they + # never change a dependant's predicate. + kept = dependant & ~family_closed + assert reported[kept].tolist() == cleared[kept].tolist() + # Where they close them, they can only lift a dependant's exclusion. + closed = dependant & family_closed + assert not np.any(~reported[closed] & cleared[closed]) + + +@SETTINGS +@given(units=closure_units_strategy, year=st.sampled_from((2025, 2026))) +def test_a_closed_legacy_award_never_excludes(units, year): + # The model ends a person's own income-related ESA once their legacy + # awards close (person_legacy_benefits_closed, read by + # esa_income_report_closed), and the family's Housing Benefit once the + # claimant's and partner's close outside the Pension Credit age + # conditions (housing_benefit_eligible). Clearing those reports leaves + # every predicate as it was. Neither report enters legacy_award_is_closed. + # They reach the closures only through a mixed-age couple's Pension + # Credit saving, which clearing both reports of a closed family can't + # create. Tax credits close from 2025 and Income Support and income-based + # JSA from 2026. The claim limbs stay out: the claim can end an award in + # a family whose legacy awards stay open. + sim = simulate(units, year) + reported = predicate(sim, year) + award_open = open_awards(sim, year, claim_limbs=False) + closed = { + name: ~award_open[name] + for name in ("esa_income_reported", "housing_benefit_reported") + } + cleared_units, index = [], 0 + for members in units: + cleared_members = [] + for p in members: + cleared_members.append( + dict(p, **{name: 0.0 for name in closed if closed[name][index]}) + ) + index += 1 + cleared_units.append(cleared_members) + cleared = predicate(simulate(cleared_units, year), year) + assert reported.tolist() == cleared.tolist() + + +@SETTINGS +@given( + parent_age=st.integers(25, 60), + young_age=st.integers(16, 19), + education=st.sampled_from(EDUCATIONS), + benefit=st.sampled_from(EXISTING_BENEFITS), + report=st.floats(0.01, 20_000), + year=st.sampled_from(YEARS), +) +def test_a_young_persons_report_never_raises_the_parents_award( + parent_age, young_age, education, benefit, report, year +): + def family(young_report): + zeros = {n: 0.0 for n in REG_5_5_REPORTS + EXISTING_BENEFITS} + parent = dict( + zeros, + age=parent_age, + current_education="NOT_IN_EDUCATION", + is_in_approved_training=False, + receives_benefits_in_own_right=False, + is_claimant_or_partner=True, + is_parent=True, + ) + young = dict( + zeros, + age=young_age, + current_education=education, + is_in_approved_training=False, + receives_benefits_in_own_right=False, + is_claimant_or_partner=False, + is_parent=False, + ) + young[benefit] = young_report + return [[parent, young]] + + without = simulate(family(0.0), year).calculate("universal_credit", year)[0] + with_report = simulate(family(report), year).calculate("universal_credit", year)[0] + assert with_report <= without + 1e-6 + + +@SETTINGS +@given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) +def test_the_predicate_is_reg_5_less_reg_28(units, year): + sim = simulate(units, year) + expected = reg_5_test(sim, year) & ~reg_28_exclusions(units, year, sim) + assert predicate(sim, year).tolist() == expected.tolist() + + +@SETTINGS +@given(units=units_strategy, year=st.sampled_from(YEARS)) +def test_a_claim_changes_no_dependants_predicate(units, year): + # A claim for Universal Credit ends the claimant's and partner's own + # awards, not a dependant's (SI 2014/1230 reg. 8(2) and (2A)). + without_claim = predicate(simulate(claiming(units, False), year), year) + with_claim = predicate(simulate(claiming(units, True), year), year) + dependant = np.array([not p["is_claimant_or_partner"] for p in flatten(units)]) + assert with_claim[dependant].tolist() == without_claim[dependant].tolist() + + +@SETTINGS +@given(units=qualifying_units_strategy, year=st.sampled_from(YEARS)) +def test_a_claim_never_excludes_a_claimant_or_partner(units, year): + # A claim only ends awards, so it can only lift a claimant's or partner's + # exclusion. + without_claim = predicate(simulate(claiming(units, False), year), year) + with_claim = predicate(simulate(claiming(units, True), year), year) + member_of_couple = np.array([p["is_claimant_or_partner"] for p in flatten(units)]) + assert not np.any(without_claim[member_of_couple] & ~with_claim[member_of_couple]) diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py new file mode 100644 index 0000000000..5a8c425f47 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_legacy_closure.py @@ -0,0 +1,140 @@ +"""The UC qualifying young person test follows the family's tax credit closure. + +The model pays tax credits to the family, on every member's reports, and +ends them when the claimant's and partner's legacy awards close +(legacy_benefits_closed, read by is_CTC_eligible and is_WTC_eligible). +Nobody is entitled to an award the model has ended, so the predicate reads a +young person's own tax credit report only while the family's legacy awards +stay open (SI 2014/1230 reg. 28). + +In the baseline the family's legacy awards close only once a benefit has +closed, and tax credits close first (gov.dwp.tax_credits.active turns false +on 6 April 2025, Income Support on 1 April 2026), so the year gate already +covers every closed tax credit report. These tests keep the tax credit +scheme open in 2026 by a parameter reform (gov.dwp.tax_credits.active), so +the family's closure decides. + +The household: a parent aged 45 and a 17-year-old on a non-advanced course, +North East, owned outright, no income, in 2026. The parent claims Universal +Credit as a single claimant over 25: £424.90 a month (standard_allowance +SINGLE_OLD) and £351.88 a month for a qualifying young person (child element +first higher amount), so 12 x (424.90 + 351.88) = £9,321.36 with the child +element and 12 x 424.90 = £5,098.80 without it. +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 +TAX_CREDITS_PAYING = {"gov.dwp.tax_credits.active": {"2020-01-01.2100-12-31": True}} + + +def household(young_report, parent_report=None): + parent = { + "age": {YEAR: 45}, + "is_parent": {YEAR: True}, + "is_claimant_or_partner": {YEAR: True}, + } + if parent_report: + parent[parent_report] = {YEAR: 3_000} + return { + "people": { + "parent": parent, + "young_person": { + "age": {YEAR: 17}, + "current_education": {YEAR: "POST_SECONDARY"}, + "is_claimant_or_partner": {YEAR: False}, + young_report: {YEAR: 1_000}, + }, + }, + "benunits": {"benunit": {"members": ["parent", "young_person"]}}, + "households": { + "household": { + "members": ["parent", "young_person"], + "region": {YEAR: "NORTH_EAST"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + } + }, + } + + +def outcome(situation, reform): + sim = Simulation(situation=situation, reform=reform) + return ( + sim.calculate("legacy_benefits_closed", YEAR).tolist(), + sim.calculate("is_qualifying_young_person_for_universal_credit", YEAR).tolist(), + round(float(sim.calculate("universal_credit", YEAR)[0]), 2), + ) + + +@pytest.mark.parametrize( + "report", ["child_tax_credit_reported", "working_tax_credit_reported"] +) +def test_a_young_persons_tax_credit_report_excludes_while_the_family_stays_open( + report, +): + # Tax credits pay in 2026 under the reform, and nothing closes the + # family's legacy awards, so the young person's own report excludes them. + assert outcome(household(report), TAX_CREDITS_PAYING) == ( + [False], + [False, False], + 5_098.80, + ) + + +@pytest.mark.parametrize( + "report", ["child_tax_credit_reported", "working_tax_credit_reported"] +) +def test_the_parents_closed_legacy_award_ends_the_young_persons_tax_credits( + report, +): + # The parent's own Income Support report in 2026, after Income Support + # closed, closes the family's legacy awards, which ends the family's tax + # credits, the young person's report included. + situation = household(report, "income_support_reported") + assert outcome(situation, TAX_CREDITS_PAYING) == ( + [True], + [False, True], + 9_321.36, + ) + + +def test_the_familys_closure_ends_the_couples_joint_tax_credit_award(): + # An 18-year-old claimant on a non-advanced course and a partner aged 45 + # with a child aged 1. The partner reports the couple's Working Tax Credit + # (Tax Credits Act 2002 s.3(3)) and their own Income Support in 2026, + # after Income Support closed, which closes the family's legacy awards + # and ends the couple's tax credits. So the couple's award doesn't + # exclude the claimant. Universal Credit can't show this: the claimant's + # status doesn't enter the award, so the test reads the predicate. + members = ["claimant", "partner", "child"] + situation = { + "people": { + "claimant": { + "age": {YEAR: 18}, + "current_education": {YEAR: "POST_SECONDARY"}, + "is_claimant_or_partner": {YEAR: True}, + }, + "partner": { + "age": {YEAR: 45}, + "is_claimant_or_partner": {YEAR: True}, + "income_support_reported": {YEAR: 3_000}, + "working_tax_credit_reported": {YEAR: 1_000}, + }, + "child": {"age": {YEAR: 1}, "is_claimant_or_partner": {YEAR: False}}, + }, + "benunits": {"benunit": {"members": members}}, + "households": { + "household": { + "members": members, + "region": {YEAR: "NORTH_EAST"}, + "tenure_type": {YEAR: "OWNED_OUTRIGHT"}, + } + }, + } + sim = Simulation(situation=situation, reform=TAX_CREDITS_PAYING) + assert sim.calculate("legacy_benefits_closed", YEAR).tolist() == [True] + assert sim.calculate( + "is_qualifying_young_person_for_universal_credit", YEAR + ).tolist() == [True, False, False] diff --git a/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py new file mode 100644 index 0000000000..041d6d3af3 --- /dev/null +++ b/policyengine_uk/tests/test_uc_qualifying_young_person_neutralised_benefits.py @@ -0,0 +1,174 @@ +"""The UC qualifying young person test under reforms that neutralise a benefit. + +The Secretary of State excludes a person entitled to an existing benefit +from the UC qualifying young person test (SI 2014/1230 regs. 2(1) and 28). +A reform that neutralises a benefit's variable abolishes that benefit, so +nobody can be entitled to it and reg. 28 can't exclude on it. The predicate +checks each report against its own variable: + +- Income Support: ``income_support``; +- income-based JSA: ``jsa_income``; +- income-related ESA: ``esa_income``; +- Housing Benefit: ``housing_benefit``; +- Child Tax Credit: ``child_tax_credit`` or ``tax_credits``; +- Working Tax Credit: ``working_tax_credit`` or ``tax_credits``. + +The model pays all six in 2024, so the tests read 2024 unless they name +another year. +""" + +import pytest +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation + +PREDICATE = "is_qualifying_young_person_for_universal_credit" +# Each report, and the variables whose neutralisation lifts its exclusion. +ROWS = { + "income_support_reported": {"income_support"}, + "jsa_income_reported": {"jsa_income"}, + "esa_income_reported": {"esa_income"}, + "housing_benefit_reported": {"housing_benefit"}, + "child_tax_credit_reported": {"child_tax_credit", "tax_credits"}, + "working_tax_credit_reported": {"working_tax_credit", "tax_credits"}, +} +NEUTRALISED = sorted(set().union(*ROWS.values())) + + +def neutralising(*names): + class neutralise(Reform): + def apply(self): + for name in names: + self.neutralize_variable(name) + + return neutralise + + +def young_people(reports, year, reform=None, amount=3_000): + """One household per report: a parent aged 45 and a 17-year-old on a + non-advanced course who reports that benefit (£3,000 a year unless the + test says otherwise). North East, owned outright, no income.""" + people, benunits, households = {}, {}, {} + for index, report in enumerate(reports): + parent, young = f"parent_{index}", f"young_{index}" + people[parent] = { + "age": {year: 45}, + "is_parent": {year: True}, + "is_claimant_or_partner": {year: True}, + } + people[young] = { + "age": {year: 17}, + "current_education": {year: "POST_SECONDARY"}, + "is_claimant_or_partner": {year: False}, + report: {year: amount}, + } + benunits[f"benunit_{index}"] = {"members": [parent, young]} + households[f"household_{index}"] = { + "members": [parent, young], + "region": {year: "NORTH_EAST"}, + "tenure_type": {year: "OWNED_OUTRIGHT"}, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": households}, + reform=reform, + ) + + +def young_flags(sim, year): + return sim.calculate(PREDICATE, year)[1::2].tolist() + + +def parent_awards(sim, year): + return [round(float(x), 2) for x in sim.calculate("universal_credit", year)] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("neutralised", [None] + NEUTRALISED) +def test_a_reform_lifts_only_the_exclusions_on_the_benefit_it_neutralises( + neutralised, +): + year = 2024 + reform = neutralising(neutralised) if neutralised else None + sim = young_people(list(ROWS), year, reform) + expected = [neutralised in variables for variables in ROWS.values()] + assert young_flags(sim, year) == expected + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +@pytest.mark.parametrize("neutralised", [None] + NEUTRALISED) +def test_a_reform_lifts_a_couples_tax_credit_exclusion_credit_by_credit( + neutralised, +): + """Two 18-year-olds on non-advanced courses form a couple, and the + partner reports the couple's Child Tax Credit or Working Tax Credit + award (Tax Credits Act 2002 s.3(3)(a)). The couple doesn't claim + Universal Credit, so no claim ends its Child Tax Credit.""" + year = 2024 + people, benunits, households = {}, {}, {} + credits = ("child_tax_credit_reported", "working_tax_credit_reported") + for index, report in enumerate(credits): + claimant, partner = f"claimant_{index}", f"partner_{index}" + for name in (claimant, partner): + people[name] = { + "age": {year: 18}, + "current_education": {year: "UPPER_SECONDARY"}, + "is_claimant_or_partner": {year: True}, + } + people[partner][report] = {year: 1_000} + benunits[f"benunit_{index}"] = { + "members": [claimant, partner], + "would_claim_uc": {year: False}, + } + households[f"household_{index}"] = {"members": [claimant, partner]} + sim = Simulation( + situation={"people": people, "benunits": benunits, "households": households}, + reform=neutralising(neutralised) if neutralised else None, + ) + lifted = [neutralised in ROWS[report] for report in credits] + expected = [flag for flag in lifted for _ in range(2)] + assert sim.calculate(PREDICATE, year).tolist() == expected + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_child_tax_credit_alone_leaves_a_working_tax_credit_exclusion(): + year = 2024 + credits = ["child_tax_credit_reported", "working_tax_credit_reported"] + alone = young_people(credits, year, neutralising("child_tax_credit")) + both = young_people(credits, year, neutralising("tax_credits")) + assert young_flags(alone, year) == [True, False] + assert young_flags(both, year) == [True, True] + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_housing_benefit_restores_an_own_housing_benefit_young_person(): + """Household G: the 17-year-old reports their own Housing Benefit in 2026.""" + year = 2026 + kept = young_people(["housing_benefit_reported"], year) + removed = young_people( + ["housing_benefit_reported"], year, neutralising("housing_benefit") + ) + assert young_flags(kept, year) == [False] + assert young_flags(removed, year) == [True] + assert parent_awards(kept, year)[0] == pytest.approx(5_098.80) + assert parent_awards(removed, year)[0] == pytest.approx(9_321.36) + + +@pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +def test_neutralising_child_tax_credit_restores_an_own_child_tax_credit_young_person(): + """Household D: the 17-year-old reports their own Child Tax Credit in + 2024, £1,000.""" + year = 2024 + sims = {} + for name, reform in ( + ("kept", None), + ("child_tax_credit", neutralising("child_tax_credit")), + ("tax_credits", neutralising("tax_credits")), + ): + sims[name] = young_people( + ["child_tax_credit_reported"], year, reform, amount=1_000 + ) + assert young_flags(sims["kept"], year) == [False] + assert parent_awards(sims["kept"], year)[0] == pytest.approx(4_721.40) + for name in ("child_tax_credit", "tax_credits"): + assert young_flags(sims[name], year) == [True] + assert parent_awards(sims[name], year)[0] == pytest.approx(8_721.36) diff --git a/policyengine_uk/tests/test_uc_receiving_education_properties.py b/policyengine_uk/tests/test_uc_receiving_education_properties.py index 44c304d9d7..a211977060 100644 --- a/policyengine_uk/tests/test_uc_receiving_education_properties.py +++ b/policyengine_uk/tests/test_uc_receiving_education_properties.py @@ -101,16 +101,34 @@ YEARS = [2019, 2021, 2024, 2026] REGIONS = ["NORTH_EAST", "LONDON", "SCOTLAND", "NORTHERN_IRELAND"] EDUCATION = ["TERTIARY", "POST_SECONDARY", "NOT_IN_EDUCATION"] -# SI 2014/1230 reg. 2(1), as reported receipt. +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# SI 2014/1230 reg. 2(1), as reported receipt, for the migration day: the +# report stands for the award the closure claim ended. EXISTING_BENEFITS = [ "jsa_income_reported", "esa_income_reported", "income_support_reported", "housing_benefit_reported", - "child_tax_credit_reported", - "working_tax_credit_reported", + *TAX_CREDITS, ] -TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +# Reg. 28 turns on entitlement, +# so a report counts only in a year the model pays that benefit (the +# parameter, or None where the model has no closure date) and only if no +# reform neutralises one of its variables. +REPORT_BENEFITS = { + "jsa_income_reported": ("JSA.income.active", ["jsa_income"]), + "esa_income_reported": (None, ["esa_income"]), + "income_support_reported": ("income_support.active", ["income_support"]), + "housing_benefit_reported": (None, ["housing_benefit"]), + "child_tax_credit_reported": ( + "tax_credits.active", + ["child_tax_credit", "tax_credits"], + ), + "working_tax_credit_reported": ( + "tax_credits.active", + ["working_tax_credit", "tax_credits"], + ), +} # Compare eligibility, maximum entitlement, income reduction and Universal # Credit awards. OUTPUTS = [ @@ -190,6 +208,7 @@ def adult(draw, ages): uc_is_responsible_foster_parent_of_child_under_one=draw(rarely), uc_is_foster_parent_or_new_friend_or_family_carer=draw(rarely), housing_benefit_reported=draw(st.sampled_from([0, 0, 0, 0, 6_000])), + housing_benefit_report_is_rate_rebate_only=draw(rarely), working_tax_credit_reported=draw(st.sampled_from([0, 0, 0, 0, 1_000])), employment_income=draw(st.sampled_from([0, 0, 4_000, 15_000])), is_claimant_or_partner=True, @@ -284,7 +303,12 @@ def all_flags(case): "meets_pension_credit_age_conditions", "uc_assessable_capital", "is_qualifying_young_person_for_universal_credit", + "is_qualifying_young_person_under_universal_credit_regulation_5", "is_qualifying_young_person_for_uc_education", + "is_claimant_or_partner", + "person_legacy_benefits_closed", + "legacy_benefits_closed", + "claims_universal_credit", "is_responsible_for_child_or_qualifying_young_person_for_universal_credit", "has_attained_state_pension_credit_qualifying_age", "is_mixed_age_couple", @@ -302,6 +326,86 @@ def all_flags(case): BEFORE = (None, True) +def paying_reports(simulation, year): + """The existing benefit reports reg. 28 reads in the year: those of + benefits the model pays then and no reform neutralises.""" + system = simulation.tax_benefit_system + dwp = system.parameters(f"{year}-01-01").gov.dwp + paying = [] + for report, (active, variables) in REPORT_BENEFITS.items(): + node = dwp + for key in active.split(".") if active else []: + node = getattr(node, key) + if (active is None or bool(node)) and not any( + system.get_variable(v).is_neutralized for v in variables + ): + paying.append(report) + return paying + + +# Reports whose award the model ends on the person's own legacy closure +# (person_legacy_benefits_closed); it ends the family's Housing Benefit, +# outside the Pension Credit age conditions, and tax credits on the family's +# (legacy_benefits_closed). +OWN_CLOSURE = ["jsa_income_reported", "esa_income_reported", "income_support_reported"] + + +def excluded_on_first_day(case, result): + """Per person, whether reg. 28 keeps them out of the definition for the + condition: an own report, or for a claimant or partner the couple's tax + credit report, that the model pays that year, no reform neutralises and + the model's legacy closure leaves open, on the first day of the + Universal Credit award. That day a Housing Benefit or income-related ESA + award the claim ends still runs (SI 2014/1230 reg. 8(2A)), but the claim + has already ended a claimant's or partner's Child Tax Credit (reg. + 8(2)(a)) and, in Northern Ireland, a report of a rate rebate alone (SR + 2016/226 reg. 6(2); housing_benefit_report_is_rate_rebate_only).""" + flags = [] + index = 0 + for f, fam in enumerate(case["families"]): + members = range(index, index + len(fam["people"])) + family_closed = bool(result["legacy_benefits_closed"][f]) + pension_age = bool(result["meets_pension_credit_age_conditions"][f]) + claims = bool(result["claims_universal_credit"][f]) + + def is_open(i, name): + if fam["people"][i - index].get(name, 0) <= 0: + return False + if name not in result["paying"]: + return False + if name in OWN_CLOSURE: + return not result["person_legacy_benefits_closed"][i] + if name == "housing_benefit_reported": + rate_rebate_ended = ( + fam["household"]["region"] == "NORTHERN_IRELAND" + and fam["people"][i - index].get( + "housing_benefit_report_is_rate_rebate_only", False + ) + and claims + and result["is_claimant_or_partner"][i] + ) + return pension_age or not (family_closed or rate_rebate_ended) + if family_closed: + return False + return not ( + name == "child_tax_credit_reported" + and claims + and result["is_claimant_or_partner"][i] + ) + + couple = [i for i in members if result["is_uc_assessed_claimant"][i]] + couple_tax_credits = any( + is_open(j, name) for j in couple for name in TAX_CREDITS + ) + for i in members: + flags.append( + any(is_open(i, name) for name in EXISTING_BENEFITS) + or (i in couple and couple_tax_credits) + ) + index += len(fam["people"]) + return np.array(flags, dtype=bool) + + def run(case, variants=(DRAWN,)): """One simulation of every variant; returns one result per variant.""" simulation = Simulation(situation=situation(case, variants)) @@ -314,6 +418,7 @@ def run(case, variants=(DRAWN,)): np.array([uses_main for _, uses_main in variants for _ in case["families"]]), ) values = {name: simulation.calculate(name, year) for name in VARIABLES} + paying = paying_reports(simulation, year) limit = simulation.tax_benefit_system.parameters( f"{year}-01-01" ).gov.dwp.universal_credit.means_test.capital.limit @@ -324,7 +429,7 @@ def run(case, variants=(DRAWN,)): } results = [] for v in range(len(variants)): - result = {"capital_limit": limit} + result = {"capital_limit": limit, "paying": paying} for name, array in values.items(): entity = simulation.tax_benefit_system.variables[name].entity.key size = sizes[entity] @@ -341,10 +446,12 @@ def reference(case, result): person for the condition, and per family, eligibility with the education condition and without it. Reads from the model only the roles (who is a claimant), the age conditions, qualifying young person status before reg. - 28, the Pension Credit route, capital, responsibility for a child, limited + 28 (the reg. 5 variable), the legacy closures, the family's claim, the + Pension Credit route, capital, responsibility for a child, limited capability for work and the disability benefit amounts, which other rules settle. """ + excluded = excluded_on_first_day(case, result) receiving, excepted, qyp_for_education = [], [], [] eligible, eligible_before = [], [] index = 0 @@ -353,17 +460,27 @@ def reference(case, result): members = range(start, start + len(fam["people"])) person_of = dict(zip(members, fam["people"])) couple = [i for i in members if result["is_uc_assessed_claimant"][i]] - couple_tax_credits = any( + couple_tax_credits_on_migration_day = any( person_of[j].get(name, 0) > 0 for j in couple for name in TAX_CREDITS ) - qyp, recv = {}, {} + qyp, qyp_on_migration_day, recv = {}, {}, {} for i, person in person_of.items(): - existing_benefit = any( - person.get(name, 0) > 0 for name in EXISTING_BENEFITS - ) or (i in couple and couple_tax_credits) qyp[i] = ( + bool( + result[ + "is_qualifying_young_person_under_universal_credit_regulation_5" + ][i] + ) + and not excluded[i] + ) + # Reg. 28 on the migration day, when the award the closure claim + # ended still existed. + existing_benefit_on_migration_day = any( + person.get(name, 0) > 0 for name in EXISTING_BENEFITS + ) or (i in couple and couple_tax_credits_on_migration_day) + qyp_on_migration_day[i] = ( bool(result["is_qualifying_young_person_for_universal_credit"][i]) - and not existing_benefit + and not existing_benefit_on_migration_day ) advanced = person["current_education"] == "TERTIARY" or person.get( "in_HE", False @@ -410,7 +527,7 @@ def reference(case, result): reg_60 = ( in_couple_role and fam["benunit"]["claims_uc_at_legacy_closure"] - and not qyp[i] + and not qyp_on_migration_day[i] ) exc[i] = limb_b or limb_c or limb_d or limb_e or limb_f or reg_60 claimants = [i for i in members if result["is_uc_single_or_joint_claimant"][i]] @@ -663,16 +780,97 @@ def no_loans_or_grants(i, values): atol=0.005, err_msg=f"{name}: {context}", ) - # 8. Reg. 28: no one who reports an existing benefit counts as a - # qualifying young person for the condition. - reports = np.array( - [ - any(p.get(name, 0) > 0 for name in EXISTING_BENEFITS) - for fam in case["families"] - for p in fam["people"] - ] - ) - assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][reports]) + # 8. Reg. 28: no one whose existing benefit report the model pays that + # year, no reform neutralises and the legacy closure leaves open, on the + # first day of the Universal Credit award, counts as a qualifying young + # person for the condition. + excluded = excluded_on_first_day(case, drawn) + assert not np.any(drawn["is_qualifying_young_person_for_uc_education"][excluded]) + + +# An 18-year-old on a non-advanced full-time course who claims Universal +# Credit: in 2026, after the family's legacy awards closed on their tax +# credit report, which ended their Housing Benefit; and in 2024, reporting +# their own Child Tax Credit, which the claim ends the day before the award, +# with no child to except them. In Northern Ireland in 2026, one owner reports +# a rate rebate alone, which the claim ends the day before the award (SR +# 2016/226 reg. 6(2)). Another owner and a tenant report a rent rebate or +# allowance, which runs on (reg. 6(2A)): an owner can get one on site or +# mooring payments (SR 2006/405 reg. 13(1)(f) and (g)). +CLAIM_EXAMPLES = [ + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + working_tax_credit_reported=1_000, + ) + ] + ) + ], + ), + dict( + year=2024, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + child_tax_credit_reported=2_000, + ) + ] + ) + ], + ), + dict( + year=2026, + families=[ + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + housing_benefit_report_is_rate_rebate_only=True, + ) + ], + region="NORTHERN_IRELAND", + ), + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + region="NORTHERN_IRELAND", + ), + single_family( + [ + person( + 18, + is_in_full_time_education=True, + current_education="POST_SECONDARY", + housing_benefit_reported=6_000, + ) + ], + region="NORTHERN_IRELAND", + tenure_type="RENT_FROM_COUNCIL", + rent=6_000, + ), + ], + ), +] @PROPERTY_SETTINGS @@ -680,6 +878,9 @@ def no_loans_or_grants(i, values): @example(EXAMPLES[1]) @example(EXAMPLES[2]) @example(EXAMPLES[3]) +@example(CLAIM_EXAMPLES[0]) +@example(CLAIM_EXAMPLES[1]) +@example(CLAIM_EXAMPLES[2]) @given(cases()) def test_education_condition_invariants(case): check(case) @@ -708,7 +909,12 @@ def test_examples_reach_the_cases(): drawn["uc_is_excepted_from_education_condition"][1:5], [True, False, True, False], ) - # People 9 and 10: the young Housing Benefit claimants. + # People 9 and 10: the young Housing Benefit claimants. Each claims + # Universal Credit, which ends their own Housing Benefit two weeks after + # the award starts (SI 2014/1230 reg. 8(2A)). The UC predicate follows + # that claim limb and reads reg. 5(5) only from reported Universal + # Credit, so it gives true; the condition reads the status on the first + # day of the award, when the Housing Benefit still runs. young = slice(9, 11) np.testing.assert_array_equal( drawn["is_qualifying_young_person_for_universal_credit"][young], [True, True] diff --git a/policyengine_uk/tests/test_uc_renter_exemption_properties.py b/policyengine_uk/tests/test_uc_renter_exemption_properties.py new file mode 100644 index 0000000000..5fbd4e1184 --- /dev/null +++ b/policyengine_uk/tests/test_uc_renter_exemption_properties.py @@ -0,0 +1,233 @@ +"""Property-based tests: the Universal Credit renter exemption from housing cost +contributions follows the claimants and reg 2's attendance allowance. + +Under UC Regs 2013 Sch 4 para 15(1), the Secretary of State makes no deduction +under para 13 for a renter who is a single person, or a joint renter where at +least one joint renter is a person, to whom para 15(2) applies. Para 1(2) calls +the claimants "renters" and joint claimants "joint renters". Where a member of +a couple claims as a single person under reg 3(3), the other member is neither. +Para 15(2)(c) names "a person in receipt of attendance allowance", which reg 2 +defines to include armed forces independence payment (head (f)). + +Each generated household has a claimant aged 18 to 64 who heads the household +and rents, a partner whom `uc_is_ineligible_partner` flags (so the claimant +claims as a single person), an adult son in their benefit unit, and an adult +non-dependant in a family of their own that makes no claim. Invariants: + +1. Making the flagged partner blind, or giving them Attendance Allowance, armed + forces independence payment, the middle or highest rate of the DLA care + component or the PIP daily living component, in any combination, never + changes any family's Universal Credit non-dependant deductions. +2. Giving the single claimant a positive armed forces independence payment + always exempts the claim and removes its non-dependant deductions. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +SLOTS = 8 +ROLES = ["claimant", "partner", "son", "lodger"] +TENURES = ["RENT_FROM_COUNCIL", "RENT_FROM_HA", "RENT_PRIVATELY"] +REGIONS = ["NORTH_WEST", "LONDON", "SCOTLAND", "WALES"] +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +DLA_CATEGORIES = ["NONE", "LOWER", "MIDDLE", "HIGHER"] + + +def situation(): + people, benunits, households = {}, {}, {} + for i in range(SLOTS): + names = {role: f"{role}_{i}" for role in ROLES} + for role, name in names.items(): + people[name] = { + "age": {YEAR: 40}, + "is_household_head": {YEAR: role == "claimant"}, + "is_claimant_or_partner": {YEAR: role != "son"}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + "employment_income": {YEAR: 0}, + } + people[names["partner"]]["uc_is_ineligible_partner"] = {YEAR: True} + benunits[f"family_{i}"] = { + "members": [names["claimant"], names["partner"], names["son"]], + "would_claim_uc": {YEAR: True}, + } + benunits[f"lodger_family_{i}"] = { + "members": [names["lodger"]], + "would_claim_uc": {YEAR: False}, + } + households[f"household_{i}"] = { + "members": list(names.values()), + "rent": {YEAR: 6_000}, + "tenure_type": {YEAR: TENURES[i % len(TENURES)]}, + "region": {YEAR: REGIONS[i % len(REGIONS)]}, + } + return {"people": people, "benunits": benunits, "households": households} + + +@pytest.fixture(scope="module") +def simulation(cloned_uk_tax_benefit_system): + return Simulation(situation=situation()) + + +money = st.floats(0, 40_000, allow_nan=False) +# The model holds amounts as 32-bit floats; positive amounts start at a penny. +positive = st.floats(0.01, 20_000, allow_nan=False) +dla_category = st.sampled_from(DLA_CATEGORIES) + + +@st.composite +def claimant_benefits(draw): + """Any mix of the para 15(2) qualifications, possibly none.""" + return dict( + is_blind=draw(st.booleans()), + attendance_allowance=draw(st.one_of(st.just(0.0), positive)), + armed_forces_independence_payment=draw(st.one_of(st.just(0.0), positive)), + dla_sc_category=draw(dla_category), + pip_dl=draw(st.one_of(st.just(0.0), positive)), + ) + + +@st.composite +def household(draw): + return dict( + claimant_age=draw(st.integers(18, 64)), + claimant_earnings=draw(st.one_of(st.just(0.0), money)), + claimant=draw(st.one_of(st.just(NO_BENEFITS), claimant_benefits())), + partner_age=draw(st.integers(18, 64)), + partner=draw(claimant_benefits()), + son_age=draw(st.integers(16, 64)), + son_earnings=draw(st.one_of(st.just(0.0), money)), + lodger_age=draw(st.integers(18, 64)), + lodger_earnings=draw(st.one_of(st.just(0.0), money)), + rent=draw(st.one_of(st.floats(0, 2_000), st.floats(2_000, 20_000))), + afip=draw(positive), + ) + + +NO_BENEFITS = dict( + is_blind=False, + attendance_allowance=0.0, + armed_forces_independence_payment=0.0, + dla_sc_category="NONE", + pip_dl=0.0, +) +EVERY_BENEFIT = dict( + is_blind=True, + attendance_allowance=5_000.0, + armed_forces_independence_payment=5_000.0, + dla_sc_category="HIGHER", + pip_dl=5_000.0, +) +populations = st.lists(household(), min_size=SLOTS, max_size=SLOTS) + + +def review_household(partner): + """The 2026 Maidstone-style household: claimant aged 45, a partner aged 43 + whom reg 3(3) excludes, an ordinary son aged 22 and 10,400 of rent.""" + return dict( + claimant_age=45, + claimant_earnings=0.0, + claimant=NO_BENEFITS, + partner_age=43, + partner=partner, + son_age=22, + son_earnings=0.0, + lodger_age=30, + lodger_earnings=0.0, + rent=10_400.0, + afip=5_000.0, + ) + + +def per_person(households, key): + """A person array of one input, from each role's own value.""" + values = [] + for h in households: + values += [ + h["claimant"][key], + h["partner"][key], + NO_BENEFITS[key], + NO_BENEFITS[key], + ] + return values + + +def calculate(sim, households, partner_benefits=True, claimant_afip=False): + ages, earnings = [], [] + for h in households: + ages += [h["claimant_age"], h["partner_age"], h["son_age"], h["lodger_age"]] + earnings += [ + h["claimant_earnings"], + 0.0, + h["son_earnings"], + h["lodger_earnings"], + ] + sim.set_input("age", YEAR, np.array(ages)) + sim.set_input("employment_income", YEAR, np.array(earnings, dtype=float)) + sim.set_input("rent", YEAR, np.array([h["rent"] for h in households])) + for key in NO_BENEFITS: + values = per_person(households, key) + if not partner_benefits: + values[1::4] = [NO_BENEFITS[key]] * len(households) + if key == "armed_forces_independence_payment" and claimant_afip: + values[0::4] = [h["afip"] for h in households] + if key == "dla_sc_category": + sim.set_input(key, YEAR, np.array(values)) + else: + sim.set_input(key, YEAR, np.array(values, dtype=type(values[0]))) + sim.drop_computed_arrays() + return { + name: np.asarray(sim.calculate(name, YEAR)) + for name in [ + "uc_is_ineligible_partner", + "uc_non_dep_deductions", + "uc_non_dep_deductions_renter_exempt", + ] + } + + +@PROPERTY_SETTINGS +@given(households=populations) +@example(households=[review_household(EVERY_BENEFIT)] * SLOTS) +@example(households=[review_household(dict(NO_BENEFITS, is_blind=True))] * SLOTS) +@example( + households=[ + review_household(dict(NO_BENEFITS, armed_forces_independence_payment=5_000.0)) + ] + * SLOTS +) +def test_an_excluded_partner_never_changes_the_deductions(simulation, households): + without = calculate(simulation, households, partner_benefits=False) + with_ = calculate(simulation, households, partner_benefits=True) + # The generator flags every partner. + assert with_["uc_is_ineligible_partner"][1::4].all() + # Invariant 1. + np.testing.assert_allclose( + with_["uc_non_dep_deductions"], without["uc_non_dep_deductions"], atol=0.01 + ) + np.testing.assert_array_equal( + with_["uc_non_dep_deductions_renter_exempt"], + without["uc_non_dep_deductions_renter_exempt"], + ) + + +@PROPERTY_SETTINGS +@given(households=populations) +@example(households=[review_household(NO_BENEFITS)] * SLOTS) +def test_the_single_claimants_afip_removes_the_deductions(simulation, households): + result = calculate(simulation, households, claimant_afip=True) + families = slice(0, 2 * SLOTS, 2) + # Invariant 2: para 15(1)(a) and (2)(c) with reg 2 head (f). + assert result["uc_non_dep_deductions_renter_exempt"][families].all() + np.testing.assert_array_equal(result["uc_non_dep_deductions"][families], 0) diff --git a/policyengine_uk/tests/test_vat_grossing_legacy.py b/policyengine_uk/tests/test_vat_grossing_legacy.py index b7d29a86d9..f7133fc3b2 100644 --- a/policyengine_uk/tests/test_vat_grossing_legacy.py +++ b/policyengine_uk/tests/test_vat_grossing_legacy.py @@ -31,7 +31,7 @@ def test_legacy_default_is_the_product_of_the_two_factors(): year ) * p.vat.household_share_of_receipts(year) assert p.microdata_vat_coverage(year) == pytest.approx(product) - assert LEGACY_COVERAGE_DEFAULT == pytest.approx(0.469) + assert LEGACY_COVERAGE_DEFAULT == pytest.approx(0.483) def test_reform_dict_to_legacy_path_changes_vat(): diff --git a/policyengine_uk/tests/test_vat_grossing_properties.py b/policyengine_uk/tests/test_vat_grossing_properties.py new file mode 100644 index 0000000000..4a9dfa5f13 --- /dev/null +++ b/policyengine_uk/tests/test_vat_grossing_properties.py @@ -0,0 +1,118 @@ +"""Properties of VAT grossing that hold for every input (#2633). + +`vat` is the statutory liability on recorded consumption divided by one +grossing factor, times one minus private school fees' share of VAT receipts, +plus VAT on energy bills, which the formula adds without grossing up. The +factor is the survey coverage times the household share of receipts, unless a +reform moves the deprecated combined parameter, in which case the formula uses +that value. +""" + +from types import SimpleNamespace + +import pytest +from hypothesis import assume, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.system import system +from policyengine_uk.utils.vat import LEGACY_COVERAGE_DEFAULT, vat_grossing_factor + +SHARE = st.floats(min_value=0.01, max_value=1, allow_nan=False) +SPEND = st.floats(min_value=0, max_value=1e6, allow_nan=False) +# A legacy value counts as untouched when it is within NumPy's default +# closeness of the declared default: 1e-8 plus 1e-5 times the default. +BAND = 1e-8 + 1e-5 * abs(LEGACY_COVERAGE_DEFAULT) + + +def params(legacy, coverage, share): + return SimpleNamespace( + microdata_vat_coverage=legacy, + vat=SimpleNamespace( + survey_consumption_coverage=coverage, + household_share_of_receipts=share, + ), + ) + + +# The helper properties check algebra and routing, not speed, so they have no +# deadline: a slow first example on a loaded host is not a failure. +@settings(deadline=None) +@given(coverage=SHARE, share=SHARE) +def test_untouched_legacy_factor_uses_the_product_of_the_two(coverage, share): + factor = vat_grossing_factor(params(LEGACY_COVERAGE_DEFAULT, coverage, share)) + assert factor == pytest.approx(coverage * share) + + +@settings(deadline=None) +@given( + offset=st.floats(min_value=-0.99, max_value=0.99), + coverage=SHARE, + share=SHARE, +) +def test_legacy_factor_within_the_band_counts_as_untouched(offset, coverage, share): + legacy = LEGACY_COVERAGE_DEFAULT + offset * BAND + factor = vat_grossing_factor(params(legacy, coverage, share)) + assert factor == pytest.approx(coverage * share) + + +@settings(deadline=None) +@given(legacy=SHARE, coverage=SHARE, share=SHARE) +def test_moved_legacy_factor_overrides_the_two(legacy, coverage, share): + assume(abs(legacy - LEGACY_COVERAGE_DEFAULT) > 1.01 * BAND) + factor = vat_grossing_factor(params(legacy, coverage, share)) + assert factor == legacy + + +@pytest.mark.parametrize( + "multiple, moved", [(-1.01, True), (-0.99, False), (0.99, False), (1.01, True)] +) +def test_legacy_factor_either_side_of_the_band(multiple, moved): + legacy = LEGACY_COVERAGE_DEFAULT + multiple * BAND + factor = vat_grossing_factor(params(legacy, 0.5, 0.7)) + assert factor == (legacy if moved else pytest.approx(0.35)) + + +def household_vat(spend, year): + """`vat` for one household per (full-rate, reduced-rate) spend pair.""" + situation = { + "people": {f"p{i}": {"age": {year: 40}} for i in range(len(spend))}, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(spend))}, + "households": { + f"h{i}": { + "members": [f"p{i}"], + "full_rate_vat_consumption": {year: full}, + "reduced_rate_vat_consumption": {year: reduced}, + } + for i, (full, reduced) in enumerate(spend) + }, + } + sim = Simulation(situation=situation) + # The households record no energy bills, so no energy-bill VAT is added. + assert (sim.calculate("domestic_energy_vat", year) == 0).all() + return sim.calculate("vat", year) + + +def expected_vat(full, reduced, year): + """Statutory liability over the shipped product, times (1 - school share).""" + p = system.parameters(year).gov + product = ( + p.simulation.vat.survey_consumption_coverage + * p.simulation.vat.household_share_of_receipts + ) + liability = full * p.hmrc.vat.standard_rate + reduced * p.hmrc.vat.reduced_rate + return liability / product * (1 - p.simulation.vat.private_school_share_of_receipts) + + +# Each spend pair is (full_rate_vat_consumption, reduced_rate_vat_consumption). +@settings(max_examples=5, deadline=None) +@given(spend=st.lists(st.tuples(SPEND, SPEND), min_size=1, max_size=20)) +@example(spend=[(0.0, 15_438.0), (10_000.0, 0.0)]) +def test_vat_is_statutory_liability_over_the_shipped_product(spend): + # One simulation of many households, so the property is checked on a + # vector: VAT is linear in each household's recorded consumption, with + # no cross-household term. + vat = household_vat(spend, 2025) + for value, (full, reduced) in zip(vat, spend): + expected = expected_vat(full, reduced, 2025) + assert value == pytest.approx(expected, rel=1e-5, abs=1e-2) diff --git a/policyengine_uk/utils/aggregate_impact/README.md b/policyengine_uk/utils/aggregate_impact/README.md index 942bc464c9..4569f14a3e 100644 --- a/policyengine_uk/utils/aggregate_impact/README.md +++ b/policyengine_uk/utils/aggregate_impact/README.md @@ -126,7 +126,12 @@ The checks are: every other head held at baseline, and fails only where it still breaks it; a response carried by another head (Incapacity Benefit lowers Carer's Allowance, which Universal Credit counts as income) is reported as - mediated. A declared double count must still be contained in its head; + mediated. A declared double count must still be contained in its head. The + check records a NaN or infinite baseline value under a blocking `finite:` + check (`finite:metrics` for a total or a double count) and continues + judging readable households. It holds such a head by leaving it to its + formula, and judges each household where the formula gives back the held + value; - **signs, bounds and finiteness**, and **determinism:** a rebuilt simulation reproduces the baseline exactly; - **inert heads:** a head that is zero for every unit is a warning, unless the diff --git a/policyengine_uk/utils/aggregate_impact/guard.py b/policyengine_uk/utils/aggregate_impact/guard.py index 4a460d37aa..d8357c5d87 100644 --- a/policyengine_uk/utils/aggregate_impact/guard.py +++ b/policyengine_uk/utils/aggregate_impact/guard.py @@ -62,7 +62,27 @@ (``households_not_held``); a problem does the same for the households that rest on the measurement the check could not make. A declared double count whose container's response is not finite fails: the check cannot - show that the container still contains it. + show that the container still contains it; +- a head, a model total or a component that is not finite at baseline has + no change to read where it is not finite, so the check cannot confirm it + there. It names each such variable once, with the households, in + ``not_finite_at_baseline``, which ``measure`` records as a failure of a + ``finite:`` check, and it fails no component for it. No rule reads a + head, or a model total, in the households where it, or one of its heads, + is not finite at baseline. The engine refuses NaN as an input, so the + check never sets such a value. To raise a component that is not finite + at baseline, it sets each such unit to 0 and raises the others. The 0s + change values only in the households that hold them, and it judges + nothing there for that component. To hold a head that is not + finite at baseline, it does not set the head at all: it leaves it to its + formula. Wherever the head does not respond to the component, its + formula gives it back the value the measurement holds it at (its + baseline, NaN where it was NaN, or its value after the first point), so + it is held there exactly and every other head reads what it would read + with the head held. The check compares the head with that value, unit by + unit and NaN matching only NaN, and judges the household only where they + match. A problem in a household it could not judge stays, and names the + head in ``heads_not_held_at_baseline``, beside ``heads_not_held``. What it cannot see: a head that adds a component back while tapering it away pound for pound (``max(0, a - c) + c``) does not move at the margin, so it is @@ -77,7 +97,8 @@ only after discarding formula results, so a value some formula computed earlier is never frozen as an input. Between perturbations it discards every formula result, and restores any input it overrode; at the end it recomputes -the baseline and requires it to match the first computation exactly. +the baseline and requires it to match the first computation exactly, with +NaN matching only NaN in the same place. """ from __future__ import annotations @@ -348,6 +369,15 @@ def _held_values(at_first, baseline, units): return np.where(units, baseline, at_first) +def _settable(values): + """``values`` in its layout (see ``_perturb``), with each value that is not + finite at 0: the engine refuses NaN as an input, and an infinite amount is + no level to hold. Finite values keep every bit.""" + if isinstance(values, list): + return [(month, np.where(np.isfinite(v), v, 0.0)) for month, v in values] + return np.where(np.isfinite(values), values, 0.0) + + def _households_text(households) -> str: """A count of households as messages give it (``count_text``); None is a small count that the published guard removed.""" @@ -404,18 +434,30 @@ def _unheld_where(households, of_them=False) -> str: return f"{households:,} {what}" -def _unheld_words(heads, component, name=str) -> str: - """The check could not hold ``heads``, which are not finite there.""" - names = [name(v) for v in heads] +def _listed(names) -> tuple: + """``names`` as a list in words, and how a relative clause refers to it.""" if len(names) == 1: - listed, which = names[0], "which is" - else: - listed = f"{', '.join(names[:-1])} and {names[-1]}" - which = "one or more of which is" - return ( - f"the check could not hold {listed}, {which} not finite there once " - f"{name(component)} rises" - ) + return names[0], "which is" + return f"{', '.join(names[:-1])} and {names[-1]}", "one or more of which is" + + +def _unheld_words(heads, component, name=str, at_baseline=()) -> str: + """The check could not hold ``heads``, which are not finite there once + ``component`` rises, or ``at_baseline``, which it left to their formulas + as they are not finite at baseline somewhere, and which moved there.""" + groups = [] + if heads: + listed, which = _listed([name(v) for v in heads]) + groups.append( + f"{listed}, {which} not finite there once {name(component)} rises" + ) + if at_baseline: + listed, which = _listed([name(v) for v in at_baseline]) + groups.append( + f"{listed}, {which} not finite at baseline in some households and " + f"moves there with {name(component)}" + ) + return "the check could not hold " + ", and ".join(groups) def not_held_note(component, head, heads, households, name=str) -> str: @@ -429,6 +471,17 @@ def not_held_note(component, head, heads, households, name=str) -> str: ) +def not_finite_note(variable, households, name=str) -> str: + """What the check says about ``variable``, not finite at baseline in + ``households`` households with weight (None: a small count that the + published guard removed).""" + return ( + f"{name(variable)} is not finite at baseline in " + f"{_unheld_where(households)}, so the counted-once check could not " + "confirm it there" + ) + + def _problem( total, head, @@ -440,15 +493,20 @@ def _problem( nan, not_held=(), unjudged=None, + at_baseline=(), ): """One failing (total, head, kind), with the households it fails in. ``nan`` marks the households where a measurement behind the problem saw a response that is not finite. ``unjudged`` marks the households of - ``where`` that rest on the second held measurement, which the check - could not make there because it could not hold ``not_held``. The + ``where`` that rest on a held measurement, which the check could not + make there because it could not hold ``not_held`` (not finite once the + component rises) or ``at_baseline`` (left to their formulas, as they are + not finite at baseline somewhere, and moved there). The direct-effect step reads ``where``, ``nan`` and ``unjudged``, and - ``counted_once`` drops them before it reports the result. + ``counted_once`` drops them before it reports the result. The problem + carries ``heads_not_held_at_baseline`` only when ``at_baseline`` names a + head. """ # Only households with weight count towards a published count. weighted = weights > 0 @@ -458,21 +516,23 @@ def _problem( not_finite = bool((where & nan).any()) not_finite_weighted = bool((where & nan & weighted).any()) unjudged = np.zeros_like(where) if unjudged is None else unjudged & where - unheld = int(np.count_nonzero(unjudged & weighted)) if not_held else 0 + unheld = ( + int(np.count_nonzero(unjudged & weighted)) if not_held or at_baseline else 0 + ) text = problem_text(kind, head, component, count, not_finite, not_finite_weighted) message = f"{total}: {text}" + words = _unheld_words(list(not_held), component, at_baseline=list(at_baseline)) if kind == "unconfirmed": message += ( - f" where {_unheld_words(list(not_held), component)}, so it could " - f"not confirm whether {head} contains {component}" + f" where {words}, so it could not confirm whether {head} contains " + f"{component}" ) - elif not_held: + elif not_held or at_baseline: message += ( - f" (in {_unheld_where(unheld, of_them=True)} " - f"{_unheld_words(list(not_held), component)}, so it could not show " - f"that {head} responds only through other heads)" + f" (in {_unheld_where(unheld, of_them=True)} {words}, so it could not " + f"show that {head} responds only through other heads)" ) - return { + out = { "total": total, "head": head, "kind": kind, @@ -482,11 +542,11 @@ def _problem( "not_finite": not_finite, "not_finite_weighted": not_finite_weighted, "heads_not_held": list(not_held), - "households_not_held": unheld, - "where": where, - "nan": nan, - "unjudged": unjudged, } + if at_baseline: + out["heads_not_held_at_baseline"] = list(at_baseline) + out.update(households_not_held=unheld, where=where, nan=nan, unjudged=unjudged) + return out def judge_total( @@ -505,13 +565,21 @@ def judge_total( model_scale: Optional[np.ndarray], allowed_readers: Iterable[str], second_changes: Optional[Mapping[str, np.ndarray]] = None, + unread: Optional[Mapping[str, np.ndarray]] = None, + model_unread: Optional[np.ndarray] = None, ) -> dict: """Apply the counted-once rules, household by household, to one total. ``head_changes`` maps each head variable of the total to its change by household when 1 per unit was added to ``component`` (``expected`` by household); ``second_changes`` gives the changes when 2 per unit were - added, where they were measured. + added, where they were measured. ``unread`` marks, for a head, the + households where its change cannot be read (it, or the component, is not + finite at baseline there), and ``model_unread`` the same for the model's + total. No rule judges a head there, nor the model's total where it or + any head of the total cannot be read. A head's response, and the model + total's coefficient, is its mean over the households where it can be + read, and is left out where no household with weight can be. - The component's own head must change by exactly ``expected`` in every household. @@ -534,19 +602,31 @@ def judge_total( mask ``nan`` of households whose judged response is not finite, and the mask ``unjudged`` (empty here). """ - added = float(np.sum(weights * expected)) allowed = set(allowed_readers) perturbed = expected > 0 responses, problems, screens, own_problem = {}, [], [], None + nowhere = np.zeros(len(expected), dtype=bool) + unread = unread or {} def problem(head, kind, where, nan): return _problem(total, head, kind, component, where, weights, perturbed, nan) + def mean_response(change, skip): + """The mean change per unit added, over households not in ``skip``; + None when ``skip`` leaves no household with weight to read.""" + added = float(np.sum(weights * np.where(skip, 0.0, expected))) + if not added: + return None if skip.any() else 0.0 + return float(np.sum(weights * np.where(skip, 0.0, change))) / added + for variable, change in head_changes.items(): - responses[variable] = float(np.sum(weights * change)) / added if added else 0.0 + skip = unread.get(variable, nowhere) + response = mean_response(change, skip) + if response is not None: + responses[variable] = response slack = _slack(head_scales[variable]) if variable == own_variable: - off = (np.abs(change - expected) > slack) | ~np.isfinite(change) + off = ((np.abs(change - expected) > slack) | ~np.isfinite(change)) & ~skip if off.any(): own_problem = problem(variable, "own_head", off, ~np.isfinite(change)) continue @@ -562,7 +642,12 @@ def problem(head, kind, where, nan): # Another head's response can add to a containing head's # slope, so any other linear response is measured again with # the other heads held. - linear = perturbed & (np.abs(slope) > 2 * response_slack) & ~containing + linear = ( + perturbed + & (np.abs(slope) > 2 * response_slack) + & ~containing + & ~skip + ) if linear.any(): screens.append( problem(variable, "contains", linear, ~np.isfinite(slope)) @@ -572,31 +657,33 @@ def problem(head, kind, where, nan): containing = perturbed & _contains(change, expected) # A response that is not finite fails wherever it is, as any # other head's does. - containing = containing | ~np.isfinite(slope) + containing = (containing | ~np.isfinite(slope)) & ~skip if containing.any(): problems.append( problem(variable, "contains", containing, ~np.isfinite(slope)) ) elif is_tax: - moved = _moves(change, expected) + moved = _moves(change, expected) & ~skip if moved.any(): problems.append(problem(variable, "moves", moved, ~np.isfinite(change))) else: - rises = _rises(change, slack) + rises = _rises(change, slack) & ~skip if rises.any(): problems.append(problem(variable, "rises", rises, ~np.isfinite(change))) partition_coefficient = float(sum(responses.values())) model_coefficient = None if model_change is not None: - model_coefficient = ( - float(np.sum(weights * model_change)) / added if added else 0.0 - ) + # The model's total cannot be read where it, or a head, cannot. + skip = nowhere if model_unread is None else model_unread + for variable in head_changes: + skip = skip | unread.get(variable, nowhere) + model_coefficient = mean_response(model_change, skip) heads_change = sum(head_changes.values()) if declared_double_count: heads_change = heads_change + expected not_finite = ~np.isfinite(model_change) | ~np.isfinite(heads_change) beyond = np.abs(model_change - heads_change) > _slack(model_scale) - mismatch = beyond | not_finite + mismatch = (beyond | not_finite) & ~skip if mismatch.any(): problems.append(problem(total, "model_total", mismatch, not_finite)) return { @@ -649,6 +736,21 @@ def _capture(sim, variables, year, layout) -> dict: return out +def _moved(sim, variable, year, layout) -> np.ndarray: + """Households holding a unit where ``variable`` now differs from its value + in ``layout`` (see ``_perturb``) in any period, NaN matching only NaN.""" + now, then = _capture(sim, [variable], year, layout)[variable], layout[variable] + if isinstance(now, list): + pairs = [(a, b) for (_, a), (_, b) in zip(now, then)] + else: + pairs = [(now, then)] + differs = np.logical_or.reduce( + [~((a == b) | (np.isnan(a) & np.isnan(b))) for a, b in pairs] + ) + entity = sim.tax_benefit_system.variables[variable].entity.key + return _household(sim, differs, entity) > 0 + + def breaks_on_slope(kind, where, expected, slack, slope): """Households of ``where`` whose head's slope, with the other heads held at their values after the first point, breaks ``kind``.""" @@ -679,25 +781,42 @@ def still_breaks(kind, where, expected, slack, first, second=None): return where -def _held_breaks(kind, where, expected, slack, first, second, slope_held, unjudged): +def _held_breaks( + kind, + where, + expected, + slack, + first, + second, + slope_held, + unjudged, + unjudged_at_baseline=None, +): """Households of ``where`` where a held measurement that judged them still breaks ``kind`` (see ``still_breaks`` and ``breaks_on_slope``). ``slope_held`` is the slope with the other heads held at their values - after the first point; it judges no household of ``unjudged``. + after the first point; it judges no household of ``unjudged``. ``first`` + and ``second``, with the other heads held at their baseline values, + judge no household of ``unjudged_at_baseline``. """ - held_at_baseline = still_breaks(kind, where, expected, slack, first, second) + at_baseline = where + if unjudged_at_baseline is not None: + at_baseline = where & ~unjudged_at_baseline + held_at_baseline = still_breaks(kind, at_baseline, expected, slack, first, second) judged = where & ~unjudged return held_at_baseline | breaks_on_slope(kind, judged, expected, slack, slope_held) -def _held_nan(kind, first, second, slope_held, unjudged): +def _held_nan(kind, first, second, slope_held, unjudged, unjudged_at_baseline=None): """Households where a held measurement that judges ``kind`` (see ``_held_breaks``) saw a response that is not finite.""" - nan = ~np.isfinite(first) | (~np.isfinite(slope_held) & ~unjudged) + nan = ~np.isfinite(first) if kind == "contains" and second is not None: nan = nan | ~np.isfinite(second) - return nan + if unjudged_at_baseline is not None: + nan = nan & ~unjudged_at_baseline + return nan | (~np.isfinite(slope_held) & ~unjudged) DIRECT_KINDS = ("contains", "rises", "moves") @@ -715,7 +834,11 @@ def counted_once( """Perturb each component (or those in ``variables_to_check``). ``tolerance`` is kept for reports of per-unit responses; the verdicts - themselves are household by household (see ``judge_total``). + themselves are household by household (see ``judge_total``). The result + carries ``not_finite_at_baseline`` only when a head, a model total or a + component it raised is not finite at baseline: one entry for each, with + the head's id and label (None for a variable that is no head), the + households with weight where it is not finite, and a message. """ if not discard_formula_results(sim): return { @@ -742,6 +865,16 @@ def counted_once( ] else: baseline[variable] = _raw(sim, variable, year).astype(np.float64) + # Where the check sets a variable, it sets these values: the baseline, + # with each value that is not finite at 0 (see the module docstring). + settable = {variable: _settable(values) for variable, values in baseline.items()} + # The households where each head, and each model total, has no change to + # read: it is not finite there at baseline. + unread = {v: ~np.isfinite(heads0[v]) for v in all_heads} + free_heads = {v for v in all_heads if unread[v].any()} + model_unread = {t: ~np.isfinite(model0[t]) for t in model_totals} + # The same for each component the check raises, by the units it holds. + blind_in = {} declared = {dc.variable: dc for dc in partition.double_counts} head_by_variable = {h.variable: h for h in partition.heads} @@ -782,19 +915,43 @@ def counted_once( if monthly else baseline[variable] ) - nonzero = year_values != 0 + # A unit that is not finite at baseline is never raised, and the + # households holding one are judged for nothing: their readings + # rest on the value the check sets there. + broken = _not_finite_units(baseline[variable]) + nonzero = (year_values != 0) & ~broken zero_baseline = not bool(nonzero.any()) - mask = np.ones_like(nonzero) if zero_baseline else nonzero + mask = ~broken if zero_baseline else nonzero added = mask.astype(np.float64) - expected = _household(sim, added, entity) + blind = _household(sim, broken, entity) > 0 + if blind.any(): + blind_in[variable] = blind + expected = np.where(blind, 0.0, _household(sim, added, entity)) # Perturbed units in households with weight: a zero-weight record # adds nothing to any published figure. record["units"] = int(round(float(np.sum(expected[hw > 0])))) if not float(np.sum(hw * expected)) > 0: + weighted_blind = bool((blind & (hw > 0)).any()) record.update( status="skipped", - detail="non-zero only in households with no weight", + detail=( + "the check could raise it only in households with no " + "weight or where it is not finite at baseline" + if weighted_blind + else "non-zero only in households with no weight" + ), ) + if weighted_blind and variable in declared: + dc = declared[variable] + double_count_results.append( + _unchecked_double_count( + dc, + f"the check could raise {variable} only in households " + "with no weight or where it is not finite at " + f"baseline, so it cannot show that {dc.contained_in} " + f"still contains {variable}", + ) + ) results.append(record) continue own = declared[variable].contained_in if variable in declared else variable @@ -809,7 +966,7 @@ def counted_once( for total in in_totals } - _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + _perturb(sim, variable, year, settable, added, monthly, touched=touched) heads1 = _head_arrays(sim, year, needed) model1 = { t: _raw(sim, t, year).astype(np.float64) @@ -832,7 +989,7 @@ def counted_once( heads2 = None if may_respond: _perturb( - sim, variable, year, baseline, 2 * added, monthly, touched=touched + sim, variable, year, settable, 2 * added, monthly, touched=touched ) heads2 = _head_arrays(sim, year, sorted(may_respond)) problems, screens = [], [] @@ -857,6 +1014,10 @@ def counted_once( second_changes=None if heads2 is None else {v: heads2[v] - heads0[v] for v in heads2}, + unread={v: unread[v] | blind for v in heads_of[total]}, + model_unread=( + model_unread[total] | blind if total in model_unread else None + ), ) record["coefficients"][total] = verdict["coefficient"] if verdict["model_coefficient"] is not None: @@ -883,17 +1044,24 @@ def counted_once( + [screen["head"] for screen in screens] ): held = [v for v in all_heads if v not in (variable, own, head)] + # The engine refuses NaN as an input, so the check sets no head + # that is not finite at baseline: it leaves each to its formula, + # and the measurement judges a household only where each came + # out at the value it holds the heads at (see ``_moved``). + fixed = [v for v in held if v not in free_heads] + free = [v for v in held if v in free_heads] _perturb( sim, variable, year, - baseline, + settable, added, monthly, - hold=held, + hold=fixed, touched=touched, ) first = _head_arrays(sim, year, [head])[head] - heads0[head] + moved_in = {v: _moved(sim, v, year, baseline) for v in free} second = None if any( p["head"] == head and p["kind"] == "contains" @@ -903,14 +1071,16 @@ def counted_once( sim, variable, year, - baseline, + settable, 2 * added, monthly, - hold=held, + hold=fixed, touched=touched, ) second = _head_arrays(sim, year, [head])[head] - heads0[head] - for v in held: + for v in free: + moved_in[v] = moved_in[v] | _moved(sim, v, year, baseline) + for v in fixed: _restore(sim, v, snapshot) # Second test: hold the other heads at their values after the # first point, and move the component on to the second. A head @@ -921,11 +1091,11 @@ def counted_once( # head is not finite after the first point keeps that head at its # baseline value, and this test judges no household the unit # belongs to. - _perturb(sim, variable, year, baseline, added, monthly, touched=touched) + _perturb(sim, variable, year, settable, added, monthly, touched=touched) at_first = _capture(sim, held, year, baseline) head_first = _head_arrays(sim, year, [head])[head] held_at, unheld_in = {}, {} - for v in held: + for v in fixed: units = _not_finite_units(at_first[v]) held_at[v] = at_first[v] if units.any(): @@ -938,15 +1108,18 @@ def counted_once( sim, variable, year, - baseline, + settable, 2 * added, monthly, - hold=held, + hold=fixed, held_at=held_at, touched=touched, ) slope_held = _head_arrays(sim, year, [head])[head] - head_first - for v in held: + # A head left to its formula is held at its value after the + # first point only where it stays there. + moved_on = {v: _moved(sim, v, year, at_first) for v in free} + for v in fixed: _restore(sim, v, snapshot) not_held = list(unheld_in) unjudged = np.zeros(len(hw), dtype=bool) @@ -957,11 +1130,30 @@ def counted_once( record["households_not_held"][head] = int( np.count_nonzero(unjudged & (hw > 0)) ) + # The households each held measurement could not judge: the + # second where a head it holds is not finite after the first + # point or one left to its formula moved, the first (at the + # baseline) where one left to its formula moved. + unjudged_at_baseline = np.zeros(len(hw), dtype=bool) + free_moved = {} + for v in free: + unjudged_at_baseline = unjudged_at_baseline | moved_in[v] + unjudged = unjudged | moved_on[v] + if (moved_in[v] | moved_on[v]).any(): + free_moved[v] = moved_in[v] | moved_on[v] + either = unjudged | unjudged_at_baseline def unheld(where): - """The heads the check could not hold in ``where``.""" + """The heads the check could not hold in ``where`` once the + component rises.""" return [v for v in not_held if bool((unheld_in[v] & where).any())] + def unheld_free(where): + """The heads left to their formula that moved in ``where``.""" + return [ + v for v in free_moved if bool((free_moved[v] & where).any()) + ] + slack = _slack(heads0[head]) kept = [] for p in problems: @@ -977,10 +1169,11 @@ def unheld(where): second, slope_held, unjudged, + unjudged_at_baseline, ) - # The second measurement clears no household it could not + # A held measurement clears no household it could not # judge: there the problem rests on that measurement. - rests = p["where"] & unjudged & ~breaks + rests = p["where"] & either & ~breaks direct = breaks | rests cleared = p["where"] & ~direct if cleared.any(): @@ -1023,10 +1216,16 @@ def unheld(where): perturbed_units, p["nan"] | _held_nan( - p["kind"], first, second, slope_held, unjudged + p["kind"], + first, + second, + slope_held, + unjudged, + unjudged_at_baseline, ), unheld(rests), rests, + unheld_free(rests), ) ) # A linear response that was not one for one may hide a @@ -1043,13 +1242,21 @@ def unheld(where): second, slope_held, unjudged, + unjudged_at_baseline, + ) + # Where a held measurement judged no household, the check + # could not confirm the reader there either way. + unconfirmed = screen["where"] & either & ~confirmed + nan = _held_nan( + "contains", + first, + second, + slope_held, + unjudged, + unjudged_at_baseline, ) - # Where the second measurement judged no household, the - # check could not confirm the reader there either way. - unconfirmed = screen["where"] & unjudged & ~confirmed - nan = _held_nan("contains", first, second, slope_held, unjudged) if confirmed.any(): - not_held_there, rests = (), None + not_held_there, rests, at_baseline_there = (), None, () same = next( ( k @@ -1062,9 +1269,10 @@ def unheld(where): if same is not None: kept.remove(same) confirmed = confirmed | same["where"] - not_held_there, rests = ( + not_held_there, rests, at_baseline_there = ( same["heads_not_held"], same["unjudged"], + same.get("heads_not_held_at_baseline", ()), ) kept.append( _problem( @@ -1078,6 +1286,7 @@ def unheld(where): nan if same is None else nan | same["nan"], not_held_there, rests, + at_baseline_there, ) ) if unconfirmed.any(): @@ -1093,6 +1302,7 @@ def unheld(where): nan, unheld(unconfirmed), unconfirmed, + unheld_free(unconfirmed), ) ) problems = kept @@ -1101,38 +1311,21 @@ def unheld(where): for p in problems ] record["mediated"] = mediated - record["heads_held_skipped"] = bool(record["heads_not_held"]) + record["heads_held_skipped"] = bool(record["heads_not_held"]) or any( + "heads_not_held_at_baseline" in p for p in problems + ) if variable in declared: - dc = declared[variable] - container_change = heads1[own] - heads0[own] - k = float(np.sum(hw * container_change)) / float(np.sum(hw * expected)) - finite = bool(np.isfinite(container_change).all()) - present = finite and not bool( - (np.abs(container_change - expected) > _slack(heads0[own])).any() - ) - row = { - "variable": variable, - "contained_in": dc.contained_in, - "coefficient": k, - "issue": dc.issue, - "status": "pass" if present else "fail", - "not_finite": not finite, - "detail": "" - if present - else ( - f"{dc.contained_in} moves by {k:.4f} per unit of {variable}: " - "the model no longer counts it twice, so remove the " - "declaration and give it its own head" - ), - } - if not finite: - # Not a stale declaration: the check cannot tell. - row["detail"] = ( - f"{dc.contained_in} is not finite when {variable} rises, " - f"so the check cannot show that {dc.contained_in} still " - f"contains {variable}" + double_count_results.append( + _double_count_row( + declared[variable], + heads1[own] - heads0[own], + expected, + # The households where the container's change can be read. + ~(unread[own] | blind), + hw, + heads0[own], ) - double_count_results.append(row) + ) if problems: record.update( status="fail", @@ -1156,8 +1349,11 @@ def unheld(where): discard_formula_results(sim) heads_again = _head_arrays(sim, year, all_heads) - reproduces = all(np.array_equal(heads_again[v], heads0[v]) for v in all_heads) - return { + # NaN matches NaN in the same place, and nothing else. + reproduces = all( + np.array_equal(heads_again[v], heads0[v], equal_nan=True) for v in all_heads + ) + result = { "status": "ran", "year": year, "tolerance": tolerance, @@ -1165,3 +1361,83 @@ def unheld(where): "double_counts": double_count_results, "reset_reproduces_baseline": bool(reproduces), } + # Each variable the check could not read somewhere, named once: the heads, + # the model totals, then the other components it raised. + where_not_finite = { + **{v: unread[v] for v in all_heads if unread[v].any()}, + **{t: model_unread[t] for t in model_totals if model_unread[t].any()}, + } + for variable, blind in blind_in.items(): + where_not_finite.setdefault(variable, blind) + if where_not_finite: + result["not_finite_at_baseline"] = [] + for variable, households in where_not_finite.items(): + head = head_by_variable.get(variable) + count = int(np.count_nonzero(households & (hw > 0))) + result["not_finite_at_baseline"].append( + { + "variable": variable, + "head": None if head is None else head.id, + "label": None if head is None else head.label, + "households": count, + "message": not_finite_note(variable, count), + } + ) + return result + + +def _double_count_row(dc, change, expected, readable, weights, scale) -> dict: + """Whether the container of a declared double count still moves by exactly + what was added to it, in each household where its ``change`` can be read + (``readable``).""" + variable, container = dc.variable, dc.contained_in + base = float(np.sum(weights * np.where(readable, expected, 0.0))) + if not base > 0: + return _unchecked_double_count( + dc, + f"{container} is not finite at baseline in every household with " + f"weight where the check raised {variable}, so it cannot show that " + f"{container} still contains {variable}", + ) + k = float(np.sum(weights * np.where(readable, change, 0.0))) / base + finite = bool(np.isfinite(change[readable]).all()) + present = finite and not bool( + ((np.abs(change - expected) > _slack(scale)) & readable).any() + ) + row = { + "variable": variable, + "contained_in": container, + "coefficient": k, + "issue": dc.issue, + "status": "pass" if present else "fail", + "not_finite": not finite, + "detail": "" + if present + else ( + f"{container} moves by {k:.4f} per unit of {variable}: the model no " + "longer counts it twice, so remove the declaration and give it its " + "own head" + ), + } + if not finite: + # Not a stale declaration: the check cannot tell. + row["detail"] = ( + f"{container} is not finite when {variable} rises, so the check " + f"cannot show that {container} still contains {variable}" + ) + return row + + +def _unchecked_double_count(dc, detail) -> dict: + """A declared double count the check could not judge anywhere: a + container or component not finite at baseline wherever it would read it. + It is skipped, not passed, and a ``finite:`` check names the cause.""" + return { + "variable": dc.variable, + "contained_in": dc.contained_in, + "coefficient": None, + "issue": dc.issue, + "status": "skipped", + "not_finite": False, + "detail": detail, + } diff --git a/policyengine_uk/utils/aggregate_impact/measure.py b/policyengine_uk/utils/aggregate_impact/measure.py index a4fb47e1cf..847b2fc4b3 100644 --- a/policyengine_uk/utils/aggregate_impact/measure.py +++ b/policyengine_uk/utils/aggregate_impact/measure.py @@ -1180,8 +1180,10 @@ def publishable_guard(guard: dict) -> dict: no count and no share. A count of households where the check could not hold a head (``households_not_held``) below ``MIN_CELL`` goes too. Messages already say "fewer than 10". The heads the check could not hold - (``heads_not_held``) and the ``not_finite`` flags hold no figures, so - they stay. + (``heads_not_held``, ``heads_not_held_at_baseline``) and the + ``not_finite`` flags hold no figures, so they stay. A count below + ``MIN_CELL`` of households where a variable is not finite at baseline + (``not_finite_at_baseline``) goes too. """ out = dict(guard) components = [] @@ -1226,8 +1228,9 @@ def publishable_guard(guard: dict) -> dict: row = dict(row) if small(units.get(row.get("variable")) or 0): row["coefficient"] = None - # A container that is not finite has a detail with no figures. - if row.get("status") != "pass" and not row.get("not_finite"): + # A container that is not finite, or a double count the check + # skipped, has a detail with no figures. + if row.get("status") == "fail" and not row.get("not_finite"): row["detail"] = ( f"{row['contained_in']} no longer moves one for one with " f"{row['variable']} (fewer than {MIN_CELL} units): the model " @@ -1237,6 +1240,14 @@ def publishable_guard(guard: dict) -> dict: double_counts.append(row) if "double_counts" in guard: out["double_counts"] = double_counts + if "not_finite_at_baseline" in guard: + out["not_finite_at_baseline"] = [ + { + **item, + "households": None if small(item["households"]) else item["households"], + } + for item in guard["not_finite_at_baseline"] + ] return out @@ -1283,10 +1294,21 @@ def record_guard_checks(rec: Recorder, guard: dict) -> None: rec.check( f"double_count_present:{result['variable']}", f"{result['variable']} is still contained in {result['contained_in']}", - result["status"] == "pass", + None if result["status"] == "skipped" else result["status"] == "pass", result["detail"], section="counted_once", ) + # A variable not finite at baseline fails a finite: check, which blocks + # whatever the base did: its own for a head, else finite:metrics. + for item in guard.get("not_finite_at_baseline") or []: + detail = f"{fiscal_year_label(guard['year'])}: {item['message']}" + scope = f"counted_once:{guard['year']}:{item['variable']}" + if item["head"] is None: + check_id, label = "finite:metrics", "Every reported figure is finite" + else: + check_id = f"finite:{item['head']}" + label = f"{item['label']} is finite for every unit" + rec.check(check_id, label, False, detail, section="finite", scope=scope) if "reset_reproduces_baseline" in guard: rec.check( "counted_once:reset_reproduces_baseline", diff --git a/policyengine_uk/utils/aggregate_impact/render.py b/policyengine_uk/utils/aggregate_impact/render.py index 8f398f3011..dff2799b13 100644 --- a/policyengine_uk/utils/aggregate_impact/render.py +++ b/policyengine_uk/utils/aggregate_impact/render.py @@ -6,7 +6,7 @@ from typing import Mapping, Optional from . import COMMENT_MARKER -from .guard import not_held_note, problem_text +from .guard import not_finite_note, not_held_note, problem_text from .privacy import withheld_text from .spec import Spec, fiscal_year_label @@ -330,6 +330,13 @@ def _guard(summary) -> str: f"response contains the component. {len(failed)} failed and the check " f"skipped {len(skipped)}." ] + code = "`{}`".format + # A variable not finite at baseline fails no component; a finite: check + # names it. + for item in guard.get("not_finite_at_baseline") or []: + lines.append( + "- " + not_finite_note(item["variable"], item.get("households"), code) + ) for c in failed: problems = c.get("problems") or [{"message": c.get("detail", "")}] lines.append( @@ -337,7 +344,6 @@ def _guard(summary) -> str: ) for c in skipped: lines.append(f"- `{c['variable']}` skipped: {c.get('detail', '')}") - code = "`{}`".format for c in tested: counts = c.get("households_not_held") or {} for head, heads in (c.get("heads_not_held") or {}).items(): @@ -361,6 +367,12 @@ def _guard(summary) -> str: f"{moved}; {m['total']}; not a failure)" ) for dc in guard.get("double_counts", []): + if dc.get("status") == "skipped": + lines.append( + f"- declared double count `{dc['variable']}` not checked: " + f"{dc['detail']} ({dc['issue']})" + ) + continue if dc.get("not_finite"): lines.append( f"- declared double count `{dc['variable']}`: `{dc['contained_in']}` " diff --git a/policyengine_uk/utils/benefit_cap.py b/policyengine_uk/utils/benefit_cap.py index 9a334e3ddf..e91e2fa09c 100644 --- a/policyengine_uk/utils/benefit_cap.py +++ b/policyengine_uk/utils/benefit_cap.py @@ -39,12 +39,12 @@ def benefit_cap_couple(benunit, period): (is_uc_assessed_claimant): a single claimant or joint claimants, and the other member of a couple where one member claims as a single person (UC Regs 2013 reg 78(2)). Otherwise it is Housing Benefit's claimant and - partner (is_claimant_or_partner; HB Regs 2006 regs 2(1), 75A). One total - of welfare benefits (benefit_cap_welfare_benefits) serves both caps: a - family receiving both awards is capped only through Universal Credit (UC - Regs 2013 reg 80(2A); HB Regs 2006 reg 75F(1)(g)), so its total is the - Universal Credit couple's. The two couples are the same unless - is_uc_claimant is entered. + partner (is_claimant_or_partner; HB Regs 2006 regs 2(1), 75A). Both caps + count this couple's welfare benefits (benefit_cap_welfare_benefits, and + uc_benefit_cap_welfare_benefits without Housing Benefit). Universal Credit + alone caps a family receiving both awards (UC Regs 2013 reg 80(2A); HB + Regs 2006 reg 75F(1)(g)), so the Universal Credit couple counts. The two + couples match unless the inputs set is_uc_claimant. """ person = benunit.members on_universal_credit = benunit("universal_credit_pre_benefit_cap", period) > 0 diff --git a/policyengine_uk/utils/benefit_unit.py b/policyengine_uk/utils/benefit_unit.py index 06ed4411b8..2060265cbf 100644 --- a/policyengine_uk/utils/benefit_unit.py +++ b/policyengine_uk/utils/benefit_unit.py @@ -54,8 +54,8 @@ def has_sixteen_year_old_dependant(benunit, period): those dates, so the benefit cap rates, which turn on responsibility for a child or young person, count such a member for the year. The Housing Benefit cap exceptions read the model's Child Benefit qualifying young - person instead, which leaves out a 16-year-old in neither non-advanced - education nor approved training (#2287). + person, which counts a 16-year-old for the year too, whatever their + education (Child Benefit (General) Regulations 2006 reg. 4). """ person = benunit.members age = person("age", period) diff --git a/policyengine_uk/utils/capital_valuation.py b/policyengine_uk/utils/capital_valuation.py new file mode 100644 index 0000000000..e2545adb26 --- /dev/null +++ b/policyengine_uk/utils/capital_valuation.py @@ -0,0 +1,218 @@ +"""Value means-tested capital as the capital-valuation regulations require. + +Universal Credit calculates capital "at its current market value or surrender +value less— (a) where there would be expenses attributable to sale, 10%; and +(b) the amount of any encumbrances secured on it" (UC Regs 2013 reg. 49(1)). +The legacy means tests have equivalent rules (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). Each programme sets its own rate and the capital sources whose sale +would incur expenses in a ``capital.sale_expenses`` parameter node, and how it +rounds a fraction of a penny in ``capital.round_down``: to the nearest penny +for Universal Credit and Housing Benefit, down for the others. +""" + +import numpy as np +from policyengine_core.model_api import max_ + +from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.dwp._person_capital_pool import ( + HOUSEHOLD_SOURCE_OF, + counted_person_sources, +) + +# Household inputs holding the debt secured on each capital source, such as a +# mortgage on a second property. The debt is deducted from the source it is +# secured on and that source is floored at nil, so negative equity in one +# source never reduces another (ADM H1615-H1616). Each source is a category +# summed over the household's assets, so within a category the debts on one +# asset still offset equity in another. +SECURED_DEBT = { + "other_residential_property_value": "other_residential_property_secured_debt", + "non_residential_property_value": "non_residential_property_secured_debt", + "owned_land": "owned_land_secured_debt", +} + + +def valued_capital(value_of, capital, *, sources=None): + """Sum ``capital.sources`` at market value less sale expenses and secured debt. + + ``value_of(variable)`` returns that household variable on the entity the + caller is working with, and ``capital`` is the programme's capital + parameter node. The sale-expense deduction is taken from the gross value + before the encumbrance (ADM H1608), and each valuation is rounded to the + penny under the programme's rule. A source carrying secured debt is never + valued below nil. The total is in float64, unrounded: the caller rounds + the capital it assesses at the last stage (``round_to_penny``). + + ``sources`` can restrict the valuation to a subset of the configured + sources, for example property retained alongside a financial-only report. + """ + total = 0 + selected_sources = capital.sources if sources is None else sources + for source in selected_sources: + if source not in capital.sources: + continue + value = exact_amount(value_of(source)) + if source in capital.sale_expenses.sources: + value = less_sale_expenses( + value, capital.sale_expenses.rate, capital.round_down + ) + if source in SECURED_DEBT: + value = max_(0, value - exact_amount(value_of(SECURED_DEBT[source]))) + total = total + value + return total + + +def valued_person_capital(benunit, period, parameters, capital, counted): + """Sum ``capital.person_sources`` over the benefit unit's members for whom + ``counted`` is true (its claimant and partner), in float64. + + A source whose formula can give a fraction of a penny + (``CALCULATED_PERSON_SOURCES``) is valued to the penny under the + programme's rule, like any other valuation. The baseline formula is + worked again in float64, so float32 cannot move the rounding, and only + where the variable's ``defined_for`` mask applies it, as Core does. Like + Core, it runs no formula when the mask applies to nobody. + Explicit inputs for this period and branch, and values from a reformed + formula, are kept as stored: matching a baseline value in float32 does + not prove where a value came from. Any other source is a stored + amount. + + The function counts only the sources ``counted_person_sources`` keeps. A + person source that a household source includes (``HOUSEHOLD_SOURCE_OF``) + holds pounds the formulas moved out of that household source, so the + function values it as ``valued_capital`` values the household source: + less the sale-expense rate where ``capital.sale_expenses.sources`` lists + the household source.""" + person = benunit.members + total = 0 + for source in counted_person_sources(capital): + stored = person(source, period) + value = exact_amount(stored) + if HOUSEHOLD_SOURCE_OF.get(source) in capital.sale_expenses.sources: + value = less_sale_expenses( + value, capital.sale_expenses.rate, capital.round_down + ) + if source in CALCULATED_PERSON_SOURCES: + calculate = CALCULATED_PERSON_SOURCES[source] + # A holder can predate a reform; the system owns the active formula. + variable = person.simulation.tax_benefit_system.get_variable(source) + if ( + not variable.is_neutralized + and variable.get_formula(period) is calculate + and supplied_input(person, source, period) is None + ): + applicable = None + if variable.defined_for is not None: + # Core keeps the formula's result only where the + # defined_for variable is above zero and stores the + # default elsewhere, and runs no formula when it is + # above zero for nobody; do the same. + applicable = ( + person.simulation.calculate( + variable.defined_for, period, map_to=variable.entity.key + ) + > 0 + ) + if applicable is None or applicable.any(): + exact = calculate(person, period, parameters) + if applicable is not None: + exact = np.where(applicable, exact, value) + value = exact + value = round_to_penny(value, capital.round_down) + total = total + benunit.sum(value * counted) + return total + + +def lifetime_isa_surrender_value(person, period, parameters): + """Surrender value of each person's Lifetime ISAs, in float64: the balance + less the withdrawal charge below the charge-free age, and the whole balance + from that age. + + 75% of a whole-penny balance can end in a quarter or half penny (75% of + £0.26 is £0.195), which float32 would store as 0.19499999 and so move a + nearest-penny rounding; 75% of £1,000.04 is £750.03.""" + p = parameters(period).gov.hmrc.isa.lifetime + balance = exact_amount(person("lifetime_isa_balance", period)) + charge_free = person("age", period) >= p.charge_free_age + return np.where(charge_free, balance, balance * (1 - p.withdrawal_charge)) + + +# Person-level capital sources the means tests value from their inputs, each +# mapped to the float64 calculation of its value. +CALCULATED_PERSON_SOURCES = { + "lifetime_isa_countable_capital": lifetime_isa_surrender_value, +} + + +def counted_share(sale_expenses, source): + """Share of a source's market value counted as capital: 90% where its + sale would incur expenses (reg. 49(1)(a)), otherwise all of it.""" + return 1 - sale_expenses.rate if source in sale_expenses.sources else 1 + + +def less_sale_expenses(value, rate, round_down): + """Market value less the sale-expense ``rate`` (reg. 49(1)(a)), rounded to + the penny under the programme's rule. + + The deduction is worked on the exact stored value: 250 shares at £4.1375 + are £1,034.375, and less 10% that is £930.9375, so £930.93 rounded down + (ADM H1665) or £930.94 to the nearest penny (UC Regs 2013 reg. 6(1)).""" + return round_to_penny( + exact_amount(value) * (1 - np.asarray(rate, dtype=np.float64)), round_down + ) + + +def exact_amount(amount): + """``amount`` in float64, with float32 storage error removed. + + Inputs are stored in float32, which holds £1,000.10 as 1,000.0999756 and + 90% of £16,001 as 14,400.8994. A float32 value, or one widened from + float32 to float64, is taken to be a whole penny when that penny is within + half its float32 spacing: it is then the penny the value was stored from. + Below £131,072 float32 holds every penny, so at most one penny is that + close. From £131,072 its spacing is wider than a penny, the penny cannot + be identified, and the value is kept as stored. A capital figure built + from it can then be a penny either way: £140,000.01 and £140,000.02 are + both stored as 140,000.015625, and 90% of that rounds down to + £126,000.01, a penny above the £126,000.00 that £140,000.01 gives. The + same holds for any stored value from £262,144, where the spacing exceeds + two pennies. A value in whole pounds below £16,777,216 is stored exactly + and is unaffected. Any other value (a share price to four decimal places, + or a float64 that is not a float32) is kept exactly.""" + wide = np.asarray(amount).astype(np.float64) + pennies = np.round(wide, 2) + # Zero and subnormal values underflow in np.spacing and in the cast to + # float32, and an infinite one overflows; neither is a penny that needs + # recovering. + with np.errstate(over="ignore", under="ignore", invalid="ignore"): + narrow = wide.astype(np.float32) + precision = np.where( + narrow == wide, + np.spacing(np.abs(narrow)).astype(np.float64), + np.spacing(np.abs(wide)), + ) + stored_from_penny = (2 * np.abs(pennies - wide) <= precision) & ( + precision < 0.01 + ) + return np.where(stored_from_penny, pennies, wide) + + +def round_to_penny(amount, round_down): + """Round ``amount`` to the penny: down where ``round_down``, otherwise to + the nearest penny with half a penny rounded up. + + Universal Credit treats a fraction of half a penny or more as a penny and + disregards a smaller one (UC Regs 2013 reg. 6(1)), and Housing Benefit may + do the same (HB Regs 2006 reg. 80). The legacy benefits treat a fraction + as a penny only where that is to the claimant's advantage (IS Regs 1987 + reg. 27, JSA Regs 1996 reg. 92, ESA Regs 2008 reg. 3(a), SPC Regs 2002 + reg. 24A), which for capital means rounding down. + + Computed in float64, and rounded to a millionth of a penny first, so binary + noise next to a whole or half penny (206.99999999999997 pence for 90% of + £2.30) does not move it. That tolerance is intended: an amount within a + millionth of a penny below a whole penny, which only a value that is not + a whole penny can produce, rounds to that penny.""" + pence = np.round(np.asarray(amount, dtype=np.float64) * 100, 6) + return np.where(round_down, np.floor(pence), np.floor(pence + 0.5)) / 100 diff --git a/policyengine_uk/utils/carers_allowance_taper.py b/policyengine_uk/utils/carers_allowance_taper.py index e72d9a1de5..12d41ec85b 100644 --- a/policyengine_uk/utils/carers_allowance_taper.py +++ b/policyengine_uk/utils/carers_allowance_taper.py @@ -32,6 +32,7 @@ from policyengine_uk.utils.benefit_unit import HALF_PENNY from policyengine_uk.utils.couple_carer_award import limit_carer_benefit_to_one_partner from policyengine_uk.utils.formula_origin import calculated_by +from policyengine_uk.utils.pence import stored_pence from policyengine_uk.utils.supplied_inputs import supplied_input @@ -45,7 +46,12 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): csp_replaces_ca = period.start.year >= 2025 receives_ca = person("carers_allowance_reported", period) > 0 meets_age_condition = person("age", period) >= ca.min_age - meets_work_condition = person("care_hours", period) >= ca.min_hours + # SSCBA 1992 s. 70(1): the hours count only for caring for a severely + # disabled person (s. 70(2)), which the model takes to hold unless the + # input says otherwise. + meets_work_condition = (person("care_hours", period) >= ca.min_hours) & person( + "cares_for_severely_disabled_person", period + ) would_claim = person("would_claim_carers_allowance", period) eligible = ( ~(in_scotland & csp_replaces_ca) @@ -62,7 +68,14 @@ def carers_allowance_weekly_by_part_of_year(person, period, parameters): # weekly earnings above the limit. taper = ca.earnings_taper country = person.household("country", period).decode_to_str() - excess = person("carers_allowance_weekly_earnings_above_limit", period) + # For a limit in whole pence the excess is whole pence, which float32 holds + # only to within half a step (1p as 0.0099999998); stored_pence reads them + # back exactly, in float64, so the formula subtracts them from the rate in + # float64. + excess = ( + stored_pence(person("carers_allowance_weekly_earnings_above_limit", period)) + / 100 + ) tapered = np.isin(country, list(taper.countries)) * np.maximum( 0, ca.rate - taper.rate * excess ) diff --git a/policyengine_uk/utils/income_tax_allocation.py b/policyengine_uk/utils/income_tax_allocation.py new file mode 100644 index 0000000000..c0c8967397 --- /dev/null +++ b/policyengine_uk/utils/income_tax_allocation.py @@ -0,0 +1,712 @@ +"""Set Income Tax reliefs and allowances against the components of income. + +At Steps 2 and 3 of the Income Tax calculation (ITA 2007 s. 23), the taxpayer +deducts reliefs and allowances "in the way which will result in the greatest +reduction in the taxpayer's liability to income tax" (s. 25(2)). From +2027-28, the taxpayer deducts them from income other than property, savings +and dividend income first (s. 25(3A), inserted by Finance Act 2026 s. 6(6) +with effect from 2027-28 under s. 6(8)), and then chooses between property, +savings and dividend income (s. 25(2)). Before 2027-28, the taxpayer chooses +between non-savings, savings and dividend income (s. 25(2)). In those years +Parliament taxes property income as non-savings income at the same rates, so +the model takes the deductions off the rest of non-savings income first, as +it always has. + +The model deducts trade loss relief against general income at Step 2 and the +amounts in ``allowances`` (the personal and blind person's allowances, gifts +of investments to charity, covenanted payments, other deductions and pension +relief). The search shares their sum between the components. Each part of it +can come off any component, and the tax depends only on how much comes off +each, so the search shares out the sum. + +Pension relief is the exception. The model takes employee contributions as a +net pay arrangement, which the employer deducts from employment income (FA +2004 s. 193(2)), and personal contributions as relief at source, under which +the payer keeps basic rate tax on the contribution (s. 192(1)). On a claim, +Parliament raises the basic and higher rate limits by the contribution where +higher or additional rate tax is chargeable (s. 192(4)), and for a Scottish +taxpayer the Scottish basic rate limit and the Scottish rate limits above it +where a Scottish rate above the Scottish basic rate is chargeable +(s. 192(4A)). Deducting the contribution from the bottom of non-savings +income moves every slice of income down against those limits, as raising +them does. Neither arrangement gives a Step 2 deduction the taxpayer can set +against savings or dividends, so the search holds +``min(pension relief, non-savings income)`` on non-savings income and shares +only the rest. + +How the search finds the least tax +---------------------------------- +Three components share what s. 25(3A) leaves: the first (non-savings income +before 2027-28, property income after), savings and dividends. Write ``x`` +for the first component left after deductions, ``w`` for ``x`` plus the +savings left, and ``T - w`` for the dividends left, where ``T``, the three +together after deductions, stays the same in every share. Savings sit above +non-savings income and dividends above savings (ss. 16 and 16A), so with +``q`` the non-savings income below the savings (``x``, or property plus the +other non-savings income left before it from 2027-28): + +- tax on the first component changes slope where ``x`` crosses a rate limit; +- the starting rate for savings (s. 12) and the savings nil rate (s. 12A) + cover the savings from ``q`` up to ``max(q, L) + A``, where ``L`` stands + for the starting rate limit and ``A`` for the personal savings allowance. + That changes slope where ``q`` reaches ``L``, where ``max(q, L) + A`` + crosses a rate limit, and where ``w`` reaches the top of that band: + ``w - x = A`` above ``L``, ``w`` constant below it; +- the rest of the savings change slope where their top, ``w``, crosses a rate + limit; +- the dividend nil rate (s. 13A) covers the lowest dividend allowance ``DA`` + of the dividends, so their tax changes slope where ``w + DA`` crosses a + dividend rate limit or reaches ``T``. + +The limits on the components add ``x = 0`` and ``x`` equal to the whole first +component, savings left from nil to all (``w - x = 0`` and ``w - x`` equal to +the savings), and dividends left from nil to all (``w = T`` and ``w = T`` +less the dividends). So the Step 5 tax runs continuous and linear on each +piece of the feasible polygon that vertical lines (``x`` constant), +horizontal lines (``w`` constant) and diagonal lines (``w - x`` constant) +cut. A linear function takes its least value on a polygon at a corner, so the +least tax lies where two of these lines cross. ``_candidates`` lists every +such crossing, and the search evaluates the tax at all of them for all people +at once. + +Before 2027-28, a reform can add an exclusion ``E`` from earned income. With +property income ``P``, the savings then sit on ``q = x`` up to ``x = P``, on +``q = P`` up to ``x = P + E``, and on ``q = x - E`` beyond. ``_candidates`` +adds the lines of each of those pieces: the two boundaries, the limits moved +by ``P`` and ``E``, and the diagonal lines that a fixed ``q = P`` gives. + +That argument needs the starting rate limit at or below the basic rate limit, +a phase-out of one pound of starting rate band for each pound of non-savings +income (s. 12) and a first UK rate threshold at nil, as in every year +modelled. A reform that sets that threshold above nil leaves savings below it +untaxed and adds lines on which ``x + w`` stays constant, which the search +doesn't list, so the search can then miss the least tax. On 6,520 synthetic +people with that threshold at £2,000, a 101 x 101 grid found less tax than +the search for 32 in 2026-27 (by up to £181.70) and 38 in 2027-28 (by up to +£214.75). The fast path sends everyone to the search under such a reform. +The search keeps the order non-savings, savings, dividends unless another +share gives less tax, so it never charges more than that order. + +The search ignores the Marriage Allowance. Parliament gives it as a tax +reduction (s. 55B(1)), which the taxpayer deducts at Step 6, not as a +deduction at Step 3, and whether a person qualifies for it (s. 55B(2)(b)) +depends on the share. The model still subtracts the allowance from earned +income (#1947), which moves a recipient's savings and dividends down by up to +that amount, so for a recipient the search's tax can differ from the model's. + +Who cannot gain +--------------- +Most people cannot gain from another share, and ``_old_order_is_least`` finds +them without a search, so the search runs only on the rest. + +1. One share: with no deduction left for the taxpayer to place under + s. 25(2), or no income left after the deductions, only one share exists, + and the old order takes it. + +Tests 2 and 3 need the rates to rise with income in every scale, as Parliament +has set them, and the old order to set every shared deduction against the +first component, so that any other share leaves more of it (``x`` rises). + +2. Nothing taxed above the first component: if the old order leaves no savings + and no dividends charged above the nil rates, any other share charges them + at least nil, and tax on the first component rises with ``x``. So no share + gives less tax. +3. Rates: moving a pound of the deductions to savings or dividends raises + ``x`` by that pound and ``w`` by at most a pound. Write ``rho_n`` for the + rate on the first component just above where the old order leaves it, + ``rho_s`` for the savings rate just above the top of the savings, and + ``sigma_s`` and ``sigma_v`` for the highest savings and dividend rates the + moved pounds can reach above the nil rates. The tax then rises by at least + ``(rho_n - sigma_s) dx + (rho_s - sigma_v) dw`` for any move with + ``0 <= dw <= dx``, or ``rho_n dx - sigma_v dw`` where the old order charges + no savings above the nil rates. Where that bound never falls below zero, + the old order gives the least tax. The top of the nil rates moves by at + most ``dx``, because the starting rate band shrinks by at most a pound for + each pound of non-savings income (s. 12) and the savings and dividend nil + rates cover fixed amounts (ss. 12A and 13A). Before 2027-28 the test also + needs no property income or no exclusion from earned income that the + search reads: with property income, such an exclusion breaks the rising + rate on the first component. + +Where any test holds, the search would keep the old order too, since it +changes the share only for a saving above ``TOLERANCE``. So both paths give +the same result, and ``test_income_tax_allocation.py`` checks it. +""" + +from types import SimpleNamespace + +import numpy as np + +from policyengine_uk.utils.income_tax import first_extended_scottish_threshold + +# A share replaces the order non-savings, savings, dividends only where it +# saves more than this (in pounds), so rounding never moves a deduction. +TOLERANCE = 1e-3 +# The search treats shares whose tax differs by less than this (in pounds) +# as equally good. +TIE = 1e-6 +# Bound on people times candidates evaluated at once, to bound memory. +CHUNK = 2_000_000 +# The variables on earned_taxable_income_exclusions that the allocation sets +# rather than reads. +ALLOCATED_EXCLUSIONS = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "taxable_property_income", + "received_allowances_earned_income", +) +# The search leaves these exclusions out. Parliament gives the Marriage +# Allowance as a tax reduction (s. 55B(1)), not a deduction at Step 3, and +# whether a person qualifies (s. 55B(2)(b)) depends on the share, so the search +# shares a recipient's deductions as if they had none. +IGNORED_EXCLUSIONS = ("marriage_allowance",) + + +def _schedule(scale, gift, first=1, last=3): + """Thresholds and rate steps of a marginal-rate scale, with the limits from + index ``first`` up to ``last`` raised by the grossed-up Gift Aid gift (ITA + 2007 s. 414(2)(b)), as calc_income_tax_with_gift_aid applies it.""" + thresholds, steps, previous = [], [], 0 + stop = min(last, len(scale.thresholds)) + for index, (threshold, rate) in enumerate(zip(scale.thresholds, scale.rates)): + thresholds.append(threshold + (gift if first <= index < stop else 0)) + steps.append(rate - previous) + previous = rate + return thresholds, steps + + +def _tax(income, schedule): + thresholds, steps = schedule + return sum(step * np.maximum(income - t, 0) for t, step in zip(thresholds, steps)) + + +def _inputs(person, period, income_tax): + """The amounts the tax on a share depends on, one float64 value a person.""" + + def values(variable): + return np.asarray(person(variable, period), dtype=np.float64) + + exclusions = [ + variable + for variable in income_tax.earned_taxable_income_exclusions + if variable not in ALLOCATED_EXCLUSIONS + IGNORED_EXCLUSIONS + ] + savings = values("taxable_savings_interest_income") + return dict( + savings=savings, + dividends=values("taxable_dividend_income"), + relief=values("trade_loss_relief_against_general_income"), + net_income=values("net_income"), + property=values("taxable_property_income"), + allowances=values("allowances"), + pension=values("pension_contributions_relief"), + earned_exclusions=sum( + (values(variable) for variable in exclusions), np.zeros_like(savings) + ), + gift=values("gift_aid_grossed_up"), + scottish=np.asarray(person("pays_scottish_income_tax", period)), + savings_allowance=values("savings_allowance"), + ) + + +def _context(person, period, parameters): + """Everything the tax on a share depends on, as float64 columns.""" + income_tax = parameters(period).gov.hmrc.income_tax + return _build(_inputs(person, period, income_tax), income_tax) + + +def _build(inputs, income_tax): + """The context for the people in ``inputs`` (see ``_inputs``).""" + rates = income_tax.rates + columns = {name: values[:, None] for name, values in inputs.items()} + savings = np.maximum(0, columns["savings"]) + dividends = np.maximum(0, columns["dividends"]) + relief = columns["relief"] + # net_income, not its components, so the search follows a supplied adjusted + # net income or a reform that sets net income, as the default order does. + non_savings = np.maximum(0, columns["net_income"] + relief - savings - dividends) + # Net pay comes off employment income (FA 2004 s. 193(2)); for relief at + # source Parliament raises the basic and higher rate limits instead + # (s. 192(4); for a Scottish taxpayer, the Scottish basic rate limit and + # those above it, s. 192(4A)), which deducting the relief from the bottom + # of non-savings income matches. So pension relief stays on non-savings + # income as far as it reaches; the search shares the rest. + # allowances includes the relief, so it never holds more than that. + pension = np.clip( + columns.get("pension", np.zeros_like(relief)), + 0, + np.minimum(np.maximum(0, columns["allowances"]), non_savings), + ) + non_savings = non_savings - pension + property_income = np.minimum(np.maximum(0, columns["property"]), non_savings) + deductions = relief + columns["allowances"] - pension + used = np.minimum(deductions, non_savings + savings + dividends) + gift = columns["gift"] + uk_thresholds = rates.uk.thresholds + savings_rates = rates.savings + property_rates = rates.property + c = SimpleNamespace( + other_first=bool(income_tax.reliefs.other_income_first), + savings=savings, + dividends=dividends, + non_savings=non_savings, + property=property_income, + used=used, + earned_exclusions=columns["earned_exclusions"], + gift=gift, + scottish=columns["scottish"], + uk=_schedule(rates.uk, gift), + scotland=_schedule( + rates.scotland.rates, + gift, + first_extended_scottish_threshold(rates.scotland.rates), + len(rates.scotland.rates.thresholds), + ), + dividend_scale=_schedule(rates.dividends, gift), + bands=( + uk_thresholds[0], + uk_thresholds[1] + gift, + uk_thresholds[2] + gift, + ), + savings_rates=savings_rates, + property_rates=property_rates, + starting_limit=rates.savings_starter_rate.allowance, + phase_out=rates.savings_starter_rate.phase_out, + savings_allowance=columns["savings_allowance"], + dividend_allowance=income_tax.allowances.dividend_allowance, + ) + if c.other_first: + # s. 25(3A): other non-savings income takes the deductions first. + other = non_savings - property_income + to_other = np.minimum(other, used) + c.other_left = other - to_other + c.first = property_income + c.shared = used - to_other + # The savings stack on the other non-savings income left, as earned + # taxable income has it, plus the property income left. + c.offset = np.maximum(0, c.other_left - c.earned_exclusions) + else: + c.first = non_savings + c.shared = used + c.offset = np.zeros_like(savings) + c.step_3_income = c.first + savings + dividends - c.shared + return c + + +def _non_savings_tax(c, income): + return np.where(c.scottish, _tax(income, c.scotland), _tax(income, c.uk)) + + +def _split(c, x): + """The other non-savings income and the property income left, and the + earned taxable income, where the share leaves ``x`` of the first + component.""" + if c.other_first: + other, property_left = c.other_left, x + else: + property_left = np.minimum(x, c.property) + other = x - property_left + return other, property_left, np.maximum(0, other - c.earned_exclusions) + + +def _savings_band(c, below, s): + """The top of the savings and the top of the part of them that the + starting rate (s. 12) and the savings nil rate (s. 12A) cover, for ``s`` + of savings above ``below`` of non-savings income.""" + low, basic_limit, _ = c.bands + top = below + s + in_basic_band = np.clip(top, low, basic_limit) - np.clip(below, low, basic_limit) + starting = np.clip( + np.minimum(in_basic_band, s), + 0, + np.maximum(0, c.starting_limit - np.maximum(0, below * c.phase_out)), + ) + return top, below + np.minimum(s, starting + c.savings_allowance) + + +def step_5_tax(c, x, s, v): + """Step 5 Income Tax on the share leaving ``x`` of the first component, + ``s`` of savings and ``v`` of dividends, as the model's + income_tax_pre_charges computes it from the variables the share sets.""" + other, property_left, earned = _split(c, x) + earned_tax = _non_savings_tax(c, earned) + if c.other_first: + # property_income_tax.formula_2027: Scottish rates in Scotland, + # property rates on the UK bands elsewhere. + rates = c.property_rates + _, basic_upper, higher_upper = c.bands + in_basic = np.minimum(property_left, np.maximum(0, basic_upper - other)) + higher_space = np.maximum(0, higher_upper - np.maximum(other, basic_upper)) + in_higher = np.minimum(np.maximum(0, property_left - in_basic), higher_space) + in_additional = np.maximum(0, property_left - in_basic - in_higher) + ruk = ( + rates.basic * in_basic + + rates.higher * in_higher + + rates.additional * in_additional + ) + scottish = _tax(other + property_left, c.scotland) - _tax(other, c.scotland) + property_tax = np.where(c.scottish, scottish, ruk) + else: + property_tax = _non_savings_tax(c, other + property_left) - ( + _non_savings_tax(c, other) + ) + # Savings and dividends sit on non_savings_taxable_income. + top, start = _savings_band(c, earned + property_left, s) + low, basic_limit, higher_limit = c.bands + rates = c.savings_rates + savings_tax = ( + rates.basic + * np.maximum( + 0, + np.clip(top, low, basic_limit) - np.clip(start, low, basic_limit), + ) + + rates.higher + * np.maximum( + 0, + np.clip(top, basic_limit, higher_limit) + - np.clip(start, basic_limit, higher_limit), + ) + + rates.additional + * np.maximum(0, np.maximum(top, higher_limit) - np.maximum(start, higher_limit)) + ) + nil_rate = np.minimum(c.dividend_allowance, v) + dividend_tax = np.maximum( + 0, + _tax(top + v, c.dividend_scale) - _tax(top + nil_rate, c.dividend_scale), + ) + return earned_tax + property_tax + savings_tax + dividend_tax + + +def _candidates(c): + """The points where two of the lines the module docstring lists cross.""" + zero = np.zeros_like(c.savings) + low, basic_limit, higher_limit = c.bands + band_limits = (basic_limit, higher_limit) + allowance = c.savings_allowance + dividend_allowance = c.dividend_allowance + if c.other_first: + first_limits = [ + np.where(c.scottish, scottish, ruk) - c.other_left + for scottish, ruk in _paired_limits( + c.scotland[0], [low, basic_limit, higher_limit], zero + ) + ] + else: + first_limits = [ + np.where(c.scottish, scottish, ruk) + for scottish, ruk in _paired_limits(c.scotland[0], c.uk[0], zero) + ] + total = c.step_3_income + vertical = [zero, c.first] + first_limits + horizontal = [total - c.dividends, total, total - dividend_allowance] + diagonal = [zero, c.savings, allowance] + # The non-savings income below the savings is x plus each offset. + offsets = [c.offset] + if not c.other_first and np.any(c.earned_exclusions > 0): + # Before 2027-28 an exclusion E from earned income splits x into three + # pieces: q = x up to the property income P, q = P up to P + E, and + # q = x - E beyond. Tax on the property income changes slope where x + # or x - P crosses a limit, and tax on earned income where x - P - E + # does. With q fixed at P, the savings band and dividend lines run + # diagonal, and the top of the dividends moves with x. + property_income, exclusion = c.property, c.earned_exclusions + offsets.append(-exclusion) + vertical += [property_income, property_income + exclusion] + vertical += [property_income + limit for limit in first_limits] + vertical += [property_income + exclusion + limit for limit in first_limits] + vertical += [property_income + total - limit for limit in c.dividend_scale[0]] + starting = np.maximum(0, c.starting_limit - property_income * c.phase_out) + diagonal += [limit - property_income for limit in band_limits] + diagonal += [starting + allowance] + diagonal += [ + limit - dividend_allowance - property_income + for limit in c.dividend_scale[0] + ] + for offset in offsets: + vertical += [c.starting_limit - offset + zero] + vertical += [limit - allowance - offset for limit in band_limits] + horizontal += [c.starting_limit + allowance - offset] + horizontal += [limit - offset + zero for limit in band_limits] + horizontal += [ + limit - dividend_allowance - offset + zero for limit in c.dividend_scale[0] + ] + xs = np.concatenate([np.broadcast_to(v, zero.shape) for v in vertical], axis=1) + ws = np.concatenate([np.broadcast_to(h, zero.shape) for h in horizontal], axis=1) + ds = np.concatenate([np.broadcast_to(d, zero.shape) for d in diagonal], axis=1) + n = len(zero) + x = np.concatenate( + [ + np.repeat(xs, ws.shape[1], axis=1), + np.repeat(xs, ds.shape[1], axis=1), + (ws[:, :, None] - ds[:, None, :]).reshape(n, -1), + ], + axis=1, + ) + w = np.concatenate( + [ + np.tile(ws, xs.shape[1]), + (xs[:, :, None] + ds[:, None, :]).reshape(n, -1), + np.repeat(ws, ds.shape[1], axis=1), + ], + axis=1, + ) + return x, w + + +def _paired_limits(scottish, other, zero): + """Pair two lists of limits by position, padding the shorter with +inf.""" + length = max(len(scottish), len(other)) + pad = zero + np.inf + return [ + ( + scottish[i] + zero if i < len(scottish) else pad, + other[i] + zero if i < len(other) else pad, + ) + for i in range(length) + ] + + +def _shares(c, x, w): + """Project candidate points onto the feasible polygon and split them into + the first component, savings and dividends left.""" + total = c.step_3_income + x = np.clip( + x, np.maximum(0, total - c.savings - c.dividends), np.minimum(c.first, total) + ) + w = np.clip(w, np.maximum(x, total - c.dividends), np.minimum(x + c.savings, total)) + return x, w - x, total - w + + +def _least_tax_share(c): + """The candidate share with the least Step 5 tax, per person. Of shares + within TIE of it, the one setting most against the first component, then + most against savings: the nearest the least tax allows to the order + non-savings, savings, dividends.""" + x, w = _candidates(c) + n, count = x.shape + tax = np.empty((n, count)) + width = max(1, CHUNK // max(n, 1)) + for start in range(0, count, width): + part = slice(start, start + width) + x[:, part], w[:, part], _ = _shares(c, x[:, part], w[:, part]) + tax[:, part] = step_5_tax( + c, x[:, part], w[:, part], c.step_3_income - x[:, part] - w[:, part] + ) + near = tax <= tax.min(axis=1, keepdims=True) + TIE + x_near = np.where(near, x, np.inf) + near &= x_near <= x_near.min(axis=1, keepdims=True) + TIE + index = np.argmin(np.where(near, w, np.inf), axis=1)[:, None] + x, s = (np.take_along_axis(part, index, axis=1) for part in (x, w)) + return x, s, c.step_3_income - x - s + + +def default_order_deductions(person, period): + """The deductions from non-savings, savings and dividend income in the + order non-savings, savings, dividends, computed as the model always has.""" + return old_order( + person("allowances", period), + person("taxable_savings_interest_income", period), + person("taxable_dividend_income", period), + person("net_income", period), + person("trade_loss_relief_against_general_income", period), + person("net_income_before_trade_loss_relief", period), + ) + + +def old_order( + allowances, savings, dividends, net_income, relief, net_income_before_relief +): + """``default_order_deductions`` from the amounts it reads.""" + non_savings = net_income - savings - dividends + relief_left = np.maximum( + 0, relief - np.maximum(0, net_income_before_relief - savings - dividends) + ) + to_non_savings = np.where( + non_savings < allowances, np.maximum(0, non_savings), allowances + ) + left = allowances + relief_left - to_non_savings + to_savings = np.where(savings < left, np.maximum(0, savings), np.maximum(0, left)) + left = left - to_savings + to_dividends = np.where( + dividends < left, np.maximum(0, dividends), np.maximum(0, left) + ) + return SimpleNamespace( + earned=to_non_savings, + savings=to_savings, + dividends=to_dividends, + relief_against_savings_and_dividends=relief_left, + ) + + +def _rate(schedule, income, above=True): + """The rate ``_tax`` charges on a schedule just above ``income`` (or just + below it): the sum of the steps passed, whatever order the thresholds + take once a Gift Aid gift raises some of them.""" + thresholds, steps = schedule + return sum( + step * (income >= threshold if above else income > threshold) + for threshold, step in zip(thresholds, steps) + ) + + +def _steps(rates): + """The steps of a basic, higher and additional rate.""" + return [rates.basic, rates.higher - rates.basic, rates.additional - rates.higher] + + +def _old_order_is_least(c, to_savings, to_dividends): + """People for whom no share gives less Step 5 tax than the old order, by + the tests the module docstring sets out. ``to_savings`` and + ``to_dividends`` are the old order's deductions from savings and + dividends.""" + in_first = (to_savings == 0) & (to_dividends == 0) + # Test 1: nothing to share, or nothing left after the deductions, so only + # one share exists, and the old order takes it. + nothing_to_share = (in_first & (c.shared <= 0)) | ( + (to_savings == c.savings) + & (to_dividends == c.dividends) + & (c.step_3_income <= TIE) + ) + low, basic_limit, higher_limit = c.bands + savings_scale = ((low, basic_limit, higher_limit), _steps(c.savings_rates)) + property_scale = ((-np.inf, basic_limit, higher_limit), _steps(c.property_rates)) + scales = (c.uk, c.scotland, c.dividend_scale, savings_scale, property_scale) + # Rates that rise with income from nil: no step below nil. + if not ( + all(step >= 0 for _, steps in scales for step in steps) + and low <= 0 + and c.phase_out >= 0 + ): + return nothing_to_share + in_first &= c.shared <= c.first + x = c.first - c.shared + other, property_left, earned = _split(c, x) + top, start = _savings_band(c, earned + property_left, c.savings) + savings_taxed = top > start + nil_rated = ~savings_taxed & (c.dividends <= c.dividend_allowance) + # Test 3. The first component's rate just above x. + if c.other_first: + rho_n = np.where( + c.scottish, _rate(c.scotland, other + x), _rate(property_scale, other + x) + ) + in_reach = in_first + else: + # With no property income, an exclusion from earned income only moves + # every band position down by its amount. + position = x - c.earned_exclusions + rho_n = np.where(c.scottish, _rate(c.scotland, position), _rate(c.uk, position)) + in_reach = in_first & ((c.earned_exclusions == 0) | (c.property == 0)) + # dx <= c.shared and dw <= min(dx, dividends). The top of the nil rates + # moves up by at most dx and never passes the top of the savings. + dw = np.minimum(c.shared, c.dividends) + sigma_s = _rate(savings_scale, np.minimum(start + c.shared, top + dw), above=False) + rho_s = _rate(savings_scale, top) + # The dividends end at the top of income, which no share moves. + sigma_v = np.where( + c.dividends > c.dividend_allowance, + _rate( + c.dividend_scale, + top + np.minimum(c.dividend_allowance + dw, c.dividends), + above=False, + ), + 0, + ) + rates_hold = np.where( + savings_taxed, + (rho_n >= sigma_s) & (rho_n - sigma_s + rho_s - sigma_v >= 0), + rho_n >= sigma_v, + ) + return nothing_to_share | (in_first & nil_rated) | (in_reach & rates_hold) + + +def allocate_deductions(person, period, parameters): + """Deductions against savings and dividend income in the share that gives + the least Income Tax (ITA 2007 s. 25(2) and (3A)). Where no share gives less + tax than the order non-savings, savings, dividends, the function returns + that order's deductions exactly.""" + default = default_order_deductions(person, period) + income_tax = parameters(period).gov.hmrc.income_tax + savings, dividends = least_tax_deductions( + _inputs(person, period, income_tax), + income_tax, + default.savings, + default.dividends, + ) + return SimpleNamespace(savings=savings, dividends=dividends) + + +def least_tax_deductions( + inputs, income_tax, to_savings, to_dividends, search_all=False +): + """The deductions from savings and dividends: the old order's + (``to_savings`` and ``to_dividends``) unless another share saves more than + TOLERANCE. The search runs only on people the tests in + ``_old_order_is_least`` leave, or on everyone with ``search_all``.""" + to_savings = np.asarray(to_savings, dtype=np.float64) + to_dividends = np.asarray(to_dividends, dtype=np.float64) + if search_all: + search = np.ones(len(to_savings), dtype=bool) + else: + search = ~_old_order_is_least( + _build(inputs, income_tax), to_savings[:, None], to_dividends[:, None] + )[:, 0] + if not search.any(): + return to_savings, to_dividends + c = _build({name: values[search] for name, values in inputs.items()}, income_tax) + x, s, v = _least_tax_share(c) + default_s = c.savings - to_savings[search][:, None] + default_v = c.dividends - to_dividends[search][:, None] + default_x = c.step_3_income - default_s - default_v + lower = ( + step_5_tax(c, x, s, v) + < step_5_tax(c, default_x, default_s, default_v) - TOLERANCE + )[:, 0] + moved = np.flatnonzero(search)[lower] + to_savings, to_dividends = to_savings.copy(), to_dividends.copy() + to_savings[moved] = (c.savings - s)[lower, 0] + to_dividends[moved] = (c.dividends - v)[lower, 0] + return to_savings, to_dividends + + +def non_savings_deductions(person, period): + """Allowances against non-savings income beyond trade loss relief, the + relief set against savings and dividends, and the non-savings income left + after the deductions, once received_allowances_savings_income and + received_allowances_dividend_income have taken their share. + + The relief comes off non-savings income first, as far as the share sets + anything against it, then the allowances. Where the share keeps the order + non-savings, savings, dividends, the function returns that order's + amounts exactly. Elsewhere it takes the non-savings income left from the + share's own arithmetic, in float64, so float32 rounding in a sum of + net income and the relief added back leaves no residue above nil beyond + the float32 rounding of the moved share, under a penny. + """ + default = default_order_deductions(person, period) + to_savings = person("received_allowances_savings_income", period) + to_dividends = person("received_allowances_dividend_income", period) + moved = (to_savings != default.savings) | (to_dividends != default.dividends) + savings = np.maximum(0, person("taxable_savings_interest_income", period)) + dividends = np.maximum(0, person("taxable_dividend_income", period)) + relief = person("trade_loss_relief_against_general_income", period).astype( + np.float64 + ) + net_income = person("net_income", period) + allowances = person("allowances", period) + non_savings = np.maximum(0, net_income + relief - savings - dividends) + used = np.minimum(relief + allowances, non_savings + savings + dividends) + to_non_savings = used - to_savings - to_dividends + # The old order: net income less savings, dividends and allowances, as the + # model has always computed it. + default_left = np.maximum(0, net_income - savings - dividends - allowances) + return SimpleNamespace( + earned=np.where(moved, np.maximum(0, to_non_savings - relief), default.earned), + relief_against_savings_and_dividends=np.where( + moved, + np.maximum(0, relief - to_non_savings), + default.relief_against_savings_and_dividends, + ), + non_savings_left=np.where( + moved, np.maximum(0, non_savings - to_non_savings), default_left + ), + ) diff --git a/policyengine_uk/utils/parameters.py b/policyengine_uk/utils/parameters.py index 2e408f7ee4..1b1aa8f821 100644 --- a/policyengine_uk/utils/parameters.py +++ b/policyengine_uk/utils/parameters.py @@ -254,6 +254,15 @@ class RemovedParameter(NamedTuple): "(the 16), or gov.dwp.ESA.exempt_work.fixed_higher_earnings_limit " "for dates before 11 April 2011.", ), + "gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands": RemovedParameter( + "removed Marriage Allowance eligible tax bands", + "The model no longer reads tax_band for the Marriage Allowance income " + "conditions. It applies the rate conditions of ITA 2007 s. 55B(2)(b) " + "and (ba) to the person's income directly, so change " + "gov.hmrc.income_tax.rates.uk[1].threshold (the basic rate limit) or " + "gov.hmrc.income_tax.rates.scotland.rates[3].threshold (the start of " + "the Scottish higher rate).", + ), } diff --git a/policyengine_uk/utils/pence.py b/policyengine_uk/utils/pence.py new file mode 100644 index 0000000000..33d3be6019 --- /dev/null +++ b/policyengine_uk/utils/pence.py @@ -0,0 +1,233 @@ +"""Sums of money as the model stores them, in pence. + +The model stores amounts of money in float32, which holds most of them only +to within half a float32 step: £10,608.52 is stored as 10,608.51953125, and +sums it forms in float32 can lie further from their pence. Rules that turn on +a single penny, such as Carer's Allowance's earnings limit, need the amounts +that were entered, and sums of them worked exactly, not the nearest float32. +""" + +from typing import NamedTuple + +import numpy as np +from policyengine_core.parameters import get_parameter +from policyengine_core.periods import MONTH, YEAR +from policyengine_core.variables import QuantityType + + +def stored_pence(amount): + """``amount``, stored in float32, in pence as float64: the whole number of + pence it was entered as, where it lies within half a float32 step of one, + and otherwise its exact value. + + An amount entered in whole pence is stored within half a step of them. + Below £131,072 a step is under a penny, so no other whole number of pence + lies that close, and the pence are recovered exactly. An amount with a + fraction of a penny keeps it, to be rounded as the rule requires, unless + the fraction is within half a step of a whole penny, which float32 cannot + tell apart from that penny. + """ + amount = np.asarray(amount, dtype=np.float32) + # Float32 has 24 significant bits, so multiplying by 100 in float64 is + # exact, as are the difference and the half step below. + pence = amount.astype(np.float64) * 100 + whole = np.round(pence) + # On macOS arm64 numpy reports the spacing of zero as underflow. + with np.errstate(under="ignore"): + half_step = np.spacing(np.abs(amount)).astype(np.float64) * 50 + return np.where(np.abs(pence - whole) <= half_step, whole, pence) + + +class Pence(NamedTuple): + """An amount in pence as float64, with what bounds the float32 rounding + of the stored amount it was read from: ``bound``, the sum of the + magnitudes of the stored amounts added to form it, in pounds, and + ``count``, how many there were.""" + + pence: np.ndarray + bound: np.ndarray + count: np.ndarray + + +def stored(amount) -> Pence: + """``amount``, stored in float32, read with stored_pence.""" + amount = np.asarray(amount, dtype=np.float32) + return Pence( + stored_pence(amount), + np.abs(amount).astype(np.float64), + np.ones(amount.shape), + ) + + +def summed(total, parts) -> Pence: + """``total``, a float32 sum of the stored amounts ``parts`` (each read as + a Pence, a subtracted one with its pence negated), in pence as float64: + the exact sum of the parts' pence, where ``total`` lies within the + rounding of the float32 additions that formed it, and otherwise + stored(total). + + Summing in float32 rounds at each addition, so a sum of whole-pence + amounts can lie more than half a step from its pence even when each part + is recovered exactly; summing the parts' pence in float64 is exact. Each + of the ``count`` stored amounts lies within half a step of its pence, and + each addition rounds to within half a step of its result. Every stored + amount and partial sum is at most ``bound``, up to that rounding, so the + total lies within ``count`` steps of ``bound`` of the exact sum. + """ + total = np.asarray(total, dtype=np.float32) + exact = sum(part.pence for part in parts) + bound = sum(part.bound for part in parts) + count = sum(part.count for part in parts) + # Each rounding is relative, at most 2^-24 of its result, so the partial + # sums exceed ``bound`` by under count * 2^-23 of it. Take the step of the + # next float32 up, so that converting to float32 cannot shrink it. + with np.errstate(under="ignore"): + # The step above zero is the smallest subnormal float32: numpy reports + # both nextafter, which returns it, and spacing of it as underflow. That + # step still bounds the rounding. + largest = np.nextafter( + (bound * (1 + count * 2.0**-23)).astype(np.float32), np.float32(np.inf) + ) + tolerance = count * np.spacing(largest).astype(np.float64) * 100 + is_sum = np.abs(total.astype(np.float64) * 100 - exact) <= tolerance + entered = stored(total) + return Pence( + np.where(is_sum, exact, entered.pence), + np.where(is_sum, bound, entered.bound), + np.where(is_sum, count, entered.count), + ) + + +def neutralized(simulation, variable, period): + """Whether policyengine-core gives ``variable`` its default for + ``period`` without calculating it: neutralized, or abolished by a + gov.abolitions parameter, read as policyengine-core reads it.""" + try: + return bool( + variable.is_neutralized + or simulation.tax_benefit_system.parameters(period).gov.abolitions[ + variable.name + ] + ) + except Exception: + return False + + +def supplied(population, variable_name, period): + """Whether the value of ``variable_name`` for ``period`` was supplied, as + an input or a value a scenario stored, rather than calculated. A value + the simulation calculates counts as calculated whether or not its cache + policy keeps it.""" + holder = population.get_holder(variable_name) + return period in holder.get_input_periods(population.simulation.branch_name) + + +def amount_pence(population, variable_name, period): + """``variable_name`` for ``period``, in pence as float64: as stored_pence + reads it, except that an amount the model calculated by adding others + (``adds`` and ``subtracts``) is the exact sum of those amounts, each read + the same way (see summed), and a monthly flow for a year is the exact + sum of its months, in whole pence where it lies within the float32 + rounding of a whole penny (see months_summed). + + policyengine-core forms such a sum one addition at a time in float32, so + a sum of whole-pence amounts can lie more than half a step from its pence. + Only an addition the simulation calculated itself is redone: a value + supplied as an input, or stored by a scenario, is read as stored, as is + one calculated by a formula. + """ + return _amount(population, variable_name, period).pence + + +def months_summed(total, months) -> Pence: + """``total``, policyengine-core's float32 sum of a monthly flow over a + year, of which ``months`` are the twelve months (each read as a Pence): + their exact sum, as summed reads it, taken to the nearest whole penny + where it lies within a float32 step of each month and of the year of + one. + + policyengine-core divides an amount entered for the year among its months + in float32, so a month need not lie near whole pence. Each month lies + within half a step of its share of the year's amount as stored, and that + within half a step of its pence, so the months' exact sum lies within + half a step of each month and of the year of the pence entered. Amounts + entered for each month in whole pence are recovered month by month, and + their sum is whole pence already. + """ + exact = summed(total, months) + steps = sum(part.count * _step(part.bound) for part in months) + _step(exact.bound) + whole = np.round(exact.pence) + return Pence( + np.where(np.abs(exact.pence - whole) <= steps * 100, whole, exact.pence), + exact.bound, + exact.count, + ) + + +def _step(bound): + """The float32 step, in pounds, of the next float32 above ``bound``, a + magnitude in pounds as float64: at least that of any float32 up to it.""" + with np.errstate(under="ignore"): + # The step above zero is the smallest subnormal float32: numpy reports + # both nextafter, which returns it, and spacing of it as underflow. That + # step still bounds the rounding. + largest = np.nextafter(np.asarray(bound, dtype=np.float32), np.float32(np.inf)) + return np.spacing(largest).astype(np.float64) + + +def _amount(population, variable_name, period) -> Pence: + simulation = population.simulation + variable = simulation.tax_benefit_system.get_variable(variable_name) + total = population(variable_name, period) + if neutralized(simulation, variable, period) or supplied( + population, variable_name, period + ): + return stored(total) + if ( + variable.definition_period == MONTH + and period.unit == YEAR + and variable.quantity_type == QuantityType.FLOW + ): + # policyengine-core's amount of a monthly flow for a year is the sum + # of its months'. (Of a stock, it is the last month's, read as + # stored.) + return months_summed( + total, + [ + _amount(population, variable_name, month) + for month in period.get_subperiods(MONTH) + ], + ) + parts = _summands(simulation, variable, period) + if parts is None: + return stored(total) + read = [] + for name, sign in parts: + part = _amount(population, name, period) + read.append(Pence(sign * part.pence, part.bound, part.count)) + return summed(total, read) + + +def _summands(simulation, variable, period): + """The amounts the simulation added to calculate ``variable`` for + ``period``, each with its sign, or None if it did not calculate it by + adding amounts of the same entity and period.""" + # A variable calculated by a formula lists no amounts to add: + # policyengine-core allows a variable one way of calculating it. One for + # another period, or masked by defined_for, is read as stored. + if variable.defined_for is not None or variable.definition_period != period.unit: + return None + summands = [] + for names, sign in ((variable.adds, 1), (variable.subtracts, -1)): + if isinstance(names, str): + # A parameter listing the amounts, as policyengine-core reads it. + names = get_parameter(simulation.tax_benefit_system.parameters, names)( + period.start + ) + for name in names or (): + part = simulation.tax_benefit_system.variables.get(name) + if part is None or part.entity.key != variable.entity.key: + # A parameter, or an amount of another entity. + return None + summands.append((name, sign)) + return summands or None diff --git a/policyengine_uk/utils/subsample.py b/policyengine_uk/utils/subsample.py new file mode 100644 index 0000000000..5e6afb49fa --- /dev/null +++ b/policyengine_uk/utils/subsample.py @@ -0,0 +1,547 @@ +"""Subsample a simulation built from a dataset. + +``Simulation.subsample`` keeps a weighted random sample of households and +gives the sample weights with the same totals as before. policyengine-core's +``subsample`` cannot do this for a UK simulation: + +- it rebuilds through ``Simulation.build_from_dataset()`` with no arguments, + and the UK builder takes the dataset to load; +- given that dataset, the UK builder still cannot load what core rebuilds + from: core's flat-file ``Dataset``, which ``Dataset.load_dataset`` does not + read. The UK builder would also rebuild every year from the first one, + projected forward, rather than keep each year's inputs; +- it reads each person's household and benefit unit back from the stored + ``person_household_id`` and ``person_benunit_id`` inputs, which need not + match the populations: ``set_input`` can store any value there, and the + model stores them as 32-bit integers, which change identifiers outside + that range; +- it rebuilds a ``baseline`` branch, while a UK simulation's baseline is a + simulation of its own. + +So the functions here take the households, benefit units and people from the +simulation's populations, whose identifiers and memberships are exact, and +subsample the baseline and the dataset with the same draw. +""" + +import copy +import hashlib +import operator +from dataclasses import dataclass +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union + +import numpy as np +import pandas as pd +from policyengine_core.periods import Period, period as period_ +from policyengine_core.tracers import SimpleTracer + +try: + # How ``get_branch`` makes a clone that shares the cached arrays instead + # of copying them (policyengine-core 3.32.27 and later). See + # ``_copy_sharing_arrays``. + from policyengine_core.simulations.simulation import ( + _BranchClone, + _branch_clone, + ) +except ImportError: # pragma: no cover - a core without shared clones + _BranchClone = _branch_clone = None + +from policyengine_uk.data.dataset_schema import ( + UKMultiYearDataset, + UKSingleYearDataset, +) +from policyengine_uk.utils.supplied_inputs import ( + is_model_value, + set_model_value, + supplied_input_arrays, +) + +if TYPE_CHECKING: + from policyengine_uk.simulation import Simulation + +# Variables that count how many units of the population a record stands for. +# A subsample rescales each one that is supplied as an input, so that its +# total over its own entity is unchanged. Each is rescaled on its own, so a +# person weight supplied alongside household weights need not equal the new +# weight of the person's household, as the model's person_weight formula +# makes it; the datasets supply neither person_weight nor benunit_weight. +WEIGHT_VARIABLES = frozenset( + { + "household_weight", + "benunit_weight", + "person_weight", + "original_weight", + "raw_person_weight", + } +) + +# policyengine-core seeds a subsample with the dataset's name by default. UK +# datasets have no name, so every UK subsample without a seed uses this one. +DEFAULT_SEED = "policyengine-uk" + +UKDataset = Union[UKSingleYearDataset, UKMultiYearDataset] + +# What a simulation records about the data it was built from, which a rebuild +# of its populations resets and a subsample keeps. Each is about variables, +# periods or the dataset as a whole, not about records, so it holds for the +# sample: +# - where each capital gains input came from, by variable and period, and the +# periods a neutralised capital gains input read as its default; +# - the dataset's ISA income contract for each year, which sets how savings +# interest is represented and projected; +# - the series the dataset's savings income grows by. +DATASET_RECORDS = ( + "_cgt_input_provenance", + "_cgt_defaulted_periods", + "_isa_income_contracts", + "_savings_income_projection", +) + + +def _random_state(seed) -> np.random.RandomState: + """A generator for ``seed``, which may be a number or text. + + NumPy's compatibility guarantee keeps ``RandomState``'s results for a + fixed seed and calls the same, up to roundoff, so a seed chooses the same + households wherever it runs. The digest gives the same number in every + process, which the built-in ``hash`` does not. + """ + digest = hashlib.sha256(str(seed).encode("utf-8")).digest() + return np.random.RandomState(int.from_bytes(digest[:4], "big")) + + +def draw_household_counts( + weights: np.ndarray, n: int, seed, quantize_weights: bool = True +) -> np.ndarray: + """Draw ``n`` households with replacement and count each one's draws. + + Only households with a positive weight can be drawn. Each draw picks a + household with probability proportional to its weight, or with equal + probability when ``quantize_weights`` is false. + + Returns: + The number of times each household was drawn; the counts sum to ``n``. + """ + n = operator.index(n) + if n < 1: + raise ValueError(f"A subsample needs at least one household, not {n}.") + weights = np.asarray(weights, dtype=float) + if (weights < 0).any(): + raise ValueError("Household weights must not be negative to sample by them.") + eligible = np.flatnonzero(weights > 0) + if eligible.size == 0: + raise ValueError("No household has a positive weight to sample by.") + if not np.isfinite(weights[eligible]).all(): + raise ValueError("Household weights must be finite to sample by them.") + probabilities = ( + weights[eligible] / weights[eligible].sum() if quantize_weights else None + ) + draws = _random_state(seed).choice( + eligible.size, size=n, replace=True, p=probabilities + ) + counts = np.zeros(weights.size, dtype=np.int64) + counts[eligible] = np.bincount(draws, minlength=eligible.size) + return counts + + +def rescale_weights( + weights: np.ndarray, counts: np.ndarray, quantize_weights: bool = True +) -> np.ndarray: + """Weights for the drawn records that keep the total of ``weights``. + + A drawn record's new weight is proportional to the number of times its + household was drawn (``counts``), and also to its old weight when + ``quantize_weights`` is false. A record that was not drawn gets zero, and + so does a record whose weight is zero: it stands for no one, which a + subsample keeps. (A household drawn by its weight in one year can have a + zero weight in another, and a person or benefit unit weight can be zero.) + """ + weights = np.asarray(weights, dtype=float) + total = weights.sum() + if not np.isfinite(total): + raise ValueError("the weights do not have a finite total") + if (weights < 0).any(): + raise ValueError("the weights include negative values") + if total == 0: + return np.zeros_like(weights) + drawn = ( + (counts * (weights > 0)).astype(float) if quantize_weights else weights * counts + ) + drawn_total = drawn.sum() + if drawn_total == 0: + raise ValueError( + "the sampled records have no weight. Draw a different sample, or " + "sample by this period's weights" + ) + return drawn * (total / drawn_total) + + +def _rescaled(name: str, period, weights, counts, quantize_weights) -> np.ndarray: + try: + return rescale_weights(weights, counts, quantize_weights) + except ValueError as error: + raise ValueError( + f"Cannot preserve the total of {name} for {period}: {error}." + ) from error + + +def _subsample_table( + table: pd.DataFrame, counts: np.ndarray, period, quantize_weights: bool +) -> pd.DataFrame: + """The rows of ``table`` drawn at least once, with their new weights.""" + kept = counts > 0 + subsample = table.loc[kept].reset_index(drop=True) + for column in table.columns: + if column in WEIGHT_VARIABLES: + weights = _rescaled( + column, period, table[column].values, counts, quantize_weights + ) + subsample[column] = weights[kept] + return subsample + + +def _subsample_single_year_dataset( + dataset: UKSingleYearDataset, + household_ids: np.ndarray, + counts: np.ndarray, + quantize_weights: bool, +) -> UKSingleYearDataset: + # Read the stored tables, in which enum columns stay encoded; the public + # properties decode every enum column of the whole table. + person, benunit, household = dataset.tables + draws = pd.Series(counts, index=household_ids) + + def draws_of(households) -> np.ndarray: + return draws.reindex(np.asarray(households)).fillna(0).values + + # A benefit unit belongs to the household of its members. + benunit_household = person.groupby("person_benunit_id")[ + "person_household_id" + ].first() + tables = [ + _subsample_table(table, table_counts, dataset.time_period, quantize_weights) + for table, table_counts in ( + (person, draws_of(person["person_household_id"])), + ( + benunit, + draws_of(benunit_household.reindex(benunit["benunit_id"].values)), + ), + (household, draws_of(household["household_id"])), + ) + ] + # The year's declarations of how its amounts were measured and grow + # describe every row, so they hold for the sample. + subsample = UKSingleYearDataset( + person=tables[0], + benunit=tables[1], + household=tables[2], + fiscal_year=dataset.time_period, + energy_price_basis=dataset.energy_price_basis, + isa_income_contract=copy.deepcopy(dataset.isa_income_contract), + savings_income_projection=dataset.savings_income_projection, + ) + subsample._enum_columns = dict(dataset._enum_columns) + return subsample + + +def subsample_dataset( + dataset: UKDataset, + household_ids: np.ndarray, + counts: np.ndarray, + quantize_weights: bool = True, +) -> UKDataset: + """A copy of ``dataset`` holding the drawn households, for every year. + + Args: + dataset: The dataset to subsample. It is not changed. + household_ids: The identifier of each household that could be drawn. + counts: The number of times each of those households was drawn. + quantize_weights: As for ``Simulation.subsample``. + """ + if isinstance(dataset, UKMultiYearDataset): + subsample = UKMultiYearDataset( + datasets=[ + _subsample_single_year_dataset( + dataset[year], household_ids, counts, quantize_weights + ) + for year in dataset.years + ] + ) + # Which capital gains inputs the loader projected, by variable and + # year, which a simulation built from the dataset reports. + if hasattr(dataset, "_cgt_extension_provenance"): + subsample._cgt_extension_provenance = copy.deepcopy( + dataset._cgt_extension_provenance + ) + return subsample + return _subsample_single_year_dataset( + dataset, household_ids, counts, quantize_weights + ) + + +@dataclass +class _Subsample: + """What one simulation holds once it is subsampled.""" + + simulation: "Simulation" + # The arguments of Simulation.build_from_ids. + ids: Tuple[np.ndarray, ...] + # Each supplied input, cut down to the sample, and whether it is the + # model's own value (set_model_value) rather than a supplied one. + inputs: List[Tuple[str, Period, np.ndarray, bool]] + dataset: Optional[UKDataset] + # The values of DATASET_RECORDS the simulation holds. + records: Dict[str, object] + + +def _sorted_positions(kept: np.ndarray, ids: np.ndarray) -> np.ndarray: + """Positions of the kept members of a group population, ordered by + identifier, which is the order ``Simulation.build_from_ids`` gives them.""" + positions = np.flatnonzero(kept) + return positions[np.argsort(ids[positions], kind="stable")] + + +def _plan( + simulation: "Simulation", + household_ids: np.ndarray, + counts: np.ndarray, + quantize_weights: bool, + datasets: Dict[int, UKDataset], +) -> _Subsample: + """Work out the subsample of ``simulation`` without changing it.""" + person = simulation.populations["person"] + benunit = simulation.populations["benunit"] + household = simulation.populations["household"] + if not np.array_equal(np.asarray(household.ids), household_ids): + raise ValueError( + "A simulation and its baseline must hold the same households to " + "be subsampled together." + ) + person_household = np.asarray(household.members_entity_id) + person_benunit = np.asarray(benunit.members_entity_id) + + # A benefit unit is kept with its household, so it must sit within one. + benunit_household = np.full(benunit.count, -1) + benunit_household[person_benunit] = person_household + if not np.array_equal(benunit_household[person_benunit], person_household): + raise ValueError( + "Cannot subsample: a benefit unit has members in more than one household." + ) + members = np.bincount(person_household, minlength=household.count) + if (members[counts > 0] == 0).any(): + raise ValueError("Cannot subsample: a sampled household has no members.") + + entity_counts = { + "person": counts[person_household], + "benunit": np.where(benunit_household >= 0, counts[benunit_household], 0), + "household": counts, + } + positions = { + "person": np.flatnonzero(entity_counts["person"] > 0), + "benunit": _sorted_positions( + entity_counts["benunit"] > 0, np.asarray(benunit.ids) + ), + "household": _sorted_positions(counts > 0, household_ids), + } + + inputs = [] + for name, period, values in supplied_input_arrays(simulation): + entity_key = simulation.tax_benefit_system.get_variable(name).entity.key + model = is_model_value(simulation, name, period, values) + if name in WEIGHT_VARIABLES: + values = _rescaled( + name, period, values, entity_counts[entity_key], quantize_weights + ) + inputs.append((name, period, values[positions[entity_key]], model)) + + # A simulation and its baseline usually share one dataset object, so + # subsample each dataset once and share the result in the same way. + dataset = simulation.dataset + if dataset is not None: + if id(dataset) not in datasets: + datasets[id(dataset)] = subsample_dataset( + dataset, household_ids, counts, quantize_weights + ) + dataset = datasets[id(dataset)] + + kept_people = positions["person"] + return _Subsample( + simulation=simulation, + ids=( + np.asarray(person.ids)[kept_people], + np.asarray(benunit.ids)[person_benunit[kept_people]], + household_ids[person_household[kept_people]], + np.asarray(benunit.ids)[positions["benunit"]], + household_ids[positions["household"]], + ), + inputs=inputs, + dataset=dataset, + records={ + name: copy.deepcopy(getattr(simulation, name)) + for name in DATASET_RECORDS + if hasattr(simulation, name) + }, + ) + + +def _apply( + subsample: _Subsample, simulation: "Simulation", tracer: SimpleTracer +) -> None: + """Rebuild ``simulation`` on ``subsample``, which was worked out for it + or for the simulation it is a copy of.""" + start_instant = simulation.start_instant + # Branches hold the populations the subsample replaces; formulas make + # them again when they next ask for them. + simulation.branches = {} + # The rebuild replaces every stored value, so the record of inputs, and + # of what was calculated from them, starts again with the inputs it sets. + simulation._user_input_keys = set() + simulation._store_history = None + # A trace of calculations on the replaced populations would otherwise be + # reported for the sample: a flat trace keeps a variable's first value. + simulation.tracer = tracer + simulation.build_from_ids(*subsample.ids) + for name, period, values, model in subsample.inputs: + holder = simulation.get_holder(name) + if holder.variable.is_neutralized: + # These are canonical stored inputs, not new inputs. set_input + # rejects them while neutralised, but they must survive so the + # input record and a later restoration still see them. + holder._set(period, values, simulation.branch_name, is_input=True) + if model: + simulation._model_values[(name, simulation.branch_name, period)] = ( + np.array(values) + ) + elif model: + # Keep it the model's: formulas that ask whether a value was + # supplied would otherwise read it as the user's. + set_model_value(simulation, name, period, values) + else: + simulation.set_input(name, period, values) + # The rebuild reset what the simulation recorded about its data, and + # recorded each capital gains input as supplied; restore them. + for name, value in subsample.records.items(): + setattr(simulation, name, copy.deepcopy(value)) + simulation.start_instant = start_instant + # Drop results calculated on the populations the subsample replaced. + simulation._invalidate_all_caches() + simulation.dataset = subsample.dataset + + +def _copy_sharing_arrays(simulation: "Simulation") -> "Simulation": + """A clone of ``simulation`` that shares its cached arrays. + + ``clone`` copies every cached array, and the subsample discards them all + at once, so for a full-size baseline that doubles its memory for nothing. + Announce the clone as ``get_branch`` does: the copy then reads views of + the arrays and copies one only if it reads it, which it never does. The + simulation is not changed; with a core that cannot share, the arrays are + copied. + """ + if _branch_clone is None: # pragma: no cover - a core without shared clones + return simulation.clone(debug=simulation.debug, trace=simulation.trace) + request = _BranchClone(simulation) + token = _branch_clone.set(request) + try: + return simulation.clone(debug=simulation.debug, trace=simulation.trace) + finally: + request.pending = False + _branch_clone.reset(token) + + +def _sampling_weights(simulation: "Simulation", time_period) -> np.ndarray: + """Household weights for ``time_period``, to draw the sample by. + + A period before the first supplied weights has none: the model would + return the default weight of one for every household. Use the first + supplied weights for such a period. + """ + periods = sorted( + ( + period + for name, period, _ in supplied_input_arrays(simulation) + if name == "household_weight" + ), + key=lambda period: period.start, + ) + if not periods: + raise ValueError( + "Cannot subsample a simulation that was given no household_weight values." + ) + requested = period_(time_period) + if requested.start < periods[0].start: + requested = periods[0] + return np.asarray(simulation.calculate("household_weight", requested)) + + +def subsample_simulation( + simulation: "Simulation", + n: Optional[int] = None, + frac: Optional[float] = None, + seed=None, + time_period=None, + quantize_weights: bool = True, +) -> "Simulation": + """Subsample ``simulation`` in place; see ``Simulation.subsample``.""" + if not simulation.built_from_dataset: + raise ValueError("Only a simulation built from a dataset can be subsampled.") + if simulation.branch_name != "default": + raise ValueError("Subsample a simulation, not one of its branches.") + if n is None and frac is None: + raise ValueError("Either n or frac must be provided.") + if time_period is None: + time_period = simulation.default_calculation_period + + weights = _sampling_weights(simulation, time_period) + if n is None: + n = int(len(weights) * frac) + counts = draw_household_counts( + weights, n, DEFAULT_SEED if seed is None else seed, quantize_weights + ) + + # The baseline is a simulation of its own, with a baseline of its own + # when this one has a scenario. Give each of them the same households. + simulations = [] + while simulation is not None and simulation not in simulations: + simulations.append(simulation) + simulation = getattr(simulation, "baseline", None) + + # Work out every subsample before changing anything, so that a + # simulation that cannot be subsampled leaves them all as they were. + household_ids = np.asarray(simulations[0].populations["household"].ids) + datasets: Dict[int, UKDataset] = {} + subsamples = [ + _plan(each, household_ids, counts, quantize_weights, datasets) + for each in simulations + ] + + # Each simulation gets a new tracer of the same kind. Simulations that + # shared a tracer share the new one. The old tracer is kept with its + # replacement so that its id is not reused while this runs. + tracers: Dict[int, Tuple[SimpleTracer, SimpleTracer]] = {} + + def new_tracer(old: SimpleTracer) -> SimpleTracer: + if id(old) not in tracers: + tracers[id(old)] = (old, type(old)()) + return tracers[id(old)][1] + + first = simulations[0] + _apply(subsamples[0], first, new_tracer(first.tracer)) + + # Replace each baseline with a subsampled copy rather than subsample it + # in place: policyengine-core's ``clone`` shares a separate baseline, so + # another simulation can hold this one and still hold every household. + # The copy shares the baseline's arrays rather than copying them, so it + # costs little memory; the old baselines stay in memory until nothing + # holds them. + copies = {id(first): first} + for subsample in subsamples[1:]: + baseline = subsample.simulation + copy = _copy_sharing_arrays(baseline) + _apply(subsample, copy, new_tracer(baseline.tracer)) + copies[id(baseline)] = copy + for each in simulations: + holder = copies[id(each)] + baseline = getattr(holder, "baseline", None) + if baseline is not None and id(baseline) in copies: + holder.baseline = copies[id(baseline)] + return first diff --git a/policyengine_uk/utils/supplied_inputs.py b/policyengine_uk/utils/supplied_inputs.py index d8a01bb8c8..60b8a15e44 100644 --- a/policyengine_uk/utils/supplied_inputs.py +++ b/policyengine_uk/utils/supplied_inputs.py @@ -7,8 +7,17 @@ (variable, branch, period) that ``set_input`` stores in ``Simulation._user_input_keys``. From 3.32.27 (policyengine-core#561), core forgets the record of an input that ``Holder.delete_arrays`` deletes, and each -clone gets its own copy of the record. The helpers also require a stored -value, so missing arrays do not count as supplied inputs. +clone gets its own copy of the record. + +The record can still outlive the value it names. A value cached over an input +takes that storage tier (memory or disk) off the holder's record of where the +input is (``Holder._user_input_storage``), but leaves the entry in +``_user_input_keys``. So every helper here reads a supplied value only if it is +still stored, and only from a tier in which ``Holder._stores_user_input`` +finds the input: one that the holder records the input in and that holds a +value the engine did not calculate (see ``stored_supplied_value``). A missing +value, or one carried over, calculated or cached into the same tier, is not +supplied; an input stored on disk under a value cached in memory still is. ``Simulation.build_from_populations`` installs new populations, and so new holders, but core keeps the record of the values they replace @@ -31,9 +40,13 @@ it), when ``delete_arrays`` removes it, and when a rebuild installs populations that do not hold it, and a clone or branch gets its own copy of the record. + +``supplied_input_arrays`` answers for every variable. It includes values +``set_model_value`` stored, which ``is_model_value`` tells apart. """ -from typing import List, Optional +from functools import cache +from typing import List, Optional, Tuple import numpy as np from policyengine_core import periods @@ -46,6 +59,7 @@ SUPPLIED_INPUT_VARIABLES = frozenset( { "adjusted_net_income", + "benefit_cap_welfare_benefits", "carers_allowance", "carers_allowance_pre_couple_limit", "carers_allowance_pre_overlap", @@ -64,31 +78,39 @@ "extended_childcare_entitlement_term_share", "extended_childcare_entitlement_total_per_child", "gas_consumption_kwh", + "guarantee_credit", "housing_benefit_applicable_income", "housing_benefit_applicable_income_childcare_element", "housing_benefit_applicable_income_disregard", "housing_benefit_childcare_earnings_limit", + "housing_benefit_pre_benefit_cap", "housing_benefit_specified_or_temporary_accommodation_disregard", "housing_benefit_wtc_disregard", "in_HE", + "is_pension_credit_eligible", "other_local_bus_trips", + "pension_credit", + "pension_credit_entitlement", "pip_dl_category", "pip_dl_points", "pip_m_category", "pip_m_points", "local_bus_trips", "local_bus_uncapped_single_fare", + "lifetime_isa_countable_capital", "miscellaneous_income_trading_allowance_deduction", "monthly_domestic_energy_consumption", "ni_class_4_losses_brought_forward", "ni_class_4_trading_loss", "ni_epg_full_rate_applies", + "savings_credit", "tenure_type", "targeted_childcare_entitlement", "trading_allowance_deduction", "trading_loss", "uc_carer_element", "uc_LCWRA_element", + "universal_credit", "universal_childcare_entitlement", } ) @@ -136,7 +158,9 @@ def drop_missing_supplied_inputs(simulation, variable_name: str) -> None: def supplied_input_periods(population, variable_name: str) -> List[Period]: """Periods for which ``variable_name`` was supplied as an input, on this - branch or one it was made from, earliest first.""" + branch or one it was made from, and the supplied value is still stored, + earliest first. A period whose input a value carried over, calculated or + cached in its place has replaced is left out.""" _check_registered(variable_name) simulation = population.simulation branch_names = set(_visible_branch_names(simulation)) @@ -148,11 +172,10 @@ def supplied_input_periods(population, variable_name: str) -> List[Period]: for name, branch_name, period in input_keys if name == variable_name and branch_name in branch_names - and holder._get_array_from_storage(period, branch_name) is not None - and not _is_model_value( - simulation, - (name, branch_name, period), - holder._get_array_from_storage(period, branch_name), + # Checked without reading the value, which may be on disk, unless + # the model stored a value for the period (set_model_value). + and _supplied_without_reading( + simulation, holder, (name, branch_name, period) ) }, key=lambda period: period.start, @@ -204,13 +227,73 @@ def stored_as_input(population, variable_name: str, period: Period) -> bool: ) +# Variable attributes that describe a variable without changing what Core +# calculates for it. +DESCRIPTIVE_ATTRIBUTES = frozenset( + { + "_explicit_attribute_names", + "baseline_variable", + "category", + "cerfa_field", + "documentation", + "hidden_input", + "index_in_module", + "label", + "metadata", + "module_name", + "reference", + "unit", + } +) + + +def calculation_definition(variable, period: Period) -> dict: + """Every attribute Core calculates ``variable`` from for ``period``: all + but the descriptive ones, with the formula for the period, by its code, + in place of the formulas, and the entity by its key.""" + definition = { + name: value + for name, value in vars(variable).items() + if name not in DESCRIPTIVE_ATTRIBUTES | {"entity", "formulas"} + } + formula = variable.get_formula(period) + definition["formula"] = None if formula is None else formula.__code__ + definition["entity"] = variable.entity.key + return definition + + +@cache +def _model_variable(variable_class): + return variable_class() + + +def replaces_definition(population, variable_class, period: Period) -> bool: + """Whether the simulation can give the variable that ``variable_class`` + defines a value for ``period`` other than the one that definition + calculates. A reform can change anything Core calculates it from: + neutralize it, give it another formula for the period or none, or change + its default, its defined_for, what it sums or its period. A caller can + also set an input for the period (``stored_as_input``), as a scenario + does with its own value (``set_model_value``). A cached formula result is + not an input, even in populations taken from another simulation or in + their clones and branches.""" + name = variable_class.__name__ + variable = population.simulation.tax_benefit_system.get_variable(name) + if calculation_definition(variable, period) != calculation_definition( + _model_variable(variable_class), period + ): + return True + return stored_as_input(population, name, period) + + def supplied_input( population, variable_name: str, period: Period ) -> Optional[np.ndarray]: """The value of ``variable_name`` for ``period`` if it was supplied as an - input, on this branch or the nearest one it was made from; otherwise - None. A value the engine carried over from an earlier period, or cached - after a calculation, is not an input.""" + input, on this branch or the nearest one it was made from, and is still + stored; otherwise None. A value the engine carried over from an earlier + period, or cached after a calculation, is not an input, including one + cached over the input.""" _check_registered(variable_name) simulation = population.simulation input_keys = getattr(simulation, "_user_input_keys", None) or () @@ -218,10 +301,12 @@ def supplied_input( for branch_name in _visible_branch_names(simulation): if (variable_name, branch_name, period) in input_keys: # Read the stored input itself: Holder.get_array would fall back - # to other branches' values, including cached calculations. - # A record can outlive its stored array (see above); a missing - # stored array must be ignored even if its key remains. - value = holder._get_array_from_storage(period, branch_name) + # to other branches' values, including cached calculations, and + # reads memory before disk whatever each holds. A record can + # outlive its input (see above); then look on the branch this one + # was made from, as policyengine-core's + # Simulation._get_recorded_input_array does. + value = stored_supplied_value(holder, period, branch_name) if value is not None: # A value the model stored itself (set_model_value) is not # supplied, though the engine keeps it like an input. @@ -240,12 +325,84 @@ def supplied_input( return None +def supplied_input_arrays(simulation) -> List[Tuple[str, Period, np.ndarray]]: + """Every value ``simulation`` was given as an input and still stores, on + its own branch, with its variable and the period it is stored for. + + Ordered by variable and period, so that the same simulation always gives + the same list. + """ + input_keys = getattr(simulation, "_user_input_keys", None) or () + arrays = [] + for variable_name, branch_name, period in input_keys: + if branch_name != simulation.branch_name: + continue + value = stored_supplied_value( + simulation.get_holder(variable_name), period, branch_name + ) + if value is not None: + arrays.append((variable_name, period, value)) + return sorted(arrays, key=lambda item: (item[0], item[1].start, str(item[1]))) + + +def stored_supplied_value(holder, period: Period, branch_name: str): + """The supplied value ``holder`` still stores for ``period``, or None. + + A record can outlive its stored value (see above), and a value calculated + later can take its place in one tier while the input survives in the + other. So read only a tier in which ``Holder._stores_user_input`` finds + the input: one that the holder records the input in (either tier when it + records none) and that holds a value it did not calculate, memory first. + Reading memory before disk regardless could return a value cached over an + input stored on disk. + """ + storage = _supplied_value_storage(holder, period, branch_name) + return None if storage is None else storage.get(period, branch_name) + + +def _supplied_value_storage(holder, period: Period, branch_name: str): + """The storage (memory or disk) from which ``stored_supplied_value`` + reads, or None if the supplied value is gone.""" + for location, storage in ( + ("memory", holder._memory_storage), + ("disk", holder._disk_storage), + ): + if storage is not None and holder._stores_user_input( + period, branch_name, location + ): + return storage + return None + + +def _supplied_without_reading(simulation, holder, key) -> bool: + """Whether ``supplied_input`` finds a supplied value for ``key`` on its + own branch. Reads the value only if the model stored one for the period + (``set_model_value``), to tell the two apart.""" + _, branch_name, period = key + storage = _supplied_value_storage(holder, period, branch_name) + if storage is None: + return False + model_values = getattr(simulation, "_model_values", None) or {} + if key not in model_values: + return True + return not _is_model_value(simulation, key, storage.get(period, branch_name)) + + def _is_model_value(simulation, key, value) -> bool: model_values = getattr(simulation, "_model_values", None) or {} written = model_values.get(key) return written is not None and np.array_equal(written, value) +def is_model_value(simulation, variable_name: str, period: Period, value) -> bool: + """Whether ``value``, stored for ``variable_name`` and ``period`` on this + simulation's branch, is the model's own value (``set_model_value``) + rather than a supplied one.""" + return _is_model_value( + simulation, (variable_name, simulation.branch_name, period), value + ) + + def set_model_value(simulation, variable_name: str, period, value) -> None: """Store ``value`` for ``variable_name`` and ``period`` with ``set_input``, as the model's own value rather than a supplied one, unless the period's diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index d8cab4feb3..39aa99efdf 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -10,6 +10,30 @@ calc_income_tax_with_gift_aid, first_extended_scottish_threshold, ) +from policyengine_uk.utils.income_tax_allocation import default_order_deductions + + +def earnings_tax_reductions(person, period, parameters): + """The Step 6 tax reductions that come off the tax on earnings first. + + All of them except the finance-cost reduction, which comes off the tax on + property income first, so only the part larger than that tax counts here. + """ + income_tax = parameters(period).gov.hmrc.income_tax + reductions = add( + person, + period, + [ + variable + for variable in income_tax.step_6_tax_reductions + if variable != "property_finance_cost_relief" + ], + ) + return reductions + max_( + 0, + person("property_finance_cost_relief", period) + - person("property_income_tax", period), + ) def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=True): @@ -23,15 +47,16 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr # Reg. 55(5)(b) deducts income tax paid by the person "in respect of # the employment" and reg. 57(2) step 3 income tax paid "in respect # of any trade". Neither says how to split a person's tax when they - # also have other income. Earnings are taken as the lowest slice of - # their non-savings income, after the allowances they actually have: - # savings and dividends sit above earnings and other non-savings - # income (as in ITA 2007 s. 16), property income above the rest of - # it (as in s. 16A from 2027-28), and other non-savings income - # (private pensions, State Pension, taxable benefits) above - # earnings. So tax on other income never comes off earnings. Under RTI, DWP deducts the PAYE - # actually taken on the job, which can include tax on a State - # Pension coded against it; that is not modelled. + # also have other income. The helper takes earnings as the lowest + # slice of their non-savings income, after the allowances set against + # non-savings income first: savings and dividends sit above earnings + # and other non-savings income (as in ITA 2007 s. 16), property + # income above the rest of it (as in s. 16A from 2027-28), and other + # non-savings income (private pensions, State Pension, taxable + # benefits) above earnings. So tax on other income never comes off + # earnings. Under RTI, DWP deducts the PAYE actually taken on the job, + # which can include tax on a State Pension coded against it; the + # model leaves that out. p = parameters(period) income_tax = p.gov.hmrc.income_tax # earned_taxable_income is net income less the incomes and @@ -56,13 +81,45 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr period, [variable for variable in earnings_components if variable not in exclusions], ) - # The income left in earned_taxable_income before allowances. The - # part of it above earnings is the person's other non-savings income. - income_in_base = person("net_income", period) - add( - person, period, excluded_incomes + # The slice follows the order non-savings, savings, dividends, not the + # least-tax share of the reliefs and allowances (ITA 2007 s. 25(2)): + # the model follows PAYE coding, which sets the allowances against the + # job; moving them onto savings or dividends to lower the year's tax + # would otherwise raise the tax on earnings. So the earnings slice reads + # the old order's deductions, and more savings or dividends never raise + # it. + default = default_order_deductions(person, period) + # The income left in earned taxable income before allowances, under + # that order. The part of it above earnings is the person's other + # non-savings income. Trade loss relief comes off that other income + # first; net_income deducts all of it, so add back the part the order + # sets against savings and dividends. + income_in_base = ( + person("net_income", period) + + default.relief_against_savings_and_dividends + - add(person, period, excluded_incomes) ) other_income = max_(0, income_in_base - earnings) - taxable_earnings = max_(0, person("earned_taxable_income", period) - other_income) + # Earned taxable income under that order: the exclusions with the old + # order's allowances against non-savings income in place of the share's. + excluded = add( + person, + period, + [ + variable + for variable in exclusions + if variable != "received_allowances_earned_income" + ], + ) + if "received_allowances_earned_income" in exclusions: + excluded = excluded + default.earned + earned_taxable = max_( + 0, + person("net_income", period) + + default.relief_against_savings_and_dividends + - excluded, + ) + taxable_earnings = max_(0, earned_taxable - other_income) rates = income_tax.rates gross_gift = person("gift_aid_grossed_up", period) tax = where( @@ -86,18 +143,4 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr # the tax on property income first. Only a part larger than that tax # (possible where Scottish rates on property income are below the # reduction's rate) comes off the tax on earnings. - reductions = add( - person, - period, - [ - variable - for variable in income_tax.step_6_tax_reductions - if variable != "property_finance_cost_relief" - ], - ) - reductions += max_( - 0, - person("property_finance_cost_relief", period) - - person("property_income_tax", period), - ) - return max_(0, tax - reductions) + return max_(0, tax - earnings_tax_reductions(person, period, parameters)) diff --git a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py index c446c4c6ac..fff7bddfaf 100644 --- a/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py +++ b/policyengine_uk/variables/gov/dwp/LHA_allowed_bedrooms.py @@ -43,8 +43,21 @@ class LHA_allowed_bedrooms(Variable): label = "The number of bedrooms covered by LHA for the benefit unit" documentation = ( "Universal Credit size criteria: one bedroom for the renter or " - "couple, one for each non-dependant aged 16 or over, and the " - "children's bedrooms. Joint tenants and other sharers of the rent, " + "couple, one for each qualifying young person the renter is " + "responsible for, one for each other member of the benefit unit aged " + "16 or over who is not the claimant, partner or household head (see " + "is_benefit_unit_non_dependant_for_universal_credit, whose members " + "also bear a housing cost contribution), one for each non-dependant " + "aged 16 or over from another family, one for each such member of a " + "family that makes no Universal Credit claim of its own (in the claim " + "that counts the household's non-dependants, which also bears their " + "contribution), and the children's bedrooms. The formula treats the " + "household head as liable for the rent, so a head who does not claim is not a non-dependant " + "(para 9(2)(d)); a head who is a qualifying young person the renter is " + "responsible for keeps their bedroom. A qualifying " + "young person no one is responsible for, such as one looked after by " + "a local authority, is not a non-dependant and has no bedroom, in " + "this family or another. Joint tenants and other sharers of the rent, " "boarders and lodgers are not non-dependants, so they add no bedroom " "to anyone's entitlement; the household's non-dependants count in one " "Universal Credit claim only (see uc_non_dependants_counted). A " @@ -56,17 +69,67 @@ class LHA_allowed_bedrooms(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/10", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", ) def formula(benunit, period, parameters): person = benunit.members + # A child is a person under 16 (WRA 2012 s.40). aged_16_or_over = person("age", period) >= 16 - # UC Regs 2013 Sch 4 para 10(1)(c): a non-dependant who is not a child. - # The flag applies para 9(2)(g), so a boarder's or lodger's - # qualifying young person gets no bedroom. - non_dependant = aged_16_or_over & person( - "is_uc_non_dependant_of_household_head", period + family_member = aged_16_or_over & ~person("is_claimant_or_partner", period) + qualifying = person("is_qualifying_young_person_for_universal_credit", period) + responsible = person( + "is_child_or_qualifying_young_person_for_universal_credit", period + ) + # UC Regs 2013 Sch 4 para 9(2)(g): a qualifying young person for whom + # no one is responsible (reg 4(6), such as one looked after by a local + # authority) is not a non-dependant, in this family or another. + no_one_responsible = qualifying & ~responsible + # Para 10(1)(b): a qualifying young person for whom the renter is + # responsible (regs 4 and 5). + qualifying_young_person = family_member & responsible + # Para 10(1)(c) with para 9(2): any other member who is not a child + # is a non-dependant with a bedroom, unless they head the household. + # is_benefit_unit_non_dependant_for_universal_credit marks them, and + # uc_non_dep_deductions charges a housing cost contribution for the + # same people (para 13), so the room and the contribution follow one + # definition. It reads the household head (is_resolved_household_head, + # the householder in whose name the accommodation is rented) as liable + # to make payments on a commercial basis for their occupation, so para + # 9(2)(d) stops them being a non-dependant. A head who does not claim + # is not a renter either (paras 1(2) and 9(1)(a)). A head who is a + # qualifying young person the renter is responsible for keeps the + # para 10(1)(b) bedroom above (para 9(1)(b)). + family_non_dependant = person( + "is_benefit_unit_non_dependant_for_universal_credit", period + ) + family_rooms = benunit.sum(qualifying_young_person | family_non_dependant) + # Para 10(1)(c): a non-dependant from another family who is not a + # child. The Universal Credit flag applies para 9(2)(g), so a boarder's + # or lodger's qualifying young person gets no bedroom, and para 9(2)(f), + # so an adult whose own family's claim counts them has their bedroom in + # that claim. + other_family = person("is_uc_non_dependant_of_household_head", period) + non_dependant = aged_16_or_over & ~no_one_responsible & other_family + # An in-unit non-dependant whose own family makes no Universal Credit + # claim counts in the claim that counts the household's other + # non-dependants (para 9(2)(f)), as in uc_non_dep_deductions, so that + # claim has their bedroom as well as their housing cost contribution. + # Where that claim is their own family's, family_rooms already holds + # the bedroom. + own_claim = ( + benunit("benunit_is_rent_liable", period) + & benunit("is_uc_eligible", period) + & benunit("claims_universal_credit", period) ) counted = benunit("uc_non_dependants_counted", period) - non_dependants = counted * benunit.max(person.household.sum(non_dependant)) - return 1 + non_dependants + bedrooms_for_children(benunit, period) + passed_on = ( + family_non_dependant & ~other_family & ~benunit.project(own_claim | counted) + ) + non_dependants = counted * benunit.max( + person.household.sum(non_dependant | passed_on) + ) + return ( + 1 + family_rooms + non_dependants + bedrooms_for_children(benunit, period) + ) diff --git a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py index 85339ff44e..e49049f393 100644 --- a/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py +++ b/policyengine_uk/variables/gov/dwp/_legacy_award_payee.py @@ -27,7 +27,8 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No can_claim, if given, limits the payee to the members of the couple who can be the claimant (for Pension Credit, those who have attained the - qualifying age). Where neither can, both are considered, so a positive + qualifying age; for income-related ESA, those who have not reached + pensionable age). Where neither can, both are considered, so a positive award entered for the couple is still paid to one of them. couple, if given (a person-level mask), replaces the claimant and @@ -51,15 +52,18 @@ def is_payee_of_couple_award(person, period, reported, can_claim=None, couple=No return where(person.benunit.any(reports), reports, claimant) -def own_report_is_paid(person, period, award, works, capital_limit): +def own_report_is_paid(person, period, award, barred, capital_limit): """Whether income-related ESA or income-based JSA (award: "esa_income" or "jsa_income") is paid on this person's own report, for a member who is neither the claimant nor the partner and so claims in their own right. - Their claim is screened on them alone. They must not be engaged in - remunerative work as its claimant (works), and the award on their report - alone must be positive: the report exceeds the tariff income from the - benefit unit's capital, within the capital limit (capital_limit). Their + The model screens their claim on them alone. They must meet each + condition of their own (barred is true where they fail one): they must + not engage in remunerative work as its claimant, and, for income-related + ESA, must not have reached pensionable age (Welfare Reform Act 2007 + s.1(3)(c)). The award on their report alone + must be positive: the report exceeds the tariff income from the benefit + unit's capital, within the capital limit (capital_limit). Their own capital is not observed, so the benefit unit's stands in for it. The benefit unit's screen (esa_income_eligible or jsa_income_eligible) cannot stand in for theirs, because when the claimant or partner reports @@ -90,7 +94,7 @@ def own_report_is_paid(person, period, award, works, capital_limit): """ benunit = person.benunit own_award = ( - ~works + ~barred & (benunit(f"{award}_assessable_capital", period) <= capital_limit) & ( person(f"{award}_reported", period) diff --git a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py index 94f3db4d17..5bf4d5695c 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py @@ -16,17 +16,17 @@ # contributory or income-related benefit; it still counts in household income. # Housing Benefit counts its own amount before the cap (HB Regs 2006 # reg. 75C(2)(b)), so this total includes it. Universal Credit counts Housing -# Benefit as nil (UC Regs 2013 reg. 80(2A)): uc_benefit_cap_reduction takes it -# out. Universal Credit before the cap is in the total too; a family receiving -# it is outside the Housing Benefit cap (HB Regs 2006 reg. 75F(1)(g)). With -# those two adjustments one total serves both schemes. A family in specified +# Benefit as nil (UC Regs 2013 reg. 80(2A)), so its cap reads +# uc_benefit_cap_welfare_benefits, these benefits without Housing Benefit. +# Universal Credit before the cap is in the total too; HB reg. 75F(1)(g) puts a +# family receiving it outside the Housing Benefit cap. A family in specified # or temporary accommodation can receive both awards (housing_benefit_eligible), # and DWP then counts its Housing Benefit as nil in the Universal Credit cap. # Where the Housing Benefit cap applies, to a family without Universal Credit, # the council counts Housing Benefit for specified accommodation as nil (reg. -# 75C(2)(a)); the model's input in_specified_or_temporary_accommodation does not separate -# specified from temporary accommodation, so the formula counts that Housing -# Benefit at its amount before the cap. +# 75C(2)(a)); the model's input in_specified_or_temporary_accommodation does +# not separate specified from temporary accommodation, so the formula counts +# that Housing Benefit at its amount before the cap. CLAIMANT_OR_PARTNER_BENEFITS = [ "jsa_contrib", "esa_contrib", @@ -58,18 +58,25 @@ class benefit_cap_welfare_benefits(Variable): "is its claimants (is_uc_assessed_claimant), including the other " "member of a couple where one member claims as a single person; " "otherwise it is the claimant and partner (is_claimant_or_partner). " - "The two are the same unless is_uc_claimant is entered. Benefits " + "The two couples match unless the inputs set is_uc_claimant. Benefits " "another member of the benefit unit claims in their own right do not " "count. The total includes Universal Credit and Housing Benefit " "before the cap (universal_credit_pre_benefit_cap, " - "housing_benefit_pre_benefit_cap). Universal Credit counts Housing " - "Benefit as nil (UC Regs 2013 reg. 80(2A)), so " - "uc_benefit_cap_reduction subtracts it; an entered total should " - "include it. The total includes Maternity Allowance and widowed " - "parent's allowance. The model has no variable for three other " - "welfare benefits Parliament lists in Welfare Reform Act 2012 s. " - "96(10): bereavement allowance, widow's pension and widowed mother's " - "allowance." + "housing_benefit_pre_benefit_cap). Universal Credit's cap reads " + "uc_benefit_cap_welfare_benefits, which totals the same benefits " + "with Housing Benefit at nil (UC Regs 2013 reg. 80(2A)). Inputs that " + "supply this total include Housing Benefit in it. They also supply " + "housing_benefit_pre_benefit_cap, which " + "uc_benefit_cap_welfare_benefits subtracts, or supply " + "uc_benefit_cap_welfare_benefits itself, with a value for every " + "benefit unit; otherwise the model raises ValueError when it " + "calculates Universal Credit's cap. A reform of this variable changes " + "the Housing Benefit cap. To change the Universal Credit cap's basket, " + "replace uc_benefit_cap_welfare_benefits. The total includes Maternity " + "Allowance and widowed parent's allowance. The model has no variable " + "for three other welfare benefits Parliament lists in Welfare Reform " + "Act 2012 s. 96(10): bereavement allowance, widow's pension and " + "widowed mother's allowance." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py index f8f0778826..e18b97e471 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_earnings.py @@ -1,5 +1,215 @@ +import functools +import types + from policyengine_uk.model_api import * from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable +from policyengine_uk.utils.pence import ( + amount_pence, + neutralized, + stored_pence, + supplied, +) +from policyengine_uk.variables.gov.hmrc.national_insurance.class_1.ni_class_1_employee import ( + ni_class_1_employee, +) +from policyengine_uk.variables.gov.hmrc.national_insurance.class_1.ni_class_1_employee_additional import ( + ni_class_1_employee_additional, +) +from policyengine_uk.variables.gov.hmrc.national_insurance.class_1.ni_class_1_employee_primary import ( + ni_class_1_employee_primary, +) + +# The model's Class 1 variables, whose calculation _primary_class_1 works out +# exactly. +_CLASS_1 = ( + ni_class_1_employee, + ni_class_1_employee_primary, + ni_class_1_employee_additional, +) +# How their formulas read the amounts they depend on, which _primary_class_1 +# reads for the year: NI income for a month is a twelfth of the year's, and +# liability and directorship for a month are the year's. +_CLASS_1_READS = ( + ("ni_class_1_income", YEAR, FLOW), + ("ni_liable", YEAR, STOCK), + ("is_full_year_category_a_director", YEAR, STOCK), +) + + +def _earned(pence): + """Pay or profit in whole pence, a fraction of a penny disregarded (reg. 5).""" + return np.floor(pence) + + +def _deducted(pence): + """A deduction in whole pence, a fraction of a penny counted as a penny + (reg. 5). The float64 arithmetic that produces it is exact to far less + than a millionth of a penny, so a deduction within a millionth of a penny + above a whole penny is that penny.""" + return np.ceil(pence - 1e-6) + + +@functools.cache +def _model_class_1(): + """The model's Class 1 variables, as the model defines them.""" + return tuple(variable() for variable in _CLASS_1) + + +def _how_calculated(variable): + """What, besides its formulas, decides how policyengine-core calculates + ``variable``.""" + return ( + variable.entity.key, + variable.definition_period, + variable.value_type, + variable.quantity_type, + variable.default_value, + variable.end, + variable.adds, + variable.subtracts, + variable.defined_for, + variable.is_neutralized, + ) + + +def _global_names(code): + """The global names ``code``, and the code nested in it, can read.""" + names = set(code.co_names) + for constant in code.co_consts: + if isinstance(constant, types.CodeType): + names |= _global_names(constant) + return names + + +def _same_formula(formula, model_formula): + """Whether ``formula`` calculates as ``model_formula`` does: the same + code, reading the same values of the globals it names, with the same + defaults and closure. Either may be None, for no formula.""" + if formula is None or model_formula is None: + return formula is model_formula + absent = object() + try: + cells = [ + (cell.cell_contents, model_cell.cell_contents) + for cell, model_cell in zip( + formula.__closure__ or (), model_formula.__closure__ or () + ) + ] + except ValueError: + # An empty cell. + return False + return ( + formula.__code__ == model_formula.__code__ + and formula.__defaults__ == model_formula.__defaults__ + and formula.__kwdefaults__ == model_formula.__kwdefaults__ + and all(value is model_value for value, model_value in cells) + and all( + formula.__globals__.get(name, absent) + is model_formula.__globals__.get(name, absent) + for name in _global_names(formula.__code__) + ) + ) + + +def _class_1_calculated(person, period): + """Whether policyengine-core calculated ni_class_1_employee for each + month of ``period`` as the model does: no reform changed how it or its + primary and additional parts are calculated in those months (their + formulas, the dates the formulas apply from and to, how the parts are + summed and masked, neutralization or abolition) or how the formulas read + the amounts they depend on, and no month of any of them was supplied as + an input or stored by a scenario.""" + simulation = person.simulation + variables = simulation.tax_benefit_system.variables + for name, definition_period, quantity_type in _CLASS_1_READS: + variable = variables.get(name) + if ( + variable is None + or variable.definition_period != definition_period + or variable.quantity_type != quantity_type + ): + return False + for model_variable in _model_class_1(): + name = model_variable.name + variable = variables.get(name) + if variable is None or _how_calculated(variable) != _how_calculated( + model_variable + ): + return False + for month in period.get_subperiods(MONTH): + if ( + neutralized(simulation, variable, month) + or supplied(person, name, month) + or not _same_formula( + variable.get_formula(month), model_variable.get_formula(month) + ) + ): + return False + return True + + +def _primary_class_1(person, period, parameters): + """ni_class_1_employee in float64 pence. + + The model works out each month's Class 1 in float32 and sums the twelve, + which can leave a whole-penny total further from it than stored_pence + recovers: £175.56 (8% of £14,764.46 less £12,569.96) is stored as + 175.5600128, and rounding that up would deduct a penny too much. So where + the model calculated it with its own formula, the formula (the primary + and additional parts, month by month) is worked exactly on the year's NI + income. Otherwise, the deduction is the amount stored for each month, + summed exactly (amount_pence): one supplied as an input, for the year or + month by month, or calculated under a reform that changes the formula, + is the amount deducted (reg. 10(4)(a)), whatever the formula would give. + """ + stored = person("ni_class_1_employee", period) + deducted = amount_pence(person, "ni_class_1_employee", period) + if not _class_1_calculated(person, period): + return deducted + income = amount_pence(person, "ni_class_1_income", period) + director = person("is_full_year_category_a_director", period) + exact = 0 + for month in period.get_subperiods(MONTH): + class_1 = parameters(month).gov.hmrc.national_insurance.class_1 + scale = class_1.employee_scale + # Weekly thresholds in pounds, as monthly amounts in pence. + primary_threshold = ( + scale.components[1].threshold * WEEKS_IN_YEAR * 100 / MONTHS_IN_YEAR + ) + upper_limit = ( + scale.components[2].threshold * WEEKS_IN_YEAR * 100 / MONTHS_IN_YEAR + ) + monthly = income / MONTHS_IN_YEAR + above_upper = max_(monthly - upper_limit, 0) + contribution = ( + scale.components[1].rate + * (max_(monthly - primary_threshold, 0) - above_upper) + + scale.components[2].rate * above_upper + ) + directors = class_1.directors.full_year_category_a_2022_23 + if directors.in_effect: + # Annual thresholds in pounds. + rates = directors.employee_scale.rates + thresholds = [t * 100 for t in directors.employee_scale.thresholds] + annual = rates[1] * min_( + max_(income - thresholds[1], 0), thresholds[2] - thresholds[1] + ) + rates[2] * max_(income - thresholds[2], 0) + contribution = where(director, annual / MONTHS_IN_YEAR, contribution) + exact = exact + contribution + exact = exact * person("ni_liable", period) + # The twelve float32 months and their sum err by less than one float32 + # step of the year's NI income plus 64 steps of the contribution. A + # larger disagreement would mean the formula worked out here no longer + # matches the model's, and then the amount calculated is deducted. + ni_income = np.abs(person("ni_class_1_income", period)) + # On macOS arm64 numpy reports the spacing of zero as underflow. + with np.errstate(under="ignore"): + tolerance = 100 * ( + np.spacing(ni_income).astype(np.float64) + + 64 * np.spacing(np.abs(stored)).astype(np.float64) + ) + agrees = np.abs(np.asarray(stored, dtype=np.float64) * 100 - exact) <= tolerance + return where(agrees, exact, deducted) class carers_allowance_earnings(Variable): @@ -35,6 +245,31 @@ class carers_allowance_earnings(Variable): "exemption after the week State Pension age is reached. Class 4 uses " "ni_class_4_liable: reaching State Pension age during the tax year " "leaves that year's contributions payable.\n\n" + "A fraction of a penny counts in the claimant's favour (reg. 5): pay " + "and profit are taken down to a whole penny, and the calculated " + "deductions (notional tax, Class 4 and Class 2, and half of pension " + "contributions) up to one. PAYE and Class 1 are in law the amounts " + "actually deducted, which are whole pence; the model's figures for " + "them are rounded up as well, a fraction counting in the claimant's " + "favour. Net earnings are therefore whole pence. They are worked in " + "double precision from the whole pence each amount was entered as, " + "since the model stores amounts in single precision, which holds most " + "sums of money only to within a fraction of a penny; an amount the " + "model sums from others (pay, pension contributions, NI income) is the " + "exact sum of its parts, read the same way, whether or not the " + "simulation's cache policy keeps it. Class 1, which the model sums " + "from twelve single-precision months, is worked out again from its " + "formula where the model calculated it with that formula; Class 1 " + "entered as an input (for the year or month by month), or calculated " + "under a reform of its formula or of how it reads NI income, is " + "deducted as entered or calculated, its months summed exactly and an " + "amount entered for the year recovered in whole pence. The model holds each pension programme as one " + "amount for the year, so half of the year's total is deducted, rounded " + "up once.\n\n" + "The Scottish Carer Support Payment reads these earnings too " + "(carer_support_payment_pre_couple_limit): SSI 2023/302 Schedule 2 has " + "the same rounding rule (para. 4) and net earnings and profit (paras. 9 " + "and 12).\n\n" "Not modelled: the deduction for care charges (Schedule 3, up to half " "of net earnings) and the Schedule 1 disregards, so this can overstate " "earnings for a carer who pays for care while at work." @@ -42,6 +277,10 @@ class carers_allowance_earnings(Variable): definition_period = YEAR unit = GBP reference = [ + dict( + title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 5", + href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/5", + ), dict( title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 10(4)", href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/10", @@ -71,9 +310,19 @@ def formula(person, period, parameters): scottish = person("pays_scottish_income_tax", period) personal_allowance = income_tax.allowances.personal_allowance.amount + # Work in float64 pence from the whole pence each amount was entered + # as, and sums of them worked exactly (amount_pence), taking pay and + # profit down to a whole penny and each deduction up to one (reg. 5 + # for the calculated deductions; for PAYE and Class 1, which the law + # takes as deducted, the same rounding in the claimant's favour). + # Scales and thresholds are in pounds. + # Employment: PAYE on the pay alone (reg. 10(4)(a)). - pay = person("employment_income", period) - taxable_pay = max_(0, pay - person("employee_pension_contributions", period)) + pay = _earned(amount_pence(person, "employment_income", period)) + employee_pension = amount_pence( + person, "employee_pension_contributions", period + ) + taxable_pay = max_(0, pay - employee_pension) / 100 pay_tax = where( scottish, income_tax.rates.scotland.rates.calc( @@ -81,12 +330,15 @@ def formula(person, period, parameters): ), income_tax.rates.uk.calc(max_(0, taxable_pay - personal_allowance)), ) - primary_class_1 = person("ni_class_1_employee", period) + primary_class_1 = _primary_class_1(person, period, parameters) # Self-employment: notional basic-rate tax and main-rate Class 4 # (reg. 14(1)-(2)). The tax is on the profit as if it were the only # income, less the full personal allowance. - profit = max_(0, person("self_employment_income", period)) + profit = _earned( + stored_pence(max_(0, person("self_employment_income", period))) + ) + profit_pounds = profit / 100 # The basic rate is the first band of the rest-of-UK scale (ITA 2007 # s. 6(2)). The Scottish basic rate is the second bracket of the # Scottish scale, after the starter rate; read it from the parameter @@ -99,13 +351,13 @@ def formula(person, period, parameters): scottish_scale.brackets[1].rate(period.start), income_tax.rates.uk.rates[0], ) - trading_tax = basic_rate * max_(0, profit - personal_allowance) + trading_tax = basic_rate * max_(0, profit_pounds - personal_allowance) trading_class_4 = ( person("ni_class_4_liable", period) * class_4.contribution_scale.components[1].rate * max_( 0, - min_(profit, class_4.contribution_scale.components[2].threshold) + min_(profit_pounds, class_4.contribution_scale.components[2].threshold) - class_4.contribution_scale.components[1].threshold, ) ) @@ -116,25 +368,30 @@ def formula(person, period, parameters): class_2 = p.national_insurance.class_2 trading_class_2 = ( person("ni_liable", period) - * class_2_liable(profit, class_2) + * class_2_liable(profit_pounds, class_2) * class_2.flat_rate * class_2_contribution_weeks(period.start.year) ) # Regs. 10(4)(b) and 13(1)(b)(ii), (4)(c) and (10)(b): one half of - # pension contributions and premiums. + # pension contributions and premiums. The model holds each programme + # as one amount for the year, not the payments the law halves one by + # one, so the half is of the year's total, rounded up once. The total + # is pension_contributions, whether entered or summed from its + # programmes (and theirs). pension_share = parameters( period ).gov.dwp.carers_allowance.pension_contribution_deduction_rate - pension_deduction = pension_share * person("pension_contributions", period) - return max_( - 0, + contributions = amount_pence(person, "pension_contributions", period) + pension_deduction = pension_share * contributions + net = ( pay - - pay_tax - - primary_class_1 + - _deducted(pay_tax * 100) + - _deducted(primary_class_1) + profit - - trading_tax - - trading_class_4 - - trading_class_2 - - pension_deduction, + - _deducted(trading_tax * 100) + - _deducted(trading_class_4 * 100) + - _deducted(trading_class_2 * 100) + - _deducted(pension_deduction) ) + return max_(0, net) / 100 diff --git a/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py index 8f77427e41..d72dca22c5 100644 --- a/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py +++ b/policyengine_uk/variables/gov/dwp/carers_allowance_weekly_earnings_above_limit.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pence import stored_pence class carers_allowance_weekly_earnings_above_limit(Variable): @@ -7,13 +8,20 @@ class carers_allowance_weekly_earnings_above_limit(Variable): label = "weekly earnings above the Carer's Allowance earnings limit" documentation = ( "How far the person's weekly net earnings exceed the Carer's Allowance " - "earnings limit, or zero at or below it. The regulation tests the " - "earnings of the preceding week; annual earnings are spread evenly " - "over 52 weeks. The model disregards a fraction of a penny, as regulation 5 " - "of the Computation of Earnings Regulations requires when that favours " - "the claimant. Any excess means the person is gainfully employed; " - "where the Budget 2026 earnings taper applies, the allowance tapers " - "away on it instead of stopping." + "earnings limit, in whole pence for a limit in whole pence, or zero at " + "or below it. The formula spreads annual earnings evenly over 52 weeks " + "to stand for the earnings of the preceding week (ICA Regulations reg. " + "8(1)), for an employed earner's yearly pay (reg. 8(1)(b)(iii)) and a " + "self-employed earner's average weekly earnings over a year (reg. " + "11(1)(a)). It disregards a fraction of a " + "penny, as regulation 5 of the Computation of Earnings Regulations " + "requires when that favours the claimant. Net earnings are whole " + "pence, since carers_allowance_earnings applies regulation 5 to each " + "step, and the formula reads the whole pence back exactly, although " + "the model stores amounts in single precision, which holds most sums " + "of money only to within a fraction of a penny. Any excess means the " + "person is gainfully employed; where the Budget 2026 earnings taper " + "applies, the allowance tapers away on it instead of stopping." ) definition_period = YEAR unit = GBP @@ -26,12 +34,31 @@ class carers_allowance_weekly_earnings_above_limit(Variable): title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 5", href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/5", ), + dict( + title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 8(1)(b)(iii)", + href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/8", + ), + dict( + title="Social Security Benefit (Computation of Earnings) Regulations 1996 reg. 11(1)(a)", + href="https://www.legislation.gov.uk/uksi/1996/2745/regulation/11", + ), ] def formula(person, period, parameters): limit = parameters(period).gov.dwp.carers_allowance.earnings_limit - weekly = person("carers_allowance_earnings", period) / WEEKS_IN_YEAR - # Disregard a fraction of a penny; the tolerance keeps an exact - # number of pence safe from floating-point error. - weekly_in_pence = np.floor(weekly * 100 + 1e-6) / 100 - return max_(0, weekly_in_pence - limit) + earnings = person("carers_allowance_earnings", period) + # carers_allowance_earnings is whole pence, which float32 holds to + # within half a step (£10,608.52 as 10,608.51953125); stored_pence + # recovers them exactly below £131,072 a year. An amount entered + # directly with a fraction of a penny has the fraction disregarded + # (reg. 5). + annual_pence = np.floor(stored_pence(earnings)) + # Spread over 52 weeks (regs. 8(1)(b)(iii) and 11(1)(a)), disregarding + # the fraction of a penny that leaves (reg. 5). Both steps are exact in + # float64, so no floating-point rounding decides whether, or by how + # much, earnings have exceeded the limit (ICA Regulations reg. 8(1)). + weekly_pence = np.floor(annual_pence / WEEKS_IN_YEAR) + # Rounding to a millionth of a penny keeps a limit such as £204.10 + # from being read as 20,409.999... pence. + limit_pence = np.round(limit * 100, 6) + return max_(0, weekly_pence - limit_pence) / 100 diff --git a/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py new file mode 100644 index 0000000000..d3699861a3 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/cares_for_severely_disabled_person.py @@ -0,0 +1,73 @@ +from policyengine_uk.model_api import * + + +class cares_for_severely_disabled_person(Variable): + value_type = bool + entity = Person + label = "Cares for a severely disabled person" + documentation = ( + "Whether the person this person cares for, in the household or " + "outside it, gets a benefit that makes them a severely disabled " + "person for Carer's Allowance (SSCBA 1992 s.70(2), with the payments " + "that SI 1976/409 reg 3 prescribes) or a qualifying disability " + "benefit for Carer Support Payment (SSI 2023/302 regs 2 and 5(1)). " + "The Department for Work and Pensions pays Carer's Allowance only for " + "caring for a severely disabled person (s.70(1)), and the model " + "applies the same condition in Northern Ireland. Social Security " + "Scotland pays Carer Support Payment only for care of a person to " + "whom a qualifying disability benefit is normally payable (reg 5(1)). " + "DWP includes the Universal Credit carer element (UC Regs 2013 reg " + "29) for a claimant with regular and substantial caring " + "responsibilities for a severely disabled person: one who meets the " + "Carer's Allowance conditions, or would but for the earnings limit or " + "residence in Scotland, or who has an entitlement to Carer Support " + "Payment (reg 30(1)). Caring hours in the survey " + "count care for anyone and don't say whom the carer cares for, and " + "the person cared for can live elsewhere, so the model doesn't derive " + "this from the household's disability benefits: it takes the person " + "cared for to qualify unless this input says otherwise, and the " + "default is true. The model reads it only where care_hours reach the " + "minimum: on the route through caring hours to Carer's Allowance, " + "Carer Support Payment and is_carer_for_benefits (and so the " + "Universal Credit carer element), and in " + "partners_care_for_same_severely_disabled_person, which counts a " + "supplied entitlement as a separate award unless caring hours for a " + "severely disabled person explain it. A reported award " + "(carers_allowance_reported, the model's reported-receipt input for " + "both benefits) still counts whatever this input says, since the " + "department or Social Security Scotland pays it. Set it to false for " + "a carer whose caring hours go to someone with none of those " + "benefits." + ) + definition_period = YEAR + default_value = True + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992 s. 70(1) and (2)", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/70", + ), + dict( + title="Social Security Contributions and Benefits (Northern Ireland) Act 1992 s. 70", + href="https://www.legislation.gov.uk/ukpga/1992/7/section/70", + ), + dict( + title="The Social Security (Invalid Care Allowance) Regulations 1976 reg. 3", + href="https://www.legislation.gov.uk/uksi/1976/409/regulation/3", + ), + dict( + title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 2", + href="https://www.legislation.gov.uk/ssi/2023/302/regulation/2", + ), + dict( + title="The Carer's Assistance (Carer Support Payment) (Scotland) Regulations 2023 reg. 5(1)", + href="https://www.legislation.gov.uk/ssi/2023/302/regulation/5", + ), + dict( + title="The Universal Credit Regulations 2013 reg. 29", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + ), + dict( + title="The Universal Credit Regulations 2013 reg. 30(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + ), + ] diff --git a/policyengine_uk/variables/gov/dwp/dla/has_highest_dla_sc_award.py b/policyengine_uk/variables/gov/dwp/dla/has_highest_dla_sc_award.py new file mode 100644 index 0000000000..f6899e6060 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/dla/has_highest_dla_sc_award.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class has_highest_dla_sc_award(Variable): + label = "Has an award of the DLA care component at the highest rate" + documentation = ( + "The model counts an award of the care component of disability " + "living allowance at the highest rate when the person has a positive " + "amount (dla_sc) and either the highest-rate category " + "(dla_sc_category) or an amount that reaches 52 × (highest weekly " + "rate − weekly tolerance), with the tolerance in " + "gov.dwp.disability_premia.top_rate_amount_tolerance. It rejects an amount of zero, including one that " + "a reform sets by neutralising dla_sc. Claimants also qualify while " + "the suspension or hospital rules stop or abate payment of the award " + "(HB Regs 2006 Sch 3 para 15(1)(b)); the model has no input for that." + ) + entity = Person + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp + amount = person("dla_sc", period) + threshold = ( + p.dla.self_care.higher - p.disability_premia.top_rate_amount_tolerance + ) * WEEKS_IN_YEAR + highest_rate = person("receives_highest_dla_sc", period) | (amount >= threshold) + return (amount > 0) & highest_rate diff --git a/policyengine_uk/variables/gov/dwp/esa_income.py b/policyengine_uk/variables/gov/dwp/esa_income.py index 4bdb566124..5300a63f2d 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income.py +++ b/policyengine_uk/variables/gov/dwp/esa_income.py @@ -3,8 +3,8 @@ def income_related_esa_award(benunit, period, reported_award): """Income-related ESA paid on a reported award: the award less tariff - income from capital, or nothing if the benefit unit fails the screen in - esa_income_eligible.""" + income from capital, or nothing if the benefit unit fails the age, Pension + Credit, work and capital screen in esa_income_eligible.""" tariff_income = benunit("esa_income_tariff_income", period) eligible = benunit("esa_income_eligible", period) return where(eligible, max_(0, reported_award - tariff_income), 0) @@ -14,7 +14,9 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) """Screen open person reports, retaining each original claim's ownership. The claimant and partner share one claim. Other reporters have separate - claims, each using the existing own-work and shared-capital/tariff proxy. + claims, each using the existing own-work and shared-capital/tariff proxy, + and the model pays none once the reporter reaches pensionable age + (Welfare Reform Act 2007 s.1(3)(c)). Selecting members changes only reports, never the original couple or the work/capital screen. This helper must not read payable-person status, which itself depends on the aggregate award. @@ -28,9 +30,10 @@ def income_related_esa_award_on_reports(benunit, period, reported, members=None) benunit, period, benunit.sum(reported * couple) ) works = person("esa_income_claimant_remunerative_work", period) + pensionable_age = person("is_SP_age", period) capital = benunit("esa_income_assessable_capital", period) limit = rules.income.capital.limit - own_claim = ~couple & ~works & benunit.project(capital <= limit) + own_claim = ~couple & ~works & ~pensionable_age & benunit.project(capital <= limit) tariff = benunit.project(benunit("esa_income_tariff_income", period)) own_awards = where(own_claim, max_(0, reported - tariff), 0) # The model pays each claim for the share of the year before its @@ -46,8 +49,9 @@ class esa_income(Variable): entity = BenUnit label = "ESA (income-based)" documentation = ( - "Reported income-related ESA screened through bounded capital and " - "remunerative work tests (esa_income_eligible). Reports whose award " + "Reported income-related ESA screened through bounded pensionable " + "age, Pension Credit, capital and remunerative work tests " + "(esa_income_eligible). Reports whose award " "has ended are left out (esa_income_report_closed): the owner's " "legacy benefits have closed, the owner is the claimant or " "partner of a family that claims Universal Credit, or the abolition " diff --git a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py index c4d0991e28..ac12bf0341 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_assessable_capital.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -11,7 +15,12 @@ class esa_income_assessable_capital(Variable): label = "Assessable capital for income-related ESA" documentation = ( "Household capital apportioned to the benefit unit for the income-related ESA " - "capital test. For capital whose owner the data do not identify, the model " + "capital test. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 113), and values each " + "such ISA balance as it values that household source. " + "For capital whose owner the data do not identify, the model " "allocates the full household remainder to any benunit with a reported " "income-related ESA award and falls back to a claimant-and-partner share when " "nobody in the household reports such an award. No regulation sets that " @@ -32,10 +41,12 @@ def formula(benunit, period, parameters): person = benunit.members claiming_esa_income = add(benunit, period, ["esa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) - - pooled_person_capital(benunit, source, period, ESA.capital) - for source in ESA.capital.sources + household_capital = valued_capital( + lambda source: ( + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, ESA.capital) + ), + ESA.capital, ) # Regulation 83(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -54,11 +65,11 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(ESA.capital) + person_capital = valued_person_capital( + benunit, period, parameters, ESA.capital, claimant_or_partner ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 3(a)). + return round_to_penny(household_share + person_capital, ESA.capital.round_down) diff --git a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py index 22f9c6b4b6..9c00f34dc1 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_eligible.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_eligible.py @@ -1,38 +1,210 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import replaces_definition +from policyengine_uk.variables.gov.dwp.pension_credit.guarantee_credit.guarantee_credit import ( + guarantee_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.is_pension_credit_eligible import ( + is_pension_credit_eligible, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit import ( + pension_credit, +) +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_entitlement import ( + pension_credit_entitlement, +) +from policyengine_uk.variables.gov.dwp.pension_credit.savings_credit.savings_credit import ( + savings_credit, +) + +# The branch in which the family has no income-related ESA. +NO_INCOME_RELATED_ESA_BRANCH = "esa_income_pension_credit_bar" + +# The Pension Credit variables whose definitions pay Pension Credit only to a +# family that meets the age conditions and would claim. +PENSION_CREDIT_VARIABLES = [ + pension_credit, + pension_credit_entitlement, + is_pension_credit_eligible, + guarantee_credit, + savings_credit, +] + + +def pension_credit_follows_model_formulas(benunit, period, parameters): + """Whether the simulation calculates Pension Credit with the model's own + formulas, which pay it only to a family that meets the Pension Credit age + conditions (meets_pension_credit_age_conditions) and would claim + (would_claim_pc). + + pension_credit equals the entitlement where the family would claim and + the couple has no income-related ESA (claimant_or_partner_esa_income), + and nil otherwise. The entitlement sums the guarantee and savings credits for + a family eligible for Pension Credit (is_pension_credit_eligible), and + gives nil to any other, and only a family that meets the age conditions + is eligible. A parameter reform keeps these definitions, and the age + conditions read its parameters. + + A caller can instead set pension_credit, its entitlement, its eligibility + or either credit as an input for the period. A reform can change anything + Core calculates one of them from (replaces_definition), such as the + default a family outside its defined_for gets, or freeze Pension Credit + at the baseline's award (gov.contrib.freeze_pension_credit) whatever the + reform's age conditions. The model can then pay Pension Credit to a + family that does not meet the age conditions or would not claim. A + reform can also give would_claim_pc a formula, which could read the couple's ESA; the screen + would then close a cycle if it read take-up in the simulation itself. + """ + simulation = benunit.simulation + if ( + parameters(period).gov.contrib.freeze_pension_credit + and simulation.baseline is not None + ): + return False + if any( + replaces_definition(benunit, variable, period) + for variable in PENSION_CREDIT_VARIABLES + ): + return False + take_up = simulation.tax_benefit_system.get_variable("would_claim_pc") + return take_up.get_formula(period) is None + + +def pension_credit_without_income_related_esa(benunit, period): + """The Pension Credit the family would get with no income-related ESA. + + Parliament bars the couple's income-related ESA while the claimant, or + the other member of the couple, has an entitlement to State Pension + Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), and the + model's Pension Credit reads that award in five ways. pension_credit + itself pays nothing while the couple has the award + (claimant_or_partner_esa_income). pension_credit_earnings_disregard reads + the couple's award for the higher earnings disregard (SPC Regs 2002 Sch + VI para 4(1)(a)(vii)). pension_credit_income counts working tax credit, + and the tax credit income test reads the award (TCA 2002 s.7(2)), as + does Income Support's gate, which that test reads. The route test + (left_pension_route_at_legacy_closure) reads Universal Credit + entitlement, whose work capability test reads esa_income + (has_own_esa_award). A formula that read Pension Credit in the + simulation itself would close a cycle. + + This function calculates Pension Credit in a branch that sets + esa_income, the couple's award and esa_income_eligible to nil, so the + screen never runs there. The branch reads pension_credit, so it counts + the claim (would_claim_pc) and an entered award. Where the branch's + Pension Credit exceeds nil, the screen refuses the couple's award, and + the simulation's own Pension Credit then equals the branch's. Where it + comes to nil, the model pays the award, and pension_credit pays nothing + while the couple has it, even where the family would get Pension Credit + with the award through the higher earnings disregard. With Pension + Credit in payment the award would stop, and with it the disregard of SPC + Regs 2002 Sch VI para 4(1)(a)(vii). DWP keeps disregarding £20 only + where it disregarded £20 of earnings in the award (para 4(2)), and it + disregards all of a claimant's permitted-work earnings up to the limit + (ESA Regs 2008 Sch 7 para 5). For a family whose only earnings are + those, the model's result agrees with the reading of para 4(2)(a) under + which "£20 was disregarded" means the £20 disregard of Sch 7 paras 6 and + 7(1): the family would lose its Pension Credit and get the award back. + On the reading under which para 4(2)(a) holds wherever DWP disregarded + at least £20 of earnings in the award, DWP could keep such a family on + Pension Credit with no award where it disregarded at least £20 of those + earnings and the earner stays in employment (para 4(2)(b)). + pension_credit_earnings_disregard does not apply para 4(2), and the + model holds no record of how an award started, so the model also pays + the award and no Pension Credit to a couple in whose award DWP + disregarded £20 of earnings (ESA Regs 2008 Sch 7 paras 6 and 7), though + DWP could keep that couple on Pension Credit with no award (para 4(2)). + + In a year with tax credits, a calculation in this branch opens the + passport branch (pension_credit_with_passported_tax_credits), and one in + the passport branch opens this one. Core reads a branch's inputs from + its parent branches as well, so a branch of either keeps both sets of + inputs. + """ + simulation = benunit.simulation + # get_branch returns an existing branch of that name, or this simulation + # if it has that name, so take a name in use by neither. + name = NO_INCOME_RELATED_ESA_BRANCH + while name in simulation.branches or name == simulation.branch_name: + name += "_" + branch = simulation.get_branch(name) + try: + for variable in ["esa_income", "claimant_or_partner_esa_income"]: + branch.set_input(variable, period, benunit.empty_array()) + branch.set_input("esa_income_eligible", period, benunit.filled_array(False)) + # Through the population, so a microsimulation returns an + # unweighted array. + return branch.populations[benunit.entity.key]("pension_credit", period) + finally: + simulation.branches.pop(name, None) class esa_income_eligible(Variable): value_type = bool entity = BenUnit label = ( - "Whether a reported income-related ESA award passes the capital and work tests" + "Whether a reported income-related ESA award passes the age, Pension " + "Credit, capital and work tests" ) documentation = ( - "Bounded work/capital screen for reported income-related ESA, not a full " - "entitlement model. When an original claimant or partner reports an open " - "award, only they are candidate claimants; otherwise other open reporters " - "are candidates. The claimant must not engage in remunerative work (Welfare " - "Reform Act 2007 Sch 1 para 6(1)(e); " - "esa_income_claimant_remunerative_work). The other member of the couple " - "must not engage in remunerative work (para 6(1)(f)): paid work of 24 hours " - "a week or more (ESA Regs 2008 reg 42(1)), subject to the existing carer " - "exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " + "Bounded age, Pension Credit, work and capital screen for reported " + "income-related ESA, not a full entitlement model. When an original claimant " + "or partner reports an open award, only they are candidate claimants; " + "otherwise other open reporters are candidates. The claimant must not have " + "reached pensionable age (Welfare Reform Act 2007 s.1(3)(c)): their own State " + "Pension age (s.1(6)), which is_SP_age places at 6 October of the year. " + "Neither the claimant nor the other member of the couple may have an " + "entitlement to State Pension Credit (Sch 1 para 6(1)(c) and (d)). The " + "model's Pension Credit reads the couple's award, so the screen reads the " + "Pension Credit the couple would get with no income-related ESA in the " + "family (pension_credit_without_income_related_esa), which counts the claim " + "(would_claim_pc) and an entered award, and refuses the couple's claim where " + "it exceeds nil. The screen works that out for each couple whose claim " + "passes the other tests, and only where some couple's claim does. Where the " + "model's own formulas calculate Pension Credit " + "(pension_credit_follows_model_formulas), they pay it only to a couple that " + "meets the Pension Credit age conditions (meets_pension_credit_age_conditions) " + "and would claim, so the screen works it out only for such a couple. DWP " + "disregards £20 a week of earnings while the claimant or partner receives " + "ESA (SPC Regs 2002 Sch VI para 4(1)(a)(vii)). Where the family gets Pension " + "Credit only with that disregard, the screen passes the couple's claim, and " + "pension_credit pays nothing while the couple has the award. A member who " + "claims in their own right keeps their claim whatever the couple's Pension " + "Credit. The claimant must not engage in remunerative work " + "(para 6(1)(e); esa_income_claimant_remunerative_work). The other member of " + "the couple must not engage in remunerative work (para 6(1)(f)): paid work of " + "24 hours a week or more (ESA Regs 2008 reg 42(1)), subject to the existing " + "carer exception (reg 43(2)(c); IS Regs 1987 Sch 1B para 4), which the model " "reads as is_carer_for_benefits. Assessable capital must remain within the " - "existing limit (para 6(1)(b); reg 110). The aggregate esa_income uses this " - "screen for its original couple's part; each outside reporter's claim is " - "paid separately on their own existing work/capital screen and tariff. The " - "benefit unit's capital is an explicit proxy for unobserved outside-claim " - "capital. Reports whose own award is closed are excluded before choosing " - "candidates; ESA also excludes the couple's reports on a UC claim. This " - "variable's candidate rules are unchanged by the separate aggregation." + "existing limit (para 6(1)(b); reg 110). The model applies neither para " + "6(1)(g) (receiving education) nor the other member's income-based JSA under " + "para 6(1)(d). The Northern Ireland Assembly set the same conditions in the " + "Welfare Reform Act (Northern Ireland) 2007 s.1(3)(c) and Sch 1 para 6(1)(c) " + "and (d), but took pensionable age in s.1(6) from the Pensions (Northern " + "Ireland) Order 1995 Sch 2 para 1, where Parliament took it from the Pensions " + "Act 1995 Sch 4 para 1. Under either schedule a person born on a given date " + "reaches pensionable age on the same day, so the model applies one rule " + "across the UK. The aggregate esa_income uses this screen for its original " + "couple's part and pays each outside reporter's claim separately, on their " + "own existing work/capital screen, pensionable age and tariff. The screen " + "uses the benefit unit's capital as an explicit proxy for unobserved " + "outside-claim capital. It drops reports whose own award has closed before it " + "chooses candidates, and ESA also drops the couple's reports on a UC claim. " + "The separate aggregation leaves this variable's candidate rules unchanged." ) definition_period = YEAR reference = ( + "https://www.legislation.gov.uk/ukpga/2007/5/section/1", "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ukpga/1995/26/schedule/4", + "https://www.legislation.gov.uk/ukpga/2002/16/section/4", + "https://www.legislation.gov.uk/uksi/2019/37/article/4", "https://www.legislation.gov.uk/uksi/2008/794/regulation/41", "https://www.legislation.gov.uk/uksi/2008/794/regulation/42", "https://www.legislation.gov.uk/uksi/2008/794/regulation/43", "https://www.legislation.gov.uk/uksi/2008/794/regulation/110", + "https://www.legislation.gov.uk/nia/2007/2/section/1", + "https://www.legislation.gov.uk/nia/2007/2/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/nisi/1995/3213/schedule/2", ) def formula(benunit, period, parameters): @@ -49,6 +221,10 @@ def formula(benunit, period, parameters): candidate = reports_award & ( claimant_or_partner | ~benunit.project(couple_reports) ) + # WRA 2007 s.1(3)(c): the claimant "has not reached pensionable age" + # (s.1(6): the age in Pensions Act 1995 Sch 4 para 1). A man born + # before 6 December 1953 reaches it at 65. + pensionable_age = person("is_SP_age", period) # Para 6(1)(e) and reg 41: the claimant's own remunerative work. claimant_works = person("esa_income_claimant_remunerative_work", period) # Para 6(1)(f) and reg 42(1): the other member of the couple works 24 @@ -62,5 +238,31 @@ def formula(benunit, period, parameters): other_member_works = claimant_or_partner & ( (benunit.project(benunit.sum(works_as_partner)) - works_as_partner) > 0 ) - claimant = candidate & ~claimant_works & ~other_member_works - return benunit.any(claimant) & (capital <= ESA.capital.limit) + passes = candidate & ~pensionable_age & ~claimant_works & ~other_member_works + within_capital = capital <= ESA.capital.limit + couple_passes = benunit.any(passes & claimant_or_partner) & within_capital + if not couple_passes.any(): + return benunit.any(passes) & within_capital + # Sch 1 para 6(1)(c) and (d): Parliament allows the income-related + # allowance only where neither the claimant nor the other member of + # the couple has an entitlement to State Pension Credit. The model's + # Pension Credit reads the couple's award, so this reads the couple's + # Pension Credit without it, for each couple whose claim passes the + # other tests. The model's own formulas pay Pension Credit only to a + # couple that meets the age conditions and would claim, so where they + # calculate it, this reads it only for a couple that does, and only + # where some couple does. A member who claims in their own right keeps + # their claim whatever the couple's Pension Credit. + on_pension_credit = couple_passes + if pension_credit_follows_model_formulas(benunit, period, parameters): + on_pension_credit = ( + couple_passes + & benunit("meets_pension_credit_age_conditions", period) + & benunit("would_claim_pc", period) + ) + if on_pension_credit.any(): + on_pension_credit = on_pension_credit & ( + pension_credit_without_income_related_esa(benunit, period) > 0 + ) + pension_credit_bars = claimant_or_partner & benunit.project(on_pension_credit) + return benunit.any(passes & ~pension_credit_bars) & within_capital diff --git a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py index 6c02b921d2..80cbc3dec7 100644 --- a/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py +++ b/policyengine_uk/variables/gov/dwp/esa_income_report_closed.py @@ -21,7 +21,8 @@ class esa_income_report_closed(Variable): "in Northern Ireland, Welfare Reform (Northern Ireland) Order 2015 " "art. 39(1)(b) and SR 2017/190 art. 6(1) to (3) and (6)). For claims " "from 22 July 2020 the award runs on for two weeks after that day (SI " - "2019/1152 reg. 5), which this annual model does not represent. A " + "2019/1152 reg. 5; in Northern Ireland, SR 2019/152 reg. 4), which this " + "annual model does not represent. A " "contributory allowance in the award continues as new style ESA " "(esa_contrib). A member who is neither the claimant nor the partner, " "such as a non-dependent adult, claims in their own right and is not " @@ -48,6 +49,7 @@ class esa_income_report_closed(Variable): "https://www.legislation.gov.uk/uksi/2025/1148/article/3A", "https://www.legislation.gov.uk/nisi/2015/2006/article/39", "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", "https://www.legislation.gov.uk/nisr/2025/176/article/3A", ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py index 4f629c776a..80042fcf0a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_exempt_specified_benefit.py @@ -62,16 +62,13 @@ class is_housing_benefit_benefit_cap_exempt_specified_benefit(Variable): "Universal Credit before the cap (universal_credit_pre_benefit_cap), " "so a Universal Credit award the cap reduces to nil still lifts the " "Housing Benefit cap. A young person is the model's Child Benefit " - "qualifying young person, which leaves out a 16-year-old in neither " - "non-advanced education nor approved training. The Housing Benefit " - "cap rate (is_housing_benefit_benefit_cap_single_claimant_rate) " - "counts such a 16-year-old for the year, so a lone parent with one " - "gets the family rate, but the 16-year-old's own disability living " - "allowance or personal independence payment does not lift the cap. " - "In law one status decides both: a school leaver remains a " - "qualifying young person until a terminal date or the end of an " - "extension period (Child Benefit (General) Regulations 2006 regs. 5 " - "and 7), which the model does not place (#2287)." + "qualifying young person (HB Regs 2006 reg. 19), which includes a " + "16-year-old who has left education, until the 31 August after their " + "16th birthday (Child Benefit (General) Regulations 2006 reg. 4), and " + "a school leaver in an extension period or before a terminal date " + "where those inputs are set (regs. 5 and 7). Such a young person's " + "own disability living allowance, personal independence payment or " + "armed forces independence payment lifts the cap." ) definition_period = YEAR reference = ( @@ -88,10 +85,9 @@ def formula(benunit, period, parameters): # A child under 16 or a Child Benefit qualifying young person the # claimant or partner is responsible for (HB Regs 2006 regs 2(1), 19). - # A 16-year-old in neither non-advanced education nor approved - # training is not one here, although the cap rate counts them for the - # year (has_sixteen_year_old_dependant); the model's qualifying young - # person has no school-leaver terminal date (#2287). + # A 16-year-old who has left education is one for the year (Child + # Benefit (General) Regulations 2006 reg. 4), as the cap rate counts + # them (has_sixteen_year_old_dependant). child_or_young_person = person( "is_child_or_young_person_for_legacy_benefits", period ) @@ -120,7 +116,12 @@ def received_by(benefits, members): receiving_income_related_esa = benunit.project( couple_esa_award > 0 ) & is_payee_of_couple_award( - person, period, "esa_income_reported", couple=claimant_or_partner + person, + period, + "esa_income_reported", + # WRA 2007 s.1(3)(c): not a member who has reached pensionable age. + can_claim=~person("is_SP_age", period), + couple=claimant_or_partner, ) receiving_esa = (person("esa_contrib", period) > 0) | ( receiving_income_related_esa diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py index 9d5daa2c20..562729465f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/benefit_cap/is_housing_benefit_benefit_cap_single_claimant_rate.py @@ -15,11 +15,10 @@ class is_housing_benefit_benefit_cap_single_claimant_rate(Variable): "the other rate even where the partner could not be a joint claimant " "for Universal Credit. A 16-year-old member counts as a young person " "for the year (reg. 19(1); Child Benefit (General) Regulations 2006 " - "regs. 5 and 7). The exceptions " + "regs. 4, 5 and 7). The exceptions " "(is_housing_benefit_benefit_cap_exempt_specified_benefit) read the " - "model's Child Benefit qualifying young person instead, which leaves " - "out a 16-year-old in neither non-advanced education nor approved " - "training (#2287)." + "model's Child Benefit qualifying young person, which counts a " + "16-year-old for the year too, whatever their education (reg. 4)." ) definition_period = YEAR reference = ( diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py index b8e4fdfd59..f3a84f8c8f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_assessable_capital.py @@ -1,6 +1,11 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -10,7 +15,13 @@ class housing_benefit_assessable_capital(Variable): entity = BenUnit label = "Housing Benefit assessable capital" documentation = ( - "Housing Benefit capital counted from the configured capital sources. The " + "Housing Benefit capital counted from the configured capital sources. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 47 of SI 2006/213, " + "reg. 45 of SI 2006/214), and values each " + "such ISA balance as it values that household source. " + "The " "formula counts person-level sources, such as a Lifetime ISA and the cash and " "stocks and shares ISAs, only in the holder's own benunit and only for its " "claimant or partner (is_claimant_or_partner), and excludes dependants' " @@ -45,15 +56,16 @@ def formula(benunit, period, parameters): "housing_benefit_pension_age_regulations_apply", period ) p = parameters(period).gov.dwp.housing_benefit.means_test.capital - household_capital = sum( - household(source, period) - - pooled_person_capital(benunit, source, period, p) - for source in p.sources + household_capital = valued_capital( + lambda source: ( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + ), + p, ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(p) + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -81,7 +93,7 @@ def formula(benunit, period, parameters): ) capital = where( savings_credit_only, - benunit("pension_credit_assessable_capital", period), + exact_amount(benunit("pension_credit_assessable_capital", period)), household_capital_proxy + person_capital, ) # Guarantee Credit passport: SI 2006/214 reg 26 (NI: SR 2006/406 @@ -103,8 +115,9 @@ def formula(benunit, period, parameters): on_passporting_benefit = benunit( "housing_benefit_on_passporting_benefit", period ) - return where( - passported | on_passporting_benefit, - 0, - max_(0, capital), + # Rounded to the nearest penny at the last stage (reg. 80; HB (SPC) + # Regs 2006 reg. 61(6)). + return round_to_penny( + where(passported | on_passporting_benefit, 0, max_(0, capital)), + p.round_down, ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_child_disability_premiums.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_child_disability_premiums.py index b2ad3bb956..171979a8cb 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_child_disability_premiums.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_child_disability_premiums.py @@ -8,15 +8,18 @@ class housing_benefit_child_disability_premiums(Variable): unit = GBP label = "Housing Benefit child disability premiums" documentation = ( - "Disabled-child and enhanced-child premiums for legally dependent " - "children and young persons. Uses actual DLA or PIP receipt, AFIP, " - "and the existing blindness indicator; enhanced rates additionally " - "require highest DLA care or enhanced PIP daily living receipt, or " - "AFIP. Scottish replacement awards must be represented in the existing " - "benefit fields: separate replacement-award fields are not available. " - "Hospital suspension, blindness run-on and bereavement continuation " - "are not reconstructed. A generic disability flag is insufficient. " - "These child amounts are separate from the shared adult premiums." + "The model pays the disabled-child and enhanced-child premiums for " + "legally dependent children and young persons. A child qualifies for " + "the disabled-child rate through a positive DLA or PIP amount (either " + "component), AFIP or the blindness indicator, and for the enhanced " + "rate through an award that meets the adult condition: the highest " + "DLA care rate (has_highest_dla_sc_award), the enhanced PIP daily " + "living rate (has_enhanced_pip_dl_award) or AFIP. The model has no " + "separate fields for Scottish replacement awards, so callers enter " + "them in the DLA and PIP fields. The model omits hospital suspension, " + "the blindness run-on and bereavement continuation, and a generic " + "disability flag does not qualify. The model computes these child " + "premiums separately from the adult premiums." ) reference = ( "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", @@ -40,8 +43,8 @@ def formula(benunit, period, parameters): afip = person("armed_forces_independence_payment", period) > 0 disabled = dla | pip | afip | person("is_blind", period) enhanced = ( - (dla_care & person("receives_highest_dla_sc", period)) - | (pip_daily & person("receives_enhanced_pip_dl", period)) + person("has_highest_dla_sc_award", period) + | person("has_enhanced_pip_dl_award", period) | afip ) weekly = p.disabled_child * disabled + p.enhanced_disabled_child * enhanced diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py index 780d17bc56..e6480cfb82 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_on_passporting_benefit.py @@ -54,12 +54,8 @@ def formula(benunit, period, parameters): p.northern_ireland, p.great_britain, ) - # Universal Credit before the benefit cap and deductions: Universal - # Credit depends on Housing Benefit through the benefit cap - # (uc_benefit_cap_reduction, whose total benefit_cap_welfare_benefits - # counts Housing Benefit), so reading the amount paid - # (universal_credit) here would be circular. Before the cap is also - # the law's reading: entitlement counts whether or not it is paid. + # Read Universal Credit before the benefit cap and deductions: + # entitlement counts whether or not DWP pays it. on_universal_credit = benunit("is_uc_entitled", period) & universal_credit_limb on_legacy_benefit = benunit( "in_receipt_of_income_support_jsa_ib_or_esa_ir", period diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py new file mode 100644 index 0000000000..ccb2eb3350 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/housing_benefit_report_is_rate_rebate_only.py @@ -0,0 +1,35 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_report_is_rate_rebate_only(Variable): + value_type = bool + entity = Person + label = "Housing Benefit report covers only a Northern Ireland rate rebate" + documentation = ( + "Whether this person's housing_benefit_reported covers only a rate " + "rebate on the home they occupy in Northern Ireland, with no rent " + "rebate or allowance. The Housing Benefit Regulations (Northern " + "Ireland) 2006 (SR 2006/405) and their twin for people at the " + "qualifying age for State Pension Credit (SR 2006/406) pay a rate " + "rebate on rates (reg. 12(1)) and a rent rebate or allowance on rent " + "and similar payments (reg. 13(1)), including mooring charges for a " + "houseboat and site payments for a caravan or mobile home (reg. " + "13(1)(f) and (g)). So tenure doesn't settle the form: an owner can " + "get a rent allowance. A Universal Credit claim ends a rate rebate the " + "day before the award starts (SR 2016/226 reg. 6(2)) and runs a rent " + "rebate or allowance on for two weeks (reg. 6(2A)). Only the first-day " + "test that is_qualifying_young_person_for_uc_education reads uses this " + "status, and only in Northern Ireland. A person with both forms leaves " + "it false, because their rent rebate or allowance runs on. It has no " + "formula and defaults to False, so the model reads a report as a rent " + "rebate or allowance unless this input says otherwise." + ) + definition_period = YEAR + default_value = False + reference = ( + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/_non_dependants.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/_non_dependants.py new file mode 100644 index 0000000000..c50885abd1 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/_non_dependants.py @@ -0,0 +1,111 @@ +"""Shared definitions for Housing Benefit and Council Tax Reduction +non-dependant deductions.""" + +from policyengine_uk.model_api import * + +# Family benefits counted in a couple's gross income, besides Universal Credit. +# They are awarded to the benefit unit, so the person-level gross income +# variables leave them out. Income-based JSA and income-related ESA count the +# claimant's and partner's award only: another member claims in their own +# right. +FAMILY_GROSS_INCOME_BENEFITS = [ + "child_tax_credit", + "working_tax_credit", + "child_benefit", + "income_support", + "claimant_or_partner_jsa_income", + "claimant_or_partner_esa_income", + "pension_credit", +] + + +def non_dependant_weekly_gross_income(person, period, parameters, own_gross_income): + """Calculate normal weekly gross income for the deduction bands. The council + bands a claimant or partner on the couple's joint income (HB reg 74(4); CTR + Sch 1 para 8(4)): each member's own gross income plus the family's tax credits, + child benefit, Income Support, income-based JSA, income-related ESA and + Pension Credit, and its Universal Credit award (universal_credit_award) + where either member claims it. The council bands each other member of the + benefit unit separately on their own income, adding Universal Credit + where that member claims it alone. own_gross_income names the programme's + person-level gross income variable. + """ + claimant_or_partner = person("is_claimant_or_partner", period) + own_income = person(own_gross_income, period) + benunit = person.simulation.populations["benunit"] + other_awards = benunit.project(add(benunit, period, FAMILY_GROSS_INCOME_BENEFITS)) + # Universal Credit is the income of the claimants it assesses, the + # claimant and partner unless is_uc_claimant names someone else. + universal_credit = benunit.project(universal_credit_award(benunit, period)) + uc_claimant = person("is_uc_assessed_claimant", period) + couple_claims_universal_credit = person.benunit.any( + uc_claimant & claimant_or_partner + ) + couple_income = ( + person.benunit.sum(own_income * claimant_or_partner) + + other_awards + + where(couple_claims_universal_credit, universal_credit, 0) + ) + single_income = own_income + where( + uc_claimant & ~couple_claims_universal_credit, universal_credit, 0 + ) + annual = where(claimant_or_partner, couple_income, single_income) + return annual / WEEKS_IN_YEAR + + +def universal_credit_award(benunit, period): + """Return a family's Universal Credit award after the benefit cap (UC + Regs 2013 reg 81) and before debt deductions. Welfare Reform Act 2012 + s.8(1) sets the award as the maximum amount less the amounts for + income. DWP then takes advances (SI 2013/383 reg 10), overpayments (SI + 2013/384 regs 10-11) and third-party payments (SI 2013/380 Sch 6 para + 2(1)) from the sum payable, so those deductions stay the family's + income. A nil award carries none.""" + universal_credit = benunit("universal_credit", period) + deductions = benunit("uc_deductions", period) + return universal_credit + where(universal_credit > 0, deductions, 0) + + +def has_earned_income(person, period): + """Read whether Universal Credit counts positive earned income for the + person (uc_individual_earned_income). HB Regs 2006 reg 74(8)(b), (11) and + the CTR schemes' equivalents give "earned income" the meaning in UC Regs + 2013 reg 52. UC deducts allowable expenses, tax, National Insurance and + relievable pension contributions (reg 55(3), (5)); attributes company + income to its owner (reg 77(3)); offsets trading losses (reg 57(2)); and + applies the floor under reg 62, a couple's floor included.""" + return person("uc_individual_earned_income", period) > 0 + + +def is_on_legacy_income_related_benefit(person, period): + """Whether the person is on Income Support, income-based JSA or + income-related ESA: the award is payable to them (HB Regs 2006 reg 2(1), + (3) and (3A); the Council Tax Reduction schemes use the same + definitions).""" + return ( + person("is_on_income_support", period) + | person("is_on_income_based_jsa", period) + | person("is_on_income_related_esa", period) + ) + + +def deduction_per_family(benunit, period, deductions, both_members_of_couple): + """One deduction for the claimant and partner, the higher of theirs (HB reg + 74(3); CTR Sch 1 para 8(3)), or both where both_members_of_couple; plus a + separate deduction for each other member.""" + claimant_or_partner = benunit.members("is_claimant_or_partner", period) + couple = where( + both_members_of_couple, + benunit.sum(deductions * claimant_or_partner), + benunit.max(deductions * claimant_or_partner), + ) + return couple + benunit.sum(deductions * ~claimant_or_partner) + + +def charged_to_other_families(benunit, period, deduction_for_benunit): + """Deductions for the non-dependant families in the household other than + this one: each family's amount counted once (via its head).""" + is_benunit_head = benunit.members("is_benunit_head", period) + counted = is_benunit_head * benunit.project(deduction_for_benunit) + in_household = benunit.max(benunit.members.household.sum(counted)) + return in_household - deduction_for_benunit diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.py index 2bec92df06..162816d04f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/household_benefits_individual_non_dep_deduction.py @@ -1,10 +1,22 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + is_on_legacy_income_related_benefit, + non_dependant_weekly_gross_income, +) class household_benefits_individual_non_dep_deduction(Variable): value_type = float entity = Person label = "Housing Benefit individual non-dependent deduction" + documentation = ( + "For a non-dependant in remunerative work, the council deducts the amount for the band " + "containing their normal gross weekly income (a couple's joint income " + "for a claimant or partner of the non-dependant's family); bands " + "include their lower edge. For a non-dependant outside remunerative work, " + "including one on Income Support, income-based JSA or income-related " + "ESA (reg 6(6)), the council deducts the lowest amount." + ) definition_period = YEAR unit = GBP defined_for = "housing_benefit_individual_non_dep_deduction_eligible" @@ -17,32 +29,19 @@ class household_benefits_individual_non_dep_deduction(Variable): def formula(person, period, parameters): p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction - income = person("housing_benefit_non_dep_gross_income", period) - weekly_income = income / WEEKS_IN_YEAR - on_income_related_benefit = ( - person("is_on_income_support", period) - | person("is_on_income_based_jsa", period) - | person("is_on_income_related_esa", period) + weekly_income = non_dependant_weekly_gross_income( + person, period, parameters, "housing_benefit_non_dep_gross_income" ) + # Reg 6(6), or pension-age reg 6(6): a person on Income Support, + # income-based JSA or income-related ESA is not in remunerative work. in_remunerative_work = ( person("weekly_hours", period) >= p.remunerative_work_hours - ) & ~on_income_related_benefit - banded = p.amount.calc(weekly_income, right=True) - # Reg 74(1)(b), or pension-age reg 55(1)(b), gives a non-dependant - # outside remunerative work the flat amount, the scale's first, - # instead of an income band. Reg 6(6) excludes income-related - # recipients from remunerative work. The gate only lowers a - # deduction: a non-worker with no banding income keeps the scale's - # zero, because the exemptions in reg 74(6), (7), (8) and (10), - # and reg 55(6)-(9) (a claimant on Attendance Allowance; a - # non-dependant on Pension Credit, or under 25 on Income Support; - # a full-time student) are not modelled, and the income-related - # benefits are not banding income, so many such non-dependants - # have none. - # #2007 adds the exemptions and the flat charge together. - deduction = where( - in_remunerative_work, - banded, - min_(banded, p.amount.amounts[0]), - ) - return deduction * WEEKS_IN_YEAR + ) & ~is_on_legacy_income_related_benefit(person, period) + # Reg 74(2), or pension-age reg 55(2): each band runs from "not less + # than" its lower edge. Reg 74(1)(b), or reg 55(1)(b): for a non-dependant + # outside remunerative work the council deducts the scale's lowest + # amount whatever their income. + banded = p.amount.calc(weekly_income) + not_in_remunerative_work = p.amount.calc(np.zeros_like(weekly_income)) + weekly = where(in_remunerative_work, banded, not_in_remunerative_work) + return weekly * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py index c77c460ef1..6983f4c87f 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_individual_non_dep_deduction_eligible.py @@ -5,16 +5,31 @@ class housing_benefit_individual_non_dep_deduction_eligible(Variable): value_type = bool entity = Person label = "eligible person for the Housing Benefit non-dependent deduction" + documentation = ( + "A non-dependant aged 18 or over: either a member of another family " + "who is not liable for rent (see is_non_dependant_of_household_head) " + "or a member of their own benefit unit who is not the claimant, " + "partner or a child or young person (see " + "is_benefit_unit_non_dependant_for_legacy_benefits), who is not " + "exempt (housing_benefit_non_dep_deduction_exempt)." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2006/213/regulation/3" + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + ) def formula(person, period, parameters): - rent_liable = person.benunit("benunit_is_liable_for_household_rent", period) p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + non_dependant = person("is_non_dependant_of_household_head", period) | person( + "is_benefit_unit_non_dependant_for_legacy_benefits", period + ) age_eligible = person("age", period) >= p.age_threshold # A boarder or lodger, and their partner (reg 8(1)(b)), is liable to # pay the claimant on a commercial basis for their occupation, so is - # not a non-dependant (reg 3(2)(e)(i)). A member of their family who - # is not liable stays a non-dependant. - commercial = person("pays_rent_to_householder", period) - return age_eligible & ~rent_liable & ~commercial + # not a non-dependant (reg 3(2)(e)(i)), and neither flag counts them. + # A member of their family who is not liable stays a non-dependant. + # An exempt non-dependant attracts no deduction (reg 74(7), (8) and (10)). + exempt = person("housing_benefit_non_dep_deduction_exempt", period) + return non_dependant & age_eligible & ~exempt diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_exempt.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_exempt.py new file mode 100644 index 0000000000..f653095d4a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deduction_exempt.py @@ -0,0 +1,60 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + has_earned_income, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + is_full_time_student_non_dep, +) + + +class housing_benefit_non_dep_deduction_exempt(Variable): + value_type = bool + entity = Person + label = "Exempt from Housing Benefit non-dependant deductions" + documentation = ( + "Modelled exemptions: a full-time student (SI 2006/213 reg 74(7)(c)-(e); " + "SI 2006/214 reg 55(7)(e)); a non-dependant on State Pension Credit " + "(reg 74(10); reg 55(9)); and a non-dependant under 25 on Income " + "Support or income-based JSA, or entitled to Universal Credit " + "calculated on no earned income (reg 74(8); reg 55(8)). Not modelled: " + "a working student's summer vacation (reg 74(7)(d)), under-25s on " + "assessment-phase income-related ESA (no ESA group input), youth " + "training allowances, a normal home elsewhere, and absence in hospital, " + "prison or on armed forces operations." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction + full_time_student = is_full_time_student_non_dep(person, period) + # IS, income-based JSA and SPC count for the person they are payable + # to (reg 2(1), (3)); Universal Credit for both joint claimants. + on_pension_credit = person("is_on_pension_credit", period) + on_legacy_income_related_benefit = person( + "is_on_income_support", period + ) | person("is_on_income_based_jsa", period) + # Read entitlement before the cap and debt deductions for the claimants + # the award assesses. DWP can reduce the payment to nil while + # entitlement still qualifies for the exemption. + entitled_to_universal_credit = person("is_uc_assessed_claimant", period) & ( + person.benunit("universal_credit_pre_benefit_cap", period) > 0 + ) + universal_credit_without_earned_income = ( + entitled_to_universal_credit & ~has_earned_income(person, period) + ) + under_age_limit = person("age", period) < p.income_related_benefit_age_limit + return ( + full_time_student + | on_pension_credit + | ( + under_age_limit + & ( + on_legacy_income_related_benefit + | universal_credit_without_earned_income + ) + ) + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py index e5e9e3a933..4144973013 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions.py @@ -1,30 +1,82 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + deduction_per_family, +) class housing_benefit_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "non-dependent deductions" + documentation = ( + "Deductions for the non-dependants residing with the claimant: " + "members of other families of the household who are not liable for " + "rent, and members of a family liable for the rent who are not its " + "claimant, partner or a child or young person. One deduction for " + "each non-dependant couple, the higher of the two members' amounts, " + "and one for each other non-dependant. Each is apportioned between " + "joint occupiers by their shares of the rent, whichever family they " + "are in; a boarder or lodger also bears the whole deduction for a " + "non-dependant in its own benefit unit. None if the claimant or " + "partner is exempt." + ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + "https://assets.publishing.service.gov.uk/media/5a7ce2b840f0b6629523c64c/hbgm-a5-calculating-benefit.pdf", ) def formula(benunit, period, parameters): - # Deductions are made for non-dependants residing with the claimant + # The council deducts for non-dependants residing with the claimant # (HB Regs 2006 reg 74; HB (SPC) Regs 2006 reg 55). Joint occupiers, # boarders, lodgers and the landlord's household are not - # non-dependants (reg 3(2)(d)-(e), 3(4)). A non-dependant of more - # than one joint occupier is apportioned between them by their shares - # of the payments (reg 74(5); SPC reg 55(5)), so each family liable - # for the household's rent bears its share; a boarder or lodger bears - # none. + # non-dependants (reg 3(2)(d)-(e), 3(4)). For a couple the council deducts once, + # the higher amount (reg 74(3)). For a non-dependant of more than one joint + # occupier, the council apportions the deduction by their shares of the + # payments (reg 74(5); SPC reg 55(5)), so each family liable for the + # household's rent bears its share; a boarder or lodger bears none of it. + # A family bearing a share is a joint occupier, never another family's + # non-dependant, even where the rent is nil and so the family is not + # benunit_is_rent_liable. person = benunit.members - deductions = person( - "household_benefits_individual_non_dep_deduction", period - ) * person("is_non_dependant_of_household_head", period) + deductions = person("household_benefits_individual_non_dep_deduction", period) + in_joint_occupier_family = person.benunit("share_of_household_rent", period) > 0 + other_family = ( + person("is_non_dependant_of_household_head", period) + & ~in_joint_occupier_family + ) + # A member of a rent-liable family who is not in its claimant's + # family under reg 3(2)(a) and (c) is a non-dependant too. That takes + # in a boarder's or lodger's adult son, whom other_family also counts: + # what a family pays the householder makes it rent-liable here. + in_unit = person("is_benefit_unit_non_dependant_for_legacy_benefits", period) + rent_liable = person.benunit("benunit_is_rent_liable", period) + own_family = in_unit & (~other_family | rent_liable) + # A non-dependant normally resides with each joint occupier (reg 3(1) + # and (4)), whichever family they belong to: another family's adult, + # a joint occupier's own adult son, or a boarder's or lodger's (reg + # 3(2)(e)(i) excludes only the person liable to pay the claimant). + # A couple pays one deduction, the higher (reg 74(3)), and each other + # non-dependant separately, counted once per family. One who is a + # non-dependant of more than one joint occupier is apportioned + # between them by their shares of the payments (reg 74(5); SPC reg + # 55(5)). + family_amount = deduction_per_family( + benunit, period, deductions * (other_family | own_family), False + ) + counted = person("is_benunit_head", period) * benunit.project(family_amount) share = benunit("share_of_household_rent", period) - return share * benunit.max(person.household.sum(deductions)) + from_joint_occupiers = share * benunit.max(person.household.sum(counted)) + # A boarder or lodger is not a joint occupier with the householder, + # so it also bears the whole deduction for a non-dependant in its own + # benefit unit. The model charges it for no one else. + joint_occupier = share > 0 + from_own_family = where(joint_occupier, 0, benunit.sum(deductions * own_family)) + claimant_exempt = benunit( + "housing_benefit_non_dep_deductions_claimant_exempt", period + ) + return where(claimant_exempt, 0, from_joint_occupiers + from_own_family) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions_claimant_exempt.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions_claimant_exempt.py new file mode 100644 index 0000000000..b8ed5eb93f --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_deductions_claimant_exempt.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_non_dep_deductions_claimant_exempt(Variable): + value_type = bool + entity = BenUnit + label = "Exempt from all Housing Benefit non-dependant deductions" + documentation = ( + "The council makes no deduction for any non-dependant if the claimant or partner " + "is blind or receives Attendance Allowance, the DLA care component, the " + "PIP daily living component or Armed Forces Independence Payment (SI " + "2006/213 reg 74(6); SI 2006/214 reg 55(6)). Scottish disability " + "assistance (pension age and adult disability payments, child " + "disability payment, Scottish adult DLA) has no separate input." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/74", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", + ) + + def formula(benunit, period, parameters): + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + qualifying = ( + person("is_blind", period) + | (person("attendance_allowance", period) > 0) + | (person("dla_sc", period) > 0) + | (person("pip_dl", period) > 0) + | (person("armed_forces_independence_payment", period) > 0) + ) + return benunit.any(claimant_or_partner & qualifying) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py index 527588841d..0faeda2b2a 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/non_dep_deduction/housing_benefit_non_dep_gross_income.py @@ -16,27 +16,29 @@ class housing_benefit_non_dep_gross_income(Variable): "https://www.legislation.gov.uk/uksi/2006/214/regulation/55", ) documentation = ( - "Annual gross income represented by the model for non-dependant " + "Annual gross income the model represents for the non-dependant " "bands, before tax, National Insurance, employment deductions and " - "pension contributions. Signed trading/property inputs are retained; " - "a separate cross-source loss-offset rule is not modelled. Claimant " - "war-pension and supplementary-pension disregards do not apply to " - "these non-dependant bands. It includes statutory sick, maternity and " - "paternity pay once through employment_benefits, child and spousal " - "maintenance, the tax-purpose social security income sources, and " - "the separately represented non-taxable periodic benefits. " - "Attendance Allowance, DLA and PIP are omitted under the specific " - "disability-payment disregards. The other disregarded disability " - "and compensation/trust payments have no separate model inputs. " - "Benefit-unit awards (including Child Benefit, tax credits and UC) " - "are not allocated to an individual recipient here; adding computed " - "UC would also create a cycle through the benefit cap and HB. " - "BSP and AFCS inputs do not separate periodic income from capital " - "payments, so they are not added. Other investment income remains " - "excluded to preserve this income base; extending it remains for " - "#2007. The deduction formula applies the remunerative-work gate. The " - "gross-income variable itself does not decide whether someone is " - "in remunerative work or whether a deduction is due." + "pension contributions. DWP tells councils to count earnings and any " + "other income (HB guidance A5.542), to count a self-employed " + "non-dependant's total income before business expenses (A5.551) and " + "to add savings interest to earnings (A5.570). The model holds " + "self-employment and property profits rather than receipts, so it " + "adds each profit and counts a loss as nil, and a loss never reduces " + "other income. The claimant war-pension and supplementary-pension " + "disregards do not apply to these bands. The total includes " + "statutory sick, maternity and paternity pay once, through " + "employment_benefits; child and spousal maintenance; the tax-purpose " + "social security income sources; the non-taxable periodic benefits " + "the model represents separately; and other investment income, ISA " + "interest (tax_free_savings_income) and ISA dividends. It leaves out " + "Attendance Allowance, DLA and PIP under the disability-payment " + "disregards; the other disregarded disability, compensation and " + "trust payments have no model inputs. BSP and AFCS inputs do not " + "separate periodic income from capital payments, so the total leaves " + "them out. Benefit-unit awards, such as Child Benefit, tax credits " + "and Universal Credit, stay out of it. The deduction formula adds " + "family awards to the claimant and partner's joint income and applies " + "the remunerative-work test." ) def formula(person, period, parameters): @@ -48,10 +50,13 @@ def formula(person, period, parameters): "employment_benefits", "private_pension_income", "social_security_income", - "self_employment_income", - "property_income", "savings_interest_income", + # Count ISA interest unless the inputs supply an exempt-interest + # total. + "tax_free_savings_income", + "other_investment_income", "dividend_income", + "individual_savings_account_dividend_income", "miscellaneous_income_before_trading_allowance", "maternity_allowance", "iidb", @@ -59,8 +64,15 @@ def formula(person, period, parameters): "war_widows_pension", ], ) + # The model has profits, not the receipts DWP HB guidance A5.551 + # counts before business expenses. As an approximation, a loss + # counts as nil, so it never reduces other income. + losses_as_nil = max_(0, person("self_employment_income", period)) + max_( + 0, person("property_income", period) + ) return ( income + + losses_as_nil + max_(0, person("maintenance_income", period)) + max_(0, person("spousal_maintenance_income", period)) ) diff --git a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py index 82fd5a0cfe..b36f36b1b3 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/income_support_assessable_capital.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -11,7 +15,12 @@ class income_support_assessable_capital(Variable): label = "Assessable capital for Income Support" documentation = ( "Household capital apportioned to the benefit unit for the Income Support " - "capital test. For capital whose owner the data do not identify, the model " + "capital test. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 49), and values each " + "such ISA balance as it values that household source. " + "For capital whose owner the data do not identify, the model " "allocates the full household remainder to any benunit on the IS claim path " "and falls back to a claimant-and-partner share when nobody in the household " "claims on that path. No regulation sets that allocation; it approximates " @@ -28,7 +37,6 @@ class income_support_assessable_capital(Variable): def formula(benunit, period, parameters): IS = parameters(period).gov.dwp.income_support - capital = IS.means_test.capital person = benunit.members would_claim_is = benunit("would_claim_IS", period) @@ -38,10 +46,12 @@ def formula(benunit, period, parameters): # If nobody in the household is on that path, use claimant/partner # weights. Regulation 23 excludes children's and young persons' capital; # the household allocation itself is a modelling convention. - household_capital = sum( - benunit.max(person.household(source, period)) - - pooled_person_capital(benunit, source, period, capital) - for source in capital.sources + household_capital = valued_capital( + lambda source: ( + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, IS.means_test.capital) + ), + IS.means_test.capital, ) benunit_claimants_and_partners = add( benunit, period, ["is_claimant_or_partner"] @@ -61,11 +71,13 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(capital) + person_capital = valued_person_capital( + benunit, period, parameters, IS.means_test.capital, claimant_or_partner ) household_share = where( household_claiming_members > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 27). + return round_to_penny( + household_share + person_capital, IS.means_test.capital.round_down + ) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py new file mode 100644 index 0000000000..5adae2bd3c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_legacy_benefits.py @@ -0,0 +1,64 @@ +from policyengine_uk.model_api import * + + +class is_benefit_unit_non_dependant_for_legacy_benefits(Variable): + value_type = bool + entity = Person + label = "Non-dependant within the benefit unit for Housing Benefit and Council Tax Reduction" + documentation = ( + "A member of the benefit unit who is neither the claimant or partner " + "nor a child or young person: someone aged 16 or over who is not a " + "Child Benefit qualifying young person. The benefit unit follows the " + "survey's family, which can include an adult child or a 16 to 19 year " + "old who is not a qualifying young person. Under the Housing Benefit " + "and Council Tax Reduction schemes such a person is not in the " + "claimant's family (SSCBA 1992 s.137(1), with young person defined " + "by Child Benefit) but normally resides with the claimant, so is a " + "non-dependant. A child or young person is either in the family or, " + "if placed with the claimant by a local authority, excluded from " + "being a non-dependant. Such a person's own benefit awards are not " + "modelled, so the exemptions for a non-dependant on an income-related " + "benefit or on Universal Credit with no earned income (HB reg 74(8), " + "CTR Sch 1 para 8(8)) never apply to them. The household head " + "(is_resolved_household_head), a householder in whose name the " + "accommodation is rented, is liable for it and so never a " + "non-dependant (HB reg 3(2)(d)-(e)), even where the benefit unit's " + "claimant and partner are someone else. Nor is a member who pays the " + "household head as a boarder or lodger (pays_rent_to_householder), " + "who is liable for their own occupation (HB reg 3(2)(e)(i)). Council " + "Tax Reduction uses " + "is_benefit_unit_non_dependant_for_council_tax_reduction, built on " + "the CTR applicant." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/19", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/21", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/3", + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/9", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/3", + "https://www.legislation.gov.uk/ukpga/1992/4/section/137", + ) + + def formula(person, period, parameters): + # HB Regs 2006 and HB (SPC) Regs 2006 reg 3(2)(a), (c) with regs 19 + # and 21(3); SI 2012/2885 and WSI 2013/3029 reg 9(2)(a), (c); SSI + # 2021/249 reg 8(2)(a)-(b); SSI 2012/319 reg 3. A Child Benefit child + # is under 16. + claimant_or_partner = person("is_claimant_or_partner", period) + child_or_young_person = person( + "is_child_or_qualifying_young_person_for_child_benefit", period + ) + # Reg 3(2)(d)-(e): the household head is liable for the dwelling, and + # a member who pays the head for their occupation is liable too. + household_head = person("is_resolved_household_head", period) + pays_householder = person("pays_rent_to_householder", period) + return ( + ~claimant_or_partner + & ~child_or_young_person + & ~household_head + & ~pays_householder + ) diff --git a/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py new file mode 100644 index 0000000000..0c611f1aa5 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_benefit_unit_non_dependant_for_universal_credit.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * + + +class is_benefit_unit_non_dependant_for_universal_credit(Variable): + value_type = bool + entity = Person + label = "Non-dependant within the benefit unit for Universal Credit" + documentation = ( + "A member of the benefit unit, aged 16 or over, who is neither the " + "claimant or partner nor a qualifying young person. The benefit unit " + "follows the survey's family, which can include an adult child or a " + "16 to 19 year old who is not a qualifying young person. In Universal " + "Credit law such a person is not in the claimant's family but is a " + "non-dependant of the renter: they normally live in the accommodation " + "and are none of the persons excluded by Sch 4 para 9(2). A qualifying " + "young person the renter is responsible for is a member of the " + "renter's extended benefit unit, and one no one is responsible for, " + "for example one looked after by a local authority, is excluded by " + "para 9(2)(g). A child, under 16, is always one or the other. The " + "household head (is_resolved_household_head), a householder in whose " + "name the accommodation is rented, is liable for it and so never a " + "non-dependant (para 9(2)(d)), even where the benefit unit's " + "claimant and partner are someone else. Nor is a member who pays the " + "household head as a boarder or lodger (pays_rent_to_householder), " + "who is liable for their own occupation." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/ukpga/2012/5/section/40", + ) + + def formula(person, period, parameters): + # UC Regs 2013 Sch 4 para 9(1)(a)-(b) and (2)(a), (g); WRA 2012 s.40. + not_a_child = ~person("is_child_for_universal_credit", period) + claimant_or_partner = person("is_claimant_or_partner", period) + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + # Para 9(2)(d): the household head is liable for the accommodation, + # and a member who pays the head for their occupation is liable too. + household_head = person("is_resolved_household_head", period) + pays_householder = person("pays_rent_to_householder", period) + return ( + not_a_child + & ~claimant_or_partner + & ~qualifying_young_person + & ~household_head + & ~pays_householder + ) diff --git a/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py b/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py index d2e8695003..2406b43ab7 100644 --- a/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py +++ b/policyengine_uk/variables/gov/dwp/is_gainfully_employed_for_carers_allowance.py @@ -8,11 +8,13 @@ class is_gainfully_employed_for_carers_allowance(Variable): documentation = ( "Whether the person's weekly net earnings exceed the Carer's Allowance " "earnings limit, so that they are treated as gainfully employed and " - "are not entitled to the allowance. The regulation tests the earnings " - "of the preceding week; annual earnings are spread evenly over 52 " - "weeks. A fraction of a penny is disregarded, as regulation 5 of the " + "are not entitled to the allowance. The formula reads " + "carers_allowance_weekly_earnings_above_limit, which spreads annual " + "earnings over 52 weeks and compares them with the limit in whole " + "pence, disregarding a fraction of a penny as regulation 5 of the " "Computation of Earnings Regulations requires when that favours the " - "claimant." + "claimant. Any excess, however small, means the person is gainfully " + "employed." ) definition_period = YEAR reference = [ @@ -31,4 +33,7 @@ class is_gainfully_employed_for_carers_allowance(Variable): ] def formula(person, period, parameters): + # The excess is positive wherever earnings exceed the limit (at least + # 1p for a limit in whole pence), which float32 holds as a positive + # amount, and exactly zero otherwise. return person("carers_allowance_weekly_earnings_above_limit", period) > 0 diff --git a/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py new file mode 100644 index 0000000000..de6be75a66 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_child_tax_credit_extension_period.py @@ -0,0 +1,21 @@ +from policyengine_uk.model_api import * + + +class is_in_child_tax_credit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Tax Credit extension period" + documentation = ( + "Whether, for some of the year, the person has ceased full-time " + "education or approved training, HMRC was notified within three " + "months that they are registered for work or training with a " + "qualifying body (the Careers Service, Connexions or the Ministry of " + "Defence), and no more than 20 weeks have passed since they left " + "(Child Tax Credit Regulations 2002 reg. 5(3)(b)(i)-(iii)). The model " + "applies the age limit (reg. 5(3)(b)) and the second condition (reg. " + "5(4)). Surveys do not record the registration, so this is false " + "unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" diff --git a/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py new file mode 100644 index 0000000000..5369423124 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/is_in_remunerative_work_for_child_tax_credit.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_tax_credit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Tax Credit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Tax Credit Regulations 2002 reg. " + "2(1)), read from average weekly hours over the year. The Working " + "Tax Credit rules the definition refers to, for counting hours and " + "for excluded work, are not modelled." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/2" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py index 83bb7684ea..99421dace5 100644 --- a/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py +++ b/policyengine_uk/variables/gov/dwp/is_non_dependant_of_household_head.py @@ -17,7 +17,9 @@ class is_non_dependant_of_household_head(Variable): "or daughter who pays nothing is a non-dependant. The Universal " "Credit formulas read is_uc_non_dependant_of_household_head, which " "also leaves out a boarder's or lodger's child or qualifying young " - "person (UC Regs 2013 Sch 4 para 9(2)(g)). Universal Credit counts a non-dependant in one claim only " + "person (UC Regs 2013 Sch 4 para 9(2)(g)) and an adult their own " + "family's claim counts (para 9(2)(f)). Universal Credit counts a " + "non-dependant in one claim only " "(uc_non_dependants_counted); Housing Benefit and Council Tax " "Reduction apportion a non-dependant of several joint occupiers " "between them. Foster children and carers engaged through a charity " 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 318ee8f10c..52b463ab20 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 @@ -55,7 +55,7 @@ def formula(person, period, parameters): person, period, "jsa_income", - works=hours >= JSA.remunerative_work.claimant_hours, + barred=hours >= JSA.remunerative_work.claimant_hours, capital_limit=JSA.income.capital.limit, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) 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 24ccc86b0b..678c5cd314 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 @@ -16,12 +16,16 @@ class is_on_income_related_esa(Variable): "couple's award is paid to the claimant, not the partner: of the " "claimant and partner, only the payee is on it, meaning the one who " "reports the award, or the claimant where neither does, and only while " - "their award (claimant_or_partner_esa_income) is positive. Any other " + "their award (claimant_or_partner_esa_income) is positive. A member " + "who has reached pensionable age cannot be the claimant (Welfare Reform " + "Act 2007 s.1(3)(c)), so where the other member has not, only the " + "other member can be the payee. Any other " "member of the benefit unit, such as a non-dependent adult, claims in " - "their own right and is on it only if their own claim passes: they are " - "not engaged in remunerative work as its claimant " - "(esa_income_claimant_remunerative_work; Welfare Reform Act 2007 Sch 1 " - "para 6(1)(e)), and the award on their own report alone is positive, " + "their own right and is on it only if their own claim passes: they " + "have not reached pensionable age (is_SP_age; s.1(3)(c)), they are not " + "engaged in remunerative work as its claimant " + "(esa_income_claimant_remunerative_work; Sch 1 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 legacy " @@ -43,19 +47,28 @@ class is_on_income_related_esa(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", "https://www.legislation.gov.uk/uksi/2012/2885/regulation/2", + "https://www.legislation.gov.uk/ukpga/2007/5/section/1", "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", ) def formula(person, period, parameters): + # WRA 2007 s.1(3)(c): a member who has reached pensionable age cannot + # be the claimant, so the model pays them none of the award. + pensionable_age = person("is_SP_age", period) couple_award = ( person.benunit("claimant_or_partner_esa_income", period) > 0 - ) & (is_payee_of_couple_award(person, period, "esa_income_reported")) - # Their own claim, on their own report and their own work alone. + ) & is_payee_of_couple_award( + person, period, "esa_income_reported", can_claim=~pensionable_age + ) + # Their own claim, on their own report, work and age alone (WRA 2007 + # s.1(3)(c): a claimant who has reached pensionable age has no + # entitlement). own_award = own_report_is_paid( person, period, "esa_income", - works=person("esa_income_claimant_remunerative_work", period), + barred=person("esa_income_claimant_remunerative_work", period) + | pensionable_age, capital_limit=parameters(period).gov.dwp.ESA.income.capital.limit, ) return where(person("is_claimant_or_partner", period), couple_award, own_award) diff --git a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py index 2c5e98dd46..53d39b176e 100644 --- a/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py +++ b/policyengine_uk/variables/gov/dwp/is_qualifying_young_person_for_child_tax_credit.py @@ -1,26 +1,99 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.highest_education import ( + EducationType, +) class is_qualifying_young_person_for_child_tax_credit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Tax Credit" + documentation = ( + "A person aged 16 to 19 who meets the Child Tax Credit Regulations " + "2002's conditions. The first condition (reg. 5(3)) is full-time " + "non-advanced education or approved training, begun before 19, or, " + "under 18, the 20 weeks after leaving while registered for work or " + "training. Reg. 4(1) lifts the first condition from the 16th " + "birthday until the 31 August that next follows it. That period " + "reads is_before_first_september_following_16th_birthday, so it " + "counts in any year that starts before the 31 August. For a 16th " + "birthday on 31 August, reg. 4(1A) gives no such period, where " + "Universal Credit gives that day and Child Benefit two: the year " + "counts all the same, as the one in which the person is a child " + "until 30 August, and not the year after. The second condition " + "(reg. 5(4)) always applies: a young person who has ceased full-time " + "education or approved training must not be in remunerative work, and " + "no one may be receiving a benefit in their own right. Full-time " + "education at any level counts (reg. 5(5)), so an advanced student " + "has not ceased it, nor has an apprentice still on a course; the " + "employment exclusion is part of the first condition only. Reg. " + "5(4)(c) bars every route for a person who receives Income Support, " + "and the formula reads is_on_income_support for it: the benefit " + "unit's calculated award, for whichever of the claimant and partner " + "receives it, and their own reported award for any other member " + "while Income Support is in payment and no reform neutralises it. A " + "reform that replaces Income Support's formula with zero ends a " + "claimant's or partner's receipt; for another member it must set " + "gov.dwp.income_support.active to false, neutralise income_support " + "or set their reported award to zero. The own-right benefits input " + "covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance of either kind, where reg. 5(4)(c) lists only " + "Universal Credit and the income-related and income-based " + "allowances. Interruptions (reg. 5(7)) are not modelled." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/4", + "https://www.legislation.gov.uk/uksi/2002/2007/regulation/5", + ] def formula(person, period, parameters): + p = parameters(period).gov.dwp.tax_credits.child_tax_credit.eligibility + # Reg. 5(3)(a), (ab) and (3A). + receiving_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", + period, + ) + in_education_or_training = receiving_education_or_training & person( + "meets_qualifying_young_person_entry_condition_for_child_tax_credit", + period, + ) + # Reg. 4(1): from the 16th birthday to the 31 August that next + # follows. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(3)(b): 20 weeks after leaving, under 18. + in_extension_period = ( + person("age", period) < p.extension_period_age_limit + ) & person("is_in_child_tax_credit_extension_period", period) + # Reg. 5(4)(a) bars remunerative work only for a person "having + # ceased to receive full-time education or approved training". + # Full-time education at a school or college counts at any level + # (reg. 5(5)), so failing the non-advanced test of reg. 5(3)(a) does + # not mean having ceased it. + # Education received by virtue of employment is left out of the first + # condition (reg. 5(3)(a)), which reg. 4(2) lifts, and not out of reg. + # 5(5): an apprentice still on a course has not ceased it. + in_full_time_education = ( + person("current_education", period) != EducationType.NOT_IN_EDUCATION + ) + ceased = ~in_full_time_education & ~person("is_in_approved_training", period) + working = person("is_in_remunerative_work_for_child_tax_credit", period) + # Reg. 4(2): the period needs no first condition, only the second. + in_regulation_4_period = before_31_august_following_16th_birthday & ~( + ceased & working + ) + # Reg. 5(3)(b) is for a person who has ceased. + in_20_weeks = in_extension_period & ceased & ~working return ( person( "meets_qualifying_young_person_age_condition_for_child_tax_credit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_tax_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_tax_credit", - period, - ) + & (in_education_or_training | in_regulation_4_period | in_20_weeks) + # Reg. 5(4)(c): Universal Credit, ESA and JSA in their own right, + # and Income Support, which reg. 4(2) keeps for the reg. 4 period. & ~person("receives_benefits_in_own_right", period) + & ~person("is_on_income_support", period) ) diff --git a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py index 2cf466c410..4ed0c7ea10 100644 --- a/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/jsa_income_assessable_capital.py @@ -1,6 +1,10 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -11,7 +15,12 @@ class jsa_income_assessable_capital(Variable): label = "Assessable capital for income-based JSA" documentation = ( "Household capital apportioned to the benefit unit for the income-based JSA " - "capital test. For capital whose owner the data do not identify, the model " + "capital test. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 111), and values each " + "such ISA balance as it values that household source. " + "For capital whose owner the data do not identify, the model " "allocates the full household remainder to any benunit with a reported " "income-based JSA award and falls back to a claimant-and-partner share when " "nobody in the household reports such an award. No regulation sets that " @@ -32,10 +41,12 @@ def formula(benunit, period, parameters): person = benunit.members claiming_jsa_income = add(benunit, period, ["jsa_income_reported"]) > 0 - household_capital = sum( - benunit.max(person.household(source, period)) - - pooled_person_capital(benunit, source, period, JSA.capital) - for source in JSA.capital.sources + household_capital = valued_capital( + lambda source: ( + benunit.max(person.household(source, period)) + - pooled_person_capital(benunit, source, period, JSA.capital) + ), + JSA.capital, ) # Regulation 88(2) excludes children's and young persons' capital. # The claimant/partner weights approximate otherwise unobserved ownership. @@ -54,11 +65,11 @@ def formula(benunit, period, parameters): household_capital * benunit_claimants_and_partners / fallback_divisor ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(JSA.capital) + person_capital = valued_person_capital( + benunit, period, parameters, JSA.capital, claimant_or_partner ) household_share = where( household_reporting_claimants > 0, claiming_proxy, fallback_proxy ) - return household_share + person_capital + # A fraction of a penny is disregarded at the last stage (reg. 92). + return round_to_penny(household_share + person_capital, JSA.capital.round_down) diff --git a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py index a842584034..cf673fbf29 100644 --- a/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py +++ b/policyengine_uk/variables/gov/dwp/lha_renter_has_non_dependant.py @@ -11,8 +11,9 @@ class lha_renter_has_non_dependant(Variable): "that counts the household's non-dependants (see " "uc_non_dependants_counted), anyone in another family who is a " "Universal Credit non-dependant of the renter " - "(is_uc_non_dependant_of_household_head), such as a lodger's adult " - "son who pays nothing. Joint tenants and other sharers of the rent, " + "(is_uc_non_dependant_of_household_head), such as the adult son, who " + "pays nothing, of a lodger whose family makes no claim of its own. " + "Joint tenants and other sharers of the rent, " "boarders and lodgers are not non-dependants, and the flag leaves " "out a boarder's or lodger's child or qualifying young person (UC " "Regs 2013 Sch 4 para 9(2)(g)). The model does not identify foster " @@ -38,8 +39,9 @@ def formula(benunit, period, parameters): within_benefit_unit = benunit.any(~claimant_or_partner & ~child_or_qyp) # UC Regs 2013 Sch 4 para 9(1)(c) and 9(2): the non-dependants of # other families, whoever in the family they are. A boarder's or - # lodger's non-paying adult son counts; their child or qualifying - # young person does not (para 9(2)(g)). + # lodger's non-paying adult son counts unless their own family's claim + # does (para 9(2)(f)); their child or qualifying young person does not + # (para 9(2)(g)). other_family_non_dependant = person( "is_uc_non_dependant_of_household_head", period ) diff --git a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py index 6daf2817c3..83ce5d8dea 100644 --- a/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py +++ b/policyengine_uk/variables/gov/dwp/lifetime_isa_countable_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import lifetime_isa_surrender_value class lifetime_isa_countable_capital(Variable): @@ -23,8 +24,8 @@ class lifetime_isa_countable_capital(Variable): "https://www.legislation.gov.uk/uksi/1998/1870/schedule", ] - def formula(person, period, parameters): - p = parameters(period).gov.hmrc.isa.lifetime - balance = person("lifetime_isa_balance", period) - charge_free = person("age", period) >= p.charge_free_age - return where(charge_free, balance, balance * (1 - p.withdrawal_charge)) + # The shared function's identity lets the means tests distinguish this + # baseline formula from a reform. They work it again in float64 before + # rounding, while preserving explicit inputs and reformed values + # (capital_valuation.valued_person_capital). + formula = lifetime_isa_surrender_value diff --git a/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py b/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py index 6b7428f74e..6ca2676bc6 100644 --- a/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py +++ b/policyengine_uk/variables/gov/dwp/num_enhanced_disabled_claimants_or_partners_for_legacy_benefits.py @@ -6,18 +6,41 @@ class num_enhanced_disabled_claimants_or_partners_for_legacy_benefits(Variable): entity = BenUnit label = "Enhanced-disabled claimants or partners for legacy benefits" documentation = ( - "Claimants and partners satisfying the model's existing " - "is_enhanced_disabled_for_benefits indicator. This count excludes " - "children and young persons, whose separate enhanced premium is " - "not modelled here." + "The model counts the claimants and partners who meet the enhanced " + "disability premium condition: they receive a qualifying benefit " + "(receives_enhanced_disability_premium_qualifying_benefit) and have " + "not attained the qualifying age for State Pension Credit. Under the " + "Housing Benefit and Jobseeker's Allowance Regulations, the Welsh " + "Council Tax Reduction scheme and England's Default Scheme, the age " + "limit applies to the claimant and to family members or partners. " + "Parliament and the Northern Ireland Assembly limit Income Support to " + "claimants under that age (SSCBA 1992 s.124(1)(aa); SSCBA (NI) 1992 " + "s.123(1)(aa), inserted by the State Pension Credit Act (Northern " + "Ireland) 2002) and Employment and Support Allowance to claimants " + "under pensionable age (WRA 2007 s.1(3)(c); WRA (NI) 2007 " + "s.1(3)(c)), so their Regulations state the age limit only for the " + "partner. The count leaves out children and young " + "persons, whose separate enhanced premium the model computes for " + "Housing Benefit only (housing_benefit_child_disability_premiums)." ) definition_period = YEAR reference = ( - "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", + "https://www.legislation.gov.uk/uksi/2008/794/schedule/4/paragraph/7", + "https://www.legislation.gov.uk/uksi/1996/207/schedule/1/paragraph/15A", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/15", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/12", ) def formula(benunit, period, parameters): - claimant_or_partner = benunit.members("is_claimant_or_partner", period) - disabled = benunit.members("is_enhanced_disabled_for_benefits", period) - return benunit.sum(claimant_or_partner & disabled) + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + qualifying = person( + "receives_enhanced_disability_premium_qualifying_benefit", period + ) + # "who has not attained the qualifying age for state pension credit" + # (HB Regs 2006 Sch 3 para 15(1)(b)-(d); IS Regs 1987 Sch 2 para + # 13A(1)(b)). + under_age = ~person("has_attained_state_pension_credit_qualifying_age", period) + return benunit.sum(claimant_or_partner & qualifying & under_age) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py index 125702ebd4..9637a2228d 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/is_qualifying_young_person_for_pension_credit.py @@ -9,12 +9,12 @@ class is_qualifying_young_person_for_pension_credit(Variable): "A person aged 16 to 19 who is either before the 1 September following " "their 16th birthday, whatever their education (reg. 4A(1)(a)), or in " "non-advanced education or approved training that meets the entry " - "condition (reg. 4A(1)(b), (2) and (3)), and who is not receiving " - "Universal Credit, Employment and Support Allowance, Jobseeker's " - "Allowance or Income Support in their own right (reg. 4A(5)). The " - "own-right flag or their own reported award shows the first three; " - "is_on_income_support shows Income Support. The model does not apply " - "reg. 4A(1)(b)'s end at the 1 September after the 19th birthday." + "condition, before the 1 September following their 19th birthday " + "(reg. 4A(1)(b), (2) and (3)), and who is not receiving Universal " + "Credit, Employment and Support Allowance, Jobseeker's Allowance or " + "Income Support in their own right (reg. 4A(5)). The own-right flag " + "or their own reported award shows the first three; " + "is_on_income_support shows Income Support." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" @@ -25,18 +25,27 @@ def formula(person, period, parameters): "is_before_first_september_following_16th_birthday", period ) # Reg. 4A(1)(b), (2) and (3). - in_education_or_training = person( - "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", - period, - ) & person( - "meets_qualifying_young_person_entry_condition_for_pension_credit", - period, + in_education_or_training = ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_pension_credit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_pension_credit", + period, + ) + & person( + "meets_qualifying_young_person_terminal_date_condition_for_pension_credit", + period, + ) ) # Reg. 4A(5) applies to both routes. Raw person-level reports identify # own awards without calculating family benefits, which depend on QYP # status and could recurse. Unlike UC reg. 5(5), it lists income # support, whose family follows the Child Benefit test and not this - # one, so is_on_income_support can read the calculated award. + # one, so is_on_income_support can read the calculated award: a + # claimant or partner receives Income Support only while the model + # pays their benefit unit an award. receives_own_benefits = ( person("receives_benefits_in_own_right", period) | (person("universal_credit_reported", period) > 0) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py new file mode 100644 index 0000000000..2a65f21606 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/meets_qualifying_young_person_terminal_date_condition_for_pension_credit.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_terminal_date_condition_for_pension_credit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person terminal date condition for Pension Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (SPC Regs 2002 reg. 4A(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_first_september_following_19th_birthday. Its default is " + "true, the value the formula gives everyone under 20 on 6 October, so " + "setting it for some people leaves everyone else under 20 where the " + "formula puts them; older people are excluded by the age limit. " + "Everyone else takes the default, not their date fact: a person whose " + "is_before_first_september_following_19th_birthday is set false " + "becomes eligible again once this variable is set for someone else. " + "So when setting both, set this one for every person whose status " + "matters." + ) + definition_period = YEAR + default_value = True + reference = "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A" + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.pension_credit.guarantee_credit.child.eligibility + age = person("age", period) + return (age < p.terminal_date_age_limit) | person( + "is_before_first_september_following_19th_birthday", period + ) diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py index 56cf7b0bca..daa584b612 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/minimum_guarantee/additional/severe_disability_minimum_guarantee_addition.py @@ -24,11 +24,17 @@ class severe_disability_minimum_guarantee_addition(Variable): "modelled: the hospital-patient deeming (para 1(2)(b)-(bd)) " "and backdating (para 1(2)(a), (c)); sight regained within 28 weeks " "(para 1(3)); polygamous marriages; and the para 2 and 3 exceptions " - "listed on is_counted_resident_for_severe_disability_addition. Whom " - "a carer benefit is paid for caring for is attributed by " - "is_cared_for_by_carer_benefit_recipient, which the legacy severe " - "disability premium shares. Blindness " - "(is_blind) is not in the survey data, so in microsimulation the " + "listed on is_counted_resident_for_severe_disability_addition. " + "is_cared_for_by_carer_benefit_recipient attributes each carer " + "benefit to the person it is paid for caring for, and the legacy " + "severe disability premium shares it. The attribution counts an award of " + "Universal Credit which includes the carer element (para 1(4)), which " + "has_uc_award_with_carer_element reads from the carer's award: a " + "carer whose award comes to nil has no award and does not bar the " + "addition. The model applies the same rule in Northern Ireland, " + "where SR 2016/236 reg 31(7) inserted the same words into SPC Regs " + "(NI) 2003 Sch 1 para 1, with the same amount parameter. " + "Blindness (is_blind) is not in the survey data, so in microsimulation the " "blind-partner route never applies and a blind resident is counted." ) entity = BenUnit @@ -40,6 +46,9 @@ class severe_disability_minimum_guarantee_addition(Variable): "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/2", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/3", "https://www.legislation.gov.uk/uksi/2002/1792/regulation/6", + "https://www.legislation.gov.uk/nisr/2003/28/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/nisr/2016/236/regulation/31", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/30", ) def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py index d949955407..97295a64ff 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit.py @@ -7,12 +7,40 @@ class pension_credit(Variable): definition_period = YEAR value_type = float unit = GBP - reference = "https://www.legislation.gov.uk/ukpga/2002/16/contents" + documentation = ( + "State Pension Credit the family receives: its entitlement where it " + "would claim (would_claim_pc), and nil where the claimant or partner " + "has income-related ESA (claimant_or_partner_esa_income above nil). " + "Parliament allows income-related ESA only where neither the claimant " + "nor the other member of the couple has an entitlement to State " + "Pension Credit (Welfare Reform Act 2007 Sch 1 para 6(1)(c) and (d)), " + "so the two awards never meet in one couple. The ESA screen " + "(esa_income_eligible) refuses the couple's award where the family " + "would get Pension Credit without it. Where the family gets Pension " + "Credit only with the award, through the higher earnings disregard " + "for a claimant or partner in receipt of ESA (SPC Regs 2002 Sch VI " + "para 4(1)(a)(vii)), the model pays the award, and this formula " + "pays no Pension Credit. A member outside the couple who claims " + "income-related ESA in their own right leaves the couple's Pension " + "Credit unchanged: in " + "para 6(1)(d) Parliament named only the other member of the " + "claimant's couple. Under gov.contrib.freeze_pension_credit, the " + "formula returns the baseline's award." + ) + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/contents", + "https://www.legislation.gov.uk/ukpga/2007/5/schedule/1/paragraph/6", + ) def formula(benunit, period, parameters): entitlement = benunit("pension_credit_entitlement", period) would_claim = benunit("would_claim_pc", period) - amount = entitlement * would_claim + # The formula pays nothing while the couple has income-related ESA, + # so that no couple gets both: Parliament allows the award only where + # neither member of the couple has an entitlement to State Pension + # Credit (WRA 2007 Sch 1 para 6(1)(c) and (d)). + couple_esa = benunit("claimant_or_partner_esa_income", period) + amount = where(couple_esa > 0, 0, entitlement * would_claim) freeze = parameters(period).gov.contrib.freeze_pension_credit baseline = benunit.simulation.baseline diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py index 1e009aa3a3..ca2f23a5c9 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_assessable_capital.py @@ -1,6 +1,11 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -10,15 +15,23 @@ class pension_credit_assessable_capital(Variable): entity = BenUnit label = "Pension Credit assessable capital" documentation = ( - "Pension Credit capital counted from the configured capital sources. The " + "Pension Credit capital counted from the configured capital sources. " + "The formula values each household source, less the ISA balances it " + "counts in their holders' own benunits, at market value less 10% where a sale would incur " + "expenses and less any debt secured on it (reg. 19), and values each " + "such ISA balance as it values that household source. " + "The " "formula allocates the household remainder only across the household's " "pension-age adults, so unrelated working-age adults leave a pensioner " "couple's share undiluted. The formula counts person-level sources, such as a " "Lifetime ISA and the cash and stocks and shares ISAs, only in the holder's " "own benunit and only for its claimant or partner (is_claimant_or_partner), " "and excludes dependants' capital. It subtracts the ISA balances from the " - "household capital before allocating the remainder. A nonnegative " - "`pension_credit_reported_capital` replaces the unit's calculated capital. The " + "household capital before allocating the remainder. An explicit nonnegative " + "pension_credit_reported_capital replaces the whole assessment. " + "Otherwise a nonnegative benunit_reported_capital replaces financial " + "household and person-level proxies, retaining separately valued " + "land and property with the same pension-age-adult allocation. The " "formula does not subtract another unit's capital report from the household " "remainder before allocating that remainder. The report and household sources " "measure different amounts." @@ -31,15 +44,16 @@ def formula(benunit, period, parameters): household = benunit.household person = benunit.members p = parameters(period).gov.dwp.pension_credit.income.capital - household_capital = sum( - household(source, period) - - pooled_person_capital(benunit, source, period, p) - for source in p.sources + household_capital = valued_capital( + lambda source: ( + household(source, period) + - pooled_person_capital(benunit, source, period, p) + ), + p, ) claimant_or_partner = person("is_claimant_or_partner", period) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(p) + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) any_pension_age = benunit.any( person("has_attained_state_pension_credit_qualifying_age", period) @@ -58,10 +72,28 @@ def formula(benunit, period, parameters): household_capital_proxy = ( household_capital * benunit_pension_age_adults / adult_divisor ) - reported_capital = benunit("pension_credit_reported_capital", period) + household_property = valued_capital( + lambda variable: household(variable, period), + p, + sources=p.property_sources, + ) + property_share = household_property * benunit_pension_age_adults / adult_divisor + # Combine in float64 before final rounding. An intermediate reported + # total stored in float32 can reduce large property values by one ULP. + financial_report = exact_amount(benunit("benunit_reported_capital", period)) + reported_capital = exact_amount( + benunit("pension_credit_reported_capital", period) + ) assessed_capital = where( reported_capital >= 0, reported_capital, - household_capital_proxy + person_capital, + where( + financial_report >= 0, + financial_report + property_share, + household_capital_proxy + person_capital, + ), + ) + # A fraction of a penny is disregarded at the last stage (reg. 24A). + return round_to_penny( + where(any_pension_age, max_(0, assessed_capital), 0), p.round_down ) - return where(any_pension_age, max_(0, assessed_capital), 0) 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 58fcbcf789..adb0e7b784 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 @@ -6,26 +6,33 @@ class pension_credit_reported_capital(Variable): entity = BenUnit label = "reported Pension Credit capital" documentation = ( - "Countable capital of the claimant and partner as recorded for this " - "benefit unit. Pension Credit counts the claimant's capital, and the " - "State Pension Credit Act 2002 s. 5 treats the partner's as the " - "claimant's, which a benefit-unit capital record covers, so this is " - "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 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 " - "benefit unit that records it. Any negative value, including -1 when " - "nothing is recorded, means the household proxy applies. 0 records no " - "capital." + "Explicit complete countable-capital override for the claimant and " + "partner together. A nonnegative value, including zero, replaces " + "household and person-level capital sources. It must already reflect " + "the regulation 19 valuation and Schedule V disregards. The default " + "-1 means no complete override. In that case a nonnegative " + "benunit_reported_capital replaces only financial proxies, while " + "separately valued land and property remain counted; with neither " + "report the household proxy and person-level sources apply. This " + "variable does not derive from the financial-only observation. A " + "value that a user sets in a situation applies only in the years the " + "user sets it for. The model uprates a value that a dataset stores in " + "this column into each later year it projects the dataset to, and the " + "formula returns -1 after the last of those years. " + "If a dataset stores a financial-only survey total, such as the " + "Family Resources Survey's TOTCAPB4, in this column rather than in " + "benunit_reported_capital, the pension_credit_assessable_capital " + "formula treats that total as a complete override and counts none of " + "the household's land or property." ) definition_period = YEAR unit = GBP quantity_type = STOCK default_value = -1 - reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/5" + reference = ( + "https://www.legislation.gov.uk/ukpga/2002/16/section/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/19", + ) def formula(benunit, period, parameters): - return benunit("benunit_reported_capital", period) + return -1 diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py index 8ac7b852c4..44d239318a 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/pension_credit_self_employment_deductions.py @@ -42,9 +42,14 @@ def formula(person, period, parameters): 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. + # Chapter 2 (personal and blind person's allowances) relieves income. + # Chapter 3 (married couple's allowance) reduces the tax, and so does + # Chapter 3A: the Marriage Allowance gives a reduction of the basic + # rate, or the Scottish basic rate, on the transferable amount + # (ITA 2007 s. 55B(1) and (3)). At this single notional rate, with + # both floored at zero, deducting the amount from the profit gives the + # same tax as that reduction: rate * max(0, profit - reliefs - amount) + # equals max(0, rate * max(0, profit - reliefs) - rate * amount). reliefs = add( person, period, diff --git a/policyengine_uk/variables/gov/dwp/pip/has_enhanced_pip_dl_award.py b/policyengine_uk/variables/gov/dwp/pip/has_enhanced_pip_dl_award.py new file mode 100644 index 0000000000..268c96c37d --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/pip/has_enhanced_pip_dl_award.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class has_enhanced_pip_dl_award(Variable): + label = "Has an award of the PIP daily living component at the enhanced rate" + documentation = ( + "The model counts an award of the daily living component of personal " + "independence payment at the enhanced rate when the person has a " + "positive amount (pip_dl) and either the enhanced-rate category " + "(pip_dl_category) or an amount that reaches 52 × (enhanced weekly " + "rate − weekly tolerance), with the tolerance in " + "gov.dwp.disability_premia.top_rate_amount_tolerance. It rejects an amount of zero, including one that " + "a reform sets by neutralising pip_dl. Claimants also qualify when the " + "hospital in-patient rules end payment of the award (HB Regs 2006 Sch " + "3 para 15(1)(c)); the model has no input for that." + ) + entity = Person + definition_period = YEAR + value_type = bool + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp + amount = person("pip_dl", period) + threshold = ( + p.pip.daily_living.enhanced - p.disability_premia.top_rate_amount_tolerance + ) * WEEKS_IN_YEAR + enhanced_rate = person("receives_enhanced_pip_dl", period) | ( + amount >= threshold + ) + return (amount > 0) & enhanced_rate diff --git a/policyengine_uk/variables/gov/dwp/rent_a_room_income_tax_without_earnings.py b/policyengine_uk/variables/gov/dwp/rent_a_room_income_tax_without_earnings.py index 2ac54725be..946cd7419a 100644 --- a/policyengine_uk/variables/gov/dwp/rent_a_room_income_tax_without_earnings.py +++ b/policyengine_uk/variables/gov/dwp/rent_a_room_income_tax_without_earnings.py @@ -13,15 +13,16 @@ class rent_a_room_income_tax_without_earnings(Variable): documentation = ( "rent_a_room_income_tax_after_reductions if nobody in the simulation " "had employed earnings (employment income and statutory sick, " - "maternity and paternity pay) or self-employment income. Where a " - "council tax reduction scheme does not count rent from boarders, " - "lodgers and sub-tenants in the home (the Universal Credit route, and " - "the Scottish working-age scheme while it does not list that income), " - "council_tax_reduction_applicable_income treats the rent as uncounted " - "income, as legacy_means_test_uncounted_income_tax_without_earnings " - "treats income from capital: it leaves out of the deducted tax the " - "tax this rent would bear without earnings, and keeps the extra tax " - "that earnings cause on it. " + DECISION + "maternity and paternity pay) or self-employment income. Where an " + "award of Universal Credit keeps rent from boarders, lodgers and " + "sub-tenants in the home out of a council tax reduction scheme's " + "income, council_tax_reduction_tax_deduction treats the rent as " + "uncounted income, as legacy_means_test_uncounted_income_tax_without_" + "earnings treats income from capital: it leaves out of the deducted " + "tax the tax this rent would bear without earnings, and keeps the " + "extra tax that earnings cause on it. Scotland's working-age scheme " + "deducts tax from earnings only, which leaves that tax out without " + "this variable. " + DECISION ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/state_pension_reported.py b/policyengine_uk/variables/gov/dwp/state_pension_reported.py index 3fdf232e62..c9b9974073 100644 --- a/policyengine_uk/variables/gov/dwp/state_pension_reported.py +++ b/policyengine_uk/variables/gov/dwp/state_pension_reported.py @@ -5,6 +5,17 @@ class state_pension_reported(Variable): value_type = float entity = Person label = "Reported income from the State Pension" + documentation = ( + "The State Pension a survey reports, as an annual amount: the weekly " + "amount times 52. basic_state_pension, new_state_pension and " + "additional_state_pension take it less any age addition " + "(state_pension_reported_excluding_age_addition), divide it by 52 and " + "split the weekly amount at the data year's full rate for the type the " + "person has in the simulated year, so a dataset must annualise weekly " + "amounts at 52 weeks. At 365.25 / 7, a full-rate pensioner reads 0.34% " + "above the full rate and gets the excess as additional State Pension or a " + "protected payment." + ) definition_period = YEAR unit = GBP 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 a0d689c3c7..5b0f20425c 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 @@ -134,7 +134,12 @@ def received_by(benefits, members): receiving_income_related_esa = benunit.project( couple_esa_award > 0 ) & is_payee_of_couple_award( - person, period, "esa_income_reported", couple=couple + person, + period, + "esa_income_reported", + # WRA 2007 s.1(3)(c): not a member who has reached pensionable age. + can_claim=~person("is_SP_age", period), + couple=couple, ) receiving_esa = (person("esa_contrib", period) > 0) | ( receiving_income_related_esa diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction.py index 776169744f..66018d9d88 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_reduction.py @@ -32,11 +32,10 @@ class uc_benefit_cap_reduction(Variable): def formula(benunit, period, parameters): # Reg 80(2A): where the welfare benefit is Housing Benefit, "the # amount to be used is nil". benefit_cap_welfare_benefits counts it - # for the Housing Benefit cap (HB Regs 2006 reg 75C(2)(b)), so it is - # taken out here; a family paid both is capped on its other benefits. - welfare_benefits = benunit("benefit_cap_welfare_benefits", period) - benunit( - "housing_benefit_pre_benefit_cap", period - ) + # for the Housing Benefit cap (HB Regs 2006 reg 75C(2)(b)); + # uc_benefit_cap_welfare_benefits leaves it out, so Universal Credit + # caps a family paid both on its other benefits. + welfare_benefits = benunit("uc_benefit_cap_welfare_benefits", period) excess = max_(welfare_benefits - benunit("uc_benefit_cap", period), 0) reduction = max_(excess - benunit("uc_childcare_element", period), 0) has_award = benunit("universal_credit_pre_benefit_cap", period) > 0 diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_welfare_benefits.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_welfare_benefits.py new file mode 100644 index 0000000000..d373a2387c --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_welfare_benefits.py @@ -0,0 +1,104 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.benefit_cap import benefit_cap_couple +from policyengine_uk.utils.benefit_unit import award_of_couple +from policyengine_uk.utils.supplied_inputs import supplied_input +from policyengine_uk.variables.gov.dwp.benefit_cap_welfare_benefits import ( + CLAIMANT_OR_PARTNER_BENEFITS, + FAMILY_BENEFITS, +) +from policyengine_uk.variables.gov.dwp.esa_income import ( + income_related_esa_award_on_reports, +) +from policyengine_uk.variables.gov.dwp.jsa_income import ( + income_related_jsa_award_on_reports, +) + +# UC Regs 2013 reg 80(2A): where the welfare benefit is Housing Benefit, "the +# amount to be used is nil". The Universal Credit cap therefore counts the +# Housing Benefit cap's benefits other than Housing Benefit. +UC_FAMILY_BENEFITS = [ + benefit + for benefit in FAMILY_BENEFITS + if benefit != "housing_benefit_pre_benefit_cap" +] + + +def supplied_total_without_housing_benefit(period) -> str: + return ( + f"The inputs supply benefit_cap_welfare_benefits for {period}, a total " + "that includes Housing Benefit, which DWP counts as nil for the " + "Universal Credit cap (UC Regs 2013 reg. 80(2A)). Supply " + "housing_benefit_pre_benefit_cap for the same period, for the model to " + "subtract, or supply uc_benefit_cap_welfare_benefits, with a value for " + "every benefit unit: a situation that sets either for some benefit " + "units stores nil for the rest. The model doesn't calculate that " + "Housing Benefit for Universal Credit, because the Housing Benefit " + "non-dependant deductions read universal_credit. A reform that " + "neutralizes housing_benefit_pre_benefit_cap drops a supplied value; " + "supply uc_benefit_cap_welfare_benefits instead." + ) + + +class uc_benefit_cap_welfare_benefits(Variable): + value_type = float + entity = BenUnit + label = "Welfare benefits counted by the Universal Credit benefit cap" + documentation = ( + "The welfare benefits the Universal Credit cap counts: those in " + "benefit_cap_welfare_benefits, for the same single person or couple, " + "with Housing Benefit at nil (UC Regs 2013 reg. 80(2A)). Where the " + "inputs supply benefit_cap_welfare_benefits, whose total includes " + "Housing Benefit, this subtracts the supplied " + "housing_benefit_pre_benefit_cap. Inputs that supply the combined " + "total also supply housing_benefit_pre_benefit_cap or this variable, " + "with a value for every benefit unit; otherwise the formula raises " + "ValueError. No baseline formula on " + "Universal Credit's route calculates Housing Benefit, so the Housing " + "Benefit non-dependant deductions can read universal_credit." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/2012/5/section/96", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/79", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/80", + ) + + def formula(benunit, period, parameters): + # A supplied combined total includes Housing Benefit, and the inputs + # supply that Housing Benefit too. Calculating it here would make + # Universal Credit depend on Housing Benefit, whose non-dependant + # deductions read universal_credit. A value a scenario stores as the + # model's own (set_model_value) supplies neither. + if supplied_input(benunit, "benefit_cap_welfare_benefits", period) is not None: + if ( + supplied_input(benunit, "housing_benefit_pre_benefit_cap", period) + is None + ): + raise ValueError(supplied_total_without_housing_benefit(period)) + return benunit("benefit_cap_welfare_benefits", period) - benunit( + "housing_benefit_pre_benefit_cap", period + ) + # The rest follows benefit_cap_welfare_benefits, in the same order. + couple = benefit_cap_couple(benunit, period) + income_based = award_of_couple( + benunit, + period, + "jsa_income", + "jsa_income_reported", + income_related_jsa_award_on_reports, + couple, + ) + award_of_couple( + benunit, + period, + "esa_income", + "esa_income_reported", + income_related_esa_award_on_reports, + couple, + ) + return income_based + add_for_members( + benunit, + period, + CLAIMANT_OR_PARTNER_BENEFITS + UC_FAMILY_BENEFITS, + couple, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py new file mode 100644 index 0000000000..1718319e90 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/carer_element/has_uc_award_with_carer_element.py @@ -0,0 +1,93 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.supplied_inputs import stored_as_input + + +def award_reads_no_other_variable(simulation, period): + """Whether reading universal_credit for this period reads no other + variable: a reform neutralized it, or an input or a scenario stored its + value for the period.""" + variable = simulation.tax_benefit_system.get_variable("universal_credit") + return variable.is_neutralized or stored_as_input( + simulation.populations["benunit"], "universal_credit", period + ) + + +class has_uc_award_with_carer_element(Variable): + value_type = bool + entity = Person + label = "has a Universal Credit award with the carer element" + documentation = ( + "Whether this person has an award of Universal Credit which includes " + "the carer element (Universal Credit Regulations 2013 reg 29) in " + "respect of them: the carer condition of the legacy severe disability " + "premium (HB Regs 2006 Sch 3 para 14(2)(a)(iii) and (b)) and of the " + "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " + "1(1) and (4)), through is_cared_for_by_carer_benefit_recipient. " + "An award needs an amount payable of at least one penny (Welfare " + "Reform Act 2012 s.5(1)(b) and (2)(b); UC Regs 2013 reg 17), so the " + "model counts a claimant in respect of whom the award includes the " + "carer element (uc_carer_element_included_for_claimant) only where " + "the family's award before the benefit cap " + "(universal_credit_pre_benefit_cap) comes to more than nil. That " + "award comes to nil where income tapers it to nil, after any " + "non-dependant deduction and with the work allowance that the " + "housing costs element sets, and where the family takes Tax-Free " + "Childcare instead (forgoes_universal_credit_for_tax_free_childcare). " + "The model reads the award before the cap, not universal_credit, " + "because the benefit cap reads Child Tax Credit, whose income reads " + "Pension Credit, whose addition reads this variable. The two agree " + "for an award that includes the carer element: DWP exempts it from " + "the cap (UC Regs 2013 reg 83(1)(j)), and debt deductions leave at " + "least one penny payable. The housing costs element reads the " + "non-dependant deduction. Its exemption for a non-dependant family " + "on Pension Credit (uc_non_dep_receives_pension_credit) calculates " + "that family's Pension Credit in a branch with every housing costs " + "element set to nil, and in that branch this variable reads the " + "award without housing costs. The branch decides only that " + "exemption. A member of the non-dependant family gains or loses it " + "through this variable only where the family gets Pension Credit " + "with the severe disability addition and none without it, and the " + "member has no other exemption: a blind partner under SPC Sch I para " + "1(1)(c) who receives no qualifying benefit. There the model reads " + "that member's exemption from the award without housing costs. For " + "the family to get the addition, DWP must ignore the renter as a " + "resident (SPC Sch I para 2(2)); a single renter whom DWP ignores as " + "blind or for a qualifying benefit gets UC Regs 2013 Sch 4 para 15's " + "exemption from every deduction (uc_non_dep_deductions_renter_exempt), " + "so there the member's exemption changes no deduction. " + "Where an input or a scenario stores universal_credit for " + "the period, or a reform neutralizes it, reading it cannot cycle, so " + "this reads the award itself (receives_uc_carer_element). The model " + "applies the same rule in Northern Ireland, whose legislation uses " + "the same words: Welfare Reform (Northern Ireland) Order 2015 art. " + "10, UC Regs (NI) 2016 regs 17 and 30, SPC Regs (NI) 2003 Sch 1 para " + "1 as SR 2016/236 reg 31(7) amended it, and HB Regs (NI) 2006 Sch 4 " + "para 14(2)(a)(iii)." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/14", + "https://www.legislation.gov.uk/uksi/2002/1792/schedule/I/paragraph/1", + "https://www.legislation.gov.uk/nisi/2015/2006/article/10", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/17", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/30", + "https://www.legislation.gov.uk/nisr/2016/216/schedule/4/paragraph/15", + "https://www.legislation.gov.uk/nisr/2003/28/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/nisr/2016/236/regulation/31", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/14", + ) + + def formula(person, period, parameters): + if award_reads_no_other_variable(person.simulation, period): + return person("receives_uc_carer_element", period) + benunit = person.benunit + # The award before the cap: DWP exempts a carer-element award from + # the cap, and universal_credit's cap reads Pension Credit. + award = benunit("universal_credit_pre_benefit_cap", period) + includes_carer_element = benunit("uc_carer_element", period) > 0 + included = person("uc_carer_element_included_for_claimant", period) + return included & includes_carer_element & (award > 0) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py index b9f6e4a784..0c79bf170d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_before_universal_credit_qualifying_young_person_terminal_date.py @@ -6,11 +6,28 @@ class is_before_universal_credit_qualifying_young_person_terminal_date(Variable) entity = Person label = "Before the Universal Credit qualifying young person terminal date" documentation = ( - "Whether this person is before the Universal Credit terminal date " - "for a 19-year-old qualifying young person. This captures date-of-birth " - "and assessment-period detail that is not otherwise available in the " - "annual model." + "Whether this person is before the Universal Credit terminal date for " + "a 19-year-old qualifying young person: the 1 September following " + "their 19th birthday (UC Regs 2013 reg. 5(1)(b)). It follows the date " + "of birth (is_before_first_september_following_19th_birthday), so a " + "year counts if it starts before that date. Datasets and situations " + "can set it directly: set it to false for a 19-year-old whose 1 " + "September has passed. Its default is true, the value the date fact " + "gives everyone under 20 on 6 October, so setting it for some people " + "leaves everyone else under 20 where the date fact puts them; older " + "people are excluded by the age limit before it is read. Everyone " + "else takes the default, not their date fact: a person whose " + "is_before_first_september_following_19th_birthday is set false " + "becomes eligible again once this variable is set for someone else. " + "So when setting both, set this one for every person whose status " + "matters." ) definition_period = YEAR - default_value = False - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + default_value = True + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) + + def formula(person, period, parameters): + return person("is_before_first_september_following_19th_birthday", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py index 4281403300..b770f9000e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_for_universal_credit.py @@ -6,55 +6,249 @@ class is_qualifying_young_person_for_universal_credit(Variable): entity = Person label = "Qualifying young person for Universal Credit" documentation = ( - "A person aged 16 to 19 who is either before the 1 September following " - "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " - "non-advanced education or approved training that meets the entry and " - "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who is not " - "receiving Universal Credit, Employment and Support Allowance or " - "Jobseeker's Allowance (reg. 5(5))." + "A qualifying young person under UC Regs 2013 reg. 5 " + "(is_qualifying_young_person_under_universal_credit_regulation_5): a " + "person aged 16 to 19 who meets reg. 5(1)(a) or (b) and receives no " + "Universal Credit, contributory ESA or contribution-based JSA (reg. " + "5(5)) and no existing benefit: income-based JSA, income-related ESA, " + "Income Support, Housing Benefit, Child Tax Credit or Working Tax " + "Credit (SI 2014/1230 regs. 2(1) and 28; SR 2016/226 regs. 2(1) and " + "29). The Secretary of State ties the exclusion to entitlement (reg. " + "28), so the formula reads a report only in a year the model pays that " + "benefit, only until the model's legacy closure, or for a claimant or " + "partner the family's Universal Credit claim, ends the award on it, and" + " only if no reform neutralises it. It doesn't apply the model's other " + "conditions of payment, so a report can exclude a person even where the" + " model pays nothing on that report. For example, the model pays Income" + " Support only on the claimant's and partner's reports, Housing Benefit" + " only to a family that rents, and income-based JSA and income-related " + "ESA only after their capital and work screens. The model pays Income " + "Support, income-based JSA and tax credits only while " + "gov.dwp.income_support.active, gov.dwp.JSA.income.active and " + "gov.dwp.tax_credits.active hold, has no closure date for Housing " + "Benefit, and pays an income-related ESA claim only while it stays " + "payable (esa_income_payable_share), so the formula reads its report " + "only then. The formula also reads the Income Support, income-based " + "JSA and tax credit reports while " + "gov.contrib.policyengine.disable_simulated_benefits is on. The reform " + "that switch turns on pays them at their reported amounts in the " + "dataset year and the nine years after it, and the formula still reads " + "them in later years. In those years the reform also pays a claimant's " + "or partner's own income-related ESA, Housing Benefit and Child Tax " + "Credit report whether or not the family claims Universal Credit, so " + "while the switch is on the formula leaves out the Universal Credit " + "claim limbs below. It leaves them out in later years too, when the " + "model again ends those awards on the family's claim. The model also ends" + " a person's legacy awards once its annual migration proxy closes them:" + " it ends their Income Support, income-based JSA and income-related ESA" + " when person_legacy_benefits_closed holds, the family's tax credits " + "when legacy_benefits_closed holds, and its Housing Benefit when " + "legacy_benefits_closed holds and the family doesn't meet the Pension " + "Credit age conditions. The formula follows both closures. A claim for " + "Universal Credit ends the claimant's and partner's own awards (SI " + "2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4; in Northern Ireland, " + "SR 2016/226 reg. 6(2) and (2A) and SR 2017/190 art. 6), and the model " + "ends the family's continuing Housing Benefit award " + "(housing_benefit_eligible) and its Child Tax Credit (is_CTC_eligible)," + " and the claimant's and partner's income-related ESA " + "(esa_income_report_closed), when the family claims " + "(claims_universal_credit). For a claimant or partner, the formula " + "follows those limbs too. The family's claim doesn't end a dependant's " + "own award, so for a dependant the formula doesn't. The model ends no " + "Working Tax Credit, Income Support or income-based JSA award on the " + "claim, so the formula follows no claim limb for them. It can't read " + "housing_benefit_eligible: that variable's Pension Credit route reads " + "left_pension_route_at_legacy_closure, which reads this variable " + "through is_uc_entitled and the child element. So the formula keeps a " + "Housing Benefit report while the family meets the Pension Credit age " + "conditions. It also keeps the report while the family lives in " + "specified or temporary accommodation " + "(in_specified_or_temporary_accommodation), since neither the claim nor " + "the closure ends that award (SI 2014/1230 regs. 8(3) and 46(2); SR " + "2016/226 regs. 6(3) and 47(2)). The formula checks each report against " + "its own variable: " + "income_support, jsa_income, esa_income, housing_benefit, " + "child_tax_credit or tax_credits for Child Tax Credit, and " + "working_tax_credit or tax_credits for Working Tax Credit. A reform " + "that changes or zeroes a benefit's formula without neutralising its " + "variable leaves the exclusion on that benefit's reports in place. For " + "a claimant or partner it also reads the couple's joint tax credit " + "award (Tax Credits Act 2002 s.3(3)), each credit by its own check." ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/5" + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/46", + "https://www.legislation.gov.uk/uksi/2013/983/article/4", + "https://www.legislation.gov.uk/uksi/2019/1152/regulation/5", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/2", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/47", + "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + ] def formula(person, period, parameters): - # Reg. 5(1)(a): no education or training condition. - before_first_september_following_16th_birthday = person( - "is_before_first_september_following_16th_birthday", period - ) - # Reg. 5(1)(b), (2) and (3). - in_education_or_training = ( - person( - "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_universal_credit", - period, - ) - & person( - "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", - period, - ) + return qualifying_young_person(person, period, parameters) + + +def qualifying_young_person(person, period, parameters, at_claim_start=False): + """The UC qualifying young person test with SI 2014/1230 reg. 28 + applied, as is_qualifying_young_person_for_universal_credit documents + it. With at_claim_start it gives a claimant's or partner's status on the + first day of their Universal Credit award + (is_qualifying_young_person_for_uc_education reads it). A Housing + Benefit or income-related ESA award the claim ends still runs that day + (reg. 8(2A); esa_income_report_closed's run-on), so the formula leaves + out those two claim limbs. The claim has already ended a tax credit + award (reg. 8(2)(a)), so the formula keeps the Child Tax Credit limb. + In Northern Ireland the claim has also already ended a rate rebate (SR + 2016/226 reg. 6(2)), and only a rent rebate or allowance runs on (reg. + 6(2A)), so there the formula keeps the Housing Benefit limb for a report + of a rate rebate alone (housing_benefit_report_is_rate_rebate_only). The + year gates and the legacy closures apply either way.""" + # The Secretary of State excludes a person entitled to an existing + # benefit (TP regs. 2(1) and 28). The formula reads raw person-level + # reports, the model's legacy closure proxy and the family's Universal + # Credit claim, so it calculates no family benefit that depends on QYP + # status. + p = parameters(period).gov + dwp = p.dwp + # The reform pays each listed benefit at its dataset-year report in + # that year and the nine after it, closure dates included, and holds + # both legacy closures false in those years. The formula reads the + # switch, not those years, so in a later year it still reads a tax + # credit report that the model no longer pays, and still leaves out + # the claim limbs below. + paid_as_reported = bool(p.contrib.policyengine.disable_simulated_benefits) + system = person.simulation.tax_benefit_system + + def paying(active, *names): + # Nobody is entitled to a benefit the model no longer pays, or one + # a reform neutralises. + return bool(active or paid_as_reported) and not any( + system.get_variable(name).is_neutralized for name in names ) - # The exclusions apply to both routes (UC reg. 5(5); SPC reg. 4A(5)). - # Raw person-level reports identify own awards without calculating - # family benefits, which depend on QYP status and could recurse. - receives_own_benefits = ( - person("receives_benefits_in_own_right", period) - | (person("universal_credit_reported", period) > 0) - | (person("esa_income_reported", period) > 0) - | (person("esa_contrib_reported", period) > 0) - | (person("jsa_income_reported", period) > 0) - | (person("jsa_contrib_reported", period) > 0) + + # The model ends a person's Income Support, income-based JSA and + # income-related ESA when their legacy awards close: the claimant's + # and partner's together (legacy_benefits_closed, read through + # income_support), and anyone else's on their own reports + # (jsa_income, esa_income). person_legacy_benefits_closed and + # legacy_benefits_closed read reports, is_claimant_or_partner, the + # Pension Credit age conditions and parameters, and no Universal + # Credit variable. + own_award_open = ~person("person_legacy_benefits_closed", period) + # A claim for Universal Credit ends the claimant's and partner's own + # awards (SI 2014/1230 reg. 8(2) and (2A); SI 2013/983 art. 4; in + # Northern Ireland, SR 2016/226 reg. 6(2) and (2A) and SR 2017/190 art. + # 6), and nobody else's. The model ends the claimant's and partner's + # income-related ESA when the family claims (esa_income_report_closed), + # and the formula follows it. claims_universal_credit reads + # would_claim_uc, would_claim_uc_at_legacy_closure and + # legacy_benefits_closed, not this variable. In its years the reform + # behind disable_simulated_benefits pays each report as reported + # whether or not the family claims, and holds the legacy closures + # false, so while the switch is on the formula keeps the person's own + # closure alone. + claimant_or_partner = person("is_claimant_or_partner", period) + # The Secretary of State (SI 2025/1148 art. 3A) and, in Northern + # Ireland, the Department for Communities (SR 2025/176 art. 3A) end old + # style income-related ESA claims on the appointed date unless an + # appointee saving keeps them (esa_income_payable_share). + esa_income_open = ( + ~person("esa_income_report_closed", period) + | (own_award_open & (paid_as_reported or at_claim_start)) + ) & ((person("esa_income_payable_share", period) > 0) | paid_as_reported) + claim_ends_own_award = ( + claimant_or_partner + & person.benunit("claims_universal_credit", period) + & (not paid_as_reported) + ) + # On the first day of the award a Housing Benefit award the claim ends + # still runs (reg. 8(2A)). In Northern Ireland only a rent rebate or + # allowance still runs (SR 2016/226 reg. 6(2A)): the claim has already + # ended a rate rebate (reg. 6(2)). The report doesn't show the form, so + # the formula reads housing_benefit_report_is_rate_rebate_only. + if at_claim_start: + country = person.household("country", period) + claim_has_ended_housing_benefit = ( + claim_ends_own_award + & (country == country.possible_values.NORTHERN_IRELAND) + & person("housing_benefit_report_is_rate_rebate_only", period) ) - return ( - person( - "meets_qualifying_young_person_age_condition_for_universal_credit", - period, - ) - & ( - before_first_september_following_16th_birthday - | in_education_or_training - ) - & ~receives_own_benefits + else: + claim_has_ended_housing_benefit = claim_ends_own_award + # The model pays Housing Benefit and tax credits to the family, on + # every member's reports, and ends them when the claimant's and + # partner's legacy awards close (housing_benefit_eligible, + # is_CTC_eligible, is_WTC_eligible). It also ends the family's + # continuing Housing Benefit award and its Child Tax Credit when the + # family claims Universal Credit, and the formula follows that limb + # for a claimant or partner, whose own award the claim ends. A family + # that meets the Pension Credit age conditions keeps Housing Benefit + # unless it claims Universal Credit at the closure and is entitled to + # it (left_pension_route_at_legacy_closure). That variable reads this + # one through is_uc_entitled and the child element, so the formula + # keeps the Housing Benefit report for such a family. Neither the claim nor + # the closure ends an award for specified or temporary accommodation (SI + # 2014/1230 regs. 8(3) and 46(2); SR 2016/226 regs. 6(3) and 47(2)), so the + # formula keeps that report too, as housing_benefit_eligible keeps the award. + family_closed = person.benunit("legacy_benefits_closed", period) + family_award_open = ~family_closed + housing_benefit_open = ( + person.benunit("meets_pension_credit_age_conditions", period) + | person.benunit("in_specified_or_temporary_accommodation", period) + | (~family_closed & ~claim_has_ended_housing_benefit) + ) + + income_support_paying = ( + paying(dwp.income_support.active, "income_support") & own_award_open + ) + jsa_income_paying = paying(dwp.JSA.income.active, "jsa_income") & own_award_open + esa_income_paying = paying(True, "esa_income") & esa_income_open + housing_benefit_paying = paying(True, "housing_benefit") & housing_benefit_open + child_tax_credit_paying = ( + paying(dwp.tax_credits.active, "child_tax_credit", "tax_credits") + & family_award_open + & ~claim_ends_own_award + ) + working_tax_credit_paying = ( + paying(dwp.tax_credits.active, "working_tax_credit", "tax_credits") + & family_award_open + ) + tax_credits_reported = child_tax_credit_paying * person( + "child_tax_credit_reported", period + ) + working_tax_credit_paying * person("working_tax_credit_reported", period) + # Tax Credits Act 2002 s.3(3): the members of a couple claim jointly, + # so the couple's award belongs to the claimant and the partner. A + # dependant claims as a single person, so their award stays theirs. + couple_tax_credits_reported = person.benunit.sum( + where(claimant_or_partner, tax_credits_reported, 0) + ) + receives_tax_credits = (tax_credits_reported > 0) | ( + claimant_or_partner & (couple_tax_credits_reported > 0) + ) + entitled_to_existing_benefit = ( + (esa_income_paying & (person("esa_income_reported", period) > 0)) + | (jsa_income_paying & (person("jsa_income_reported", period) > 0)) + | (income_support_paying & (person("income_support_reported", period) > 0)) + | (housing_benefit_paying & (person("housing_benefit_reported", period) > 0)) + | receives_tax_credits + ) + return ( + person( + "is_qualifying_young_person_under_universal_credit_regulation_5", + period, ) + & ~entitled_to_existing_benefit + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py new file mode 100644 index 0000000000..882f7cf52a --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/is_qualifying_young_person_under_universal_credit_regulation_5.py @@ -0,0 +1,71 @@ +from policyengine_uk.model_api import * + + +class is_qualifying_young_person_under_universal_credit_regulation_5(Variable): + value_type = bool + entity = Person + label = "Qualifying young person under Universal Credit Regulations reg. 5" + documentation = ( + "A person aged 16 to 19 who is either before the 1 September following " + "their 16th birthday, whatever their education (reg. 5(1)(a)), or in " + "non-advanced education or approved training that meets the entry and " + "terminal-date conditions (reg. 5(1)(b), (2) and (3)), and who receives " + "no Universal Credit, contributory ESA or contribution-based JSA (reg. " + "5(5)). The Secretary of State defines employment and support " + "allowance and jobseeker's allowance without their income-related and " + "income-based forms (UC Regs 2013 reg. 2), so this test leaves those to " + "SI 2014/1230 reg. 28, which is_qualifying_young_person_for_universal_credit " + "applies. Northern Ireland's twin is reg. 6 of the Universal Credit " + "Regulations (Northern Ireland) 2016 (SR 2016/216). The Scottish " + "Ministers define the young person for Scotland's working-age Council " + "Tax Reduction by this regulation (SSI 2021/249 reg. 6(1))." + ) + definition_period = YEAR + reference = [ + "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", + ] + + def formula(person, period, parameters): + # Reg. 5(1)(a): no education or training condition. + before_first_september_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5(1)(b), (2) and (3). + in_education_or_training = ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_universal_credit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_universal_credit", + period, + ) + & person( + "meets_qualifying_young_person_terminal_date_condition_for_universal_credit", + period, + ) + ) + # The Secretary of State excludes a person receiving Universal Credit, + # contributory ESA or contribution-based JSA (reg. 5(5)), on both + # routes. The formula reads raw person-level reports, so it calculates + # no family benefit that depends on qualifying young person status. + receives_reg_5_5_benefit = ( + person("receives_benefits_in_own_right", period) + | (person("universal_credit_reported", period) > 0) + | (person("esa_contrib_reported", period) > 0) + | (person("jsa_contrib_reported", period) > 0) + ) + return ( + person( + "meets_qualifying_young_person_age_condition_for_universal_credit", + period, + ) + & ( + before_first_september_following_16th_birthday + | in_education_or_training + ) + & ~receives_reg_5_5_benefit + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py index 73837e7b14..9ab4e6a4f5 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/child_element/meets_qualifying_young_person_terminal_date_condition_for_universal_credit.py @@ -7,8 +7,19 @@ class meets_qualifying_young_person_terminal_date_condition_for_universal_credit value_type = bool entity = Person label = "Meets qualifying young person terminal date condition for Universal Credit" + documentation = ( + "A young person in education or training is a qualifying young person " + "up to, but not including, the 1 September following their 19th " + "birthday (UC Regs 2013 reg. 5(1)(b)). Under 19 that date is always " + "ahead; at 19 it is read from " + "is_before_universal_credit_qualifying_young_person_terminal_date, " + "which follows the date of birth unless set." + ) definition_period = YEAR - reference = "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.gov.uk/government/publications/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders/universal-credit-and-families-with-more-than-2-children-information-for-stakeholders", + ) def formula(person, period, parameters): p = parameters(period).gov.dwp.universal_credit.elements.child.eligibility diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py index d44a0cfef2..b73e503b6e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/childcare_element/uc_unable_to_provide_childcare.py @@ -21,10 +21,12 @@ class uc_unable_to_provide_childcare(Variable): "Limb (ii), defined in regulation 30, reads " "is_carer_for_benefits: receipt of Carer's Allowance or Carer Support " "Payment, or at least the Carer's Allowance qualifying hours of care " - "a week. Its hours branch does not check that the person cared for " - "is severely disabled (a Carer's Allowance condition, SSCBA 1992 " - "s. 70(1)-(2), that regulation 30(1)(a) imports) or exclude a carer " - "paid for the caring (regulation 30(3)). Limb (iii) is the input " + "a week. Its hours branch checks that the person cared for is " + "severely disabled (a Carer's Allowance condition, SSCBA 1992 " + "s. 70(1)-(2), that regulation 30(1)(a) imports) only through the " + "input cares_for_severely_disabled_person, which defaults to true, " + "and does not exclude a carer paid for the caring (regulation " + "30(3)). Limb (iii) is the input " "uc_is_temporarily_absent_from_claimant_household." ) definition_period = YEAR diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py index 541e637805..006a5f46b7 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/company/uc_company_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, round_to_penny class uc_company_capital(Variable): @@ -47,5 +48,13 @@ def formula(person, period, parameters): person("owned_company_trade_assets", period), 0, ) - capital = max_(0, person("owned_company_capital", period) - disregarded) - return where(applies, capital, 0) + # Worked in float64 from the stored amounts and rounded to the nearest + # penny (UC Regs 2013 reg. 6(1)): a float32 difference of two large + # amounts can be most of a penny out. + capital = max_( + 0, + exact_amount(person("owned_company_capital", period)) + - exact_amount(disregarded), + ) + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return where(applies, round_to_penny(capital, p.round_down), 0) 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 fe96f39a8f..24f7db19eb 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,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, +) from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( HOLDINGS_SOURCE, ) @@ -26,8 +31,10 @@ class household_uc_pool_company_holdings(Variable): "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 " + "any part of a disregarded holding. A financial report below its " + "holder's known holding still consumes the holding at its counted " + "value (household_uc_reported_financial_capital), so no report leaves " + "a holding in the residual pool. A holding " "is shares, held within the household's share_like_capital. Each " "person's holding is first clipped to [0, max(0, share_like_capital)], " "with positive infinity mapped to the upper bound and negative infinity " @@ -39,7 +46,9 @@ class household_uc_pool_company_holdings(Variable): "rule for inconsistent inputs, not a statutory determination. The " "shared budget prevents holdings from removing ordinary savings or " "property. It does not change regulation 77 substitution in the " - "owner's own assessment." + "owner's own assessment. The holdings leave the pool at the value at " + "which the pool counts share_like_capital: 90% where regulation 49(1)(a) " + "deducts 10% for the expenses of selling shares." ) definition_period = YEAR unit = GBP @@ -54,8 +63,8 @@ def formula(household, period, parameters): off_reported_capital = ( has_financial_report | has_complete_override ) & claimant_or_partner - holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) + holding = exact_amount(person("uc_company_holding_disregard", 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) ) @@ -64,4 +73,10 @@ def formula(household, period, parameters): * person.household("household_uc_company_holding_pool_scale", period) * ~off_reported_capital ) - return holdings + # The holdings sit in share_like_capital, 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, 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 eaaae275f0..f8fea0f06c 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,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, +) from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( HOLDINGS_SOURCE, ) @@ -16,7 +21,9 @@ 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. Each person's holding is " + "household_uc_company_holding_pool_scale and valuing them as " + "share_like_capital is valued (90% under regulation 49(1)). Each " + "person's holding is " "first clipped to [0, max(0, share_like_capital)], with positive infinity " "mapped to the upper bound and negative infinity to zero before any " "multiplication. All household holdings share " @@ -30,7 +37,12 @@ class household_uc_reported_financial_capital(Variable): "holding leaves the pool separately in household_uc_pool_company_holdings. " "Subtracting complete overrides retains the inherited residual-pool " "convention. The scale changes only deductions from the household " - "pool, not regulation 77 substitution in the owner's own assessment." + "pool, not regulation 77 substitution in the owner's own assessment. " + "A recorded financial total is unvalued, so it consumes its part " + "beyond its claimants' holdings in full and the holdings at their " + "counted value. Like an override, a total below the holdings still " + "consumes them, so no part of an owner's holding is left in the pool " + "for another unit to count." ) definition_period = YEAR unit = GBP @@ -38,24 +50,41 @@ class household_uc_reported_financial_capital(Variable): def formula(household, period, parameters): person = household.members - financial_capital = person.benunit("benunit_reported_capital", period) - complete_override = person.benunit("uc_reported_capital", period) - holding = person("uc_company_holding_disregard", period) - upper_bound = max_(0, person.household(HOLDINGS_SOURCE, period)) + financial_capital = exact_amount( + person.benunit("benunit_reported_capital", period) + ) + 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(HOLDINGS_SOURCE, period))) finite_holding = np.where( np.isfinite(holding), holding, np.where(holding > 0, upper_bound, 0) ) bounded_holdings = np.clip(finite_holding, 0, upper_bound) - # Match the claimant holding variable's storage precision before scaling. own_holdings = person.benunit.sum( bounded_holdings * person("is_uc_claimant", period) - ).astype(holding.dtype) * person.household( - "household_uc_company_holding_pool_scale", period + ) * person.household("household_uc_company_holding_pool_scale", period) + # The pool counts share_like_capital, and the holdings in it, 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 + # unvalued and holds the holdings at full value, so it consumes its + # part beyond the holdings in full and the holdings at their counted + # value; a total below the holdings still consumes them, so no part of + # an owner's holding is left for another unit. The owner's own + # 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, HOLDINGS_SOURCE), + p.round_down, ) consumed_capital = where( complete_override >= 0, - max_(complete_override, own_holdings), - financial_capital, + max_(complete_override, valued_holdings), + where( + financial_capital >= 0, + max_(0, financial_capital - own_holdings) + valued_holdings, + financial_capital, + ), ) return household.sum( max_(0, consumed_capital) * person("is_benunit_head", period) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py index db7dd33230..782d86478e 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/is_uc_non_dependant_of_household_head.py @@ -21,7 +21,11 @@ class is_uc_non_dependant_of_household_head(Variable): "non-dependant, because the parent belongs to the extended benefit " "unit. Where a child's benefit unit has no claimant or partner, the " "model cannot tell who is responsible for them and keeps them a " - "non-dependant. The Housing Benefit and Council Tax Reduction " + "non-dependant. Under para 9(2)(f), the formula also leaves out an " + "adult in a family's own Universal Credit claim: it gives a boarder's " + "or lodger's non-paying adult son to that family's claim if it makes " + "one (see uc_non_dep_deductions). The Housing Benefit and Council Tax " + "Reduction " "formulas read is_non_dependant_of_household_head, which follows " "their own definitions." ) @@ -56,4 +60,18 @@ def formula(person, period, parameters): & responsible & ~responsible_in_extended_unit ) - return non_dependant & ~no_one_responsible + # Para 9(2)(f): someone already treated as a non-dependant in another + # claim is not one here. A member of a benefit unit who is not its + # claimant, partner or a qualifying young person counts in their own + # family's claim if it makes one, as in uc_non_dep_deductions. Only a + # boarder's or lodger's family reaches this: any other family is + # either liable for the household's rent or pays none. + own_claim = ( + person.benunit("benunit_is_rent_liable", period) + & person.benunit("is_uc_eligible", period) + & person.benunit("claims_universal_credit", period) + ) + in_own_claim = own_claim & person( + "is_benefit_unit_non_dependant_for_universal_credit", period + ) + return non_dependant & ~no_one_responsible & ~in_own_claim diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py index 49242222e8..c2fa9c4a1d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_individual_non_dep_deduction_eligible.py @@ -5,22 +5,32 @@ class uc_individual_non_dep_deduction_eligible(Variable): value_type = bool entity = Person label = "Eligible person for the Universal Credit non-dependent deduction" + documentation = ( + "A non-dependant of a renter, aged 21 or over and not otherwise " + "exempt: either a member of another family who is not liable for rent " + "(see is_non_dependant_of_household_head) or a member of their own " + "benefit unit who is not the claimant, partner or a qualifying young " + "person (see is_benefit_unit_non_dependant_for_universal_credit)." + ) definition_period = YEAR - reference = "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16", + ) def formula(person, period, parameters): - not_rent_liable = ~person.benunit( - "benunit_is_liable_for_household_rent", period - ) p = parameters( period ).gov.dwp.universal_credit.elements.housing.non_dep_deduction - age = person("age", period) - age_eligible = age >= p.age_threshold + non_dependant = person("is_non_dependant_of_household_head", period) | person( + "is_benefit_unit_non_dependant_for_universal_credit", period + ) + age_eligible = person("age", period) >= p.age_threshold exempt = person("uc_non_dep_deduction_exempt", period) # A boarder or lodger is liable to pay on a commercial basis for their # occupation, so is not a non-dependant (Sch 4 para 9(2)(d)). The - # model treats their partner as liable too (pays_rent_to_householder); - # anyone else in their family who pays nothing stays a non-dependant. - commercial = person("pays_rent_to_householder", period) - return ~exempt & age_eligible & not_rent_liable & ~commercial + # model treats their partner as liable too (pays_rent_to_householder), + # and neither flag counts them. Anyone else in their family who pays + # nothing stays a non-dependant. + return non_dependant & age_eligible & ~exempt 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 ec6430b768..90283b0cd2 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 @@ -19,7 +19,8 @@ class uc_non_dep_deduction_exempt(Variable): "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." + "for the carer exemption. Pension Credit is awarded to a benefit unit's claimant and partner, " + "so another adult in the unit is not receiving it." ) reference = ( "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/16", @@ -38,7 +39,12 @@ def formula(person, period, parameters): < p.child_responsibility_age_limit ) return ( - person.benunit("uc_non_dep_receives_pension_credit", period) + # Pension Credit is the claimant's and partner's award, not that of + # another adult in their benefit unit (para 16(2)(b)). + ( + person("is_claimant_or_partner", period) + & person.benunit("uc_non_dep_receives_pension_credit", period) + ) | person("dla_sc_middle_plus", period) | (person("pip_dl", period) > 0) # Para 16(2)(d) uses "attendance allowance" as reg 2 defines it. diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py index e21ca84e66..228ffad96c 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions.py @@ -5,24 +5,61 @@ class uc_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "Universal Credit non-dependent deductions" + documentation = ( + "Housing cost contributions for the renter's non-dependants. Those in " + "other families of the household count in one claim, the one " + "uc_non_dependants_counted picks (the household head's family if it " + "claims, otherwise the first claiming sharer). Those within a " + "renter's own benefit unit count in that family's claim if it makes " + "one, otherwise in the same counted claim. Making a claim is the " + "model's claims_universal_credit flag for a renting, UC-eligible " + "family, so a family flagged as claiming counts its own non-dependants " + "even where its award is nil. None if the renter or a joint renter is " + "exempt." + ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/9", "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/13", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/15", ) def formula(benunit, period, parameters): # A housing cost contribution is deducted for each non-dependant in # the renter's extended benefit unit (UC Regs 2013 Sch 4 para 13). - # Non-dependants are not liable for rent (para 9(2)(d)), and each - # counts in one Universal Credit claim only (para 9(2)(f)): see - # uc_non_dependants_counted. The Universal Credit flag also leaves - # out a boarder's or lodger's child or qualifying young person (para - # 9(2)(g)). person = benunit.members - deductions = person("uc_individual_non_dep_deduction", period) * person( - "is_uc_non_dependant_of_household_head", period + deductions = person("uc_individual_non_dep_deduction", period) + # Non-dependants from other families are not liable for rent (para + # 9(2)(d)), and each counts in one Universal Credit claim only (para + # 9(2)(f)): see uc_non_dependants_counted. The Universal Credit flag + # also leaves out a boarder's or lodger's child or qualifying young + # person (para 9(2)(g)), and an adult their own family's claim counts. + other_family = person("is_uc_non_dependant_of_household_head", period) + # A member of a renter's own benefit unit who is not the claimant, + # partner or a qualifying young person is a non-dependant of that + # renter (para 9(1)(c) and (2)(a), (g)). Their own family's claim + # counts them if it makes one; otherwise the claim that counts the + # household's other non-dependants does (para 9(2)(f)). + in_unit = ( + person("is_benefit_unit_non_dependant_for_universal_credit", period) + & ~other_family + ) + own_claim = ( + benunit("benunit_is_rent_liable", period) + & benunit("is_uc_eligible", period) + & benunit("claims_universal_credit", period) ) + to_own_claim = in_unit & benunit.project(own_claim) counted = benunit("uc_non_dependants_counted", period) - return counted * benunit.max(person.household.sum(deductions)) + from_household = counted * benunit.max( + person.household.sum( + deductions * ((other_family | in_unit) & ~to_own_claim) + ) + ) + from_own_family = benunit.sum(deductions * to_own_claim) + # Sch 4 para 15: no contribution for any non-dependant where the + # renter, or either joint renter, is blind or receives a qualifying + # disability benefit. + renter_exempt = benunit("uc_non_dep_deductions_renter_exempt", period) + return where(renter_exempt, 0, from_household + from_own_family) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py new file mode 100644 index 0000000000..65ef428c16 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/housing_costs_element/non_dep_deduction/uc_non_dep_deductions_renter_exempt.py @@ -0,0 +1,50 @@ +from policyengine_uk.model_api import * + + +class uc_non_dep_deductions_renter_exempt(Variable): + value_type = bool + entity = BenUnit + label = "Universal Credit renter exempt from all housing cost contributions" + documentation = ( + "Under the Universal Credit Regulations 2013 Sch 4 para 15, the " + "Secretary of State deducts no housing cost contribution for any " + "non-dependant where the renter, or either joint renter, is blind, " + "receives the middle or highest rate of the DLA care component, " + "receives the PIP daily living component, or is 'a person in receipt " + "of attendance allowance' (para 15(2)(c)). Para 1(2) calls the " + "claimants 'renters' and joint claimants 'joint renters', so the " + "formula reads is_uc_single_or_joint_claimant: a partner who cannot " + "be a joint claimant, and whose partner claims as a single person " + "under reg 3(3), brings no exemption. Para 15(2)(c) carries reg 2's " + "definition of attendance allowance, whose heads include (a) " + "attendance allowance under section 64 of the Contributions and " + "Benefits Act and (f) armed forces independence payment. The formula " + "reads uc_receives_attendance_allowance, which counts heads (a) and " + "(f) and no other head, because the model holds no separate amount " + "for heads (b), (d) and (e). Scottish disability assistance (adult " + "and pension age disability payments, child disability payment, " + "Scottish adult DLA) has no separate input, and the formula does not " + "follow entitlement that a hospital stay suspends (para 15(2)(e))." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/15", + "https://www.legislation.gov.uk/uksi/2013/376/schedule/4/paragraph/1", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/3", + ) + + def formula(benunit, period, parameters): + # Sch 4 para 1(2): "renters" are the claimants and "joint renters" + # the joint claimants, so a partner whom reg 3(3) leaves out of the + # claim is neither. + person = benunit.members + renter = person("is_uc_single_or_joint_claimant", period) + qualifying = ( + person("is_blind", period) + | person("dla_sc_middle_plus", period) + # Para 15(2)(c) uses "attendance allowance" as reg 2 defines it. + | person("uc_receives_attendance_allowance", period) + | (person("pip_dl", period) > 0) + ) + return benunit.any(renter & qualifying) 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 37a47aab42..110f476e32 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 @@ -10,22 +10,27 @@ class uc_non_dep_receives_pension_credit(Variable): documentation = ( "Whether a family not liable for rent receives Pension Credit, for the " "Universal Credit non-dependant housing contribution exemption. Such " - "a family's own Universal Credit housing element is zero. Calculating " - "its Pension Credit in a branch with that element fixed at zero " - "therefore preserves its entitlement, including legacy-closure " - "routing, take-up, explicit inputs and frozen Pension Credit reforms. " - "It avoids evaluating a renter's own Pension Credit while calculating " - "their Universal Credit housing element. Results for families liable " - "for the household's rent (benunit_is_liable_for_household_rent) are " - "disregarded because none of their members is a non-dependant. A " - "boarder's or lodger's family is not ruled out: its non-paying adult " - "members can be non-dependants, and keep the exemption when the " - "family receives Pension Credit. For that family the branch fixes " - "the family's Universal Credit housing element, which may cover the " - "rent it pays the householder, at nil, and computes its Pension " - "Credit there. A family whose members are all over the qualifying " - "age claims no Universal " - "Credit, so the branch gives its actual Pension Credit." + "a family's own Universal Credit housing element is zero. The formula " + "calculates its Pension Credit in a branch with every family's " + "housing costs element set to nil, which keeps the family's " + "legacy-closure routing, take-up, explicit inputs and frozen Pension " + "Credit reforms. Where the family's severe disability addition reads " + "a renter's Universal Credit award that includes the carer element, " + "the branch reads that award without housing costs, so the branch's " + "Pension Credit can differ from the family's own. The branch avoids " + "evaluating a renter's own Pension Credit while calculating their " + "Universal Credit housing element. The formula ignores the branch " + "result for a family liable for the household's rent " + "(benunit_is_liable_for_household_rent), none of whose members is a " + "non-dependant. A boarder's or lodger's family is not ruled out: where " + "a member other than its claimant or partner pays, the claimant and " + "partner can be non-dependants, and keep the exemption when the family " + "receives Pension Credit. For that family " + "the branch fixes the family's Universal Credit housing element, " + "which may cover the rent it pays the householder, at nil, and " + "computes its Pension Credit there. A family whose members are all " + "over the qualifying age claims no Universal Credit, so the branch " + "gives its actual Pension Credit." ) definition_period = YEAR reference = ( @@ -35,9 +40,11 @@ class uc_non_dep_receives_pension_credit(Variable): def formula(benunit, period, parameters): # A non-dependant is not liable for the rent (Sch 4 para 9(2)(d)), - # so their own housing element is already zero. This branch keeps - # their actual Pension Credit calculation while breaking the cycle - # through the renter's UC entitlement and pension-age route. + # so their own housing element is already zero. The branch keeps + # their Pension Credit calculation and breaks the cycle through the + # renter's UC entitlement and pension-age route. Where their severe + # disability addition reads a renter's carer element, the branch + # reads the renter's award without housing costs. simulation = benunit.simulation # Income tax is independent of the UC housing element. Calculate # the earnings counterfactuals on the parent so this branch and @@ -58,9 +65,9 @@ def formula(benunit, period, parameters): finally: # A later period or changed input must start from current inputs. del simulation.branches[name] - # A boarder's or lodger's family still has a non-paying member who can - # be a non-dependant, so only liability for the household's rent - # rules the family out here. + # A boarder's or lodger's family can still have a claimant or partner + # who pays nothing and can be a non-dependant, so only liability for + # the household's rent rules the family out here. return (pension_credit > 0) & ~benunit( "benunit_is_liable_for_household_rent", period ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py index 39307c22a0..cee52f742f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_for_uc_education.py @@ -1,15 +1,7 @@ from policyengine_uk.model_api import * - -# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). -EXISTING_BENEFITS = [ - "jsa_income_reported", - "esa_income_reported", - "income_support_reported", - "housing_benefit_reported", - "child_tax_credit_reported", - "working_tax_credit_reported", -] -TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] +from policyengine_uk.variables.gov.dwp.universal_credit.child_element.is_qualifying_young_person_for_universal_credit import ( + qualifying_young_person, +) class is_qualifying_young_person_for_uc_education(Variable): @@ -19,50 +11,83 @@ class is_qualifying_young_person_for_uc_education(Variable): documentation = ( "Whether this person counts as a qualifying young person for the " "Universal Credit condition that a claimant is not receiving " - "education: they meet is_qualifying_young_person_for_universal_credit " - "and have no entitlement to an existing benefit. Under the Universal " - "Credit (Transitional Provisions) Regulations 2014 reg. 28, a person " - "entitled to an existing benefit doesn't count as a qualifying young " - "person, and the Secretary of State lists the existing benefits in " - "reg. 2(1): income-based Jobseeker's Allowance, income-related " - "Employment and Support Allowance, Income Support, Housing Benefit, " - "Child Tax Credit and Working Tax Credit. The model reads entitlement " - "from the year's reported receipt: the person's own report of any of " - "them, or, for a claimant or partner, either member's report of tax " - "credits, which a couple claims jointly. A person outside the " - "definition under reg. 28 takes the ordinary route under the Universal " - "Credit Regulations 2013 reg. 12(2). The model's other rules read " - "is_qualifying_young_person_for_universal_credit, which leaves reg. 28 " - "out. In Northern Ireland the decision maker applies the same rule " - "under reg. 29 of the Universal Credit (Transitional Provisions) " - "Regulations (Northern Ireland) 2016, with reg. 6 of the Universal " - "Credit Regulations (Northern Ireland) 2016 in place of the Universal " - "Credit Regulations 2013 reg. 5." + "education. Under the Universal Credit (Transitional Provisions) " + "Regulations 2014 reg. 28, a person entitled to an existing benefit " + "doesn't count as a qualifying young person, and the Secretary of " + "State lists the existing benefits in reg. 2(1): income-based " + "Jobseeker's Allowance, income-related Employment and Support " + "Allowance, Income Support, Housing Benefit, Child Tax Credit and " + "Working Tax Credit. It reads " + "is_qualifying_young_person_for_universal_credit, which applies reg. " + "28, but for a claimant or partner it reads the status on the first " + "day of their Universal Credit award. A claim ends a tax credit award " + "the day before the award starts (reg. 8(2)(a)), but a Housing " + "Benefit award only two weeks after (reg. 8(2A)), and the model's " + "esa_income_report_closed describes the same run-on for " + "income-related ESA. The Secretary of State inserted reg. 8(2A) from " + "11 April 2018 (SI 2018/65), and SI 2019/1152 reg. 5 gives the " + "income-related ESA run-on only to claims made from 22 July 2020; " + "the annual model applies both run-ons in earlier years too. So the " + "Secretary of State still excludes a " + "claimant on such an award that day (reg. 28), and once they receive " + "Universal Credit, as a person receiving it (Universal Credit " + "Regulations 2013 reg. 5(5)), which the model's reg. 5 test reads " + "only from reported Universal Credit. The formula therefore leaves " + "out the Housing Benefit and income-related ESA claim limbs that " + "is_qualifying_young_person_for_universal_credit follows, and keeps " + "the Child Tax Credit one, the year gates and the legacy closures. A " + "claimant whose Universal Credit comes to nil receives none, so after " + "the run-on the law counts them as a qualifying young person again; " + "the annual model keeps them outside the definition all year. A " + "person outside the definition under reg. 28 takes the ordinary " + "route under the Universal Credit Regulations 2013 reg. 12(2). For " + "the reg. 60(1) protection the model reads the status on the " + "migration day instead (is_qualifying_young_person_on_uc_migration_day). " + "In Northern Ireland the decision maker applies the same rule under " + "reg. 29 of the Universal Credit (Transitional Provisions) Regulations " + "(Northern Ireland) 2016, with reg. 6 of the Universal Credit " + "Regulations (Northern Ireland) 2016 in place of the Universal Credit " + "Regulations 2013 reg. 5. SR 2016/226 reg. 6 ends awards on the same " + "days as reg. 8, and SR 2019/152 reg. 4 gives the income-related ESA " + "run-on to claims made from 22 July 2020, with one difference: a claim " + "ends a rate rebate the day before the award starts (reg. 6(2)), and " + "only a rent rebate or allowance runs on (reg. 6(2A)). " + "housing_benefit_reported doesn't show the form, so the formula reads " + "housing_benefit_report_is_rate_rebate_only: a claimant with that " + "status and no other existing benefit counts as a qualifying young " + "person on the first day. It defaults to False, so the formula reads " + "any other report as a rent rebate or allowance." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2013/983/article/4", + "https://www.legislation.gov.uk/uksi/2019/1152/regulation/5", "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", "https://www.legislation.gov.uk/ukpga/2002/21/section/3", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", "https://www.legislation.gov.uk/nisr/2016/216/regulation/6", + "https://www.legislation.gov.uk/nisr/2017/190/article/6", + "https://www.legislation.gov.uk/nisr/2019/152/regulation/4", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/405/regulation/13", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/12", + "https://www.legislation.gov.uk/nisr/2006/406/regulation/13", ) def formula(person, period, parameters): - qualifying_young_person = person( - "is_qualifying_young_person_for_universal_credit", period - ) - # SI 2014/1230 reg. 28: where a person who would be a qualifying - # young person "is entitled to an existing benefit", "that person is - # not a qualifying young person for the purposes of the Universal - # Credit Regulations". - own_award = add(person, period, EXISTING_BENEFITS) > 0 - # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a - # tax credit jointly, so the award belongs to both. - claimant_or_partner = person("is_claimant_or_partner", period) - joint_tax_credits = claimant_or_partner & ( - person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) - > 0 + # Those whom the Housing Benefit and income-related ESA claim limbs + # readmit: on the first day of their award the Secretary of State + # still excludes them through reg. 28, and once they receive + # Universal Credit through reg. 5(5). Removing only them keeps any + # input or reform that sets the predicate. + readmitted = qualifying_young_person( + person, period, parameters + ) & ~qualifying_young_person(person, period, parameters, at_claim_start=True) + return ( + person("is_qualifying_young_person_for_universal_credit", period) + & ~readmitted ) - return qualifying_young_person & ~(own_award | joint_tax_credits) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py new file mode 100644 index 0000000000..076ac7d474 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/is_qualifying_young_person_on_uc_migration_day.py @@ -0,0 +1,69 @@ +from policyengine_uk.model_api import * + +# Universal Credit (Transitional Provisions) Regulations 2014 reg. 2(1). +EXISTING_BENEFITS = [ + "jsa_income_reported", + "esa_income_reported", + "income_support_reported", + "housing_benefit_reported", + "child_tax_credit_reported", + "working_tax_credit_reported", +] +TAX_CREDITS = ["child_tax_credit_reported", "working_tax_credit_reported"] + + +class is_qualifying_young_person_on_uc_migration_day(Variable): + value_type = bool + entity = Person + label = "Qualifying young person on the Universal Credit migration day" + documentation = ( + "Whether this person counted as a qualifying young person for the " + "Universal Credit condition that a claimant is not receiving " + "education on the migration day of a closure claim, the day before " + "the Universal Credit award starts (Universal Credit (Transitional " + "Provisions) Regulations 2014 reg. 49). Under reg. 28, a person " + "entitled to an existing benefit doesn't count as a qualifying young " + "person, and the Secretary of State lists the existing benefits in " + "reg. 2(1). Under reg. 8(2)(a), a claim for Universal Credit ends a " + 'tax credit award "on the day before the first date on which the ' + 'claimant is entitled to universal credit", so on the migration day ' + "reg. 28 still applied to that award. The model reads the year's " + "reported receipt of an existing benefit as that award, whether or " + "not it still pays the benefit that year: the person's own report, " + "or, for a claimant or partner, either member's report of tax " + "credits, which a couple claims jointly. " + "uc_is_excepted_from_education_condition reads this status for the " + "reg. 60(1) protection. In Northern Ireland the decision maker applies " + "the same rules under regs. 6(2), 29 and 50 of the Universal Credit " + "(Transitional Provisions) Regulations (Northern Ireland) 2016." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/2", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/8", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", + "https://www.legislation.gov.uk/ukpga/2002/21/section/3", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/6", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", + "https://www.legislation.gov.uk/nisr/2016/226/regulation/50", + ) + + def formula(person, period, parameters): + qualifying_young_person = person( + "is_qualifying_young_person_for_universal_credit", period + ) + # SI 2014/1230 reg. 28: where a person who would be a qualifying + # young person "is entitled to an existing benefit", "that person is + # not a qualifying young person for the purposes of the Universal + # Credit Regulations". The report stands for the award the closure + # claim ended, so the formula reads it in every year. + own_award = add(person, period, EXISTING_BENEFITS) > 0 + # Tax Credits Act 2002 s. 3(3)(a): the members of a couple claim a + # tax credit jointly, so the award belongs to both. + claimant_or_partner = person("is_claimant_or_partner", period) + joint_tax_credits = claimant_or_partner & ( + person.benunit.sum(claimant_or_partner * add(person, period, TAX_CREDITS)) + > 0 + ) + return qualifying_young_person & ~(own_award | joint_tax_credits) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py index 7675aabfec..cdc9b5c09d 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/meets_uc_minimum_age_condition.py @@ -22,8 +22,9 @@ class meets_uc_minimum_age_condition(Variable): is_carer_for_benefits means receipt of Carer's Allowance or Scottish Carer Support Payment, underlying entitlement to either benefit, or at least the Carer's Allowance qualifying hours of care (currently 35 weekly). - Its hours limb does not verify the recipient's - qualifying disability benefit, and it does not exclude paid care. + Its hours limb reads the cared-for person's qualifying disability benefit + only from the input cares_for_severely_disabled_person, which defaults to + true, and it does not exclude paid care. For regulation 8(1)(d), responsibility is proxied by being a claimant in a benefit unit with a nonclaimant child under the UC child definition. A 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 11ca5eac23..52760e5f73 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,6 +1,15 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import ( + counted_share, + exact_amount, + round_to_penny, + valued_capital, + valued_person_capital, +) +from policyengine_uk.variables.gov.dwp.universal_credit.household_uc_company_holding_pool_scale import ( + HOLDINGS_SOURCE, +) from policyengine_uk.variables.gov.dwp._person_capital_pool import ( - counted_person_sources, pooled_person_capital, ) @@ -14,7 +23,8 @@ class uc_assessable_capital(Variable): "uc_reported_capital, including zero, replaces ordinary capital sources before " "the regulation 77 company substitution. Otherwise a nonnegative " "benunit_reported_capital replaces financial household and person-level " - "proxies only; it does not replace countable property. The formula subtracts " + "proxies; it does not replace separately valued domestic, overseas or " + "unclassified other property. The formula subtracts " "complete overrides, otherwise observed financial totals, from the household " "financial pool, and allocates the financial remainder by claimant and partner " "counts among units without either report. Under this inherited convention, " @@ -30,7 +40,17 @@ class uc_assessable_capital(Variable): "for the claimant and partner of a unit that reports its capital: it leaves " "their ISAs in the pool and subtracts the report, as for their company " "holdings. Under regulation 77, it replaces disregarded company holdings with " - "the claimant's or partner's share of company capital." + "the claimant's or 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 share_like_capital) at 90%, and the " + "formula values a stocks and shares ISA balance it moves out of " + "share_like_capital at the same 90%. A disregarded holding comes off at " + "the value at which the formula counted it: 90% in the pool and in a " + "complete override, which is a countable figure, and in full from a " + "recorded financial total, which the formula uses without valuation. " + "The formula rounds every amount it calculates to the nearest penny " + "(regulation 6(1)), including this capital, which it tests against the " + "limit." ) definition_period = YEAR unit = GBP @@ -54,26 +74,30 @@ def formula(benunit, period, parameters): (person.benunit("benunit_reported_capital", period) >= 0) | (person.benunit("uc_reported_capital", period) >= 0) ) - household_financial_capital = sum( - household(source, period) - - pooled_person_capital(benunit, source, period, p, ~covered) - for source in p.sources + household_financial_capital = valued_capital( + lambda source: ( + household(source, period) + - pooled_person_capital(benunit, source, period, p, ~covered) + ), + p, ) + # A reg. 77 holding sits in share_like_capital, 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) benunit_claimants = benunit.sum(claimant_or_partner) - person_capital = sum( - benunit.sum(person(source, period) * claimant_or_partner) - for source in counted_person_sources(p) + person_capital = valued_person_capital( + benunit, period, parameters, p, claimant_or_partner ) - financial_report = benunit("benunit_reported_capital", period) - complete_override = benunit("uc_reported_capital", period) + financial_report = exact_amount(benunit("benunit_reported_capital", period)) + complete_override = exact_amount(benunit("uc_reported_capital", period)) unreported_claimants = household("household_uc_unreported_claimants", period) # Each unit consumes its complete override, otherwise its financial # report, from the financial pool under the inherited convention. residual_financial_capital = max_( 0, household_financial_capital - - household("household_uc_reported_financial_capital", period) - - household("household_uc_pool_company_holdings", period), + - exact_amount(household("household_uc_reported_financial_capital", period)) + - exact_amount(household("household_uc_pool_company_holdings", period)), ) financial_proxy = where( unreported_claimants > 0, @@ -83,7 +107,7 @@ def formula(benunit, period, parameters): 0, ) own_holdings = benunit.sum( - benunit.members("uc_company_holding_disregard", period) + exact_amount(benunit.members("uc_company_holding_disregard", period)) * claimant_or_partner ) financial_capital = where( @@ -93,10 +117,17 @@ def formula(benunit, period, parameters): ) assessed_capital = where( complete_override >= 0, - max_(0, complete_override - own_holdings), - financial_capital + benunit("uc_property_capital", period), + max_( + 0, + complete_override + - round_to_penny(own_holdings * holding_value, p.round_down), + ), + financial_capital + exact_amount(benunit("uc_property_capital", period)), ) company_capital = benunit.sum( - benunit.members("uc_company_capital", period) * claimant_or_partner + exact_amount(benunit.members("uc_company_capital", period)) + * claimant_or_partner ) - return max_(0, assessed_capital) + company_capital + # Every amount is rounded to the nearest penny (reg. 6(1)), the + # capital tested against the limit included. + return round_to_penny(max_(0, assessed_capital) + company_capital, p.round_down) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py index 209eddad27..5b42c4658f 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_is_excepted_from_education_condition.py @@ -22,10 +22,10 @@ class uc_is_excepted_from_education_condition(Variable): "a family that claims Universal Credit when DWP moves it off its " "legacy benefits (claims_uc_at_legacy_closure), which the model takes " "as the qualifying claim under reg. 48 of the 2014 Regulations, and who " - "doesn't count " - "as a qualifying young person for the condition " - "(is_qualifying_young_person_for_uc_education, which applies reg. 28 " - "of the 2014 Regulations). The model needs no rule of its own for limb " + "didn't count as a qualifying young person for the condition on the " + "migration day (is_qualifying_young_person_on_uc_migration_day, which " + "applies reg. 28 of the 2014 Regulations to the award the claim " + "ended). The model needs no rule of its own for limb " "(e)(i): it places responsibility for a child on the benefit unit, so " "both members of the couple meet (c). Under (b), the decision maker " "must determine limited capability for work before the course starts. " @@ -63,6 +63,7 @@ class uc_is_excepted_from_education_condition(Variable): "https://www.legislation.gov.uk/uksi/2013/376/regulation/2", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/48", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/49", "https://www.legislation.gov.uk/uksi/2014/1230/regulation/60", "https://www.legislation.gov.uk/nisr/2016/216/regulation/14", "https://www.legislation.gov.uk/nisr/2016/226/regulation/29", @@ -127,12 +128,13 @@ def formula(person, period, parameters): # that condition is not to apply ... while they are continuing to # undertake that course". A qualifying young person receives # education under reg. 12(1A), not reg. 12(2); reg. 28 applies - # first, so a person entitled to an existing benefit takes reg. - # 12(2) and keeps the protection. + # first, so a person entitled to an existing benefit on the + # migration day took reg. 12(2) and keeps the protection while they + # continue the course, after that award has ended. migrated_student = ( claimant_or_partner & benunit("claims_uc_at_legacy_closure", period) - & ~person("is_qualifying_young_person_for_uc_education", period) + & ~person("is_qualifying_young_person_on_uc_migration_day", period) ) return ( disabled_with_limited_capability_for_work diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py index d59947a042..4574f8ad39 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_non_residential_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses class uc_non_residential_property_capital(Variable): @@ -21,4 +22,8 @@ def formula(household, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital market_value = max_(0, household("non_residential_property_value", period)) debt = max_(0, household("non_residential_property_secured_debt", period)) - return max_(0, market_value * (1 - p.property_sale_cost_rate) - debt) + return max_( + 0, + less_sale_expenses(market_value, p.sale_expenses.rate, p.round_down) + - exact_amount(debt), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py new file mode 100644 index 0000000000..5ab875fcf4 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_property_capital.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses + + +class uc_other_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net unclassified other property capital" + documentation = ( + "Assessment-year value of unclassified other real estate less regulation " + "49 sale expenses. Assumes no Schedule 10 disregard; the broad input " + "does not identify asset-level disregards or secured debt. Valued " + "separately from financial reports and allocated through uc_property_capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return less_sale_expenses( + max_(0, household("other_property_value", period)), + p.sale_expenses.rate, + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py index eaeebb1435..fec4d08457 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_other_residential_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, less_sale_expenses class uc_other_residential_property_capital(Variable): @@ -21,4 +22,8 @@ def formula(household, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital market_value = max_(0, household("other_residential_property_value", period)) debt = max_(0, household("other_residential_property_secured_debt", period)) - return max_(0, market_value * (1 - p.property_sale_cost_rate) - debt) + return max_( + 0, + less_sale_expenses(market_value, p.sale_expenses.rate, p.round_down) + - exact_amount(debt), + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py new file mode 100644 index 0000000000..bf696151d7 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_overseas_property_capital.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import less_sale_expenses + + +class uc_overseas_property_capital(Variable): + value_type = float + entity = Household + label = "Universal Credit net overseas property capital" + documentation = ( + "Assessment-year value of overseas land and property less regulation " + "49 sale expenses. Assumes no Schedule 10 disregard; the broad input " + "does not identify asset-level disregards or secured debt. Valued " + "separately from financial reports and allocated through uc_property_capital." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49" + + def formula(household, period, parameters): + p = parameters(period).gov.dwp.universal_credit.means_test.capital + return less_sale_expenses( + max_(0, household("overseas_property_value", period)), + p.sale_expenses.rate, + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py index 7ae4bbb10e..2a673af312 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/uc_property_capital.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.capital_valuation import exact_amount, round_to_penny class uc_property_capital(Variable): @@ -28,11 +29,15 @@ class uc_property_capital(Variable): def formula(benunit, period, parameters): p = parameters(period).gov.dwp.universal_credit.means_test.capital household_property = sum( - benunit.household(source, period) for source in p.property_sources + exact_amount(benunit.household(source, period)) + for source in p.property_sources ) person = benunit.members household_claimants = benunit.max( person.household.sum(person.household.members("is_uc_claimant", period)) ) benunit_claimants = benunit.sum(person("is_uc_claimant", period)) - return household_property * benunit_claimants / max_(1, household_claimants) + return round_to_penny( + household_property * benunit_claimants / max_(1, household_claimants), + p.round_down, + ) diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py index fbf470039c..785e042a10 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_child_index.py @@ -8,7 +8,7 @@ class child_benefit_child_index(Variable): definition_period = YEAR def formula(person, period, parameters): - child = person("is_child_or_qualifying_young_person_for_child_benefit", period) + child = person("is_child_benefit_payable_for_person", period) rank = ( person.get_rank( person.benunit, diff --git a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py index e8e66493c9..3f5b702adc 100644 --- a/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py +++ b/policyengine_uk/variables/gov/hmrc/child_benefit_respective_amount.py @@ -12,7 +12,7 @@ class child_benefit_respective_amount(Variable): "https://www.legislation.gov.uk/ukpga/1992/4/part/IX", "https://www.legislation.gov.uk/uksi/2006/965/regulation/2", ) - defined_for = "is_child_or_qualifying_young_person_for_child_benefit" + defined_for = "is_child_benefit_payable_for_person" def formula(person, period, parameters): eligible = True diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py index d54f76158a..75ea9edc77 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance.py @@ -20,6 +20,12 @@ class marriage_allowance(Variable): value_type = float entity = Person label = "Marriage Allowance" + documentation = ( + "The amount of personal allowance transferred to this person. It does " + "not reduce their taxable income: marriage_allowance_tax_reduction " + "gives them a tax reduction of the appropriate percentage of it at " + "Step 6 (ITA 2007 s. 55B(1))." + ) definition_period = YEAR reference = "https://www.legislation.gov.uk/ukpga/2007/3/part/3/chapter/3A" unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py new file mode 100644 index 0000000000..d4ade32ba5 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_appropriate_percentage.py @@ -0,0 +1,46 @@ +from policyengine_uk.model_api import * + + +class marriage_allowance_appropriate_percentage(Variable): + value_type = float + entity = Person + label = "Marriage Allowance appropriate percentage" + documentation = ( + "The rate of the Marriage Allowance tax reduction: the basic rate, or " + "for a Scottish taxpayer the Scottish basic rate, whatever rates the " + "person's income bears (ITA 2007 s. 55B(3)). The Scottish rates of " + "s. 11A start in 2016-17, so before then the formula gives a Scottish " + "taxpayer the basic rate. Section 55B(3) gives a Welsh taxpayer the " + "Welsh basic rate, but the model has no Welsh rates, so a Welsh " + "taxpayer takes the basic rate." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Scottish Parliament motion S6M-16531 (2025-26 Scottish rates)", + href="https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531", + ), + ] + unit = "/1" + + def formula(person, period, parameters): + rates = parameters(period).gov.hmrc.income_tax.rates + basic_rate = rates.uk.rates[0] + if period.start.year < 2016: + return basic_rate + # The Scottish basic rate band is the second band of the Scottish + # scale in every year: before 2018-19 the first band (the starter + # rate) has no values. Read that band itself rather than an index into + # the rates in force at the instant, which would move if a reform + # removed the starter rate. + scottish_bands = parameters.gov.hmrc.income_tax.rates.scotland.rates + scottish_basic_rate = scottish_bands.brackets[1].rate(period.start) + return where( + person("pays_scottish_income_tax", period), + scottish_basic_rate, + basic_rate, + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py new file mode 100644 index 0000000000..6fc83049ee --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/marriage_allowance_tax_reduction.py @@ -0,0 +1,45 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions + + +class marriage_allowance_tax_reduction(Variable): + value_type = float + entity = Person + label = "Marriage Allowance tax reduction" + documentation = ( + "The Marriage Allowance recipient's tax reduction at Step 6 of the " + "Income Tax calculation: the appropriate percentage of the " + "transferable amount (ITA 2007 s. 55A(2) and s. 55B(1)). It does not " + "depend on the rates the recipient's income bears. The formula " + "deducts it after the reductions listed before it in " + "step_6_tax_reductions, and only so far as there is Step 5 tax left " + "(s. 29(2) and (3))." + ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007 s. 55A(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55A", + ), + dict( + title="Income Tax Act 2007 s. 55B(1) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + dict( + title="Income Tax Act 2007 s. 29(2) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + unit = GBP + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + reduction = person("marriage_allowance_appropriate_percentage", period) * ( + person("marriage_allowance", period) + ) + earlier = earlier_tax_reductions(income_tax, "marriage_allowance_tax_reduction") + tax_left = max_( + 0, + person("income_tax_pre_charges", period) - add(person, period, earlier), + ) + return min_(max_(0, reduction), tax_left) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py index e91f14cbd3..e868a10b7b 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/meets_marriage_allowance_income_conditions.py @@ -1,18 +1,126 @@ from policyengine_uk.model_api import * -import numpy as np + + +def liable_only_at_marriage_allowance_rates(person, period, parameters): + """Whether the person pays no income tax above the basic rates. + + Apply the rates Parliament permits for the tax year (s. 55B(2)(b) and + s. 55C(1)(c)). From 2016-17 these include the savings nil rate; from + 2018-19 the Scottish list includes rates below basic and the intermediate + rate. Where s. 55B(2)(ba) and s. 55C(1)(ca) apply, test dividends as if + s. 13A were omitted. + + Parliament extends the rate limits by the grossed-up Gift Aid gift + (s. 414(2)(b)); the gift does not reduce taxable income. Before 2017-18 + this test uses the basic rate limit for Scottish taxpayers, even though + the model's historical Scottish liability currently reads the 2017-18 + schedule. The test also follows the statute where the model's liability + departs from it: it starts the dividend upper rate at the basic rate + limit (ss. 10 and 13), counts no savings nil rate before 2016-17 (FA 2016 + s. 4(17)), and places the nil-rate slice above the starting rate limit + for savings (s. 12A). + + Parliament charges tax at each rate at Step 4 and takes tax reductions + off at Step 6 (s. 23), and it gives the allowance as a tax reduction at + Step 6 (ss. 55A(2) and 55B(1)). So the allowance never changes the income + each rate applies to, and this test never reads it: + marriage_allowance_tax_reduction takes the allowance off the tax, and + the test reads income after allowances and the savings, dividend and + Gift Aid amounts directly. + """ + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + # Earnings, pensions, property and other non-savings income after the + # allowances set against it (s. 25(2)): the income below savings and + # dividends (s. 16). + non_savings = person("non_savings_income_after_allowances", period) + # The Scottish higher rate follows the starter, basic and intermediate + # rates; in 2017-18 the Scottish scale had only the basic rate below it. + # Before 2017-18 the test holds a Scottish taxpayer to the basic rate + # limit. The Scottish rates of s. 11A start in 2016-17 (FA 2014 Sch. 38 + # para. 12, SI 2015/2000 art. 3). That year Parliament charged the Scottish + # basic rate on income that would otherwise bear the basic rate + # (s. 11A(1)). The model reads its Scottish scale's 2017-18 values in + # earlier years too, so the test sets the limit by the year. + scottish = rates.scotland.rates + basic_rate_limit = rates.uk.thresholds[1] + gross_gift + if period.start.year < 2017 or len(scottish.rates) < 3: + scottish_limit = basic_rate_limit + elif len(scottish.rates) == 3: + scottish_limit = scottish.thresholds[1] + gross_gift + else: + scottish_limit = scottish.thresholds[3] + gross_gift + non_savings_limit = where( + person("pays_scottish_income_tax", period), + scottish_limit, + basic_rate_limit, + ) + # Savings sit above non-savings income (s. 16) and take the rest-of-UK + # limits (s. 11D(6)). Up to the basic rate limit they bear a basic rate or + # the starting rate for savings, both permitted: Parliament charges the + # starting rate only on savings that would otherwise bear a basic rate + # (s. 12(1), and s. 12(1)(b) from 2017-18). Above the limit it permits only + # the savings nil rate (s. 55B(2)(b)), which it charges on the lowest + # savings above the starting rate limit for savings, up to the savings + # allowance (s. 12A(1) to (4)); dividends sit above savings (s. 16) and + # don't move this slice. The person fails the test if any savings above the + # basic rate limit lie outside the slice. + savings = max_( + 0, + person("taxable_savings_interest_income", period) + - person("received_allowances_savings_income", period), + ) + # Parliament gave the savings nil rate effect from 2016-17 (FA 2016 + # s. 4(17)). + if period.start.year < 2016: + savings_allowance = 0 + else: + savings_allowance = person("savings_allowance", period) + below_dividends = non_savings + savings + nil_rate_start = max_(non_savings, rates.savings_starter_rate.allowance) + nil_rate_top = min_(below_dividends, nil_rate_start + savings_allowance) + savings_above_basic = max_(0, below_dividends - max_(non_savings, basic_rate_limit)) + nil_rate_above_basic = max_( + 0, nil_rate_top - max_(nil_rate_start, basic_rate_limit) + ) + savings_at_forbidden_rates = savings_above_basic - nil_rate_above_basic > 0 + # Dividends form the top slice, above all non-savings income and savings + # (s. 16); count them before the dividend allowance. Parliament charges the + # dividend upper rate on dividends that would otherwise bear the higher + # rate (s. 13(2)), and the higher rate starts at the basic rate limit plus + # the gross gift (ss. 10(3) and 414(2)(b)). + dividends = max_( + 0, + person("taxable_dividend_income", period) + - person("received_allowances_dividend_income", period), + ) + dividend_upper_limit = basic_rate_limit + dividends_above_basic = (dividends > 0) & ( + below_dividends + dividends > dividend_upper_limit + ) + return ( + (non_savings <= non_savings_limit) + & ~savings_at_forbidden_rates + & ~dividends_above_basic + ) class meets_marriage_allowance_income_conditions(Variable): label = "Meets Marriage Allowance income conditions" - documentation = "Whether this person (and their partner) meets the conditions for this person to be eligible for the Marriage Allowance, as set out in the Income Tax Act 2007 sections 55B and 55C" + documentation = ( + "Whether this person pays income tax only at rates that Parliament " + "allows for receiving the Marriage Allowance (ITA 2007 s. 55B(2)(b) " + "and (ba))." + ) entity = Person definition_period = YEAR value_type = bool - reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/55B" + reference = [ + dict( + title="Income Tax Act 2007 s. 55B(2)(b) and (ba)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/55B", + ), + ] def formula(person, period, parameters): - band = person("tax_band", period) - eligible_bands = parameters( - period - ).gov.hmrc.income_tax.allowances.marriage_allowance.eligible_bands - return np.isin(band.decode_to_str(), eligible_bands) + return liable_only_at_marriage_allowance_rates(person, period, parameters) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py index 8503f249b8..16164c7531 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_dividend_income.py @@ -1,35 +1,23 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import allocate_deductions class received_allowances_dividend_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (excluding those for a particular income type) calculated last, applied to dividends." + label = "Allowances and reliefs deducted from dividend income" + documentation = ( + "The part of the allowances and trade loss relief against general " + "income that Income Tax deducts from dividend income, in the share " + "that gives the least tax (ITA 2007 s. 25(2) and, from 2027-28, " + "s. 25(3A)). See received_allowances_savings_income." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - all_allowances = person("allowances", period) - received_allowances_earned_income = person( - "received_allowances_earned_income", period - ) - received_allowances_savings_income = person( - "received_allowances_savings_income", period - ) - # Includes any trade loss relief beyond non-savings and savings - # income (ITA 2007 s.25(2)). - remaining_allowance = ( - all_allowances - + person("trade_loss_relief_against_savings_and_dividends", period) - - received_allowances_earned_income - - received_allowances_savings_income - ) - - dividend_income = person("taxable_dividend_income", period) - - return where( - dividend_income < remaining_allowance, - max_(0, dividend_income), - max_(0, remaining_allowance), - ) + return allocate_deductions(person, period, parameters).dividends diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py index 3103af399a..00fd878f87 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_earned_income.py @@ -1,27 +1,25 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class received_allowances_earned_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (minus those only applicable to certain types of income) that is applied to earned income" + label = "Allowances deducted from non-savings income" + documentation = ( + "The part of the allowances that Income Tax deducts from non-savings, " + "non-dividend income, property income included. Trade loss relief " + "against general income comes off that income first, inside " + "net_income, so it holds what the share in " + "received_allowances_savings_income sets against that income beyond " + "the relief." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - # This portion of all allowances, applied first, excludes savings and dividends - - all_allowances = person("allowances", period) - - all_income = person("net_income", period) - excluded_income = person("taxable_savings_interest_income", period) + person( - "taxable_dividend_income", period - ) - earned_income = all_income - excluded_income - - return where( - earned_income < all_allowances, - max_(0, earned_income), - all_allowances, - ) + return non_savings_deductions(person, period).earned diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py index f306c5649a..4f306ce65a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/received_allowances/received_allowances_savings_income.py @@ -1,31 +1,26 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import allocate_deductions class received_allowances_savings_income(Variable): value_type = float entity = Person - label = "The portion of all allowances (minus those only applicable to one income type) calculated after earned taxable income, but before dividends. This is applied to savings interest income." + label = "Allowances and reliefs deducted from savings income" + documentation = ( + "The part of the allowances and trade loss relief against general " + "income that Income Tax deducts from savings income. The taxpayer " + "deducts reliefs and allowances in the way that gives the least tax " + "(ITA 2007 s. 25(2)), and from 2027-28 deducts them from income other " + "than property, savings and dividend income first (s. 25(3A)). Pension " + "relief stays on non-savings income as far as it reaches. " + "policyengine_uk.utils.income_tax_allocation finds that share." + ) unit = GBP definition_period = YEAR - reference = "Income Tax Act 2007 s. 35" + reference = dict( + title="Income Tax Act 2007 s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ) def formula(person, period, parameters): - all_allowances = person("allowances", period) - received_allowances_earned_income = person( - "received_allowances_earned_income", period - ) - # Trade loss relief beyond non-savings income comes off savings income - # next, then dividends in the model's fixed allowance order (#2106). - remaining_allowance = ( - all_allowances - + person("trade_loss_relief_against_savings_and_dividends", period) - - received_allowances_earned_income - ) - - savings_income = person("taxable_savings_interest_income", period) - - return where( - savings_income < remaining_allowance, - max_(0, savings_income), - max_(0, remaining_allowance), - ) + return allocate_deductions(person, period, parameters).savings diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py index 99fe833270..e3cfc8e3cc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/savings_allowance.py @@ -13,11 +13,11 @@ class savings_allowance(Variable): "12B(8)(a)(iv) and (b)(iv) count as higher- or additional-rate income " "what would be charged at those rates if they were not a Scottish or " "Welsh taxpayer, so the bands are the rest-of-UK ones for everyone. " - "tax_band follows the Scottish bands for a Scottish taxpayer, as the " - "Marriage Allowance conditions need, so it is not read here. Taxable " - "income is taken, as in tax_band, as net income less allowances, and " - "a Gift Aid gift raises the basic and higher rate limits by its " - "grossed-up amount (ITA 2007 s. 414(2)(b))." + "For Scottish taxpayers, tax_band also uses Scottish limits for " + "non-savings income. This calculation uses the rest-of-UK limits " + "directly. Like tax_band's rest-of-UK band, it takes taxable income " + "as net income less allowances, and Parliament raises the basic and " + "higher rate limits by the grossed-up gift (ITA 2007 s. 414(2)(b))." ) definition_period = YEAR reference = "https://www.legislation.gov.uk/ukpga/2007/3/section/12B" diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py index 5de4387597..3da7a2609d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/earned_taxable_income.py @@ -1,5 +1,13 @@ from policyengine_uk.model_api import * +# The exclusions that take earned income down to the non-savings income left +# after the allowances. +NON_SAVINGS_BASE = ( + "taxable_savings_interest_income", + "taxable_dividend_income", + "received_allowances_earned_income", +) + class earned_taxable_income(Variable): value_type = float @@ -16,6 +24,17 @@ def formula(person, period, parameters): exclusion_list = parameters( period ).gov.hmrc.income_tax.earned_taxable_income_exclusions - ani = person("net_income", period) + # net_income deducts all trade loss relief against general income. + # Add back the part set against savings and dividends, which the + # exclusions take off at their full amounts (ITA 2007 s. 25). + income = person("net_income", period) + person( + "trade_loss_relief_against_savings_and_dividends", period + ) exclusions = add(person, period, exclusion_list) - return max_(0, ani - exclusions) + earned = max_(0, income - exclusions) + if all(variable in exclusion_list for variable in NON_SAVINGS_BASE): + # Earned income is part of the non-savings income left after + # the allowances, which the allocation computes without the + # float32 rounding of the sum above. + return min_(earned, person("non_savings_income_after_allowances", period)) + return earned diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py index daff67c55d..c42df88337 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_income_after_allowances.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class non_savings_income_after_allowances(Variable): @@ -8,27 +9,39 @@ class non_savings_income_after_allowances(Variable): documentation = ( "Net income (before the Gift Aid step of adjusted net income, which " "does not reduce the income charged to tax) less savings and dividend " - "income and allowances, floored at nil: the income below savings and " - "dividends (ITA 2007 s. 16) once the allowances, which go to it first, " - "are taken off. " - "property_income_tax and taxable_property_income_after_allowances " - "both read it." + "income and the reliefs and allowances set against non-savings " + "income, floored at nil: the income below savings and dividends " + "(ITA 2007 s. 16) after Steps 2 and 3. The taxpayer sets the reliefs " + "and allowances against it in the way that gives the least tax (ITA " + "2007 s. 25(2)), and from 2027-28 against the rest of it before " + "property income (s. 25(3A)). Parliament raises the rate limits by " + "the grossed-up Gift Aid gift (s. 414(2)(b)). The allocation computes " + "it from its own share, without the float32 rounding of a sum that " + "adds the relief back. property_income_tax, " + "taxable_property_income_after_allowances, earned_taxable_income, the " + "Marriage Allowance rate test and, for Scottish taxpayers, tax_band " + "read it, and non_savings_taxable_income reads it through " + "taxable_property_income_after_allowances and earned_taxable_income." ) definition_period = YEAR - reference = dict( - title="Income Tax Act 2007, s. 16", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", - ) + reference = [ + dict( + title="Income Tax Act 2007, s. 16", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/16", + ), + dict( + title="Income Tax Act 2007, s. 25", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/25", + ), + ] unit = GBP def formula(person, period, parameters): - return max_( - 0, - # Not adjusted_net_income: it deducts the grossed-up Gift Aid - # gift (ITA 2007 s. 58), which raises the rate limits instead - # (s. 414(2)(b)) and is no deduction from taxable income. - person("net_income", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - - person("allowances", period), - ) + # Not adjusted_net_income: it deducts the grossed-up Gift Aid gift + # (ITA 2007 s. 58), which raises the rate limits instead + # (s. 414(2)(b)) and is no deduction from taxable income. The + # allocation takes net income, adds back the trade loss relief that + # the share sets against savings and dividends (s. 25), and deducts + # savings, dividends and the reliefs and allowances the share sets + # against non-savings income. + return non_savings_deductions(person, period).non_savings_left diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py index 8e1d2253a4..db6335c2c7 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_earned_income/non_savings_taxable_income.py @@ -12,10 +12,10 @@ class non_savings_taxable_income(Variable): "earned_taxable_income leaves property income out because " "property_income_tax taxes it separately, but property income still " "fills the bands before savings and dividends do (from 2027-28, " - "s. 16A places it immediately below savings income). With no Marriage " - "Allowance it equals non_savings_income_after_allowances; " - "earned_taxable_income also subtracts Marriage Allowance, which the " - "law gives as a tax reduction (#1947)." + "s. 16A places it immediately below savings income). It equals " + "non_savings_income_after_allowances. The Marriage Allowance does not " + "reduce it: marriage_allowance_tax_reduction gives the allowance as a " + "tax reduction at Step 6 (s. 55B(1))." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py index 73f78c28d5..e44c34a8b1 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/bracketized_liability/tax_band.py @@ -11,68 +11,141 @@ class TaxBand(Enum): ADDITIONAL = "Additional" +def uk_tax_band_above_limits(person, period, parameters): + """Return the rest-of-UK band before 2018-19, counting income above each + limit in the next band.""" + allowances = person("allowances", period) + ani = person("net_income", period) + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + limits = [ + threshold + (gross_gift if index in (1, 2) else 0) + for index, threshold in enumerate(rates.uk.thresholds) + ] + basic = allowances + limits[0] + higher = allowances + limits[1] + add = allowances + limits[2] + return select( + [ani > add, ani > higher, ani > basic], + [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], + default=TaxBand.NONE, + ) + + +def scottish_tax_band(person, period, parameters): + """The highest band in which a Scottish taxpayer has taxable income. + + Use Scottish limits for income other than savings and dividends. + Parliament applies Scottish rates to this income (ITA 2007 s. 11A and the + Scottish rate resolutions). Treat savings and dividends as the top + slices of income (s. 16) and apply the rest-of-UK limits, since they + bear the rates they would bear outside Scotland (ss. 11D(6) and 13(5)). + Compare the rest-of-UK band only when taxable savings or dividends + remain, then return the higher band. Keep income at each upper limit in + the lower band. Parliament charges income "up to" a limit at the lower + rate (s. 10(2) to (3A)), and the Scottish Parliament charges each Scottish + rate "on income above" one limit "and up to" the next (each Scottish rate + resolution). Keep income charged at a nil rate in the band where it sits, + as for every taxpayer; the Marriage Allowance has its own rate test + (liable_only_at_marriage_allowance_rates). + """ + rates = parameters(period).gov.hmrc.income_tax.rates + gross_gift = person("gift_aid_grossed_up", period) + scale = rates.scotland.rates + if len(scale.thresholds) == 3: + # 2017-18: the Scottish basic, higher and additional rates. + scottish_bands = [TaxBand.BASIC, TaxBand.HIGHER, TaxBand.ADDITIONAL] + else: + # From 2018-19: the starter, basic, intermediate and higher rates, + # then the top rate, which the advanced rate precedes from 2024-25. + scottish_bands = [ + TaxBand.STARTER, + TaxBand.BASIC, + TaxBand.INTERMEDIATE, + TaxBand.HIGHER, + ] + [TaxBand.ADDITIONAL] * (len(scale.thresholds) - 4) + labels = np.array([band.index for band in [TaxBand.NONE] + scottish_bands]) + # Income after the allowances set against it (s. 25(2)), as in + # earned_income_tax. Parliament gives the Marriage Allowance as a tax + # reduction (s. 55B(1)), so it does not move the band. + non_savings = person("non_savings_income_after_allowances", period) + # Parliament raises the Scottish basic rate's upper limit and every limit + # above it by the grossed-up gift (s. 414(2)(b)). + first_extended = first_extended_scottish_threshold(scale) + limits_exceeded = sum( + non_savings > threshold + (gross_gift if index >= first_extended else 0) + for index, threshold in enumerate(scale.thresholds) + ) + scottish_band = labels[limits_exceeded] + uk_limits = rates.uk.thresholds + taxable_income = person("net_income", period) - person("allowances", period) + # Savings and dividends make up the taxable income above the non-savings + # income. Parliament raises the basic and higher rate limits by the gift + # too (s. 414(2)(b)). + uk_band = select( + [ + taxable_income <= non_savings, + taxable_income > uk_limits[2] + gross_gift, + taxable_income > uk_limits[1] + gross_gift, + ], + [TaxBand.NONE.index, TaxBand.ADDITIONAL.index, TaxBand.HIGHER.index], + default=TaxBand.BASIC.index, + ) + bands = np.array(list(TaxBand), dtype=object) + return bands[max_(scottish_band, uk_band)] + + class tax_band(Variable): value_type = Enum possible_values = TaxBand default_value = TaxBand.NONE entity = Person label = "Tax band of the individual" - definition_period = YEAR - reference = dict( - title="Income Tax Act 2007, s. 10", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/10", + documentation = ( + "The highest band in which the person has taxable income. For a " + "Scottish taxpayer the model takes the higher of the Scottish band " + "of their income other than savings and dividends and the rest-of-UK " + "band of their savings and dividends. Savings and dividends charged " + "at a nil rate count where they sit. ADDITIONAL covers the Scottish " + "advanced and top rates and the dividend additional rate; HIGHER " + "covers the dividend upper rate." ) + definition_period = YEAR + reference = [ + dict( + title="Income Tax Act 2007, s. 10", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/10", + ), + dict( + title="Income Tax Act 2007, s. 11A", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/11A", + ), + dict( + title="Income Tax Act 2007, s. 13(5)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/13", + ), + dict( + title="Scottish Rate Resolution 2025-26 (S6M-16531)", + href="https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-16531", + ), + ] def formula(person, period, parameters): - allowances = person("allowances", period) - ani = person("net_income", period) - rates = parameters(period).gov.hmrc.income_tax.rates - gross_gift = person("gift_aid_grossed_up", period) - limits = [ - threshold + (gross_gift if index in (1, 2) else 0) - for index, threshold in enumerate(rates.uk.thresholds) - ] - basic = allowances + limits[0] - higher = allowances + limits[1] - add = allowances + limits[2] - return select( - [ani > add, ani > higher, ani > basic], - [TaxBand.ADDITIONAL, TaxBand.HIGHER, TaxBand.BASIC], - default=TaxBand.NONE, - ) + return uk_tax_band_above_limits(person, period, parameters) - def formula_2017_04_06(person, period, parameters): - allowances = person("allowances", period) - ani = person("net_income", period) - rates = parameters(period).gov.hmrc.income_tax.rates - scot = person("pays_scottish_income_tax", period) - income = ani - allowances - gross_gift = person("gift_aid_grossed_up", period) - # s.414(2)(b) extends the limits used to determine the tax band. - uk_band = select( - [ - income < threshold + (gross_gift if index > 0 else 0) - for index, threshold in enumerate(rates.uk.thresholds[:3]) - ], - [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], - default=TaxBand.ADDITIONAL, + def formula_2017(person, period, parameters): + # Annual formulas start on 1 January, so this runs from 2017-18, when + # the Scottish Parliament first set its own band limits. + return where( + person("pays_scottish_income_tax", period), + scottish_tax_band(person, period, parameters), + uk_tax_band_above_limits(person, period, parameters), ) - first_extended = first_extended_scottish_threshold(rates.scotland.rates) - scottish_band = select( - [ - income < threshold + (gross_gift if index >= first_extended else 0) - for index, threshold in enumerate(rates.scotland.rates.thresholds[:3]) - ], - [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], - default=TaxBand.ADDITIONAL, - ) - return where(scot, scottish_band, uk_band) - def formula_2018_06_01(person, period, parameters): + def formula_2018(person, period, parameters): allowances = person("allowances", period) ani = person("net_income", period) rates = parameters(period).gov.hmrc.income_tax.rates - scot = person("pays_scottish_income_tax", period) income = ani - allowances gross_gift = person("gift_aid_grossed_up", period) # s.414(2)(b) extends the limits used to determine the tax band. @@ -84,18 +157,8 @@ def formula_2018_06_01(person, period, parameters): [TaxBand.NONE, TaxBand.BASIC, TaxBand.HIGHER], default=TaxBand.ADDITIONAL, ) - scottish_band = select( - [ - income < threshold + (gross_gift if index >= 2 else 0) - for index, threshold in enumerate(rates.scotland.rates.thresholds[:5]) - ], - [ - TaxBand.NONE, - TaxBand.STARTER, - TaxBand.BASIC, - TaxBand.INTERMEDIATE, - TaxBand.HIGHER, - ], - default=TaxBand.ADDITIONAL, + return where( + person("pays_scottish_income_tax", period), + scottish_tax_band(person, period, parameters), + uk_band, ) - return where(scot, scottish_band, uk_band) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py index 201cc012df..57183792c2 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/trade_loss_relief_against_savings_and_dividends.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax_allocation import non_savings_deductions class trade_loss_relief_against_savings_and_dividends(Variable): @@ -6,15 +7,16 @@ class trade_loss_relief_against_savings_and_dividends(Variable): entity = Person label = "Trade loss relief deducted from savings and dividend income" documentation = ( - "The part of trade loss relief against general income left after " - "non-savings income is used up; it joins the allowances applied to " - "savings income and then to dividends. ITA 2007 s.25(2) deducts " - "reliefs and allowances in the order giving the greatest reduction in " - "liability, and from 2027-28 s.25(3A) puts income other than property, " - "savings and dividends first. The model uses its fixed allowance order " - "(non-savings income, property last, then savings, then dividends), " - "which can overcharge where savings are taxed at 0%; that is model-wide " - "and tracked in #2106." + "The part of trade loss relief against general income that Income Tax " + "deducts from savings and dividend income. The taxpayer deducts " + "reliefs and allowances in the way that gives the least tax (ITA 2007 " + "s. 25(2)), and from 2027-28 deducts them from income other than " + "property, savings and dividend income first (s. 25(3A)); " + "received_allowances_savings_income and " + "received_allowances_dividend_income hold that share. The relief comes " + "off non-savings income first, as far as the share sets anything " + "against that income, and this variable holds the rest. net_income " + "deducts the whole relief, so the non-savings bases add this back." ) definition_period = YEAR unit = GBP @@ -24,10 +26,6 @@ class trade_loss_relief_against_savings_and_dividends(Variable): ) def formula(person, period, parameters): - non_savings_income = ( - person("net_income_before_trade_loss_relief", period) - - person("taxable_savings_interest_income", period) - - person("taxable_dividend_income", period) - ) - relief = person("trade_loss_relief_against_general_income", period) - return max_(0, relief - max_(0, non_savings_income)) + return non_savings_deductions( + person, period + ).relief_against_savings_and_dividends diff --git a/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py new file mode 100644 index 0000000000..64f48ee6e7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_before_child_benefit_terminal_date.py @@ -0,0 +1,20 @@ +from policyengine_uk.model_api import * + + +class is_before_child_benefit_terminal_date(Variable): + value_type = bool + entity = Person + label = "Before the Child Benefit terminal date after leaving education" + documentation = ( + "Whether, for some of the year, the person has left relevant " + "education or approved training and the week that includes their " + "terminal date has not ended. The terminal date is the last day of " + "February, May, August or November, whichever comes first after they " + "left, or after their last external examination if they were entered " + "for one (Child Benefit (General) Regulations 2006 reg. 7(2), Cases 1 " + "and 2). The model holds one education status for the year and no " + "leaving date, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/7" diff --git a/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py new file mode 100644 index 0000000000..8114bf40e2 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_child_benefit_payable_for_person.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class is_child_benefit_payable_for_person(Variable): + value_type = bool + entity = Person + label = "Child Benefit is payable for this person" + documentation = ( + "Whether the benefit unit's Child Benefit includes an amount for this " + "person: they are its child or qualifying young person, and Child " + "Benefit is not barred by remunerative work. Child Benefit is not " + "payable for a qualifying young person 'by virtue of' the terminal " + "date rule for any week they are in remunerative work (Child Benefit " + "(General) Regulations 2006 reg. 7(3)). So a young person who " + "qualifies only through the terminal date, and works 24 hours or " + "more a week, stays a qualifying young person (and a Housing Benefit " + "young person) but brings no Child Benefit. One who also meets " + "another condition is paid through that one (reg. 2(2))." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + ) + + def formula(person, period, parameters): + another_condition = ( + ( + person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) + & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + ) + | person("is_before_first_september_following_16th_birthday", period) + | person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + ) + only_through_terminal_date = ( + person("is_qualifying_young_person_for_child_benefit", period) + & ~person("is_child_for_child_benefit", period) + & ~another_condition + ) + barred = only_through_terminal_date & person( + "is_in_remunerative_work_for_child_benefit", period + ) + return ( + person("is_child_or_qualifying_young_person_for_child_benefit", period) + & ~barred + ) diff --git a/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py new file mode 100644 index 0000000000..beb5ed3938 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_child_benefit_extension_period.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class is_in_child_benefit_extension_period(Variable): + value_type = bool + entity = Person + label = "In a Child Benefit extension period" + documentation = ( + "Whether the person is in a Child Benefit extension period in the " + "year: they have ceased education or training, are registered for " + "work, education or training with a qualifying body (the Careers " + "Service, Connexions or the Ministry of Defence), the person " + "responsible for them was entitled to Child Benefit for them " + "immediately before and asked for the extension within three months, " + "and the 20 weeks from the week after they left have not run out " + "(Child Benefit (General) Regulations 2006 reg. 5(2)(a), (b), (d), " + "(e) and (f), and (3)). The model applies the age limit (reg. 5(1)) " + "and the bar on remunerative work (reg. 5(2)(c)). Surveys do not " + "record registration or the request, so this is false unless set." + ) + definition_period = YEAR + default_value = False + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" diff --git a/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py new file mode 100644 index 0000000000..10a05ee191 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/is_in_remunerative_work_for_child_benefit.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class is_in_remunerative_work_for_child_benefit(Variable): + value_type = bool + entity = Person + label = "In remunerative work for Child Benefit" + documentation = ( + "Whether the person works at least 24 hours a week for payment or in " + "expectation of payment (Child Benefit (General) Regulations 2006 " + "reg. 1(3)), read from average weekly hours over the year." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/1" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return person("weekly_hours", period) >= p.remunerative_work_hours diff --git a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py index 898a91b7cd..efc736e199 100644 --- a/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py +++ b/policyengine_uk/variables/gov/hmrc/is_qualifying_young_person_for_child_benefit.py @@ -5,25 +5,85 @@ class is_qualifying_young_person_for_child_benefit(Variable): value_type = bool entity = Person label = "Qualifying young person for Child Benefit" + documentation = ( + "A person aged 16 to 19 who meets any of the conditions in Child " + "Benefit (General) Regulations 2006 regs 3 to 7 (reg. 2(2): a " + "qualifying young person until the last of them ceases to be met) " + "and is not receiving a benefit in their own right (reg. 8). The " + "conditions modelled are: non-advanced education or approved " + "training, begun before 19 (reg. 3); the period to the 31 August " + "after the 16th birthday, whatever their education (reg. 4); an " + "extension period for a 16- or 17-year-old (reg. 5); and the period " + "to the terminal date after leaving education (reg. 7). Reg. 4 " + "reads is_before_first_september_following_16th_birthday: its 31 " + "August (1 September for a 16th birthday on 31 August) falls in the " + "same fiscal year as Universal Credit's 1 September, so a year that " + "starts before one starts before the other. Interruptions (reg. 6) " + "are not modelled. Reg. 8 reads the own-right benefits input, which " + "covers Universal Credit, Employment and Support Allowance and " + "Jobseeker's Allowance, the person's own reported Universal " + "Credit, Employment and Support Allowance of either kind and " + "income-based Jobseeker's Allowance, the awards reg. 8(2)(b), (e) " + "and (f) list, and receives_income_support_in_own_right (reg. " + "8(2)(a)). It does not read the incapacity benefit and tax credits " + "that reg. 8 also lists. A positive report for the year ends the " + "status for the whole year." + ) definition_period = YEAR reference = [ "https://www.legislation.gov.uk/ukpga/1992/4/section/142", - "https://www.legislation.gov.uk/uksi/2006/223", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/2", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/3", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/4", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/5", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/7", + "https://www.legislation.gov.uk/uksi/2006/223/regulation/8", ] def formula(person, period, parameters): + # Reg. 3: education or training, begun before 19. + in_education_or_training = person( + "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", + period, + ) & person( + "meets_qualifying_young_person_entry_condition_for_child_benefit", + period, + ) + # Reg. 4: until the 31 August after the 16th birthday. A person in + # that period is under 17, as reg. 4(1) requires. + before_31_august_following_16th_birthday = person( + "is_before_first_september_following_16th_birthday", period + ) + # Reg. 5: the extension period. + in_extension_period = person( + "meets_qualifying_young_person_extension_period_condition_for_child_benefit", + period, + ) + # Reg. 7: until the terminal date after leaving. + before_terminal_date = person("is_before_child_benefit_terminal_date", period) + # Reg. 8(2)(b), (e) and (f): income-based JSA, ESA and Universal + # Credit. Reports are inputs, so reading them makes no cycle. + receives_own_benefits = ( + person("receives_benefits_in_own_right", period) + | (person("universal_credit_reported", period) > 0) + | (person("esa_income_reported", period) > 0) + | (person("esa_contrib_reported", period) > 0) + | (person("jsa_income_reported", period) > 0) + ) return ( person( "meets_qualifying_young_person_age_condition_for_child_benefit", period, ) - & person( - "meets_qualifying_young_person_education_or_training_condition_for_child_benefit", - period, - ) - & person( - "meets_qualifying_young_person_entry_condition_for_child_benefit", - period, + & ( + in_education_or_training + | before_31_august_following_16th_birthday + | in_extension_period + | before_terminal_date ) - & ~person("receives_benefits_in_own_right", period) + & ~receives_own_benefits + # Reg. 8(2)(a). The reported award, not is_on_income_support: + # the family's Income Support reads this test, so the calculated + # award would make a cycle. + & ~person("receives_income_support_in_own_right", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py new file mode 100644 index 0000000000..592e0189f7 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/meets_qualifying_young_person_extension_period_condition_for_child_benefit.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class meets_qualifying_young_person_extension_period_condition_for_child_benefit( + Variable +): + value_type = bool + entity = Person + label = "Meets qualifying young person extension period condition for Child Benefit" + documentation = ( + "A person under 18 who has left education or training, is registered " + "with a qualifying body and is not in remunerative work, for the 20 " + "weeks of the extension period their parent or carer asked for." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/5" + + def formula(person, period, parameters): + p = parameters(period).gov.hmrc.child_benefit.eligibility + return ( + (person("age", period) < p.extension_period_age_limit) + & person("is_in_child_benefit_extension_period", period) + & ~person("is_in_remunerative_work_for_child_benefit", period) + ) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index e450df98a9..eb345f2fb5 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -5,18 +5,24 @@ class sdlt_liable(Variable): label = "Liable for Stamp Duty" documentation = ( "Whether the household is liable for Stamp Duty Land Tax: it lives in " - "England or Northern Ireland and the tax has not been abolished." + "England or Northern Ireland, or in Wales while HMRC charges the tax " + "there, and no reform sets gov.hmrc.stamp_duty.abolish." ) entity = Household definition_period = YEAR value_type = bool + reference = "https://www.legislation.gov.uk/ukpga/2003/14/section/48/2017-04-30" def formula(household, period, parameters): + stamp_duty = parameters(period).gov.hmrc.stamp_duty country = household("country", period) countries = country.possible_values in_england_or_northern_ireland = np.isin( country.decode_to_str(), [countries.ENGLAND.name, countries.NORTHERN_IRELAND.name], ) - abolished = parameters(period).gov.hmrc.stamp_duty.abolish - return in_england_or_northern_ireland & (not abolished) + in_wales = country == countries.WALES + in_charged_area = in_england_or_northern_ireland | ( + in_wales & stamp_duty.applies_in_wales + ) + return in_charged_area & (not stamp_duty.abolish) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py b/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py index 6f2fd883d2..97dd720bf9 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_on_non_residential_property_transactions.py @@ -3,7 +3,15 @@ class sdlt_on_non_residential_property_transactions(Variable): label = "Stamp Duty on non-residential property" - documentation = "Tax charge from purchase of non-residential property" + documentation = ( + "Tax charge from purchase of non-residential property. When " + "gov.hmrc.stamp_duty.non_residential.purchase_charged_on_whole_price " + "holds true, HMRC charges the whole price at the rate for the band " + "the price falls in: the highest band whose threshold the price " + "exceeds, or no band, and so no tax, when the price exceeds no " + "threshold. When it holds false, HMRC charges each band's rate on the " + "slice of the price in that band." + ) entity = Household definition_period = YEAR value_type = float @@ -11,6 +19,20 @@ class sdlt_on_non_residential_property_transactions(Variable): reference = "https://www.legislation.gov.uk/ukpga/2003/14/section/55" def formula(household, period, parameters): - stamp_duty = parameters(period).gov.hmrc.stamp_duty + non_residential = parameters(period).gov.hmrc.stamp_duty.non_residential price = household("non_residential_property_purchased", period) - return stamp_duty.non_residential.purchase.calc(price) + scale = non_residential.purchase + thresholds = np.array(scale.thresholds, dtype=float) + rates = np.array(scale.rates, dtype=float) + # Each band covers prices that exceed its threshold and do not exceed + # the next one. A price that exceeds no threshold falls in no band. + exceeds = np.asarray(price, dtype=float)[:, None] > thresholds[None, :] + band = exceeds.sum(axis=1) - 1 + whole_price_rate = where(band >= 0, rates[max_(band, 0)], 0) + whole_price_tax = whole_price_rate * price + slice_tax = scale.calc(price) + return where( + non_residential.purchase_charged_on_whole_price, + whole_price_tax, + slice_tax, + ) 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 c9f4403b45..daf7a3a831 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 @@ -3,7 +3,12 @@ class sdlt_on_residential_property_transactions(Variable): label = "Stamp Duty on residential property" - documentation = "Tax charge from purchase of residential property" + documentation = ( + "Tax charge from purchase of residential property. HMRC charges a " + "purchase of an additional dwelling at the higher rates when " + "gov.hmrc.stamp_duty.residential.purchase.additional.higher_rates_apply " + "holds true, and at the main rates when it holds false." + ) entity = Household definition_period = YEAR value_type = float @@ -23,16 +28,16 @@ def formula(household, period, parameters): stamp_duty.residential.purchase.main.first.rate.calc(price), stamp_duty.residential.purchase.main.subsequent.calc(price), ) - # Tax on second-home purchases + # Tax on second-home purchases: the higher rates for additional + # dwellings once they apply, and the main rates before then. + additional = stamp_duty.residential.purchase.additional second_home_price = household( "additional_residential_property_purchased", period ) - price = where( - second_home_price < stamp_duty.residential.purchase.additional.min, - 0, - second_home_price, - ) - additional_residential_purchase_tax = ( - stamp_duty.residential.purchase.additional.rate.calc(price) + chargeable = where(second_home_price < additional.min, 0, second_home_price) + additional_residential_purchase_tax = where( + additional.higher_rates_apply, + additional.rate.calc(chargeable), + stamp_duty.residential.purchase.main.subsequent.calc(second_home_price), ) return main_residential_purchase_tax + additional_residential_purchase_tax diff --git a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py index cd24f7df64..6be67396a3 100644 --- a/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py +++ b/policyengine_uk/variables/gov/hmrc/student_loans/student_loan_repayment.py @@ -13,7 +13,9 @@ class student_loan_repayment(Variable): "Annual student loan repayment calculated from income and plan type. " "Repayments are 9% of income above threshold for Plans 1/2/4/5, " "and 6% for Postgraduate loans, capped by the outstanding balance " - "when that balance is available." + "when that balance is available. Plan 5 borrowers repay nothing " + "before 6 April 2026 (SI 2009/470 reg. 15(2BA) and (2BB)), so the " + "formula charges them nil for 2025-26 and earlier years." ) definition_period = YEAR unit = GBP @@ -46,6 +48,10 @@ def formula(person, period, parameters): rate = person("student_loan_repayment_rate", period) uncapped_repayment = rate * max_(0, income - threshold) + # SI 2009/470 reg. 15(2BA) and (2BB): no Plan 5 repayment is due on or + # before 5 April 2026. + repayment_due = (plan != StudentLoanPlan.PLAN_5) | p.repayments_due.plan_5 + uncapped_repayment = where(repayment_due, uncapped_repayment, 0) return where(balance > 0, min_(uncapped_repayment, balance), uncapped_repayment) 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 1b7a3c2885..3488834bbe 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 @@ -4,6 +4,21 @@ ) +def in_other_non_liable_family(person, period): + """Whether each person is outside every family claiming Council Tax + Reduction for the household, outside every family liable for a share of + its rent, and not paying the householder for their own occupation: all + such people aged 18 or over are the applicants' non-dependants (SI + 2012/2885 reg 9(2)(a), (d)-(e) and the Welsh and Scottish equivalents). + The eligibility for a non-dependant deduction and the local schemes' + aggregation both use this, so they stay on one definition.""" + return ( + ~person.benunit("council_tax_reduction_claimant_benunit", period) + & ~person.benunit("liable_for_share_of_household_rent", period) + & ~person("pays_rent_to_householder", period) + ) + + def is_full_time_student_non_dep(person, period): return (person("current_education", period) != EducationType.NOT_IN_EDUCATION) | ( person("in_HE", period) @@ -116,7 +131,14 @@ def local_non_dep_deductions( individual_deduction_variable, one_deduction_for_uc_couples=True, ): - deductions = benunit.members(individual_deduction_variable, period) + # The local schemes still charge only the non-dependants of other + # families. Non-dependants within a family's own benefit unit, now + # eligible for the national schemes, are left out here until each + # council's aggregation is brought into line. + person = benunit.members + deductions = person(individual_deduction_variable, period) * ( + in_other_non_liable_family(person, period) + ) deduction_for_benunit = benunit.max(deductions) if not one_deduction_for_uc_couples: has_uc = benunit("universal_credit", period) > 0 diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_national_non_dependants.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_national_non_dependants.py new file mode 100644 index 0000000000..e5bc21bb04 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/_national_non_dependants.py @@ -0,0 +1,188 @@ +"""National Council Tax Reduction non-dependant deductions under a given +applicant's scheme. Each applicant's scheme follows their own family +(council_tax_reduction_pensioner), so a person's deduction can differ between +two applicants in one household: England deducts under its pensioner scheme +only (working-age applicants are in the local schemes), Wales deducts for both +members of a Universal Credit couple only from a working-age applicant (WSI +2013/3029 Sch 6 para 5(3)), and Scotland's partner rule applies only in its +working-age scheme (SSI 2021/249 reg 90(8)(a)).""" + +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + has_earned_income, + is_on_legacy_income_related_benefit, + non_dependant_weekly_gross_income, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + is_full_time_student_non_dep, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_england_pensioner_scheme, + is_scotland_scheme, + is_wales_scheme, +) + + +def household_head_family_is_pensioner(person, period): + """The scheme of the household head's family, which the person-level + variables report.""" + head_family = person.benunit("benunit_contains_household_head", period) + pensioner = person.benunit("council_tax_reduction_pensioner", period) + return person.household.any(head_family & pensioner) + + +def is_qualifying_income_related_benefit_claimant( + person, period, parameters, pensioner_applicant +): + """Whether the person is on Income Support, income-based JSA or + income-related ESA for a scheme's non-dependant rules, under the claim of + an applicant who is (pensioner_applicant) or is not a pensioner. Such a + person is not in remunerative work (SI 2012/2885 reg 10(6); WSI 2013/3029 + reg 10(6); SSI 2012/319 reg 6(6); SSI 2021/249 reg 10(6)) and has no + deduction (Sch 1 para 8(8)(a) and equivalents). In Scotland's working-age + scheme from April 2022 this is a "qualifying income-related benefit + claimant", defined in SSI 2021/249 reg 4(1) as one "who is, or who has a + partner who is," on one of those benefits "and is not on universal + credit". It is read for the non-dependant, as the definition's + "applicant" would leave reg 90(8)(a) no application.""" + scotland = parameters(period).gov.local_authorities.scotland.council_tax_reduction + country = person.household("country", period) + claimant_or_partner = person("is_claimant_or_partner", period) + on_benefit = is_on_legacy_income_related_benefit(person, period) + couple_on_benefit = claimant_or_partner & ( + person.benunit.any(on_benefit & claimant_or_partner) + ) + entitled_to_universal_credit = person("is_uc_assessed_claimant", period) & ( + person.benunit("universal_credit_pre_benefit_cap", period) > 0 + ) + partner_rule = ( + is_scotland_scheme(country) + & ~pensioner_applicant + & scotland.non_dep_deduction.working_age_exempt_partner_of_income_related_benefit_claimant + ) + return where( + partner_rule, + (on_benefit | couple_on_benefit) & ~entitled_to_universal_credit, + on_benefit, + ) + + +def non_dep_deduction_exempt(person, period, parameters, pensioner_applicant): + """Whether a national scheme makes no deduction for this person from the + claim of an applicant who is (pensioner_applicant) or is not a pensioner.""" + local_authorities = parameters(period).gov.local_authorities + england = local_authorities.england.council_tax_reduction.pensioners + scotland = local_authorities.scotland.council_tax_reduction + wales = local_authorities.wales.council_tax_reduction + country = person.household("country", period) + schemes = [ + is_england_pensioner_scheme(country, pensioner_applicant), + is_scotland_scheme(country), + is_wales_scheme(country), + ] + full_time_student = is_full_time_student_non_dep(person, period) + # IS, income-based JSA, income-related ESA and SPC count for the person + # they are payable to; Universal Credit for both joint claimants. + claimant_or_partner = person("is_claimant_or_partner", period) + on_pension_credit = person("is_on_pension_credit", period) + entitled_to_universal_credit = person("is_uc_assessed_claimant", period) & ( + person.benunit("universal_credit_pre_benefit_cap", period) > 0 + ) + qualifying_income_related_benefit_claimant = ( + is_qualifying_income_related_benefit_claimant( + person, period, parameters, pensioner_applicant + ) + ) + # LGFA 1992 Sch 1 para 3 (via para 8(8)(b) and equivalents): an adult + # "in respect of whom another person is entitled to child benefit". + # Entitlement needs a claim (SSAA 1992 s.1(1)); an election not to be + # paid keeps it (s.13A), so the opt-out flag is not read. + entitled_to_child_benefit_for = ( + add(person, period, ["child_benefit_respective_amount"]) > 0 + ) & person.benunit("would_claim_child_benefit", period) + disregarded_for_child_benefit = ( + (person("age", period) >= 18) + & entitled_to_child_benefit_for + & ~claimant_or_partner + ) + universal_credit_limb = select( + schemes, + [ + england.non_dep_deduction.exempt_universal_credit_without_earned_income, + scotland.non_dep_deduction.exempt_universal_credit_without_earned_income, + wales.non_dep_deduction.exempt_universal_credit_without_earned_income, + ], + default=False, + ) + universal_credit_without_earned_income = ( + universal_credit_limb + & entitled_to_universal_credit + & ~has_earned_income(person, period) + ) + return ( + full_time_student + | qualifying_income_related_benefit_claimant + | on_pension_credit + | disregarded_for_child_benefit + | universal_credit_without_earned_income + ) + + +def individual_non_dep_deduction(person, period, parameters, pensioner_applicant): + """A person's annual deduction from the claim of an applicant who is + (pensioner_applicant) or is not a pensioner, before eligibility. Each + scheme's own weekly scale; the band containing normal gross weekly income + in remunerative work, bands including their lower edge ("not less than"); + the lowest amount otherwise (Sch 1 para 8(1)(b) and equivalents); nothing + if exempt.""" + local_authorities = parameters(period).gov.local_authorities + england = ( + local_authorities.england.council_tax_reduction.pensioners.non_dep_deduction + ) + scotland = local_authorities.scotland.council_tax_reduction.non_dep_deduction + wales = local_authorities.wales.council_tax_reduction.non_dep_deduction + country = person.household("country", period) + schemes = [ + is_england_pensioner_scheme(country, pensioner_applicant), + is_scotland_scheme(country), + is_wales_scheme(country), + ] + weekly_income = non_dependant_weekly_gross_income( + person, period, parameters, "council_tax_reduction_non_dep_gross_income" + ) + no_income = np.zeros_like(weekly_income) + remunerative_work_hours = select( + schemes, + [ + england.remunerative_work_hours, + scotland.remunerative_work_hours, + wales.remunerative_work_hours, + ], + default=np.inf, + ) + in_remunerative_work = ( + person("weekly_hours", period) >= remunerative_work_hours + ) & ~is_qualifying_income_related_benefit_claimant( + person, period, parameters, pensioner_applicant + ) + banded = select( + schemes, + [ + england.amount.calc(weekly_income), + scotland.amount.calc(weekly_income), + wales.amount.calc(weekly_income), + ], + default=0, + ) + not_in_remunerative_work = select( + schemes, + [ + england.amount.calc(no_income), + scotland.amount.calc(no_income), + wales.amount.calc(no_income), + ], + default=0, + ) + weekly = where(in_remunerative_work, banded, not_in_remunerative_work) + exempt = non_dep_deduction_exempt(person, period, parameters, pensioner_applicant) + return where(exempt, 0, weekly * WEEKS_IN_YEAR) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicable_income.py index 5af65bdeab..c79cc478a5 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 @@ -43,18 +43,32 @@ class council_tax_reduction_applicable_income(Variable): "children or young persons. We disregard war pensions and Armed " "Forces Compensation Scheme payments in full: Scottish councils count " "none of them, and English and Welsh councils may relieve them beyond " - "the prescribed £10 a week. No scheme " - "counts income derived from capital, such as rent from property, " - "interest and dividends, as income, so it is not listed and tax on it " - "is not deducted (legacy_means_test_income_tax). The pensioner schemes " - "disregard any actual income from capital; the Welsh working-age " - "scheme and the English default scheme treat it as capital; the " - "Scottish working-age scheme counts only the unearned income it " - "lists, which includes the assumed yield from capital but not actual " - "rent, interest or dividends. Instead, the national schemes (England " - "for pensioners, Wales and Scotland) count the tariff income they " - "treat capital as yielding (council_tax_reduction_tariff_income); the " - "English local schemes for people under pension age add their own. " + "the prescribed £10 a week. " + "The formula counts no income derived from capital, such as rent " + "from property other than the home, interest and dividends. In each " + "pensioner scheme, a council disregards any actual income from " + "capital, except income from the capital that Part 2 of the capital " + "Schedule lists: life interests and liferents, rights to rent where " + "the applicant has no reversionary interest, annuities, and property " + "held under a trust that pays or may pay the applicant or partner. It " + "disregards that income too while such capital totals £10,000 or " + "less. The model has no input that marks capital as Part 2 capital, " + "and other_investment_income takes in interest from trusts and " + "settlements, so on the pensioner route the formula counts none of " + "the interest, other investment income, dividends or rent from " + "property, and council_tax_reduction_tax_deduction deducts no tax on " + "any of it. The model makes that general approximation of the income " + "rules: where Part 2 capital above £10,000 yields such income, a " + "council counts it and the model doesn't. The Welsh working-age " + "scheme and the English default scheme treat income from capital as " + "capital; the Scottish working-age scheme counts only the unearned " + "income it lists, which includes the assumed yield from capital but " + "not actual rent, interest or dividends. Instead, the national " + "schemes (England for pensioners, Wales and Scotland) count the " + "tariff income they treat capital as yielding " + "(council_tax_reduction_tariff_income), and in the pensioner schemes " + "a council leaves Part 2 capital out of it. The English local schemes " + "for people under pension age add their own tariff income. " "Rent the applicant and partner receive for letting part of the home " "counts, less the sub-tenant disregard, on the schemes' own income " "rules (council_tax_reduction_home_letting_income), with rent from " @@ -64,10 +78,14 @@ class council_tax_reduction_applicable_income(Variable): "whose income the schemes take from the Secretary of State's " "Universal Credit assessment (which does not count it), or in the " "Scottish scheme for people under pension age from April 2022, which " - "does not list it. There the rent is uncounted income like income " - "from capital: the tax deducted leaves out the tax the rent would " - "bear without earnings (rent_a_room_income_tax_without_earnings) and " - "keeps the extra tax earnings cause on it." + "does not list it. " + "The formula deducts the income tax and National Insurance each " + "scheme allows (council_tax_reduction_tax_deduction): on earnings " + "only in Scotland's working-age scheme, on income taken into account " + "in the pensioner schemes, and in the other schemes the income tax " + "the legacy means tests deduct (legacy_means_test_deducted_income_tax), " + "less, on the Universal Credit route, the tax uncounted rent would " + "bear without earnings (rent_a_room_income_tax_without_earnings)." ) definition_period = YEAR unit = GBP @@ -108,6 +126,9 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/9", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9", "https://www.legislation.gov.uk/ssi/2012/319/schedule/3", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/4/part/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/6/part/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/5/part/2", "https://www.legislation.gov.uk/ssi/2012/303/regulation/26", "https://www.legislation.gov.uk/ssi/2021/249/regulation/3", "https://www.legislation.gov.uk/ssi/2021/249/regulation/63", @@ -364,25 +385,11 @@ def formula(benunit, period, parameters): pension_contributions = add_for_members( benunit, period, ["pension_contributions"], members ) * benunit("council_tax_reduction_pension_contribution_deduction_rate", period) - # Where the scheme does not count the rent, the formula treats it as - # uncounted income like income from capital: it leaves out of the - # deduction the tax the rent would bear without earnings, and keeps - # the extra tax earnings cause on it. - income_tax = add_for_members( - benunit, period, ["legacy_means_test_deducted_income_tax"], members - ) - where( - counts_home_letting, - 0, - add_for_members( - benunit, period, ["rent_a_room_income_tax_without_earnings"], members - ), - ) - # The deduction can be negative for one person, where another - # member's earnings move a Step 7 charge off them; the test deducts the - # counted members' sum, floored at nil, so it never adds tax to income. - tax = max_(0, income_tax) + add_for_members( - benunit, period, ["national_insurance"], members - ) + # The income tax and National Insurance each scheme allows: on earnings + # only in Scotland's working-age scheme, on income taken into account + # in the pensioner schemes, and in the other schemes what the legacy + # means tests deduct (council_tax_reduction_tax_deduction). + tax = benunit("council_tax_reduction_tax_deduction", period) # SI 2012/2885 Sch 1 para 17(9) and Sch 4; WSI 2013/3029 Sch 1 para # 11(9) and Sch 3; SSI 2012/319 reg 31(8) and Sch 2: the pensioner # earnings disregards, capped at net earnings. Zero for working-age diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py index 53c4079bea..f2483e0bbf 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_applicant_has_non_dep_exemption.py @@ -6,15 +6,15 @@ class council_tax_reduction_applicant_has_non_dep_exemption(Variable): entity = BenUnit label = "No non-dependant deductions from this family's Council Tax Reduction" documentation = ( - "Whether no deduction is made for any non-dependant from this family's " + "Whether the council makes no deduction for any non-dependant from this family's " "Council Tax Reduction because the applicant or partner is blind, or " "receives in respect of themselves Attendance Allowance, the care " "component of Disability Living Allowance, the daily living component " "of Personal Independence Payment or Armed Forces Independence Payment. " "The exemption belongs to each applicant: where families share the " "rent and each claims, one family's disability does not exempt " - "another's claim. The model applies it in the English working-age " - "local schemes." + "another's claim. The model applies it in the national schemes and the " + "English working-age local schemes." ) definition_period = YEAR reference = ( 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 05115192ad..bc21a1e729 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 @@ -7,10 +7,11 @@ # The debt input secured on each capital source. The schemes deduct "the # amount of any encumbrance secured on it" from that asset after the sale # expenses, so a source is never valued below nil and negative equity in one -# source does not reduce another. Land has no secured-debt input. +# source does not reduce another. SECURED_DEBT = { "other_residential_property_value": "other_residential_property_secured_debt", "non_residential_property_value": "non_residential_property_secured_debt", + "owned_land": "owned_land_secured_debt", } @@ -39,8 +40,8 @@ class council_tax_reduction_assessable_capital(Variable): "the applicant's: savings, land and property other than the home " "(gov.local_authorities.council_tax_reduction.capital.sources), with " "land and property valued at market value less 10% for the expenses " - "of sale, then less any debt secured on that property (which defaults " - "to nil because the survey does not record it). The schemes also " + "of sale, then less any debt secured on that land or property (which " + "defaults to nil because the survey does not record it). The schemes also " "count shares and other investments, which in the data are mixed " "with pension wealth the schemes disregard, so they are left out for " "now. The recalculation when capital rises above the limit during an " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py new file mode 100644 index 0000000000..cda349ac2e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_carer_premium.py @@ -0,0 +1,107 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.household.demographic.country import Country + + +class council_tax_reduction_carer_premium(Variable): + value_type = float + entity = BenUnit + label = "Working-age CTR carer premium" + documentation = ( + "The carer premium in a working-age Council Tax Reduction applicable " + "amount. Outside Scotland's working-age scheme it equals carer_premium. " + "In Scotland's scheme (SSI 2021/249 Sch 1 paras 5 and 6, from 1 April " + "2022) councils pay it in respect of each applicant or partner who " + "has regular and substantial caring responsibilities for a severely " + "disabled person. The Scottish Ministers define those in para 6(1) " + "as (a) receiving Carer's Allowance or Carer Support Payment, (b) " + "being entitled to an allowance that DWP or Social Security Scotland " + "reduces to nil under the overlapping benefits rules, or (c) having " + "an award of Universal Credit which includes the carer element. " + "carer_premium tests limbs (a) and (b) " + "(is_entitled_to_carer_benefit). For limb (c), the Scottish Ministers " + "defined an award of Universal Credit from 1 April 2022 to include " + '"a universal credit award with a value of zero" (reg 4, as SSI ' + '2022/52 reg 3 amended it), and from 1 April 2025 as "an award of ' + 'universal credit made under Part 1 of the 2012 Act" (SSI 2025/24 ' + "reg 8). An income that tapers the amount payable below one penny " + "fails a financial condition of entitlement (Welfare Reform Act 2012 " + "s.5(1)(b); UC Regs 2013 reg 17), so DWP makes no award or the award " + "ends (UC Claims and Payments Regs 2013 reg 32A(1)), and there is no " + "award to have a value of zero. The Scottish Government's policy " + "note to SSI 2025/24 confines the earlier words to an award a " + "sanction reduces to zero, and the Scottish Ministers inserted reg " + "10A to bring part of the earnings case in from 1 April 2025. So in " + "every year from 2022-23 the model reads limb (c) from the award " + "itself (receives_uc_carer_element): an award of at least one penny " + "that includes the carer element in respect of this claimant. For " + "an award that earnings end, its last assessment period and any " + "reclaim months from 2022-23 to 2024-25 remain arguable; the model " + "cannot tell that award from a first claim, on which DWP makes no " + "award. From 1 April " + "2025 the Scottish Ministers also count two nil awards as awards (reg " + "10A): one a sanction reduces to nil, and one whose claimant the " + "Secretary of State treats as reclaiming after earnings end it (UC " + "Claims and Payments Regs 2013 reg 32A). The model has neither " + "sanctions nor reclaims, so it pays no premium for those, nor for " + "a sanctioned nil award from 2022-23 to 2024-25. A person " + "with both an allowance and a carer element qualifies once: councils " + "include the premium for each applicant or partner who qualifies " + "(paras 5(2) and (3)), not for each award. Partners caring for the " + "same severely disabled person " + "get one premium (para 5(3) and (4); " + "partners_care_for_same_severely_disabled_person), as in " + "carer_premium. Councils give it, under para 5(4), in respect of the " + "one whose Universal Credit award includes the carer element first, " + "then one in receipt of an allowance, then one entitled to an " + "allowance reduced to nil; that choice does not change the amount. " + "Before 1 April 2022 councils paid the premium to persons under " + "pension age on entitlement to Carer's Allowance only (SSI 2012/303 " + "Sch 1 para 14; carer_premium_counts_uc_carer_element)." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/5", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/1", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2022-04-01", + "https://www.legislation.gov.uk/ssi/2022/52/regulation/3/made", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/4/2025-04-01", + "https://www.legislation.gov.uk/ssi/2025/24/regulation/8/made", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/10A", + "https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/14/2021-04-01", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/uksi/2013/380/regulation/32A", + ) + + def formula(benunit, period, parameters): + carer_premium = benunit("carer_premium", period) + scotland = parameters( + period + ).gov.local_authorities.scotland.council_tax_reduction.working_age + if not scotland.carer_premium_counts_uc_carer_element: + return carer_premium + scottish_working_age = ( + benunit.household("country", period) == Country.SCOTLAND + ) & ~benunit("council_tax_reduction_pensioner", period) + person = benunit.members + claimant_or_partner = person("is_claimant_or_partner", period) + # SSI 2021/249 Sch 1 para 6(1)(c): an award of Universal Credit which + # includes the carer element. An earnings nil case has no award in + # any year (Welfare Reform Act 2012 s.5(1)(b); reg 4 and reg 10A). + carer_element_award = person("receives_uc_carer_element", period) + # Para 6(1)(a) and (b), as in carer_premium, or (c). + caring = person("is_entitled_to_carer_benefit", period) | carer_element_award + qualifying_carers = benunit.sum(claimant_or_partner & caring) + same_person = benunit("partners_care_for_same_severely_disabled_person", period) + qualifying_carers = where( + same_person, min_(qualifying_carers, 1), qualifying_carers + ) + amount = parameters(period).gov.dwp.carer_premium.single + return where( + scottish_working_age, + qualifying_carers * amount * WEEKS_IN_YEAR, + carer_premium, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_enhanced_disability_premium.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_enhanced_disability_premium.py new file mode 100644 index 0000000000..46823f1bae --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_enhanced_disability_premium.py @@ -0,0 +1,59 @@ +from policyengine_uk.model_api import * + + +class council_tax_reduction_enhanced_disability_premium(Variable): + value_type = float + entity = BenUnit + label = "Working-age CTR enhanced disability premium" + documentation = ( + "The model includes the enhanced disability premium in a working-age " + "Council Tax Reduction applicable amount at the single rate, or at the " + "couple rate where either or both members of a couple meet the " + "scheme's condition " + "(meets_council_tax_reduction_enhanced_disability_premium_condition). " + "It pays a different premium from the legacy one " + "(enhanced_disability_premium) in Scotland, Merton, Kingston upon " + "Thames, Newham and Westminster, where the scheme counts its own " + "awards. This variable pays the Housing Benefit amounts in every " + "scheme; Scotland's and Wales's working-age awards read only whether it " + "is payable and pay their own amounts " + "(council_tax_reduction_working_age_adult_premiums). Both 2026-27 " + "editions of Kingston upon Thames's scheme print £17.10 single and " + "£24.50 couple (Sch 3 para 17(5)), and their introductions say the " + "council uprates premiums as set out in the Prescribed Requirement " + "Scheme. In December 2013 the council delegated each year's uprating, " + "and the choice of its sources, to its Director of Finance, naming " + "the Government's general uprating documents where the pension-age " + "uprating lacks the detail. In July 2014 it kept its scheme for " + "2015-17 apart from a general uprating. The 2026 pension-age uprating " + "(S.I. 2026/27) has no adult enhanced premium. The model reads that " + "mechanism as giving the " + "Housing Benefit amounts, although the council's texts for 2021-22 to " + "2024-25 uprate the working-age personal allowances to the Housing " + "Benefit rates and keep this premium at its April 2020 rates; no " + "decision the model cites sets Kingston's own 2026-27 amounts. " + "Pension-age " + "Council Tax Reduction includes no adult enhanced disability premium." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/14", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/12", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/part/4", + ) + + def formula(benunit, period, parameters): + dis = parameters(period).gov.dwp.disability_premia + person = benunit.members + qualifies = benunit.any( + person("is_claimant_or_partner", period) + & person( + "meets_council_tax_reduction_enhanced_disability_premium_condition", + period, + ) + ) + single = benunit("is_single", period.this_year) + couple = benunit("is_couple", period.this_year) + weekly_amount = single * dis.enhanced_single + couple * dis.enhanced_couple + return weekly_amount * WEEKS_IN_YEAR * qualifies diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py new file mode 100644 index 0000000000..8000638d82 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_income_tax_on_other_counted_income.py @@ -0,0 +1,140 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pension_credit import employed_earnings_sources +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_earnings_income_tax import ( + income_tax_with_inputs_zeroed, +) + +# Taxed income that council_tax_reduction_applicable_income leaves out on the +# pensioner schemes' general route: the Scottish Carer Supplement (SSI +# 2012/319 reg 27(1)(j)(xxib)), miscellaneous income, benefits in kind (no +# scheme counts a payment in kind as earnings), and income from capital: +# savings interest, other investment income, dividends and the profits of a +# property business (property_income), such as rent from a property the +# applicant doesn't live in. +# +# In each pensioner scheme, a council disregards any actual income from +# capital, except income from the capital that Part 2 of the capital +# Schedule lists: life interests and liferents, rights to rent where the +# applicant has no reversionary interest, annuities, and property held under +# a trust that pays or may pay the applicant or partner. It disregards that +# income too while such capital totals £10,000 or less. A council counts +# other capital through tariff income and leaves Part 2 capital out of it +# (SSI 2012/319 reg 27(1)(i) and (2), reg 31(10), Sch 3 paras 21 and 22 and +# Sch 4 Part 2; SI 2012/2885 Sch 1 paras 16(1)(i), 17(11) and 37, Sch 5 +# paras 22 and 23 and Sch 6 Part 2; WSI 2013/3029 Sch 1 paras 10(1)(i), +# 11(11) and 31, Sch 4 paras 22 and 23 and Sch 5 Part 2). So a council +# disregards the interest on savings in an ordinary bank deposit and the +# dividends on shares held outright, and counts the deposit and the shares +# through tariff income. The model has no input that marks capital as Part 2 +# capital, and other_investment_income takes in interest from trusts and +# settlements, so council_tax_reduction_applicable_income counts none of +# this income on the pensioner route, and the formula deducts no tax on any +# of it. The model makes that general approximation of the income rules: +# where Part 2 capital above £10,000 yields such income, a council counts it +# and the model doesn't. +# +# The model taxes property profits through property_income alone. Without +# them, the finance-cost reduction comes to nil on the branch, because +# property_finance_costs_relieved caps the costs it relieves at the property +# profits, so the reduction goes with the rent. property_rental_income only +# measures receipts for the property allowance, which can't take taxable +# property income below the nil the branch leaves, so the formula keeps it. +# Rent for letting part of the home (sublet_income) counts on the pensioner +# route (council_tax_reduction_home_letting_income), so the formula keeps it +# too, with the income tax the model charges on it above the rent-a-room limit. +PENSIONER_UNCOUNTED_INCOME = [ + "scottish_carer_supplement", + "miscellaneous_income", + "benefits_in_kind", + "savings_interest_income", + "other_investment_income", + "dividend_income", + "property_income", +] + +# The Step 7 charges of ITA 2007 s. 23 that income_tax_charges holds, +# other than the winter fuel payment charge +# (gov.hmrc.income_tax.step_7_additional_tax: the High Income Child +# Benefit Charge and the pension annual allowance charge). Neither falls +# on income the pensioner schemes count: through the first, HMRC +# recovers Child Benefit, which council_tax_reduction_applicable_income +# leaves out on the pensioner route, and the second falls on pension +# contributions. +STEP_7_CHARGES = ["income_tax_charges"] + + +class council_tax_reduction_income_tax_on_other_counted_income(Variable): + value_type = float + entity = Person + label = "Income tax on the other income a pensioner Council Tax Reduction counts" + documentation = ( + "The person's income tax before the winter fuel payment charge if " + "nobody in the simulation had employed earnings (employment income " + "and statutory sick, maternity and paternity pay), self-employment " + "income, or the taxed income the pensioner schemes do not count: the " + "Scottish Carer Supplement, miscellaneous income, benefits in kind, " + "savings interest, other investment income, dividends and the profits " + "of a property business (property_income), with the finance-cost " + "reduction that goes with them. In each pensioner scheme, a council " + "disregards any actual income from capital, except income from the " + "capital that Part 2 of the capital Schedule lists: life interests " + "and liferents, rights to rent where the applicant has no " + "reversionary interest, annuities, and property held under a trust " + "that pays or may pay the applicant or partner. It disregards that " + "income too while such capital totals £10,000 or less. A council " + "counts other capital, such as " + "savings in an ordinary bank deposit or shares held outright, through " + "tariff income, and leaves Part 2 capital out of it. The model has no " + "input that marks capital as Part 2 capital, and " + "other_investment_income takes in interest from trusts and " + "settlements, so council_tax_reduction_applicable_income counts none " + "of the interest, other investment income, dividends or rent from " + "property on the pensioner route, and the formula deducts no tax on " + "any of it. The model makes that general approximation of the income " + "rules: where Part 2 capital above £10,000 yields such income, a " + "council counts it and the model doesn't. It also " + "leaves out the Step 7 charges of ITA 2007 s. 23 that " + "income_tax_charges holds, the High Income Child Benefit Charge and " + "the pension annual allowance charge: through the first, HMRC " + "recovers Child Benefit, which the pensioner schemes do not count, " + "and the second falls on pension contributions, so the formula " + "treats neither as tax payable on income taken into account. This is " + "the tax on the rest of the income the pensioner schemes count, with " + "the uncounted income above it. The " + "formula zeroes those inputs and the charges on a branch that it " + "drops afterwards, and builds the branch only when someone has " + "uncounted income or a charge." + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/21", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/22", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/4/part/2", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/16", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/37", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/22", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/23", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/6/part/2", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/10", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/31", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/22", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/23", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/5/part/2", + ] + + def formula(person, period, parameters): + zeroed = PENSIONER_UNCOUNTED_INCOME + STEP_7_CHARGES + if not any((person(variable, period) != 0).any() for variable in zeroed): + return person("pension_credit_income_tax_without_earnings", period) + return income_tax_with_inputs_zeroed( + person.simulation, + period, + employed_earnings_sources(parameters, period) + + ["self_employment_income"] + + zeroed, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py index 133d9ab75f..0956c62437 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction.py @@ -1,59 +1,44 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._national_non_dependants import ( + household_head_family_is_pensioner, + individual_non_dep_deduction, +) class council_tax_reduction_individual_non_dep_deduction(Variable): value_type = float entity = Person label = "CTR individual non-dependent deduction" + documentation = ( + "The England pensioner, Scottish and Welsh schemes' own weekly scales. " + "For a non-dependant in remunerative work, the council deducts the amount for the band " + "containing their normal gross weekly income (a couple's joint income " + "for a claimant or partner of the non-dependant's family); bands " + "include their lower edge. For a non-dependant outside remunerative work, " + "including one on Income Support, income-based JSA or income-related " + "ESA, the council deducts the lowest amount; for an exempt non-dependant it deducts nothing. English working-age local schemes have their own " + "variables. The person-level variable reports the scheme of the household head's family; " + "each applicant's deductions follow their own scheme." + ) definition_period = YEAR unit = GBP defined_for = "council_tax_reduction_individual_non_dep_deduction_eligible" reference = ( "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", "https://www.legislation.gov.uk/uksi/2012/2885/regulation/10", - "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", - "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/10", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/6", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/10", ) def formula(person, period, parameters): - p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction - local = parameters(period).gov.local_authorities - country = person.household("country", period) - countries = country.possible_values - remunerative_work_hours = select( - [country == countries.SCOTLAND, country == countries.WALES], - [ - local.scotland.council_tax_reduction.non_dep_deduction.remunerative_work_hours, - local.wales.council_tax_reduction.non_dep_deduction.remunerative_work_hours, - ], - default=local.england.council_tax_reduction.pensioners.non_dep_deduction.remunerative_work_hours, - ) - income = person("council_tax_reduction_non_dep_gross_income", period) - weekly_total_income = income / WEEKS_IN_YEAR - on_income_related_benefit = ( - person("is_on_income_support", period) - | person("is_on_income_based_jsa", period) - | person("is_on_income_related_esa", period) - ) - in_remunerative_work = ( - person("weekly_hours", period) >= remunerative_work_hours - ) & ~on_income_related_benefit - banded = p.amount.calc(weekly_total_income, right=True) - # The national provisions' paragraph (1)(b) gives a non-dependant - # outside remunerative work the flat amount instead of an income - # band; the existing HB scale is retained pending #2007. The gate - # only lowers a deduction: a non-worker with no banding income keeps - # the scale's zero, because the exemptions in paragraphs (6) to (8) - # (a claimant on Attendance Allowance, a non-dependant on Income - # Support, income-based JSA, income-related ESA, Pension Credit or - # Universal Credit without earnings, a full-time student) are not - # modelled, and the income-related benefits are not banding income, - # so many such non-dependants have none. - deduction = where( - in_remunerative_work, - banded, - min_(banded, p.amount.amounts[0]), + return individual_non_dep_deduction( + person, + period, + parameters, + household_head_family_is_pensioner(person, period), ) - return deduction * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py index 861bd2dc56..b05ef2f086 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_individual_non_dep_deduction_eligible.py @@ -1,4 +1,7 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + in_other_non_liable_family, +) class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): @@ -6,24 +9,32 @@ class council_tax_reduction_individual_non_dep_deduction_eligible(Variable): entity = Person label = "eligible person for CTR non-dependent deduction" documentation = ( - "An adult outside every family claiming Council Tax Reduction for the " - "household. Someone jointly and severally liable for the council tax " - "with the claimant (a joint tenant or other sharer of the rent) and " - "someone liable to the claimant on a commercial basis (a boarder or " - "lodger) is not a non-dependant." + "A non-dependant aged 18 or over: an adult outside every family " + "claiming Council Tax Reduction for the household who is not liable " + "for rent, or a member of any family's benefit unit who is not its " + "applicant, partner or a child or young person (see " + "is_benefit_unit_non_dependant_for_council_tax_reduction), including " + "a boarder's or lodger's adult son. Someone jointly and severally " + "liable for the council tax with the applicant (a joint tenant or " + "other sharer of the rent) and a boarder or lodger who pays the " + "applicant are not non-dependants." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", - "https://www.legislation.gov.uk/wsi/2013/3029", - "https://www.legislation.gov.uk/ssi/2021/249", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/9", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/3", ) def formula(person, period, parameters): - # SI 2012/2885 reg 9(2)(d)-(e) and the Welsh and Scottish equivalents. - return ( - (person("age", period) >= 18) - & ~person.benunit("council_tax_reduction_claimant_benunit", period) - & ~person.benunit("liable_for_share_of_household_rent", period) - & ~person("pays_rent_to_householder", period) + # SI 2012/2885 reg 9(2)(a), (c)-(e) and the Welsh and Scottish + # equivalents; deductions are for non-dependants aged 18 or over. + other_family = in_other_non_liable_family(person, period) + # A member of any family's benefit unit who is not in its applicant's + # family. Reg 9(2)(e) excludes only the person liable to pay the + # applicant, so a boarder's or lodger's adult son is a non-dependant. + in_unit = person( + "is_benefit_unit_non_dependant_for_council_tax_reduction", period ) + return (person("age", period) >= 18) & (other_family | in_unit) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deduction_exempt.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deduction_exempt.py new file mode 100644 index 0000000000..5c965738bb --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deduction_exempt.py @@ -0,0 +1,47 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._national_non_dependants import ( + household_head_family_is_pensioner, + non_dep_deduction_exempt, +) + + +class council_tax_reduction_non_dep_deduction_exempt(Variable): + value_type = bool + entity = Person + label = "Exempt from national Council Tax Reduction non-dependant deductions" + documentation = ( + "Modelled exemptions in the England pensioner, Scottish and Welsh " + "schemes: a full-time student; a non-dependant on Income Support, " + "income-based JSA, income-related ESA or State Pension Credit (any " + "age), and in Scotland's working-age scheme from April 2022 one whose " + "partner is on Income Support, income-based JSA or income-related ESA " + "and who is not on Universal Credit; an adult for whom someone else is " + "entitled to child benefit (LGFA 1992 Sch 1 para 3); and, from the year " + "each scheme added it, one entitled to Universal Credit calculated on " + "no earned income. Welsh councils deduct for members of the ESA " + "work-related activity group; with no ESA group input, the model " + "exempts every income-related ESA recipient. Not modelled: other " + "persons disregarded for council tax discounts (LGFA 1992 Sch 1), " + "youth training allowances, a normal home elsewhere, and " + "absence in hospital or on armed forces operations." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/4", + "https://www.legislation.gov.uk/ukpga/1992/14/schedule/1/paragraph/3", + ) + + def formula(person, period, parameters): + # Under the scheme of the household head's family; each applicant's + # deductions use its own scheme (council_tax_reduction_non_dep_deductions). + return non_dep_deduction_exempt( + person, + period, + parameters, + household_head_family_is_pensioner(person, period), + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py index 6241abf4c7..ccfa21cb6c 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_deductions.py @@ -1,20 +1,109 @@ from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.housing_benefit.non_dep_deduction._non_dependants import ( + deduction_per_family, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._national_non_dependants import ( + household_head_family_is_pensioner, + individual_non_dep_deduction, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_wales_scheme, +) class council_tax_reduction_non_dep_deductions(Variable): value_type = float entity = BenUnit label = "CTR non-dependent deductions" + documentation = ( + "Deductions for a claiming family's non-dependants, whether in its " + "own benefit unit or another family's: one per couple, the higher of " + "the two members' amounts (both, in the Welsh scheme for people who " + "are not pensioners, when the couple's own Universal Credit award " + "assesses either member), one for each other non-dependant, each " + "under the applicant family's own scheme and apportioned between " + "jointly liable applicants; none if the applicant or partner is exempt." + ) definition_period = YEAR unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/8", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/3", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/48", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/90", + ) def formula(benunit, period, parameters): - deductions = benunit.members( - "council_tax_reduction_individual_non_dep_deduction", period + person = benunit.members + country = benunit.household("country", period) + # WSI 2013/3029 Sch 6 para 5(3): from a working-age applicant's claim + # the council deducts for each member of a non-dependant couple with a + # Universal Credit award. The award must be the couple's own, one that + # assesses the claimant or partner. Another member's award as a single + # claimant leaves the couple one deduction. + couple_claims_universal_credit = benunit.any( + person("is_uc_assessed_claimant", period) + & person("is_claimant_or_partner", period) + ) + universal_credit_couple = ( + is_wales_scheme(country) + & couple_claims_universal_credit + & (benunit("universal_credit_pre_benefit_cap", period) > 0) + ) + # council_tax_reduction_individual_non_dep_deduction is each person's + # deduction under the scheme of the household head's family. A jointly + # liable applicant whose own family is under the other scheme (SI + # 2012/2885 reg 3) has its deductions recomputed under that scheme. + head_pensioner = household_head_family_is_pensioner(person, period) + deductions = { + "head": person( + "council_tax_reduction_individual_non_dep_deduction", period + ), + "other": person( + "council_tax_reduction_individual_non_dep_deduction_eligible", period + ) + * individual_non_dep_deduction(person, period, parameters, ~head_pensioner), + } + # Every eligible non-dependant normally resides with each liable + # person (SI 2012/2885 reg 9(1)): another family's adult, an + # applicant's own adult son, or a boarder's or lodger's (reg 9(2)(e) + # excludes only the person liable to pay the applicant). Members of + # the applicant's own family are not its non-dependants (reg + # 9(2)(a)); the non-dependants in its benefit unit are. + in_unit = person( + "is_benefit_unit_non_dependant_for_council_tax_reduction", period + ) + head_family_pensioner = benunit.any(head_pensioner) + non_dependants = {} + for scheme, amounts in deductions.items(): + pensioner = head_family_pensioner ^ (scheme == "other") + both = universal_credit_couple & ~pensioner + family_amount = deduction_per_family(benunit, period, amounts, both) + # Each family's amount counted once. + counted = person("is_benunit_head", period) * benunit.project(family_amount) + in_household = benunit.max(person.household.sum(counted)) + own_in_unit = deduction_per_family(benunit, period, amounts * in_unit, both) + non_dependants[scheme] = in_household - family_amount + own_in_unit + own_scheme_is_heads = ( + benunit("council_tax_reduction_pensioner", period) == head_family_pensioner ) - deductions_in_household = benunit.max(benunit.members.household.sum(deductions)) - deductions_in_benunit = benunit.sum(deductions) # A non-dependant of two or more jointly liable people is apportioned - # equally between them (SI 2012/2885 Sch 1 para 8(5)). + # equally between them (Sch 1 para 8(5)). share = benunit("council_tax_reduction_joint_liability_share", period) - return (deductions_in_household - deductions_in_benunit) * share + # Only a family that claims bears deductions; a non-dependant's own + # family does not. + claims = benunit("council_tax_reduction_claimant_benunit", period) + applicant_exempt = benunit( + "council_tax_reduction_applicant_has_non_dep_exemption", period + ) + return where( + applicant_exempt, + 0, + claims + * where( + own_scheme_is_heads, non_dependants["head"], non_dependants["other"] + ) + * share, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py index 24b75757db..01e88a693e 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_non_dep_gross_income.py @@ -20,27 +20,29 @@ class council_tax_reduction_non_dep_gross_income(Variable): "https://www.legislation.gov.uk/wsi/2013/3029/schedule/6/paragraph/5", ) documentation = ( - "Annual gross income represented by the model for non-dependant " + "Annual gross income the model represents for the non-dependant " "bands, before tax, National Insurance, employment deductions and " - "pension contributions. Signed trading/property inputs are retained; " - "a separate cross-source loss-offset rule is not modelled. Claimant " - "war-pension and supplementary-pension disregards do not apply to " - "these non-dependant bands. It includes statutory sick, maternity and " - "paternity pay once through employment_benefits, child and spousal " - "maintenance, the tax-purpose social security income sources, and " - "the separately represented non-taxable periodic benefits. " - "Attendance Allowance, DLA and PIP are omitted under the specific " - "disability-payment disregards. The other disregarded disability " - "and compensation/trust payments have no separate model inputs. " - "Benefit-unit awards (including Child Benefit, tax credits and UC) " - "are not allocated to an individual recipient here; adding computed " - "UC would also create a cycle through the benefit cap and HB. " - "BSP and AFCS inputs do not separate periodic income from capital " - "payments, so they are not added. Other investment income remains " - "excluded to preserve this income base; extending it remains for " - "#2007. The deduction formula applies the remunerative-work gate. The " - "gross-income variable itself does not decide whether someone is " - "in remunerative work or whether a deduction is due." + "pension contributions. DWP tells councils to count earnings and any " + "other income (HB guidance A5.542), to count a self-employed " + "non-dependant's total income before business expenses (A5.551) and " + "to add savings interest to earnings (A5.570). The model holds " + "self-employment and property profits rather than receipts, so it " + "adds each profit and counts a loss as nil, and a loss never reduces " + "other income. The claimant war-pension and supplementary-pension " + "disregards do not apply to these bands. The total includes " + "statutory sick, maternity and paternity pay once, through " + "employment_benefits; child and spousal maintenance; the tax-purpose " + "social security income sources; the non-taxable periodic benefits " + "the model represents separately; and other investment income, ISA " + "interest (tax_free_savings_income) and ISA dividends. It leaves out " + "Attendance Allowance, DLA and PIP under the disability-payment " + "disregards; the other disregarded disability, compensation and " + "trust payments have no model inputs. BSP and AFCS inputs do not " + "separate periodic income from capital payments, so the total leaves " + "them out. Benefit-unit awards, such as Child Benefit, tax credits " + "and Universal Credit, stay out of it. The deduction formula adds " + "family awards to the claimant and partner's joint income and applies " + "the remunerative-work test." ) def formula(person, period, parameters): @@ -52,10 +54,13 @@ def formula(person, period, parameters): "employment_benefits", "private_pension_income", "social_security_income", - "self_employment_income", - "property_income", "savings_interest_income", + # Count ISA interest unless the inputs supply an exempt-interest + # total. + "tax_free_savings_income", + "other_investment_income", "dividend_income", + "individual_savings_account_dividend_income", "miscellaneous_income_before_trading_allowance", "maternity_allowance", "iidb", @@ -63,8 +68,15 @@ def formula(person, period, parameters): "war_widows_pension", ], ) + # The model has profits, not the receipts DWP HB guidance A5.551 + # counts before business expenses. As an approximation, a loss + # counts as nil, so it never reduces other income. + losses_as_nil = max_(0, person("self_employment_income", period)) + max_( + 0, person("property_income", period) + ) return ( income + + losses_as_nil + max_(0, person("maintenance_income", period)) + max_(0, person("spousal_maintenance_income", period)) ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py index d6bc318621..9630d443c8 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_premiums.py @@ -15,13 +15,18 @@ class council_tax_reduction_premiums(Variable): "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 " + "the same conditions: the disability premium on her own disability and " + "the enhanced disability premium on the scheme's condition " + "(meets_council_tax_reduction_enhanced_disability_premium_condition), " + "both 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. " + "At working age the benefit unit's carer premium comes from " + "council_tax_reduction_carer_premium, which in Scotland also counts " + "an award of Universal Credit which includes the carer element. " "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 " @@ -40,13 +45,18 @@ class council_tax_reduction_premiums(Variable): "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", + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/6", ) 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). + # premium), the working-age carer premium its own caring test + # (council_tax_reduction_carer_premium), and the enhanced disability + # premium the scheme's own condition + # (council_tax_reduction_enhanced_disability_premium). The first two + # differ from the shared premiums only in Scotland. benefit_unit_premiums = where( pensioner, benunit("pension_age_benefits_premiums", period), @@ -54,6 +64,14 @@ def formula(benunit, period, parameters): + ( benunit("council_tax_reduction_severe_disability_premium", period) - benunit("severe_disability_premium", period) + ) + + ( + benunit("council_tax_reduction_carer_premium", period) + - benunit("carer_premium", period) + ) + + ( + benunit("council_tax_reduction_enhanced_disability_premium", period) + - benunit("enhanced_disability_premium", period) ), ) head_applies_alone = benunit("council_tax_reduction_head_applies_alone", period) @@ -86,6 +104,10 @@ def head_has(condition): ) & (others_counted == 0) ) * disability.severe_single + # A head who applies alone holds no Universal Credit award of her own + # (the benefit unit's claimant and partner hold its award), so + # Scotland's carer element limb (council_tax_reduction_carer_premium) + # never reaches her. carer_premium = ( head_has(person("is_entitled_to_carer_benefit", period)) * carer.single ) @@ -94,7 +116,12 @@ def head_has(condition): disability_premiums = ~pensioner * ( head_has(person("is_disabled_for_benefits", period)) * disability.disability_single - + head_has(person("is_enhanced_disabled_for_benefits", period)) + + head_has( + person( + "meets_council_tax_reduction_enhanced_disability_premium_condition", + period, + ) + ) * disability.enhanced_single ) head_premiums = (disability_premiums + severe + carer_premium) * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py new file mode 100644 index 0000000000..bf6bd79650 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_scotland_self_employment_deductions.py @@ -0,0 +1,106 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable + + +class council_tax_reduction_scotland_self_employment_deductions(Variable): + value_type = float + entity = Person + label = ( + "Notional tax and contributions on self-employed profit, for Scottish " + "Council Tax Reduction" + ) + documentation = ( + "The income tax and social security contributions that a Scottish " + "council deducts from self-employed earnings for Council Tax " + "Reduction in place of those actually payable: under SSI 2012/303 " + "regs 37 and 38, which the Scottish Ministers apply in SSI 2021/249 " + "reg 51(9) at working age where nobody in the family has Universal " + "Credit, and under SSI 2012/319 regs 36 and 37 at pension age. The " + "formula charges notional income tax at the basic " + "rate (the formula reads the Scottish basic rate for a Scottish " + "taxpayer; both rates are 20%) on the profit " + "less only the personal allowance; 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, for years before 2024-25, the Class " + "2 contributions payable, which the Treasury omitted from reg 38(2) " + "and reg 37(2) from 6 April 2024 (SI 2024/377). At working age, the " + "Scottish Ministers allow the personal relief under ITA 2007 s. " + "35(1) (SSI 2012/303 reg 38(1)); the formula reads it as the " + "allowance left after the taper, because in s. 35(2) Parliament " + "reduces 'the allowance under subsection (1)'. At pension age, they " + "allow the relief under ss. 36 or 37 (SSI 2012/319 reg 37(1)). " + "Parliament omitted s. 36 for 2015-16 and later years and s. 37 for " + "2016-17 and later years, and each time extended s. 35 to the people " + "the omitted section had covered (Finance Act 2014 s. 2(4), (5) and " + "(9); Finance Act 2015 s. 5(4), (5) and (11)). A council that read " + "reg 37(1) literally would allow no applicant any relief from " + "2016-17. Where an Act " + "repeals and re-enacts a provision, Parliament provides that a " + "reference to it in subordinate legislation reads as a reference to " + "the provision re-enacted (Interpretation Act 1978 s. 17(2)(a), read " + "with s. 23(1) and (2)). The formula takes each Finance Act as " + "repealing an age-related allowance and re-enacting it in the " + "extended s. 35, so " + "it reads the reference as the tapered s. 35 allowance, which s. " + "35(1) now gives every individual. A child " + "minder's chargeable income comes to one-third of the earnings; the " + "model does not identify child minders, so it treats every profit as " + "chargeable income in full." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/37", + "https://www.legislation.gov.uk/ssi/2012/303/regulation/38", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/36", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", + "https://www.legislation.gov.uk/ukpga/2007/3/section/35", + "https://www.legislation.gov.uk/ukpga/2007/3/section/37", + "https://www.legislation.gov.uk/ukpga/2014/26/section/2", + "https://www.legislation.gov.uk/ukpga/2015/11/section/5", + "https://www.legislation.gov.uk/ukpga/1978/30/section/17", + "https://www.legislation.gov.uk/ukpga/1978/30/section/23", + ) + + 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. + 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], + ) + # "less only the personal relief": no blind person's or marriage + # allowance, and no married couple's allowance. + notional_tax = basic_rate * max_( + 0, profit - person("personal_allowance", period) + ) + class_4 = parameters(period).gov.hmrc.national_insurance.class_4 + lower = class_4.contribution_scale.components[1].threshold + upper = class_4.contribution_scale.components[2].threshold + notional_class_4 = where( + person("ni_class_4_liable", period), + class_4.contribution_scale.components[1].rate + * min_(max_(0, profit - lower), upper - lower), + 0, + ) + # Reg 38(2)(a) and reg 37(2)(a) as they stood on 5 April 2024: the + # Class 2 contributions payable, unless chargeable income does not + # exceed the threshold of SSCBA 1992 s. 11. + class_2 = parameters(period).gov.hmrc.national_insurance.class_2 + notional_class_2 = ( + (period.start.year < 2024) + * 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/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py new file mode 100644 index 0000000000..6e25a55c6e --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/council_tax_reduction_tax_deduction.py @@ -0,0 +1,198 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.dwp.pension_credit.pension_credit_earnings_income_tax import ( + DECISION, + PRE_CHARGE_TAX, +) +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction._legacy import ( + applicant_has_universal_credit, +) +from policyengine_uk.variables.household.demographic.country import Country + + +class council_tax_reduction_tax_deduction(Variable): + value_type = float + entity = BenUnit + label = "Council Tax Reduction deduction for income tax and National Insurance" + documentation = ( + "Income tax and National Insurance that Council Tax Reduction income " + "deducts, for the applicant and partner " + "(is_council_tax_reduction_applicant_or_partner). T is their income " + "tax before the winter fuel payment charge, and T(X) the same with " + "every person's X set to nil (pension_credit_income_tax_without_" + "employment_income, pension_credit_income_tax_without_earnings and " + "council_tax_reduction_income_tax_on_other_counted_income). E is " + "employed earnings: employment income and statutory sick, maternity " + "and paternity pay. S is self-employment income. U is the taxed " + "income the pensioner schemes do not count, such as interest, " + "dividends and rent from property. " + "Scotland's and Wales's working-age awards take their own route " + "(council_tax_reduction_devolved_working_age), which takes the " + "Universal Credit split of the tax (uc_income_tax_on_earnings) and " + "doesn't read this deduction; it still feeds " + "council_tax_reduction_applicable_income. " + "Scotland's working-age scheme (SSI 2021/249) deducts tax from " + "earnings only. Where the applicant has no award of Universal Credit " + "(council_tax_reduction_universal_credit_award, which includes an " + "award the benefit cap or deductions reduce to nil), the formula " + "deducts T less T(E), never negative, primary Class 1 " + "contributions, and the notional tax and contributions on profit " + "(council_tax_reduction_scotland_self_employment_deductions; reg " + "51(9)). With an award it deducts T less T(E, S), never " + "negative, and the Class 1, 2 and 4 contributions charged (regs " + "49(6)(b) and 51(2) step 3). The pensioner schemes of England (SI " + "2012/2885), Wales (WSI 2013/3029) and Scotland (SSI 2012/319) " + "disregard the tax deducted from earnings and the tax payable on " + "income taken into account, so the formula deducts T less T(E), " + "never negative; T(E, S, U), the tax on the other counted income " + "with the uncounted income above it and without the Step 7 charges in " + "income_tax_charges (through the High Income Child Benefit Charge, " + "HMRC recovers Child Benefit, which these schemes do not count, and " + "the pension annual allowance charge falls on contributions, so the " + "formula treats neither as tax payable on income taken into " + "account); the notional tax and " + "contributions on profit (pension_credit_self_employment_deductions " + "in England and Wales, the Scottish variable in Scotland); and " + "primary Class 1 contributions. Earnings are the top slice of income " + "and self-employed profit sits beneath them, so the tax on earnings " + "includes any tax they add on savings and dividends and through the " + "marriage allowance. Benefits in kind stay in both terms of each " + "difference, since no scheme counts a payment in kind as earnings. " + "In the English and Welsh working-age schemes, and for families " + "outside Great Britain, the formula deducts what the legacy means " + "tests deduct: the applicant's and partner's " + "legacy_means_test_deducted_income_tax, with their sum floored at " + "nil, and all their National Insurance. With an award of Universal " + "Credit, those schemes do not count rent from boarders, lodgers and " + "sub-tenants in the home, so the formula first takes off the tax " + "that rent would bear without earnings " + "(rent_a_room_income_tax_without_earnings). " + DECISION + ) + definition_period = YEAR + unit = GBP + reference = [ + "https://www.legislation.gov.uk/ssi/2021/249/regulation/49", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/50", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/51", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/31", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/33", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/37", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/17", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/19", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/1/paragraph/30", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/11", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/13", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/1/paragraph/24", + ] + + def formula(benunit, period, parameters): + person = benunit.members + members = person("is_council_tax_reduction_applicant_or_partner", period) + + def total(values): + return benunit.sum(values * members) + + country = benunit.household("country", period) + scotland = country == Country.SCOTLAND + in_great_britain = ( + (country == Country.ENGLAND) | scotland | (country == Country.WALES) + ) + pensioner = benunit("council_tax_reduction_pensioner", period) + pensioner_scheme = in_great_britain & pensioner + scottish_working_age = scotland & ~pensioner + with_universal_credit = scottish_working_age & applicant_has_universal_credit( + benunit, period + ) + without_universal_credit = scottish_working_age & ~with_universal_credit + + income_tax = person(PRE_CHARGE_TAX, period) + tax = total(income_tax) + class_1 = total(person("ni_class_1_employee", period)) + zeros = np.zeros_like(tax) + + # The other schemes deduct what the legacy means tests deduct: the + # applicant's and partner's legacy_means_test_deducted_income_tax, + # whose sum the formula floors at nil because one member's value can + # turn negative where another's earnings move a Step 7 charge off + # them, and all their National Insurance. + other_schemes = ~( + with_universal_credit | without_universal_credit | pensioner_scheme + ) + if other_schemes.any(): + # Where a working-age applicant's award of Universal Credit keeps + # rent out of the income, the formula leaves out the tax the rent + # would bear without earnings and keeps the extra tax earnings + # cause on it. + uncounted_rent_tax = where( + ~pensioner & applicant_has_universal_credit(benunit, period), + total(person("rent_a_room_income_tax_without_earnings", period)), + 0, + ) + legacy_deduction = max_( + 0, + total(person("legacy_means_test_deducted_income_tax", period)) + - uncounted_rent_tax, + ) + total(person("national_insurance", period)) + else: + legacy_deduction = zeros + + # Each branch of income tax is read only where a scheme uses it. + if (without_universal_credit | pensioner_scheme).any(): + tax_without_employment = total( + person("pension_credit_income_tax_without_employment_income", period) + ) + else: + tax_without_employment = tax + tax_on_employed_earnings = max_(0, tax - tax_without_employment) + + if with_universal_credit.any(): + tax_without_earnings = total( + person("pension_credit_income_tax_without_earnings", period) + ) + else: + tax_without_earnings = tax + uc_deduction = ( + max_(0, tax - tax_without_earnings) + + class_1 + + total(add(person, period, ["ni_class_2", "ni_class_4"])) + ) + + scottish_notional = ( + total( + person( + "council_tax_reduction_scotland_self_employment_deductions", + period, + ) + ) + if (scotland & (without_universal_credit | pensioner_scheme)).any() + else zeros + ) + no_uc_deduction = tax_on_employed_earnings + class_1 + scottish_notional + + if pensioner_scheme.any(): + tax_on_other_counted_income = total( + person( + "council_tax_reduction_income_tax_on_other_counted_income", + period, + ) + ) + # The formula prices the English and Welsh notional tax with + # Pension Credit's variable, because the Secretary of State and + # the Welsh Ministers allow the same personal reliefs (SI + # 2012/2885 Sch 1 para 30; WSI 2013/3029 Sch 1 para 24): those + # under ITA 2007 Part 3 Chapters 2, 3 and 3A. + england_and_wales_notional = total( + person("pension_credit_self_employment_deductions", period) + ) + else: + tax_on_other_counted_income = zeros + england_and_wales_notional = zeros + notional = where(scotland, scottish_notional, england_and_wales_notional) + pensioner_deduction = ( + tax_on_employed_earnings + tax_on_other_counted_income + notional + class_1 + ) + + return select( + [with_universal_credit, without_universal_credit, pensioner_scheme], + [uc_deduction, no_uc_deduction, pensioner_deduction], + default=legacy_deduction, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_benefit_unit_non_dependant_for_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_benefit_unit_non_dependant_for_council_tax_reduction.py new file mode 100644 index 0000000000..5ff9195f89 --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_benefit_unit_non_dependant_for_council_tax_reduction.py @@ -0,0 +1,48 @@ +from policyengine_uk.model_api import * + + +class is_benefit_unit_non_dependant_for_council_tax_reduction(Variable): + value_type = bool + entity = Person + label = "Non-dependant within the benefit unit for Council Tax Reduction" + documentation = ( + "A member of the benefit unit who is neither the Council Tax " + "Reduction applicant or partner (is_council_tax_reduction_applicant_or_partner) " + "nor a child or young person: someone aged 16 or over who is not a " + "Child Benefit qualifying young person. Such a person is outside the " + "applicant's family but normally resides with the applicant, so is a " + "non-dependant. Usually the applicant is the benefit unit's claimant, " + "and this is the same set as " + "is_benefit_unit_non_dependant_for_legacy_benefits. Where a household " + "head who is not the benefit unit's claimant or partner applies alone " + "(council_tax_reduction_head_applies_alone), the benefit unit's " + "claimant and partner are the head's non-dependants. A member who pays " + "the household head as a boarder or lodger (pays_rent_to_householder) " + "is liable for their own occupation, so is not one (SI 2012/2885 reg " + "9(2)(e)). This person's own " + "benefit awards are not modelled, so the exemptions for a " + "non-dependant on an income-related benefit or on Universal Credit " + "with no earned income never apply to them." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2012/2885/regulation/9", + "https://www.legislation.gov.uk/wsi/2013/3029/regulation/9", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/8", + "https://www.legislation.gov.uk/ssi/2012/319/regulation/3", + ) + + def formula(person, period, parameters): + # SI 2012/2885 and WSI 2013/3029 reg 9(1), (2)(a), (c); SSI 2021/249 + # reg 8(2)(a)-(b); SSI 2012/319 reg 3: anyone normally residing with the + # applicant except the applicant's own family. + applicant_or_partner = person( + "is_council_tax_reduction_applicant_or_partner", period + ) + child_or_young_person = person( + "is_child_or_qualifying_young_person_for_child_benefit", period + ) + # Reg 9(2)(e) and its equivalents: a person liable to pay the applicant + # on a commercial basis for their occupation is not a non-dependant. + pays_householder = person("pays_rent_to_householder", period) + return ~applicant_or_partner & ~child_or_young_person & ~pays_householder diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py index 476e395c44..dadb1f9777 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/is_child_or_young_person_for_council_tax_reduction.py @@ -9,8 +9,13 @@ class is_child_or_young_person_for_council_tax_reduction(Variable): young person: - Scotland's working-age scheme, from 1 April 2022, uses the Universal - Credit qualifying young person (Council Tax Reduction (Scotland) - Regulations 2021 reg. 6(1); UC Regs 2013 reg. 5). The applicant is + Credit child and the qualifying young person of UC Regs 2013 reg. 5 + (Council Tax Reduction (Scotland) Regulations 2021 reg. 6(1)). The + Secretary of State excludes a person entitled to an existing benefit + only for the purposes of the UC Regulations and WRA 2012 Part 1 (SI + 2014/1230 reg. 28), so the formula reads reg. 5 alone and keeps a + young person who receives Income Support, Housing Benefit, a tax + credit, income-related ESA or income-based JSA. The applicant is responsible for one who normally lives with them, never for one they live with as a couple, and no one is responsible for one placed with a foster carer (reg. 7). @@ -34,6 +39,8 @@ class is_child_or_young_person_for_council_tax_reduction(Variable): definition_period = YEAR reference = ( "https://www.legislation.gov.uk/ssi/2021/249/regulation/4", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2014/1230/regulation/28", "https://www.legislation.gov.uk/ssi/2021/249/regulation/6", "https://www.legislation.gov.uk/ssi/2021/249/regulation/7", "https://www.legislation.gov.uk/ssi/2012/319/regulation/4", @@ -50,10 +57,18 @@ def formula(person, period, parameters): & ~person.benunit("council_tax_reduction_pensioner", period) & p.young_person_follows_universal_credit ) - # SSI 2021/249 regs 4, 6 and 7. The Universal Credit flag already - # leaves out a child or young person looked after by a local authority. - universal_credit = person( - "is_child_or_qualifying_young_person_for_universal_credit", period - ) & ~person("is_claimant_or_partner", period) + # SSI 2021/249 regs 4, 6 and 7: a child under UC Regs reg. 4, or a + # qualifying young person under reg. 5 without TP reg. 28. + universal_credit = ( + ( + person("is_child_for_universal_credit", period) + | person( + "is_qualifying_young_person_under_universal_credit_regulation_5", + period, + ) + ) + & ~person("is_looked_after_by_local_authority", period) + & ~person("is_claimant_or_partner", period) + ) child_benefit = person("is_child_or_young_person_for_legacy_benefits", period) return where(scottish_working_age, universal_credit, child_benefit) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/meets_council_tax_reduction_enhanced_disability_premium_condition.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/meets_council_tax_reduction_enhanced_disability_premium_condition.py new file mode 100644 index 0000000000..61fbdb7a8b --- /dev/null +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/meets_council_tax_reduction_enhanced_disability_premium_condition.py @@ -0,0 +1,102 @@ +from policyengine_uk.model_api import * +from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( + is_kingston_upon_thames_area, + is_merton_area, + is_newham_area, + is_scotland_scheme, + is_westminster_area, +) + + +class meets_council_tax_reduction_enhanced_disability_premium_condition(Variable): + value_type = bool + entity = Person + label = "Meets the working-age CTR enhanced disability premium condition" + documentation = ( + "The model checks whether this person, as an applicant or partner, " + "meets the enhanced disability premium condition of the working-age " + "Council Tax Reduction scheme that covers their household. Scottish " + "councils count attendance allowance at either rate from 1 April " + "2023, and the Scottish Ministers removed the age limit from 20 June " + "2022. Merton and Kingston upon Thames count the PIP daily living " + "component at either rate and omit armed forces independence payment; " + "Newham counts the PIP daily living component at either rate and " + "armed forces independence payment. In Westminster the model applies " + "the Default Scheme's list (S.I. 2012/2886 Sch 3 para 12), which " + "counts the PIP daily living component at either rate and omits armed " + "forces independence payment: the council has approved the same " + "scheme, based on the Default Scheme Regulations, since 2013/14, and " + "its Cabinet says from December 2020 that its local scheme mirrors " + "those Regulations, though the model cites no full Westminster scheme " + "text. Those four councils count an award only for a person under the " + "qualifying age for State Pension Credit. The model applies Kingston's " + "list in 2013-14 to 2017-18 and from 2023-24. It cites no complete " + "Kingston scheme text for 2018-19 to 2022-23, so for those years it " + "assumes the Housing Benefit list. The other three councils' lists " + "start on or before 1 April 2013 and apply in every model year. In " + "every other scheme the model applies the Housing Benefit condition " + "(receives_enhanced_disability_premium_qualifying_benefit, under the " + "same age limit). Welsh councils apply that condition under W.S.I. " + "2013/3029 Sch 7 para 12. Oxford City Council sets its working-age " + "award from income bands, so the premium does not reach it." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/ssi/2021/249/schedule/1/paragraph/14", + "https://www.legislation.gov.uk/ssi/2012/303/schedule/1/paragraph/12/2021-04-01", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/12", + "https://web.archive.org/web/20150530202253id_/http://www.merton.gov.uk/council_tax_reduction_scheme_l_b_merton_april_2013_with_uprated_amounts_final_310113-2.pdf", + "https://www.merton.gov.uk/system/files/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202023-24_0.pdf", + "https://www.merton.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%20L%20B%20Merton%202026-27.pdf", + "https://moderngov.kingston.gov.uk/mgAi.aspx?ID=20744", + "https://www.legislation.gov.uk/uksi/2012/2886/appendix/3/regulation/12/made", + "https://www.kingston.gov.uk/sites/default/files/2026-03/Council%20Tax%20Reduction%20Scheme%202026%20-%202027.pdf", + "https://mgov.newham.gov.uk/documents/s74450/Appendix%201%20-%20LB%20Newham%20Local%20CTR%20Scheme_16_01_13.pdf", + "https://www.newham.gov.uk/downloads/file/10753/council-tax-reduction-scheme-2026", + "https://web.archive.org/web/20240621074656id_/https://committees.westminster.gov.uk/documents/s40219/Report%20of%20the%20Cabinet%2014%20December%202020%20-Council%20Tax%20Discounts.pdf", + "https://committees.westminster.gov.uk/mgAi.aspx?ID=22982", + "https://www.westminster.gov.uk/sites/default/files/media/documents/business-and-financial-planning-2026-27-to-2028-29.pdf", + "https://committees.westminster.gov.uk/ieDecisionDetails.aspx?ID=1181", + ) + + def formula(person, period, parameters): + la = parameters(period).gov.local_authorities + household = person.household + country = household("country", period) + local_authority = household("local_authority", period) + under_age = ~person("has_attained_state_pension_credit_qualifying_age", period) + + def receives(benefits): + return add(person, period, benefits) > 0 + + housing_benefit = ( + person("receives_enhanced_disability_premium_qualifying_benefit", period) + & under_age + ) + + def council_condition(council): + p = council.council_tax_reduction.enhanced_disability_premium + return receives(p.qualifying_benefits) & under_age + + p = la.scotland.council_tax_reduction.working_age.enhanced_disability_premium + scottish = receives(p.qualifying_benefits) & ( + under_age | (not p.age_limit_applies) + ) + return select( + [ + is_scotland_scheme(country), + is_merton_area(local_authority, country), + is_kingston_upon_thames_area(local_authority, country) + & la.kingston_upon_thames.council_tax_reduction.enhanced_disability_premium.qualifying_benefits_apply, + is_newham_area(local_authority, country), + is_westminster_area(local_authority, country), + ], + [ + scottish, + council_condition(la.merton), + council_condition(la.kingston_upon_thames), + council_condition(la.newham), + council_condition(la.westminster), + ], + default=housing_benefit, + ) diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_applicant.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_applicant.py index a244de2cd3..df967451ce 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_applicant.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_applicant.py @@ -48,15 +48,16 @@ def working_age_adult_premium_conditions(benunit, period, parameters): disability and severe disability premiums, and whether the severe disability premium is at the double rate. - Who qualifies follows the model's legacy premium variables, with Council - Tax Reduction's own severe disability premium family test - (council_tax_reduction_severe_disability_premium). Where the household - head applies alone, the conditions are the head's own as a single person, - as in council_tax_reduction_premiums. + Who qualifies follows the model's legacy disability premium, with Council + Tax Reduction's own enhanced disability premium condition + (council_tax_reduction_enhanced_disability_premium) and severe disability + premium family test (council_tax_reduction_severe_disability_premium). + Where the household head applies alone, the conditions are the head's own + as a single person, as in council_tax_reduction_premiums. """ dwp = parameters(period).gov.dwp.disability_premia disability = benunit("disability_premium", period) > 0 - enhanced = benunit("enhanced_disability_premium", period) > 0 + enhanced = benunit("council_tax_reduction_enhanced_disability_premium", period) > 0 severe_weekly = ( benunit("council_tax_reduction_severe_disability_premium", period) / WEEKS_IN_YEAR @@ -99,7 +100,12 @@ def head_has(condition): ), where( head_applies_alone, - head_has(person("is_enhanced_disabled_for_benefits", period)), + head_has( + person( + "meets_council_tax_reduction_enhanced_disability_premium_condition", + period, + ) + ), enhanced, ), where(head_applies_alone, head_severe, severe), diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py index dca397211a..640c6ff413 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/_earnings.py @@ -1,5 +1,6 @@ from policyengine_uk.model_api import * from policyengine_uk.utils.class_2 import class_2_contribution_weeks, class_2_liable +from policyengine_uk.utils.uc_income_tax import earnings_tax_reductions from policyengine_uk.variables.gov.local_authorities.council_tax_reduction.config import ( is_wales_scheme, ) @@ -169,20 +170,23 @@ def working_age_earnings(person, period, parameters): return net, net_employed -def working_age_unearned_income_tax(person, period): +def working_age_unearned_income_tax(person, period, parameters): """Income tax on the person's counted taxable unearned income. earned_income_tax is the tax on non-savings income other than property - income: earnings, pensions and taxable social security benefits. Less the - tax on earnings (uc_income_tax_on_earnings, earnings being the lowest - slice), the rest is tax on pensions and benefits, shared pro rata with the - taxable ones the schemes do not count (the Scottish Carer Supplement). - The tax on miscellaneous income, which the schemes count as unearned - income, is added (_pay_and_miscellaneous_tax). + income, before the Step 6 tax reductions: earnings, pensions and taxable + social security benefits. uc_income_tax_on_earnings (earnings being the + lowest slice) bears those reductions first, so the formula takes them off + earned_income_tax too. Less the tax on earnings, the rest is tax on + pensions and benefits, shared pro rata with the taxable ones the schemes + do not count (the Scottish Carer Supplement). The tax on miscellaneous + income, which the schemes count as unearned income, is added + (_pay_and_miscellaneous_tax). """ other_non_savings_tax = max_( 0, person("earned_income_tax", period) + - earnings_tax_reductions(person, period, parameters) - person("uc_income_tax_on_earnings", period), ) base = add( diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py index 4ac8bbbe35..be6b4cb75f 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_adult_premiums.py @@ -16,8 +16,9 @@ class council_tax_reduction_working_age_adult_premiums(Variable): "Annual disability, severe disability, enhanced disability and carer " "premiums in a working-age applicable amount in Scotland or Wales, at " "each country's own amounts. Who qualifies follows the model's " - "legacy-benefit premium variables (disability_premium, " - "enhanced_disability_premium) and Council Tax Reduction's own severe " + "legacy-benefit disability premium (disability_premium) and Council " + "Tax Reduction's own enhanced disability premium condition " + "(council_tax_reduction_enhanced_disability_premium) and severe " "disability premium test (council_tax_reduction_severe_disability_" "premium), including whether that premium is at the single or couple " "rate; where the household head applies alone, the conditions are her " diff --git a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py index 3306f7986d..a9dbd78401 100644 --- a/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py +++ b/policyengine_uk/variables/gov/local_authorities/council_tax_reduction/working_age/council_tax_reduction_working_age_unearned_income_tax.py @@ -15,8 +15,9 @@ class council_tax_reduction_working_age_unearned_income_tax(Variable): "Income tax of the applicant and partner on the taxable unearned " "income the working-age schemes count (pensions, State Pension and " "taxable benefits): their non-savings income tax other than on " - "property income (earned_income_tax) less the tax on their earnings, " - "shared pro rata with taxable income the schemes do not count (the " + "property income (earned_income_tax), less the Step 6 tax reductions " + "that come off the tax on earnings first and less the tax on their " + "earnings, shared pro rata with taxable income the schemes do not count (the " "Scottish Carer Supplement). Wales disregards it from unearned income " "(WSI 2013/3029 Sch 9 para 4); Scotland counts unearned income gross " "(SSI 2021/249 reg 57)." @@ -27,5 +28,5 @@ class council_tax_reduction_working_age_unearned_income_tax(Variable): def formula(benunit, period, parameters): person = benunit.members - tax = working_age_unearned_income_tax(person, period) + tax = working_age_unearned_income_tax(person, period, parameters) return benunit.sum(working_age_applicant_or_partner(person, period) * tax) diff --git a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py index 775a09b2e4..80aa3b657c 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py +++ b/policyengine_uk/variables/gov/social_security_scotland/carer_support_payment_pre_couple_limit.py @@ -16,6 +16,7 @@ class carer_support_payment_pre_couple_limit(Variable): "https://www.legislation.gov.uk/asp/2018/9/part/4", "https://www.legislation.gov.uk/ssi/2023/302/regulation/3", "https://www.legislation.gov.uk/ssi/2023/302/regulation/4", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/5", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", "https://www.legislation.gov.uk/ssi/2023/302/regulation/14", "https://www.legislation.gov.uk/ssi/2023/302/schedule/2/paragraph/4", @@ -29,7 +30,12 @@ def formula(person, period, parameters): csp = parameters(period).gov.social_security_scotland.carer_support_payment meets_age_condition = person("age", period) >= csp.min_age weekly_care_hours = person("care_hours", period) - meets_hours = weekly_care_hours >= csp.min_hours + # Reg 5(1): only care of a person to whom a qualifying disability + # benefit is normally payable counts, which the model takes to hold + # unless the input says otherwise. + meets_hours = (weekly_care_hours >= csp.min_hours) & person( + "cares_for_severely_disabled_person", period + ) receives_ca = person("carers_allowance_reported", period) > 0 would_claim = person("would_claim_carers_allowance", period) earnings = person("carers_allowance_earnings", period) diff --git a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py index 7dae35df45..64beeee5fd 100644 --- a/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py +++ b/policyengine_uk/variables/gov/social_security_scotland/scottish_carer_supplement.py @@ -6,20 +6,66 @@ class scottish_carer_supplement(Variable): entity = Person label = "Scottish Carer Supplement" documentation = ( - "Paid for each week in which Carer Support Payment is payable, from 15 " - "March 2026. Where an overlapping benefit such as State Pension " - "reduces Carer Support Payment to £0, nothing is payable and no " - "supplement is paid; where it reduces the payment but leaves some, " - "the supplement is paid in full. It is a component of Carer Support " - "separate from the Carer Support Payment component. Pension Credit, " - "Housing Benefit and Scottish Council Tax Reduction, at pension age " - "and working age, do not count it as income; it is taxable." + "From 15 March 2026, Social Security Scotland pays the Scottish Carer " + "Supplement for each award week in which Carer Support Payment is " + "payable. The formula counts those weeks from annual amounts: it " + "divides the larger of carer_support_payment_pre_overlap and " + "carer_support_payment by the weekly Carer Support Payment rate, keeps" + " any part week and caps the count at the model's 52 weeks a year. It " + "pays the weekly supplement rate for each of those weeks, and nothing " + "where carer_support_payment is £0, as where an overlapping benefit " + "such as State Pension takes the payment to £0. Where the model " + "computes the amount before the overlapping-benefit reduction, that " + "amount comes to 52 weekly rates or nothing, so a carer whose " + "overlapping benefit reduces the payment but leaves some keeps all 52 " + "weeks. Where a user supplies the payment and the inputs give no " + "amount before the reduction, the formula divides the payment by the " + "weekly rate to count the weeks. A carer who moves to England, for " + "example, keeps Carer Support for 13 weeks after the move. Where the " + "cared-for person dies in that time, the carer keeps it for up to 25 " + "weeks and 6 days in all. A move on a Sunday gives 13 complete award " + "weeks, or up to 25 after such a death, each counted from a Sunday. A " + "move on any other day spreads the 13 weeks over 14 award weeks. The " + "count differs from the law in these cases, among others. Where an " + "overlapping benefit or earnings vary within the year, the formula " + "counts 52 weeks or none from the annual amounts, while Social " + "Security Scotland pays the supplement only for the award weeks in " + "which the payment stays payable. Where a user supplies a part-year " + "payment and also sets inputs that make the person eligible in " + "Scotland, the formula counts 52 weeks, since the model reads those " + "inputs as a whole year. Where a user supplies a payment that an " + "overlapping benefit has reduced, the formula counts fewer weeks than " + "the award weeks in which that payment is payable, unless the inputs " + "also give the amount before the reduction. Under " + "gov.contrib.policyengine.disable_simulated_benefits, a Scottish " + "carer's reported Carer's Allowance becomes carer_support_payment " + "while the amount before the reduction stays the model's, so the " + "formula counts 52 weeks for a reported carer the model finds " + "eligible, even where the report covers only part of the year, and the" + " weeks the report implies at each year's weekly rate for any other " + "reported carer; the reform doesn't uprate the report, so a report of " + "a full year counts fewer weeks in later years. Where a user supplies " + "a part-year amount before the overlapping-benefit reduction and the " + "person has an overlapping benefit paid all year, the model nets the " + "annual amounts. The formula can then count no weeks, while Social " + "Security Scotland compares the weekly amounts in each award week. " + "The formula also counts part weeks where a payment falls between " + "whole numbers of weekly rates. Such a payment arises for an award " + "that starts or ends during an award week or spans an April uprating. " + "Social Security Scotland instead pays a whole weekly supplement for " + "each award week in which Carer Support Payment is payable. The " + "supplement is a component of Carer Support separate from the Carer " + "Support Payment component. Pension Credit, Housing Benefit and " + "Scottish Council Tax Reduction, at pension age and working age, do " + "not count it as income; it is taxable." ) definition_period = YEAR unit = GBP reference = [ + "https://www.legislation.gov.uk/ssi/2023/302/regulation/2", "https://www.legislation.gov.uk/ssi/2023/302/regulation/14A", "https://www.legislation.gov.uk/ssi/2023/302/regulation/16", + "https://www.legislation.gov.uk/ssi/2023/302/regulation/42", "https://www.legislation.gov.uk/uksi/2026/246/article/17/made", "https://www.legislation.gov.uk/uksi/2026/246/article/20/made", "https://www.legislation.gov.uk/uksi/2026/246/article/21/made", @@ -29,6 +75,18 @@ class scottish_carer_supplement(Variable): ] def formula(person, period, parameters): - receives_csp = person("carer_support_payment", period) > 0 csp = parameters(period).gov.social_security_scotland.carer_support_payment - return receives_csp * csp.supplement * WEEKS_IN_YEAR + payable = person("carer_support_payment", period) + # The model computes the entitlement before the overlapping-benefit + # reduction as 52 weekly rates or nothing; a supplied payment with no + # such entitlement gives its own weeks. + amount = max_(person("carer_support_payment_pre_overlap", period), payable) + # 52 weekly rates in the amount's dtype, the value the model stores for + # a full year's entitlement, so that a full year counts exactly 52 + # weeks. The share of the year is the award weeks over 52. + full_year = np.asarray(csp.rate * WEEKS_IN_YEAR, dtype=amount.dtype) + has_rate = full_year > 0 + share = where(has_rate, amount / where(has_rate, full_year, 1), 0) + # The model counts 52 award weeks in a year. + year_of_supplement = csp.supplement * WEEKS_IN_YEAR + return where(payable > 0, year_of_supplement * min_(share, 1), 0) diff --git a/policyengine_uk/variables/gov/wra/ltt_liable.py b/policyengine_uk/variables/gov/wra/ltt_liable.py index 94b548761f..25aaf4167a 100644 --- a/policyengine_uk/variables/gov/wra/ltt_liable.py +++ b/policyengine_uk/variables/gov/wra/ltt_liable.py @@ -4,12 +4,22 @@ class ltt_liable(Variable): label = "Liable for Land Transaction Tax" documentation = ( - "Whether the household is liable to pay the Wales Land Transaction Tax" + "Whether the household is liable to pay the Wales Land Transaction " + "Tax: it lives in Wales and HMRC no longer charges Stamp Duty Land " + "Tax there." ) entity = Household definition_period = YEAR value_type = bool + reference = [ + # Land Transaction Tax and Anti-avoidance of Devolved Taxes (Wales) + # Act 2017 s. 2, in force from 1 April 2018 under WSI 2018/34 art. 3. + "https://www.legislation.gov.uk/anaw/2017/1/section/2", + "https://www.legislation.gov.uk/wsi/2018/34/article/3/made", + ] - def formula(household, period): + def formula(household, period, parameters): country = household("country", period) - return country == country.possible_values.WALES + in_wales = country == country.possible_values.WALES + sdlt_applies_in_wales = parameters(period).gov.hmrc.stamp_duty.applies_in_wales + return in_wales & (not sdlt_applies_in_wales) diff --git a/policyengine_uk/variables/household/demographic/carer_premium.py b/policyengine_uk/variables/household/demographic/carer_premium.py index fa9c6e1d24..6ba97fc802 100644 --- a/policyengine_uk/variables/household/demographic/carer_premium.py +++ b/policyengine_uk/variables/household/demographic/carer_premium.py @@ -25,7 +25,10 @@ class carer_premium(Variable): "person caps the benefit unit at one amount. The premium enters the " "Income Support, Housing Benefit and council tax reduction applicable " "amounts; the model takes income-based JSA and income-related ESA " - "from reported awards." + "from reported awards. In Scotland's working-age council tax " + "reduction, councils also pay it for an award of Universal Credit " + "which includes the carer element (council_tax_reduction_carer_premium); " + "no other scheme's carer premium has that limb." ) definition_period = YEAR reference = ( @@ -57,8 +60,10 @@ def formula(benunit, period, parameters): # tests caring responsibilities and pays each partner who qualifies. # Para 6(1) defines those as (a) receiving either allowance, (b) being # entitled to one reduced to nil by the overlapping benefits rules, or - # (c) a Universal Credit award with the carer element; the model - # applies (a) and (b) but not (c) (#2124). + # (c) a Universal Credit award with the carer element. This variable + # applies (a) and (b), which every scheme shares; Scottish working-age + # council tax reduction reads council_tax_reduction_carer_premium, + # which adds (c). claimant_or_partner = benunit.members("is_claimant_or_partner", period) entitled = benunit.members("is_entitled_to_carer_benefit", period) qualifying_carers = benunit.sum(claimant_or_partner & entitled) diff --git a/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py b/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py index 296ab7b49e..aecfcfecff 100644 --- a/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/enhanced_disability_premium.py @@ -6,15 +6,22 @@ class enhanced_disability_premium(Variable): entity = BenUnit label = "Enhanced disability premium" documentation = ( - "Legacy benefit enhanced disability premium for the claimant or " - "partner, using the model's existing enhanced-disability indicator. " - "The separate Housing Benefit enhanced rate for children and young " - "persons is not modelled here." + "The model pays the legacy benefit enhanced disability premium for " + "the claimant or partner at the single rate, or at the couple rate " + "where either or both members of a couple meet the condition " + "(num_enhanced_disabled_claimants_or_partners_for_legacy_benefits). " + "The model's Housing Benefit, Income Support and Council Tax " + "Reduction applicable amounts read it, and Council Tax Reduction " + "replaces it with its own scheme's premium " + "(council_tax_reduction_enhanced_disability_premium). " + "housing_benefit_child_disability_premiums computes Housing Benefit's " + "separate enhanced rate for children and young persons." ) definition_period = YEAR reference = ( "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/part/4", ) unit = GBP diff --git a/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py new file mode 100644 index 0000000000..6d30c8ad83 --- /dev/null +++ b/policyengine_uk/variables/household/demographic/is_before_first_september_following_19th_birthday.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.dates import birth_day + + +class is_before_first_september_following_19th_birthday(Variable): + value_type = bool + entity = Person + label = ( + "Before the 1 September following the 19th birthday at the start of the year" + ) + documentation = ( + "Whether the fiscal year starts (6 April) before the 1 September " + "following the person's 19th birthday, the date to which a young " + "person in non-advanced education or approved training stays a " + "qualifying young person (UC Regs 2013 reg. 5(1)(b); SPC Regs 2002 " + "reg. 4A(1)(b)). The date comes from date_of_birth, or from age and " + "months_since_last_birthday. A 19th birthday on 31 August ends the " + "period that day, and one on 1 September runs it to the 20th " + "birthday. The period ends on a fixed 1 September that annual " + "periods cannot split, so a person in it for part of the year counts " + "for the year, as is_before_first_september_following_16th_birthday " + "does. Everyone aged 19 on 6 October is before the date. So is a " + "20-year-old born from 1 September to 6 October, whom the qualifying " + "young person age limit, read on 6 October, already excludes. So the " + "formula gives true for everyone under 20 on 6 October, which is " + "everyone that limit lets be a qualifying young person, and the " + "default is true: a situation that sets this for some people gives " + "everyone else true. That is the formula's value for anyone under 20; " + "for someone older it is not, but every reader of this variable is " + "behind the qualifying young person age limit, which excludes them " + "first. Set it to false for a person whose 1 September has passed, or " + "set their date_of_birth." + ) + definition_period = YEAR + default_value = True + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/5", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/4A", + ) + + def formula(person, period, parameters): + date_of_birth = birth_day(person, period) + # The 19th birthday falls in the birth year plus 19; the 1 September + # following it is in that year if the birthday is before 1 September, + # and otherwise in the next. + born_on_or_after_first_september = date_of_birth % 10_000 >= 901 + year_of_first_september = ( + date_of_birth // 10_000 + 19 + born_on_or_after_first_september + ) + # The year starts on 6 April, before 1 September of the same calendar + # year and after 1 September of the one before. + return year_of_first_september >= period.start.year diff --git a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py index 6772dfa69b..80bc7d9619 100644 --- a/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py +++ b/policyengine_uk/variables/household/demographic/is_cared_for_by_carer_benefit_recipient.py @@ -7,17 +7,21 @@ class is_cared_for_by_carer_benefit_recipient(Variable): label = "Someone receives a carer benefit for caring for this person" documentation = ( "Someone is entitled to and in receipt of Carer's Allowance or Carer " - "Support Payment in respect of caring for this person: the carer " + "Support Payment, or has an award of Universal Credit which includes " + "the carer element, in respect of caring for this person: the carer " "condition of the legacy severe disability premium (HB Regs 2006 Sch 3 " "para 14(2)(a)(iii) and (2)(b); IS, ESA and JSA alike) and of the " "Pension Credit severe disability addition (SPC Regs 2002 Sch I para " - "1(1)). The data say who receives a carer benefit but not whom they " - "care for, so each award is attributed. A carer benefit is paid for " - "caring for a person who is severely disabled for Carer's Allowance " - "(is_severely_disabled_for_carers_allowance), a carer has one award " - "(SSCBA 1992 s.70(7)), and only one person is paid for caring for the " - "same severely disabled person (s.70(7ZA) to (7ZC); both rules were " - "in s.70(7) before 16 November 2023). No one is their own carer. A " + "1(1) and (4)). The data say who receives a carer benefit but not whom " + "they care for, so the model attributes each award. DWP and Social " + "Security Scotland pay a carer benefit or carer element for caring " + "for a person severely disabled for Carer's Allowance " + "(is_severely_disabled_for_carers_allowance; UC Regs 2013 reg 30), a " + "carer has one award (SSCBA 1992 s.70(7)), and only " + "one person has an allowance or a carer element for caring for the " + "same severely disabled person (s.70(7ZA) to (7ZC) and (7A); before " + "16 November 2023, s.70(7) contained both rules). The model makes no " + "one their own carer. A " "carer cares for someone in their own benefit unit if they can: a " "claimant or partner first, then another member such as a disabled " "child. Every other carer in the household, including one left over " @@ -35,20 +39,32 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "pays the single rate instead of the double rate. Where not " "everyone can have a carer, those who are not carers themselves come " "first, then person order; the number cared for in a benefit unit " - "does not depend on that choice. Carers outside the household are not " - "observed, and nor is a carer whose allowance runs on for up to eight " - "weeks after the person they cared for has died (s.70(1A)). A " - "Universal Credit award that includes the carer element also counts " - "in law but is not attributed here: " - "reading Universal Credit would be circular, because the Universal " - "Credit non-dependant deduction exemption reads Pension Credit. Such a " - "carer living in the household already bars both the premium and the " - "addition through their residence conditions unless their presence " - "is ignored. Receipt is receives_carer_benefit: a positive Carer's " - "Allowance or Carer Support Payment after the overlapping-benefit " - "adjustment. A carer whose award an overlapping benefit, such as a " - "higher State Pension, reduces to nil is entitled but not in receipt, " - "so is not attributed." + "does not depend on that choice. The model does not observe carers " + "outside the household, nor a carer whose allowance runs on for up to " + "eight weeks after the person they cared for has died (s.70(1A)). The " + "model reads receipt from receives_carer_benefit: a positive Carer's " + "Allowance or Carer " + "Support Payment after the overlapping-benefit adjustment. A carer " + "whose allowance an overlapping benefit, such as a higher State " + "Pension, reduces to nil keeps the entitlement but receives nothing, " + "so the model does not attribute the allowance. " + "has_uc_award_with_carer_element gives " + "a carer element award from the family's award before the benefit " + "cap: a carer whose award comes to nil, whether income tapers it to " + "nil or the family takes Tax-Free Childcare instead, has no award " + "(Welfare Reform Act 2012 s.5(1)(b); UC Regs 2013 reg 17), and the " + "model attributes none. The model applies the same attribution in " + "Northern Ireland (SPC Regs (NI) 2003 Sch 1 para 1(1) and (4), as SR " + "2016/236 reg 31(7) amended it; HB Regs (NI) 2006 Sch 4 para 14(2); " + "SR 2016/236 regs 26, 28, 35 and 36 insert the same words in Northern " + "Ireland's Income Support, Jobseeker's Allowance, pension-age Housing " + "Benefit and ESA schemes). A carer with both an allowance and a " + "carer element " + "counts once. Partners who care for the same severely disabled person " + "(partners_care_for_same_severely_disabled_person) have one relevant " + "entitlement between them (s.70(7ZA) and (7A)), so where one of them " + "receives a carer benefit, the other's carer element adds no second " + "carer." ) definition_period = YEAR reference = ( @@ -58,12 +74,35 @@ class is_cared_for_by_carer_benefit_recipient(Variable): "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13", "https://www.legislation.gov.uk/uksi/2008/794/schedule/4/paragraph/6", "https://www.legislation.gov.uk/uksi/1996/207/schedule/1/paragraph/15", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/29", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + "https://www.legislation.gov.uk/ukpga/2012/5/section/5", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/17", + "https://www.legislation.gov.uk/nisr/2003/28/schedule/1/paragraph/1", + "https://www.legislation.gov.uk/nisr/2016/236/regulation/31", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/14", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/30", + "https://www.legislation.gov.uk/nisi/2015/2006/article/10", + "https://www.legislation.gov.uk/nisr/2016/216/regulation/17", ) def formula(person, period, parameters): claimant_or_partner = person("is_claimant_or_partner", period) could_be_cared_for = person("is_severely_disabled_for_carers_allowance", period) - carer = person("receives_carer_benefit", period) + receives = person("receives_carer_benefit", period) + # An award of Universal Credit which includes the carer element counts + # as a carer too. A partner's carer element adds no second carer for + # a couple caring for the same person where one of them receives a + # carer benefit: they have one relevant entitlement between them + # (SSCBA 1992 s.70(7ZA) and (7A)). + same_person = person.benunit( + "partners_care_for_same_severely_disabled_person", period + ) + partner_receives = person.benunit.any(claimant_or_partner & receives) + carer_element = person("has_uc_award_with_carer_element", period) & ~( + same_person & partner_receives + ) + carer = receives | carer_element age = person("age", period) # Who takes a carer when not everyone can: someone who is not a carer # themselves, then person order (get_rank breaks ties by position). diff --git a/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py b/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py index f86e87520d..7a01840354 100644 --- a/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py +++ b/policyengine_uk/variables/household/demographic/is_carer_for_benefits.py @@ -9,28 +9,39 @@ class is_carer_for_benefits(Variable): documentation = ( "Receives or is entitled to a carer benefit, or provides at least the " - "Carer's Allowance qualifying hours of care a week. The Universal " + "Carer's Allowance qualifying hours of care a week for a severely " + "disabled person. The Universal " "Credit carer element does not require a Carer's Allowance claim. " "Entitlement-based additions and premiums use " "is_entitled_to_carer_benefit; receipt-based rules use " "receives_carer_benefit. A " "carer whose allowance is reduced to nil by an overlapping benefit, " - "such as State Pension, stays entitled. The hours limb tests " - "hours alone, as carers_allowance_pre_overlap does: it does not check that the " - "person cared for receives a qualifying disability benefit (part of " - "the Carer's Allowance conditions referenced by regulation 30), so a " - "survey hours field counts caring for " - "anyone and a dataset's take-up input carries that simplification. " + "such as State Pension, stays entitled. The hours limb, as in " + "carers_allowance_pre_overlap, counts the hours only where the input " + "cares_for_severely_disabled_person holds: the person cared for gets " + "a benefit that makes them a severely disabled person for Carer's " + "Allowance (SSCBA 1992 s.70(1) and (2)), one of the Carer's Allowance " + "conditions in regulation 30(1)(a). The input defaults to true, " + "because caring hours in the survey don't say whom the carer cares " + "for, so at the default a survey hours field counts caring for anyone " + "and a dataset's take-up input carries that simplification. Set it to " + "false in a household calculation for a carer whose hours go to " + "someone with no such benefit. " "A reported Carer's Allowance award the person would claim also " "counts, so a carer whose earnings put them over the Carer's Allowance " "earnings limit keeps the Universal Credit carer element, which does " "not apply that limit (regulation 30(1)(a)(i))." ) - reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/30" + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/30", + "https://www.legislation.gov.uk/ukpga/1992/4/section/70", + ) def formula(person, period, parameters): min_hours = parameters(period).gov.dwp.carers_allowance.min_hours - meets_hours = person("care_hours", period) >= min_hours + meets_hours = (person("care_hours", period) >= min_hours) & person( + "cares_for_severely_disabled_person", period + ) # A reported Carer's Allowance award the person would claim is # evidence of the caring itself, where no hours are recorded. It still # counts when the earnings limit (which the Universal Credit carer diff --git a/policyengine_uk/variables/household/demographic/is_enhanced_disabled_for_benefits.py b/policyengine_uk/variables/household/demographic/is_enhanced_disabled_for_benefits.py index 7782b4ca94..932af29201 100644 --- a/policyengine_uk/variables/household/demographic/is_enhanced_disabled_for_benefits.py +++ b/policyengine_uk/variables/household/demographic/is_enhanced_disabled_for_benefits.py @@ -4,11 +4,22 @@ class is_enhanced_disabled_for_benefits(Variable): value_type = bool entity = Person - label = "Whether meets the middle disability benefit entitlement" + label = "Receives an enhanced disability premium qualifying benefit (deprecated)" + documentation = ( + "No premium reads this deprecated flag. The enhanced disability premium " + "reads receives_enhanced_disability_premium_qualifying_benefit, which " + "this returns, and applies each programme's own conditions. The model " + "keeps it for downstream compatibility and for the deprecated counts " + "num_enhanced_disabled_adults and num_enhanced_disabled_children. " + "policyengine-uk-data stores its own value of this flag in the " + "Enhanced FRS, and the model uses that value in place of this formula " + "in dataset runs. The stored flag holds where reported attendance " + "allowance reaches the higher weekly rate less £1, reported disability " + "living allowance care exceeds the highest weekly rate less £1, or " + "reported personal independence payment daily living reaches the " + "enhanced weekly rate less £1." + ) definition_period = YEAR def formula(person, period, parameters): - DLA_requirement = ( - parameters(period).gov.dwp.dla.self_care.higher * WEEKS_IN_YEAR - ) - return person("dla_sc", period) >= DLA_requirement + return person("receives_enhanced_disability_premium_qualifying_benefit", period) diff --git a/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py b/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py index 89d137939c..10a6775f8f 100644 --- a/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py +++ b/policyengine_uk/variables/household/demographic/partners_care_for_same_severely_disabled_person.py @@ -19,19 +19,23 @@ class partners_care_for_same_severely_disabled_person(Variable): "Carer's Allowance or Carer Support Payment award " "(carers_allowance_reported, the model's reported-receipt input for " "both), or an entitlement that caring hours do not explain (fewer " - "than the qualifying hours, or no claim), which must have been " + "than the qualifying hours, care of someone who isn't severely " + "disabled (cares_for_severely_disabled_person false), or no claim), " + "which must have been " "supplied directly (for example as carers_allowance, " "carers_allowance_pre_overlap or is_entitled_to_carer_benefit; supply " "it for every member, since members left out take the default). The " "qualifying hours are Carer Support Payment's in Scotland from 2025 " "and Carer's Allowance's otherwise. Two awards mean two different " "people cared for, while caring hours cannot show who is cared for, " - "so the entitlement of someone who cares the qualifying hours and " - "would claim is not read as an award. " + "so the default doesn't read as an award the entitlement of someone " + "who cares the qualifying hours for a severely disabled person and " + "would claim. " "With more than two members supplied as claimant or partner, " "two awards make the default false for all of them. The model's " "Carer's Allowance and Carer Support Payment candidate amounts follow " - "caring hours or a reported award. The default uses those candidates " + "a reported award or caring hours for a severely disabled person. " + "The default uses those candidates " "and directly supplied awards before the same-person couple limit, " "so it does not depend on the final awards it restricts." ) @@ -48,11 +52,13 @@ def formula(benunit, period, parameters): entitled = unlimited_carer_benefit_entitlement(benunit.members, period) reported_award = benunit.members("carers_allowance_reported", period) > 0 # The model makes someone entitled to a carer benefit only on a - # reported award or the qualifying hours, each for someone who would - # claim, so an entitlement the hours do not explain comes from a - # reported award or was supplied directly (an amount in payment, a - # pre-overlap amount or the entitlement itself). The hours are those - # of the benefit that applies, as in carers_allowance_pre_overlap. + # reported award or the qualifying hours of care for a severely + # disabled person (cares_for_severely_disabled_person), each for + # someone who would claim, so an entitlement the hours do not explain + # comes from a reported award or an input that supplies it directly + # (an amount in payment, a pre-overlap amount or the entitlement + # itself). The hours are those of the benefit that applies, as in + # carers_allowance_pre_overlap. gov = parameters(period).gov in_scotland = ( benunit.members.household("country", period).decode_to_str() == "SCOTLAND" @@ -64,7 +70,12 @@ def formula(benunit, period, parameters): gov.dwp.carers_allowance.min_hours, ) hours = benunit.members("care_hours", period) + cares_for_severely_disabled = benunit.members( + "cares_for_severely_disabled_person", period + ) would_claim = benunit.members("would_claim_carers_allowance", period) - explained_by_hours = (hours >= min_hours) & would_claim + explained_by_hours = ( + (hours >= min_hours) & cares_for_severely_disabled & would_claim + ) award = reported_award | ~explained_by_hours return benunit.sum(claimant_or_partner & entitled & award) < 2 diff --git a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py index 5a41077d36..65913bcb92 100644 --- a/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/pension_age_severe_disability_premium.py @@ -19,9 +19,11 @@ class pension_age_severe_disability_premium(Variable): "its amounts and its documented assumptions: where only one partner " "qualifies and the other is blind, the qualifying partner is taken to " "claim, as a couple may agree under HB(SPC) reg 63(1) and the CTR " - "schemes (England SI 2012/2885 Sch 8 para 4(1)); carers are attributed " - "by is_cared_for_by_carer_benefit_recipient, which does not count " - "Universal Credit carer-element awards. The Pension Credit severe " + "schemes (England SI 2012/2885 Sch 8 para 4(1)). " + "is_cared_for_by_carer_benefit_recipient attributes carers, and " + "counts a Universal Credit award with the carer element only where " + "the carer's award comes to more than nil " + "(has_uc_award_with_carer_element). The Pension Credit severe " "disability addition has the same conditions apart from its wider " "residence exceptions, but its own amount parameter, so a Pension " "Credit reform does not move this premium." diff --git a/policyengine_uk/variables/household/demographic/receives_enhanced_disability_premium_qualifying_benefit.py b/policyengine_uk/variables/household/demographic/receives_enhanced_disability_premium_qualifying_benefit.py new file mode 100644 index 0000000000..e955416fbb --- /dev/null +++ b/policyengine_uk/variables/household/demographic/receives_enhanced_disability_premium_qualifying_benefit.py @@ -0,0 +1,55 @@ +from policyengine_uk.model_api import * + + +class receives_enhanced_disability_premium_qualifying_benefit(Variable): + value_type = bool + entity = Person + label = "Receives an enhanced disability premium qualifying benefit" + documentation = ( + "The model counts a person as receiving a qualifying benefit for the " + "legacy enhanced disability premium when they have an award of the " + "care component of disability living allowance at the highest rate " + "(has_highest_dla_sc_award), the daily living component of personal " + "independence payment at the enhanced rate (has_enhanced_pip_dl_award) " + "or armed forces independence payment. It counts only positive " + "amounts, and takes the rate from the award category or, for an " + "amount entered without its category, from the amount. The model " + "applies each programme's further conditions elsewhere: the age limit " + "(num_enhanced_disabled_claimants_or_partners_for_legacy_benefits) " + "and, in Council Tax Reduction, the scheme's own list " + "(meets_council_tax_reduction_enhanced_disability_premium_condition). " + "Attendance allowance does not qualify. The model has no separate " + "variables for the Scottish equivalents (the care components of child " + "disability payment and Scottish adult disability living allowance at " + "the highest rate, and the daily living component of adult disability " + "payment at the enhanced rate). It omits the limited capability for " + "work-related activity limb (Housing Benefit), the support component " + "limb (Employment and Support Allowance), the hospital rules and the " + "long-term patient exclusion. The model reads the Northern Ireland " + "list for households in Northern Ireland and the Great Britain list " + "elsewhere. Claimants in Northern Ireland qualify through armed forces " + "independence payment from 24 December 2013 and through PIP daily " + "living from 20 June 2016, and claimants in Great Britain through both " + "from 8 April 2013. The model reads each list as at 30 April of the " + "year from 2015, without within-year proration." + ) + definition_period = YEAR + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/3/paragraph/15", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/2/paragraph/13A", + "https://www.legislation.gov.uk/uksi/2008/794/schedule/4/paragraph/7", + "https://www.legislation.gov.uk/uksi/1996/207/schedule/1/paragraph/15A", + "https://www.legislation.gov.uk/nisr/2006/405/schedule/4/paragraph/15", + "https://www.legislation.gov.uk/nisr/2008/280/schedule/4/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/7/paragraph/12", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.dwp.disability_premia + country = person.household("country", period) + northern_ireland = country == country.possible_values.NORTHERN_IRELAND + return where( + northern_ireland, + add(person, period, p.enhanced_qualifying_benefits_northern_ireland) > 0, + add(person, period, p.enhanced_qualifying_benefits) > 0, + ) diff --git a/policyengine_uk/variables/household/demographic/severe_disability_premium.py b/policyengine_uk/variables/household/demographic/severe_disability_premium.py index d748f85fb4..658c8eeb5f 100644 --- a/policyengine_uk/variables/household/demographic/severe_disability_premium.py +++ b/policyengine_uk/variables/household/demographic/severe_disability_premium.py @@ -19,11 +19,14 @@ class severe_disability_premium(Variable): "for caring for them. A couple who both qualify get the double rate " "when no carer benefit is paid for either of them, the single rate " "when one is paid for only one of them, and nothing when carers are " - "paid for both. Whom a carer benefit is paid for caring for is " - "attributed by is_cared_for_by_carer_benefit_recipient, shared with " - "the Pension Credit severe disability addition; the law also counts " - "a Universal Credit award with the carer element, which the model " - "does not. Not modelled: a carer's " + "paid for both. is_cared_for_by_carer_benefit_recipient attributes " + "each carer benefit to the person it is paid for caring for, and the " + "Pension Credit severe disability addition shares it. The attribution " + "counts an award of Universal Credit which includes the carer " + "element (has_uc_award_with_carer_element). A carer whose award " + "comes to nil, after any non-dependant deduction or because the " + "family takes Tax-Free Childcare instead, does not bar the premium. " + "Not modelled: a carer's " "allowance or carer element that would be paid but for a loss of " "benefit restriction under section 6B or 7 of the Social Security " "Fraud Act 2001 also counts (HB Regs 2006 Sch 3 para 14(7), IS Regs " diff --git a/policyengine_uk/variables/household/income/inflation_adjustment.py b/policyengine_uk/variables/household/income/inflation_adjustment.py index 996763f91e..8a01dbf2ff 100644 --- a/policyengine_uk/variables/household/income/inflation_adjustment.py +++ b/policyengine_uk/variables/household/income/inflation_adjustment.py @@ -1,10 +1,22 @@ from policyengine_uk.model_api import * -import datetime -import numpy as np + + +def price_base_date(parameters, period) -> str: + """1 January of gov.simulation.price_base_year, as a date string. + + A reform can set the year as a float, so the formula takes its integer + part before it builds the date. + """ + year = int(parameters(period).gov.simulation.price_base_year) + return f"{year:04d}-01-01" class inflation_adjustment(Variable): - label = f"inflation multiplier to get {datetime.datetime.now().year} prices" + label = "inflation multiplier to get base-year prices" + documentation = ( + "Consumer price index on 1 January of gov.simulation.price_base_year " + "divided by the index in the period." + ) entity = Household definition_period = YEAR value_type = float @@ -12,28 +24,31 @@ class inflation_adjustment(Variable): def formula(household, period, parameters): cpi = parameters.gov.economic_assumptions.indices.obr.consumer_price_index - current_period_cpi = cpi(period) - now_cpi = cpi(datetime.datetime.now().strftime("%Y-01-01")) - return now_cpi / current_period_cpi + ratio = cpi(price_base_date(parameters, period)) / cpi(period) + return household.empty_array() + ratio class inflation_adjustment_ahc(Variable): - label = f"inflation multiplier to get {datetime.datetime.now().year} after housing costs prices" + label = "inflation multiplier to get base-year after housing costs prices" + documentation = ( + "Ratio of one price index on 1 January of " + "gov.simulation.price_base_year to the same index in the period. The " + "formula uses consumer_price_index_ahc where that index has a value " + "on both dates, and consumer_price_index on both dates otherwise." + ) entity = Household definition_period = YEAR value_type = float unit = "/1" def formula(household, period, parameters): - cpi = parameters.gov.economic_assumptions.indices.obr.consumer_price_index - cpi_ahc = ( - parameters.gov.economic_assumptions.indices.obr.consumer_price_index_ahc - ) - - cpi_ahc_val = cpi_ahc(period) - # Fall back to use cpi, for periods beyond 2029 - if not cpi_ahc_val: - cpi_ahc_val = cpi(period) - - now_cpi_ahc = cpi(datetime.datetime.now().strftime("%Y-01-01")) - return now_cpi_ahc / cpi_ahc_val + indices = parameters.gov.economic_assumptions.indices.obr + base_date = price_base_date(parameters, period) + ahc_base = indices.consumer_price_index_ahc(base_date) + ahc_period = indices.consumer_price_index_ahc(period) + if ahc_base is not None and ahc_period is not None: + ratio = ahc_base / ahc_period + else: + cpi = indices.consumer_price_index + ratio = cpi(base_date) / cpi(period) + return household.empty_array() + ratio diff --git a/policyengine_uk/variables/household/income/real_household_net_income.py b/policyengine_uk/variables/household/income/real_household_net_income.py index d762af06d9..e12174fc35 100644 --- a/policyengine_uk/variables/household/income/real_household_net_income.py +++ b/policyengine_uk/variables/household/income/real_household_net_income.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * -import datetime import numpy as np class real_household_net_income(Variable): - label = f"real household net income ({datetime.datetime.now().year} prices)" + label = "real household net income (base-year prices)" entity = Household definition_period = YEAR value_type = float diff --git a/policyengine_uk/variables/household/income/real_household_net_income_ahc.py b/policyengine_uk/variables/household/income/real_household_net_income_ahc.py index 5cdd1c50e3..c25e4b80c9 100644 --- a/policyengine_uk/variables/household/income/real_household_net_income_ahc.py +++ b/policyengine_uk/variables/household/income/real_household_net_income_ahc.py @@ -1,10 +1,9 @@ from policyengine_uk.model_api import * -import datetime import numpy as np class real_household_net_income_ahc(Variable): - label = f"real household net income ({datetime.datetime.now().year} prices)" + label = "real household net income after housing costs (base-year prices)" entity = Household definition_period = YEAR value_type = float diff --git a/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py new file mode 100644 index 0000000000..98b89e96a2 --- /dev/null +++ b/policyengine_uk/variables/household/income/receives_income_support_in_own_right.py @@ -0,0 +1,40 @@ +from policyengine_uk.model_api import * + + +class receives_income_support_in_own_right(Variable): + value_type = bool + entity = Person + label = "Receives Income Support in own right" + documentation = ( + "Whether this person receives Income Support on a claim of their own, " + "as the Child Benefit qualifying young person test reads it (Child " + "Benefit (General) Regulations 2006 reg. 8(2)(a)); through that test " + "it also ends the Housing Benefit and legacy young person. It is the " + "person's own reported Income Support (income_support_reported) " + "while Income Support is in payment (gov.dwp.income_support.active) " + "and no reform neutralises income_support; set it directly otherwise. " + "For a member who is not the claimant or partner, " + "is_on_income_support reads the same report. For a claimant or " + "partner, is_on_income_support reads the benefit unit's calculated " + "award, for whichever of the claimant and partner receives it, but " + "that award depends on who the family's Child Benefit young persons " + "are, so this variable cannot read it without a cycle. A reform that " + "removes Income Support must set gov.dwp.income_support.active to " + "false, neutralise income_support, or set reported awards to zero, " + "for the Child Benefit status to follow; replacing its formula with " + "zero leaves this variable true. Pension Credit (SPC Regs 2002 reg. " + "4A(5)) and Child Tax Credit (CTC Regs 2002 reg. 5(4)(c)) read " + "is_on_income_support instead, so they follow such a reform for a " + "claimant or partner. Universal Credit's list (UC Regs 2013 reg. " + "5(5)) does not include Income Support." + ) + definition_period = YEAR + reference = "https://www.legislation.gov.uk/uksi/2006/223/regulation/8" + + def formula(person, period, parameters): + active = parameters(period).gov.dwp.income_support.active + # A reform that removes Income Support leaves no award to receive. + removed = person.simulation.tax_benefit_system.get_variable( + "income_support" + ).is_neutralized + return active & (not removed) & (person("income_support_reported", period) > 0) diff --git a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py index f99b7c3d78..65401cb11a 100644 --- a/policyengine_uk/variables/household/wealth/benunit_reported_capital.py +++ b/policyengine_uk/variables/household/wealth/benunit_reported_capital.py @@ -14,7 +14,9 @@ class benunit_reported_capital(Variable): "This financial observation excludes property. Universal Credit uses " "it instead of financial savings, investments and covered Lifetime " "ISA proxies, while retaining countable property separately. Pension " - "Credit retains its existing treatment via pension_credit_reported_capital. " + "Credit also counts separately valued property alongside this report; " + "setting pension_credit_reported_capital directly " + "supplies a complete override. " "Survey financial totals approximate countable financial capital: " "asset-specific disregards and valuations are not observed. Supply " "uc_reported_capital directly for a complete UC countable-capital " diff --git a/policyengine_uk/variables/household/wealth/household_land_value.py b/policyengine_uk/variables/household/wealth/household_land_value.py index 7557b2a842..e93ffd6597 100644 --- a/policyengine_uk/variables/household/wealth/household_land_value.py +++ b/policyengine_uk/variables/household/wealth/household_land_value.py @@ -5,7 +5,12 @@ class household_land_value(Variable): entity = Household label = "Land value" - documentation = "Estimated total land value directly owned by the household" + documentation = ( + "Estimated UK land value: regional land intensity applied to property " + "excluding direct UK land and identified overseas holdings, plus " + "direct UK land at full value. Unclassified other real estate retains " + "the regional approximation because its geography is not identified." + ) unit = GBP definition_period = YEAR value_type = float @@ -14,7 +19,15 @@ class household_land_value(Variable): def formula(household, period, parameters): region = household("region", period) Region = region.possible_values - property_wealth = household("property_wealth", period) + domestic_property = add( + household, + period, + [ + "residential_property_value", + "non_residential_property_value", + "other_property_value", + ], + ) owned_land = household("owned_land", period) regional = parameters( @@ -53,4 +66,4 @@ def formula(household, period, parameters): default=regional.UNKNOWN, ) - return property_wealth * property_wealth_intensity + owned_land + return domestic_property * property_wealth_intensity + owned_land diff --git a/policyengine_uk/variables/household/wealth/property_wealth.py b/policyengine_uk/variables/household/wealth/property_wealth.py index 2ea53aa28c..3cf876685d 100644 --- a/policyengine_uk/variables/household/wealth/property_wealth.py +++ b/policyengine_uk/variables/household/wealth/property_wealth.py @@ -3,10 +3,21 @@ class property_wealth(Variable): label = "Property wealth" - documentation = "Total property wealth across all owned properties" + documentation = ( + "Worldwide gross value of all owned property, including direct UK " + "land, overseas land and property, and unclassified other real estate. " + "Sale expenses and secured debt are considered separately when " + "valuing countable benefit capital." + ) entity = Household definition_period = YEAR value_type = float unit = GBP quantity_type = STOCK - adds = ["residential_property_value", "non_residential_property_value"] + adds = [ + "residential_property_value", + "non_residential_property_value", + "owned_land", + "overseas_property_value", + "other_property_value", + ] diff --git a/policyengine_uk/variables/household/wealth/residential_property_value.py b/policyengine_uk/variables/household/wealth/residential_property_value.py index b97fa30ac1..82ac87dede 100644 --- a/policyengine_uk/variables/household/wealth/residential_property_value.py +++ b/policyengine_uk/variables/household/wealth/residential_property_value.py @@ -3,7 +3,12 @@ class residential_property_value(Variable): label = "Residential property value" - documentation = "Total value of all owned residential property" + documentation = ( + "Gross value of domestic residential property: the main residence " + "plus other residential property, which combines second homes and " + "buy-to-let holdings. Overseas and unclassified other real estate " + "cannot be classified as residential from their aggregate values." + ) entity = Household definition_period = YEAR value_type = float diff --git a/policyengine_uk/variables/input/non_residential_property_secured_debt.py b/policyengine_uk/variables/input/non_residential_property_secured_debt.py index ec04a1c86d..d64fe5a50b 100644 --- a/policyengine_uk/variables/input/non_residential_property_secured_debt.py +++ b/policyengine_uk/variables/input/non_residential_property_secured_debt.py @@ -7,10 +7,14 @@ class non_residential_property_secured_debt(Variable): label = "Debt secured on non-residential property" documentation = ( "Outstanding encumbrances secured specifically on the household's " - "non-residential property. Excludes main-home mortgage debt and unsecured " - "debt. Deducted after sale expenses in UC regulation 49 valuation. " - "The survey lacks reliable property-specific debt flags, so the " - "default zero is a modelling approximation unless supplied explicitly." + "non-residential property. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "UC regulation 49(1) and its legacy equivalents (HB, IS, JSA, ESA and " + "Pension Credit), flooring the asset class at nil. The value and the " + "debt are household totals across the class, so debt on one asset " + "offsets equity in another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/other_property_value.py b/policyengine_uk/variables/input/other_property_value.py new file mode 100644 index 0000000000..eced2715ec --- /dev/null +++ b/policyengine_uk/variables/input/other_property_value.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class other_property_value(Variable): + label = "Other property value" + documentation = ( + "Gross value of other real estate with unspecified geography and " + "property type. Included in worldwide property wealth and the regional " + "property-to-land approximation, but not UK property purchase values." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/other_residential_property_secured_debt.py b/policyengine_uk/variables/input/other_residential_property_secured_debt.py index ff3488f386..adfa435fa8 100644 --- a/policyengine_uk/variables/input/other_residential_property_secured_debt.py +++ b/policyengine_uk/variables/input/other_residential_property_secured_debt.py @@ -7,10 +7,14 @@ class other_residential_property_secured_debt(Variable): label = "Debt secured on other residential property" documentation = ( "Outstanding encumbrances secured specifically on the household's " - "other residential property. Excludes main-home mortgage debt and unsecured " - "debt. Deducted after sale expenses in UC regulation 49 valuation. " - "The survey lacks reliable property-specific debt flags, so the " - "default zero is a modelling approximation unless supplied explicitly." + "other residential property. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "UC regulation 49(1) and its legacy equivalents (HB, IS, JSA, ESA and " + "Pension Credit), flooring the asset class at nil. The value and the " + "debt are household totals across the class, so debt on one asset " + "offsets equity in another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/input/overseas_property_value.py b/policyengine_uk/variables/input/overseas_property_value.py new file mode 100644 index 0000000000..f7ff16653e --- /dev/null +++ b/policyengine_uk/variables/input/overseas_property_value.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class overseas_property_value(Variable): + label = "Overseas property value" + documentation = ( + "Gross value of overseas land and property, including time-shares. " + "Included in worldwide property wealth and excluded from UK land " + "estimates and UK property purchase values." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/owned_land_secured_debt.py b/policyengine_uk/variables/input/owned_land_secured_debt.py new file mode 100644 index 0000000000..18d93d7c0b --- /dev/null +++ b/policyengine_uk/variables/input/owned_land_secured_debt.py @@ -0,0 +1,25 @@ +from policyengine_uk.model_api import * + + +class owned_land_secured_debt(Variable): + value_type = float + entity = Household + label = "Debt secured on owned land" + documentation = ( + "Outstanding encumbrances secured specifically on the household's " + "land-only plots. Excludes main-home mortgage debt and unsecured debt. " + "Deducted after the 10% for sale expenses in the capital valuation of " + "the legacy means tests (HB, IS, JSA, ESA and Pension Credit), " + "flooring the asset class at nil. UC counts no land, so this input " + "leaves UC capital unchanged. The value and the debt are household " + "totals across the class, so debt on one asset offsets equity in " + "another. The survey lacks reliable property-specific " + "debt flags, so the default zero is a modelling approximation unless " + "supplied explicitly." + ) + definition_period = YEAR + unit = GBP + quantity_type = STOCK + default_value = 0 + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" + reference = "https://www.legislation.gov.uk/uksi/2013/376/regulation/49/1"