Fix hours_worked units in participation dynamics - #1828
Conversation
hours_worked is annual in policyengine-uk: its label is "Annual hours worked", it has a YEAR definition period, and weekly_hours derives from it as hours_worked / WEEKS_IN_YEAR. impute_wages_for_nonworkers computed the hourly wage as employment_income / (hours_worked * 52), dividing by a year a second time. On enhanced_frs_2024_25 that gives an implied hourly wage of about £0.43 against a realistic £22, and imputes roughly £194 of annual earnings for a non-worker entering work at 18.8 hours a week rather than about £21,600. Entering work therefore appears to pay almost nothing, which collapses the extensive-margin response to near-zero entrants and makes the exchequer appear to recover nothing from anyone who moves into work. The hourly wage calculation is extracted into a new hourly_wage helper so the units can be unit-tested without a dataset. apply_participation_responses had the same confusion in the other direction, writing the weekly hours_for_new_entrants straight into the annual hours_worked variable, recording a new entrant as working 18.8 hours per year. It is now annualised. Fixes #1826 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011NKBpgpwwY5ZXHUwh1GJQT
Adversarial review — policyengine-uk PR 1828Verdict: APPROVE WITH COMMENT. The two unit fixes are correct. SHOULD ADDRESS — Validation: 10 relevant tests passed, 11 credential-dependent tests skipped, Ruff passed, and GitHub checks passed. |
|
Thanks. Agreed on the gap: the tests pin the arithmetic in A direct regression there needs a simulation that will actually produce an entrant — Two options and I am happy either way:
Given #1829 is stacked on this branch, my inclination is (1) so this PR stands on its own. Say which you prefer and I will push it. |
|
Let #1829 carry it. |
Fixes #1826.
The bug
hours_workedis annual in policyengine-uk —variables/household/income/hours_worked.pylabels it "Annual hours worked" with aYEARdefinition period, andweekly_hoursderives from it ashours_worked / WEEKS_IN_YEAR.dynamics/participation.py::impute_wages_for_nonworkersdivided by it a second time:On
enhanced_frs_2024_25, among workers with positive earnings and hours:hours_workedmedianweekly_hoursmedian£16 is consistent with ONS median hourly earnings; £0.31 is a quarter of the National Living Wage.
Why it matters
Imputed annual earnings for a non-worker entering work at 18.8 hours a week came out at £194 against a realistic ~£21,600. Entering work appears to pay almost nothing, so the extensive margin collapses: in a childcare reform analysis built on this module the response was 21 entrants and £0.007 of exchequer revenue from them; with the units corrected the same code gives 10,031 entrants and £38m.
calculate_gain_to_workcalls this helper withimpute_nonworker_wages=True, so non-workers'in_work_incomewas affected too, andimputed_wagesis written straight intoemployment_incomeviasim.set_input— nothing downstream compensates.Changes
hourly_wage(sim, year)helper with the correct units, so it can be unit-tested without a dataset.apply_participation_responseshad the same confusion in the other direction, assigning the weeklyhours_for_new_entrants(18.8) into the annualhours_worked, recording a new entrant as working 18.8 hours per year. Now annualised.mainand pass here.Not fixed here
The donor pool for this imputation is selected by elasticity group, which is keyed off
calculate_earnings_quintile— and that function ranks the whole population including children, leaving its bottom two quintiles with no employed donors at all. That is #1827 and is a separate, larger change; this PR is deliberately limited to the units so it can be reviewed and merged on its own.Testing
Existing
test_labour_supply_response_formulas.pyandtest_behavioral_responses.pystill pass (6 passed, 11 skipped without a dataset token).ruff format --checkclean.