Skip to content

Stop CPI-uprating energy, which the data build already prices - #1868

Merged
vahid-ahmadi merged 2 commits into
mainfrom
energy-not-cpi-uprated
Sep 23, 2026
Merged

vahid-ahmadi merged 2 commits into
mainfrom
energy-not-cpi-uprated

Conversation

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator

Fixes #1867. This reverses the direction of #1860, which I wrote and @juaristi22 reviewed — neither of us caught it at the time.

The defect

policyengine-uk-data calibrates the energy variables to NEED mean kWh by income band and converts to pounds at Ofgem Q2 2026 unit rates. Its imputations/consumption.py says so outright:

the stored £/yr values represent FY26/27 price levels. Energy variables are not CPI-uprated, so this avoids the need for price-level adjustment at simulation time.

But uprating_indices.yaml uprates from the dataset's data year, 2024. So applying CPI to these variables re-applies 2024–2026 price changes the build had already priced in — roughly 5.8pp of a 7.9% uplift.

Evidence

The cap parameter falls over this period, which makes the stored price level testable. On enhanced_frs_2024_25:

Modelled mean electricity + gas £1,584
gov.ofgem.energy_price_cap 2024 £2,389
gov.ofgem.energy_price_cap 2025 £1,849
gov.ofgem.energy_price_cap 2026 £1,641

A 2024-priced series would have to sit near the 2024 cap. It sits just below the 2026 one — the April 2026 level the docstring describes.

Why #1860 was the wrong fix

#1859 observed that electricity_consumption and gas_consumption were flat while domestic_energy_consumption rose, and treated the flat pair as the defect. It was the other way round. The flat pair was right — accidentally, being absent from the YAML rather than deliberately excluded — and domestic_energy_consumption, on the CPI list since before 2.90.2, was carrying the double count.

#1860 restored the documented identity by propagating the defect to all three. The identity held; the price level was wrong for all of them.

The fix

Removing all three restores the identity and holds the level:

Year elec gas sum domestic identity
2024 £895 £689 £1,584 £1,584 ✓
2025 £895 £689 £1,584 £1,584 ✓
2026 £895 £689 £1,584 £1,584 ✓
2027 £895 £689 £1,584 £1,584 ✓

Other consumption variables come from the LCFS at data-year prices and do need uprating, so only these three are excluded. The rationale is recorded at the top of the YAML so the absence reads as deliberate rather than as the gap #1859 reported.

A code-health test asserts they appear under no index. I verified it fails when one is re-added, rather than only passing today.

Verification

Full suite: 266 passed, 45 skipped.

Downstream

Modelled energy spending in projected years falls about 8%, back to the build's own price level. Anyone with pinned regression values on domestic_energy_consumption will see them move — that movement is the correction.

For impact-iran-war-living-standards, which defines every scenario percentage against the pre-conflict April–June 2026 baseline, this means the baseline is already at the right price level and takes no uplift. I had been reporting a pending +4% correction to that study's headline on the strength of #1860; that figure is withdrawn.

Interacts with #1863 — if that merges first its guard will need an exception entry for these three, pointing here.

Reverses the direction of #1860, which I wrote. Fixes #1867.

policyengine-uk-data calibrates the energy variables to NEED mean kWh by
income band and converts to pounds at Ofgem Q2 2026 unit rates. Its
imputations/consumption.py says so outright:

  "the stored £/yr values represent FY26/27 price levels. Energy variables
   are not CPI-uprated, so this avoids the need for price-level adjustment
   at simulation time."

uprating_indices.yaml uprates from the dataset's DATA year, 2024. So applying
CPI to these variables re-applies 2024-2026 price changes the build had
already priced in — about 5.8pp of a 7.9% uplift.

The cap parameter falls over this period, which makes the stored price level
testable. On enhanced_frs_2024_25 the modelled mean of electricity + gas is
£1,584, against cap parameters of £2,389 (2024), £1,849 (2025) and £1,641
(2026). A 2024-priced series would have to sit near the 2024 cap; it sits just
below the 2026 one, which is the April 2026 level the docstring describes.

#1859 read the symptom backwards and #1860 followed it. The flat pair was
right — accidentally, being absent from the YAML rather than deliberately
excluded — and domestic_energy_consumption, on the CPI list since before
2.90.2, was carrying the double count. #1860 restored the documented identity
by propagating the defect to all three.

Removing all three restores the identity AND holds the level: electricity
£895 + gas £689 = £1,584 = domestic, in every year from 2024 to 2027.

Other consumption variables come from the LCFS at data-year prices and do
need uprating, so only these three are excluded. The rationale is recorded at
the top of the YAML, and a code-health test asserts they appear under no
index. Verified that test fails when one is re-added rather than only passing
today.

Downstream: modelled energy spending in projected years falls about 8%, back
to the build's own price level. Anyone with pinned regression values on
domestic_energy_consumption will see them move; that movement is the
correction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juaristi22

Copy link
Copy Markdown
Collaborator

Program review

Base repository: PolicyEngine/policyengine-uk
PR number: 1868
Reviewed head SHA: 0e02174
Merge base SHA: a16193a
Mode: full
Scope: changed behavior and affected dependencies
Source manifest: /private/tmp/policyengine-command-runs/b9a4078e78c3/pr-1868-review-sources.json
Review status: PARTIAL

Source Documents

Critical

C1 — CRITICAL (Must Fix): the new changelog file is not a Towncrier fragment and will be omitted from the release (OPEN)

Location: changelog.d/energy-not-cpi-uprated.md:1.

Trigger: run the repository's release command (towncrier build) after merging. Expected: the energy correction appears under a configured fragment type. Observed: towncrier 26.9.0 build --draft --version 0 reports No significant changes. and does not render this file. The repository explicitly requires changelog.d/<branch>.<type>.md (.github/CONTRIBUTING.md:19-26) and configures fixed as a type (pyproject.toml:103-106), but this filename has no type component; it should be named, for example, energy-not-cpi-uprated.fixed.md.

Impact / why this diff: this PR adds the malformed file, so its release note is silently absent. This is a demonstrated failure of an explicit repository completion requirement, which the workflow rubric classifies as critical rather than a style suggestion.

Should Address

A1 — SHOULD ADDRESS — Scope the “not uprated by any index” invariant to dataset projection (OPEN)

  • Location introduced by this diff: policyengine_uk/tests/code_health/test_energy_price_level.py:1-14,31-42, reinforced by policyengine_uk/data/uprating_indices.yaml:1-14 and changelog.d/energy-not-cpi-uprated.md:1.
  • Trigger/year: supply one of the three variables for an earlier year in a direct household/situation input and request a later year (for example, a 2024 input queried in 2025 or 2027) without an explicit later-period input.
  • Expected under the new categorical wording: a value that genuinely carries the data build's Q2-2026/FY26/27 price basis is carried without another CPI adjustment.
  • Observed by reachable code trace: the unchanged classes at policyengine_uk/variables/input/consumption/energy.py:14,24,34 still declare the cumulative CPI index. PolicyEngine Core's fallback (simulation.py:823-851 in the installed 3.30.1 source) uprates the latest known earlier-period input by the index ratio. The new test inspects only data/uprating_indices.yaml, so it cannot enforce its module-level/global wording on this second path.
  • Impact and scope: multi-year population datasets set explicit inputs for every projected year after apply_uprating, so this fallback does not undo the PR's dataset fix. No source or repository contract found says that an arbitrary 2024 situation input inherits the data build's FY26/27 basis; it may instead be a user's contemporaneous 2024 bill. Accordingly, the direct-input wrong-result premise is unproved and is not a critical policy defect. The confirmed issue is an overbroad contract/guard: scope the text/test to dataset projection, or explicitly define direct-input price-basis semantics and align the class metadata if the invariant is meant globally.
  • Why this diff exposes it: the active class metadata predates the PR, but this diff newly asserts that the variables “must not be CPI-uprated” and adds a guard presented as preventing that behavior everywhere.

Coordinator assessment: Complementary executed evidence from the code review: the new YAML guard and the dataset freeze/identity diagnostic pass, while a separate direct-situation path still applies the unchanged class-level CPI metadata (2024→2027 factor 1.078934022). That observation proves the wording/test are overbroad, but not that arbitrary manual inputs carry the dataset artifact's Q2-2026 price basis or produce an incorrect result.

A2 — SHOULD ADDRESS: published growth-factor documentation still says domestic energy is CPI-uprated (OPEN)

Location: docs/book/assumptions/growthfactors.md:17-20.

Trigger: consult the economic-assumptions documentation after this change. Expected: the CPI list excludes the three FY26/27-priced energy variables and explains the exception. Observed: line 19 still says CPI applies to all consumption categories and explicitly lists domestic_energy_consumption. This directly contradicts the changed dataset behavior and will mislead users and future maintainers; update the list/rationale with this PR.

Suggestions

None.

Evidence Gaps

  • Post-2026 price-basis contract: the evidence confirms the Q2-2026 anchor and removal of the duplicate 2024→2026 adjustment, but does not establish whether 2027+ outputs should stay in constant FY26/27 pounds or follow a nominal energy-price series. The Ofgem source ends 30 June 2026; resolve this with a documented model contract or an approved post-anchor projection before treating indefinite flat nominal values as verified.
  • Role checks incomplete: pr-1868-review-policy.md; see recovered report.

Notes

  • The exact policyengine-uk-data 1.56.16 builder and official Ofgem/NEED sources confirm that electricity and gas are priced from NEED 2023 kWh at Q2-2026 unit rates and domestic energy is recomputed as their sum; exactly these three share the treatment.
  • The PR body's comparison with Ofgem's £1,641 typical annual cap is not like-for-like because the builder excludes standing charges; the review instead verified the underlying unit rates and kWh targets.

Validation Summary

Remote CI: 6/6 checks passed at the reviewed head. Local bounded invocation: the changed guard, two relevant repository tests, the dataset freeze/identity diagnostic and three direct-path observations passed (7 passed); one diagnostic snapshot-path assertion false-failed only because macOS canonicalizes /tmp as /private/tmp. git diff --check passed. Towncrier 26.9 draft rendered no fragment. Source cache: 3/3 originals validated, 0 rejected, 0 derivatives; no PDF renders.

Timing

setup seconds: 4.00s; scope seconds: 115.00s; parallel review seconds: 853.00s; policy role seconds: 853.00s; code role seconds: 723.00s; adjudication seconds: 36.00s; consolidation cleanup seconds: 82.00s; elapsed seconds: 1005.00s

Review Severity

REQUEST_CHANGES. Open findings: 1 critical, 2 should address, 0 suggestions.

Addresses the three findings on this PR.

C1: the changelog fragment had no type component, so towncrier omitted it
entirely — the draft rendered "No significant changes". Renamed to
energy-not-cpi-uprated.fixed.md, per changelog.d/<branch>.<type>.md in
CONTRIBUTING, and the leading bullet removed so towncrier does not double it.
Verified the draft now renders it under Fixed.

A1: the wording claimed more than the change delivers, and the reviewer is
right. uprating_indices.yaml governs dataset projection. It does not govern
the class-level `uprating` attribute, which policyengine-core still uses to
carry a direct situation input forward: a 2024 electricity_consumption input
queried at 2027 returns 1078.93 against 1000, which I confirmed directly.

That path is deliberately left alone. A value a user supplies for 2024 may be
their own contemporaneous bill rather than this build's artifact, so it has no
claim on the build's price basis. The YAML header, the test docstring and the
test name now say "projection" rather than making a global claim.

Worth recording because #1860 and #1862 both described the class-level
attribute as dead metadata that nothing reads. That is true of dataset
projection and false of the direct-input path; core reads it there.

A2: docs/book/assumptions/growthfactors.md still listed
domestic_energy_consumption under "all consumption categories" get CPI. The
list now excludes the three energy variables and states why.

Full suite: 266 passed, 45 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

All three findings fixed.

C1 — the fragment was genuinely broken. towncrier build --draft rendered "No significant changes", so the release note would have been silently absent. Renamed to energy-not-cpi-uprated.fixed.md per changelog.d/<branch>.<type>.md, and dropped the leading - so towncrier does not double the bullet. Verified the draft now renders it under Fixed.

A1 — the wording claimed more than the change delivers, and you're right. I confirmed the path directly rather than taking it:

electricity_consumption, direct 2024 input
  queried 2024: £1000.00
  queried 2027: £1078.93

So uprating_indices.yaml governs dataset projection, while the class-level attribute still carries a direct situation input forward through core's fallback. My YAML header and test asserted a global invariant that only one of those paths obeys.

Scoped rather than extended, for the reason you give: a value a user supplies for 2024 may be their own contemporaneous bill, not this build's artifact, so it has no claim on the build's price basis. The header, the test docstring and the test name now say projection. The test itself is unchanged in what it checks — it was always a YAML check; only the claim around it was too broad.

This also corrects something I asserted twice. #1860's root-cause section and #1862 both called the class-level uprating attribute "dead metadata: nothing in policyengine-uk reads it". That is true for dataset projection and false for the direct-input path — core reads it there, as the £1,078.93 shows. I have recorded the distinction in the test docstring so the next person does not inherit my error.

A2 — docs/book/assumptions/growthfactors.md still listed domestic_energy_consumption under "all consumption categories". The list now excludes the three energy variables and explains why, pointing at #1867.

On the evidence gap you flagged — whether 2027+ should stay in constant FY26/27 pounds or follow a nominal energy-price series — I agree that is unresolved and should not be settled inside this PR. This PR removes a demonstrable double count; what the right post-anchor projection is needs the data build's own contract, since only it knows the anchor. Worth its own issue against policyengine-uk-data, and I'm happy to raise it.

On the £1,641 comparison — fair, and I should have said so. The builder excludes standing charges, so mean spend against a typical-consumption cap is not like-for-like. It was directional evidence for which year the level sits in, not a level check; your unit-rate and kWh verification is the right test.

Full suite: 266 passed, 45 skipped.

@juaristi22

Copy link
Copy Markdown
Collaborator

Program review

Base repository: PolicyEngine/policyengine-uk
PR number: 1868
Reviewed head SHA: c6bcc2d
Merge base SHA: a16193a
Mode: incremental from 0e02174
Scope: new commits, affected prior findings and gaps, and touched dependencies
Source manifest: /private/tmp/policyengine-command-runs/b9a4078e78c3/pr-1868-review-sources.json
Review status: PARTIAL

Source Documents

Critical

C1 — RESOLVED (was CRITICAL): Towncrier now recognizes and renders the fragment (RESOLVED)

  • Changed location: changelog.d/energy-not-cpi-uprated.fixed.md:1 replaces the untyped changelog.d/energy-not-cpi-uprated.md.
  • Verification: Towncrier 26.9.0, run as python -m towncrier build --draft --version 0, exited 0 and rendered the text under ### Fixed as a bulleted fragment. This matches the repository's <branch>.<type>.md convention and configured fixed type.
  • Disposition: the release-note omission demonstrated at the prior head no longer occurs.

Should Address

A1 — RESOLVED (was SHOULD ADDRESS): the invariant is now correctly scoped to dataset projection (RESOLVED)

  • Changed locations: policyengine_uk/tests/code_health/test_energy_price_level.py:1-22,39-50 and policyengine_uk/data/uprating_indices.yaml:1-23 now explicitly limit the claim to dataset projection and accurately document the separate direct-situation path.
  • Current dataset path: extend_single_year_dataset calls apply_uprating (policyengine_uk/data/economic_assumptions.py:35-77); standard projection reads only uprating_indices.yaml (:80-98). The three custom steps at :100-110 affect council tax, rent, and student-loan plans, not energy. A repository-wide trace found no other dataset-projection alias or custom mutation for these three variables.
  • Guard strength for the stated contract: the committed test scans every variable under every YAML index, so it directly guards the only active generic dataset-projection mechanism changed by this PR. It passes at the current head. A focused runtime diagnostic also projected a 2024 dataset through 2027 and observed all three values unchanged in every year: 1584, 895, and 689; the identity 1584 = 895 + 689 remained true.
  • Separate live path: the class attributes remain at policyengine_uk/variables/input/consumption/energy.py:14,24,34. A direct 2024 situation queried in 2027 still uses CPI factor 1.078934022, yielding 1709.031616, 965.645996, and 743.385559. The revised comments correctly explain why this does not contradict the dataset contract: an arbitrary manual 2024 input is not established to carry the dataset artifact's FY26/27 price basis.
  • Disposition: the prior defect was overbroad wording, not a proven wrong-result case. The new scope and test name resolve it. The YAML-only guard can intentionally pass while direct situation inputs uprate; that behavior is now accurately described rather than hidden.
  • Non-blocking test gap: a future custom energy mutation added outside the YAML could evade this configuration test. No such path exists today, and the changed mechanism is YAML membership, so a permanent end-to-end projection test would strengthen defense in depth but is not required to establish the current behavior.

Coordinator assessment: Policy/source confirmation: the scoped wording no longer assigns the dataset artifact's Q2-2026 price basis to arbitrary direct inputs, so it resolves the prior overbroad contract without inventing a source premise.

A2 — RESOLVED — Growth-factor documentation now matches the exact variable split (RESOLVED)

docs/book/assumptions/growthfactors.md:17-23 removes domestic_energy_consumption from the CPI list and explicitly names all three excluded variables. It leaves housing_water_and_electricity_consumption on the CPI list. That is the correct distinction: the validated policyengine-uk-data 1.56.16 builder maps housing_water_and_electricity_consumption independently from LCFS p604 (consumption.py:131-146) and CPI-uprates every ordinary imputation except the explicit three-variable energy_vars set (:641-667); electricity and gas are separately NEED-raked and domestic energy is recomputed as their sum (:1002-1066). The heading “EXCEPT energy” is colloquial, but the adjacent exact lists make the implemented split unambiguous.

Suggestions

None.

Evidence Gaps

  • Post-2026 price-basis contract remains unresolved: the new prose correctly documents exclusion from dataset projection, but still does not state whether 2027+ GBP outputs are intentionally constant FY26/27-price measures or should follow an approved post-anchor nominal energy-price series, nor how that basis aligns with downstream price-cap/subsidy calculations.
  • Role checks incomplete: pr-1868-review-policy.md; see recovered report.

Notes

  • The renamed Towncrier fragment rendered under Fixed, and the incremental local validation passed 8/8 tests and diagnostics.
  • The committed guard checks the active YAML membership mechanism. A future custom energy mutation outside that YAML could evade it, but no such path exists today; an end-to-end permanent regression would be defense in depth rather than a current defect.
  • At the captured current head, six remote checks passed and the main Test job remained in progress.

Validation Summary

Incremental ancestry and exact head verified. One bounded local invocation passed 8/8 checks with 0 failures (pytest 2.94s; runner 3.90s); Towncrier 26.9 rendered the typed fragment under Fixed; full and incremental git diff --check passed. Source cache revalidated 3/3 originals with 0 rejected or discarded derivatives and no new acquisition/renders. Remote CI: 6 passed, main Test pending at capture.

Timing

setup seconds: 4.00s; scope seconds: 84.00s; parallel review seconds: 235.00s; policy role seconds: 294.00s; code role seconds: 231.00s; adjudication seconds: 0.00s; consolidation cleanup seconds: 64.00s; elapsed seconds: 375.00s

Review Severity

COMMENT. Open findings: 0 critical, 0 should address, 0 suggestions.

@vahid-ahmadi

Copy link
Copy Markdown
Collaborator Author

All three findings resolved, and CI is now 7/7 including the Test job that was in progress at your capture.

I have raised the evidence gap as PolicyEngine/policyengine-uk-data#485 rather than leaving it in a review thread. The question is which of two readings the build intends: that the values are deliberately constant FY26/27-price measures after the anchor, or that they are nominal and should follow an approved energy-price path once Ofgem's Q2 2026 rates run out. Only that repository can answer it, since only it knows the anchor.

I flagged there that it matters beyond bookkeeping: downstream cap and subsidy calculations combine these values with cap parameters that do move by year, so a real series beside a nominal cap drifts with nobody intending it. And housing_water_and_electricity_consumption stays CPI-uprated, so one dataset can carry two related series on two bases.

On your non-blocking note about the guard: agreed that a custom energy mutation added outside the YAML would evade a configuration test, and that an end-to-end projection assertion would be defence in depth. I have left it as a configuration test because YAML membership is the mechanism this PR changes, and a test that asserts more than the change controls is what produced finding A1 in the first place. Worth adding when there is a second projection mechanism to defend against.

Ready to merge whenever you are happy — I am not merging it myself, since the last time a change of mine went in unreviewed it took two rounds of your review to find what it had left stale.

@vahid-ahmadi
vahid-ahmadi merged commit 3378e21 into main Sep 23, 2026
14 checks passed
@vahid-ahmadi
vahid-ahmadi deleted the energy-not-cpi-uprated branch September 23, 2026 11:19
vahid-ahmadi added a commit that referenced this pull request Oct 7, 2026
…lose the gaps it now finds

- ruff format test_uprating_declarations.py (the Lint failure).
- Rename the fragment to uprate-four-remaining-columns.fixed.md so towncrier
  renders it under Fixed rather than dropping it.
- After rebasing onto main, the guard flags ten variables declared since:
  seven are genuine gaps and now uprate by the index they declare
  (cash_isa, stocks_and_shares_isa, mortgage_debt, consumer_debt,
  rent_paid_as_boarder, rent_paid_as_lodger by per-capita GDP;
  self_employment_gross_receipts by per-capita mixed income); the three
  energy columns are listed as deliberately unuprated, per #1868.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
vahid-ahmadi added a commit that referenced this pull request Oct 12, 2026
…lose the gaps it now finds

- ruff format test_uprating_declarations.py (the Lint failure).
- Rename the fragment to uprate-four-remaining-columns.fixed.md so towncrier
  renders it under Fixed rather than dropping it.
- After rebasing onto main, the guard flags ten variables declared since:
  seven are genuine gaps and now uprate by the index they declare
  (cash_isa, stocks_and_shares_isa, mortgage_debt, consumer_debt,
  rent_paid_as_boarder, rent_paid_as_lodger by per-capita GDP;
  self_employment_gross_receipts by per-capita mixed income); the three
  energy columns are listed as deliberately unuprated, per #1868.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Energy uprating starts from the data year, not the price-level year the build stores

2 participants