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/slc-zero-targets.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Preserve zero-valued SLC borrower targets so calibration can enforce explicit zero years.
1 change: 1 addition & 0 deletions changelog.d/student-loan-repayment-targets.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add 2025 Student Loans Company repayment amount calibration targets by UK country and by England repayment plan.
3 changes: 3 additions & 0 deletions policyengine_uk_data/targets/build_loss_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
compute_scotland_demographics,
compute_scotland_uc_child,
compute_scottish_child_payment,
compute_student_loan_repayment,
compute_student_loan_plan,
compute_student_loan_plan_liable,
compute_ss_contributions,
Expand Down Expand Up @@ -317,6 +318,8 @@ def _compute_column(target: Target, ctx: _SimContext, year: int) -> np.ndarray |
return compute_scottish_child_payment(target, ctx)

# Student loan plan borrower counts (SLC)
if name.startswith("slc/student_loan_repayment/"):
return compute_student_loan_repayment(target, ctx)
if name.startswith("slc/plan_") and "above_threshold" in name:
return compute_student_loan_plan(target, ctx)
if name.startswith("slc/plan_") and "liable" in name:
Expand Down
2 changes: 2 additions & 0 deletions policyengine_uk_data/targets/compute/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
compute_regional_land_value,
compute_savings_interest,
compute_scottish_child_payment,
compute_student_loan_repayment,
compute_student_loan_plan,
compute_student_loan_plan_liable,
compute_vehicles,
Expand All @@ -61,6 +62,7 @@
"compute_scotland_demographics",
"compute_scotland_uc_child",
"compute_scottish_child_payment",
"compute_student_loan_repayment",
"compute_student_loan_plan",
"compute_student_loan_plan_liable",
"compute_ss_contributions",
Expand Down
38 changes: 38 additions & 0 deletions policyengine_uk_data/targets/compute/other.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

import numpy as np

_STUDENT_LOAN_COUNTRIES = {
"england": "ENGLAND",
"scotland": "SCOTLAND",
"wales": "WALES",
"northern_ireland": "NORTHERN_IRELAND",
}

_STUDENT_LOAN_PLAN_MAP = {
"plan_1": "PLAN_1",
"plan_2": "PLAN_2",
"postgraduate": "POSTGRADUATE",
"plan_5": "PLAN_5",
}


def compute_vehicles(target, ctx) -> np.ndarray:
"""Compute vehicle ownership targets."""
Expand Down Expand Up @@ -88,3 +102,27 @@ def compute_student_loan_plan_liable(target, ctx) -> np.ndarray:
on_plan = (plan == plan_value) & (person_country == "ENGLAND")

return ctx.household_from_person(on_plan.astype(float))


def compute_student_loan_repayment(target, ctx) -> np.ndarray:
"""Compute SLC repayment amount targets by country and optional plan."""
parts = target.name.split("/")
if len(parts) < 3 or parts[:2] != ["slc", "student_loan_repayment"]:
return None

country = _STUDENT_LOAN_COUNTRIES.get(parts[2])
if country is None:
return None

person_country = ctx.sim.calculate("country", map_to="person").values
repayments = ctx.pe_person("student_loan_repayment")
mask = person_country == country

if len(parts) == 4:
plan_value = _STUDENT_LOAN_PLAN_MAP.get(parts[3])
if plan_value is None:
return None
plan = ctx.pe_person("student_loan_plan")
mask &= plan == plan_value

return ctx.household_from_person(repayments * mask)
3 changes: 2 additions & 1 deletion policyengine_uk_data/targets/sources/slc.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
},
"plan_5": {
"above_threshold": {
2025: 0,
2026: 35_000,
2027: 145_000,
2028: 390_000,
Expand Down Expand Up @@ -145,7 +146,7 @@ def parse_values(row, start_index, years):
if cell_idx >= len(row):
continue
value_text = row[cell_idx].get("text", "")
if value_text and value_text not in ("no data", "0"):
if value_text and value_text != "no data":
data[year] = int(value_text.replace(",", ""))
return data

Expand Down
100 changes: 100 additions & 0 deletions policyengine_uk_data/targets/sources/slc_repayments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Student Loans Company repayment amount calibration targets.

These are 2024-25 higher-education repayment amounts from the official
Student Loans Company publications, mapped to calendar year 2025 for the
2025 calibration build. We deliberately do not project these forward here;
the target resolver will carry the latest observed value forward for nearby
years when needed.
"""

from policyengine_uk_data.targets.schema import GeographicLevel, Target, Unit

_ENGLAND_TABLES_URL = (
"https://assets.publishing.service.gov.uk/media/"
"6943ee619273c48f554cf5c5/slcsp012025_Corrected.xlsx"
)
_SCOTLAND_URL = (
"https://www.gov.uk/government/statistics/"
"student-loans-in-scotland-2024-to-2025/"
"student-loans-for-higher-education-in-scotland-financial-year-2024-25"
)
_WALES_URL = (
"https://www.gov.uk/government/statistics/"
"student-loans-in-wales-2024-to-2025/"
"student-loans-for-higher-education-in-wales-financial-year-2024-25"
)
_NORTHERN_IRELAND_URL = (
"https://www.gov.uk/government/statistics/"
"student-loans-in-northern-ireland-2024-to-2025/"
"student-loans-for-higher-education-in-northern-ireland-financial-year-2024-25"
)

# England values come from Table 1A of the official corrected workbook for
# financial year 2024-25, using the exact £m entries converted to pounds.
_TARGETS_2025 = {
"slc/student_loan_repayment/england": (
5_018_231_834.95,
_ENGLAND_TABLES_URL,
GeographicLevel.COUNTRY,
"ENGLAND",
),
"slc/student_loan_repayment/england/plan_1": (
1_852_699_178.55,
_ENGLAND_TABLES_URL,
GeographicLevel.COUNTRY,
"ENGLAND",
),
"slc/student_loan_repayment/england/plan_2": (
2_778_253_361.64,
_ENGLAND_TABLES_URL,
GeographicLevel.COUNTRY,
"ENGLAND",
),
"slc/student_loan_repayment/england/postgraduate": (
346_409_713.95,
_ENGLAND_TABLES_URL,
GeographicLevel.COUNTRY,
"ENGLAND",
),
"slc/student_loan_repayment/england/plan_5": (
40_869_580.81,
_ENGLAND_TABLES_URL,
GeographicLevel.COUNTRY,
"ENGLAND",
),
"slc/student_loan_repayment/scotland": (
203_300_000,
_SCOTLAND_URL,
GeographicLevel.COUNTRY,
"SCOTLAND",
),
"slc/student_loan_repayment/wales": (
229_100_000,
_WALES_URL,
GeographicLevel.COUNTRY,
"WALES",
),
"slc/student_loan_repayment/northern_ireland": (
181_700_000,
_NORTHERN_IRELAND_URL,
GeographicLevel.COUNTRY,
"NORTHERN_IRELAND",
),
}


def get_targets() -> list[Target]:
"""Return SLC repayment amount targets."""
return [
Target(
name=name,
variable="student_loan_repayment",
source="slc",
unit=Unit.GBP,
geographic_level=level,
geo_code=geo_code,
values={2025: value},
reference_url=reference_url,
)
for name, (value, reference_url, level, geo_code) in _TARGETS_2025.items()
]
156 changes: 155 additions & 1 deletion policyengine_uk_data/tests/test_student_loan_targets.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ def test_slc_targets_registered():
assert "slc/plan_5_borrowers_above_threshold" in targets
assert "slc/plan_2_borrowers_liable" in targets
assert "slc/plan_5_borrowers_liable" in targets
assert "slc/student_loan_repayment/england" in targets
assert "slc/student_loan_repayment/scotland" in targets
assert "slc/student_loan_repayment/england/plan_2" in targets


def test_slc_snapshot_values_match_higher_education_total_rows():
Expand All @@ -28,7 +31,7 @@ def test_slc_snapshot_values_match_higher_education_total_rows():
assert targets["slc/plan_2_borrowers_liable"].values[2025] == 8_940_000
assert targets["slc/plan_2_borrowers_liable"].values[2030] == 10_525_000

assert 2025 not in targets["slc/plan_5_borrowers_above_threshold"].values
assert targets["slc/plan_5_borrowers_above_threshold"].values[2025] == 0
assert targets["slc/plan_5_borrowers_above_threshold"].values[2026] == 35_000
assert targets["slc/plan_5_borrowers_above_threshold"].values[2030] == 1_235_000
assert targets["slc/plan_5_borrowers_liable"].values[2025] == 10_000
Expand All @@ -46,6 +49,54 @@ def test_liable_targets_exceed_above_threshold_targets():
assert targets["slc/plan_5_borrowers_liable"].values[year] > count


def test_slc_repayment_targets_match_official_2025_values():
"""Repayment amount targets should match the official 2024-25 releases."""
from policyengine_uk_data.targets.registry import get_all_targets

targets = {t.name: t for t in get_all_targets()}

assert (
targets["slc/student_loan_repayment/england"].values[2025] == 5_018_231_834.95
)
assert (
targets["slc/student_loan_repayment/england/plan_1"].values[2025]
== 1_852_699_178.55
)
assert (
targets["slc/student_loan_repayment/england/plan_2"].values[2025]
== 2_778_253_361.64
)
assert (
targets["slc/student_loan_repayment/england/postgraduate"].values[2025]
== 346_409_713.95
)
assert (
targets["slc/student_loan_repayment/england/plan_5"].values[2025]
== 40_869_580.81
)
assert targets["slc/student_loan_repayment/scotland"].values[2025] == 203_300_000
assert targets["slc/student_loan_repayment/wales"].values[2025] == 229_100_000
assert (
targets["slc/student_loan_repayment/northern_ireland"].values[2025]
== 181_700_000
)


def test_slc_england_plan_repayments_sum_to_england_total():
"""England plan-level repayment targets should reconcile to the total."""
from policyengine_uk_data.targets.registry import get_all_targets

targets = {t.name: t for t in get_all_targets()}
england_total = targets["slc/student_loan_repayment/england"].values[2025]
england_plans = (
targets["slc/student_loan_repayment/england/plan_1"].values[2025]
+ targets["slc/student_loan_repayment/england/plan_2"].values[2025]
+ targets["slc/student_loan_repayment/england/postgraduate"].values[2025]
+ targets["slc/student_loan_repayment/england/plan_5"].values[2025]
)
assert england_plans == england_total


def test_slc_testing_mode_uses_snapshot_without_network(monkeypatch):
"""Dataset-build CI should not depend on a live SLC endpoint."""
from policyengine_uk_data.targets.sources import slc
Expand Down Expand Up @@ -120,6 +171,53 @@ def raise_for_status():
slc._fetch_slc_data.cache_clear()


def test_slc_parser_preserves_zero_value_years(monkeypatch):
"""A literal zero should remain a real target year, not be dropped."""
from policyengine_uk_data.targets.sources import slc

table_json = {
"thead": [
[],
[{"text": "2024-25"}] * 6 + [{"text": "2024-25"}] * 6,
],
"tbody": [
[{"text": "Higher education total"}, {"text": "liable"}]
+ [{"text": "8,940,000"}] * 6
+ [{"text": "10,000"}] * 6,
[
{
"text": "Number of borrowers liable to repay and earning above repayment threshold"
}
]
+ [{"text": "3,985,000"}] * 6
+ [{"text": "0"}] * 6,
],
}
html = (
'<script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(
{"props": {"pageProps": {"data": {"table": {"json": table_json}}}}}
)
+ "</script>"
)

class DummyResponse:
text = html

@staticmethod
def raise_for_status():
return None

slc._fetch_slc_data.cache_clear()
monkeypatch.delenv("TESTING", raising=False)
monkeypatch.setattr(slc.requests, "get", lambda *args, **kwargs: DummyResponse())

data = slc._fetch_slc_data()
assert data["plan_5"]["above_threshold"][2025] == 0

slc._fetch_slc_data.cache_clear()


def test_student_loan_target_compute_distinguishes_liable_from_repaying():
"""Above-threshold counts should require repayments, while liable counts should not."""
from policyengine_uk_data.targets.compute.other import (
Expand Down Expand Up @@ -162,3 +260,59 @@ def household_from_person(values):

assert above_threshold.tolist() == [1.0, 0.0, 0.0, 0.0]
assert liable.tolist() == [1.0, 1.0, 0.0, 0.0]


def test_student_loan_repayment_target_compute_filters_country_and_plan():
"""Repayment amount targets should filter on modeled plan and country."""
from policyengine_uk_data.targets.compute.other import (
compute_student_loan_repayment,
)

class DummyCtx:
class sim:
@staticmethod
def calculate(variable, map_to=None):
if variable == "country" and map_to == "person":
return SimpleNamespace(
values=np.array(
[
"ENGLAND",
"ENGLAND",
"SCOTLAND",
"ENGLAND",
"WALES",
]
)
)
raise AssertionError(f"Unexpected calculate call: {variable}, {map_to}")

@staticmethod
def pe_person(variable):
values = {
"student_loan_plan": np.array(
["PLAN_1", "PLAN_2", "PLAN_4", "POSTGRADUATE", "PLAN_1"]
),
"student_loan_repayment": np.array([100.0, 200.0, 300.0, 400.0, 500.0]),
}
return values[variable]

@staticmethod
def household_from_person(values):
return values

england_total = compute_student_loan_repayment(
SimpleNamespace(name="slc/student_loan_repayment/england"),
DummyCtx(),
)
england_plan_2 = compute_student_loan_repayment(
SimpleNamespace(name="slc/student_loan_repayment/england/plan_2"),
DummyCtx(),
)
scotland_total = compute_student_loan_repayment(
SimpleNamespace(name="slc/student_loan_repayment/scotland"),
DummyCtx(),
)

assert england_total.tolist() == [100.0, 200.0, 0.0, 400.0, 0.0]
assert england_plan_2.tolist() == [0.0, 200.0, 0.0, 0.0, 0.0]
assert scotland_total.tolist() == [0.0, 0.0, 300.0, 0.0, 0.0]
Loading