Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
108 commits
Select commit Hold shift + click to select a range
ff0b130
Stop targeting OBR total NICs on ni_employee
MaxGhenis Oct 5, 2026
64d07fc
Test OBR target variables and shared-column agreement
MaxGhenis Oct 5, 2026
ca87af4
Map the 2023-24 SPI and CGT outturns to PolicyEngine year 2023
MaxGhenis Oct 10, 2026
a608ddb
Calibrate SPI income bands to their projected values
MaxGhenis Oct 10, 2026
ff5c998
Read approved training from the FRS TRAINEE flag
MaxGhenis Oct 10, 2026
c952694
Flag approved training for FRS dependants only
MaxGhenis Oct 11, 2026
98b2583
State the TRAINEE pattern as observed, and fix the adult fixture's age
MaxGhenis Oct 11, 2026
47fa84e
Serve the SPI merge test the committed table, and fix the sampling date
MaxGhenis Oct 11, 2026
d071ad0
Start capital gains on HMRC's 2024-25 size-of-gain table
MaxGhenis Oct 12, 2026
bb5bda3
Impute capital gains after the other imputations
MaxGhenis Oct 12, 2026
dd3e2ab
Merge remote-tracking branch 'origin/main' into fix/obr-ni-total-target
MaxGhenis Oct 12, 2026
22ef313
Drop obr/ni from the fallback test set; key conflicts on countries
MaxGhenis Oct 12, 2026
4d30fbe
Address Sol review r1 nits on the OBR NIC comment and conflict check
MaxGhenis Oct 12, 2026
f5de403
Compare the reference pairs in every year their column lists
MaxGhenis Oct 12, 2026
6c81e89
Add locked pytest-xdist development dependency
MaxGhenis Oct 15, 2026
248688c
Fix local UC target alignment by area code
MaxGhenis Oct 15, 2026
c1953c6
Build OBR targets only from hash-verified EFO workbooks
MaxGhenis Oct 15, 2026
677f803
Save the calibrated weights from the final optimiser step
MaxGhenis Oct 15, 2026
39e652a
Fail the calibration build on targets it drops or cannot fit
MaxGhenis Oct 15, 2026
018e0b5
Compare each pull request's calibration fit with its base build's
MaxGhenis Oct 15, 2026
98ad844
Bind EFO vintages to their workbook titles; refuse rows with their ow…
MaxGhenis Oct 15, 2026
594c044
Measure each build's HBAI distribution and single ages against its base
MaxGhenis Oct 15, 2026
eb247c6
List the three UC payment bands with no signal in pull request builds
MaxGhenis Oct 15, 2026
0cf62aa
Read Scottish council tax targets from a hash-verified committed work…
MaxGhenis Oct 15, 2026
b4ec4b5
Compare each build's national aggregates with its base's, model fixed
MaxGhenis Oct 15, 2026
2fb5cfa
Record each browser download's time, and rehearse ingest with --dry-run
MaxGhenis Oct 15, 2026
b6c83c4
Measure HBAI incomes as DWP does; fail on incomplete reports
MaxGhenis Oct 15, 2026
9d37844
Merge branch 'w3-distribution-checks' into w3-aggregate-data-comparison
MaxGhenis Oct 15, 2026
1d5a249
Pin the aggregate-impact tool to policyengine-uk#2425's model-fingerp…
MaxGhenis Oct 15, 2026
22e823f
Compare fit summaries, not raw calibration logs (Sol review of #599)
MaxGhenis Oct 15, 2026
4bf9cd8
Merge branch 'w3-fit-comparison' into w3-distribution-checks
MaxGhenis Oct 15, 2026
48f88ad
An empty HBAI group has no rate, not a NaN
MaxGhenis Oct 15, 2026
564f370
Format calibrate.py and say only the fit summary leaves the base build
MaxGhenis Oct 15, 2026
756cfe6
Merge branch 'w3-fit-comparison' into w3-distribution-checks
MaxGhenis Oct 15, 2026
80a5715
Merge remote-tracking branch 'origin/w3-distribution-checks' into w3-…
MaxGhenis Oct 15, 2026
ddef8bb
Stop uploading raw calibration logs as public Actions artifacts
MaxGhenis Oct 15, 2026
b957157
Describe the fit summary in the changelog fragment
MaxGhenis Oct 15, 2026
342ea79
Say the flag evidence predates the records filter
MaxGhenis Oct 15, 2026
da4acc7
Merge branch 'w3-fit-comparison' into w3-distribution-checks
MaxGhenis Oct 15, 2026
4219c79
Merge remote-tracking branch 'origin/w3-distribution-checks' into w3-…
MaxGhenis Oct 15, 2026
e42d23d
Guard every route out of a job that can reach the data (Sol review of…
MaxGhenis Oct 15, 2026
f1faff2
Harden OBR ingest for Budget day (Sol re-review of #592)
MaxGhenis Oct 15, 2026
61f8f7e
Match ONS age targets to the FRS top code of 80
MaxGhenis Oct 15, 2026
449a519
Close three bypasses in the data-job guard (Sol re-review of #603)
MaxGhenis Oct 15, 2026
9f50a95
Publish only which targets are within 10% (Sol re-review of #599)
MaxGhenis Oct 15, 2026
2f0ed39
Merge branch 'w3-fit-comparison' into w3-distribution-checks
MaxGhenis Oct 15, 2026
512e85e
Measure the distribution after the tests and report a crashed compari…
MaxGhenis Oct 15, 2026
bed48ad
Merge branch 'w3-distribution-checks' into w3-aggregate-data-comparison
MaxGhenis Oct 15, 2026
2c95697
Say which log columns describe the saved weights
MaxGhenis Oct 15, 2026
e13f22b
Test the final-state invariant as a property over epochs and loops
MaxGhenis Oct 15, 2026
8374b30
Stop data jobs saving the uv cache, and widen the data-job checks (So…
MaxGhenis Oct 15, 2026
30fbb83
Publish error bands and flag a worse band (Sol re-review 2 of #599)
MaxGhenis Oct 15, 2026
88e6624
Merge #599's band rework; keep the test job about the tests (delta re…
MaxGhenis Oct 15, 2026
ca9c0ab
Merge #600; report a crashed aggregate comparison and keep the base a…
MaxGhenis Oct 15, 2026
13e8eb2
Pin the aggregate-impact tool to policyengine-uk#2425's c0d0c49d
MaxGhenis Oct 15, 2026
3991f69
Report dry-run refusals, record download-time basis, tighten OBR checks
MaxGhenis Oct 15, 2026
c41f10c
Recognise secrets in any case and write-all; let the versioning job r…
MaxGhenis Oct 16, 2026
79ffd76
Don't let a shell or environment change what the versioning job runs …
MaxGhenis Oct 16, 2026
e9fff87
Block a step working-directory and more curl header spellings (Sol re…
MaxGhenis Oct 16, 2026
29ca754
Fix national age-band tests for open-ended 80+ targets
MaxGhenis Oct 16, 2026
dc81b55
Put an estimate exactly on a band edge inside it (delta review of #599)
MaxGhenis Oct 16, 2026
07a8ea7
Merge branch 'w3-fit-comparison' into w3-distribution-checks
MaxGhenis Oct 16, 2026
45d4742
Merge #599; test the fit summary's and the base's step order (delta r…
MaxGhenis Oct 16, 2026
3eec058
Merge branch 'w3-distribution-checks' into w3-aggregate-data-comparison
MaxGhenis Oct 16, 2026
15426b7
Merge #600; pin the tool to #2425's 5d5558ed and test the aggregate w…
MaxGhenis Oct 16, 2026
159db44
Show the own-unit sub-heading test reads the heading; rewrap two docs…
MaxGhenis Oct 16, 2026
cac704b
Pin a non-verbose example of the final-weights property (delta review…
MaxGhenis Oct 16, 2026
133632a
Give the download pages in the documented ingest command
MaxGhenis Oct 16, 2026
a590573
Report duplicate target names, scope known gaps to a build kind, and …
MaxGhenis Oct 16, 2026
7313f8b
Count any curl or wget in a data job as a route out (delta review of …
MaxGhenis Oct 16, 2026
5881ce3
Rewrap the docstring line the curl change ran long (nit 1, delta revi…
MaxGhenis Oct 16, 2026
ff643be
Revert "Add locked pytest-xdist development dependency"
MaxGhenis Oct 16, 2026
0822699
Pin the aggregate tool to #2425's approved head 6406b2ce
MaxGhenis Oct 16, 2026
26e51a2
Test mapped Scottish constituency target rows
MaxGhenis Oct 16, 2026
a2eedce
Check local income and age source code ordering
MaxGhenis Oct 16, 2026
fa4ebe9
Merge origin/main and regenerate SPI projections from the 2023 outturn
MaxGhenis Oct 16, 2026
c9229ee
Refresh SPI changelog after integrating current uprating factors
MaxGhenis Oct 16, 2026
0b0325c
Conserve capital gains pair priors through calibration
MaxGhenis Oct 16, 2026
3a3ee3c
Cover national prior raking and clarify CGT assumptions
MaxGhenis Oct 16, 2026
77b6527
Merge origin/main into fix/cgt-start-on-hmrc-bands
MaxGhenis Oct 16, 2026
4840083
Defer CGT target year changes to the follow-up after #571
MaxGhenis Oct 16, 2026
473adaf
Describe the SPI-only calibration fix and defer CGT follow-up
MaxGhenis Oct 16, 2026
d5555a5
Merge origin/main into w3-target-coverage (integration with #604 and …
MaxGhenis Oct 16, 2026
ef65f75
Merge origin/fix/593-age-topcode into w3-target-coverage (integration…
MaxGhenis Oct 16, 2026
45964bc
Merge origin/fix/spi-cgt-tax-year-start into w3-target-coverage (inte…
MaxGhenis Oct 16, 2026
6e03d4c
Drop the gaps #557 and #604 close (integration with both)
MaxGhenis Oct 16, 2026
0785612
Bring known_gaps.yaml's header counts up to the integrated inventory …
MaxGhenis Oct 16, 2026
64d50f7
Merge #604 (Match ONS age targets to the FRS top code of 80) at 29ca7…
MaxGhenis Oct 17, 2026
dcd648b
Merge #557 (Calibrate SPI income bands to their projected values, and…
MaxGhenis Oct 17, 2026
842cb4d
Merge #595 (Fail the calibration build on targets it drops or cannot …
MaxGhenis Oct 17, 2026
da068a0
Merge #571 (Start capital gains on HMRC's 2024-25 size-of-gain table)…
MaxGhenis Oct 17, 2026
1e74b61
Merge #589 (Fix local UC target alignment by area code) at a2eedce44e…
MaxGhenis Oct 17, 2026
5ac126d
Merge #537 (Stop calibrating OBR total NICs on ni_employee) at f5de40…
MaxGhenis Oct 17, 2026
56ec29b
Merge #561 (Read approved training from the FRS TRAINEE flag for depe…
MaxGhenis Oct 17, 2026
4c0f72e
Merge #592 (Build OBR targets only from hash-verified EFO workbooks) …
MaxGhenis Oct 17, 2026
9e01ec9
Merge #594 (Save the calibrated weights from the final optimiser step…
MaxGhenis Oct 17, 2026
bb663de
Merge #599 (Compare each pull request's calibration fit with its base…
MaxGhenis Oct 17, 2026
4c4082e
Merge #600 (Measure each build's HBAI distribution and single ages ag…
MaxGhenis Oct 17, 2026
d61a30e
Merge #601 (Read Scottish council tax targets from a hash-verified co…
MaxGhenis Oct 17, 2026
efab491
Merge #602 (Compare each build's national aggregates with its base's,…
MaxGhenis Oct 17, 2026
006d4a1
Merge #603 (Stop uploading raw calibration logs as public Actions art…
MaxGhenis Oct 17, 2026
a52dd83
Fix release integration formatting and lock package version
MaxGhenis Oct 17, 2026
21d5dbf
Reconcile target selection and OBR tests across release members
MaxGhenis Oct 17, 2026
18c9066
Revert "Merge #571 (Start capital gains on HMRC's 2024-25 size-of-gai…
Oct 18, 2026
8f91762
Seed the calibration weight dropout
MaxGhenis Oct 1, 2026
2e2fde5
Diagnostic only: build the head and its fit summary, skip tests, base…
Oct 18, 2026
2fdafa5
Diagnostic only: production-setting head build (TESTING=0, 512 epochs)
Oct 19, 2026
0b44588
Diagnostic only: excuse the three release-build zero-signal UC bands …
Oct 19, 2026
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
331 changes: 318 additions & 13 deletions .github/workflows/pull_request.yaml

Large diffs are not rendered by default.

20 changes: 9 additions & 11 deletions .github/workflows/push.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ jobs:
fetch-depth: 0 # Fetch all history for all tags and branches
- name: Install uv
uses: astral-sh/setup-uv@v8.1.0
# Restore uv's package cache but never save one from a job that
# holds the data: its location can be moved (UV_CACHE_DIR).
with:
save-cache: false
- name: Set up Python
uses: actions/setup-python@v6
with:
Expand All @@ -139,17 +143,11 @@ jobs:
HUGGING_FACE_TOKEN: ${{ secrets.HUGGING_FACE_TOKEN }}
- name: Build datasets
run: uv run --frozen make data
- name: Save calibration log (constituencies)
uses: actions/upload-artifact@v7
with:
name: constituency_calibration_log.csv
path: constituency_calibration_log.csv

- name: Save calibration log (local authorities)
uses: actions/upload-artifact@v7
with:
name: la_calibration_log.csv
path: la_calibration_log.csv
# The raw calibration logs stay on the runner. They hold every target's
# weighted estimate at every logged epoch with no small-cell control, so a
# target supported by a few households, divided by a published count,
# could give one household's value back, and this repository's
# artifacts are public.
- name: Run tests
run: uv run --frozen make test
- name: Upload data
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
!policyengine_uk_data/datasets/local_areas/**/*.csv
**/_build
!policyengine_uk_data/storage/*.csv
!policyengine_uk_data/tests/fixtures/ons_uk_population_2025.csv
**/version.json
# Build output: household weights by local area, derived from FRS microdata
policyengine_uk_data/storage/local_geography_weights.csv.gz
Expand All @@ -26,3 +27,6 @@ policyengine_uk_data/storage/local_geography_weights.csv.gz

# Hypothesis example database (property-based tests)
.hypothesis/

# Local build-job notes, caches and hand-off artefacts
.job/
10 changes: 10 additions & 0 deletions changelog.d/468.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Align local Universal Credit targets by area code, preserve constituencies' 2024 boundaries, and use each matched constituency's country for its children split.

Restore the correct 2024 code order for the mapped income, age and housing targets of all 57 Scottish constituencies. This is a pure reorder; Scotland's totals are unchanged. Target moves from the previous positional assignment are:

| Target | Rows moving >5% | Rows moving >25% | Maximum move |
|---|---:|---:|---:|
| Age population | 44 | 11 | 269.7% |
| Housing anchor | 44 | 11 | 269.7% |
| Employment income amount | 53 | 26 | 245% |
| Self-employment income amount | 53 | 37 | 799% |
1 change: 1 addition & 0 deletions changelog.d/593-age-topcode.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Matched national and regional ONS age targets to the FRS top code with open-ended 80+ bands, including the projection's oldest age groups.
1 change: 1 addition & 0 deletions changelog.d/aggregate-data-comparison.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Compare each pull request build's national aggregates (receipts, spending, caseloads, poverty, deciles and the standard reform costings) with its base build's, measured with the locked model by policyengine-uk's aggregate-impact tool, and report them in the pull request comment.
1 change: 1 addition & 0 deletions changelog.d/deterministic-calibration.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Seed the weight dropout in `calibrate_local_areas` with its own generator (`seed`, default 0). The dropout masks were drawn from torch's global generator, which torch seeds differently in every process, so two builds of the same commit with the same inputs gave different household weights and aggregates.
1 change: 1 addition & 0 deletions changelog.d/distribution-checks.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Measure each pull request build's HBAI income distribution (medians, means, Gini and relative low income by group against DWP's HBAI FYE 2025 estimates and intervals) and single-age population (against the ONS 2024-based projection), and report them against the base build in the pull request comment. They flag but do not fail yet.
1 change: 1 addition & 0 deletions changelog.d/final-calibration-weights.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Save and return the calibrated weights from the final optimiser step. Weights were saved only every tenth epoch, so a 512-epoch build shipped the state after epoch 510 of 0–511 and dropped the last update (#456).
1 change: 1 addition & 0 deletions changelog.d/fit-comparison.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Build each pull request's base the same way as its head, with the head's lockfile, and fail when the share of national or local targets within 10% falls by more than 2 percentage points over the targets both builds have (`targets/fit_policy.yaml`). National targets in a worse error band on the head are flagged in a pull request comment. The raw calibration logs stay on the runner; only each target's error band (within 10%, 10-25%, 25-50%, 50-100%, over 100%) is uploaded, with no estimate or error (`python -m policyengine_uk_data.utils.fit_report summarise` and `compare`).
1 change: 1 addition & 0 deletions changelog.d/frs-approved-training-trainee.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Code FRS dependants in unwaged government training as in approved training, from the TRAINEE flag on child-table records. The build read `train`, which is the question name of `TRAIN2` and not a column, so nobody was in approved training. In FRS 2024-25 that left all 20 dependants aged 16 to 19 outside full-time education (33.0k grossed, every one a trainee) failing the education-or-training condition of the UC, Pension Credit, Child Benefit and Child Tax Credit qualifying-young-person tests.
1 change: 1 addition & 0 deletions changelog.d/obr-ni-total-target.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Stop calibrating OBR total NICs (Table 3.8, £200bn in 2025-26) on `ni_employee`, which the Class 1 employee NICs target (£50bn) also used; the two targets on one matrix column could not both be met. The Table 3.4 class targets (employee, employer, self-employed) already cover every NIC class the data populates. Tests now pin the variable of each OBR receipts target and check that no two calibration targets sharing a plain-variable column disagree on a year's value.
1 change: 1 addition & 0 deletions changelog.d/obr-source-manifest.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Build the OBR targets only from the committed EFO workbooks named, with their SHA-256 and title, in a vintage manifest (`targets/source_manifests/`). Read each table's financial years from its header row rather than fixed column letters, and fail the build when a manifest target or year is missing instead of logging and dropping it. Add `python -m policyengine_uk_data.targets.obr_sources ingest` for adopting a new EFO vintage, which prints every target's change; its `--dry-run` reports, and exits non-zero on, an ingested vintage, an interrupted ingest's leftovers or a download page off obr.uk, which the real ingest refuses.
1 change: 1 addition & 0 deletions changelog.d/scotland-council-tax-workbook.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Read the Scottish council tax band targets from the committed September 2025 chargeable-dwellings workbook, verified by its SHA-256, instead of downloading it and substituting a fixed distribution when gov.scot blocked the download (#596).
1 change: 1 addition & 0 deletions changelog.d/spi-tax-year-start.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Map the HMRC Survey of Personal Incomes 2023-24 outturn to PolicyEngine year 2023, the tax year it describes, instead of 2024. Return each SPI income band as one target that holds both the base year and the projected years, so calibration uses the projected values rather than the base-year outturn carried forward. Regenerate the SPI projections from the corrected base year with the current uprating factors. Defer the CGT year mapping and aggregate/band feasibility to a follow-up on top of #571.
1 change: 1 addition & 0 deletions changelog.d/stop-raw-calibration-log-artifacts.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Pull request and push builds no longer upload the raw constituency and local authority calibration logs as Actions artifacts, which are public on this repository; the logs stay on the runner.
1 change: 1 addition & 0 deletions changelog.d/target-coverage-gate.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fail the calibration build when a national target is dropped (its name already taken, no value for the year, no column, an error), has a non-finite value, or enters with no household contributing, unless `targets/known_gaps.yaml` lists it with a reason and an issue, for every build or only for testing or release builds. Add `python -m policyengine_uk_data.targets.coverage` to report every target's status on a dataset.
64 changes: 39 additions & 25 deletions policyengine_uk_data/calibration/matrix_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,22 @@ def _load_area_targets(
get_uk_total_population,
)

y = pd.DataFrame()
time_period = dataset.time_period

# ── Income targets ──────────────────────────────────────────────
if area_type == "constituency":
incomes = get_constituency_income_targets()
age_targets = get_constituency_age_targets()
else:
incomes = get_la_income_targets()
age_targets = get_la_age_targets()

# SPI income and age inputs share this source order. Keep the codes
# through any boundary mapping before selecting the requested rows.
assert incomes["code"].equals(age_targets["code"]), (
"Income and age code order differs"
)
y = pd.DataFrame(index=pd.Index(age_targets["code"], name="code"))

national_incomes = get_national_income_projections(int(time_period))

Expand All @@ -215,11 +223,6 @@ def _load_area_targets(
)

# ── Age targets ─────────────────────────────────────────────────
if area_type == "constituency":
age_targets = get_constituency_age_targets()
else:
age_targets = get_la_age_targets()

uk_total_population = get_uk_total_population(int(time_period))
age_cols = [c for c in age_targets.columns if c.startswith("age/")]

Expand All @@ -231,31 +234,40 @@ def _load_area_targets(
for col in age_cols:
y[col] *= uk_total_population / targets_total_pop * 0.9

# ── Boundary mapping (constituency 2010 → 2024 only) ───────────
if area_type == "constituency":
from policyengine_uk_data.datasets.local_areas.constituencies.boundary_changes.mapping_matrix import (
mapping_matrix,
old_codes,
new_codes,
)

y = pd.DataFrame(
mapping_matrix @ y.loc[old_codes].to_numpy(),
index=pd.Index(new_codes, name="code"),
columns=y.columns,
)

# Both the OA assignment matrix and the LA extras use area_codes order.
y = y.loc[area_codes["code"]]

# ── UC targets ──────────────────────────────────────────────────
# Constituency UC counts are already on 2024 boundaries and must stay
# outside the mapping applied to the older income and age sources.
if area_type == "constituency":
from policyengine_uk_data.targets.sources.local_uc import (
get_constituency_uc_targets,
get_constituency_uc_by_children_targets,
)

y["uc_households"] = get_constituency_uc_targets().values
uc_by_children = get_constituency_uc_by_children_targets()
y["uc_households"] = get_constituency_uc_targets().loc[y.index]
uc_by_children = get_constituency_uc_by_children_targets().loc[y.index]
for col in uc_by_children.columns:
y[col] = uc_by_children[col].values
y[col] = uc_by_children[col]
else:
from policyengine_uk_data.targets.sources.local_uc import get_la_uc_targets

y["uc_households"] = get_la_uc_targets().values

# ── Boundary mapping (constituency 2010 → 2024 only) ───────────
if area_type == "constituency":
from policyengine_uk_data.datasets.local_areas.constituencies.boundary_changes.mapping_matrix import (
mapping_matrix,
)

y_columns = list(y.columns)
y_values = mapping_matrix @ y.values
y = pd.DataFrame(y_values, columns=y_columns)
y["uc_households"] = get_la_uc_targets().loc[y.index]

# ── LA extras: ONS income, tenure, rent ─────────────────────────
if area_type == "la":
Expand All @@ -264,8 +276,7 @@ def _load_area_targets(
load_household_counts,
load_tenure_data,
load_private_rents,
UPRATING_NET_INCOME_BHC_2020_TO_2025,
UPRATING_HOUSING_COSTS_2020_TO_2025,
get_ons_income_uprating_factors,
)

original_weights = sim.calculate("household_weight", 2025).values
Expand All @@ -288,16 +299,19 @@ def _load_area_targets(
"equiv_hbai_household_net_income_ahc"
).values
housing_costs = hbai_net_income - hbai_net_income_ahc
income_bhc_uprating_factor, housing_costs_uprating_factor = (
get_ons_income_uprating_factors(int(time_period))
)

ons_merged["equiv_net_income_bhc_target"] = (
ons_merged["net_income_bhc"]
* ons_merged["households"]
* UPRATING_NET_INCOME_BHC_2020_TO_2025
* income_bhc_uprating_factor
)
ons_merged["equiv_housing_costs_target"] = (
ons_merged["net_income_bhc"] * ons_merged["households"]
- ons_merged["net_income_ahc"] * ons_merged["households"]
) * UPRATING_HOUSING_COSTS_2020_TO_2025
) * housing_costs_uprating_factor
ons_merged["equiv_net_income_ahc_target"] = (
ons_merged["equiv_net_income_bhc_target"]
- ons_merged["equiv_housing_costs_target"]
Expand Down Expand Up @@ -385,7 +399,7 @@ def _load_area_targets(
national_rent * la_household_share,
)

return y
return y.reset_index(drop=True)


def create_cloned_target_matrix(
Expand Down
41 changes: 32 additions & 9 deletions policyengine_uk_data/datasets/frs.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,11 @@
"UPPER_SECONDARY",
"POST_SECONDARY",
)
# FRS government-training question variants use 10 or 13 for "None of these".
FRS_APPROVED_TRAINING_CODES = tuple(range(1, 10))
# FRS TRAINEE, "Whether on a government scheme for employment training". It
# shares the FRS dictionary's value format with EDUCFT, where 1 is yes; in each
# of FRS 2020-21, 2022-23, 2023-24 and 2024-25 the child-table records aged 16
# to 19 outside full-time education are exactly the child-table records with 1.
FRS_TRAINEE_YES = 1
UNKNOWN_QUALIFYING_EDUCATION_OR_TRAINING_ENTRY_AGE = 1000
# FRS RENTPROF: whether ROYYR1 is a profit (1) or a loss (2).
FRS_RENTPROF_LOSS = 2
Expand Down Expand Up @@ -366,14 +369,34 @@ def derive_is_in_non_advanced_education(

def derive_is_in_approved_training_from_frs_person(
person: pd.DataFrame,
adult_person_ids,
) -> pd.Series:
"""Identify reported government training scheme participation in FRS."""

if "train" not in person.columns:
return pd.Series(False, index=person.index)
"""Identify FRS dependants in unwaged government training.

The FRS counts a 16- to 19-year-old as a dependent child when they are in
full-time non-advanced education or unwaged government training. In each
of FRS 2020-21, 2022-23, 2023-24 and 2024-25, every child-table record
aged 16 to 19 outside full-time education carries TRAINEE = 1.

Leaving adult-table records out is a proxy. The FRS has already decided
they are not dependants, and their TRAINEE answer does not say the training
is unwaged, so it may be training under a contract of employment, which is
not approved training (UC Regulations 2013, reg. 5(3)). TRAIN2 names the
scheme but only the adult table has it.

The build fails when TRAINEE is missing rather than marking nobody as in
training: an earlier version read TRAIN2's question name, ``train``, which
is not a column, so every record was coded as not in training.
"""

train = pd.to_numeric(person.train, errors="coerce").fillna(0)
return train.isin(FRS_APPROVED_TRAINING_CODES)
if "trainee" not in person.columns:
raise KeyError(
"The FRS person table has no TRAINEE column, so approved training "
"cannot be derived."
)
is_trainee = pd.to_numeric(person.trainee, errors="coerce").eq(FRS_TRAINEE_YES)
is_adult_record = person.person_id.isin(np.asarray(adult_person_ids))
return is_trainee & ~is_adult_record


def derive_age_started_or_accepted_current_education_or_training(
Expand Down Expand Up @@ -1055,7 +1078,7 @@ def determine_education_level(fted_val, typeed2_val, age_val):
pe_person.current_education
)
pe_person["is_in_approved_training"] = (
derive_is_in_approved_training_from_frs_person(person)
derive_is_in_approved_training_from_frs_person(person, frs["adult"].person_id)
)
pe_person["age_started_or_accepted_current_education_or_training"] = (
derive_age_started_or_accepted_current_education_or_training(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@
# 1. Read the boundary change data into a DataFrame
df = pd.read_csv(Path(__file__).parent / "boundary_changes.csv")

# Five unchanged Scottish constituencies retain their 2010 codes in the
# boundary-change source. Use their 2024 codes from constituencies_2024.csv
# so the mapped rows can be joined to the other 2024 targets by code.
df["code_2024"] = df["code_2024"].replace(
{
"S14000006": "S14000107", # Ayr, Carrick and Cumnock
"S14000008": "S14000108", # Berwickshire, Roxburgh and Selkirk
"S14000010": "S14000109", # Central Ayrshire
"S14000040": "S14000110", # Kilmarnock and Loudoun
"S14000058": "S14000111", # West Aberdeenshire and Kincardine
}
)

# 2. Get unique constituency codes for 2010 and 2024, sorted alphabetically
old_codes = sorted(df["code_2010"].unique())
new_codes = sorted(df["code_2024"].unique())
Expand Down
Loading
Loading