diff --git a/changelog.d/2572-bonus-sacrifice.added.md b/changelog.d/2572-bonus-sacrifice.added.md new file mode 100644 index 0000000000..c25bec35d0 --- /dev/null +++ b/changelog.d/2572-bonus-sacrifice.added.md @@ -0,0 +1 @@ +- Added `pension_contributions_via_bonus_sacrifice`, an input for employer pension contributions made in place of a bonus. It is read summed with `pension_contributions_via_salary_sacrifice`, so the £2,000 NICs exemption cap from April 2029 and the annual allowance threshold income cover both routes, and datasets can keep the two apart. diff --git a/changelog.d/2572-heating-fuels.added.md b/changelog.d/2572-heating-fuels.added.md new file mode 100644 index 0000000000..229bf5689c --- /dev/null +++ b/changelog.d/2572-heating-fuels.added.md @@ -0,0 +1 @@ +- Added `liquid_fuel_consumption` and `solid_fuel_consumption`, household spending on heating oil, LPG, coal and wood (COICOP 04.5.3 and 04.5.4). They default to zero, are already part of `housing_water_and_electricity_consumption` so add nothing to `consumption`, and dataset projection uprates them by CPI like other LCFS spending, since Ofgem's price cap does not cover them. diff --git a/changelog.d/2572-spousal-maintenance.added.md b/changelog.d/2572-spousal-maintenance.added.md new file mode 100644 index 0000000000..9dc1bbacc0 --- /dev/null +++ b/changelog.d/2572-spousal-maintenance.added.md @@ -0,0 +1 @@ +- Added `spousal_maintenance_income`, maintenance paid by a spouse, civil partner, former spouse or former civil partner. It is Universal Credit unearned income, Pension Credit income (but not savings credit qualifying income) and Housing Benefit income, less £15 a week where the family includes a child or young person, as well as market and HBAI income. `maintenance_income` is documented as child maintenance, which counts in none of the means tests. diff --git a/changelog.d/2572-student-loan-plans.fixed.md b/changelog.d/2572-student-loan-plans.fixed.md new file mode 100644 index 0000000000..baa34a38b9 --- /dev/null +++ b/changelog.d/2572-student-loan-plans.fixed.md @@ -0,0 +1 @@ +- Fixed `uprate_student_loan_plans` in dataset projection: it takes the base year from the dataset instead of assuming 2023, compares the data's fixed ages with the base year's oldest holder of each plan (so new Plan 2 holders above the 2012 intake now appear, and new Plan 1 holders come only from the 1998 and later intakes, with loans first paid before September 2006 written off at 65), and keys each person's take-up draw on `person_id`, so the same people hold a loan in every year and in any row order. diff --git a/changelog.d/2572-widowed-parents-allowance.added.md b/changelog.d/2572-widowed-parents-allowance.added.md new file mode 100644 index 0000000000..9c998fdff5 --- /dev/null +++ b/changelog.d/2572-widowed-parents-allowance.added.md @@ -0,0 +1 @@ +- Added `widowed_parents_allowance_reported` (CPI-uprated) and `widowed_parents_allowance`, so datasets can separate Widowed Parent's Allowance from Bereavement Support Payment. Unlike Bereavement Support Payment, the allowance is taxable, Universal Credit unearned income, a welfare benefit for the benefit cap and an overlapping benefit for Carer's Allowance and Carer Support Payment. Housing Benefit counts it less £15 a week and Income Support less £10 a week. diff --git a/changelog.d/fix-2573-council-tax-reduction.fixed.md b/changelog.d/fix-2573-council-tax-reduction.fixed.md new file mode 100644 index 0000000000..2aadacbc18 --- /dev/null +++ b/changelog.d/fix-2573-council-tax-reduction.fixed.md @@ -0,0 +1 @@ +- Count claimant and partner spousal maintenance and Widowed Parent’s Allowance in Council Tax Reduction income across Great Britain, with the statutory £15 weekly disregards and Pension Credit routing. diff --git a/changelog.d/fix-2573-income-support.fixed.md b/changelog.d/fix-2573-income-support.fixed.md new file mode 100644 index 0000000000..d6b077a67a --- /dev/null +++ b/changelog.d/fix-2573-income-support.fixed.md @@ -0,0 +1 @@ +- Count the claimant's and partner's spousal maintenance in Income Support while continuing to disregard child maintenance. diff --git a/changelog.d/fix-2573-non-dependants.fixed.md b/changelog.d/fix-2573-non-dependants.fixed.md new file mode 100644 index 0000000000..53eb6f7c6b --- /dev/null +++ b/changelog.d/fix-2573-non-dependants.fixed.md @@ -0,0 +1 @@ +- Included spousal maintenance in Housing Benefit and Council Tax Reduction non-dependant income bands while preserving its income-tax exemption. diff --git a/changelog.d/fix-2573-student-loans.fixed.md b/changelog.d/fix-2573-student-loans.fixed.md new file mode 100644 index 0000000000..351e7f842f --- /dev/null +++ b/changelog.d/fix-2573-student-loans.fixed.md @@ -0,0 +1 @@ +- Correct newly assigned projected student loan plans by funding nation, using household region as a proxy: Plan 4 for Scotland, Plan 1 for Northern Ireland, Plan 2 for Wales, and Plan 2 or 5 by entry cohort for England. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 6946f91a02..b6017d3402 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -18,9 +18,9 @@ We apply CPI to these variables: - All reported benefits– `carers_allowance_reported`, `child_benefit_reported`, `child_tax_credit_reported`, `esa_contrib_reported`, `esa_income_reported`, `housing_benefit_reported`, `iidb_reported`, `incapacity_benefit_reported`, `income_support_reported`, `jsa_contrib_reported`, `jsa_income_reported`, `maternity_allowance_reported`, `pension_credit_reported`, `sda_reported`, `state_pension_reported`, `universal_credit_reported`, `winter_fuel_allowance_reported`, `working_tax_credit_reported` - All consumption categories EXCEPT energy– `alcohol_and_tobacco_consumption`, `clothing_and_footwear_consumption`, `communication_consumption`, `education_consumption`, `food_and_non_alcoholic_beverages_consumption`, `health_consumption`, `household_furnishings_consumption`, `housing_water_and_electricity_consumption`, `miscellaneous_consumption`, `recreation_consumption`, `restaurants_and_hotels_consumption`, `transport_consumption` -`domestic_energy_consumption`, `electricity_consumption` and `gas_consumption` are deliberately excluded. The data build calibrates them to NEED mean kWh and converts to pounds at Ofgem Q2 2026 unit rates, so the stored values already carry FY26/27 price levels; uprating them from the dataset's data year would re-apply price changes that are already included. See PolicyEngine/policyengine-uk#1867. +`domestic_energy_consumption`, `electricity_consumption` and `gas_consumption` are deliberately excluded. The data build calibrates them to NEED mean kWh and converts to pounds at Ofgem Q2 2026 unit rates, so the stored values already carry FY26/27 price levels; uprating them from the dataset's data year would re-apply price changes that are already included. See PolicyEngine/policyengine-uk#1867. `liquid_fuel_consumption` and `solid_fuel_consumption` (heating oil, LPG, coal and wood) are not covered by Ofgem's price cap, so they are CPI-uprated from the data year like other LCFS spending. -- Other variables– `afcs_reported`, `bsp_reported`, `childcare_expenses`, `free_school_fruit_veg`, `free_school_meals`, `free_school_milk`, `maintenance_expenses`, `statutory_maternity_pay`, `statutory_paternity_pay`, `statutory_sick_pay`, `state_pension`, `war_widows_pension_reported` +- Other variables– `afcs_reported`, `bsp_reported`, `childcare_expenses`, `free_school_fruit_veg`, `free_school_meals`, `free_school_milk`, `maintenance_expenses`, `statutory_maternity_pay`, `statutory_paternity_pay`, `statutory_sick_pay`, `state_pension`, `war_widows_pension_reported`, `widowed_parents_allowance_reported` ## Road fuel volume @@ -58,6 +58,29 @@ We apply the OBR's wage growth forecasts to employment-related variables. We use average earnings for these variables: `employee_pension_contributions`, `employer_pension_contributions`, `employment_income`, `employment_income_before_lsr`, `personal_pension_contributions`, `student_loan_repayments` +### Newly assigned student loan plans + +Dataset projection holds ages fixed, so a person of a given age represents a +later university intake in each projected year. When projection adds a loan +to a tertiary-educated person without one, it uses current household region +as a proxy for the funding nation, because the dataset does not record the +funding agency. This proxy cannot identify people who moved between nations. + +The [official repayment-plan rules](https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on) +give SAAS borrowers Plan 4 and Student Finance Northern Ireland borrowers +Plan 1. For undergraduate loans, England uses Plan 1 before the 2012 intake, +Plan 2 for 2012–2022, and Plan 5 from 2023; Wales uses Plan 1 before 2012 and +Plan 2 from 2012. New assignments follow these routes; an unknown region +leaves the person without an assigned loan. New holders from the 2023 and +later intakes are added only in England: Plan 5 is the only plan that did not +exist in the base year, while the other nations' plans for those intakes are +already in the data. Plan 1 loans first paid before September 2006 (the 2005 +and earlier intakes) are written off at 65, later ones about 29 years after +the course started. Take-up remains a fixed 40% +probability with a draw keyed on person ID. This funding-nation routing +applies to newly assigned loans; the existing-holder relabelling and +write-off approximations are unchanged. + ## Lagged average earnings We lag earnings growth by one year and use that to uprate housing service charges (including ground rent). @@ -74,7 +97,7 @@ We derive these rates from OBR GDP growth and ONS population projections. |-------------|------|------|------|------|------|------|------|------| | Growth rate | 9.2% | 5.0% | 3.8% | 2.8% | 2.8% | 3.1% | 3.3% | 3.3% | -We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` +We use per capita GDP for these variables: `benunit_reported_capital`, `capital_gains`, `capital_gains_badr`, `capital_gains_before_response`, `capital_gains_carried_interest`, `capital_gains_residential_property`, `cash_isa`, `corporate_wealth`, `directly_held_shares`, `dividend_income`, `gross_financial_wealth`, `household_lifetime_isa_balance`, `lifetime_isa_balance`, `lump_sum_income`, `main_residence_value`, `maintenance_income`, `miscellaneous_income`, `mortgage_capital_repayment`, `net_financial_wealth`, `non_residential_property_value`, `other_investment_income`, `other_residential_property_value`, `owned_land`, `pension_credit_reported_capital`, `pension_income`, `private_pension_wealth`, `private_transfer_income`, `property_income`, `property_rental_income`, `savings`, `savings_interest_income`, `spousal_maintenance_income`, `stocks_and_shares_isa`, `sublet_income`, `uc_reported_capital`, `unit_and_investment_trusts` ## Council tax diff --git a/docs/book/programs/gov/cms/child-maintenance.md b/docs/book/programs/gov/cms/child-maintenance.md index 7dc437cd20..697391089c 100644 --- a/docs/book/programs/gov/cms/child-maintenance.md +++ b/docs/book/programs/gov/cms/child-maintenance.md @@ -1,25 +1,33 @@ # Child maintenance (planned) ```{warning} -**Not yet modelled in detail.** PolicyEngine UK has two generic person-level -inputs, `maintenance_income` and `maintenance_expenses`. They do not tell -child maintenance apart from maintenance between former partners, and the -model does not calculate a Child Maintenance Service (CMS) award. This page -sets out the scope for doing that, tracked in -[#669](https://github.com/PolicyEngine/policyengine-uk/issues/669) (still -open, October 2026). +**Not yet modelled in detail.** The model does not calculate a Child +Maintenance Service (CMS) award. This page sets out the scope for doing that, +tracked in [#669](https://github.com/PolicyEngine/policyengine-uk/issues/669) +(still open, October 2026). ``` ## What the model does today -- `maintenance_income` is added to `market_income`, `household_market_income` - and `hbai_household_net_income`. -- `maintenance_expenses` is subtracted in `market_income` and - `hbai_household_net_income`. -- Neither variable feeds any means test. Universal Credit unearned income is - the list in `gov.dwp.universal_credit.means_test.income_definitions.unearned`, - which contains no maintenance, and UC earned income does not net off - maintenance paid. +- `maintenance_income` holds child maintenance received. Datasets fill it + from the Family Resources Survey child maintenance module. +- `spousal_maintenance_income` holds maintenance paid by a spouse, civil + partner, former spouse or former civil partner + ([#2572](https://github.com/PolicyEngine/policyengine-uk/issues/2572)). +- Both are added to `market_income`, `household_market_income` and + `hbai_household_net_income`. `maintenance_expenses` (maintenance paid) is + subtracted in `market_income` and `hbai_household_net_income`. +- Only `spousal_maintenance_income` feeds the means tests: + - Universal Credit unearned income + ([UC Regulations 2013 reg. 66(1)(d)](https://www.legislation.gov.uk/uksi/2013/376/regulation/66)); + - Pension Credit income + ([State Pension Credit Regulations 2002 reg. 15(5)(d)](https://www.legislation.gov.uk/uksi/2002/1792/regulation/15)), + but not savings credit qualifying income (reg. 9(f)); + - Housing Benefit income, less £15 a week where the family includes a child + or young person + ([HB Regulations 2006 Sch. 5 para. 47](https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/47); + [HB (SPC) Regulations 2006 reg. 29(1)(o) and Sch. 5 para. 20](https://www.legislation.gov.uk/uksi/2006/214/regulation/29)). +- UC earned income does not net off maintenance paid. - `uc_deductions` does cover child maintenance collected from a paying parent's UC award, but only in aggregate: it sits in the above-cap excess of `uc_deduction_rate` with last resort deductions, estimated from DWP @@ -35,35 +43,19 @@ For **child** maintenance that is the right answer for UC: This asymmetry is the work-incentive problem described by the Centre for Social Justice in [*The Hidden Parent Poverty Trap: Child Maintenance and Universal -Credit*][csj]. The model already reproduces it, because neither input reaches -UC. - -The gap is **maintenance between former spouses or civil partners**. -Reg. 66(1)(d) counts it as unearned income in UC, but the model cannot see it, -because it shares `maintenance_income` with child maintenance. Those -recipients' UC is overstated. +Credit*][csj]. The model reproduces it, because neither child maintenance +variable reaches UC. ## Scope -### Phase 1: separate the inputs - -- New input `child_maintenance_received` (Person, GBP, annual). -- New input `child_maintenance_paid` (Person, GBP, annual). -- Keep `maintenance_income` and `maintenance_expenses` for maintenance - between former partners, and say so in their documentation. -- In the data pipeline - ([Microcosm UK](https://github.com/PolicyEngine/microcosm)), route the FRS - child-maintenance items to the new inputs and the spousal items to the - existing ones. -- HBAI income keeps both kinds, received and paid. - -### Phase 2: count spousal maintenance in UC - -- Add `maintenance_income` (now spousal only) to the UC unearned income list - from 2013-04-29, citing reg. 66(1)(d). -- Leave `child_maintenance_received` and `child_maintenance_paid` out of UC. -- Check the legacy benefits and Pension Credit the same way before adding - either input to their income definitions. +### Phases 1 and 2: separate the inputs and count spousal maintenance + +Done in the engine by #2572. What remains is in the data: datasets that put +the FRS payments from an absent partner in `private_transfer_income` should +move them to `spousal_maintenance_income` +([policyengine-uk-data#627](https://github.com/PolicyEngine/policyengine-uk-data/issues/627); +[Microcosm UK](https://github.com/PolicyEngine/microcosm)). Datasets that do +not supply it keep today's results. ### Phase 3: model the CMS calculation diff --git a/docs/book/programs/gov/dwp/bereavement-support-plan.md b/docs/book/programs/gov/dwp/bereavement-support-plan.md index 9499139048..1d06731a45 100644 --- a/docs/book/programs/gov/dwp/bereavement-support-plan.md +++ b/docs/book/programs/gov/dwp/bereavement-support-plan.md @@ -36,7 +36,7 @@ That means: - Reform analyses changing BSP rates have no household-level surface. - Anyone bereaved after the FRS reference week never appears as a BSP recipient even if eligible. -- The legacy WPA has no variable of its own. Both the Enhanced FRS +- Both the Enhanced FRS ([`frs.py`](https://github.com/PolicyEngine/policyengine-uk-data/blob/main/policyengine_uk_data/datasets/frs.py)) and Microcosm UK fill `bsp_reported` from FRS benefit codes 6 and 9 ([`frs_spine.py`](https://github.com/PolicyEngine/microcosm/blob/75167a68/packages/microcosm-build/src/microcosm/build/uk_runtime/frs_spine.py#L825)), @@ -52,6 +52,18 @@ That means: [microcosm#1222](https://github.com/PolicyEngine/microcosm/issues/1222)); [#2381](https://github.com/PolicyEngine/policyengine-uk/issues/2381) tracks freezing it after that. +- Since [#2572](https://github.com/PolicyEngine/policyengine-uk/issues/2572), + WPA also has its own variables, `widowed_parents_allowance_reported` + (CPI-uprated) and `widowed_parents_allowance`. Unlike BSP, WPA is taxable + ([ITEPA 2003 s. 577](https://www.legislation.gov.uk/ukpga/2003/1/section/577)), + Universal Credit unearned income + ([UC Regulations 2013 reg. 66(1)(b)(vi)](https://www.legislation.gov.uk/uksi/2013/376/regulation/66)), + Housing Benefit income less £15 a week, a welfare benefit for the benefit + cap and an overlapping benefit for Carer's Allowance and Carer Support + Payment. It ends at State Pension age and while the recipient lives with a + partner ([SSCBA 1992 s. 39A(4) and (5)][sscba-39a]), so it never reaches + Pension Credit. The datasets still put it in `bsp_reported` until they + split FRS code 6. ## Proposed scope @@ -125,15 +137,16 @@ For historical analyses the model should: - Add `wpa_eligible` (Person, YEAR): partner died between April 2001 and 5 April 2017, the survivor is under State Pension age and is entitled to Child Benefit. -- Add `widowed_parents_allowance` (Person, YEAR) as a derived - formula reading the WPA rate parameter (which would need +- Replace the `adds` of `widowed_parents_allowance` (Person, YEAR) with + a formula reading the WPA rate parameter (which would need backfilling under `gov/dwp/wpa/rate.yaml`). WPA has no new awards, but existing awards continue while the surviving parent has a dependent child, so some recipients remain. In the current data they sit inside `bsp_reported` (see above), so they -are already counted in income; the gap is that they can't be told -apart from BSP or reformed separately. +are already counted in income. Once datasets fill +`widowed_parents_allowance_reported`, they are told apart from BSP; the +gap left is a rule-based amount for reforms. ### Phase 3: data-side bereavement inputs diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index 1de2bbf680..db0ce336c9 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -27,7 +27,7 @@ The table below shows how the HBAI income components map to PolicyEngine variabl | State support - all benefits and tax credits |`hbai_benefits`| | Income from occupational and private pensions |`private_pension_income` (includes occupational pension income)| | Investment income |`dividend_income`, `savings_interest_income`, `property_income_after_finance_costs`| -| Maintenance payments |`maintenance_income`| +| Maintenance payments |`maintenance_income`, `spousal_maintenance_income`| | Income from educational grants and scholarships (including, for students, student loans and parental contributions) |`student_payments`| | The cash value of certain forms of income in kind (free school meals, free school breakfast, free school milk, free school fruit and vegetables, Healthy Start vouchers and free TV licences for those aged 75 and over who receive Pension Credit) |`free_school_meals`, `free_school_fruit_veg`, `free_school_milk`, `healthy_start_vouchers`, `free_tv_licence_value`| | **Subtractions** | | @@ -98,6 +98,7 @@ Each of the variables above is either derived from other variables, or is a dire | `savings_interest_income` | gov.economic_assumptions.indices.obr.per_capita.gdp | Uprated based on OBR per capita GDP projections | | `property_income` | gov.economic_assumptions.indices.obr.per_capita.gdp | Uprated based on OBR per capita GDP projections | | `maintenance_income` | gov.economic_assumptions.indices.obr.per_capita.gdp | Uprated based on OBR per capita GDP projections | +| `spousal_maintenance_income` | gov.economic_assumptions.indices.obr.per_capita.gdp | Uprated based on OBR per capita GDP projections | | `student_payments` | Not uprated directly | Currently no specific uprating for student payments | | `free_school_meals` |gov.obr.consumer_price_index | Uprated based on CPI inflation | | `free_school_fruit_veg` | gov.obr.consumer_price_index | Uprated based on CPI inflation | diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 93f8ec037f..beef9054ec 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -112,6 +112,9 @@ because the survey does not record arrangement dates. Supply `pension_contributions_via_salary_sacrifice_pre_2015` for the part attributable to earlier arrangements. Its exclusion is limited to full salary sacrifice before the NICs exemption cap: that cap does not limit the s. 228ZA add-back. +Bonus sacrifice (`pension_contributions_via_bonus_sacrifice`) is a relevant +flexible remuneration arrangement under the same subsection and is added back +with salary sacrifice; the pre-2015 input is a share of their sum. Flexible remuneration not represented by these contribution inputs needs an explicit threshold-income override. diff --git a/docs/engineering/salary-sacrifice-nics.md b/docs/engineering/salary-sacrifice-nics.md index 86da793f53..9b7967bbdc 100644 --- a/docs/engineering/salary-sacrifice-nics.md +++ b/docs/engineering/salary-sacrifice-nics.md @@ -5,6 +5,18 @@ separately. UKSimulation preserves this existing input convention; the model does not deduct that sacrifice a second time from cash pay. +`pension_contributions_via_bonus_sacrifice` supplies employer contributions +made in place of a bonus, under the same convention: cash pay is net of the +sacrificed bonus. The rules treat bonus sacrifice as salary sacrifice, and the +cap applies to the sum of the two, so +`pension_contributions_via_salary_sacrifice_from_pay` adds both inputs and the +cap, the NICs excess and the annual-allowance readers follow it. The inputs are +separate because sources measure the routes apart: HMRC's salary sacrifice +estimates come from ASHE, which does not cover bonus sacrifice, and the OBR +costs the bonus route on its own base +([costing note](https://obr.uk/docs/dlm_uploads/Supplementary-forecast-information-salary-sacrifice-costing-1.pdf), +paragraphs 1.4, 1.5 and 1.9). + The cap limits NIC exemption, rather than returning ordinary pay or creating a member pension payment. `salary_sacrifice_pension_ni_excess` adds the excess once to `ni_class_1_income`. Full actual sacrifice still counts once in the current diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index d85822a051..33a98d653e 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -14,8 +14,7 @@ import pandas as pd import logging -# Base year for the FRS dataset - used to calculate age offsets -_FRS_BASE_YEAR = 2023 # FRS 2023-24 represents calendar year 2024 +from policyengine_uk.utils.stochastic import splitmix64_uniform # Approximate take-up rate for assigning loans to tertiary-educated NONE people. # This represents P(has loan AND earning above threshold | tertiary educated). @@ -38,6 +37,22 @@ } _PLAN1_WRITEOFF_YEARS = 29 +# Plan 1 loans first paid before 1 September 2006 (the 2005 and earlier +# intakes) are written off at 65 instead (GOV.UK, "When your student loan +# gets written off or cancelled"). +_PLAN1_LAST_AGE_65_WRITEOFF_UNI_START = 2005 +_PLAN1_WRITEOFF_AGE = 65 + +# The first university intakes of income-contingent (Plan 1), Plan 2 and +# Plan 5 loans. The oldest holder of a plan in the year the data describe +# started university in its first intake, at 18. +_PLAN1_FIRST_UNI_START = 1998 +_PLAN2_FIRST_UNI_START = 2012 +_PLAN5_FIRST_UNI_START = 2023 + +# Salt for the student loan take-up draws, keyed on person_id. Other salts of +# splitmix64_uniform are used by variable formulas. +_STUDENT_LOAN_DRAW_SALT = 3 # The council tax growth series (a child of # gov.economic_assumptions.yoy_growth.obr.council_tax) for each Region label. @@ -264,7 +279,10 @@ def apply_uprating( current_year = dataset.datasets[year] prev_year = dataset.datasets[year - 1] apply_single_year_uprating( - current_year, prev_year, tax_benefit_system_parameters + current_year, + prev_year, + tax_benefit_system_parameters, + base_year=first_year, ) # Core projects from the supplied observation in one multiplication. # Chaining annual stock projections can lose an exact band boundary @@ -283,8 +301,11 @@ def apply_single_year_uprating( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, parameters: ParameterNode, + base_year: int | None = None, ): - # Apply uprating to a single year dataset. + # Apply uprating to a single year dataset. base_year is the year the + # projected data describe (apply_uprating passes the dataset's first + # year); without it, the previous year is taken as that year. projection = parameters.get_child(CGT_INDEX_PATH).metadata["projection"] if "base_year" in projection and any( @@ -344,7 +365,9 @@ def apply_single_year_uprating( current_year = uprate_rent(current_year, previous_year, parameters) - current_year = uprate_student_loan_plans(current_year, previous_year, parameters) + current_year = uprate_student_loan_plans( + current_year, previous_year, parameters, base_year=base_year + ) # Stored arrays take precedence over Core's variable-level uprating. # Reproduce the index growth Core used after the old dataset horizon for @@ -552,29 +575,43 @@ def uprate_student_loan_plans( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, parameters: ParameterNode, + base_year: int | None = None, ) -> UKSingleYearDataset: """Assign student loan plans based on cohort and add new entrants. - This function is idempotent: for any given year, it produces the same - cross-sectional snapshot regardless of whether previous years were - processed. It operates on the base year data, not accumulated state. + Projected datasets copy the base year with ages fixed, so in a later year + a person of a given age stands for a later university intake. Plans + follow that intake: year - age + 18. The function works on the base + year's plans, not accumulated state, so each year's plans do not depend + on which other years were projected. base_year is the year the data + describe; without it, the previous year is taken as that year. - The FRS base year (2023-24) captures loan holders up to certain ages. As we project forward, we need to: 1. Re-label existing holders to correct plan based on uni start year - 2. Add Plan 1/2 holders in age bands beyond the base year's coverage - 3. Add Plan 5 holders (new plan starting 2023) + 2. Add holders in age bands beyond the base year's coverage + 3. Add Plan 5 holders in England (new plan starting 2023) For (2) and (3), we use highest_education == TERTIARY as the signal - for who is a graduate, then apply a flat take-up probability. + for who is a graduate, then apply a flat take-up probability. Each + person's take-up draw is keyed on person_id, so the same people take up + a loan in every year and in any row order. + + New holders follow the funding nation's undergraduate repayment plan. + Funding agency is unavailable in the dataset, so current household + region proxies for it: England, Wales, SAAS for Scotland, and Student + Finance NI for Northern Ireland. This cannot identify cross-border + movers. An unknown region leaves the plan unchanged. See + https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on. """ # Skip if required columns don't exist yet (e.g., during # initial dataset creation before imputation runs) - required = ("student_loan_plan", "highest_education") + required = ("student_loan_plan", "highest_education", "person_id") if any(col not in current_year.person.columns for col in required): return current_year year = int(current_year.time_period) + if base_year is None: + base_year = int(previous_year.time_period) person = current_year.person.copy() household = current_year.household[["household_id", "region"]].copy() @@ -591,30 +628,41 @@ def uprate_student_loan_plans( region = person["region"].values.astype(str) highest_ed = person["highest_education"].values.astype(str) - # Age in the base year (used to identify "new" cohorts) - base_year_age = age - (year - _FRS_BASE_YEAR) - uni_start_year = year - age + 18 is_england = np.isin(region, list(_ENGLAND_REGIONS)) + is_wales = region == "WALES" + is_scotland = region == "SCOTLAND" + is_northern_ireland = region == "NORTHERN_IRELAND" is_tertiary = highest_ed == "TERTIARY" + # England introduced Plan 5 in 2023; Wales kept Plan 2. SAAS and + # Student Finance NI use Plans 4 and 1 respectively for all intakes. + # Current residence is the funding-nation proxy described above. + new_borrower_plan = np.select( + [ + is_scotland, + is_northern_ireland, + is_england & (uni_start_year >= _PLAN5_FIRST_UNI_START), + (is_england | is_wales) & (uni_start_year >= _PLAN2_FIRST_UNI_START), + is_england | is_wales, + ], + ["PLAN_4", "PLAN_1", "PLAN_5", "PLAN_2", "PLAN_1"], + default="NONE", + ) + # Initialize output arrays new_plan = base_plan.copy() repayments = person["student_loan_repayments"].values.copy() - # Deterministic RNG seeded by year for reproducibility - rng = np.random.default_rng(seed=year) + # One draw per person, the same in every year. + draws = splitmix64_uniform(person["person_id"].values, salt=_STUDENT_LOAN_DRAW_SALT) # Helper to assign plans to eligible people - def assign_with_probability(mask, plan_value, prob=_GRADUATE_LOAN_TAKE_UP): - """Assign plan_value to a random subset of masked people.""" - if not mask.any(): - return - indices = np.where(mask)[0] - draws = rng.random(len(indices)) - sampled = draws < prob - new_plan[indices[sampled]] = plan_value - repayments[indices[sampled]] = 0.0 + def assign_with_probability(mask, prob=_GRADUATE_LOAN_TAKE_UP): + """Assign the funding-nation plan to sampled new borrowers.""" + sampled = mask & (draws < prob) & (new_borrower_plan != "NONE") + new_plan[sampled] = new_borrower_plan[sampled] + repayments[sampled] = 0.0 # === Step 1: Re-label existing loan holders === has_loan = base_plan != "NONE" @@ -632,48 +680,60 @@ def assign_with_probability(mask, plan_value, prob=_GRADUATE_LOAN_TAKE_UP): new_plan[is_plan5_cohort & is_england] = "PLAN_5" new_plan[is_plan5_cohort & ~is_england] = "PLAN_2" - # === Step 2: Add Plan 1 holders in extended age bands === - # In base year, Plan 1 holders exist up to ~age 40 (started pre-2012). - # By 2029, Plan 1 should include people up to age 46. - # Target: NONE people who are tertiary-educated, in the "new" age band, - # whose uni_start_year < 2012 and loan not written off. - max_plan1_age_base = 40 # Approximate max age of Plan 1 in base year + # === Step 2: Add holders in the extended pre-2012 age bands === + # Income-contingent (Plan 1) loans began with the 1998 intake, aged 44 in + # 2024. With ages fixed, later years' 1998-and-later intakes reach older + # ages: in 2029, ages 45 to 49 (the 1998 to 2002 intakes). + # Target: NONE people who are tertiary-educated, older than the base + # year's oldest Plan 1 holder, from the 1998 to 2011 intakes, whose loan + # is not written off: at 65 for the 2005 and earlier intakes, otherwise + # about 29 years after the course started. + max_plan1_age_base = base_year - _PLAN1_FIRST_UNI_START + 18 + plan1_written_off = np.where( + uni_start_year <= _PLAN1_LAST_AGE_65_WRITEOFF_UNI_START, + age >= _PLAN1_WRITEOFF_AGE, + uni_start_year + _PLAN1_WRITEOFF_YEARS <= year, + ) plan1_new_cohort = ( (new_plan == "NONE") & is_tertiary - & (base_year_age > max_plan1_age_base) - & (uni_start_year < 2012) - & (uni_start_year + _PLAN1_WRITEOFF_YEARS > year) + & (age > max_plan1_age_base) + & (uni_start_year >= _PLAN1_FIRST_UNI_START) + & (uni_start_year < _PLAN2_FIRST_UNI_START) + & ~plan1_written_off ) - assign_with_probability(plan1_new_cohort, "PLAN_1") - - # === Step 3: Add Plan 2 holders in extended age bands === - # In base year (2024), Plan 2 holders exist up to age 29 (started 2012). - # By 2029, Plan 2 should include people up to age 35. - # Target: NONE people who are tertiary-educated, in the "new" age band, - # whose uni_start_year is 2012-2022. - max_plan2_age_base = 29 # Max age of Plan 2 in base year + assign_with_probability(plan1_new_cohort) + + # === Step 3: Add holders in the extended 2012-2022 age bands === + # In the base year Plan 2 holders are at most the age of the 2012 intake + # (30 in 2024). With ages fixed, by 2029 Plan 2 reaches age 35. + # Target: NONE people who are tertiary-educated, older than the base + # year's oldest Plan 2 holder, whose uni_start_year is 2012-2022. + max_plan2_age_base = base_year - _PLAN2_FIRST_UNI_START + 18 plan2_new_cohort = ( (new_plan == "NONE") & is_tertiary - & (base_year_age > max_plan2_age_base) - & (uni_start_year >= 2012) - & (uni_start_year < 2023) + & (age > max_plan2_age_base) + & (uni_start_year >= _PLAN2_FIRST_UNI_START) + & (uni_start_year < _PLAN5_FIRST_UNI_START) ) - assign_with_probability(plan2_new_cohort, "PLAN_2") + assign_with_probability(plan2_new_cohort) # === Step 4: Add Plan 5 holders (new plan from 2023) === - # Plan 5 didn't exist in base year. Eligible: tertiary-educated NONE - # people in England who would have started uni 2023+. + # Plan 5 is England's, and did not exist in the base year. The other + # nations' 2023-and-later intakes are on plans (2, 4 and 1) the base year + # already holds at those ages, so no holders are added there. + # Eligible: tertiary-educated NONE people in England who would have + # started uni 2023+. # Age constraint: must be 21+ (finished 3-year degree) to be repaying. plan5_eligible = ( (new_plan == "NONE") & is_tertiary & is_england - & (uni_start_year >= 2023) + & (uni_start_year >= _PLAN5_FIRST_UNI_START) & (age >= 21) ) - assign_with_probability(plan5_eligible, "PLAN_5") + assign_with_probability(plan5_eligible) # Write back to the person table (without the merged region column). person_out = current_year.person.copy() diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index 3ae9f9fef0..5df224328d 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -953,6 +953,13 @@ spending_heads: report_group: child_and_family_benefits totals: [gov_spending, household_benefits] sign: nonnegative + - id: widowed_parents_allowance + label: Widowed Parent's Allowance + variable: widowed_parents_allowance + entity: person + report_group: child_and_family_benefits + totals: [gov_spending, household_benefits] + sign: nonnegative # Childcare and education support - id: tax_free_childcare label: Tax-Free Childcare diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 7b261920dc..17f363f2cd 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -4,6 +4,9 @@ # An explicit energy_price_basis opts into the variables' existing annual OBR-CPI # assumption from the declared price year. This does not supply an energy-specific # forecast or automatically label existing private Microcosm artifacts. +# liquid_fuel_consumption and solid_fuel_consumption (heating oil, LPG, coal +# and wood) are not in that group: Ofgem's price cap does not cover them, so +# they are uprated by CPI from the data year below, like other LCFS spending. # # This map governs dataset projection. Core's class-level variable uprating still # carries direct situation inputs forward independently of dataset declarations. @@ -21,6 +24,7 @@ gov.economic_assumptions.yoy_growth.obr.average_earnings: - employer_pension_contributions - employment_income - employment_income_before_lsr +- pension_contributions_via_bonus_sacrifice - pension_contributions_via_salary_sacrifice # The pre-9 July 2015 share of the line above (FA 2004 s. 228ZA(6)). - pension_contributions_via_salary_sacrifice_pre_2015 @@ -31,9 +35,11 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - alcohol_and_tobacco_consumption - bus_fare_spending - bus_fare_spending_reported -# bsp_reported still holds FRS code 9 (War Widow's Pension, CPI-uprated) as -# well as Bereavement Support Payment, whose rates are frozen. It moves off -# this index once datasets put code 9 in war_widows_pension_reported (#2381). +# bsp_reported still holds FRS code 9 (War Widow's Pension, CPI-uprated) and +# the Widowed Parent's Allowance in code 6 (CPI-uprated) as well as +# Bereavement Support Payment, whose rates are frozen. It moves off this index +# once datasets put code 9 in war_widows_pension_reported and Widowed +# Parent's Allowance in widowed_parents_allowance_reported (#2381). - bsp_reported - carers_allowance_reported - child_benefit_reported @@ -57,6 +63,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - income_support_reported - jsa_contrib_reported - jsa_income_reported +- liquid_fuel_consumption - local_bus_uncapped_single_fare - maintenance_expenses - maternity_allowance_reported @@ -65,6 +72,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - recreation_consumption - restaurants_and_hotels_consumption - sda_reported +- solid_fuel_consumption - state_pension - state_pension_reported - statutory_maternity_pay @@ -73,6 +81,7 @@ gov.economic_assumptions.yoy_growth.obr.consumer_price_index: - transport_consumption - universal_credit_reported - war_widows_pension_reported +- widowed_parents_allowance_reported - winter_fuel_allowance_reported - working_tax_credit_reported gov.economic_assumptions.yoy_growth.obr.diesel_spending_litre_proxy: @@ -126,6 +135,7 @@ gov.economic_assumptions.yoy_growth.obr.per_capita.gdp: - property_rental_income - savings - savings_interest_income +- spousal_maintenance_income - stocks_and_shares_isa - sublet_income - cash_isa diff --git a/policyengine_uk/parameters/gov/dwp/carers_allowance/overlapping_benefits.yaml b/policyengine_uk/parameters/gov/dwp/carers_allowance/overlapping_benefits.yaml index 0b126edb4f..308ba2f0fc 100644 --- a/policyengine_uk/parameters/gov/dwp/carers_allowance/overlapping_benefits.yaml +++ b/policyengine_uk/parameters/gov/dwp/carers_allowance/overlapping_benefits.yaml @@ -23,9 +23,10 @@ values: # reg. 4(1)(a), and Schedule 1 adjusts Carer's Allowance only by # unemployability supplement, death benefits paid to a surviving spouse and # training allowance, so war disablement pensions and AFCS payments do not - # reduce it. Benefits the model does not hold (widow's benefits, - # bereavement allowance, unemployability supplement, training allowance) - # are not listed. + # reduce it. Widowed parent's allowance, a Part II benefit, is listed as + # the whole payment: its additional pension is not separated. Benefits the + # model does not hold (the other widow's benefits, bereavement allowance, + # unemployability supplement, training allowance) are not listed. 2008-10-27: - overlapping_state_pension - incapacity_benefit @@ -33,3 +34,4 @@ values: - maternity_allowance - jsa_contrib - esa_contrib + - widowed_parents_allowance diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/maintenance_disregard.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/maintenance_disregard.yaml new file mode 100644 index 0000000000..28b42664ea --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/maintenance_disregard.yaml @@ -0,0 +1,14 @@ +description: Housing Benefit disregards this much of the weekly maintenance paid to the claimant or partner by a spouse, civil partner or former partner, where the family includes a child or young person. +values: + 2006-03-06: 15 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit maintenance disregard + reference: + - title: The Housing Benefit Regulations 2006, Schedule 5 paragraph 47 + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/47 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006, Schedule 5 paragraph 20 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/20 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006, regulation 27(4)(c)(ii) + href: https://www.legislation.gov.uk/uksi/2006/214/regulation/27 diff --git a/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/widowed_parents_allowance_disregard.yaml b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/widowed_parents_allowance_disregard.yaml new file mode 100644 index 0000000000..1aa9287d1d --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/housing_benefit/means_test/widowed_parents_allowance_disregard.yaml @@ -0,0 +1,12 @@ +description: Housing Benefit disregards this much of a claimant's weekly Widowed Parent's Allowance. +values: + 2006-03-06: 15 +metadata: + period: week + unit: currency-GBP + label: Housing Benefit Widowed Parent's Allowance disregard + reference: + - title: The Housing Benefit Regulations 2006, Schedule 5 paragraph 16 + href: https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/16 + - title: The Housing Benefit (Persons who have attained the qualifying age for state pension credit) Regulations 2006, Schedule 5 paragraph 7 + href: https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/7 diff --git a/policyengine_uk/parameters/gov/dwp/income_support/means_test/widowed_parents_allowance_disregard.yaml b/policyengine_uk/parameters/gov/dwp/income_support/means_test/widowed_parents_allowance_disregard.yaml new file mode 100644 index 0000000000..764400a3e5 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/income_support/means_test/widowed_parents_allowance_disregard.yaml @@ -0,0 +1,10 @@ +description: Income Support disregards this much of a claimant's or partner's weekly Widowed Parent's Allowance. +values: + 2001-04-09: 10 +metadata: + period: week + unit: currency-GBP + label: Income Support Widowed Parent's Allowance disregard + reference: + - title: The Income Support (General) Regulations 1987, Schedule 9 paragraph 16(h) + href: https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/16 diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml index eb564bca84..28b819f856 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/guarantee_credit/income.yaml @@ -22,6 +22,9 @@ values: - private_pension_income - property_income_after_finance_costs - pension_credit_deemed_income + # Reg. 15(5)(d): maintenance from a spouse, civil partner, former spouse or + # former civil partner. Child maintenance is not listed. + - spousal_maintenance_income - esa_contrib - jsa_contrib # Reg. 15(1) prescribes all social security benefits except those it lists; diff --git a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml index 145e714047..268adb47d3 100644 --- a/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml +++ b/policyengine_uk/parameters/gov/dwp/pension_credit/savings_credit/excluded_income.yaml @@ -12,9 +12,10 @@ values: # Reg. 9(c) and (g). - jsa_contrib - esa_contrib - # Reg. 9(a), (b), (d) and (e). Reg. 9(f) maintenance is not modelled: - # maintenance_income does not separate spousal from child maintenance. + # Reg. 9(a), (b), (d) and (e). - working_tax_credit - incapacity_benefit - sda - maternity_allowance + # Reg. 9(f): the reg. 15(5)(d) maintenance payments. + - spousal_maintenance_income diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml index f49cc9ef8c..46cbc04731 100644 --- a/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/means_test/income_definitions/unearned.yaml @@ -47,6 +47,10 @@ metadata: href: https://www.legislation.gov.uk/ukpga/2002/16/section/16 - title: The Universal Credit Regulations 2013 reg. 66(1)(b)(ii), (viii) and (ix) (contributory ESA, maternity allowance, industrial injuries benefit) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/66 + - title: The Universal Credit Regulations 2013 reg. 66(1)(b)(vi) (widowed parent's allowance; bereavement support payment is not listed) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/66 + - title: The Universal Credit Regulations 2013 reg. 66(1)(d) (maintenance from a spouse, civil partner, former spouse or former civil partner; child maintenance is not listed) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/66 - title: The Universal Credit Regulations 2013 reg. 2 (employment and support allowance and jobseeker's allowance mean the contributory allowances; industrial injuries benefit means benefit under SSCBA 1992 Part 5) href: https://www.legislation.gov.uk/uksi/2013/376/regulation/2 - title: S.I. 2023/1218 art. 23(2) and (5) (defines carer support payment in reg. 2 and inserts reg. 66(1)(b)(iiia), from 19 November 2023) @@ -75,6 +79,8 @@ values: - private_pension_income - state_pension - uc_tariff_income + - spousal_maintenance_income + - widowed_parents_allowance 2014-06-16: - carers_allowance - jsa_contrib @@ -84,6 +90,8 @@ values: - private_pension_income - state_pension - uc_tariff_income + - spousal_maintenance_income + - widowed_parents_allowance - incapacity_benefit - sda 2023-11-19: @@ -96,5 +104,7 @@ values: - private_pension_income - state_pension - uc_tariff_income + - spousal_maintenance_income + - widowed_parents_allowance - incapacity_benefit - sda diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/income_disregards/maintenance.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/income_disregards/maintenance.yaml new file mode 100644 index 0000000000..911a57fa85 --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/income_disregards/maintenance.yaml @@ -0,0 +1,24 @@ +description: Council Tax Reduction disregards this weekly amount of the applicant's and partner's combined spousal maintenance where the family includes a child or young person (the family-premium condition in the English default and Welsh schemes). +values: + 2013-04-01: 15 +metadata: + period: week + unit: currency-GBP + label: council tax reduction maintenance disregard + reference: + - title: Council Tax Reduction England pension-age Schedule 5 paragraph 20 + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/20 + - title: Council Tax Reduction England default Schedule 8 paragraph 49 + href: https://www.legislation.gov.uk/uksi/2012/2886/appendix/8/paragraph/49 + - title: Council Tax Reduction Wales prescribed pension-age Schedule 4 paragraph 20 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/20 + - title: Council Tax Reduction Wales prescribed working-age Schedule 9 paragraph 49 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/49 + - title: Council Tax Reduction Wales default pension-age Schedule 5 paragraph 20 + href: https://www.legislation.gov.uk/wsi/2013/3035/schedule/5/paragraph/20 + - title: Council Tax Reduction Wales default working-age Schedule 7 paragraph 49 + href: https://www.legislation.gov.uk/wsi/2013/3035/schedule/7/paragraph/49 + - title: Council Tax Reduction Scotland pension-age Schedule 3 paragraph 19 + href: https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/19 + - title: Council Tax Reduction Scotland working-age regulation 57(6) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/57 diff --git a/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/income_disregards/widowed_parents_allowance.yaml b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/income_disregards/widowed_parents_allowance.yaml new file mode 100644 index 0000000000..13ba0cc7fe --- /dev/null +++ b/policyengine_uk/parameters/gov/local_authorities/council_tax_reduction/income_disregards/widowed_parents_allowance.yaml @@ -0,0 +1,24 @@ +description: Council Tax Reduction disregards this weekly amount of each applicant's or partner's Widowed Parent's Allowance. +values: + 2013-04-01: 15 +metadata: + period: week + unit: currency-GBP + label: council tax reduction widowed parents allowance disregard + reference: + - title: Council Tax Reduction England pension-age Schedule 5 paragraph 7 + href: https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/7 + - title: Council Tax Reduction England default Schedule 8 paragraph 21 + href: https://www.legislation.gov.uk/uksi/2012/2886/appendix/8/paragraph/21 + - title: Council Tax Reduction Wales prescribed pension-age Schedule 4 paragraph 7 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/7 + - title: Council Tax Reduction Wales prescribed working-age Schedule 9 paragraph 21 + href: https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/21 + - title: Council Tax Reduction Wales default pension-age Schedule 5 paragraph 7 + href: https://www.legislation.gov.uk/wsi/2013/3035/schedule/5/paragraph/7 + - title: Council Tax Reduction Wales default working-age Schedule 7 paragraph 21 + href: https://www.legislation.gov.uk/wsi/2013/3035/schedule/7/paragraph/21 + - title: Council Tax Reduction Scotland pension-age Schedule 3 paragraph 6 + href: https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/6 + - title: Council Tax Reduction Scotland working-age regulation 57(4) + href: https://www.legislation.gov.uk/ssi/2021/249/regulation/57 diff --git a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/overlapping_benefits.yaml b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/overlapping_benefits.yaml index d695aa181e..f778025988 100644 --- a/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/overlapping_benefits.yaml +++ b/policyengine_uk/parameters/gov/social_security_scotland/carer_support_payment/overlapping_benefits.yaml @@ -13,9 +13,11 @@ values: # pension) and Retirement Pension (excluding additional pension and # graduated retirement benefit), incapacity benefit, severe disablement # allowance, maternity allowance, contribution-based JSA and contributory - # ESA. Its other overlapping benefits (unemployability supplement, widow's - # and bereavement benefits, war widow's pension, industrial death benefit, - # training allowance) are not held by the model. + # ESA. Reg. 16(3)(e) lists widowed parent's allowance excluding additional + # pension; it is listed as the whole payment. Its other overlapping + # benefits (unemployability supplement, the other widow's and bereavement + # benefits, war widow's pension, industrial death benefit, training + # allowance) are not listed. 2023-11-19: - overlapping_state_pension - incapacity_benefit @@ -23,3 +25,4 @@ values: - maternity_allowance - jsa_contrib - esa_contrib + - widowed_parents_allowance diff --git a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py index 15b042658e..6c4ef3aeec 100644 --- a/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py +++ b/policyengine_uk/reforms/policyengine/disable_simulated_benefits.py @@ -14,6 +14,7 @@ "afcs", "bsp", "war_widows_pension", + "widowed_parents_allowance", "child_benefit", "child_tax_credit", "council_tax_benefit", diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/spousal_maintenance_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/spousal_maintenance_income.yaml new file mode 100644 index 0000000000..b343ca90f3 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/spousal_maintenance_income.yaml @@ -0,0 +1,156 @@ +# Maintenance from a spouse, civil partner, former spouse or former civil +# partner (spousal_maintenance_income) counts in the means tests; child +# maintenance (maintenance_income) does not. + +- name: UC counts spousal maintenance as unearned income, but not child maintenance + period: 2026 + absolute_error_margin: 0 + input: + spousal_maintenance_income: 100 * 12 + maintenance_income: 200 * 12 + output: + # UC Regs 2013 reg. 66(1)(d). + uc_unearned_income: 100 * 12 + +- name: Pension Credit counts spousal maintenance, but not as savings credit qualifying income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 10_000 + spousal_maintenance_income: 50 * 52 + maintenance_income: 30 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + # SPC Regs 2002 reg. 15(5)(d): 10,000 + 2,600 = 12,600, above the 12,376 + # standard minimum guarantee. + pension_credit_income: 10_000 + 50 * 52 + guarantee_credit: 0 + # Reg. 9(f): qualifying income is the 10,000 pension, below the 10,819.64 + # threshold. Counting the maintenance as qualifying income would give + # 0.6 x (12,600 - 10,819.64) - 0.4 x (12,600 - 12,376) = 978.62. + savings_credit_income: 10_000 + savings_credit: 0 + +- name: Guarantee credit falls by the spousal maintenance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 6_000 + spousal_maintenance_income: 50 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + output: + pension_credit_income: 6_000 + 50 * 52 + # 12,376 - 8,600 = 3,776. + guarantee_credit: 238 * 52 - (6_000 + 50 * 52) + +- name: Pension-age Housing Benefit counts spousal maintenance in full without a child + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 80 + state_pension: 0 + private_pension_income: 10_000 + spousal_maintenance_income: 50 * 52 + benunits: + benunit: + members: [person1] + households: + household: + members: [person1] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + guarantee_credit: 0 + savings_credit: 0 + housing_benefit_maintenance_disregard: 0 + # HB (SPC) Regs 2006 reg. 29(1)(o): 10,000 + 2,600. + housing_benefit_applicable_income: 10_000 + 50 * 52 + +- name: Working-age Housing Benefit disregards £15 a week of spousal maintenance for a family with a child + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + spousal_maintenance_income: 100 * 52 + maintenance_income: 60 * 52 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + # A legacy Housing Benefit claim, so the means test itself applies. + claims_universal_credit: false + # Held at zero to isolate the maintenance. + child_benefit: 0 + tax_credits: 0 + households: + household: + members: [parent, child] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # HB Regs 2006 Sch 5 para 47(1); the child maintenance is disregarded in + # full (para 47A). + housing_benefit_maintenance_disregard: 15 * 52 + housing_benefit_applicable_income: (100 - 15) * 52 + +- name: Child maintenance alone is not Housing Benefit income + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + maintenance_income: 60 * 52 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + claims_universal_credit: false + child_benefit: 0 + tax_credits: 0 + households: + household: + members: [parent, child] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + housing_benefit_maintenance_disregard: 0 + housing_benefit_applicable_income: 0 + +- name: Spousal maintenance is market income + period: 2026 + absolute_error_margin: 0 + input: + spousal_maintenance_income: 5_000 + maintenance_income: 1_000 + output: + market_income: 6_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml new file mode 100644 index 0000000000..950f0a9523 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/widowed_parents_allowance.yaml @@ -0,0 +1,134 @@ +# Widowed Parent's Allowance (SSCBA 1992 s.39A) and Bereavement Support +# Payment share FRS benefit code 6 but are treated differently. 2026-27 weekly +# rates: Widowed Parent's Allowance 156.65 (SI 2026/148 art. 18), Carer's +# Allowance 86.45. + +- name: Widowed Parent's Allowance is taxable and Universal Credit unearned income + period: 2026 + absolute_error_margin: 0.01 + input: + widowed_parents_allowance_reported: 156.65 * 52 + output: + widowed_parents_allowance: 156.65 * 52 + # ITEPA 2003 s.577(1). + social_security_income: 156.65 * 52 + # UC Regs 2013 reg. 66(1)(b)(vi). + uc_unearned_income: 156.65 * 52 + +- name: The same amount of Bereavement Support Payment is neither + period: 2026 + absolute_error_margin: 0.01 + input: + bsp_reported: 156.65 * 52 + output: + social_security_income: 0 + uc_unearned_income: 0 + +- name: Widowed Parent's Allowance is in the benefit totals that count Bereavement Support Payment + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + widowed_parents_allowance_reported: 156.65 * 52 + benunits: + benunit: + members: [person1] + would_claim_uc: false + households: + household: + members: [person1] + country: ENGLAND + output: + household_benefits: 156.65 * 52 + hbai_benefits: 156.65 * 52 + gov_spending: 156.65 * 52 + pre_budget_change_household_benefits: 156.65 * 52 + # 8,145.80 is below the personal allowance. + income_tax: 0 + +- name: Working-age Housing Benefit counts Widowed Parent's Allowance less £15 a week + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + widowed_parents_allowance_reported: 156.65 * 52 + child: + age: 5 + benunits: + benunit: + members: [parent, child] + # A legacy Housing Benefit claim, so the means test itself applies. + claims_universal_credit: false + # Held at zero to isolate the allowance. + child_benefit: 0 + tax_credits: 0 + households: + household: + members: [parent, child] + country: ENGLAND + tenure_type: RENT_FROM_COUNCIL + rent: 6_000 + output: + # HB Regs 2006 Sch 5 para 16(b). + housing_benefit_widowed_parents_allowance_income: (156.65 - 15) * 52 + housing_benefit_applicable_income: (156.65 - 15) * 52 + +- name: Income Support disregards £10 a week of Widowed Parent's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + widowed_parents_allowance_reported: 156.65 * 52 + child: + age: 3 + benunits: + benunit: + members: [parent, child] + households: + household: + members: [parent, child] + country: ENGLAND + output: + # IS Regs 1987 Sch 9 para 16(h), then the model's £20 lone parent + # disregard. + income_support_applicable_income: (156.65 - 10 - 20) * 52 + +- name: Widowed Parent's Allowance overlaps with Carer's Allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 40 + care_hours: 35 + country: ENGLAND + widowed_parents_allowance_reported: 156.65 * 52 + output: + # SI 1979/597 reg. 12: the allowance exceeds Carer's Allowance, so none + # is paid. + carers_allowance_pre_overlap: 86.45 * 52 + carers_allowance_overlapping_benefits: 156.65 * 52 + carers_allowance: 0 + +- name: The claimant's Widowed Parent's Allowance is in the capped total + period: 2025 + input: + people: + claimant: + age: 40 + widowed_parents_allowance_reported: 2_000 + benunits: + family: + members: [claimant] + universal_credit_pre_benefit_cap: 18_000 + households: + home: + members: [claimant] + region: NORTH_EAST + output: + # Welfare Reform Act 2012 s. 96(10)(n). + benefit_cap_welfare_benefits: 20_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_bonus_sacrifice.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_bonus_sacrifice.yaml new file mode 100644 index 0000000000..7704b8f142 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_bonus_sacrifice.yaml @@ -0,0 +1,88 @@ +# Bonus sacrifice is read summed with salary sacrifice. This runner does not +# copy an employment_income input to employment_income_before_lsr (which +# policyengine_uk.Simulation does), so each case with pay sets both. The +# broad-base haircut is set to zero so pay stays at the input. + +- name: The 2029 cap charges NICs on bonus sacrifice above £2,000 + period: 2029 + absolute_error_margin: 0.1 + input: + employment_income: 80_000 + employment_income_before_lsr: 80_000 + pension_contributions_via_bonus_sacrifice: 10_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + pension_contributions_via_salary_sacrifice_from_pay: 10_000 + pension_contributions_via_salary_sacrifice_adjusted: 2_000 + salary_sacrifice_pension_ni_excess: 8_000 + ni_class_1_income: 88_000 + # Above the upper earnings limit, so the £8,000 excess adds 2% employee + # and 15% employer NICs: £160 and £1,200 more than on £80,000 alone. + ni_class_1_employee: (50_270 - 12_570) * 0.08 + (88_000 - 50_270) * 0.02 + ni_class_1_employer: (88_000 - 5_000) * 0.15 + salary_sacrifice_pension_ni_employee: 8_000 * 0.02 + salary_sacrifice_pension_ni_employer: 8_000 * 0.15 + +- name: The £2,000 threshold applies to salary and bonus sacrifice together + period: 2029 + absolute_error_margin: 0.1 + input: + employment_income: 40_000 + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 1_500 + pension_contributions_via_bonus_sacrifice: 1_500 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + pension_contributions_via_salary_sacrifice_from_pay: 3_000 + pension_contributions_via_salary_sacrifice_adjusted: 2_000 + salary_sacrifice_pension_ni_excess: 1_000 + ni_class_1_income: 41_000 + salary_sacrifice_pension_ni_employee: 1_000 * 0.08 + salary_sacrifice_pension_ni_employer: 1_000 * 0.15 + +- name: Before the cap, bonus sacrifice is free of NICs + period: 2028 + input: + employment_income: 80_000 + employment_income_before_lsr: 80_000 + pension_contributions_via_bonus_sacrifice: 10_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + pension_contributions_via_salary_sacrifice_from_pay: 10_000 + pension_contributions_via_salary_sacrifice_adjusted: 10_000 + salary_sacrifice_pension_ni_excess: 0 + ni_class_1_income: 80_000 + +# FA 2004 s. 228ZA(5)(b) adds back pay given up under relevant flexible +# remuneration arrangements, as it does salary sacrifice. The same case with +# the £30,000 entered as salary sacrifice is in pension_annual_allowance.yaml. +- name: Bonus sacrifice raises threshold income and adjusted income + period: 2024 + input: + age: 45 + employment_income: 190_000 + employment_income_before_lsr: 190_000 + pension_contributions_via_bonus_sacrifice: 30_000 + employer_pension_contributions: 80_000 + output: + pension_contributions_for_annual_allowance: 110_000 + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 220_000 + pension_annual_allowance: 40_000 + +- name: A person without pay has no bonus to sacrifice + period: 2029 + input: + pension_contributions_via_bonus_sacrifice: 27_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 0 + salary_sacrifice_pension_ni_excess: 0 + +- name: A negative salary sacrifice input does not offset a bonus sacrifice + period: 2029 + input: + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: -500 + pension_contributions_via_bonus_sacrifice: 1_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 1_000 diff --git a/policyengine_uk/tests/test_bonus_sacrifice_properties.py b/policyengine_uk/tests/test_bonus_sacrifice_properties.py new file mode 100644 index 0000000000..7e6b206f42 --- /dev/null +++ b/policyengine_uk/tests/test_bonus_sacrifice_properties.py @@ -0,0 +1,131 @@ +"""Bonus sacrifice is scored exactly as salary sacrifice of the same amount. + +The NICs exemption cap (NICs Act 2026 s. 1) covers salary-sacrificed and +bonus-sacrificed pension contributions together, and FA 2004 s. 228ZA(5)(b) +adds both back to annual allowance threshold income. +pension_contributions_via_bonus_sacrifice is a separate input only because +sources measure the two routes apart, so moving any part of a sacrifice from +one input to the other must change no result, before or after the cap. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEARS = (2028, 2029, 2030) +HAIRCUT = "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate" +# age, cash pay, salary sacrifice, bonus sacrifice, employer contributions +CASES = [ + (40, 80_000, 0, 10_000, 0), + (40, 40_000, 1_500, 1_500, 0), + # Crosses the upper earnings limit. + (40, 49_000, 2_000, 3_000, 2_000), + # Crosses the primary threshold. + (40, 11_000, 0, 5_000, 0), + # Over State Pension age: no employee NICs. + (70, 45_000, 1_000, 5_000, 0), + # Tapered annual allowance. + (45, 190_000, 10_000, 30_000, 80_000), + # No pay, so nothing to sacrifice. + (40, 0, 0, 27_000, 0), +] +OUTPUTS = ( + "pension_contributions_via_salary_sacrifice_from_pay", + "pension_contributions_via_salary_sacrifice_adjusted", + "salary_sacrifice_pension_ni_excess", + "ni_class_1_income", + "national_insurance", + "ni_class_1_employer", + "salary_sacrifice_pension_ni_employee", + "salary_sacrifice_pension_ni_employer", + "income_tax", + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance", + "household_net_income", + "hbai_household_net_income", +) + + +def simulation(route): + """route maps a case to its (salary, bonus) sacrifice inputs.""" + people = {} + for i, case in enumerate(CASES): + age, pay, _, _, employer = case + salary, bonus = route(case) + people[f"p{i}"] = { + name: {str(year): value for year in YEARS} + for name, value in { + "age": age, + "employment_income": pay, + "employer_pension_contributions": employer, + "pension_contributions_via_salary_sacrifice": salary, + "pension_contributions_via_bonus_sacrifice": bonus, + }.items() + } + return Simulation( + situation={ + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + }, + reform={HAIRCUT: {"2028-01-01.2030-12-31": 0}}, + ) + + +@pytest.fixture(scope="module") +def simulations(): + return { + "split": simulation(lambda c: (c[2], c[3])), + "all_salary": simulation(lambda c: (c[2] + c[3], 0)), + "all_bonus": simulation(lambda c: (0, c[2] + c[3])), + "none": simulation(lambda c: (0, 0)), + } + + +def values(sim, variable, year): + return np.asarray(sim.calculate(variable, year), dtype=float) + + +@pytest.mark.parametrize("year", YEARS) +@pytest.mark.parametrize("route", ("split", "all_bonus")) +def test_bonus_sacrifice_scores_like_salary_sacrifice(simulations, route, year): + for name in OUTPUTS: + np.testing.assert_allclose( + values(simulations[route], name, year), + values(simulations["all_salary"], name, year), + atol=0.01, + err_msg=name, + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_cap_charges_nics_on_bonus_sacrifice_above_threshold(simulations, year): + # £80,000 of pay and a £10,000 bonus sacrifice: £8,000 above the £2,000 + # threshold, all above the upper earnings limit, so employee NICs rise by + # 2% of it and employer NICs by 15%. + split, none = simulations["split"], simulations["none"] + assert values(split, "salary_sacrifice_pension_ni_excess", year)[0] == 8_000 + employee_rise = ( + values(split, "national_insurance", year)[0] + - values(none, "national_insurance", year)[0] + ) + employer_rise = ( + values(split, "ni_class_1_employer", year)[0] + - values(none, "ni_class_1_employer", year)[0] + ) + assert employee_rise == pytest.approx(160, abs=0.01) + assert employer_rise == pytest.approx(1_200, abs=0.01) + + +def test_bonus_sacrifice_is_free_of_nics_before_the_cap(simulations): + split, none = simulations["split"], simulations["none"] + for name in ("national_insurance", "ni_class_1_employer"): + np.testing.assert_allclose( + values(split, name, 2028), values(none, name, 2028), err_msg=name + ) diff --git a/policyengine_uk/tests/test_council_tax_reduction_maintenance_wpa.py b/policyengine_uk/tests/test_council_tax_reduction_maintenance_wpa.py new file mode 100644 index 0000000000..55df38bfb2 --- /dev/null +++ b/policyengine_uk/tests/test_council_tax_reduction_maintenance_wpa.py @@ -0,0 +1,189 @@ +"""Statutory CTR income and disregards, independently calculated at 52 weeks. + +England: SI 2012/2885 Sch 1 para 16, Sch 5 paras 7/20; default SI +2012/2886 Sch para 54, Sch 8 paras 21/49. Wales: WSI 2013/3029 Sch 1 +para 10, Sch 6 para 17, Sch 4 paras 7/20, Sch 9 paras 21/49; default +WSI 2013/3035 Sch 5 paras 7/20, Sch 7 paras 21/49. Scotland: SSI +2021/249 reg 57(1)(b)(vi), (d), (4), (6); SSI 2012/319 reg 27(1)(j), +(o), Sch 3 paras 6/19. Wales's family premium applies to a family with +a child or young person (3029 Sch 1 para 1(1)(c), Sch 7 para 3). +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 +AREAS = { + "ENGLAND": ("LONDON", "WESTMINSTER"), + "WALES": ("WALES", "CARDIFF"), + "SCOTLAND": ("SCOTLAND", "GLASGOW_CITY"), +} +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def family( + country, + *, + age=40, + recipient="claimant", + maintenance=0, + wpa=0, + child=False, + child_maintenance=0, + route="general", + head_applies_alone=False, +): + people = { + "claimant": { + "age": age, + "is_claimant_or_partner": True, + "is_household_head": True, + "state_pension": 0, + }, + "partner": { + "age": age, + "is_claimant_or_partner": True, + "is_household_head": False, + "state_pension": 0, + }, + "other": { + "age": 22, + "is_claimant_or_partner": False, + "is_household_head": False, + }, + } + if child: + people["child"] = { + "age": 5, + "is_claimant_or_partner": False, + "is_household_head": False, + } + people[recipient].update( + spousal_maintenance_income=maintenance, + widowed_parents_allowance_reported=wpa, + maintenance_income=child_maintenance, + ) + region, authority = AREAS[country] + # Pin the route and other awards to isolate the statutory income list. + # Reported WPA is deliberate even at pension age: its statutory income + # treatment also applies to an under-pension-age partner's payment. + benunit = { + "members": list(people), + "council_tax_reduction_pensioner": age >= 67, + "council_tax_reduction_head_applies_alone": head_applies_alone, + "child_benefit": 0, + "tax_credits": 0, + "universal_credit": 0, + "income_support": 0, + "in_receipt_of_guarantee_credit": route == "guarantee", + "in_receipt_of_savings_credit_only": route == "savings", + "pension_credit_income": 13_000 if route == "savings" else 0, + "pension_credit": 200 if route == "savings" else 0, + "pension_credit_war_pension_income": 0, + } + return Simulation( + situation={ + "people": { + name: {key: {YEAR: value} for key, value in inputs.items()} + for name, inputs in people.items() + }, + "benunits": { + "family": { + key: value if key == "members" else {YEAR: value} + for key, value in benunit.items() + } + }, + "households": { + "home": { + "members": list(people), + "region": region, + "local_authority": authority, + } + }, + } + ) + + +def income(sim): + return float(sim.calculate("council_tax_reduction_applicable_income", YEAR)[0]) + + +@pytest.mark.parametrize("country", AREAS) +@pytest.mark.parametrize("age", [40, 80]) +@pytest.mark.parametrize("recipient", ["claimant", "partner"]) +def test_general_rules_count_wpa_after_fifteen_pounds_weekly(country, age, recipient): + # £156.65 * 52 = £8,145.80; less £15 * 52 = £780 gives £7,365.80. + sim = family(country, age=age, recipient=recipient, wpa=8_145.80) + assert income(sim) == pytest.approx(7_365.80, abs=0.01) + + +@pytest.mark.parametrize("country", AREAS) +@pytest.mark.parametrize("age", [40, 80]) +@pytest.mark.parametrize("child, expected", [(False, 5_200), (True, 4_420)]) +def test_maintenance_disregard_depends_on_the_family(country, age, child, expected): + # £100/week counts in full without a child; (£100 - £15)*52 otherwise. + sim = family(country, age=age, maintenance=5_200, child=child) + assert income(sim) == pytest.approx(expected, abs=0.01) + + +@pytest.mark.parametrize("country", AREAS) +def test_maintenance_payments_share_one_family_disregard(country): + sim = family(country, maintenance=2_600, child=True) + sim.set_input("spousal_maintenance_income", YEAR, [2_600, 2_600, 0, 0]) + assert income(sim) == pytest.approx(4_420, abs=0.01) + + +@pytest.mark.parametrize("country", AREAS) +def test_small_payments_are_not_over_disregarded(country): + assert income(family(country, maintenance=500, wpa=500, child=True)) == 0 + + +@pytest.mark.parametrize("country", AREAS) +def test_only_applicant_partner_income_counts_and_child_maintenance_is_excluded( + country, +): + assert ( + income(family(country, recipient="other", maintenance=5_200, wpa=8_145.80)) == 0 + ) + assert income(family(country, child_maintenance=5_200, child=True)) == 0 + assert ( + income( + family( + country, recipient="child", child=True, maintenance=5_200, wpa=8_145.80 + ) + ) + == 0 + ) + + +@pytest.mark.parametrize("country", AREAS) +def test_head_applying_alone_has_no_partner_or_child_disregard(country): + assert ( + income( + family( + country, recipient="partner", maintenance=5_200, head_applies_alone=True + ) + ) + == 0 + ) + assert income( + family(country, maintenance=5_200, child=True, head_applies_alone=True) + ) == pytest.approx(5_200) + + +@pytest.mark.parametrize("country", AREAS) +def test_guarantee_credit_still_passports_the_whole_income(country): + sim = family(country, age=80, maintenance=5_200, wpa=8_145.80, route="guarantee") + assert income(sim) == 0 + + +@pytest.mark.parametrize("country", AREAS) +@pytest.mark.parametrize("child, expected", [(False, 13_200), (True, 12_420)]) +def test_savings_credit_uses_assessed_income_without_adding_maintenance_again( + country, child, expected +): + # The Secretary of State's £13,000 already contains £5,200 maintenance. + # Add £200 savings credit and subtract only CTR's child-family disregard. + sim = family(country, age=80, maintenance=5_200, child=child, route="savings") + assert income(sim) == pytest.approx(expected, abs=0.01) diff --git a/policyengine_uk/tests/test_heating_fuel_inputs.py b/policyengine_uk/tests/test_heating_fuel_inputs.py new file mode 100644 index 0000000000..418c7b818f --- /dev/null +++ b/policyengine_uk/tests/test_heating_fuel_inputs.py @@ -0,0 +1,95 @@ +"""Liquid and solid heating fuel inputs (#2572). + +Homes off the gas grid heat with oil, LPG, coal or wood: COICOP 04.5.3 and +04.5.4. Their spending is part of housing_water_and_electricity_consumption, +so the inputs default to zero and add nothing to consumption. Ofgem's price +cap does not cover them, so dataset projection uprates them by CPI like other +LCFS spending. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import yaml + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + DATASET_UPRATING_EXCLUSIONS, + extend_single_year_dataset, +) +from policyengine_uk.system import system + +FUELS = ("liquid_fuel_consumption", "solid_fuel_consumption") +UPRATING_INDICES = Path(__file__).parents[1] / "data" / "uprating_indices.yaml" +CPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +CPI = system.parameters.gov.economic_assumptions.yoy_growth.obr.consumer_price_index + + +def household(**inputs): + return Simulation( + situation={ + "people": {"adult": {"age": 50}}, + "households": { + "home": { + "members": ["adult"], + "housing_water_and_electricity_consumption": {2026: 3_000}, + **{k: {2026: v} for k, v in inputs.items()}, + } + }, + } + ) + + +@pytest.mark.parametrize("fuel", FUELS) +def test_defaults_are_zero_and_add_nothing_to_consumption(fuel): + assert household().calculate(fuel, 2026)[0] == 0 + with_fuel = household(**{fuel: 1_200}) + assert with_fuel.calculate(fuel, 2026)[0] == 1_200 + assert with_fuel.calculate("consumption", 2026)[0] == pytest.approx( + household().calculate("consumption", 2026)[0] + ) + + +def test_fuels_are_uprated_by_cpi_like_other_lcfs_spending(): + # Ofgem's cap prices the electricity and gas inputs, which are held at + # their stored prices; it does not cover heating oil, LPG, coal or wood. + listed = yaml.safe_load(UPRATING_INDICES.read_text()) + for fuel in FUELS: + assert fuel not in DATASET_UPRATING_EXCLUSIONS + assert [i for i, v in listed.items() if fuel in v] == [CPI_GROWTH] + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [50], + } + ), + benunit=pd.DataFrame({"benunit_id": [1]}), + household=pd.DataFrame( + { + "household_id": [1], + "household_weight": [1.0], + "region": ["NORTHERN_IRELAND"], + "tenure_type": ["OWNED_OUTRIGHT"], + "rent": [0.0], + "council_tax": [0.0], + "gas_consumption": [0.0], + "liquid_fuel_consumption": [1_500.0], + "solid_fuel_consumption": [300.0], + } + ), + fiscal_year=2024, + ) + projected = extend_single_year_dataset(dataset, system.parameters, end_year=2029) + growth = np.prod([1 + CPI(str(year)) for year in range(2025, 2030)]) + household = projected.datasets[2029].household + assert household["liquid_fuel_consumption"].iloc[0] == pytest.approx( + 1_500.0 * growth + ) + assert household["solid_fuel_consumption"].iloc[0] == pytest.approx(300.0 * growth) + assert growth > 1 diff --git a/policyengine_uk/tests/test_income_support_spousal_maintenance.py b/policyengine_uk/tests/test_income_support_spousal_maintenance.py new file mode 100644 index 0000000000..e8a5d2a47d --- /dev/null +++ b/policyengine_uk/tests/test_income_support_spousal_maintenance.py @@ -0,0 +1,111 @@ +"""Maintenance in the Income Support means test for continuing awards. + +IS Regs 1987 reg 40 counts non-earnings income. Schedule 9 para 15(3)(a) +excludes former-partner maintenance from the voluntary-payment disregard; +para 73 disregards child maintenance. Reg 23 counts the claimant's and +partner's income, but not a child's or another benefit-unit member's income. + +https://www.legislation.gov.uk/uksi/1987/1967/regulation/40 +https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/15 +https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/73 +https://www.legislation.gov.uk/uksi/1987/1967/schedule/8/paragraph/5 +https://www.legislation.gov.uk/uksi/1987/1967/schedule/8/paragraph/6 +https://www.gov.uk/government/publications/benefit-and-pension-rates-2024-to-2025/benefit-and-pension-rates-2024-to-2025 +""" + +import pytest + +from policyengine_uk import Simulation + +YEAR = 2024 + + +def family(maintenance=None, partner=False, other_adult=False): + people = { + "claimant": { + "age": {YEAR: 40}, + "is_parent": {YEAR: True}, + "is_claimant_or_partner": {YEAR: True}, + "income_support_reported": {YEAR: 100}, + "employment_income": {YEAR: 2_000}, + "hours_worked": {YEAR: 520}, + }, + "child": { + "age": {YEAR: 4}, + "is_claimant_or_partner": {YEAR: False}, + }, + } + if partner: + people["partner"] = { + "age": {YEAR: 38}, + "is_claimant_or_partner": {YEAR: True}, + } + if other_adult: + people["other_adult"] = { + "age": {YEAR: 21}, + "is_claimant_or_partner": {YEAR: False}, + "current_education": {YEAR: "NOT_IN_EDUCATION"}, + } + for person, variable, amount in maintenance or []: + people[person][variable] = {YEAR: amount} + members = list(people) + return Simulation( + situation={ + "people": people, + "benunits": {"family": {"members": members}}, + "households": {"home": {"members": members, "region": "LONDON"}}, + } + ) + + +def amount(simulation, variable): + return float(simulation.calculate(variable, YEAR)[0]) + + +def test_spousal_maintenance_reduces_an_existing_income_support_award(): + baseline = family() + maintained = family([("claimant", "spousal_maintenance_income", 1_200)]) + # 2024-25 lone-parent allowance: £90.50 * 52 = £4,706. Earnings of + # £2,000 exceed the £20 * 52 = £1,040 earnings disregard, leaving £960. + # Maintenance is counted in full: £960 + £1,200 = £2,160. The continuing + # award therefore falls from £4,706 - £960 = £3,746 to £2,546. + assert amount(baseline, "income_support_eligible") == 1 + assert amount(maintained, "income_support_eligible") == 1 + assert amount(baseline, "income_support_applicable_income") == pytest.approx(960) + assert amount(maintained, "income_support_applicable_income") == pytest.approx( + 2_160 + ) + assert amount(baseline, "income_support") == pytest.approx(3_746, abs=0.01) + assert amount(maintained, "income_support") == pytest.approx(2_546, abs=0.01) + + +@pytest.mark.parametrize("recipient", ["claimant", "partner"]) +def test_either_partners_spousal_maintenance_counts(recipient): + baseline = family(partner=True) + maintained = family( + [(recipient, "spousal_maintenance_income", 1_200)], partner=True + ) + # Couple earnings disregard: £10 * 52 = £520. The claimant's £2,000 + # earnings leave £1,480; either partner's £1,200 raises it to £2,680. + assert amount(baseline, "income_support_applicable_income") == pytest.approx(1_480) + assert amount(maintained, "income_support_applicable_income") == pytest.approx( + 2_680 + ) + + +def test_child_maintenance_and_other_members_income_remain_disregarded(): + baseline = family(other_adult=True) + maintained = family( + [ + ("claimant", "maintenance_income", 1_200), + ("other_adult", "spousal_maintenance_income", 3_000), + ("child", "spousal_maintenance_income", 2_000), + ], + other_adult=True, + ) + # Only the claimant's £2,000 earnings count: £2,000 - £1,040 = £960. + for simulation in (baseline, maintained): + assert amount(simulation, "income_support_applicable_income") == pytest.approx( + 960 + ) + assert amount(simulation, "income_support") == pytest.approx(3_746, abs=0.01) diff --git a/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py b/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py new file mode 100644 index 0000000000..e829efd5ef --- /dev/null +++ b/policyengine_uk/tests/test_non_dependant_spousal_maintenance.py @@ -0,0 +1,101 @@ +"""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. +It remains exempt from income tax under ITTOIA 2005 s 727. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 + + +@pytest.fixture(scope="module") +def non_dependants(): + # 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: + people[name] = { + "age": {YEAR: 30}, + "weekly_hours": {YEAR: 16}, + "employment_income": {YEAR: earnings}, + "spousal_maintenance_income": {YEAR: maintenance}, + "housing_benefit_individual_non_dep_deduction_eligible": {YEAR: True}, + "council_tax_reduction_individual_non_dep_deduction_eligible": {YEAR: True}, + } + benunits[name] = { + "members": [name], + "universal_credit": {YEAR: 0}, + "pension_credit": {YEAR: 0}, + } + households[name] = { + "members": [name], + "country": {YEAR: "ENGLAND"}, + "local_authority": {YEAR: "MERTON"}, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + + +def values(simulation, variable): + return np.asarray(simulation.calculate(variable, YEAR), dtype=float) + + +def test_housing_benefit_non_dependant_band_counts_spousal_maintenance(non_dependants): + # HB reg 74(2): £150/week is below £192; £150 + £100 = £250 is + # in the £192–278.99 band. SI 2026/148 art 23(3) sets £20.40 and + # £46.85 weekly deductions: £1,060.80 and £2,436.20 a year. + np.testing.assert_allclose( + values(non_dependants, "household_benefits_individual_non_dep_deduction")[:2], + [1_060.80, 2_436.20], + atol=0.01, + ) + + +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" + ) + 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): + # Merton's 2026 scheme para 30(1), (2), (9), pp 58–61: + # £150 + £150 = £300/week falls in £279–484.99, with £10.60/week + # deducted (£551.20/year), rather than £5.20 (£270.40/year). + np.testing.assert_allclose( + values( + non_dependants, "merton_council_tax_reduction_individual_non_dep_deduction" + )[:3], + [270.40, 270.40, 551.20], + atol=0.01, + ) + + +def test_non_dependant_spousal_maintenance_stays_exempt_from_income_tax(non_dependants): + # ITTOIA 2005 s 727: maintenance leaves taxable income at £7,800. + # The £13,000 earnings comparator is taxable above the £12,570 allowance: + # (£13,000 - £12,570) × 20% = £86. + np.testing.assert_allclose( + values(non_dependants, "total_income"), + [7_800, 7_800, 7_800, 13_000], + atol=0.01, + ) + np.testing.assert_allclose( + values(non_dependants, "income_tax"), [0, 0, 0, 86], atol=0.01 + ) diff --git a/policyengine_uk/tests/test_spousal_maintenance_income.py b/policyengine_uk/tests/test_spousal_maintenance_income.py new file mode 100644 index 0000000000..40b4207483 --- /dev/null +++ b/policyengine_uk/tests/test_spousal_maintenance_income.py @@ -0,0 +1,110 @@ +"""Spousal maintenance reaches the means tests; child maintenance does not. + +Maintenance from a spouse, civil partner, former spouse or former civil +partner is Universal Credit unearned income (UC Regs 2013 reg. 66(1)(d)), +Pension Credit income (SPC Regs 2002 reg. 15(5)(d)) and Housing Benefit +income (HB Regs 2006 reg. 40; HB (SPC) Regs 2006 reg. 29(1)(o)). Child +maintenance is none of these. Both are market and HBAI income. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEAR = 2026 +# age, employment income, private pension, tenure, rent +PEOPLE = [ + # Working age, no other income: on Universal Credit. + (30, 0, 0, "RENT_PRIVATELY", 9_000), + # Pension age, low pension: on guarantee credit. + (80, 0, 6_000, "RENT_FROM_COUNCIL", 6_000), + # A high earner on no benefit. + (45, 80_000, 0, "OWNED_OUTRIGHT", 0), +] +MEANS_TESTS = ( + "uc_unearned_income", + "pension_credit_income", + "housing_benefit_applicable_income", +) + + +def simulation(maintenance_variable=None, amount=0): + people, benunits, households = {}, {}, {} + for i, (age, pay, pension, tenure, rent) in enumerate(PEOPLE): + inputs = { + "age": age, + "employment_income": pay, + "private_pension_income": pension, + "state_pension": 0, + } + if maintenance_variable is not None: + inputs[maintenance_variable] = amount + people[f"p{i}"] = {k: {str(YEAR): v} for k, v in inputs.items()} + benunits[f"b{i}"] = {"members": [f"p{i}"]} + households[f"h{i}"] = { + "members": [f"p{i}"], + "country": "ENGLAND", + "tenure_type": tenure, + "rent": rent, + } + return Simulation( + situation={"people": people, "benunits": benunits, "households": households} + ) + + +@pytest.fixture(scope="module") +def simulations(): + return { + "none": simulation(), + "spousal": simulation("spousal_maintenance_income", 1_200), + "child": simulation("maintenance_income", 1_200), + } + + +def values(sim, variable): + return np.asarray(sim.calculate(variable, YEAR), dtype=float) + + +def change(simulations, kind, variable): + return values(simulations[kind], variable) - values(simulations["none"], variable) + + +def test_uc_award_falls_by_spousal_maintenance(simulations): + # £100 a month: the award falls by £100 a month. + assert values(simulations["none"], "universal_credit")[0] > 1_200 + assert change(simulations, "spousal", "universal_credit")[0] == pytest.approx( + -1_200, abs=0.01 + ) + + +def test_guarantee_credit_falls_by_spousal_maintenance(simulations): + assert values(simulations["none"], "guarantee_credit")[1] > 1_200 + assert change(simulations, "spousal", "guarantee_credit")[1] == pytest.approx( + -1_200, abs=0.01 + ) + assert change(simulations, "spousal", "savings_credit_income")[1] == 0 + + +def test_child_maintenance_changes_no_means_test(simulations): + for name in MEANS_TESTS + ( + "universal_credit", + "pension_credit", + "housing_benefit", + ): + np.testing.assert_allclose( + change(simulations, "child", name), 0, atol=0.01, err_msg=name + ) + + +@pytest.mark.parametrize("kind", ("spousal", "child")) +def test_both_kinds_are_market_and_hbai_income(simulations, kind): + # The high earner gets no means-tested benefit, so HBAI income rises by + # the whole payment, which is not taxable. + for name in ("market_income", "household_market_income"): + np.testing.assert_allclose( + change(simulations, kind, name), 1_200, atol=0.01, err_msg=name + ) + assert change(simulations, kind, "hbai_household_net_income")[2] == pytest.approx( + 1_200, abs=0.01 + ) diff --git a/policyengine_uk/tests/test_student_loan_plan_uprating.py b/policyengine_uk/tests/test_student_loan_plan_uprating.py new file mode 100644 index 0000000000..17325d3071 --- /dev/null +++ b/policyengine_uk/tests/test_student_loan_plan_uprating.py @@ -0,0 +1,257 @@ +"""Student loan plans in projected years (uprate_student_loan_plans, #2572). + +Projected datasets copy the base year with ages fixed, so in a later year a +person of a given age stands for a later university intake. Tertiary-educated +people without a loan in the data, above the base year's oldest holder of a +plan, take up that plan at a flat rate in later years. The base year is the +dataset's own, and each person's draw is keyed on person_id. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + extend_single_year_dataset, + uprate_student_loan_plans, +) +from policyengine_uk.system import system + +AGES = range(18, 51) +PEOPLE_PER_AGE = 600 +TAKE_UP = 0.4 + + +def mock_dataset( + base_year: int, + *, + ages=AGES, + region="LONDON", + people_per_age=PEOPLE_PER_AGE, +) -> UKSingleYearDataset: + """MOCK data: tertiary-educated people, none with a loan.""" + ages = np.repeat(list(ages), people_per_age) + ids = np.arange(1, len(ages) + 1) + person = pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": ages, + "student_loan_plan": "NONE", + "highest_education": "TERTIARY", + "student_loan_repayments": 0.0, + } + ) + household = pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": region, + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ) + return UKSingleYearDataset( + person=person, + benunit=pd.DataFrame({"benunit_id": ids}), + household=household, + fiscal_year=base_year, + ) + + +def projected_plans(base_year: int, end_year: int = 2029) -> dict[int, pd.DataFrame]: + projected = extend_single_year_dataset( + mock_dataset(base_year), system.parameters, end_year=end_year + ) + return { + year: projected.datasets[year].person.set_index("person_id")[ + ["age", "student_loan_plan"] + ] + for year in projected.datasets + } + + +@pytest.fixture(scope="module") +def from_2024(): + return projected_plans(2024) + + +def share(plans: pd.DataFrame, plan: str, ages) -> float: + band = plans[plans.age.isin(list(ages))] + return (band.student_loan_plan == plan).mean() + + +def test_new_plan_2_holders_are_aged_31_to_35_in_2029(from_2024): + # The 2012 intake was 30 in 2024. By 2029, with ages fixed, Plan 2 covers + # intakes up to age 35 (started 2012), so ages 31 to 35 take it up. + plans = from_2024[2029] + assert share(plans, "PLAN_2", range(31, 36)) == pytest.approx(TAKE_UP, abs=0.03) + assert share(plans, "PLAN_2", range(25, 31)) == 0 + assert share(plans, "PLAN_2", range(36, 51)) == 0 + + +def test_the_base_year_comes_from_the_dataset(from_2024): + # With a 2023 base the oldest Plan 2 holder was 29, so 30-year-olds take + # it up too. With a 2024 base they were already covered by the data. + from_2023 = projected_plans(2023)[2029] + assert share(from_2023, "PLAN_2", [30]) == pytest.approx(TAKE_UP, abs=0.06) + assert share(from_2024[2029], "PLAN_2", [30]) == 0 + + +def test_new_plan_1_holders_are_older_than_the_first_income_contingent_intake( + from_2024, +): + # The 1998 intake was 44 in 2024 and is 49 in 2029. Loans first paid + # before September 2006 are written off at 65, so the 1998 to 2002 + # intakes (ages 45 to 49) are all still outstanding; age 50 is the 1997 + # intake, before income-contingent loans. + plans = from_2024[2029] + assert share(plans, "PLAN_1", range(45, 50)) == pytest.approx(TAKE_UP, abs=0.03) + assert share(plans, "PLAN_1", range(36, 45)) == 0 + assert share(plans, "PLAN_1", [50]) == 0 + + +def test_the_two_plan_1_write_off_rules(all_graduates_take_up): + # Projected from 2024 to 2040, ages 47 to 52 are the 2011 to 2006 + # intakes, written off 29 years after the intake (by 2040 at the latest). + # Ages 53 to 60 are the 2005 to 1998 intakes, first paid before + # September 2006, so outstanding until 65. Age 61 is the 1997 intake, + # before income-contingent loans. + previous = mock_dataset( + 2024, ages=[47, 52, 53, 60, 61], region="LONDON", people_per_age=1 + ) + current = previous.copy() + current.time_period = "2040" + projected = uprate_student_loan_plans(current, previous, system.parameters) + assert projected.person.student_loan_plan.tolist() == [ + "NONE", + "NONE", + "PLAN_1", + "PLAN_1", + "NONE", + ] + + +def test_a_persons_draw_is_the_same_in_every_year(from_2024): + # Ages 31 to 34 are in the Plan 2 band in both 2028 and 2029. + in_band = from_2024[2028].age.between(31, 34) + took_up_2028 = from_2024[2028].student_loan_plan[in_band] == "PLAN_2" + took_up_2029 = from_2024[2029].student_loan_plan[in_band] == "PLAN_2" + assert took_up_2028.any() + pd.testing.assert_series_equal(took_up_2028, took_up_2029) + + +def test_draws_do_not_depend_on_row_order(from_2024): + dataset = mock_dataset(2024) + order = np.random.default_rng(0).permutation(len(dataset.person)) + shuffled = UKSingleYearDataset( + person=dataset.person.iloc[order].reset_index(drop=True), + benunit=dataset.benunit, + household=dataset.household, + fiscal_year=2024, + ) + projected = extend_single_year_dataset(shuffled, system.parameters, end_year=2029) + plans = projected.datasets[2029].person.set_index("person_id").student_loan_plan + pd.testing.assert_series_equal( + plans.sort_index(), from_2024[2029].student_loan_plan.sort_index() + ) + + +@pytest.fixture +def all_graduates_take_up(monkeypatch): + """Make the funding-plan regressions independent of random take-up.""" + monkeypatch.setattr( + "policyengine_uk.data.economic_assumptions.splitmix64_uniform", + lambda ids, salt: np.zeros(len(ids)), + ) + + +@pytest.mark.parametrize( + "region, expected_plan", + [ + ("LONDON", "PLAN_2"), + ("WALES", "PLAN_2"), + ("SCOTLAND", "PLAN_4"), + ("NORTHERN_IRELAND", "PLAN_1"), + ("UNKNOWN", "NONE"), + ], +) +def test_extended_2012_intakes_follow_funding_nation( + all_graduates_take_up, region, expected_plan +): + # GOV.UK: England/Wales 2012-2022 undergraduate intakes use Plan 2; + # SAAS uses Plan 4 and Student Finance NI uses Plan 1. Residence proxies + # for funding nation. In 2024 the 2012 intake was 30; projecting to 2029 + # adds ages 31 (2016 intake) through 35 (2012 intake). Ages 30 and 36 + # remain outside this new band. Unknown residence supplies no proxy. + dataset = mock_dataset(2024, ages=[30, 31, 35, 36], region=region, people_per_age=1) + projected = extend_single_year_dataset(dataset, system.parameters, end_year=2029) + assert projected.datasets[2029].person.student_loan_plan.tolist() == [ + "NONE", + expected_plan, + expected_plan, + "NONE", + ] + + +@pytest.mark.parametrize( + "region, expected_plan", + [ + ("LONDON", "PLAN_1"), + ("WALES", "PLAN_1"), + ("SCOTLAND", "PLAN_4"), + ("NORTHERN_IRELAND", "PLAN_1"), + ], +) +def test_extended_pre_2012_intakes_follow_funding_nation( + all_graduates_take_up, region, expected_plan +): + # The existing extension starts above age 44 (2024 - 1998 + 18). + # In 2029 ages 45/46 represent the 2002/2001 intakes: England/Wales + # use Plan 1 before September 2012; SAAS uses Plan 4; NI uses Plan 1. + previous = mock_dataset(2024, ages=[44, 45, 46], region=region, people_per_age=1) + current = previous.copy() + current.time_period = "2029" + projected = uprate_student_loan_plans(current, previous, system.parameters) + assert projected.person.student_loan_plan.tolist() == [ + "NONE", + expected_plan, + expected_plan, + ] + + +@pytest.mark.parametrize( + "region, plan_from_2023, plan_before_2023", + [ + ("LONDON", "PLAN_5", "PLAN_2"), + # Only England's Plan 5 is new since the base year: the other + # nations' 2023-and-later intakes are on plans the data already has. + ("WALES", "NONE", "PLAN_2"), + ("SCOTLAND", "NONE", "PLAN_4"), + ("NORTHERN_IRELAND", "NONE", "PLAN_1"), + ("UNKNOWN", "NONE", "NONE"), + ], +) +def test_new_intakes_follow_funding_nation_and_entry_year( + all_graduates_take_up, region, plan_from_2023, plan_before_2023 +): + # https://www.gov.uk/repaying-your-student-loan/which-repayment-plan-you-are-on + # Only England switches to Plan 5 in 2023; Wales retains Plan 2. + # In 2029 age 24 represents entry in 2023, age 25 entry in 2022. + # A 2018 base's Plan 2 coverage ends at 24, so both are newly assigned. + # Age 21 (2026 entry) qualifies; age 20 has not finished a degree. + previous = mock_dataset( + 2018, ages=[20, 21, 24, 25], region=region, people_per_age=1 + ) + current = previous.copy() + current.time_period = "2029" + projected = uprate_student_loan_plans(current, previous, system.parameters) + assert projected.person.student_loan_plan.tolist() == [ + "NONE", + plan_from_2023, + plan_from_2023, + plan_before_2023, + ] diff --git a/policyengine_uk/tests/test_uc_incapacity_income.py b/policyengine_uk/tests/test_uc_incapacity_income.py index 3594a20d12..1f25e810d6 100644 --- a/policyengine_uk/tests/test_uc_incapacity_income.py +++ b/policyengine_uk/tests/test_uc_incapacity_income.py @@ -17,6 +17,8 @@ "private_pension_income", "state_pension", "uc_tariff_income", + "spousal_maintenance_income", + "widowed_parents_allowance", ] diff --git a/policyengine_uk/tests/test_widowed_parents_allowance.py b/policyengine_uk/tests/test_widowed_parents_allowance.py new file mode 100644 index 0000000000..edd47155d3 --- /dev/null +++ b/policyengine_uk/tests/test_widowed_parents_allowance.py @@ -0,0 +1,148 @@ +"""Widowed Parent's Allowance has its own variable, apart from bsp_reported. + +FRS benefit code 6 is "Bereavement Support Payment / Widowed Parent's +Allowance", and datasets put it in bsp_reported. Bereavement Support Payment +is tax-free, is not Universal Credit unearned income and has frozen rates. +Widowed Parent's Allowance is taxable (ITEPA 2003 s.577), is UC unearned +income (UC Regs 2013 reg. 66(1)(b)(vi)) and rises with CPI. Once datasets +split code 6, the allowance needs its own CPI-projected input that counts +wherever Bereavement Support Payment counts, and in tax and UC as well. +""" + +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest +import yaml + +from policyengine_uk import Microsimulation, Simulation, parameters +from policyengine_uk.data import UKSingleYearDataset + +UPRATING_INDICES = Path(__file__).parents[1] / "data" / "uprating_indices.yaml" +CPI_GROWTH = "gov.economic_assumptions.yoy_growth.obr.consumer_price_index" +CPI_INDEX = "gov.economic_assumptions.indices.obr.consumer_price_index" +CPI = parameters.gov.economic_assumptions.yoy_growth.obr.consumer_price_index +AGGREGATES_THAT_COUNT_BSP = ( + "household_benefits", + "hbai_household_net_income", + "gov_spending", +) +WEEKLY_RATE_2026 = 156.65 + + +def test_widowed_parents_allowance_reported_is_projected_by_cpi(): + listed = yaml.safe_load(UPRATING_INDICES.read_text()) + indices = sorted( + index + for index, variables in listed.items() + if "widowed_parents_allowance_reported" in variables + ) + assert indices == [CPI_GROWTH] + + +def test_widowed_parents_allowance_reported_declares_cpi_uprating(): + sim = Simulation(situation={"people": {"parent": {"age": 40}}}) + variable = sim.tax_benefit_system.variables["widowed_parents_allowance_reported"] + assert variable.uprating == CPI_INDEX + + +def single_parent(inputs, **benunit): + return Simulation( + situation={ + "people": { + "parent": {"age": 40, **{k: {2026: v} for k, v in inputs.items()}}, + "child": {"age": 5}, + }, + "benunits": {"benunit": {"members": ["parent", "child"], **benunit}}, + "households": { + "household": { + "members": ["parent", "child"], + "country": "ENGLAND", + "tenure_type": "RENT_PRIVATELY", + "rent": 9_000, + } + }, + } + ) + + +@pytest.mark.parametrize("aggregate", AGGREGATES_THAT_COUNT_BSP) +def test_widowed_parents_allowance_counts_wherever_bsp_does(aggregate): + # A £20,000 earner who does not claim Universal Credit gets no + # means-tested benefit, so each aggregate moves by the payment. The + # allowance is taxable at the basic rate here, so HBAI income gains 80% + # of it; Bereavement Support Payment is untaxed. + def value(inputs): + sim = Simulation( + situation={ + "people": { + "parent": { + "age": 40, + "employment_income": {2026: 20_000}, + **{k: {2026: v} for k, v in inputs.items()}, + } + }, + "benunits": { + "benunit": {"members": ["parent"], "would_claim_uc": False} + }, + "households": { + "household": {"members": ["parent"], "country": "ENGLAND"} + }, + } + ) + return sim.calculate(aggregate, 2026).sum() + + base = value({}) + amount = WEEKLY_RATE_2026 * 52 + assert value({"bsp_reported": amount}) - base == pytest.approx(amount, abs=0.01) + expected = amount * (0.8 if aggregate == "hbai_household_net_income" else 1) + assert value({"widowed_parents_allowance_reported": amount}) - base == ( + pytest.approx(expected, abs=0.01) + ) + + +def test_universal_credit_falls_by_the_allowance_and_not_by_bsp(): + amount = WEEKLY_RATE_2026 * 52 + + def uc(inputs): + return single_parent(inputs).calculate("universal_credit", 2026).sum() + + base = uc({}) + assert base > amount + assert uc({"widowed_parents_allowance_reported": amount}) - base == ( + pytest.approx(-amount, abs=0.01) + ) + assert uc({"bsp_reported": amount}) == pytest.approx(base, abs=0.01) + + +def test_projected_widowed_parents_allowance_grows_with_cpi(): + dataset = UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1], + "person_benunit_id": [1], + "person_household_id": [1], + "age": [40], + "widowed_parents_allowance_reported": [8_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=2024, + ) + sim = Microsimulation(dataset=dataset) + expected = 8_000.0 * np.prod([1 + CPI(str(year)) for year in range(2025, 2030)]) + assert sim.calculate("widowed_parents_allowance", 2029).iloc[0] == ( + pytest.approx(expected) + ) + assert expected > 8_000.0 diff --git a/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py b/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py index 5fc9115244..a65a6bb943 100644 --- a/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py +++ b/policyengine_uk/variables/contrib/policyengine/pre_budget_change_household_benefits.py @@ -16,6 +16,7 @@ "afcs", "bsp", "war_widows_pension", + "widowed_parents_allowance", "carers_allowance", "dla", "esa_contrib", 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 03f20ded9c..c4d1aef33a 100644 --- a/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py +++ b/policyengine_uk/variables/gov/dwp/benefit_cap_welfare_benefits.py @@ -25,6 +25,8 @@ "esa_contrib", "incapacity_benefit", "sda", + # s. 96(10)(n). + "widowed_parents_allowance", ] FAMILY_BENEFITS = [ "child_benefit", @@ -54,8 +56,9 @@ class benefit_cap_welfare_benefits(Variable): "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. Maternity allowance and the bereavement benefits, also " - "welfare benefits, are not modelled here." + "include it. Widowed parent's allowance counts; maternity allowance " + "and the other bereavement benefits, also welfare benefits, are not " + "modelled here." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/bsp_reported.py b/policyengine_uk/variables/gov/dwp/bsp_reported.py index 474afbe47f..22bfc6c103 100644 --- a/policyengine_uk/variables/gov/dwp/bsp_reported.py +++ b/policyengine_uk/variables/gov/dwp/bsp_reported.py @@ -12,8 +12,10 @@ class bsp_reported(Variable): "this from Family Resources Survey benefit codes 6 (Bereavement " "Support Payment or Widowed Parent's Allowance) and 9 (War Widow's or " "Widower's Pension). Code 9 is most of the value and is uprated by " - "CPI, so this variable keeps CPI uprating until datasets move code 9 " - "into war_widows_pension_reported. It should then be frozen (#2381)." + "CPI, as is Widowed Parent's Allowance, so this variable keeps CPI " + "uprating until datasets move code 9 into war_widows_pension_reported " + "and Widowed Parent's Allowance into " + "widowed_parents_allowance_reported. It should then be frozen (#2381)." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py index 96398e5041..cd7ee152ec 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_applicable_income.py @@ -22,6 +22,10 @@ class housing_benefit_applicable_income(Variable): "https://www.legislation.gov.uk/nisr/2006/405/schedule/6/paragraph/4", "https://www.legislation.gov.uk/uksi/2006/213/regulation/2", "https://www.legislation.gov.uk/uksi/2006/213/regulation/5", + "https://www.legislation.gov.uk/uksi/2006/213/regulation/40", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/16", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/47", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/29", "https://www.legislation.gov.uk/uksi/2006/214/regulation/26", "https://www.legislation.gov.uk/uksi/2006/214/regulation/27", "https://www.legislation.gov.uk/nisr/2006/406/regulation/24", @@ -92,7 +96,30 @@ def formula(benunit, period, parameters): income = add_for_members(benunit, period, INCOME_COMPONENTS, members) personal_benefits = add_for_members(benunit, period, PERSONAL_BENEFITS, members) credits = add_for_members(benunit, period, ["tax_credits"], members) - increased_income = income + personal_benefits + credits + benefits + # Maintenance paid to the claimant or partner by a spouse, civil + # partner or former partner is income other than earnings (HB Regs + # 2006 reg 40(1); HB (SPC) Regs 2006 reg 29(1)(o)), less £15 a week + # where the family includes a child or young person. Child + # maintenance is disregarded in full (HB Regs 2006 Sch 5 para 47A) or + # is not listed as income (reg 29(1)), so maintenance_income is not + # counted. + spousal_maintenance = benunit.sum( + max_(0, person("spousal_maintenance_income", period)) + * person("is_claimant_or_partner", period) + ) - benunit("housing_benefit_maintenance_disregard", period) + # Widowed parent's allowance less £15 a week (HB Regs 2006 Sch 5 + # para 16(b); HB (SPC) Regs 2006 Sch 5 para 7). + widowed_parents_allowance = benunit( + "housing_benefit_widowed_parents_allowance_income", period + ) + increased_income = ( + income + + personal_benefits + + credits + + benefits + + spousal_maintenance + + widowed_parents_allowance + ) if not bi.interactions.include_in_means_tests: # Basic income is already in personal benefits, deduct if needed diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_maintenance_disregard.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_maintenance_disregard.py new file mode 100644 index 0000000000..ba5363ff70 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_maintenance_disregard.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_maintenance_disregard(Variable): + value_type = float + entity = BenUnit + label = "Housing Benefit maintenance disregard" + documentation = ( + "The part of the claimant's and partner's spousal maintenance that " + "Housing Benefit disregards: £15 a week where the family includes a " + "child or young person, nothing otherwise. Payments to the claimant " + "and partner are added together and get one disregard (HB Regs 2006 " + "Sch 5 para 47(2); HB (SPC) Regs 2006 Sch 5 para 20(2)). Child " + "maintenance (maintenance_income) is not counted at all, so it needs " + "no disregard." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/47", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/20", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.means_test + person = benunit.members + maintenance = benunit.sum( + max_(0, person("spousal_maintenance_income", period)) + * person("is_claimant_or_partner", period) + ) + has_child = benunit.any( + person("is_child_or_young_person_for_legacy_benefits", period) + ) + return where( + has_child, + min_(maintenance, p.maintenance_disregard * WEEKS_IN_YEAR), + 0, + ) diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py index 76424d1474..47ccfaa1c6 100644 --- a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_savings_credit_only_income.py @@ -21,12 +21,14 @@ class housing_benefit_savings_credit_only_income(Variable): "or blind earner, otherwise 5 pounds single or 10 pounds couple), so " "Housing Benefit's own 5 and 10 pound amounts are not deducted again. " "Childcare charges are deducted as in the general " - "route, without the work condition or the cap at earnings. Housing " + "route, without the work condition or the cap at earnings. Pension " + "Credit counts spousal maintenance in full, so Housing Benefit's 15 " + "pound maintenance disregard (where the family includes a child or " + "young person) is deducted. Housing " "Benefit's own income and tariff income rules do not apply to the " "Secretary of State's figure, which already counts Pension Credit " - "deemed income from capital. Not modelled: the " - "maintenance disregards (neither income definition counts " - "maintenance), the exempt work disregard, the income and capital of a " + "deemed income from capital. Not modelled: " + "the exempt work disregard, the income and capital of a " "partner treated as a household member but not in the Pension Credit " "assessment, and income of a non-dependant treated as the claimant's. " "War pensions (Great Britain: local schemes under section 134(8) of " @@ -41,6 +43,7 @@ class housing_benefit_savings_credit_only_income(Variable): reference = ( "https://www.legislation.gov.uk/uksi/2006/214/regulation/27", "https://www.legislation.gov.uk/uksi/2006/214/schedule/4", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/20", "https://www.legislation.gov.uk/nisr/2006/406/regulation/25", "https://www.legislation.gov.uk/nisr/2006/406/schedule/5", "https://www.legislation.gov.uk/uksi/2002/1792/schedule/VI", @@ -96,7 +99,11 @@ def formula(benunit, period, parameters): max_(0, standard - benunit("pension_credit_earnings_disregard", period)), 0, ) - disregard = higher_lone_parent + additional + # Reg 27(4)(c)(ii): Pension Credit counts spousal maintenance in full + # (SPC Regs 2002 reg 15(5)(d)), so the higher amount Housing Benefit + # disregards is all of its Sch 5 para 20 disregard. + maintenance = benunit("housing_benefit_maintenance_disregard", period) + disregard = higher_lone_parent + additional + maintenance return max_( 0, pension_credit_income + savings_credit_payable - childcare - disregard, diff --git a/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_widowed_parents_allowance_income.py b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_widowed_parents_allowance_income.py new file mode 100644 index 0000000000..5b8352a078 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/housing_benefit/applicable_income/housing_benefit_widowed_parents_allowance_income.py @@ -0,0 +1,29 @@ +from policyengine_uk.model_api import * + + +class housing_benefit_widowed_parents_allowance_income(Variable): + value_type = float + entity = BenUnit + label = "Widowed Parent's Allowance counted for Housing Benefit" + documentation = ( + "The claimant's and partner's Widowed Parent's Allowance, each less " + "£15 a week. Not modelled: the £20 weekly limit on this and the £10 " + "war pension disregards together (HB Regs 2006 Sch 5 para 34), which " + "cannot bind while war pensions are taken as wholly disregarded." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2006/213/schedule/5/paragraph/16", + "https://www.legislation.gov.uk/uksi/2006/214/schedule/5/paragraph/7", + ) + + def formula(benunit, period, parameters): + p = parameters(period).gov.dwp.housing_benefit.means_test + person = benunit.members + counted = max_( + person("widowed_parents_allowance", period) + - p.widowed_parents_allowance_disregard * WEEKS_IN_YEAR, + 0, + ) + return benunit.sum(counted * person("is_claimant_or_partner", period)) 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 b23543c89a..9701a9ff4d 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 @@ -8,6 +8,10 @@ class household_benefits_individual_non_dep_deduction(Variable): definition_period = YEAR unit = GBP defined_for = "housing_benefit_individual_non_dep_deduction_eligible" + 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 @@ -15,10 +19,13 @@ def formula(person, period, parameters): # apply only to a non-dependant in remunerative work (HB Regs 2006 reg # 74(2); SPC Regs reg 55(2)), and the model does not yet test that, so # counting it would band non-workers whose only income is investment - # income. #2007 implements reg 74 in full and can then read - # total_income directly. - income = person("total_income", period) - person( - "other_investment_income", period + # income. #2007 implements reg 74 in full and can then include it. + # Spousal maintenance is tax-exempt, but is normal gross income and + # has no disregard under reg 74(9) or pension-age reg 55(10). + income = ( + person("total_income", period) + - person("other_investment_income", period) + + max_(0, person("spousal_maintenance_income", period)) ) weekly_income = income / WEEKS_IN_YEAR deduction = p.amount.calc(weekly_income, right=True) diff --git a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py index 4ae8cb39de..cb08fba8f3 100644 --- a/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py +++ b/policyengine_uk/variables/gov/dwp/income_support_applicable_income.py @@ -5,12 +5,20 @@ class income_support_applicable_income(Variable): value_type = float entity = BenUnit label = "Relevant income for Income Support means test" + documentation = ( + "The claimant's and partner's income for Income Support, including " + "spousal maintenance. Child maintenance is disregarded." + ) definition_period = YEAR unit = GBP reference = ( "https://www.legislation.gov.uk/uksi/1987/1967/schedule/8", "https://www.legislation.gov.uk/uksi/1987/1967/regulation/36", "https://www.legislation.gov.uk/uksi/1987/1967/regulation/38", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/40", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/15", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/16", + "https://www.legislation.gov.uk/uksi/1987/1967/schedule/9/paragraph/73", ) def formula(benunit, period, parameters): @@ -27,6 +35,10 @@ def formula(benunit, period, parameters): "self_employment_income", "property_income_after_finance_costs", "private_pension_income", + # Reg 40 and Sch 9 para 15(3)(a): spousal maintenance is + # non-earnings income, even when paid voluntarily. Child + # maintenance (maintenance_income) is disregarded by para 73. + "spousal_maintenance_income", ] bi = parameters(period).gov.contrib.ubi_center.basic_income if bi.interactions.include_in_means_tests: @@ -36,6 +48,16 @@ def formula(benunit, period, parameters): benunit, period, ["income_tax", "national_insurance"], members ) income += add_for_members(benunit, period, ["social_security_income"], members) + # IS Regs 1987 Sch 9 para 16(h): £10 a week of widowed parent's + # allowance is disregarded; social_security_income counts it in full. + # Not modelled: paras 36 and 37, which limit this and the war + # pension disregards together. + wpa_disregard = ( + IS.means_test.widowed_parents_allowance_disregard * WEEKS_IN_YEAR + ) + income -= benunit.sum( + min_(person("widowed_parents_allowance", period), wpa_disregard) * members + ) income += benunit("income_support_tariff_income", period) income -= tax # IS Regs 1987 reg 36(3)(b) and reg 38(1)(b)(ii), (3)(c) and (9)(b). diff --git a/policyengine_uk/variables/gov/dwp/widowed_parents_allowance.py b/policyengine_uk/variables/gov/dwp/widowed_parents_allowance.py new file mode 100644 index 0000000000..63f3c32681 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/widowed_parents_allowance.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class widowed_parents_allowance(Variable): + value_type = float + entity = Person + label = "Widowed Parent's Allowance" + documentation = ( + "Widowed Parent's Allowance under SSCBA 1992 s.39A, paid to a " + "surviving spouse or civil partner with a dependent child whose " + "partner died before 6 April 2017. Unlike Bereavement Support " + "Payment, it is taxable (ITEPA 2003 s.577; any increase for a child, " + "exempt under s.645, is not separated), it is Universal Credit " + "unearned income (UC Regs 2013 reg. 66(1)(b)(vi)), it counts towards " + "the benefit cap (Welfare Reform Act 2012 s.96(10)(n)) and it " + "overlaps with Carer's Allowance and Carer Support Payment. Housing " + "Benefit counts it less £15 a week. It is not payable from State " + "Pension age or while the recipient lives with a partner (s.39A(4) " + "and (5)), so it never reaches a Pension Credit or pension-age " + "Housing Benefit assessment." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/ukpga/1992/4/section/39A", + "https://www.legislation.gov.uk/ukpga/2003/1/section/577", + "https://www.legislation.gov.uk/uksi/2013/376/regulation/66", + "https://www.legislation.gov.uk/ukpga/2012/5/section/96", + ) + + adds = ["widowed_parents_allowance_reported"] diff --git a/policyengine_uk/variables/gov/dwp/widowed_parents_allowance_reported.py b/policyengine_uk/variables/gov/dwp/widowed_parents_allowance_reported.py new file mode 100644 index 0000000000..e002ddeec5 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/widowed_parents_allowance_reported.py @@ -0,0 +1,19 @@ +from policyengine_uk.model_api import * + + +class widowed_parents_allowance_reported(Variable): + value_type = float + entity = Person + label = "Widowed Parent's Allowance (reported)" + documentation = ( + "Reported Widowed Parent's Allowance. In the Family Resources Survey " + "it shares benefit code 6 with Bereavement Support Payment, and " + "datasets built so far put that code in bsp_reported. Its rates rise " + "each April with CPI, unlike Bereavement Support Payment's, which " + "have been frozen since April 2017 (see bsp_reported). Closed to new " + "claims since 6 April 2017." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/uksi/2026/148/article/18/made" + uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" diff --git a/policyengine_uk/variables/gov/gov_spending.py b/policyengine_uk/variables/gov/gov_spending.py index ff12e342e8..3a47e27453 100644 --- a/policyengine_uk/variables/gov/gov_spending.py +++ b/policyengine_uk/variables/gov/gov_spending.py @@ -18,6 +18,7 @@ "afcs", "bsp", "war_widows_pension", + "widowed_parents_allowance", "carers_allowance", "dla", "iidb", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/social_security_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/social_security_income.py index 269f47493d..1766d2d40f 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/social_security_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/social_security_income.py @@ -17,4 +17,6 @@ class social_security_income(Variable): "carers_allowance", "carer_support_payment", "scottish_carer_supplement", + # ITEPA 2003 s.577(1). + "widowed_parents_allowance", ] diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py index fd8ec12eda..6bb67a2e6f 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py @@ -2,10 +2,13 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): - label = "Salary sacrifice pension contributions of people with pay" + label = "Salary and bonus sacrifice pension contributions of people with pay" documentation = ( - "Salary sacrifice pension contributions, counted only for people with " - "pay. A salary sacrifice is pay given up in exchange for an employer " + "Salary sacrifice and bonus sacrifice pension contributions, counted " + "only for people with pay. The rules treat the two routes alike and the " + "NICs exemption cap applies to their sum, so the cap, the NICs excess " + "and the annual allowance tests all read this total. A salary " + "sacrifice is pay given up in exchange for an employer " "pension contribution, so a person without pay (employment income " "before labour supply responses, which policyengine_uk.Simulation fills " "from an employment_income input) has nothing to sacrifice. The " @@ -13,7 +16,8 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): "sacrifice, for example, a whole bonus, as long as their cash pay stays " "above the National Minimum Wage. The cap includes excess sacrifice " "only in NIC earnings. Without this pay condition a sacrifice " - "input with no pay behind it would create a NIC earnings base." + "input with no pay behind it would create a NIC earnings base. A " + "negative input counts as none." ) entity = Person definition_period = YEAR @@ -22,6 +26,7 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): reference = "https://www.gov.uk/guidance/salary-sacrifice-and-the-effects-on-paye" def formula(person, period, parameters): - sacrifice = person("pension_contributions_via_salary_sacrifice", period) + salary = person("pension_contributions_via_salary_sacrifice", period) + bonus = person("pension_contributions_via_bonus_sacrifice", period) pay = person("employment_income_before_lsr", period) - return where(pay > 0, max_(sacrifice, 0), 0) + return where(pay > 0, max_(salary, 0) + max_(bonus, 0), 0) 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 5d96830c62..ffd58ffd21 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 @@ -144,6 +144,9 @@ def normal_gross_income_non_dep_deduction( "savings_interest_income", "dividend_income", "state_pension", + # Normal gross income includes maintenance, although it is exempt + # from income tax (Default Scheme Sch para 30(2), (9)). + "spousal_maintenance_income", ] earned_income_components = [ "employment_income", 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 321375bd61..6a2209e55f 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 @@ -16,12 +16,18 @@ class council_tax_reduction_applicable_income(Variable): "pensioner earnings disregards. Great Britain's pensioner schemes " "also exclude Child Benefit from general-rule income; statutory sick " "and maternity pay remain counted as earnings. The other adjustments those " - "provisions allow (childcare charges, the £20 earnings disregards, " - "maintenance disregards and the rest) are not modelled. " + "provisions allow (childcare charges, the £20 earnings disregards " + "and the rest) are not modelled. Spousal maintenance counts less one " + "£15 weekly disregard where the applicant's family includes a child " + "or young person (the statutory family-premium condition in England's " + "default and Welsh schemes). " + "Widowed Parent's Allowance counts less £15 weekly per recipient. " + "Child maintenance is excluded. Scotland's working-age maintenance " + "rule, like Universal Credit, requires a court order or agreement; " + "the input is assumed to meet that condition. " "The income counted is the Council Tax Reduction applicant's and " - "partner's (is_council_tax_reduction_applicant_or_partner), with, as " - "the model did before, the income of the family's children or young " - "persons. Where the household head applies alone " + "partner's (is_council_tax_reduction_applicant_or_partner). " + "Where the household head applies alone " "(council_tax_reduction_head_applies_alone), the benefit unit's own " "awards (Child Benefit, Income Support, income-based JSA, " "income-related ESA, Universal Credit, tax credits, Pension Credit) " @@ -47,6 +53,18 @@ class council_tax_reduction_applicable_income(Variable): "https://www.legislation.gov.uk/ssi/2012/319/regulation/25", "https://www.legislation.gov.uk/ssi/2012/319/regulation/27", "https://www.legislation.gov.uk/ssi/2021/249/regulation/57", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/7", + "https://www.legislation.gov.uk/uksi/2012/2885/schedule/5/paragraph/20", + "https://www.legislation.gov.uk/uksi/2012/2886/appendix/8/paragraph/21", + "https://www.legislation.gov.uk/uksi/2012/2886/appendix/8/paragraph/49", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/7", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/4/paragraph/20", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/21", + "https://www.legislation.gov.uk/wsi/2013/3029/schedule/9/paragraph/49", + "https://www.legislation.gov.uk/wsi/2013/3035/schedule/5/paragraph/20", + "https://www.legislation.gov.uk/wsi/2013/3035/schedule/7/paragraph/49", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/6", + "https://www.legislation.gov.uk/ssi/2012/319/schedule/3/paragraph/19", "https://www.legislation.gov.uk/uksi/2012/2885/schedule/4", "https://www.legislation.gov.uk/wsi/2013/3029/schedule/3", "https://www.legislation.gov.uk/ssi/2012/319/schedule/2", @@ -205,7 +223,46 @@ def formula(benunit, period, parameters): benunit, period, applicant_and_partner_benefits, applicant_and_partner ) credits = applicant_family * add(benunit, period, ["tax_credits"]) - increased_income = income + personal_benefit_income + credits + benefits + disregards = parameters( + period + ).gov.local_authorities.council_tax_reduction.income_disregards + # England's default working-age scheme Sch 8 paras 21 and 49; + # pensioners SI 2012/2885 Sch 5 paras 7 and 20. Wales WSI 2013/3029 + # Sch 9 paras 21/49 and Sch 4 paras 7/20 (default WSI 2013/3035 Schs + # 7 and 5). Scotland SSI 2021/249 reg 57(4), (6) and SSI 2012/319 + # Sch 3 paras 6/19. All aggregate the family's maintenance for one + # £15 disregard. The default English and Welsh family premiums are for families + # with a child or young person (SI 2012/2886 Sch para 26(1)(c); + # WSI 2013/3029 Sch 1 para 1(1)(c), Sch 6 para 1(1)(c)). + maintenance = in_great_britain * benunit.sum( + max_(0, person("spousal_maintenance_income", period)) + * applicant_and_partner + ) + has_child = applicant_family & benunit( + "is_responsible_for_child_or_young_person_for_council_tax_reduction", period + ) + maintenance_disregard = where( + has_child, + min_(maintenance, disregards.maintenance * WEEKS_IN_YEAR), + 0, + ) + widowed_parents_allowance = in_great_britain * benunit.sum( + max_( + 0, + person("widowed_parents_allowance", period) + - disregards.widowed_parents_allowance * WEEKS_IN_YEAR, + ) + * applicant_and_partner + ) + increased_income = ( + income + + personal_benefit_income + + credits + + benefits + + maintenance + - maintenance_disregard + + widowed_parents_allowance + ) if not bi.interactions.include_in_means_tests: increased_income -= add_for_members( @@ -252,7 +309,11 @@ def formula(benunit, period, parameters): savings_credit_only_income = max_( 0, benunit("pension_credit_income", period) - - benunit("pension_credit_war_pension_income", period), + - benunit("pension_credit_war_pension_income", period) + # PC already counts maintenance in full: apply only CTR's + # extra disregard (England Sch 1 para 14(2)(c)(ii); Wales Sch 1 + # para 8(2)(c)(ii); Scotland reg 25(2)(c)(ii)). + - maintenance_disregard, ) + benunit("pension_credit", period) return select( [in_receipt_of_guarantee_credit, has_savings_credit_only_award], 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 64458e2319..552973d3a9 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 @@ -8,6 +8,7 @@ class council_tax_reduction_individual_non_dep_deduction(Variable): 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" def formula(person, period, parameters): p = parameters(period).gov.dwp.housing_benefit.non_dep_deduction @@ -15,8 +16,12 @@ def formula(person, period, parameters): # household_benefits_individual_non_dep_deduction: the bands apply only # to a non-dependant in remunerative work (SI 2012/2885 Sch 1 para # 8(2)), which this formula does not yet test (#2007). - income = person("total_income", period) - person( - "other_investment_income", period + # Tax-exempt spousal maintenance is normal gross income, with no + # disregard in SI 2012/2885 Sch 1 para 8(9). + income = ( + person("total_income", period) + - person("other_investment_income", period) + + max_(0, person("spousal_maintenance_income", period)) ) weekly_total_income = income / WEEKS_IN_YEAR return p.amount.calc(weekly_total_income, right=True) * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/household/income/hbai_benefits.py b/policyengine_uk/variables/household/income/hbai_benefits.py index 0a0e0714ee..31cf41489a 100644 --- a/policyengine_uk/variables/household/income/hbai_benefits.py +++ b/policyengine_uk/variables/household/income/hbai_benefits.py @@ -23,6 +23,7 @@ class hbai_benefits(Variable): "afcs", "bsp", "war_widows_pension", + "widowed_parents_allowance", "carers_allowance", "dla", "iidb", diff --git a/policyengine_uk/variables/household/income/hbai_household_net_income.py b/policyengine_uk/variables/household/income/hbai_household_net_income.py index ff1a8913f3..a44113ce9d 100644 --- a/policyengine_uk/variables/household/income/hbai_household_net_income.py +++ b/policyengine_uk/variables/household/income/hbai_household_net_income.py @@ -16,6 +16,7 @@ "private_pension_income", "private_transfer_income", "maintenance_income", + "spousal_maintenance_income", "free_school_meals", "free_school_fruit_veg", "free_school_milk", @@ -36,6 +37,7 @@ "afcs", "bsp", "war_widows_pension", + "widowed_parents_allowance", "carers_allowance", "dla", "iidb", diff --git a/policyengine_uk/variables/household/income/household_benefits.py b/policyengine_uk/variables/household/income/household_benefits.py index a640a5eece..548f50e71c 100644 --- a/policyengine_uk/variables/household/income/household_benefits.py +++ b/policyengine_uk/variables/household/income/household_benefits.py @@ -18,6 +18,7 @@ "afcs", "bsp", "war_widows_pension", + "widowed_parents_allowance", "carers_allowance", "dla", "iidb", diff --git a/policyengine_uk/variables/household/income/household_market_income.py b/policyengine_uk/variables/household/income/household_market_income.py index 156abbf36b..6e2b6e0ccb 100644 --- a/policyengine_uk/variables/household/income/household_market_income.py +++ b/policyengine_uk/variables/household/income/household_market_income.py @@ -14,6 +14,7 @@ "private_pension_income", "private_transfer_income", "maintenance_income", + "spousal_maintenance_income", "capital_gains", ] diff --git a/policyengine_uk/variables/household/income/market_income.py b/policyengine_uk/variables/household/income/market_income.py index 7fa92da969..3fa0860879 100644 --- a/policyengine_uk/variables/household/income/market_income.py +++ b/policyengine_uk/variables/household/income/market_income.py @@ -23,6 +23,7 @@ def formula(person, period, parameters): "private_pension_income", "private_transfer_income", "maintenance_income", + "spousal_maintenance_income", ] income = add(person, period, INCOME_VARIABLES) return ( diff --git a/policyengine_uk/variables/input/consumption/liquid_fuel_consumption.py b/policyengine_uk/variables/input/consumption/liquid_fuel_consumption.py new file mode 100644 index 0000000000..4a08ab332e --- /dev/null +++ b/policyengine_uk/variables/input/consumption/liquid_fuel_consumption.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + +# COICOP 04.5.3 (liquid fuels). A sub-component of +# housing_water_and_electricity_consumption (COICOP 04), so not added into the +# `consumption` total, which already counts it. + + +class liquid_fuel_consumption(Variable): + label = "Liquid fuel consumption" + documentation = ( + "Annual household spending on liquid fuels such as heating oil and " + "liquefied petroleum gas (COICOP 04.5.3), mostly for heating homes off " + "the gas grid. Already included in " + "housing_water_and_electricity_consumption; do not add it to " + "consumption again. Dataset projection uprates it by CPI from the data " + "year, like other LCFS spending: Ofgem's price cap, which prices the " + "electricity and gas inputs, does not cover it." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = FLOW + uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" diff --git a/policyengine_uk/variables/input/consumption/property/pension_contributions_via_bonus_sacrifice.py b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_bonus_sacrifice.py new file mode 100644 index 0000000000..7fa48c4af1 --- /dev/null +++ b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_bonus_sacrifice.py @@ -0,0 +1,31 @@ +from policyengine_uk.model_api import * + + +class pension_contributions_via_bonus_sacrifice(Variable): + label = "Pension contributions via bonus sacrifice" + documentation = ( + "Annual employer pension contributions made in place of a bonus the " + "employee gives up. The tax and NICs rules treat bonus sacrifice as " + "salary sacrifice, and the NICs exemption cap covers the two together, " + "so every formula reads this input summed with " + "pension_contributions_via_salary_sacrifice. It is a separate input " + "because sources measure the two routes apart: ASHE, the source of " + "HMRC's salary sacrifice estimates, does not cover bonus sacrifice, and " + "the OBR costs the bonus route on its own tax base. Cash pay " + "(employment_income) is net of the sacrificed bonus." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = [ + dict( + title="OBR, Costing of charging NICs on salary-sacrificed pension contributions (February 2026), paragraphs 1.4, 1.5 and 1.9", + href="https://obr.uk/docs/dlm_uploads/Supplementary-forecast-information-salary-sacrifice-costing-1.pdf", + ), + dict( + title="Finance Act 2004 s. 228ZA(6): relevant flexible remuneration arrangements", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA", + ), + ] + uprating = "gov.economic_assumptions.indices.obr.average_earnings" diff --git a/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py index 86046c2325..3ecc85aba7 100644 --- a/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py +++ b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py @@ -6,8 +6,8 @@ class pension_contributions_via_salary_sacrifice_pre_2015(Variable): entity = Person label = "Pension salary sacrifice under arrangements before 9 July 2015" documentation = ( - "The annual portion of actual pension salary sacrifice attributable " - "to arrangements made before 9 July 2015. Excluded from the annual " + "The annual portion of actual pension salary or bonus sacrifice " + "attributable to arrangements made before 9 July 2015. Excluded from the annual " "allowance threshold-income addback, limited to full sacrifice before " "the NICs exemption cap. " "Defaults to zero because survey arrangement dates are unobserved." diff --git a/policyengine_uk/variables/input/consumption/solid_fuel_consumption.py b/policyengine_uk/variables/input/consumption/solid_fuel_consumption.py new file mode 100644 index 0000000000..302c3a72d5 --- /dev/null +++ b/policyengine_uk/variables/input/consumption/solid_fuel_consumption.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + +# COICOP 04.5.4 (solid fuels). A sub-component of +# housing_water_and_electricity_consumption (COICOP 04), so not added into the +# `consumption` total, which already counts it. + + +class solid_fuel_consumption(Variable): + label = "Solid fuel consumption" + documentation = ( + "Annual household spending on solid fuels such as coal, smokeless fuel " + "and wood (COICOP 04.5.4), mostly for heating homes off the gas grid. " + "Already included in housing_water_and_electricity_consumption; do not " + "add it to consumption again. Dataset projection uprates it by CPI from " + "the data year, like other LCFS spending: Ofgem's price cap, which " + "prices the electricity and gas inputs, does not cover it." + ) + entity = Household + definition_period = YEAR + value_type = float + unit = GBP + quantity_type = FLOW + uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" diff --git a/policyengine_uk/variables/input/maintenance_income.py b/policyengine_uk/variables/input/maintenance_income.py index 8c11192af1..dde47e3ff1 100644 --- a/policyengine_uk/variables/input/maintenance_income.py +++ b/policyengine_uk/variables/input/maintenance_income.py @@ -5,7 +5,12 @@ class maintenance_income(Variable): value_type = float entity = Person label = "maintenance payment income" - documentation = "Income from maintenance payments to you" + documentation = ( + "Child maintenance received. It counts as market and HBAI income but " + "in no means test. Maintenance from a spouse, civil partner, former " + "spouse or former civil partner is spousal_maintenance_income, which " + "the means tests count." + ) definition_period = YEAR unit = GBP uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp" diff --git a/policyengine_uk/variables/input/spousal_maintenance_income.py b/policyengine_uk/variables/input/spousal_maintenance_income.py new file mode 100644 index 0000000000..bcd20ed6c9 --- /dev/null +++ b/policyengine_uk/variables/input/spousal_maintenance_income.py @@ -0,0 +1,36 @@ +from policyengine_uk.model_api import * + + +class spousal_maintenance_income(Variable): + value_type = float + entity = Person + label = "spousal maintenance income" + documentation = ( + "Maintenance paid to this person by their spouse, civil partner, " + "former spouse or former civil partner, under a court order, an " + "agreement or voluntarily. Child maintenance is maintenance_income. " + "Unlike child maintenance, this counts as income for Universal " + "Credit, Pension Credit (but not as savings credit qualifying income), " + "Income Support, Council Tax Reduction and Housing Benefit, and it is " + "market and HBAI income as maintenance_income is. Universal Credit " + "counts payments under a court order or an agreement for maintenance " + "(reg. 66(1)(d)); voluntary payments are counted too, since survey " + "data cannot separate them and an informal arrangement may be an " + "agreement. Maintenance from a former cohabiting partner who was not " + "a spouse or civil partner does not belong here: Universal Credit and " + "Pension Credit do not count it. Working-age Housing Benefit does " + '(HB Regs 2006 Sch 5 para 47, "former partner"), which the model ' + "does not capture." + ) + definition_period = YEAR + unit = GBP + reference = ( + "https://www.legislation.gov.uk/uksi/2013/376/regulation/66", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/15", + "https://www.legislation.gov.uk/uksi/2002/1792/regulation/9", + "https://www.legislation.gov.uk/uksi/2006/214/regulation/29", + "https://www.legislation.gov.uk/uksi/2006/213/schedule/5", + "https://www.legislation.gov.uk/uksi/1987/1967/regulation/40", + "https://www.legislation.gov.uk/ssi/2021/249/regulation/57", + ) + uprating = "gov.economic_assumptions.indices.obr.per_capita.gdp"