From 88cc5e340c26efc780d487788774d5d02e318031 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Mon, 16 Mar 2026 11:50:24 +0000 Subject: [PATCH 1/4] Add ONS land value calibration targets Co-Authored-By: Claude Opus 4.6 --- .../targets/build_loss_matrix.py | 10 +++ .../targets/compute/__init__.py | 2 + policyengine_uk_data/targets/compute/other.py | 5 ++ .../targets/sources/ons_land_values.py | 83 +++++++++++++++++++ 4 files changed, 100 insertions(+) create mode 100644 policyengine_uk_data/targets/sources/ons_land_values.py diff --git a/policyengine_uk_data/targets/build_loss_matrix.py b/policyengine_uk_data/targets/build_loss_matrix.py index 3358a646..4f48b63b 100644 --- a/policyengine_uk_data/targets/build_loss_matrix.py +++ b/policyengine_uk_data/targets/build_loss_matrix.py @@ -30,6 +30,7 @@ compute_household_type, compute_housing, compute_income_band, + compute_land_value, compute_obr_council_tax, compute_pip_claimants, compute_regional_age, @@ -289,6 +290,15 @@ def _compute_column(target: Target, ctx: _SimContext, year: int) -> np.ndarray | if name in ("housing/total_mortgage", "housing/rent_private"): return compute_housing(target, ctx) + # Land and property wealth (ONS National Balance Sheet) + if name in ( + "ons/household_land_value", + "ons/corporate_land_value", + "ons/land_value", + "ons/property_wealth", + ): + return compute_land_value(target, ctx) + # Savings if name == "ons/savings_interest_income": return compute_savings_interest(target, ctx) diff --git a/policyengine_uk_data/targets/compute/__init__.py b/policyengine_uk_data/targets/compute/__init__.py index 8a329c30..974de711 100644 --- a/policyengine_uk_data/targets/compute/__init__.py +++ b/policyengine_uk_data/targets/compute/__init__.py @@ -35,6 +35,7 @@ ) from policyengine_uk_data.targets.compute.other import ( compute_housing, + compute_land_value, compute_savings_interest, compute_scottish_child_payment, compute_student_loan_plan, @@ -48,6 +49,7 @@ "compute_gender_age", "compute_household_type", "compute_housing", + "compute_land_value", "compute_income_band", "compute_obr_council_tax", "compute_pip_claimants", diff --git a/policyengine_uk_data/targets/compute/other.py b/policyengine_uk_data/targets/compute/other.py index c2037c8e..b86418cf 100644 --- a/policyengine_uk_data/targets/compute/other.py +++ b/policyengine_uk_data/targets/compute/other.py @@ -49,6 +49,11 @@ def compute_scottish_child_payment(target, ctx) -> np.ndarray: return ctx.household_from_person(scp) +def compute_land_value(target, ctx) -> np.ndarray: + """Compute land/property wealth targets from household-level variables.""" + return ctx.pe(target.variable) + + def compute_student_loan_plan(target, ctx) -> np.ndarray: """Count England borrowers on a given plan with repayments > 0. diff --git a/policyengine_uk_data/targets/sources/ons_land_values.py b/policyengine_uk_data/targets/sources/ons_land_values.py new file mode 100644 index 00000000..93af3557 --- /dev/null +++ b/policyengine_uk_data/targets/sources/ons_land_values.py @@ -0,0 +1,83 @@ +"""ONS National Balance Sheet land value targets. + +Aggregate land values from the ONS National Balance Sheet 2025. +The ONS directly measured total UK land at £7.1 trillion for 2024, +broken down into household land (£4.31tn in 2020) and corporate +land (£1.76tn in 2020). + +Source: https://www.ons.gov.uk/economy/nationalaccounts/uksectoraccounts/bulletins/nationalbalancesheet/2025 +""" + +from policyengine_uk_data.targets.schema import Target, Unit + +# ONS National Balance Sheet 2025 +# 2020 breakdown: household £4.31tn, corporate £1.76tn, total £6.07tn +# 2024 measured total: £7.1tn +# We scale the 2020 household/corporate split proportionally to match +# the 2024 measured total, then hold constant for 2025-2026 (no newer +# ONS measurement available). + +_ONS_2020_HOUSEHOLD = 4.31e12 +_ONS_2020_CORPORATE = 1.76e12 +_ONS_2020_TOTAL = _ONS_2020_HOUSEHOLD + _ONS_2020_CORPORATE +_ONS_2024_TOTAL = 7.1e12 + +# Scale 2020 split to 2024 measured total +_SCALE = _ONS_2024_TOTAL / _ONS_2020_TOTAL +_ONS_2024_HOUSEHOLD = _ONS_2020_HOUSEHOLD * _SCALE +_ONS_2024_CORPORATE = _ONS_2020_CORPORATE * _SCALE + +_REF_URL = "https://www.ons.gov.uk/economy/nationalaccounts/uksectoraccounts/bulletins/nationalbalancesheet/2025" + + +def get_targets() -> list[Target]: + return [ + Target( + name="ons/household_land_value", + variable="household_land_value", + source="ons", + unit=Unit.GBP, + values={ + 2024: _ONS_2024_HOUSEHOLD, + 2025: _ONS_2024_HOUSEHOLD, + 2026: _ONS_2024_HOUSEHOLD, + }, + reference_url=_REF_URL, + ), + Target( + name="ons/corporate_land_value", + variable="corporate_land_value", + source="ons", + unit=Unit.GBP, + values={ + 2024: _ONS_2024_CORPORATE, + 2025: _ONS_2024_CORPORATE, + 2026: _ONS_2024_CORPORATE, + }, + reference_url=_REF_URL, + ), + Target( + name="ons/land_value", + variable="land_value", + source="ons", + unit=Unit.GBP, + values={ + 2024: _ONS_2024_TOTAL, + 2025: _ONS_2024_TOTAL, + 2026: _ONS_2024_TOTAL, + }, + reference_url=_REF_URL, + ), + Target( + name="ons/property_wealth", + variable="property_wealth", + source="ons", + unit=Unit.GBP, + values={ + 2024: _ONS_2024_TOTAL, + 2025: _ONS_2024_TOTAL, + 2026: _ONS_2024_TOTAL, + }, + reference_url=_REF_URL, + ), + ] From 9e933def88e42399675a43fe85a0e966720e119a Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Mon, 16 Mar 2026 11:54:03 +0000 Subject: [PATCH 2/4] Add tests for ONS land value calibration targets Marked xfail until recalibration runs with the new targets. Co-Authored-By: Claude Opus 4.6 --- .../tests/test_land_value_targets.py | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 policyengine_uk_data/tests/test_land_value_targets.py diff --git a/policyengine_uk_data/tests/test_land_value_targets.py b/policyengine_uk_data/tests/test_land_value_targets.py new file mode 100644 index 00000000..1ff9f403 --- /dev/null +++ b/policyengine_uk_data/tests/test_land_value_targets.py @@ -0,0 +1,88 @@ +"""Tests for ONS land value calibration targets. + +These validate that the generated Enhanced FRS dataset reproduces +aggregate land and property wealth values from the ONS National +Balance Sheet 2025. + +Source: https://www.ons.gov.uk/economy/nationalaccounts/uksectoraccounts/bulletins/nationalbalancesheet/2025 +""" + +import pytest + +# ONS National Balance Sheet 2025 +# 2024 measured total: £7.1tn +# 2020 split scaled proportionally: household £5.04tn, corporate £2.06tn +_ONS_2020_HOUSEHOLD = 4.31e12 +_ONS_2020_CORPORATE = 1.76e12 +_ONS_2020_TOTAL = _ONS_2020_HOUSEHOLD + _ONS_2020_CORPORATE +_ONS_2024_TOTAL = 7.1e12 +_SCALE = _ONS_2024_TOTAL / _ONS_2020_TOTAL + +LAND_TARGETS = { + "land_value": _ONS_2024_TOTAL, + "household_land_value": _ONS_2020_HOUSEHOLD * _SCALE, + "corporate_land_value": _ONS_2020_CORPORATE * _SCALE, + "property_wealth": _ONS_2024_TOTAL, +} + +YEAR = 2025 +TOLERANCE = 0.30 # 30% relative error allowed + + +@pytest.mark.xfail( + reason="Will pass after recalibration with ONS land value targets" +) +@pytest.mark.parametrize( + "variable,target", + list(LAND_TARGETS.items()), + ids=list(LAND_TARGETS.keys()), +) +def test_land_value_aggregate(baseline, variable, target): + """Check weighted aggregate land/property values against ONS targets.""" + weights = baseline.calculate("household_weight", period=YEAR).values + values = baseline.calculate(variable, map_to="household", period=YEAR).values + estimate = (values * weights).sum() + + rel_error = abs(estimate / target - 1) + assert rel_error < TOLERANCE, ( + f"{variable}: expected £{target / 1e12:.2f}tn, " + f"got £{estimate / 1e12:.2f}tn " + f"(relative error = {rel_error:.1%})" + ) + + +@pytest.mark.xfail( + reason="Will pass after recalibration with ONS land value targets" +) +def test_land_value_composition(baseline): + """Household + corporate land should equal total land value.""" + weights = baseline.calculate("household_weight", period=YEAR).values + total = baseline.calculate("land_value", map_to="household", period=YEAR).values + hh = baseline.calculate("household_land_value", map_to="household", period=YEAR).values + corp = baseline.calculate("corporate_land_value", map_to="household", period=YEAR).values + + total_agg = (total * weights).sum() + sum_agg = ((hh + corp) * weights).sum() + + assert abs(total_agg / sum_agg - 1) < 0.01, ( + f"Total land (£{total_agg / 1e12:.2f}tn) should equal " + f"household + corporate (£{sum_agg / 1e12:.2f}tn)" + ) + + +@pytest.mark.xfail( + reason="Will pass after recalibration with ONS land value targets" +) +def test_household_land_less_than_property_wealth(baseline): + """Household land value should not exceed total property wealth.""" + weights = baseline.calculate("household_weight", period=YEAR).values + hh_land = baseline.calculate("household_land_value", map_to="household", period=YEAR).values + prop = baseline.calculate("property_wealth", map_to="household", period=YEAR).values + + hh_land_agg = (hh_land * weights).sum() + prop_agg = (prop * weights).sum() + + assert hh_land_agg <= prop_agg * 1.05, ( + f"Household land (£{hh_land_agg / 1e12:.2f}tn) should not " + f"exceed property wealth (£{prop_agg / 1e12:.2f}tn)" + ) From c5f0b37a35bd3cdecc519f2142efd6d340823d62 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Mon, 16 Mar 2026 11:54:38 +0000 Subject: [PATCH 3/4] Add changelog fragment Co-Authored-By: Claude Opus 4.6 --- changelog.d/add-ons-land-value-targets.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/add-ons-land-value-targets.added.md diff --git a/changelog.d/add-ons-land-value-targets.added.md b/changelog.d/add-ons-land-value-targets.added.md new file mode 100644 index 00000000..ce3e9199 --- /dev/null +++ b/changelog.d/add-ons-land-value-targets.added.md @@ -0,0 +1 @@ +Add ONS land value calibration targets (household, corporate, total land, property wealth) from the National Balance Sheet 2025. From fb1985a8dcbdb9133880b7b96c1994c3d6cf05c6 Mon Sep 17 00:00:00 2001 From: Vahid Ahmadi Date: Mon, 16 Mar 2026 11:58:12 +0000 Subject: [PATCH 4/4] Format test file with ruff Co-Authored-By: Claude Opus 4.6 --- .../tests/test_land_value_targets.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/policyengine_uk_data/tests/test_land_value_targets.py b/policyengine_uk_data/tests/test_land_value_targets.py index 1ff9f403..0472890d 100644 --- a/policyengine_uk_data/tests/test_land_value_targets.py +++ b/policyengine_uk_data/tests/test_land_value_targets.py @@ -29,9 +29,7 @@ TOLERANCE = 0.30 # 30% relative error allowed -@pytest.mark.xfail( - reason="Will pass after recalibration with ONS land value targets" -) +@pytest.mark.xfail(reason="Will pass after recalibration with ONS land value targets") @pytest.mark.parametrize( "variable,target", list(LAND_TARGETS.items()), @@ -51,15 +49,17 @@ def test_land_value_aggregate(baseline, variable, target): ) -@pytest.mark.xfail( - reason="Will pass after recalibration with ONS land value targets" -) +@pytest.mark.xfail(reason="Will pass after recalibration with ONS land value targets") def test_land_value_composition(baseline): """Household + corporate land should equal total land value.""" weights = baseline.calculate("household_weight", period=YEAR).values total = baseline.calculate("land_value", map_to="household", period=YEAR).values - hh = baseline.calculate("household_land_value", map_to="household", period=YEAR).values - corp = baseline.calculate("corporate_land_value", map_to="household", period=YEAR).values + hh = baseline.calculate( + "household_land_value", map_to="household", period=YEAR + ).values + corp = baseline.calculate( + "corporate_land_value", map_to="household", period=YEAR + ).values total_agg = (total * weights).sum() sum_agg = ((hh + corp) * weights).sum() @@ -70,13 +70,13 @@ def test_land_value_composition(baseline): ) -@pytest.mark.xfail( - reason="Will pass after recalibration with ONS land value targets" -) +@pytest.mark.xfail(reason="Will pass after recalibration with ONS land value targets") def test_household_land_less_than_property_wealth(baseline): """Household land value should not exceed total property wealth.""" weights = baseline.calculate("household_weight", period=YEAR).values - hh_land = baseline.calculate("household_land_value", map_to="household", period=YEAR).values + hh_land = baseline.calculate( + "household_land_value", map_to="household", period=YEAR + ).values prop = baseline.calculate("property_wealth", map_to="household", period=YEAR).values hh_land_agg = (hh_land * weights).sum()