Skip to content

Commit 08fd066

Browse files
authored
Add Scotland and Wales private-rent calibration targets (#331)
* Add Scotland and Wales private-rent calibration targets * Format devolved housing targets * Use rent-average constraints for devolved housing targets * Wire devolved rent anchors into constituency calibration
1 parent 1050b25 commit 08fd066

6 files changed

Lines changed: 308 additions & 15 deletions

File tree

changelog.d/316.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add Scotland and Wales private-rent country targets so constituency calibration no longer relies only on UK-wide rent anchors for those countries.
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
"""Country-level private-rent anchors for constituency calibration.
2+
3+
These are used directly by the constituency loss builder rather than the
4+
general target registry, because constituency calibration uses a bespoke
5+
matrix constructor.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import numpy as np
11+
import pandas as pd
12+
13+
14+
_PRIVATE_RENT_TARGETS = {
15+
"WALES": {
16+
"private_renter_households": 200_700,
17+
"annual_private_rent": 795 * 12,
18+
},
19+
"SCOTLAND": {
20+
"private_renter_households": 357_706,
21+
"annual_private_rent": 999 * 12,
22+
},
23+
}
24+
25+
_CODE_TO_COUNTRY = {
26+
"W": "WALES",
27+
"S": "SCOTLAND",
28+
}
29+
30+
31+
def add_private_rent_targets(
32+
matrix: pd.DataFrame,
33+
y: pd.DataFrame,
34+
age_targets: pd.DataFrame,
35+
*,
36+
country: np.ndarray,
37+
tenure_type: np.ndarray,
38+
rent: np.ndarray,
39+
) -> None:
40+
"""Append Wales/Scotland private-rent count and amount targets.
41+
42+
Country totals are allocated across 2010 constituencies in proportion to
43+
their official age-target population shares within each country.
44+
"""
45+
46+
constituency_population = age_targets.filter(like="age/").sum(axis=1)
47+
constituency_country = age_targets["code"].str[0].map(_CODE_TO_COUNTRY)
48+
private_renter = tenure_type == "RENT_PRIVATELY"
49+
50+
for country_name, target in _PRIVATE_RENT_TARGETS.items():
51+
area_mask = constituency_country == country_name
52+
country_population = constituency_population.where(area_mask, 0).sum()
53+
if country_population <= 0:
54+
raise ValueError(
55+
f"No constituency population available for {country_name} housing targets"
56+
)
57+
58+
share = np.where(area_mask, constituency_population / country_population, 0.0)
59+
in_country_private_rent = (country == country_name) & private_renter
60+
prefix = country_name.lower()
61+
62+
matrix[f"housing/{prefix}_private_renter_households"] = (
63+
in_country_private_rent
64+
).astype(float)
65+
matrix[f"housing/{prefix}_private_rent_amount"] = np.where(
66+
in_country_private_rent,
67+
rent,
68+
0.0,
69+
)
70+
71+
y[f"housing/{prefix}_private_renter_households"] = (
72+
share * target["private_renter_households"]
73+
)
74+
y[f"housing/{prefix}_private_rent_amount"] = (
75+
share * target["private_renter_households"] * target["annual_private_rent"]
76+
)

policyengine_uk_data/datasets/local_areas/constituencies/loss.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
get_constituency_uc_targets,
3333
get_constituency_uc_by_children_targets,
3434
)
35+
from policyengine_uk_data.datasets.local_areas.constituencies.devolved_housing import (
36+
add_private_rent_targets,
37+
)
3538

3639

3740
def create_constituency_target_matrix(
@@ -114,6 +117,16 @@ def create_constituency_target_matrix(
114117
for col in uc_by_children.columns:
115118
y[col] = uc_by_children[col].values
116119

120+
# ── Wales/Scotland housing anchors ───────────────────────────────
121+
add_private_rent_targets(
122+
matrix,
123+
y,
124+
age_targets,
125+
country=sim.calculate("country").values,
126+
tenure_type=sim.calculate("tenure_type").values,
127+
rent=sim.calculate("rent").values,
128+
)
129+
117130
# ── Boundary mapping (2010 → 2024) ────────────────────────────
118131
const_2024 = pd.read_csv(STORAGE_FOLDER / "constituencies_2024.csv")
119132

policyengine_uk_data/targets/compute/households.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -60,28 +60,36 @@ def ft_hh(value):
6060
def compute_tenure(target, ctx) -> np.ndarray | None:
6161
"""Compute dwelling count by tenure type."""
6262
_TENURE_MAP = {
63-
"tenure_england_owned_outright": "OWNED_OUTRIGHT",
64-
"tenure_england_owned_with_mortgage": "OWNED_WITH_MORTGAGE",
65-
"tenure_england_rented_privately": "RENT_PRIVATELY",
66-
"tenure_england_social_rent": [
67-
"RENT_FROM_COUNCIL",
68-
"RENT_FROM_HA",
69-
],
70-
"tenure_england_total": None,
63+
"tenure_england_owned_outright": ("OWNED_OUTRIGHT", "ENGLAND"),
64+
"tenure_england_owned_with_mortgage": (
65+
"OWNED_WITH_MORTGAGE",
66+
"ENGLAND",
67+
),
68+
"tenure_england_rented_privately": ("RENT_PRIVATELY", "ENGLAND"),
69+
"tenure_england_social_rent": (
70+
[
71+
"RENT_FROM_COUNCIL",
72+
"RENT_FROM_HA",
73+
],
74+
"ENGLAND",
75+
),
76+
"tenure_england_total": (None, "ENGLAND"),
7177
}
72-
suffix = target.name.removeprefix("ons/")
73-
pe_values = _TENURE_MAP.get(suffix)
74-
if pe_values is None and suffix == "tenure_england_total":
75-
return (ctx.country == "ENGLAND").astype(float)
76-
if pe_values is None:
78+
suffix = target.name.split("/", 1)[-1]
79+
tenure_spec = _TENURE_MAP.get(suffix)
80+
if tenure_spec is None:
7781
return None
7882

83+
pe_values, country = tenure_spec
84+
if pe_values is None:
85+
return (ctx.country == country).astype(float)
86+
7987
tenure = ctx.sim.calculate("tenure_type", map_to="household").values
80-
in_england = ctx.country == "ENGLAND"
88+
in_country = ctx.country == country
8189
if isinstance(pe_values, list):
8290
match = np.zeros_like(tenure, dtype=bool)
8391
for v in pe_values:
8492
match = match | (tenure == v)
8593
else:
8694
match = tenure == pe_values
87-
return (match & in_england).astype(float)
95+
return (match & in_country).astype(float)

policyengine_uk_data/targets/compute/other.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ def compute_housing(target, ctx) -> np.ndarray:
3535
return ctx.pe("mortgage_capital_repayment") + ctx.pe(
3636
"mortgage_interest_repayment"
3737
)
38+
3839
tenure = ctx.sim.calculate("tenure_type", map_to="household").values
3940
if name == "housing/rent_social":
4041
is_social = (tenure == "RENT_FROM_COUNCIL") | (tenure == "RENT_FROM_HA")
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import numpy as np
2+
import pandas as pd
3+
4+
from policyengine_uk_data.datasets.local_areas.constituencies.devolved_housing import (
5+
_PRIVATE_RENT_TARGETS,
6+
add_private_rent_targets,
7+
)
8+
from policyengine_uk_data.datasets.local_areas.constituencies import (
9+
loss as constituency_loss,
10+
)
11+
12+
13+
def _age_targets():
14+
return pd.DataFrame(
15+
{
16+
"code": ["W07000041", "W07000042", "S14000001", "S14000002"],
17+
"name": ["W1", "W2", "S1", "S2"],
18+
"age/0_10": [100, 300, 200, 200],
19+
"age/10_20": [100, 300, 200, 200],
20+
}
21+
)
22+
23+
24+
def test_add_private_rent_targets_filters_matrix_to_country_private_renters():
25+
matrix = pd.DataFrame()
26+
y = pd.DataFrame()
27+
28+
add_private_rent_targets(
29+
matrix,
30+
y,
31+
_age_targets(),
32+
country=np.array(["WALES", "WALES", "SCOTLAND", "ENGLAND"]),
33+
tenure_type=np.array(
34+
["RENT_PRIVATELY", "OWNED_OUTRIGHT", "RENT_PRIVATELY", "RENT_PRIVATELY"]
35+
),
36+
rent=np.array([9_600.0, 0.0, 12_000.0, 15_000.0]),
37+
)
38+
39+
np.testing.assert_array_equal(
40+
matrix["housing/wales_private_renter_households"].values,
41+
np.array([1.0, 0.0, 0.0, 0.0]),
42+
)
43+
np.testing.assert_array_equal(
44+
matrix["housing/scotland_private_renter_households"].values,
45+
np.array([0.0, 0.0, 1.0, 0.0]),
46+
)
47+
np.testing.assert_array_equal(
48+
matrix["housing/wales_private_rent_amount"].values,
49+
np.array([9_600.0, 0.0, 0.0, 0.0]),
50+
)
51+
np.testing.assert_array_equal(
52+
matrix["housing/scotland_private_rent_amount"].values,
53+
np.array([0.0, 0.0, 12_000.0, 0.0]),
54+
)
55+
56+
57+
def test_add_private_rent_targets_allocate_country_totals_by_population_share():
58+
matrix = pd.DataFrame()
59+
y = pd.DataFrame()
60+
61+
add_private_rent_targets(
62+
matrix,
63+
y,
64+
_age_targets(),
65+
country=np.array(["WALES", "SCOTLAND"]),
66+
tenure_type=np.array(["RENT_PRIVATELY", "RENT_PRIVATELY"]),
67+
rent=np.array([9_600.0, 12_000.0]),
68+
)
69+
70+
wales_shares = np.array([0.25, 0.75, 0.0, 0.0])
71+
scotland_shares = np.array([0.0, 0.0, 0.5, 0.5])
72+
73+
np.testing.assert_allclose(
74+
y["housing/wales_private_renter_households"].values,
75+
wales_shares * _PRIVATE_RENT_TARGETS["WALES"]["private_renter_households"],
76+
)
77+
np.testing.assert_allclose(
78+
y["housing/scotland_private_renter_households"].values,
79+
scotland_shares
80+
* _PRIVATE_RENT_TARGETS["SCOTLAND"]["private_renter_households"],
81+
)
82+
np.testing.assert_allclose(
83+
y["housing/wales_private_rent_amount"].values.sum(),
84+
_PRIVATE_RENT_TARGETS["WALES"]["private_renter_households"]
85+
* _PRIVATE_RENT_TARGETS["WALES"]["annual_private_rent"],
86+
)
87+
np.testing.assert_allclose(
88+
y["housing/scotland_private_rent_amount"].values.sum(),
89+
_PRIVATE_RENT_TARGETS["SCOTLAND"]["private_renter_households"]
90+
* _PRIVATE_RENT_TARGETS["SCOTLAND"]["annual_private_rent"],
91+
)
92+
93+
94+
class _FakeDataset:
95+
time_period = 2025
96+
97+
98+
class _FakeSim:
99+
def __init__(self, *args, **kwargs):
100+
self.default_calculation_period = 2025
101+
102+
def calculate(self, variable):
103+
mapping = {
104+
"self_employment_income": np.array([0.0, 0.0]),
105+
"employment_income": np.array([0.0, 0.0]),
106+
"income_tax": np.array([1.0, 1.0]),
107+
"age": np.array([35, 35]),
108+
"universal_credit": np.array([1.0, 1.0]),
109+
"is_child": np.array([0.0, 0.0]),
110+
"country": np.array(["WALES", "SCOTLAND"]),
111+
"tenure_type": np.array(["RENT_PRIVATELY", "RENT_PRIVATELY"]),
112+
"rent": np.array([9_600.0, 12_000.0]),
113+
}
114+
return type("Result", (), {"values": mapping[variable]})()
115+
116+
def map_result(self, values, source_entity, target_entity):
117+
return np.asarray(values)
118+
119+
120+
def test_constituency_target_matrix_includes_devolved_housing_targets(monkeypatch):
121+
age_targets = _age_targets().iloc[[0, 2]].reset_index(drop=True)
122+
income_targets = pd.DataFrame(
123+
{
124+
"self_employment_income_amount": [1.0, 1.0],
125+
"self_employment_income_count": [1.0, 1.0],
126+
"employment_income_amount": [1.0, 1.0],
127+
"employment_income_count": [1.0, 1.0],
128+
}
129+
)
130+
national_income = pd.DataFrame(
131+
{
132+
"total_income_lower_bound": [12_570],
133+
"total_income_upper_bound": [np.inf],
134+
"self_employment_income_amount": [1.0],
135+
"employment_income_amount": [1.0],
136+
}
137+
)
138+
uc_by_children = pd.DataFrame(
139+
{
140+
"uc_hh_0_children": [1.0, 1.0],
141+
"uc_hh_1_child": [0.0, 0.0],
142+
"uc_hh_2_children": [0.0, 0.0],
143+
"uc_hh_3plus_children": [0.0, 0.0],
144+
}
145+
)
146+
147+
monkeypatch.setattr(constituency_loss, "Microsimulation", _FakeSim)
148+
monkeypatch.setattr(
149+
constituency_loss, "get_constituency_income_targets", lambda: income_targets
150+
)
151+
monkeypatch.setattr(
152+
constituency_loss,
153+
"get_national_income_projections",
154+
lambda year: national_income,
155+
)
156+
monkeypatch.setattr(
157+
constituency_loss, "get_constituency_age_targets", lambda: age_targets
158+
)
159+
monkeypatch.setattr(constituency_loss, "get_uk_total_population", lambda year: 2.0)
160+
monkeypatch.setattr(
161+
constituency_loss,
162+
"get_constituency_uc_targets",
163+
lambda: pd.Series([1.0, 1.0]),
164+
)
165+
monkeypatch.setattr(
166+
constituency_loss,
167+
"get_constituency_uc_by_children_targets",
168+
lambda: uc_by_children,
169+
)
170+
monkeypatch.setattr(constituency_loss, "mapping_matrix", np.eye(2))
171+
monkeypatch.setattr(
172+
constituency_loss.pd,
173+
"read_csv",
174+
lambda path: pd.DataFrame({"code": ["W07000041", "S14000001"]}),
175+
)
176+
177+
matrix, y, country_mask = constituency_loss.create_constituency_target_matrix(
178+
_FakeDataset()
179+
)
180+
181+
assert "housing/wales_private_renter_households" in matrix.columns
182+
assert "housing/scotland_private_rent_amount" in matrix.columns
183+
np.testing.assert_allclose(
184+
y["housing/wales_private_renter_households"].values,
185+
np.array([_PRIVATE_RENT_TARGETS["WALES"]["private_renter_households"], 0.0]),
186+
)
187+
np.testing.assert_allclose(
188+
y["housing/scotland_private_renter_households"].values,
189+
np.array([0.0, _PRIVATE_RENT_TARGETS["SCOTLAND"]["private_renter_households"]]),
190+
)
191+
np.testing.assert_array_equal(
192+
country_mask,
193+
np.array([[1.0, 0.0], [0.0, 1.0]]),
194+
)

0 commit comments

Comments
 (0)