Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/fix-2573-council-tax-reduction.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions changelog.d/fix-2573-income-support.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Count the claimant's and partner's spousal maintenance in Income Support while continuing to disregard child maintenance.
1 change: 1 addition & 0 deletions changelog.d/fix-2573-non-dependants.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Included spousal maintenance in Housing Benefit and Council Tax Reduction non-dependant income bands while preserving its income-tax exemption.
1 change: 1 addition & 0 deletions changelog.d/fix-2573-student-loans.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions docs/book/assumptions/growthfactors.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,24 @@ 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. 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).
Expand Down
58 changes: 41 additions & 17 deletions policyengine_uk/data/economic_assumptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,13 +544,20 @@ def uprate_student_loan_plans(

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 holders from the 2023-and-later intakes

For (2) and (3), we use highest_education == TERTIARY as the signal
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)
Expand Down Expand Up @@ -579,8 +586,26 @@ def uprate_student_loan_plans(

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()
Expand All @@ -589,10 +614,10 @@ def uprate_student_loan_plans(
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 the masked people whose draw is below prob."""
sampled = mask & (draws < prob)
new_plan[sampled] = plan_value
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 ===
Expand All @@ -611,7 +636,7 @@ 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 ===
# === 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' Plan 1 intakes reach older ages, up
# to the write-off.
Expand All @@ -626,9 +651,9 @@ def assign_with_probability(mask, plan_value, prob=_GRADUATE_LOAN_TAKE_UP):
& (uni_start_year < _PLAN2_FIRST_UNI_START)
& (uni_start_year + _PLAN1_WRITEOFF_YEARS > year)
)
assign_with_probability(plan1_new_cohort, "PLAN_1")
assign_with_probability(plan1_new_cohort)

# === Step 3: Add Plan 2 holders in extended age bands ===
# === 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
Expand All @@ -641,20 +666,19 @@ def assign_with_probability(mask, plan_value, prob=_GRADUATE_LOAN_TAKE_UP):
& (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+.
# === Step 4: Add holders from the 2023-and-later intakes ===
# Plan 5 is for England only. Other funding nations retain their plans.
# Eligible: tertiary-educated NONE people who would have started uni 2023+.
# Age constraint: must be 21+ (finished 3-year degree) to be repaying.
plan5_eligible = (
new_intake_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(new_intake_eligible)

# Write back to the person table (without the merged region column).
person_out = current_year.person.copy()
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
184 changes: 184 additions & 0 deletions policyengine_uk/tests/test_council_tax_reduction_maintenance_wpa.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""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": benunit},
"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)
Loading
Loading