Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/land-target-series.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Refactor land calibration targets to share one annual ONS land-value series across national targets, regional targets, and tests, including 2021 to 2023 backfill values.
65 changes: 65 additions & 0 deletions policyengine_uk_data/targets/sources/_land.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Shared ONS land target series used across uk-data targets and tests.

This stays in uk-data for now because uk-data CI consumes the released
policyengine-uk package, not the in-flight repository branch. Once the
country package release includes the refreshed land series, this helper
can switch to importing that series directly.
"""

from __future__ import annotations

_ONS_2020_HOUSEHOLD = 4_309_138_000_000
_ONS_2020_CORPORATE = 1_757_818_000_000
_ONS_2020_TOTAL = _ONS_2020_HOUSEHOLD + _ONS_2020_CORPORATE
_HOUSEHOLD_SHARE = _ONS_2020_HOUSEHOLD / _ONS_2020_TOTAL
_CORPORATE_SHARE = _ONS_2020_CORPORATE / _ONS_2020_TOTAL

_OBSERVED_TOTAL_LAND_VALUES = {
2021: 7_106_785_000_000,
2022: 7_138_696_000_000,
2023: 6_756_315_000_000,
2024: 7_100_000_000_000,
}

_REF_URL = (
"https://www.ons.gov.uk/economy/nationalaccounts/"
"uksectoraccounts/bulletins/nationalbalancesheet/2025"
)


def _split_total_land(total_land: float) -> tuple[float, float]:
"""Split aggregate land by the latest direct household/corporate shares."""
return (
total_land * _HOUSEHOLD_SHARE,
total_land * _CORPORATE_SHARE,
)


HOUSEHOLD_LAND_VALUES = {
year: _split_total_land(total_land)[0]
for year, total_land in _OBSERVED_TOTAL_LAND_VALUES.items()
}

CORPORATE_LAND_VALUES = {
year: _split_total_land(total_land)[1]
for year, total_land in _OBSERVED_TOTAL_LAND_VALUES.items()
}

TOTAL_LAND_VALUES = {
**_OBSERVED_TOTAL_LAND_VALUES,
2025: _OBSERVED_TOTAL_LAND_VALUES[2024],
2026: _OBSERVED_TOTAL_LAND_VALUES[2024],
}

HOUSEHOLD_LAND_VALUES.update(
{
2025: HOUSEHOLD_LAND_VALUES[2024],
2026: HOUSEHOLD_LAND_VALUES[2024],
}
)
CORPORATE_LAND_VALUES.update(
{
2025: CORPORATE_LAND_VALUES[2024],
2026: CORPORATE_LAND_VALUES[2024],
}
)
63 changes: 22 additions & 41 deletions policyengine_uk_data/targets/sources/mhclg_regional_land.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,4 @@
"""Regional household land value targets.

Splits the ONS National Balance Sheet household land total across
regions in proportion to total property wealth (dwellings × avg
house price from UK HPI Dec 2025).

The model's regional intensity ratios (in policyengine-uk) handle the
conversion from property wealth to land value per household. These
targets ensure the weighted regional totals match official estimates.

Sources:
- UK House Price Index Dec 2025
https://www.gov.uk/government/statistics/uk-house-price-index-for-december-2025
- ONS National Balance Sheet 2025
https://www.ons.gov.uk/economy/nationalaccounts/uksectoraccounts/bulletins/nationalbalancesheet/2025

See: https://github.com/PolicyEngine/policyengine-uk-data/issues/314
"""
"""Regional household land value targets."""

import pandas as pd

Expand All @@ -24,36 +7,38 @@
Target,
Unit,
)
from policyengine_uk_data.targets.sources._common import STORAGE

# ONS National Balance Sheet 2025 — household land value
_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
_ONS_2024_HOUSEHOLD = _ONS_2020_HOUSEHOLD * _SCALE

_ONS_REF = (
"https://www.ons.gov.uk/economy/nationalaccounts/"
"uksectoraccounts/bulletins/nationalbalancesheet/2025"
from policyengine_uk_data.targets.sources._land import (
HOUSEHOLD_LAND_VALUES,
_REF_URL,
)
from policyengine_uk_data.targets.sources._common import STORAGE


def _compute_regional_targets() -> dict[str, float]:
"""Split the ONS household land total across regions.
def _compute_regional_shares() -> dict[str, float]:
"""Split household land totals across regions using fixed 2025 shares.

Each region's share is proportional to its total property wealth
(dwellings × avg_house_price). The shares are then scaled so the
GB total matches the ONS national household land value.
GB total sums to 1.
"""
csv_path = STORAGE / "regional_land_values.csv"
df = pd.read_csv(csv_path)

df["property_wealth"] = df["dwellings"] * df["avg_house_price"]
total = df["property_wealth"].sum()

return dict(zip(df["region"], df["property_wealth"] / total * _ONS_2024_HOUSEHOLD))
return dict(zip(df["region"], df["property_wealth"] / total))


def _compute_regional_targets() -> dict[str, dict[int, float]]:
"""Scale fixed regional shares by the national household-land series."""
shares = _compute_regional_shares()
return {
region: {
year: share * HOUSEHOLD_LAND_VALUES[year] for year in HOUSEHOLD_LAND_VALUES
}
for region, share in shares.items()
}


def get_targets() -> list[Target]:
Expand All @@ -73,12 +58,8 @@ def get_targets() -> list[Target]:
unit=Unit.GBP,
geographic_level=GeographicLevel.REGION,
geo_name=region,
values={
2024: value,
2025: value,
2026: value,
},
reference_url=_ONS_REF,
values=value,
reference_url=_REF_URL,
)
)

Expand Down
53 changes: 10 additions & 43 deletions policyengine_uk_data/targets/sources/ons_land_values.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,12 @@
"""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
"""
"""ONS National Balance Sheet land value targets."""

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"
from policyengine_uk_data.targets.sources._land import (
CORPORATE_LAND_VALUES,
HOUSEHOLD_LAND_VALUES,
TOTAL_LAND_VALUES,
_REF_URL,
)


def get_targets() -> list[Target]:
Expand All @@ -37,35 +16,23 @@ def get_targets() -> list[Target]:
variable="household_land_value",
source="ons",
unit=Unit.GBP,
values={
2024: _ONS_2024_HOUSEHOLD,
2025: _ONS_2024_HOUSEHOLD,
2026: _ONS_2024_HOUSEHOLD,
},
values=HOUSEHOLD_LAND_VALUES,
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,
},
values=CORPORATE_LAND_VALUES,
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,
},
values=TOTAL_LAND_VALUES,
reference_url=_REF_URL,
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reforms:
parameters:
gov.hmrc.child_benefit.amount.additional: 25
- name: Reduce Universal Credit taper rate to 20%
expected_impact: -39.0
expected_impact: -17.2
tolerance: 15.0
parameters:
gov.dwp.universal_credit.means_test.reduction_rate: 0.2
Expand Down
66 changes: 30 additions & 36 deletions policyengine_uk_data/tests/test_land_value_targets.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,24 @@
"""Tests for ONS land value calibration targets.

These validate that the generated Enhanced FRS dataset reproduces
aggregate land values from the ONS National
Balance Sheet 2025.

Source: https://www.ons.gov.uk/economy/nationalaccounts/uksectoraccounts/bulletins/nationalbalancesheet/2025
"""
"""Tests for ONS land value calibration targets."""

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
from policyengine_uk_data.targets.sources._land import (
CORPORATE_LAND_VALUES,
HOUSEHOLD_LAND_VALUES,
TOTAL_LAND_VALUES,
)

LAND_TARGETS = {
"land_value": _ONS_2024_TOTAL,
"household_land_value": _ONS_2020_HOUSEHOLD * _SCALE,
"corporate_land_value": _ONS_2020_CORPORATE * _SCALE,
"land_value": TOTAL_LAND_VALUES,
"household_land_value": HOUSEHOLD_LAND_VALUES,
"corporate_land_value": CORPORATE_LAND_VALUES,
}

YEAR = 2025
# The target series is backfilled to 2021, but the enhanced 2023/24 simulation
# fixture is only a stable regression base from its dataset year onward.
# Keep the broader year coverage in the target-registry tests, and only run the
# simulation-vs-target aggregate check for years the fixture can represent.
MODEL_CHECK_YEARS = [2023, 2025]

TOLERANCES = {
"land_value": 0.65,
"household_land_value": 0.65,
Expand All @@ -34,15 +28,13 @@
}


@pytest.mark.parametrize(
"variable,target",
list(LAND_TARGETS.items()),
ids=list(LAND_TARGETS.keys()),
)
def test_land_value_aggregate(baseline, variable, target):
@pytest.mark.parametrize("year", MODEL_CHECK_YEARS, ids=["2023", "2025"])
@pytest.mark.parametrize("variable", list(LAND_TARGETS), ids=list(LAND_TARGETS))
def test_land_value_aggregate(baseline, variable, year):
"""Check weighted aggregate land values against ONS targets."""
weights = baseline.calculate("household_weight", period=YEAR).values
values = baseline.calculate(variable, map_to="household", period=YEAR).values
target = LAND_TARGETS[variable][year]
weights = baseline.calculate("household_weight", period=year).values
values = baseline.calculate(variable, map_to="household", period=year).values
estimate = (values * weights).sum()

tolerance = TOLERANCES[variable]
Expand All @@ -56,13 +48,14 @@ def test_land_value_aggregate(baseline, variable, target):

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
year = 2025
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
"household_land_value", map_to="household", period=year
).values
corp = baseline.calculate(
"corporate_land_value", map_to="household", period=YEAR
"corporate_land_value", map_to="household", period=year
).values

total_agg = (total * weights).sum()
Expand All @@ -76,11 +69,12 @@ def test_land_value_composition(baseline):

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
year = 2025
weights = baseline.calculate("household_weight", period=year).values
hh_land = baseline.calculate(
"household_land_value", map_to="household", period=YEAR
"household_land_value", map_to="household", period=year
).values
prop = baseline.calculate("property_wealth", 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()
Expand Down
Loading
Loading