gla-data 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gla_data/__init__.py +16 -0
- gla_data/_arcgis.py +112 -0
- gla_data/_cache.py +48 -0
- gla_data/_ons.py +207 -0
- gla_data/_schemas.py +68 -0
- gla_data/_ukds.py +55 -0
- gla_data/catalogue.py +62 -0
- gla_data/census.py +101 -0
- gla_data/cli.py +35 -0
- gla_data/config.py +20 -0
- gla_data/geography.py +156 -0
- gla_data/py.typed +0 -0
- gla_data/transforms.py +244 -0
- gla_data-0.1.0.dist-info/METADATA +43 -0
- gla_data-0.1.0.dist-info/RECORD +17 -0
- gla_data-0.1.0.dist-info/WHEEL +4 -0
- gla_data-0.1.0.dist-info/entry_points.txt +2 -0
gla_data/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from importlib.metadata import version
|
|
2
|
+
|
|
3
|
+
from gla_data.catalogue import catalogue
|
|
4
|
+
from gla_data.census import load_census_dwellings
|
|
5
|
+
from gla_data.geography import load_boundaries, load_geography_lookup
|
|
6
|
+
from gla_data.transforms import aggregate, crosswalk
|
|
7
|
+
|
|
8
|
+
__version__ = version("gla-data")
|
|
9
|
+
__all__ = [
|
|
10
|
+
"load_boundaries",
|
|
11
|
+
"crosswalk",
|
|
12
|
+
"aggregate",
|
|
13
|
+
"load_geography_lookup",
|
|
14
|
+
"load_census_dwellings",
|
|
15
|
+
"catalogue",
|
|
16
|
+
]
|
gla_data/_arcgis.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Private module for querying ArcGIS FeatureServer REST APIs.
|
|
2
|
+
|
|
3
|
+
Implements paginated attribute and GeoJSON queries against the ArcGIS
|
|
4
|
+
FeatureServer /query endpoint. The WHERE clause uses the ArcGIS SQL dialect:
|
|
5
|
+
equality, LIKE with % wildcard, IN lists, and 1=1 for no filter. Subqueries
|
|
6
|
+
and joins are not supported.
|
|
7
|
+
|
|
8
|
+
Documentation:
|
|
9
|
+
https://developers.arcgis.com/rest/services-reference/enterprise/query-feature-service-layer/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
PAGE_SIZE = 1000
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def query_attributes(
|
|
20
|
+
base_url: str,
|
|
21
|
+
service_name: str,
|
|
22
|
+
where: str = "1=1",
|
|
23
|
+
out_fields: str = "*",
|
|
24
|
+
) -> list[dict[str, Any]]:
|
|
25
|
+
"""Query an ArcGIS FeatureServer layer, returning attributes only (no geometry).
|
|
26
|
+
|
|
27
|
+
Results are paginated automatically using ``resultOffset``.
|
|
28
|
+
"""
|
|
29
|
+
url = f"{base_url}/{service_name}/FeatureServer/0/query"
|
|
30
|
+
attributes: list[dict[str, Any]] = []
|
|
31
|
+
offset = 0
|
|
32
|
+
|
|
33
|
+
while True:
|
|
34
|
+
response = requests.post(
|
|
35
|
+
url,
|
|
36
|
+
data={
|
|
37
|
+
"where": where,
|
|
38
|
+
"outFields": out_fields,
|
|
39
|
+
"f": "json",
|
|
40
|
+
"returnGeometry": "false",
|
|
41
|
+
"resultOffset": offset,
|
|
42
|
+
"resultRecordCount": PAGE_SIZE,
|
|
43
|
+
},
|
|
44
|
+
)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
result = response.json()
|
|
47
|
+
|
|
48
|
+
if "error" in result:
|
|
49
|
+
raise RuntimeError(
|
|
50
|
+
f"ArcGIS API error querying {service_name!r}: {result['error']}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
batch = result.get("features", [])
|
|
54
|
+
attributes.extend(f["attributes"] for f in batch)
|
|
55
|
+
|
|
56
|
+
if len(batch) < PAGE_SIZE:
|
|
57
|
+
break
|
|
58
|
+
offset += PAGE_SIZE
|
|
59
|
+
|
|
60
|
+
return attributes
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def query_geojson(
|
|
64
|
+
base_url: str,
|
|
65
|
+
service_name: str,
|
|
66
|
+
where: str = "1=1",
|
|
67
|
+
out_fields: str = "*",
|
|
68
|
+
out_sr: int = 27700,
|
|
69
|
+
) -> list[dict[str, Any]]:
|
|
70
|
+
"""Query an ArcGIS FeatureServer layer, returning GeoJSON features with geometry.
|
|
71
|
+
|
|
72
|
+
Returns a list of GeoJSON feature dicts (each with ``geometry`` and
|
|
73
|
+
``properties`` keys). Uses POST to avoid URL length limits for large IN clauses.
|
|
74
|
+
|
|
75
|
+
Note: GeoJSON is formally defined to use WGS84, but ArcGIS honours ``outSR``
|
|
76
|
+
and returns coordinates in the requested CRS. The caller is responsible for
|
|
77
|
+
passing the same EPSG code to ``GeoDataFrame.from_features``.
|
|
78
|
+
|
|
79
|
+
Results are paginated automatically using ``resultOffset``.
|
|
80
|
+
"""
|
|
81
|
+
url = f"{base_url}/{service_name}/FeatureServer/0/query"
|
|
82
|
+
features = []
|
|
83
|
+
offset = 0
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
response = requests.post(
|
|
87
|
+
url,
|
|
88
|
+
data={
|
|
89
|
+
"where": where,
|
|
90
|
+
"outFields": out_fields,
|
|
91
|
+
"outSR": out_sr,
|
|
92
|
+
"f": "geojson",
|
|
93
|
+
"resultOffset": offset,
|
|
94
|
+
"resultRecordCount": PAGE_SIZE,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
response.raise_for_status()
|
|
98
|
+
result = response.json()
|
|
99
|
+
|
|
100
|
+
if "error" in result:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"ArcGIS API error querying {service_name!r}: {result['error']}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
batch = result.get("features", [])
|
|
106
|
+
features.extend(batch)
|
|
107
|
+
|
|
108
|
+
if len(batch) < PAGE_SIZE:
|
|
109
|
+
break
|
|
110
|
+
offset += PAGE_SIZE
|
|
111
|
+
|
|
112
|
+
return features
|
gla_data/_cache.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared disk cache for slow external data fetches."""
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import geopandas as gpd
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
CACHE_DIR = Path.home() / ".cache" / "gla_data"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cache_path(key: str) -> Path:
|
|
13
|
+
return CACHE_DIR / f"{key}.parquet"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_cache(key: str) -> pd.DataFrame | gpd.GeoDataFrame | None:
|
|
17
|
+
path = cache_path(key)
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return None
|
|
20
|
+
# GeoDataFrame parquet files contain geometry metadata that gpd.read_parquet
|
|
21
|
+
# understands; plain DataFrames can also be read by gpd but return without
|
|
22
|
+
# a geometry column. We try gpd first and fall back to pd if it errors.
|
|
23
|
+
try:
|
|
24
|
+
return gpd.read_parquet(path)
|
|
25
|
+
except Exception:
|
|
26
|
+
return pd.read_parquet(path)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def write_cache(key: str, df: pd.DataFrame | gpd.GeoDataFrame) -> None:
|
|
30
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
df.to_parquet(cache_path(key), index=False)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def warn_slow_fetch(function_name: str, key: str, cache: bool) -> None:
|
|
35
|
+
path = cache_path(key)
|
|
36
|
+
if cache:
|
|
37
|
+
detail = (
|
|
38
|
+
f"The result will be cached to {path} for future calls. "
|
|
39
|
+
f"Pass cache=False to disable caching."
|
|
40
|
+
)
|
|
41
|
+
else:
|
|
42
|
+
detail = "Pass cache=True to cache the result and avoid this on future calls."
|
|
43
|
+
warnings.warn(
|
|
44
|
+
f"{function_name} fetches data from an external source and may be slow. "
|
|
45
|
+
f"{detail}",
|
|
46
|
+
UserWarning,
|
|
47
|
+
stacklevel=3,
|
|
48
|
+
)
|
gla_data/_ons.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Private module for fetching data from the ONS Open Geography Portal.
|
|
2
|
+
|
|
3
|
+
The ONS Open Geography Portal exposes boundaries and lookup tables as ArcGIS
|
|
4
|
+
FeatureServer layers queried via _arcgis. Service names and field mappings are
|
|
5
|
+
defined here; HTTP mechanics live in _arcgis.
|
|
6
|
+
|
|
7
|
+
Browse available datasets: https://geoportal.statistics.gov.uk
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import geopandas as gpd
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from gla_data._arcgis import query_attributes, query_geojson
|
|
16
|
+
|
|
17
|
+
ONS_ARCGIS_BASE = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"
|
|
18
|
+
BATCH_SIZE = 500
|
|
19
|
+
|
|
20
|
+
# London borough LAD codes all begin with E09 — a permanent ONS GSS prefix.
|
|
21
|
+
# E = England, 09 = London Borough at the LAD level.
|
|
22
|
+
LONDON_LAD_PREFIX = "E09"
|
|
23
|
+
|
|
24
|
+
# (geography, year) -> (service_name, code_col, name_col)
|
|
25
|
+
# BFC = Full Resolution Clipped, BFE = Full Resolution Extent,
|
|
26
|
+
# BSC = Super Generalised Clipped.
|
|
27
|
+
BOUNDARY_SERVICES: dict[tuple[str, int | None], tuple[str, str, str]] = {
|
|
28
|
+
("lsoa", 2021): (
|
|
29
|
+
"Lower_layer_Super_Output_Areas_December_2021_Boundaries_EW_BFC_V10",
|
|
30
|
+
"LSOA21CD",
|
|
31
|
+
"LSOA21NM",
|
|
32
|
+
),
|
|
33
|
+
("lsoa", 2011): (
|
|
34
|
+
"LSOA_2011_Boundaries_Super_Generalised_Clipped_BSC_EW_V4",
|
|
35
|
+
"LSOA11CD",
|
|
36
|
+
"LSOA11NM",
|
|
37
|
+
),
|
|
38
|
+
("msoa", 2021): (
|
|
39
|
+
"Middle_layer_Super_Output_Areas_December_2021_Boundaries_EW_BFE_V8",
|
|
40
|
+
"MSOA21CD",
|
|
41
|
+
"MSOA21NM",
|
|
42
|
+
),
|
|
43
|
+
("msoa", 2011): (
|
|
44
|
+
"Middle_layer_Super_Output_Areas_Dec_2011_Boundaries_Full_Clipped_BFC_EW_V3_2022",
|
|
45
|
+
"MSOA11CD",
|
|
46
|
+
"MSOA11NM",
|
|
47
|
+
),
|
|
48
|
+
("borough", 2021): (
|
|
49
|
+
"LAD_Dec_2021_GB_BFC_2022",
|
|
50
|
+
"LAD21CD",
|
|
51
|
+
"LAD21NM",
|
|
52
|
+
),
|
|
53
|
+
("borough", 2011): (
|
|
54
|
+
"Local_Authority_Districts_December_2011_FCB_EW_2022",
|
|
55
|
+
"LAD11CD",
|
|
56
|
+
"LAD11NM",
|
|
57
|
+
),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# (geography, year) -> (service_name, code_col, lad_col)
|
|
61
|
+
# Lookup tables used to find London codes before fetching boundaries.
|
|
62
|
+
LOOKUP_SERVICES: dict[tuple[str, int], tuple[str, str, str]] = {
|
|
63
|
+
("lsoa", 2021): ("LSOA21_LAD23_EW_LU", "LSOA21CD", "LAD23CD"),
|
|
64
|
+
("lsoa", 2011): ("LSOA11_LSOA21_LAD22_EW_LU_v5", "LSOA11CD", "LAD22CD"),
|
|
65
|
+
("msoa", 2021): ("MSOA11_MSOA21_LAD22_EW_LU_v2", "MSOA21CD", "LAD22CD"),
|
|
66
|
+
("msoa", 2011): ("MSOA11_MSOA21_LAD22_EW_LU_v2", "MSOA11CD", "LAD22CD"),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# year -> (service_name, lad_col, oa_col, out_fields)
|
|
70
|
+
# OA-grain lookup tables covering OA, LSOA, MSOA, and LAD for England and Wales.
|
|
71
|
+
# The 2011 table uses 2020 LAD boundaries — this is what ONS provides at this grain.
|
|
72
|
+
GEOGRAPHY_LOOKUP_SERVICES: dict[int, tuple[str, str, str, str]] = {
|
|
73
|
+
2021: (
|
|
74
|
+
"OA_LSOA_MSOA_EW_DEC_2021_LU_v3",
|
|
75
|
+
"LAD22CD",
|
|
76
|
+
"OA21CD",
|
|
77
|
+
"OA21CD,LSOA21CD,LSOA21NM,MSOA21CD,MSOA21NM,LAD22CD,LAD22NM",
|
|
78
|
+
),
|
|
79
|
+
2011: (
|
|
80
|
+
"OA11_LSOA11_MSOA11_LAD20_RGN20_EW_LU_a1cf695c9b074c708921b2a7555f808a",
|
|
81
|
+
"LAD20CD",
|
|
82
|
+
"OA11CD",
|
|
83
|
+
"OA11CD,LSOA11CD,LSOA11NM,MSOA11CD,MSOA11NM,LAD20CD,LAD20NM",
|
|
84
|
+
),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def london_codes(geography: str, year: int) -> list[str]:
|
|
89
|
+
"""Return geography codes for all London areas using the ONS lookup table."""
|
|
90
|
+
key = (geography, year)
|
|
91
|
+
if key not in LOOKUP_SERVICES:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"No London lookup available for geography={geography!r}, year={year}"
|
|
94
|
+
)
|
|
95
|
+
service_name, code_col, lad_col = LOOKUP_SERVICES[key]
|
|
96
|
+
rows = query_attributes(
|
|
97
|
+
ONS_ARCGIS_BASE,
|
|
98
|
+
service_name,
|
|
99
|
+
where=f"{lad_col} LIKE '{LONDON_LAD_PREFIX}%'",
|
|
100
|
+
out_fields=code_col,
|
|
101
|
+
)
|
|
102
|
+
return list({row[code_col] for row in rows})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def fetch_boundaries_for_codes(
|
|
106
|
+
service_name: str,
|
|
107
|
+
code_col: str,
|
|
108
|
+
name_col: str,
|
|
109
|
+
codes: list[str],
|
|
110
|
+
) -> list[dict[str, Any]]:
|
|
111
|
+
"""Fetch boundary GeoJSON features for a list of geography codes in batches."""
|
|
112
|
+
features = []
|
|
113
|
+
for i in range(0, len(codes), BATCH_SIZE):
|
|
114
|
+
batch = codes[i : i + BATCH_SIZE]
|
|
115
|
+
codes_str = ",".join(f"'{c}'" for c in batch)
|
|
116
|
+
features.extend(
|
|
117
|
+
query_geojson(
|
|
118
|
+
ONS_ARCGIS_BASE,
|
|
119
|
+
service_name,
|
|
120
|
+
where=f"{code_col} IN ({codes_str})",
|
|
121
|
+
out_fields=f"{code_col},{name_col}",
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
return features
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def fetch_boundaries(geography: str, year: int | None) -> gpd.GeoDataFrame:
|
|
128
|
+
"""Fetch London boundary polygons from ONS for the given geography and year.
|
|
129
|
+
|
|
130
|
+
Returns a GeoDataFrame in EPSG:27700 (British National Grid).
|
|
131
|
+
"""
|
|
132
|
+
key = (geography, year)
|
|
133
|
+
if key not in BOUNDARY_SERVICES:
|
|
134
|
+
available = sorted(f"geography={g!r}, year={y}" for g, y in BOUNDARY_SERVICES)
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"No boundary data available for geography={geography!r}, year={year}. "
|
|
137
|
+
f"Available combinations: {available}"
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
service_name, code_col, name_col = BOUNDARY_SERVICES[key]
|
|
141
|
+
|
|
142
|
+
if geography == "borough":
|
|
143
|
+
features = query_geojson(
|
|
144
|
+
ONS_ARCGIS_BASE,
|
|
145
|
+
service_name,
|
|
146
|
+
where=f"{code_col} LIKE '{LONDON_LAD_PREFIX}%'",
|
|
147
|
+
out_fields=f"{code_col},{name_col}",
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
codes = london_codes(geography, year) # type: ignore[arg-type]
|
|
151
|
+
features = fetch_boundaries_for_codes(service_name, code_col, name_col, codes)
|
|
152
|
+
|
|
153
|
+
if not features:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"No features returned from ONS for geography={geography!r}, year={year}"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
gdf = gpd.GeoDataFrame.from_features(features, crs=27700)
|
|
159
|
+
gdf.columns = [c.upper() if c != "geometry" else c for c in gdf.columns]
|
|
160
|
+
return gdf.reset_index(drop=True)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def fetch_lsoa_crosswalk() -> pd.DataFrame:
|
|
164
|
+
"""Fetch the ONS LSOA 2011→2021 exact-fit crosswalk for London.
|
|
165
|
+
|
|
166
|
+
Returns one row per 2011→2021 LSOA pair. CHGIND: U=unchanged, X=split, M=merged.
|
|
167
|
+
"""
|
|
168
|
+
rows = query_attributes(
|
|
169
|
+
ONS_ARCGIS_BASE,
|
|
170
|
+
"LSOA11_LSOA21_LAD22_EW_LU_v5",
|
|
171
|
+
where=f"LAD22CD LIKE '{LONDON_LAD_PREFIX}%'",
|
|
172
|
+
out_fields="LSOA11CD,LSOA21CD,CHGIND",
|
|
173
|
+
)
|
|
174
|
+
return pd.DataFrame(rows)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def fetch_geography_lookup(year: int, smallest_geography: str) -> pd.DataFrame:
|
|
178
|
+
"""Fetch the ONS geography lookup table for London.
|
|
179
|
+
|
|
180
|
+
Source tables are at OA grain. For smallest_geography="lsoa", deduplicates
|
|
181
|
+
to LSOA grain by dropping the OA column.
|
|
182
|
+
"""
|
|
183
|
+
if year not in GEOGRAPHY_LOOKUP_SERVICES:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"year={year!r} is not supported. "
|
|
186
|
+
f"Available years: {sorted(GEOGRAPHY_LOOKUP_SERVICES)}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
service_name, lad_col, oa_col, out_fields = GEOGRAPHY_LOOKUP_SERVICES[year]
|
|
190
|
+
rows = query_attributes(
|
|
191
|
+
ONS_ARCGIS_BASE,
|
|
192
|
+
service_name,
|
|
193
|
+
where=f"{lad_col} LIKE '{LONDON_LAD_PREFIX}%'",
|
|
194
|
+
out_fields=out_fields,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
df = pd.DataFrame(rows)
|
|
198
|
+
yr = str(year)[2:]
|
|
199
|
+
|
|
200
|
+
if smallest_geography == "lsoa":
|
|
201
|
+
df = df.drop(columns=[oa_col]).drop_duplicates(subset=f"LSOA{yr}CD")
|
|
202
|
+
elif smallest_geography == "msoa":
|
|
203
|
+
df = df.drop(columns=[oa_col, f"LSOA{yr}CD", f"LSOA{yr}NM"]).drop_duplicates(
|
|
204
|
+
subset=f"MSOA{yr}CD"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return df.reset_index(drop=True)
|
gla_data/_schemas.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Output schemas for gla_data public functions.
|
|
2
|
+
|
|
3
|
+
Each public load function validates its output against a schema defined here.
|
|
4
|
+
This catches upstream data format changes (e.g. ONS renaming a column) at the
|
|
5
|
+
point of load rather than surfacing as a confusing error in user code.
|
|
6
|
+
|
|
7
|
+
Schemas are constructed as factory functions rather than class-level constants
|
|
8
|
+
because the expected columns depend on function parameters (year, geography).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pandera as pa
|
|
12
|
+
|
|
13
|
+
# LAD column year suffix varies by lookup vintage — this is what ONS provides.
|
|
14
|
+
LAD_YEAR_BY_LOOKUP_YEAR = {2021: "22", 2011: "20"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def boundary_schema(geography: str, year: int | None) -> pa.DataFrameSchema:
|
|
18
|
+
"""Schema for the output of load_boundaries()."""
|
|
19
|
+
yr = str(year)[2:]
|
|
20
|
+
geo = geography.upper()
|
|
21
|
+
|
|
22
|
+
if geography == "borough":
|
|
23
|
+
code_col = f"LAD{yr}CD"
|
|
24
|
+
name_col = f"LAD{yr}NM"
|
|
25
|
+
else:
|
|
26
|
+
code_col = f"{geo}{yr}CD"
|
|
27
|
+
name_col = f"{geo}{yr}NM"
|
|
28
|
+
|
|
29
|
+
return pa.DataFrameSchema( # type: ignore[no-untyped-call]
|
|
30
|
+
{
|
|
31
|
+
code_col: pa.Column(str, nullable=False),
|
|
32
|
+
name_col: pa.Column(str, nullable=False),
|
|
33
|
+
},
|
|
34
|
+
coerce=False,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def geography_lookup_schema(year: int, smallest_geography: str) -> pa.DataFrameSchema:
|
|
39
|
+
"""Schema for the output of load_geography_lookup()."""
|
|
40
|
+
yr = str(year)[2:]
|
|
41
|
+
lad_yr = LAD_YEAR_BY_LOOKUP_YEAR[year]
|
|
42
|
+
cols: dict[str, pa.Column] = {}
|
|
43
|
+
|
|
44
|
+
if smallest_geography == "oa":
|
|
45
|
+
cols[f"OA{yr}CD"] = pa.Column(str, nullable=False)
|
|
46
|
+
|
|
47
|
+
if smallest_geography in ("oa", "lsoa"):
|
|
48
|
+
cols[f"LSOA{yr}CD"] = pa.Column(str, nullable=False)
|
|
49
|
+
cols[f"LSOA{yr}NM"] = pa.Column(str, nullable=False)
|
|
50
|
+
|
|
51
|
+
cols[f"MSOA{yr}CD"] = pa.Column(str, nullable=False)
|
|
52
|
+
cols[f"MSOA{yr}NM"] = pa.Column(str, nullable=False)
|
|
53
|
+
cols[f"LAD{lad_yr}CD"] = pa.Column(str, nullable=False)
|
|
54
|
+
cols[f"LAD{lad_yr}NM"] = pa.Column(str, nullable=False)
|
|
55
|
+
|
|
56
|
+
return pa.DataFrameSchema(cols, coerce=False) # type: ignore[no-untyped-call]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def census_dwellings_schema(year: int) -> pa.DataFrameSchema:
|
|
60
|
+
"""Schema for the output of load_census_dwellings()."""
|
|
61
|
+
yr = str(year)[2:]
|
|
62
|
+
return pa.DataFrameSchema( # type: ignore[no-untyped-call]
|
|
63
|
+
{
|
|
64
|
+
f"LSOA{yr}CD": pa.Column(str, nullable=False),
|
|
65
|
+
"dwellings_total": pa.Column(int, pa.Check.ge(0), nullable=False),
|
|
66
|
+
},
|
|
67
|
+
coerce=False,
|
|
68
|
+
)
|
gla_data/_ukds.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Private module for downloading datasets from the UK Data Service (UKDS).
|
|
2
|
+
|
|
3
|
+
UK Data Service is the principal repository for economic, population, and
|
|
4
|
+
social research data in the UK, funded by UKRI. Datasets are available at:
|
|
5
|
+
https://ukdataservice.ac.uk
|
|
6
|
+
|
|
7
|
+
UKDS hosts data as static files. There is no query API — each entry below
|
|
8
|
+
is a direct download URL. Files are downloaded in full and filtered
|
|
9
|
+
client-side where needed.
|
|
10
|
+
|
|
11
|
+
To browse available datasets: https://beta.ukdataservice.ac.uk/datacatalogue/
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import io
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
# Registry of all UKDS datasets used in this package.
|
|
21
|
+
# Each entry: dataset_key -> (url, format, description)
|
|
22
|
+
# format is "csv" or "excel".
|
|
23
|
+
DATASETS: dict[str, tuple[str, str, str]] = {
|
|
24
|
+
"census_dwellings_2011": (
|
|
25
|
+
"https://ukds-ckan.s3.eu-west-1.amazonaws.com/DWLTYP/DWLTYP_LSOADZ_England_Northern_Ireland_Scotland_Wales_Descriptions.csv",
|
|
26
|
+
"csv",
|
|
27
|
+
"QS418EW - Dwellings, 2011 Census, LSOA level, England and Wales",
|
|
28
|
+
),
|
|
29
|
+
"census_dwellings_2021": (
|
|
30
|
+
"https://ukds-ckan.s3.eu-west-1.amazonaws.com/2021/ONS/number-of-dwellings/RM204-Number-Of-Dwellings-2021-lsoa-ONS.xlsx",
|
|
31
|
+
"excel",
|
|
32
|
+
"RM204 - Number of Dwellings, 2021 Census, LSOA level, England and Wales",
|
|
33
|
+
),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def fetch(dataset_key: str, **kwargs: Any) -> pd.DataFrame:
|
|
38
|
+
"""Download a UKDS dataset and return it as a DataFrame.
|
|
39
|
+
|
|
40
|
+
Extra keyword arguments are forwarded to ``pd.read_csv`` or
|
|
41
|
+
``pd.read_excel`` as appropriate.
|
|
42
|
+
"""
|
|
43
|
+
if dataset_key not in DATASETS:
|
|
44
|
+
raise KeyError(
|
|
45
|
+
f"Unknown UKDS dataset {dataset_key!r}. Available: {sorted(DATASETS)}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
url, fmt, _ = DATASETS[dataset_key]
|
|
49
|
+
response = requests.get(url)
|
|
50
|
+
response.raise_for_status()
|
|
51
|
+
|
|
52
|
+
if fmt == "csv":
|
|
53
|
+
return pd.read_csv(io.BytesIO(response.content), **kwargs)
|
|
54
|
+
else:
|
|
55
|
+
return pd.read_excel(io.BytesIO(response.content), **kwargs)
|
gla_data/catalogue.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Discovery catalogue listing all datasets available in gla_data."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
_CATALOGUE = [
|
|
6
|
+
{
|
|
7
|
+
"dataset": "ONS Boundaries",
|
|
8
|
+
"function": "load_boundaries",
|
|
9
|
+
"description": "London boundary polygons (OA, LSOA, MSOA, borough) in BNG (EPSG:27700)", # noqa: E501
|
|
10
|
+
"access_tier": "public",
|
|
11
|
+
"status": "implemented",
|
|
12
|
+
"sources": "public",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"dataset": "ONS Geography Lookup",
|
|
16
|
+
"function": "load_geography_lookup",
|
|
17
|
+
"description": "ONS lookup table mapping London LSOAs (or OAs) to MSOA and borough", # noqa: E501
|
|
18
|
+
"access_tier": "public",
|
|
19
|
+
"status": "implemented",
|
|
20
|
+
"sources": "public",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"dataset": "Census Dwellings",
|
|
24
|
+
"function": "load_census_dwellings",
|
|
25
|
+
"description": "Census dwelling counts for London LSOAs (2011 and 2021)",
|
|
26
|
+
"access_tier": "public",
|
|
27
|
+
"status": "implemented",
|
|
28
|
+
"sources": "public",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"dataset": "OS AddressBase",
|
|
32
|
+
"function": "load_addressbase",
|
|
33
|
+
"description": "Ordnance Survey AddressBase Premium — full address and UPRN data for London", # noqa: E501
|
|
34
|
+
"access_tier": "licensed",
|
|
35
|
+
"status": "planned",
|
|
36
|
+
"sources": "s3",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"dataset": "EPC",
|
|
40
|
+
"function": "load_epc",
|
|
41
|
+
"description": "Energy Performance Certificate ratings for London dwellings",
|
|
42
|
+
"access_tier": "registered",
|
|
43
|
+
"status": "planned",
|
|
44
|
+
"sources": "public",
|
|
45
|
+
},
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def catalogue() -> pd.DataFrame:
|
|
50
|
+
"""Return a DataFrame listing all datasets available in gla_data.
|
|
51
|
+
|
|
52
|
+
Includes both implemented and planned datasets. For datasets with
|
|
53
|
+
``access_tier`` other than ``"public"``, credentials are required —
|
|
54
|
+
see the README for setup instructions.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
pd.DataFrame
|
|
59
|
+
Columns: ``dataset``, ``function``, ``description``,
|
|
60
|
+
``access_tier``, ``status``, ``sources``.
|
|
61
|
+
"""
|
|
62
|
+
return pd.DataFrame(_CATALOGUE)
|
gla_data/census.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Load ONS census data for London."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from gla_data import _ukds
|
|
8
|
+
from gla_data._cache import read_cache, warn_slow_fetch, write_cache
|
|
9
|
+
from gla_data._schemas import census_dwellings_schema
|
|
10
|
+
from gla_data.geography import load_geography_lookup
|
|
11
|
+
|
|
12
|
+
_VALID_YEARS = (2011, 2021)
|
|
13
|
+
|
|
14
|
+
_RAW_COLUMNS = {
|
|
15
|
+
2011: {
|
|
16
|
+
"code": "GEO_CODE",
|
|
17
|
+
"total": "Dwellings : Total\\ Dwellings - Unit : Dwellings",
|
|
18
|
+
},
|
|
19
|
+
2021: {"code": "Lower layer Super Output Areas Code", "total": "Observation"},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_EXCEL_KWARGS = {
|
|
23
|
+
2021: {"sheet_name": "Dataset"},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fetch_census_dwellings(year: int) -> pd.DataFrame:
|
|
28
|
+
"""Download and normalise the raw UKDS census dwellings file for the given year."""
|
|
29
|
+
kwargs = _EXCEL_KWARGS.get(year, {})
|
|
30
|
+
raw = _ukds.fetch(f"census_dwellings_{year}", **kwargs)
|
|
31
|
+
|
|
32
|
+
cols = _RAW_COLUMNS[year]
|
|
33
|
+
code_col = f"LSOA{str(year)[2:]}CD"
|
|
34
|
+
|
|
35
|
+
return raw[[cols["code"], cols["total"]]].rename(
|
|
36
|
+
columns={
|
|
37
|
+
cols["code"]: code_col,
|
|
38
|
+
cols["total"]: "dwellings_total",
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_census_dwellings(
|
|
44
|
+
year: int = 2021,
|
|
45
|
+
geography: str = "lsoa",
|
|
46
|
+
source: Literal["public", "s3"] = "public",
|
|
47
|
+
cache: bool = True,
|
|
48
|
+
) -> pd.DataFrame:
|
|
49
|
+
"""Load census dwelling counts for London LSOAs.
|
|
50
|
+
|
|
51
|
+
Parameters
|
|
52
|
+
----------
|
|
53
|
+
year:
|
|
54
|
+
Census year. One of: 2011, 2021.
|
|
55
|
+
geography:
|
|
56
|
+
Geographic unit. Only ``"lsoa"`` is currently supported.
|
|
57
|
+
source:
|
|
58
|
+
Data source. Only ``"public"`` is currently supported.
|
|
59
|
+
cache:
|
|
60
|
+
If True (default), save the result to ``~/.cache/gla_data/`` after the
|
|
61
|
+
first fetch and read from there on subsequent calls.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
pd.DataFrame
|
|
66
|
+
Columns: geography code (e.g. ``LSOA21CD``), ``dwellings_total``.
|
|
67
|
+
|
|
68
|
+
Examples
|
|
69
|
+
--------
|
|
70
|
+
>>> df = load_census_dwellings(year=2021)
|
|
71
|
+
"""
|
|
72
|
+
if source == "s3":
|
|
73
|
+
raise NotImplementedError(
|
|
74
|
+
"S3 source is not yet supported for census dwellings."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if year not in _VALID_YEARS:
|
|
78
|
+
raise ValueError(f"year={year!r} is not valid. Choose from: {_VALID_YEARS}")
|
|
79
|
+
|
|
80
|
+
if geography != "lsoa":
|
|
81
|
+
raise NotImplementedError(
|
|
82
|
+
f"geography={geography!r} is not yet supported. "
|
|
83
|
+
f"Only 'lsoa' is currently implemented."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
cache_key = f"census_dwellings_{year}_{geography}"
|
|
87
|
+
|
|
88
|
+
if cache and (cached := read_cache(cache_key)) is not None:
|
|
89
|
+
df = cached
|
|
90
|
+
else:
|
|
91
|
+
warn_slow_fetch("load_census_dwellings", cache_key, cache)
|
|
92
|
+
df = _fetch_census_dwellings(year)
|
|
93
|
+
code_col = f"LSOA{str(year)[2:]}CD"
|
|
94
|
+
lookup = load_geography_lookup(year=year, smallest_geography="lsoa")
|
|
95
|
+
london_codes = lookup[code_col]
|
|
96
|
+
df = df[df[code_col].isin(london_codes)].reset_index(drop=True)
|
|
97
|
+
if cache:
|
|
98
|
+
write_cache(cache_key, df)
|
|
99
|
+
|
|
100
|
+
census_dwellings_schema(year).validate(df)
|
|
101
|
+
return df
|
gla_data/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Command-line interface for gla_data."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
import gla_data
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
help="GLA City Intelligence Data Science package.", no_args_is_help=True
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@app.command()
|
|
15
|
+
def catalogue() -> None:
|
|
16
|
+
"""List all datasets available in gla_data."""
|
|
17
|
+
df = gla_data.catalogue()
|
|
18
|
+
|
|
19
|
+
display_cols = ["dataset", "function", "description", "access_tier", "status"]
|
|
20
|
+
|
|
21
|
+
table = Table(show_header=True, header_style="bold", expand=True)
|
|
22
|
+
table.add_column("dataset", no_wrap=True, ratio=2)
|
|
23
|
+
table.add_column("function", no_wrap=True, ratio=2)
|
|
24
|
+
table.add_column("description", ratio=4)
|
|
25
|
+
table.add_column("access_tier", no_wrap=True, ratio=2)
|
|
26
|
+
table.add_column("status", no_wrap=True, ratio=2)
|
|
27
|
+
|
|
28
|
+
for row in df[display_cols].itertuples(index=False):
|
|
29
|
+
table.add_row(*[str(v) for v in row])
|
|
30
|
+
|
|
31
|
+
Console(width=120).print(table)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> None:
|
|
35
|
+
app()
|
gla_data/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from pydantic_settings import BaseSettings
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Settings(BaseSettings):
|
|
7
|
+
"""Runtime configuration.
|
|
8
|
+
|
|
9
|
+
Values are loaded from .env (gitignored).
|
|
10
|
+
All settings can be overridden with environment variables prefixed
|
|
11
|
+
GLA_DATA_.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
data_dir: Path = Path("data")
|
|
15
|
+
output_dir: Path = Path("outputs")
|
|
16
|
+
|
|
17
|
+
model_config = {"env_prefix": "GLA_DATA_", "env_file": ".env"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
settings = Settings()
|
gla_data/geography.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Load ONS geographic reference data for London."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from gla_data._cache import read_cache, warn_slow_fetch, write_cache
|
|
9
|
+
from gla_data._ons import fetch_boundaries as _fetch_boundaries
|
|
10
|
+
from gla_data._ons import fetch_geography_lookup as _fetch_geography_lookup
|
|
11
|
+
from gla_data._schemas import boundary_schema, geography_lookup_schema
|
|
12
|
+
|
|
13
|
+
_VALID_BOUNDARY_GEOGRAPHIES = ("oa", "lsoa", "msoa", "ward", "borough", "london")
|
|
14
|
+
_BOUNDARY_GEOGRAPHIES_WITH_YEAR = ("oa", "lsoa", "msoa", "borough")
|
|
15
|
+
_VALID_LOOKUP_YEARS = (2011, 2021)
|
|
16
|
+
_VALID_LOOKUP_GEOGRAPHIES = ("oa", "lsoa", "msoa")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_boundaries(
|
|
20
|
+
geography: str = "lsoa",
|
|
21
|
+
year: int | None = 2021,
|
|
22
|
+
source: Literal["public", "s3"] = "public",
|
|
23
|
+
cache: bool = True,
|
|
24
|
+
) -> gpd.GeoDataFrame:
|
|
25
|
+
"""Load London boundary polygons from the ONS Open Geography Portal.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
geography:
|
|
30
|
+
Geographic unit. One of: ``"oa"``, ``"lsoa"``, ``"msoa"``,
|
|
31
|
+
``"ward"``, ``"borough"``, ``"london"``. Defaults to ``"lsoa"``.
|
|
32
|
+
year:
|
|
33
|
+
Boundary vintage year. Required for ``"oa"``, ``"lsoa"``, ``"msoa"``,
|
|
34
|
+
and ``"borough"``; ignored for ``"london"``.
|
|
35
|
+
Valid values depend on geography — LSOAs: 2011, 2021; boroughs: 2011, 2021.
|
|
36
|
+
source:
|
|
37
|
+
Data source. Only ``"public"`` is currently supported.
|
|
38
|
+
cache:
|
|
39
|
+
If True (default), save the result to ``~/.cache/gla_data/`` after the
|
|
40
|
+
first fetch and read from there on subsequent calls. Pass ``cache=False``
|
|
41
|
+
to always fetch fresh from the ONS API.
|
|
42
|
+
|
|
43
|
+
Returns
|
|
44
|
+
-------
|
|
45
|
+
gpd.GeoDataFrame
|
|
46
|
+
Boundary polygons for London in British National Grid (EPSG:27700).
|
|
47
|
+
Columns: geography code, geography name, geometry.
|
|
48
|
+
|
|
49
|
+
Examples
|
|
50
|
+
--------
|
|
51
|
+
>>> gdf = load_boundaries(geography="lsoa", year=2021)
|
|
52
|
+
>>> gdf = load_boundaries(geography="borough", year=2021)
|
|
53
|
+
"""
|
|
54
|
+
if source == "s3":
|
|
55
|
+
raise NotImplementedError(
|
|
56
|
+
"S3 source is not yet supported for boundaries. "
|
|
57
|
+
"See the README for setup instructions when S3 support is added."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if geography not in _VALID_BOUNDARY_GEOGRAPHIES:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"geography={geography!r} is not valid. "
|
|
63
|
+
f"Choose from: {_VALID_BOUNDARY_GEOGRAPHIES}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
if geography in _BOUNDARY_GEOGRAPHIES_WITH_YEAR and year is None:
|
|
67
|
+
raise ValueError(f"year is required for geography={geography!r}.")
|
|
68
|
+
|
|
69
|
+
resolved_year = None if geography not in _BOUNDARY_GEOGRAPHIES_WITH_YEAR else year
|
|
70
|
+
cache_key = f"boundaries_{geography}_{resolved_year}"
|
|
71
|
+
|
|
72
|
+
if cache and (cached := read_cache(cache_key)) is not None:
|
|
73
|
+
gdf = cached
|
|
74
|
+
else:
|
|
75
|
+
warn_slow_fetch("load_boundaries", cache_key, cache)
|
|
76
|
+
gdf = _fetch_boundaries(geography, resolved_year)
|
|
77
|
+
if cache:
|
|
78
|
+
write_cache(cache_key, gdf)
|
|
79
|
+
|
|
80
|
+
boundary_schema(geography, resolved_year).validate(gdf)
|
|
81
|
+
|
|
82
|
+
return gdf
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_geography_lookup(
|
|
86
|
+
year: int = 2021,
|
|
87
|
+
smallest_geography: str = "lsoa",
|
|
88
|
+
source: Literal["public", "s3"] = "public",
|
|
89
|
+
cache: bool = True,
|
|
90
|
+
) -> pd.DataFrame:
|
|
91
|
+
"""Load the ONS geography lookup table for London.
|
|
92
|
+
|
|
93
|
+
Maps each London area to its parent geographies. Useful for aggregating
|
|
94
|
+
data to higher geographies via a merge.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
year:
|
|
99
|
+
Boundary vintage year. One of: 2011, 2021.
|
|
100
|
+
smallest_geography:
|
|
101
|
+
The finest geographic unit to include. ``"lsoa"`` returns one row per
|
|
102
|
+
LSOA (default); ``"oa"`` returns one row per output area and includes
|
|
103
|
+
the OA code column.
|
|
104
|
+
source:
|
|
105
|
+
Data source. Only ``"public"`` is currently supported.
|
|
106
|
+
cache:
|
|
107
|
+
If True (default), save the result to ``~/.cache/gla_data/`` after the
|
|
108
|
+
first fetch and read from there on subsequent calls. Pass ``cache=False``
|
|
109
|
+
to always fetch fresh from the ONS API.
|
|
110
|
+
|
|
111
|
+
Returns
|
|
112
|
+
-------
|
|
113
|
+
pd.DataFrame
|
|
114
|
+
Columns for 2021 (lsoa grain):
|
|
115
|
+
``LSOA21CD``, ``LSOA21NM``, ``MSOA21CD``, ``MSOA21NM``,
|
|
116
|
+
``LAD22CD``, ``LAD22NM``.
|
|
117
|
+
For 2011: ``LSOA11CD``, ``LSOA11NM``, ``MSOA11CD``, ``MSOA11NM``,
|
|
118
|
+
``LAD20CD``, ``LAD20NM``.
|
|
119
|
+
Note: the 2011 table uses 2020 LAD boundaries, which is what ONS
|
|
120
|
+
provides at this grain.
|
|
121
|
+
|
|
122
|
+
Examples
|
|
123
|
+
--------
|
|
124
|
+
>>> df = load_geography_lookup(year=2021)
|
|
125
|
+
>>> df_msoa = data.merge(df[["LSOA21CD", "MSOA21CD"]], on="LSOA21CD")
|
|
126
|
+
"""
|
|
127
|
+
if source == "s3":
|
|
128
|
+
raise NotImplementedError(
|
|
129
|
+
"S3 source is not yet supported for geography lookups. "
|
|
130
|
+
"See the README for setup instructions when S3 support is added."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
if year not in _VALID_LOOKUP_YEARS:
|
|
134
|
+
raise ValueError(
|
|
135
|
+
f"year={year!r} is not valid. Choose from: {_VALID_LOOKUP_YEARS}"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if smallest_geography not in _VALID_LOOKUP_GEOGRAPHIES:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"smallest_geography={smallest_geography!r} is not valid. "
|
|
141
|
+
f"Choose from: {_VALID_LOOKUP_GEOGRAPHIES}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
cache_key = f"geography_lookup_{year}_{smallest_geography}"
|
|
145
|
+
|
|
146
|
+
if cache and (cached := read_cache(cache_key)) is not None:
|
|
147
|
+
df = cached
|
|
148
|
+
else:
|
|
149
|
+
warn_slow_fetch("load_geography_lookup", cache_key, cache)
|
|
150
|
+
df = _fetch_geography_lookup(year, smallest_geography)
|
|
151
|
+
if cache:
|
|
152
|
+
write_cache(cache_key, df)
|
|
153
|
+
|
|
154
|
+
geography_lookup_schema(year, smallest_geography).validate(df)
|
|
155
|
+
|
|
156
|
+
return df
|
gla_data/py.typed
ADDED
|
File without changes
|
gla_data/transforms.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Geographic transformation functions: crosswalk and aggregate."""
|
|
2
|
+
|
|
3
|
+
import pandas as pd
|
|
4
|
+
|
|
5
|
+
from gla_data._cache import read_cache, warn_slow_fetch, write_cache
|
|
6
|
+
from gla_data._ons import fetch_lsoa_crosswalk
|
|
7
|
+
from gla_data.geography import load_geography_lookup
|
|
8
|
+
|
|
9
|
+
_SUPPORTED_CROSSWALKS = {(2011, 2021)}
|
|
10
|
+
_SUPPORTED_METHODS = {"uniform"}
|
|
11
|
+
_SUPPORTED_GEOGRAPHIES = {"lsoa"}
|
|
12
|
+
|
|
13
|
+
# Ordered from smallest to largest — used to validate that
|
|
14
|
+
# to_geography > from_geography.
|
|
15
|
+
_GEOGRAPHY_ORDER = ["oa", "lsoa", "msoa", "borough"]
|
|
16
|
+
|
|
17
|
+
# Maps (from_geography, to_geography) → the smallest_geography grain to load from
|
|
18
|
+
# load_geography_lookup. Only combinations where ONS publishes a lookup at the
|
|
19
|
+
# from_geography grain are listed; aggregations not in this dict are unsupported.
|
|
20
|
+
#
|
|
21
|
+
# OA grain lookup contains: OA, LSOA, MSOA, LAD → oa→{lsoa,msoa,borough}
|
|
22
|
+
# LSOA grain lookup contains: LSOA, MSOA, LAD → lsoa→{msoa,borough}
|
|
23
|
+
# No MSOA grain lookup exists, so msoa→borough is not supported.
|
|
24
|
+
_SUPPORTED_AGGREGATIONS: dict[tuple[str, str], str] = {
|
|
25
|
+
("oa", "lsoa"): "oa",
|
|
26
|
+
("oa", "msoa"): "oa",
|
|
27
|
+
("oa", "borough"): "oa",
|
|
28
|
+
("lsoa", "msoa"): "lsoa",
|
|
29
|
+
("lsoa", "borough"): "lsoa",
|
|
30
|
+
("msoa", "borough"): "msoa",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Borough (LAD) column suffix varies by lookup year — ONS provides 2022 boundaries
|
|
34
|
+
# for 2021 vintage and 2020 boundaries for 2011 vintage.
|
|
35
|
+
_LAD_COL_SUFFIX_BY_YEAR: dict[int, str] = {2021: "LAD22CD", 2011: "LAD20CD"}
|
|
36
|
+
|
|
37
|
+
_SUPPORTED_AGGREGATE_METHODS = {"sum"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _lookup_col(geography: str, year: int) -> str:
|
|
41
|
+
"""Return the column name for a geography in the ONS lookup table."""
|
|
42
|
+
if geography == "borough":
|
|
43
|
+
return _LAD_COL_SUFFIX_BY_YEAR[year]
|
|
44
|
+
yr = str(year)[2:]
|
|
45
|
+
return f"{geography.upper()}{yr}CD"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def crosswalk(
|
|
49
|
+
df: pd.DataFrame,
|
|
50
|
+
from_year: int,
|
|
51
|
+
to_year: int,
|
|
52
|
+
value_cols: list[str],
|
|
53
|
+
geography: str = "lsoa",
|
|
54
|
+
method: str = "uniform",
|
|
55
|
+
cache: bool = True,
|
|
56
|
+
) -> pd.DataFrame:
|
|
57
|
+
"""Convert LSOA-level data from one boundary vintage to another.
|
|
58
|
+
|
|
59
|
+
Uses the ONS exact-fit lookup to map values from 2011 LSOA boundaries to
|
|
60
|
+
2021 LSOA boundaries. Where a 2011 LSOA was split into multiple 2021 LSOAs,
|
|
61
|
+
the value is divided equally among them (method="uniform"). Where multiple
|
|
62
|
+
2011 LSOAs were merged into one 2021 LSOA, their values are summed.
|
|
63
|
+
|
|
64
|
+
Parameters
|
|
65
|
+
----------
|
|
66
|
+
df:
|
|
67
|
+
DataFrame containing a column named ``LSOA{from_year_short}CD``
|
|
68
|
+
(e.g. ``LSOA11CD``) and the columns listed in ``value_cols``.
|
|
69
|
+
from_year:
|
|
70
|
+
Boundary vintage of the input data. Currently only 2011 is supported.
|
|
71
|
+
to_year:
|
|
72
|
+
Target boundary vintage. Currently only 2021 is supported.
|
|
73
|
+
value_cols:
|
|
74
|
+
Columns to crosswalk. Must all be present in ``df``.
|
|
75
|
+
geography:
|
|
76
|
+
Geographic unit to crosswalk. Currently only ``"lsoa"`` is supported.
|
|
77
|
+
method:
|
|
78
|
+
Split method for LSOAs that changed boundaries. Currently only
|
|
79
|
+
``"uniform"`` is supported: values are divided equally among target LSOAs.
|
|
80
|
+
cache:
|
|
81
|
+
If True (default), cache the crosswalk lookup table to
|
|
82
|
+
``~/.cache/gla_data/`` after the first fetch.
|
|
83
|
+
|
|
84
|
+
Returns
|
|
85
|
+
-------
|
|
86
|
+
pd.DataFrame
|
|
87
|
+
One row per target LSOA, with columns ``LSOA{to_year_short}CD`` and
|
|
88
|
+
the crosswalked value columns.
|
|
89
|
+
|
|
90
|
+
Examples
|
|
91
|
+
--------
|
|
92
|
+
>>> df_2021 = crosswalk(df_2011, from_year=2011, to_year=2021, value_cols=["count"])
|
|
93
|
+
"""
|
|
94
|
+
if geography not in _SUPPORTED_GEOGRAPHIES:
|
|
95
|
+
raise NotImplementedError(
|
|
96
|
+
f"geography={geography!r} is not yet supported. "
|
|
97
|
+
f"Currently supported: {sorted(_SUPPORTED_GEOGRAPHIES)}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if (from_year, to_year) not in _SUPPORTED_CROSSWALKS:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"from_year={from_year!r}, to_year={to_year!r} is not supported. "
|
|
103
|
+
f"Supported combinations: {sorted(_SUPPORTED_CROSSWALKS)}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if method not in _SUPPORTED_METHODS:
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"method={method!r} is not supported. "
|
|
109
|
+
f"Choose from: {sorted(_SUPPORTED_METHODS)}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
from_col = f"LSOA{str(from_year)[2:]}CD"
|
|
113
|
+
to_col = f"LSOA{str(to_year)[2:]}CD"
|
|
114
|
+
|
|
115
|
+
if from_col not in df.columns:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{from_col!r} column not found in df. "
|
|
118
|
+
f"Expected a column named {from_col!r} for from_year={from_year!r}."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
missing = [c for c in value_cols if c not in df.columns]
|
|
122
|
+
if missing:
|
|
123
|
+
raise ValueError(f"value_cols {missing} not found in df.")
|
|
124
|
+
|
|
125
|
+
cache_key = f"crosswalk_{geography}_{from_year}_{to_year}"
|
|
126
|
+
if cache and (cached := read_cache(cache_key)) is not None:
|
|
127
|
+
lookup = cached[[from_col, to_col]]
|
|
128
|
+
else:
|
|
129
|
+
warn_slow_fetch("crosswalk", cache_key, cache)
|
|
130
|
+
lookup = fetch_lsoa_crosswalk()[[from_col, to_col]]
|
|
131
|
+
if cache:
|
|
132
|
+
write_cache(cache_key, lookup)
|
|
133
|
+
|
|
134
|
+
merged = df[[from_col] + value_cols].merge(lookup, on=from_col, how="left")
|
|
135
|
+
|
|
136
|
+
# Count how many target LSOAs each source LSOA maps to, then divide
|
|
137
|
+
# values by that count so the total is preserved across splits.
|
|
138
|
+
counts = merged.groupby(from_col)[to_col].transform("count")
|
|
139
|
+
for col in value_cols:
|
|
140
|
+
merged[col] = merged[col] / counts
|
|
141
|
+
|
|
142
|
+
return merged.groupby(to_col)[value_cols].sum().reset_index()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def aggregate(
|
|
146
|
+
df: pd.DataFrame,
|
|
147
|
+
from_geography: str,
|
|
148
|
+
to_geography: str,
|
|
149
|
+
value_cols: list[str],
|
|
150
|
+
method: str = "sum",
|
|
151
|
+
year: int = 2021,
|
|
152
|
+
) -> pd.DataFrame:
|
|
153
|
+
"""Aggregate data from a smaller to a larger geography using the ONS lookup.
|
|
154
|
+
|
|
155
|
+
Parameters
|
|
156
|
+
----------
|
|
157
|
+
df:
|
|
158
|
+
DataFrame containing a source geography code column (e.g. ``OA21CD``)
|
|
159
|
+
and the columns listed in ``value_cols``. Any additional columns not
|
|
160
|
+
in ``value_cols`` are treated as extra grouping keys and preserved in
|
|
161
|
+
the output (e.g. a ``financial_year`` column).
|
|
162
|
+
from_geography:
|
|
163
|
+
Source geographic unit. One of: ``"oa"``, ``"lsoa"``, ``"msoa"``.
|
|
164
|
+
to_geography:
|
|
165
|
+
Target geographic unit. Must be larger than ``from_geography``.
|
|
166
|
+
One of: ``"lsoa"``, ``"msoa"``, ``"borough"``.
|
|
167
|
+
value_cols:
|
|
168
|
+
Columns to aggregate. Must all be present in ``df``.
|
|
169
|
+
method:
|
|
170
|
+
Aggregation method. Currently only ``"sum"`` is supported.
|
|
171
|
+
year:
|
|
172
|
+
Lookup vintage year. Determines which ONS geography lookup is used
|
|
173
|
+
and the year suffix on geography code columns (e.g. 2021 → ``OA21CD``).
|
|
174
|
+
|
|
175
|
+
Returns
|
|
176
|
+
-------
|
|
177
|
+
pd.DataFrame
|
|
178
|
+
One row per unique combination of target geography code and any extra
|
|
179
|
+
grouping columns, with ``value_cols`` aggregated by ``method``.
|
|
180
|
+
|
|
181
|
+
Examples
|
|
182
|
+
--------
|
|
183
|
+
>>> df_lsoa = aggregate(df_oa, "oa", "lsoa", value_cols=["count"])
|
|
184
|
+
>>> df_borough = aggregate(df_lsoa, "lsoa", "borough", value_cols=["count"])
|
|
185
|
+
"""
|
|
186
|
+
if from_geography not in _GEOGRAPHY_ORDER:
|
|
187
|
+
raise ValueError(
|
|
188
|
+
f"from_geography={from_geography!r} is not a valid geography. "
|
|
189
|
+
f"Choose from: {_GEOGRAPHY_ORDER}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if to_geography not in _GEOGRAPHY_ORDER:
|
|
193
|
+
raise ValueError(
|
|
194
|
+
f"to_geography={to_geography!r} is not a valid geography. "
|
|
195
|
+
f"Choose from: {_GEOGRAPHY_ORDER}"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
from_idx = _GEOGRAPHY_ORDER.index(from_geography)
|
|
199
|
+
to_idx = _GEOGRAPHY_ORDER.index(to_geography)
|
|
200
|
+
if to_idx <= from_idx:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"to_geography={to_geography!r} must be larger than "
|
|
203
|
+
f"from_geography={from_geography!r}."
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if (from_geography, to_geography) not in _SUPPORTED_AGGREGATIONS:
|
|
207
|
+
supported_for = sorted(
|
|
208
|
+
t for f, t in _SUPPORTED_AGGREGATIONS if f == from_geography
|
|
209
|
+
)
|
|
210
|
+
raise NotImplementedError(
|
|
211
|
+
f"Aggregation from {from_geography!r} to {to_geography!r} is not supported "
|
|
212
|
+
f"because no ONS lookup table is published at {from_geography!r} grain. "
|
|
213
|
+
f"Supported targets for from_geography={from_geography!r}: {supported_for}"
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
if method not in _SUPPORTED_AGGREGATE_METHODS:
|
|
217
|
+
raise ValueError(
|
|
218
|
+
f"method={method!r} is not supported. "
|
|
219
|
+
f"Choose from: {sorted(_SUPPORTED_AGGREGATE_METHODS)}"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
from_col = _lookup_col(from_geography, year)
|
|
223
|
+
to_col = _lookup_col(to_geography, year)
|
|
224
|
+
|
|
225
|
+
if from_col not in df.columns:
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"{from_col!r} column not found in df. "
|
|
228
|
+
f"Expected a column named {from_col!r} for "
|
|
229
|
+
f"from_geography={from_geography!r}, year={year}."
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
missing = [c for c in value_cols if c not in df.columns]
|
|
233
|
+
if missing:
|
|
234
|
+
raise ValueError(f"value_cols {missing} not found in df.")
|
|
235
|
+
|
|
236
|
+
grain = _SUPPORTED_AGGREGATIONS[(from_geography, to_geography)]
|
|
237
|
+
lookup = load_geography_lookup(year=year, smallest_geography=grain)
|
|
238
|
+
lookup = lookup[[from_col, to_col]].drop_duplicates()
|
|
239
|
+
|
|
240
|
+
merged = df.merge(lookup, on=from_col, how="left").drop(columns=[from_col])
|
|
241
|
+
|
|
242
|
+
group_cols = [c for c in merged.columns if c not in value_cols]
|
|
243
|
+
|
|
244
|
+
return merged.groupby(group_cols)[value_cols].sum().reset_index()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gla-data
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared tools for loading, transforming, and visualising the GLA datasets used in City Data analysis.
|
|
5
|
+
Author-email: Conor Dempsey <conor.dempsey@london.gov.uk>
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Requires-Dist: geopandas>=1.0
|
|
9
|
+
Requires-Dist: pandas>=2.2
|
|
10
|
+
Requires-Dist: pandera>=0.32.1
|
|
11
|
+
Requires-Dist: pyarrow>=24.0.0
|
|
12
|
+
Requires-Dist: pydantic-settings
|
|
13
|
+
Requires-Dist: requests>=2.32
|
|
14
|
+
Requires-Dist: typer>=0.26.8
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# gla-data
|
|
18
|
+
|
|
19
|
+
Shared tools for loading, transforming, and visualising the GLA datasets used in City Data analysis.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
Add `gla-data` as a PyPI dependency in your `pixi.toml`:
|
|
24
|
+
|
|
25
|
+
```toml
|
|
26
|
+
[pypi-dependencies]
|
|
27
|
+
gla-data = ">=0.1"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Then run `pixi install`. Pixi pulls geospatial dependencies (geopandas, pyproj, etc.) from conda-forge, which provides reliable pre-built binaries across all platforms and Python versions.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone <repo-url>
|
|
36
|
+
cd gla-data
|
|
37
|
+
pixi install
|
|
38
|
+
|
|
39
|
+
pixi run test # run tests
|
|
40
|
+
pixi run lint # check code style
|
|
41
|
+
pixi run format # auto-format
|
|
42
|
+
pixi run typecheck # type checking
|
|
43
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
gla_data/__init__.py,sha256=dNcSoqJS5fLxC6TpFPieC-pe5iYFYY0zX4I-H11o_VQ,435
|
|
2
|
+
gla_data/_arcgis.py,sha256=57MjMqusVXl5qqkwEZohP32hYQNAr132XCPTyia5DgE,3236
|
|
3
|
+
gla_data/_cache.py,sha256=zFrjmJTxfcellN0qGVMABccDxWh0QbLlvC8uh8SYaSI,1458
|
|
4
|
+
gla_data/_ons.py,sha256=vncHPMzPneFJxZgLB17Uv-u-aVgih0WdzpoJtmasL2Y,7105
|
|
5
|
+
gla_data/_schemas.py,sha256=EqaHm79_6B8v-vtguzfrGTziWjWjQcnyQj5y4qcxyUo,2405
|
|
6
|
+
gla_data/_ukds.py,sha256=GqM-H2HKpvqK-k-iHR6EMg73I4_OVkGf5T8A5xO0JSE,1959
|
|
7
|
+
gla_data/catalogue.py,sha256=xoBIfv4BO6-7PX18TqCm6RKZ4SzQ8EKY2EyzP4Wpg3Y,2020
|
|
8
|
+
gla_data/census.py,sha256=BO0FTgLHbs95BrVfmXnqrUhl8xeyTYDupd3yDjKnRWI,2952
|
|
9
|
+
gla_data/cli.py,sha256=ugzCWhhuhuoz2zng5Bm3lFNuzbk2iFbvkm0gxWJjaFM,955
|
|
10
|
+
gla_data/config.py,sha256=hhIcMyjnGkrjww_9UWH6HVZsq62j52U6pMcTfc1WcKg,438
|
|
11
|
+
gla_data/geography.py,sha256=DYHEtoGpRd5arj8E-4Y0JtplIYNbE3KK-u8aEhyU4yM,5513
|
|
12
|
+
gla_data/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
gla_data/transforms.py,sha256=0c5gCdOOP3alScIjfU6049tvc9pMsdiPC-DnOJdFcNw,9130
|
|
14
|
+
gla_data-0.1.0.dist-info/METADATA,sha256=JeowQGuDekHSoSyGopdJiNMxwXiXpWRDvvQ0v1NRMCI,1156
|
|
15
|
+
gla_data-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
16
|
+
gla_data-0.1.0.dist-info/entry_points.txt,sha256=H6tQ2DyXVUT3fksx2EnX7qSirx2rRaMTq8VVewet4WQ,47
|
|
17
|
+
gla_data-0.1.0.dist-info/RECORD,,
|