Repository navigation
Re-average fuel duty and other blended rates after mid-year reforms - #2185
vahid-ahmadi wants to merge 1 commit into
Conversation
Reforms edit the converted parameter tree, where each fiscal_year_blend parameter already holds a day-weighted model-year average. A reform dated part-way through a year applied only from its calendar month, against a baseline average that still included the rates it replaces: 60p a litre from 1 September 2026 scored +£24.19 for 1,200 litres instead of the day-weighted +£38.26. Record each reform update to a blended parameter, replay it on the dated schedule (1 January and 31 December boundaries become 6 April and 5 April) and re-average the model years. Whole-year reforms keep their values exactly. Document the semantics and syntax per API. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of #2185 — Re-average fuel duty and other blended rates after mid-year reformsBase repository: PolicyEngine/policyengine-uk Source DocumentsCriticalNone. Should AddressNone. SuggestionsNone. Evidence GapsNo material gaps reported. Notes
Validation SummaryReused exact-head validation (not rerun in this batch): 48 passed, 1 warning in 31.13s; snapshot import confirmed; raw command/paths in pr-2185-code-tests-config.json and pr-2185-code-tests.log; Reused exact-head validation (not rerun in this batch): Official HMRC original and extract acquired; statutory fuel dates checked; hypothetical £38.26356 example independently derived; no duplicate model run.; bundled check_source_manifest.py passed under existing Python3.13; cache result pr-2185-review-source-cache-check.json CI at 0210317: Install + smoke-import (py3.11)=SUCCESS, Test documentation builds=SUCCESS, Install + smoke-import (py3.12)=SUCCESS, Install + smoke-import (py3.13)=SUCCESS, Install + smoke-import (py3.14)=SUCCESS, Lint=SUCCESS, Test=SUCCESS. Detailed code, policy, source and reproduction artifacts were retained locally. Timingelapsed seconds: 1151.70s; setup seconds: 270.16s Review SeverityAPPROVE. Open findings: 0 critical, 0 should address, 0 suggestions. |
|
Suggested The check isn't required yet (decision d1368); it fails fix PRs whose description names no reproducer. 🤖 Generated with Claude Code |
Fixes #2167.
What was wrong
Parameters marked
fiscal_year_blend: true(petrol and diesel, LPG and natural gas duty, and the CGT rates) are converted to one day-weighted average per model year, 6 April to 5 April. Reforms were applied to that converted tree and never re-averaged. A persistent reform dated part-way through a year therefore applied only from its calendar month, against a baseline average that still included the rates it replaces.For the issue's example (60p a litre from 1 September 2026, 1,200 litres, model year 2026):
fuel_dutyReform.from_dict({path: {"2026-09-01.2100-12-31": 0.60}}), beforeThe £38.26 assumes equal daily litres, which is what the day weighting means.
Fix
Recording updates.
reblend_dated_reforms(utils/parameters.py) is a context manager around every place a reform changes the converted tree:Simulation.apply_reform;_apply_reform_classbefore data load;Scenario.from_reform.Inside it, each update to a blended parameter is recorded as it is applied.
Replaying them on the dated schedule. On exit, the recorded updates are replayed on the dated schedule the averages came from. That is the uprated
parameters.baselinetree, or the schedule an earlier reform left. Each model year from 2015 to 2040 is then re-averaged.Model-year boundaries. On the converted tree, 1 January and 31 December bound model years. So those boundaries move to 6 April and 5 April when replayed, and any other date is kept as written.
Whole-year reforms are left exactly as they are. A parameter is re-averaged only when some update starts or ends part-way through a year. Bare years,
year:periods, scalars and ranges such as"2026-01-01.2100-12-31"produce the same model-year values as before, and a test pins this for 2015–2040.Chained reforms. Each re-averaged parameter keeps its replayed schedule, so a later reform in a tuple builds on an earlier mid-year change.
Untouched: the baseline tree, unblended parameters and
preserve_calendar_datesparameters.No policyengine-core API changes.
One behaviour change beyond the issue: a plain dictionary reform (
reform={path: {"2026-09-01": v}}) reads a single date as one day, asScenariodoes. It is now re-averaged as one day (+£0.23 in the example), where before the converted tree applied it to the whole September monthly reading.Documentation
docs/engineering/excise-duties.mdcovers:Scenariosingle-date key is a one-day period and range keys raiseValueError;preserve_calendar_datesexception.petrol_and_diesel.yamlheader points to that section.Tests
policyengine_uk/tests/test_mid_year_reforms.pyhas 15 cases. The 8 mid-year cases below fail onmain; the 7 that check nothing else changes pass on both.mainyet; a direct electricity test should follow once The temporary 0% VAT on domestic electricity in Great Britain (1 October 2026 to 31 March 2027) is not modelled, and VAT on domestic energy cannot be changed on its own #2166 lands.Reformclass applied before data load.Suite results:
tests/policyandtests/microsimulation: 3,217 passed, 8 skipped, 2 xfailed, 0 failed;ruff formatandruff checkare clean;Performance
Median time to build a household
Simulationand calculatefuel_duty, 7 runs:mainThe differences are within run-to-run noise on a busy machine. Recording wraps the six blended parameters for each reform, and re-averaging runs only for parameters a reform changes part-way through a year.
🤖 Generated with Claude Code