From fd13f1685b7f1583bae0a6ca16520a77fbd6003c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:13:20 -0400 Subject: [PATCH 1/7] Refresh the OBR receipts growth series from the EFO receipts tables import_obr_forecasts.py now rewrites yoy_growth.obr.stamp_duty_land_tax.* and business_rates.* from a new EFO's receipts tables, with the previous EFO's tables as the base for outturn growth. Tables are found by title and row label, years before the outturn year are kept, years after the OBR forecast follow average_earnings, and the rewrite is checked to change only those values and references. Run on the March 2026 tables, it reproduces the committed series exactly. Fixes #2341 Co-Authored-By: Claude Opus 5.5 --- changelog.d/obr-receipts-importer.added.md | 1 + .../gov/economic_assumptions/README.md | 33 + .../gov/economic_assumptions/yoy_growth.yaml | 25 +- .../tests/test_import_obr_forecasts.py | 782 +++++++++++++++++- policyengine_uk/utils/import_obr_forecasts.py | 618 +++++++++++++- 5 files changed, 1403 insertions(+), 56 deletions(-) create mode 100644 changelog.d/obr-receipts-importer.added.md diff --git a/changelog.d/obr-receipts-importer.added.md b/changelog.d/obr-receipts-importer.added.md new file mode 100644 index 0000000000..523018e9e5 --- /dev/null +++ b/changelog.d/obr-receipts-importer.added.md @@ -0,0 +1 @@ +- `import_obr_forecasts.py` now refreshes the OBR receipts growth series that project corporate SDLT and business rates (`yoy_growth.obr.stamp_duty_land_tax.*` and `.business_rates.*`) from a new EFO's receipts tables, with the previous EFO's tables (`--previous-receipts-file`) as the base for outturn growth, and refuses receipts tables from a different EFO. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index d59e6c8878..7bc8f2a1bf 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -20,6 +20,19 @@ generated from it by | `petrol_spending_litre_proxy` | Spending growth that preserves road-fuel litres | HMRC + OBR | | `diesel_spending_litre_proxy` | Same, for diesel | HMRC + OBR | | `finance_ni.domestic_rates` | Average Northern Ireland domestic rates bill (regional plus district poundage) | DoF rate poundages, LPS rates paid, NI draft Budget 2026-29 | +| `stamp_duty_land_tax.residential` | SDLT receipts from residential property | EFO receipts Table 3.7 | +| `stamp_duty_land_tax.non_residential` | SDLT receipts from non-residential property | EFO receipts Table 3.7 | +| `business_rates.great_britain` | Business rates receipts, England, Scotland and Wales (cash) | EFO receipts, current receipts (cash basis): Table 3.8 in March 2026 | +| `business_rates.northern_ireland` | Northern Ireland business rates receipts | EFO receipts Table 3.2 | + +The four receipts series project the corporate SDLT and business rates +statistics (`gov.hmrc.stamp_duty.statistics`, +`gov.hmrc.business_rates.statistics`) from their latest outturn. Each is the +ratio of consecutive published receipts levels, to six decimals: outturn +growth for the EFO's outturn year (its outturn over the year before's outturn +in an earlier EFO), the EFO's forecast years, and after them the +`average_earnings` path. Years before the outturn year are kept, so each +index keeps its base year. ## Time horizons @@ -118,6 +131,26 @@ threshold by the same rises, up to the last year of `yoy_growth.yaml`; pass the receipts tables with `--receipts-file` or `--receipts-url` so September CPI uses the OBR's September forecast. If you edit `yoy_growth.yaml` by hand instead, rerun it with `--gaps-only`. +6. Refresh the receipts series (`stamp_duty_land_tax.*`, + `business_rates.*`). The importer does this in the same run when it also + gets the receipts tables of the EFO whose outturn year is one earlier + (March 2025 for March 2026), with `--previous-receipts-file` or + `--previous-receipts-url`; their outturn is the base for the outturn + year's growth. It finds each line by table title and row label, so table + renumbering does not matter, and it refuses receipts tables from another + EFO. Then update the published levels in + `policyengine_uk/tests/test_corporate_receipts_statistics.py` + (`OBR_RECEIPTS_BN`), which pin the series to the OBR's tables. Pass + `--skip-receipts-growth` only to leave them on the previous EFO. + +For example, for March 2026: + +```bash +uv run python -m policyengine_uk.utils.import_obr_forecasts \ + --file economy_tables_march_2026.xlsx \ + --receipts-file receipts_tables_march_2026.xlsx \ + --previous-receipts-file receipts_tables_march_2025.xlsx +``` The EFO refresh does not touch `finance_ni.domestic_rates`; OBR does not publish a Northern Ireland domestic rates bill series (see below). diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 4492945ab4..665aab2e75 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -647,19 +647,22 @@ obr: href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ # Receipts used to project the statistics behind corporate incidence. - # Each is the OBR's growth in a receipts line: 2024 is outturn growth - # (2024-25 outturn in the March 2026 EFO over 2023-24 outturn in the March - # 2025 EFO), 2025-2030 the March 2026 EFO forecast, and 2031 onwards the - # average_earnings long-run path (the OBR forecasts receipts only to - # 2030-31). Values are ratios of the published levels, to six decimals. - # The 2024 value never enters a projection: indices start at 1.0 in a - # series' first year, so it only anchors the index at 2024, where the + # Each is the OBR's growth in a receipts line, the ratio of consecutive + # published levels to six decimals. The latest EFO supplies outturn growth + # for its outturn year (its outturn over the previous year's outturn in an + # earlier EFO) and growth for each forecast year. After the OBR's receipts + # forecast, each series follows the average_earnings long-run path. The + # comments in each series name the EFOs and tables behind its values. + # policyengine_uk/utils/import_obr_forecasts.py rewrites these series from + # a new EFO's receipts tables and keeps the years before its outturn year. + # The first value, 2024, never enters a projection: indices start at 1.0 + # in a series' first year, so it only anchors the index at 2024, where the # statistics with 2024-25 as their latest outturn are uprated from. stamp_duty_land_tax: residential: description: Growth in stamp duty land tax receipts from residential property (England and Northern Ireland). values: - # Outturn (2024-25 over 2023-24), Table 3.7 "SDLT from residential property" + # Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, receipts Table 3.7 "SDLT from residential property" 2024-01-01: 0.211202 # Forecast (2025-2030) from OBR EFO March 2026, receipts Table 3.7 2025-01-01: 0.1302 @@ -686,7 +689,7 @@ obr: non_residential: description: Growth in stamp duty land tax receipts from non-residential property (England and Northern Ireland). values: - # Outturn (2024-25 over 2023-24), Table 3.7 "SDLT from non-residential property" + # Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, receipts Table 3.7 "SDLT from non-residential property" 2024-01-01: 0.151067 # Forecast (2025-2030) from OBR EFO March 2026, receipts Table 3.7 2025-01-01: 0.048515 @@ -714,7 +717,7 @@ obr: great_britain: description: Growth in business rates receipts in England, Scotland and Wales (the OBR "Business rates" line, cash basis). values: - # Outturn (2024-25 over 2023-24), Table 3.8 (March 2025: Table 3.9) "Business rates" + # Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, receipts Table 3.8 (March 2025: Table 3.9) "Business rates" 2024-01-01: 0.093818 # Forecast (2025-2030) from OBR EFO March 2026, receipts Table 3.8 2025-01-01: 0.051739 @@ -741,7 +744,7 @@ obr: northern_ireland: description: Growth in Northern Ireland business rates receipts (the OBR "Northern Ireland business rates" line in other taxes). values: - # Outturn (2024-25 over 2023-24), Table 3.2 "Northern Ireland business rates" + # Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, receipts Table 3.2 "Northern Ireland business rates" 2024-01-01: 0.032513 # Forecast (2025-2030) from OBR EFO March 2026, receipts Table 3.2 2025-01-01: 0.032388 diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index 9e14ed94b9..34abcd633b 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -6,16 +6,27 @@ import pytest import yaml +from hypothesis import given, settings +from hypothesis import strategies as st from policyengine_uk.utils import import_obr_forecasts from policyengine_uk.utils.import_obr_forecasts import ( + RECEIPTS_SERIES_SPECS, STATUTORY_GAP_SPECS, ForecastGap, + average_earnings_path, + compute_receipts_growth, + find_receipts_line, format_gap, + format_growth, + get_yoy_growth_path, + infer_workbook_release, main, read_calendar_values, + read_workbook_sheets, render_forecast_gap_yaml, + render_receipts_growth_yaml, build_efo_href, compute_statutory_forecast_gaps, extract_annual_series_from_xlsx, @@ -365,22 +376,110 @@ def test_update_yoy_growth_yaml_keeps_existing_values_when_obr_has_blank_years( assert "2027-01-01: 0.0553" in content -def make_receipts_xlsx() -> bytes: - workbook_xml = """ - - - - - - -""" - rels_xml = """ - - - - -""" +def make_workbook(sheets: dict[str, bytes]) -> bytes: + """An XLSX with the given sheets, in order.""" + names = list(sheets) + workbook_xml = ( + '' + '' + "" + + "".join( + f'' + for i, name in enumerate(names, start=1) + ) + + "" + ) + rels_xml = ( + '' + '' + + "".join( + f'' + for i in range(1, len(names) + 1) + ) + + "" + ) + buffer = BytesIO() + with ZipFile(buffer, "w") as archive: + archive.writestr("xl/workbook.xml", workbook_xml) + archive.writestr("xl/_rels/workbook.xml.rels", rels_xml) + for i, name in enumerate(names, start=1): + archive.writestr(f"xl/worksheets/sheet{i}.xml", sheets[name]) + return buffer.getvalue() + + +def column_letter(index: int) -> str: + return chr(ord("A") + index - 1) + + +def receipts_table( + title: str, + first_year: int, + rows: list[tuple[str, tuple]], + first_column: int = 3, + outturn_years: int = 1, +) -> bytes: + """A receipts table laid out as the OBR's: title in B2, the "Outturn" + and "Forecast" markers in row 4 (each only where it has years), fiscal + years in row 5, then one row per line (label in column B). Levels may be + numbers or published cell text.""" + years = max(len(levels) for _, levels in rows) + columns = [column_letter(first_column + i) for i in range(years)] + sheet = { + 1: [make_inline_cell("A1", "Back to contents")], + 2: [make_inline_cell("B2", title)], + 3: [make_inline_cell(f"{columns[0]}3", "£ billion")], + 4: [ + *([make_inline_cell(f"{columns[0]}4", "Outturn")] if outturn_years else []), + *( + [make_inline_cell(f"{columns[outturn_years]}4", "Forecast")] + if outturn_years < years + else [] + ), + ], + 5: [ + make_inline_cell( + f"{column}5", f"{first_year + i}-{str(first_year + i + 1)[2:]}" + ) + for i, column in enumerate(columns) + ], + } + for row, (label, levels) in enumerate(rows, start=6): + sheet[row] = [make_inline_cell(f"B{row}", label)] + [ + make_number_cell(f"{column}{row}", level) + for column, level in zip(columns, levels) + ] + return make_sheet(sheet) + + +# Synthetic receipts, £bn, by property type and nation, from each EFO's +# first (outturn) year. Growth is checked against these by hand. +RECEIPTS_LEVELS = { + "residential": (10.0, 11.0, 12.1), + "non_residential": (4.0, 5.0, 4.5), + "great_britain": (30.0, 31.5, 33.075), + "northern_ireland": (0.4, 0.41, 0.4182), +} +PREVIOUS_RECEIPTS_LEVELS = { + "residential": (8.0, 9.9, 10.5), + "non_residential": (3.2, 3.0, 3.1), + "great_britain": (29.0, 30.1, 31.0), + "northern_ireland": (0.39, 0.395, 0.4), +} + + +def make_receipts_xlsx( + release: str | None = "March 2026", + first_year: int = 2024, + levels: dict[str, tuple] | None = None, + cash_table: str = "3.8", + northern_ireland_label: str = "Northern Ireland business rates", + outturn_years: int = 1, + sdlt_rows: list[tuple[str, tuple]] | None = None, +) -> bytes: + levels = levels or RECEIPTS_LEVELS sheet_318 = make_sheet({2: [make_inline_cell("B2", "Other table")]}) sheet_319 = make_sheet( { @@ -400,13 +499,89 @@ def make_receipts_xlsx() -> bytes: ], } ) - buffer = BytesIO() - with ZipFile(buffer, "w") as archive: - archive.writestr("xl/workbook.xml", workbook_xml) - archive.writestr("xl/_rels/workbook.xml.rels", rels_xml) - archive.writestr("xl/worksheets/sheet1.xml", sheet_318) - archive.writestr("xl/worksheets/sheet2.xml", sheet_319) - return buffer.getvalue() + sheets = {} + if release is not None: + sheets["Contents"] = make_sheet( + { + 2: [ + make_inline_cell( + "B2", + f"{release} Economic and fiscal outlook – detailed " + "forecast tables: receipts", + ) + ], + # The contents list names tables but holds none of their rows. + 6: [make_inline_cell("B6", "3.2 Other taxes")], + } + ) + sheets["3.2"] = receipts_table( + "3.2 Other taxes", + first_year, + [ + ( + "Northern Ireland domestic rates", + (0.5,) * len(levels["northern_ireland"]), + ), + (northern_ireland_label, levels["northern_ireland"]), + ("Other taxes1", (12.0,) * len(levels["northern_ireland"])), + ], + outturn_years=outturn_years, + ) + sheets["3.7"] = receipts_table( + "3.7 Property transactions taxes: Receipts by sector", + first_year, + sdlt_rows + if sdlt_rows is not None + else [ + ( + "Total SDLT", + tuple( + r + n + for r, n in zip(levels["residential"], levels["non_residential"]) + ), + ), + ("SDLT from residential property", levels["residential"]), + ("SDLT from non-residential property", levels["non_residential"]), + ], + outturn_years=outturn_years, + ) + # An accrued table with a "Business rates" row of its own, which the + # cash-basis series must not read. + sheets["3.5"] = receipts_table( + "3.5 Current receipts (on an accrued basis)", + first_year, + [("Business rates", (99.0,) * len(levels["great_britain"]))], + outturn_years=outturn_years, + ) + sheets[cash_table] = receipts_table( + f"{cash_table} Current receipts (on a cash basis)", + first_year, + [ + ("Stamp duty land tax", (14.0,) * len(levels["great_britain"])), + ("Business rates", levels["great_britain"]), + ("Council tax", (46.0,) * len(levels["great_britain"])), + ], + first_column=4, + outturn_years=outturn_years, + ) + sheets["3.18"] = sheet_318 + sheets["3.19"] = sheet_319 + return make_workbook(sheets) + + +def make_previous_receipts_xlsx(**overrides) -> bytes: + """The EFO before: outturn a year earlier, current receipts in Table 3.9 + and the Northern Ireland label in title case, as in March 2025.""" + return make_receipts_xlsx( + **{ + "release": "March 2025", + "first_year": 2023, + "levels": PREVIOUS_RECEIPTS_LEVELS, + "cash_table": "3.9", + "northern_ireland_label": "Northern Ireland Business Rates", + **overrides, + } + ) def test_september_cpi_is_keyed_to_the_september_before_the_uprating_year(): @@ -534,8 +709,7 @@ def test_small_gaps_are_written_in_fixed_point(value, text): "mortgage_interest", "rent", ] -YOY_GROWTH_FIXTURE = "obr:\n" + "".join( - f""" {key}: +FLAT_SERIES_FIXTURE = """ {key}: values: 2024-01-01: 0.0200 2025-01-01: 0.0000 @@ -546,7 +720,42 @@ def test_small_gaps_are_written_in_fixed_point(value, text): - title: Old href: https://example.com/old """ - for key in SERIES_KEYS +RECEIPTS_SERIES_FIXTURE = """ {key}: + description: {key} receipts growth. + values: + # Outturn (2023-24 over 2022-23), from an older EFO + 2023-01-01: 0.0500 + # Older forecast + 2024-01-01: 0.0100 + 2025-01-01: 0.0100 + 2031-01-01: 0.0200 + metadata: + unit: /1 + label: {key} receipts growth + reference: + - title: Old + href: https://example.com/old +""" +RECEIPTS_FIXTURE = ( + "\n # Receipts growth; this comment and the blank lines around it stay.\n" + " stamp_duty_land_tax:\n" + + RECEIPTS_SERIES_FIXTURE.format(key="residential") + + RECEIPTS_SERIES_FIXTURE.format(key="non_residential") + + " business_rates:\n" + + RECEIPTS_SERIES_FIXTURE.format(key="great_britain") + + RECEIPTS_SERIES_FIXTURE.format(key="northern_ireland") + + "\n" +) +YOY_GROWTH_FIXTURE = ( + "obr:\n" + + "".join(FLAT_SERIES_FIXTURE.format(key=key) for key in SERIES_KEYS[:2]) + + RECEIPTS_FIXTURE + + "".join(FLAT_SERIES_FIXTURE.format(key=key) for key in SERIES_KEYS[2:]) + + """ons: + population: + values: + 2024-01-01: 0.0100 +""" ) GAP_FIXTURE = """description: Gap. values: @@ -572,11 +781,15 @@ def write_tree(tmp_path): return yoy, gap_dir -def run_main(tmp_path, workbook, *extra): +def run_main( + tmp_path, workbook, *extra, receipts=None, previous_receipts=None, previous=True +): economy = tmp_path / "economy_march_2026.xlsx" economy.write_bytes(workbook) - receipts = tmp_path / "receipts.xlsx" - receipts.write_bytes(make_receipts_xlsx()) + receipts_path = tmp_path / "receipts.xlsx" + receipts_path.write_bytes(receipts or make_receipts_xlsx()) + previous_path = tmp_path / "previous_receipts.xlsx" + previous_path.write_bytes(previous_receipts or make_previous_receipts_xlsx()) return main( [ "--file", @@ -590,7 +803,8 @@ def run_main(tmp_path, workbook, *extra): "--release-year", "2026", "--receipts-file", - str(receipts), + str(receipts_path), + *(["--previous-receipts-file", str(previous_path)] if previous else []), *extra, ] ) @@ -634,7 +848,14 @@ def test_gaps_only_leaves_growth_alone(tmp_path): yoy, gap_dir = write_tree(tmp_path) growth = yoy.read_text() assert ( - run_main(tmp_path, make_test_xlsx(), "--yaml-path", str(yoy), "--gaps-only") + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + "--gaps-only", + previous=False, + ) == 0 ) @@ -721,3 +942,502 @@ def failing_replace(source, target): if path not in (unrestored, Path(calls[2])): assert path.read_text() == original, path assert list(tmp_path.rglob("*.staged")) == [] + + +# Receipts growth series: stamp_duty_land_tax and business_rates under obr. + +RECEIPTS_PATHS = {spec.path[-1]: spec.path for spec in RECEIPTS_SERIES_SPECS} + + +def _series(tree, path): + node = tree["obr"] + for key in path: + node = node[key] + return node + + +def _values(content, path): + return { + instant.year: value + for instant, value in _series(yaml.safe_load(content), path)["values"].items() + } + + +def _in_effect(values, year): + return values[max(y for y in values if y <= year)] + + +def render(content=YOY_GROWTH_FIXTURE, current=None, previous=None, **releases): + growth = compute_receipts_growth( + current or make_receipts_xlsx(), previous or make_previous_receipts_xlsx() + ) + return render_receipts_growth_yaml( + content, + growth, + releases.get("release", ("March", 2026)), + releases.get("previous_release", ("March", 2025)), + ) + + +def test_receipts_lines_are_found_by_title_and_label_not_table_number(): + current = read_workbook_sheets(make_receipts_xlsx()) + previous = read_workbook_sheets(make_previous_receipts_xlsx()) + tables = { + spec.path: ( + find_receipts_line(current, spec), + find_receipts_line(previous, spec), + ) + for spec in RECEIPTS_SERIES_SPECS + } + + great_britain, great_britain_before = tables[RECEIPTS_PATHS["great_britain"]] + # Current receipts on a cash basis move from 3.9 to 3.8; the accrued + # table's own "Business rates" row is ignored. + assert (great_britain.table, great_britain_before.table) == ("3.8", "3.9") + assert great_britain.levels == {2024: 30.0, 2025: 31.5, 2026: 33.075} + northern_ireland, northern_ireland_before = tables[ + RECEIPTS_PATHS["northern_ireland"] + ] + assert northern_ireland.label == "Northern Ireland business rates" + assert northern_ireland_before.label == "Northern Ireland Business Rates" + assert northern_ireland_before.levels[2023] == 0.39 + for line, line_before in tables.values(): + assert line.outturn_years == {2024} + assert line_before.outturn_years == {2023} + + +def test_receipts_growth_is_the_ratio_of_published_levels(): + growth = compute_receipts_growth( + make_receipts_xlsx(), make_previous_receipts_xlsx() + ) + # The first year is this EFO's outturn over the previous EFO's outturn, + # e.g. residential 10.0 / 8.0; later years are this EFO's own ratios. + assert {path[-1]: series.values for path, series in growth.items()} == { + "residential": {2024: 0.25, 2025: 0.1, 2026: 0.1}, + "non_residential": {2024: 0.25, 2025: 0.25, 2026: -0.1}, + "great_britain": {2024: 0.034483, 2025: 0.05, 2026: 0.05}, + "northern_ireland": {2024: 0.025641, 2025: 0.025, 2026: 0.02}, + } + + +def test_the_previous_efo_must_publish_outturn_for_the_base_year(): + # An EFO whose first outturn year is 2022-23 has 2023-24 as a forecast. + two_years_back = make_previous_receipts_xlsx(first_year=2022) + with pytest.raises(ValueError, match="no 2023-24 outturn"): + compute_receipts_growth(make_receipts_xlsx(), two_years_back) + # An EFO from the same outturn year has no 2023-24 at all. + with pytest.raises(ValueError, match="no 2023-24 outturn"): + compute_receipts_growth(make_receipts_xlsx(), make_receipts_xlsx()) + + +def test_the_first_year_must_be_outturn(): + no_outturn = make_receipts_xlsx(outturn_years=0) + with pytest.raises( + ValueError, + match="residential: 2024-25, the first year in Table 3.7, is not outturn", + ): + compute_receipts_growth(no_outturn, make_previous_receipts_xlsx()) + + +@pytest.mark.parametrize( + "sdlt_rows, message", + [ + ([("SDLT from non-residential property", (4.0, 5.0, 4.5))], "found none"), + ( + [ + ("SDLT from residential property", (10.0, 11.0, 12.1)), + ("SDLT from residential property", (10.0, 11.0, 12.1)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ], + "found sheet 3.7, sheet 3.7", + ), + ], +) +def test_each_receipts_line_must_appear_exactly_once(sdlt_rows, message): + with pytest.raises(ValueError, match=message): + compute_receipts_growth( + make_receipts_xlsx(sdlt_rows=sdlt_rows), make_previous_receipts_xlsx() + ) + + +def test_receipts_growth_rejects_a_missing_or_non_positive_level(): + missing = make_receipts_xlsx( + sdlt_rows=[ + ("SDLT from residential property", (10.0, 11.0)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ] + ) + with pytest.raises(ValueError, match="no 2026-27"): + compute_receipts_growth(missing, make_previous_receipts_xlsx()) + zero = make_receipts_xlsx( + sdlt_rows=[ + ("SDLT from residential property", (10.0, 0.0, 12.1)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ] + ) + with pytest.raises(ValueError, match="cannot grow from 2025-26"): + compute_receipts_growth(zero, make_previous_receipts_xlsx()) + + +def test_render_rewrites_values_and_references(): + rendered = render() + start = rendered.index(" residential:") + end = rendered.index(" non_residential:") + assert rendered[start:end] == ( + """ residential: + description: residential receipts growth. + values: + # Outturn (2023-24 over 2022-23), from an older EFO + 2023-01-01: 0.0500 + # Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, receipts Table 3.7 "SDLT from residential property" + 2024-01-01: 0.2500 + # Forecast (2025-2026) from OBR EFO March 2026, receipts Table 3.7 + 2025-01-01: 0.1000 + 2026-01-01: 0.1000 + # Beyond the OBR forecast: the average_earnings long-run path above + 2027-01-01: 0.0000 + 2031-01-01: 0.0200 + metadata: + unit: /1 + label: residential receipts growth + reference: + - title: OBR EFO March 2026 (detailed forecast tables, receipts, Table 3.7) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/ + - title: OBR EFO March 2025 (detailed forecast tables, receipts, Table 3.7; 2023-24 outturn) + href: https://obr.uk/efo/economic-and-fiscal-outlook-march-2025/ +""" + ) + assert ( + "# Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, " + 'receipts Table 3.8 (March 2025: Table 3.9) "Business rates"' + ) in rendered + assert "receipts, Table 3.9; 2023-24 outturn" in rendered + + +def test_render_changes_only_the_receipts_values_and_references(): + rendered = render() + before, after = yaml.safe_load(YOY_GROWTH_FIXTURE), yaml.safe_load(rendered) + for tree in (before, after): + for path in RECEIPTS_PATHS.values(): + del _series(tree, path)["values"] + del _series(tree, path)["metadata"]["reference"] + assert after == before + # Text outside the series' values and reference blocks is untouched. + assert rendered.startswith( + YOY_GROWTH_FIXTURE[: YOY_GROWTH_FIXTURE.index(" residential:")] + ) + tail = YOY_GROWTH_FIXTURE[YOY_GROWTH_FIXTURE.index("\n consumer_price_index:") :] + assert rendered.endswith(tail) + assert ( + "# Receipts growth; this comment and the blank lines around it stay." + in rendered + ) + + +def test_render_is_idempotent(): + once = render() + assert render(once) == once + + +def test_a_later_efo_keeps_the_years_before_its_outturn_year(): + """The next EFO's first year is 2025: 2024 keeps its value and comment, + so each index keeps its base year.""" + march_2026 = render() + march_2027 = render( + march_2026, + current=make_receipts_xlsx( + release="March 2027", + first_year=2025, + levels={key: (11.0, 12.1, 13.31) for key in RECEIPTS_LEVELS}, + ), + previous=make_receipts_xlsx(), + release=("March", 2027), + previous_release=("March", 2026), + ) + path = RECEIPTS_PATHS["residential"] + assert _values(march_2027, path) == { + 2023: 0.05, + 2024: 0.25, + # 2025-26 outturn (11.0) over 2024-25 outturn in March 2026 (10.0). + 2025: 0.1, + 2026: 0.1, + 2027: 0.1, + 2028: 0.0, + 2031: 0.02, + } + assert ( + "# Outturn (2024-25 over 2023-24) from OBR EFO March 2026 over March 2025, " + 'receipts Table 3.7 "SDLT from residential property"\n' + " 2024-01-01: 0.2500\n" + " # Outturn (2025-26 over 2024-25) from OBR EFO March 2027 over March 2026" + ) in march_2027 + assert "March 2025 (detailed" not in march_2027 + + +def test_later_outturn_years_get_their_own_comment(): + rendered = render(current=make_receipts_xlsx(outturn_years=2)) + assert ( + "# Outturn (2025) from OBR EFO March 2026, receipts Table 3.7\n" + " 2025-01-01: 0.1000\n" + " # Forecast (2026) from OBR EFO March 2026, receipts Table 3.7\n" + ) in rendered + + +def test_average_earnings_path_lists_each_change_after_the_forecast(): + content = """obr: + average_earnings: + values: + 2029-01-01: 0.022 + 2030-01-01: 0.024 + 2031-01-01: 0.0332 + 2032-01-01: 0.0371 + 2036-01-01: 0.0383 + 2037-01-01: 0.0383 + 2073-01-01: 0.0383 +""" + assert average_earnings_path(content, 2030) == { + 2031: 0.0332, + 2032: 0.0371, + 2036: 0.0383, + } + # Starting between entries takes the value then in effect. + assert average_earnings_path(content, 2033) == {2034: 0.0371, 2036: 0.0383} + with pytest.raises(ValueError, match="no value in effect in 2028"): + average_earnings_path(content, 2027) + + +@pytest.mark.parametrize( + "value, text", + [ + (0.0383, "0.0383"), + (0.038, "0.0380"), + (0.1302, "0.1302"), + (0.06083, "0.06083"), + (0.211202, "0.211202"), + (-0.1, "-0.1000"), + (1e-05, "0.00001"), + (-0.0, "0.0000"), + ], +) +def test_growth_is_written_in_fixed_point(value, text): + assert format_growth(value) == text + + +@settings(max_examples=300, deadline=None, derandomize=True) +@given(st.floats(min_value=-1, max_value=100, allow_nan=False)) +def test_written_growth_reads_back_exactly(value): + text = format_growth(value) + assert "e" not in text.lower() + assert len(text.partition(".")[2]) >= 4 + assert yaml.safe_load(f"x: {text}")["x"] == value + + +def test_read_calendar_values_includes_nested_series(tmp_path): + path = tmp_path / "yoy_growth.yaml" + path.write_text(YOY_GROWTH_FIXTURE) + values = read_calendar_values(path) + assert values["stamp_duty_land_tax.residential"] == { + 2023: 0.05, + 2024: 0.01, + 2025: 0.01, + 2031: 0.02, + } + assert set(values) == set(SERIES_KEYS) | { + ".".join(path) for path in RECEIPTS_PATHS.values() + } + + +LEVELS = st.floats(min_value=0.01, max_value=1_000, allow_nan=False) + + +@settings(max_examples=40, deadline=None, derandomize=True) +@given( + years=st.integers(2, 8), + data=st.data(), +) +def test_rendered_growth_compounds_the_published_levels(years, data): + """For any published levels: every year from the outturn year is the + six-decimal ratio of consecutive levels, every later year equals + average_earnings, years before the outturn year are kept, and a second + run changes nothing.""" + levels = { + key: tuple(data.draw(st.lists(LEVELS, min_size=years, max_size=years))) + for key in RECEIPTS_LEVELS + } + base = {key: (data.draw(LEVELS),) for key in RECEIPTS_LEVELS} + current = make_receipts_xlsx(levels=levels) + previous = make_previous_receipts_xlsx(levels=base) + rendered = render(current=current, previous=previous) + + average_earnings = _values(rendered, ("average_earnings",)) + last = 2024 + years - 1 + for key, path in RECEIPTS_PATHS.items(): + values = _values(rendered, path) + published = { + 2023: base[key][0], + **dict(zip(range(2024, last + 1), levels[key])), + } + for year in range(2024, last + 1): + expected = round(published[year] / published[year - 1] - 1, 6) + assert values[year] == expected + for year in range(last + 1, 2050): + assert _in_effect(values, year) == _in_effect(average_earnings, year) + assert values[2023] == 0.05 + assert render(rendered, current=current, previous=previous) == rendered + + +# Published OBR receipts levels as the workbooks hold them (£bn): the March +# 2026 EFO from 2024-25 and the March 2025 EFO from 2023-24. +MARCH_2026_RECEIPTS = { + "residential": ("10.379999999999999", "11.731471524432562", "13.428906667032589", "14.984943814530874", "16.673948133476184", "17.964513468126285", "19.057301113969185"), + "non_residential": ("3.5049999999999999", "3.6750451743530199", "4.72127759316189", "5.3288288104799904", "5.9009139798817092", "6.5330695355310402", "7.1046581772762902"), + "great_britain": ("32.376814327646052", "34.051963932489258", "37.413586572144581", "38.240930947824609", "39.111850954748434", "42.419067764546902", "42.463193622519263"), + "northern_ireland": ("0.38233350730688931", "0.39471664406441903", "0.40263364127921164", "0.41045270712298504", "0.41810894612429189", "0.42590403232705243", "0.43449009714292341"), +} # fmt: skip +MARCH_2025_RECEIPTS = { + "residential": ("8.57", "10.067621905140079", "11.260655778417849", "13.448356441943533", "15.629462727222798", "17.837320625934193", "19.309303893346414"), + "non_residential": ("3.0449999999999999", "3.51897590165244", "2.8597545561212101", "3.7329253327014698", "4.2294622915006306", "4.6980571035732099", "5.2115072362568595"), + "great_britain": ("29.599814327646055", "32.080747417794186", "34.009749097370083", "37.622908483210999", "37.878733699384433", "38.522006059307493", "39.56809170547703"), + "northern_ireland": ("0.37029404557706441", "0.38436854999535813", "0.39453673994468591", "0.40107161940999719", "0.40924102844267779", "0.41720986951771938", "0.42505803014305893"), +} # fmt: skip + + +def test_march_2026_tables_reproduce_the_committed_series(): + """yoy_growth.yaml holds exactly what the importer writes from the March + 2026 receipts tables (Tables 3.7, 3.8 and 3.2) and the March 2025 ones + (3.7, 3.9 and 3.2): rerunning it on the committed file changes nothing.""" + committed = get_yoy_growth_path().read_text() + rendered = render( + committed, + current=make_receipts_xlsx(levels=MARCH_2026_RECEIPTS), + previous=make_previous_receipts_xlsx(levels=MARCH_2025_RECEIPTS), + ) + assert rendered == committed + + +def test_workbook_release_comes_from_the_contents_sheet(): + assert infer_workbook_release(make_receipts_xlsx()) == ("March", 2026) + assert infer_workbook_release(make_receipts_xlsx(release=None)) is None + assert infer_workbook_release(make_test_xlsx()) is None + + +def test_main_updates_receipts_growth_from_the_new_average_earnings(tmp_path): + yoy, _ = write_tree(tmp_path) + assert run_main(tmp_path, make_test_xlsx(), "--yaml-path", str(yoy)) == 0 + + content = yoy.read_text() + assert _values(content, RECEIPTS_PATHS["great_britain"]) == { + 2023: 0.05, + 2024: 0.034483, + 2025: 0.05, + 2026: 0.05, + # 2026 average earnings as this run writes it, then the long run. + 2027: 0.0333, + 2031: 0.02, + } + assert "2026-01-01: 0.0333" in content + + +def test_main_requires_the_previous_receipts_tables(tmp_path): + yoy, gap_dir = write_tree(tmp_path) + before = {path: path.read_text() for path in [yoy, *gap_dir.iterdir()]} + with pytest.raises(ValueError, match="--skip-receipts-growth"): + run_main(tmp_path, make_test_xlsx(), "--yaml-path", str(yoy), previous=False) + assert {path: path.read_text() for path in before} == before + + assert ( + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + "--skip-receipts-growth", + previous=False, + ) + == 0 + ) + content = yoy.read_text() + assert "2026-01-01: 0.0248" in content + for path in RECEIPTS_PATHS.values(): + assert _values(content, path) == _values(YOY_GROWTH_FIXTURE, path) + + +def test_main_dry_run_without_previous_receipts_only_warns(tmp_path, capsys): + yoy, _ = write_tree(tmp_path) + assert ( + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + "--dry-run", + previous=False, + ) + == 0 + ) + assert "Warning: Pass the receipts tables" in capsys.readouterr().out + assert yoy.read_text() == YOY_GROWTH_FIXTURE + + +@pytest.mark.parametrize( + "receipts, previous_receipts, message", + [ + ( + make_receipts_xlsx(release="November 2025"), + None, + "receipts tables are from November 2025 but the economy tables are " + "from March 2026", + ), + ( + None, + make_previous_receipts_xlsx(release="March 2026"), + r"previous receipts tables \(March 2026\) must come from an EFO " + "before March 2026", + ), + ( + None, + make_previous_receipts_xlsx(release=None), + "Could not tell which EFO the previous receipts tables are from", + ), + ], +) +def test_main_refuses_tables_from_the_wrong_efo( + tmp_path, receipts, previous_receipts, message +): + yoy, gap_dir = write_tree(tmp_path) + before = {path: path.read_text() for path in [yoy, *gap_dir.iterdir()]} + with pytest.raises(ValueError, match=message): + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + receipts=receipts, + previous_receipts=previous_receipts, + ) + assert {path: path.read_text() for path in before} == before + + +def test_gaps_only_refuses_the_previous_receipts_tables(tmp_path): + yoy, _ = write_tree(tmp_path) + with pytest.raises(ValueError, match="--gaps-only leaves yoy_growth.yaml alone"): + run_main(tmp_path, make_test_xlsx(), "--yaml-path", str(yoy), "--gaps-only") + + +def test_main_writes_receipts_growth_to_the_output_path(tmp_path): + yoy, _ = write_tree(tmp_path) + output = tmp_path / "out.yaml" + assert ( + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + "--output", + str(output), + "--skip-statutory-gaps", + ) + == 0 + ) + assert yoy.read_text() == YOY_GROWTH_FIXTURE + assert _values(output.read_text(), RECEIPTS_PATHS["residential"])[2024] == 0.25 diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index cba8bce113..716da9a560 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -1,10 +1,13 @@ from __future__ import annotations import argparse +import copy import os import re import shutil from dataclasses import dataclass +from datetime import date +from decimal import Decimal from io import BytesIO from pathlib import Path from urllib.request import Request, urlopen @@ -18,10 +21,21 @@ "pkgrel": "http://schemas.openxmlformats.org/package/2006/relationships", } YEAR_RE = re.compile(r"^\d{4}$") -MONTH_RE = re.compile( - r"(january|february|march|april|may|june|july|august|september|october|november|december)[-_ ]+(\d{4})", - re.IGNORECASE, +MONTHS = ( + "january", + "february", + "march", + "april", + "may", + "june", + "july", + "august", + "september", + "october", + "november", + "december", ) +MONTH_RE = re.compile(rf"({'|'.join(MONTHS)})[-_ ]+(\d{{4}})", re.IGNORECASE) @dataclass(frozen=True) @@ -451,16 +465,27 @@ def compute_statutory_forecast_gaps( def read_calendar_values(yaml_path: Path) -> dict[str, dict[int, float]]: - """Calendar-year growth by year for each series in a yoy_growth.yaml.""" - obr = yaml.safe_load(yaml_path.read_text())["obr"] - return { - key: { - int(str(instant)[:4]): float(value) - for instant, value in series["values"].items() - } - for key, series in obr.items() - if isinstance(series, dict) and "values" in series - } + """Calendar-year growth by year for each series in a yoy_growth.yaml. + + Nested series are keyed by their path under ``obr`` joined with dots, + such as ``stamp_duty_land_tax.residential``. + """ + return _calendar_values(yaml.safe_load(yaml_path.read_text())["obr"]) + + +def _calendar_values(node: dict, prefix: str = "") -> dict[str, dict[int, float]]: + result = {} + for key, child in node.items(): + if not isinstance(child, dict) or key == "metadata": + continue + if "values" in child: + result[prefix + key] = { + int(str(instant)[:4]): float(value) + for instant, value in child["values"].items() + } + else: + result.update(_calendar_values(child, f"{prefix}{key}.")) + return result def format_gap(value: float) -> str: @@ -559,6 +584,486 @@ def update_forecast_gap_yaml( ) +TABLE_TITLE_RE = re.compile(r"^(\d+\.\d+)\s+(.+)$") +FOOTNOTE_RE = re.compile(r"(?<=[a-z])\d+$") +VALUE_LINE_RE = re.compile(r"^\s*(\d{4})-\d{2}-\d{2}:") + + +@dataclass(frozen=True) +class ReceiptsSeriesSpec: + """A line of the OBR receipts tables, grown by a yoy_growth.yaml series. + + ``path`` is the series' place under ``obr``. The table is found by its + title, not its number, because numbers move between EFOs: current + receipts on a cash basis are Table 3.8 in March 2026 and 3.9 in March + 2025. Titles and row labels are compared lowercased, without footnote + markers. + """ + + path: tuple[str, ...] + title: str + row: str + + +RECEIPTS_SERIES_SPECS = ( + ReceiptsSeriesSpec( + path=("stamp_duty_land_tax", "residential"), + title="property transactions taxes: receipts by sector", + row="sdlt from residential property", + ), + ReceiptsSeriesSpec( + path=("stamp_duty_land_tax", "non_residential"), + title="property transactions taxes: receipts by sector", + row="sdlt from non-residential property", + ), + ReceiptsSeriesSpec( + path=("business_rates", "great_britain"), + title="current receipts (on a cash basis)", + row="business rates", + ), + ReceiptsSeriesSpec( + path=("business_rates", "northern_ireland"), + title="other taxes", + row="northern ireland business rates", + ), +) + + +@dataclass(frozen=True) +class ReceiptsLine: + """A receipts line as one EFO publishes it: £bn by the calendar year in + which each fiscal year starts, and which of those years are outturn.""" + + table: str + label: str + levels: dict[int, float] + outturn_years: frozenset[int] + + +@dataclass(frozen=True) +class ReceiptsGrowth: + """Growth in a receipts line, by year, rounded to six decimals. + + The first year is outturn growth: this EFO's first (outturn) year over + the year before in the previous EFO, where it is outturn. Each later + year is the ratio of this EFO's consecutive levels. + """ + + values: dict[int, float] + line: ReceiptsLine + previous_line: ReceiptsLine + + +def fiscal_year_label(year: int) -> str: + return f"{year}-{str(year + 1)[2:]}" + + +def column_index(column: str) -> int: + index = 0 + for letter in column: + index = index * 26 + ord(letter.upper()) - ord("A") + 1 + return index + + +def receipts_label(value: str | None) -> str: + return FOOTNOTE_RE.sub("", normalise_label(value)) + + +def read_workbook_sheets(xlsx_bytes: bytes) -> dict[str, list[dict[str, str | None]]]: + with ZipFile(BytesIO(xlsx_bytes)) as archive: + sheet_names = list(_sheet_paths(archive)) + return {sheet: read_sheet_rows(xlsx_bytes, sheet) for sheet in sheet_names} + + +def table_title(rows: list[dict[str, str | None]]) -> tuple[str, str] | None: + """The number and normalised title of a table, e.g. ("3.8", "current + receipts (on a cash basis)"), from the first title cell in its top rows.""" + for row in rows[:3]: + for column in sorted(row, key=column_index): + match = TABLE_TITLE_RE.match(" ".join(str(row[column] or "").split())) + if match: + return match.group(1), receipts_label(match.group(2)) + return None + + +def _fiscal_year_columns(row: dict[str, str | None]) -> dict[str, int]: + columns = {} + for column, value in row.items(): + match = FISCAL_YEAR_RE.match(str(value or "").strip()) + if match and int(match.group(2)) == (int(match.group(1)) + 1) % 100: + columns[column] = int(match.group(1)) + return columns + + +def _outturn_years( + years: dict[str, int], markers: dict[str, int], where: str +) -> frozenset[int]: + """Years from the "Outturn" marker's column up to the "Forecast" one. + + A table without an "Outturn" marker has no outturn years, and one + without a "Forecast" marker no forecast years. + """ + if not markers: + raise ValueError(f"{where} does not mark its outturn and forecast years") + if "outturn" not in markers: + return frozenset() + end = markers.get("forecast", float("inf")) + return frozenset( + year + for column, year in years.items() + if markers["outturn"] <= column_index(column) < end + ) + + +def find_receipts_line( + sheets: dict[str, list[dict[str, str | None]]], spec: ReceiptsSeriesSpec +) -> ReceiptsLine: + """The one row labelled ``spec.row`` in the table titled ``spec.title``.""" + matches = [] + for sheet, rows in sheets.items(): + title = table_title(rows) + if title is None or title[1] != spec.title: + continue + years: dict[str, int] = {} + markers: dict[str, int] = {} + for row in rows: + row_years = _fiscal_year_columns(row) + if row_years: + years = row_years + continue + row_markers = { + normalise_label(value): column_index(column) + for column, value in row.items() + if normalise_label(value) in ("outturn", "forecast") + } + if row_markers: + markers = row_markers + continue + if not years: + continue + first_year_column = min(column_index(column) for column in years) + labels = [ + value + for column, value in sorted( + row.items(), key=lambda item: column_index(item[0]) + ) + if column_index(column) < first_year_column and normalise_label(value) + ] + if labels and receipts_label(labels[0]) == spec.row: + matches.append((title[0], sheet, labels[0], row, years, markers)) + + name = ".".join(spec.path) + if len(matches) != 1: + found = ", ".join(f"sheet {match[1]}" for match in matches) or "none" + raise ValueError( + f'Expected one "{spec.row}" row in the "{spec.title}" table for ' + f"{name}; found {found}" + ) + table, sheet, label, row, years, markers = matches[0] + where = f"Table {table} (sheet {sheet})" + levels = {} + for column, year in years.items(): + if row.get(column) in (None, ""): + raise ValueError(f"{where} has no {fiscal_year_label(year)} {name} value") + levels[year] = float(row[column]) + ordered = sorted(levels) + if ordered != list(range(ordered[0], ordered[-1] + 1)): + raise ValueError(f"{where} skips a year: {ordered}") + return ReceiptsLine( + table=table, + label=" ".join(str(label).split()).rstrip("0123456789"), + levels=levels, + outturn_years=_outturn_years(years, markers, where), + ) + + +def compute_receipts_growth( + xlsx_bytes: bytes, previous_xlsx_bytes: bytes +) -> dict[tuple[str, ...], ReceiptsGrowth]: + """Growth in each receipts line, from an EFO's receipts tables and an + earlier EFO's, whose outturn is the base for the first year's growth. + + The first year must be outturn in this EFO, and the year before it + outturn in the earlier one (March 2025 for March 2026). + """ + sheets = read_workbook_sheets(xlsx_bytes) + previous_sheets = read_workbook_sheets(previous_xlsx_bytes) + result = {} + for spec in RECEIPTS_SERIES_SPECS: + name = ".".join(spec.path) + line = find_receipts_line(sheets, spec) + previous = find_receipts_line(previous_sheets, spec) + first_year = min(line.levels) + base_year = first_year - 1 + if first_year not in line.outturn_years: + raise ValueError( + f"{name}: {fiscal_year_label(first_year)}, the first year in " + f"Table {line.table}, is not outturn" + ) + if base_year not in previous.outturn_years: + raise ValueError( + f"{name}: the previous receipts tables have no " + f"{fiscal_year_label(base_year)} outturn; pass the EFO whose " + f"outturn year is {fiscal_year_label(base_year)}" + ) + levels = {base_year: previous.levels[base_year], **line.levels} + values = {} + for year in sorted(line.levels): + if levels[year - 1] <= 0: + raise ValueError( + f"{name}: cannot grow from {fiscal_year_label(year - 1)} " + f"receipts of {levels[year - 1]}" + ) + values[year] = round(levels[year] / levels[year - 1] - 1, 6) + 0.0 + result[spec.path] = ReceiptsGrowth(values, line, previous) + return result + + +def format_growth(value: float) -> str: + """Fixed-point text with at least four decimals that reads back as the + same float: 0.038 is written 0.0380, 1e-05 as 0.00001.""" + text = format(Decimal(repr(value + 0.0)), "f") + whole, _, decimals = text.partition(".") + return f"{whole}.{decimals.ljust(4, '0')}" + + +def average_earnings_path(content: str, after_year: int) -> dict[int, float]: + """``average_earnings`` from the year after ``after_year``: its value in + effect then and each later change, so a series given these values equals + average_earnings in every later year.""" + values = yaml.safe_load(content)["obr"]["average_earnings"]["values"] + entries = sorted((int(str(instant)[:4]), float(v)) for instant, v in values.items()) + start = after_year + 1 + in_effect = [value for year, value in entries if year <= start] + if not in_effect: + raise ValueError(f"average_earnings has no value in effect in {start}") + path = {start: in_effect[-1]} + for year, value in entries: + if year > start and value != path[max(path)]: + path[year] = value + return path + + +def _indent(line: str) -> int: + return len(line) - len(line.lstrip(" ")) + + +def _yaml_block( + lines: list[str], key: str, indent: int, start: int, end: int +) -> tuple[int, int]: + """Lines [first, last) of the mapping entry ``key`` at ``indent`` within + lines[start:end], from its key through its last non-blank child line.""" + pattern = re.compile(rf"^ {{{indent}}}{re.escape(key)}:(\s|$)") + for first in range(start, end): + if not pattern.match(lines[first]): + continue + last = first + 1 + for index in range(first + 1, end): + if not lines[index].strip(): + continue + if _indent(lines[index]) <= indent: + break + last = index + 1 + return first, last + raise ValueError(f"Could not find {key} at indent {indent}") + + +def _series_block(lines: list[str], path: tuple[str, ...]) -> tuple[int, int]: + start, end = 0, len(lines) + for depth, key in enumerate(path): + start, end = _yaml_block(lines, key, 2 * depth, start, end) + return start, end + + +def render_receipts_values( + old_lines: list[str], + indent: int, + growth: ReceiptsGrowth, + tail: dict[int, float], + release: tuple[str, int], + previous_release: tuple[str, int], +) -> list[str]: + """A series' values entries: earlier years as they were, then this + EFO's growth, then the average_earnings path.""" + first_year = min(growth.values) + kept: list[str] = [] + pending: list[str] = [] + for line in old_lines: + match = VALUE_LINE_RE.match(line) + if match is None: + if line.strip() and not line.strip().startswith("#"): + raise ValueError(f"Unexpected line in a values block: {line!r}") + pending.append(line) + continue + if int(match.group(1)) < first_year: + kept += pending + [line] + pending = [] + + month, year = release + previous_month, previous_year = previous_release + published, previous = growth.line, growth.previous_line + table = f"Table {published.table}" + if previous.table != published.table: + table += f" ({previous_month} {previous_year}: Table {previous.table})" + pad = " " * indent + lines = kept + [ + f"{pad}# Outturn ({fiscal_year_label(first_year)} over " + f"{fiscal_year_label(first_year - 1)}) from OBR EFO {month} {year} over " + f'{previous_month} {previous_year}, receipts {table} "{published.label}"', + f"{pad}{first_year}-01-01: {format_growth(growth.values[first_year])}", + ] + later = sorted(y for y in growth.values if y > first_year) + for kind in ("Outturn", "Forecast"): + years = [ + y for y in later if (y in published.outturn_years) == (kind == "Outturn") + ] + if not years: + continue + span = f"{years[0]}" if len(years) == 1 else f"{years[0]}-{years[-1]}" + lines.append( + f"{pad}# {kind} ({span}) from OBR EFO {month} {year}, receipts " + f"Table {published.table}" + ) + lines += [f"{pad}{y}-01-01: {format_growth(growth.values[y])}" for y in years] + lines.append( + f"{pad}# Beyond the OBR forecast: the average_earnings long-run path above" + ) + lines += [f"{pad}{y}-01-01: {format_growth(v)}" for y, v in sorted(tail.items())] + return lines + + +def receipts_references( + growth: ReceiptsGrowth, + release: tuple[str, int], + previous_release: tuple[str, int], +) -> list[dict[str, str]]: + month, year = release + previous_month, previous_year = previous_release + base_year = min(growth.values) - 1 + return [ + { + "title": f"OBR EFO {month} {year} (detailed forecast tables, receipts, " + f"Table {growth.line.table})", + "href": build_efo_href(month, year), + }, + { + "title": f"OBR EFO {previous_month} {previous_year} (detailed forecast " + f"tables, receipts, Table {growth.previous_line.table}; " + f"{fiscal_year_label(base_year)} outturn)", + "href": build_efo_href(previous_month, previous_year), + }, + ] + + +def render_receipts_growth_yaml( + content: str, + growth: dict[tuple[str, ...], ReceiptsGrowth], + release: tuple[str, int], + previous_release: tuple[str, int], +) -> str: + """Rewrite each receipts series' values and references in a + yoy_growth.yaml. + + Years before this EFO's first year are kept, so a series' index keeps + its base year. Years after the OBR's receipts forecast follow + average_earnings as ``content`` holds it. The result is checked to + differ from ``content`` only in those values and references. + """ + before = yaml.safe_load(content) + expected = copy.deepcopy(before) + lines = content.split("\n") + for path, series in growth.items(): + tail = average_earnings_path(content, max(series.values)) + references = receipts_references(series, release, previous_release) + + start, end = _series_block(lines, ("obr", *path)) + indent = 2 * len(path) + 2 + values_start, values_end = _yaml_block(lines, "values", indent, start, end) + old_values = lines[values_start + 1 : values_end] + entry_indent = next( + (_indent(line) for line in old_values if VALUE_LINE_RE.match(line)), + indent + 2, + ) + new_values = render_receipts_values( + old_values, entry_indent, series, tail, release, previous_release + ) + lines[values_start + 1 : values_end] = new_values + + start, end = _series_block(lines, ("obr", *path)) + metadata = _yaml_block(lines, "metadata", indent, start, end) + ref_start, ref_end = _yaml_block( + lines, "reference", indent + 2, metadata[0], metadata[1] + ) + pad = " " * (indent + 2) + lines[ref_start:ref_end] = [f"{pad}reference:"] + [ + text + for reference in references + for text in ( + f"{pad} - title: {reference['title']}", + f"{pad} href: {reference['href']}", + ) + ] + + node = expected["obr"] + for key in path: + node = node[key] + kept = { + instant: value + for instant, value in node["values"].items() + if int(str(instant)[:4]) < min(series.values) + } + node["values"] = { + **kept, + **{ + date(year, 1, 1): value + for year, value in {**series.values, **tail}.items() + }, + } + node["metadata"]["reference"] = references + + rendered = "\n".join(lines) + if yaml.safe_load(rendered) != expected: + raise ValueError( + "Rewriting the receipts growth series changed more than their " + "values and references" + ) + return rendered + + +def infer_workbook_release(xlsx_bytes: bytes) -> tuple[str, int] | None: + """The release a detailed forecast tables workbook names on its contents + sheet, e.g. "March 2026 Economic and fiscal outlook - detailed forecast + tables", or None.""" + with ZipFile(BytesIO(xlsx_bytes)) as archive: + sheet_names = list(_sheet_paths(archive)) + contents = next((s for s in sheet_names if s.lower() == "contents"), None) + if contents is None: + return None + for row in read_sheet_rows(xlsx_bytes, contents)[:5]: + for value in row.values(): + text = str(value or "") + match = MONTH_RE.search(text) + if match and "outlook" in text.lower(): + return match.group(1).capitalize(), int(match.group(2)) + return None + + +def resolve_release(xlsx_bytes: bytes, source_name: str) -> tuple[str, int] | None: + """A workbook's release from its contents sheet, else from its name.""" + release = infer_workbook_release(xlsx_bytes) + if release is not None: + return release + try: + return infer_release(source_name) + except ValueError: + return None + + +def release_order(release: tuple[str, int]) -> tuple[int, int]: + month, year = release + return year, MONTHS.index(month.lower()) + + def infer_release(source_name: str) -> tuple[str, int]: match = MONTH_RE.search(source_name) if not match: @@ -710,6 +1215,16 @@ def print_summary( print(f"- {spec.key}: {', '.join(window)}") +def print_receipts_summary(growth: dict[tuple[str, ...], ReceiptsGrowth]) -> None: + print(f"Updating {len(growth)} OBR receipts growth series") + for path, series in growth.items(): + window = ", ".join( + f"{year}: {format_growth(value)}" + for year, value in sorted(series.values.items()) + ) + print(f"- {'.'.join(path)} (Table {series.line.table}): {window}") + + def build_arg_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="Import OBR economy forecast tables into yoy_growth.yaml.", @@ -756,6 +1271,25 @@ def build_arg_parser() -> argparse.ArgumentParser: ), ) parser.add_argument("--receipts-file", help="Local receipts XLSX file path") + parser.add_argument( + "--previous-receipts-url", + help=( + "Receipts detailed forecast tables (XLSX) from the EFO whose outturn " + "year is the year before this one's (March 2025 for March 2026), " + "the base for the receipts growth series' first year" + ), + ) + parser.add_argument( + "--previous-receipts-file", help="Local previous receipts XLSX file path" + ) + parser.add_argument( + "--skip-receipts-growth", + action="store_true", + help=( + "Do not update the receipts growth series (stamp_duty_land_tax, " + "business_rates) that project corporate SDLT and business rates" + ), + ) parser.add_argument( "--gaps-only", action="store_true", @@ -867,11 +1401,62 @@ def main(argv: list[str] | None = None) -> int: if not args.dry_run: raise ValueError(message) print(f"Warning: {message}") + has_previous_receipts = bool( + args.previous_receipts_url or args.previous_receipts_file + ) + if args.gaps_only and has_previous_receipts: + raise ValueError( + "--gaps-only leaves yoy_growth.yaml alone, so it cannot update the " + "receipts growth series from --previous-receipts-file or " + "--previous-receipts-url" + ) + write_receipts = not args.gaps_only and not args.skip_receipts_growth + if write_receipts and not ( + has_previous_receipts and (args.receipts_url or args.receipts_file) + ): + message = ( + "Pass the receipts tables (--receipts-file or --receipts-url) and the " + "previous EFO's (--previous-receipts-file or --previous-receipts-url) " + "so the receipts growth series move to this EFO, or " + "--skip-receipts-growth" + ) + if not args.dry_run: + raise ValueError(message) + print(f"Warning: {message}") + write_receipts = False print_summary(series_values, forecast_start_year, args.forecast_years) receipts_bytes = None if args.receipts_url or args.receipts_file: - _, receipts_bytes = load_source_bytes(args.receipts_url, args.receipts_file) + receipts_name, receipts_bytes = load_source_bytes( + args.receipts_url, args.receipts_file + ) + receipts_release = resolve_release(receipts_bytes, receipts_name) + if receipts_release not in (None, (month, year)): + raise ValueError( + "The receipts tables are from " + f"{receipts_release[0]} {receipts_release[1]} but the economy " + f"tables are from {month} {year}" + ) + receipts_growth = None + if write_receipts: + previous_name, previous_bytes = load_source_bytes( + args.previous_receipts_url, args.previous_receipts_file + ) + previous_release = resolve_release(previous_bytes, previous_name) + if previous_release is None: + raise ValueError( + "Could not tell which EFO the previous receipts tables are from; " + "name the file with its release, e.g. receipts_march_2025.xlsx" + ) + if release_order(previous_release) >= release_order((month, year)): + raise ValueError( + "The previous receipts tables " + f"({previous_release[0]} {previous_release[1]}) must come from " + f"an EFO before {month} {year}" + ) + receipts_growth = compute_receipts_growth(receipts_bytes, previous_bytes) + print_receipts_summary(receipts_growth) calendar_values = ( read_calendar_values(source_yaml_path) if args.gaps_only else series_values ) @@ -900,6 +1485,11 @@ def main(argv: list[str] | None = None) -> int: forecast_start_year, args.forecast_years, ) + if receipts_growth is not None: + yoy_path = args.output or source_yaml_path + outputs[yoy_path] = render_receipts_growth_yaml( + outputs[yoy_path], receipts_growth, (month, year), previous_release + ) if write_gaps: gap_dir = get_forecast_gap_dir(source_yaml_path) for spec in STATUTORY_GAP_SPECS: From 0da72d8aa8227a4bb31e5ee09213af194fdad073 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 15:15:42 -0400 Subject: [PATCH 2/7] Refuse non-numeric receipts cells and invalid release months; strip only footnote markers from labels Co-Authored-By: Claude Opus 5.5 --- .../tests/test_import_obr_forecasts.py | 28 ++++++++++++++++++- policyengine_uk/utils/import_obr_forecasts.py | 19 +++++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index 34abcd633b..be2fd47ad9 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -1067,7 +1067,7 @@ def test_receipts_growth_rejects_a_missing_or_non_positive_level(): ("SDLT from non-residential property", (4.0, 5.0, 4.5)), ] ) - with pytest.raises(ValueError, match="no 2026-27"): + with pytest.raises(ValueError, match="no numeric 2026-27 .* value: None"): compute_receipts_growth(missing, make_previous_receipts_xlsx()) zero = make_receipts_xlsx( sdlt_rows=[ @@ -1079,6 +1079,32 @@ def test_receipts_growth_rejects_a_missing_or_non_positive_level(): compute_receipts_growth(zero, make_previous_receipts_xlsx()) +def test_receipts_labels_lose_footnote_markers_only(): + line = find_receipts_line( + read_workbook_sheets( + make_receipts_xlsx( + sdlt_rows=[ + ("SDLT from residential property2", (10.0, 11.0, 12.1)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ] + ) + ), + RECEIPTS_SERIES_SPECS[0], + ) + assert line.label == "SDLT from residential property" + + +def test_a_non_numeric_level_is_refused(): + text = make_receipts_xlsx( + sdlt_rows=[ + ("SDLT from residential property", (10.0, "[x]", 12.1)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ] + ) + with pytest.raises(ValueError, match="no numeric 2025-26 .* value: '\\[x\\]'"): + compute_receipts_growth(text, make_previous_receipts_xlsx()) + + def test_render_rewrites_values_and_references(): rendered = render() start = rendered.index(" residential:") diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 716da9a560..bfda5ab755 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -585,7 +585,7 @@ def update_forecast_gap_yaml( TABLE_TITLE_RE = re.compile(r"^(\d+\.\d+)\s+(.+)$") -FOOTNOTE_RE = re.compile(r"(?<=[a-z])\d+$") +FOOTNOTE_RE = re.compile(r"(?<=[a-z])\d+$", re.IGNORECASE) VALUE_LINE_RE = re.compile(r"^\s*(\d{4})-\d{2}-\d{2}:") @@ -763,15 +763,20 @@ def find_receipts_line( where = f"Table {table} (sheet {sheet})" levels = {} for column, year in years.items(): - if row.get(column) in (None, ""): - raise ValueError(f"{where} has no {fiscal_year_label(year)} {name} value") - levels[year] = float(row[column]) + text = row.get(column) + try: + levels[year] = float(text) + except (TypeError, ValueError): + raise ValueError( + f"{where} has no numeric {fiscal_year_label(year)} {name} value: " + f"{text!r}" + ) from None ordered = sorted(levels) if ordered != list(range(ordered[0], ordered[-1] + 1)): raise ValueError(f"{where} skips a year: {ordered}") return ReceiptsLine( table=table, - label=" ".join(str(label).split()).rstrip("0123456789"), + label=FOOTNOTE_RE.sub("", " ".join(str(label).split())), levels=levels, outturn_years=_outturn_years(years, markers, where), ) @@ -1379,6 +1384,10 @@ def main(argv: list[str] | None = None) -> int: series_values = extract_annual_series_from_xlsx(workbook_bytes) if args.release_month and args.release_year: + if args.release_month.lower() not in MONTHS: + raise ValueError( + f"--release-month must be a month name, not {args.release_month!r}" + ) month = args.release_month.capitalize() year = args.release_year elif args.release_month or args.release_year: From daabdf343cee2d3fa2e655c74aaa7571e1089a3c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 02:53:51 -0400 Subject: [PATCH 3/7] Fix the receipts importer's review findings: tables, markers, releases An independent attack review of 0da72d8aa (11 October) asked for changes, with seven should-fix findings and one pre-existing nit, each reproduced. All are fixed here, each with a test that fails on 0da72d8aa: 1. Receipts tables of unknown release are refused, as previous ones already were. Both the growth series and the September CPI gap read them, so unidentified tables could silently write the wrong EFO. 2. The economy tables' release comes from their contents sheet first, then the file name or URL (now URL-decoded). --release-month and --release-year must agree with the contents sheet. 3. Each sheet is split into tables at its title rows. A line is read only from its own table, so a later table on the same sheet can no longer stand in for it, and notes above a title no longer hide the table. 4. A fiscal year heading more than one column is refused instead of the later column silently replacing the earlier one. 5. "Outturn" and "Forecast" markers are collected across the table's rows; a marker in two different columns is refused. 6. A YAML comment indented no deeper than a key no longer ends its block. 7. Statutory gaps are built only when written, so a growth-only run (--output or --skip-statutory-gaps) no longer needs September CPI. 8. (Nit) A forecast year a series has no entry for now names the series and year; the README says to add the entry first. Also: a receipts level at or below zero in any year is refused. A zero last level used to write growth of -1.0, zeroing the index from then on. The real March 2026 and March 2025 workbooks still reproduce the committed yoy_growth.yaml byte for byte, with and without the CLI. Co-Authored-By: Claude Opus 5.5 --- changelog.d/obr-receipts-importer.added.md | 2 +- .../gov/economic_assumptions/README.md | 11 +- .../tests/test_import_obr_forecasts.py | 234 +++++++++++++++++- policyengine_uk/utils/import_obr_forecasts.py | 194 ++++++++++----- 4 files changed, 379 insertions(+), 62 deletions(-) diff --git a/changelog.d/obr-receipts-importer.added.md b/changelog.d/obr-receipts-importer.added.md index 523018e9e5..22a8244ea9 100644 --- a/changelog.d/obr-receipts-importer.added.md +++ b/changelog.d/obr-receipts-importer.added.md @@ -1 +1 @@ -- `import_obr_forecasts.py` now refreshes the OBR receipts growth series that project corporate SDLT and business rates (`yoy_growth.obr.stamp_duty_land_tax.*` and `.business_rates.*`) from a new EFO's receipts tables, with the previous EFO's tables (`--previous-receipts-file`) as the base for outturn growth, and refuses receipts tables from a different EFO. +- `import_obr_forecasts.py` now refreshes the OBR receipts growth series that project corporate SDLT and business rates (`yoy_growth.obr.stamp_duty_land_tax.*` and `.business_rates.*`) from a new EFO's receipts tables, with the previous EFO's tables (`--previous-receipts-file`) as the base for outturn growth, and refuses receipts tables from a different or unidentifiable EFO. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index e3acc89755..7a40d2f493 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -252,7 +252,10 @@ threshold by the same rises, up to the last year of 1. Replace the 2025-2030 block in each series with values from the new EFO detailed forecast tables (note the EFO release month in the header comment). 2. Recompute the 2031-2073 convergence path so the first long-run year - continues smoothly from the new last forecast year (no jump). + continues smoothly from the new last forecast year (no jump). If the new + EFO forecasts a year that a series has no entry for, add that entry first: + the importer only replaces existing entries, and stops naming the series + and year it cannot update. 3. Nothing needs regenerating: `create_economic_assumption_indices` rebuilds the cumulative `indices` parameters from these series whenever the tax-benefit system processes its parameters (`tax_benefit_system.py`). @@ -268,8 +271,10 @@ threshold by the same rises, up to the last year of (March 2025 for March 2026), with `--previous-receipts-file` or `--previous-receipts-url`; their outturn is the base for the outturn year's growth. It finds each line by table title and row label, so table - renumbering does not matter, and it refuses receipts tables from another - EFO. Then update the published levels in + renumbering does not matter, and reads only that table's rows. It takes + each workbook's release from its contents sheet, or else its file name or + URL, and refuses receipts tables from another EFO or of unknown release. + Then update the published levels in `policyengine_uk/tests/test_corporate_receipts_statistics.py` (`OBR_RECEIPTS_BN`), which pin the series to the OBR's tables. Pass `--skip-receipts-growth` only to leave them on the previous EFO. diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index be2fd47ad9..c35baaa894 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -13,7 +13,9 @@ from policyengine_uk.utils.import_obr_forecasts import ( RECEIPTS_SERIES_SPECS, + SERIES_SPECS, STATUTORY_GAP_SPECS, + _sheet_paths, ForecastGap, average_earnings_path, compute_receipts_growth, @@ -27,6 +29,7 @@ read_workbook_sheets, render_forecast_gap_yaml, render_receipts_growth_yaml, + render_yoy_growth_yaml, build_efo_href, compute_statutory_forecast_gaps, extract_annual_series_from_xlsx, @@ -1075,10 +1078,25 @@ def test_receipts_growth_rejects_a_missing_or_non_positive_level(): ("SDLT from non-residential property", (4.0, 5.0, 4.5)), ] ) - with pytest.raises(ValueError, match="cannot grow from 2025-26"): + with pytest.raises(ValueError, match="2025-26 receipts of 0.0 are not positive"): compute_receipts_growth(zero, make_previous_receipts_xlsx()) +@pytest.mark.parametrize("last", [0.0, -1.0]) +def test_a_last_level_at_or_below_zero_is_refused(last): + # Growth of -100% or less would zero (or flip) the index from then on. + levels = make_receipts_xlsx( + sdlt_rows=[ + ("SDLT from residential property", (10.0, 11.0, last)), + ("SDLT from non-residential property", (4.0, 5.0, 4.5)), + ] + ) + with pytest.raises( + ValueError, match=f"2026-27 receipts of {last} are not positive" + ): + compute_receipts_growth(levels, make_previous_receipts_xlsx()) + + def test_receipts_labels_lose_footnote_markers_only(): line = find_receipts_line( read_workbook_sheets( @@ -1105,6 +1123,71 @@ def test_a_non_numeric_level_is_refused(): compute_receipts_growth(text, make_previous_receipts_xlsx()) +CASH_TITLE = "3.8 Current receipts (on a cash basis)" +GREAT_BRITAIN = next( + spec for spec in RECEIPTS_SERIES_SPECS if spec.path[-1] == "great_britain" +) + + +def cash_table_rows(*, markers=None, years=None, label="Business rates"): + """Rows of a cash-basis receipts table as read_workbook_sheets returns + them: title, markers, fiscal years, then one line.""" + return [ + {"B": CASH_TITLE}, + *(markers or [{"D": "Outturn", "E": "Forecast"}]), + years or {"D": "2024-25", "E": "2025-26", "F": "2026-27"}, + {"B": label, "D": "30", "E": "31.5", "F": "33.075"}, + ] + + +def test_a_line_is_read_only_from_its_own_table(): + # A cash-basis table without the line, then an accrued-basis table with + # one, on the same sheet: the accrued line must not stand in for it. + rows = [ + {"B": CASH_TITLE}, + {"D": "Outturn", "E": "Forecast"}, + {"D": "2024-25", "E": "2025-26", "F": "2026-27"}, + {"B": "Council tax", "D": "46", "E": "47", "F": "48"}, + {"B": "3.5 Current receipts (on an accrued basis)"}, + {"D": "Outturn", "E": "Forecast"}, + {"D": "2024-25", "E": "2025-26", "F": "2026-27"}, + {"B": "Business rates", "D": "99", "E": "100", "F": "101"}, + ] + with pytest.raises(ValueError, match="found none"): + find_receipts_line({"3.8": rows}, GREAT_BRITAIN) + + +def test_a_table_title_below_publication_notes_is_found(): + rows = [ + {"A": "Publication note"}, + {"A": "Another note"}, + {"A": "Back to contents"}, + *cash_table_rows(), + ] + line = find_receipts_line({"3.8": rows}, GREAT_BRITAIN) + assert (line.table, line.levels) == ("3.8", {2024: 30, 2025: 31.5, 2026: 33.075}) + + +def test_outturn_and_forecast_markers_may_sit_on_separate_rows(): + rows = cash_table_rows(markers=[{"E": "Forecast"}, {"D": "Outturn"}]) + line = find_receipts_line({"3.8": rows}, GREAT_BRITAIN) + assert line.outturn_years == {2024} + + +def test_a_marker_in_two_columns_is_refused(): + rows = cash_table_rows( + markers=[{"D": "Outturn", "E": "Forecast"}, {"E": "Outturn"}] + ) + with pytest.raises(ValueError, match='marks "Outturn" in two different columns'): + find_receipts_line({"3.8": rows}, GREAT_BRITAIN) + + +def test_a_fiscal_year_heading_two_columns_is_refused(): + rows = cash_table_rows(years={"D": "2024-25", "E": "2025-26", "F": "2025-26"}) + with pytest.raises(ValueError, match="heads more than one column 2025-26"): + find_receipts_line({"3.8": rows}, GREAT_BRITAIN) + + def test_render_rewrites_values_and_references(): rendered = render() start = rendered.index(" residential:") @@ -1160,6 +1243,20 @@ def test_render_changes_only_the_receipts_values_and_references(): ) +@pytest.mark.parametrize("indent", [0, 2, 4, 6, 8]) +def test_a_comment_at_any_indent_ends_no_yaml_block(indent): + comment = " " * indent + "# A comment before the values" + anchor = " residential:\n description: residential receipts growth.\n" + assert YOY_GROWTH_FIXTURE.count(anchor) == 1 + content = YOY_GROWTH_FIXTURE.replace(anchor, anchor + comment + "\n") + assert yaml.safe_load(content) == yaml.safe_load(YOY_GROWTH_FIXTURE) + + rendered = render(content) + + assert anchor + comment + "\n values:\n" in rendered + assert rendered.replace(comment + "\n", "", 1) == render() + + def test_render_is_idempotent(): once = render() assert render(once) == once @@ -1425,6 +1522,19 @@ def test_main_dry_run_without_previous_receipts_only_warns(tmp_path, capsys): make_previous_receipts_xlsx(release=None), "Could not tell which EFO the previous receipts tables are from", ), + # No contents sheet and a file name without a release: the growth + # would otherwise be written from tables of an unknown EFO. + ( + make_receipts_xlsx(release=None), + None, + "Could not tell which EFO the receipts tables are from", + ), + ], + ids=[ + "receipts_from_another_efo", + "previous_not_earlier", + "previous_unidentified", + "receipts_unidentified", ], ) def test_main_refuses_tables_from_the_wrong_efo( @@ -1444,6 +1554,128 @@ def test_main_refuses_tables_from_the_wrong_efo( assert {path: path.read_text() for path in before} == before +def rebuild_workbook(xlsx_bytes, *, drop=(), first=None): + """The workbook without the ``drop`` sheets, with ``first`` sheets + (name to sheet XML) in front.""" + with ZipFile(BytesIO(xlsx_bytes)) as archive: + sheets = { + name: archive.read(path) + for name, path in _sheet_paths(archive).items() + if name not in drop + } + return make_workbook({**(first or {}), **sheets}) + + +def economy_with_contents(release): + return rebuild_workbook( + make_test_xlsx(), + first={ + "Contents": make_sheet( + { + 2: [ + make_inline_cell( + "B2", + f"{release} Economic and fiscal outlook – detailed " + "forecast tables: economy", + ) + ] + } + ) + }, + ) + + +def test_main_refuses_release_flags_the_economy_tables_contradict(tmp_path): + yoy, gap_dir = write_tree(tmp_path) + before = {path: path.read_text() for path in [yoy, *gap_dir.iterdir()]} + # The file is named March 2026 and the flags say March 2026, but the + # workbook's contents sheet says November 2025. + with pytest.raises( + ValueError, + match="economy tables are from November 2025, not March 2026", + ): + run_main( + tmp_path, + economy_with_contents("November 2025"), + "--yaml-path", + str(yoy), + ) + assert {path: path.read_text() for path in before} == before + + +def test_main_takes_the_release_from_the_economy_contents_sheet(tmp_path): + yoy, _ = write_tree(tmp_path) + economy = tmp_path / "economy.xlsx" + economy.write_bytes(economy_with_contents("March 2026")) + receipts = tmp_path / "receipts.xlsx" + receipts.write_bytes(make_receipts_xlsx()) + previous = tmp_path / "previous_receipts.xlsx" + previous.write_bytes(make_previous_receipts_xlsx()) + assert ( + main( + [ + "--file", + str(economy), + "--forecast-start-year", + "2025", + "--forecast-years", + "2", + "--receipts-file", + str(receipts), + "--previous-receipts-file", + str(previous), + "--yaml-path", + str(yoy), + ] + ) + == 0 + ) + assert "OBR EFO March 2026" in yoy.read_text() + + +def test_release_is_read_from_a_url_encoded_name(): + url = "https://obr.uk/docs/March%202026%20Economic%20and%20fiscal%20outlook.xlsx" + assert infer_release(url) == ("March", 2026) + + +@pytest.mark.parametrize( + "flags", [["--skip-statutory-gaps"], [], ["--skip-statutory-gaps", "--dry-run"]] +) +def test_a_growth_only_run_does_not_need_september_cpi(tmp_path, flags): + yoy, _ = write_tree(tmp_path) + output = tmp_path / "out.yaml" + # Receipts tables whose September CPI memo (sheet 3.19) is missing. + no_cpi = rebuild_workbook(make_receipts_xlsx(), drop={"3.19"}) + with pytest.raises(ValueError, match="Could not find September CPI"): + extract_september_cpi_from_receipts(no_cpi) + assert ( + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + "--output", + str(output), + *flags, + receipts=no_cpi, + ) + == 0 + ) + assert yoy.read_text() == YOY_GROWTH_FIXTURE + if "--dry-run" not in flags: + assert _values(output.read_text(), RECEIPTS_PATHS["residential"])[2024] == 0.25 + + +def test_a_forecast_year_a_series_lacks_is_named(): + # The fixture's series hold 2024-2026 and 2031, so a forecast reaching + # 2027 has no entry to replace. + values = {spec.key: {2025: 0.01, 2026: 0.02, 2027: 0.03} for spec in SERIES_SPECS} + with pytest.raises( + ValueError, match=f"{SERIES_SPECS[0].key} has no 2027-01-01 entry" + ): + render_yoy_growth_yaml(YOY_GROWTH_FIXTURE, values, "March", 2026, 2025, 3) + + def test_gaps_only_refuses_the_previous_receipts_tables(tmp_path): yoy, _ = write_tree(tmp_path) with pytest.raises(ValueError, match="--gaps-only leaves yoy_growth.yaml alone"): diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index bfda5ab755..4c330268ec 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -10,6 +10,7 @@ from decimal import Decimal from io import BytesIO from pathlib import Path +from urllib.parse import unquote from urllib.request import Request, urlopen from zipfile import BadZipFile, ZipFile import xml.etree.ElementTree as ET @@ -675,17 +676,35 @@ def read_workbook_sheets(xlsx_bytes: bytes) -> dict[str, list[dict[str, str | No return {sheet: read_sheet_rows(xlsx_bytes, sheet) for sheet in sheet_names} -def table_title(rows: list[dict[str, str | None]]) -> tuple[str, str] | None: - """The number and normalised title of a table, e.g. ("3.8", "current - receipts (on a cash basis)"), from the first title cell in its top rows.""" - for row in rows[:3]: - for column in sorted(row, key=column_index): - match = TABLE_TITLE_RE.match(" ".join(str(row[column] or "").split())) - if match: - return match.group(1), receipts_label(match.group(2)) +def row_title(row: dict[str, str | None]) -> tuple[str, str] | None: + """The number and normalised title of the table a row heads, e.g. ("3.8", + "current receipts (on a cash basis)"), if its first non-empty cell is a + table title.""" + for column in sorted(row, key=column_index): + text = " ".join(str(row[column] or "").split()) + if text: + match = TABLE_TITLE_RE.match(text) + return (match.group(1), receipts_label(match.group(2))) if match else None return None +def sheet_tables( + rows: list[dict[str, str | None]], +) -> list[tuple[str, str, list[dict[str, str | None]]]]: + """Each table on a sheet: its number, normalised title and rows, from + the row below its title to the row above the next title. Rows above the + first title, such as a contents link or publication notes, are in no + table.""" + tables = [] + for row in rows: + title = row_title(row) + if title is not None: + tables.append((*title, [])) + elif tables: + tables[-1][2].append(row) + return tables + + def _fiscal_year_columns(row: dict[str, str | None]) -> dict[str, int]: columns = {} for column, value in row.items(): @@ -718,39 +737,50 @@ def _outturn_years( def find_receipts_line( sheets: dict[str, list[dict[str, str | None]]], spec: ReceiptsSeriesSpec ) -> ReceiptsLine: - """The one row labelled ``spec.row`` in the table titled ``spec.title``.""" + """The one row labelled ``spec.row`` in a table titled ``spec.title``. + + Only that table's rows are read: its year headers and its "Outturn" and + "Forecast" markers, which may sit on one row or several. + """ matches = [] for sheet, rows in sheets.items(): - title = table_title(rows) - if title is None or title[1] != spec.title: - continue - years: dict[str, int] = {} - markers: dict[str, int] = {} - for row in rows: - row_years = _fiscal_year_columns(row) - if row_years: - years = row_years - continue - row_markers = { - normalise_label(value): column_index(column) - for column, value in row.items() - if normalise_label(value) in ("outturn", "forecast") - } - if row_markers: - markers = row_markers + for table, title, table_rows in sheet_tables(rows): + if title != spec.title: continue - if not years: - continue - first_year_column = min(column_index(column) for column in years) - labels = [ - value - for column, value in sorted( - row.items(), key=lambda item: column_index(item[0]) - ) - if column_index(column) < first_year_column and normalise_label(value) - ] - if labels and receipts_label(labels[0]) == spec.row: - matches.append((title[0], sheet, labels[0], row, years, markers)) + where = f"Table {table} (sheet {sheet})" + years: dict[str, int] = {} + markers: dict[str, int] = {} + for row in table_rows: + row_years = _fiscal_year_columns(row) + if row_years: + years = row_years + continue + row_markers = [ + (normalise_label(value), column_index(column)) + for column, value in row.items() + if normalise_label(value) in ("outturn", "forecast") + ] + for marker, column in row_markers: + if markers.setdefault(marker, column) != column: + raise ValueError( + f'{where} marks "{marker.capitalize()}" in two ' + "different columns" + ) + if row_markers: + continue + if not years: + continue + first_year_column = min(column_index(column) for column in years) + labels = [ + value + for column, value in sorted( + row.items(), key=lambda item: column_index(item[0]) + ) + if column_index(column) < first_year_column + and normalise_label(value) + ] + if labels and receipts_label(labels[0]) == spec.row: + matches.append((table, sheet, labels[0], row, years, markers)) name = ".".join(spec.path) if len(matches) != 1: @@ -761,6 +791,12 @@ def find_receipts_line( ) table, sheet, label, row, years, markers = matches[0] where = f"Table {table} (sheet {sheet})" + repeated = sorted({y for y in years.values() if list(years.values()).count(y) > 1}) + if repeated: + raise ValueError( + f"{where} heads more than one column " + + ", ".join(fiscal_year_label(y) for y in repeated) + ) levels = {} for column, year in years.items(): text = row.get(column) @@ -812,14 +848,18 @@ def compute_receipts_growth( f"outturn year is {fiscal_year_label(base_year)}" ) levels = {base_year: previous.levels[base_year], **line.levels} - values = {} - for year in sorted(line.levels): - if levels[year - 1] <= 0: + # Growth to or from a level at or below zero means nothing, and a + # zero last level would zero the series' index from then on. + for year, level in sorted(levels.items()): + if level <= 0: raise ValueError( - f"{name}: cannot grow from {fiscal_year_label(year - 1)} " - f"receipts of {levels[year - 1]}" + f"{name}: {fiscal_year_label(year)} receipts of {level} are " + "not positive, so there is no growth to or from them" ) - values[year] = round(levels[year] / levels[year - 1] - 1, 6) + 0.0 + values = { + year: round(levels[year] / levels[year - 1] - 1, 6) + 0.0 + for year in sorted(line.levels) + } result[spec.path] = ReceiptsGrowth(values, line, previous) return result @@ -857,7 +897,11 @@ def _yaml_block( lines: list[str], key: str, indent: int, start: int, end: int ) -> tuple[int, int]: """Lines [first, last) of the mapping entry ``key`` at ``indent`` within - lines[start:end], from its key through its last non-blank child line.""" + lines[start:end], from its key through its last non-blank child line. + + A comment indented no deeper than the key does not end the entry, as it + does not in YAML; it is kept where it is, outside the entry's lines if it + follows them.""" pattern = re.compile(rf"^ {{{indent}}}{re.escape(key)}:(\s|$)") for first in range(start, end): if not pattern.match(lines[first]): @@ -867,6 +911,8 @@ def _yaml_block( if not lines[index].strip(): continue if _indent(lines[index]) <= indent: + if lines[index].lstrip().startswith("#"): + continue break last = index + 1 return first, last @@ -1070,7 +1116,9 @@ def release_order(release: tuple[str, int]) -> tuple[int, int]: def infer_release(source_name: str) -> tuple[str, int]: - match = MONTH_RE.search(source_name) + """The release a file name or URL names, e.g. "receipts_march_2026.xlsx" + or ".../March%202026%20EFO.xlsx".""" + match = MONTH_RE.search(unquote(source_name)) if not match: raise ValueError( "Could not infer the OBR release month/year from the source name. " @@ -1185,9 +1233,19 @@ def render_yoy_growth_yaml( ) for target_year in available_years: - section = replace_year_value( - section, target_year, series_values[spec.key][target_year] - ) + try: + section = replace_year_value( + section, target_year, series_values[spec.key][target_year] + ) + except ValueError: + # Only existing entries are replaced: an entry added here + # would also move every later year up to the next entry. + raise ValueError( + f"{spec.key} has no {target_year}-01-01 entry for this EFO's " + f"{target_year} forecast. Add one, with the long-run path " + "after it recomputed (see the README's steps for a new EFO), " + "then rerun" + ) from None section = replace_first_reference( section, @@ -1383,6 +1441,9 @@ def main(argv: list[str] | None = None) -> int: ) series_values = extract_annual_series_from_xlsx(workbook_bytes) + # The economy tables' contents sheet names their release; the file name + # or URL is the fallback, and --release-month/--release-year must agree. + economy_release = infer_workbook_release(workbook_bytes) if args.release_month and args.release_year: if args.release_month.lower() not in MONTHS: raise ValueError( @@ -1390,10 +1451,16 @@ def main(argv: list[str] | None = None) -> int: ) month = args.release_month.capitalize() year = args.release_year + if economy_release not in (None, (month, year)): + raise ValueError( + f"The economy tables are from {economy_release[0]} " + f"{economy_release[1]}, not {month} {year} as " + "--release-month and --release-year say" + ) elif args.release_month or args.release_year: raise ValueError("Pass both --release-month and --release-year together") else: - month, year = infer_release(f"{source_name} {workbook_name}") + month, year = economy_release or infer_release(f"{source_name} {workbook_name}") forecast_start_year = args.forecast_start_year or infer_forecast_start_year( month, year @@ -1441,7 +1508,14 @@ def main(argv: list[str] | None = None) -> int: args.receipts_url, args.receipts_file ) receipts_release = resolve_release(receipts_bytes, receipts_name) - if receipts_release not in (None, (month, year)): + # Both the receipts growth series and the September CPI gap read + # these tables, so they must be identifiably this EFO's. + if receipts_release is None: + raise ValueError( + "Could not tell which EFO the receipts tables are from; name the " + "file with its release, e.g. receipts_march_2026.xlsx" + ) + if receipts_release != (month, year): raise ValueError( "The receipts tables are from " f"{receipts_release[0]} {receipts_release[1]} but the economy " @@ -1469,12 +1543,18 @@ def main(argv: list[str] | None = None) -> int: calendar_values = ( read_calendar_values(source_yaml_path) if args.gaps_only else series_values ) - statutory_gaps = compute_statutory_forecast_gaps( - workbook_bytes, - forecast_start_year, - args.forecast_years, - receipts_bytes, - calendar_values, + # Gaps are only built when they are written, so a growth-only run + # (--output or --skip-statutory-gaps) never needs September CPI. + statutory_gaps = ( + compute_statutory_forecast_gaps( + workbook_bytes, + forecast_start_year, + args.forecast_years, + receipts_bytes, + calendar_values, + ) + if write_gaps + else {} ) for key, gaps in statutory_gaps.items(): window = ", ".join( From 749b547c59568a9ceebae01cc5439474fb857bfc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 05:02:31 -0400 Subject: [PATCH 4/7] Rebuild the YAML lines instead of assigning into a slice of them test_cached_arrays_not_written_in_place.py's static scan treats a method call whose first argument is a string literal as a read from the simulation, so lines = content.split("\n") was tracked as a cached array and the two slice assignments in render_receipts_growth_yaml failed it (CI run 39915137960, Test / Python 4/5). The function edits text, not a simulation array; build each new list instead. The output is unchanged: the importer tests and the real-workbook check still pass byte for byte. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/utils/import_obr_forecasts.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 4c330268ec..0537178037 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -1038,7 +1038,7 @@ def render_receipts_growth_yaml( new_values = render_receipts_values( old_values, entry_indent, series, tail, release, previous_release ) - lines[values_start + 1 : values_end] = new_values + lines = [*lines[: values_start + 1], *new_values, *lines[values_end:]] start, end = _series_block(lines, ("obr", *path)) metadata = _yaml_block(lines, "metadata", indent, start, end) @@ -1046,7 +1046,7 @@ def render_receipts_growth_yaml( lines, "reference", indent + 2, metadata[0], metadata[1] ) pad = " " * (indent + 2) - lines[ref_start:ref_end] = [f"{pad}reference:"] + [ + new_references = [f"{pad}reference:"] + [ text for reference in references for text in ( @@ -1054,6 +1054,7 @@ def render_receipts_growth_yaml( f"{pad} href: {reference['href']}", ) ] + lines = [*lines[:ref_start], *new_references, *lines[ref_end:]] node = expected["obr"] for key in path: From 2a08b4bf879961cb118c013b147ee09a37e8a3b2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 08:32:58 -0400 Subject: [PATCH 5/7] Test receipt units against mutated real OBR workbooks --- .../tests/fixtures/obr_receipts/README.md | 22 ++ .../obr_efo_march_2025_receipts.xlsx | Bin 0 -> 48486 bytes .../obr_efo_march_2026_receipts.xlsx | Bin 0 -> 35740 bytes .../tests/test_import_obr_forecasts.py | 195 ++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 policyengine_uk/tests/fixtures/obr_receipts/README.md create mode 100644 policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2025_receipts.xlsx create mode 100644 policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2026_receipts.xlsx diff --git a/policyengine_uk/tests/fixtures/obr_receipts/README.md b/policyengine_uk/tests/fixtures/obr_receipts/README.md new file mode 100644 index 0000000000..163912f1aa --- /dev/null +++ b/policyengine_uk/tests/fixtures/obr_receipts/README.md @@ -0,0 +1,22 @@ +# Real OBR receipts table fixtures + +These parser fixtures retain the original worksheet XML for Contents, Other +taxes (3.2), Property transactions taxes: receipts by sector (3.7), and Current +receipts (on a cash basis: 3.9 in 2025, 3.8 in 2026), plus the original shared +strings. Only the workbook's sheet list and relationships were reduced to those +entries. The published unit cells, fiscal-year headers, markers, row labels and +numeric cells are unchanged. These are minimal archives for the importer's XLSX +reader, rather than complete Excel documents with styles and charts. + +Sources: OBR [March 2025 EFO](https://obr.uk/efo/economic-and-fiscal-outlook-march-2025/) +and [March 2026 EFO](https://obr.uk/efo/economic-and-fiscal-outlook-march-2026/), +receipts detailed forecast tables. The original files used for extraction are +`obr_efo_march_2025_receipts.xlsx` and `obr_efo_march_2026_receipts.xlsx`. +Their SHA-256 hashes are: + +- March 2025: `6b20e05e93c50e3ccae1d3111fd84b8fa326b75c38c8ab055fad694648c75905` +- March 2026: `f65b6cb7f96931d4abd85940794eb60f6f65afca8bbf16c74a4548bf92766a28` + +Tests mutate the unit cell and selected receipt levels inside these real tables; +the unchanged control must reproduce the committed YAML byte for byte. No test +downloads a workbook or depends on a developer's local source directory. diff --git a/policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2025_receipts.xlsx b/policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2025_receipts.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..3e6ca8b1e8782c08f280a9da292737c59654d1aa GIT binary patch literal 48486 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 Qc&NWGC?DD#`v>Fy0FT~bWB>pF literal 0 HcmV?d00001 diff --git a/policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2026_receipts.xlsx b/policyengine_uk/tests/fixtures/obr_receipts/obr_efo_march_2026_receipts.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..6b8b16f9bfaf8b26f4b0812bca59ef8a9083955f GIT binary patch literal 35740 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

+LrQI!U8x^3F2msAzM9!c<2C3q?mJfxnp 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 Kg|wXYqdx(3-s9^4 literal 0 HcmV?d00001 diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index c35baaa894..75442edafa 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -3,6 +3,7 @@ from pathlib import Path from io import BytesIO from zipfile import ZipFile +import xml.etree.ElementTree as ET import pytest import yaml @@ -1134,6 +1135,7 @@ def cash_table_rows(*, markers=None, years=None, label="Business rates"): them: title, markers, fiscal years, then one line.""" return [ {"B": CASH_TITLE}, + {"D": "£ billion"}, *(markers or [{"D": "Outturn", "E": "Forecast"}]), years or {"D": "2024-25", "E": "2025-26", "F": "2026-27"}, {"B": label, "D": "30", "E": "31.5", "F": "33.075"}, @@ -1438,6 +1440,199 @@ def test_march_2026_tables_reproduce_the_committed_series(): assert rendered == committed +REAL_RECEIPTS_DIR = Path(__file__).parent / "fixtures" / "obr_receipts" +REAL_RECEIPTS = { + year: (REAL_RECEIPTS_DIR / f"obr_efo_march_{year}_receipts.xlsx").read_bytes() + for year in (2025, 2026) +} +XLSX_MAIN = "http://schemas.openxmlformats.org/spreadsheetml/2006/main" +XLSX_NS = {"m": XLSX_MAIN} + + +def mutate_real_receipts(workbook, table, unit, scale=1): + """Change the real table's unit cell and scale its selected receipts rows. + + Keep all other archive entries, including the shared strings, unchanged. + """ + output = BytesIO() + with ZipFile(BytesIO(workbook)) as source, ZipFile(output, "w") as dest: + path = _sheet_paths(source)[table] + strings = [ + "".join(node.itertext()) + for node in ET.fromstring(source.read("xl/sharedStrings.xml")) + ] + sheet = ET.fromstring(source.read(path)) + first_column = "D" if table in ("3.8", "3.9") else "C" + cell = sheet.find(f"m:sheetData/m:row/m:c[@r='{first_column}3']", XLSX_NS) + cell.clear() + cell.attrib.update(r=f"{first_column}3", t="inlineStr") + ET.SubElement( + ET.SubElement(cell, f"{{{XLSX_MAIN}}}is"), f"{{{XLSX_MAIN}}}t" + ).text = unit + labels = {spec.row for spec in RECEIPTS_SERIES_SPECS} + for row in sheet.findall("m:sheetData/m:row", XLSX_NS): + label = row.find("m:c[@t='s']/m:v", XLSX_NS) + if label is None or strings[int(label.text)].lower() not in labels: + continue + for number in row.findall("m:c", XLSX_NS): + value = number.find("m:v", XLSX_NS) + if value is not None and number.attrib.get("t") != "s": + value.text = repr(float(value.text) * scale) + for entry in source.infolist(): + dest.writestr( + entry, + ET.tostring(sheet) if entry.filename == path else source.read(entry), + ) + return output.getvalue() + + +def test_real_receipts_workbooks_reproduce_committed_yaml_byte_for_byte(): + committed = get_yoy_growth_path().read_bytes() + rendered = render( + committed.decode(), current=REAL_RECEIPTS[2026], previous=REAL_RECEIPTS[2025] + ) + assert rendered.encode() == committed + + +@pytest.mark.parametrize( + "year, tables", [(2026, ("3.2", "3.7", "3.8")), (2025, ("3.2", "3.7", "3.9"))] +) +@pytest.mark.parametrize( + "unit, scale", + [ + ("£ million", 1000), + ("£m", 1000), + ("% of GDP", 1), + ("£ billion (real terms)", 1), + ("£ billion per cent", 1), + ("€ billion", 1), + ("", 1), + ], +) +def test_real_receipts_units_are_refused_before_any_write( + tmp_path, year, tables, unit, scale +): + for table in tables: + yoy, gap_dir = write_tree(tmp_path) + before = {path: path.read_bytes() for path in [yoy, *gap_dir.iterdir()]} + receipts = dict(REAL_RECEIPTS) + receipts[year] = mutate_real_receipts(receipts[year], table, unit, scale) + with pytest.raises(ValueError, match=rf"Table {table} .*receipts unit"): + run_main( + tmp_path, + make_test_xlsx(), + "--yaml-path", + str(yoy), + receipts=receipts[2026], + previous_receipts=receipts[2025], + ) + assert {path: path.read_bytes() for path in before} == before + assert not list(yoy.parent.rglob("*.staged")) + + +SUPPORTED_BILLION_LABELS = ( + "£ billion", + "£bn", + "£ bn", + " £ BILLION ", + "£\u00a0billion", +) + + +@pytest.mark.parametrize("unit", SUPPORTED_BILLION_LABELS) +def test_explicit_billion_unit_aliases_preserve_the_real_workbook_output(unit): + assert ( + render( + get_yoy_growth_path().read_text(), + current=mutate_real_receipts(REAL_RECEIPTS[2026], "3.8", unit), + previous=mutate_real_receipts(REAL_RECEIPTS[2025], "3.9", unit), + ).encode() + == get_yoy_growth_path().read_bytes() + ) + + +@pytest.mark.parametrize( + "declarations", + [[], [{"D": "£ billion"}, {"D": "£ million"}], [{"D": "£ billion", "E": "% GDP"}]], +) +def test_receipts_units_must_be_unambiguous_and_belong_to_the_selected_table( + declarations, +): + rows = cash_table_rows() + rows[1:2] = declarations + # A valid unit in another table must not supply a missing cash-table unit. + rows += [{"B": "3.5 Current receipts (on an accrued basis)"}, {"D": "£ billion"}] + with pytest.raises(ValueError, match="receipts unit"): + find_receipts_line({"3.8": rows}, GREAT_BRITAIN) + + +@settings(max_examples=50, deadline=None, derandomize=True) +@given( + current_unit=st.one_of( + st.sampled_from(SUPPORTED_BILLION_LABELS), + st.sampled_from( + ("£m", "£ million", "£ billion per cent", "% GDP", "USD billion", "") + ), + st.text( + alphabet="£€$%abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789./()-_", + max_size=30, + ), + ), + previous_unit=st.sampled_from((*SUPPORTED_BILLION_LABELS, "£m", "€ billion", "")), + scale=st.floats( + min_value=0.001, max_value=1000, allow_nan=False, allow_infinity=False + ), +) +def test_real_receipts_unit_labels_and_scale_factors( + current_unit, previous_unit, scale +): + """A common scale cannot change ratios; unsupported declarations always fail.""" + current = mutate_real_receipts(REAL_RECEIPTS[2026], "3.8", current_unit, scale) + previous = mutate_real_receipts(REAL_RECEIPTS[2025], "3.9", previous_unit, scale) + supported = {"£ billion", "£bn", "£ bn"} + if any( + " ".join(unit.lower().split()) not in supported + for unit in (current_unit, previous_unit) + ): + with pytest.raises(ValueError, match="receipts unit"): + compute_receipts_growth(current, previous) + else: + expected = compute_receipts_growth(REAL_RECEIPTS[2026], REAL_RECEIPTS[2025]) + actual = compute_receipts_growth(current, previous) + assert {path: series.values for path, series in actual.items()} == { + path: series.values for path, series in expected.items() + } + + +def test_next_release_real_receipts_refuse_a_thousandfold_unit_change(): + """The review's next-EFO attack must never turn zero growth into 999.""" + output = BytesIO() + with ZipFile(BytesIO(REAL_RECEIPTS[2026])) as source, ZipFile(output, "w") as dest: + # Shift only the release and year labels in the original shared strings, + # retaining every numeric cell and the worksheets' original layout. + path = "xl/sharedStrings.xml" + strings = ET.fromstring(source.read(path)) + for text in strings.iter(f"{{{XLSX_MAIN}}}t"): + if text.text and import_obr_forecasts.FISCAL_YEAR_RE.fullmatch(text.text): + text.text = import_obr_forecasts.fiscal_year_label( + int(text.text[:4]) + 1 + ) + elif text.text: + text.text = text.text.replace("March 2026", "March 2027") + for entry in source.infolist(): + dest.writestr( + entry, + ET.tostring(strings) if entry.filename == path else source.read(entry), + ) + advanced = output.getvalue() + assert infer_workbook_release(advanced) == ("March", 2027) + control = compute_receipts_growth(advanced, REAL_RECEIPTS[2026]) + assert control[RECEIPTS_PATHS["great_britain"]].values[2025] == 0.0 + attacked = mutate_real_receipts(advanced, "3.8", "£m", 1000) + with pytest.raises(ValueError, match="receipts unit"): + compute_receipts_growth(attacked, REAL_RECEIPTS[2026]) + + def test_workbook_release_comes_from_the_contents_sheet(): assert infer_workbook_release(make_receipts_xlsx()) == ("March", 2026) assert infer_workbook_release(make_receipts_xlsx(release=None)) is None From 3a8da1918c2056e37f56479f8f851d5ce73eea0d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 08:44:41 -0400 Subject: [PATCH 6/7] Reject unsupported OBR receipt units before importing --- changelog.d/obr-receipts-importer.added.md | 2 +- .../gov/economic_assumptions/README.md | 13 ++++++ .../tests/test_import_obr_forecasts.py | 2 +- policyengine_uk/utils/import_obr_forecasts.py | 42 +++++++++++++++++-- 4 files changed, 53 insertions(+), 6 deletions(-) diff --git a/changelog.d/obr-receipts-importer.added.md b/changelog.d/obr-receipts-importer.added.md index 22a8244ea9..412581ff0c 100644 --- a/changelog.d/obr-receipts-importer.added.md +++ b/changelog.d/obr-receipts-importer.added.md @@ -1 +1 @@ -- `import_obr_forecasts.py` now refreshes the OBR receipts growth series that project corporate SDLT and business rates (`yoy_growth.obr.stamp_duty_land_tax.*` and `.business_rates.*`) from a new EFO's receipts tables, with the previous EFO's tables (`--previous-receipts-file`) as the base for outturn growth, and refuses receipts tables from a different or unidentifiable EFO. +- `import_obr_forecasts.py` now refreshes the OBR receipts growth series that project corporate SDLT and business rates (`yoy_growth.obr.stamp_duty_land_tax.*` and `.business_rates.*`) from a new EFO's receipts tables, with the previous EFO's tables (`--previous-receipts-file`) as the base for outturn growth, and refuses receipts tables from a different or unidentifiable EFO or without an unambiguous pound-billion unit declaration in every selected table in both releases. diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index 7a40d2f493..03b96b0f15 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -251,6 +251,14 @@ threshold by the same rises, up to the last year of 1. Replace the 2025-2030 block in each series with values from the new EFO detailed forecast tables (note the EFO release month in the header comment). + The importer converts annual economy growth percentages to decimal rates + and rounds them to four decimal places. Review the economy-series diff + deliberately when running a full CLI import: with the real March 2026 + economy workbook, the existing extraction changes cumulative 2030 indices + relative to the stored series by RPI −0.0880%, CPI −0.0584%, average + earnings −0.0585% and house prices +0.0194%. Receipts-only rendering can + reproduce the committed YAML byte for byte; a full import also refreshes + these economy series. 2. Recompute the 2031-2073 convergence path so the first long-run year continues smoothly from the new last forecast year (no jump). If the new EFO forecasts a year that a series has no entry for, add that entry first: @@ -274,6 +282,11 @@ threshold by the same rises, up to the last year of renumbering does not matter, and reads only that table's rows. It takes each workbook's release from its contents sheet, or else its file name or URL, and refuses receipts tables from another EFO or of unknown release. + Every selected table in both releases must declare `£ billion` in its + header (`£bn` and `£ bn` are also accepted, ignoring case and whitespace). + Other units, including `£m`, and missing or ambiguous declarations stop + the import before any file is written, so different cash magnitudes + cannot be combined across releases. Then update the published levels in `policyengine_uk/tests/test_corporate_receipts_statistics.py` (`OBR_RECEIPTS_BN`), which pin the series to the OBR's tables. Pass diff --git a/policyengine_uk/tests/test_import_obr_forecasts.py b/policyengine_uk/tests/test_import_obr_forecasts.py index 75442edafa..58db83d3f4 100644 --- a/policyengine_uk/tests/test_import_obr_forecasts.py +++ b/policyengine_uk/tests/test_import_obr_forecasts.py @@ -1513,7 +1513,7 @@ def test_real_receipts_units_are_refused_before_any_write( tmp_path, year, tables, unit, scale ): for table in tables: - yoy, gap_dir = write_tree(tmp_path) + yoy, gap_dir = write_tree(tmp_path / table) before = {path: path.read_bytes() for path in [yoy, *gap_dir.iterdir()]} receipts = dict(REAL_RECEIPTS) receipts[year] = mutate_real_receipts(receipts[year], table, unit, scale) diff --git a/policyengine_uk/utils/import_obr_forecasts.py b/policyengine_uk/utils/import_obr_forecasts.py index 0537178037..d950da7652 100644 --- a/policyengine_uk/utils/import_obr_forecasts.py +++ b/policyengine_uk/utils/import_obr_forecasts.py @@ -734,13 +734,41 @@ def _outturn_years( ) +def validate_receipts_unit( + header_rows: list[dict[str, str | None]], where: str +) -> None: + """Require one explicit pound-billion declaration in this table's header. + + Only spelling, case and whitespace aliases of £ billion are supported. + Requiring this same cash unit in every selected table in both releases + prevents mixing magnitudes when their outturn levels are combined. A new + magnitude, currency or real-terms measure needs deliberate importer support. + """ + units = [ + normalise_label(value) + for row in header_rows + for value in row.values() + if normalise_label(value) not in ("", "outturn", "forecast") + ] + if len(units) != 1: + raise ValueError( + f"{where} must declare exactly one receipts unit before its " + f"fiscal-year header; found {units}" + ) + if units[0] not in ("£ billion", "£bn", "£ bn"): + raise ValueError( + f"{where} has unsupported receipts unit {units[0]!r}; only " + "£ billion (£bn or £ bn) is supported in both releases" + ) + + def find_receipts_line( sheets: dict[str, list[dict[str, str | None]]], spec: ReceiptsSeriesSpec ) -> ReceiptsLine: """The one row labelled ``spec.row`` in a table titled ``spec.title``. - Only that table's rows are read: its year headers and its "Outturn" and - "Forecast" markers, which may sit on one row or several. + Only that table's rows are read: its cash unit, year headers and its + "Outturn" and "Forecast" markers, which may sit on one row or several. """ matches = [] for sheet, rows in sheets.items(): @@ -750,11 +778,14 @@ def find_receipts_line( where = f"Table {table} (sheet {sheet})" years: dict[str, int] = {} markers: dict[str, int] = {} + header_rows = [] for row in table_rows: row_years = _fiscal_year_columns(row) if row_years: years = row_years continue + if not years: + header_rows.append(row) row_markers = [ (normalise_label(value), column_index(column)) for column, value in row.items() @@ -780,7 +811,9 @@ def find_receipts_line( and normalise_label(value) ] if labels and receipts_label(labels[0]) == spec.row: - matches.append((table, sheet, labels[0], row, years, markers)) + matches.append( + (table, sheet, labels[0], row, years, markers, header_rows) + ) name = ".".join(spec.path) if len(matches) != 1: @@ -789,7 +822,7 @@ def find_receipts_line( f'Expected one "{spec.row}" row in the "{spec.title}" table for ' f"{name}; found {found}" ) - table, sheet, label, row, years, markers = matches[0] + table, sheet, label, row, years, markers, header_rows = matches[0] where = f"Table {table} (sheet {sheet})" repeated = sorted({y for y in years.values() if list(years.values()).count(y) > 1}) if repeated: @@ -797,6 +830,7 @@ def find_receipts_line( f"{where} heads more than one column " + ", ".join(fiscal_year_label(y) for y in repeated) ) + validate_receipts_unit(header_rows, where) levels = {} for column, year in years.items(): text = row.get(column) From 9ca3cfd2d7a2cbc9b17602b69b3881ef405a9764 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 08:45:32 -0400 Subject: [PATCH 7/7] Fix the 2019 England business rates source link --- .../parameters/gov/hmrc/business_rates/statistics.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml b/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml index 84cdf1b00c..0acc293323 100644 --- a/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml +++ b/policyengine_uk/parameters/gov/hmrc/business_rates/statistics.yaml @@ -49,7 +49,7 @@ revenue: - title: MHCLG, National non-domestic rates collected by councils in England 2025 to 2026, Table 1 "Non-domestic rating income from rates retention scheme" (2021-22 to 2025-26; 2025-26 provisional) href: https://www.gov.uk/government/statistics/national-non-domestic-rates-collected-by-councils-in-england-2025-to-2026 - title: MHCLG, NNDR3 2019 to 2020 local authority data, November 2025 update, Datasheet1 "nndrincome", England - href: https://www.gov.uk/government/statistics/national-non-domestic-rates-collected-by-councils-in-england-2019-to-2020 + href: https://www.gov.uk/government/statistics/national-non-domestic-rates-collected-by-councils-in-england-2019-to-2020-provisional-data - title: MHCLG, NNDR3 2020 to 2021 consolidated data, November 2025 update, "nndrincome", England href: https://www.gov.uk/government/statistics/national-non-domestic-rates-collected-by-councils-in-england-2020-to-2021 ENGLAND_CENTRAL_LIST: