gla-data 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → gla_data-0.4.0}/PKG-INFO +3 -3
- {gla_data-0.3.0 → gla_data-0.4.0}/README.md +2 -2
- {gla_data-0.3.0 → gla_data-0.4.0}/pyproject.toml +4 -4
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/__init__.py +14 -0
- gla_data-0.4.0/src/gla_data/_epc.py +560 -0
- gla_data-0.4.0/src/gla_data/_epc_clean.py +1451 -0
- gla_data-0.4.0/src/gla_data/_epc_features.py +255 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_ons.py +3 -1
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_postgres.py +3 -5
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_schemas.py +208 -15
- gla_data-0.4.0/src/gla_data/addressbase.py +105 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/catalogue.py +5 -5
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/config.py +37 -13
- gla_data-0.4.0/src/gla_data/epc.py +468 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/explore.py +1 -1
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/front_gardens.py +2 -1
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/geography.py +114 -38
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_addressbase.py +5 -45
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_boundaries.py +96 -21
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_config.py +26 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_conventions.py +2 -0
- gla_data-0.4.0/tests/test_epc.py +465 -0
- gla_data-0.4.0/tests/test_epc_clean.py +728 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_front_gardens.py +3 -2
- gla_data-0.4.0/tests/test_gis_layer.py +201 -0
- gla_data-0.3.0/src/gla_data/addressbase.py +0 -129
- gla_data-0.3.0/tests/test_gis_layer.py +0 -77
- {gla_data-0.3.0 → gla_data-0.4.0}/.gitignore +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_addressbase.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_aerial_imagery.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_arcgis.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_bbox.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_cache.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_lbsm.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_london.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_londondatastore.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_s3.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_tree_canopy.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_ukds.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/addressbase_ngd.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/aerial_imagery.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/census.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/cli.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/lbsm.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/mastermap.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/py.typed +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/transforms.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/tree_canopy.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/__init__.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/conftest.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_addressbase_ngd.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_aerial_imagery.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_aggregate.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_catalogue.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_census_dwellings.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_cli.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_crosswalk.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_explore.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_geography_lookup.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_lbsm.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_london.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_londondatastore.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_mastermap.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_postgres.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_s3.py +0 -0
- {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_tree_canopy.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: gla-data
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Shared tools for loading, transforming, and visualising the GLA datasets used in City Data analysis.
|
|
5
5
|
Author-email: Conor Dempsey <conor.dempsey@london.gov.uk>
|
|
6
6
|
License: MIT
|
|
@@ -140,14 +140,14 @@ A misspelt `GLA_DATA_` setting in a settings file raises an error rather than be
|
|
|
140
140
|
| Setting | Needed for |
|
|
141
141
|
|---|---|
|
|
142
142
|
| `GLA_DATA_GIS_DB_HOST`, `_PORT`, `_NAME`, `_USER`, `_PASSWORD` | Anything from the GIS team's Postgres database (`source="postgres"`, `load_gis_layer`, `load_front_gardens`, `load_mastermap_topography`, `gla-data db ...`). Use a read-only login. |
|
|
143
|
-
| `GLA_DATA_GIS_DB_SCHEMA` | Optional: the AddressBase schema, if not the default search path. |
|
|
144
|
-
| `GLA_DATA_ADDRESSBASE_BLPU_TABLE`, `_CLASSIFICATION_TABLE` | `load_addressbase(source="postgres")`, if the tables are not named `blpu` / `classification`. |
|
|
145
143
|
| `GLA_DATA_GISAPDATA_<LAYER>_TABLE` | Each `gisapdata` layer used by `load_boundaries(source="postgres")`, `load_gis_layer` and `load_front_gardens`. |
|
|
146
144
|
| `GLA_DATA_GISAPDATA_MASTERMAP_TOPO_2024_TABLE` | `load_mastermap_topography` (2024 snapshot). |
|
|
147
145
|
| `GLA_DATA_BLUESKY_IMAGERY_BUCKET` | Bluesky aerial imagery (`build_imagery_tile_index`, `download_imagery_tiles`). |
|
|
148
146
|
| `GLA_DATA_BLUESKY_IMAGERY_DIR` | Optional: folder for the imagery tile index and downloads (default `data/bluesky_imagery`). Tiles are 256MB each. |
|
|
149
147
|
| `GLA_DATA_AWS_PROFILE`, `GLA_DATA_AWS_REGION` | Optional: pin the AWS profile or region used for S3. |
|
|
150
148
|
| `GLA_DATA_TREE_CANOPY_DIR` | Optional: folder for the tree canopy download and the GeoPackage built from it (default `data/tree_canopy`). Point every project at the same folder so the data is downloaded once. |
|
|
149
|
+
| `GLA_DATA_EPC_API_TOKEN` | `download_epc`. Token from the "my account" page of the MHCLG EPC service (get-energy-performance-data.communities.gov.uk, free sign-in with GOV.UK One Login). Keep it in the user-level file. |
|
|
150
|
+
| `GLA_DATA_EPC_DIR` | Optional: folder for the London EPC file built by `download_epc` (default `data/epc`). Point every project at the same folder. |
|
|
151
151
|
|
|
152
152
|
AWS credentials are never set here. gla-data uses the standard AWS sources: an AWS profile (`~/.aws`), `AWS_*` environment variables, or the machine's own role on GLA cloud machines. Keep database passwords in your user-level file, never in a project's repository.
|
|
153
153
|
|
|
@@ -114,14 +114,14 @@ A misspelt `GLA_DATA_` setting in a settings file raises an error rather than be
|
|
|
114
114
|
| Setting | Needed for |
|
|
115
115
|
|---|---|
|
|
116
116
|
| `GLA_DATA_GIS_DB_HOST`, `_PORT`, `_NAME`, `_USER`, `_PASSWORD` | Anything from the GIS team's Postgres database (`source="postgres"`, `load_gis_layer`, `load_front_gardens`, `load_mastermap_topography`, `gla-data db ...`). Use a read-only login. |
|
|
117
|
-
| `GLA_DATA_GIS_DB_SCHEMA` | Optional: the AddressBase schema, if not the default search path. |
|
|
118
|
-
| `GLA_DATA_ADDRESSBASE_BLPU_TABLE`, `_CLASSIFICATION_TABLE` | `load_addressbase(source="postgres")`, if the tables are not named `blpu` / `classification`. |
|
|
119
117
|
| `GLA_DATA_GISAPDATA_<LAYER>_TABLE` | Each `gisapdata` layer used by `load_boundaries(source="postgres")`, `load_gis_layer` and `load_front_gardens`. |
|
|
120
118
|
| `GLA_DATA_GISAPDATA_MASTERMAP_TOPO_2024_TABLE` | `load_mastermap_topography` (2024 snapshot). |
|
|
121
119
|
| `GLA_DATA_BLUESKY_IMAGERY_BUCKET` | Bluesky aerial imagery (`build_imagery_tile_index`, `download_imagery_tiles`). |
|
|
122
120
|
| `GLA_DATA_BLUESKY_IMAGERY_DIR` | Optional: folder for the imagery tile index and downloads (default `data/bluesky_imagery`). Tiles are 256MB each. |
|
|
123
121
|
| `GLA_DATA_AWS_PROFILE`, `GLA_DATA_AWS_REGION` | Optional: pin the AWS profile or region used for S3. |
|
|
124
122
|
| `GLA_DATA_TREE_CANOPY_DIR` | Optional: folder for the tree canopy download and the GeoPackage built from it (default `data/tree_canopy`). Point every project at the same folder so the data is downloaded once. |
|
|
123
|
+
| `GLA_DATA_EPC_API_TOKEN` | `download_epc`. Token from the "my account" page of the MHCLG EPC service (get-energy-performance-data.communities.gov.uk, free sign-in with GOV.UK One Login). Keep it in the user-level file. |
|
|
124
|
+
| `GLA_DATA_EPC_DIR` | Optional: folder for the London EPC file built by `download_epc` (default `data/epc`). Point every project at the same folder. |
|
|
125
125
|
|
|
126
126
|
AWS credentials are never set here. gla-data uses the standard AWS sources: an AWS profile (`~/.aws`), `AWS_*` environment variables, or the machine's own role on GLA cloud machines. Keep database passwords in your user-level file, never in a project's repository.
|
|
127
127
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "gla-data"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.0"
|
|
4
4
|
description = "Shared tools for loading, transforming, and visualising the GLA datasets used in City Data analysis."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = { text = "MIT" }
|
|
@@ -27,8 +27,8 @@ dependencies = [
|
|
|
27
27
|
# Postgres driver (psycopg2) is a conda-only dep here (see pixi.toml) to
|
|
28
28
|
# avoid pip/conda installing it twice in the pixi env, matching how
|
|
29
29
|
# openpyxl (needed by census.py) is handled — a plain `pip install
|
|
30
|
-
# gla-data` user wanting
|
|
31
|
-
#
|
|
30
|
+
# gla-data` user wanting the Postgres-backed loaders needs to separately
|
|
31
|
+
# install a psycopg2 driver.
|
|
32
32
|
]
|
|
33
33
|
|
|
34
34
|
[project.scripts]
|
|
@@ -89,7 +89,7 @@ disallow_untyped_defs = false
|
|
|
89
89
|
disallow_incomplete_defs = false
|
|
90
90
|
|
|
91
91
|
[tool.bumpversion]
|
|
92
|
-
current_version = "0.
|
|
92
|
+
current_version = "0.4.0"
|
|
93
93
|
commit = true
|
|
94
94
|
tag = true
|
|
95
95
|
|
|
@@ -22,6 +22,14 @@ from gla_data.aerial_imagery import (
|
|
|
22
22
|
)
|
|
23
23
|
from gla_data.catalogue import catalogue
|
|
24
24
|
from gla_data.census import load_census_dwellings
|
|
25
|
+
from gla_data.epc import (
|
|
26
|
+
clean_epc,
|
|
27
|
+
download_epc,
|
|
28
|
+
get_epc_feature_spec,
|
|
29
|
+
get_latest_epc_per_uprn,
|
|
30
|
+
load_epc,
|
|
31
|
+
prepare_epc_features,
|
|
32
|
+
)
|
|
25
33
|
from gla_data.explore import describe_table, list_schemas, list_tables
|
|
26
34
|
from gla_data.front_gardens import load_front_gardens
|
|
27
35
|
from gla_data.geography import load_boundaries, load_geography_lookup, load_gis_layer
|
|
@@ -57,4 +65,10 @@ __all__ = [
|
|
|
57
65
|
"load_mastermap_topography",
|
|
58
66
|
"load_tree_canopy",
|
|
59
67
|
"download_tree_canopy",
|
|
68
|
+
"download_epc",
|
|
69
|
+
"load_epc",
|
|
70
|
+
"get_latest_epc_per_uprn",
|
|
71
|
+
"clean_epc",
|
|
72
|
+
"prepare_epc_features",
|
|
73
|
+
"get_epc_feature_spec",
|
|
60
74
|
]
|
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""Domestic EPCs: MHCLG EPC API bulk download, London parquet build and reads.
|
|
2
|
+
|
|
3
|
+
The API's bulk endpoint (``BULK_PATH``) has no filters: it answers with a
|
|
4
|
+
redirect to a signed S3 link for one zip of every domestic EPC in England and
|
|
5
|
+
Wales, rebuilt on the 1st of each month. As of the file of 1 October 2026 the
|
|
6
|
+
zip is 8.3GB and holds one ``certificates-<year>.csv`` per lodgement year
|
|
7
|
+
(2008-2026) plus recommendation CSVs, which are skipped. Column names are
|
|
8
|
+
not documented on the API pages; ``COLUMN_TYPES`` lists the 93 found in that
|
|
9
|
+
file, and a file with any other set of columns is refused rather than
|
|
10
|
+
guessed at.
|
|
11
|
+
|
|
12
|
+
``build`` reads each certificates CSV in chunks, keeps London rows (local
|
|
13
|
+
authority code ``E09...``), cleans them (``clean_chunk``) and appends them to
|
|
14
|
+
one parquet file. Like ``_tree_canopy.build`` it runs under a file lock and
|
|
15
|
+
writes under a temporary name that is renamed into place at the end, with a
|
|
16
|
+
JSON record of the snapshot alongside.
|
|
17
|
+
|
|
18
|
+
The address fields are Ordnance Survey and Royal Mail data (see the licence
|
|
19
|
+
note in ``epc.py``): nothing here logs or prints row values.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import zipfile
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, date, datetime
|
|
28
|
+
from importlib.metadata import version
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import pandas as pd
|
|
33
|
+
import pyarrow as pa
|
|
34
|
+
import pyarrow.parquet as pq
|
|
35
|
+
import requests
|
|
36
|
+
from filelock import FileLock
|
|
37
|
+
|
|
38
|
+
from gla_data._schemas import epc_schema as _epc_schema
|
|
39
|
+
|
|
40
|
+
API_BASE = "https://api.get-energy-performance-data.communities.gov.uk"
|
|
41
|
+
INFO_PATH = "/api/files/domestic/csv/info"
|
|
42
|
+
BULK_PATH = "/api/files/domestic/csv"
|
|
43
|
+
_REDIRECT_CODES = (301, 302, 303, 307, 308)
|
|
44
|
+
_DOWNLOAD_CHUNK_BYTES = 8 * 1024 * 1024
|
|
45
|
+
_READ_CHUNK_ROWS = 200_000
|
|
46
|
+
_LONDON_PREFIX = "E09"
|
|
47
|
+
|
|
48
|
+
STEM = "epc_domestic_london"
|
|
49
|
+
ZIP_NAME = "epc_domestic_bulk.zip"
|
|
50
|
+
|
|
51
|
+
# Column types, from a scan of every London row of the file of 1 October
|
|
52
|
+
# 2026 (4,631,787 certificates). "string" columns are kept as text; numeric
|
|
53
|
+
# columns whose values were all whole numbers are nullable integers.
|
|
54
|
+
_STRING = "string"
|
|
55
|
+
_INT = "Int64"
|
|
56
|
+
_FLOAT = "float64"
|
|
57
|
+
_BOOL = "boolean"
|
|
58
|
+
_DATE = "date"
|
|
59
|
+
_DATETIME = "datetime"
|
|
60
|
+
COLUMN_TYPES: dict[str, str] = {
|
|
61
|
+
"certificate_number": _STRING,
|
|
62
|
+
"address1": _STRING,
|
|
63
|
+
"address2": _STRING,
|
|
64
|
+
"address3": _STRING,
|
|
65
|
+
"postcode": _STRING,
|
|
66
|
+
"posttown": _STRING,
|
|
67
|
+
"address": _STRING,
|
|
68
|
+
"constituency": _STRING,
|
|
69
|
+
"constituency_label": _STRING,
|
|
70
|
+
"local_authority": _STRING,
|
|
71
|
+
"local_authority_label": _STRING,
|
|
72
|
+
"built_form": _STRING,
|
|
73
|
+
"co2_emiss_curr_per_floor_area": _FLOAT,
|
|
74
|
+
"co2_emissions_current": _FLOAT,
|
|
75
|
+
"co2_emissions_potential": _FLOAT,
|
|
76
|
+
"construction_age_band": _STRING,
|
|
77
|
+
"current_energy_efficiency": _INT,
|
|
78
|
+
"current_energy_rating": _STRING,
|
|
79
|
+
"energy_consumption_current": _FLOAT,
|
|
80
|
+
"energy_consumption_potential": _FLOAT,
|
|
81
|
+
"energy_tariff": _STRING,
|
|
82
|
+
"environment_impact_current": _INT,
|
|
83
|
+
"environment_impact_potential": _INT,
|
|
84
|
+
"extension_count": _INT,
|
|
85
|
+
"fixed_lighting_outlets_count": _INT,
|
|
86
|
+
"flat_storey_count": _INT,
|
|
87
|
+
"flat_top_storey": _BOOL,
|
|
88
|
+
"floor_description": _STRING,
|
|
89
|
+
"floor_energy_eff": _STRING,
|
|
90
|
+
"floor_height": _FLOAT,
|
|
91
|
+
"floor_level": _STRING,
|
|
92
|
+
"glazed_area": _STRING,
|
|
93
|
+
"glazed_type": _STRING,
|
|
94
|
+
"heat_loss_corridor": _STRING,
|
|
95
|
+
"heating_cost_current": _FLOAT,
|
|
96
|
+
"heating_cost_potential": _FLOAT,
|
|
97
|
+
"hot_water_cost_current": _FLOAT,
|
|
98
|
+
"hot_water_cost_potential": _FLOAT,
|
|
99
|
+
"hot_water_energy_eff": _STRING,
|
|
100
|
+
"hot_water_env_eff": _STRING,
|
|
101
|
+
"hotwater_description": _STRING,
|
|
102
|
+
"inspection_date": _DATE,
|
|
103
|
+
"lighting_cost_current": _FLOAT,
|
|
104
|
+
"lighting_cost_potential": _FLOAT,
|
|
105
|
+
"lighting_description": _STRING,
|
|
106
|
+
"lighting_energy_eff": _STRING,
|
|
107
|
+
"lighting_env_eff": _STRING,
|
|
108
|
+
"lodgement_date": _DATE,
|
|
109
|
+
"lodgement_datetime": _DATETIME,
|
|
110
|
+
"low_energy_lighting": _FLOAT,
|
|
111
|
+
"low_energy_fixed_lighting_outlets_count": _INT,
|
|
112
|
+
"main_fuel": _STRING,
|
|
113
|
+
"mainheat_description": _STRING,
|
|
114
|
+
"mainheat_energy_eff": _STRING,
|
|
115
|
+
"mainheat_env_eff": _STRING,
|
|
116
|
+
"mainheatc_energy_eff": _STRING,
|
|
117
|
+
"mainheatc_env_eff": _STRING,
|
|
118
|
+
"mainheatcont_description": _STRING,
|
|
119
|
+
"main_heating_controls": _STRING,
|
|
120
|
+
"mains_gas_flag": _BOOL,
|
|
121
|
+
"mechanical_ventilation": _STRING,
|
|
122
|
+
"multi_glaze_proportion": _INT,
|
|
123
|
+
"number_habitable_rooms": _INT,
|
|
124
|
+
"number_heated_rooms": _INT,
|
|
125
|
+
"number_open_fireplaces": _INT,
|
|
126
|
+
"photo_supply": _INT,
|
|
127
|
+
"potential_energy_efficiency": _INT,
|
|
128
|
+
"potential_energy_rating": _STRING,
|
|
129
|
+
"property_type": _STRING,
|
|
130
|
+
"report_type": _INT,
|
|
131
|
+
"roof_description": _STRING,
|
|
132
|
+
"roof_energy_eff": _STRING,
|
|
133
|
+
"roof_env_eff": _STRING,
|
|
134
|
+
"secondheat_description": _STRING,
|
|
135
|
+
"sheating_energy_eff": _STRING,
|
|
136
|
+
"sheating_env_eff": _STRING,
|
|
137
|
+
"solar_water_heating_flag": _BOOL,
|
|
138
|
+
"tenure": _STRING,
|
|
139
|
+
"total_floor_area": _FLOAT,
|
|
140
|
+
"transaction_type": _STRING,
|
|
141
|
+
"unheated_corridor_length": _FLOAT,
|
|
142
|
+
"walls_description": _STRING,
|
|
143
|
+
"walls_energy_eff": _STRING,
|
|
144
|
+
"walls_env_eff": _STRING,
|
|
145
|
+
"wind_turbine_count": _INT,
|
|
146
|
+
"windows_description": _STRING,
|
|
147
|
+
"windows_energy_eff": _STRING,
|
|
148
|
+
"windows_env_eff": _STRING,
|
|
149
|
+
"floor_env_eff": _STRING,
|
|
150
|
+
"region": _STRING,
|
|
151
|
+
"country": _STRING,
|
|
152
|
+
"uprn": _INT,
|
|
153
|
+
"uprn_source": _STRING,
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
# Address fields: Ordnance Survey and Royal Mail data under their own notice.
|
|
157
|
+
ADDRESS_COLUMNS = (
|
|
158
|
+
"address1",
|
|
159
|
+
"address2",
|
|
160
|
+
"address3",
|
|
161
|
+
"address",
|
|
162
|
+
"postcode",
|
|
163
|
+
"posttown",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
# Values that mean "no value" in the source.
|
|
167
|
+
_BLANK_MARKERS = {"", "NO DATA!", "N/A", "NULL"}
|
|
168
|
+
|
|
169
|
+
# Category values that differ only in case: each maps (by casefold) to the
|
|
170
|
+
# spelling most common in the London rows of the file of 1 October 2026.
|
|
171
|
+
# Values not listed here are kept as published (whitespace trimmed).
|
|
172
|
+
CASE_NORMALISED: dict[str, dict[str, str]] = {
|
|
173
|
+
"tenure": {
|
|
174
|
+
"owner-occupied": "owner-occupied",
|
|
175
|
+
"rented (private)": "rented (private)",
|
|
176
|
+
"rented (social)": "rented (social)",
|
|
177
|
+
"unknown": "unknown",
|
|
178
|
+
},
|
|
179
|
+
"transaction_type": {
|
|
180
|
+
"marketed sale": "Marketed sale",
|
|
181
|
+
"new dwelling": "New dwelling",
|
|
182
|
+
"rental": "Rental",
|
|
183
|
+
},
|
|
184
|
+
"floor_level": {"basement": "Basement"},
|
|
185
|
+
}
|
|
186
|
+
# The same age band appears with and without this prefix.
|
|
187
|
+
_AGE_BAND_PREFIX = "England and Wales: "
|
|
188
|
+
|
|
189
|
+
_FLAG_VALUES = {"Y": True, "N": False}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True)
|
|
193
|
+
class LocalPaths:
|
|
194
|
+
"""Files kept in the destination folder."""
|
|
195
|
+
|
|
196
|
+
zip: Path
|
|
197
|
+
parquet: Path
|
|
198
|
+
record: Path
|
|
199
|
+
lock: Path
|
|
200
|
+
building: Path
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def local_paths(destination: Path) -> LocalPaths:
|
|
204
|
+
"""Return the local file paths in ``destination``."""
|
|
205
|
+
return LocalPaths(
|
|
206
|
+
zip=destination / ZIP_NAME,
|
|
207
|
+
parquet=destination / f"{STEM}.parquet",
|
|
208
|
+
record=destination / f"{STEM}.json",
|
|
209
|
+
lock=destination / f"{STEM}.lock",
|
|
210
|
+
building=destination / f"{STEM}.building.parquet",
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# API
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _auth_headers(token: str) -> dict[str, str]:
|
|
220
|
+
return {"Authorization": f"Bearer {token}", "Accept": "application/json"}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def fetch_file_info(token: str) -> dict[str, Any]:
|
|
224
|
+
"""Return the current bulk file's size and date (``fileSize``, ``lastUpdated``)."""
|
|
225
|
+
response = requests.get(
|
|
226
|
+
API_BASE + INFO_PATH, headers=_auth_headers(token), timeout=60
|
|
227
|
+
)
|
|
228
|
+
response.raise_for_status()
|
|
229
|
+
data: dict[str, Any] = response.json()["data"]
|
|
230
|
+
return data
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def download_bulk_zip(token: str, partial: Path) -> None:
|
|
234
|
+
"""Download the bulk zip to ``partial``.
|
|
235
|
+
|
|
236
|
+
The API answers with a redirect to a signed S3 link. The redirect is
|
|
237
|
+
followed by hand so the bearer token is sent only to the API, never to S3.
|
|
238
|
+
"""
|
|
239
|
+
response = requests.get(
|
|
240
|
+
API_BASE + BULK_PATH,
|
|
241
|
+
headers=_auth_headers(token),
|
|
242
|
+
allow_redirects=False,
|
|
243
|
+
timeout=60,
|
|
244
|
+
)
|
|
245
|
+
if response.status_code not in _REDIRECT_CODES:
|
|
246
|
+
response.raise_for_status()
|
|
247
|
+
raise RuntimeError(
|
|
248
|
+
f"Expected a redirect from {BULK_PATH}, got HTTP {response.status_code}."
|
|
249
|
+
)
|
|
250
|
+
signed_url = response.headers["location"]
|
|
251
|
+
with requests.get(signed_url, stream=True, timeout=600) as download:
|
|
252
|
+
download.raise_for_status()
|
|
253
|
+
with open(partial, "wb") as f:
|
|
254
|
+
for block in download.iter_content(chunk_size=_DOWNLOAD_CHUNK_BYTES):
|
|
255
|
+
f.write(block)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def snapshot_date(last_updated: str) -> date:
|
|
259
|
+
"""Return the date part of the API's ``lastUpdated`` timestamp."""
|
|
260
|
+
return date.fromisoformat(last_updated[:10])
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
# ---------------------------------------------------------------------------
|
|
264
|
+
# Cleaning
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def normalise_column_name(name: str) -> str:
|
|
269
|
+
"""'LODGEMENT_DATE' / 'lodgementDate' / 'Lodgement Date' -> 'lodgement_date'."""
|
|
270
|
+
snake = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", name.strip())
|
|
271
|
+
return re.sub(r"[^a-z0-9]+", "_", snake.lower()).strip("_")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def certificate_members(names: list[str]) -> list[str]:
|
|
275
|
+
"""Return the certificate CSVs in the bulk zip, without the recommendations."""
|
|
276
|
+
members = sorted(
|
|
277
|
+
n
|
|
278
|
+
for n in names
|
|
279
|
+
if n.lower().endswith(".csv") and "recommendation" not in n.lower()
|
|
280
|
+
)
|
|
281
|
+
if not members:
|
|
282
|
+
raise ValueError("No certificate CSV found in the EPC zip.")
|
|
283
|
+
return members
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_columns(columns: list[str]) -> None:
|
|
287
|
+
"""Refuse a file whose columns differ from ``COLUMN_TYPES``."""
|
|
288
|
+
missing = sorted(set(COLUMN_TYPES) - set(columns))
|
|
289
|
+
extra = sorted(set(columns) - set(COLUMN_TYPES))
|
|
290
|
+
if missing or extra:
|
|
291
|
+
raise ValueError(
|
|
292
|
+
"The EPC file's columns have changed since gla-data's column list "
|
|
293
|
+
f"was written. Missing: {missing}. New: {extra}. Update "
|
|
294
|
+
"_epc.COLUMN_TYPES and the schema before building from this file."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _normalise_categories(chunk: pd.DataFrame) -> None:
|
|
299
|
+
for col, spellings in CASE_NORMALISED.items():
|
|
300
|
+
values = chunk[col]
|
|
301
|
+
mapped = values.str.casefold().map(spellings)
|
|
302
|
+
chunk[col] = mapped.fillna(values)
|
|
303
|
+
band = chunk["construction_age_band"]
|
|
304
|
+
chunk["construction_age_band"] = band.str.removeprefix(_AGE_BAND_PREFIX)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def clean_chunk(chunk: pd.DataFrame) -> tuple[pd.DataFrame, dict[str, int]]:
|
|
308
|
+
"""Keep London rows of one chunk of strings and give columns their types.
|
|
309
|
+
|
|
310
|
+
Returns
|
|
311
|
+
-------
|
|
312
|
+
tuple[pd.DataFrame, dict[str, int]]
|
|
313
|
+
The cleaned rows, and per column the number of non-blank values that
|
|
314
|
+
couldn't be converted to the column's type (they become null).
|
|
315
|
+
"""
|
|
316
|
+
london = chunk.loc[
|
|
317
|
+
chunk["local_authority"].str.startswith(_LONDON_PREFIX, na=False)
|
|
318
|
+
]
|
|
319
|
+
out = london.apply(lambda s: s.str.strip()).astype("string")
|
|
320
|
+
out = out.mask(out.isin(_BLANK_MARKERS))
|
|
321
|
+
_normalise_categories(out)
|
|
322
|
+
|
|
323
|
+
unparsed: dict[str, int] = {}
|
|
324
|
+
for col, kind in COLUMN_TYPES.items():
|
|
325
|
+
values = out[col]
|
|
326
|
+
if kind == _STRING:
|
|
327
|
+
continue
|
|
328
|
+
if kind in (_INT, _FLOAT):
|
|
329
|
+
converted = pd.to_numeric(values, errors="coerce")
|
|
330
|
+
if kind == _INT:
|
|
331
|
+
whole = converted.notna() & (converted == converted.round())
|
|
332
|
+
converted = converted.where(whole).astype("Int64")
|
|
333
|
+
else:
|
|
334
|
+
converted = converted.astype("float64")
|
|
335
|
+
elif kind == _BOOL:
|
|
336
|
+
converted = values.map(_FLAG_VALUES).astype("boolean")
|
|
337
|
+
else:
|
|
338
|
+
converted = pd.to_datetime(values, errors="coerce", format="ISO8601")
|
|
339
|
+
if kind == _DATE:
|
|
340
|
+
converted = converted.dt.normalize()
|
|
341
|
+
converted = converted.astype("datetime64[ms]")
|
|
342
|
+
failed = int((values.notna() & converted.isna()).sum())
|
|
343
|
+
if failed:
|
|
344
|
+
unparsed[col] = failed
|
|
345
|
+
out[col] = converted
|
|
346
|
+
return out[list(COLUMN_TYPES)].reset_index(drop=True), unparsed
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def arrow_schema() -> pa.Schema:
|
|
350
|
+
"""Return the parquet file's schema, from ``COLUMN_TYPES``."""
|
|
351
|
+
types = {
|
|
352
|
+
_STRING: pa.string(),
|
|
353
|
+
_INT: pa.int64(),
|
|
354
|
+
_FLOAT: pa.float64(),
|
|
355
|
+
_BOOL: pa.bool_(),
|
|
356
|
+
_DATE: pa.timestamp("ms"),
|
|
357
|
+
_DATETIME: pa.timestamp("ms"),
|
|
358
|
+
}
|
|
359
|
+
return pa.schema([(col, types[kind]) for col, kind in COLUMN_TYPES.items()])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ---------------------------------------------------------------------------
|
|
363
|
+
# Build
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _write_parquet(zip_path: Path, out: Path) -> dict[str, Any]:
|
|
368
|
+
"""Write the London rows of every certificates CSV in ``zip_path`` to ``out``.
|
|
369
|
+
|
|
370
|
+
Returns
|
|
371
|
+
-------
|
|
372
|
+
dict[str, Any]
|
|
373
|
+
Counts for the record: rows read and kept, unparsed values per column,
|
|
374
|
+
local authority code to name.
|
|
375
|
+
"""
|
|
376
|
+
rows_read = 0
|
|
377
|
+
rows_kept = 0
|
|
378
|
+
unparsed: dict[str, int] = {}
|
|
379
|
+
authorities: dict[str, str] = {}
|
|
380
|
+
schema = arrow_schema()
|
|
381
|
+
# Checked chunk by chunk, so the whole file is never in memory at once.
|
|
382
|
+
validator = _epc_schema(COLUMN_TYPES)
|
|
383
|
+
with zipfile.ZipFile(zip_path) as zf, pq.ParquetWriter(out, schema) as writer: # type: ignore[no-untyped-call]
|
|
384
|
+
for member in certificate_members(zf.namelist()):
|
|
385
|
+
with zf.open(member) as fh:
|
|
386
|
+
reader = pd.read_csv(
|
|
387
|
+
fh,
|
|
388
|
+
dtype=str,
|
|
389
|
+
keep_default_na=False,
|
|
390
|
+
chunksize=_READ_CHUNK_ROWS,
|
|
391
|
+
encoding="utf-8",
|
|
392
|
+
encoding_errors="replace",
|
|
393
|
+
)
|
|
394
|
+
for chunk in reader:
|
|
395
|
+
chunk.columns = [normalise_column_name(c) for c in chunk.columns]
|
|
396
|
+
check_columns(list(chunk.columns))
|
|
397
|
+
rows_read += len(chunk)
|
|
398
|
+
london, failed = clean_chunk(chunk)
|
|
399
|
+
for col, n in failed.items():
|
|
400
|
+
unparsed[col] = unparsed.get(col, 0) + n
|
|
401
|
+
pairs = london[
|
|
402
|
+
["local_authority", "local_authority_label"]
|
|
403
|
+
].dropna()
|
|
404
|
+
authorities.update(
|
|
405
|
+
zip(pairs.iloc[:, 0], pairs.iloc[:, 1], strict=True)
|
|
406
|
+
)
|
|
407
|
+
if len(london):
|
|
408
|
+
validator.validate(london)
|
|
409
|
+
rows_kept += len(london)
|
|
410
|
+
table = pa.Table.from_pandas(
|
|
411
|
+
london, schema=schema, preserve_index=False
|
|
412
|
+
)
|
|
413
|
+
writer.write_table(table)
|
|
414
|
+
if rows_kept == 0:
|
|
415
|
+
raise ValueError(f"No London certificates found in {zip_path}.")
|
|
416
|
+
numbers = pq.read_table(out, columns=["certificate_number"]).column(0) # type: ignore[no-untyped-call]
|
|
417
|
+
return {
|
|
418
|
+
"rows_read": rows_read,
|
|
419
|
+
"rows_kept_london": rows_kept,
|
|
420
|
+
"duplicate_certificate_numbers": rows_kept - len(numbers.unique()),
|
|
421
|
+
"unparsed_values": unparsed,
|
|
422
|
+
"local_authorities": dict(sorted(authorities.items())),
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _sha256(path: Path) -> str:
|
|
427
|
+
digest = hashlib.sha256()
|
|
428
|
+
with open(path, "rb") as f:
|
|
429
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
430
|
+
digest.update(chunk)
|
|
431
|
+
return digest.hexdigest()
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _iso(timestamp: float | None = None) -> str:
|
|
435
|
+
moment = (
|
|
436
|
+
datetime.now(UTC)
|
|
437
|
+
if timestamp is None
|
|
438
|
+
else datetime.fromtimestamp(timestamp, UTC)
|
|
439
|
+
)
|
|
440
|
+
return moment.isoformat(timespec="seconds")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def build(
|
|
444
|
+
destination: Path,
|
|
445
|
+
*,
|
|
446
|
+
token: str | None,
|
|
447
|
+
from_zip: Path | None,
|
|
448
|
+
snapshot: date | None,
|
|
449
|
+
keep_download: bool,
|
|
450
|
+
) -> Path:
|
|
451
|
+
"""Build the London parquet file and its record in ``destination``.
|
|
452
|
+
|
|
453
|
+
Either downloads the current bulk zip with ``token`` (its date comes from
|
|
454
|
+
the API), or builds from ``from_zip`` with its ``snapshot`` date. The
|
|
455
|
+
finished file replaces any earlier one only once complete and valid.
|
|
456
|
+
|
|
457
|
+
Returns
|
|
458
|
+
-------
|
|
459
|
+
Path
|
|
460
|
+
The parquet file.
|
|
461
|
+
"""
|
|
462
|
+
paths = local_paths(destination)
|
|
463
|
+
file_info: dict[str, Any] = {}
|
|
464
|
+
if from_zip is None:
|
|
465
|
+
if token is None:
|
|
466
|
+
raise ValueError("token is required to download the EPC bulk file.")
|
|
467
|
+
file_info = fetch_file_info(token)
|
|
468
|
+
snapshot = snapshot_date(file_info["lastUpdated"])
|
|
469
|
+
if snapshot is None:
|
|
470
|
+
raise ValueError("snapshot is required when building from a zip on disk.")
|
|
471
|
+
|
|
472
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
473
|
+
with FileLock(paths.lock):
|
|
474
|
+
zip_path = from_zip
|
|
475
|
+
if zip_path is None:
|
|
476
|
+
partial = paths.zip.with_name(f"{paths.zip.name}.part")
|
|
477
|
+
download_bulk_zip(token or "", partial)
|
|
478
|
+
partial.replace(paths.zip)
|
|
479
|
+
zip_path = paths.zip
|
|
480
|
+
|
|
481
|
+
paths.building.unlink(missing_ok=True)
|
|
482
|
+
try:
|
|
483
|
+
counts = _write_parquet(zip_path, paths.building)
|
|
484
|
+
record = {
|
|
485
|
+
"dataset": "Domestic Energy Performance Certificates, London",
|
|
486
|
+
"source": API_BASE + BULK_PATH,
|
|
487
|
+
"snapshot_date": snapshot.isoformat(),
|
|
488
|
+
"file_info": file_info,
|
|
489
|
+
"zip_name": zip_path.name,
|
|
490
|
+
"zip_sha256": _sha256(zip_path),
|
|
491
|
+
"zip_size_bytes": zip_path.stat().st_size,
|
|
492
|
+
**counts,
|
|
493
|
+
"built": _iso(),
|
|
494
|
+
"gla_data_version": version("gla-data"),
|
|
495
|
+
}
|
|
496
|
+
record_partial = paths.record.with_name(f"{paths.record.name}.part")
|
|
497
|
+
record_partial.write_text(json.dumps(record, indent=2), encoding="utf-8")
|
|
498
|
+
paths.building.replace(paths.parquet)
|
|
499
|
+
record_partial.replace(paths.record)
|
|
500
|
+
finally:
|
|
501
|
+
paths.building.unlink(missing_ok=True)
|
|
502
|
+
|
|
503
|
+
if from_zip is None and not keep_download:
|
|
504
|
+
paths.zip.unlink(missing_ok=True)
|
|
505
|
+
return paths.parquet
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
# Reads
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def read_record(destination: Path) -> dict[str, Any]:
|
|
514
|
+
"""Return the JSON record written alongside the parquet file."""
|
|
515
|
+
paths = local_paths(destination)
|
|
516
|
+
if not paths.parquet.exists():
|
|
517
|
+
raise FileNotFoundError(
|
|
518
|
+
f"No EPC data in {destination}. Run gla_data.download_epc() first."
|
|
519
|
+
)
|
|
520
|
+
if not paths.record.exists():
|
|
521
|
+
raise FileNotFoundError(
|
|
522
|
+
f"{paths.record} is missing. Rebuild with gla_data.download_epc()."
|
|
523
|
+
)
|
|
524
|
+
record: dict[str, Any] = json.loads(paths.record.read_text(encoding="utf-8"))
|
|
525
|
+
return record
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def is_out_of_date(snapshot: date, today: date) -> bool:
|
|
529
|
+
"""Whether a newer monthly bulk file should exist.
|
|
530
|
+
|
|
531
|
+
The bulk file is rebuilt on the 1st of each month, so a snapshot from
|
|
532
|
+
before the start of the current month has been superseded.
|
|
533
|
+
"""
|
|
534
|
+
return snapshot < today.replace(day=1)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def read(
|
|
538
|
+
parquet: Path, columns: list[str] | None, local_authorities: list[str] | None
|
|
539
|
+
) -> pd.DataFrame:
|
|
540
|
+
"""Read the London file, optionally only some columns and authorities.
|
|
541
|
+
|
|
542
|
+
Both filters are applied by pyarrow while reading, so only what is asked
|
|
543
|
+
for is loaded.
|
|
544
|
+
"""
|
|
545
|
+
filters = (
|
|
546
|
+
[("local_authority", "in", local_authorities)] if local_authorities else None
|
|
547
|
+
)
|
|
548
|
+
table = pq.read_table(parquet, columns=columns, filters=filters) # type: ignore[no-untyped-call]
|
|
549
|
+
return table.to_pandas(types_mapper=_pandas_type).reset_index(drop=True)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _pandas_type(arrow_type: pa.DataType) -> Any:
|
|
553
|
+
"""Nullable pandas types for the parquet file's columns."""
|
|
554
|
+
if pa.types.is_integer(arrow_type):
|
|
555
|
+
return pd.Int64Dtype()
|
|
556
|
+
if pa.types.is_boolean(arrow_type):
|
|
557
|
+
return pd.BooleanDtype()
|
|
558
|
+
if pa.types.is_string(arrow_type) or pa.types.is_large_string(arrow_type):
|
|
559
|
+
return pd.StringDtype()
|
|
560
|
+
return None
|