From e0e83e8296819c3e8e37d9927d2504addd9b4cf6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 16:01:57 -0400 Subject: [PATCH 1/6] Project household energy spending with retail energy price indices Adds gov.economic_assumptions.energy_prices: ONS CPI electricity (D7DT), gas (D7DU) and electricity, gas and solid fuels (DK9U) to the latest month, then the OBR October 2026 utilities inflation forecast (Table 1.19), with the electricity VAT cut and its Budget 2026 extension divided out so every level holds VAT at the energy input basis rate. Consumption-weighted calendar and fiscal year indices feed uprate_energy and the energy variables' uprating. no_economic_assumptions holds them flat from its first input year. WIP: tests and docs follow. Co-Authored-By: Claude Opus 5.5 --- .../energy-retail-price-index.changed.md | 1 + policyengine_uk/data/economic_assumptions.py | 117 ++-- .../create_energy_price_indices.py | 267 +++++++++ .../energy_prices/consumption_shares.yaml | 95 ++++ .../embedded_electricity_vat.yaml | 46 ++ .../energy_prices/held_flat.yaml | 7 + .../energy_prices/index.yaml | 10 + .../energy_prices/obr.yaml | 35 ++ .../energy_prices/ons.yaml | 518 ++++++++++++++++++ .../scenarios/no_economic_assumptions.py | 6 + policyengine_uk/tax_benefit_system.py | 4 + policyengine_uk/utils/import_energy_prices.py | 464 ++++++++++++++++ .../variables/input/consumption/energy.py | 6 +- 13 files changed, 1537 insertions(+), 39 deletions(-) create mode 100644 changelog.d/energy-retail-price-index.changed.md create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/energy_prices/embedded_electricity_vat.yaml create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/energy_prices/held_flat.yaml create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml create mode 100644 policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml create mode 100644 policyengine_uk/utils/import_energy_prices.py 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..2f3591d22c --- /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 come from the ONS CPI electricity, gas and solid fuel indices to the latest month and the OBR's utilities inflation forecast after it, with the electricity VAT cut and its extension taken out, so a VAT reform no longer moves projected spending. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 9b94fe1c65..87f5957474 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -165,7 +165,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 each declared input is projected with. +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", @@ -183,7 +189,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. @@ -200,7 +206,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()) ) @@ -459,7 +466,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 are valued at, so the spending is before any 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 @@ -467,44 +482,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" + # Every ratio is checked before any amount changes. + 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/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..153fe0bfa2 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py @@ -0,0 +1,267 @@ +"""Household electricity and gas price indices, before the Budget measures. + +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 +are valued at (``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: + +- where ONS publishes all three months of a quarter, 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 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. +""" + +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 _values(parameter, period): + """A dated series' stated values, by (year, month) or (year, quarter).""" + values = {} + for entry in parameter.values_list: + if entry.value is None: + continue + year, month = int(entry.instant_str[:4]), int(entry.instant_str[5:7]) + values[(year, month) if period == "month" else (year, (month + 2) // 3)] = ( + entry.value + ) + return values + + +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 + self.monthly = { + key: _values(self.prices.ons.get_child(key), "month") for key in SERIES + } + self.utilities = _values(self.prices.obr.utilities, "quarter") + if not self.utilities: + raise ValueError("The OBR utilities inflation forecast has no values.") + + 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.""" + first, last = min(self.utilities), max(self.utilities) + if quarter < first: + raise ValueError( + f"The OBR utilities forecast starts in {first[0]}Q{first[1]}, " + f"after {quarter[0]}Q{quarter[1]}, the first quarter without " + "three months of ONS prices." + ) + growth = self.utilities[min(quarter, last)] + 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.""" + monthly = inputs.monthly[key] + levels = {} + projected_from = None + for quarter in _quarters((FIRST_YEAR, 1), (LAST_YEAR + 1, 1)): + months = _months(quarter) + if projected_from is None and all(month in monthly for month in months): + share = inputs.electricity_share(key, quarter[0]) + levels[quarter] = ( + sum( + monthly[month] + / (1 + share * (inputs.electricity_vat_factor(*month) - 1)) + for month in months + ) + / 3 + ) + continue + projected_from = projected_from or quarter + earlier = _year_earlier(quarter) + if earlier not in levels: + raise ValueError( + f"ONS {key} prices must cover every month from January " + f"{FIRST_YEAR} to a year before the OBR forecast starts." + ) + levels[quarter] = levels[earlier] * (1 + inputs.utilities_growth(quarter)) + later = [ + month for month in monthly if (month[0], (month[1] + 2) // 3) > projected_from + ] + if later: + raise ValueError( + f"ONS {key} prices skip a month before {projected_from[0]}Q" + f"{projected_from[1]} but continue to {max(later)[0]}-{max(later)[1]:02d}." + ) + 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..631d973aaf --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml @@ -0,0 +1,95 @@ +# Written by policyengine_uk/utils/import_energy_prices.py 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 used 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 used 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 used 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 used 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 used 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 used 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 used 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 used 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..db65b823cd --- /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 extended relief is expected 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..59b3d404b8 --- /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 are held at 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..653bd769e1 --- /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; none is annualised to fiscal years. + # A bare-year reform key names the calendar year + # (policyengine_uk.utils.dated_reforms). Leaves only see node metadata that + # is propagated. + 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..4e51ec11d3 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml @@ -0,0 +1,35 @@ +# Written by policyengine_uk/utils/import_energy_prices.py 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 are built from. +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). As published, 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..fdc2cf2c37 --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml @@ -0,0 +1,518 @@ +# Written by policyengine_uk/utils/import_energy_prices.py 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 are built from. +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..68368dec24 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 are built from price levels and the OBR's + # quarterly forecast rather than these growth series, so they are held at + # the year before the cutoff directly. 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 e5decfdb1a..579b715770 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, add_threshold_indexation, add_uc_standard_allowance_uprating, add_uc_health_element_uprating, diff --git a/policyengine_uk/utils/import_energy_prices.py b/policyengine_uk/utils/import_energy_prices.py new file mode 100644 index 0000000000..a915e3e73e --- /dev/null +++ b/policyengine_uk/utils/import_energy_prices.py @@ -0,0 +1,464 @@ +"""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; +- 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}.") + 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) + lines = [ + "# Written by policyengine_uk/utils/import_energy_prices.py 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 are built from.", + ] + 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 = [ + "# Written by policyengine_uk/utils/import_energy_prices.py 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 are " + "built from.", + "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}). As published, 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 = [ + "# Written by policyengine_uk/utils/import_energy_prices.py 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"used 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): From 7c69b0d19bf19aecf3672c85cc7fa18855120370 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 16:48:36 -0400 Subject: [PATCH 2/6] Test, document and import the household energy price indices - test_energy_price_indices.py: coverage, the outturn VAT division, the OBR round trip, quarterly and annual alignment, held_flat, statutory VAT invariance (Hypothesis), Core uprating against dataset projection. - test_import_energy_prices.py and cut-down real ONS, OBR and DESNZ fixtures: the importer reproduces the committed YAML byte for byte. - Energy projection, reset and ISA tests control the energy indices rather than CPI; process-time reform tests expect the energy_prices tree. - Check ONS gaps before the forecast in quarterly_levels. - README section on household energy prices and refresh step; dataset sources and growth factor docs. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + docs/book/assumptions/growthfactors.md | 2 +- docs/engineering/skills/dataset-sources.md | 34 +- policyengine_uk/data/uprating_indices.yaml | 6 +- .../gov/economic_assumptions/README.md | 152 +- .../create_energy_price_indices.py | 51 +- .../code_health/test_energy_price_level.py | 4 +- .../ET_4.1_SEP_26_quarter_gwh.xlsx | Bin 0 -> 24873 bytes .../energy_prices/ET_5.2_SEP_26_quarter.xlsx | Bin 0 -> 34513 bytes .../tests/fixtures/energy_prices/README.md | 31 + .../efo_october_2026_economy_table_1_19.xlsx | Bin 0 -> 21652 bytes .../energy_prices/mm23_energy_columns.csv | 1499 +++++++++++++++++ .../tests/test_energy_dataset_projection.py | 58 +- .../tests/test_energy_price_indices.py | 587 +++++++ .../tests/test_energy_reset_coherence.py | 22 +- .../tests/test_import_energy_prices.py | 146 ++ .../tests/test_isa_income_contract.py | 26 +- .../tests/test_process_time_reforms.py | 15 +- 18 files changed, 2530 insertions(+), 104 deletions(-) create mode 100644 policyengine_uk/tests/fixtures/energy_prices/ET_4.1_SEP_26_quarter_gwh.xlsx create mode 100644 policyengine_uk/tests/fixtures/energy_prices/ET_5.2_SEP_26_quarter.xlsx create mode 100644 policyengine_uk/tests/fixtures/energy_prices/README.md create mode 100644 policyengine_uk/tests/fixtures/energy_prices/efo_october_2026_economy_table_1_19.xlsx create mode 100644 policyengine_uk/tests/fixtures/energy_prices/mm23_energy_columns.csv create mode 100644 policyengine_uk/tests/test_energy_price_indices.py create mode 100644 policyengine_uk/tests/test_import_energy_prices.py 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/docs/book/assumptions/growthfactors.md b/docs/book/assumptions/growthfactors.md index 29112514f4..341189967a 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, with VAT on electricity held 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..d76fa80646 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, with +VAT held at the rate the inputs are valued at. 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,22 @@ 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 indices, their +sources and the VAT adjustment are described in the economic assumptions +[README](../../../policyengine_uk/parameters/gov/economic_assumptions/README.md#household-energy-prices). +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/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 2ad1f9247f..90d52ebd21 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,86 @@ 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 are valued at, +`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); and their annual CPI weights (CJXA, CJXB, A9F4) | 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 | Set by `no_economic_assumptions` | + +`policyengine_uk/utils/import_energy_prices.py` writes the first three; the +last two are maintained 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 run whenever the +parameters are processed, builds three series for each of `electricity`, +`gas` and `electricity_gas_and_solid_fuels`: + +- `quarterly`, 2015Q1 to 2042Q1. Where ONS publishes all three months of a + quarter (to 2026Q3 in the October 2026 inputs), the mean of the monthly + index, each month divided by 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 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 and CJXA / A9F4 for the combined series. After that, + the level a year earlier grown by the OBR's utilities inflation 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 utilities, + CJXA / (CJXA + CJXB), averaged 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 dated 1 January of its first year. Each averages the + year's four quarterly levels weighted by their shares 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. + +### 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 is fixed 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 +433,26 @@ 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 takes the OBR forecast from the first quarter + ONS has not published in full, and stops before writing anything if an + MM23 series has a gap, 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 index 153fe0bfa2..7013a327a0 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py @@ -168,37 +168,40 @@ def fuel(name): def quarterly_levels(inputs: EnergyPriceInputs, key: str) -> dict: """Quarterly levels at the basis rate, 2015 = 100 before the adjustment.""" monthly = inputs.monthly[key] + quarters = _quarters((FIRST_YEAR, 1), (LAST_YEAR + 1, 1)) + outturn = [] + for quarter in quarters: + if not all(month in monthly for month in _months(quarter)): + break + outturn.append(quarter) + if not outturn: + raise ValueError(f"ONS {key} prices must start in January {FIRST_YEAR}.") + # Months of the quarter after the outturn may be published; later ones + # mean a month is missing. + later = [month for month in monthly if month > _months(_next(outturn[-1]))[-1]] + if later: + raise ValueError( + f"ONS {key} prices skip a month before {max(later)[0]}-{max(later)[1]:02d}." + ) levels = {} - projected_from = None - for quarter in _quarters((FIRST_YEAR, 1), (LAST_YEAR + 1, 1)): - months = _months(quarter) - if projected_from is None and all(month in monthly for month in months): - share = inputs.electricity_share(key, quarter[0]) - levels[quarter] = ( - sum( - monthly[month] - / (1 + share * (inputs.electricity_vat_factor(*month) - 1)) - for month in months - ) - / 3 + for quarter in outturn: + share = inputs.electricity_share(key, quarter[0]) + levels[quarter] = ( + sum( + monthly[month] + / (1 + share * (inputs.electricity_vat_factor(*month) - 1)) + for month in _months(quarter) ) - continue - projected_from = projected_from or quarter + / 3 + ) + for quarter in quarters[len(outturn) :]: earlier = _year_earlier(quarter) if earlier not in levels: raise ValueError( - f"ONS {key} prices must cover every month from January " - f"{FIRST_YEAR} to a year before the OBR forecast starts." + f"ONS {key} prices must cover the four quarters before " + f"{quarter[0]}Q{quarter[1]}." ) levels[quarter] = levels[earlier] * (1 + inputs.utilities_growth(quarter)) - later = [ - month for month in monthly if (month[0], (month[1] + 2) // 3) > projected_from - ] - if later: - raise ValueError( - f"ONS {key} prices skip a month before {projected_from[0]}Q" - f"{projected_from[1]} but continue to {max(later)[0]}-{max(later)[1]:02d}." - ) return levels 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 0000000000000000000000000000000000000000..1268df4cf36af3d04acd734845a6505283d2b5bf GIT binary patch literal 24873 zcmc$^RZwM3&@Fgy=iu({4vo9JH8k$-?(XjH?lf+VySux)J9Gn=Z{}gFE1!JSlDbW<6O!hGy|owqJIVld-Ge@Gz4ht3jb;T`KsXmilf z`qIgv@937~2JD~U_qA;ld%38viy2J{#K>rgM&PmyyK8zi@gBhBLrJBvH128d1aj!o zIE4vc0U}U>VM=huI?%=!XkCV(V?qwkA-`;cToel)cJzDA=QiQyvLUCe`?ycS+eF|c zRZXc*co-FvVTGioQMBtYetQwO*An!xX_Ab}M1tqmrTP6ChY5*7ds;-WPewo|RQC=b zrcmi>Pd(P%N8=-=nNZeO+8mHAEvrlv9+qG}rn;`cuP3*1M$0#0rgEU+NHxe1ruTtr z^P>l?^~cZ0sp*!yK2=#Oj434hIai2B_A!}#@q1Wqv<8hd2!x*O9JV8VmL z6f=7Tzv;X4uwBC0LWun9zWBN`_`1UNWN;44_L(wPZ@C)16AD0 zl`aXMItzQ+sNuT8bmc1Yyyb6~aV^b-<&Obo1<^>eRk=&r{wh&LD0vAA$F_7yb+{n+ z(&9_$aX%#lS2_{q%Cq!J-$1LQhk@oq_1bq`VVYtUexD5Tyq_i9iGjcF&tn z*&hbqq-gv>_x;r~qEqaf!~Ry}cBK&be(qPsx9^=&*r+^E);J&fa&Y!qJ^5$u(E}ql zZJiJp_}RYpF;@I##r?k4dqMcOY(==KN}AwfcKnAQK=FTM$c_5}dii%|q<{bbIRAHs z=sB8LJN=J5Vfc@!Ntl4`XF?M5BR>S={#hI~?|M|K^#mo%Ehn4>l@b2_0*U#Az@Uxb z#F)*+6jRi&y+@aL7uy6gIPL1HdeV8sU7`pUzi1AD&YwSVby|c@{aZ;0$q@zCKqnP_ zPy+ELQ=Q`?EOo@UoRdcz^t1ovz?{ssi&xY6Yyg|{jWa=97MrH9fAOB2q%Ed$RCRjy z&z$9h0{J}2ex1S`r&zpcb<&X(cvOOO{3%fhtfg_>nj7a8Aw3u$!ZE7}&s50re|SWnP1Mj7GWZ^bWr z_eVite1&kSlyoUve)AiSTYt|kuX9E;F~Q%D-=*w+qFqP~6vV3Y8hH|}@P=iEsAALZ zmT4kBX*#5yqMwMRA{Nb=b4;d>dXc4?=<=S)$(!*hAMZPj<*g-}i+h>HHFJ7tXX~I< zBXuA#%Se%6z|{gs{CMf634rw>HZB#vYOnJ1Wg4Hw#t(!eexYeK9CE3DswviQq9jn3 zFf67fcd$=dkvrW3Q)~Rd|JdhpT;xqelP;US*$qX~HXw0Pff%OPa&SVmZ^qtWKksB$ zn6CXztO#t4)H{g)pqo^TX^s^~`($cz$}9D0nKPIKEQi8Oq59=8tXw#6RC`&Vc~dy) zm|E@hnr1F5qsPEHmr8g}8QQblV${vD-{f@Bo={Knz>>hwwoyp9cxlj-?MxHLUO@N4 zVPNLF_lbHQn%jAhU`=kFDCbkhU8uU9=hRL;e^qnF@qe5DK&AyrhHWa#fR_R;dc21N;RopUj?_ zdX?Aa4xEqA=al-PeCe5zswlT%RE6U?S&r94MPY4H#dV+B>I}q$fUjHvcs>{8`I!Gd zp&dU#;vYAv%ly0#v{+UQ>i`y#+u~~sBXmnSX1M)y2#3i)PE#Q~%Kz5@;di0$Z~$MO|Bj8UZ?jT}E8(x86D5zwHd^-o z%TS6}C?d)e7vhfz^u~dBV>>@q@jHG0%djd?`G%XIX8~`kkq8anuOW?*8x1GA&w-Qs zplYkx9^<5z4BL^p7%WARq0isV<@U%nBXbJqU}4WX3#8cFqWIPgehXXOw!+YfyRrLs1O=HUC|Tip?d{ z>K-oWO-1YSCAGqRX@@;(bfFoAdikI7v9}&`=uX9%L*EL-%(3F|^R*nsVQX8w6WlRZ zoLp45OEK(vDr-6zSXF4grFZs>ULZZ{Bp%mDFUT*v3G1o*n$fh~mKCB2taiJ5w(@!~ zl16yny*64`gvMzPc}_a~Iy%4@Q)ALSxjTpCp=Q)~Emo*`ZIGQ(p06usBrol%6$53R z^k9LSA>O5zb;`nsj9P~EI@ZUlAeOD0LoDTyp8#(rs{phlM0G)(wXj-)+pB3N2-hb9 zDwew=&be0VQMMsIC$aAO(_p+>SPdJG=|w~#U%S-(sx@H*MsT*dN6*l@VM9@TO37+ecX(>!AJ-Cjw!YdKXL?K7)wDumr$6RSU6j%~U ze_^CKAs^6#L7%T^{)>4SN50P>N6<#yQK*>tgPNcbwPUzj@~Z)X7PcX=TKaPs z!y0kLhDV##YS)N&tCv9|>86LfuF`F!Bd00UQcuVbKW_&8OX9A% z;)~)w3-WE5<`q7cSer_|ILBbWO4Txk%yy<+`gj8#@9iy2Pfr_(v+0)c@WhBw#-NT0 z%|X1Ncf|=iEQi|}TolJOw90QC3ORG6BoOsZpZYhHiPew#eQr9#68KwBEkg4788TPA zozvU~hf(W;jghk!^(osCrS>be#<;Mdd6o&S2k=;LxS|Y)SO_*QCIz^7o4G5sq*<1X z;8OkhBX-}@ul?$BKB7wbh$j_t0<XGUJ_wB0!59I>0_6k23wiXZ`20=TfTE%CbVh!)S%^lHe z??N7+Ur5gJnEs8WkiF#gv~hktd;$*C6rlZ?;oojAkne`#1n&9`8ZgN-gt~-PN_Pye=^Gw9gfl3B~1x@_Mcx&FO%B;Y+8xst8ww{HQbU%3~_2 zSwSgndeYr@33Vu?;Go59zSsH5*LIKVjnjt2bg)G6H}WU`NKGtbw=R`wvlif$b4KQ`HR}?m+o7sRwBF^{(yCt| zCg!)|;nT+yperPMSfqApNXe;O|EN~c#{q#U=?(oRy~Oq=7kyV)x&ph#k;z@}*@3|-jj%roB3bC9Ml_3<~)ztg4;jt(d&w{n0`nQ3wWQDi$ zWWlp~Cv#=(XFuMt5bU*c-(2 zvpG8R1@Mz>mSjl$E7HvN{Kmr{!-F7StTE^#*qiOlz~*1i5l+jSO)EQ@2JR?{8I>$Y z>q9&G2E(sR9DWT|g|oCHSUYBIIepG9>M*YsY-E6x|r; zN-yR0jvFf=mj1CsB$6&vTT}%5*A^l|^`!{@Z z3nSj7A9^qX4Rn`&H5Xp0-9-|s^C!ZFMxm{I*cC?__4*Y z0?aq}dof0(TO`QA=rz%*Pl%scK!fD3c&k+Yv$=Apo<*5>L%7^})yb6Jd59p?lVyx7 z(_=6gTR4^knUD|>(uLqoFZ`X2wQU5Q*1gCZ0<)WJ?WC9F)r!rXqy6LbWIVnS?TwKK z)3_$5&@vl?QekWfVb-YT2wYh(PCyBrIGAcI`kZ}>k01@I#^IY7eMMUsj1w0t{-xoot7Q+Y~sD@ zTr9d1EgW|WnBZ`o*ATV^u;75fPuhlFV@3%>ck4d3dn5#TsdM_uxTE)_R%R44HqC7d z7MntF>D`eOML2SZ=R5kV8zwiHOh4lIIeq2&^sWiQ2}RBR{AS=*2jVf;08NQfeQTba zH5_vQHKJum{g*efz|@~1XFR*@XIUWH(_CN5c%u7lzZ{glzM@+LXR&J}6nZ`XWZiR6 zA~Z8hf5QFGbA(S*%6X^)0N|Jm0>JuzZW5f#O-!7fzK;~&hU34pM6-`Y5`Js_(Mv@7 zSN^VBGc3<;9dAJ8xzA3TU6ZI(G-U#8V#`ie#M@1h$Db@82gLKlTx^=$k7}*8@@jB- zl7!Kt-(Lp;U#}m3{JP(6@2>v333Rh)4OHE1cm6&5n6caWct5DRsLHu{sJh|z_58R# zS?THVdpRCa|9Uz;sQS8V{rcSbeEayO#h*+yQuE^1?Ln#F>+!W0w~}|LC-ComU_?Nm)8pgeJnr-L1BwU!E=)kT$LHnTA-Ko; zHIsS!>waP*Z+r5_?|p>OZrjHb?{FY%N6!!CQ{eOYc_wbA=;Ll-1^#8^0{C?oC(!eC z_Ce@3j^yX}_wRbWZN^3wcppyE2*-inZoDXdU+M@(ozS~^b}pcLp$%VYMj;dSCCy_fusT0!Md@b^d0`?vnY?4~v$%lYqZit+c?-QTjakDFFIzt6{wxZn3` zEFuonou0m~cb|G*cH5ut8CHBUsk>kca$2Z_o?(%H*ep|2-Uwnwg~Oe z>l)F`AzYU%KW_lJ$}ZOtz?$<`2Cal`!8+S_fzXYmviH`Y`AU4 z?{ejfv$zlYZ;1e6-WO-*KZjKo-|&x~kG;Dhb>$pAKhKZW9$)vb-#7f#-R}o^zkj}^ z_h8XxN<*9q#NBBW!|kKhIww~SgbNy59`#AQz2*6_w*Pwy^EAkPQ_npaN7eBtrB!e_YOm#8OEL30TqE#kk;uJD_&(O3d{P8$J|KTXnv$?J@4T__bxTS+l+Su+CwNaDAX4Y7j%UnreDJ_nK;YJr|s6`bPp)D>H{6#>&Fg zi>;z$?PBgQ)wD+fY&t3kQ4SoN;MrVO#%Zw#gp2USe0M{^lZ^?pj1MP0s*wO;a>yeN z^3Z$kThx0t7o1kd&jfeuzMk{Xw)12oI@usV7=5di0CViV`KAc}qcpuliB8u=EJ3AK z2vkwSJD%$@g_fJ4mf`zr`I=1QU9bAsgW9Zszp)Q>y!@w#Zr4Su#UoybvXu7uCQXyw zfla3%HGGR0->3zGe0#t~DB{ImOL3p?@IR{KKg#x->iv&;{EzDXkNTY*8BiIf&ze8! zg~b5|?Q%k1O3!@exKRx^h8u;Ke^y(l{xY=CkNTb)ZUkPIF|^D% zaJbZ82|lY}ZXhB&Ftp(RUsAw-(*Fk*e6M%?Z$005ca%-se<6#$sptPe;kI5@MA>XM zeg|+6$n97iM({W{*x1+Qd8)HMDa{+ z4zvv6n_hprBeK4HyWgaHt+tdGoqRi0PP)!NIkWKJGF!<|cSeXw>4o(}XQ{)P051JfQM6LKTEygnb3#ycX1ja;J{m zHam$jLQpfm785vh=bGLq@k5&e`wQsRd-68=IH+zI^6bM6 zhq)!}@<;`TsMrg76dT#TBl^vCt0-|5Sti#gZw&uYEY#d*8bKDQC{(Q%AxP>X9Sxvh z-(dK$A%tp&f#8ZI#IFuAQLXYTOE@{9=qJ-CUS@h;IrxUs*4eRNAccW*)~%yrkWA=E zuzKbJD^9x;K($nSZX^bO#w^IjkiT{iqvJyL@LGqh9f4x;CNCIxl z@ff9c3u;{J^QG2yptV{^Hh-&KYiOK~-n2B3z~mjDyZ~h0_iwWqjGWLf~=NYcN9i4Uz;;D~o|0WEWyiiNN5MRLT+oeBy_(HF{od zuu?g^&K+U9aTq3alemI4A<;AvfXIZ2r?7LR!s;0R!o@Uh<}b($oX*8`2Ao1Xld3Hg z+V?j?#~`-DLP)6k$J=Gk4$w*>VWUc`{74OWY{$keK*}9gA|K}#XbQBPwQg|-7!8-v z8huAvkDW=Dur!ODiGg|+fhLTp*#U;_tCP)$Geg9 z$cB@mv>GV#zJxG`T>l!P4jm5PWas7QPA#O;5(?>OxM`962pC?aPF8{D8(J8SO@^TQlX|t+wf072Hqwh>ohd?a}(OHRFHa8 zwTD|pkrP5jgICgLdUH5Kg7dx!LOtTYu7XS@tsBym4}k07T~#JHu@EF^X;%Odr~w*l zN_65;H6&*6$(~UR_2@~fIjl`8lMED$>Br`fl3yN%|MGt~x6DosBgE>^ z0A`S&tXda=OMcA$KclU=?Bi!n2$<~})0<>?JZ$c0)!Ng^sVG15iROx$DzcCh6A#19eQvG z{l^E>TbUXhEYSt)gAgw1us{f;C{kig0bf+6DWqx3;IE7vV*zosU0M)|kaL?_k_-C+ zzy_t!waW(qG+a<88rp>k7$1WvIWK5eE?Ao(gEMW|j=s@;1?Tk)%@iab=Md*Pa2#TH z(g*PXN_~jeLn9)+!$lfU=3=AG!UfqXDA5WoC9t;#WH=w;GR~PH9PPq|Bpk^zi_EC; zpnt?C^eG4wx7ZFH=H;SO6S_FMrl5L0Ylt`|vZ1feOUfnH7%1YQ3M=#}K(Qv)T*%U_ zRLf=oTLY?fXE;0f^Yi@P9G-mXbJD`uN-y^!>!R4r(*+R`^sWV5T*uU2O2nYkjzmm* z<~YG?<+>*BlZt|IA$-w8vGf4R+g{tut7jhu+ytm`SKoPvx3^kJv&esC54IvHgMu&S zo|9hma;no%t;Ii&Kc0ttkHB4JG)?#g`HQee^SB8?^OW53X4qoV$b4}br7k_B>_wt3 zJOLsl9DLb7_%^p6?adYjw&Z{>9DJKQkGJ{@0|RovI1WDE?Z;c=g@FY*;LP{rTQOW1 zn2-bh=HTo6R!kQLHspXe-;-~}U|~S>2me;v@x`#1-jCY%4u`cB*Dc&g^Qx=$m(+gP z^(VVf*^_Gw@UaCRO`>8Dk-c+bN2#zA4CFO;h1RKN2yv9gP-SjSce1W>%2qKEl<1CO zX4kTzNc=&0qAyjr3={+#{-?mUz9OS8QQKFtUs`C`Crc*&pw@H7k(n3Eerk`Ww3c_| zWt!|u@n0Bb-R_RD&H4{j0OZJ<_ArCT$GEWk^*{x_vddE}E%?eUmi! zdZI=-&&k0AP3>V~EBxvN%2C2wF1| zXSg`}a2wc&K<8nM5yRdM$qQfJ;0P9S>pi|+ckrsYjix6@=~5@hC~I1Yo&Zu(dBKS4 zf9!3-n$8oT2{VK4L-ir8gtAzt&@3&lYCR=o>D70jqQ9>(rK2pFuMY<~>;9a;Vut#I zFKw&_ZdTD#ibhqPKpyjsp9PU5XvXt!6=3Dg@DMdjw3NXHbBF5#BF?e_jhfJ}Mg%%bKuMV3iGrFt4LK;F$ z9Qk-lh}|E{@P>#8qqEYBP%4C=;iT?rEaU4^pZbM5lugyLjprfTcoFi_p9*gDN-Q{y zLQ>QBsP6QP-w5lcn+sEbf-dFTdZba-~P{chrb!j z2SbxE;~+qmGWw*kyqKZC!WC6zhev}qjX0aB@^$YqOmhzr{C|vfA@mXm_ zOSv-%=N~Z*;c(d^;sCUeIr&6dM+`x&#un&i77#-#rCb;!Y)am}%(kFr>QH1e!De#= zDaNz8%nEu#pJ}eUyB3HV+JJGfru^n%FOIiij8?-ht3?2Tme%R{k>(-m4hK@jon+Y| zMz5jxt0-ke{ZO4XV(5p35uDLY44ijo*pwr>YZRus5uB;Ypf{?P%iNniw3UJarL!60 z?zEwd^_u~V5vg@KmwoL%w3lT)4sR^jUN?^Pn)AK@*u61CLS9kSC@Ton4~F}pgVn-K zNJ7v(eemRypOConvQhW?PwsLOPJh~2A=^RP-X$0l#kje_U)+>>@PF!ALI;=K{$og)24x&B?C0CJ3-~jQYt|BJc zc;gaip?{Lu54k9x81%g~utCi9iKI zvw{TX83ZM)=(uzaPP+kaNgTl_a;Ds--bOHk6!Q=eneT#i*o6?1Wc-X%$W)Fm`+LJK zy!t($wa$CS8z5SEPPJjpZ|#9oO;OYh-FfGIXm{{7$~Ob03DquUO|rF(>-bN$C_bStS>udt-K+EwcfR`>{*K1mUb(`t8-V!$6#eG=SG zFMdc^LZ)NTbZGOB>=sh+%1EbBS$M1o*KNhuz{r=vqrRkKh7EONO(yl%3}jkKoz|Q1 zQ}GT9Oxc!nIfspI5|8W~p5&kbpH~7k@LXvE{W%P77WIGfvFca}gGz&-y%R;YRrKA} z3eG>e5$qxo9JyWn2Qj42&6OxH0PO;r5$&SXSby_r;Kwfgmm#-*EMk}^qpE6UV{^G4 zK>Ig}VwF?&nhzQdHj8Bvwgpqsu#fwiCp>p8ZJIn<`Ev#b!^ITFfE!EiLJn_Jl~5@ZQ%;P)5Q-t$g)_G&CEx`ZYvO3XuL*&n40 z_>;K@nnQnyC$|NSn}y2PDi$JU;DoB}nD`2fn~hasu|R*b3aT_&Q0F@!5uyy#ueZXP zh);_iM{W_OuV;@hvl;wQ%qKW4wXm+Qe4|dr4*FB***%NO*FRl8Y`lK>BOr$5IGOY0 z2=AxTNY=9OuL~=e%81_Fih8>oW%@P;%O5-}JraVAE0zj}8*L!9C|pIh4xWnxVn3zy z37>Lz-GNlI!R-I)FqSANsw^m@%1SWyTqRQ#=Gq!Vr%Tv0YlAvc@1WN}x2l&}xK!-J z@H-DK8(}y!suGr*Um-fnnCimJ0&84dtfY}a>Ndvlc;s395q?<0!$43s`L~eBz=r$l ztenhqeG~;)<)}sV79h=hKLx|f+3$b@hx) z$8u$0lAsUtA!vhK0e?lvObv9MU)=vO{+1{S=w&cBps(}?!I8!M!GOG(s>0;#o~v&Z zJBKFin$t((s`iNa7t7wp%7OWw*+0Gza$0E+;xdvM1GX?z>-4>R8n&?weOdu)dd9^K zg~#G#D|8s4!`~shEA;qpb-k_XX<>mBi}{- zVacq6XXeV%q-W-LeffYN&a}NoSpV>P@U&sGpnAZU1u?aoVy+#8^>}>*qTCZ= z0BhYYNWP5)3dUxAsuq5yPv0lQ-nhcdepZ^cAf2nek{a6|`E8!7xno68!D#sm2iYpKuT zJa)AxoMyB+g3;df>W|STZujS*V=I&(Bvsp5uQGLRUu~j0F{(1erX?GTxkYknd%8Y!ufEdWQgcT6;OV2}t181gz);sR%;g7qYzwGMeKgM&zwcQ_=u^65? zo5?)W!k(~BQbl{^(jqq=Nx~@q;S<3G2|e{hb*=^y4Dub8fb+863`AwSxzoA>$mUeK z`b2icXlwL+V0Oi1@&`1z#dYzXl(<0>jbY^vsPC0Q*Qw1$W%9FnoU%#t6p?iS5sVl& zAq)waQ#=r55Vy+NBUxcq?g=hn-KU|)k{=1ZxAe8?NNjGn%dhw_l#w1G1i@aJRX;AQv4?#ux*7M(9KrtUT+0|6kUcEIIn;IL27%_X0f0n ztP0(gTykHh=xFN=TbYvNYP=`{59$7*%WG^=b`_`WQ={zpJ14DE9zqB7Ny7{O+)w-$ ziv-0o7E&vD!F+!jhSz332s3U6Z3wDtl%bNmLnOEZnk3{AFt$Fg+$Rl@-VZ1EiT+$1C)>w~BnBl_m(|8zJ31 z4`QBge`%r}5VKgR50NPew>?Ewkf^bR1mp+W<7kZ`2BZGNsLw<3gQJ2*4HUms;lX8t z$j0(94I+~gf?szHHpFMJZ)tos)C^v6kFc5=I$V)IKdd!qX)f(Ggd$QTFl>dyTxEtC zA2?2SexkR@IiP0%l(B2yMl%&k6=1VPEYv7^nAbarOn=^iWK6C(Kx|ed#wO4QZi=Gf zp!1iD95fQG^?}*Nt;fXRO6MyP4@cttS}+@ddh?3cy6EpU!JX}}*CsF0qSvj!zPJ$){9w%l7c_n{GEbmq*6 z865HK`x20)=w~kiCNe~2U-?jg_(CV}2F;{$Px2H2l!hxmOI{%B`xi0=s!a{F&;>Tb z?huV7PM)~u7_DEvNr6q_&2~rYi9LxFBRreq&p=dgJ9(rgH714iA9AT}2~83n`j|Oo zn$a1CThe}dwpjuskEw$=E6moq)91Y*@o78G4=DN=)*S0H5pO#fkQr_XdIT{;yw(|! zyUnA$U{iUW_jGbF!W6uh3~sQ@(>C3EF4z$##8(I=G8M{`ACJX4pebq~gZR&|gVh|E zn#n7@3BU5GhO$xzEw_3amJ0(qC72wj?+eZle>I}*IRc|C%J|^*Jpj{*{;@vdSDe$h zwm6`-OYk1!ARv$L)?C`A;-X`M?vGpIrS0^lmj(b8rHBAC_oSGRgOj& z)w-^V7w3T5uZ_5Xcf$`dJwTLL$VAlZ;b6Qf&(qYxCK4WKqkn?oBUOvt^v7xcF&xIB z7NRKZ>6RXi&S1OZca))(BG9VDj3zHqhGSJ89u-t)qPY-+1*A=EfHU?&(0T&K^R!)) zpdz<{HE~;8nLPd>bu>dBv;M4*yg^9*%N$KUjXuFu_&B((D)sa)Cnw@_c5Q(4Ooz7T zA*6148{U#L9BD-V*d=7URYl?6?adj12Tgm@)gwSv_o7ExUuLL&F-8KLj?_DEse4D2 z4?WB=mNoY{BYj2E>%O?@1{ZV(ZDywYGn<7q9x~5OEAu9Y`~D7(>xMYRHKRncLa_J) zCgL=o97PD*7SRcsv}T>C1{vBdV9R0~%7As!eK7*!U{1gFt=tIU=B78_QmIFkm8NA}4$vphY7+Ur`| zMj>5H^wVeY8_)x{PXSAOok|$i^S2=(RwGc^GsGU(V2ZSk-(A1N_Y%Oe+}xpyE2k_~ zN43zdYKOD<9s;Nmzpw28wKG0d}<{# zDmg>cb85Jk{3r5L1D(zZYOG=-jKK&a$mq(c&;^DZ`}mUsq5=N{loI<_7*hvjEi!S0XQCj2cW)zG4WEerOkuJ8G79-D4y zkLi`_ut5b~zDr=ihC*L!31>W(KgnYbkO znykUMkY0CV#r@|7@uyk2MJYn!l~@Y#^BvrBlj;-+Im;&UywNFNVYbB)iSdO9(?{G% z+0ewBK(w867?w#df~v6&TLi-rE_PEnRK-r82i1~#aoVmu!N~|uD4ZDJeyaPymYUZ zO1#O!aeV)Ph@-5Dt+#}Go^e;W^ z=@5dl<|6JzLs?W$&~XTbg+^u1XV`nQV_~)>#X#GV&!{JVf zSI!}zO`3<{mehJhkmDt-%CoNaj+J9Mpp9)Czt@AE3^ceu+-opn8M{WtV1*_}P*Ry9 z#e?6`#(cOBU<~coL>+-Mq!I1V{uPV}>qv!XtZAzmbD^^ox8AKxMF_;tt>)yay8l(k zPYaK6rJwJA*1et-*07tpF0;V?X2)0XbKJX3t1P<0%wDtA{ z6qB`W{IfhAEb6e)uoXU%#H$l>QM4YX9&*OddIvPmcukTu;OcJ|=sY4%F|-MpbjI)_ z=vO+;w`g9eE3dFV2e(I5G8Qni*=$fJc{U_$Ub9|h=k=bLY>6vL2lQ01XuVx$PtiSc z#p3YN6Le)oiFB*tx^fg-f0AVR+kJYZ2m-kaW}e$+9y}E^8mTh^ilN%|rqUAJ4XA7p z1jw_tXu(G%$P)K>WRu_W*s$9G?=+&0w1-gBf6IsM(xO}9*CL1RPq0Xgw)p@&fP3mx zGFUJz$>yJ>+&^r3E`$j!(y$_xV4khRCh|9M?LOL~^i( z++Dm=2&n7gC$y5RtzO@1R%1Gi7_y__8rQm}hPGQ;Mtde)s)4h_LleHZU`5QSF)ktW zy;|vL9eIGqTxG%m`2{_k;cosN?5~x_&wwi4WiLEAe0HtXmTHo5x!4@YdUUZ=HkZK4 zt$Zf7yyQYXz|opz6K19_X`y#>Ikh0@%U%8w`Pc*)Rh-hFBC?()xLClOVrcRz5HcP! zrK$v9kLI>PrToZnHn+5VYZ&a1-NGrh!I&l*@JS@Dbdn9+efe&xH*D6 z&|{lnQH;tIWsaJ1*iZ^!n35-RtR!Zkq?pQTn}?*phT^l;1jrNe(d*7v#kaK-;S7hN zcd1RF76VHi#5%sRM#mYa?T#?>iTG%se=kW?h%;&!Hay?bxC(y|;j)Zq$(IufQ-$KO zo1ap0{ratL(5HU0r+(_gDdw4vzuOc+oAM-9vb!W9@0%i`$+%vGFrz5(S})yP4Ns{G zQTwa0Vs8$ne+3xflp=rk=Se(F?(;6@(I))D83ua4bDX|gZ$KSxWQ#{qnR z6xU{oRvscZlYF8*GhOnc`nvT&UW6`#@1o zbK~z>OEtaL8ucQI##Y^b2HnhNgc_XJqqFCaXrypXPC&m>#)Vb>EHtPyEOBEEHHVJh=2L?V1T9hKlPJ6ZVsN$KS zHXXIa<~d66IY+k^U+kg{bYCtz416OQtsyUZfg>Y*S^~i?fjah~s-YWUN}#zQ$b2Co z42Xa^1sgjoe=#F$&N&<4vG8MnOB*h~HkMZYwKn{z515^y33AC*Auf0fa%pJsCzcX8 zs~fG>StbQ&ODk^O-_(Oyn}6p~b-^~U6QNWuA$e>*#Z+Ht7rJf^Ai0BhT}jq=!B6so zF5j$dqF|2;RX0V*9v1u?$xURzBPTs7gkM*wAXuV}{5EXaRc%5!7z{m8>a?5dP{K9k zYapQ%)oo~4mcT?LfQB$#2#+P`Yrh7})&O^Z5t}gEUvu9asaKx13P3@N0C&e+P_)w< zrCdZo(5&$9`~h$yjJ{zv>aTAC(!n6^UPCa0g|%hR|Eg8F!@4D?DRncDi$=aBC>-ul zF2L^fV#-|g7tz_59qR4U75DdoR-4#W0<$ol823g&v2XoBZdv&@zsFE9smItP}^V|9b6cZ2*y3Tn=|Y%}|Lo4uDjUMX<1zt02Z z*?Qle@VAV3o~v&2>l`Jt!IGGC4jn$Fxl>z%)=$xhD&VcH- zODOf;9U>Nd5XNl3i8oMny(_K?i4U-(FI(2C3_(*ALAvPXG5CYKK8N%DJF;VvWwnj{ zSJR`A6CM?j8Ln=QEQ9m!9|8woPG7~vIt^TqSz zAYFC~c%cyB!K_z=h3NbtmB^G%#Q{V%@v{e<0OctLlz>GSO9u={aIy~xy~q<%U)tIm zN>E+yDL)ZzI&vVl#W!iW`~bEzW3eU`yP>fhX*f6KQ*#MW*RwrSUxVbN5@fKnkhi6W z5GB`$MF9bfcN=oCLEE1;gTu9_0-_APaQQ87_Glb8Hw&&0ET4T<#;QF9RdQdapcfK( zO1xv^`Q9KimHe${AKmU10LDJ5z)4LJRE8$_*9B`y#AS=MsLY%=IOur1MQqtQ229SY zZk4!Gl#&7unNu(@Rm!wBZ#@WOD+-%NhXOWR`@WJ%{B>K0J{Ru3pS>N{V*z`?`h?QE z-D>`>H&64E47KC+`_Yyl9K0QIRD5%YoA_D9BM^bZ*lSTahU6PmEH+kQq*p`-B9y-& z-E`@B+Y4e&+QKd~ro%vQjTS4dPxU~WqU4a$q33Gfwp5jIX0L-ZtMQ`2vJy}J%p=d{ zO8?eJ666JUk+=VcaLV6hCOzQ@(L|~8+QtI9-kdM+;|sJ&0>O|RIDzgPbXlt!4VcYs zTE;DD0~w489YC}~Fys`0TMlLacg$nRs5p2GZ9ape9QoZMF`p6g!re7MCa!gJa_)o* zhq_7>>5(>VULsa+;xXpihFXu)Ac>g5CZ--PgBV66$ATDnX zT`8x1XQ`a$3u?J^;l`M5jqq$R-sbb=Y}rzU(J0acs2(7s8r@|15i58M?)2 zl=A}0+fRqDBO9j;y0WFT16WGhM+yG5Sf&ji6 zm$-4GT>q|Qo-t8Y`C473!av5i?#jF>^DC^UA5ODvu2~LIR(9JqXF+hAV~*Qa5#9{Q zOUs=*^JG&nWh}E$UleX(S?yLjly|u8^81k?6ho1oI(+lIVF8#ilO$VnC}XZomD62^ zBiRO%<=APQtQ-Ff*LpBUHbF4do?;!6)#vrLyN9tQ_6df~hqtwX}p9HA23bJ!#2su0Z|O3dS!HNlHo z=`-|lbu(BRGleV>DHJa5+N6yprmFQ-9norwd1C&ry3R5xu4ZY}Ft{^#a0VwC+}$;} z4;lyr_uvVGySs)!2=49<2?TdCxDD3t9Mt`s$ToYUe#6gbe$!BC=nX(+s> z4d{N*D}lW!ShN0euOUHi#_wJk)fQ`UNxn5G=ZjF_JCtAR>G}q|uCUe;4zr&^je1-v z&crm%t)Qkg%erXlSU#WwSWg~&y=zpEEm~TcRj0fT&-=_$iPPg0ly_+(ukKob-)bf+ zCc~zBU??Kx-bkIKR;)%SxptSbmL;gf`Cx@tz4c z)ACvYc9Of>pG<>s>sN`~{~1BHYe5pItl#%()t(9?>ajO=wd@en5~d*ArOoE%OMB6| z>ZqMVcyew^0u2AY&Qv|txcCR^-Msu8+Q)%^s)jYXk-Hn=?RBs+z!>7Pz+`HCfG$<_ zblGA?5ooNkoS=>=MQyRoT}dD0pNc7+TZF$Yg*BJpjxI&bYxRW$Hwb1gq)(0|b&@o{ zyCW?kNi6&%E)Qs(Ums$sA`F62#B%Q~zVa>%N^?2{F2C+7>BEvXa!`;taFVuZ*BxSt z3!U`L@|SVNLO;&$jQ^<`IEg#xq4pR3SQj57R&J7?oKQWE%KzOrYkxw+)8Na7HYVhz zrgl*AiENLlm8E+Ic4fBg@9nFYqpMb@#2r}jK4l3inA>tJ$(6?hp|gpKDOK?0j89ut za*14DQP;S*eVk9rmQT{nXO-5h%0q7p+^Kn(q$%Q-l0$M_crkH%72A2$j+Robm)^_S z*9u7WXV5pzQg|H7SDkESB#tQ($6})sB{W+m#x89-kCmH2ZK0W?pVQ_~G)%=FF7Naq zEr}uN+0iKpE3w%)zDf!SGXHYxsu5dU6b46=)oAgW?e?x*qsIE+26hML}B=Rv+W6nUUvlO2t`#6Tp?fM{T2AeA|O`ak!LM zLX3);`1>Ec)ul|SQ4=#jH3zqqKDGJp$y)g@JeAerq^F9|~a8pfve z`Ik7Rb~%+hJuAervw)WtFgI_UjOxwp&t({Y{&CqEYbRuV)(CSJNQ`ixg>j@V+`q6> zlLwVkg-^K75p@|?K#dCJIeY-@u2tH*Rcrc!3xV|}Y!=?cvLVxwl+~ClDLZ&5Z$-x5 z(sR(|FB|pZ6R)=pr&c%d|B-`s&Xx)ywKN?#zI8gM7y?d}?~E<5`v{_743WyoXNvRK zvG&KcyJn}Qt~P?ZUqPMu8X}?wk2a&u^S01$BE-}%sBc}r^`&j2_n6&V{`%qC^Tti8eph!mV?O{i&`u0r<1Rtx)H5t2pO%CYHz zBP;F+@mxXBgm1g5H-i)Pa*nT$R$49sMNEcPYOr`FG>74k2T`JlB*?b2X~T~$h*6)+S?{kw9wp( zcj%8Iyld)vDekbN^-vyosRLWQhD~2%;#OKR1kevFn!3)+2 zg4%TBM3n1_M!2y3(`#`!X5&m}%e>Jdau$*%z0FtTsvIUyzN=ta<1Jo74w3bLiFX)^ zU@;0KQPT$4M)<$>Mt_XNp_z802%#r1Gq-=v7$~Nzo(ISH0JiDu9MDl`azKT_B$|Uf zeXq_)ldKf_g+`nWP^J=Yi;6Dvwp=xq%m7B7y7mZLV^vUaVeMiIZ*^kjdr&h?n^coB zb|~*2xGwc-2r=RW0urKCX#W~>ET(2}@bNFS+Pi1zN-sI|3~rFvn_;!qhwvM7aw6E& zQj4fBBx%&wACBAzg}WUvHaO7R`=ma^R3JVlH<54n7n{5vE2H|kVvtelFb4|LNV8dVl2!J} zcNOr=y2u;3Pl`-v+VxHeBh?IV);$PG$RO?mZw9w}l&-F$LW&t#Ss*A0)f>vW+`piY zEN^g)t@_^LJLSXC|&n!2qn#UqK@G4b8gumu&AxW_%9Z*+0g z9d1*_L7yvD3ixLaC_ijZwGTXnXka0eHcDM=N_nH{hsjof(p6M)+p~-y0=NpLaUPtckVO*M(5A~y(2_Zc>=ZI_pJ>LFTzNX^pty=h7%&d!wXbpSlf^iIl>Z~+zpSa zk=Z1Pd@jNyylM3FcwqXL!aeukx^Hk;967DOfqs+)8?emZUQ?ADZwIs8O!d9_n0bw}!k`VX6n-n>w}G{Ol`x_v)6!c^K20n7wwE z-=z)k(7?TIyJXh<-hwf^=Bxa=2T}*F^?Fpo47dZvSUC7L;RyFfL0kS0y4lHy{OCf= zzcVr_J66McUEkd0nvTi>v>*o1%ZNwA%37_XpkI3!-Z)uWL1^7RsK8~7I}49hCZKiY zo7k`X6C)c!@{Fi_0&gJ}uFXa1xn^&+8w|h>tM>b!Fw$P@PyFc#xvxa&$+w=+@Pp=eDQDa5kvT;=phptjA!)W8T+E;jN}p%(!dF1V{$HiYn& zwL6mS)dv46*z+%D^pat=^Gr9G`5YNd`o*uusxD{08BYc`&LRR2%1{;?w_2Ds;AL?h z56xa&0>x=sr}-k}te5kS46u)^6LoXg(KHPj3L-J7z;~p36Y`oTvGQF@_}LQ?)W!6F zV1$vHT?)(LX{zLl$+;l447PrxboCCeu(PUbVd?W5SX_tWUIf~-xawBs2A(-JfeM0u zqen);(@xhfDtVO0>uBmgUBUCgJ@pT`Rm6^6gTxNzOoVGb3t}PiuYRCcL|BvZj1r$a zI1CpDMerApmh_zgFTV9{D^lZ2tw)tyBkQ+kn5CeG8q8PRH;%Y$1BB>XNye3Bc7~13tW+VRspcGpbXS4&uWY^6b* z3te6mPTS0~9XAxeiGqXH7YeG0f)Upj024$(fr|_7tyrTmpc}sUjIv$Ie0&il1B6dw z=|w42Avn>;Y^i{>ZK!!2Sifd) z&Uiz{LvO)nM0^q-TMJjHT+%x-3`=fK14V>Hn6+5(1ZT>}m7}HX8aIAx<~OVT z5z^8@vJfk-Ks@W-Q`W9>x6RGH(|7_OytO}(dcBwYR zhf?XQ4IQ=Uo;nNRwTPB6G7GxUmk;d9d<8)Xr*DtymZiFaw!GV2Nk8at97G#JPPeGt zTDTw2S4v2e_8BMtL~*7>O6b3quA9=g74 z#`umJ4S0w7hXxwu#D@4+b}KEtgZkHUzqd3H+1Jvg@V+ezYAhIgKiKM6#h)RXYaLSG`e_}hqu3*`$M&%tcgu6QWpFYXTp$M?XP17ZF6V*Q~ z1k9U^@o418O~_qL*|JA>2dX1LhtcxwDS`o`8$dx*!sYx@`SBYf98 zp6Ce>9y1C|-K#oyX_$ddSbGYXBBai|HtlH0V&90>i|0v+@861C_t?*F+y8+>exBwaKk5)47p35UMT9Qmc>Q*gYkhK?XrQGPVvJC;x|T713SO*prFcOZqg`w2?n>0 zbRiI?ha*ihQKq)7IwY#EQdlhzq@~yUDmL)l#U40x_ebH}r$B}dQj-gomQXV;jFYtm zNsGIrM^wc(q$^!=Ns5OjaSFY;d(?`vot<3_l9PCXpyue3R9iSyoErzF9)UmhGz8<9 zU>x0Lg%iChbB0Cuu$0e&2@!F4_PkgQMD0WQpF4IZD=j>4O12%hh)0jELv$-eO_ysj zr8mrDxo4ay+-NLwoa$fF^tHrfZi30w`wL1nfW2|{pLIfNVhs7uq29Ru57p>!H9lxm z#JokTPrn~qW>o>~$y(5aQlg3-V|I5^RK%!ib|cX_go3OHZZeD+qg zX7{lt(R`)UM5e}bc8E-~GEibRIvc2>hAqpt1VY|7J*P;cAvTdxr0~>>kFf1eU!b>u zjD~9tJAPyRb|K&Ax>Z`xC;^Jow{*%^cxzPEzzK@u%fY*srOfWBlcU6gVZ4()!c8;E zMD$cC9^sK})?mLz?->i+LvzV)#e7Dt0q5-S#}0slJOPtwJ((Ol0X!e4zH734N5MA| z9{dEXh1{i4GVsQG_@6rTd%RKj1+l~Bp?fc8gi9!`7ks=#D*fBVW3(m%zbgx1k=8tc z@s)DS_k-E@fUxU5o`%Bcjs1?IsSe~QSes;)T40e%J}_01y8}Qjgb;cSPK!cB`qY#= zt_F^!5H3F^u`w};+*k-y2wdYvc9ZBK`lN_zP-LM*^0(S`a@1|_4=L*y+mbn z|7UvAa1+7h{=UosEnyQCf_jd{^|qqPIIgoSNAFlxR1t?$T4u(4j8?21+3=t2 zem#+_@S@f5g~i@{YY7AbiT5c#>0Go>2cCc<44{#?M8ZmypsLt3hP#B;jagQ1!VU)> z4k_|QD-6MzUq-bpX%@ocO6KIo5noeDS?Z0?XkC@+!_Qyo_hhPiCv?bL*U4q?qFSX* zm!VJhiGRd0UYf%Z+NIifZ+D5ti+}2hm@Vp(cZ*b)uCht?9inLffyNUAAasHze=$c3 z11-yS>~SrndVex_CdIlm+KRtH!9vK&oeAPQ+3_By19R50+EGjRD43_BsQNJ}V3~_P z6eMCEfow}0G?)o6@DiL*(@}zsm=JFqC+Dra9ngN*$Ey#QYBLL#L!dz&M#Xo^U#!|# zw)bOXcegp4GbD@;JHr75+{6ZF{7-_i8Zz$zi$ls7?yr zttZ&0@AC%m_7>lL9S#HDdUKTLY{?KA zngZ|*!d*UJv(P2_nG;`Als_A1Vw?9Jp=-JTYo?kdX-|uPLE7Bci{2JZTZ-3Fk3A;O zu7#U%D&O>!%~2!}hpZMRsegJip~TpWdv5CuBAi<^tTGTA^>(hv{+=S8`Rp92sZSf? zJ%zb88cW2yyd&4^g1!{;yF+RDS8UMhI8Ek6vyYo-u>0>C!n%ca^K}3_?9cAdKtY9Qs0CQi# zV(L~HtV-M7%h)xO@gX61PQ-|fF}Pp`hm>#TG3M`{WZdwBgATrRGoyR(_m7rL7aeH9 zl}RF#mvw6dI~8L6Qyc;`JsL7o6tv0#!pL2Z=2;@W^GaJ?iOEA^!nS=`$*9M5%vtGO zbbZB!zxpyDZs1c9mkbS+_el;pMozW&2mCeoe9WNk#g=?RPpD`{#ylRCWEU1C1^78| z)DKS(ci9?sZd<&=;8&(}*b`y^_OLh=K>dPWWr|gSWl%R>cm!gnz_x6kW+KKpEDnEy zCk?2*dt0r9;@#;NMxb5H^}s)_X#x4`A+&h{xTn;?87gY@#_+e>C-Qh`c>C}zTw=rq z0F;;N;Z7E4d5kK8NH-5;Zncbvn5#v!z=p)wnZZn?Br~qm5V!lN0V-uXl$-aa)8G9Q zfP)!x<}wl}C8!@9nMGTIoDw?a-=dFzmxdTK3K@sdSpbEM-AhV{cV23h91Z-hSrBLj zJ*=8;G+nZiq^dA95G@?+sCE58R;MD zn{w&!fIVx(U?NEv(zBdWwiQE|vqbH1c7 zzTxt;^IY4c_=r^GQ6-||__Ga1iRYU7gARRtdaBl^{MKvA76HIn=i<-YA#EE1Syiii zOAZJ?CNa6t@MKw4KCsDe@bwMQ5s}MOd*UxrnKf}hIOXOzO(PG+_0~|OS(D)?E(PpQ zVODpG#SoH?Q`YOwj;S~sv;o}(DmI;9cM36mZSr_crh}t`+DW1WOC~gz_wc!#tmjg{e7vx(O4HWd&#(5wmA+&(y1V z+r8e7(mOU&JNWXFBjb|eTcOMalY&zXbbODM=N!&fe487MOD$f`GTt|C)5rPZP}&8M zraj}hkTqz64*K>s8RWCGY8(&G>9i5=PwAQ}z#$J&f z;c))cC5Tbmz!WvLp9te~BjQH(V36p8TU4*q)1c2q_6zv~d^a^hxoV;y&Rq3hkBESY$}{ z+)I4+$>)Mw%?y;$EnB0CoH9I%4>ygC%jdv9`V?vYR|OeO5`XeUkO6>sd=Kb7Pd9Z1 z1dVuG2r&W-`aYO}1pgBBqTtKsRE%-}je&>}4-YEzFoeK)@nOp&@RW;tQv0}|qWfRs zEm40IiN?=3z=BGZkcdM9#Dlj=ndMFn0XS3R3;4$`3;B~a=WaQVn2N@8m*07=dKd7i zKI2RQF?n1p^L!uqWFyAefuIJJ5Ek9+)oK(fZI07`WNNNQM&FRDbU5OMyvU%%O1SS6 zpR9nHjK`lB`D>|}^F_W(V%dlr>Zu5uQv$^0## zgJfOO(Sa~VY2@Dnp(YA1Gx&*p#7TD;95J#%?oS(?s#;n=Jhki7i;s8daNx0CbV{LN z+>0l4%k3{-u>%WCvct8|TxI>z(f_Xx_{Ylt=*Vx-{}bp`xr(`&h|N+i?w`M3z<DI>;HUosm#hAazl`U+_DT5ZGWqdv`1G^i z<-=a{>1SQ%!Gonkvo*@@=d(j2pQ}IFW_q7-IE?+hJ^X)k|0p^AYuqaN{QNvqd>c^l zTpV|?8!V1j9TIr^CGYQ!?T=A`FVE+DUIAUtmt*0h&40DMrym9+I-j4$EX6W!X;!;@ zZucAAuC~h8%)0*=<~`lZuRf$VU(nIq1el)R+$H7C!Pj1-kE>6e);>QRM;iI$PW$^l z-L9?WbUvT$Z+S^{{vB>lt+qa1NC#GG{XLUr|9kN-=A9+uboUdDh6*Bp2{79_a4I|O&v;O-vWB|z}t!7WIT!5wChpg{+B_hEK^Z+G9T zf7R~Rd%IiJRo^-Nece5C&UD|teeY-}BO>9$!NH-y6@(d^)U1iGe?f$U3q*s1!-3WM zIeqqZ^>DOwb#-L-b9O4%c6QBEc>l%{tNsu*aE)EfLV_q%l>W&(NKw5Rzs|y07d~sv zl;etAz@qsjkn@GpysRyvXNCiCeN*_2L0 zoSR$PJYbc^q*o;9{EDqK_jLuO^k09kk~4#J8Mqj**EX|3Q(r2aYjNv-rjdw(it zNhg(zr<(mf+P;bO4m_u=fl-}sb6 zl?0eKQ*^prw#6A5Mp%$!p%0y)_JMcS-RZ3a1>x8+KT*aY>Wq)0w3i!8MUMW%Bj|K7 z7ed6ss4V~3J(519&lVmQ>8s0OZ)T7hYJ?GdpLCa$Lt~mC%!*mQwA;^@c7D^m(xKm5N1yBH!sFj>x` z=o@@pg#C>qk0&v9bK!Af%;Aw8Ib#JyWn1*r^4#f=cWd9h=yZ`_cwRsnxcta*G@rs2 ztG$_6ZeCJddoA$bknkdfQEhoHsCK84(|A?59efu+TL<0?PcP`$ZS-V*kvBYW;{T$$ zdH1s`yYyXBSm#NdeC{NI6vCj`)7wWcBZ;H2t3MKPO9?K45~B5M-%ow$rGI4=>8Ft( z8CtFs+5%bC^D0bhksaO(DsHJH+20409jMkpKIDtDcz^D6W!sl1sJ@+fl5A~!vB=H< zdWOn~#2hYGWw5{A-Xf22M9$}rnQox@2cR^4v4>k3S>Q%6;{Gr>>m<&>yV<7VY&&YZ zJ9$Gw8K_8tE%!5h_mjdb4F-tDInJobnQ@M)`t7YYbc3H1={nuoQ=<1s-i{{#E5GA z6#s5W9^3UqkMHC;dbIwtps+E*+rY!1`A3%?5ks$|K@x!{fpj?)l20WA3zvel9Z4Q( zzw-NiOZT3Ol*(j>LW)Y<-SsLu*Lm}2L~sh0|)n?H3zo3c-mWd z*jQ_Mc{sS(dH!dKSvNk(_}7*O?bUe^-?|@k(BUmKnVIUfbnuL)WFKx~wbQHo({UXx zZE2gjmueOEPyX%s#04%0OCsV#z`L``X+;|0P_Fdq1eG-C?q$?@$@g>WN&9ca4YlV`h}XBcJua{ zzG?ymaC^Esb*shU0!0kmK1 zLb%MPlIzWw-68Q~uWDyINMnNn@soieUXV^hTiQ6~t-Nygae_c^O;dnSKl{Sjvx)JD z(`Y%qBN8~1elHIsbt z@PCp_jns){yx~;-<2RbBRTQnO0}c9OO{zh_mGqnZEA31X8=L+%P?Sv&g`EJco)W7p z!vrJ4)1Qf0`C)7v7tFSd;T{D+Blkjf@NV)M7bT0@?oOH4H-9asZF(RVGM+7m`()29 zi#ehF0W$l*pxj5YtRcb15#7eDU(T)@MAyl-K7N#b2`Q6Bk^>-3YO74Scpefdbo%$a#AMD7zOt{KS5v=eS zDuTX-HlyW|@)5jPo+51WM6C@A!^%|0L5#@*af~%f|drQ1|}%yyZlc{&4Af# z5f)1FI=>>%PL%5p0$NT>tLdY$2xTmslTV0&@BIx$x^K_F=xB3sl37ens-_Vdzz@#F z+lerjD`>>Q$0+?&N~>xbBzrTYu<051MdoGbpjFkP;8F>728s=`P1-k3lfg$ zcpdI;kB8YvCVmj;kg)b6wNed4tjo+%EnJ3n7+c2n}rM-G);RfP2o%eo4c*n+V{#q4|CRZT30w5&y z=~1@>qt8&J-}-F_2kZw2oCgQMj85onD4fJY5u^;>4|b1Z-BQ8Dda={W8$@rtT)uiyS%Nzi)=4s^0;|TVo2g1B5$9 zAzY=j)3%ut*5`=BiyNyiaZmCI&kd?8v-tKHDw1}b%=eihKaK!66ETgHiKi7O`??I{ zQWgDtUe}kBtk&=LZvouG3(#g>j#r#kH&Xm`-&g9#gP2Fxt&|{kN^|Wl#uZMdwQRY0 zk}!`Toa8Wy<*J>!=uq(IB2~H%gDYN~cexkIFH!5~eZywsmTP>>;vs*II*h0lf-oH? zsIKHAxkG3b({$F1UwTG#Gg9)42@E4E0dY6H(L{TQa>S25MsOzMO|ob8hGf4i-I`8f zc7Jj`^5Yo`-To%`{WxmavJ4db5e7Jh7M=y(n-(E4lgU|3Fvrx<*75|^l-qYMDCakj zAno8d#i&sk4SP&_5u>cxU{LMWLYCG)#WP z#(qZ1n=C%mpN-#5QYie~{JFijMmG??Ea~4LT4(P8e&pwjlX|nW&3ydQV?S&<&N+f# z;HfN@`J~l9=f$JbE8!^-?8GYVvWqykMUrx5E^^l~sD{G($v#tRr7RCCrwg;R=?g1f zBXaN`)==IbM(XQvKmzvvFe_q8o3XLRM$k`j;2Oczn4qwrO?YE&O35ceD<9 zNh+zSQ6~IFYS(>Xr@DL^ES^1hLG-@*768Bi{U3$21gFnUNTz552UPihczXolfG|mj zV}Mc+ZcE`H24Qck5}|~};+UW!)I%WR?E(Dr4ID%^?2T3;6t@_F27)pFqu_wV|IY%W z{(GSs5)Tkp34ISeg!L}@TKX_}i+Yu^fista@JNJw`59x&48IS+6c+jg4q5{DCJX$L1dB>X!hMp1*G@#JB1YEW#3;7Jml$RWi~j;gQ2KvI*uiV(BWt)5 zN(>vo5!gjm#hU&G&fobLiKT!J%AZ6Si}=Ae96`F9c5%Urvvy5Ex%C z6n54sdwan1b{C`($>fw+Y+Ou=u80L*!XnGwk)9U<#VS4Ed{g86mIQ8zZ!Es(y^|Q_ z9DzT9BQzyJtJUa%4|^{U{8$28DW^DAK)7%`u8N*~5KZ{!U=Cps;te&Bxu3Y;AJ9kY zfOPgibTn=ro-*SEe)Ov;&MTfD*A)=D+@b0jTMh0Ban0pZ^_;hby^LIbyL|`+q~a2_ zFy3#=4PV!&+;|<5JrlxGsQtl~&xGhqd)dOPc1j>Qt$BI-6n$@bEqmXFy|jf>?;JfpOHjf zqXuay0D+-5N1s1e>htX?K3V#t1wVz5-v)K1*lFKICJvTqO=ubZq^VZ^#bSacFtRr& z*=9%amsl_3XV-6hTjO^Ahx~=k-QsmFc4neP+U3if=2K=f8eQLOH(-o*|@4|Z#jlSmX*lInDCt2U=L^4Qr6iRjMl>E{$pIx8feu#Av7 zmL@sl)n_4*#U6PjAsqJC9h&hUBGM9Gv@|*BME`Sd6k6rS=Z6KkDQ!`ihF5Gz3!U6( zCoJh%%4#$@??aq`$Wi#VUg{zHrSL&UA2ur3m^sQWemFIn(Jg#2YVginV5t`OlKFeu z6ILt%$#fX1A}HYYNv`kkHO|MrFd%P?7vVqfY7{Qh(2tb6(=;I$$YY;+x?DL#EWG~N zI68^ZFUrolN>|L@A^@;z_H&c3fsF90;ue#Pun;?+PfHPmQ2~v)_pv~wtZKpl_QgF^ zrgh+j3lA$nV1azoz8*d)4(djw2uMU?L%Z#f;80*jdpj3J16ke1N=cT6g1+SyTM~ER zI!7#5JwK@H&UL^-K}OPNbX;KhOt8g0<{M;q8b6R&{|-ne^6Hrr_!F_sbq5msI`B&) z(1P|ir?a!g2jTq`eYV+pGV_O^NN?vuwe+UO%EU8``SF?|fMDHDDR7Zso_^ z`%BWRH+a&?=;v=c5(|j8EZz!L(vtK_U?H#+L!N$p(~2R6IT6dIYOz9;Ao%?{9pkKU zOmgb2Nu;DMqlFlSZy$#8Ac#_+cu9HKEPAl4PnaBEb^Qw6(A_Oc@B36!koCZ$6*G7! z02l>wUgGRtz0xEkX=o!(jZ{x5RsU*NFvfQm(v~+^+t#6V5ATbEvV9tU!osN9O}Mmy z(v{K@pP{1r;?ltI1@mF(YffkS^jF2)XqJR z31fMXt}&@0R_{@-pLA?Vdm;fifGJP{gM6XFDi1x zw@ix$@GBH?;Mb|Cll$}QWP*34NWc|7g2BO0O7sTBQ|rFTX`MH0oR?0$51rqqsz(-m ztnIza0Eaor6QaL{Gl2_PfS8H8_f5qj4y{?NI`gwE!#n?-4`9oz2)UC9g-_k9>DAI2 z=$Jp{HFqxlB`w}b2`kn#g=hS2AFd+a*f&vOY4nSrLb?2bK2fI4L3*!R05g}J500KVTkw2yfM(}6{{4OZTiiu_wYJ}iZsn)D?=tW8 zX&6V2sV5ihau1bzKj6J4o#ol574svu;Se(a;+o=jl(t_^n6Dd4lRZ0We{}CDJy7a5 zXt&*vn}GuMUGX@3(fJD%7G-zCfA) zR}+4(dV%#H8xUwrWB(I=sO8;{vTS$l zo!1(5WWfu3lH3$(5=O8r#4s;G$U~yj*ICTMWF_9);+H~L8a+6e${Ouk^aFSk8( z(jgbIzn2E$^$WA@f;`xP3{bOaY@{g!@r+&mQlMg+pNwll z$CB(mygO(NJ(x#!oY-QL1a;JS(QAf!4{gLIE+I?43_p3zxBy%#3T_mQB!9NNQE*kh zBUulYTc;R)h?B3+Zb%X9E7E$r2_m`6-%X;0;M7j(IXWThf75-=?fYjo1xj0S3Rp>7 zSvk7JvbnG0&H*o#JhaIe<{}{;QuCiHpgtV3kzLQD* zY&2O?O&_9Dif^yV2<`Q_)j}@sK0ODcZ6We}wZuz$3@_*r5}ZQS+~4%ex4)J2F^3)- zV!M$w8NypwE=I*2by9BlrQ7q*8q2Y<=Nfe^0(cHxlj%MnaW3Pq*ZKQ?N{cQ-ms7wY zza;;a#!YwlFUlRVjqW276TEOPwB*#A-#lweXf8Ym)M-tM899i%N(gAN_d%GwebtkE z5H#InJY4)uOr58#`E$0}d9hpY+sNeNn;{Fu-1UCN6xD?OjGYNRLWG}qo(S!D211Z* zCR+U|0*3}mmdx200&%53GN>hx<5R8&zF67 z+Z#fYMxPB@DyBsWI_=d3^njSNTuY(A>n+vnzc!sNj8rh#lhI^_Vze~sfB$$TOGMrK+~O>nHnLT+WX9cM zFKdRaT}S!~Z83OTVDETb(oEPFPSJj>{@Jj4l%kNnpb)~)lAM;zaR8$#|aKcZo~PJ>sZr%hS&^A03sKGRI5?lToA72!4D4l zcUx?_f6muzpG1~aM*ecN0gxkt4l`5bgg4t=D&Vk%^j4p`ZE69jB`Tc~fzJ~`)>11t zU46twT~rmdePjkrl_dxammz4j#2Jf*srqlZa=7(@l|3duGLeSY(O2AV-3F!p3geG}?@d^;}=wSEzMJF2`0 zeSKO3sz2T|I2K+_ZbIL{I7NJb*Gnq#;OD0YAkNd>yf^?_@CZGl5`Q~sKbsGEwwMNo zJijeLAym-k^BA*`XCl#^P{<>YDim6D^m8-hW*#S0?B$^-^y%&edZ+$;4&{4$q3V6Q zFgps(db?i7xH`+V;1^!mJNVz%w_;CS;a#i=PzL&C#UEmAoQc^5PAjPe0x5F8hP@OfhT;y?$w)5;Dyla9x?z~keQs8 ziU%(wbn|V^tO9lY?fISRi2Bkd6n6dEB2*WLoV=nE_#Wy}Mv(ETO#Q0Phf@T>pLd)j%Gk3jK|=Y~z6H%Q**+tp-f!Rs@4 zlN}a7ylK0+Z_t~`&*Gx5mk@Drlbg&Z#-5**x`j{BlWem$eqW&%GCVGM@le3;i`#@+ z*tiY0Z^C{A0La^<`sX5tscgpJkZ8y?mbm!fGW(AZH-|g!fo!{PDTTPtI7PSs5v#{b z8dPGE{mQaE=6delloNUDv9*}p^_bT$y3uD^(d(7VVyD8u z_>G|9yt996=NJQPsm+4RYtudJdfjWl9`JeB0SUz7fX8f7;s>a0J&|qmv(fGYN9HOyFxJLmYv4GW1}HEeUc19y@76Y}?!=+f zq|j_}2qWMWQx&3uCl(6Vo>*c-XP7KA*<>$>%T`&kr_!3AkcBzx!E9JBGkKSt7N3=5FZh9Nv}8}FH9tO!d)9;9kX>dH zcK-E&ZK-5WuJvDhmhh~{XG2Pv$x?g&4{X4aJ=xao_$>a}Yj#7WG*!)N+4$cIorKKG zwDd-0_6eEJX#;GUe`Mp^6`ly0w`l2&%VHBU-P5GlH78`_I~4?pm=9^`P0FegGQHCV z*fm#ml)980^q9{+m7A2e)@1s8pR500u1wKfwL)wvZIY;?FVUCJAT(AMo zYqHHAR&rO2Lg-ilYHqZKd?kAdt>g(=c(Wd#3}<8}JMBB)vt^g;DYY6WWRcB!uo#}p zO!nCOzh_G+*;8y4Nys9az5ZmlAv4)!&n(NQ_=}Kvk#^6p%se5}DXoM}vrQJGUEzq3 zd4qP(s4OfY(=CmbU2{Yhqf>#IhX)`I+}~Q_kIov z-8?d;q+Flh{mhPDP?kO|r+JurALO9W%Ohh(+Wh(5c6Riwvh-^?4RkICuACE5$qFNL zgq$f!QG>Lln5RP0VJdPp2lY26&Q~kSg%OdeB!3OEn_|9wN$3Cf#K?#UvC-pZ4yu(E zWnz*)2j71G^8cLkXhV5gciyEMSQBVpcRovDxH@>Sbj>;5-Bt;|c+Zx$$qN@|?}Ymf zA~sv0Xnh}-oGar~bl9P|dmpEkD`Vc7&llI%8M^opfoRA-gXs*n{E7Ja049SdXH4`h z1KNI>pKh*dbKGCRh!@ROYmUeAUvndUhuIaHWQc=ixnG^J5#pe$B=)o5d!=QVhQ^t0-81Fb} zfwE}LS|H6d*V>*Zxxtw$pT^ah>visdk7O!U)ghxFYh{6f#LzgeNfiaLc?C9hMP4Z% z3D($thiY_@+Z+SptgBj{wf$eG8AEGqn?HiC=|3gAI_)#q)Ym*`hO#_u|IRAE?hsB0 zH{fP16VYY^1n{~~ga^3iL*F8E+tp!@d4msvFa{6AnY5|fBNGZv0w1Et7occ4ieis5 z*k}x+FV+>6b$~OJ)zwgcRzHuhIP;97!}YgG#)$NMj6%^X5kKhO_ThRz{1mhIqs75k z)6I%kFx4}|C{dpjcJQePo3oh!${7I3gX+2WCRpL@ORtpevxJmPN0(f+wdd)FccXTc zAao=VkddHa;MkdDuk4SHxx{>eH&Hf^1Hv>{P9{rTXRVP}UGmq&Ev0ea-f~f`g={xF zZ}%{V`Nc}~U*8KwIQbpp^%j7}(;hwYR_s0kt;2muoS%tu?CKxtl@n4ehD^h|518>C zzBsZR=Mgu7UR*B6hJ`^tLK@rXwtoZa@QIu8MI|%kM{R#eec!>i$Qmn}T+5rAHWIQ8M}agt<{sONNn;xY#_PWV;h4l9GO|?)3}v-( zn^0XXbW>l00;FoBirOWj$cw<4#p)+0CmGjKLWg(rR7nOL*`2J59V8CdRW!bs>+VP9YF{wQzPxlQWBS>-JB0uK94E5+B>zZ zAEV9zl(gyId-Yrv!(Zv2I8E7V<43QKiNTzaDHDrGobN4=aPL zELkkEZK^WeIPYwkmU#p|d;f}eodF>v2ICM~!e&+W>-XE!V$4T9ON6^+%a!k6V4on;%xi;P=l5g$fSx;obO#`C)a>x+4lSC z2AeGQqGG4>czlM^hc>lbt0@xdx{)Ue?F5sqL93OFUKjqYo|?8x+@K7@RAeQsiX=)f z=CzeWTMJ`pQZJZAB$Rr%Vnxig>RS=+NXA=go2@rxH=?KkZtEz8ZOglnP%R|!R>OOw z^RvH!TJ9n}Gz-`_4<&T2NFnx3zydE>j8DMAlx_+FuS1HTnf8%OxaZ88>*SG{%R&_g zV3Os1nMA&6cCJuRf`9xg1JE#hL(z{V81+nZjLQX>G%W}++W3$`-$1>#FEu8V>q31~ zQ-_(yrnzE)zR~-&)r5c5h19t{sjW+HEx7c|qQM#1l-s-7luMfYH}NIzv&@FG?;28O ztGm`zoO_Xotl>phzt!?6%9zw?%uI&PU&7&8G5?Cc16~_svM0&mc4{~aPaH3U95UEB zKZ8SoIqU5Q>&&Sy63UWe9k{~Mg?M!dr=iX#{{$~j{rKg$4ctRa;qa`@-m8JWf=p4_ z-MhJy#>{uJNZ@i<@B@6Dz{1F8_2K#VJWkh6Y$81VuIAQQU8Cc)oM&mBvR9YUkv^(x zm63kuZq!MoN#6Mnfy9xhyW`jF9)bd4mEv$0kUj&2lF2jmU>VNY8{ySzrd{>1E}I97 z_7WhV61jG1ajcUL2uzvHKGmwhiy5$CL1sCN<3p36g zF_F_FN2e}Es=VM)ts;CPpwFarkQNNTtSG|9mXAg19EcdW=gfg_>E%-+$THf}fgLv{tW<4p-SD)!|B&UBPJ?prhii zvd$_bGttPb{YSeg{SnhwkXE|)!mZ<-qzslI)24sQGbp3*9pv<)&pouyy%L8ZY@YS@ zI`$^s=a_90+rKNO-*sv6gnF?* z4%8c-0ztReBYW%#8j10b1Z@4{7SCC(x0pX?WgBKhKJx+?3{5OJ5ChM@m(IuA64Ai- zFVQmME}^|<6tj{5HM?zxmk4^22*UqC4aSSXB?!XAaUPw2s`OFn6J!_TFZms)qmXOW> zfO=66J@@BXFoEa~_i5LM!a_ID{X~;RFj9c{<>O=7lVH*P5uW=DW6R_6K`@t`Rdaud z&W!pAW~1cdDn41W4O!(C3#e=3rlLb{tG1U$Tw28>nL7Z?<0t=&ACOF;fejj=eQXL+ zAaxET9>ut2$RM%KkNrh%(V+-MV0)NptDF=V)PIyJTFx&XZWBER&x(m|JCwNtd-l}yqPDSw(##&ry8R?xKSwW z>-P(P1;sB$;kSWPk~@`)D+@5#S}ZhbhpsRln+o};toQ9*ZXI_4jCXY2I>`DRVMErdPM|_I-3h?QMYI>L_F=YJnvZG!_z?s6Pzd05*q?et0o$e1 zh@NFcq=1D%U45gn=nb|m7}sfv?1LX#!)J!$!BXU2kf4buzgCimBT?s%(lL6bd}NY; z=NV*?v|W=g&rbR7d-UWiJh5)mzWh+3L>A>|9E;grX>$7d4Uy>RMIz68m9*_ksAqJk z>YC`PHHmB&-+@FC>-2yas;UqX_1%V@SlPvQ>EUU@x_jf!q_z{eHPzCywppJg`!3g3 z(bL~&ogO!OT^^o;-&xLyY;LFj%!~+Ts~b=wV@mPlqQD8FE~L#(VMKB^k74~_ zj^-1_c)AkvZ%h8dJJW@mL(O>UrCgj!WcIFPl{ppFa8kfcjYb^N_VF7)om+-xr0^mG zCr-CF6PJwr@+bM=4~Qa|V+8&r?@i_9SA|7uM}*{>}up zl*rsp6*E^30=PvQ)#POOD9$AQD4Zk_lFGV;T<4!Jy$l&2-FQBE?&}0q+u=FfBf%d7 z;JL{sm4NyFsNH_39tUGvQ_2c#LZ%oKaaibfL;!pt1(q4bWW>hFk9gRoyQ3iek(H#l z6?_ah;=mk1m#<=W5`Y6->=5d~NT0g+S_~=u{cl$m*wIAETzwn^T7^PJu#M)tw<4lZ@eGmw#@Lb0)oo1vLi&LG7wJp7I!Q1A0K zLgZr*QXBDUYY0oK8KrwK@)B3>l+`pv%)@|Y(J1&ipJH?8Pr%u-LW?o{wH$w zQPL|fE6$=nm>kHz8)`1EsCj_1MYc&&@~-ReeyD5^*)qVkgkcloku{ z$7}Ed=T0S&@x|_dEzJ!?+bh`YZ=WHaBA%SWvFtbbp}^KoyRTh_yVFYG%Vi~P@4KMW zGnDU8$8f!GK$ghzp1do0?jX# zTMVw?#KAAS_#r83e-PAoLC*@7u; zGH0AX=fIG~?HWj}^dvAoYpBsK)<5~hBf(e0o;_9AM`xzL(nAeS@K+iYAr7ufSMvQh zMZ+0x%NOzsJR7MBa;XOQpk{T-Cj@!5mQqw-%F35rYfaqIe*0a!ORqLkpMS(1>Vl{q zQi_IYr6VuMsp{>(mniZUjh)KeWIhkcz-NM>HMvkXuONxmIigh%z{UOCrG#I>Mi=_TlUw|>LKA&GIiyD z%^3vsSqPh7E0-_JIL+XiN`7F|XYlda$P{psZy)MBze~n;c1o`=MV3h5k7%OuS~}po zmQFbS0KXJluwHpk;OAy##u7dM;=d_hmY^mHS@vLy1 zS^FH+SFcjK6JfN4K;Z&sN^ZV$wxu0X@2*X*N_IQ3PL1mtQraPgqtA=9R^FY@i-C@y ze^oHJbw=yH5F2P6S>UdQM*2RXOg`wNH=YLNRnw`J6xnY$O&iCqa5fERbNK3LG54~# zdiKu(1r%oXiQ>QeEI(n5GMPdKCb0`wyJRS=9!8-273!GGyO9oE-x`y`m=SfU0sXuHF64mJ9uY z=cW(oi78npF;uVBUZHms3ofQ^aceyFVhZEwC9Y$-^%J4W%7$GLXykIMoXgarvu}i9 zDsvD0DN9}!2E{D6FPAa&tFBa1)n2@wS19?JhZo+9&>-tT`N3krg*&Bzf0h0@h4tF0 z4m&F3(QQTCF$n9p@{PXSTBphPLiTvr)g+W=PxlKm&?(fy3+2`s9RGe1M+{L2Up9+T z>+z%pKQ7U*#y|y@i6nr6mAh;&Y&>V3R%FggNo{#@YtE%-^|L;k_JPZeYo4RIZ#9r6 zExqkj*?BAF-*-PegoLwi%Rd}aR2pLRe64m06Px#FJ}JZ%GYAr^gu@hIOiue0Fq2UA zX)Dg}6Y4V7ak_i_Z9Z^%2T2Ui80(arl!tvLZDXX58J+hTv;VHo0(mQnK!(#ttO~o4 z<;nr67jXl>5Jw%*6T&C2m9!qvq&BLWneMC^c2P7 zw~)RV+cn>k5fGifB(73D|R{Hi761U_mXFw{sIeK1Yk2-sk@O?o3Ze4-}B+W~BI zw}A67t5;r4c6cx)hKaMhXrt9(gy zB!WXmOPsG;FIL(|$vR#87e2g9NPM@B=nZmOY{hpAy{f+gM8&($L!))aWbeP%7NpZ3 zQaXO4pIpi+(XwcumT})APD;OQ!r+>DW3o&`<{`=bJ#xY>F~l&~PiCN3K-(ABBh!j9 z{o;sQ|A~kV#V4DWhDN0G-+Xsdr0qODypE_H#;sacu_n^jrqn|`nX*q}eq)71_Ph(a zaC+t-FVhxUOj@EYEQgEwKOC4+dgQd=y05;3L!1NUeY3^`51)GfdTOr*aVrU#4O3UB ztQ8IS(C=tBNl(Oy($I2-Z{^&VAtkE?Ho|cvSK;`NUd4pyHJLX9N2_%`2^hX_FLVSq zn9%=1n0VB6^(tYBgEl8C1 zSx~jXEk+DXCSs9JR_kL{+`C0aOTs1XwThhc98VD-EZZmOoVigPNinsHsOuTuEsaVf zz~iwThay?T(mXV>+=xd>*F0^BT2GXkzqI8+o<^_6afE35n~I2IO^ChcBqFcH3=?$? z*9WbYBEi7;R9sG|S79@-W;iW{5!64QF)m?Jj4mh`g`I}eYF~k_$1+FKOP`Y!Ntc!( z5maKMO4aJ^*PZIR#mSkQ#${@WsuptQW0@=qsxfC4ob$Si*zkKt!LSLp@?n6AMc?iT z)!Tr=^(W$w_ic17o`oj@uf8RSl@ZgH4752mnoh|Vx>T=Wk6))Vo2{aix4#c#r*RKX z3hAm#$mvVP>nl;YKU83p^TcRY9uUAzSXpchU-HmqU{v-MsTZIF*^gEM{~&pH2qKSr;QeE9m;YZas}kxC-)M z4BCfBpJ+Z7&mKDRoq30l3h7~ zO63!Ed&&rx>v(mQze`2YYFY9vWb|E1bzs8g45KbFK?2Wwo%C}0zl4=hdU}+*VH}V9 zy$+U-pfLHegM=5(s#|pl^%E&K)6h3=5b8|NZ#L5bBaUqT*lxr}l$n@LJbv~PZ`=}* z$j`d>Y-lQJH};*T2Z(9RxVLwc-i}Y@Xj(y*&ezGLN(XE^n4af%5(-FcR4W&akqU=? znP?+jleLs21n3s0XH9=fiLVk}&mJ~Tnl@scgHG1ZXHc>BWZl58EF}T1k_wZ?jC6*O zRs_!Jdo5EA-Z0p|Aj-wKnNBR4V!wW)`hm#J8~KO2)*u^i#q-dS1x+bFQ$hYYpFC2h3=pVh9cpmqJy`IC9dpQa{G? zxKHPg8JQZ2gBPoNR=-@ z;jX*HuD4qY5v=Z9@l`QD5($JGmB-m)zLX3y3CW0A`Bby`OZI*a(e3TXku9&LqXruV z9Ys3&gE__cJt>)LqJJeaf@p5I7KL>L@QX;G4n7a{L*o0{rDg8TgI;_^)vnSLfTG`iM~BS#mA2-(7IH7o&4Gy80rV?;Qz1=)L|P>58v9oLa&w+W77=U>ws z)y8ypzR%a6>nyUghK(SsoHb6Y$>5sJAdD$3wzu z0{%OUsUX+L^^kC!fd36+U{1b=gy#hOUl>zKu2JYA5jX*V2V*KJK}7))p~vu$U;qC( zBZ$x3l+E+lhd@7x(EsLuimt-m5>apMM2f}1@DLK5|3|9hFva=p&1>u701*Hne}vri zUAUr#ghC%5R0S^sMK53cwjQR#H&d`*LxQ#*G7tepl#h_pzKa9Y5P-ntORf0J+P)mv zyXLCmuix6&Zb#mYc2cT)vWNER^ON@J`X*<>Ut(?At8Gt%Mf_CIpxZ0pC=9NzfR!+~ zzXBe>;OYvP4THNY;1UdOu7K?@c(?*SP(fcWuYf5qfVBg{0M@Pn2C#M)R8ZrI@J*aU zMZsdGy|9W*#A;3KLnXmt*1fQ{OvGcDqbyj=u@^RyiTFti`%p!&m}f6+F%$7C%uy9A z7T62hhqZw@YJ$ZgdtvvmHZVtBv{?Ku>}>(@Sqta?H|M`4!@82@(=-36pxU{z6(aw5OG-AR>C)dIIkh@ zTMq`I_wCb3{}-2R*F4CQYhJL`8u@!*ySCZF^lg^om{H78j2!h8bIiZ;oJ>nFS(&e~ z5inPawCjl=On-Gy#T0*hVmwyKcXsF5;=N{KeI^UJA!WT)^xEkLrAdDN2r=2`032yxD zk`tQu+-*7A`%Y(_GikgRGUJj^u8us&J{z|8M9Z9bSzx~GUZP_q;qef`?x`f-OTIzg zf!fcX{dq92BW3K&y!*&0-?dlrbtGep_R{7iGn1?Wn0^v|V`ToJhG=)Ov#(t&iEE-k zVRMaHE~qyy{^${zDvnpzbUKLWW?;3D`(nISL%>7ibE3)4YLqL2{=P9eg&n+0jj5!@ zPStoJi_}0#Cnt{rW%PHDShd{w%CXfXjV--* zF;3vGN~vkz@d+6kp)^#fNZOas6k!NVM>)%)g!E5tePI0R)&@ZtVnPrvG-&;^Rf=11 z*k9;)IUNvJB9%iLP_Jq88k8n!%J6}1Ge#jS?tur>lAw+=|2aAfF6e)2?Jc0w^>8Qe8Mu;5Pc;64!C-4om)xFxvD;1+@ecY+6pK<<41{m=dHJ?CBPJ8Rw3 zvwEhVs@l6tcK6h(XIB+t5uw5j{`-!49RY33uu4ksGsohZ76i~~k#*2My<$UWhIf@` ziBF(Qzz)zE?7ZRMH9;Z{tEMcQm>b`d>y!M$&YJp@d*JTO^YS^hlM3R=eY6gB=>bpSMuw5mty97>xJV;w5 ziJX}CMU&}JBs>cG(5(QZpZXl6>gz%3DiQjyl?zb|GzT$~shlmP*V~c6&||bIy)8n~ zs_CWRra(`tOFirME*h1ohhVM;NO%qNzz& zE%63M!^tD}Ak1jOY?4Z=S zP-3ogkW6o`48D9I*}i9KpU%O+;Sq|OJ8dSY5!qV<_(0XD~9G1J7$=agM) zqT@>t)5h!msU+-oV^9*N4=xoozBP8E+@ippv*q>YL=f6I#v{_Yq`)`jW$-)dhi>fO;g zdm8(I2?ZU+XwKFMK=4uhO>2d zoT@Hs7koDCp-axIcP9~b@H4}P#A%LWfdrFpkfmnKO-9Ws5$+)1zTy^do6K*YALrgM z9Jq|SDK<e`AR3A-=@&tKY4P1fS(5A`&2LA}jp4ACx1zWNi!if5&qi>a$5SY-$eJ#}59 zl$W+;_T0LMUZQ!4-B19WSGp-%f1+{chlb3@i6DOR{nXAU;u70216%ElcS7rJ?r8JJ z)yhsb%;uPPucyXitN^z2$%7xd81*5q%K7X@FQmW+AxXDnN7j9>%);1^&j_Q4ESZBR zemnWMAgfBfF8-K86@l}mDMmg1Q;TL}!CkzcU_0OKvnRxP73}I)QNUErjK3t@g_cpz z8D;aDQ6Z?zl$a~Z*6|m%2v{ThiiGx5WI*>Z!T)V1qE!49wrhf$Cmqd~fCFJJuZ?3A zcf7^!9r_^E6ykLY!Zar6P?w4vZc`5H>S;+%a&-Biw5Ut6oxsLBB4)u|&eGhFW~C0B_cDlfb2GTT{-3{`7aQw^9wgDc)N5 zj&_j@ar8U9@7)+Q-Kc& zN`P(EP2CxNPA6JuySJhZK(v)P{R01dzHRWs52@+q=7Ocn@2Wm z#1W`P(Vo`)!1qfwhe=B%L!pb0Ap^ah`}B`Szu1x*lpN|2eFBhJ8;j#`sBOhgUO_QVLmg!nlhgcRpMLhKA59N~=hs zmVoP(S`5%MrtMP}Z@~Z!`fEaepFAUx@wh`*M%l@1zE30=dQ4Z%BY${zP`btBrb-(v z+js6wFh*Uan%R{bwvVG#Y-CaI94dFA04i-7DwGM*5$h8kT^rdf^c>aw;*jDfq1XGI zvUS_As9qWMa$~EyccRcBPb{wO^?( zsIrBXfTTn}JA*;`@uSS#J}Cvs#}BMmzr1nL70~dGI$(i=YqL`4j3ET&hm9hmdf`p@ zebEkn=cRWUoW?$(N1cCrFIH$2hgjOp4u}q|<(qkPh!fs-yX?|coa(rw`PSmrVJ*sV zzioF$*kIxweZ-}c4;b;hSw$1Mz}_aR0i5g(m%gXR9Y;1eBCe~UJRxhieg67SU9Kmm zoy8|7%W>z};mr*DXr@yFH0mfzGMWhF%bvG*t2E*3Gm!R&ogEA#fte{&_3@rVx_p8NKuEqs<9ld_d1@e2ByLB`EI zF0~kC`PKxTxGmLEREI6!N#|`D(Gj1Mu5D-{PN3v4cs1*XZ&l8%M9e55+ShOUF#NYf zlB$|AT!+ts!-S+CGveG0?C2uVdH}Yq+B) z7A(CV!K9}8gsFm;gFJ2i{)#oxniQ`PwV%m=X1=qis17SR#utb3CxdhZUa_ze%NwHv zWBm1ZW^|;SQ)f>ckU}AXEju?}uj_b26{I%#obBEd78*Z?61ppZHf4sDYc9xbzvSL?P*(^CD;m#97+ZNJ|T6b@V7Pn;z454pTpmN^KP~(`w%$5UsqXU0q_|&{1|8G5SJB?{!w#O zW#|B(7c9RHc}j|Xjx2VgS7uwprafu@5Q%V=pmFzEJ65Bkf?wPcjiTH_#{JVP?Hm%TQ8%*Of=nWxAs}*j zv!6k6!EIDjW@qUQm|cfthUA&EyRYd$Y(j+3L!Q`bbpGVnHnF1MwhW`MfoYuGvul*rxcS?YYc`H=S+ClUZgkGR4d`y)RpzX8ZdF z%*@&jP6xI6sr)D#-d9z_?ncAg*YhHqXX`fS&K1zrpAm|JvNYyb;h0FpPsTx?y0`vm zC?H_q=r~}Ya*skj_7F3@#XL4TVtT)21%rN$VtP-Lhu@JNHsOI|X%Y||PhWzQTvx=g zg(3M7>)PrzSzt3%*X)n%h0LweYs(KJ?%|Q7M{Vsw6F>M~TWJ6CM#7sB!BREky(YCt zyE%{+-Ec9NZHX8y{iC>1np>|#I>=T+egE0+w}DcUL!ZPuiP&)+I=sQyKi67ALh*kM zOU?tfzeE`%UF2@&t4gOXoOda_1gKM};f=X{ur6idceIPH_|+R&9^v;r242LxzntQH!e3uz zu=RsJK%!}%^w^Y5aqBCPp02(t?$P10{l2x=dtX>hg;{;IN3p_&a*7QwaaD})A|DVH zyA*>3j zH6H_+^HAZ_vibj;K^1bHF6QG5(x^RBy~Ipg zJg#aP^gu4;4>!?O`y9lA3582_vE)65$GAl*yamhq%5twqF0i-->;_ zhyo!7haW(~FjUlMXvqX^uR#g{!$Md&Mr?kk+W`T6K*8_?5bjT41x{TN(sA?6FC)JJ z^F7F+AgX8D`jC*-2$8rzJVmV8=*Wli*QG90I<&2$NA0qAg^SLFA>$c>1MYM9mk}rr za~9_Nju(9jiX&vkFz!WhsYov7>e!KCpg8W_kUX)ENZ~6JDaOJ| zpv-2HpwoB*SUSVcz!1?%yE##u*llyQ`f;dd61{P^Eu!P@mqLCFFhaM^jNfTFXVlrH zx$-UMn)6in<~{eYnL+4exDWSf$SLjun)56+6!wo@v!tuVrzH1*NcJXIr_s)z^aKwy zy2(yIl0h}}A|?u6jR_|npyg#qJr1sJL}Tz%=FKux?ckIE1#@Oe`ltzz`;lT%D>hO zuhP;18oJVw`q2p141lzBW)0nbI6$&yz)ee6gvUtfN26FXkfx>E!eih7&69!Zd%BOt zyKO1s{~=&3df#a71{F~k$)>{exS)KHGlW*1;E%+B8p(XB7`mV#3AcWu_GolFsQTp` znEr7s#?L-s&IMGt&%GSpaiCQ~)3vIE5rPw3|M?+8oy;GVq?5gNpX&|!eIh(4C+6-{ zcqyS5U=J7QyBNV0*k!mCjmHoF1qbhF9JLceV+;zwgQ+>1L*&#Rn9QC&ju29xY1Zq$ zdx<)F7;Q{l^x5E6*CT6Af$3D(t#bev?EV}!)DsLkYmnJJ#s-tu41}iPPd+f5*B)L( zZCm`62TE6~hj;>CV^Oy~xXh$(6=+XV3v@>TbHrBIIeAa&X%;gHIhRShzt=(1HtIvk z{A%9?Z9FD;l!&h-H4cxKNuq7IBZp*P-N&*Q5s8p$G9G;2d6|r2mz!dd;Lq`s96_s~ z^`DgL0`~(4(mZGE#+t#z8)KD%#effDeP|-x2~2d_>L{O-a!?Bw*FQ3nmPId(6qF9F z=fpN7P{!@GEagth9b-O)5%~!6xt#i6kpI(I#U{4tc^}1RDpAp*wG`0r%^JF&)$}3; z3SONFCsv^4BS>SDr_}-6Ab%tq*V1>uBf1h!-h-A;A&niLR(EiN{E;kHOYd)>a0{2P z1}z^$8ozm3ogc%L^_9M9o{uXJaj^;XRBJysYTk`2?{TrUz`@@}&A;QyubA2J3aZ0` z8u5|FlyRBa-oim}BR<-gG7U3ZG8}|7;$w~}b2GEe!a-;wKF%*?(F(T9g4)5*_WvQ! z;nPbP;Xs0=CQC>pmC2sDu49Y{=ip>MD89X^i{Hjh788@|a25C;7w+gr9`daRi6vJn)S_GQtEmlbUVw6G?Sr#+$h=4?ZS*I9+2&zpw93a*{Q{f7 zfC=*Rm3eI{m)I{&Xm9)n(hqy(n&bL7c3KK{isJFS3wGiQD9X|rzu+9fy%}c1TeCmD zYrUvPD$gKVm>>3+IYQxj3MPHQ4(hB3LC1BcC$qjVEF?tY1GlA#0DF z-5#*a4AHACrffNu&yI&8DQsBAL{26b?4)U+%J2(~gw)~~Q>RAgXA_*vxXTk}F7$S3 z&6;nfpzC+cbbuW^>t%F9ai-|gfz|Cj8rWr81VP&`WEBv9~1z39IOmch31NIv33iPqlj!VDrZjfan%*Hoxml(5Dy%&dq13&&|UHw!11#EGNnZr*7>sXycsU-&@@6ZUL@9PgX$p5c z)r+N!Mt3NOgNb8SH#Nj;D7yVYx$n=Y4;P_%EThym)c>lI#N`{F@b{P z$Mx|a8BfLENLg7LpV)q5G)k8y0#Dl-)wUFN;?6OalFcE~!rFWJSCG_h)QKio0QM|; zQ$lT7HOywREm`FqFkRNj%u5h{|m` zx=3gBrMJzT^}?|k+gC1U{5_zfbWiS9);w*NwO9W{fW8*vDsJ8-zypE8H9-}7WgYk{ zqla$FS|QbWiRCalaxlt0dcN>Y+xwqx6;bZNb-OrQ3rAPjPXY@kH|J- z6xzA9aBn2l(I?d6Liw&xV2(&S_mXYjhZ`ygRz?*)=MkA4@i@vu_o~?Spyn#ea?}RA z`{wggNmmT`mtf`%_>Jay%V6RS8N>?pbe$t9ru}b74wez~)a@769%-~~Ta46+72EE@ zATWq{R8l2hXXBE>*7)$zd`gpb?1JzT8~RoRA~0BG5m^cAnEb`)5eAfXZo{Y{SB2+L z(ErUwlaNB!qc1Xuk)j-@NOyr}j_g3ZGY*Of!Y82{4?W8h8v=&?H7eIkUGzZ(!I-y| zv);zvh>(y^^k&78RrGbdT?9Vb+q18uOp_6`OrmgX0QYboXro7`3cW=>Gq z9|LJX)ydDi;z}n6noRmCRQ%#QLa(PeN2Gp+CU6P6Q;Hxzl`L=UumvL7SPPkte&RfH zjQy0#M`HQ?mww{;cCnaw^xcX@1H21IE9#-2eqCmPc6+?E$?v%wcaPoN_Sucs&Upy{ zzTnu&()o6je2;3`vAN)MxUV-++WYOwf1x8Fgk4`Pd9@1oce3(6qL=)HKj&u-t;&Nt z*u=@+5%<7WxAnt_F{89B-T|ErkC6WU^XlB`Lz>|~{ZX=H*WIyXXQ-2%)^RNS*pD-M z^^-vWjcbN!5!~{A=gwc(WIyoqGb(`Tg0+hopKa;cq_Sfl|em?=Rmhx=YtD z`dj5BHV@R|dg3Fr)}2Na?sJ1bj{7g4>|=|{7sXss8|6@5onAr-ui_UY&GsL#uGQl& zag3+bSZ=T5{!mOc%Nlw!1i!?Re&pq8mn44eR}jpp8qS(Am!c=Dd`O$A^cBo1SH3>f z8Qn~GZ%sy+Tx~(;J{xUIo@bM$p-x=qS%eIfUN$RZcMj}+3VmT6Z0u8C7~{)3BSx_J zp)RMfzlXWj9%aGeZbDqQzR3)*4BeUFLSC60zzTaU$+>5fE-fhciuBaKZ8&oui#5t@ z;wkM#m=%QE9}(E7K2cDiA%$hhB%zPP$eg}}5u&#ZA_`5>Tq~xDDIF9z0E~WZ0_7G1 zEW<1!)`+F5@FIkV!C%lLyB3rU(P@t33*&#*%TlA~H|det0{52K6iso*Y-Q9`ny~Hj zR0nzSc@j0Vs#G?2Ww=%H7^zO54``52f^0J!v7+_nIxA7O;x@(Q)C#MzFi~9N15AUj z?pTC%aF+nRxz?sHx7fnUQI1yngDm-_x}B)W95{AgRL;^_O?8$Wm&u0<3l@s(NS%x> z=&qG%zs4wzM3WN~w-wFn(B$oWRt+Z3yyZ2igQ`tJu(E0`2vn$ak zWwHDi?o7Mq-=IZlEeJdJ-4KeI-}p^mk>-8Lx^$%C54FPxhId^a+yfM-2c%#8Pbtm{ zk+SV`uGm?JSbrh9t6+SrUH&WphVz&P-nm7qw_$#+BE#S|uUB zIIYUm`;pB_e)1zezVJX;J zG$KDD)@kNk<4ArIUJc8akVxtxjxdIo_~Hri&7Jz^{v(YE`B`@d`5CbYE?#aZeYA*5 zksBaS|4um((p|oseNEY*c)o?u8Z32euVGcr+nvb}tah@rkIRyJkkxClwy)Ca?5mIRs@(lhXM!z--Z!Fk>&M zkv_RM$M|W_Qb-Ly;hqPd=q7y%UK3tfUde?zmI#RUdUPr9nxw)47ZO0!$YPVoUQR{M zdZJ#Nq~cD9qAiD({43AVHkY1S2p`Ig_xzHtGvvzFy#3P~V~v$bnXVXNKEcFo-Rlhc zzihV%(~ETHXoqLK4K+ywK6yzc(JCIWy&^lpHHx!t@&VYt1%;6f6i!}~%+XHGG?PJi zC9Sg>3bM}-?GOS{lHa%c$j8#o(e~ql6mZOcUkd>UIc5oRgzL=1?T(f=9WQ(ko&}Yq0!ig>wZ!MP|3CU4GKU z6fYx!6x^1v5Ya*>KAWOG*6l8*_u3e@c<{~!t>w{Bu3*d+V_8;#d|SH=6!t{Z*-fX0 zO}XCnIx+Q39=SRgX>?l?T8+nu1+rz?-X{7?tR2Qp6%g{%I$`^eJ_Fn6iuQz%R4xN| z996KKoQxtY>0TFFryhzj-z~fSo2ZD;qY^$eW-^(HWORhzL*Tq=aIQP~q3ID}kyk&U-Q` z-2P2)D;9DHZg5H#>u&qjv?e808@3>9Y#w%;f|MDAkcw#QG-H!~xI&7O#5B zUVH6Q&~!c0scHZALh@9ldlm|1d^&1f&lVzNFc0Jt>KE$RIVhypjZp9|h^($2iTY4a z47BUR0eLOmTk^x03~vQ79)A+6svDt@Ll*eXD28f`5N|2i-QS?t#l`2O!!NH{M!Da5KT?HMPY}7pSV3J|O=B7abLwaM-fTJB zkG5jdeLirn?@S{m;8~Bd%|YFxC*7Ec`g$Jc#BcJA+}R-hTg`BX?oL3}rFoC*2HA1< zI@vM#l%7ZDcX&6dj&%9SsYa1F+=DZ$)%z4Dw_fqQ4Zqs8z*<4^4%}$TgvsA-865b9 z>D<3A=RPOuz+KGubL3cCUu8l0Le&n<<1MnNj32Ltozd@tu#}wG%c)OmOyfkX9y4@e zBmxW_s4lmfbFODkR_Qcf*?1BtKfn3NR<5?FIDNDtl6fHGvehIP2unEv?4>=p{UlXT z!;A;Eibx$0EXF9DPj_uDy5KjPbadU&prSFpDKM&%1EjT7QnZoGV2ipz zL{Sy+?*PFKwo4qYkZzpjh2zvBiMnkO8HqM8Gx08T$k&sA8KB*IsmX6S?yc^jw@nd9 z8L9XeCdi^F;}2bppS7hnzjLP_WL(A}7TohUU&-_{%LHps3QT4IA19FcDm) z_~j@rTCkvhA+~tOhj=)Q$}m{7dA6ixu>e#=grKF>@R`)A)(DIVMu;AWnbo1w-hZ}+ zje)+fa2IhQK`ONKc zi(cZEgEPqBbgL7rEuJKn^4%d;hbEmu_@l~U`jG*O#d0N9!?h7+Ns&O9>O_)kx3S$0 zD&6YnDC27DQt*IPXD-MbHWs3n?=AGB-jh;QGbH*Khgbcd2vV`BbP*}oZAL=)4VB0Y zyJ|BQWN%6h?or~rAsMq3>Xq6f9s|sHZkUZ0?NWpVCxS#g(wHP-nV5JnU>b8FC#J8@ ztr%`T%s@J0fc%Sg(@w4!ozdp6DWZH8aLz@z_DbNYU{#2m8hQMaGuTf+T{j-6jFdN<5NnN6KzzleTk|MIuD_T1r_E4LJs7XrWo{!b z-)H51kYyh4@x%14kw?x~&F|0BSw$Q;HqH~F!z}%2N|b-Lu-ZQe@~NJ7nEX@c#=GmW z4t`Fn3%nP4LR>*Kt*FK}l^q!w3?;wkM-(&UY0i?LBx#iLyT!7u^TSLSymJ5hRfXpMCpnjvLqc8;J%eDxDl{Gc8{{`0_%REwm@!2DeL!V)|u_2#d8 z^!0m;@3QWj%1Ox(Mw)iwPPZ2e*X(EcB>nMj(#h}LMCVh%SBla6-64)NFipOW4NN~E zDk(S1RVdBQYiwfmY^tHwCZk-0r(>lg_@WRvq5Fpc#Lfq7y;9iU1Cyr@(nx(y|LQO! zMMjIPc65#=7KzzvGHr;6XT-$=Wsb(k>4F70=)$rX@wUfgWmQ-qHn{5ikYIWY5Ms1^ ztsaRE)|+W%To>_^ za4;nlgsTellGFn)As|HT6O2^T^%BaG#sib(ie+SH50Oi*o4fZj2s}W>NGb*%EeK3+ zAkoa`F#|tNVp(VRB+;Vz(IAR2@Qjq%w8yVj0_`{uG63|8X68@>4M8Omq6jVev{7Eb zF|UKT5DbjcgXh8mbP^P0eNUL{RbjufjneR^9r*2I^S~PQsSB3~7HH%B3}hl|@6i)P z_18ACTw-NQ--BjHM6dcl+f;Y1tG2|Sz;dz7f8<~p_9ErF(GFq(TZwxzTN=}Kk;(YM zY{X7zSPx>TX#TLFXeI^9HL|=%q784cWDY6VzhGfZG#=4act*6H**>j>O7(AW?TJDO zg^wGPD3CHgD4{C>)bYeGpD!25lHM}^#W{|vD0GYe!#1T^ELW2Xw<^n6E@XVYUS``Y zA!{LL{JbdC-I)IF&J|NPflbg?glRJh-U}JKsiOC5Tbq+YSSn1cd zyd4VYwdgO_W0Iv{HrSw9OPpOchj?&D)2z>$eW#tk`d9LEgZR6e;mGIV0B@UnKlu6Q zdzYeCEJ5ps(axs|=uvBeBb?!~1ub7f8dE*3%8y}FKaz9n=sWZjyxI~@>_N*nkj89J zt8d4!vmeQ8b@cvv3b(BZCw8FaD@bFer&ZlCZ2d=aR~67Z)2*PxbddjhZ*( z%4=L~>2UDUsQEmu{LICMP*5!kCmRsQl(CrEq~HKfHlU6vQ!=v!!vUOZz!+0zXJ+ez z131|L9Qz+98{pK%*><0esc{1IM)N0L;$EUx1>A`56+|5Ck(k`QXC272hl}CHd!Ohf z{7?*1YY1&JQju>Jozk7ltt;u!Q}SjJkE+r(*kb84Q1b3dIeX8rR+Ga|X}DI^z0m;| zd`a~3xi1D$4Sl-}6e?-POSwlA5&b(0Q~ru*mzj6T{jQr2UP)(mNDm zi@O@t@wS-%-3~k{uEc`bJOiiuRt#%L%I!_*9tUhkoVs(9S&ipf4*%4tD<|$*FDK3x z7wBZHXAs%dGL-wOui(|3aN+}69)&c{ds^ARn-$JzR?~MFDR{LcocMv3Cn1eLJ*~Xq z%?f8UtLgoX6mH=XzM$oCNaLcXl{36q7n9Q)Q$CcgXQs!P>e|Ce%}49fv&{4{IDnIy zFV>|mndz@Ibm=7Z(cz>fYFavthHeoYz)4MDTDl}WMoJ$YPHGaTrJKQH-~i3~f1K2W zQ>5OPK`i%UiWnl%ExS$|&p@yn^B=XZ+Mg=$ASIkJuV;3mw7ZY}ds2l}hucoQxdV2M zK)mhPFx9ihAr-=%1{w+oTYsV&a{BqU8fvM$wbvo8W@c{k%~CMC>)!4fa$LV<1W4Fl zn*NwB$@M6=Sy+}_pX5$tNtgk==&ofFjk62!i4PIJUsarbrfJy1_Hrr13{7M`_%;*l z@CxPNnzl0(C>}XAoXaN9yYJK^vpME;8>$+GvyI)Y^4>;m=h{!!I8KX>xvKH{bz0d$ z=cau|$0eBvGN(rJO=4gl@VS?Pn*?n3eI-QQ@M8>44yI(hHFN-&Lo(!xH-67~Sf&#D zT!8hrED9LW03gZ1`?=M$j{7KI*2>%KMFLqUz0W_Z8AOFwdN z_QES7d((e~?iFQ(L36$G1~yrFm}GI_K3CEThSRTC`(qmY?gj3{ztdVcvk10B8m!se$jVE7#jFH9+x2iQOa8fQa^d7(`)!Wxp^z#m(RMWG zv>4AjD4ZB8nU9@Ci8(I`XPU%BvBFS={Kq4FNy1rImKN|R3iKd$Q$#kyMzhhj6XS(r zQh}V}tt4oKOBH3dCB@=AB#&>FfYG566_YmwpX}Q@O3gN8J3apWm|#+K(Goz<%Eje3 zPgfi!T}G^xiVLHW~ zONa-#8>L!L-qiDgin9iPsJ`{gDG; z);zt16>g&9Y=}Ebnpz>Fj4?+T#;YQu)-m3_s98#v;c6nK;*Nh@Mu^N~)0U&?wo;^x zI|RcY%`q){_sfSkjL)E3c*tCpe1#@YUjY^2AZ+G$4Q*a>G{w>seCZe+^N$aCx51BN z#^DJ2K52deS~5h$S(D2bD?wrXsK@k7!zx|8xmEoA($n5Y&DCu>dgj3(#L7E^QE&5v zSxNjj`1&+q=EI&XKFVf!Qs&9Yfj<*6@P>*i`(I;f^$nTN{Dxp;JGRg5RWC+pky<}* z0t!+hY<2Xk85`2M^-i%d8z)6%R!sMX_UO5oWYESvPjmd@Yu3#pgH!d(q^d}b&kK<# zUbaG^tDaKA66YWEfh*x(9-~0jpMIgo#$Bl^3R?;O`cqKK5)c7Q!1AkQ4axj)9J|?e z&^V#e4qpxt50W*ZZ*pm26)-S+Tw@c?D-1Y?*`I^&9YepM;>h&`F zhziJNjku7DRYTOZ?!}06)eFZqMo;{yP51BhdEZdQV^x|+q>0)Rn9Y+iDlRnD%7l$S z3^JK+rGgfni&(xe#B~k>uhYw#q`Kb6O74~EDOsYv;Ra|YIN2M>7i)g=h3^QaqXts8rn{3frjG1XQarhb5d5;|7M1`L?drFu#Mlpj!3Qh27KE@Hf(d)do!hP zmSy1s#VhXLgVt!Zyby`VcK^!#uDVN4mvIX2FslDD)`ht)bJ8Jh>+qv2%6XHrtxuRk z-HZrSh6wG7lBPg8M_SG{O4joIE$^}U-Dmd3Aa8`8C&!2~2Ukr=wF5j1Y@Q)kTj4a|de**}ipIBf{ULs7lOj6RoA7g?N9ql*jUd^BM_ zM0}*gd@MY$w=m&BQBMj~eogjNLNmZJmz`Ahs72>W|GO_zTSnS>yshB)fmBnNkTYpY zU%XcU>B5_(I}+1R_O0~3a;H!u->hA!Uv09uTi(lId?U>oAh1o(#7&nYIdn%^nH#|+)C;6e;r%I;er zC44FY5Gv<5F_D-1LQt?6&V|vmXD|u?qHez2j%O_L`gX72quti!zbY<%&`ImvCS&5{ zN><30NjO*YpBDwOl20y?Wv0#d)76hLJpR}{P)`pogW)>`qgQhiIpHy0eA0*w zV*xU3S}UfmVTD%P^A#3=c|F6?MzTyTz^65 z?B4dvz0&n?+_{x+VEFQ?8n?`Vbj9y2N$(0oo$|z)Jqo@w@n~zK7W&>gi{J z@`qTn=zz`UKlUJ#y%Avd)w12xPa&S1=Hw$=uHrY*-j~?XssLYJFma!9RwKLpNU-G) zFi%~_f9)qPe5%)!eu6KYrjjK7>N5cRTd;6UnOGmH5*-_0iSXl+6um2Vjuf>k4d?;L z+fghwSUAx30EF4fC@K^6U#ec5!Rm{!Yxdj4Guy`(E>(4jlP;wvK-)#fO^dIg(V)(~ zt9zQ9?RS#4_=~n!`mq?4bqxdDF2WV$yFwUNP0zTtrQR#|m{xpNx2Z~Hz8&(if+p0D zoH#3Az!+Bac>cVOI>BO5K)X%h^<~+lDZ@1GQ#Z&tJZ#jtz?gvxQ905EibN*JA6dP! zC5Lr9qrMQ({ML_X!_0Q}O56^F+3O?e&ms*hvARZGFxDRu zs&^k_k9I2MBk2ilZlk}SGpeCri4?Z`taTjl3-MF2RK52yW&>m)%%OSC*ea@FI zikk;$#K*zm3|8_&>DOARRr&G_$t#^}Y{C-H?2f>vCzmE8-fss1da8*MY!Vl!Uz?}f zp?}!k zH)qR>l7SuRt~b2-(4Gkph-+lz+m59_^dv5E?Xp2g{B~rd?_QXaT486$2bvR?IDhFX zC_XhZGIZZcN&RkT#}B$1m-zGY(jMALMa{-A5uV3f$gFG|ga4%{td_ZuUD-Ak|5s61 zH*+DkvTYnbYjM~Vb0NR7Z9Kkxao9R@p|G-T0{)lcuruaDQDxgi{9nakFSdme28W5n zkHz8t|1KG>M^b9%!(Q~HKe87}WXw>j945lU@EzOzGzd}T^fd@ka0m5?63ZkT|%%o2X> zA#Kf8Mh4yYFyenj9I>tAuY>^$RSxkUFZgSMxL?NsETxsBFkWaRXWcCcL2fCPaJ~s^( zI(`5DV~;fK=xqI;>uDVkGRt?kNt_9=4sRQ%Gapzu7n}0S39imfjlE|-X?eVIl8&5V zxCR9D|KEA(591cw+II5T%mAB9{hv|*7K2n4Mgl$JKH`Hyi;!#yAYq(^3Wo%!VYKG5 zqy&s%N9g{u!G`-`DCSCfct66h%~e|IjF7x$c_NA}Us4aV2toglQ}v}-;3_W@`~NO9 zz_6cai@ZKI0Zf@&MV+Vy=_xwa=*_98aqKbwTZveU&{6Iu@JE|O;zc|23Z(^khS39H zES)&Rgawf4kK$%j1;Eb~`nxcPUQS5t)}c|poWj_+z#htf71QdE z^0XvzaWsS`UW$a)j1H2{oT0x) zN9s@Fri()-=uhLOpFqd#PpPM?MyKfin)r;AZw%dRR($>6Nh+~{tc<31Eb(u%5Wn@D z>)PFu3e3sy3KJ}pImGu16E~M6#g`A`F_->d#{7S5fPBDEXa{=C2okPY0sL2^VVD58 z_lMNKt_C>tN7SEM{_UOiww?>(8LJejT!6Ol%^_}k7*(MGkJ;s4aIM5px-L8N72+`A^(l)c#{oo zyY79n(v0^X&**vjM-uS~$3Pc2*U-|C&*g6r+L@3otk|F6#ti@AIj z0dW|Exttz>U|0>f(~j?egj(3?-`BA~q>xjY>t*nKS^U4P2zQ_P_n-g!YWElZdnJ3b z)ckx6{kBGxkv*`V`B7r+lu8k)!(JF{B!Tb zk&Tz;hsJopC$781>2s>D`KE{s+_g+sm9Fjr;J~`yjw{bZjXi(eg!n~H@)A?IaX7H;r+0Sb6IbHo^VZoh+q_u9juSB3l=|z@`;R_>cdmC& z4WjdR&eu2UP~SL-uJz8Rp^x5A8zxhSnSYTqR1gpekqF@%tpQ%;1{oPw{ghaHi}dQ1 zKj76X9C+w|96<>Gy7F=4@^*G}uyA&UPl9xE{LjML9>ISr;K|bAiOK&%68;;65C~TZ z7dCaXc69&$sNnop$bZ(eqVXQDkl@uTVVzg6{*|@=ID!yNkpGt+cUyBeYbz}eH@gou r?*EjN|GbOb|Ixq$-&F2@7vsO|DW{==g8HwN$nXy~ybQJB|3d#4BJ;~6 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a446283fb87c24c279efefe88e3ce9c7c66d9a97 GIT binary patch literal 21652 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 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_a_gap_in_the_ons_prices_is_an_error(raw): + def edit(tree): + prices_of(tree).ons.gas.update(period="month:2020-05", value=None) + + with pytest.raises(ValueError, match="gas prices skip a month"): + 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") 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..a0506976e8 --- /dev/null +++ b/policyengine_uk/tests/test_import_energy_prices.py @@ -0,0 +1,146 @@ +"""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"] + obr = render(mm23=to_csv(rows))["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)) + + +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 5569b6981a..99c251e292 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") @@ -396,7 +399,15 @@ def test_generated_parameters_are_the_series_processing_builds(): ): assert not generates(path), path continue - built = path.startswith((f"{INDICES}.", f"{GROWTH}.")) and not statutory(path) + built = ( + path.startswith((f"{INDICES}.", f"{GROWTH}.")) + or path.startswith( + tuple( + f"{ENERGY_PRICES}.{kind}." + for kind in ("quarterly", "calendar_year", "fiscal_year") + ) + ) + ) and not statutory(path) assert generates(path) is built, path assert generates(TRIPLE_LOCK) assert generates(f"{INDICES}.uc_standard_allowance") From 32b669194a9e52c6ee53c8b514fe86c5b6821a72 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 18:37:01 -0400 Subject: [PATCH 3/6] Read energy price inputs at each month and quarter, not their stated dates A reform keyed by year, such as {"...energy_prices.obr.utilities": {"2027": 0.5}}, leaves one stated value for the year, and the generator read only stated values, so it found no rate for 2027Q2 (Sol review r1). Reforms to the ONS monthly prices failed the same way. - The generator reads each input's effective value at the month or quarter it applies to. - The outturn ends at ons.metadata.last_full_quarter, which the importer writes and a reform cannot move. Before, it ended where the stated months ran out, and a reform's re-inserted value would have moved it. - Source gaps: a test that ons.yaml states every month to that quarter, and errors for removed values and for prices that end before it. - Tests: a Hypothesis property over year- and quarter-span forecast reforms, a public-API regression and year-keyed ONS reforms. - The docstring no longer says "before the Budget measures". Co-Authored-By: Claude Opus 5.5 --- .../gov/economic_assumptions/README.md | 25 +++- .../create_energy_price_indices.py | 133 ++++++++++------- .../energy_prices/ons.yaml | 5 + .../tests/test_energy_price_indices.py | 141 +++++++++++++++++- .../tests/test_import_energy_prices.py | 4 +- policyengine_uk/utils/import_energy_prices.py | 10 +- 6 files changed, 251 insertions(+), 67 deletions(-) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 90d52ebd21..daa1041ab3 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -327,7 +327,7 @@ Competitiveness Scheme brings consumers outside energy-intensive industry | 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); and their annual CPI weights (CJXA, CJXB, A9F4) | ONS MM23 | +| `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 | @@ -344,8 +344,8 @@ OBR assumed, which a reform to `gov.hmrc.vat` does not change. parameters are processed, builds three series for each of `electricity`, `gas` and `electricity_gas_and_solid_fuels`: -- `quarterly`, 2015Q1 to 2042Q1. Where ONS publishes all three months of a - quarter (to 2026Q3 in the October 2026 inputs), the mean of the monthly +- `quarterly`, 2015Q1 to 2042Q1. To `last_full_quarter` (2026Q3 in the + October 2026 inputs), the mean of the monthly index, each month divided by 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 Great @@ -368,6 +368,16 @@ 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. +Every input is read at the month or quarter it applies to, not from 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). The ONS +prices are read only to the end of `last_full_quarter` and `obr.utilities` +only after it, and a reform cannot move that boundary. 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 @@ -436,10 +446,11 @@ of the indices keeps it. 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 takes the OBR forecast from the first quarter - ONS has not published in full, and stops before writing anything if an - MM23 series has a gap, if the series come from different releases or if - Table 1.19 has no quarter after the ONS outturn. Then check + 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, 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 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 index 7013a327a0..2dfae3cf7a 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py @@ -1,4 +1,4 @@ -"""Household electricity and gas price indices, before the Budget measures. +"""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 @@ -9,8 +9,9 @@ Quarterly levels, from the first quarter of 2015: -- where ONS publishes all three months of a quarter, the mean of the monthly - CPI index, each month divided by the VAT factor the price includes: +- 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 @@ -22,6 +23,10 @@ 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. +Every input is read at the month or quarter it applies to, not from its +stated dates, so a reform keyed by year (one value for a calendar year) +applies to each month or quarter of it. + 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. @@ -35,6 +40,8 @@ sets it, the annual levels take the previous year's. """ +import re + from policyengine_core.parameters import Parameter, ParameterNode FIRST_YEAR = 2015 @@ -73,17 +80,15 @@ def _start(quarter): return f"{quarter[0]:04d}-{3 * quarter[1] - 2:02d}-01" -def _values(parameter, period): - """A dated series' stated values, by (year, month) or (year, quarter).""" - values = {} - for entry in parameter.values_list: - if entry.value is None: - continue - year, month = int(entry.instant_str[:4]), int(entry.instant_str[5:7]) - values[(year, month) if period == "month" else (year, (month + 2) // 3)] = ( - entry.value - ) - return values +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: @@ -92,12 +97,48 @@ class EnergyPriceInputs: 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 - self.monthly = { - key: _values(self.prices.ons.get_child(key), "month") for key in SERIES - } - self.utilities = _values(self.prices.obr.utilities, "quarter") - if not self.utilities: + 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}." + ) + last_month = "{:04d}-{:02d}-01".format(*_months(self.last_outturn)[-1]) + for key in SERIES: + stated = _stated(self.prices.ons.get_child(key)) + if not stated or min(stated) > f"{FIRST_YEAR}-01-01": + raise ValueError( + f"ONS {key} prices must start in January {FIRST_YEAR}." + ) + if max(stated) < last_month: + raise ValueError( + f"ONS {key} prices end in {max(stated)[:7]}, before the end " + f"of {last}, the last quarter ONS publishes in full." + ) + 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.""" @@ -131,14 +172,11 @@ def utilities_vat_factor(self, quarter) -> float: def utilities_growth(self, quarter) -> float: """OBR utilities inflation for a quarter, before the VAT change.""" - first, last = min(self.utilities), max(self.utilities) - if quarter < first: + growth = self.prices.obr.utilities(_start(quarter)) + if growth is None: raise ValueError( - f"The OBR utilities forecast starts in {first[0]}Q{first[1]}, " - f"after {quarter[0]}Q{quarter[1]}, the first quarter without " - "three months of ONS prices." + f"The OBR utilities forecast has no value for {_label(quarter)}." ) - growth = self.utilities[min(quarter, last)] return (1 + growth) * self.utilities_vat_factor( _year_earlier(quarter) ) / self.utilities_vat_factor(quarter) - 1 @@ -167,41 +205,22 @@ def fuel(name): def quarterly_levels(inputs: EnergyPriceInputs, key: str) -> dict: """Quarterly levels at the basis rate, 2015 = 100 before the adjustment.""" - monthly = inputs.monthly[key] - quarters = _quarters((FIRST_YEAR, 1), (LAST_YEAR + 1, 1)) - outturn = [] - for quarter in quarters: - if not all(month in monthly for month in _months(quarter)): - break - outturn.append(quarter) - if not outturn: - raise ValueError(f"ONS {key} prices must start in January {FIRST_YEAR}.") - # Months of the quarter after the outturn may be published; later ones - # mean a month is missing. - later = [month for month in monthly if month > _months(_next(outturn[-1]))[-1]] - if later: - raise ValueError( - f"ONS {key} prices skip a month before {max(later)[0]}-{max(later)[1]:02d}." - ) levels = {} - for quarter in outturn: - share = inputs.electricity_share(key, quarter[0]) - levels[quarter] = ( - sum( - monthly[month] - / (1 + share * (inputs.electricity_vat_factor(*month) - 1)) - for month in _months(quarter) + 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 ) - / 3 - ) - for quarter in quarters[len(outturn) :]: - earlier = _year_earlier(quarter) - if earlier not in levels: - raise ValueError( - f"ONS {key} prices must cover the four quarters before " - f"{quarter[0]}Q{quarter[1]}." + else: + levels[quarter] = levels[_year_earlier(quarter)] * ( + 1 + inputs.utilities_growth(quarter) ) - levels[quarter] = levels[earlier] * (1 + inputs.utilities_growth(quarter)) return levels diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml index fdc2cf2c37..6f4f065941 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml @@ -1,6 +1,11 @@ # Written by policyengine_uk/utils/import_energy_prices.py 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 are built from. +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: diff --git a/policyengine_uk/tests/test_energy_price_indices.py b/policyengine_uk/tests/test_energy_price_indices.py index 584b8a8ed3..f09a54622b 100644 --- a/policyengine_uk/tests/test_energy_price_indices.py +++ b/policyengine_uk/tests/test_energy_price_indices.py @@ -21,6 +21,9 @@ 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, + and the outturn ends at ``last_full_quarter`` whatever a reform states. """ import math @@ -320,11 +323,46 @@ def edit(tree): 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) + + +def test_ons_prices_that_end_before_the_last_full_quarter_are_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 end in 2026-08"): + build(raw, edit) + + def test_a_gap_in_the_ons_prices_is_an_error(raw): def edit(tree): prices_of(tree).ons.gas.update(period="month:2020-05", value=None) - with pytest.raises(ValueError, match="gas prices skip a month"): + with pytest.raises(ValueError, match="gas prices have no value for 2020-05"): build(raw, edit) @@ -585,3 +623,104 @@ def test_no_economic_assumptions_holds_direct_energy_amounts_flat(scenario): 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), + by_year=st.booleans(), +) +def test_a_forecast_reform_sets_the_rate_of_every_quarter_it_covers( + raw, current, first, span, rate, by_year +): + # One stated value for whole years, as a reform keyed by year leaves, or + # for a span of quarters from a fourth quarter, across the year end; + # either removes the stated values of the quarters inside it. + if by_year: + years = min(span, 3) + period = f"year:{first}:{years}" + covered = indices._quarters((first, 1), (first + years - 1, 4)) + else: + period = f"month:{first}-10:{3 * span}" + covered = indices._quarters((first, 4), (first + 2, 4))[:span] + + def edit(tree): + prices_of(tree).obr.utilities.update(period=period, value=rate) + + 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] + ) + + +@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_import_energy_prices.py b/policyengine_uk/tests/test_import_energy_prices.py index a0506976e8..3f39ee8630 100644 --- a/policyengine_uk/tests/test_import_energy_prices.py +++ b/policyengine_uk/tests/test_import_energy_prices.py @@ -75,7 +75,9 @@ 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"] - obr = render(mm23=to_csv(rows))["obr.yaml"] + 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" diff --git a/policyengine_uk/utils/import_energy_prices.py b/policyengine_uk/utils/import_energy_prices.py index a915e3e73e..e36517a45f 100644 --- a/policyengine_uk/utils/import_energy_prices.py +++ b/policyengine_uk/utils/import_energy_prices.py @@ -6,7 +6,9 @@ - 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; + 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; @@ -242,11 +244,17 @@ def _long_date(day: date) -> str: def render_ons_yaml(mm23: Mm23) -> str: released = _long_date(mm23.release) + year, quarter = last_complete_quarter(mm23) lines = [ "# Written by policyengine_uk/utils/import_energy_prices.py 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 are built from.", + "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 += [ From f843cf11c0d46e61e424cae2bd36d31490f5b766 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 19:44:05 -0400 Subject: [PATCH 4/6] Accept energy price inputs changed with no end date Parameter.update with a start and no stop removes every stated value after its start. The generator checked that each ONS series' stated values reached the end of last_full_quarter, so such a change raised "ONS electricity prices end in 2025-01" although every month still had an effective price. Drop the stated-date checks on the ONS series: ons_price already stops generation at any month with no effective value, and a test keeps the committed file stating every month. Add a Hypothesis property for open-ended ONS changes, an open-ended shape to the forecast property, and gap tests at 2015-01 and 2026-09. Co-Authored-By: Claude Opus 5.5 --- .../gov/economic_assumptions/README.md | 9 +- .../create_energy_price_indices.py | 16 +--- .../tests/test_energy_price_indices.py | 86 +++++++++++++++---- 3 files changed, 78 insertions(+), 33 deletions(-) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index daa1041ab3..2e5c9f8d53 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -372,9 +372,12 @@ Every input is read at the month or quarter it applies to, not from 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). The ONS -prices are read only to the end of `last_full_quarter` and `obr.utilities` -only after it, and a reform cannot move that boundary. A forecast quarter +Core's `Reform.from_dict` a bare year applies from that year onward). A change +written with `Parameter.update` and no end, which removes the stated values +after its start, sets every month or quarter after it. The ONS prices are +read 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. 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 index 2dfae3cf7a..fc2aa13b8d 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py @@ -25,7 +25,9 @@ Every input is read at the month or quarter it applies to, not from its stated dates, so a reform keyed by year (one value for a calendar year) -applies to each month or quarter of it. +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 @@ -109,18 +111,6 @@ def __init__(self, parameters: ParameterNode): raise ValueError( f"ONS prices must cover the four quarters of {FIRST_YEAR}." ) - last_month = "{:04d}-{:02d}-01".format(*_months(self.last_outturn)[-1]) - for key in SERIES: - stated = _stated(self.prices.ons.get_child(key)) - if not stated or min(stated) > f"{FIRST_YEAR}-01-01": - raise ValueError( - f"ONS {key} prices must start in January {FIRST_YEAR}." - ) - if max(stated) < last_month: - raise ValueError( - f"ONS {key} prices end in {max(stated)[:7]}, before the end " - f"of {last}, the last quarter ONS publishes in full." - ) stated = _stated(self.prices.obr.utilities) if not stated: raise ValueError("The OBR utilities inflation forecast has no values.") diff --git a/policyengine_uk/tests/test_energy_price_indices.py b/policyengine_uk/tests/test_energy_price_indices.py index f09a54622b..1eae69f1d3 100644 --- a/policyengine_uk/tests/test_energy_price_indices.py +++ b/policyengine_uk/tests/test_energy_price_indices.py @@ -23,7 +23,8 @@ 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, - and the outturn ends at ``last_full_quarter`` whatever a reform states. + 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 @@ -350,19 +351,27 @@ def edit(tree): build(raw, edit) -def test_ons_prices_that_end_before_the_last_full_quarter_are_an_error(raw): +@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(start="2026-09-01", value=None) + prices_of(tree).ons.gas.update(period=period, value=None) - with pytest.raises(ValueError, match="gas prices end in 2026-08"): + with pytest.raises(ValueError, match=f"gas prices have no value for {month}"): build(raw, edit) -def test_a_gap_in_the_ons_prices_is_an_error(raw): +def test_removing_the_ons_prices_from_a_month_is_an_error(raw): def edit(tree): - prices_of(tree).ons.gas.update(period="month:2020-05", value=None) + prices_of(tree).ons.gas.update(start="2026-09-01", value=None) - with pytest.raises(ValueError, match="gas prices have no value for 2020-05"): + with pytest.raises(ValueError, match="gas prices have no value for 2026-09"): build(raw, edit) @@ -640,24 +649,28 @@ def realised_obr_rate(raw, level, quarter): first=st.integers(2026, 2034), span=st.integers(1, 6), rate=st.floats(-0.3, 0.6, allow_nan=False, allow_infinity=False), - by_year=st.booleans(), + 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, by_year + raw, current, first, span, rate, shape ): - # One stated value for whole years, as a reform keyed by year leaves, or - # for a span of quarters from a fourth quarter, across the year end; - # either removes the stated values of the quarters inside it. - if by_year: + # 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) - period = f"year:{first}:{years}" + change = dict(period=f"year:{first}:{years}") covered = indices._quarters((first, 1), (first + years - 1, 4)) - else: - period = f"month:{first}-10:{3 * span}" + 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(period=period, value=rate) + prices_of(tree).obr.utilities.update(value=rate, **change) changed = build(raw, edit) effective = changed.obr.utilities @@ -698,6 +711,45 @@ def test_a_year_keyed_forecast_reform_builds_a_simulation(raw): ) +@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): From edc2ce7224116ec77b3ab9df2fae09f8316bc806 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 19:52:45 -0400 Subject: [PATCH 5/6] Write the energy price index docs and descriptions in the active voice Name who acts in the README section, docstrings, comments, YAML descriptions and the changelog entry. The importer writes the new headers and descriptions, so the generated YAML still matches it byte for byte. List the energy step in process_parameter_tree's docstring. Co-Authored-By: Claude Opus 5.5 --- .../energy-retail-price-index.changed.md | 2 +- docs/book/assumptions/growthfactors.md | 2 +- docs/engineering/skills/dataset-sources.md | 11 +++-- policyengine_uk/data/economic_assumptions.py | 8 ++-- .../gov/economic_assumptions/README.md | 47 ++++++++++--------- .../create_energy_price_indices.py | 12 ++--- .../energy_prices/consumption_shares.yaml | 20 ++++---- .../embedded_electricity_vat.yaml | 2 +- .../energy_prices/held_flat.yaml | 2 +- .../energy_prices/index.yaml | 8 ++-- .../energy_prices/obr.yaml | 8 ++-- .../energy_prices/ons.yaml | 4 +- .../scenarios/no_economic_assumptions.py | 8 ++-- policyengine_uk/tax_benefit_system.py | 1 + policyengine_uk/utils/import_energy_prices.py | 23 +++++---- 15 files changed, 80 insertions(+), 78 deletions(-) diff --git a/changelog.d/energy-retail-price-index.changed.md b/changelog.d/energy-retail-price-index.changed.md index 2f3591d22c..1c6bec9210 100644 --- a/changelog.d/energy-retail-price-index.changed.md +++ b/changelog.d/energy-retail-price-index.changed.md @@ -1 +1 @@ -Project household electricity and gas spending with retail energy price indices instead of headline CPI. The indices come from the ONS CPI electricity, gas and solid fuel indices to the latest month and the OBR's utilities inflation forecast after it, with the electricity VAT cut and its extension taken out, so a VAT reform no longer moves projected spending. +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 88dc6d15c0..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. 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, with VAT on electricity held 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. +`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 d76fa80646..05e60efa21 100644 --- a/docs/engineering/skills/dataset-sources.md +++ b/docs/engineering/skills/dataset-sources.md @@ -144,8 +144,8 @@ 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 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, with -VAT held at the rate the inputs are valued at. Other generic +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: @@ -170,9 +170,10 @@ 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 indices, their -sources and the VAT adjustment are described in the economic assumptions -[README](../../../policyengine_uk/parameters/gov/economic_assumptions/README.md#household-energy-prices). +`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. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 5eeaa934bf..7574e2e124 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -167,7 +167,7 @@ def _unmapped_uprating_indices(uprating): PRIVATE_RENT_GROWTH = "gov.economic_assumptions.yoy_growth.ons.private_rental_prices" RESIDENTIAL_PROPERTY_INDEX = "gov.economic_assumptions.indices.obr.house_prices" ENERGY_PRICES = "gov.economic_assumptions.energy_prices" -# The annual energy price index each declared input is projected with. +# The annual energy price index that projects each declared input. ENERGY_PRICE_SERIES = { "electricity_consumption": "electricity", "gas_consumption": "gas", @@ -472,8 +472,8 @@ def uprate_energy( 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 are valued at, so the spending is before any VAT change, - and the VAT formulas apply the rate in force. An undeclared basis holds 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 @@ -492,7 +492,7 @@ def uprate_energy( return current_year source_instant = f"{basis['period_value']:04d}-01-01" target_instant = f"{int(current_year.time_period):04d}-01-01" - # Every ratio is checked before any amount changes. + # Check every ratio before changing any amount. ratios = { variable: _energy_price_ratio( parameters, diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 7763a68fe3..dd590a0aed 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -306,7 +306,7 @@ threshold by the same rises, up to the last year of `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 are valued at, +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 @@ -331,35 +331,36 @@ Competitiveness Scheme brings consumers outside energy-intensive industry | `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 | Set by `no_economic_assumptions` | +| `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; the -last two are maintained by hand. `embedded_electricity_vat` describes the +`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 run whenever the -parameters are processed, builds three series for each of `electricity`, -`gas` and `electricity_gas_and_solid_fuels`: +`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 divided by the VAT factor its price includes, + 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 Great - Britain and Northern Ireland rates weighted by consumption), `b` the basis + 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 grown by the OBR's utilities inflation with the - change in electricity VAT taken out, `(1 + g) × u(q − 4) / u(q) − 1`, where + 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), averaged over the quarter's months. After the OBR's + 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 dated 1 January of its first year. Each averages the - year's four quarterly levels weighted by their shares of the year's + 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. @@ -368,15 +369,15 @@ 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. -Every input is read at the month or quarter it applies to, not from its -stated dates, so a reform can state one value for a year or a span of +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). A change -written with `Parameter.update` and no end, which removes the stated values -after its start, sets every month or quarter after it. The ONS prices are -read 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, +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. @@ -390,7 +391,7 @@ the forecast levels, though not their growth. - 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 is fixed at 2024's. The share matters only + 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. 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 index fc2aa13b8d..e3f1426fec 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_energy_price_indices.py @@ -4,7 +4,7 @@ ``.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 -are valued at (``gov.simulation.vat.energy_input_price_basis_rate``), so a VAT +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: @@ -23,11 +23,11 @@ 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. -Every input is read at the month or quarter it applies to, not from 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 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 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 index 631d973aaf..fa6e83c84f 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/consumption_shares.yaml @@ -1,10 +1,10 @@ -# Written by policyengine_uk/utils/import_energy_prices.py from DESNZ -# Energy Trends. Rerun the importer on new tables; do not edit by hand. +# 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 used in January to March. + 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 @@ -15,7 +15,7 @@ electricity: values: 2015-01-01: 0.2929 q2: - description: Share of a year's domestic electricity consumption used in April to June. + 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 @@ -26,7 +26,7 @@ electricity: values: 2015-01-01: 0.2242 q3: - description: Share of a year's domestic electricity consumption used in July to September. + 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 @@ -37,7 +37,7 @@ electricity: values: 2015-01-01: 0.2051 q4: - description: Share of a year's domestic electricity consumption used in October to December. + 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 @@ -50,7 +50,7 @@ electricity: 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 used in January to March. + 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 @@ -61,7 +61,7 @@ gas: values: 2015-01-01: 0.4250 q2: - description: Share of a year's domestic gas consumption used in April to June. + 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 @@ -72,7 +72,7 @@ gas: values: 2015-01-01: 0.1642 q3: - description: Share of a year's domestic gas consumption used in July to September. + 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 @@ -83,7 +83,7 @@ gas: values: 2015-01-01: 0.0799 q4: - description: Share of a year's domestic gas consumption used in October to December. + 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 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 index db65b823cd..d28f9d59af 100644 --- 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 @@ -23,7 +23,7 @@ northern_ireland: label: VAT rate in published electricity prices, Northern Ireland period: month reference: - - title: Budget 2026, paragraph 6.210 (the extended relief is expected to apply UK-wide from April 2027) + - 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 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 index 59b3d404b8..94a32df6b2 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/held_flat.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/held_flat.yaml @@ -1,4 +1,4 @@ -description: Whether the annual energy price indices are held at 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. +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 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml index 653bd769e1..475902d1f5 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/index.yaml @@ -1,10 +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; none is annualised to fiscal years. - # A bare-year reform key names the calendar year - # (policyengine_uk.utils.dated_reforms). Leaves only see node metadata that - # is propagated. + # 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 index 4e51ec11d3..23db4d2398 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/obr.yaml @@ -1,8 +1,8 @@ -# Written by policyengine_uk/utils/import_energy_prices.py 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 are built from. +# 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). As published, so it includes the measures the OBR forecast includes; the generation step removes the electricity VAT change. + 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 diff --git a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml index 6f4f065941..875d827192 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/energy_prices/ons.yaml @@ -1,6 +1,6 @@ -# Written by policyengine_uk/utils/import_energy_prices.py from ONS MM23. +# 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 are built from. +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; diff --git a/policyengine_uk/scenarios/no_economic_assumptions.py b/policyengine_uk/scenarios/no_economic_assumptions.py index 68368dec24..0524debc2c 100644 --- a/policyengine_uk/scenarios/no_economic_assumptions.py +++ b/policyengine_uk/scenarios/no_economic_assumptions.py @@ -26,10 +26,10 @@ def remove_economic_assumptions(simulation: Simulation): if value_at_instant.instant_str >= cutoff: value_at_instant.value = 0 - # The energy price indices are built from price levels and the OBR's - # quarterly forecast rather than these growth series, so they are held at - # the year before the cutoff directly. An input, like the zeroed growth, - # so a later rebuild of the parameters keeps it. + # 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 252039f023..6db84d01f4 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -200,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/utils/import_energy_prices.py b/policyengine_uk/utils/import_energy_prices.py index e36517a45f..1c84327bb9 100644 --- a/policyengine_uk/utils/import_energy_prices.py +++ b/policyengine_uk/utils/import_energy_prices.py @@ -246,10 +246,10 @@ def render_ons_yaml(mm23: Mm23) -> str: released = _long_date(mm23.release) year, quarter = last_complete_quarter(mm23) lines = [ - "# Written by policyengine_uk/utils/import_energy_prices.py from ONS MM23.", + "# 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 are built from.", + "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;", @@ -318,16 +318,15 @@ def render_obr_yaml( raise ValueError("Table 1.19's forecast quarters have gaps.") first = f"{quarters[0][0]}Q{quarters[0][1]}" lines = [ - "# Written by policyengine_uk/utils/import_energy_prices.py 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 are " - "built from.", + "# 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}). As published, so it includes the " - "measures the OBR forecast includes; the generation step removes the " - "electricity VAT change.", + 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", @@ -362,8 +361,8 @@ def render_consumption_shares_yaml( ), } lines = [ - "# Written by policyengine_uk/utils/import_energy_prices.py from DESNZ", - "# Energy Trends. Rerun the importer on new tables; do not edit by hand.", + "# 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.", ] @@ -378,7 +377,7 @@ def render_consumption_shares_yaml( lines += [ f" q{quarter}:", f" description: Share of a year's domestic {name} consumption " - f"used in {QUARTER_MONTHS[quarter]}.", + f"that households use in {QUARTER_MONTHS[quarter]}.", " metadata:", " unit: /1", f" label: Domestic {name} consumption share, quarter {quarter}", From 3ddc4b1fb5179050168e0637828f76c5b5714ec6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 28 Oct 2026 20:25:58 -0400 Subject: [PATCH 6/6] Stop the energy price importer at a year without its CPI weight The importer checked the monthly prices for gaps but not the annual CPI weights. A blank weight (say CJXA for 2026) rendered, and generation then gave that year the year before's weight: FY2026 electricity came out at 207.293 instead of 207.392. Require a weight for every year from 2015 to the last year with prices, for each series, and test a missing weight in each series in 2020 and 2026. The committed inputs have every weight, so nothing they produce changes. Co-Authored-By: Claude Opus 5.5 --- .../gov/economic_assumptions/README.md | 6 +++--- .../tests/test_import_energy_prices.py | 17 +++++++++++++++++ policyengine_uk/utils/import_energy_prices.py | 12 ++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index dd590a0aed..c1b8ccabf8 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -452,9 +452,9 @@ the forecast levels, though not their growth. 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, if the - series come from different releases or if Table 1.19 has no quarter after - the ONS outturn. Then check + 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 diff --git a/policyengine_uk/tests/test_import_energy_prices.py b/policyengine_uk/tests/test_import_energy_prices.py index 3f39ee8630..e8ee13cd28 100644 --- a/policyengine_uk/tests/test_import_energy_prices.py +++ b/policyengine_uk/tests/test_import_energy_prices.py @@ -94,6 +94,23 @@ def test_a_missing_month_is_an_error(): 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"): diff --git a/policyengine_uk/utils/import_energy_prices.py b/policyengine_uk/utils/import_energy_prices.py index 1c84327bb9..da2b78c869 100644 --- a/policyengine_uk/utils/import_energy_prices.py +++ b/policyengine_uk/utils/import_energy_prices.py @@ -152,6 +152,18 @@ def read_mm23(text: str) -> Mm23: ] 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)