153 lines
5.7 KiB
Python
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()
|