diff --git a/changelog.d/slc-zero-targets.fixed.md b/changelog.d/slc-zero-targets.fixed.md new file mode 100644 index 00000000..6be47e8b --- /dev/null +++ b/changelog.d/slc-zero-targets.fixed.md @@ -0,0 +1 @@ +Preserve zero-valued SLC borrower targets so calibration can enforce explicit zero years. diff --git a/policyengine_uk_data/targets/sources/slc.py b/policyengine_uk_data/targets/sources/slc.py index 08689c41..97a865f4 100644 --- a/policyengine_uk_data/targets/sources/slc.py +++ b/policyengine_uk_data/targets/sources/slc.py @@ -51,6 +51,7 @@ }, "plan_5": { "above_threshold": { + 2025: 0, 2026: 35_000, 2027: 145_000, 2028: 390_000, @@ -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 diff --git a/policyengine_uk_data/tests/test_student_loan_targets.py b/policyengine_uk_data/tests/test_student_loan_targets.py index fc9858f3..406c6b6d 100644 --- a/policyengine_uk_data/tests/test_student_loan_targets.py +++ b/policyengine_uk_data/tests/test_student_loan_targets.py @@ -28,7 +28,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 @@ -120,6 +120,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 = ( + '" + ) + + 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 (