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.
Files changed (66) hide show
  1. {gla_data-0.3.0 → gla_data-0.4.0}/PKG-INFO +3 -3
  2. {gla_data-0.3.0 → gla_data-0.4.0}/README.md +2 -2
  3. {gla_data-0.3.0 → gla_data-0.4.0}/pyproject.toml +4 -4
  4. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/__init__.py +14 -0
  5. gla_data-0.4.0/src/gla_data/_epc.py +560 -0
  6. gla_data-0.4.0/src/gla_data/_epc_clean.py +1451 -0
  7. gla_data-0.4.0/src/gla_data/_epc_features.py +255 -0
  8. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_ons.py +3 -1
  9. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_postgres.py +3 -5
  10. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_schemas.py +208 -15
  11. gla_data-0.4.0/src/gla_data/addressbase.py +105 -0
  12. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/catalogue.py +5 -5
  13. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/config.py +37 -13
  14. gla_data-0.4.0/src/gla_data/epc.py +468 -0
  15. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/explore.py +1 -1
  16. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/front_gardens.py +2 -1
  17. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/geography.py +114 -38
  18. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_addressbase.py +5 -45
  19. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_boundaries.py +96 -21
  20. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_config.py +26 -0
  21. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_conventions.py +2 -0
  22. gla_data-0.4.0/tests/test_epc.py +465 -0
  23. gla_data-0.4.0/tests/test_epc_clean.py +728 -0
  24. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_front_gardens.py +3 -2
  25. gla_data-0.4.0/tests/test_gis_layer.py +201 -0
  26. gla_data-0.3.0/src/gla_data/addressbase.py +0 -129
  27. gla_data-0.3.0/tests/test_gis_layer.py +0 -77
  28. {gla_data-0.3.0 → gla_data-0.4.0}/.gitignore +0 -0
  29. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_addressbase.py +0 -0
  30. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_aerial_imagery.py +0 -0
  31. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_arcgis.py +0 -0
  32. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_bbox.py +0 -0
  33. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_cache.py +0 -0
  34. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_lbsm.py +0 -0
  35. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_london.py +0 -0
  36. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_londondatastore.py +0 -0
  37. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_s3.py +0 -0
  38. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_tree_canopy.py +0 -0
  39. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/_ukds.py +0 -0
  40. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/addressbase_ngd.py +0 -0
  41. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/aerial_imagery.py +0 -0
  42. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/census.py +0 -0
  43. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/cli.py +0 -0
  44. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/lbsm.py +0 -0
  45. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/mastermap.py +0 -0
  46. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/py.typed +0 -0
  47. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/transforms.py +0 -0
  48. {gla_data-0.3.0 → gla_data-0.4.0}/src/gla_data/tree_canopy.py +0 -0
  49. {gla_data-0.3.0 → gla_data-0.4.0}/tests/__init__.py +0 -0
  50. {gla_data-0.3.0 → gla_data-0.4.0}/tests/conftest.py +0 -0
  51. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_addressbase_ngd.py +0 -0
  52. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_aerial_imagery.py +0 -0
  53. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_aggregate.py +0 -0
  54. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_catalogue.py +0 -0
  55. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_census_dwellings.py +0 -0
  56. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_cli.py +0 -0
  57. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_crosswalk.py +0 -0
  58. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_explore.py +0 -0
  59. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_geography_lookup.py +0 -0
  60. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_lbsm.py +0 -0
  61. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_london.py +0 -0
  62. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_londondatastore.py +0 -0
  63. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_mastermap.py +0 -0
  64. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_postgres.py +0 -0
  65. {gla_data-0.3.0 → gla_data-0.4.0}/tests/test_s3.py +0 -0
  66. {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.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.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 load_addressbase(source="postgres") needs to
31
- # separately install a psycopg2 driver.
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.3.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