perfect-postcode/pipeline/download/census_population.py
2026-07-03 18:39:34 +01:00

153 lines
5.7 KiB
Python

"""Download Census 2021 usual-resident counts per unit postcode.
ONS Census 2021 publishes a *direct* headcount of usual residents for every
unit postcode in England and Wales (table P001, disaggregated by sex) as a bulk
CSV on the NOMIS webservice. We sum the two sex rows per postcode to get the
total usual-resident population and emit one row per unit postcode.
This is a display-only side table (shown in the right-hand area pane); it is NOT
merged into the property/postcode feature frames and is never a filterable
attribute. The Rust server loads the parquet directly via --population-path.
Source: NOMIS bulk product "Postcode resident and household estimates, England
and Wales: Census 2021" (table P001), as at census day 21 March 2021.
https://www.nomisweb.co.uk/sources/census_2021_pc
License: Open Government Licence v3.0
Caveats (Census 2021 disclosure control): counts carry targeted record swapping
and cell-key perturbation, and only postcodes with at least one usual resident
are present, so vacant/non-residential postcodes are absent from the output.
"""
import argparse
import tempfile
import time
from pathlib import Path
import polars as pl
from pipeline.utils import download
# P001 = "Number of usual residents by postcode by sex", split alphabetically by
# postcode into four CSVs (each: Postcode, Sex Code, Sex Label, Count). The
# per-file minimums are ~95% of the published record counts and let us retry
# until a download is complete (see _fetch_csv).
BASE = "https://www.nomisweb.co.uk/output/census/2021"
P001_FILES = [
("pcd_p001_a_d.csv", 720_000),
("pcd_p001_e_l.csv", 540_000),
("pcd_p001_m_r.csv", 630_000),
("pcd_p001_s_z.csv", 750_000),
]
# P001 covers England & Wales (~1.37M postcodes). A materially smaller result
# means a split file was truncated or silently 404'd.
MIN_EXPECTED_POSTCODES = 1_000_000
def _canonical_postcode(col: pl.Expr) -> pl.Expr:
"""Canonical spaced unit postcode (e.g. "AL1 1AG"), matching the Rust
server's `normalize_postcode`: uppercase, strip non-alphanumerics, then
insert a single space before the final three (inward-code) characters."""
cleaned = col.cast(pl.String).str.to_uppercase().str.replace_all(r"[^A-Z0-9]", "")
return (
cleaned.str.slice(0, cleaned.str.len_chars() - 3)
+ pl.lit(" ")
+ cleaned.str.slice(-3)
)
def _fetch_csv(
url: str, dest: Path, min_rows: int, *, max_attempts: int = 20
) -> pl.DataFrame:
"""Download a CSV, defending against silent truncation.
The NOMIS bulk files are served with chunked transfer encoding and no
Content-Length, so a dropped connection ends the stream without raising,
yielding a short file. We retry until the parsed row count reaches the
file's known approximate size, keeping the largest parse seen.
"""
best: pl.DataFrame | None = None
for attempt in range(1, max_attempts + 1):
try:
download(url, dest)
df = pl.read_csv(dest)
except Exception as exc: # noqa: BLE001: retry any transport/parse error
print(f" {url} attempt {attempt}: error {exc}")
time.sleep(3)
continue
rows = df.height
if best is None or rows > best.height:
best = df
complete = rows >= min_rows
print(
f" {url} attempt {attempt}: {rows} rows "
f"({'complete' if complete else f'< {min_rows}, retrying'})"
)
if complete:
return df
time.sleep(2)
raise RuntimeError(
f"Failed to fully download {url} after {max_attempts} attempts "
f"(best {best.height if best is not None else 0} rows, need >= {min_rows})"
)
def download_and_convert(output_path: Path) -> None:
frames: list[pl.DataFrame] = []
with tempfile.TemporaryDirectory() as tmp:
for name, min_rows in P001_FILES:
url = f"{BASE}/{name}"
dest = Path(tmp) / name
print(f"Downloading {url} ...")
frames.append(_fetch_csv(url, dest, min_rows))
df = pl.concat(frames)
print(f"Total P001 rows (postcode x sex): {df.height}")
if "Count" not in df.columns or "Postcode" not in df.columns:
raise ValueError(
f"Unexpected P001 columns {df.columns}; expected 'Postcode' and 'Count'."
)
result = (
df.with_columns(_canonical_postcode(pl.col("Postcode")).alias("postcode"))
.filter(pl.col("postcode").str.len_chars() >= 5)
.group_by("postcode")
.agg(pl.col("Count").cast(pl.Int64).sum().alias("population"))
.filter(pl.col("population") > 0)
.with_columns(pl.col("population").cast(pl.UInt32))
.sort("postcode")
)
print(f"Unit postcodes with population: {result.height}")
if result.height < MIN_EXPECTED_POSTCODES:
raise ValueError(
f"Only {result.height} postcodes (expected >= {MIN_EXPECTED_POSTCODES}); "
"a NOMIS P001 split file was likely truncated or unavailable."
)
total = result["population"].sum()
print(f"Total usual residents (England & Wales): {total:,}")
print(
f"Per-postcode population range: {result['population'].min()} - "
f"{result['population'].max()}"
)
output_path.parent.mkdir(parents=True, exist_ok=True)
result.write_parquet(output_path, compression="zstd")
print(f"Saved to {output_path}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Download Census 2021 usual-resident counts per unit postcode"
)
parser.add_argument(
"--output", type=Path, required=True, help="Output parquet file path"
)
args = parser.parse_args()
download_and_convert(args.output)
if __name__ == "__main__":
main()