diff --git a/.gitignore b/.gitignore index a6c962c3ff..d0f289aae2 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ policyengine_uk/calibration/*.h5 !docs/**/*.ipynb !uprating_growth_factors.csv !policyengine_uk/parameters/gov/dwp/LHA/lha_published_rates_sources.csv +!policyengine_uk/tests/fixtures/energy_prices/*.csv # Virtual environments .venv/ diff --git a/changelog.d/energy-retail-price-index.changed.md b/changelog.d/energy-retail-price-index.changed.md new file mode 100644 index 0000000000..1c6bec9210 --- /dev/null +++ b/changelog.d/energy-retail-price-index.changed.md @@ -0,0 +1 @@ +Project household electricity and gas spending with retail energy price indices instead of headline CPI. The indices follow the ONS CPI electricity, gas and solid fuel indices to the latest month and the OBR's utilities inflation forecast after it, and leave out the electricity VAT cut and its extension, so a VAT reform no longer moves projected spending. diff --git a/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 07c4ce5007..2ad24c8689 100644 --- a/docs/book/assumptions/growthfactors.md +++ b/docs/book/assumptions/growthfactors.md @@ -18,7 +18,7 @@ We apply CPI to these variables: - All reported benefits– `carers_allowance_reported`, `child_benefit_reported`, `child_tax_credit_reported`, `esa_contrib_reported`, `esa_income_reported`, `housing_benefit_reported`, `iidb_reported`, `incapacity_benefit_reported`, `income_support_reported`, `jsa_contrib_reported`, `jsa_income_reported`, `maternity_allowance_reported`, `pension_credit_reported`, `sda_reported`, `state_pension_reported`, `universal_credit_reported`, `winter_fuel_allowance_reported`, `working_tax_credit_reported` - All consumption categories EXCEPT energy– `alcohol_and_tobacco_consumption`, `clothing_and_footwear_consumption`, `communication_consumption`, `education_consumption`, `food_and_non_alcoholic_beverages_consumption`, `health_consumption`, `household_furnishings_consumption`, `housing_water_and_electricity_consumption`, `miscellaneous_consumption`, `recreation_consumption`, `restaurants_and_hotels_consumption`, `transport_consumption` -`domestic_energy_consumption`, `electricity_consumption` and `gas_consumption` are deliberately excluded. The data build calibrates them to NEED mean kWh and converts to pounds at Ofgem Q2 2026 unit rates, so the stored values already carry FY26/27 price levels; uprating them from the dataset's data year would re-apply price changes that are already included. See PolicyEngine/policyengine-uk#1867. `liquid_fuel_consumption` and `solid_fuel_consumption` (heating oil, LPG, coal and wood) are not covered by Ofgem's price cap, so they are CPI-uprated from the data year like other LCFS spending. +`domestic_energy_consumption`, `electricity_consumption` and `gas_consumption` are deliberately excluded. The data build calibrates them to NEED mean kWh and converts to pounds at Ofgem Q2 2026 unit rates, so the stored values already carry FY26/27 price levels; uprating them from the dataset's data year would re-apply price changes that are already included. See PolicyEngine/policyengine-uk#1867. A dataset that declares its energy price year instead projects them with household electricity and gas price indices (`gov.economic_assumptions.energy_prices`): ONS CPI prices to the latest outturn, then the OBR's utilities inflation forecast, holding VAT on electricity at 5% so the VAT formulas apply any change in the rate. See the economic assumptions README. `liquid_fuel_consumption` and `solid_fuel_consumption` (heating oil, LPG, coal and wood) are not covered by Ofgem's price cap, so they are CPI-uprated from the data year like other LCFS spending. - Other variables– `afcs_reported`, `bsp_reported`, `childcare_expenses`, `free_school_fruit_veg`, `free_school_meals`, `free_school_milk`, `maintenance_expenses`, `statutory_maternity_pay`, `statutory_paternity_pay`, `statutory_sick_pay`, `state_pension`, `war_widows_pension_reported`, `widowed_parents_allowance_reported` diff --git a/docs/engineering/skills/dataset-sources.md b/docs/engineering/skills/dataset-sources.md index 600d59943c..05e60efa21 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -142,8 +142,10 @@ Do not infer its price year from the dataset year, filename, URL or file format. Legacy builds can hold FY2026-27 energy prices in a dataset labelled 2024. An explicit declaration opts only `domestic_energy_consumption`, -`electricity_consumption` and `gas_consumption` into the existing generic annual -OBR consumer-price-index assumption as an interim price proxy. Other generic +`electricity_consumption` and `gas_consumption` into the household energy +price indices, `gov.economic_assumptions.energy_prices`: ONS CPI electricity +and gas prices to the latest outturn, then the OBR's utilities forecast, +holding VAT at the rate the inputs assume. Other generic uprating exclusions do not join this declaration or its reset group. For an input whose producer receipt declares calendar-2024 prices, a caller can label a local UK single-year H5 before constructing a simulation: @@ -164,22 +166,23 @@ financial year. Both keys are required. The year must be an integer from 1 through 9999; malformed or conflicting caller/file declarations raise a clear error. A caller's `None` leaves an existing file declaration intact. -The model retains the period kind as provenance but uses the January 1 index -level of the starting year for both calendar and fiscal price periods. -Projection multiplies the supplied amount by `I[target year] / I[price year]`. -This shared start-year convention approximates calendar/fiscal alignment; -generic CPI does not establish constant-consumption energy-price outturns or -an energy-specific forecast. Open [issue #2526](https://github.com/PolicyEngine/policyengine-uk/issues/2526) -tracks energy-specific index alignment and a sourced future assumption. The -original observation remains unchanged; a projected child's declaration records -its effective fiscal-year price basis so later extension does not apply the -original ratio twice. +The period kind selects the index: a `calendar_year` basis reads the January to +December level of its year, a `fiscal_year` basis the April to March level +starting in its year. The target is always the fiscal year level, since model +year Y is fiscal year Y–(Y+1). Projection multiplies the supplied amount by +`I[target year] / I[price year]`, holding consumption fixed. The economic +assumptions +[README](../../../policyengine_uk/parameters/gov/economic_assumptions/README.md#household-energy-prices) +describes the indices, their sources and the VAT adjustment. +The original observation remains unchanged; a projected child's declaration +records its effective fiscal-year price basis so later extension does not apply +the original ratio twice. When a declaration projects stored energy inputs, both January 1 dates must -fall on or after the selected index's earliest non-null value. An earlier -basis or target, including January 1 before a midyear start, raises a clear -error rather than using Core's backward first-value fallback. An index with -no usable history also raises an error for that projection. Unlabelled +fall on or after the selected index's first stated value (1 January 2015). An +earlier basis or target raises a clear error rather than using Core's backward +first-value fallback. An index with no values also raises an error for that +projection. Unlabelled observations and declarations with no relevant stored inputs retain their existing behaviour without requiring index history. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 6db07617f5..7574e2e124 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -166,7 +166,13 @@ def _unmapped_uprating_indices(uprating): SOCIAL_RENT_GROWTH = "gov.economic_assumptions.yoy_growth.obr.social_rent" PRIVATE_RENT_GROWTH = "gov.economic_assumptions.yoy_growth.ons.private_rental_prices" RESIDENTIAL_PROPERTY_INDEX = "gov.economic_assumptions.indices.obr.house_prices" -ENERGY_PRICE_INDEX = "gov.economic_assumptions.indices.obr.consumer_price_index" +ENERGY_PRICES = "gov.economic_assumptions.energy_prices" +# The annual energy price index that projects each declared input. +ENERGY_PRICE_SERIES = { + "electricity_consumption": "electricity", + "gas_consumption": "gas", + "domestic_energy_consumption": "electricity_gas_and_solid_fuels", +} RESIDENTIAL_PROPERTY_INPUTS = ( "main_residence_value", "other_residential_property_value", @@ -184,7 +190,7 @@ def uprating_input_paths() -> list: ``apply_single_year_uprating`` and the functions it calls read these, and no other parameters: the growth rates in ``uprating_indices.yaml``, the council tax and rent series, the cumulative residential property index, - the conditional energy CPI index, the indices of the input variables that + the annual energy price indices, the indices of the input variables that file leaves out (after ``LEGACY_PROJECTION_END_YEAR``) and the capital gains projection's metadata (``CGT_INDEX_PATH``). A path may name a node (the regional private rent series), which covers its parameters. @@ -201,7 +207,8 @@ def uprating_input_paths() -> list: PRIVATE_RENT_GROWTH, CGT_INDEX_PATH, RESIDENTIAL_PROPERTY_INDEX, - ENERGY_PRICE_INDEX, + f"{ENERGY_PRICES}.calendar_year", + f"{ENERGY_PRICES}.fiscal_year", } | set(_unmapped_uprating_indices(uprating).values()) ) @@ -460,7 +467,15 @@ def uprate_energy( observed_year: UKSingleYearDataset, parameters: ParameterNode, ) -> UKSingleYearDataset: - """Project declared energy prices with the existing annual CPI assumption.""" + """Project declared energy spending with the energy price indices. + + Each input moves by its fuel's index (``ENERGY_PRICE_SERIES``) from the + period its prices describe (the calendar or fiscal year index, by the + declared basis) to the projected fiscal year. The indices hold VAT at the + rate the inputs assume, so the spending carries no VAT change, and the + VAT formulas apply the rate in force. An undeclared basis holds the + amounts flat. + """ basis = observed_year.energy_price_basis variables = [ name @@ -468,44 +483,74 @@ def uprate_energy( if name in observed_year.household.columns and name in current_year.household.columns ] - ratio = 1 - projected = False - if basis is not None and variables: - index = parameters.get_child(ENERGY_PRICE_INDEX) - source_instant = f"{basis['period_value']:04d}-01-01" - target_instant = f"{int(current_year.time_period):04d}-01-01" - supported_instants = [ - value.instant_str - for value in getattr(index, "values_list", []) - if value.value is not None + if basis is None or not variables: + for variable in variables: + current_year.household[variable] = observed_year.household[ + variable + ].to_numpy() + current_year.energy_price_basis = basis + return current_year + source_instant = f"{basis['period_value']:04d}-01-01" + target_instant = f"{int(current_year.time_period):04d}-01-01" + # Check every ratio before changing any amount. + ratios = { + variable: _energy_price_ratio( + parameters, + f"{ENERGY_PRICES}.{basis['period_type']}.{ENERGY_PRICE_SERIES[variable]}", + source_instant, + f"{ENERGY_PRICES}.fiscal_year.{ENERGY_PRICE_SERIES[variable]}", + target_instant, + ) + for variable in variables + } + for variable, ratio in ratios.items(): + current_year.household[variable] = ( + observed_year.household[variable].to_numpy() * ratio + ) + current_year.energy_price_basis = { + "period_type": "fiscal_year", + "period_value": int(current_year.time_period), + } + return current_year + + +def _energy_price_ratio( + parameters: ParameterNode, + source_path: str, + source_instant: str, + target_path: str, + target_instant: str, +) -> float: + """Target index level over source level. + + Each instant must be on or after its index's first stated value; after the + last, the last value holds, as in Core's variable uprating. + """ + levels = [] + for path, instant in ((source_path, source_instant), (target_path, target_instant)): + index = parameters.get_child(path) + stated = [ + entry.instant_str + for entry in getattr(index, "values_list", []) + if entry.value is not None ] - if not supported_instants: + if not stated: raise ValueError( - f"Energy index {ENERGY_PRICE_INDEX!r} has no non-null history " - f"for declared basis {source_instant} and target {target_instant}." + f"Energy price index {path!r} has no values; the projection " + f"needs {instant}." ) - first_supported = min(supported_instants) - if source_instant < first_supported or target_instant < first_supported: + if instant < min(stated): raise ValueError( - f"Energy index {ENERGY_PRICE_INDEX!r} first supports " - f"{first_supported}; declared basis {source_instant} and target " - f"{target_instant} must be on or after that instant." + f"Energy price index {path!r} starts at {min(stated)}; the " + f"projection needs {instant}." ) - source_index = _uprating_index_value(index, source_instant) - target_index = _uprating_index_value(index, target_instant) - projected = source_index not in (None, 0) and target_index is not None - if projected: - ratio = target_index / source_index - for variable in variables: - current_year.household[variable] = ( - observed_year.household[variable].to_numpy() * ratio + levels.append(_uprating_index_value(index, instant)) + source, target = levels + if not source > 0: + raise ValueError( + f"Energy price index {source_path!r} is {source} at {source_instant}." ) - current_year.energy_price_basis = ( - {"period_type": "fiscal_year", "period_value": int(current_year.time_period)} - if projected - else basis - ) - return current_year + return target / source def uprate_residential_property( diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 8c2e4a57ae..d50b255e83 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -1,9 +1,9 @@ # Energy variables use the declaration-aware projector in economic_assumptions.py, # rather than an unconditional mapping here. Unlabelled dataset inputs remain # flat: legacy builds can already carry FY2026-27 prices despite a 2024 data year. -# An explicit energy_price_basis opts into the variables' existing annual OBR-CPI -# assumption from the declared price year. This does not supply an energy-specific -# forecast or automatically label existing private Microcosm artifacts. +# An explicit energy_price_basis opts into the household energy price indices +# (gov.economic_assumptions.energy_prices) from the declared price year. This does +# not automatically label existing private Microcosm artifacts. # liquid_fuel_consumption and solid_fuel_consumption (heating oil, LPG, coal # and wood) are not in that group: Ofgem's price cap does not cover them, so # they are uprated by CPI from the data year below, like other LCFS spending. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index b4e58b8cfc..c1b8ccabf8 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -128,30 +128,34 @@ projection through 2030 retains its previous handling; correcting those earlier mapping omissions is a separate change. `domestic_energy_consumption`, `electricity_consumption` and `gas_consumption` -use a separate declaration-aware projector. Unlabelled datasets retain their -stored amounts through the projection horizon, preserving legacy inputs that -already use FY2026-27 prices. A dataset's explicit `energy_price_basis` opts -into the variables' existing cumulative annual OBR-CPI index as an interim -price proxy from its declared price year. The declaration covers only these -three inputs, independently of any other generic uprating exclusions. They -remain outside both the unconditional growth mapping and fallback, so no -second index grows them after 2030. - -The declaration retains `calendar_year` or `fiscal_year` and its integer year. -The model uses the January 1 index level for the declared starting year for -both calendar and fiscal price periods. This shared start-year convention is -an approximation, rather than precise fiscal/calendar price alignment. The -projector applies `I[target year] / I[declared price year]` to the supplied -observation. A requested declared projection raises an error if either -January 1 precedes the selected index's earliest non-null value, including a -midyear start, or if the index has no usable history. Unlabelled inputs and -declarations with no stored energy inputs do not require index history. Each -projected child records its effective fiscal-year basis, while the source -observation keeps its original declaration. - -Generic CPI does not establish constant-consumption energy-price outturns or -an energy-specific forecast. Open [issue #2526](https://github.com/PolicyEngine/policyengine-uk/issues/2526) -tracks energy-specific index alignment and a sourced future assumption. +use a separate declaration-aware projector, `uprate_energy`. Unlabelled +datasets retain their stored amounts through the projection horizon, +preserving legacy inputs that already use FY2026-27 prices. A dataset's +explicit `energy_price_basis` opts into the household energy price indices +(see [Household energy prices](#household-energy-prices)): electricity +spending moves with `energy_prices.*.electricity`, gas spending with +`energy_prices.*.gas` and combined spending with +`energy_prices.*.electricity_gas_and_solid_fuels`. The declaration covers +only these three inputs, independently of any other generic uprating +exclusions. They remain outside both the unconditional growth mapping and +fallback, so no second index grows them after 2030. + +The declaration retains `calendar_year` or `fiscal_year` and its integer year, +and the projector reads the index of that kind: a `calendar_year` 2024 basis +reads the January to December 2024 level, a `fiscal_year` 2024 basis the +April 2024 to March 2025 level. The target is always the fiscal year index, +because model year Y is fiscal year Y–(Y+1). Both annual indices are dated +1 January of their first year. The projector applies `I[target year] / +I[declared price year]` to the supplied observation, so each projected year +is the observation times one ratio, not a chain of yearly ratios. A requested +declared projection raises an error if either 1 January precedes the selected +index's first stated value, or if the index has no values. Unlabelled inputs +and declarations with no stored energy inputs do not require index history. +Each projected child records its effective fiscal-year basis, while the +source observation keeps its original declaration. The variables' own +`uprating` names the same fiscal year indices, so Core's uprating of a +directly supplied amount after its last period agrees with the dataset +projection. See [dataset sources](../../../../docs/engineering/skills/dataset-sources.md) for the declaration and migration contract. Existing metadata-free private @@ -298,6 +302,100 @@ and Class 4 profits limits aligned with them, and raises the secondary threshold by the same rises, up to the last year of `indices.september_cpi_uprating`. +## Household energy prices + +`energy_prices` holds the price indices that project household electricity +and gas spending (see [Dataset projection horizon](#dataset-projection-horizon)). +They price each fuel with VAT at the rate the energy inputs assume, +`gov.simulation.vat.energy_input_price_basis_rate` (5%). `domestic_energy_vat` +divides the inputs by one plus that rate and applies the rate in force, so an +index that included a VAT change would count it twice: once in spending and +again in the tax. Holding VAT at the basis rate takes out every change in VAT +on domestic electricity that the published prices include, the zero rate +from 1 October 2026 and its Budget 2026 extension among them, and a reform to +`gov.hmrc.vat` never moves the indices. + +The other Budget 2026 measures that bear on energy bills stay in. The EFO +gives an inflation effect for the fuel duty changes and the VAT cut +(paragraph 3.10), and none for abolishing the carbon price support or funding +lower contracts for difference levies. It says those measures "are intended +to offset the increase in electricity bills" that the British Industrial +Competitiveness Scheme brings consumers outside energy-intensive industry +(paragraph 3.19). + +### Inputs + +| Parameter | Contents | Source | +|---|---|---| +| `ons` | Monthly CPI indices, 2015 = 100, from January 2015: electricity (D7DT), gas (D7DU) and electricity, gas and solid fuels (DK9U, classes 04.5.1, 04.5.2 and 04.5.4); their annual CPI weights (CJXA, CJXB, A9F4); and, as node metadata, `last_full_quarter`, the last quarter ONS publishes all three months of in every series | ONS MM23 | +| `obr.utilities` | CPI utilities (electricity and gas) inflation, year on year, by quarter, from the first quarter ONS has not published in full | EFO economy tables, CPI category inflation (Table 1.19 in October 2026) | +| `consumption_shares` | Each calendar quarter's share of a year's domestic electricity and of domestic gas consumption, the mean over the complete years from 2015 | DESNZ Energy Trends ET 5.2 and ET 4.1 | +| `embedded_electricity_vat` | The VAT rate on domestic electricity that the published prices and forecast include, in Great Britain and in Northern Ireland, and Northern Ireland's share of UK domestic electricity consumption (2.69% in 2024), which weights them | SI 2026/987, Budget 2026 and its policy costings, EFO paragraph 3.10, DESNZ subnational consumption statistics | +| `held_flat` | Whether the annual indices hold the previous year's level | `no_economic_assumptions` sets it | + +`policyengine_uk/utils/import_energy_prices.py` writes the first three; +maintainers edit the last two by hand. `embedded_electricity_vat` describes the +source data, not policy: it follows the VAT the ONS prices include and the +OBR assumed, which a reform to `gov.hmrc.vat` does not change. + +### Method + +`create_energy_price_indices.py`, a generation step that runs whenever +policyengine-uk processes the parameters, builds three series for each of +`electricity`, `gas` and `electricity_gas_and_solid_fuels`: + +- `quarterly`, 2015Q1 to 2042Q1. To `last_full_quarter` (2026Q3 in the + October 2026 inputs), the mean of the monthly + index, each month over the VAT factor its price includes, + `1 + s × ((1 + r) / (1 + b) − 1)`. Here `r` is the UK rate on domestic + electricity in the published price on the 15th of the month (the mean of + the Great Britain and Northern Ireland rates, weighting each by its + consumption), `b` the basis + rate and `s` electricity's share of the series' CPI weight: 1 for + electricity, 0 for gas and CJXA / A9F4 for the combined series. After that, + the level a year earlier times one plus the OBR's utilities inflation net + of the change in electricity VAT, `(1 + g) × u(q − 4) / u(q) − 1`, where + `u` is the same factor for electricity's share of utilities, + CJXA / (CJXA + CJXB), as the mean over the quarter's months. After the OBR's + last quarter (2032Q1), its last rate holds. +- `calendar_year`, January to December, and `fiscal_year`, April to March, + 2015 to 2041, each at 1 January of its first year. Each averages the + year's four quarterly levels, weighting each by its share of the year's + consumption; the combined series weights the electricity and gas shares by + their CPI weights. + +Where `held_flat` is true for a year, both annual series take the previous +year's level. `no_economic_assumptions` sets it from its first input year as +it zeroes the growth series, and since it is a raw input, every later rebuild +of the indices keeps it. + +The generator reads every input at the month or quarter it applies to, not +at its stated dates, so a reform can state one value for a year or a span of +quarters: `{"gov.economic_assumptions.energy_prices.obr.utilities": {"2027": +0.1}}` sets utilities inflation to 10% in each quarter of 2027 (through +Core's `Reform.from_dict` a bare year applies from that year onward). +`Parameter.update` with a start and no end removes the stated values after +the start, and its value then sets every month or quarter after it. The +generator reads the ONS prices only to the end of `last_full_quarter` and +`obr.utilities` only after it, and a reform cannot move that boundary. A month or quarter with no value, +such as one a reform sets to `None`, stops generation. A forecast quarter +grows from the level a year earlier, so a reform to the ONS prices also moves +the forecast levels, though not their growth. + +### Limits + +- The OBR forecasts utilities only, so after the ONS outturn electricity and + gas grow at the same rate: each index is its fuel's own price to there and + a utilities proxy after it. The combined series includes solid fuels in its + outturn and not in its forecast. +- The indices are prices, so a projection holds each household's consumption + fixed. +- The seasonal shares are fixed averages, not each year's, and Northern + Ireland's share of consumption stays at 2024's. The share matters only + while the two VAT rates differ, October 2026 to March 2027. +- The OBR's rates are year on year, so each forecast quarter rests on the + level four quarters before it. + ## Refreshing after a new EFO 1. Replace the forecast block in each series with values from the new EFO @@ -349,6 +447,27 @@ threshold by the same rises, up to the last year of `--skip-receipts-growth` only to leave them on the previous EFO. 7. If the new EFO runs past `PROJECTION_END_YEAR`, move it on (see [Dataset projection horizon](#dataset-projection-horizon)). +8. Refresh the household energy price inputs (see + [Household energy prices](#household-energy-prices)) from the new EFO's + economy tables, the MM23 release of the same month and the latest Energy + Trends tables. The importer records the last quarter ONS publishes in + full as `last_full_quarter`, takes the OBR forecast from the quarter after + it, and stops before writing anything if an MM23 series has a gap or a + year without its CPI weight, if the series come from different releases or + if Table 1.19 has no quarter after the ONS outturn. Then check + `embedded_electricity_vat.yaml` against the VAT on domestic electricity + the EFO assumes. Replace the cut-down sources in + `policyengine_uk/tests/fixtures/energy_prices/` and their SHA-256s, and + update the outturn quarter and the hand-computed 2027Q1 and 2028Q3 checks + in `policyengine_uk/tests/test_energy_price_indices.py`. + + ```bash + uv run python -m policyengine_uk.utils.import_energy_prices \ + --mm23 mm23.csv \ + --obr-economy efo-october-2026-detailed-forecast-tables-economy.xlsx \ + --electricity-trends ET_5.2_SEP_26.xlsx \ + --gas-trends ET_4.1_SEP_26.xlsx + ``` For example, for March 2026: diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py new file mode 100644 index 0000000000..e3f1426fec --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py @@ -0,0 +1,279 @@ +"""Household electricity and gas price indices, with VAT at the input basis rate. + +Builds ``gov.economic_assumptions.energy_prices.quarterly``, +``.calendar_year`` and ``.fiscal_year`` from the published inputs under +``gov.economic_assumptions.energy_prices`` (see the README beside this file). +Each index prices its fuel with VAT at the rate the electricity and gas inputs +assume (``gov.simulation.vat.energy_input_price_basis_rate``), so a VAT +change, in the published prices or in a reform, never moves it. + +Quarterly levels, from the first quarter of 2015: + +- to the last quarter ONS publishes in full (``last_full_quarter`` in the + ``ons`` metadata, which the importer writes), the mean of the monthly CPI + index, each month divided by the VAT factor the price includes: + ``1 + s * ((1 + r) / (1 + b) - 1)``, where ``r`` is the UK electricity VAT + rate in the published price on the 15th (``embedded_electricity_vat``, the + Great Britain and Northern Ireland rates weighted by consumption), ``b`` the + basis rate and ``s`` electricity's share of the series' CPI weight (1 for + electricity, 0 for gas); +- after that, the level a year earlier grown by the OBR's utilities + inflation for the quarter with the change in electricity VAT taken out: + ``(1 + g) * u(q - 4) / u(q) - 1``, where ``u`` is the same factor for + electricity's share of the utilities weight (electricity and gas), averaged + over the quarter's months. Past the OBR's last quarter the last rate holds. + +The generator reads every input at the month or quarter it applies to, not +at its stated dates, so a reform keyed by year (one value for a calendar +year) applies to each month or quarter of it, and one with no end, which +removes the stated values after its start, to every month or quarter after +it. A month or quarter with no value is an error. + +The OBR forecasts utilities only, so electricity and gas grow at the same +rate after the ONS outturn: each index is the fuel's own price to there and a +utilities proxy after it. + +Annual levels average a year's four quarters, each weighted by its share of +the year's domestic consumption (``consumption_shares``; for electricity, gas +and solid fuels, electricity's and gas's shares weighted by their CPI +weights). ``calendar_year`` averages January to December, ``fiscal_year`` +April to March: model year 2026 is fiscal year 2026-27. Both are dated +1 January. Where ``held_flat`` is true for a year, as ``no_economic_assumptions`` +sets it, the annual levels take the previous year's. +""" + +import re + +from policyengine_core.parameters import Parameter, ParameterNode + +FIRST_YEAR = 2015 +# The last calendar and fiscal year of the annual indices; fiscal year 2041 +# needs the first quarter of 2042. +LAST_YEAR = 2041 +SERIES = { + "electricity": "electricity", + "gas": "gas", + "electricity_gas_and_solid_fuels": "electricity, gas and solid fuels", +} + + +def _next(quarter): + year, number = quarter + return (year + 1, 1) if number == 4 else (year, number + 1) + + +def _year_earlier(quarter): + return quarter[0] - 1, quarter[1] + + +def _quarters(first, last): + quarters = [first] + while quarters[-1] < last: + quarters.append(_next(quarters[-1])) + return quarters + + +def _months(quarter): + year, number = quarter + return [(year, month) for month in range(3 * number - 2, 3 * number + 1)] + + +def _start(quarter): + return f"{quarter[0]:04d}-{3 * quarter[1] - 2:02d}-01" + + +def _label(quarter): + return f"{quarter[0]}Q{quarter[1]}" + + +def _stated(parameter): + """The dates of a series' stated values, leaving out removed ones.""" + return [ + entry.instant_str for entry in parameter.values_list if entry.value is not None + ] + + +class EnergyPriceInputs: + """The published inputs, read from a raw parameter tree.""" + + def __init__(self, parameters: ParameterNode): + self.prices = parameters.gov.economic_assumptions.energy_prices + self.basis_rate = parameters.gov.simulation.vat.energy_input_price_basis_rate + last = str(self.prices.ons.metadata.get("last_full_quarter")) + match = re.fullmatch(r"(\d{4})Q([1-4])", last) + if match is None: + raise ValueError( + "The ONS prices must state the last quarter ONS publishes in " + f"full as last_full_quarter metadata, such as 2026Q3, not {last}." + ) + self.last_outturn = int(match[1]), int(match[2]) + if self.last_outturn < (FIRST_YEAR, 4): + raise ValueError( + f"ONS prices must cover the four quarters of {FIRST_YEAR}." + ) + stated = _stated(self.prices.obr.utilities) + if not stated: + raise ValueError("The OBR utilities inflation forecast has no values.") + self.forecast_start = _next(self.last_outturn) + first = int(min(stated)[:4]), (int(min(stated)[5:7]) + 2) // 3 + if first > self.forecast_start: + raise ValueError( + f"The OBR utilities forecast starts in {_label(first)}, after " + f"{_label(self.forecast_start)}, the first quarter without three " + "months of ONS prices." + ) + + def ons_price(self, key: str, year: int, month: int) -> float: + """The ONS CPI index for a series in a month.""" + price = self.prices.ons.get_child(key)(f"{year:04d}-{month:02d}-01") + if price is None: + raise ValueError(f"ONS {key} prices have no value for {year}-{month:02d}.") + return price + + def electricity_vat_factor(self, year: int, month: int) -> float: + """Published electricity price over its price at the basis rate.""" + day = f"{year:04d}-{month:02d}-15" + vat = self.prices.embedded_electricity_vat + ni = vat.northern_ireland_share(day) + rate = (1 - ni) * vat.great_britain(day) + ni * vat.northern_ireland(day) + return (1 + rate) / (1 + self.basis_rate(day)) + + def electricity_share(self, key: str, year: int) -> float: + """Electricity's share of a series' CPI weight in a year.""" + if key == "electricity": + return 1.0 + if key == "gas": + return 0.0 + weights = self.prices.ons.weights + instant = f"{year}-01-01" + return weights.electricity(instant) / weights.get_child(key)(instant) + + def utilities_electricity_share(self, year: int) -> float: + weights = self.prices.ons.weights + instant = f"{year}-01-01" + electricity = weights.electricity(instant) + return electricity / (electricity + weights.gas(instant)) + + def utilities_vat_factor(self, quarter) -> float: + """Published utilities price over its price at the basis rate.""" + factor = sum(self.electricity_vat_factor(*month) for month in _months(quarter)) + share = self.utilities_electricity_share(quarter[0]) + return 1 + share * (factor / 3 - 1) + + def utilities_growth(self, quarter) -> float: + """OBR utilities inflation for a quarter, before the VAT change.""" + growth = self.prices.obr.utilities(_start(quarter)) + if growth is None: + raise ValueError( + f"The OBR utilities forecast has no value for {_label(quarter)}." + ) + return (1 + growth) * self.utilities_vat_factor( + _year_earlier(quarter) + ) / self.utilities_vat_factor(quarter) - 1 + + def consumption_shares(self, key: str, year: int) -> list: + """Each quarter's weight in a year's average, summing to one.""" + instant = f"{year}-01-01" + + def fuel(name): + node = self.prices.consumption_shares.get_child(name) + return [node.get_child(f"q{n}")(instant) for n in range(1, 5)] + + if key == "electricity_gas_and_solid_fuels": + weights = self.prices.ons.weights + electricity = weights.electricity(instant) + gas = weights.gas(instant) + shares = [ + (electricity * e + gas * g) / (electricity + gas) + for e, g in zip(fuel("electricity"), fuel("gas")) + ] + else: + shares = fuel(key) + total = sum(shares) + return [share / total for share in shares] + + +def quarterly_levels(inputs: EnergyPriceInputs, key: str) -> dict: + """Quarterly levels at the basis rate, 2015 = 100 before the adjustment.""" + levels = {} + for quarter in _quarters((FIRST_YEAR, 1), (LAST_YEAR + 1, 1)): + if quarter <= inputs.last_outturn: + share = inputs.electricity_share(key, quarter[0]) + levels[quarter] = ( + sum( + inputs.ons_price(key, *month) + / (1 + share * (inputs.electricity_vat_factor(*month) - 1)) + for month in _months(quarter) + ) + / 3 + ) + else: + levels[quarter] = levels[_year_earlier(quarter)] * ( + 1 + inputs.utilities_growth(quarter) + ) + return levels + + +def annual_levels(inputs: EnergyPriceInputs, key: str, quarterly: dict): + """Consumption-weighted calendar and fiscal year levels, held where set.""" + calendar, fiscal = {}, {} + for year in range(FIRST_YEAR, LAST_YEAR + 1): + q1, q2, q3, q4 = inputs.consumption_shares(key, year) + calendar[year] = sum( + share * quarterly[(year, n)] for n, share in enumerate((q1, q2, q3, q4), 1) + ) + fiscal[year] = ( + q2 * quarterly[(year, 2)] + + q3 * quarterly[(year, 3)] + + q4 * quarterly[(year, 4)] + + q1 * quarterly[(year + 1, 1)] + ) + for year in range(FIRST_YEAR + 1, LAST_YEAR + 1): + if inputs.prices.held_flat(f"{year}-01-01"): + calendar[year] = calendar[year - 1] + fiscal[year] = fiscal[year - 1] + return calendar, fiscal + + +def add_energy_price_indices(parameters: ParameterNode) -> ParameterNode: + inputs = EnergyPriceInputs(parameters) + prices = inputs.prices + descriptions = { + "quarterly": ( + "Quarterly price level of household {}, with VAT at the energy " + "input basis rate, by calendar quarter." + ), + "calendar_year": ( + "Price level of household {} in a calendar year, January to " + "December, with VAT at the energy input basis rate." + ), + "fiscal_year": ( + "Price level of household {} in a model year, the fiscal year " + "April to March, with VAT at the energy input basis rate." + ), + } + nodes = {} + for name in descriptions: + nodes[name] = ParameterNode(name=f"{prices.name}.{name}", data={}) + prices.add_child(name, nodes[name]) + for key, label in SERIES.items(): + quarterly = quarterly_levels(inputs, key) + calendar, fiscal = annual_levels(inputs, key, quarterly) + series = { + "quarterly": {_start(q): value for q, value in quarterly.items()}, + "calendar_year": {f"{y}-01-01": value for y, value in calendar.items()}, + "fiscal_year": {f"{y}-01-01": value for y, value in fiscal.items()}, + } + for name, values in series.items(): + nodes[name].add_child( + key, + Parameter( + name=f"{prices.name}.{name}.{key}", + data={ + "description": descriptions[name].format(label), + "metadata": {"label": f"{label.capitalize()} price index"}, + "values": values, + }, + ), + ) + return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml new file mode 100644 index 0000000000..fa6e83c84f --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml @@ -0,0 +1,95 @@ +# policyengine_uk/utils/import_energy_prices.py writes this file from +# DESNZ Energy Trends. Rerun the importer on new tables; do not edit by hand. +description: Shares of a year's domestic energy consumption in each calendar quarter, which weight quarterly price levels into annual ones. +electricity: + description: Mean share of each calendar quarter in a year's UK domestic electricity consumption, 2015 to 2025. + q1: + description: Share of a year's domestic electricity consumption that households use in January to March. + metadata: + unit: /1 + label: Domestic electricity consumption share, quarter 1 + period: year + reference: + - title: DESNZ, Energy Trends UK electricity, ET 5.2 (Quarter sheet, Domestic (excluding electric vehicles)), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/electricity-section-5-energy-trends + values: + 2015-01-01: 0.2929 + q2: + description: Share of a year's domestic electricity consumption that households use in April to June. + metadata: + unit: /1 + label: Domestic electricity consumption share, quarter 2 + period: year + reference: + - title: DESNZ, Energy Trends UK electricity, ET 5.2 (Quarter sheet, Domestic (excluding electric vehicles)), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/electricity-section-5-energy-trends + values: + 2015-01-01: 0.2242 + q3: + description: Share of a year's domestic electricity consumption that households use in July to September. + metadata: + unit: /1 + label: Domestic electricity consumption share, quarter 3 + period: year + reference: + - title: DESNZ, Energy Trends UK electricity, ET 5.2 (Quarter sheet, Domestic (excluding electric vehicles)), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/electricity-section-5-energy-trends + values: + 2015-01-01: 0.2051 + q4: + description: Share of a year's domestic electricity consumption that households use in October to December. + metadata: + unit: /1 + label: Domestic electricity consumption share, quarter 4 + period: year + reference: + - title: DESNZ, Energy Trends UK electricity, ET 5.2 (Quarter sheet, Domestic (excluding electric vehicles)), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/electricity-section-5-energy-trends + values: + 2015-01-01: 0.2778 +gas: + description: Mean share of each calendar quarter in a year's UK domestic gas consumption, 2015 to 2025. + q1: + description: Share of a year's domestic gas consumption that households use in January to March. + metadata: + unit: /1 + label: Domestic gas consumption share, quarter 1 + period: year + reference: + - title: DESNZ, Energy Trends UK gas, ET 4.1 (Quarter (GWh) sheet, Domestic), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/gas-section-4-energy-trends + values: + 2015-01-01: 0.4250 + q2: + description: Share of a year's domestic gas consumption that households use in April to June. + metadata: + unit: /1 + label: Domestic gas consumption share, quarter 2 + period: year + reference: + - title: DESNZ, Energy Trends UK gas, ET 4.1 (Quarter (GWh) sheet, Domestic), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/gas-section-4-energy-trends + values: + 2015-01-01: 0.1642 + q3: + description: Share of a year's domestic gas consumption that households use in July to September. + metadata: + unit: /1 + label: Domestic gas consumption share, quarter 3 + period: year + reference: + - title: DESNZ, Energy Trends UK gas, ET 4.1 (Quarter (GWh) sheet, Domestic), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/gas-section-4-energy-trends + values: + 2015-01-01: 0.0799 + q4: + description: Share of a year's domestic gas consumption that households use in October to December. + metadata: + unit: /1 + label: Domestic gas consumption share, quarter 4 + period: year + reference: + - title: DESNZ, Energy Trends UK gas, ET 4.1 (Quarter (GWh) sheet, Domestic), mean of 2015 to 2025 + href: https://www.gov.uk/government/statistics/gas-section-4-energy-trends + values: + 2015-01-01: 0.3309 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/embedded_electricity_vat.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/embedded_electricity_vat.yaml new file mode 100644 index 0000000000..d28f9d59af --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/embedded_electricity_vat.yaml @@ -0,0 +1,46 @@ +description: VAT rates on domestic electricity that the published ONS prices and the OBR utilities forecast include. The energy price indices divide them out, so the indices price electricity at the energy input VAT basis rate. This describes the source data, not policy; a reform to VAT is a change to gov.hmrc.vat, not to this. +great_britain: + description: VAT rate on domestic electricity in Great Britain included in the ONS prices and the OBR forecast. + metadata: + unit: /1 + label: VAT rate in published electricity prices, Great Britain + period: month + reference: + - title: The Value Added Tax (Supplies of Domestic Electricity) Order 2026 (SI 2026/987), article 3 + href: https://www.legislation.gov.uk/uksi/2026/987/contents/made + - title: Budget 2026 policy costings, page 12 (zero rate from 1 October 2026 until 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=12 + - title: OBR, Economic and fiscal outlook, October 2026, paragraph 3.10 (the VAT cut is in the inflation forecast) + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + values: + 1997-09-01: 0.05 + 2026-10-01: 0 + 2027-07-01: 0.05 +northern_ireland: + description: VAT rate on domestic electricity in Northern Ireland included in the ONS prices and the OBR forecast. + metadata: + unit: /1 + label: VAT rate in published electricity prices, Northern Ireland + period: month + reference: + - title: Budget 2026, paragraph 6.210 (the Budget expects the extended relief to apply UK-wide from April 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1ec57bdb574cf1c9b3f12/E03668986_HMT_Budget_2026_ELAY__2_.pdf#page=138 + - title: Budget 2026 policy costings, page 12 (until 1 July 2027) + href: https://assets.publishing.service.gov.uk/media/6ae1883fe4367e5c8b0467df/AB26_Policy_Costing.pdf#page=12 + values: + 1997-09-01: 0.05 + 2027-04-01: 0 + 2027-07-01: 0.05 +northern_ireland_share: + description: Northern Ireland's share of UK domestic electricity consumption, which weights the two VAT rates into the UK rate in the published prices. The 2024 share, used for every year; it only matters while the rates differ. + metadata: + unit: /1 + label: Northern Ireland share of UK domestic electricity consumption + period: year + reference: + - title: DESNZ, Subnational electricity consumption statistics 2005 to 2024 (2024, Great Britain, all domestic consumption 97,503.6 GWh) + href: https://www.gov.uk/government/statistics/regional-and-local-authority-electricity-consumption-statistics + - title: DESNZ, Northern Ireland subnational electricity consumption 2009 to 2024 (2024, all domestic consumption 2,695.7 GWh) + href: https://www.gov.uk/government/statistics/sub-national-electricity-consumption-statistics-in-northern-ireland + values: + 2015-01-01: 0.0269 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/held_flat.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/held_flat.yaml new file mode 100644 index 0000000000..94a32df6b2 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/held_flat.yaml @@ -0,0 +1,7 @@ +description: Whether the annual energy price indices hold the previous year's level. The no_economic_assumptions scenario sets this from its first input year, as it zeroes the growth series, so energy prices stay flat in projected years. +metadata: + unit: bool + label: Hold energy prices flat + period: year +values: + 2015-01-01: false diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml new file mode 100644 index 0000000000..475902d1f5 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml @@ -0,0 +1,10 @@ +metadata: + # The ONS series are monthly, the OBR forecast and the quarterly levels + # quarterly, and the annual indices one value per year dated 1 January, + # which each reads at its own dates; processing annualises none of them to + # fiscal years. A bare-year reform key names the calendar year + # (policyengine_uk.utils.dated_reforms). Leaves see only the node metadata + # that propagation copies to them. + preserve_calendar_dates: true + calendar_year_series: true + propagate_metadata_to_children: true diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml new file mode 100644 index 0000000000..23db4d2398 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml @@ -0,0 +1,35 @@ +# policyengine_uk/utils/import_energy_prices.py writes this file from the +# OBR economy tables. Rerun the importer on a new EFO; do not edit by hand. +description: OBR forecasts that the household energy price indices build on. +utilities: + description: OBR forecast of CPI utilities inflation (electricity and gas), year on year, by calendar quarter, from the first quarter ONS has not published in full (2026Q4). The OBR's published rate, so it includes the measures the OBR forecast includes; the generation step removes the electricity VAT change. + metadata: + unit: /1 + label: OBR forecast of CPI utilities inflation + period: quarter + reference: + - title: OBR, Economic and fiscal outlook, October 2026, detailed forecast tables (economy), Table 1.19 + href: https://obr.uk/efo/economic-and-fiscal-outlook-october-2026/ + values: + 2026-10-01: 0.045313 + 2027-01-01: 0.113241 + 2027-04-01: 0.190038 + 2027-07-01: 0.037959 + 2027-10-01: -0.011437 + 2028-01-01: -0.105705 + 2028-04-01: -0.112697 + 2028-07-01: -0.070888 + 2028-10-01: -0.053906 + 2029-01-01: -0.029833 + 2029-04-01: 0.030000 + 2029-07-01: 0.039129 + 2029-10-01: 0.043442 + 2030-01-01: 0.049671 + 2030-04-01: 0.015595 + 2030-07-01: 0.015609 + 2030-10-01: 0.016184 + 2031-01-01: 0.017247 + 2031-04-01: 0.019232 + 2031-07-01: 0.019247 + 2031-10-01: 0.019256 + 2032-01-01: 0.019263 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml new file mode 100644 index 0000000000..875d827192 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml @@ -0,0 +1,523 @@ +# policyengine_uk/utils/import_energy_prices.py writes this file from ONS MM23. +# Rerun the importer on a new MM23 release; do not edit by hand. +description: ONS consumer prices index series that the household energy price indices build on. +metadata: + # The last quarter ONS publishes all three months of in every series. + # The indices use the prices to its end and the OBR forecast after it; + # a reform to the prices cannot move it. + last_full_quarter: 2026Q3 +electricity: + description: ONS CPI index for electricity (class 04.5.1, series D7DT), monthly, 2015 = 100. The prices include VAT at the rate in force. + metadata: + label: CPI index for electricity (ONS D7DT) + period: month + reference: + - title: ONS, Consumer price inflation time series (MM23), series D7DT, released 21 October 2026 + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/d7dt/mm23 + values: + 2015-01-01: 100.2 + 2015-02-01: 100.2 + 2015-03-01: 100.2 + 2015-04-01: 100.0 + 2015-05-01: 100.0 + 2015-06-01: 99.9 + 2015-07-01: 99.9 + 2015-08-01: 99.9 + 2015-09-01: 99.9 + 2015-10-01: 99.9 + 2015-11-01: 99.9 + 2015-12-01: 99.9 + 2016-01-01: 99.9 + 2016-02-01: 99.9 + 2016-03-01: 99.9 + 2016-04-01: 99.7 + 2016-05-01: 99.7 + 2016-06-01: 99.7 + 2016-07-01: 99.7 + 2016-08-01: 99.7 + 2016-09-01: 99.7 + 2016-10-01: 99.7 + 2016-11-01: 99.7 + 2016-12-01: 99.7 + 2017-01-01: 99.7 + 2017-02-01: 99.7 + 2017-03-01: 100.7 + 2017-04-01: 103.2 + 2017-05-01: 107.4 + 2017-06-01: 107.4 + 2017-07-01: 108.7 + 2017-08-01: 108.7 + 2017-09-01: 108.7 + 2017-10-01: 111.1 + 2017-11-01: 111.1 + 2017-12-01: 111.1 + 2018-01-01: 111.1 + 2018-02-01: 111.1 + 2018-03-01: 111.1 + 2018-04-01: 112.1 + 2018-05-01: 112.2 + 2018-06-01: 114.7 + 2018-07-01: 116.7 + 2018-08-01: 116.7 + 2018-09-01: 118.8 + 2018-10-01: 121.2 + 2018-11-01: 121.2 + 2018-12-01: 121.2 + 2019-01-01: 115.3 + 2019-02-01: 115.4 + 2019-03-01: 115.4 + 2019-04-01: 127.9 + 2019-05-01: 127.9 + 2019-06-01: 127.9 + 2019-07-01: 127.9 + 2019-08-01: 127.9 + 2019-09-01: 127.9 + 2019-10-01: 125.2 + 2019-11-01: 125.2 + 2019-12-01: 125.2 + 2020-01-01: 125.2 + 2020-02-01: 125.2 + 2020-03-01: 125.2 + 2020-04-01: 125.4 + 2020-05-01: 125.4 + 2020-06-01: 125.4 + 2020-07-01: 125.3 + 2020-08-01: 125.3 + 2020-09-01: 125.3 + 2020-10-01: 121.2 + 2020-11-01: 121.2 + 2020-12-01: 121.2 + 2021-01-01: 121.2 + 2021-02-01: 121.2 + 2021-03-01: 121.2 + 2021-04-01: 132.3 + 2021-05-01: 132.3 + 2021-06-01: 132.3 + 2021-07-01: 132.5 + 2021-08-01: 132.5 + 2021-09-01: 132.5 + 2021-10-01: 144.0 + 2021-11-01: 144.0 + 2021-12-01: 144.0 + 2022-01-01: 144.5 + 2022-02-01: 144.5 + 2022-03-01: 144.5 + 2022-04-01: 203.2 + 2022-05-01: 203.2 + 2022-06-01: 203.2 + 2022-07-01: 204.1 + 2022-08-01: 204.1 + 2022-09-01: 204.1 + 2022-10-01: 238.6 + 2022-11-01: 238.1 + 2022-12-01: 238.1 + 2023-01-01: 240.9 + 2023-02-01: 240.9 + 2023-03-01: 240.9 + 2023-04-01: 238.3 + 2023-05-01: 238.3 + 2023-06-01: 238.3 + 2023-07-01: 217.7 + 2023-08-01: 217.7 + 2023-09-01: 217.7 + 2023-10-01: 201.4 + 2023-11-01: 201.4 + 2023-12-01: 201.4 + 2024-01-01: 209.6 + 2024-02-01: 209.6 + 2024-03-01: 209.6 + 2024-04-01: 188.1 + 2024-05-01: 188.1 + 2024-06-01: 188.1 + 2024-07-01: 175.3 + 2024-08-01: 175.3 + 2024-09-01: 175.3 + 2024-10-01: 188.8 + 2024-11-01: 188.8 + 2024-12-01: 189.0 + 2025-01-01: 191.2 + 2025-02-01: 191.2 + 2025-03-01: 191.1 + 2025-04-01: 196.8 + 2025-05-01: 196.7 + 2025-06-01: 196.7 + 2025-07-01: 189.3 + 2025-08-01: 189.3 + 2025-09-01: 189.4 + 2025-10-01: 193.9 + 2025-11-01: 194.1 + 2025-12-01: 194.2 + 2026-01-01: 201.3 + 2026-02-01: 201.3 + 2026-03-01: 201.9 + 2026-04-01: 184.9 + 2026-05-01: 185.7 + 2026-06-01: 186.5 + 2026-07-01: 193.2 + 2026-08-01: 194.0 + 2026-09-01: 195.1 +gas: + description: ONS CPI index for gas (class 04.5.2, series D7DU), monthly, 2015 = 100. The prices include VAT at the rate in force. + metadata: + label: CPI index for gas (ONS D7DU) + period: month + reference: + - title: ONS, Consumer price inflation time series (MM23), series D7DU, released 21 October 2026 + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/d7du/mm23 + values: + 2015-01-01: 104.1 + 2015-02-01: 103.4 + 2015-03-01: 100.7 + 2015-04-01: 100.6 + 2015-05-01: 100.0 + 2015-06-01: 100.0 + 2015-07-01: 100.0 + 2015-08-01: 100.0 + 2015-09-01: 97.8 + 2015-10-01: 97.8 + 2015-11-01: 97.8 + 2015-12-01: 97.8 + 2016-01-01: 97.8 + 2016-02-01: 97.1 + 2016-03-01: 94.6 + 2016-04-01: 93.3 + 2016-05-01: 93.3 + 2016-06-01: 93.3 + 2016-07-01: 93.3 + 2016-08-01: 93.3 + 2016-09-01: 93.3 + 2016-10-01: 93.3 + 2016-11-01: 93.3 + 2016-12-01: 93.3 + 2017-01-01: 92.9 + 2017-02-01: 92.9 + 2017-03-01: 92.9 + 2017-04-01: 92.4 + 2017-05-01: 92.8 + 2017-06-01: 92.8 + 2017-07-01: 93.3 + 2017-08-01: 93.3 + 2017-09-01: 93.3 + 2017-10-01: 93.3 + 2017-11-01: 93.4 + 2017-12-01: 93.4 + 2018-01-01: 93.5 + 2018-02-01: 93.5 + 2018-03-01: 93.5 + 2018-04-01: 93.8 + 2018-05-01: 93.9 + 2018-06-01: 96.2 + 2018-07-01: 97.3 + 2018-08-01: 97.3 + 2018-09-01: 98.4 + 2018-10-01: 100.4 + 2018-11-01: 100.4 + 2018-12-01: 100.4 + 2019-01-01: 91.9 + 2019-02-01: 92.0 + 2019-03-01: 92.0 + 2019-04-01: 100.5 + 2019-05-01: 100.5 + 2019-06-01: 100.5 + 2019-07-01: 100.5 + 2019-08-01: 100.5 + 2019-09-01: 100.5 + 2019-10-01: 91.7 + 2019-11-01: 91.7 + 2019-12-01: 91.7 + 2020-01-01: 91.7 + 2020-02-01: 91.7 + 2020-03-01: 91.7 + 2020-04-01: 88.4 + 2020-05-01: 88.5 + 2020-06-01: 88.5 + 2020-07-01: 88.4 + 2020-08-01: 88.4 + 2020-09-01: 88.4 + 2020-10-01: 77.6 + 2020-11-01: 77.6 + 2020-12-01: 77.6 + 2021-01-01: 77.6 + 2021-02-01: 77.6 + 2021-03-01: 77.6 + 2021-04-01: 84.9 + 2021-05-01: 84.9 + 2021-06-01: 84.9 + 2021-07-01: 84.9 + 2021-08-01: 84.9 + 2021-09-01: 84.9 + 2021-10-01: 99.4 + 2021-11-01: 99.4 + 2021-12-01: 99.4 + 2022-01-01: 99.5 + 2022-02-01: 99.5 + 2022-03-01: 99.5 + 2022-04-01: 165.9 + 2022-05-01: 165.9 + 2022-06-01: 165.9 + 2022-07-01: 166.1 + 2022-08-01: 166.1 + 2022-09-01: 166.1 + 2022-10-01: 227.4 + 2022-11-01: 227.4 + 2022-12-01: 227.4 + 2023-01-01: 228.2 + 2023-02-01: 228.2 + 2023-03-01: 228.2 + 2023-04-01: 225.9 + 2023-05-01: 225.9 + 2023-06-01: 225.9 + 2023-07-01: 168.9 + 2023-08-01: 168.9 + 2023-09-01: 168.9 + 2023-10-01: 157.0 + 2023-11-01: 157.0 + 2023-12-01: 157.0 + 2024-01-01: 167.7 + 2024-02-01: 167.7 + 2024-03-01: 167.7 + 2024-04-01: 141.3 + 2024-05-01: 141.3 + 2024-06-01: 141.3 + 2024-07-01: 130.3 + 2024-08-01: 130.3 + 2024-09-01: 130.3 + 2024-10-01: 145.5 + 2024-11-01: 145.5 + 2024-12-01: 145.5 + 2025-01-01: 147.4 + 2025-02-01: 147.5 + 2025-03-01: 147.5 + 2025-04-01: 158.6 + 2025-05-01: 158.6 + 2025-06-01: 158.7 + 2025-07-01: 147.3 + 2025-08-01: 147.2 + 2025-09-01: 147.2 + 2025-10-01: 148.6 + 2025-11-01: 148.6 + 2025-12-01: 148.5 + 2026-01-01: 143.4 + 2026-02-01: 142.9 + 2026-03-01: 143.4 + 2026-04-01: 137.1 + 2026-05-01: 137.8 + 2026-06-01: 138.6 + 2026-07-01: 159.0 + 2026-08-01: 159.9 + 2026-09-01: 161.6 +electricity_gas_and_solid_fuels: + description: ONS CPI index for electricity, gas and solid fuels (classes 04.5.1, 04.5.2 and 04.5.4, series DK9U), monthly, 2015 = 100. The prices include VAT at the rate in force. + metadata: + label: CPI index for electricity, gas and solid fuels (ONS DK9U) + period: month + reference: + - title: ONS, Consumer price inflation time series (MM23), series DK9U, released 21 October 2026 + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/dk9u/mm23 + values: + 2015-01-01: 102.1 + 2015-02-01: 101.7 + 2015-03-01: 100.4 + 2015-04-01: 100.3 + 2015-05-01: 99.9 + 2015-06-01: 99.9 + 2015-07-01: 99.9 + 2015-08-01: 99.9 + 2015-09-01: 98.9 + 2015-10-01: 99.0 + 2015-11-01: 99.0 + 2015-12-01: 99.0 + 2016-01-01: 99.0 + 2016-02-01: 98.6 + 2016-03-01: 97.4 + 2016-04-01: 96.7 + 2016-05-01: 96.7 + 2016-06-01: 96.6 + 2016-07-01: 96.6 + 2016-08-01: 96.6 + 2016-09-01: 96.6 + 2016-10-01: 96.7 + 2016-11-01: 96.7 + 2016-12-01: 96.7 + 2017-01-01: 96.5 + 2017-02-01: 96.5 + 2017-03-01: 97.0 + 2017-04-01: 98.0 + 2017-05-01: 100.3 + 2017-06-01: 100.3 + 2017-07-01: 101.2 + 2017-08-01: 101.2 + 2017-09-01: 101.2 + 2017-10-01: 102.4 + 2017-11-01: 102.5 + 2017-12-01: 102.5 + 2018-01-01: 102.5 + 2018-02-01: 102.5 + 2018-03-01: 102.6 + 2018-04-01: 103.2 + 2018-05-01: 103.2 + 2018-06-01: 105.6 + 2018-07-01: 107.1 + 2018-08-01: 107.1 + 2018-09-01: 108.6 + 2018-10-01: 110.8 + 2018-11-01: 110.8 + 2018-12-01: 110.9 + 2019-01-01: 104.0 + 2019-02-01: 104.1 + 2019-03-01: 104.1 + 2019-04-01: 114.3 + 2019-05-01: 114.3 + 2019-06-01: 114.4 + 2019-07-01: 114.4 + 2019-08-01: 114.3 + 2019-09-01: 114.4 + 2019-10-01: 109.0 + 2019-11-01: 109.1 + 2019-12-01: 109.1 + 2020-01-01: 109.1 + 2020-02-01: 109.1 + 2020-03-01: 109.1 + 2020-04-01: 107.7 + 2020-05-01: 107.7 + 2020-06-01: 107.7 + 2020-07-01: 107.6 + 2020-08-01: 107.6 + 2020-09-01: 107.6 + 2020-10-01: 100.4 + 2020-11-01: 100.5 + 2020-12-01: 100.5 + 2021-01-01: 100.5 + 2021-02-01: 100.5 + 2021-03-01: 100.5 + 2021-04-01: 109.5 + 2021-05-01: 109.5 + 2021-06-01: 109.5 + 2021-07-01: 109.6 + 2021-08-01: 109.6 + 2021-09-01: 109.6 + 2021-10-01: 122.3 + 2021-11-01: 122.3 + 2021-12-01: 122.4 + 2022-01-01: 122.8 + 2022-02-01: 122.8 + 2022-03-01: 122.9 + 2022-04-01: 184.3 + 2022-05-01: 184.4 + 2022-06-01: 184.6 + 2022-07-01: 185.2 + 2022-08-01: 185.3 + 2022-09-01: 185.4 + 2022-10-01: 232.7 + 2022-11-01: 232.4 + 2022-12-01: 232.4 + 2023-01-01: 234.3 + 2023-02-01: 234.4 + 2023-03-01: 234.3 + 2023-04-01: 231.9 + 2023-05-01: 231.8 + 2023-06-01: 231.8 + 2023-07-01: 196.2 + 2023-08-01: 196.2 + 2023-09-01: 196.2 + 2023-10-01: 182.1 + 2023-11-01: 182.1 + 2023-12-01: 182.1 + 2024-01-01: 191.4 + 2024-02-01: 191.4 + 2024-03-01: 191.4 + 2024-04-01: 167.8 + 2024-05-01: 167.7 + 2024-06-01: 167.7 + 2024-07-01: 155.8 + 2024-08-01: 155.8 + 2024-09-01: 155.8 + 2024-10-01: 170.0 + 2024-11-01: 170.0 + 2024-12-01: 170.1 + 2025-01-01: 172.2 + 2025-02-01: 172.2 + 2025-03-01: 172.2 + 2025-04-01: 180.3 + 2025-05-01: 180.4 + 2025-06-01: 180.3 + 2025-07-01: 171.1 + 2025-08-01: 171.1 + 2025-09-01: 171.0 + 2025-10-01: 174.2 + 2025-11-01: 174.3 + 2025-12-01: 174.3 + 2026-01-01: 176.3 + 2026-02-01: 176.1 + 2026-03-01: 176.6 + 2026-04-01: 164.3 + 2026-05-01: 165.0 + 2026-06-01: 165.8 + 2026-07-01: 178.0 + 2026-08-01: 178.9 + 2026-09-01: 180.2 +weights: + description: ONS CPI weights of the series, in parts per thousand of the CPI, by calendar year. + electricity: + description: CPI weight of electricity (class 04.5.1, series CJXA), parts per thousand. + metadata: + label: CPI weight of electricity (ONS CJXA) + period: year + reference: + - title: ONS, Consumer price inflation time series (MM23), series CJXA, released 21 October 2026 + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/cjxa/mm23 + values: + 2015-01-01: 20.0000 + 2016-01-01: 17.0000 + 2017-01-01: 17.0000 + 2018-01-01: 17.0000 + 2019-01-01: 17.0000 + 2020-01-01: 18.0000 + 2021-01-01: 19.0000 + 2022-01-01: 20.0000 + 2023-01-01: 27.0000 + 2024-01-01: 23.3142 + 2025-01-01: 19.1488 + 2026-01-01: 20.6065 + gas: + description: CPI weight of gas (class 04.5.2, series CJXB), parts per thousand. + metadata: + label: CPI weight of gas (ONS CJXB) + period: year + reference: + - title: ONS, Consumer price inflation time series (MM23), series CJXB, released 21 October 2026 + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/cjxb/mm23 + values: + 2015-01-01: 20.0000 + 2016-01-01: 16.0000 + 2017-01-01: 15.0000 + 2018-01-01: 15.0000 + 2019-01-01: 12.0000 + 2020-01-01: 13.0000 + 2021-01-01: 12.0000 + 2022-01-01: 14.0000 + 2023-01-01: 20.0000 + 2024-01-01: 16.1597 + 2025-01-01: 13.0967 + 2026-01-01: 10.3002 + electricity_gas_and_solid_fuels: + description: CPI weight of electricity, gas and solid fuels (classes 04.5.1, 04.5.2 and 04.5.4, series A9F4), parts per thousand. + metadata: + label: CPI weight of electricity, gas and solid fuels (ONS A9F4) + period: year + reference: + - title: ONS, Consumer price inflation time series (MM23), series A9F4, released 21 October 2026 + href: https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/a9f4/mm23 + values: + 2015-01-01: 41.0000 + 2016-01-01: 34.0000 + 2017-01-01: 33.0000 + 2018-01-01: 33.0000 + 2019-01-01: 30.0000 + 2020-01-01: 32.0000 + 2021-01-01: 32.0000 + 2022-01-01: 35.0000 + 2023-01-01: 48.0000 + 2024-01-01: 39.9010 + 2025-01-01: 32.6423 + 2026-01-01: 31.1670 diff --git a/policyengine_uk/scenarios/no_economic_assumptions.py b/policyengine_uk/scenarios/no_economic_assumptions.py index 61623d7e43..0524debc2c 100644 --- a/policyengine_uk/scenarios/no_economic_assumptions.py +++ b/policyengine_uk/scenarios/no_economic_assumptions.py @@ -26,6 +26,12 @@ def remove_economic_assumptions(simulation: Simulation): if value_at_instant.instant_str >= cutoff: value_at_instant.value = 0 + # The energy price indices build on price levels and the OBR's quarterly + # forecast rather than these growth series, so this holds them at the year + # before the cutoff directly. It sets an input, like the zeroed growth, so + # a later rebuild of the parameters keeps it. + economic_assumptions.energy_prices.held_flat.update(start=cutoff, value=True) + simulation.tax_benefit_system.process_parameters() diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index 9a9fdb349e..6db84d01f4 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -54,6 +54,9 @@ from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, ) +from policyengine_uk.parameters.gov.economic_assumptions.create_energy_price_indices import ( + add_energy_price_indices, +) from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( add_statutory_uprating_inputs, ) @@ -138,6 +141,7 @@ def _install_parameters(system, parameters: ParameterNode) -> None: add_triple_lock, add_september_cpi_uprating, create_economic_assumption_indices, + add_energy_price_indices, project_savings_credit_threshold, project_pension_age_allowances, add_threshold_indexation, @@ -196,6 +200,7 @@ def process_parameter_tree( - triple lock calculations for state pensions - earnings and September CPI uprating - economic assumption indices + - household energy price indices - Pension Credit threshold and protected Housing Benefit forecast indices - income tax and NICs threshold indexation after the freeze - Universal Credit standard allowance uprating (UC Act 2025 s. 1) diff --git a/policyengine_uk/tests/code_health/test_energy_price_level.py b/policyengine_uk/tests/code_health/test_energy_price_level.py index 0c9dae1331..0a1734b923 100644 --- a/policyengine_uk/tests/code_health/test_energy_price_level.py +++ b/policyengine_uk/tests/code_health/test_energy_price_level.py @@ -2,8 +2,8 @@ Unlabelled files retain flat energy spending: their data year does not establish their price year. An explicit energy_price_basis instead opts into -the conditional annual CPI projector (#2526). A YAML entry would grow the -unlabelled files and could grow a declared observation twice. +the conditional energy price index projector (#2526). A YAML entry would grow +the unlabelled files and could grow a declared observation twice. This guard covers the unconditional YAML map. Core still uses the variables' class-level uprating for direct situation inputs; that separate behavior is diff --git a/policyengine_uk/tests/fixtures/energy_prices/ET_4.1_SEP_26_quarter_gwh.xlsx b/policyengine_uk/tests/fixtures/energy_prices/ET_4.1_SEP_26_quarter_gwh.xlsx new file mode 100644 index 0000000000..1268df4cf3 Binary files /dev/null and b/policyengine_uk/tests/fixtures/energy_prices/ET_4.1_SEP_26_quarter_gwh.xlsx differ diff --git a/policyengine_uk/tests/fixtures/energy_prices/ET_5.2_SEP_26_quarter.xlsx b/policyengine_uk/tests/fixtures/energy_prices/ET_5.2_SEP_26_quarter.xlsx new file mode 100644 index 0000000000..4c8621151e Binary files /dev/null and b/policyengine_uk/tests/fixtures/energy_prices/ET_5.2_SEP_26_quarter.xlsx differ diff --git a/policyengine_uk/tests/fixtures/energy_prices/README.md b/policyengine_uk/tests/fixtures/energy_prices/README.md new file mode 100644 index 0000000000..31d2885789 --- /dev/null +++ b/policyengine_uk/tests/fixtures/energy_prices/README.md @@ -0,0 +1,31 @@ +# Real energy price source fixtures + +The importer `policyengine_uk/utils/import_energy_prices.py` reads these and +must reproduce `ons.yaml`, `obr.yaml` and `consumption_shares.yaml` under +`policyengine_uk/parameters/gov/economic_assumptions/energy_prices` byte for +byte. No test downloads a source file. + +- `mm23_energy_columns.csv`: the row label column and the D7DT, D7DU, DK9U, + CJXA, CJXB and A9F4 columns of ONS MM23 (consumer price inflation time + series), every row, released 21 October 2026 (to September 2026). Other + columns are dropped; the cells kept are unchanged. Source: + , + SHA-256 of the full file + `ed5b20bea47cda76f38519c17d4c48f7d2b003aad5b3c69aa8f4d4f805f6ebc1`. +- `efo_october_2026_economy_table_1_19.xlsx`: the Contents and 1.19 (CPI + category inflation) worksheets of the OBR October 2026 EFO detailed forecast + tables, economy (`efo-october-2026-detailed-forecast-tables-economy.xlsx`, + SHA-256 `96795d1747bb5a4978c7fb768689d5fd31f568e5223db8a94b1bd92ba7f6c118`). + Source: . +- `ET_5.2_SEP_26_quarter.xlsx`: the Quarter worksheet of DESNZ Energy Trends + table ET 5.2, September 2026 (`ET_5.2_SEP_26.xlsx`, SHA-256 + `f3055161b08365a959af349a3d587056b8e9afaf21c0b087986bd204d8ca65c8`). + Source: . +- `ET_4.1_SEP_26_quarter_gwh.xlsx`: the Quarter (GWh) worksheet of DESNZ + Energy Trends table ET 4.1, September 2026 (`ET_4.1_SEP_26.xlsx`, SHA-256 + `3b40e0858ad75ede9fc671c00a1a3bd9a59df8640df504acf54b3afc22301c7c`). + Source: . + +In each workbook only the sheet list and relationships were reduced to those +worksheets; the worksheet XML and shared strings are the originals. They are +minimal archives for the importer's XLSX reader, not complete Excel files. diff --git a/policyengine_uk/tests/fixtures/energy_prices/efo_october_2026_economy_table_1_19.xlsx b/policyengine_uk/tests/fixtures/energy_prices/efo_october_2026_economy_table_1_19.xlsx new file mode 100644 index 0000000000..a446283fb8 Binary files /dev/null and b/policyengine_uk/tests/fixtures/energy_prices/efo_october_2026_economy_table_1_19.xlsx differ diff --git a/policyengine_uk/tests/fixtures/energy_prices/mm23_energy_columns.csv b/policyengine_uk/tests/fixtures/energy_prices/mm23_energy_columns.csv new file mode 100644 index 0000000000..f2d1a03620 --- /dev/null +++ b/policyengine_uk/tests/fixtures/energy_prices/mm23_energy_columns.csv @@ -0,0 +1,1499 @@ +"Title","CPI INDEX 04.5.1 : ELECTRICITY 2015=100","CPI INDEX 04.5.2 : GAS 2015=100","CPI INDEX: Electricity, gas & misc. energy (G) 2015=100","CPI WEIGHTS 04.5.1 : ELECTRICITY","CPI WEIGHTS 04.5.2 : GAS","CPI wts: Electricity, gas & misc. energy GOODS" +"CDID","D7DT","D7DU","DK9U","CJXA","CJXB","A9F4" +"PreUnit","","","","","","" +"Unit","Index, base year = 100","Index, base year = 100","Index, base year = 100","Parts per 1000","Parts per 1000","Parts per 1000" +"Release Date","21-10-2026","21-10-2026","21-10-2026","21-10-2026","21-10-2026","21-10-2026" +"Next release","18 November 2026","18 November 2026","18 November 2026","18 November 2026","18 November 2026","18 November 2026" +"Important Notes","","","","","","" +"1800","","","","","","" +"1801","","","","","","" +"1802","","","","","","" +"1803","","","","","","" +"1804","","","","","","" +"1805","","","","","","" +"1806","","","","","","" +"1807","","","","","","" +"1808","","","","","","" +"1809","","","","","","" +"1810","","","","","","" +"1811","","","","","","" +"1812","","","","","","" +"1813","","","","","","" +"1814","","","","","","" +"1815","","","","","","" +"1816","","","","","","" +"1817","","","","","","" +"1818","","","","","","" +"1819","","","","","","" +"1820","","","","","","" +"1821","","","","","","" +"1822","","","","","","" +"1823","","","","","","" +"1824","","","","","","" +"1825","","","","","","" +"1826","","","","","","" +"1827","","","","","","" +"1828","","","","","","" +"1829","","","","","","" +"1830","","","","","","" +"1831","","","","","","" +"1832","","","","","","" +"1833","","","","","","" +"1834","","","","","","" +"1835","","","","","","" +"1836","","","","","","" +"1837","","","","","","" +"1838","","","","","","" +"1839","","","","","","" +"1840","","","","","","" +"1841","","","","","","" +"1842","","","","","","" +"1843","","","","","","" +"1844","","","","","","" +"1845","","","","","","" +"1846","","","","","","" +"1847","","","","","","" +"1848","","","","","","" +"1849","","","","","","" +"1850","","","","","","" +"1851","","","","","","" +"1852","","","","","","" +"1853","","","","","","" +"1854","","","","","","" +"1855","","","","","","" +"1856","","","","","","" +"1857","","","","","","" +"1858","","","","","","" +"1859","","","","","","" +"1860","","","","","","" +"1861","","","","","","" +"1862","","","","","","" +"1863","","","","","","" +"1864","","","","","","" +"1865","","","","","","" +"1866","","","","","","" +"1867","","","","","","" +"1868","","","","","","" +"1869","","","","","","" +"1870","","","","","","" +"1871","","","","","","" +"1872","","","","","","" +"1873","","","","","","" +"1874","","","","","","" +"1875","","","","","","" +"1876","","","","","","" +"1877","","","","","","" +"1878","","","","","","" +"1879","","","","","","" +"1880","","","","","","" +"1881","","","","","","" +"1882","","","","","","" +"1883","","","","","","" +"1884","","","","","","" +"1885","","","","","","" +"1886","","","","","","" +"1887","","","","","","" +"1888","","","","","","" +"1889","","","","","","" +"1890","","","","","","" +"1891","","","","","","" +"1892","","","","","","" +"1893","","","","","","" +"1894","","","","","","" +"1895","","","","","","" +"1896","","","","","","" +"1897","","","","","","" +"1898","","","","","","" +"1899","","","","","","" +"1900","","","","","","" +"1901","","","","","","" +"1902","","","","","","" +"1903","","","","","","" +"1904","","","","","","" +"1905","","","","","","" +"1906","","","","","","" +"1907","","","","","","" +"1908","","","","","","" +"1909","","","","","","" +"1910","","","","","","" +"1911","","","","","","" +"1912","","","","","","" +"1913","","","","","","" +"1914","","","","","","" +"1915","","","","","","" +"1916","","","","","","" +"1917","","","","","","" +"1918","","","","","","" +"1919","","","","","","" +"1920","","","","","","" +"1921","","","","","","" +"1922","","","","","","" +"1923","","","","","","" +"1924","","","","","","" +"1925","","","","","","" +"1926","","","","","","" +"1927","","","","","","" +"1928","","","","","","" +"1929","","","","","","" +"1930","","","","","","" +"1931","","","","","","" +"1932","","","","","","" +"1933","","","","","","" +"1934","","","","","","" +"1935","","","","","","" +"1936","","","","","","" +"1937","","","","","","" +"1938","","","","","","" +"1939","","","","","","" +"1940","","","","","","" +"1941","","","","","","" +"1942","","","","","","" +"1943","","","","","","" +"1944","","","","","","" +"1945","","","","","","" +"1946","","","","","","" +"1947","","","","","","" +"1948","","","","","","" +"1949","","","","","","" +"1950","","","","","","" +"1951","","","","","","" +"1952","","","","","","" +"1953","","","","","","" +"1954","","","","","","" +"1955","","","","","","" +"1956","","","","","","" +"1957","","","","","","" +"1958","","","","","","" +"1959","","","","","","" +"1960","","","","","","" +"1961","","","","","","" +"1962","","","","","","" +"1963","","","","","","" +"1964","","","","","","" +"1965","","","","","","" +"1966","","","","","","" +"1967","","","","","","" +"1968","","","","","","" +"1969","","","","","","" +"1970","","","","","","" +"1971","","","","","","" +"1972","","","","","","" +"1973","","","","","","" +"1974","","","","","","" +"1975","","","","","","" +"1976","","","","","","" +"1977","","","","","","" +"1978","","","","","","" +"1979","","","","","","" +"1980","","","","","","" +"1981","","","","","","" +"1982","","","","","","" +"1983","","","","","","" +"1984","","","","","","" +"1985","","","","","","" +"1986","","","","","","" +"1987","","","","","","" +"1988","34.9","26.1","30.2","29.0000","24.0000","" +"1989","37.4","27.2","31.8","30.0000","25.0000","" +"1990","40.4","29.1","34.1","28.0000","22.0000","" +"1991","44.5","31.1","37.1","27.0000","20.0000","" +"1992","46.8","31.0","38.1","27.0000","21.0000","" +"1993","46.7","29.9","37.5","27.0000","21.0000","" +"1994","48.2","31.7","39.2","25.0000","20.0000","" +"1995","48.9","32.7","40.1","24.0000","21.0000","" +"1996","48.7","32.8","40.1","22.0000","20.0000","44.0000" +"1997","46.3","32.5","38.9","22.0000","19.0000","43.0000" +"1998","44.2","31.4","37.5","21.0000","19.0000","42.0000" +"1999","43.7","31.4","37.3","20.0000","17.0000","39.0000" +"2000","42.8","30.7","36.6","17.0000","13.0000","31.0000" +"2001","42.4","31.6","36.9","15.0000","11.0000","27.0000" +"2002","42.6","33.6","38.1","14.0000","12.0000","27.0000" +"2003","43.0","34.3","38.6","14.0000","12.0000","27.0000" +"2004","45.6","36.7","41.1","13.0000","11.0000","25.0000" +"2005","50.4","41.9","46.1","14.0000","12.0000","27.0000" +"2006","61.4","55.0","57.9","15.0000","14.0000","30.0000" +"2007","66.3","59.3","62.5","19.0000","18.0000","38.0000" +"2008","76.6","70.8","73.4","17.0000","15.0000","33.0000" +"2009","80.1","80.3","80.1","20.0000","23.0000","44.0000" +"2010","78.1","75.8","76.8","19.0000","25.0000","45.0000" +"2011","83.8","84.1","83.8","19.0000","22.0000","42.0000" +"2012","88.5","92.9","90.7","20.0000","32.0000","53.0000" +"2013","95.1","100.0","97.5","19.0000","26.0000","46.0000" +"2014","100.3","104.7","102.4","21.0000","21.0000","43.0000" +"2015","100.0","100.0","100.0","20.0000","20.0000","41.0000" +"2016","99.8","94.1","97.1","17.0000","16.0000","34.0000" +"2017","106.5","93.0","100.0","17.0000","15.0000","33.0000" +"2018","115.7","96.6","106.2","17.0000","15.0000","33.0000" +"2019","124.1","96.1","110.5","17.0000","12.0000","30.0000" +"2020","124.3","86.5","106.2","18.0000","13.0000","32.0000" +"2021","132.5","86.7","110.5","19.0000","12.0000","32.0000" +"2022","197.5","164.7","181.3","20.0000","14.0000","35.0000" +"2023","224.6","195.0","211.1","27.0000","20.0000","48.0000" +"2024","190.5","146.2","171.2","23.3142","16.1597","39.9010" +"2025","192.8","150.5","174.5","19.1488","13.0967","32.6423" +"2026","","","","20.6065","10.3002","31.1670" +"1948 Q3","","","","","","" +"1948 Q4","","","","","","" +"1949 Q1","","","","","","" +"1949 Q2","","","","","","" +"1949 Q3","","","","","","" +"1949 Q4","","","","","","" +"1950 Q1","","","","","","" +"1950 Q2","","","","","","" +"1950 Q3","","","","","","" +"1950 Q4","","","","","","" +"1951 Q1","","","","","","" +"1951 Q2","","","","","","" +"1951 Q3","","","","","","" +"1951 Q4","","","","","","" +"1952 Q1","","","","","","" +"1952 Q2","","","","","","" +"1952 Q3","","","","","","" +"1952 Q4","","","","","","" +"1953 Q1","","","","","","" +"1953 Q2","","","","","","" +"1953 Q3","","","","","","" +"1953 Q4","","","","","","" +"1954 Q1","","","","","","" +"1954 Q2","","","","","","" +"1954 Q3","","","","","","" +"1954 Q4","","","","","","" +"1955 Q1","","","","","","" +"1955 Q2","","","","","","" +"1955 Q3","","","","","","" +"1955 Q4","","","","","","" +"1956 Q1","","","","","","" +"1956 Q2","","","","","","" +"1956 Q3","","","","","","" +"1956 Q4","","","","","","" +"1957 Q1","","","","","","" +"1957 Q2","","","","","","" +"1957 Q3","","","","","","" +"1957 Q4","","","","","","" +"1958 Q1","","","","","","" +"1958 Q2","","","","","","" +"1958 Q3","","","","","","" +"1958 Q4","","","","","","" +"1959 Q1","","","","","","" +"1959 Q2","","","","","","" +"1959 Q3","","","","","","" +"1959 Q4","","","","","","" +"1960 Q1","","","","","","" +"1960 Q2","","","","","","" +"1960 Q3","","","","","","" +"1960 Q4","","","","","","" +"1961 Q1","","","","","","" +"1961 Q2","","","","","","" +"1961 Q3","","","","","","" +"1961 Q4","","","","","","" +"1962 Q1","","","","","","" +"1962 Q2","","","","","","" +"1962 Q3","","","","","","" +"1962 Q4","","","","","","" +"1963 Q1","","","","","","" +"1963 Q2","","","","","","" +"1963 Q3","","","","","","" +"1963 Q4","","","","","","" +"1964 Q1","","","","","","" +"1964 Q2","","","","","","" +"1964 Q3","","","","","","" +"1964 Q4","","","","","","" +"1965 Q1","","","","","","" +"1965 Q2","","","","","","" +"1965 Q3","","","","","","" +"1965 Q4","","","","","","" +"1966 Q1","","","","","","" +"1966 Q2","","","","","","" +"1966 Q3","","","","","","" +"1966 Q4","","","","","","" +"1967 Q1","","","","","","" +"1967 Q2","","","","","","" +"1967 Q3","","","","","","" +"1967 Q4","","","","","","" +"1968 Q1","","","","","","" +"1968 Q2","","","","","","" +"1968 Q3","","","","","","" +"1968 Q4","","","","","","" +"1969 Q1","","","","","","" +"1969 Q2","","","","","","" +"1969 Q3","","","","","","" +"1969 Q4","","","","","","" +"1970 Q1","","","","","","" +"1970 Q2","","","","","","" +"1970 Q3","","","","","","" +"1970 Q4","","","","","","" +"1971 Q1","","","","","","" +"1971 Q2","","","","","","" +"1971 Q3","","","","","","" +"1971 Q4","","","","","","" +"1972 Q1","","","","","","" +"1972 Q2","","","","","","" +"1972 Q3","","","","","","" +"1972 Q4","","","","","","" +"1973 Q1","","","","","","" +"1973 Q2","","","","","","" +"1973 Q3","","","","","","" +"1973 Q4","","","","","","" +"1974 Q1","","","","","","" +"1974 Q2","","","","","","" +"1974 Q3","","","","","","" +"1974 Q4","","","","","","" +"1975 Q1","","","","","","" +"1975 Q2","","","","","","" +"1975 Q3","","","","","","" +"1975 Q4","","","","","","" +"1976 Q1","","","","","","" +"1976 Q2","","","","","","" +"1976 Q3","","","","","","" +"1976 Q4","","","","","","" +"1977 Q1","","","","","","" +"1977 Q2","","","","","","" +"1977 Q3","","","","","","" +"1977 Q4","","","","","","" +"1978 Q1","","","","","","" +"1978 Q2","","","","","","" +"1978 Q3","","","","","","" +"1978 Q4","","","","","","" +"1979 Q1","","","","","","" +"1979 Q2","","","","","","" +"1979 Q3","","","","","","" +"1979 Q4","","","","","","" +"1980 Q1","","","","","","" +"1980 Q2","","","","","","" +"1980 Q3","","","","","","" +"1980 Q4","","","","","","" +"1981 Q1","","","","","","" +"1981 Q2","","","","","","" +"1981 Q3","","","","","","" +"1981 Q4","","","","","","" +"1982 Q1","","","","","","" +"1982 Q2","","","","","","" +"1982 Q3","","","","","","" +"1982 Q4","","","","","","" +"1983 Q1","","","","","","" +"1983 Q2","","","","","","" +"1983 Q3","","","","","","" +"1983 Q4","","","","","","" +"1984 Q1","","","","","","" +"1984 Q2","","","","","","" +"1984 Q3","","","","","","" +"1984 Q4","","","","","","" +"1985 Q1","","","","","","" +"1985 Q2","","","","","","" +"1985 Q3","","","","","","" +"1985 Q4","","","","","","" +"1986 Q1","","","","","","" +"1986 Q2","","","","","","" +"1986 Q3","","","","","","" +"1986 Q4","","","","","","" +"1987 Q1","","","","","","" +"1987 Q2","","","","","","" +"1987 Q3","","","","","","" +"1987 Q4","","","","","","" +"1988 Q1","33.1","25.2","29.0","","","" +"1988 Q2","34.5","25.9","29.9","","","" +"1988 Q3","35.9","26.7","30.8","","","" +"1988 Q4","35.9","26.7","30.9","","","" +"1989 Q1","35.9","26.7","31.0","","","" +"1989 Q2","37.1","27.1","31.6","","","" +"1989 Q3","38.3","27.5","32.3","","","" +"1989 Q4","38.3","27.5","32.4","","","" +"1990 Q1","38.3","27.7","32.5","","","" +"1990 Q2","40.0","29.2","33.9","","","" +"1990 Q3","41.8","29.6","34.9","","","" +"1990 Q4","41.8","29.8","35.2","","","" +"1991 Q1","41.8","30.5","35.6","","","" +"1991 Q2","43.9","31.0","36.7","","","" +"1991 Q3","46.2","31.5","37.9","","","" +"1991 Q4","46.2","31.5","38.1","","","" +"1992 Q1","46.2","31.5","38.1","","","" +"1992 Q2","46.6","31.5","38.3","","","" +"1992 Q3","47.2","31.0","38.2","","","" +"1992 Q4","47.2","30.2","37.9","","","" +"1993 Q1","47.2","29.9","37.8","","","" +"1993 Q2","46.8","29.9","37.5","","","" +"1993 Q3","46.4","29.9","37.3","","","" +"1993 Q4","46.3","29.9","37.4","","","" +"1994 Q1","45.8","29.9","37.2","","","" +"1994 Q2","48.9","32.3","39.9","","","" +"1994 Q3","49.2","32.3","40.0","","","" +"1994 Q4","48.9","32.3","39.9","","","" +"1995 Q1","48.9","32.5","40.1","","","" +"1995 Q2","48.8","32.8","40.1","","","" +"1995 Q3","48.9","32.8","40.1","","","" +"1995 Q4","48.8","32.8","40.1","","","" +"1996 Q1","48.8","32.8","40.1","","","" +"1996 Q2","48.9","32.8","40.2","","","" +"1996 Q3","48.9","32.8","40.2","","","" +"1996 Q4","48.0","32.8","39.8","","","" +"1997 Q1","47.4","32.8","39.6","","","" +"1997 Q2","47.2","32.7","39.5","","","" +"1997 Q3","46.1","32.5","38.8","","","" +"1997 Q4","44.6","31.9","37.9","","","" +"1998 Q1","44.6","31.4","37.7","","","" +"1998 Q2","44.5","31.4","37.6","","","" +"1998 Q3","43.8","31.4","37.3","","","" +"1998 Q4","43.8","31.4","37.4","","","" +"1999 Q1","43.8","31.6","37.5","","","" +"1999 Q2","43.7","31.3","37.3","","","" +"1999 Q3","43.6","31.3","37.2","","","" +"1999 Q4","43.6","31.3","37.3","","","" +"2000 Q1","43.6","31.3","37.3","","","" +"2000 Q2","43.0","30.5","36.5","","","" +"2000 Q3","42.3","30.5","36.2","","","" +"2000 Q4","42.3","30.6","36.3","","","" +"2001 Q1","42.3","30.7","36.4","","","" +"2001 Q2","42.3","31.9","37.0","","","" +"2001 Q3","42.4","32.0","37.1","","","" +"2001 Q4","42.5","32.0","37.2","","","" +"2002 Q1","42.5","33.4","37.9","","","" +"2002 Q2","42.6","33.6","38.0","","","" +"2002 Q3","42.6","33.7","38.1","","","" +"2002 Q4","42.6","33.7","38.2","","","" +"2003 Q1","42.7","33.8","38.2","","","" +"2003 Q2","42.8","34.1","38.4","","","" +"2003 Q3","43.2","34.5","38.8","","","" +"2003 Q4","43.4","34.7","39.1","","","" +"2004 Q1","44.1","35.4","39.7","","","" +"2004 Q2","45.2","36.3","40.7","","","" +"2004 Q3","45.8","36.6","41.1","","","" +"2004 Q4","47.2","38.4","42.8","","","" +"2005 Q1","49.2","40.6","44.8","","","" +"2005 Q2","49.8","41.0","45.3","","","" +"2005 Q3","50.2","41.4","45.8","","","" +"2005 Q4","52.6","44.7","48.6","","","" +"2006 Q1","54.7","46.9","50.7","","","" +"2006 Q2","60.4","53.5","56.7","","","" +"2006 Q3","63.4","57.1","59.9","","","" +"2006 Q4","66.9","62.7","64.4","","","" +"2007 Q1","68.3","64.3","65.9","","","" +"2007 Q2","67.0","60.3","63.3","","","" +"2007 Q3","65.0","56.6","60.6","","","" +"2007 Q4","64.8","55.9","60.2","","","" +"2008 Q1","69.7","60.9","65.1","","","" +"2008 Q2","73.5","64.5","68.7","","","" +"2008 Q3","78.2","73.6","75.6","","","" +"2008 Q4","85.1","84.1","84.4","","","" +"2009 Q1","84.8","82.9","83.7","","","" +"2009 Q2","79.0","79.7","79.3","","","" +"2009 Q3","78.5","79.3","78.8","","","" +"2009 Q4","78.2","79.2","78.7","","","" +"2010 Q1","78.2","77.8","77.9","","","" +"2010 Q2","78.0","74.8","76.2","","","" +"2010 Q3","78.0","74.8","76.2","","","" +"2010 Q4","78.2","75.9","76.9","","","" +"2011 Q1","80.4","79.3","79.8","","","" +"2011 Q2","81.2","79.6","80.3","","","" +"2011 Q3","83.8","84.0","83.8","","","" +"2011 Q4","89.7","93.4","91.5","","","" +"2012 Q1","88.8","93.0","90.9","","","" +"2012 Q2","87.7","91.9","89.8","","","" +"2012 Q3","87.7","91.9","89.8","","","" +"2012 Q4","89.9","94.7","92.3","","","" +"2013 Q1","94.1","99.2","96.6","","","" +"2013 Q2","94.5","99.5","96.9","","","" +"2013 Q3","94.8","99.5","97.1","","","" +"2013 Q4","96.9","101.7","99.2","","","" +"2014 Q1","100.9","105.2","102.9","","","" +"2014 Q2","100.2","104.6","102.3","","","" +"2014 Q3","100.2","104.6","102.2","","","" +"2014 Q4","100.2","104.6","102.3","","","" +"2015 Q1","100.2","102.7","101.4","","","" +"2015 Q2","99.9","100.2","100.1","","","" +"2015 Q3","99.9","99.3","99.6","","","" +"2015 Q4","99.9","97.8","99.0","","","" +"2016 Q1","99.9","96.5","98.3","","","" +"2016 Q2","99.7","93.3","96.7","","","" +"2016 Q3","99.7","93.3","96.6","","","" +"2016 Q4","99.7","93.3","96.7","","","" +"2017 Q1","100.1","92.9","96.7","","","" +"2017 Q2","106.0","92.7","99.5","","","" +"2017 Q3","108.7","93.3","101.2","","","" +"2017 Q4","111.1","93.3","102.5","","","" +"2018 Q1","111.1","93.5","102.6","","","" +"2018 Q2","113.0","94.6","104.0","","","" +"2018 Q3","117.4","97.7","107.6","","","" +"2018 Q4","121.2","100.4","110.8","","","" +"2019 Q1","115.3","91.9","104.1","","","" +"2019 Q2","127.9","100.5","114.3","","","" +"2019 Q3","127.9","100.5","114.4","","","" +"2019 Q4","125.2","91.7","109.1","","","" +"2020 Q1","125.2","91.7","109.1","","","" +"2020 Q2","125.4","88.5","107.7","","","" +"2020 Q3","125.3","88.4","107.6","","","" +"2020 Q4","121.2","77.6","100.5","","","" +"2021 Q1","121.2","77.6","100.5","","","" +"2021 Q2","132.3","84.9","109.5","","","" +"2021 Q3","132.5","84.9","109.6","","","" +"2021 Q4","144.0","99.4","122.4","","","" +"2022 Q1","144.5","99.5","122.8","","","" +"2022 Q2","203.2","165.9","184.4","","","" +"2022 Q3","204.1","166.1","185.3","","","" +"2022 Q4","238.3","227.4","232.5","","","" +"2023 Q1","240.9","228.2","234.3","","","" +"2023 Q2","238.3","225.9","231.8","","","" +"2023 Q3","217.7","168.9","196.2","","","" +"2023 Q4","201.4","157.0","182.1","","","" +"2024 Q1","209.6","167.7","191.4","","","" +"2024 Q2","188.1","141.3","167.7","","","" +"2024 Q3","175.3","130.3","155.8","","","" +"2024 Q4","188.9","145.5","170.1","","","" +"2025 Q1","191.2","147.5","172.2","","","" +"2025 Q2","196.7","158.6","180.3","","","" +"2025 Q3","189.3","147.3","171.1","","","" +"2025 Q4","194.1","148.6","174.2","","","" +"2026 Q1","201.5","143.3","176.4","","","" +"2026 Q2","185.7","137.8","165.0","","","" +"2026 Q3","194.1","160.2","179.0","","","" +"1947 JUN","","","","","","" +"1947 JUL","","","","","","" +"1947 AUG","","","","","","" +"1947 SEP","","","","","","" +"1947 OCT","","","","","","" +"1947 NOV","","","","","","" +"1947 DEC","","","","","","" +"1948 JAN","","","","","","" +"1948 FEB","","","","","","" +"1948 MAR","","","","","","" +"1948 APR","","","","","","" +"1948 MAY","","","","","","" +"1948 JUN","","","","","","" +"1948 JUL","","","","","","" +"1948 AUG","","","","","","" +"1948 SEP","","","","","","" +"1948 OCT","","","","","","" +"1948 NOV","","","","","","" +"1948 DEC","","","","","","" +"1949 JAN","","","","","","" +"1949 FEB","","","","","","" +"1949 MAR","","","","","","" +"1949 APR","","","","","","" +"1949 MAY","","","","","","" +"1949 JUN","","","","","","" +"1949 JUL","","","","","","" +"1949 AUG","","","","","","" +"1949 SEP","","","","","","" +"1949 OCT","","","","","","" +"1949 NOV","","","","","","" +"1949 DEC","","","","","","" +"1950 JAN","","","","","","" +"1950 FEB","","","","","","" +"1950 MAR","","","","","","" +"1950 APR","","","","","","" +"1950 MAY","","","","","","" +"1950 JUN","","","","","","" +"1950 JUL","","","","","","" +"1950 AUG","","","","","","" +"1950 SEP","","","","","","" +"1950 OCT","","","","","","" +"1950 NOV","","","","","","" +"1950 DEC","","","","","","" +"1951 JAN","","","","","","" +"1951 FEB","","","","","","" +"1951 MAR","","","","","","" +"1951 APR","","","","","","" +"1951 MAY","","","","","","" +"1951 JUN","","","","","","" +"1951 JUL","","","","","","" +"1951 AUG","","","","","","" +"1951 SEP","","","","","","" +"1951 OCT","","","","","","" +"1951 NOV","","","","","","" +"1951 DEC","","","","","","" +"1952 JAN","","","","","","" +"1952 FEB","","","","","","" +"1952 MAR","","","","","","" +"1952 APR","","","","","","" +"1952 MAY","","","","","","" +"1952 JUN","","","","","","" +"1952 JUL","","","","","","" +"1952 AUG","","","","","","" +"1952 SEP","","","","","","" +"1952 OCT","","","","","","" +"1952 NOV","","","","","","" +"1952 DEC","","","","","","" +"1953 JAN","","","","","","" +"1953 FEB","","","","","","" +"1953 MAR","","","","","","" +"1953 APR","","","","","","" +"1953 MAY","","","","","","" +"1953 JUN","","","","","","" +"1953 JUL","","","","","","" +"1953 AUG","","","","","","" +"1953 SEP","","","","","","" +"1953 OCT","","","","","","" +"1953 NOV","","","","","","" +"1953 DEC","","","","","","" +"1954 JAN","","","","","","" +"1954 FEB","","","","","","" +"1954 MAR","","","","","","" +"1954 APR","","","","","","" +"1954 MAY","","","","","","" +"1954 JUN","","","","","","" +"1954 JUL","","","","","","" +"1954 AUG","","","","","","" +"1954 SEP","","","","","","" +"1954 OCT","","","","","","" +"1954 NOV","","","","","","" +"1954 DEC","","","","","","" +"1955 JAN","","","","","","" +"1955 FEB","","","","","","" +"1955 MAR","","","","","","" +"1955 APR","","","","","","" +"1955 MAY","","","","","","" +"1955 JUN","","","","","","" +"1955 JUL","","","","","","" +"1955 AUG","","","","","","" +"1955 SEP","","","","","","" +"1955 OCT","","","","","","" +"1955 NOV","","","","","","" +"1955 DEC","","","","","","" +"1956 JAN","","","","","","" +"1956 FEB","","","","","","" +"1956 MAR","","","","","","" +"1956 APR","","","","","","" +"1956 MAY","","","","","","" +"1956 JUN","","","","","","" +"1956 JUL","","","","","","" +"1956 AUG","","","","","","" +"1956 SEP","","","","","","" +"1956 OCT","","","","","","" +"1956 NOV","","","","","","" +"1956 DEC","","","","","","" +"1957 JAN","","","","","","" +"1957 FEB","","","","","","" +"1957 MAR","","","","","","" +"1957 APR","","","","","","" +"1957 MAY","","","","","","" +"1957 JUN","","","","","","" +"1957 JUL","","","","","","" +"1957 AUG","","","","","","" +"1957 SEP","","","","","","" +"1957 OCT","","","","","","" +"1957 NOV","","","","","","" +"1957 DEC","","","","","","" +"1958 JAN","","","","","","" +"1958 FEB","","","","","","" +"1958 MAR","","","","","","" +"1958 APR","","","","","","" +"1958 MAY","","","","","","" +"1958 JUN","","","","","","" +"1958 JUL","","","","","","" +"1958 AUG","","","","","","" +"1958 SEP","","","","","","" +"1958 OCT","","","","","","" +"1958 NOV","","","","","","" +"1958 DEC","","","","","","" +"1959 JAN","","","","","","" +"1959 FEB","","","","","","" +"1959 MAR","","","","","","" +"1959 APR","","","","","","" +"1959 MAY","","","","","","" +"1959 JUN","","","","","","" +"1959 JUL","","","","","","" +"1959 AUG","","","","","","" +"1959 SEP","","","","","","" +"1959 OCT","","","","","","" +"1959 NOV","","","","","","" +"1959 DEC","","","","","","" +"1960 JAN","","","","","","" +"1960 FEB","","","","","","" +"1960 MAR","","","","","","" +"1960 APR","","","","","","" +"1960 MAY","","","","","","" +"1960 JUN","","","","","","" +"1960 JUL","","","","","","" +"1960 AUG","","","","","","" +"1960 SEP","","","","","","" +"1960 OCT","","","","","","" +"1960 NOV","","","","","","" +"1960 DEC","","","","","","" +"1961 JAN","","","","","","" +"1961 FEB","","","","","","" +"1961 MAR","","","","","","" +"1961 APR","","","","","","" +"1961 MAY","","","","","","" +"1961 JUN","","","","","","" +"1961 JUL","","","","","","" +"1961 AUG","","","","","","" +"1961 SEP","","","","","","" +"1961 OCT","","","","","","" +"1961 NOV","","","","","","" +"1961 DEC","","","","","","" +"1962 JAN","","","","","","" +"1962 FEB","","","","","","" +"1962 MAR","","","","","","" +"1962 APR","","","","","","" +"1962 MAY","","","","","","" +"1962 JUN","","","","","","" +"1962 JUL","","","","","","" +"1962 AUG","","","","","","" +"1962 SEP","","","","","","" +"1962 OCT","","","","","","" +"1962 NOV","","","","","","" +"1962 DEC","","","","","","" +"1963 JAN","","","","","","" +"1963 FEB","","","","","","" +"1963 MAR","","","","","","" +"1963 APR","","","","","","" +"1963 MAY","","","","","","" +"1963 JUN","","","","","","" +"1963 JUL","","","","","","" +"1963 AUG","","","","","","" +"1963 SEP","","","","","","" +"1963 OCT","","","","","","" +"1963 NOV","","","","","","" +"1963 DEC","","","","","","" +"1964 JAN","","","","","","" +"1964 FEB","","","","","","" +"1964 MAR","","","","","","" +"1964 APR","","","","","","" +"1964 MAY","","","","","","" +"1964 JUN","","","","","","" +"1964 JUL","","","","","","" +"1964 AUG","","","","","","" +"1964 SEP","","","","","","" +"1964 OCT","","","","","","" +"1964 NOV","","","","","","" +"1964 DEC","","","","","","" +"1965 JAN","","","","","","" +"1965 FEB","","","","","","" +"1965 MAR","","","","","","" +"1965 APR","","","","","","" +"1965 MAY","","","","","","" +"1965 JUN","","","","","","" +"1965 JUL","","","","","","" +"1965 AUG","","","","","","" +"1965 SEP","","","","","","" +"1965 OCT","","","","","","" +"1965 NOV","","","","","","" +"1965 DEC","","","","","","" +"1966 JAN","","","","","","" +"1966 FEB","","","","","","" +"1966 MAR","","","","","","" +"1966 APR","","","","","","" +"1966 MAY","","","","","","" +"1966 JUN","","","","","","" +"1966 JUL","","","","","","" +"1966 AUG","","","","","","" +"1966 SEP","","","","","","" +"1966 OCT","","","","","","" +"1966 NOV","","","","","","" +"1966 DEC","","","","","","" +"1967 JAN","","","","","","" +"1967 FEB","","","","","","" +"1967 MAR","","","","","","" +"1967 APR","","","","","","" +"1967 MAY","","","","","","" +"1967 JUN","","","","","","" +"1967 JUL","","","","","","" +"1967 AUG","","","","","","" +"1967 SEP","","","","","","" +"1967 OCT","","","","","","" +"1967 NOV","","","","","","" +"1967 DEC","","","","","","" +"1968 JAN","","","","","","" +"1968 FEB","","","","","","" +"1968 MAR","","","","","","" +"1968 APR","","","","","","" +"1968 MAY","","","","","","" +"1968 JUN","","","","","","" +"1968 JUL","","","","","","" +"1968 AUG","","","","","","" +"1968 SEP","","","","","","" +"1968 OCT","","","","","","" +"1968 NOV","","","","","","" +"1968 DEC","","","","","","" +"1969 JAN","","","","","","" +"1969 FEB","","","","","","" +"1969 MAR","","","","","","" +"1969 APR","","","","","","" +"1969 MAY","","","","","","" +"1969 JUN","","","","","","" +"1969 JUL","","","","","","" +"1969 AUG","","","","","","" +"1969 SEP","","","","","","" +"1969 OCT","","","","","","" +"1969 NOV","","","","","","" +"1969 DEC","","","","","","" +"1970 JAN","","","","","","" +"1970 FEB","","","","","","" +"1970 MAR","","","","","","" +"1970 APR","","","","","","" +"1970 MAY","","","","","","" +"1970 JUN","","","","","","" +"1970 JUL","","","","","","" +"1970 AUG","","","","","","" +"1970 SEP","","","","","","" +"1970 OCT","","","","","","" +"1970 NOV","","","","","","" +"1970 DEC","","","","","","" +"1971 JAN","","","","","","" +"1971 FEB","","","","","","" +"1971 MAR","","","","","","" +"1971 APR","","","","","","" +"1971 MAY","","","","","","" +"1971 JUN","","","","","","" +"1971 JUL","","","","","","" +"1971 AUG","","","","","","" +"1971 SEP","","","","","","" +"1971 OCT","","","","","","" +"1971 NOV","","","","","","" +"1971 DEC","","","","","","" +"1972 JAN","","","","","","" +"1972 FEB","","","","","","" +"1972 MAR","","","","","","" +"1972 APR","","","","","","" +"1972 MAY","","","","","","" +"1972 JUN","","","","","","" +"1972 JUL","","","","","","" +"1972 AUG","","","","","","" +"1972 SEP","","","","","","" +"1972 OCT","","","","","","" +"1972 NOV","","","","","","" +"1972 DEC","","","","","","" +"1973 JAN","","","","","","" +"1973 FEB","","","","","","" +"1973 MAR","","","","","","" +"1973 APR","","","","","","" +"1973 MAY","","","","","","" +"1973 JUN","","","","","","" +"1973 JUL","","","","","","" +"1973 AUG","","","","","","" +"1973 SEP","","","","","","" +"1973 OCT","","","","","","" +"1973 NOV","","","","","","" +"1973 DEC","","","","","","" +"1974 JAN","","","","","","" +"1974 FEB","","","","","","" +"1974 MAR","","","","","","" +"1974 APR","","","","","","" +"1974 MAY","","","","","","" +"1974 JUN","","","","","","" +"1974 JUL","","","","","","" +"1974 AUG","","","","","","" +"1974 SEP","","","","","","" +"1974 OCT","","","","","","" +"1974 NOV","","","","","","" +"1974 DEC","","","","","","" +"1975 JAN","","","","","","" +"1975 FEB","","","","","","" +"1975 MAR","","","","","","" +"1975 APR","","","","","","" +"1975 MAY","","","","","","" +"1975 JUN","","","","","","" +"1975 JUL","","","","","","" +"1975 AUG","","","","","","" +"1975 SEP","","","","","","" +"1975 OCT","","","","","","" +"1975 NOV","","","","","","" +"1975 DEC","","","","","","" +"1976 JAN","","","","","","" +"1976 FEB","","","","","","" +"1976 MAR","","","","","","" +"1976 APR","","","","","","" +"1976 MAY","","","","","","" +"1976 JUN","","","","","","" +"1976 JUL","","","","","","" +"1976 AUG","","","","","","" +"1976 SEP","","","","","","" +"1976 OCT","","","","","","" +"1976 NOV","","","","","","" +"1976 DEC","","","","","","" +"1977 JAN","","","","","","" +"1977 FEB","","","","","","" +"1977 MAR","","","","","","" +"1977 APR","","","","","","" +"1977 MAY","","","","","","" +"1977 JUN","","","","","","" +"1977 JUL","","","","","","" +"1977 AUG","","","","","","" +"1977 SEP","","","","","","" +"1977 OCT","","","","","","" +"1977 NOV","","","","","","" +"1977 DEC","","","","","","" +"1978 JAN","","","","","","" +"1978 FEB","","","","","","" +"1978 MAR","","","","","","" +"1978 APR","","","","","","" +"1978 MAY","","","","","","" +"1978 JUN","","","","","","" +"1978 JUL","","","","","","" +"1978 AUG","","","","","","" +"1978 SEP","","","","","","" +"1978 OCT","","","","","","" +"1978 NOV","","","","","","" +"1978 DEC","","","","","","" +"1979 JAN","","","","","","" +"1979 FEB","","","","","","" +"1979 MAR","","","","","","" +"1979 APR","","","","","","" +"1979 MAY","","","","","","" +"1979 JUN","","","","","","" +"1979 JUL","","","","","","" +"1979 AUG","","","","","","" +"1979 SEP","","","","","","" +"1979 OCT","","","","","","" +"1979 NOV","","","","","","" +"1979 DEC","","","","","","" +"1980 JAN","","","","","","" +"1980 FEB","","","","","","" +"1980 MAR","","","","","","" +"1980 APR","","","","","","" +"1980 MAY","","","","","","" +"1980 JUN","","","","","","" +"1980 JUL","","","","","","" +"1980 AUG","","","","","","" +"1980 SEP","","","","","","" +"1980 OCT","","","","","","" +"1980 NOV","","","","","","" +"1980 DEC","","","","","","" +"1981 JAN","","","","","","" +"1981 FEB","","","","","","" +"1981 MAR","","","","","","" +"1981 APR","","","","","","" +"1981 MAY","","","","","","" +"1981 JUN","","","","","","" +"1981 JUL","","","","","","" +"1981 AUG","","","","","","" +"1981 SEP","","","","","","" +"1981 OCT","","","","","","" +"1981 NOV","","","","","","" +"1981 DEC","","","","","","" +"1982 JAN","","","","","","" +"1982 FEB","","","","","","" +"1982 MAR","","","","","","" +"1982 APR","","","","","","" +"1982 MAY","","","","","","" +"1982 JUN","","","","","","" +"1982 JUL","","","","","","" +"1982 AUG","","","","","","" +"1982 SEP","","","","","","" +"1982 OCT","","","","","","" +"1982 NOV","","","","","","" +"1982 DEC","","","","","","" +"1983 JAN","","","","","","" +"1983 FEB","","","","","","" +"1983 MAR","","","","","","" +"1983 APR","","","","","","" +"1983 MAY","","","","","","" +"1983 JUN","","","","","","" +"1983 JUL","","","","","","" +"1983 AUG","","","","","","" +"1983 SEP","","","","","","" +"1983 OCT","","","","","","" +"1983 NOV","","","","","","" +"1983 DEC","","","","","","" +"1984 JAN","","","","","","" +"1984 FEB","","","","","","" +"1984 MAR","","","","","","" +"1984 APR","","","","","","" +"1984 MAY","","","","","","" +"1984 JUN","","","","","","" +"1984 JUL","","","","","","" +"1984 AUG","","","","","","" +"1984 SEP","","","","","","" +"1984 OCT","","","","","","" +"1984 NOV","","","","","","" +"1984 DEC","","","","","","" +"1985 JAN","","","","","","" +"1985 FEB","","","","","","" +"1985 MAR","","","","","","" +"1985 APR","","","","","","" +"1985 MAY","","","","","","" +"1985 JUN","","","","","","" +"1985 JUL","","","","","","" +"1985 AUG","","","","","","" +"1985 SEP","","","","","","" +"1985 OCT","","","","","","" +"1985 NOV","","","","","","" +"1985 DEC","","","","","","" +"1986 JAN","","","","","","" +"1986 FEB","","","","","","" +"1986 MAR","","","","","","" +"1986 APR","","","","","","" +"1986 MAY","","","","","","" +"1986 JUN","","","","","","" +"1986 JUL","","","","","","" +"1986 AUG","","","","","","" +"1986 SEP","","","","","","" +"1986 OCT","","","","","","" +"1986 NOV","","","","","","" +"1986 DEC","","","","","","" +"1987 JAN","","","","","","" +"1987 FEB","","","","","","" +"1987 MAR","","","","","","" +"1987 APR","","","","","","" +"1987 MAY","","","","","","" +"1987 JUN","","","","","","" +"1987 JUL","","","","","","" +"1987 AUG","","","","","","" +"1987 SEP","","","","","","" +"1987 OCT","","","","","","" +"1987 NOV","","","","","","" +"1987 DEC","","","","","","" +"1988 JAN","33.1","25.2","29.0","","","" +"1988 FEB","33.1","25.2","29.0","","","" +"1988 MAR","33.1","25.2","29.0","","","" +"1988 APR","33.7","25.4","29.4","","","" +"1988 MAY","34.6","25.9","29.9","","","" +"1988 JUN","35.4","26.4","30.4","","","" +"1988 JUL","35.9","26.7","30.8","","","" +"1988 AUG","35.9","26.7","30.8","","","" +"1988 SEP","35.9","26.7","30.9","","","" +"1988 OCT","35.9","26.7","30.9","","","" +"1988 NOV","35.9","26.7","30.9","","","" +"1988 DEC","35.9","26.7","30.9","","","" +"1989 JAN","35.9","26.7","31.0","","","" +"1989 FEB","35.9","26.7","31.0","","","" +"1989 MAR","35.9","26.7","31.0","","","" +"1989 APR","36.4","26.8","31.2","","","" +"1989 MAY","37.1","27.1","31.6","","","" +"1989 JUN","37.9","27.4","32.0","","","" +"1989 JUL","38.3","27.5","32.2","","","" +"1989 AUG","38.3","27.5","32.3","","","" +"1989 SEP","38.3","27.5","32.4","","","" +"1989 OCT","38.3","27.5","32.4","","","" +"1989 NOV","38.3","27.5","32.4","","","" +"1989 DEC","38.3","27.5","32.4","","","" +"1990 JAN","38.3","27.6","32.5","","","" +"1990 FEB","38.3","27.6","32.5","","","" +"1990 MAR","38.3","27.9","32.7","","","" +"1990 APR","38.7","28.6","33.1","","","" +"1990 MAY","40.1","29.3","33.9","","","" +"1990 JUN","41.1","29.6","34.5","","","" +"1990 JUL","41.8","29.6","34.8","","","" +"1990 AUG","41.8","29.6","34.9","","","" +"1990 SEP","41.8","29.6","35.0","","","" +"1990 OCT","41.8","29.6","35.0","","","" +"1990 NOV","41.8","29.8","35.2","","","" +"1990 DEC","41.8","30.0","35.3","","","" +"1991 JAN","41.8","30.4","35.5","","","" +"1991 FEB","41.8","30.6","35.6","","","" +"1991 MAR","41.8","30.6","35.6","","","" +"1991 APR","42.5","30.7","36.0","","","" +"1991 MAY","43.9","31.0","36.6","","","" +"1991 JUN","45.3","31.3","37.3","","","" +"1991 JUL","46.2","31.5","37.8","","","" +"1991 AUG","46.2","31.5","37.9","","","" +"1991 SEP","46.2","31.5","38.0","","","" +"1991 OCT","46.2","31.5","38.0","","","" +"1991 NOV","46.2","31.5","38.1","","","" +"1991 DEC","46.2","31.5","38.1","","","" +"1992 JAN","46.2","31.5","38.1","","","" +"1992 FEB","46.2","31.5","38.1","","","" +"1992 MAR","46.2","31.5","38.1","","","" +"1992 APR","46.3","31.5","38.2","","","" +"1992 MAY","46.6","31.5","38.3","","","" +"1992 JUN","47.0","31.5","38.3","","","" +"1992 JUL","47.2","31.3","38.4","","","" +"1992 AUG","47.2","31.0","38.2","","","" +"1992 SEP","47.2","30.7","38.1","","","" +"1992 OCT","47.2","30.4","38.0","","","" +"1992 NOV","47.2","30.2","37.9","","","" +"1992 DEC","47.2","30.0","37.8","","","" +"1993 JAN","47.2","29.9","37.8","","","" +"1993 FEB","47.2","29.9","37.8","","","" +"1993 MAR","47.2","29.9","37.8","","","" +"1993 APR","47.1","29.9","37.7","","","" +"1993 MAY","46.7","29.9","37.5","","","" +"1993 JUN","46.5","29.9","37.4","","","" +"1993 JUL","46.3","29.9","37.3","","","" +"1993 AUG","46.3","29.9","37.3","","","" +"1993 SEP","46.4","29.9","37.4","","","" +"1993 OCT","46.4","29.9","37.4","","","" +"1993 NOV","46.3","29.9","37.4","","","" +"1993 DEC","46.2","29.9","37.3","","","" +"1994 JAN","46.1","29.9","37.3","","","" +"1994 FEB","45.8","29.9","37.2","","","" +"1994 MAR","45.6","29.9","37.1","","","" +"1994 APR","49.1","32.3","40.0","","","" +"1994 MAY","48.9","32.3","39.8","","","" +"1994 JUN","48.8","32.3","39.8","","","" +"1994 JUL","49.1","32.3","39.9","","","" +"1994 AUG","49.3","32.3","40.0","","","" +"1994 SEP","49.2","32.3","40.0","","","" +"1994 OCT","49.0","32.3","39.9","","","" +"1994 NOV","48.9","32.3","39.9","","","" +"1994 DEC","48.9","32.3","39.9","","","" +"1995 JAN","49.0","32.4","40.0","","","" +"1995 FEB","48.9","32.5","40.1","","","" +"1995 MAR","48.9","32.7","40.1","","","" +"1995 APR","48.9","32.8","40.2","","","" +"1995 MAY","48.8","32.8","40.1","","","" +"1995 JUN","48.8","32.8","40.1","","","" +"1995 JUL","48.9","32.8","40.1","","","" +"1995 AUG","48.9","32.8","40.1","","","" +"1995 SEP","48.9","32.8","40.1","","","" +"1995 OCT","48.8","32.8","40.1","","","" +"1995 NOV","48.8","32.8","40.1","","","" +"1995 DEC","48.8","32.8","40.1","","","" +"1996 JAN","48.8","32.8","40.1","","","" +"1996 FEB","48.8","32.8","40.1","","","" +"1996 MAR","48.8","32.8","40.1","","","" +"1996 APR","48.8","32.8","40.1","","","" +"1996 MAY","48.9","32.8","40.1","","","" +"1996 JUN","49.1","32.8","40.2","","","" +"1996 JUL","49.2","32.8","40.2","","","" +"1996 AUG","49.0","32.8","40.2","","","" +"1996 SEP","48.7","32.8","40.1","","","" +"1996 OCT","48.2","32.8","39.9","","","" +"1996 NOV","47.9","32.8","39.8","","","" +"1996 DEC","47.7","32.8","39.7","","","" +"1997 JAN","47.2","32.8","39.5","","","" +"1997 FEB","47.4","32.8","39.6","","","" +"1997 MAR","47.6","32.8","39.7","","","" +"1997 APR","47.5","32.7","39.6","","","" +"1997 MAY","47.2","32.7","39.4","","","" +"1997 JUN","46.9","32.7","39.3","","","" +"1997 JUL","46.6","32.7","39.2","","","" +"1997 AUG","46.5","32.7","39.1","","","" +"1997 SEP","45.1","31.9","38.1","","","" +"1997 OCT","45.0","31.9","38.1","","","" +"1997 NOV","44.6","31.9","37.9","","","" +"1997 DEC","44.3","31.9","37.8","","","" +"1998 JAN","44.2","31.5","37.5","","","" +"1998 FEB","44.6","31.5","37.7","","","" +"1998 MAR","45.0","31.4","37.8","","","" +"1998 APR","45.0","31.4","37.8","","","" +"1998 MAY","44.5","31.4","37.6","","","" +"1998 JUN","44.1","31.3","37.4","","","" +"1998 JUL","43.8","31.4","37.3","","","" +"1998 AUG","43.8","31.4","37.3","","","" +"1998 SEP","43.8","31.4","37.3","","","" +"1998 OCT","43.8","31.4","37.4","","","" +"1998 NOV","43.8","31.4","37.4","","","" +"1998 DEC","43.8","31.4","37.4","","","" +"1999 JAN","43.8","31.5","37.5","","","" +"1999 FEB","43.8","31.5","37.5","","","" +"1999 MAR","43.8","31.6","37.5","","","" +"1999 APR","43.8","31.3","37.3","","","" +"1999 MAY","43.7","31.3","37.2","","","" +"1999 JUN","43.7","31.3","37.2","","","" +"1999 JUL","43.6","31.3","37.2","","","" +"1999 AUG","43.6","31.3","37.2","","","" +"1999 SEP","43.6","31.3","37.2","","","" +"1999 OCT","43.6","31.3","37.3","","","" +"1999 NOV","43.6","31.3","37.3","","","" +"1999 DEC","43.6","31.3","37.3","","","" +"2000 JAN","43.6","31.3","37.3","","","" +"2000 FEB","43.6","31.3","37.3","","","" +"2000 MAR","43.5","31.3","37.2","","","" +"2000 APR","43.4","30.5","36.8","","","" +"2000 MAY","43.0","30.5","36.5","","","" +"2000 JUN","42.6","30.5","36.3","","","" +"2000 JUL","42.3","30.5","36.2","","","" +"2000 AUG","42.3","30.5","36.2","","","" +"2000 SEP","42.3","30.5","36.2","","","" +"2000 OCT","42.3","30.6","36.3","","","" +"2000 NOV","42.3","30.6","36.3","","","" +"2000 DEC","42.3","30.6","36.3","","","" +"2001 JAN","42.3","30.6","36.3","","","" +"2001 FEB","42.3","30.7","36.4","","","" +"2001 MAR","42.3","30.7","36.4","","","" +"2001 APR","42.3","31.9","37.0","","","" +"2001 MAY","42.3","32.0","37.0","","","" +"2001 JUN","42.4","32.0","37.0","","","" +"2001 JUL","42.4","32.0","37.0","","","" +"2001 AUG","42.4","32.0","37.0","","","" +"2001 SEP","42.5","32.0","37.1","","","" +"2001 OCT","42.5","32.0","37.2","","","" +"2001 NOV","42.5","32.0","37.2","","","" +"2001 DEC","42.5","32.0","37.2","","","" +"2002 JAN","42.5","33.4","37.9","","","" +"2002 FEB","42.5","33.4","37.9","","","" +"2002 MAR","42.5","33.5","37.9","","","" +"2002 APR","42.5","33.6","38.0","","","" +"2002 MAY","42.6","33.6","38.0","","","" +"2002 JUN","42.6","33.7","38.1","","","" +"2002 JUL","42.6","33.7","38.1","","","" +"2002 AUG","42.6","33.7","38.1","","","" +"2002 SEP","42.6","33.7","38.1","","","" +"2002 OCT","42.6","33.7","38.2","","","" +"2002 NOV","42.6","33.7","38.2","","","" +"2002 DEC","42.6","33.8","38.2","","","" +"2003 JAN","42.7","33.8","38.2","","","" +"2003 FEB","42.7","33.8","38.2","","","" +"2003 MAR","42.7","33.8","38.2","","","" +"2003 APR","42.7","33.9","38.3","","","" +"2003 MAY","42.8","34.1","38.4","","","" +"2003 JUN","42.9","34.3","38.6","","","" +"2003 JUL","43.0","34.4","38.7","","","" +"2003 AUG","43.2","34.5","38.8","","","" +"2003 SEP","43.3","34.5","38.9","","","" +"2003 OCT","43.3","34.6","39.0","","","" +"2003 NOV","43.4","34.7","39.0","","","" +"2003 DEC","43.5","34.8","39.1","","","" +"2004 JAN","43.7","35.0","39.4","","","" +"2004 FEB","44.0","35.4","39.7","","","" +"2004 MAR","44.5","35.9","40.1","","","" +"2004 APR","45.0","36.2","40.5","","","" +"2004 MAY","45.2","36.3","40.7","","","" +"2004 JUN","45.5","36.4","40.9","","","" +"2004 JUL","45.6","36.5","41.0","","","" +"2004 AUG","45.7","36.6","41.1","","","" +"2004 SEP","45.9","36.7","41.3","","","" +"2004 OCT","46.4","37.2","41.8","","","" +"2004 NOV","47.2","38.4","42.8","","","" +"2004 DEC","48.1","39.7","43.9","","","" +"2005 JAN","48.8","40.4","44.6","","","" +"2005 FEB","49.2","40.6","44.9","","","" +"2005 MAR","49.5","40.7","45.1","","","" +"2005 APR","49.7","40.9","45.2","","","" +"2005 MAY","49.8","41.0","45.4","","","" +"2005 JUN","49.9","41.2","45.5","","","" +"2005 JUL","49.9","41.2","45.5","","","" +"2005 AUG","50.0","41.3","45.6","","","" +"2005 SEP","50.6","41.8","46.2","","","" +"2005 OCT","51.7","43.4","47.5","","","" +"2005 NOV","52.8","45.0","48.8","","","" +"2005 DEC","53.3","45.9","49.5","","","" +"2006 JAN","53.8","46.3","50.0","","","" +"2006 FEB","54.4","46.5","50.4","","","" +"2006 MAR","55.9","48.0","51.8","","","" +"2006 APR","58.3","51.0","54.4","","","" +"2006 MAY","60.7","54.1","57.1","","","" +"2006 JUN","62.2","55.6","58.5","","","" +"2006 JUL","62.8","56.2","59.1","","","" +"2006 AUG","63.3","56.8","59.7","","","" +"2006 SEP","64.1","58.2","60.7","","","" +"2006 OCT","65.8","61.0","63.0","","","" +"2006 NOV","67.1","62.9","64.6","","","" +"2006 DEC","67.9","64.1","65.6","","","" +"2007 JAN","68.2","64.3","65.9","","","" +"2007 FEB","68.4","64.5","66.1","","","" +"2007 MAR","68.4","64.0","65.8","","","" +"2007 APR","67.8","62.2","64.7","","","" +"2007 MAY","67.0","60.2","63.3","","","" +"2007 JUN","66.1","58.5","62.0","","","" +"2007 JUL","65.3","57.2","61.0","","","" +"2007 AUG","64.9","56.4","60.5","","","" +"2007 SEP","64.9","56.0","60.2","","","" +"2007 OCT","64.8","55.8","60.2","","","" +"2007 NOV","64.8","55.8","60.2","","","" +"2007 DEC","64.8","55.9","60.3","","","" +"2008 JAN","65.1","56.2","60.5","","","" +"2008 FEB","72.0","63.2","67.3","","","" +"2008 MAR","72.0","63.2","67.3","","","" +"2008 APR","73.5","64.5","68.7","","","" +"2008 MAY","73.5","64.5","68.7","","","" +"2008 JUN","73.5","64.5","68.7","","","" +"2008 JUL","73.5","64.6","68.8","","","" +"2008 AUG","76.6","72.1","74.0","","","" +"2008 SEP","84.5","84.0","83.8","","","" +"2008 OCT","85.1","84.2","84.4","","","" +"2008 NOV","85.1","84.1","84.4","","","" +"2008 DEC","85.1","84.1","84.4","","","" +"2009 JAN","84.9","84.1","84.3","","","" +"2009 FEB","84.9","84.1","84.3","","","" +"2009 MAR","84.5","80.5","82.3","","","" +"2009 APR","80.2","79.7","79.9","","","" +"2009 MAY","78.5","79.7","79.1","","","" +"2009 JUN","78.5","79.7","79.0","","","" +"2009 JUL","78.5","79.3","78.8","","","" +"2009 AUG","78.5","79.3","78.8","","","" +"2009 SEP","78.5","79.3","78.9","","","" +"2009 OCT","78.2","79.2","78.7","","","" +"2009 NOV","78.2","79.2","78.7","","","" +"2009 DEC","78.2","79.2","78.7","","","" +"2010 JAN","78.2","79.3","78.8","","","" +"2010 FEB","78.2","77.0","77.5","","","" +"2010 MAR","78.2","77.0","77.5","","","" +"2010 APR","78.0","74.8","76.2","","","" +"2010 MAY","78.0","74.8","76.2","","","" +"2010 JUN","78.0","74.8","76.2","","","" +"2010 JUL","78.0","74.8","76.2","","","" +"2010 AUG","78.0","74.8","76.2","","","" +"2010 SEP","78.0","74.8","76.2","","","" +"2010 OCT","77.8","74.8","76.1","","","" +"2010 NOV","77.8","74.8","76.1","","","" +"2010 DEC","78.8","78.2","78.5","","","" +"2011 JAN","79.4","78.9","79.1","","","" +"2011 FEB","80.6","79.2","79.9","","","" +"2011 MAR","81.2","79.6","80.3","","","" +"2011 APR","81.2","79.6","80.3","","","" +"2011 MAY","81.2","79.6","80.3","","","" +"2011 JUN","81.2","79.6","80.3","","","" +"2011 JUL","81.2","79.6","80.3","","","" +"2011 AUG","82.0","81.0","81.4","","","" +"2011 SEP","88.1","91.5","89.7","","","" +"2011 OCT","89.4","92.8","91.0","","","" +"2011 NOV","89.9","93.7","91.7","","","" +"2011 DEC","89.9","93.7","91.7","","","" +"2012 JAN","89.9","93.7","91.7","","","" +"2012 FEB","88.8","92.8","90.8","","","" +"2012 MAR","87.8","92.4","90.2","","","" +"2012 APR","87.8","91.9","89.8","","","" +"2012 MAY","87.7","91.9","89.8","","","" +"2012 JUN","87.7","91.9","89.8","","","" +"2012 JUL","87.7","91.9","89.8","","","" +"2012 AUG","87.7","91.9","89.8","","","" +"2012 SEP","87.7","91.9","89.8","","","" +"2012 OCT","87.3","91.9","89.6","","","" +"2012 NOV","88.9","93.6","91.3","","","" +"2012 DEC","93.4","98.6","95.9","","","" +"2013 JAN","93.4","98.5","95.9","","","" +"2013 FEB","94.5","99.5","96.9","","","" +"2013 MAR","94.5","99.5","96.9","","","" +"2013 APR","94.5","99.5","96.9","","","" +"2013 MAY","94.5","99.5","96.9","","","" +"2013 JUN","94.5","99.5","96.9","","","" +"2013 JUL","94.8","99.5","97.1","","","" +"2013 AUG","94.8","99.5","97.1","","","" +"2013 SEP","94.8","99.5","97.1","","","" +"2013 OCT","94.8","99.5","97.1","","","" +"2013 NOV","94.8","99.5","97.1","","","" +"2013 DEC","101.0","106.2","103.4","","","" +"2014 JAN","100.8","105.2","102.9","","","" +"2014 FEB","101.0","105.4","103.1","","","" +"2014 MAR","100.8","105.1","102.8","","","" +"2014 APR","100.2","104.6","102.3","","","" +"2014 MAY","100.2","104.6","102.3","","","" +"2014 JUN","100.2","104.6","102.2","","","" +"2014 JUL","100.2","104.6","102.2","","","" +"2014 AUG","100.2","104.6","102.2","","","" +"2014 SEP","100.2","104.6","102.3","","","" +"2014 OCT","100.2","104.6","102.3","","","" +"2014 NOV","100.2","104.6","102.3","","","" +"2014 DEC","100.2","104.6","102.3","","","" +"2015 JAN","100.2","104.1","102.1","","","" +"2015 FEB","100.2","103.4","101.7","","","" +"2015 MAR","100.2","100.7","100.4","","","" +"2015 APR","100.0","100.6","100.3","","","" +"2015 MAY","100.0","100.0","99.9","","","" +"2015 JUN","99.9","100.0","99.9","","","" +"2015 JUL","99.9","100.0","99.9","","","" +"2015 AUG","99.9","100.0","99.9","","","" +"2015 SEP","99.9","97.8","98.9","","","" +"2015 OCT","99.9","97.8","99.0","","","" +"2015 NOV","99.9","97.8","99.0","","","" +"2015 DEC","99.9","97.8","99.0","","","" +"2016 JAN","99.9","97.8","99.0","","","" +"2016 FEB","99.9","97.1","98.6","","","" +"2016 MAR","99.9","94.6","97.4","","","" +"2016 APR","99.7","93.3","96.7","","","" +"2016 MAY","99.7","93.3","96.7","","","" +"2016 JUN","99.7","93.3","96.6","","","" +"2016 JUL","99.7","93.3","96.6","","","" +"2016 AUG","99.7","93.3","96.6","","","" +"2016 SEP","99.7","93.3","96.6","","","" +"2016 OCT","99.7","93.3","96.7","","","" +"2016 NOV","99.7","93.3","96.7","","","" +"2016 DEC","99.7","93.3","96.7","","","" +"2017 JAN","99.7","92.9","96.5","","","" +"2017 FEB","99.7","92.9","96.5","","","" +"2017 MAR","100.7","92.9","97.0","","","" +"2017 APR","103.2","92.4","98.0","","","" +"2017 MAY","107.4","92.8","100.3","","","" +"2017 JUN","107.4","92.8","100.3","","","" +"2017 JUL","108.7","93.3","101.2","","","" +"2017 AUG","108.7","93.3","101.2","","","" +"2017 SEP","108.7","93.3","101.2","","","" +"2017 OCT","111.1","93.3","102.4","","","" +"2017 NOV","111.1","93.4","102.5","","","" +"2017 DEC","111.1","93.4","102.5","","","" +"2018 JAN","111.1","93.5","102.5","","","" +"2018 FEB","111.1","93.5","102.5","","","" +"2018 MAR","111.1","93.5","102.6","","","" +"2018 APR","112.1","93.8","103.2","","","" +"2018 MAY","112.2","93.9","103.2","","","" +"2018 JUN","114.7","96.2","105.6","","","" +"2018 JUL","116.7","97.3","107.1","","","" +"2018 AUG","116.7","97.3","107.1","","","" +"2018 SEP","118.8","98.4","108.6","","","" +"2018 OCT","121.2","100.4","110.8","","","" +"2018 NOV","121.2","100.4","110.8","","","" +"2018 DEC","121.2","100.4","110.9","","","" +"2019 JAN","115.3","91.9","104.0","","","" +"2019 FEB","115.4","92.0","104.1","","","" +"2019 MAR","115.4","92.0","104.1","","","" +"2019 APR","127.9","100.5","114.3","","","" +"2019 MAY","127.9","100.5","114.3","","","" +"2019 JUN","127.9","100.5","114.4","","","" +"2019 JUL","127.9","100.5","114.4","","","" +"2019 AUG","127.9","100.5","114.3","","","" +"2019 SEP","127.9","100.5","114.4","","","" +"2019 OCT","125.2","91.7","109.0","","","" +"2019 NOV","125.2","91.7","109.1","","","" +"2019 DEC","125.2","91.7","109.1","","","" +"2020 JAN","125.2","91.7","109.1","","","" +"2020 FEB","125.2","91.7","109.1","","","" +"2020 MAR","125.2","91.7","109.1","","","" +"2020 APR","125.4","88.4","107.7","","","" +"2020 MAY","125.4","88.5","107.7","","","" +"2020 JUN","125.4","88.5","107.7","","","" +"2020 JUL","125.3","88.4","107.6","","","" +"2020 AUG","125.3","88.4","107.6","","","" +"2020 SEP","125.3","88.4","107.6","","","" +"2020 OCT","121.2","77.6","100.4","","","" +"2020 NOV","121.2","77.6","100.5","","","" +"2020 DEC","121.2","77.6","100.5","","","" +"2021 JAN","121.2","77.6","100.5","","","" +"2021 FEB","121.2","77.6","100.5","","","" +"2021 MAR","121.2","77.6","100.5","","","" +"2021 APR","132.3","84.9","109.5","","","" +"2021 MAY","132.3","84.9","109.5","","","" +"2021 JUN","132.3","84.9","109.5","","","" +"2021 JUL","132.5","84.9","109.6","","","" +"2021 AUG","132.5","84.9","109.6","","","" +"2021 SEP","132.5","84.9","109.6","","","" +"2021 OCT","144.0","99.4","122.3","","","" +"2021 NOV","144.0","99.4","122.3","","","" +"2021 DEC","144.0","99.4","122.4","","","" +"2022 JAN","144.5","99.5","122.8","","","" +"2022 FEB","144.5","99.5","122.8","","","" +"2022 MAR","144.5","99.5","122.9","","","" +"2022 APR","203.2","165.9","184.3","","","" +"2022 MAY","203.2","165.9","184.4","","","" +"2022 JUN","203.2","165.9","184.6","","","" +"2022 JUL","204.1","166.1","185.2","","","" +"2022 AUG","204.1","166.1","185.3","","","" +"2022 SEP","204.1","166.1","185.4","","","" +"2022 OCT","238.6","227.4","232.7","","","" +"2022 NOV","238.1","227.4","232.4","","","" +"2022 DEC","238.1","227.4","232.4","","","" +"2023 JAN","240.9","228.2","234.3","","","" +"2023 FEB","240.9","228.2","234.4","","","" +"2023 MAR","240.9","228.2","234.3","","","" +"2023 APR","238.3","225.9","231.9","","","" +"2023 MAY","238.3","225.9","231.8","","","" +"2023 JUN","238.3","225.9","231.8","","","" +"2023 JUL","217.7","168.9","196.2","","","" +"2023 AUG","217.7","168.9","196.2","","","" +"2023 SEP","217.7","168.9","196.2","","","" +"2023 OCT","201.4","157.0","182.1","","","" +"2023 NOV","201.4","157.0","182.1","","","" +"2023 DEC","201.4","157.0","182.1","","","" +"2024 JAN","209.6","167.7","191.4","","","" +"2024 FEB","209.6","167.7","191.4","","","" +"2024 MAR","209.6","167.7","191.4","","","" +"2024 APR","188.1","141.3","167.8","","","" +"2024 MAY","188.1","141.3","167.7","","","" +"2024 JUN","188.1","141.3","167.7","","","" +"2024 JUL","175.3","130.3","155.8","","","" +"2024 AUG","175.3","130.3","155.8","","","" +"2024 SEP","175.3","130.3","155.8","","","" +"2024 OCT","188.8","145.5","170.0","","","" +"2024 NOV","188.8","145.5","170.0","","","" +"2024 DEC","189.0","145.5","170.1","","","" +"2025 JAN","191.2","147.4","172.2","","","" +"2025 FEB","191.2","147.5","172.2","","","" +"2025 MAR","191.1","147.5","172.2","","","" +"2025 APR","196.8","158.6","180.3","","","" +"2025 MAY","196.7","158.6","180.4","","","" +"2025 JUN","196.7","158.7","180.3","","","" +"2025 JUL","189.3","147.3","171.1","","","" +"2025 AUG","189.3","147.2","171.1","","","" +"2025 SEP","189.4","147.2","171.0","","","" +"2025 OCT","193.9","148.6","174.2","","","" +"2025 NOV","194.1","148.6","174.3","","","" +"2025 DEC","194.2","148.5","174.3","","","" +"2026 JAN","201.3","143.4","176.3","","","" +"2026 FEB","201.3","142.9","176.1","","","" +"2026 MAR","201.9","143.4","176.6","","","" +"2026 APR","184.9","137.1","164.3","","","" +"2026 MAY","185.7","137.8","165.0","","","" +"2026 JUN","186.5","138.6","165.8","","","" +"2026 JUL","193.2","159.0","178.0","","","" +"2026 AUG","194.0","159.9","178.9","","","" +"2026 SEP","195.1","161.6","180.2","","","" diff --git a/policyengine_uk/tests/test_energy_dataset_projection.py b/policyengine_uk/tests/test_energy_dataset_projection.py index 31a2cc705d..4ca5ccbfd4 100644 --- a/policyengine_uk/tests/test_energy_dataset_projection.py +++ b/policyengine_uk/tests/test_energy_dataset_projection.py @@ -1,7 +1,8 @@ """Dataset energy prices move only when their price basis is declared. -The controlled CPI levels are test data for the agreed annual projection -contract, not forecasts or an energy-specific price index. +The controlled index levels are test data for the annual projection contract, +not forecasts: every calendar and fiscal year energy price index is set to the +same levels, so the expected amounts are simple ratios. """ import json @@ -32,7 +33,12 @@ "electricity_consumption", "gas_consumption", ) -CPI = "gov.economic_assumptions.indices.obr.consumer_price_index" +ENERGY_PRICES = "gov.economic_assumptions.energy_prices" +INDICES = tuple( + f"{ENERGY_PRICES}.{kind}.{series}" + for kind in ("calendar_year", "fiscal_year") + for series in ("electricity", "gas", "electricity_gas_and_solid_fuels") +) LEVELS = dict(enumerate([100, 110, 125, 150, 160, 170, 180, 190, 200], 2024)) DECLARATION = {"period_type": "calendar_year", "period_value": 2024} @@ -67,10 +73,11 @@ def energy_dataset(basis=None, year=2024): @pytest.fixture def parameters(): parameters = CountryTaxBenefitSystem().parameters.clone() - index = parameters.get_child(CPI) # In particular, 2026 / 2024 = 125 / 100 and 2027 / 2026 = 150 / 125. - for year, value in LEVELS.items(): - index.update(period=str(year), value=value) + for path in INDICES: + index = parameters.get_child(path) + for year, value in LEVELS.items(): + index.update(period=str(year), value=value) return parameters @@ -213,10 +220,11 @@ def test_reset_restores_original_amounts_and_original_kind(parameters): def controlled_changes(target=125): return { - CPI: { + path: { f"year:{year}-01-01:1": target if year == 2026 else value for year, value in LEVELS.items() } + for path in INDICES } @@ -359,10 +367,11 @@ def test_index_only_reform_dict_matches_before_load_scenario(): ) -def test_cpi_index_changes_the_projection_fingerprint(parameters): - assert CPI in uprating_input_paths() +@pytest.mark.parametrize("path", INDICES) +def test_energy_price_index_changes_the_projection_fingerprint(parameters, path): + assert any(path.startswith(f"{read}.") for read in uprating_input_paths()) changed = parameters.clone() - changed.get_child(CPI).update(period="2026", value=160) + changed.get_child(path).update(period="2026", value=160) assert uprating_input_fingerprint(parameters) != uprating_input_fingerprint(changed) assert uprating_input_fingerprint(parameters) == uprating_input_fingerprint( parameters.clone() @@ -447,7 +456,7 @@ def test_external_dataframe_amounts_are_authoritative(): pd.testing.assert_frame_equal(frame, before) -def test_direct_situation_keeps_existing_cpi_uprating(): +def test_direct_situation_uprates_by_the_fiscal_year_energy_index(): simulation = Simulation( situation={ "people": {"person": {"age": {2024: 40}}}, @@ -466,14 +475,25 @@ def test_direct_situation_keeps_existing_cpi_uprating(): def _controlled_energy_history(monkeypatch, history): - """A real, clean index leaf with only the explicitly supplied history.""" + """Real, clean index leaves with only the explicitly supplied history. + + Every series, calendar and fiscal year alike, gets the same history. + """ from policyengine_core.parameters import Parameter, ParameterNode from policyengine_uk.data import economic_assumptions name = "controlled_energy_index" parameters = ParameterNode("", data={}) - parameters.add_child(name, Parameter(name, data=history)) - monkeypatch.setattr(economic_assumptions, "ENERGY_PRICE_INDEX", name) + root = ParameterNode(name, data={}) + parameters.add_child(name, root) + for kind in ("calendar_year", "fiscal_year"): + node = ParameterNode(f"{name}.{kind}", data={}) + root.add_child(kind, node) + for series in set(economic_assumptions.ENERGY_PRICE_SERIES.values()): + node.add_child( + series, Parameter(f"{name}.{kind}.{series}", data=dict(history)) + ) + monkeypatch.setattr(economic_assumptions, "ENERGY_PRICES", name) return parameters @@ -498,7 +518,7 @@ def test_declared_energy_projection_ignores_widened_generic_exclusions( economic_assumptions.uprate_energy(current, observed, parameters) for variable in ENERGY: - # Controlled annual CPI oracle: 100 * 125 / 100 = 125. + # Controlled annual index oracle: 100 * 125 / 100 = 125. np.testing.assert_array_equal(current.household[variable], [125.0, 0.0]) np.testing.assert_array_equal(current.household[unrelated], [777.0, 888.0]) assert current.energy_price_basis == { @@ -640,7 +660,9 @@ def test_energy_projection_validates_reformed_selected_index_history(monkeypatch monkeypatch, {"2030-01-01": 100, "2033-01-01": 125} ) reformed = parameters.clone() - selected_index = reformed.get_child(economic_assumptions.ENERGY_PRICE_INDEX) + selected_index = reformed.get_child( + f"{economic_assumptions.ENERGY_PRICES}.calendar_year.electricity" + ) # A parameter reform withdraws the two earliest years of this clean history. selected_index.update(period="year:2030:2", value=None) assert ( @@ -663,7 +685,9 @@ def test_energy_projection_validates_reformed_selected_index_history(monkeypatch with pytest.raises(ValueError, match="controlled_energy_index"): economic_assumptions.uprate_energy(current.copy(), observed, reformed) assert ( - parameters.get_child(economic_assumptions.ENERGY_PRICE_INDEX)("2030-01-01") + parameters.get_child( + f"{economic_assumptions.ENERGY_PRICES}.calendar_year.electricity" + )("2030-01-01") == 100 ) assert observed.energy_price_basis == { diff --git a/policyengine_uk/tests/test_energy_price_indices.py b/policyengine_uk/tests/test_energy_price_indices.py new file mode 100644 index 0000000000..1eae69f1d3 --- /dev/null +++ b/policyengine_uk/tests/test_energy_price_indices.py @@ -0,0 +1,778 @@ +"""Properties of the household energy price indices. + +``add_energy_price_indices`` builds them from the published inputs under +``gov.economic_assumptions.energy_prices``. These tests run it on the +statutory parameters, alone or with changed inputs; the changed inputs are +test data, not forecasts. + +Invariants: + +1. Every index covers 2015 to 2041 (quarterly to the first quarter of 2042) + with finite, positive levels, and generation is deterministic. +2. Outturn: a quarter ONS publishes in full is the mean of its monthly index, + each month divided by the published-over-basis VAT factor for that series. +3. Forecast: re-applying the published VAT to an index's year-on-year growth + gives the OBR's utilities inflation (the round trip of the adjustment), and + with no VAT change the growth is the OBR's exactly. +4. Annual levels are the consumption-weighted means of their quarters: + January to December for calendar years, April to March for fiscal years. +5. A change to statutory VAT never moves the indices, the projected dataset + amounts or a direct situation's uprated amounts. +6. Higher OBR growth in one forecast quarter raises that quarter and the same + quarter of each later year, and nothing else. +7. ``held_flat`` from a year holds the annual levels at the year before. +8. Inputs are read at each month and quarter, so a reform that states one + value for a year or a span of quarters sets each month or quarter in it, + one with no end sets every month or quarter after its start, and the + outturn ends at ``last_full_quarter`` whatever a reform states. +""" + +import math + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + DECLARED_ENERGY_INPUTS, + ENERGY_PRICE_SERIES, + ENERGY_PRICES, + extend_single_year_dataset, + uprate_energy, +) +from policyengine_uk.parameters.gov.economic_assumptions import ( + create_energy_price_indices as indices, +) +from policyengine_uk.scenarios import no_economic_assumptions +from policyengine_uk.system import system +from policyengine_uk.tax_benefit_system import statutory_parameters +from policyengine_uk.utils.scenario import Scenario + +SERIES = tuple(indices.SERIES) +FIRST, LAST = indices.FIRST_YEAR, indices.LAST_YEAR +QUARTERS = indices._quarters((FIRST, 1), (LAST + 1, 1)) +OUTTURN = indices._quarters((FIRST, 1), (2026, 3)) +FORECAST = indices._quarters((2026, 4), (LAST + 1, 1)) +BASIS = 0.05 +ENERGY = tuple(sorted(DECLARED_ENERGY_INPUTS)) +GB_RATE = "gov.hmrc.vat.domestic_electricity_rate.great_britain" +NI_RATE = "gov.hmrc.vat.domestic_electricity_rate.northern_ireland" +REDUCED_RATE = "gov.hmrc.vat.reduced_rate" + + +@pytest.fixture(scope="module") +def raw(): + return statutory_parameters() + + +def prices_of(tree): + return tree.gov.economic_assumptions.energy_prices + + +def build(raw, edit=None): + """The generated indices for a copy of ``raw``, after ``edit(copy)``.""" + tree = raw.clone() + if edit is not None: + edit(tree) + indices.add_energy_price_indices(tree) + return prices_of(tree) + + +def levels(prices, kind, key): + parameter = prices.get_child(kind).get_child(key) + return {entry.instant_str: entry.value for entry in parameter.values_list} + + +def quarter_level(prices, key, quarter): + return levels(prices, "quarterly", key)[indices._start(quarter)] + + +@pytest.fixture(scope="module") +def current(raw): + return build(raw) + + +def electricity_share(raw, key, year): + """Electricity's share of a series' CPI weight, from the ONS weights.""" + if key == "electricity": + return 1.0 + if key == "gas": + return 0.0 + weights = prices_of(raw).ons.weights + instant = f"{year}-01-01" + return weights.electricity(instant) / weights.get_child(key)(instant) + + +def published_rate(raw, year, month): + vat = prices_of(raw).embedded_electricity_vat + day = f"{year:04d}-{month:02d}-15" + ni = vat.northern_ireland_share(day) + return (1 - ni) * vat.great_britain(day) + ni * vat.northern_ireland(day) + + +def utilities_factor(raw, quarter): + """Published utilities price over its price with VAT at the basis rate.""" + weights = prices_of(raw).ons.weights + instant = f"{quarter[0]}-01-01" + share = weights.electricity(instant) / ( + weights.electricity(instant) + weights.gas(instant) + ) + electricity = np.mean( + [(1 + published_rate(raw, *m)) / (1 + BASIS) for m in indices._months(quarter)] + ) + return 1 + share * (electricity - 1) + + +# 1. Coverage, positivity, determinism. + + +@pytest.mark.parametrize("key", SERIES) +def test_indices_cover_every_year_and_quarter_with_positive_levels(current, key): + quarterly = levels(current, "quarterly", key) + assert sorted(quarterly) == [indices._start(q) for q in QUARTERS] + assert all(math.isfinite(v) and v > 0 for v in quarterly.values()) + for kind in ("calendar_year", "fiscal_year"): + annual = levels(current, kind, key) + assert sorted(annual) == [f"{y}-01-01" for y in range(FIRST, LAST + 1)] + assert all(math.isfinite(v) and v > 0 for v in annual.values()) + + +def test_generation_is_deterministic(raw, current): + again = build(raw) + for key in SERIES: + for kind in ("quarterly", "calendar_year", "fiscal_year"): + assert levels(again, kind, key) == levels(current, kind, key) + + +def test_the_processed_system_reads_the_generated_levels_at_their_dates(current): + processed = system.parameters.gov.economic_assumptions.energy_prices + for key in SERIES: + for kind in ("quarterly", "calendar_year", "fiscal_year"): + parameter = processed.get_child(kind).get_child(key) + assert parameter.metadata["preserve_calendar_dates"] + for instant, value in levels(current, kind, key).items(): + assert parameter(instant) == value + + +def test_energy_variables_uprate_by_the_fiscal_year_index_of_their_fuel(): + for variable, key in ENERGY_PRICE_SERIES.items(): + assert system.variables[variable].uprating == ( + f"{ENERGY_PRICES}.fiscal_year.{key}" + ) + assert set(ENERGY_PRICE_SERIES) == DECLARED_ENERGY_INPUTS + assert set(ENERGY_PRICE_SERIES.values()) == set(SERIES) + + +# 2. Outturn. + + +def ons_mean(raw, key, quarter, divide=lambda year, month: 1.0): + ons = prices_of(raw).ons.get_child(key) + return np.mean( + [ons(f"{y:04d}-{m:02d}-15") / divide(y, m) for y, m in indices._months(quarter)] + ) + + +@pytest.mark.parametrize("key", SERIES) +def test_outturn_is_ons_where_the_published_vat_is_the_basis_rate(raw, current, key): + # Every published price to September 2026 includes VAT at 5%. + for quarter in OUTTURN: + assert quarter_level(current, key, quarter) == pytest.approx( + ons_mean(raw, key, quarter), rel=1e-12 + ) + + +@settings(max_examples=30, deadline=None, derandomize=True) +@given( + year=st.integers(2015, 2025), + month=st.integers(1, 12), + rate=st.sampled_from([0.0, 0.02, 0.2]), + ni_too=st.booleans(), +) +def test_outturn_divides_out_a_published_vat_change(raw, year, month, rate, ni_too): + period = f"month:{year:04d}-{month:02d}" + + def edit(tree): + vat = prices_of(tree).embedded_electricity_vat + vat.great_britain.update(period=period, value=rate) + if ni_too: + vat.northern_ireland.update(period=period, value=rate) + + changed = build(raw, edit) + ni = ( + 0.0 + if ni_too + else prices_of(raw).embedded_electricity_vat.northern_ireland_share( + "2024-01-01" + ) + ) + published = (1 - ni) * rate + ni * BASIS + quarter = (year, (month + 2) // 3) + for key in SERIES: + share = electricity_share(raw, key, year) + + def divide(y, m): + if (y, m) != (year, month): + return 1.0 + return 1 + share * ((1 + published) / (1 + BASIS) - 1) + + assert quarter_level(changed, key, quarter) == pytest.approx( + ons_mean(raw, key, quarter, divide), rel=1e-12 + ) + + +# 3. Forecast. + + +@pytest.mark.parametrize("key", SERIES) +def test_reapplying_the_published_vat_gives_the_obr_forecast(raw, current, key): + utilities = prices_of(raw).obr.utilities + last = max(entry.instant_str for entry in utilities.values_list) + for quarter in FORECAST: + earlier = (quarter[0] - 1, quarter[1]) + growth = quarter_level(current, key, quarter) / quarter_level( + current, key, earlier + ) + published = ( + growth * utilities_factor(raw, quarter) / utilities_factor(raw, earlier) + ) + obr = utilities(min(indices._start(quarter), last)) + assert published - 1 == pytest.approx(obr, abs=1e-12), quarter + + +def test_the_vat_cut_quarters_are_the_ones_adjusted(raw, current): + # Hand-computed for 2027Q1: Great Britain zero-rated, Northern Ireland at + # 5% (its relief starts in April 2027), 2026Q1 all at 5%. Weights: + # ONS 2026 CPI weights CJXA 20.6065 and CJXB 10.3002; DESNZ 2024 NI share. + share = 20.6065 / (20.6065 + 10.3002) + published = (1 - 0.0269) * 0 + 0.0269 * 0.05 + factor = 1 + share * ((1 + published) / 1.05 - 1) + expected = (1 + 0.113241) / factor - 1 + for key in SERIES: + growth = quarter_level(current, key, (2027, 1)) / quarter_level( + current, key, (2026, 1) + ) + assert growth - 1 == pytest.approx(expected, abs=1e-12) + # 2028Q3 compares two quarters at 5%: the OBR's rate as published. + growth = quarter_level(current, "gas", (2028, 3)) / quarter_level( + current, "gas", (2027, 3) + ) + assert growth - 1 == pytest.approx(-0.070888, abs=1e-12) + + +@pytest.mark.parametrize("key", SERIES) +def test_without_a_published_vat_change_growth_is_the_obr_forecast(raw, key): + def edit(tree): + vat = prices_of(tree).embedded_electricity_vat + for rate in (vat.great_britain, vat.northern_ireland): + rate.update(start="2026-01-01", value=BASIS) + + unchanged = build(raw, edit) + utilities = prices_of(raw).obr.utilities + for quarter in indices._quarters((2026, 4), (2032, 1)): + earlier = (quarter[0] - 1, quarter[1]) + growth = quarter_level(unchanged, key, quarter) / quarter_level( + unchanged, key, earlier + ) + assert growth - 1 == pytest.approx( + utilities(indices._start(quarter)), abs=1e-12 + ) + + +@settings(max_examples=25, deadline=None, derandomize=True) +@given( + position=st.integers(0, len(indices._quarters((2026, 4), (2032, 1))) - 1), + delta=st.sampled_from([1e-4, 0.01, 0.2]), +) +def test_higher_obr_growth_raises_only_its_quarter_of_each_later_year( + raw, current, position, delta +): + forecast = indices._quarters((2026, 4), (2032, 1)) + quarter = forecast[position] + start = indices._start(quarter) + + def edit(tree): + utilities = prices_of(tree).obr.utilities + value = utilities(start) + if quarter == forecast[-1]: + utilities.update(start=start, value=value + delta) + else: + utilities.update(period=f"month:{start[:7]}:3", value=value + delta) + + changed = build(raw, edit) + for key in SERIES: + for q in QUARTERS: + before = quarter_level(current, key, q) + after = quarter_level(changed, key, q) + # The last forecast rate also holds for every quarter after it. + if q >= quarter and (q[1] == quarter[1] or quarter == forecast[-1]): + assert after > before, (key, q) + else: + assert after == before, (key, q) + + +def test_a_forecast_that_starts_after_the_outturn_ends_is_an_error(raw): + def edit(tree): + utilities = prices_of(tree).obr.utilities + utilities.update(period="month:2026-10:3", value=None) + + with pytest.raises(ValueError, match="OBR utilities forecast starts in 2027Q1"): + build(raw, edit) + + +def test_the_ons_prices_state_every_month_to_the_last_full_quarter(raw): + # The generator reads each month's effective price, so a month missing + # from the file would silently take the month before's. + inputs = indices.EnergyPriceInputs(raw.clone()) + assert inputs.last_outturn == OUTTURN[-1] + months = { + f"{year:04d}-{month:02d}-01" + for quarter in OUTTURN + for year, month in indices._months(quarter) + } + for key in SERIES: + assert months <= set(indices._stated(prices_of(raw).ons.get_child(key))), key + + +@pytest.mark.parametrize("value", [None, "2026-07", "2026Q5"]) +def test_the_last_full_quarter_must_be_stated(raw, value): + def edit(tree): + metadata = prices_of(tree).ons.metadata + if value is None: + del metadata["last_full_quarter"] + else: + metadata["last_full_quarter"] = value + + with pytest.raises(ValueError, match="last_full_quarter"): + build(raw, edit) + + +@pytest.mark.parametrize( + "period, month", + [ + ("month:2020-05", "2020-05"), + ("month:2014-01:13", "2015-01"), + ("month:2026-09", "2026-09"), + ], +) +def test_a_month_without_an_ons_price_is_an_error(raw, period, month): + def edit(tree): + prices_of(tree).ons.gas.update(period=period, value=None) + + with pytest.raises(ValueError, match=f"gas prices have no value for {month}"): + build(raw, edit) + + +def test_removing_the_ons_prices_from_a_month_is_an_error(raw): + def edit(tree): + prices_of(tree).ons.gas.update(start="2026-09-01", value=None) + + with pytest.raises(ValueError, match="gas prices have no value for 2026-09"): + build(raw, edit) + + +# 4. Annual levels. + + +@pytest.mark.parametrize("key", SERIES) +def test_annual_levels_are_consumption_weighted_quarterly_means(raw, current, key): + inputs = indices.EnergyPriceInputs(raw.clone()) + quarterly = {q: quarter_level(current, key, q) for q in QUARTERS} + for year in range(FIRST, LAST + 1): + shares = inputs.consumption_shares(key, year) + assert sum(shares) == pytest.approx(1, abs=1e-15) + calendar = [quarterly[(year, n)] for n in range(1, 5)] + fiscal = [quarterly[(year + 1, 1)]] + calendar[1:] + for kind, quarters in (("calendar_year", calendar), ("fiscal_year", fiscal)): + assert levels(current, kind, key)[f"{year}-01-01"] == pytest.approx( + np.dot(shares, quarters), rel=1e-12 + ) + + +def test_combined_seasonal_shares_weight_the_fuels_by_cpi_weight(raw): + inputs = indices.EnergyPriceInputs(raw.clone()) + electricity = inputs.consumption_shares("electricity", 2026) + gas = inputs.consumption_shares("gas", 2026) + combined = inputs.consumption_shares("electricity_gas_and_solid_fuels", 2026) + weight = 20.6065 / (20.6065 + 10.3002) + for e, g, c in zip(electricity, gas, combined): + assert c == pytest.approx(weight * e + (1 - weight) * g, rel=1e-12) + # The DESNZ 2015-2025 means: winter quarters weigh most, gas far more so. + assert electricity == pytest.approx([0.2929, 0.2242, 0.2051, 0.2778], abs=1e-12) + assert gas == pytest.approx([0.4250, 0.1642, 0.0799, 0.3309], abs=1e-12) + + +@settings(max_examples=30, deadline=None, derandomize=True) +@given(year=st.integers(2016, 2024), month=st.integers(1, 12)) +def test_a_month_moves_its_calendar_year_and_the_fiscal_year_it_falls_in( + raw, current, year, month +): + def edit(tree): + gas = prices_of(tree).ons.gas + gas.update( + period=f"month:{year:04d}-{month:02d}", + value=gas(f"{year:04d}-{month:02d}-15") * 1.5, + ) + + changed = build(raw, edit) + fiscal_year = year if month >= 4 else year - 1 + for kind, moved in (("calendar_year", year), ("fiscal_year", fiscal_year)): + before, after = levels(current, kind, "gas"), levels(changed, kind, "gas") + for y in range(FIRST, 2025): + if y == moved: + assert after[f"{y}-01-01"] > before[f"{y}-01-01"], (kind, y) + else: + assert after[f"{y}-01-01"] == before[f"{y}-01-01"], (kind, y) + + +@settings(max_examples=20, deadline=None, derandomize=True) +@given(start=st.integers(FIRST + 1, LAST)) +def test_held_flat_holds_the_annual_levels_at_the_year_before(raw, current, start): + def edit(tree): + prices_of(tree).held_flat.update(start=f"{start}-01-01", value=True) + + held = build(raw, edit) + for key in SERIES: + assert levels(held, "quarterly", key) == levels(current, "quarterly", key) + for kind in ("calendar_year", "fiscal_year"): + before, after = levels(current, kind, key), levels(held, kind, key) + for year in range(FIRST, LAST + 1): + instant = f"{year}-01-01" + if year < start: + assert after[instant] == before[instant] + else: + assert after[instant] == before[f"{start - 1}-01-01"] + + +# 5. VAT reforms move neither the indices nor projected spending. + +VAT_PATHS = (GB_RATE, NI_RATE, REDUCED_RATE) + + +@settings(max_examples=40, deadline=None, derandomize=True) +@given( + path=st.sampled_from(VAT_PATHS), + year=st.integers(2015, 2040), + month=st.integers(1, 12), + months=st.integers(1, 60), + rate=st.sampled_from([0.0, 0.025, 0.05, 0.2]), +) +def test_statutory_vat_changes_never_move_the_indices( + raw, current, path, year, month, months, rate +): + def edit(tree): + tree.get_child(path).update( + period=f"month:{year:04d}-{month:02d}:{months}", value=rate + ) + + changed = build(raw, edit) + for key in SERIES: + for kind in ("quarterly", "calendar_year", "fiscal_year"): + assert levels(changed, kind, key) == levels(current, kind, key) + + +def energy_dataset(basis): + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": [1, 2], + "person_benunit_id": [1, 2], + "person_household_id": [1, 2], + "age": [40, 50], + } + ), + benunit=pd.DataFrame({"benunit_id": [1, 2]}), + household=pd.DataFrame( + { + "household_id": [1, 2], + "region": ["LONDON", "NORTHERN_IRELAND"], + "tenure_type": ["OWNED_OUTRIGHT", "OWNED_OUTRIGHT"], + "council_tax": [0.0, 0.0], + "rent": [0.0, 0.0], + "household_weight": [1.0, 2.0], + "domestic_energy_consumption": [1_800.0, 900.0], + "electricity_consumption": [1_200.0, 600.0], + "gas_consumption": [600.0, 300.0], + } + ), + fiscal_year=2025, + energy_price_basis=basis, + ) + + +def situation(): + return { + "people": {"person": {"age": {2025: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": { + "members": ["person"], + "region": {2025: "LONDON"}, + "electricity_consumption": {2025: 1_200.0}, + "gas_consumption": {2025: 600.0}, + "domestic_energy_consumption": {2025: 1_800.0}, + } + }, + } + + +VAT_REFORMS = { + "no electricity VAT from 2027": {GB_RATE: {"2027-04-06.2033-04-05": 0.0}}, + "20% in Northern Ireland": {NI_RATE: {"2026-04-06.2033-04-05": 0.2}}, + "reduced rate at 8%": {REDUCED_RATE: {"2026-04-06.2033-04-05": 0.08}}, +} + + +@pytest.fixture(scope="module") +def baseline_runs(): + basis = {"period_type": "calendar_year", "period_value": 2025} + return ( + Simulation(dataset=energy_dataset(basis)), + Simulation(situation=situation()), + ) + + +@pytest.mark.parametrize("reform", list(VAT_REFORMS), ids=list(VAT_REFORMS)) +def test_vat_reforms_leave_projected_energy_spending_unchanged(baseline_runs, reform): + basis = {"period_type": "calendar_year", "period_value": 2025} + data_baseline, situation_baseline = baseline_runs + data = Simulation(dataset=energy_dataset(basis), reform=VAT_REFORMS[reform]) + direct = Simulation(situation=situation(), reform=VAT_REFORMS[reform]) + vat_moved = False + for year in range(2026, 2033): + for variable in ENERGY: + np.testing.assert_array_equal( + data.calculate(variable, year), + data_baseline.calculate(variable, year), + ) + np.testing.assert_array_equal( + direct.calculate(variable, year), + situation_baseline.calculate(variable, year), + ) + vat_moved |= not np.array_equal( + data.calculate("domestic_energy_vat", year), + data_baseline.calculate("domestic_energy_vat", year), + ) + # Not vacuous: the reform does change the VAT charged on that spending. + assert vat_moved + + +# 6. The dataset projection and Core's variable uprating agree. + + +@pytest.mark.parametrize("target", range(2026, 2033)) +def test_core_uprating_matches_the_dataset_projection(target): + basis = {"period_type": "fiscal_year", "period_value": 2025} + observed = energy_dataset(basis) + projected = observed.copy() + projected.time_period = str(target) + uprate_energy(projected, observed, system.parameters) + direct = Simulation(situation=situation()) + for variable in ENERGY: + np.testing.assert_allclose( + direct.calculate(variable, target)[0], + projected.household[variable].to_numpy()[0], + rtol=1e-6, + ) + + +def test_calendar_and_fiscal_bases_read_their_own_index(): + calendar = {"period_type": "calendar_year", "period_value": 2025} + fiscal = {"period_type": "fiscal_year", "period_value": 2025} + prices = system.parameters.gov.economic_assumptions.energy_prices + for basis, kind in ((calendar, "calendar_year"), (fiscal, "fiscal_year")): + projected = extend_single_year_dataset( + energy_dataset(basis), system.parameters, end_year=2028 + ) + for variable, key in ENERGY_PRICE_SERIES.items(): + ratio = prices.fiscal_year.get_child(key)("2028-01-01") / prices.get_child( + kind + ).get_child(key)("2025-01-01") + np.testing.assert_allclose( + projected[2028].household[variable], + energy_dataset(basis).household[variable] * ratio, + rtol=1e-12, + ) + + +# 7. No economic assumptions. + + +@pytest.mark.parametrize( + "scenario", + [ + "freeze", + "freeze then reform", + "reform then freeze", + ], +) +def test_no_economic_assumptions_holds_direct_energy_amounts_flat(scenario): + reform = Scenario.from_reform( + {"gov.hmrc.income_tax.allowances.personal_allowance.amount": {"2027": 15_000}} + ) + chosen = { + "freeze": no_economic_assumptions, + "freeze then reform": no_economic_assumptions + reform, + "reform then freeze": reform + no_economic_assumptions, + }[scenario] + simulation = Simulation(situation=situation(), scenario=chosen) + for year in range(2025, 2033): + for variable in ENERGY: + np.testing.assert_allclose( + simulation.calculate(variable, year), + simulation.calculate(variable, 2025), + rtol=1e-12, + ) + held = simulation.tax_benefit_system.parameters.gov.economic_assumptions + fiscal = held.energy_prices.fiscal_year.electricity + assert fiscal("2030-01-01") == fiscal("2024-01-01") + current = system.parameters.gov.economic_assumptions.energy_prices + assert fiscal("2023-01-01") == current.fiscal_year.electricity("2023-01-01") + + +# 8. Reforms to the inputs. + + +def realised_obr_rate(raw, level, quarter): + """Growth in a forecast quarter with the published VAT re-applied.""" + earlier = (quarter[0] - 1, quarter[1]) + growth = level(quarter) / level(earlier) + return growth * utilities_factor(raw, quarter) / utilities_factor(raw, earlier) - 1 + + +@settings(max_examples=30, deadline=None, derandomize=True) +@given( + first=st.integers(2026, 2034), + span=st.integers(1, 6), + rate=st.floats(-0.3, 0.6, allow_nan=False, allow_infinity=False), + shape=st.sampled_from(["years", "quarters", "open"]), +) +def test_a_forecast_reform_sets_the_rate_of_every_quarter_it_covers( + raw, current, first, span, rate, shape +): + # One stated value for whole years, as a reform keyed by year leaves, for + # a span of quarters from a fourth quarter, across the year end, or from + # a fourth quarter with no end; each removes the stated values of the + # quarters inside it. + if shape == "years": + years = min(span, 3) + change = dict(period=f"year:{first}:{years}") + covered = indices._quarters((first, 1), (first + years - 1, 4)) + elif shape == "quarters": + change = dict(period=f"month:{first}-10:{3 * span}") + covered = indices._quarters((first, 4), (first + 2, 4))[:span] + else: + change = dict(start=f"{first}-10-01") + covered = indices._quarters((first, 4), FORECAST[-1]) + + def edit(tree): + prices_of(tree).obr.utilities.update(value=rate, **change) + + changed = build(raw, edit) + effective = changed.obr.utilities + covered = [quarter for quarter in covered if quarter in FORECAST] + assert covered + for key in SERIES: + for quarter in OUTTURN: + assert quarter_level(changed, key, quarter) == quarter_level( + current, key, quarter + ) + for quarter in FORECAST: + expected = effective(indices._start(quarter)) + if quarter in covered: + assert expected == rate + assert realised_obr_rate( + raw, lambda q: quarter_level(changed, key, q), quarter + ) == pytest.approx(expected, abs=1e-9), (key, quarter) + + +def test_a_year_keyed_forecast_reform_builds_a_simulation(raw): + # Regression: one stated value for 2027 used to leave the generator + # without a rate for 2027Q2 to 2027Q4. + reform = {f"{ENERGY_PRICES}.obr.utilities": {"2027": 0.5}} + reformed = Simulation(situation=situation(), reform=reform) + baseline = Simulation(situation=situation()) + prices = reformed.tax_benefit_system.parameters.gov.economic_assumptions + quarterly = prices.energy_prices.quarterly + for key in SERIES: + level = quarterly.get_child(key) + for quarter in indices._quarters((2027, 1), (2027, 4)): + assert realised_obr_rate( + raw, lambda q: level(indices._start(q)), quarter + ) == pytest.approx(0.5, abs=1e-9), (key, quarter) + for variable in ENERGY: + assert ( + reformed.calculate(variable, 2027)[0] + > baseline.calculate(variable, 2027)[0] + ) + + +@settings(max_examples=30, deadline=None, derandomize=True) +@given( + key=st.sampled_from(SERIES), + month=st.integers(0, len(OUTTURN) * 3 - 1), + value=st.floats(50, 300, allow_nan=False, allow_infinity=False), +) +def test_an_open_ended_ons_reform_sets_every_month_after_it( + raw, current, key, month, value +): + # Regression: a change with no end removes the stated values after its + # start, and the generator used to reject the series as ending there. + year, number = FIRST + month // 12, month % 12 + 1 + start = f"{year:04d}-{number:02d}-01" + + def edit(tree): + prices_of(tree).ons.get_child(key).update(start=start, value=value) + + changed = build(raw, edit) + assert indices._stated(changed.ons.get_child(key))[0] == start + effective = changed.ons.get_child(key) + for quarter in OUTTURN: + # Published VAT is the basis rate in every outturn quarter. + months = indices._months(quarter) + monthly = [effective(f"{y:04d}-{m:02d}-01") for y, m in months] + if months[0] >= (year, number): + assert monthly == [value] * 3 + assert quarter_level(changed, key, quarter) == pytest.approx( + np.mean(monthly), rel=1e-12 + ), quarter + for quarter in FORECAST: + earlier = (quarter[0] - 1, quarter[1]) + assert quarter_level(changed, key, quarter) / quarter_level( + changed, key, earlier + ) == pytest.approx( + quarter_level(current, key, quarter) / quarter_level(current, key, earlier), + rel=1e-12, + ), quarter + + +@pytest.mark.parametrize("year", [2025, 2026]) +def test_a_year_keyed_ons_reform_sets_each_month_of_the_year(raw, current, year): + def edit(tree): + prices_of(tree).ons.electricity.update(period=f"year:{year}:1", value=130.0) + + changed = build(raw, edit) + outturn = [quarter for quarter in OUTTURN if quarter[0] == year] + for quarter in outturn: + # Published VAT is the basis rate in these quarters. + assert quarter_level(changed, "electricity", quarter) == pytest.approx( + 130.0, rel=1e-12 + ) + # The forecast still starts after 2026Q3, though the reform states a + # value for 2027 (the September 2026 price, after the year it sets). + for quarter in FORECAST: + earlier = (quarter[0] - 1, quarter[1]) + assert quarter_level(changed, "gas", quarter) == quarter_level( + current, "gas", quarter + ) + assert quarter_level(changed, "electricity", quarter) / quarter_level( + changed, "electricity", earlier + ) == pytest.approx( + quarter_level(current, "electricity", quarter) + / quarter_level(current, "electricity", earlier), + rel=1e-12, + ) diff --git a/policyengine_uk/tests/test_energy_reset_coherence.py b/policyengine_uk/tests/test_energy_reset_coherence.py index 87a60091b8..8ba3a1f19f 100644 --- a/policyengine_uk/tests/test_energy_reset_coherence.py +++ b/policyengine_uk/tests/test_energy_reset_coherence.py @@ -1,8 +1,9 @@ """External energy observations retain a coherent amount group and price basis. -CPI levels 100/125/150 are controlled test inputs, not an economic forecast. -The later FY2026 observation must extend to 2027 by 150 / 125 after reset -and serialization; the original external observations must remain untouched. +CPI and energy price index levels 100/125/150 are controlled test inputs, not +an economic forecast. The later FY2026 observation must extend to 2027 by +150 / 125 after reset and serialization; the original external observations +must remain untouched. """ from copy import deepcopy @@ -68,11 +69,16 @@ def _state(observation): @pytest.fixture def controlled_parameters(): parameters = CountryTaxBenefitSystem().parameters.clone() - index = parameters.get_child( - "gov.economic_assumptions.indices.obr.consumer_price_index" - ) - for year, level in {2024: 100.0, 2026: 125.0, 2027: 150.0}.items(): - index.update(period=str(year), value=level) + # Ordinary consumption grows with CPI, energy with its price indices. + paths = ["gov.economic_assumptions.indices.obr.consumer_price_index"] + [ + f"gov.economic_assumptions.energy_prices.{kind}.{series}" + for kind in ("calendar_year", "fiscal_year") + for series in ("electricity", "gas", "electricity_gas_and_solid_fuels") + ] + for path in paths: + index = parameters.get_child(path) + for year, level in {2024: 100.0, 2026: 125.0, 2027: 150.0}.items(): + index.update(period=str(year), value=level) return parameters diff --git a/policyengine_uk/tests/test_import_energy_prices.py b/policyengine_uk/tests/test_import_energy_prices.py new file mode 100644 index 0000000000..e8ee13cd28 --- /dev/null +++ b/policyengine_uk/tests/test_import_energy_prices.py @@ -0,0 +1,165 @@ +"""The energy price importer reproduces the committed inputs from real sources. + +The fixtures are the real ONS, OBR and DESNZ files cut down to what the +importer reads (see ``fixtures/energy_prices/README.md``). Tests that change +them change copies in memory. +""" + +import csv +import io +from pathlib import Path +from zipfile import ZipFile + +import pytest + +from policyengine_uk.utils import import_energy_prices as importer + +FIXTURES = Path(__file__).parent / "fixtures" / "energy_prices" +MM23 = FIXTURES / "mm23_energy_columns.csv" +OBR = FIXTURES / "efo_october_2026_economy_table_1_19.xlsx" +ELECTRICITY = FIXTURES / "ET_5.2_SEP_26_quarter.xlsx" +GAS = FIXTURES / "ET_4.1_SEP_26_quarter_gwh.xlsx" +OUTPUTS = ("ons.yaml", "obr.yaml", "consumption_shares.yaml") + + +def render(mm23=None, obr=None): + return importer.render_all( + MM23.read_text(encoding="utf-8-sig") if mm23 is None else mm23, + OBR.read_bytes() if obr is None else obr, + OBR.name, + ELECTRICITY.read_bytes(), + GAS.read_bytes(), + ) + + +def mm23_rows(): + return list(csv.reader(io.StringIO(MM23.read_text(encoding="utf-8-sig")))) + + +def to_csv(rows): + buffer = io.StringIO() + csv.writer(buffer, quoting=csv.QUOTE_ALL, lineterminator="\n").writerows(rows) + return buffer.getvalue() + + +def test_real_sources_reproduce_the_committed_yaml_byte_for_byte(): + rendered = render() + assert sorted(rendered) == sorted(OUTPUTS) + for name, text in rendered.items(): + assert text.encode() == (importer.PARAMETER_DIR / name).read_bytes(), name + + +def test_main_writes_every_file(tmp_path): + importer.main( + [ + "--mm23", + str(MM23), + "--obr-economy", + str(OBR), + "--electricity-trends", + str(ELECTRICITY), + "--gas-trends", + str(GAS), + "--output-dir", + str(tmp_path), + ] + ) + for name in OUTPUTS: + assert (tmp_path / name).read_bytes() == ( + importer.PARAMETER_DIR / name + ).read_bytes() + assert sorted(path.name for path in tmp_path.iterdir()) == sorted(OUTPUTS) + + +def test_the_forecast_starts_after_the_last_full_ons_quarter(): + # Without September 2026, ONS has the third quarter only in part, so the + # OBR's rate for it (3.7143% in Table 1.19) is the first one kept. + rows = [row for row in mm23_rows() if row[0] != "2026 SEP"] + rendered = render(mm23=to_csv(rows)) + assert " last_full_quarter: 2026Q2\n" in rendered["ons.yaml"] + obr = rendered["obr.yaml"] + values = obr.split(" values:\n")[1].splitlines() + assert values[0] == " 2026-07-01: 0.037143" + assert values[1] == " 2026-10-01: 0.045313" + assert "first quarter ONS has not published in full (2026Q3)" in obr + + +def test_a_missing_month_is_an_error(): + rows = mm23_rows() + column = rows[1].index("D7DU") + for row in rows: + if row[0] == "2020 MAY": + row[column] = "" + with pytest.raises(ValueError, match="D7DU has gaps"): + render(mm23=to_csv(rows)) + + +@pytest.mark.parametrize("cdid", ["CJXA", "CJXB", "A9F4"]) +@pytest.mark.parametrize("year", ["2020", "2026"]) +def test_a_missing_weight_is_an_error(cdid, year): + # Generation would otherwise give the year the year before's weight. + rows = mm23_rows() + column = rows[1].index(cdid) + blanked = 0 + for row in rows: + if row[0] == year: + assert row[column].strip() + row[column] = "" + blanked += 1 + assert blanked == 1 + with pytest.raises(ValueError, match=f"{cdid} has no weight for {year}"): + render(mm23=to_csv(rows)) + + +def test_a_missing_series_is_an_error(): + rows = [row[:-1] for row in mm23_rows()] + with pytest.raises(ValueError, match="A9F4"): + render(mm23=to_csv(rows)) + + +def test_series_from_different_releases_are_an_error(): + rows = mm23_rows() + rows[4][1] = "18-11-2026" + with pytest.raises(ValueError, match="different releases"): + render(mm23=to_csv(rows)) + + +def replace_in_workbook(data, old, new): + source = ZipFile(io.BytesIO(data)) + buffer = io.BytesIO() + with ZipFile(buffer, "w") as target: + for item in source.infolist(): + content = source.read(item.filename) + if item.filename == "xl/sharedStrings.xml": + assert old.encode() in content + content = content.replace(old.encode(), new.encode()) + target.writestr(item, content) + return buffer.getvalue() + + +def test_a_table_without_a_utilities_column_is_an_error(): + obr = replace_in_workbook(OBR.read_bytes(), ">Utilities<", ">Water<") + with pytest.raises(ValueError, match="one Utilities column"): + render(obr=obr) + + +def test_a_different_table_is_an_error(): + obr = replace_in_workbook(OBR.read_bytes(), "CPI category inflation", "Other") + with pytest.raises(ValueError, match="not the CPI category inflation table"): + render(obr=obr) + + +def test_seasonal_shares_average_each_complete_year_from_2015(): + consumption = {(2014, q): 1.0 for q in range(1, 5)} + consumption.update({(2015, q): v for q, v in zip(range(1, 5), (4, 3, 2, 1))}) + consumption.update({(2016, q): v for q, v in zip(range(1, 5), (1, 1, 1, 1))}) + consumption[(2017, 1)] = 9.0 + shares, last = importer.seasonal_shares(consumption) + assert last == 2016 + assert shares == [0.325, 0.275, 0.225, 0.175] + + +def test_seasonal_shares_need_complete_years_from_2015(): + consumption = {(2015, q): 1.0 for q in range(2, 5)} + with pytest.raises(ValueError, match="complete years from 2015"): + importer.seasonal_shares(consumption) diff --git a/policyengine_uk/tests/test_isa_income_contract.py b/policyengine_uk/tests/test_isa_income_contract.py index 81f5b62353..1607974ccb 100644 --- a/policyengine_uk/tests/test_isa_income_contract.py +++ b/policyengine_uk/tests/test_isa_income_contract.py @@ -30,6 +30,12 @@ NORMALIZED = {"version": 1, "representation": "additive", "projection": "legacy_gross"} ADDITIVE = {"version": 1, "representation": "additive", "projection": "additive"} GDP_GROWTH = "gov.economic_assumptions.yoy_growth.obr.per_capita.gdp" +# The energy inputs grow with these, from their declared basis. +ENERGY_PRICE_INDICES = [ + f"gov.economic_assumptions.energy_prices.{kind}.{series}" + for kind in ("calendar_year", "fiscal_year") + for series in ("electricity", "gas", "electricity_gas_and_solid_fuels") +] MACRO = Scenario( parameter_changes={ GDP_GROWTH: {"year:2026-01-01:1": 0.1, "year:2027-01-01:1": 0.2} @@ -725,8 +731,8 @@ def test_isa_and_energy_contracts_compose_through_reset_export_and_projection( Resetting legacy interest gives gross3000 less the later exemption1200. Reprojection gives gross3000*1.2=3600, then ordinary2400 plus exempt1200. Energy grows by150/100 from the complete reset group's2025 basis, or by - 150/125 from the preserved later group's2026 basis. CPI levels and growth - are controlled test inputs, not forecasts. + 150/125 from the preserved later group's2026 basis. CPI and energy price + levels and growth are controlled test inputs, not forecasts. """ energy = ( "domestic_energy_consumption", @@ -784,11 +790,13 @@ def test_isa_and_energy_contracts_compose_through_reset_export_and_projection( ) parameters = simulation.tax_benefit_system.parameters.clone() - cpi = parameters.get_child( - "gov.economic_assumptions.indices.obr.consumer_price_index" - ) - for year, level in {2025: 100, 2026: 125, 2027: 150}.items(): - cpi.update(period=str(year), value=level) + for path in [ + "gov.economic_assumptions.indices.obr.consumer_price_index", + *ENERGY_PRICE_INDICES, + ]: + index = parameters.get_child(path) + for year, level in {2025: 100, 2026: 125, 2027: 150}.items(): + index.update(period=str(year), value=level) parameters.get_child(GDP_GROWTH).update(period="2027", value=0.2) projected = extend_single_year_dataset(reloaded, parameters, end_year=2027) projected_simulation = Simulation(dataset=projected) @@ -879,6 +887,10 @@ def test_split_bsp_isa_and_energy_share_reset_and_projection_boundaries(): }, "gov.economic_assumptions.yoy_growth.obr.consumer_price_index": {"2027": 0.2}, GDP_GROWTH: {"2027": 0.2}, + **{ + path: {str(year): level for year, level in {2025: 100, 2027: 150}.items()} + for path in ENERGY_PRICE_INDICES + }, } parameters = simulation.tax_benefit_system.parameters.clone() for name, periods in changes.items(): diff --git a/policyengine_uk/tests/test_process_time_reforms.py b/policyengine_uk/tests/test_process_time_reforms.py index b53fa00b0a..1474427919 100644 --- a/policyengine_uk/tests/test_process_time_reforms.py +++ b/policyengine_uk/tests/test_process_time_reforms.py @@ -52,6 +52,7 @@ TRIPLE_LOCK = f"{GROWTH}.triple_lock" LAGGED_CPI = f"{GROWTH}.obr.lagged_cpi" INDICES = "gov.economic_assumptions.indices" +ENERGY_PRICES = "gov.economic_assumptions.energy_prices" CPI_INDEX = f"{INDICES}.obr.consumer_price_index" INPUTS = "gov.economic_assumptions.statutory_uprating_inputs" AWE = f"{INPUTS}.awe_total_pay_may_july" @@ -313,7 +314,9 @@ def test_calendar_year_series_are_the_growth_rates_indices_and_household_tree(): for path in paths: if path.startswith("baseline."): continue - expected = path.startswith((f"{GROWTH}.", f"{INDICES}.", "household.")) + expected = path.startswith( + (f"{GROWTH}.", f"{INDICES}.", f"{ENERGY_PRICES}.", "household.") + ) assert reads(path) is expected, path assert not reads("gov.not.a.parameter") @@ -394,12 +397,17 @@ def test_generated_parameters_are_the_series_processing_builds(): "gov.dwp.housing_benefit.allowances.protected_pension_age_uprating" ) savings_credit_index = "gov.dwp.pension_credit.savings_credit.threshold_uprating" - # Pension-age forecast indices are generated in their program trees. + # Pension-age forecast indices are generated in their program trees, and + # the energy price indices beside their inputs. generated_prefixes = ( f"{INDICES}.", f"{GROWTH}.", f"{protected_allowance_index}.", f"{savings_credit_index}.", + *( + f"{ENERGY_PRICES}.{kind}." + for kind in ("quarterly", "calendar_year", "fiscal_year") + ), ) for path in tbs_module._leaf_paths(processed): if path.startswith("baseline.") or path.startswith( diff --git a/policyengine_uk/utils/import_energy_prices.py b/policyengine_uk/utils/import_energy_prices.py new file mode 100644 index 0000000000..da2b78c869 --- /dev/null +++ b/policyengine_uk/utils/import_energy_prices.py @@ -0,0 +1,483 @@ +"""Write the published inputs of the household energy price indices. + +``python -m policyengine_uk.utils.import_energy_prices`` reads four files and +rewrites ``ons.yaml``, ``obr.yaml`` and ``consumption_shares.yaml`` under +``policyengine_uk/parameters/gov/economic_assumptions/energy_prices``: + +- ONS MM23, the consumer price inflation time series (CSV): the monthly CPI + indices for electricity (D7DT), gas (D7DU) and electricity, gas and solid + fuels (DK9U), and their annual CPI weights (CJXA, CJXB, A9F4), from 2015, + with the last quarter ONS publishes all three months of in every series + (``last_full_quarter``), where the generation step ends the outturn; +- the economy workbook of an OBR Economic and fiscal outlook's detailed + forecast tables: Table 1.19's year-on-year CPI utilities inflation, for the + quarters after the last one ONS publishes all three months of; +- DESNZ Energy Trends tables ET 5.2 (electricity) and ET 4.1 (gas): domestic + consumption by quarter, averaged into each quarter's share of a year's + consumption over the complete years from 2015. + +The generation step ``add_energy_price_indices`` builds the indices from these +and from the hand-maintained ``embedded_electricity_vat.yaml``. The importer +writes every file or none, and rerunning it on the same sources reproduces the +committed files byte for byte. +""" + +from __future__ import annotations + +import argparse +import csv +import io +import re +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path + +import yaml + +from policyengine_uk.utils.import_obr_forecasts import ( + build_efo_href, + normalise_label, + read_sheet_rows, + resolve_release, + write_all_or_none, +) + +PARAMETER_DIR = ( + Path(__file__).resolve().parents[1] + / "parameters" + / "gov" + / "economic_assumptions" + / "energy_prices" +) +FIRST_YEAR = 2015 +MM23_URL = ( + "https://www.ons.gov.uk/file?uri=/economy/inflationandpriceindices/" + "datasets/consumerpriceindices/current/mm23.csv" +) +ONS_SERIES_URL = ( + "https://www.ons.gov.uk/economy/inflationandpriceindices/timeseries/{}/mm23" +) +ELECTRICITY_TRENDS_URL = ( + "https://www.gov.uk/government/statistics/electricity-section-5-energy-trends" +) +GAS_TRENDS_URL = "https://www.gov.uk/government/statistics/gas-section-4-energy-trends" +MONTHS = ("JAN FEB MAR APR MAY JUN JUL AUG SEP OCT NOV DEC").split() +MONTH_ROW = re.compile(r"^(\d{4}) ([A-Z]{3})$") +YEAR_ROW = re.compile(r"^(\d{4})$") +QUARTER_COLUMN = re.compile(r"^(\d{4})\s+(1st|2nd|3rd|4th) quarter") +QUARTER_NUMBER = {"1st": 1, "2nd": 2, "3rd": 3, "4th": 4} +QUARTER_ROW = re.compile(r"^(\d{4})\s*Q([1-4])$") +QUARTER_MONTHS = {1: "January to March", 2: "April to June"} +QUARTER_MONTHS.update({3: "July to September", 4: "October to December"}) + + +@dataclass(frozen=True) +class OnsSeries: + key: str + cdid: str + weight_cdid: str + name: str + coicop: str + + +ONS_SERIES = ( + OnsSeries("electricity", "D7DT", "CJXA", "electricity", "class 04.5.1"), + OnsSeries("gas", "D7DU", "CJXB", "gas", "class 04.5.2"), + OnsSeries( + "electricity_gas_and_solid_fuels", + "DK9U", + "A9F4", + "electricity, gas and solid fuels", + "classes 04.5.1, 04.5.2 and 04.5.4", + ), +) + + +@dataclass(frozen=True) +class Mm23: + release: date + monthly: dict[str, dict[tuple[int, int], str]] + annual: dict[str, dict[int, str]] + + +def read_mm23(text: str) -> Mm23: + """The monthly indices and annual weights the energy indices use. + + Values keep the text ONS publishes, so the YAML states them unchanged. + """ + rows = list(csv.reader(io.StringIO(text))) + if len(rows) < 8 or rows[1][:1] != ["CDID"]: + raise ValueError("Not an MM23 CSV: the second row must hold the CDIDs.") + columns = {cdid: index for index, cdid in enumerate(rows[1])} + labels = {row[0]: row for row in rows if row} + wanted = [series.cdid for series in ONS_SERIES] + wanted += [series.weight_cdid for series in ONS_SERIES] + missing = [cdid for cdid in wanted if cdid not in columns] + if missing: + raise ValueError(f"MM23 has no column for {', '.join(missing)}.") + releases = {labels["Release Date"][columns[cdid]] for cdid in wanted} + if len(releases) != 1: + raise ValueError(f"MM23 series come from different releases: {releases}.") + release = datetime.strptime(releases.pop(), "%d-%m-%Y").date() + + monthly: dict[str, dict[tuple[int, int], str]] = {} + annual: dict[str, dict[int, str]] = {} + for series in ONS_SERIES: + monthly[series.cdid] = {} + annual[series.weight_cdid] = {} + for row in rows[7:]: + if not row: + continue + month = MONTH_ROW.match(row[0]) + year = YEAR_ROW.match(row[0]) + for series in ONS_SERIES: + if month and int(month.group(1)) >= FIRST_YEAR: + value = row[columns[series.cdid]].strip() + if value: + float(value) + key = (int(month.group(1)), MONTHS.index(month.group(2)) + 1) + monthly[series.cdid][key] = value + if year and int(year.group(1)) >= FIRST_YEAR: + value = row[columns[series.weight_cdid]].strip() + if value: + float(value) + annual[series.weight_cdid][int(year.group(1))] = value + for series in ONS_SERIES: + months = sorted(monthly[series.cdid]) + expected = [ + (year, month) + for year in range(FIRST_YEAR, months[-1][0] + 1) + for month in range(1, 13) + if (year, month) <= months[-1] + ] + if months != expected: + raise ValueError(f"MM23 {series.cdid} has gaps after {FIRST_YEAR}.") + # The generation step would give a year without a weight the year + # before's, so every year with prices needs its own. + unweighted = [ + str(year) + for year in range(FIRST_YEAR, months[-1][0] + 1) + if year not in annual[series.weight_cdid] + ] + if unweighted: + raise ValueError( + f"MM23 {series.weight_cdid} has no weight for " + f"{', '.join(unweighted)}, where {series.cdid} has prices." + ) + return Mm23(release=release, monthly=monthly, annual=annual) + + +def last_complete_quarter(mm23: Mm23) -> tuple[int, int]: + """The last quarter ONS publishes all three months of, in every series.""" + last = min(max(values) for values in mm23.monthly.values()) + year, month = last + quarter = month // 3 + if quarter == 0: + return year - 1, 4 + return year, quarter + + +def read_obr_utilities(xlsx_bytes: bytes) -> dict[tuple[int, int], float]: + """Table 1.19's utilities inflation, year on year, as fractions.""" + rows = read_sheet_rows(xlsx_bytes, "1.19") + if not any( + "cpi category inflation" in normalise_label(value) + for row in rows[:3] + for value in row.values() + ): + raise ValueError("Sheet 1.19 is not the CPI category inflation table.") + columns = [ + column + for row in rows[:5] + for column, value in row.items() + if normalise_label(value) == "utilities" + ] + if len(columns) != 1: + raise ValueError("Table 1.19 must have exactly one Utilities column.") + values = {} + for row in rows: + label = QUARTER_ROW.match(str(row.get("B") or "").strip()) + raw = row.get(columns[0]) + if label and raw not in (None, ""): + values[(int(label.group(1)), int(label.group(2)))] = float(raw) / 100 + if not values: + raise ValueError("Table 1.19 has no quarterly utilities inflation.") + return values + + +def read_domestic_consumption( + xlsx_bytes: bytes, sheet: str, row_label: str +) -> dict[tuple[int, int], float]: + """Domestic consumption by quarter from an Energy Trends quarterly sheet.""" + rows = read_sheet_rows(xlsx_bytes, sheet) + headers = {} + for row in rows[:10]: + for column, value in row.items(): + match = QUARTER_COLUMN.match(" ".join(str(value or "").split())) + if match: + quarter = QUARTER_NUMBER[match.group(2)] + headers[column] = (int(match.group(1)), quarter) + matches = [row for row in rows if str(row.get("A") or "").strip() == row_label] + if not headers or len(matches) != 1: + raise ValueError(f"Could not find one {row_label!r} row in sheet {sheet}.") + return { + quarter: float(matches[0][column]) + for column, quarter in headers.items() + if matches[0].get(column) not in (None, "") + } + + +def seasonal_shares( + consumption: dict[tuple[int, int], float], +) -> tuple[list[float], int]: + """Mean share of each quarter in a year's consumption, from 2015. + + Uses each complete calendar year from 2015. Returns the four shares, + rounded to four decimal places, and the last year averaged. + """ + years = [ + year + for year in range(FIRST_YEAR, max(year for year, _ in consumption) + 1) + if all((year, quarter) in consumption for quarter in range(1, 5)) + ] + if not years or years != list(range(FIRST_YEAR, years[-1] + 1)): + raise ValueError("Domestic consumption must run in complete years from 2015.") + shares = [0.0] * 4 + for year in years: + total = sum(consumption[(year, quarter)] for quarter in range(1, 5)) + for quarter in range(1, 5): + shares[quarter - 1] += consumption[(year, quarter)] / total / len(years) + return [round(share, 4) for share in shares], years[-1] + + +def _long_date(day: date) -> str: + return f"{day.day} {day.strftime('%B')} {day.year}" + + +def render_ons_yaml(mm23: Mm23) -> str: + released = _long_date(mm23.release) + year, quarter = last_complete_quarter(mm23) + lines = [ + "# policyengine_uk/utils/import_energy_prices.py writes this file from ONS MM23.", + "# Rerun the importer on a new MM23 release; do not edit by hand.", + "description: ONS consumer prices index series that the household energy " + "price indices build on.", + "metadata:", + " # The last quarter ONS publishes all three months of in every series.", + " # The indices use the prices to its end and the OBR forecast after it;", + " # a reform to the prices cannot move it.", + f" last_full_quarter: {year}Q{quarter}", + ] + for series in ONS_SERIES: + lines += [ + f"{series.key}:", + f" description: ONS CPI index for {series.name} ({series.coicop}, " + f"series {series.cdid}), monthly, 2015 = 100. The prices include VAT " + "at the rate in force.", + " metadata:", + f" label: CPI index for {series.name} (ONS {series.cdid})", + " period: month", + " reference:", + f" - title: ONS, Consumer price inflation time series (MM23), " + f"series {series.cdid}, released {released}", + f" href: {ONS_SERIES_URL.format(series.cdid.lower())}", + " values:", + ] + for (year, month), value in sorted(mm23.monthly[series.cdid].items()): + lines.append(f" {year:04d}-{month:02d}-01: {value}") + lines += [ + "weights:", + " description: ONS CPI weights of the series, in parts per thousand of " + "the CPI, by calendar year.", + ] + for series in ONS_SERIES: + lines += [ + f" {series.key}:", + f" description: CPI weight of {series.name} ({series.coicop}, " + f"series {series.weight_cdid}), parts per thousand.", + " metadata:", + f" label: CPI weight of {series.name} (ONS {series.weight_cdid})", + " period: year", + " reference:", + f" - title: ONS, Consumer price inflation time series (MM23), " + f"series {series.weight_cdid}, released {released}", + f" href: {ONS_SERIES_URL.format(series.weight_cdid.lower())}", + " values:", + ] + for year, value in sorted(mm23.annual[series.weight_cdid].items()): + lines.append(f" {year:04d}-01-01: {value}") + return "\n".join(lines) + "\n" + + +def render_obr_yaml( + utilities: dict[tuple[int, int], float], + after: tuple[int, int], + release: tuple[str, int], +) -> str: + month, year = release + forecast = { + quarter: value for quarter, value in utilities.items() if quarter > after + } + if not forecast: + raise ValueError(f"Table 1.19 has no quarter after {after[0]}Q{after[1]}.") + quarters = sorted(forecast) + expected = [] + quarter = quarters[0] + while quarter <= quarters[-1]: + expected.append(quarter) + quarter = (quarter[0] + quarter[1] // 4, quarter[1] % 4 + 1) + if quarters != expected: + raise ValueError("Table 1.19's forecast quarters have gaps.") + first = f"{quarters[0][0]}Q{quarters[0][1]}" + lines = [ + "# policyengine_uk/utils/import_energy_prices.py writes this file from the", + "# OBR economy tables. Rerun the importer on a new EFO; do not edit by hand.", + "description: OBR forecasts that the household energy price indices build on.", + "utilities:", + " description: OBR forecast of CPI utilities inflation (electricity and " + "gas), year on year, by calendar quarter, from the first quarter ONS has " + f"not published in full ({first}). The OBR's published rate, so it " + "includes the measures the OBR forecast includes; the generation step " + "removes the electricity VAT change.", + " metadata:", + " unit: /1", + " label: OBR forecast of CPI utilities inflation", + " period: quarter", + " reference:", + f" - title: OBR, Economic and fiscal outlook, {month} {year}, " + "detailed forecast tables (economy), Table 1.19", + f" href: {build_efo_href(month, year)}", + " values:", + ] + for year_, quarter_ in quarters: + value = round(forecast[(year_, quarter_)], 6) + 0.0 + lines.append(f" {year_:04d}-{3 * quarter_ - 2:02d}-01: {value:.6f}") + return "\n".join(lines) + "\n" + + +def render_consumption_shares_yaml( + shares: dict[str, tuple[list[float], int]], +) -> str: + sources = { + "electricity": ( + "electricity", + "ET 5.2 (Quarter sheet, Domestic (excluding electric vehicles))", + "Energy Trends UK electricity", + ELECTRICITY_TRENDS_URL, + ), + "gas": ( + "gas", + "ET 4.1 (Quarter (GWh) sheet, Domestic)", + "Energy Trends UK gas", + GAS_TRENDS_URL, + ), + } + lines = [ + "# policyengine_uk/utils/import_energy_prices.py writes this file from", + "# DESNZ Energy Trends. Rerun the importer on new tables; do not edit by hand.", + "description: Shares of a year's domestic energy consumption in each " + "calendar quarter, which weight quarterly price levels into annual ones.", + ] + for key, (name, table, title, href) in sources.items(): + values, last_year = shares[key] + lines += [ + f"{key}:", + f" description: Mean share of each calendar quarter in a year's UK " + f"domestic {name} consumption, {FIRST_YEAR} to {last_year}.", + ] + for quarter, value in enumerate(values, start=1): + lines += [ + f" q{quarter}:", + f" description: Share of a year's domestic {name} consumption " + f"that households use in {QUARTER_MONTHS[quarter]}.", + " metadata:", + " unit: /1", + f" label: Domestic {name} consumption share, quarter {quarter}", + " period: year", + " reference:", + f" - title: DESNZ, {title}, {table}, mean of {FIRST_YEAR} " + f"to {last_year}", + f" href: {href}", + " values:", + f" {FIRST_YEAR}-01-01: {value:.4f}", + ] + return "\n".join(lines) + "\n" + + +def render_all( + mm23_text: str, + obr_bytes: bytes, + obr_name: str, + electricity_trends: bytes, + gas_trends: bytes, +) -> dict[str, str]: + mm23 = read_mm23(mm23_text) + release = resolve_release(obr_bytes, obr_name) + if release is None: + raise ValueError(f"Could not tell which EFO {obr_name} comes from.") + shares = { + "electricity": seasonal_shares( + read_domestic_consumption( + electricity_trends, + "Quarter", + "Domestic (excluding electric vehicles)", + ) + ), + "gas": seasonal_shares( + read_domestic_consumption(gas_trends, "Quarter (GWh)", "Domestic") + ), + } + outputs = { + "ons.yaml": render_ons_yaml(mm23), + "obr.yaml": render_obr_yaml( + read_obr_utilities(obr_bytes), last_complete_quarter(mm23), release + ), + "consumption_shares.yaml": render_consumption_shares_yaml(shares), + } + for text in outputs.values(): + # Fails on text a source could make invalid YAML, before any write. + yaml.safe_load(text) + return outputs + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--mm23", required=True, help=f"ONS MM23 CSV ({MM23_URL})") + parser.add_argument( + "--obr-economy", + required=True, + help="OBR detailed forecast tables, economy workbook (.xlsx)", + ) + parser.add_argument( + "--electricity-trends", required=True, help="DESNZ ET 5.2 workbook (.xlsx)" + ) + parser.add_argument( + "--gas-trends", required=True, help="DESNZ ET 4.1 workbook (.xlsx)" + ) + parser.add_argument( + "--output-dir", + type=Path, + default=PARAMETER_DIR, + help="Directory to write the YAML files to", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_arg_parser().parse_args(argv) + obr = Path(args.obr_economy).expanduser() + outputs = render_all( + Path(args.mm23).expanduser().read_text(encoding="utf-8-sig"), + obr.read_bytes(), + obr.name, + Path(args.electricity_trends).expanduser().read_bytes(), + Path(args.gas_trends).expanduser().read_bytes(), + ) + write_all_or_none({args.output_dir / name: text for name, text in outputs.items()}) + for name in outputs: + print(f"Wrote {args.output_dir / name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/policyengine_uk/variables/input/consumption/energy.py b/policyengine_uk/variables/input/consumption/energy.py index b41846ae66..b134d4da90 100644 --- a/policyengine_uk/variables/input/consumption/energy.py +++ b/policyengine_uk/variables/input/consumption/energy.py @@ -11,7 +11,7 @@ class domestic_energy_consumption(Variable): definition_period = YEAR value_type = float unit = GBP - uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" + uprating = "gov.economic_assumptions.energy_prices.fiscal_year.electricity_gas_and_solid_fuels" class electricity_consumption(Variable): @@ -21,7 +21,7 @@ class electricity_consumption(Variable): definition_period = YEAR value_type = float unit = GBP - uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" + uprating = "gov.economic_assumptions.energy_prices.fiscal_year.electricity" class gas_consumption(Variable): @@ -31,7 +31,7 @@ class gas_consumption(Variable): definition_period = YEAR value_type = float unit = GBP - uprating = "gov.economic_assumptions.indices.obr.consumer_price_index" + uprating = "gov.economic_assumptions.energy_prices.fiscal_year.gas" class uses_energy_prepayment_meter(Variable):