|
| 1 | +"""Tests for the ONS Public Sector Employment calibration target. |
| 2 | +
|
| 3 | +The target constrains the simulated count of public-sector workers |
| 4 | +(`employment_sector == PUBLIC`) towards the official ONS Public Sector |
| 5 | +Employment (PSE) headcount. A 20% relative tolerance is accepted: the |
| 6 | +FRS self-reported sector over-counts public employment, so calibration |
| 7 | +only needs to bring the figure within a fifth of the official total. |
| 8 | +""" |
| 9 | + |
| 10 | +import pytest |
| 11 | + |
| 12 | +from policyengine_uk_data.datasets.frs_release import CURRENT_FRS_RELEASE |
| 13 | +from policyengine_uk_data.targets import get_all_targets |
| 14 | +from policyengine_uk_data.targets.build_loss_matrix import _resolve_value |
| 15 | +from policyengine_uk_data.targets.sources.ons_public_sector_employment import ( |
| 16 | + get_targets, |
| 17 | +) |
| 18 | + |
| 19 | +# Accepted error between the target *value* and the official ONS PSE figure |
| 20 | +# (a sanity check on the hardcoded target, not a calibration outcome). |
| 21 | +ACCEPTED_RELATIVE_ERROR = 0.20 |
| 22 | + |
| 23 | +# Tolerance for the simulated weighted total after data generation. The FRS |
| 24 | +# self-reported sector over-counts public employment (~7.9m vs ONS ~5.9m) and |
| 25 | +# the national calibration only partially pulls it in, so a loose tolerance is |
| 26 | +# used, in line with the other aggregate-vs-target tests (land value ~0.65-0.70, |
| 27 | +# spending aggregates ~0.70, vehicle ownership ~0.30). |
| 28 | +SIMULATED_RELATIVE_TOLERANCE = 0.50 |
| 29 | + |
| 30 | +# Official ONS Public Sector Employment, UK (headcount), by year. Held |
| 31 | +# independently of the source module so a wrong target value is caught. |
| 32 | +ONS_PSE_HEADCOUNT = { |
| 33 | + 2023: 5_900_000.0, |
| 34 | + 2024: 5_940_000.0, |
| 35 | +} |
| 36 | + |
| 37 | +# Years the enhanced FRS fixture can represent (mirrors land value tests). |
| 38 | +MODEL_CHECK_YEARS = sorted( |
| 39 | + { |
| 40 | + CURRENT_FRS_RELEASE.base_year, |
| 41 | + CURRENT_FRS_RELEASE.calibration_year, |
| 42 | + } |
| 43 | +) |
| 44 | + |
| 45 | + |
| 46 | +# ── Target structure ───────────────────────────────────────────────── |
| 47 | + |
| 48 | + |
| 49 | +def test_get_targets_returns_one(): |
| 50 | + """get_targets() should return the single public sector target.""" |
| 51 | + assert len(get_targets()) == 1 |
| 52 | + |
| 53 | + |
| 54 | +def test_target_variable_and_metadata(): |
| 55 | + """Target should count employment_sector from ONS.""" |
| 56 | + target = get_targets()[0] |
| 57 | + assert target.name == "ons/public_sector_employment" |
| 58 | + assert target.variable == "employment_sector" |
| 59 | + assert target.source == "ons" |
| 60 | + assert target.is_count |
| 61 | + |
| 62 | + |
| 63 | +def test_targets_in_registry(): |
| 64 | + """The target should appear in the global registry.""" |
| 65 | + names = {t.name for t in get_all_targets()} |
| 66 | + assert "ons/public_sector_employment" in names |
| 67 | + |
| 68 | + |
| 69 | +# ── Target values ──────────────────────────────────────────────────── |
| 70 | + |
| 71 | + |
| 72 | +def test_target_values_within_20pct_of_ons(): |
| 73 | + """Each target value is within the accepted 20% of the ONS PSE figure.""" |
| 74 | + values = get_targets()[0].values |
| 75 | + for year, official in ONS_PSE_HEADCOUNT.items(): |
| 76 | + assert year in values, f"missing target for {year}" |
| 77 | + rel_error = abs(values[year] / official - 1) |
| 78 | + assert rel_error <= ACCEPTED_RELATIVE_ERROR, ( |
| 79 | + f"{year} target {values[year]:,.0f} differs from ONS PSE " |
| 80 | + f"{official:,.0f} by {rel_error:.1%} (>20%)." |
| 81 | + ) |
| 82 | + |
| 83 | + |
| 84 | +# ── Simulated total after data generation ──────────────────────────── |
| 85 | + |
| 86 | + |
| 87 | +@pytest.mark.parametrize("year", MODEL_CHECK_YEARS, ids=map(str, MODEL_CHECK_YEARS)) |
| 88 | +def test_public_sector_employment_total(enhanced_frs, baseline, year): |
| 89 | + """Weighted public-sector total is within tolerance of the ONS PSE target. |
| 90 | +
|
| 91 | + Runs against the generated enhanced FRS, whose national calibration |
| 92 | + includes the public sector employment target. Skipped if the dataset |
| 93 | + predates the variable (rebuild with ``make data``). |
| 94 | + """ |
| 95 | + if "employment_sector" not in enhanced_frs.person.columns: |
| 96 | + pytest.skip("dataset predates employment_sector; rebuild with `make data`") |
| 97 | + |
| 98 | + target = _resolve_value(get_targets()[0], year) |
| 99 | + assert target is not None, f"no target value resolvable for {year}" |
| 100 | + |
| 101 | + weights = baseline.calculate("household_weight", period=year).values |
| 102 | + sector = baseline.calculate("employment_sector", period=year).values |
| 103 | + is_public = (sector == "PUBLIC").astype(float) |
| 104 | + estimate = (baseline.map_result(is_public, "person", "household") * weights).sum() |
| 105 | + |
| 106 | + rel_error = abs(estimate / target - 1) |
| 107 | + assert rel_error < SIMULATED_RELATIVE_TOLERANCE, ( |
| 108 | + f"public sector employment ({year}): expected {target:,.0f}, " |
| 109 | + f"got {estimate:,.0f} (relative error = {rel_error:.1%}, " |
| 110 | + f"tolerance = {SIMULATED_RELATIVE_TOLERANCE:.0%})" |
| 111 | + ) |
0 commit comments