diff --git a/.gitignore b/.gitignore index 4b1aeaa..6ab0ba9 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,8 @@ video/auth.* *.jpeg *.mp4 +**/*.log + r5-java/tmp property-data property-data2 diff --git a/Dockerfile b/Dockerfile index 7fb2826..f460db4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,6 +39,10 @@ VOLUME ["/app/data"] RUN chown -R appuser:appuser /app USER appuser +# Fallback for any allocations not served by jemalloc (the binary's global +# allocator, tuned via the baked-in malloc_conf): cap glibc to 2 arenas so freed +# memory coalesces and is returned instead of fragmenting across per-CPU arenas. +ENV MALLOC_ARENA_MAX=2 EXPOSE 8001 HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \ CMD curl -f http://localhost:8001/health || exit 1 diff --git a/Makefile.data b/Makefile.data index 4d41cbd..af3a1f3 100644 --- a/Makefile.data +++ b/Makefile.data @@ -64,8 +64,6 @@ PBF := $(DATA_DIR)/england-latest.osm.pbf FR_TOW := $(DATA_DIR)/FR_TOW_V1_ALL.zip NFI := $(DATA_DIR)/NFI_WOODLAND_ENGLAND.zip TREE_DENSITY_PC := $(DATA_DIR)/tree_density_by_postcode.parquet -TREE_DENSITY_STREETS := $(DATA_DIR)/tree_density_by_street.parquet -TREE_DENSITY_ADDR := $(DATA_DIR)/tree_density_by_address.parquet OFS_REGISTER := $(DATA_DIR)/ofs_register.xlsx PLACES := $(DATA_DIR)/places.parquet MEDIAN_AGE := $(DATA_DIR)/median_age.parquet @@ -117,10 +115,10 @@ MAP_ASSETS_DEPS := pipeline/download/map_assets.py pipeline/transform/transform_ transform-school-proximity transform-tree-density \ generate-postcode-boundaries generate-travel-times enrich-actual-listings -prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) - $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) --postcode-boundary-match "$(POSTCODES_PQ)::$(PC_BOUNDARIES)" +prepare: $(PRICES_STAMP) download-places tiles satellite-tiles overlay-tiles property-border-tiles tree-overlay-tiles crime-hotspot-tiles property-border-tiles generate-postcode-boundaries download-map-assets generate-travel-times | $(POSTCODES_PQ) $(PROPERTIES_PQ) $(PRICE_INDEX) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --parquet $(PRICE_INDEX) --postcode-boundary-match "$(POSTCODES_PQ)::$(PC_BOUNDARIES)" --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) merge: $(MERGE_STAMP) | $(POSTCODES_PQ) $(PROPERTIES_PQ) - $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" enrich-actual-listings: $(ACTUAL_LISTINGS_ENRICHED) tiles: $(TILES) $(SATELLITE_TILES) $(SATELLITE_HIGHRES_TILES) satellite-tiles: $(SATELLITE_TILES) @@ -183,6 +181,7 @@ $(PC_BOUNDARIES_STAMP): $(OA_BOUNDARIES) $(INSPIRE_STAMP) $(UPRN_LOOKUP) $(ARCGI --oa-boundaries $(OA_BOUNDARIES) \ --inspire $(INSPIRE_DIR) \ --output $(PC_BOUNDARIES) + $(VALIDATE_OUTPUTS) --active-postcode-boundary-match "$(ARCGIS)::$(PC_BOUNDARIES)" @touch $@ generate-travel-times: $(ARCGIS) $(PLACES) $(PBF) download-transit-network @if [ -f "$(R5_NETWORK_CACHE)" ] && { [ "$(PBF)" -nt "$(R5_NETWORK_CACHE)" ] || [ "$(TRANSIT_STAMP)" -nt "$(R5_NETWORK_CACHE)" ]; }; then \ @@ -328,8 +327,8 @@ $(GREENSPACE): $(PBF) $(OS_GREENSPACE): uv run python -m pipeline.download.os_greenspace --output $@ -$(PLACES): $(PBF) $(ENGLAND_BOUNDARY) $(NAPTAN) $(OFS_REGISTER) $(ARCGIS) - uv run python -m pipeline.download.places --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY) --naptan $(NAPTAN) --university-register $(OFS_REGISTER) --postcodes $(ARCGIS) +$(PLACES): $(PBF) $(ENGLAND_BOUNDARY) $(NAPTAN) $(OFS_REGISTER) $(ARCGIS) $(POIS_RAW) + uv run python -m pipeline.download.places --output $@ --pbf $(PBF) --boundary $(ENGLAND_BOUNDARY) --naptan $(NAPTAN) --university-register $(OFS_REGISTER) --postcodes $(ARCGIS) --pois $(POIS_RAW) --include-streets $(MEDIAN_AGE): @@ -358,7 +357,7 @@ $(POIS_FILTERED): $(POIS_RAW) $(NAPTAN) $(GROCERY_RETAIL_POINTS) $(GIAS) $(OFSTE $(EPC_PP): $(PRICE_PAID) $(EPC) pipeline/transform/join_epc_pp.py pipeline/utils/fuzzy_join.py uv run python -m pipeline.transform.join_epc_pp --epc $(EPC) --price-paid $(PRICE_PAID) --output $@ -$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py +$(CRIME) $(CRIME_BY_YEAR) &: $(CRIME_STAMP) $(PC_BOUNDARIES_STAMP) pipeline/transform/crime_spatial.py pipeline/transform/postcode_boundaries/loader.py pipeline/transform/crime.py $(VALIDATE_OUTPUTS) --file $(CRIME_DIR)/archive_manifest.json --glob "$(CRIME_DIR)::**/*-street.csv" uv run python -m pipeline.transform.crime_spatial --input $(CRIME_DIR) --boundaries $(PC_BOUNDARIES)/units --output $(CRIME) --output-by-year $(CRIME_BY_YEAR) @@ -368,15 +367,12 @@ $(POI_PROXIMITY): $(ARCGIS) $(POIS_FILTERED) $(OS_GREENSPACE) $(POI_PROXIMITY_DE $(SCHOOL_PROX): $(OFSTED) $(ARCGIS) pipeline/transform/school_proximity.py pipeline/utils/poi_counts.py uv run python -m pipeline.transform.school_proximity --ofsted $(OFSTED) --arcgis $(ARCGIS) --output $@ -$(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(PRICE_PAID) $(TREE_DENSITY_DEPS) +$(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(TREE_DENSITY_DEPS) uv run python -m pipeline.transform.tree_density \ --tow-zip $(FR_TOW) \ --nfi-zip $(NFI) \ --arcgis $(ARCGIS) \ - --price-paid $(PRICE_PAID) \ - --output-postcodes $(TREE_DENSITY_PC) \ - --output-streets $(TREE_DENSITY_STREETS) \ - --output-addresses $(TREE_DENSITY_ADDR) + --output-postcodes $(TREE_DENSITY_PC) # Postcode boundaries require manual generation — fail with instructions $(PC_BOUNDARIES): @@ -417,13 +413,13 @@ $(MERGE_STAMP): $(EPC_PP) $(ARCGIS) $(IOD) $(POI_PROXIMITY) \ --tree-density-postcodes $(TREE_DENSITY_PC) \ --output-postcodes $(POSTCODES_PQ) \ --output-properties $(PROPERTIES_PQ) - $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" @touch $@ # ── Price estimation (post-merge) ─────────────────────────────────────────── $(POSTCODES_PQ) $(PROPERTIES_PQ) &: $(MERGE_STAMP) - $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) + $(VALIDATE_OUTPUTS) --parquet $(POSTCODES_PQ) --parquet $(PROPERTIES_PQ) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" $(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) uv run python -m pipeline.transform.price_estimation.index --input $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --output $@ @@ -432,7 +428,7 @@ $(PRICE_INDEX): $(MERGE_STAMP) $(PRICE_INDEX_DEPS) | $(PROPERTIES_PQ) $(POSTCODE $(PRICES_STAMP): $(MERGE_STAMP) $(PRICE_INDEX) $(PRICE_ESTIMATE_DEPS) | $(PROPERTIES_PQ) $(POSTCODES_PQ) @rm -f $@ uv run python -m pipeline.transform.price_estimation.estimate --properties $(PROPERTIES_PQ) --postcodes $(POSTCODES_PQ) --index $(PRICE_INDEX) - $(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) + $(VALIDATE_OUTPUTS) --parquet $(PROPERTIES_PQ) --parquet $(POSTCODES_PQ) --parquet $(PRICE_INDEX) --postcode-features $(POSTCODES_PQ) --postcode-universe "$(ARCGIS)::$(POSTCODES_PQ)" --properties-subset "$(PROPERTIES_PQ)::$(POSTCODES_PQ)" --price-index $(PRICE_INDEX) @touch $@ $(ACTUAL_LISTINGS_ENRICHED): $(ACTUAL_LISTINGS_RAW) $(EPC) \ diff --git a/analyses/online_listings_buy.ipynb b/analyses/online_listings_buy.ipynb index 6f461c2..44dba93 100644 --- a/analyses/online_listings_buy.ipynb +++ b/analyses/online_listings_buy.ipynb @@ -2,68 +2,99 @@ "cells": [ { "cell_type": "markdown", - "id": "55bc43ad", + "id": "21f27a93", "metadata": {}, "source": [ - "# Online Buy Listings\n", + "# Online Buy Listings — data quality & cleanup\n", "\n", - "Analysis of `finder/data/online_listings_buy.parquet`." + "Source: `finder/data/online_listings_buy.parquet` — ~112k UK *for-sale* property listings\n", + "(Greater London) scraped from **Rightmove**, **OnTheMarket** and **Zoopla** and merged by the\n", + "`finder` pipeline (`transform.py` / `onthemarket.py` / `zoopla.py` → `storage.py`).\n", + "\n", + "This notebook:\n", + "1. **Profiles** the raw data (shape, schema, missingness by provider).\n", + "2. **Demonstrates each data-quality issue** found in the audit, with the query that proves it.\n", + "3. **Cleans** the data (cell block in §4), adding provenance/flag columns rather than silently dropping where possible.\n", + "4. **Re-shows the stats** so the fixes are visible (§5), incl. a before/after summary table.\n", + "\n", + "Every count below is computed live from the file." ] }, { "cell_type": "code", "execution_count": 1, - "id": "1560f01a", + "id": "075fbea3", "metadata": { "execution": { - "iopub.execute_input": "2026-05-17T10:23:47.594553Z", - "iopub.status.busy": "2026-05-17T10:23:47.594488Z", - "iopub.status.idle": "2026-05-17T10:23:47.858517Z", - "shell.execute_reply": "2026-05-17T10:23:47.858194Z" + "iopub.execute_input": "2026-06-03T07:34:30.479194Z", + "iopub.status.busy": "2026-06-03T07:34:30.479121Z", + "iopub.status.idle": "2026-06-03T07:34:30.900789Z", + "shell.execute_reply": "2026-06-03T07:34:30.900538Z" } }, - "outputs": [], + "outputs": [ + { + "ename": "ModuleNotFoundError", + "evalue": "No module named 'matplotlib'", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mModuleNotFoundError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[1]\u001b[39m\u001b[32m, line 3\u001b[39m\n\u001b[32m 1\u001b[39m \u001b[38;5;28;01mfrom\u001b[39;00m pathlib \u001b[38;5;28;01mimport\u001b[39;00m Path\n\u001b[32m 2\u001b[39m \n\u001b[32m----> \u001b[39m\u001b[32m3\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m matplotlib.pyplot \u001b[38;5;28;01mas\u001b[39;00m plt\n\u001b[32m 4\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m numpy \u001b[38;5;28;01mas\u001b[39;00m np\n\u001b[32m 5\u001b[39m \u001b[38;5;28;01mimport\u001b[39;00m polars \u001b[38;5;28;01mas\u001b[39;00m pl\n\u001b[32m 6\u001b[39m \n", + "\u001b[31mModuleNotFoundError\u001b[39m: No module named 'matplotlib'" + ] + } + ], "source": [ "from pathlib import Path\n", "\n", - "import plotly.express as px\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", "import polars as pl\n", "\n", - "pl.Config.set_tbl_rows(45)\n", - "pl.Config.set_fmt_str_lengths(80)\n", + "%matplotlib inline\n", + "plt.rcParams[\"figure.dpi\"] = 110\n", + "pl.Config.set_tbl_rows(40)\n", + "pl.Config.set_fmt_str_lengths(90)\n", + "pl.Config.set_tbl_cols(30)\n", "\n", - "DATA_PATH = Path(\"/volumes/projects/perfect-postcode/finder/data/test/online_listings_buy.parquet\")" + "DATA_PATH = Path(\"/volumes/projects/perfect-postcode/finder/data/online_listings_buy.parquet\")\n", + "\n", + "# Provider derived from the listing URL domain (mirrors the existing analysis convention).\n", + "PROVIDER = (\n", + " pl.when(pl.col(\"Listing URL\").str.contains(\"rightmove.co.uk\", literal=True)).then(pl.lit(\"Rightmove\"))\n", + " .when(pl.col(\"Listing URL\").str.contains(\"onthemarket.com\", literal=True)).then(pl.lit(\"OnTheMarket\"))\n", + " .when(pl.col(\"Listing URL\").str.contains(\"zoopla.co.uk\", literal=True)).then(pl.lit(\"Zoopla\"))\n", + " .otherwise(pl.lit(\"Unknown\"))\n", + " .alias(\"provider\")\n", + ")\n", + "\n", + "raw = pl.read_parquet(DATA_PATH).with_columns(PROVIDER)\n", + "print(f\"Rows: {raw.height:,} Columns: {len(raw.columns)}\")\n", + "raw.group_by(\"provider\").len().sort(\"len\", descending=True)" ] }, { "cell_type": "markdown", - "id": "6c5d1f0a", + "id": "8d632ad4", "metadata": {}, "source": [ - "## Load" + "## 1 · Overview" ] }, { "cell_type": "code", - "execution_count": 2, - "id": "09a54208", + "execution_count": null, + "id": "692830dd", "metadata": { "execution": { - "iopub.execute_input": "2026-05-17T10:23:47.859716Z", - "iopub.status.busy": "2026-05-17T10:23:47.859600Z", - "iopub.status.idle": "2026-05-17T10:23:47.955592Z", - "shell.execute_reply": "2026-05-17T10:23:47.955345Z" + "iopub.execute_input": "2026-06-03T07:34:30.901690Z", + "iopub.status.busy": "2026-06-03T07:34:30.901608Z", + "iopub.status.idle": "2026-06-03T07:34:30.903798Z", + "shell.execute_reply": "2026-06-03T07:34:30.903603Z" } }, "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Rows: 242\n", - "Columns: 25\n" - ] - }, { "data": { "text/html": [ @@ -74,183 +105,63 @@ " white-space: pre-wrap;\n", "}\n", "\n", - "shape: (3, 2)
providerlen
stru32
"OnTheMarket"85
"Rightmove"99
"Zoopla"58
" + "shape: (25, 2)
columndtype
strstr
"Bedrooms""Int32"
"Bathrooms""Int32"
"Number of bedrooms & living rooms""Int32"
"lon""Float64"
"lat""Float64"
"Postcode""String"
"Postcode source""String"
"Extracted postcode""String"
"Inferred postcode""String"
"Listing raw address""String"
"Address per Property Register""String"
"UPRN""String"
"Property number or name""String"
"Leasehold/Freehold""String"
"Property type""String"
"Property sub-type""String"
"Price qualifier""String"
"Total floor area (sqm)""Float64"
"Listing URL""String"
"Listing features""List(String)"
"Listing date""Datetime(time_unit='us', time_zone=None)"
"Listing status""String"
"Asking price""Int64"
"Asking price per sqm""Int32"
"provider""String"
" ], "text/plain": [ - "shape: (3, 2)\n", - "┌─────────────┬─────┐\n", - "│ provider ┆ len │\n", - "│ --- ┆ --- │\n", - "│ str ┆ u32 │\n", - "╞═════════════╪═════╡\n", - "│ OnTheMarket ┆ 85 │\n", - "│ Rightmove ┆ 99 │\n", - "│ Zoopla ┆ 58 │\n", - "└─────────────┴─────┘" + "shape: (25, 2)\n", + "┌───────────────────────────────────┬──────────────────────────────────────────┐\n", + "│ column ┆ dtype │\n", + "│ --- ┆ --- │\n", + "│ str ┆ str │\n", + "╞═══════════════════════════════════╪══════════════════════════════════════════╡\n", + "│ Bedrooms ┆ Int32 │\n", + "│ Bathrooms ┆ Int32 │\n", + "│ Number of bedrooms & living rooms ┆ Int32 │\n", + "│ lon ┆ Float64 │\n", + "│ lat ┆ Float64 │\n", + "│ Postcode ┆ String │\n", + "│ Postcode source ┆ String │\n", + "│ Extracted postcode ┆ String │\n", + "│ Inferred postcode ┆ String │\n", + "│ Listing raw address ┆ String │\n", + "│ Address per Property Register ┆ String │\n", + "│ UPRN ┆ String │\n", + "│ Property number or name ┆ String │\n", + "│ Leasehold/Freehold ┆ String │\n", + "│ Property type ┆ String │\n", + "│ Property sub-type ┆ String │\n", + "│ Price qualifier ┆ String │\n", + "│ Total floor area (sqm) ┆ Float64 │\n", + "│ Listing URL ┆ String │\n", + "│ Listing features ┆ List(String) │\n", + "│ Listing date ┆ Datetime(time_unit='us', time_zone=None) │\n", + "│ Listing status ┆ String │\n", + "│ Asking price ┆ Int64 │\n", + "│ Asking price per sqm ┆ Int32 │\n", + "│ provider ┆ String │\n", + "└───────────────────────────────────┴──────────────────────────────────────────┘" ] }, + "execution_count": 2, "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "
\n", - "shape: (45, 25)
providerBedroomsBathroomsNumber of bedrooms & living roomslonlatPostcodePostcode sourceExtracted postcodeInferred postcodeListing raw addressAddress per Property RegisterUPRNProperty number or nameLeasehold/FreeholdProperty typeProperty sub-typePrice qualifierTotal floor area (sqm)Listing URLListing featuresListing dateListing statusAsking priceAsking price per sqm
stri32i32i32f64f64strstrstrstrstrstrstrstrstrstrstrstrf64strlist[str]datetime[μs]stri64i32
"OnTheMarket"112-0.12910251.463636"SW4 7EF""coordinates"null"SW4 7EF""Bedford Road, London""Bedford Road, London"nullnull"Leasehold""Flats/Maisonettes""Apartment"""null"https://www.onthemarket.com/details/19209711/"["Tenure: Leasehold (111 years remaining)", "Top floor one-bedroom apartment within a modern development completed in 2021", … "Generous double bedroom, contemporary bathroom and separate utility/storage room"]null"For sale"525000null
"OnTheMarket"112-0.10023351.466129"SE5 9AA""coordinates"null"SE5 9AA""Padfield Road, London, SE5""Padfield Road, London"nullnull"Freehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19522441/"["Tenure: Share of freehold (999 years remaining)", "One Bedroom"]null"For sale"275000null
"OnTheMarket"415-0.10891351.471775"SW9 6NN""coordinates"null"SW9 6NN""Lord Holland Lane, Myatts Fields South, London""Lord Holland Lane, Myatts Fields South, London"nullnull"Freehold""Terraced""Terraced House""Offers in excess of"null"https://www.onthemarket.com/details/18847208/"["Tenure: Freehold", "Four Bedrooms", … "Superb Condition"]null"For sale"600000null
"OnTheMarket"112-0.10790451.473026"SW9 6BF""coordinates"null"SW9 6BF""Mostyn Road, London SW9""Mostyn Road, London"nullnull"Leasehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19305103/"["Tenure: Leasehold", "One bedroom"]null"For sale"350000null
"OnTheMarket"112-0.11537251.475374"SW9 0RG""coordinates"null"SW9 0RG""Hackford Road, London SW9""Hackford Road, London"nullnull"Freehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19601952/"["Tenure: Share of freehold", "Separate dining and living room", … "Study"]null"For sale"475000null
"OnTheMarket"011-0.11791151.476212"SW9 0HP""coordinates"null"SW9 0HP""Clapham Road, London SW9""Clapham Road, London"nullnull"Leasehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19601638/"["Tenure: Leasehold", "Nearest station 0.3mi."]null"For sale"225000null
"OnTheMarket"224-0.10557951.463969"SW9 8SE""coordinates"null"SW9 8SE""Coldharbour Lane, Brixton, London, SW9""Coldharbour Lane, Brixton, London"nullnull"Leasehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19601141/"["Tenure: Leasehold", "Fantastic two bedroom period flat"]null"For sale"575000null
"OnTheMarket"112-0.11643751.466426"SW9 9FX""coordinates"null"SW9 9FX""Stockwell Road, SW9""Stockwell Road"nullnull"Leasehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19599310/"["Tenure: Leasehold (120 years remaining)", "One double bedroom", "Chain-free"]null"For sale"435000null
"OnTheMarket"224-0.10235551.46566"SW9 8RU""coordinates"null"SW9 8RU""Coldharbour Lane, SW9""Coldharbour Lane"nullnull"Leasehold""Flats/Maisonettes""Flat""Offers over"null"https://www.onthemarket.com/details/19599313/"["Tenure: Leasehold (127 years remaining)", "Two double bedrooms", "Chain-free"]null"For sale"450000null
"OnTheMarket"224-0.11449651.466421"SW9 0FH""coordinates"null"SW9 0FH""Stockwell Park Walk, London, SW9""Stockwell Park Walk, London"nullnull"Leasehold""Flats/Maisonettes""Flat""Offers in excess of"null"https://www.onthemarket.com/details/18060975/"["Tenure: Leasehold (113 years remaining)", "Stylish and well-presented two-bedroom, two-bathroom apartment", "Study"]null"For sale"500000null
"OnTheMarket"224-0.12691851.469851"SW4 6BF""coordinates"null"SW4 6BF""20 Fergusson Mews, London, SW4""20 Fergusson Mews, London"nullnull"Leasehold""Flats/Maisonettes""Flat""Offers in excess of"null"https://www.onthemarket.com/details/19266851/"["Tenure: Leasehold", "Gated Development", … "Chain-free"]null"For sale"650000null
"OnTheMarket"112-0.11653551.466385"SW9 9FX""coordinates"null"SW9 9FX""Stockwell Road, Stockwell, London, SW9""Stockwell Road, Stockwell, London"nullnull"Leasehold""Flats/Maisonettes""Flat""Offers in excess of"null"https://www.onthemarket.com/details/19167472/"["Tenure: Leasehold (125 years remaining)", "Stylish finish", … "Chain-free"]null"For sale"450000null
"OnTheMarket"112-0.110951.462364"SW9 8GA""coordinates"null"SW9 8GA""Milles Square, Brixton, London, SW9""Milles Square, Brixton, London"nullnull"Leasehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19592532/"["Tenure: Leasehold (155 years remaining)", "Neatly presented 1 bedroom flat in a convenient location"]null"For sale"340000null
"OnTheMarket"213-0.11026951.46006"SE24 0LW""coordinates"null"SE24 0LW""Railton Road, London SE24""Railton Road, London"nullnull"Leasehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19591254/"["Tenure: Leasehold", "Two Double Bedrooms", "Chain-free"]null"For sale"500000null
"OnTheMarket"112-0.12629751.466742"SW9 9LE""coordinates"null"SW9 9LE""Hemberton Road, London SW9""Hemberton Road, London"nullnull"Freehold""Flats/Maisonettes""Flat"""null"https://www.onthemarket.com/details/19591126/"["Tenure: Share of freehold", "Chain Free", … "Study"]null"For sale"550000null
"Rightmove"112-0.12156951.471079"SW9 9EU""coordinates"null"SW9 9EU""Stockwell Road, London, SW9""Stockwell Road, London"nullnullnull"Flats/Maisonettes""Apartment""Guide Price"null"https://www.rightmove.co.uk/properties/89076063#/?channel=RES_BUY"["Within easy reach of local amenities, green spaces and transport connections", "Excellent value-add opportunity for owner-occupiers and investors", … "Well-proportioned one-bedroom apartment in a popular SW9 location"]2026-05-29 14:30:37"For sale"310000null
"Rightmove"549-0.1181751.47115"SW9 0DG""coordinates"null"SW9 0DG""Stockwell Park Crescent II, London SW9""Stockwell Park Crescent II, London"nullnull"Freehold""Semi-Detached""Semi-Detached"""310.6"https://www.rightmove.co.uk/properties/174610352#/?channel=RES_BUY"[]2026-04-17 18:34:21"For sale"25950008355
"Rightmove"639-0.11472751.473247"SW9 0AR""coordinates"null"SW9 0AR""Groveway, Stockwell""Groveway, Stockwell"nullnull"Freehold""Detached""Detached"""null"https://www.rightmove.co.uk/properties/174282005#/?channel=RES_BUY"["Early Victorian Family Home", "Grade II Listed", … "Wonderful Potential"]2026-04-10 11:17:35"For sale"2295000null
"Rightmove"426-0.11660351.472141"SW9 0DD""coordinates"null"SW9 0DD""Stockwell Park Road, London, SW9""Stockwell Park Road, London"nullnull"Freehold""Semi-Detached""Semi-Detached"""216.1"https://www.rightmove.co.uk/properties/170781485#/?channel=RES_BUY"["4 bedrooms", "2 reception rooms", … "Semi-Detached"]2026-01-02 17:30:39"For sale"225000010412
"Rightmove"224-0.1124751.46428"SW9 7QH""coordinates"null"SW9 7QH""St Johns Buildings, London SW9""St Johns Buildings, London"nullnullnull"Terraced""Terraced"""213.4"https://www.rightmove.co.uk/properties/174206714#/?channel=RES_BUY"[]2026-04-08 17:23:59"For sale"21000009841
"Rightmove"437-0.11676551.47089"SW9 0DB""coordinates"null"SW9 0DB""Stockwell Park Road, London SW9""Stockwell Park Road, London"nullnull"Freehold""Terraced""Terraced""Guide Price"197.5"https://www.rightmove.co.uk/properties/88435821#/?channel=RES_BUY"["4 bedrooms", "2 reception rooms", … "Garden"]2026-05-13 16:21:42"For sale"210000010633
"Rightmove"538-0.11311151.478768"SW9 0LJ""coordinates"null"SW9 0LJ""Crewdson Road, London SW9""Crewdson Road, London"nullnull"Freehold""Terraced""Terraced"""246.7"https://www.rightmove.co.uk/properties/174798002#/?channel=RES_BUY"["5 bedrooms", "1 reception room", … "Garden"]2026-04-22 16:04:47"For sale"19000007702
"Rightmove"336-0.1180551.47091"SW9 0DG""coordinates"null"SW9 0DG""Stockwell Park Crescent, Stockwell, London, SW9""Stockwell Park Crescent, Stockwell, London"nullnull"Freehold""Detached""House"""156.8"https://www.rightmove.co.uk/properties/88376967#/?channel=RES_BUY"["Wonderful 2-bedroom semi-detached house situated over 3 floors", "Spacious kitchen", … "Well located for the local shops and amenities of Clapham and Stockwell Road"]2026-05-12 16:14:48"For sale"169500010810
"Rightmove"538-0.11919251.474381"SW9 0RU""coordinates"null"SW9 0RU""Stockwell Park Road, London, SW9""Stockwell Park Road, London"nullnull"Freehold""Semi-Detached""Semi-Detached"""193.2"https://www.rightmove.co.uk/properties/167893637#/?channel=RES_BUY"[]2025-10-07 09:57:34"For sale"16950008773
"Rightmove"224-0.1177851.470774"SW9 0DG""coordinates"null"SW9 0DG""Stockwell Park Crescent, Stockwell""Stockwell Park Crescent, Stockwell"nullnull"Freehold""Semi-Detached""Semi-Detached"""null"https://www.rightmove.co.uk/properties/173668991#/?channel=RES_BUY"["Georgian Home", "Excellent Condition", … "No Onward Chain"]2026-03-25 09:55:49"For sale"1695000null
"Rightmove"437-0.12104151.465546"SW9 9RF""coordinates"null"SW9 9RF""Hargwyne Street, London, SW9""Hargwyne Street, London"nullnull"Freehold""Terraced""Terraced"""165.7"https://www.rightmove.co.uk/properties/174050450#/?channel=RES_BUY"[]2026-04-02 16:51:56"For sale"16500009958
"Rightmove"527-0.11919451.470986"SW9 0SW""coordinates"null"SW9 0SW""Burnley Road, London, SW9""Burnley Road, London"nullnull"Freehold""Terraced""Terraced""Guide Price"199.5"https://www.rightmove.co.uk/properties/88367181#/?channel=RES_BUY"["Quiet Residential Street", "Versatile Accommodation", … "Period Features"]2026-05-12 14:23:19"For sale"15950007995
"Rightmove"538-0.11891451.475262"SW9 0QQ""coordinates"null"SW9 0QQ""Clapham Road, Stockwell""Clapham Road, Stockwell"nullnull"Freehold""Terraced""Terraced"""null"https://www.rightmove.co.uk/properties/172178081#/?channel=RES_BUY"["Georgian Townhouse", "Five Double Bedrooms", … "2617sq.ft"]2026-02-13 14:31:16"For sale"1550000null
"Rightmove"819-0.11432951.465359"SW9 7AW""coordinates"null"SW9 7AW""Brixton Road, Brixton High Street""Brixton Road, Brixton High Street"nullnull"Freehold""Terraced""Terraced""Guide Price"44.0"https://www.rightmove.co.uk/properties/88139847#/?channel=RES_BUY"["FREEHOLD MIXED USE COMMERCIAL & RESIDENTIAL", "GROUND FLOOR TAKE-AWAY - LEASE EXPIRING 2030", … "NEAR BRIXTON UNDERGROUND & OVERGROUND STATIONS"]2026-05-06 17:31:38"For sale"155000035227
"Rightmove"426-0.11890351.461726"SW9 8DR""coordinates"null"SW9 8DR""Trinity Gardens, LONDON""Trinity Gardens, LONDON"nullnull"Freehold""Terraced""Terraced""Guide Price"114.5"https://www.rightmove.co.uk/properties/174274454#/?channel=RES_BUY"["Victorian House", "Mid Terraced", … "Central Location"]2026-04-10 09:39:04"For sale"150000013100
"Zoopla"426-0.11681951.477238"SW9 0HD""detail_address"null"SW9 0HU""Caldwell Street, Stockwell SW9""1, Caldwell Street, Stockwell""100021818679""1""Freehold""Other""Property"""114.0"https://www.zoopla.co.uk/for-sale/details/73326946/"[]null"For sale"9950008728
"Zoopla"213-0.11879851.468221"SW9 0TZ""detail_address"null"SW9 9PU""Stockwell Road, Stockwell, London SW9""Flat 27, Addington House,, Stockwell Road, Stockwell, London""100021894599""Flat 27, Addington House,""Leasehold""Flats/Maisonettes""Flat"""77.9"https://www.zoopla.co.uk/for-sale/details/72554891/"[]null"For sale"3500004493
"Zoopla"538-0.11919251.474381"SW9 0AP""detail_address"null"SW9 0RU""Stockwell Park Road, London SW9""1, Stockwell Park Road, London""100023386020""1""Freehold""Semi-Detached""Semi Detached"""194.9"https://www.zoopla.co.uk/for-sale/details/69392186/"[]null"For sale"16950008697
"Zoopla"325-0.12265851.469424"SW9 9HG""detail_address"null"SW9 9HE""Lingham Street, Stockwell, London SW9""Flat 11 Beale House, 45, Lingham Street, Stockwell, London""100021894650""Flat 11 Beale House, 45""Leasehold""Flats/Maisonettes""Flat"""102.9"https://www.zoopla.co.uk/for-sale/details/73306789/"[]null"For sale"5250005102
"Zoopla"224-0.12900251.46795"SW9 9AN""detail_address"null"SW9 9AN""Houghton Square, London SW9""17, Houghton Square, London""10008788775""17""Leasehold""Flats/Maisonettes""Flat"""72.0"https://www.zoopla.co.uk/for-sale/details/73302918/"[]null"For sale"5000006944
"Zoopla"213-0.11502951.477508"SW9 0ET""detail_address"null"SW9 0EN""Cleveland House, Hackford Road, London SW9""Flat 4,, Cleveland House, Hackford Road, London""100021847214""Flat 4,""Leasehold""Flats/Maisonettes""Flat"""55.0"https://www.zoopla.co.uk/for-sale/details/73302851/"[]null"For sale"5150009364
"Zoopla"213-0.12568151.466593"SW9 9LF""detail_address"null"SW9 9LF""Hemberton Road, Clapham North, London SW9""chpk0226649, Hemberton Road, Clapham North, London"null"chpk0226649"null"Flats/Maisonettes""Flat"""49.7"https://www.zoopla.co.uk/for-sale/details/71215394/"[]null"For sale"4750009557
"Zoopla"112-0.11708651.466529"SW9 9FX""detail_address"null"SW9 9TG""Stockwell Road SW9""Flat 59, 151, Stockwell Road""10094957235""Flat 59, 151""Leasehold""Flats/Maisonettes""Flat"""52.0"https://www.zoopla.co.uk/for-sale/details/73250562/"[]null"For sale"4650008942
"Zoopla"213-0.1287651.464473"SW9 9NW""detail_address"null"SW9 9PG""Fenwick Place SW9""First Floor Flat, 70, Fenwick Place"null"First Floor Flat, 70"null"Flats/Maisonettes""Flat"""70.2"https://www.zoopla.co.uk/for-sale/details/73268982/"[]null"For sale"5999508546
"Zoopla"112-0.10877151.470695"SW9 7SB""detail_address"null"SW9 7JX""Loughborough Road SW9""Flat C, 58, Loughborough Road"null"Flat C, 58""Leasehold""Flats/Maisonettes""Flat"""44.1"https://www.zoopla.co.uk/for-sale/details/73268027/"[]null"For sale"3750008503
"Zoopla"224-0.10835151.475345"SW9 7RH""detail_address"null"SW9 7BN""Eythorne Road, Oval SW9""Flat 7, 5, Eythorne Road, Oval"null"Flat 7, 5"null"Flats/Maisonettes""Flat"""null"https://www.zoopla.co.uk/for-sale/details/73266802/"[]null"For sale"575000null
"Zoopla"213-0.1214251.472181"SW9 9AU""detail_address"null"SW9 9AU""Yates Court, Stockwell, London SW9""chpk3677610, Yates Court, Stockwell, London"null"chpk3677610""Leasehold""Flats/Maisonettes""Flat"""72.0"https://www.zoopla.co.uk/for-sale/details/73261921/"[]null"For sale"4750006597
"Zoopla"224-0.1078751.476628"SW9 6HP""detail_address"null"SW9 6SB""Beam, Lambeth SW9, New home""Beam, Lambeth SW9, New home"nullnull"Leasehold""Flats/Maisonettes""Flat"""74.4"https://www.zoopla.co.uk/new-homes/details/73247019/"[]null"For sale"5800007796
"Zoopla"224-0.1078751.476628"SW9 6HP""detail_address"null"SW9 6SB""Beam, Lambeth SW9, New home""Beam, Lambeth SW9, New home"nullnull"Leasehold""Flats/Maisonettes""Flat"""74.4"https://www.zoopla.co.uk/new-homes/details/73246935/"[]null"For sale"5950007997
"Zoopla"224-0.12273851.467418"SW9 9FX""detail_address"null"SW9 9DG""Stockwell Road, Brixton SW9, New home""Stockwell Road, Brixton SW9, New home""10094957206"null"Leasehold""Flats/Maisonettes""Flat"""72.0"https://www.zoopla.co.uk/new-homes/details/73239981/"[]null"For sale"5750007986
" - ], - "text/plain": [ - "shape: (45, 25)\n", - "┌───────────┬──────────┬───────────┬───────────┬───┬───────────┬───────────┬───────────┬───────────┐\n", - "│ provider ┆ Bedrooms ┆ Bathrooms ┆ Number of ┆ … ┆ Listing ┆ Listing ┆ Asking ┆ Asking │\n", - "│ --- ┆ --- ┆ --- ┆ bedrooms ┆ ┆ date ┆ status ┆ price ┆ price per │\n", - "│ str ┆ i32 ┆ i32 ┆ & living ┆ ┆ --- ┆ --- ┆ --- ┆ sqm │\n", - "│ ┆ ┆ ┆ rooms ┆ ┆ datetime[ ┆ str ┆ i64 ┆ --- │\n", - "│ ┆ ┆ ┆ --- ┆ ┆ μs] ┆ ┆ ┆ i32 │\n", - "│ ┆ ┆ ┆ i32 ┆ ┆ ┆ ┆ ┆ │\n", - "╞═══════════╪══════════╪═══════════╪═══════════╪═══╪═══════════╪═══════════╪═══════════╪═══════════╡\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 525000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 275000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 4 ┆ 1 ┆ 5 ┆ … ┆ null ┆ For sale ┆ 600000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 350000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 475000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 0 ┆ 1 ┆ 1 ┆ … ┆ null ┆ For sale ┆ 225000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 575000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 435000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 450000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 500000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 650000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 450000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 340000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 2 ┆ 1 ┆ 3 ┆ … ┆ null ┆ For sale ┆ 500000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ OnTheMark ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 550000 ┆ null │\n", - "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ 2026-05-2 ┆ For sale ┆ 310000 ┆ null │\n", - "│ ┆ ┆ ┆ ┆ ┆ 9 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 14:30:37 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 5 ┆ 4 ┆ 9 ┆ … ┆ 2026-04-1 ┆ For sale ┆ 2595000 ┆ 8355 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 7 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 18:34:21 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 6 ┆ 3 ┆ 9 ┆ … ┆ 2026-04-1 ┆ For sale ┆ 2295000 ┆ null │\n", - "│ ┆ ┆ ┆ ┆ ┆ 0 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 11:17:35 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 4 ┆ 2 ┆ 6 ┆ … ┆ 2026-01-0 ┆ For sale ┆ 2250000 ┆ 10412 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 2 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 17:30:39 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ 2026-04-0 ┆ For sale ┆ 2100000 ┆ 9841 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 8 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 17:23:59 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 4 ┆ 3 ┆ 7 ┆ … ┆ 2026-05-1 ┆ For sale ┆ 2100000 ┆ 10633 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 3 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 16:21:42 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 5 ┆ 3 ┆ 8 ┆ … ┆ 2026-04-2 ┆ For sale ┆ 1900000 ┆ 7702 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 2 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 16:04:47 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 3 ┆ 3 ┆ 6 ┆ … ┆ 2026-05-1 ┆ For sale ┆ 1695000 ┆ 10810 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 2 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 16:14:48 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 5 ┆ 3 ┆ 8 ┆ … ┆ 2025-10-0 ┆ For sale ┆ 1695000 ┆ 8773 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 7 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 09:57:34 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ 2026-03-2 ┆ For sale ┆ 1695000 ┆ null │\n", - "│ ┆ ┆ ┆ ┆ ┆ 5 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 09:55:49 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 4 ┆ 3 ┆ 7 ┆ … ┆ 2026-04-0 ┆ For sale ┆ 1650000 ┆ 9958 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 2 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 16:51:56 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 5 ┆ 2 ┆ 7 ┆ … ┆ 2026-05-1 ┆ For sale ┆ 1595000 ┆ 7995 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 2 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 14:23:19 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 5 ┆ 3 ┆ 8 ┆ … ┆ 2026-02-1 ┆ For sale ┆ 1550000 ┆ null │\n", - "│ ┆ ┆ ┆ ┆ ┆ 3 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 14:31:16 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 8 ┆ 1 ┆ 9 ┆ … ┆ 2026-05-0 ┆ For sale ┆ 1550000 ┆ 35227 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 6 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 17:31:38 ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 4 ┆ 2 ┆ 6 ┆ … ┆ 2026-04-1 ┆ For sale ┆ 1500000 ┆ 13100 │\n", - "│ ┆ ┆ ┆ ┆ ┆ 0 ┆ ┆ ┆ │\n", - "│ ┆ ┆ ┆ ┆ ┆ 09:39:04 ┆ ┆ ┆ │\n", - "│ Zoopla ┆ 4 ┆ 2 ┆ 6 ┆ … ┆ null ┆ For sale ┆ 995000 ┆ 8728 │\n", - "│ Zoopla ┆ 2 ┆ 1 ┆ 3 ┆ … ┆ null ┆ For sale ┆ 350000 ┆ 4493 │\n", - "│ Zoopla ┆ 5 ┆ 3 ┆ 8 ┆ … ┆ null ┆ For sale ┆ 1695000 ┆ 8697 │\n", - "│ Zoopla ┆ 3 ┆ 2 ┆ 5 ┆ … ┆ null ┆ For sale ┆ 525000 ┆ 5102 │\n", - "│ Zoopla ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 500000 ┆ 6944 │\n", - "│ Zoopla ┆ 2 ┆ 1 ┆ 3 ┆ … ┆ null ┆ For sale ┆ 515000 ┆ 9364 │\n", - "│ Zoopla ┆ 2 ┆ 1 ┆ 3 ┆ … ┆ null ┆ For sale ┆ 475000 ┆ 9557 │\n", - "│ Zoopla ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 465000 ┆ 8942 │\n", - "│ Zoopla ┆ 2 ┆ 1 ┆ 3 ┆ … ┆ null ┆ For sale ┆ 599950 ┆ 8546 │\n", - "│ Zoopla ┆ 1 ┆ 1 ┆ 2 ┆ … ┆ null ┆ For sale ┆ 375000 ┆ 8503 │\n", - "│ Zoopla ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 575000 ┆ null │\n", - "│ Zoopla ┆ 2 ┆ 1 ┆ 3 ┆ … ┆ null ┆ For sale ┆ 475000 ┆ 6597 │\n", - "│ Zoopla ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 580000 ┆ 7796 │\n", - "│ Zoopla ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 595000 ┆ 7997 │\n", - "│ Zoopla ┆ 2 ┆ 2 ┆ 4 ┆ … ┆ null ┆ For sale ┆ 575000 ┆ 7986 │\n", - "└───────────┴──────────┴───────────┴───────────┴───┴───────────┴───────────┴───────────┴───────────┘" - ] - }, - "metadata": {}, - "output_type": "display_data" + "output_type": "execute_result" } ], "source": [ - "df = (\n", - " pl.read_parquet(DATA_PATH)\n", - " .with_columns(\n", - " pl.when(pl.col(\"Listing URL\").str.contains(\"rightmove.co.uk\", literal=True))\n", - " .then(pl.lit(\"Rightmove\"))\n", - " .when(pl.col(\"Listing URL\").str.contains(\"onthemarket.com\", literal=True))\n", - " .then(pl.lit(\"OnTheMarket\"))\n", - " .when(pl.col(\"Listing URL\").str.contains(\"zoopla.co.uk\", literal=True))\n", - " .then(pl.lit(\"Zoopla\"))\n", - " .otherwise(pl.lit(\"Unknown\"))\n", - " .alias(\"provider\")\n", - " )\n", - " .filter(pl.col(\"provider\").is_in([\"Rightmove\", \"OnTheMarket\", \"Zoopla\"]))\n", - ")\n", - "\n", - "print(f\"Rows: {df.height:,}\")\n", - "print(f\"Columns: {len(df.columns):,}\")\n", - "display(df.group_by(\"provider\").len().sort(\"provider\"))\n", - "display(df.group_by(\"provider\", maintain_order=False).head(15).sort(\"provider\"))" - ] - }, - { - "cell_type": "markdown", - "id": "e775e26e", - "metadata": {}, - "source": [ - "## NA Counts By Provider" + "# Schema\n", + "pl.DataFrame({\"column\": raw.columns, \"dtype\": [str(t) for t in raw.dtypes]})" ] }, { "cell_type": "code", - "execution_count": 3, - "id": "059462d2", + "execution_count": null, + "id": "8c4641b6", "metadata": { "execution": { - "iopub.execute_input": "2026-05-17T10:23:47.956431Z", - "iopub.status.busy": "2026-05-17T10:23:47.956358Z", - "iopub.status.idle": "2026-05-17T10:23:47.979826Z", - "shell.execute_reply": "2026-05-17T10:23:47.979565Z" + "iopub.execute_input": "2026-06-03T07:34:30.904380Z", + "iopub.status.busy": "2026-06-03T07:34:30.904313Z", + "iopub.status.idle": "2026-06-03T07:34:30.907029Z", + "shell.execute_reply": "2026-06-03T07:34:30.906739Z" } }, "outputs": [ @@ -264,23 +175,1688 @@ " white-space: pre-wrap;\n", "}\n", "\n", - "shape: (3, 25)
providerBedroomsBathroomsNumber of bedrooms & living roomslonlatPostcodePostcode sourceExtracted postcodeInferred postcodeListing raw addressAddress per Property RegisterUPRNProperty number or nameLeasehold/FreeholdProperty typeProperty sub-typePrice qualifierTotal floor area (sqm)Listing URLListing featuresListing dateListing statusAsking priceAsking price per sqm
stru32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32
"OnTheMarket"000000084000858500008200850082
"Rightmove"000000098000999917000340000034
"Zoopla"00000005800025111400050058005
" + "shape: (8, 10)
providerPostcodeProperty typeProperty sub-typeBedroomsBathroomsAsking priceTotal floor area (sqm)Price qualifierListing date
strstrstrstri32i32i64f64strdatetime[μs]
"Rightmove""BR1 5RW""Semi-Detached""Semi-Detached"31550000null""2025-11-12 13:58:41
"Rightmove""BR1 2PF""Detached""Detached"643000000361.8""2026-01-22 10:22:09
"Rightmove""BR1 2QD""Detached""Detached"553000000null"Guide Price"2026-03-24 10:55:03
"Rightmove""BR1 3LU""Detached""Detached"602750000322.1""2026-04-25 11:59:31
"Rightmove""BR1 3AR""Detached""Detached"642695000483.6""2026-01-06 21:38:25
"Rightmove""BR1 2AY""Detached""Detached"552500000402.8"Offers in Excess of"2026-02-20 11:48:01
"Rightmove""BR1 2HG""Detached""Detached"542500000457.5"Guide Price"2026-02-07 18:12:38
"Rightmove""BR1 2JG""Detached""Detached"532350000null""2026-05-01 11:14:35
" + ], + "text/plain": [ + "shape: (8, 10)\n", + "┌─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬─────────┬────────┐\n", + "│ provide ┆ Postcod ┆ Propert ┆ Propert ┆ Bedroom ┆ Bathroo ┆ Asking ┆ Total ┆ Price ┆ Listin │\n", + "│ r ┆ e ┆ y type ┆ y sub-t ┆ s ┆ ms ┆ price ┆ floor ┆ qualifi ┆ g date │\n", + "│ --- ┆ --- ┆ --- ┆ ype ┆ --- ┆ --- ┆ --- ┆ area ┆ er ┆ --- │\n", + "│ str ┆ str ┆ str ┆ --- ┆ i32 ┆ i32 ┆ i64 ┆ (sqm) ┆ --- ┆ dateti │\n", + "│ ┆ ┆ ┆ str ┆ ┆ ┆ ┆ --- ┆ str ┆ me[μs] │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ ┆ │\n", + "╞═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪═════════╪════════╡\n", + "│ Rightmo ┆ BR1 5RW ┆ Semi-De ┆ Semi-De ┆ 3 ┆ 1 ┆ 550000 ┆ null ┆ ┆ 2025-1 │\n", + "│ ve ┆ ┆ tached ┆ tached ┆ ┆ ┆ ┆ ┆ ┆ 1-12 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 13:58: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 41 │\n", + "│ Rightmo ┆ BR1 2PF ┆ Detache ┆ Detache ┆ 6 ┆ 4 ┆ 3000000 ┆ 361.8 ┆ ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ ┆ 1-22 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 10:22: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 09 │\n", + "│ Rightmo ┆ BR1 2QD ┆ Detache ┆ Detache ┆ 5 ┆ 5 ┆ 3000000 ┆ null ┆ Guide ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ Price ┆ 3-24 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 10:55: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 03 │\n", + "│ Rightmo ┆ BR1 3LU ┆ Detache ┆ Detache ┆ 6 ┆ 0 ┆ 2750000 ┆ 322.1 ┆ ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ ┆ 4-25 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 11:59: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 31 │\n", + "│ Rightmo ┆ BR1 3AR ┆ Detache ┆ Detache ┆ 6 ┆ 4 ┆ 2695000 ┆ 483.6 ┆ ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ ┆ 1-06 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 21:38: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 25 │\n", + "│ Rightmo ┆ BR1 2AY ┆ Detache ┆ Detache ┆ 5 ┆ 5 ┆ 2500000 ┆ 402.8 ┆ Offers ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ in ┆ 2-20 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ Excess ┆ 11:48: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ of ┆ 01 │\n", + "│ Rightmo ┆ BR1 2HG ┆ Detache ┆ Detache ┆ 5 ┆ 4 ┆ 2500000 ┆ 457.5 ┆ Guide ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ Price ┆ 2-07 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 18:12: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 38 │\n", + "│ Rightmo ┆ BR1 2JG ┆ Detache ┆ Detache ┆ 5 ┆ 3 ┆ 2350000 ┆ null ┆ ┆ 2026-0 │\n", + "│ ve ┆ ┆ d ┆ d ┆ ┆ ┆ ┆ ┆ ┆ 5-01 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 11:14: │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 35 │\n", + "└─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴─────────┴────────┘" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# A few rows\n", + "raw.select(\n", + " \"provider\", \"Postcode\", \"Property type\", \"Property sub-type\",\n", + " \"Bedrooms\", \"Bathrooms\", \"Asking price\", \"Total floor area (sqm)\",\n", + " \"Price qualifier\", \"Listing date\",\n", + ").head(8)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d44013c8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:30.907698Z", + "iopub.status.busy": "2026-06-03T07:34:30.907610Z", + "iopub.status.idle": "2026-06-03T07:34:30.912246Z", + "shell.execute_reply": "2026-06-03T07:34:30.911979Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (9, 9)
statisticBedroomsBathroomsNumber of bedrooms & living roomsAsking priceTotal floor area (sqm)Asking price per sqmlatlon
strf64f64f64f64f64f64f64f64
"count"112605.0112605.0112605.0112241.056914.056914.0112605.0112605.0
"null_count"0.00.00.0364.055691.055691.00.00.0
"mean"2.391911.5673993.959309921922.833465113.7201369283.32329551.500839-0.147325
"std"1.4002471.0138462.2250191.6093e6108.2881737445.3164630.0839350.166763
"min"0.00.00.01.05.04.051.242629-0.593469
"25%"1.01.02.0375000.060.05624.051.450302-0.245267
"50%"2.01.04.0550000.081.87301.051.5008-0.15191
"75%"3.02.05.0875000.0126.010492.051.550221-0.05151
"max"20.020.040.01e81893.1410959.051.7810740.452373
" + ], + "text/plain": [ + "shape: (9, 9)\n", + "┌──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┬──────────┐\n", + "│ statisti ┆ Bedrooms ┆ Bathroom ┆ Number ┆ Asking ┆ Total ┆ Asking ┆ lat ┆ lon │\n", + "│ c ┆ --- ┆ s ┆ of ┆ price ┆ floor ┆ price ┆ --- ┆ --- │\n", + "│ --- ┆ f64 ┆ --- ┆ bedrooms ┆ --- ┆ area ┆ per sqm ┆ f64 ┆ f64 │\n", + "│ str ┆ ┆ f64 ┆ & living ┆ f64 ┆ (sqm) ┆ --- ┆ ┆ │\n", + "│ ┆ ┆ ┆ rooms ┆ ┆ --- ┆ f64 ┆ ┆ │\n", + "│ ┆ ┆ ┆ --- ┆ ┆ f64 ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ │\n", + "╞══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╪══════════╡\n", + "│ count ┆ 112605.0 ┆ 112605.0 ┆ 112605.0 ┆ 112241.0 ┆ 56914.0 ┆ 56914.0 ┆ 112605.0 ┆ 112605.0 │\n", + "│ null_cou ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 364.0 ┆ 55691.0 ┆ 55691.0 ┆ 0.0 ┆ 0.0 │\n", + "│ nt ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ mean ┆ 2.39191 ┆ 1.567399 ┆ 3.959309 ┆ 921922.8 ┆ 113.7201 ┆ 9283.323 ┆ 51.50083 ┆ -0.14732 │\n", + "│ ┆ ┆ ┆ ┆ 33465 ┆ 36 ┆ 295 ┆ 9 ┆ 5 │\n", + "│ std ┆ 1.400247 ┆ 1.013846 ┆ 2.225019 ┆ 1.6093e6 ┆ 108.2881 ┆ 7445.316 ┆ 0.083935 ┆ 0.166763 │\n", + "│ ┆ ┆ ┆ ┆ ┆ 73 ┆ 463 ┆ ┆ │\n", + "│ min ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 1.0 ┆ 5.0 ┆ 4.0 ┆ 51.24262 ┆ -0.59346 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 9 ┆ 9 │\n", + "│ 25% ┆ 1.0 ┆ 1.0 ┆ 2.0 ┆ 375000.0 ┆ 60.0 ┆ 5624.0 ┆ 51.45030 ┆ -0.24526 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 2 ┆ 7 │\n", + "│ 50% ┆ 2.0 ┆ 1.0 ┆ 4.0 ┆ 550000.0 ┆ 81.8 ┆ 7301.0 ┆ 51.5008 ┆ -0.15191 │\n", + "│ 75% ┆ 3.0 ┆ 2.0 ┆ 5.0 ┆ 875000.0 ┆ 126.0 ┆ 10492.0 ┆ 51.55022 ┆ -0.05151 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 1 ┆ │\n", + "│ max ┆ 20.0 ┆ 20.0 ┆ 40.0 ┆ 1e8 ┆ 1893.1 ┆ 410959.0 ┆ 51.78107 ┆ 0.452373 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 4 ┆ │\n", + "└──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┴──────────┘" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Numeric summary\n", + "raw.select(\n", + " \"Bedrooms\", \"Bathrooms\", \"Number of bedrooms & living rooms\",\n", + " \"Asking price\", \"Total floor area (sqm)\", \"Asking price per sqm\", \"lat\", \"lon\",\n", + ").describe()" + ] + }, + { + "cell_type": "markdown", + "id": "bc5e4659", + "metadata": {}, + "source": [ + "## 2 · Missingness (null **or** empty-string) by provider\n", + "\n", + "Note the *structural* gaps: `UPRN` & `Listing date` are populated by only one or two portals,\n", + "and `Total floor area` is ~half-missing with a strong portal skew." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6a41dce1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:30.912874Z", + "iopub.status.busy": "2026-06-03T07:34:30.912800Z", + "iopub.status.idle": "2026-06-03T07:34:30.930428Z", + "shell.execute_reply": "2026-06-03T07:34:30.930051Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (3, 25)
providerBedroomsBathroomsNumber of bedrooms & living roomslonlatPostcodePostcode sourceExtracted postcodeInferred postcodeListing raw addressAddress per Property RegisterUPRNProperty number or nameLeasehold/FreeholdProperty typeProperty sub-typePrice qualifierTotal floor area (sqm)Listing URLListing featuresListing dateListing statusAsking priceAsking price per sqm
strf64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64
"OnTheMarket"0.00.00.00.00.00.00.095.90.00.00.1100.0100.011.20.00.060.986.90.00.0100.00.00.186.9
"Rightmove"0.00.00.00.00.00.00.095.10.00.00.0100.0100.014.20.00.058.846.80.00.00.00.00.046.8
"Zoopla"0.00.00.00.00.00.00.0100.00.040.140.270.761.550.20.00.0100.016.80.00.0100.00.04.916.8
" ], "text/plain": [ "shape: (3, 25)\n", - "┌────────────┬──────────┬───────────┬────────────┬───┬────────────┬───────────┬────────┬───────────┐\n", - "│ provider ┆ Bedrooms ┆ Bathrooms ┆ Number of ┆ … ┆ Listing ┆ Listing ┆ Asking ┆ Asking │\n", - "│ --- ┆ --- ┆ --- ┆ bedrooms & ┆ ┆ date ┆ status ┆ price ┆ price per │\n", - "│ str ┆ u32 ┆ u32 ┆ living ┆ ┆ --- ┆ --- ┆ --- ┆ sqm │\n", - "│ ┆ ┆ ┆ rooms ┆ ┆ u32 ┆ u32 ┆ u32 ┆ --- │\n", - "│ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ u32 │\n", - "│ ┆ ┆ ┆ u32 ┆ ┆ ┆ ┆ ┆ │\n", - "╞════════════╪══════════╪═══════════╪════════════╪═══╪════════════╪═══════════╪════════╪═══════════╡\n", - "│ OnTheMarke ┆ 0 ┆ 0 ┆ 0 ┆ … ┆ 85 ┆ 0 ┆ 0 ┆ 82 │\n", - "│ t ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", - "│ Rightmove ┆ 0 ┆ 0 ┆ 0 ┆ … ┆ 0 ┆ 0 ┆ 0 ┆ 34 │\n", - "│ Zoopla ┆ 0 ┆ 0 ┆ 0 ┆ … ┆ 58 ┆ 0 ┆ 0 ┆ 5 │\n", - "└────────────┴──────────┴───────────┴────────────┴───┴────────────┴───────────┴────────┴───────────┘" + "┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐\n", + "│ pro ┆ Bed ┆ Bat ┆ Num ┆ lon ┆ lat ┆ Pos ┆ Pos ┆ Ext ┆ Inf ┆ Lis ┆ Add ┆ UPR ┆ Pro ┆ Lea ┆ Pro ┆ Pro ┆ Pri ┆ Tot ┆ Lis ┆ Lis ┆ Lis ┆ Lis ┆ Ask ┆ Ask │\n", + "│ vid ┆ roo ┆ hro ┆ ber ┆ --- ┆ --- ┆ tco ┆ tco ┆ rac ┆ err ┆ tin ┆ res ┆ N ┆ per ┆ seh ┆ per ┆ per ┆ ce ┆ al ┆ tin ┆ tin ┆ tin ┆ tin ┆ ing ┆ ing │\n", + "│ er ┆ ms ┆ oms ┆ of ┆ f64 ┆ f64 ┆ de ┆ de ┆ ted ┆ ed ┆ g ┆ s ┆ --- ┆ ty ┆ old ┆ ty ┆ ty ┆ qua ┆ flo ┆ g ┆ g ┆ g ┆ g ┆ pri ┆ pri │\n", + "│ --- ┆ --- ┆ --- ┆ bed ┆ ┆ ┆ --- ┆ sou ┆ pos ┆ pos ┆ raw ┆ per ┆ f64 ┆ num ┆ /Fr ┆ typ ┆ sub ┆ lif ┆ or ┆ URL ┆ fea ┆ dat ┆ sta ┆ ce ┆ ce │\n", + "│ str ┆ f64 ┆ f64 ┆ roo ┆ ┆ ┆ f64 ┆ rce ┆ tco ┆ tco ┆ add ┆ Pro ┆ ┆ ber ┆ eeh ┆ e ┆ -ty ┆ ier ┆ are ┆ --- ┆ tur ┆ e ┆ tus ┆ --- ┆ per │\n", + "│ ┆ ┆ ┆ ms ┆ ┆ ┆ ┆ --- ┆ de ┆ de ┆ res ┆ per ┆ ┆ or ┆ old ┆ --- ┆ pe ┆ --- ┆ a ┆ f64 ┆ es ┆ --- ┆ --- ┆ f64 ┆ sqm │\n", + "│ ┆ ┆ ┆ & ┆ ┆ ┆ ┆ f64 ┆ --- ┆ --- ┆ s ┆ ty ┆ ┆ nam ┆ --- ┆ f64 ┆ --- ┆ f64 ┆ (sq ┆ ┆ --- ┆ f64 ┆ f64 ┆ ┆ --- │\n", + "│ ┆ ┆ ┆ liv ┆ ┆ ┆ ┆ ┆ f64 ┆ f64 ┆ --- ┆ Reg ┆ ┆ e ┆ f64 ┆ ┆ f64 ┆ ┆ m) ┆ ┆ f64 ┆ ┆ ┆ ┆ f64 │\n", + "│ ┆ ┆ ┆ ing ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ ist ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ roo ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ er ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ ms ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "╞═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╡\n", + "│ OnT ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 95. ┆ 0.0 ┆ 0.0 ┆ 0.1 ┆ 100 ┆ 100 ┆ 11. ┆ 0.0 ┆ 0.0 ┆ 60. ┆ 86. ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 0.0 ┆ 0.1 ┆ 86. │\n", + "│ heM ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 9 ┆ ┆ ┆ ┆ .0 ┆ .0 ┆ 2 ┆ ┆ ┆ 9 ┆ 9 ┆ ┆ ┆ .0 ┆ ┆ ┆ 9 │\n", + "│ ark ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ Rig ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 95. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 100 ┆ 14. ┆ 0.0 ┆ 0.0 ┆ 58. ┆ 46. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 46. │\n", + "│ htm ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ 1 ┆ ┆ ┆ ┆ .0 ┆ .0 ┆ 2 ┆ ┆ ┆ 8 ┆ 8 ┆ ┆ ┆ ┆ ┆ ┆ 8 │\n", + "│ ove ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ Zoo ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 0.0 ┆ 40. ┆ 40. ┆ 70. ┆ 61. ┆ 50. ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 16. ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 0.0 ┆ 4.9 ┆ 16. │\n", + "│ pla ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ .0 ┆ ┆ 1 ┆ 2 ┆ 7 ┆ 5 ┆ 2 ┆ ┆ ┆ .0 ┆ 8 ┆ ┆ ┆ .0 ┆ ┆ ┆ 8 │\n", + "└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def missing_count(col: str, dtype) -> pl.Expr:\n", + " m = pl.col(col).is_null()\n", + " if dtype in (pl.Float32, pl.Float64):\n", + " m = m | pl.col(col).is_nan().fill_null(False)\n", + " if dtype == pl.String:\n", + " m = m | (pl.col(col).str.strip_chars() == \"\")\n", + " return m.sum().alias(col)\n", + "\n", + "value_cols = [c for c in raw.columns if c != \"provider\"]\n", + "miss = (\n", + " raw.group_by(\"provider\")\n", + " .agg([missing_count(c, raw.schema[c]) for c in value_cols] + [pl.len().alias(\"__rows\")])\n", + " .sort(\"provider\")\n", + ")\n", + "miss_pct = miss.select(\n", + " [\"provider\"] + [(pl.col(c) / pl.col(\"__rows\") * 100).round(1).alias(c) for c in value_cols]\n", + ")\n", + "miss_pct" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e1c09cfb", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:30.931519Z", + "iopub.status.busy": "2026-06-03T07:34:30.931390Z", + "iopub.status.idle": "2026-06-03T07:34:31.040814Z", + "shell.execute_reply": "2026-06-03T07:34:31.040504Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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EBsbiyAIrFmzhsqVK1OqVCni4uJITEzE399fannfzddrzcOHD5k+fTr5+fmcPHkSLS0trKys8PPzo0aNGiofeBDbGhUVxdOnT5kxYwa1atXi2LFjREZG4urqSnh4uNQylUpubi67d+9m165dAMyZMwcnJyeJVSkX8XAcFhaGt7c3e/fuZezYsYpXDqmpqeTl5VGzZk21OEj/KNy/f58DBw6Qnp7Oq1evaNSoEYsWLaJ58+YANGjQgEePHn3jmFd1vL29CQgI+OaF5OHDh7l8+TInT56UUJlyMTMzIykpCWdnZ/r160flypXVNlVucXExHz584MyZM/j5+ZGdnY25uTmLFi1CU1NTLWx9cV29ePEily9fVuw38KX96enpGBgYqM2rSTEF5507d+jXrx8WFhZ06dLln9KiyagGom0bERHBp0+fqF+/PiEhIXh7e5OQkECHDh1wdXVVq9fc/wqqbvOLCILAqVOnOHToEM+ePWPkyJEsXryYcuXKKbITqMO6JJKVlcXatWvx9fXFysqKTZs2qU1ffs22bdt49uwZmzZt+mbtzcvLo1SpUhIqk1En5CCLjIyK8fnzZ549e8b9+/cJDg7GwMCAHTt2SC1LRuaHJz09nVKlSnHv3j0OHjzI/v37iY6O5vjx42hoaGBkZES7du2oV6+e1FK/i6+N7j179jBixAjOnTuHv78/6enpFBUVYWNjw/Tp01XeQBcPUE+ePGHmzJkEBARw7do1fHx8uHDhAtbW1gwYMIBevXqppSM+Pz+fM2fOsH//fiIiIjh06BCDBg2SWpZSEPvW2tpaUUuosLCQHTt2EBISgpGRERUqVJBapsx/SEREBEFBQdy4cYMaNWrw7t07SpQogbGxMT///LNazdeEhAQsLS2pXr06q1evRl9fnylTprBq1Sp69eql0o6gV69eceHCBTp16kT9+vX57bffOH36NA0bNqRfv360a9dObVLxiP304cMHrl27homJCXXq1CEpKYnr168TGhrKnj17pJapdL4Ong0YMIBKlSqp7audjIwM7t+/j6+vLx8/fkRPT4/+/fvTu3dvqSXK/BsUFRVx584d9PX1cXJyYs+ePTRt2pTc3FwyMjIIDQ0lOjqaefPmSS1VRgkEBQWxc+dOLl68yM6dO5k8ebLUkpSKuD6Fh4czb948DAwM8PDwwNDQUGVthz8jOTkZ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KrVr16ZZs2bk5OQwfvx48uTJY3RMeUBxcXGEhYWxb98+Vq9ezZAhQ/j6668ZPXo0GzZsIDEx0eiIIiIiIiIij4V2sog8pMTERPbu3cuRI0c4ffo0x44dw9/fn6+//lqDW0VERB7QlClT8PLyIm/evCxfvpwVK1aQkJBAq1atiImJwdFRzwbZimPHjvHOO+/g7e3N1q1bad68OevXr+f8+fMsXryYb775hl69ejFo0CCjo4qIiIiIiDwyFVlE7pOlX7zlX4uUlBSio6NZt24dnTp1okWLFv/xGREREfnvcnNzOX36NMHBwURFRbF79268vb0ZOHAgRYsWZcyYMZrFYkP69etHmTJlCAoK4vr163Tv3p3g4GBq164NQEZGBjk5ORQqVMi6k0lERERERMRWafC9yH369ttvadq0KQULFgT+3d7E29sbb29v/P39re+pwCIiIvLnLA8lhIeH4+fnR+3atYmOjqZPnz74+PgQGxvL+vXrAZ1bbUViYiKbN2+2tgMrWLAgJpOJpKQkateuTU5ODp6enlie81KBRUREREREbJ3uVkXuw7Zt2wgPD6dgwYLs37+f324As/x/kSJF9IStiIjIfTKbzTg6OpKVlcXq1aupUKECQ4cOZf369bRu3ZqaNWuyatUqPD09MZlMWoy3EadOnSI5ORl/f3927dpFamoqN2/epEOHDgDWayUdTxERERERsRfaySJyH+Lj4ylcuDC9e/cmJyeHunXrGh1JRETEpuXk5ODi4kJERAQFChSwPrRQuXJlKleu/LvPaheL7WjWrBkmk4mJEyfSvn17MjMzadu2rfV9FVdERERERMTe6I5V5D5069aNsmXLsnr1ajw8PNi6dSupqamAFgtEREQehouLCwDXr19ny5Yt1K9fn+3btxucSh6XESNGkJ6ezooVK4iKisLLy4uxY8cCoJGQIiIiIiJiTzT4XuQ+bdy4kdWrV1OpUiVOnjxJ4cKF8fX1pX79+nh7exsdT0RExGaMHTuWl156iU6dOgFw9epVZsyYwcqVK/H09GTAgAG8++67BqeUx2n16tVERETw/fffGx1FRERERETksVKRReQBZGZmki9fPg4dOsT27duJiYmhcePG9O3b1+hoIiIiNsFkMvH555+zePFi8ufPz+DBg63FlqysLGbMmEFSUhKzZs0yOKk8Kbm5uZpjJyIiIiIi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Fi04fPgwU6ZMwcvLSwUWEREREREREREREflT2skizxRL+6+JEyeyfPly3N3dqVOnDrVr16Zx48aULFmSnJwcnJ2dVWQRERERERERERERkf9JRRZ55phMJl5++WUiIyO5evUqa9asITY2luzsbPr27UuTJk2MjigiIiIiIiIiIiIiNkDtwuSZ8dtZLO+88w4eHh4ULlyYoKAgEhISWLVqFZUqVTI4pYiIiIiIiIiIiIjYCu1kkWdOYGAg+/bto1ChQvTp04fWrVuTP39+o2OJiIiIiIiIiIiIiI1RkUWeCZb5Kt988w3Tp09n5MiRnD59mqNHj5Kbm0v16tV5//33cXZ21hwWEREREREREREREbkvahcmzwRL4SQuLo4ZM2ZQu3ZtUlNTiY+P58CBA2RlZeHi4mJwShERERERERERERGxJdrJIs+M6OhoatWqRbNmzVi2bBmFCxcGIDMzE7PZjKen5+/mtoiIiIiIiIiIiIiI/C9aTZZngtlspmbNmmzevBlHR0eqV69OYGAgcXFx5MuXD09PTwAVWERERERERERERETkvmlFWeyayWQCID4+nl9//ZXmzZuzefNm1q1bR3Z2Ni+99BJ79uwxOKWIiIiIiIiIiIiI2CIVWcSuWXambN68mdq1a9OhQwcOHjyIr68vCxcu5OTJkzRs2NDglCIiIiIiIiIiIiJiizSTRZ4ZZ8+eZe7cuaxatYqqVasSGBhIy5YtNYdFRERERERERERERB6Kiixit8xmMw4ODv/xenR0NH379uXGjRvExsbi5ORkQDoRERERERERERERsXV6fF/sloODAxcuXKBt27YcOXLE+nrNmjVp06YNX331FU5OTuTm5hqYUkRERERERERERERslYosYtdcXFwoVKgQb7/9Nq+99hrbtm0jKSmJ8PBwqlevDqCdLCIiIiIiIiIiIiLyUFRkEbsTFRWFr68vmzdvpkiRIixcuJCffvoJf39/unbtSo8ePRg8eDD58+fXLhYREREREREREREReWiaySJ2Jy0tjS+++II1a9bg5ubGoEGDeOedd6zvnzt3jhIlSuDo6Phf57aIiIiIiIiIiIiIiPwZFVnEbmVlZbF48WKWLl3KlStXGDp0KAEBAUbHEhERERERERERERE7oSKL2JWrV69SuHBhTp48iZeXF9euXSMjI4PVq1czbtw4XnjhBaKioihcuLDRUUVERERERERERETExjkbHUDkcUlOTqZRo0aULVuWMmXKsG3bNmrUqEFMTAz+/v68+uqrlCtXjsKFC6tNmIiIiIiIiIiIiIg8MhVZxG78+uuvnDlzhjNnzvDVV1/x5Zdf4urqitls5vz585QsWZLs7GwATCYTTk5OBicWEREREREREREREVumdmFid6ZPn87o0aPx9vZm2LBh9OjRw+hIIiIiIiIiIiIiImKHVGQRu/XVV18xbtw47t69y4ABAxg9erTahImIiIiIiIiIiIjIY6Mii9i91atXExERwffff290FBERERERERERERGxIyqyyDMlNzdXs1hERERERERERERE5LFQkUVEREREREREREREROQhOBodQERERERERERERERExBapyCIiIiIiIiIiIiIiIvIQVGQRERERERERERERERF5CCqyiIiIiIiIiIiIiIiIPAQVWURERERERERERERERB6CiiwiIiIiIiIiIiIiIiIPQUUWERERERERERERERGRh6Aii4iIiIiIiIiIiIiIyENQkUVEREREREREREREROQh/D+tWCIAhJPVAQAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Heatmap of % missing (null or empty) per column per provider\n", + "mat = miss_pct.select(value_cols).to_numpy()\n", + "fig, ax = plt.subplots(figsize=(15, 2.8))\n", + "im = ax.imshow(mat, aspect=\"auto\", cmap=\"Reds\", vmin=0, vmax=100)\n", + "ax.set_xticks(range(len(value_cols)))\n", + "ax.set_xticklabels(value_cols, rotation=65, ha=\"right\", fontsize=7)\n", + "ax.set_yticks(range(miss_pct.height))\n", + "ax.set_yticklabels(miss_pct[\"provider\"].to_list())\n", + "for (i, j), v in np.ndenumerate(mat):\n", + " if v > 0:\n", + " ax.text(j, i, f\"{v:.0f}\", ha=\"center\", va=\"center\", fontsize=6,\n", + " color=\"white\" if v > 55 else \"black\")\n", + "fig.colorbar(im, ax=ax, label=\"% missing\", fraction=0.025, pad=0.01)\n", + "ax.set_title(\"Missing (null or empty-string) % by provider\")\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "ac443ace", + "metadata": {}, + "source": [ + "## 3 · Data issues\n", + "\n", + "Each subsection demonstrates one issue group. Severity tags (🔴 high / 🟠 medium / 🟡 low) match the audit." + ] + }, + { + "cell_type": "markdown", + "id": "7ea9293c", + "metadata": {}, + "source": [ + "### 3.1 · 🔴 Mislabeled column — `Number of bedrooms & living rooms` is actually `Bedrooms + Bathrooms`\n", + "\n", + "The column name promises *living rooms / receptions*, but `storage.py` recomputes it as\n", + "`Bedrooms + Bathrooms` for **every** row of **every** portal (Zoopla's `+ receptions` line is\n", + "dead code, overwritten at write time). It carries no living-room information." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b4844918", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.041972Z", + "iopub.status.busy": "2026-06-03T07:34:31.041873Z", + "iopub.status.idle": "2026-06-03T07:34:31.050408Z", + "shell.execute_reply": "2026-06-03T07:34:31.050059Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Fraction of rows where col == Bedrooms + Bathrooms: 1.0000\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (3, 2)
providerfrac == beds+baths
strf64
"OnTheMarket"1.0
"Rightmove"1.0
"Zoopla"1.0
" + ], + "text/plain": [ + "shape: (3, 2)\n", + "┌─────────────┬────────────────────┐\n", + "│ provider ┆ frac == beds+baths │\n", + "│ --- ┆ --- │\n", + "│ str ┆ f64 │\n", + "╞═════════════╪════════════════════╡\n", + "│ OnTheMarket ┆ 1.0 │\n", + "│ Rightmove ┆ 1.0 │\n", + "│ Zoopla ┆ 1.0 │\n", + "└─────────────┴────────────────────┘" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "share = raw.select(\n", + " (pl.col(\"Number of bedrooms & living rooms\") == pl.col(\"Bedrooms\") + pl.col(\"Bathrooms\")).mean()\n", + ").item()\n", + "print(f\"Fraction of rows where col == Bedrooms + Bathrooms: {share:.4f}\")\n", + "raw.group_by(\"provider\").agg(\n", + " (pl.col(\"Number of bedrooms & living rooms\") == pl.col(\"Bedrooms\") + pl.col(\"Bathrooms\"))\n", + " .mean().round(4).alias(\"frac == beds+baths\")\n", + ").sort(\"provider\")" + ] + }, + { + "cell_type": "markdown", + "id": "c59f527f", + "metadata": {}, + "source": [ + "### 3.2 · 🔴 Price integrity\n", + "\n", + "- **Shared-ownership / part-buy** listings store the *share* price as `Asking price`, not full value\n", + " (median ~£146k vs ~£550k) — corrupts any price / £-per-sqm aggregate.\n", + "- **38% of rows carry a non-firm qualifier** (Guide / Offers Over / OIEO / From / Shared); `Asking price`\n", + " is stored identically regardless.\n", + "- A `<£10k` tail of auction land/garage lots + two `£1` placeholders; 364 nulls (price ≤ 0 → null)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1e713c7a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.051474Z", + "iopub.status.busy": "2026-06-03T07:34:31.051346Z", + "iopub.status.idle": "2026-06-03T07:34:31.070546Z", + "shell.execute_reply": "2026-06-03T07:34:31.070206Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Shared-ownership / part-buy rows : 1,404\n", + "Non-firm price qualifier rows : 42,734 (38.0%)\n", + "Asking price null : 364\n", + "Asking price < £10k : 39\n", + "Asking price >= £20M : 124 (max = £100,000,000)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (2, 4)
shared_ownershiprowsmedian_pricemedian_£/sqm
boolu32f64f64
false111201550000.07323.0
true1404146250.02163.5
" + ], + "text/plain": [ + "shape: (2, 4)\n", + "┌──────────────────┬────────┬──────────────┬──────────────┐\n", + "│ shared_ownership ┆ rows ┆ median_price ┆ median_£/sqm │\n", + "│ --- ┆ --- ┆ --- ┆ --- │\n", + "│ bool ┆ u32 ┆ f64 ┆ f64 │\n", + "╞══════════════════╪════════╪══════════════╪══════════════╡\n", + "│ false ┆ 111201 ┆ 550000.0 ┆ 7323.0 │\n", + "│ true ┆ 1404 ┆ 146250.0 ┆ 2163.5 │\n", + "└──────────────────┴────────┴──────────────┴──────────────┘" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "SHARED = pl.col(\"Price qualifier\").str.to_lowercase().str.contains(\"shared|part buy|part-buy\")\n", + "NONFIRM = pl.col(\"Price qualifier\").str.to_lowercase().str.contains(\n", + " \"offers|from|guide|region|shared|part buy|part-buy|invited\"\n", + ")\n", + "print(f\"Shared-ownership / part-buy rows : {raw.filter(SHARED).height:,}\")\n", + "print(f\"Non-firm price qualifier rows : {raw.filter(NONFIRM).height:,} \"\n", + " f\"({raw.filter(NONFIRM).height / raw.height:.1%})\")\n", + "print(f\"Asking price null : {raw['Asking price'].null_count():,}\")\n", + "print(f\"Asking price < £10k : {raw.filter(pl.col('Asking price') < 10_000).height:,}\")\n", + "print(f\"Asking price >= £20M : {raw.filter(pl.col('Asking price') >= 20_000_000).height:,}\"\n", + " f\" (max = £{raw['Asking price'].max():,})\")\n", + "\n", + "raw.with_columns(SHARED.alias(\"shared_ownership\")).group_by(\"shared_ownership\").agg(\n", + " pl.len().alias(\"rows\"),\n", + " pl.col(\"Asking price\").median().alias(\"median_price\"),\n", + " pl.col(\"Asking price per sqm\").median().alias(\"median_£/sqm\"),\n", + ").sort(\"shared_ownership\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e5a61f92", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.071403Z", + "iopub.status.busy": "2026-06-03T07:34:31.071326Z", + "iopub.status.idle": "2026-06-03T07:34:31.155910Z", + "shell.execute_reply": "2026-06-03T07:34:31.155577Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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fvkF6enpWLt2LerXr4+lS5ciJCQE//vf/6BQKAAoi92HqQrmu3fvAoB4z+tH2dvbizG6xGlTWFiIwsJC8XVOTk5VDpGIiMgwqK4XsmqVtHkQEekjjpHaGVG/GFQhXVlZiXv37mH79u1o164dAKBr167w8PDAJ598gtDQUIkz/MeqVasQGxsrdRpERETPx+nTUmdARKS/OEZqZ0T9YlCndtvb28PR0VEsogHl2uYOHTogIyNDnHl++MrbAMSZatVaZ3t7e40YVdzD66GrGqdNTEwMsrOzxcdpI/qmISIiIiIiqssMaka6devWuHjxotZtJSUlaNmyJczMzCCXy9Vmp1XrmVXrnX18fHDr1i0oFAq1ddKPron28fHRWAtdUFCAnJwcjbXTj7K1tYWtra1uB0hEREREBsdrwp2nxmStb1QLmRBRbTGoGen+/fsjLy8Pv/32m9iWl5eHX3/9FR07doSFhQWCgoI07hmdmJgIX19fePz/y62HhITAxMQEO3bsEGMUCgWSk5MRHh4utoWFhSElJQX5+fli27Zt22BiYoKQkJDncoxERERERESk3wxqRjoiIgIvv/wyIiMjERcXBysrKyxbtgwWFhaYPHkyAOC9995Dr169MHnyZAwZMgRpaWnYunUrEhMTxf24uLhgwoQJmDVrFkxNTdG8eXMsXboUDRs2xBtvvCHGRUdHY82aNYiIiMDcuXNx48YNzJo1C9HR0byHNBER1W02NlJnQESkvzhGamdE/WJQhbSJiQn279+P6dOn44033kBZWRl69OiBI0eOoGnTpgCA7t27Y+fOnZg/fz42bNgANzc3rF+/XuPe0h9//DEaNGiAOXPm4O+//0a3bt2QkpKChg0bijH29vZITU3FlClTEBERARsbG0yYMAFxcXG1etxERER6JylJ6gyIiPQXx0jtjKhfZIIgCFInURdcv34drq6uyM7OhouLi9TpEBEREUmiKuuJjRHXSBPpP11qNoNaI01ERER6Ii5O+SAiIk0cI7Uzon5hIU1ERES6S0oyqlP0iIhqFMdI7YyoX1hIExEREREREemAhTQRERERERGRDlhIExEREREREemAhTQRERERERGRDgzqPtJERESkJ/77X6kzICLSXxwjtTOifmEhTURERLozN5c6AyIi/cUxUjsj6hee2k1ERES6W7dO+SAiIk0cI7Uzon7h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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Asking-price distribution (log scale) with the implausible tails marked\n", + "prices = raw[\"Asking price\"].drop_nulls().to_numpy().astype(float)\n", + "fig, ax = plt.subplots(figsize=(9, 3.2))\n", + "ax.hist(np.log10(np.clip(prices, 1, None)), bins=90, color=\"#2563eb\")\n", + "for v, lab in [(10_000, \"£10k\"), (20_000_000, \"£20M\"), (100_000_000, \"£100M\")]:\n", + " ax.axvline(np.log10(v), color=\"red\", ls=\"--\", lw=1)\n", + " ax.text(np.log10(v), ax.get_ylim()[1] * 0.92, lab, rotation=90, fontsize=7,\n", + " color=\"red\", va=\"top\")\n", + "ax.set_xlabel(\"log10(Asking price)\"); ax.set_ylabel(\"count\")\n", + "ax.set_title(\"Asking price distribution (log scale)\")\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "238478fd", + "metadata": {}, + "source": [ + "### 3.3 · 🔴 Floor area & price-per-sqm\n", + "\n", + "- ~49% of `Total floor area (sqm)` is null (OnTheMarket ~87%).\n", + "- **Impossible tiny areas**: rows under 20 m² with ≥2 bedrooms (single-room dimensions parsed as total).\n", + "- **Suspiciously large areas** (>400 m²) — likely sq ft never converted to m².\n", + "- `Asking price per sqm` is mechanically correct but faithfully amplifies bad areas (max £410,959/m²)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "acfba0f8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.156976Z", + "iopub.status.busy": "2026-06-03T07:34:31.156880Z", + "iopub.status.idle": "2026-06-03T07:34:31.164246Z", + "shell.execute_reply": "2026-06-03T07:34:31.163925Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Floor area null : 55,691 (49.5%)\n", + "Impossible (area<20 & beds>=2): 89\n", + "Suspiciously large (area>400) : 1,310\n", + "£/sqm > £30k : 969 (max = £410,959/sqm)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (6, 5)
providerBedroomsTotal floor area (sqm)Asking priceAsking price per sqm
stri32f64i64i32
"Zoopla"26.11270000208197
"Zoopla"26.11270000208197
"Zoopla"26.11270000208197
"Rightmove"26.81400000205882
"Zoopla"26.11250000204918
"Rightmove"27.21250000173611
" + ], + "text/plain": [ + "shape: (6, 5)\n", + "┌───────────┬──────────┬────────────────────────┬──────────────┬──────────────────────┐\n", + "│ provider ┆ Bedrooms ┆ Total floor area (sqm) ┆ Asking price ┆ Asking price per sqm │\n", + "│ --- ┆ --- ┆ --- ┆ --- ┆ --- │\n", + "│ str ┆ i32 ┆ f64 ┆ i64 ┆ i32 │\n", + "╞═══════════╪══════════╪════════════════════════╪══════════════╪══════════════════════╡\n", + "│ Zoopla ┆ 2 ┆ 6.1 ┆ 1270000 ┆ 208197 │\n", + "│ Zoopla ┆ 2 ┆ 6.1 ┆ 1270000 ┆ 208197 │\n", + "│ Zoopla ┆ 2 ┆ 6.1 ┆ 1270000 ┆ 208197 │\n", + "│ Rightmove ┆ 2 ┆ 6.8 ┆ 1400000 ┆ 205882 │\n", + "│ Zoopla ┆ 2 ┆ 6.1 ┆ 1250000 ┆ 204918 │\n", + "│ Rightmove ┆ 2 ┆ 7.2 ┆ 1250000 ┆ 173611 │\n", + "└───────────┴──────────┴────────────────────────┴──────────────┴──────────────────────┘" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "fa = pl.col(\"Total floor area (sqm)\")\n", + "print(f\"Floor area null : {raw['Total floor area (sqm)'].null_count():,} \"\n", + " f\"({raw['Total floor area (sqm)'].null_count() / raw.height:.1%})\")\n", + "print(f\"Impossible (area<20 & beds>=2): {raw.filter((fa < 20) & (pl.col('Bedrooms') >= 2)).height:,}\")\n", + "print(f\"Suspiciously large (area>400) : {raw.filter(fa > 400).height:,}\")\n", + "print(f\"£/sqm > £30k : {raw.filter(pl.col('Asking price per sqm') > 30_000).height:,}\"\n", + " f\" (max = £{raw['Asking price per sqm'].max():,}/sqm)\")\n", + "raw.filter((fa < 20) & (pl.col(\"Bedrooms\") >= 2)).select(\n", + " \"provider\", \"Bedrooms\", \"Total floor area (sqm)\", \"Asking price\", \"Asking price per sqm\",\n", + ").sort(\"Asking price per sqm\", descending=True).head(6)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bc4faa65", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.165062Z", + "iopub.status.busy": "2026-06-03T07:34:31.164982Z", + "iopub.status.idle": "2026-06-03T07:34:31.550386Z", + "shell.execute_reply": "2026-06-03T07:34:31.550028Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA9IAAAGtCAYAAAD6eEzjAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAQ6wAAEOsBUJTofAABAABJREFUeJzs3XmcXFWZ+P/PuffWXl1d1XunsyeQhEVAJOybyCYiO0EYJITglxmX38AIwrgAMqDOCCquSEJwQUQRmFE2QUFBBEQQQ0JC9nTSe3ft+73n/P646SaddLbuVHcHzvv1yku66lbV6Vraeu7znOcRSimFpmmapmmapmmapmm7xRjrBWiapmmapmmapmnavkQH0pqmaZqmaZqmaZq2B3QgrWmapmmapmmapml7QAfSmqZpmqZpmqZpmrYHdCCtaZqmaZqmaZqmaXtAB9Kapmmapmmapmmatgd0IK1pmqZpmqZpmqZpe0AH0pqmaZqmaZqmaZq2B3QgrWmapmmapmmapml7QAfSmqZpmqZpmqZpmrYHdCCtaZr2HnLSSScxderUsV6GNkLz589HCDHqjyuEYP78+aP+uOPdKaecwhlnnDHWy9jnvP766xx77LF88IMfZNasWXz0ox9l7dq1A9evXbsWr9fLgw8+OIar1DRNGx4dSGuapo1zzz//PEKIHf67//77x3qJmvae9eijj/Lcc89x++2377X7zGQyfPWrX+Xcc89lypQpCCGYOXPmLm/3+uuvc9FFF9HU1ITP56OlpYWPf/zjrF+/frtj//nPf3L22WcTi8UIhUIcddRRPPbYY3vtd9gd1dXVLFmyhNdff53ly5dTVVXFZZddNnD99OnTWbBgATfeeCP5fH5U16ZpmjZS1lgvQNM0Tds9F154Ieecc852lx9zzDFjsBpNe3+45ZZbOOmkkzj88MP32n329PRw880309DQwGGHHUZfX98ub/Pggw9y+eWXc+ihh3LttddSX19PV1cXf/vb3+jr6xtUifLmm29y3HHH4fP5+I//+A/q6ur4+c9/znnnnceSJUtGrepgxowZA/9tmiaHH344zzzzzKBj/uM//oN77rmHxYsX85nPfGZU1qVpmrY36EBa0zRtH3HIIYfwL//yL2O9jF1Kp9NUVVVV7P4dx6FYLBIMBiv2GNrOpVIpIpHIWC+j4l544QX++c9/8pOf/GTY99HX18fvf/97LrnkkoHLmpub2bhxI5MmTQLY5XaMd955hwULFnDppZdy//33Yxg7Lyj87Gc/Szab5bnnnuNDH/oQAFdddRVHHnkk1157Leeff/6ov359fX18+9vfHpSRBthvv/04+uij+eEPf6gDaU3T9im6tFvTNO194JVXXuFjH/sYNTU1+P1+Zs+ezW233UapVNru2M2bN7Nw4UJaWlrwer1MnDiRT33qU7S3tw86rr/k/P777+eee+7hAx/4AH6/n89+9rM7Xcurr77KggULmDVrFqFQiFAoxBFHHMGSJUu2O/aWW25BCMHy5cu54YYbmDJlCj6fj1/96lcAKKW49957mTt37sB9HXPMMUOWsD700EMDpbR+v5+amhrOOOMMXnzxxd16Do877jiqq6uHLEFtbW3FNE0uv/zygcueeuopPvzhD9PQ0IDf72fixImceeaZvPDCC7v1eAC9vb0sWLCA+vp6AoEARx99NH/4wx+GPPa5557jzDPPJBaL4fP5mDNnDt/4xjdwHGe7Y5955hmOOuooAoEA9fX1LFiwgJ6enu2OW79+PUIIbrnlFn7zm98wd+5cgsEgH//4xwf9nieffDKRSIRAIMChhx7K97//fZRS293fihUruOSSS2hsbMTn8zF9+nQ+//nPk0qlBh13//33I4TgD3/4A3fccQfTp0/H7/dzyCGH8OSTTwKwfPlyPvaxj1FdXU00GmX+/PlkMplB9xOPx7n++uvZb7/9CAQCxGIxDj74YP793/99l889uO8ZgLPOOmu3jgeQUvLyyy9zyy23cNRRR1FfX88nPvGJQcf4fL6BIHp3/M///A+2bXPXXXdhGAa5XG7Izy64r9kLL7zAiSeeOBBEA3g8Hj73uc+RSCT4v//7v10+Zv9n7+233+bzn/88LS0tBINBjjnmGF599VUA/vKXv3DSSScRDoepr6/nuuuuw7bt7e6rXC5z4YUXUldXx9e+9rXtrj/rrLNYvnw5S5cu3d2nRNM0bczpjLSmado+IpfLbRfseDweqqurd3q7p556io9//ONEIhH+7d/+jaamJp544gm+8pWv8NJLL/H4448PZLg2b97MEUccQVdXFwsXLuSQQw7hzTff5N577+Wpp57ib3/7G42NjYPu/zvf+Q6dnZ1cffXVTJw4cZfZ6EcffZS33nqLCy+8kClTppBMJvnVr37FggUL6O7u5oYbbtjuNpdddhmWZfHpT3+acDjMrFmzALjyyiv56U9/yjnnnDOQ6XrkkUc477zz+OEPf8g111wzcB/f+973iMViLFy4kObmZlpbW1m8eDEnn3wyf/rTn3ZZIj9//nyuvvpqHnnkke2yaj/5yU+QUg6UzP75z3/mYx/7GAcccADXX389tbW1dHR08NJLL/HGG29w/PHH7/Sx+p1++ulEIhG+/OUv09fXxz333MMZZ5zBb3/720HNr+677z4WLlzIYYcdxo033kg0GuUvf/kLN910E2+88Qa//OUvB459/PHHOeecc6ivr+cLX/gCNTU1PPLIIzttpvW///u/fPvb3+aaa67h6quvHgiSFy9ezNVXX83kyZO5/vrrCYfDPPzww3zmM5/hzTff5Mc//vHAffzjH//ghBNOwLZt/u3f/o3p06fz4osvcuedd/KHP/yBv/zlL9tVGdx0000Ui0X+9V//FdM0+c53vsM555zDww8/zFVXXcXFF1/M2WefzV//+ld+8pOf4PP5uOeeewZuf/HFF/Pcc8/xqU99ikMPPZRSqcSaNWt49tlnd+v5f+6555g5cya1tbU7Pa69vZ2nn36ap556imeeeYa+vj5M0+TII4/klltu4cwzz9ytx9uRxx9/nNmzZ/Pyyy9zww038Pbbb2MYBnPnzuVrX/saJ5100sCxr7zyCjD0lo/+y1599dXdrm654oor8Pv93HDDDWSzWb75zW9y6qmn8rOf/Yz58+ezcOFCLrnkEp588km+9a1vUV9fz0033TRwe8dxuPTSS9m4cSPPP/884XB4h+t67rnnOPjgg3f7edE0TRtTStM0TRvXnnvuOQUM+e/www8fdOyJJ56opkyZMvCzbdtq6tSpKhAIqFWrVg069sorr1SA+tnPfjZw2eWXX64A9cADDww69ic/+YkC1FVXXbXduqLRqGpvb9/t3yeTyWx3meM46vjjj1fV1dWqVCoNXH7zzTcrQB133HGDLldKqccee0wB6q677tru/s4++2wViURUKpXa6eO2t7er2tpa9dGPfnSX604mkyoYDKqPfOQj2103c+ZMNWXKFCWlVEopde211ypAdXR07PJ+h3LFFVcoQJ199tnKcZyByzdu3KjC4bCaPn36wOXt7e3K7/erc889d+Dx+33zm99UgHr++eeVUu7zPHXqVBUOh9XGjRsHjrNtW330ox9VgLriiisGLl+3bp0ClGVZaunSpYPuO5FIqHA4rJqbm1V3d/fA5eVyWZ166qkKUC+88MLA5ccff7wSQqgXX3xx0P3ceuutClC33XbbwGVLlixRgDrkkENUoVAYuPyNN95QgBJCqIceemjQ/ZxzzjnK4/GodDo9sD5AXXPNNTt5pnfMcRxlGIY644wzhrx++fLl6gtf+II65JBDBj6Pzc3Nav78+eqhhx5SfX19u/1YU6ZMUTNmzBjyuv7fo6amRlmWpT796U+rRx55RH3jG99QkUhEWZY18Poq9e5r/oMf/GC7+8pmswpQ55133i7X1P/ZO/PMMwe9Bx999FEFKNM01csvvzzoNoceeqhqbm4e+NlxHHXZZZepWbNmqdbW1h0+VmtrqwLUwoULd7kuTdO08UKXdmuapu0j5s+fzzPPPDPo3w9+8IOd3ub1119n/fr1XH755dt1Bb7lllsA+M1vfgO4JamPPfYYs2bN4tJLLx107OWXX86MGTN45JFHtivZveKKK2hqatrt3yMUCg38dz6fp7e3l76+Ps444wySySQrV67c7jb/8R//gcfjGXTZz372MwKBAPPmzaOnp2fQv3PPPZdUKsVf//rXIR83nU7T29uLZVkceeSRA1m8nYlEIpx//vn88Y9/pLW1deDyF198kdWrV/PJT35yYGRVNBoF4Ne//jXlcnn3npgh3HTTTYP2w06aNInLL7+ctWvX8sYbbwDw8MMPUygUWLhwIb29vYOeh4997GMAPP300wD8/e9/Z/369Xzyk58cVFpsmib/+Z//ucN1nHXWWRx00EGDLvv9739PJpPhs5/9LHV1dQOXW5bFl770JeDd91Z3dzcvvPACp556Kscee+yg+/n85z9PKBQaOHZrn/70p/H5fAM/H3rooUQiEZqbm7n44osHHXviiSdSLpcHOlgHAgH8fj+vvPLKoJFLu6u3txcp5Q6z0Q899BDf+MY3ePPNN5kwYQJLlixh8+bNLFmyhIsvvphYLLbHjzmUdDoNuHuMr7/+er73ve9x3nnnccMNN/DII49g2/agDHAulwMY9Lz18/v9g47ZHddee+2g9+CJJ54IwJFHHsmRRx456NgTTjiB9vb2gRL7hx56iAceeADbtjn33HP50Ic+NKjcvF//c9zV1bXb69I0TRtrurRb0zRt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O5f33nuPa665ptJt5Ro5f3v37uWjjz6iV69euFwuXC5XmTIrV67k7rvvZsaMGfTv359Vq1Zx00038dVXX9G7d+8K911cXMyAAQMAWLFiBadOneLBBx9k7NixVb6BuhCd7ZwcPnyYN998k7///e88/fTTFBYWMnv2bHr37s0PP/xA+/btz/oaS5YsoV27dp7nYWFhNf53NCZVuU4A+vbty+zZs0sti4+Pr3Tfcp1U39nOR2RkJF9//XWpZUoprr/+evr371+l15BrpOqqcq97QX6OKNHgPPPMM8rf31+dOHHCs+zNN99URqNRpaSkVLhdQUGBCgwMVA8//LBnWVFRkYqLi1P33nuvV2NuzNLT08ssu/vuu1VgYKByOp0VbhcXF6fuu+8+b4Z2wVqyZIkCyj03FZHro3a1aNFCDRw4sNIyco3UjJL/h+644w7VoUOHMmXatm2rxowZU2rZpZdeqm644YZK971ixQqlaZpKSEjwLPv4448VoL799tvzjLzxOts5ycvLU/n5+aWWnTx5UoWEhKhJkyZVuu/PPvtMAer777+vuYAvAFW5Tvr166cGDRpU7X3LdVJ9VTkfZ3K/91evXl2lcnKNVF1V7nUvxM8RadrdAG3evJlrrrmGkJAQz7KRI0ficrnYunVrhdvt3LmT3NxcRo4c6VlmsVi4+eab2bRpk1djbszK+wazW7du5Obmkp+fXwcRiXMh10ft2blzJ4mJidx66611HcoFwWCo/KP+4MGD/PHHH6Xe+wCjR49m27ZtFBUVVbjt5s2b6dy5MxdddJFn2bXXXktISIhcN5U42znx9/enSZMmpZYFBATQunVrjh496s3QLlhnOyfnQ66T6juX87FixQoCAwO58cYbvRDRhe1s97oX6ueIJNINUEJCQqmmKAA2m43IyMhK++m41525bfv27Tl8+DAFBQU1H+wFaseOHURFRWG1Wist9+677+Lj40NAQAADBw6sUv83UXUdOnTAaDTSsmVLnn32WZxOZ4Vl5fqoPStWrMDf35+hQ4eetaxcI95X2XvfbreTmJhY6bZnbqdpGu3atatSv1FRddnZ2fz6669VatYNMHDgQIxGI9HR0UydOlX+h9WQL774An9/f3x9fenXrx9ffvnlWbeR68T7iouLee+997jpppvO2kfaTa6R81PyXvdC/RyRPtINUFZWFjabrczy4OBgMjMzK93Ox8enzD+Y4OBglFJkZWXh5+dX0+FecHbs2MHKlSuZM2dOpeWGDBlCr169iI2N5eDBg8yaNYvLLruMn376SfrjnqfIyEiefPJJevXqhaZpbNiwgUcffZSUlJQKxxGQ66N2OBwOVq9ezZAhQ/D396+0rFwjtSMrKwugzOdKcHAwwFk/V87l80hU37Rp09A0jYkTJ1ZaLigoiGnTpnHFFVfg5+fH9u3bmT17Nvv27av//Q3ruX79+jFu3DjatGnD0aNHmT17Ntdccw1ffPEFl156aYXbyXXifZs3byYzM5OxY8eetaxcI+fvzHvdC/VzRBJpIWpQcnIyo0aN4qqrruL++++vtOwrr7zi+f3yyy/nuuuuo127dsyePZv58+d7O9RGbcCAAZ6BKwCuu+46/Pz8+O9//8uMGTOIjIysw+gubJ988gnp6elVutmRa0QI3ZIlS3jrrbdYunQp0dHRlZbt1q0b3bp18zzv378/kZGRTJo0ie+++46ePXt6O9xG68knnyz1fPDgwXTo0IGnnnqq3jdBbezeffddmjZtytVXX33WsnKNnJ/q3Os2dtK0uwEKDg4mJyenzPKsrKxS/abL266oqKjM1DFZWVlomub51kicm+zsbG644QZCQ0N57733qt2/JzIykssuu4xdu3Z5KcIL28iRI3E6nfz888/lrpfro3asWLGC0NDQUl90VJVcI97hfm+f+bnirmE42+fKuXweiarbvHkz99xzD4899hh33HHHOe3D3W9Rrp2a5e/vz6BBg856XOU68a68vDw+/PBDRo0ahdFoPKd9yDVSNRXd616onyOSSDdA5fUZyMnJ4dixY2X6GJy5HcDvv/9eanlCQoJn3lxxbgoKChg8eDA5OTls3ry51JRLomGQ68P7CgoK+OCDD7jlllswm811HY74i/u9f+bnSkJCAhaLpdJm9OV9Himl+P333yv9PBJV88033zBixAjuuOMO/vOf/9R1OOIcyXXiXevWraOgoKBKLZ3EuavsXvdC/RyRRLoBuuGGG/j000/Jzs72LFuzZg0Gg4Hrrruuwu369OlDYGAga9as8SwrLi7m/fffZ+DAgd4MuVFzOByMHDmSffv2sWXLlrPOVVyRo0ePsmPHDi655JIajlCAPr+h0Wgs1ZyrJLk+vG/Dhg3k5eWd882OXCPe0bJlS9q2bVvqvQ+watUqrr76aiwWS4Xb3nDDDezevZv9+/d7lm3bto0TJ07IdXOefvvtNwYNGkT//v154403zmtfK1euBJBrp4bl5+ezcePGsx5XuU68a8WKFbRq1YpevXqd8z7kGqnc2e51L9jPkTqdfEuck8zMTBUZGan69eunPv74Y7V48WJls9nKzLfav39/1apVq1LLnn32WeXj46Pmzp2rtm3bpoYPH66sVqv6888/a/NPaFTuvvtuBag5c+aor7/+utSjsLBQKVX2XKxYsUKNHTtWvfPOO2r79u1q4cKFqlWrVio4OFgdPHiwrv6URuO6665Tzz33nProo4/URx99pP7xj38oTdPUv//9b08ZuT5q35AhQ1RsbKxyuVxl1sk14j35+flqzZo1as2aNerKK69UMTExnudpaWlKqdPzeD7++OPqs88+UxMnTlQmk0nt3LnTs5+kpCRlNBrVk08+6Vlmt9tVx44dVadOndSHH36oVq1apWJiYs5prt0LydnOSWpqqoqOjlZRUVFq27ZtpT5X9u7d69lPeefk1ltvVU888YRav369+vjjj9VDDz2kLBaLGjZsWF38qQ3G2c7Jl19+qW688Ua1ePFitX37dvXOO++obt26KYvFUmquW7lOakZV/m8ppVRaWpoymUzq0UcfLXc/co3UjKrc616InyOSSDdQv/32m7r66quVn5+fioiIUA8++KAqKioqVaZfv34qLi6u1DKXy6WeeeYZFR0drXx8fFSvXr1KvcFF9cXFxSmg3EdiYqJSquy5+Prrr9WVV16pwsLClMlkUmFhYWrkyJGlJqMX5+7+++9Xbdq0UX5+fsrHx0d16tRJvfzyy6USOLk+aldmZqayWCxq2rRp5a6Xa8R7EhMTK/wf9dlnn3nKLVy4ULVu3VpZLBbPDU15+3niiSdKLU9OTlY333yzCggIUDabTd11110qJyenFv6yhuts5+Szzz6rcH2/fv3K7KfkOXnmmWdUhw4dVEBAgDKbzapt27Zq5syZZe4RRGlnOyf79+9XAwYMUM2aNVNms1nZbDY1cODAUkl0yf3IdXJ+qvp/a968eQpQv/32W6X7kWvk/FTlXlepC+9zRFNKqZqu5RZCCCGEEEIIIRor6SMthBBCCCGEEEJUgyTSQgghhBBCCCFENUgiLYQQQgghhBBCVIMk0kIIIYQQQgghRDVIIi2E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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Floor area vs bedrooms — red points are physically impossible (area < beds * 8 m²)\n", + "s = raw.filter(fa.is_not_null() & (fa < 160)).select(\"Bedrooms\", \"Total floor area (sqm)\")\n", + "rng = np.random.default_rng(0)\n", + "xb = s[\"Bedrooms\"].to_numpy().astype(float)\n", + "x = xb + (rng.random(s.height) - 0.5) * 0.35\n", + "y = s[\"Total floor area (sqm)\"].to_numpy()\n", + "impossible = y < np.maximum(10.0, xb * 8.0)\n", + "fig, ax = plt.subplots(figsize=(9, 4))\n", + "ax.scatter(x[~impossible], y[~impossible], s=4, alpha=0.15, color=\"#2563eb\", label=\"plausible\")\n", + "ax.scatter(x[impossible], y[impossible], s=14, alpha=0.8, color=\"red\", label=\"impossible (area < beds×8)\")\n", + "xs = np.linspace(0, x.max(), 60)\n", + "ax.plot(xs, np.maximum(10, xs * 8), \"k--\", lw=1, label=\"area = beds × 8 m²\")\n", + "ax.set_xlabel(\"Bedrooms\"); ax.set_ylabel(\"Total floor area (m²)\")\n", + "ax.set_title(\"Floor area vs bedrooms (≤160 m²)\"); ax.legend(fontsize=8)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "d1a7f09b", + "metadata": {}, + "source": [ + "### 3.4 · 🔴/🟠 Zero bedrooms / bathrooms conflation\n", + "\n", + "`storage.py` maps missing **and** capped-implausible values to literal `0`, so `Bedrooms == 0` mixes\n", + "genuine studios, missing data, and capped outliers (e.g. a £29.9M 0-bed \"Detached\"). Same for bathrooms." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c41b90f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.551560Z", + "iopub.status.busy": "2026-06-03T07:34:31.551429Z", + "iopub.status.idle": "2026-06-03T07:34:31.560471Z", + "shell.execute_reply": "2026-06-03T07:34:31.560043Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Bedrooms == 0 : 5,495 of which Studio sub-type: 1,407\n", + "Bathrooms == 0: 6,448 with >=3 beds: 1,057\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (5, 8)
providerProperty typeProperty sub-typeBedroomsBathroomsAsking priceTotal floor area (sqm)Address per Property Register
strstrstri32i32i64f64str
"OnTheMarket""Other""Land"00nullnull"Sutton Road, Hounslow, Greater London"
"Zoopla""Other""Property"00nullnull""
"Zoopla""Other""Property"00nullnull""
"Zoopla""Other""Property"00nullnull""
"Zoopla""Other""Property"00nullnull""
" + ], + "text/plain": [ + "shape: (5, 8)\n", + "┌─────────────┬─────────────┬────────────┬──────────┬───────────┬────────┬────────────┬────────────┐\n", + "│ provider ┆ Property ┆ Property ┆ Bedrooms ┆ Bathrooms ┆ Asking ┆ Total ┆ Address │\n", + "│ --- ┆ type ┆ sub-type ┆ --- ┆ --- ┆ price ┆ floor area ┆ per │\n", + "│ str ┆ --- ┆ --- ┆ i32 ┆ i32 ┆ --- ┆ (sqm) ┆ Property │\n", + "│ ┆ str ┆ str ┆ ┆ ┆ i64 ┆ --- ┆ Register │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ --- │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ str │\n", + "╞═════════════╪═════════════╪════════════╪══════════╪═══════════╪════════╪════════════╪════════════╡\n", + "│ OnTheMarket ┆ Other ┆ Land ┆ 0 ┆ 0 ┆ null ┆ null ┆ Sutton │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ Road, │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ Hounslow, │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ Greater │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ London │\n", + "│ Zoopla ┆ Other ┆ Property ┆ 0 ┆ 0 ┆ null ┆ null ┆ │\n", + "│ Zoopla ┆ Other ┆ Property ┆ 0 ┆ 0 ┆ null ┆ null ┆ │\n", + "│ Zoopla ┆ Other ┆ Property ┆ 0 ┆ 0 ┆ null ┆ null ┆ │\n", + "│ Zoopla ┆ Other ┆ Property ┆ 0 ┆ 0 ┆ null ┆ null ┆ │\n", + "└─────────────┴─────────────┴────────────┴──────────┴───────────┴────────┴────────────┴────────────┘" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "zb = raw.filter(pl.col(\"Bedrooms\") == 0)\n", + "print(f\"Bedrooms == 0 : {zb.height:,} of which Studio sub-type: \"\n", + " f\"{zb.filter(pl.col('Property sub-type') == 'Studio').height:,}\")\n", + "print(f\"Bathrooms == 0: {raw.filter(pl.col('Bathrooms') == 0).height:,} with >=3 beds: \"\n", + " f\"{raw.filter((pl.col('Bathrooms') == 0) & (pl.col('Bedrooms') >= 3)).height:,}\")\n", + "zb.filter(pl.col(\"Property sub-type\") != \"Studio\").sort(\"Asking price\", descending=True).select(\n", + " \"provider\", \"Property type\", \"Property sub-type\", \"Bedrooms\", \"Bathrooms\",\n", + " \"Asking price\", \"Total floor area (sqm)\", \"Address per Property Register\",\n", + ").head(5)" + ] + }, + { + "cell_type": "markdown", + "id": "589d9960", + "metadata": {}, + "source": [ + "### 3.5 · 🟠 Geo & postcode precision\n", + "\n", + "- ~11% of rows have coordinates not confirmed by an address. **Outcode-only** rows (Zoopla) are pinned\n", + " to one arbitrary postcode per outcode and stamped with a plausible-looking *fake* unit postcode\n", + " (e.g. all SW6 listings → `SW6 1AA`).\n", + "- Development/building **centroid collisions**: many *distinct* properties share one exact coordinate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3026540e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.561309Z", + "iopub.status.busy": "2026-06-03T07:34:31.561230Z", + "iopub.status.idle": "2026-06-03T07:34:31.574626Z", + "shell.execute_reply": "2026-06-03T07:34:31.574256Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "shape: (6, 2)\n", + "┌────────────────────┬────────┐\n", + "│ Postcode source ┆ count │\n", + "│ --- ┆ --- │\n", + "│ str ┆ u32 │\n", + "╞════════════════════╪════════╡\n", + "│ detail_address ┆ 100274 │\n", + "│ coordinates ┆ 8903 │\n", + "│ search_outcode ┆ 2748 │\n", + "│ detail_coordinates ┆ 564 │\n", + "│ address ┆ 102 │\n", + "│ address_outcode ┆ 14 │\n", + "└────────────────────┴────────┘\n", + "\n", + "Outcode-only rows: 2,762 -> distinct coordinates: 204 (one fake point per outcode)\n", + "Coordinates shared by >1 DISTINCT property: 8,122 points / 27,080 rows (max 44 distinct properties on one point)\n" + ] + } + ], + "source": [ + "print(raw[\"Postcode source\"].value_counts(sort=True))\n", + "oc = raw.filter(pl.col(\"Postcode source\").is_in([\"search_outcode\", \"address_outcode\"]))\n", + "print(f\"\\nOutcode-only rows: {oc.height:,} -> distinct coordinates: \"\n", + " f\"{oc.select(['lat', 'lon']).unique().height:,} (one fake point per outcode)\")\n", + "coll = (\n", + " raw.group_by([\"lat\", \"lon\"])\n", + " .agg(pl.struct(\"Asking price\", \"Bedrooms\").n_unique().alias(\"distinct_props\"), pl.len().alias(\"n\"))\n", + " .filter(pl.col(\"distinct_props\") > 1)\n", + ")\n", + "print(f\"Coordinates shared by >1 DISTINCT property: {coll.height:,} points / {coll['n'].sum():,} rows \"\n", + " f\"(max {coll['distinct_props'].max()} distinct properties on one point)\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1487ffb4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.575601Z", + "iopub.status.busy": "2026-06-03T07:34:31.575519Z", + "iopub.status.idle": "2026-06-03T07:34:31.709176Z", + "shell.execute_reply": "2026-06-03T07:34:31.708781Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA2QAAAL2CAYAAAAuMVU0AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAQ6wAAEOsBUJTofAABAABJREFUeJzs3Xd4HNXV+PHvzGzTqqx6tyQXXLGNMdiAaTbFMb2EbsDUEAiQkEINPSQhvJTkfdP4kUDoAUIghBowvZhiGxdsuclqVlmVXUnbZ+7vD0WLZBVLtuyV7fN5Hj/gmdmZu7Oz6zlz7j1XU0ophBBCCCGEEELscnqiGyCEEEIIIYQQeysJyIQQQgghhBAiQSQgE0IIIYQQQogEkYBMCCGEEEIIIRJEAjIhhBBCCCGESBAJyIQQQgghhBAiQSQgE0IIIYQQQogEkYBMCCGEEEIIIRJEAjIhhBBCCCGESBAJyIQQQgghhBAiQSQgE0LskIqKCjRN4/bbbx/W/b777rtomsajjz46rPvdm9x+++1omkZFRcWAy/YGRx55JGVlZYluxogy1O9Yoq+dO++8k5ycHNra2nbJ8XbWb9vuaEd/j03TZMKECXzve98b3oYJsYeQgEwI0aeuf4DvvvvunXqM22+/ndbW1p12DJFY8hmL4VBdXc2vf/1rbrzxRlJTU+PL33vvPa6++mqmT59Oeno66enpzJw5k/vvv59gMNhrP11BZV9/vvvd7+7Kt7RXMQyDO+64g0ceeYSvv/460c0RYsSxJboBQojdW2lpKcFgEJtt6D8n7777LnfccQeLFi0iPT29x7rDDz+cYDCI3W4fppYKgFtuuYUbbrgBp9O5S4430GcsEmt3+o7de++96LreK8Ny/fXXU1lZyamnnsqVV15JJBLhpZde4sc//jFPPvkkH330ES6Xq9f+HnjgAbKzs3ssKy0t3anvYW935pln8tOf/pQ777yT559/PtHNEWJEkYBMCLFDNE3r84ZnR+m6vlP2u6cwTZNwOIzb7R7S62w223YFz2LX6wqWdtbnNRK+Y0opOjo6SElJ6Xeb9vZ2HnvsMc444wySk5N7rPvVr37FoYce2uMcXX311Zx77rk8/fTT/OUvf+HKK6/stc9TTjlFurDuYrquc/755/Ob3/yG2tpaCgsLE90kIUYM6bIohNgh/Y2zePLJJzn44IPJzMwkKSmJkpISTjvtNFavXg10jum54447ABg9enS821DXfvoas9B92eOPP860adNwuVwUFRVx0003YZpmr/YtWbKEefPmkZycTEZGBmeeeSaVlZWUlZVx5JFHDvp9vvzyyxx99NFkZGTgcrkYM2YMl156KV6vt9f7nj17NsnJySQnJ3PQQQfxzDPP9LnPzz77jBNOOIHMzExcLhcTJ07krrvuIhKJ9Niuq5vV6tWr+dnPfkZpaSlOp5O///3vALS1tXHttddSUFBAUlIS+++/P88991yfxxxoXFl5eTm33nprfP+TJk3iySef7LWPN998k3POOYexY8eSlJREWloahx9+OP/61796bLetzxggEolw7733Mm3atPi+jj76aN5///1ex93WNTUYmzdv5vTTTycjI4Pk5GSOOeYYvvrqq/j6cDhMdnY2s2bN6vP1//jHP9A0jf/93/8d8DiPPvoomqbxn//8h7vvvpvRo0fjdDqZMGECv/vd73pt3zXGbfPmzZx99tlkZ2fjdruprq4GoKamhksvvZSioiIcDgfFxcVcfvnlbNmyJb6P9evXo2kal112WZ9tuvvuu9E0jbfffhvof1zQUK4ngPr6eq6++mrKyspwOBzk5eWxcOHCXmPNup+TX/7yl4wfPx6n08l999034Ll8/fXX8fv9HH/88X2et74C1rPPPhtgwO5xbW1tRKPRAY/dl48//picnBwmT57M5s2bB9y2urqayy+/nNGjR+NyucjOzmbmzJncc889Pbb7wx/+wPz58ykuLsbhcJCbm8vpp5/OypUre+2z67dr5cqVzJ8/n7S0NLKysrj00kvp6OjAsizuvfdexo0bh9PpZMqUKfz73//utR9N01i0aBGLFy9mzpw5JCcnk52dzaJFi2hoaBjUuVBK8fDDDzNr1qz4b94hhxzCP//5zz63P/7444nFYrzwwguD2r8Qewt5TCqEGHZPPvkkCxcuZM6cOdx2222kpKRQU1PDO++8w9q1a5k8eTI333wzmZmZvPjiiz26D02bNm2b+//Tn/4Uv0HNycnhH//4B7/85S9JS0vjhhtuiG/3+eefc+SRR+JwOLj22mspLi7m7bff5ogjjqCjo2PQ7+fWW2/lrrvuYuzYsVx99dUUFxdTWVnJv/71L6qrq+Nt79pu6tSp3HbbbSileOKJJzjnnHPYuHEjN910U3yfr7/+OieddBJpaWlceeWV5Ofn8+qrr3Lrrbfy8ccf8+9//xtd7/nM7LzzzsNms3HVVVeRkpLChAkTiMViLFiwgI8++ohTTz2Vo446isrKSi6++GLGjx8/6PcIcOGFF6JpGtdccw26rvP73/+ehQsXMnbsWA466KD4do8++ij19fUsXLiQ4uJiGhsbeeyxxzjppJN45plnOOusswC2+RnHYjGOO+443nvvPc455xyuuOIKAoEATzzxBPPmzeOf//wnJ5xwAjC4a2pbOjo6OOKII5gxYwZ33303VVVV/P73v+fwww/no48+Yvr06TidTi688ELuv/9+vv76617X48MPP0xSUhILFy4c1Dm94YYb8Pl8XHbZZTidTp5++mmuueYa6uvre43PbG9v57DDDuPAAw/kjjvuoK2tLf4+DzzwQBoaGrj00kuZPn06y5cv5+GHH+b111/n888/Jy8vj3HjxjFnzhz+/ve/89vf/pakpKQe+3/ssccoKSlh3rx5/bZ3qNdTVVUVhxxyCO3t7VxyySWMHz+empoa/vCHP/Dmm2/yxRdfUFJS0uM1P/3pTwkEAlx44YXk5OQwatSoAc/h4sWLAXpcg9tSU1MDQF5eXp/rp0+fjt/vR9M0pk6dytVXX82ll166zf2+8MILLFy4kFmzZvHPf/6TjIyMfreNxWIcc8wxVFVV8f3vf5+JEyfS3t7OmjVreOedd3r8Htx7773Mnj2bq666iuzsbNatW8f/+3//j7feeoulS5cyduzYXu9v3rx5fPe73+XUU0/lk08+4ZFHHiEYDJKRkcGHH37I9773PQzD4KGHHuK0006jvLy8V7fMpUuX8vzzz3PRRRexcOFClixZwmOPPcZnn33G559/PmDmEuCiiy7ib3/7GyeffDLnnXce0PnQ4tRTT+UPf/gDV1xxRY/tDzjgABwOB4sXL+bqq6/e5vkWYq+hhBCiD4sXL1aAuuuuuwbcbtOmTQpQt912W3zZqaeeqlJTU1UkEhnwtbfddpsC1KZNm/o9/l//+tdey/Lz81Vzc3N8uWmaatKkSaqgoKDHPg455BBlGIb6+uuveyy/+uqrFaCOOOKIAdunlFJLlixRgDrooINUe3t7r/WmaSqllCovL1e6rqvp06erjo6O+Pr29na17777KsMw4u8zFoupsrIylZSUpNatW9djfxdddJEC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ALfddhtAv5/JhAkTeP/990lOTmbu3Ll9juvams/n61VW32azMX36dODb7tX9faf++Mc/Dls37/6Ul5f3uq7+7//+j9bW1vi10J8LLrgAgJ/97Gd9Zt/6avuXX35JJBJh7ty5O9BqIfY8kiETQgzovffe63fdzTff3Ofy+fPnk5qayuGHH05JSQmBQIBnnnmG1tbW+FgN+LaM9fXXX895552Hy+Vi33337XO8zPZ44IEHOPLIIznssMO48sor42Xvly5dSnZ29qAydAceeCA33XQT99xzD9OmTePcc89l1KhRVFdX89JLL/HXv/6V/fbbj3HjxnHzzTdz1113cdBBB3HeeefFy96vWLGCX/ziF/GJaA3D4A9/+AMnnXQSBx54IFdccQW5ubm89tprvPrqq8yfP79XAYf+XHjhhTzyyCM89NBDVFVVxcuU/9///R8zZszoMcfWcJgzZw4FBQX8+Mc/ZuPGjZSVlfHNN9/w8MMPM3XqVL788sse2w/0GV977bW8/fbb3H777bz//vsce+yxZGZmUlVVxccff8zGjRvjXdUGe00NJDs7m1deeYWampp4OfL/+7//w+Vy8eCDD/bavqysjPnz5/PUU08BDKmYR5e8vDwOPPBALr74YhwOB08//TRffvklN9xww6AyZNAZHP3nP//hnHPOYfHixUydOjVe9n7UqFF9BrQXXnghd911F5dffjmWZbFo0aJBHWuo19Mf//hHDj30UI455hjOPfdcDjzwQHRdZ/Pmzbz66qsccMABvQqMDNV3vvMd0tLSeOWVV+JTKnQ577zzeOWVV5gzZw4lJSU88cQTPdaPHTuWgw8+GOjsbltWVsZJJ53EpEmTyMnJYfPmzTz66KNUV1dz/fXXM3PmzH7bMWbMGD744AOOOuoojj76aF555ZUBs+aLFy/msssu49RTT2XChAmkp6ezevVq/vjHP1JUVBTPKp522mncf//9LFiwgMsvvxy3282HH37IG2+8wdixY4nFYtt76rZp6tSpLFq0iPfff59Jkybx+eef8+ijjzJ+/Hh+8pOfDPja008/ncsuu4yHH36Y5cuXx7v51tbW8uWXX/Lqq6/2CkhfeeUVbDbbNoM9IfY6iSjtKIQY+brKHA/0JxqN9ln2/uGHH1bz589XBQUFyuFwqJycHHX44YerZ599ttdxfv3rX6vRo0crm83W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PvroI+68804AFi5cyJNPPhnfb2NjI2+//TbnnnsulmVx0kknMW7cODZv3sxnn33GSy+9xCOPPDLs53C4SEAmhBBCCCFEAtS3WjT7LXI9fd+S53p0mv0W9a3Dl9257rrr8Hg8pKWlsWTJEp544ole27zyyiuMHz+ec889F5vNxrnnnsv06dN7bKPrOnfffTcOh4NZs2YxceJEli1bNuCxTz75ZA466CDsdjtnnXUWDQ0N3HLLLdjtds444wyCwSAbNmzANE2efvppfv3rX5Oenk5+fj533HEHf/vb3wA45ZRT2LBhA6tWrQLg2WefZc6cORQXF/PFF19QVVXF3XffjcvlorCwkOuuu46nn356eE7gTiABmRBCCCGEEAkQiSlMi3hmbGsOu0bM6txuuNx///34fD7WrVuHruusW7eu1za1tbWMGjWqx7Kt/56ZmYnD4Yj/3e12097ePuCxu1dmdLvdZGdnYxhGfFlSUhLt7e14vV4ikUiPyZbLysrYsmULSincbjennnpqPEv25JNPsnDhQqBzoubGxkYyMjJIT08nPT2dq666ivr6+m2cmcSRgEwIIYQQQogEcNg0DB0i0b4DrkhUYdM7txtu48aN46GHHuIHP/hBvJtgl8LCQqqqqnos2/rvA9G0HWtvdnY2DoejR3fHiooKCgoK4vteuHAhTz31FOvXr2f58uV897vfBaCkpITi4mJaW1vjf/x+fzybNhJJQCaEEEIIIUQC5KXrZKbpNPj67pLY4LPITNPJS985t+wLFiwgLy+PP/3pTz2WH3/88ZSXl/Pss88Si8V45plnWL58+aD3m5eXR0VFxXYX0jAMg7PPPpsbb7yR1tZW6uvrueOOOzj//PPj2xx11FFEIhGuvPJKTjjhBNLS0gA48MADycnJ4a677qKjowPLsli3bh3vvffedrVlV5CATAghhBBCiARIcmpMLLah6RrVXjOeKYtEFdVeE03vXJ/kHP4MWZfrr7+eX//61z2yZFlZWfzjH//g9ttvJyMjg1dffZXvfOc7OJ3OQe3zjDPOwG63k5WVxdixY7erXb/97W/Jy8tjwoQJzJgxg1mzZnHrrbfG13cFbW+99Va8u2LX8n/961+sW7eOffbZh4yMDM4880y2bNmyXe3YFTSl1PCXbdmDVVdXM2rUKKqqqiguLk50c4QQQgghRIJ1da3rPuZp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+slPfkIikRj/e3Nz8/j//9a3vsVLL73EG97wBj7/+c/P+TE///nPk0ql+MQnPsGaNWt49NFH+ehHP8rhw4f50pe+tKTjFxERERERmY8LKpDt2bOH2traGf/t61//OrZdWNCbTyD73Oc+N+kxb775Znzf5y//8i+59957Z/1+IiIiIiIiy+2C2LI4F8UwNl8zBa7du3djjKGzs3OxwxIREREREVmwCyqQbdu2Dcdx2LBhA5/85CfxPG9Zvs/DDz9MOBxm/fr1573v8PAwbW1t438U4kREREREZKlcEFsWGxsb+fjHP86+ffuwLIvvf//7/OVf/iXt7e189rOfXdLvdezYMf7u7/6OP/qjPyIWi533/n/7t3/Lxz/+8SUdg4iIiIiICIBljDGlHsRM/vzP/5zPfOYznDlzhsbGxkn/ZlkW9957Lx/+8Ifn9ZjDw8Ncd911ADz66KOUl5fP6WuGh4fH/97Z2cnevXs5c+YMLS0t8/r+IiIiIiJy6Wtra2P16tVzygwX1JbFid72trfheR7PPvvskjxeLpfjTW96E4ODg/zoRz+aUxgDiMfjtLS0jP+ZGg5FREREREQW6oLYsrjcfN/nne98J0899RQPPfQQq1evLvWQRERERERELtxA9rWvfQ3Hcdi9e/eiH+uP//iP+cEPfsD999/Pjh07lmB0IiIiIiIii3dBBLI77riDW2+9dTwsff/73+cLX/gC/+k//ScaGhoAOHz4MIcPHx7/moMHD/Ktb32L8vJy7rrrLgBOnz7Nxo0bufvuu7n77rsBuOeee/iHf/gH/vzP/5xwOMyjjz46/hhXXHEF8Xh8pZ6miIiIiIjIJBdEINu6dStf/OIXaWtrw/d9Nm/ezH333ccHPvCB8ft84xvfmFTt8Ctf+Qpf+cpXWLt2LadOnQLAGIPnefi+P36/Bx54AIB7772Xe++9d9L33b9/PzfffPPyPTEREREREZFzuGCrLF6o5lMxRUREREREXnkuiSqLIiIiIiIilzoFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESUSATEREREREpEQUyERERERGRElEgExERERERKREFMhERERERkRJRIBMRERERESkRBTIREREREZESCZR6ABebfD4PQGdnZ4lHIiIiIiIiF6JiVihmh3NRIJun3t5eAPbu3VvikYiIiIiIyIWst7eXdevWnfM+ljHGrMxwLg2ZTIaDBw9SV1dHILCyebazs5O9e/fy+OOP09jYuKLfWy58mh8yG80NORfNDzkXzQ85F82P2eXzeXp7e9mxYweRSOSc99UK2TxFIhFe/epXl3QMjY2NtLS0lHQMcuHS/JDZaG7IuWh+yLlofsi5aH7M7HwrY0Uq6iEiIiIiIlIiCmQiIiIiIiIlokB2EYnH43z0ox8lHo+XeihyAdL8kNlobsi5aH7IuWh+yLlofiwNFfUQEREREREpEa2QiYiIiIiIlIgCmYiIiIiISIkokImIiIiIiJSIApmIiIiIiEiJKJCJiIiIiIiUiALZBe4HP/gBu3btIhKJsHnzZr70pS/N+WsfffRRXvva11JRUUE8Hufqq6/m2WefXb7ByopbzPwoeuMb34hlWXz6059ehhFKKS1kfjzxxBO8973vZdOmTUSjUS677DL+4i/+grGxsRUYsSy1I0eOcNttt1FeXk5DQwMf+chHyOVy5/06Ywyf+tSnWLNmDWVlZVxzzTU8+uijKzBiWUkLmR+dnZ185CMf4corr6SiooKWlhZ+7/d+j9OnT6/QqGWlLPT3x0T33XcflmXxhje8YZlGeWlQILuAPfzww7zpTW/immuu4cc//jG/+7u/y7/7d/+Ob33rW+f92p///OfcfPPNbN68mW9/+9t87Wtf48477ySVSq3AyGUlLGZ+FP34xz/WRdYlaqHz4+tf/zrHjh3jIx/5CD/60Y/4z//5P/OFL3yB3/qt31qhkctSGRwc5NZbbyWXy/Htb3+be+65hy984Qv86Z/+6Xm/9m/+5m/46Ec/yoc+9CF++MMf0tjYyO23385LL720AiOXlbDQ+fHUU0/x7W9/m7e97W1873vf42//9m85ePAge/fupbe3d4VGL8ttMb8/irq6uvj4xz/OqlWrlnGklwgjF6zbb7/dXHvttZNue8c73mEuv/zyc36d67pm3bp15iMf+chyDk9KbKHzoyiTyZhNmzaZf/qnfzKAuffee5djmFIiC50fPT0902776le/agDz5JNPLukYZXndc889pry83PT394/f9vnPf944jmPa29tn/bp0Om3i8bj5i7/4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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Listings by provider (downsampled) — shows London footprint + centroid clusters\n", + "g = raw.sample(min(25_000, raw.height), seed=0).select(\"lat\", \"lon\", \"provider\")\n", + "colors = {\"Rightmove\": \"#2563eb\", \"OnTheMarket\": \"#16a34a\", \"Zoopla\": \"#dc2626\"}\n", + "fig, ax = plt.subplots(figsize=(8, 7))\n", + "for prov, c in colors.items():\n", + " gp = g.filter(pl.col(\"provider\") == prov)\n", + " ax.scatter(gp[\"lon\"], gp[\"lat\"], s=2, alpha=0.30, color=c, label=prov)\n", + "ax.set_xlabel(\"lon\"); ax.set_ylabel(\"lat\"); ax.set_aspect(\"equal\", adjustable=\"datalim\")\n", + "ax.set_title(\"Listing coordinates by provider (25k sample)\"); ax.legend(markerscale=4, fontsize=8)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "6edd8876", + "metadata": {}, + "source": [ + "### 3.6 · 🔴 Duplicate listings\n", + "\n", + "No cross-source identity resolution: the same physical property appears across portals, and is\n", + "re-listed multiple times within a portal (especially Zoopla). `UPRN` (Zoopla-only, 1.8% coverage)\n", + "is not 1:1 — 405 UPRNs repeat up to 7×, breaking the intended exact EPC join." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd1bf199", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.711042Z", + "iopub.status.busy": "2026-06-03T07:34:31.710934Z", + "iopub.status.idle": "2026-06-03T07:34:31.723397Z", + "shell.execute_reply": "2026-06-03T07:34:31.723059Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Cross-portal duplicate groups : 2,589 -> 5,304 rows\n", + "Intra-portal re-list groups : 2,863 -> 3,261 extra rows\n", + "UPRN coverage: 2,060 (1.8%) distinct: 1,547 repeated UPRNs: 405 (max 7×)\n" + ] + } + ], + "source": [ + "key = [\"lat\", \"lon\", \"Asking price\", \"Bedrooms\"]\n", + "dups = (\n", + " raw.filter(pl.col(\"Asking price\").is_not_null())\n", + " .group_by(key)\n", + " .agg(pl.len().alias(\"n\"), pl.col(\"provider\").n_unique().alias(\"providers\"))\n", + ")\n", + "cross = dups.filter((pl.col(\"n\") > 1) & (pl.col(\"providers\") > 1))\n", + "intra = dups.filter((pl.col(\"n\") > 1) & (pl.col(\"providers\") == 1))\n", + "print(f\"Cross-portal duplicate groups : {cross.height:,} -> {cross['n'].sum():,} rows\")\n", + "print(f\"Intra-portal re-list groups : {intra.height:,} -> {(intra['n'] - 1).sum():,} extra rows\")\n", + "u = raw.filter(pl.col(\"UPRN\").is_not_null())\n", + "rep = u.group_by(\"UPRN\").len().filter(pl.col(\"len\") > 1)\n", + "print(f\"UPRN coverage: {u.height:,} ({u.height / raw.height:.1%}) distinct: {u['UPRN'].n_unique():,} \"\n", + " f\"repeated UPRNs: {rep.height:,} (max {rep['len'].max() if rep.height else 0}×)\")" + ] + }, + { + "cell_type": "markdown", + "id": "29057e83", + "metadata": {}, + "source": [ + "### 3.7 · 🟠/🟡 Categorical normalization & dead columns\n", + "\n", + "- `Listing status` is a constant `\"For sale\"` (dead column).\n", + "- `Property sub-type` has 79 values with portal-spelling variants (`Apartment`↔`Flat`, `X House`↔`X`, …).\n", + "- `Price qualifier` has case-only duplicate pairs (Rightmove TitleCase vs OTM sentence-case).\n", + "- Missing values are encoded inconsistently — empty-string in some columns, `null` in others." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "04084e7d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.724469Z", + "iopub.status.busy": "2026-06-03T07:34:31.724385Z", + "iopub.status.idle": "2026-06-03T07:34:31.743743Z", + "shell.execute_reply": "2026-06-03T07:34:31.743363Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Listing status distinct : ['For sale']\n", + "Property sub-type distinct: 79\n", + "\n", + "Price-qualifier values that differ only by casing:\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (6, 3)
normvariantsrows
stru32u32
"guide price"223851
"offers in excess of"211832
"offers over"23449
"offers in region of"21963
"fixed price"2464
"shared equity"27
" + ], + "text/plain": [ + "shape: (6, 3)\n", + "┌─────────────────────┬──────────┬───────┐\n", + "│ norm ┆ variants ┆ rows │\n", + "│ --- ┆ --- ┆ --- │\n", + "│ str ┆ u32 ┆ u32 │\n", + "╞═════════════════════╪══════════╪═══════╡\n", + "│ guide price ┆ 2 ┆ 23851 │\n", + "│ offers in excess of ┆ 2 ┆ 11832 │\n", + "│ offers over ┆ 2 ┆ 3449 │\n", + "│ offers in region of ┆ 2 ┆ 1963 │\n", + "│ fixed price ┆ 2 ┆ 464 │\n", + "│ shared equity ┆ 2 ┆ 7 │\n", + "└─────────────────────┴──────────┴───────┘" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Empty-string counts in String columns:\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (1, 15)
PostcodePostcode sourceExtracted postcodeInferred postcodeListing raw addressAddress per Property RegisterUPRNProperty number or nameLeasehold/FreeholdProperty typeProperty sub-typePrice qualifierListing URLListing statusprovider
u32u32u32u32u32u32u32u32u32u32u32u32u32u32u32
0000282528370000069358000
" + ], + "text/plain": [ + "shape: (1, 15)\n", + "┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬────────┬───────┬───────┬───────┐\n", + "│ Pos ┆ Pos ┆ Ext ┆ Inf ┆ Lis ┆ Add ┆ UPR ┆ Pro ┆ Lea ┆ Pro ┆ Pro ┆ Price ┆ Listi ┆ Listi ┆ provi │\n", + "│ tco ┆ tco ┆ rac ┆ err ┆ tin ┆ res ┆ N ┆ per ┆ seh ┆ per ┆ per ┆ qualif ┆ ng ┆ ng ┆ der │\n", + "│ de ┆ de ┆ ted ┆ ed ┆ g ┆ s ┆ --- ┆ ty ┆ old ┆ ty ┆ ty ┆ ier ┆ URL ┆ statu ┆ --- │\n", + "│ --- ┆ sou ┆ pos ┆ pos ┆ raw ┆ per ┆ u32 ┆ num ┆ /Fr ┆ typ ┆ sub ┆ --- ┆ --- ┆ s ┆ u32 │\n", + "│ u32 ┆ rce ┆ tco ┆ tco ┆ add ┆ Pro ┆ ┆ ber ┆ eeh ┆ e ┆ -ty ┆ u32 ┆ u32 ┆ --- ┆ │\n", + "│ ┆ --- ┆ de ┆ de ┆ res ┆ per ┆ ┆ or ┆ old ┆ --- ┆ pe ┆ ┆ ┆ u32 ┆ │\n", + "│ ┆ u32 ┆ --- ┆ --- ┆ s ┆ ty ┆ ┆ nam ┆ --- ┆ u32 ┆ --- ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ u32 ┆ u32 ┆ --- ┆ Reg ┆ ┆ e ┆ u32 ┆ ┆ u32 ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ ┆ u32 ┆ ist ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ ┆ ┆ er ┆ ┆ u32 ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ ┆ ┆ u32 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "╞═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪════════╪═══════╪═══════╪═══════╡\n", + "│ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 282 ┆ 283 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 0 ┆ 69358 ┆ 0 ┆ 0 ┆ 0 │\n", + "│ ┆ ┆ ┆ ┆ 5 ┆ 7 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴────────┴───────┴───────┴───────┘" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "print(\"Listing status distinct :\", raw[\"Listing status\"].unique().to_list())\n", + "print(\"Property sub-type distinct:\", raw[\"Property sub-type\"].n_unique())\n", + "\n", + "pq = (\n", + " raw.filter(pl.col(\"Price qualifier\") != \"\")\n", + " .with_columns(pl.col(\"Price qualifier\").str.strip_chars().str.to_lowercase().alias(\"norm\"))\n", + " .group_by(\"norm\")\n", + " .agg(pl.col(\"Price qualifier\").n_unique().alias(\"variants\"), pl.len().alias(\"rows\"))\n", + " .filter(pl.col(\"variants\") > 1)\n", + " .sort(\"rows\", descending=True)\n", + ")\n", + "print(\"\\nPrice-qualifier values that differ only by casing:\")\n", + "display(pq)\n", + "\n", + "print(\"Empty-string counts in String columns:\")\n", + "display(raw.select([\n", + " (pl.col(c).str.strip_chars() == \"\").sum().alias(c)\n", + " for c in raw.columns if raw.schema[c] == pl.String\n", + "]))" + ] + }, + { + "cell_type": "markdown", + "id": "cda8c639", + "metadata": {}, + "source": [ + "### 3.8 · 🔴/🟡 Listing date\n", + "\n", + "`Listing date` is **null for 100% of OnTheMarket & Zoopla** (Rightmove-only `firstVisibleDate`), so any\n", + "recency analysis is biased to 82% of the data — and it reaches back to **2011** in a live for-sale set." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "df376178", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.744727Z", + "iopub.status.busy": "2026-06-03T07:34:31.744645Z", + "iopub.status.idle": "2026-06-03T07:34:31.753658Z", + "shell.execute_reply": "2026-06-03T07:34:31.753266Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (3, 5)
providerrowsdate_nulloldestnewest
stru32u32datetime[μs]datetime[μs]
"OnTheMarket"1277112771nullnull
"Rightmove"9279702011-03-14 13:57:532026-05-31 02:38:38
"Zoopla"70377037nullnull
" + ], + "text/plain": [ + "shape: (3, 5)\n", + "┌─────────────┬───────┬───────────┬─────────────────────┬─────────────────────┐\n", + "│ provider ┆ rows ┆ date_null ┆ oldest ┆ newest │\n", + "│ --- ┆ --- ┆ --- ┆ --- ┆ --- │\n", + "│ str ┆ u32 ┆ u32 ┆ datetime[μs] ┆ datetime[μs] │\n", + "╞═════════════╪═══════╪═══════════╪═════════════════════╪═════════════════════╡\n", + "│ OnTheMarket ┆ 12771 ┆ 12771 ┆ null ┆ null │\n", + "│ Rightmove ┆ 92797 ┆ 0 ┆ 2011-03-14 13:57:53 ┆ 2026-05-31 02:38:38 │\n", + "│ Zoopla ┆ 7037 ┆ 7037 ┆ null ┆ null │\n", + "└─────────────┴───────┴───────────┴─────────────────────┴─────────────────────┘" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Listings dated before 2024-01-01: 911\n" + ] + } + ], + "source": [ + "display(\n", + " raw.group_by(\"provider\").agg(\n", + " pl.len().alias(\"rows\"),\n", + " pl.col(\"Listing date\").null_count().alias(\"date_null\"),\n", + " pl.col(\"Listing date\").min().alias(\"oldest\"),\n", + " pl.col(\"Listing date\").max().alias(\"newest\"),\n", + " ).sort(\"provider\")\n", + ")\n", + "print(f\"Listings dated before 2024-01-01: \"\n", + " f\"{raw.filter(pl.col('Listing date') < pl.datetime(2024, 1, 1)).height:,}\")" + ] + }, + { + "cell_type": "markdown", + "id": "dd559a4a", + "metadata": {}, + "source": [ + "## 4 · Cleanup\n", + "\n", + "We build a cleaned frame `clean` that **fixes or flags** the issues above. Guiding principle: prefer\n", + "adding provenance/flag columns and nulling clearly-invalid values over fabricating data we don't have\n", + "(e.g. shared-ownership *full* prices and un-converted sq ft are **flagged**, not invented).\n", + "\n", + "**Reference tables** (canonicalisation / classification):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12a191be", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.754641Z", + "iopub.status.busy": "2026-06-03T07:34:31.754556Z", + "iopub.status.idle": "2026-06-03T07:34:31.756985Z", + "shell.execute_reply": "2026-06-03T07:34:31.756603Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Reference tables defined.\n" + ] + } + ], + "source": [ + "RESIDENTIAL_TYPES = [\"Flats/Maisonettes\", \"Terraced\", \"Detached\", \"Semi-Detached\"]\n", + "\n", + "# Collapse portal-specific sub-type spellings to one canonical label.\n", + "SUBTYPE_CANON = {\n", + " \"Apartment\": \"Flat\",\n", + " \"Terraced House\": \"Terraced\", \"End Of Terrace House\": \"End Of Terrace\", \"End Terrace\": \"End Of Terrace\",\n", + " \"Semi-Detached House\": \"Semi-Detached\", \"Semi Detached\": \"Semi-Detached\",\n", + " \"Semi Detached Bungalow\": \"Semi-Detached Bungalow\",\n", + " \"Detached House\": \"Detached\", \"Link Detached House\": \"Link Detached\",\n", + " \"Townhouse\": \"Town House\", \"Houseboat\": \"House Boat\",\n", + " \"Ground Floor Flat\": \"Ground Flat\", \"Ground Floor Maisonette\": \"Ground Maisonette\",\n", + " \"Block Of Flats\": \"Block Of Apartments\",\n", + " \"Property\": \"Unknown\", \"Not Specified\": \"Unknown\",\n", + "}\n", + "\n", + "# Sub-types that are not residential dwellings (canonical names).\n", + "NONRES_SUBTYPES = [\n", + " \"Land\", \"Plot\", \"Parking\", \"Garage\", \"Garages\", \"Office\", \"Retail\", \"Industrial\",\n", + " \"Light Industrial\", \"Warehouse\", \"Woodland\", \"Farm Land\", \"Mixed Use\",\n", + " \"Mixed Use Auction Lot\", \"Residential Development\", \"Block Of Apartments\",\n", + " \"Commercial\", \"Restaurant\", \"Unknown\",\n", + "]\n", + "\n", + "# Positional-confidence tier from the pipeline's Postcode source.\n", + "PRECISION = {\n", + " \"detail_address\": \"address-unit\", \"address\": \"address-unit\",\n", + " \"coordinates\": \"coordinate-nearest\", \"detail_coordinates\": \"coordinate-nearest\",\n", + " \"search_outcode\": \"outcode-only\", \"address_outcode\": \"outcode-only\",\n", + "}\n", + "print(\"Reference tables defined.\")" + ] + }, + { + "cell_type": "markdown", + "id": "2491e545", + "metadata": {}, + "source": [ + "**Step A** — drop dead/redundant columns, normalise empty-string → null, canonicalise `Price qualifier`, derive `price_basis` + `is_shared_ownership`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "241f8359", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.757767Z", + "iopub.status.busy": "2026-06-03T07:34:31.757691Z", + "iopub.status.idle": "2026-06-03T07:34:31.771814Z", + "shell.execute_reply": "2026-06-03T07:34:31.771466Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Step A done. price_basis:\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (7, 2)
price_basiscount
stru32
"firm"69358
"auction_guide"23851
"floor"17258
"share"1404
"other"494
"from"221
"unpriced"19
" + ], + "text/plain": [ + "shape: (7, 2)\n", + "┌───────────────┬───────┐\n", + "│ price_basis ┆ count │\n", + "│ --- ┆ --- │\n", + "│ str ┆ u32 │\n", + "╞═══════════════╪═══════╡\n", + "│ firm ┆ 69358 │\n", + "│ auction_guide ┆ 23851 │\n", + "│ floor ┆ 17258 │\n", + "│ share ┆ 1404 │\n", + "│ other ┆ 494 │\n", + "│ from ┆ 221 │\n", + "│ unpriced ┆ 19 │\n", + "└───────────────┴───────┘" + ] + }, + "execution_count": 19, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ql = pl.col(\"Price qualifier\").fill_null(\"\").str.to_lowercase().str.strip_chars()\n", + "\n", + "clean = (\n", + " raw\n", + " # \"Number of bedrooms & living rooms\" == Bedrooms+Bathrooms (no extra info) -> drop;\n", + " # \"Listing status\" is constant -> drop.\n", + " .drop([\"Listing status\", \"Number of bedrooms & living rooms\"])\n", + " .with_columns(\n", + " ql.str.contains(\"shared|part buy|part-buy\").alias(\"is_shared_ownership\"),\n", + " pl.when(ql.str.contains(\"shared|part buy|part-buy\")).then(pl.lit(\"share\"))\n", + " .when(ql.str.contains(\"offers in excess|offers over|offers in region|invited\")).then(pl.lit(\"floor\"))\n", + " .when(ql.str.contains(\"guide\")).then(pl.lit(\"auction_guide\"))\n", + " .when(ql == \"from\").then(pl.lit(\"from\"))\n", + " .when(ql.str.contains(\"application|coming soon\")).then(pl.lit(\"unpriced\"))\n", + " .when(ql == \"\").then(pl.lit(\"firm\"))\n", + " .otherwise(pl.lit(\"other\")).alias(\"price_basis\"),\n", + " )\n", + ")\n", + "\n", + "# Empty-string -> null for all String columns (except the derived enum price_basis).\n", + "str_cols = [c for c in clean.columns if clean.schema[c] == pl.String and c != \"price_basis\"]\n", + "clean = clean.with_columns([\n", + " pl.when(pl.col(c).str.strip_chars() == \"\").then(None).otherwise(pl.col(c)).alias(c)\n", + " for c in str_cols\n", + "])\n", + "\n", + "# Canonical casing for Price qualifier (drop comma variants, Title Case).\n", + "clean = clean.with_columns(\n", + " pl.when(pl.col(\"Price qualifier\").is_null()).then(None)\n", + " .otherwise(pl.col(\"Price qualifier\").str.replace_all(\",\", \"\").str.to_titlecase())\n", + " .alias(\"Price qualifier\")\n", + ")\n", + "print(\"Step A done. price_basis:\")\n", + "clean[\"price_basis\"].value_counts(sort=True)" + ] + }, + { + "cell_type": "markdown", + "id": "58767c84", + "metadata": {}, + "source": [ + "**Step B** — floor-area sanity (null impossibly-small, flag suspiciously-large) + recompute `Asking price per sqm` (excluding shared-ownership); null sentinel-`0` beds/baths for dwellings; canonicalise sub-types; add `is_residential`, `location_precision`; strip fake unit postcodes from outcode-only rows." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e20392f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.772616Z", + "iopub.status.busy": "2026-06-03T07:34:31.772540Z", + "iopub.status.idle": "2026-06-03T07:34:31.783427Z", + "shell.execute_reply": "2026-06-03T07:34:31.783029Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Step B done. clean shape: (112605, 30)\n", + "location_precision: [{'location_precision': 'address-unit', 'count': 100376}, {'location_precision': 'coordinate-nearest', 'count': 9467}, {'location_precision': 'outcode-only', 'count': 2762}]\n" + ] + } + ], + "source": [ + "fa = pl.col(\"Total floor area (sqm)\")\n", + "beds0 = pl.col(\"Bedrooms\").fill_null(0).cast(pl.Float64)\n", + "# Beds-aware floor: a dwelling needs ~10 m² per bedroom (min 12 m²). Anything below this is a\n", + "# single-room dimension mis-parsed as the total (a 2-bed < 20 m² is physically impossible).\n", + "min_plausible_area = pl.max_horizontal(pl.lit(12.0), beds0 * 10.0)\n", + "\n", + "clean = clean.with_columns(\n", + " (fa.is_not_null() & (fa < min_plausible_area)).alias(\"floor_area_suspect_small\"),\n", + " (fa.is_not_null() & (fa > 400)).alias(\"floor_area_suspect_large\"),\n", + ")\n", + "# Null impossibly-small areas (single-room dims parsed as total).\n", + "clean = clean.with_columns(\n", + " pl.when(pl.col(\"floor_area_suspect_small\")).then(None).otherwise(fa).alias(\"Total floor area (sqm)\")\n", + ")\n", + "\n", + "# Canonical sub-types + residential / build-type flags.\n", + "clean = clean.with_columns(pl.col(\"Property sub-type\").replace(SUBTYPE_CANON).alias(\"Property sub-type\"))\n", + "clean = clean.with_columns(\n", + " (~pl.col(\"Property sub-type\").is_in(NONRES_SUBTYPES)).alias(\"is_residential\"),\n", + " (pl.col(\"Property sub-type\") == \"House\").alias(\"build_type_unspecified\"), # was wrongly forced to 'Detached'\n", + ")\n", + "\n", + "# Sentinel 0 -> null for beds/baths on real dwellings (keep 0 for genuine Studios).\n", + "is_dwelling = pl.col(\"Property type\").is_in(RESIDENTIAL_TYPES)\n", + "is_studio = pl.col(\"Property sub-type\") == \"Studio\"\n", + "clean = clean.with_columns(\n", + " pl.when((pl.col(\"Bedrooms\") == 0) & is_dwelling & ~is_studio).then(None)\n", + " .otherwise(pl.col(\"Bedrooms\")).alias(\"Bedrooms\"),\n", + " pl.when((pl.col(\"Bathrooms\") == 0) & is_dwelling).then(None)\n", + " .otherwise(pl.col(\"Bathrooms\")).alias(\"Bathrooms\"),\n", + ")\n", + "\n", + "# Recompute £/sqm from cleaned area; undefined for shared-ownership (share price) & missing inputs.\n", + "clean = clean.with_columns(\n", + " pl.when(\n", + " pl.col(\"Total floor area (sqm)\").is_not_null()\n", + " & pl.col(\"Asking price\").is_not_null()\n", + " & ~pl.col(\"is_shared_ownership\")\n", + " )\n", + " .then((pl.col(\"Asking price\") / pl.col(\"Total floor area (sqm)\")).round().cast(pl.Int64))\n", + " .otherwise(None)\n", + " .alias(\"Asking price per sqm\")\n", + ")\n", + "\n", + "# Positional-confidence tier; strip the fabricated unit from outcode-only postcodes.\n", + "clean = clean.with_columns(\n", + " pl.col(\"Postcode source\").replace_strict(PRECISION, default=\"unknown\").alias(\"location_precision\")\n", + ")\n", + "clean = clean.with_columns(\n", + " pl.when(pl.col(\"location_precision\") == \"outcode-only\")\n", + " .then(pl.col(\"Postcode\").str.split(\" \").list.first())\n", + " .otherwise(pl.col(\"Postcode\"))\n", + " .alias(\"Postcode\")\n", + ")\n", + "print(f\"Step B done. clean shape: {clean.shape}\")\n", + "print(\"location_precision:\", clean[\"location_precision\"].value_counts(sort=True).to_dicts())" + ] + }, + { + "cell_type": "markdown", + "id": "33e81cf5", + "metadata": {}, + "source": [ + "**Step C** — de-duplicate to one row per physical property. Heuristic key: `(lat, lon, Asking price, Bedrooms)`, keeping the most-recently-listed row. This collapses both cross-portal and intra-portal duplicates; `raw` is retained for provenance." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ac920525", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.784400Z", + "iopub.status.busy": "2026-06-03T07:34:31.784318Z", + "iopub.status.idle": "2026-06-03T07:34:31.829788Z", + "shell.execute_reply": "2026-06-03T07:34:31.829419Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "De-duplicated: 112,605 -> 106,511 rows (removed 6,094, 5.4%)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (3, 3)
providercleandedup
stru32u32
"OnTheMarket"127719949
"Rightmove"9279791064
"Zoopla"70375498
" + ], + "text/plain": [ + "shape: (3, 3)\n", + "┌─────────────┬───────┬───────┐\n", + "│ provider ┆ clean ┆ dedup │\n", + "│ --- ┆ --- ┆ --- │\n", + "│ str ┆ u32 ┆ u32 │\n", + "╞═════════════╪═══════╪═══════╡\n", + "│ OnTheMarket ┆ 12771 ┆ 9949 │\n", + "│ Rightmove ┆ 92797 ┆ 91064 │\n", + "│ Zoopla ┆ 7037 ┆ 5498 │\n", + "└─────────────┴───────┴───────┘" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "dedup = (\n", + " clean\n", + " .sort(\"Listing date\", descending=True, nulls_last=True)\n", + " .unique(subset=[\"lat\", \"lon\", \"Asking price\", \"Bedrooms\"], keep=\"first\", maintain_order=True)\n", + ")\n", + "removed = clean.height - dedup.height\n", + "print(f\"De-duplicated: {clean.height:,} -> {dedup.height:,} rows (removed {removed:,}, {removed / clean.height:.1%})\")\n", + "display(\n", + " pl.concat([\n", + " clean.group_by(\"provider\").len().rename({\"len\": \"clean\"}),\n", + " dedup.group_by(\"provider\").len().rename({\"len\": \"dedup\"}),\n", + " ], how=\"align\").sort(\"provider\")\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "7d17dd05", + "metadata": {}, + "source": [ + "## 5 · After cleanup — re-show the stats" + ] + }, + { + "cell_type": "markdown", + "id": "b10a9386", + "metadata": {}, + "source": [ + "**Before / after summary** of the headline issues:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "df564e2b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.830811Z", + "iopub.status.busy": "2026-06-03T07:34:31.830735Z", + "iopub.status.idle": "2026-06-03T07:34:31.932037Z", + "shell.execute_reply": "2026-06-03T07:34:31.931618Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (3, 9)
datasetrowsimpossible_tiny_areapqualifier_casing_dupesempty_string_cellsmax_£/sqmsubtype_distinctzero_bed_non_studiodead_columns
stri64i64i64i64i64i64i64i64
"raw"112605896750204109597940882
"clean"112605000212963639340
"dedup"106511000212963637110
" + ], + "text/plain": [ + "shape: (3, 9)\n", + "┌─────────┬────────┬───────────┬───────────┬───────────┬──────────┬──────────┬──────────┬──────────┐\n", + "│ dataset ┆ rows ┆ impossibl ┆ pqualifie ┆ empty_str ┆ max_£/sq ┆ subtype_ ┆ zero_bed ┆ dead_col │\n", + "│ --- ┆ --- ┆ e_tiny_ar ┆ r_casing_ ┆ ing_cells ┆ m ┆ distinct ┆ _non_stu ┆ umns │\n", + "│ str ┆ i64 ┆ ea ┆ dupes ┆ --- ┆ --- ┆ --- ┆ dio ┆ --- │\n", + "│ ┆ ┆ --- ┆ --- ┆ i64 ┆ i64 ┆ i64 ┆ --- ┆ i64 │\n", + "│ ┆ ┆ i64 ┆ i64 ┆ ┆ ┆ ┆ i64 ┆ │\n", + "╞═════════╪════════╪═══════════╪═══════════╪═══════════╪══════════╪══════════╪══════════╪══════════╡\n", + "│ raw ┆ 112605 ┆ 89 ┆ 6 ┆ 75020 ┆ 410959 ┆ 79 ┆ 4088 ┆ 2 │\n", + "│ clean ┆ 112605 ┆ 0 ┆ 0 ┆ 0 ┆ 212963 ┆ 63 ┆ 934 ┆ 0 │\n", + "│ dedup ┆ 106511 ┆ 0 ┆ 0 ┆ 0 ┆ 212963 ┆ 63 ┆ 711 ┆ 0 │\n", + "└─────────┴────────┴───────────┴───────────┴───────────┴──────────┴──────────┴──────────┴──────────┘" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def diagnostics(d: pl.DataFrame, label: str) -> dict:\n", + " fa = pl.col(\"Total floor area (sqm)\")\n", + " pq_casing = (\n", + " d.filter(pl.col(\"Price qualifier\").is_not_null())\n", + " .with_columns(pl.col(\"Price qualifier\").str.to_lowercase().alias(\"n\"))\n", + " .group_by(\"n\").agg(pl.col(\"Price qualifier\").n_unique().alias(\"v\"))\n", + " .filter(pl.col(\"v\") > 1).height\n", + " )\n", + " empties = sum(\n", + " d.select((pl.col(c).str.strip_chars() == \"\").sum()).item()\n", + " for c in d.columns if d.schema[c] == pl.String\n", + " )\n", + " return {\n", + " \"dataset\": label,\n", + " \"rows\": d.height,\n", + " \"impossible_tiny_area\": d.filter(fa.is_not_null() & (fa < 20) & (pl.col(\"Bedrooms\").fill_null(0) >= 2)).height,\n", + " \"pqualifier_casing_dupes\": pq_casing,\n", + " \"empty_string_cells\": int(empties),\n", + " \"max_£/sqm\": d[\"Asking price per sqm\"].max(),\n", + " \"subtype_distinct\": d[\"Property sub-type\"].n_unique(),\n", + " \"zero_bed_non_studio\": d.filter((pl.col(\"Bedrooms\") == 0) & (pl.col(\"Property sub-type\") != \"Studio\")).height,\n", + " \"dead_columns\": int(\"Listing status\" in d.columns) + int(\"Number of bedrooms & living rooms\" in d.columns),\n", + " }\n", + "\n", + "pl.DataFrame([diagnostics(raw, \"raw\"), diagnostics(clean, \"clean\"), diagnostics(dedup, \"dedup\")])" + ] + }, + { + "cell_type": "markdown", + "id": "8afe0492", + "metadata": {}, + "source": [ + "**Schema after cleanup** (dropped 2 mislabeled/dead columns; added flags & provenance):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0688cda7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.933084Z", + "iopub.status.busy": "2026-06-03T07:34:31.933005Z", + "iopub.status.idle": "2026-06-03T07:34:31.935851Z", + "shell.execute_reply": "2026-06-03T07:34:31.935570Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Added columns : ['is_shared_ownership', 'price_basis', 'floor_area_suspect_small', 'floor_area_suspect_large', 'is_residential', 'build_type_unspecified', 'location_precision']\n", + "Dropped columns: ['Number of bedrooms & living rooms', 'Listing status']\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (30, 2)
columndtype
strstr
"Bedrooms""Int32"
"Bathrooms""Int32"
"lon""Float64"
"lat""Float64"
"Postcode""String"
"Postcode source""String"
"Extracted postcode""String"
"Inferred postcode""String"
"Listing raw address""String"
"Address per Property Register""String"
"UPRN""String"
"Property number or name""String"
"Leasehold/Freehold""String"
"Property type""String"
"Property sub-type""String"
"Price qualifier""String"
"Total floor area (sqm)""Float64"
"Listing URL""String"
"Listing features""List(String)"
"Listing date""Datetime(time_unit='us', time_zone=None)"
"Asking price""Int64"
"Asking price per sqm""Int64"
"provider""String"
"is_shared_ownership""Boolean"
"price_basis""String"
"floor_area_suspect_small""Boolean"
"floor_area_suspect_large""Boolean"
"is_residential""Boolean"
"build_type_unspecified""Boolean"
"location_precision""String"
" + ], + "text/plain": [ + "shape: (30, 2)\n", + "┌───────────────────────────────┬──────────────────────────────────────────┐\n", + "│ column ┆ dtype │\n", + "│ --- ┆ --- │\n", + "│ str ┆ str │\n", + "╞═══════════════════════════════╪══════════════════════════════════════════╡\n", + "│ Bedrooms ┆ Int32 │\n", + "│ Bathrooms ┆ Int32 │\n", + "│ lon ┆ Float64 │\n", + "│ lat ┆ Float64 │\n", + "│ Postcode ┆ String │\n", + "│ Postcode source ┆ String │\n", + "│ Extracted postcode ┆ String │\n", + "│ Inferred postcode ┆ String │\n", + "│ Listing raw address ┆ String │\n", + "│ Address per Property Register ┆ String │\n", + "│ UPRN ┆ String │\n", + "│ Property number or name ┆ String │\n", + "│ Leasehold/Freehold ┆ String │\n", + "│ Property type ┆ String │\n", + "│ Property sub-type ┆ String │\n", + "│ Price qualifier ┆ String │\n", + "│ Total floor area (sqm) ┆ Float64 │\n", + "│ Listing URL ┆ String │\n", + "│ Listing features ┆ List(String) │\n", + "│ Listing date ┆ Datetime(time_unit='us', time_zone=None) │\n", + "│ Asking price ┆ Int64 │\n", + "│ Asking price per sqm ┆ Int64 │\n", + "│ provider ┆ String │\n", + "│ is_shared_ownership ┆ Boolean │\n", + "│ price_basis ┆ String │\n", + "│ floor_area_suspect_small ┆ Boolean │\n", + "│ floor_area_suspect_large ┆ Boolean │\n", + "│ is_residential ┆ Boolean │\n", + "│ build_type_unspecified ┆ Boolean │\n", + "│ location_precision ┆ String │\n", + "└───────────────────────────────┴──────────────────────────────────────────┘" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "added = [c for c in clean.columns if c not in raw.columns]\n", + "dropped = [c for c in raw.columns if c not in clean.columns]\n", + "print(\"Added columns :\", added)\n", + "print(\"Dropped columns:\", dropped)\n", + "pl.DataFrame({\"column\": clean.columns, \"dtype\": [str(t) for t in clean.dtypes]})" + ] + }, + { + "cell_type": "markdown", + "id": "0a16d168", + "metadata": {}, + "source": [ + "**Missingness after cleanup** — beds/baths now legitimately null where unknown; empty-strings gone; per-sqm follows cleaned area:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cfe2fc79", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.936668Z", + "iopub.status.busy": "2026-06-03T07:34:31.936597Z", + "iopub.status.idle": "2026-06-03T07:34:31.950929Z", + "shell.execute_reply": "2026-06-03T07:34:31.950552Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (3, 30)
providerBedroomsBathroomslonlatPostcodePostcode sourceExtracted postcodeInferred postcodeListing raw addressAddress per Property RegisterUPRNProperty number or nameLeasehold/FreeholdProperty typeProperty sub-typePrice qualifierTotal floor area (sqm)Listing URLListing featuresListing dateAsking priceAsking price per sqmis_shared_ownershipprice_basisfloor_area_suspect_smallfloor_area_suspect_largeis_residentialbuild_type_unspecifiedlocation_precision
strf64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64f64
"OnTheMarket"0.25.10.00.00.00.095.90.00.00.1100.0100.011.20.00.060.987.00.00.0100.00.187.50.00.00.00.00.00.00.0
"Rightmove"0.82.40.00.00.00.095.10.00.00.0100.0100.014.20.00.058.846.90.00.00.00.047.10.00.00.00.00.00.00.0
"Zoopla"33.736.10.00.00.00.0100.00.040.140.270.761.550.20.00.0100.017.00.00.0100.04.917.00.00.00.00.00.00.00.0
" + ], + "text/plain": [ + "shape: (3, 30)\n", + "┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐\n", + "│ pro ┆ Bed ┆ Bat ┆ lon ┆ lat ┆ Pos ┆ Pos ┆ Ext ┆ Inf ┆ Lis ┆ Add ┆ UPR ┆ Pro ┆ Lea ┆ Pro ┆ Pro ┆ Pri ┆ Tot ┆ Lis ┆ Lis ┆ Lis ┆ Ask ┆ Ask ┆ is_ ┆ pri ┆ flo ┆ flo ┆ is_ ┆ bui ┆ loc │\n", + "│ vid ┆ roo ┆ hro ┆ --- ┆ --- ┆ tco ┆ tco ┆ rac ┆ err ┆ tin ┆ res ┆ N ┆ per ┆ seh ┆ per ┆ per ┆ ce ┆ al ┆ tin ┆ tin ┆ tin ┆ ing ┆ ing ┆ sha ┆ ce_ ┆ or_ ┆ or_ ┆ res ┆ ld_ ┆ ati │\n", + "│ er ┆ ms ┆ oms ┆ f64 ┆ f64 ┆ de ┆ de ┆ ted ┆ ed ┆ g ┆ s ┆ --- ┆ ty ┆ old ┆ ty ┆ ty ┆ qua ┆ flo ┆ g ┆ g ┆ g ┆ pri ┆ pri ┆ red ┆ bas ┆ are ┆ are ┆ ide ┆ typ ┆ on_ │\n", + "│ --- ┆ --- ┆ --- ┆ ┆ ┆ --- ┆ sou ┆ pos ┆ pos ┆ raw ┆ per ┆ f64 ┆ num ┆ /Fr ┆ typ ┆ sub ┆ lif ┆ or ┆ URL ┆ fea ┆ dat ┆ ce ┆ ce ┆ _ow ┆ is ┆ a_s ┆ a_s ┆ nti ┆ e_u ┆ pre │\n", + "│ str ┆ f64 ┆ f64 ┆ ┆ ┆ f64 ┆ rce ┆ tco ┆ tco ┆ add ┆ Pro ┆ ┆ ber ┆ eeh ┆ e ┆ -ty ┆ ier ┆ are ┆ --- ┆ tur ┆ e ┆ --- ┆ per ┆ ner ┆ --- ┆ usp ┆ usp ┆ al ┆ nsp ┆ cis │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ --- ┆ de ┆ de ┆ res ┆ per ┆ ┆ or ┆ old ┆ --- ┆ pe ┆ --- ┆ a ┆ f64 ┆ es ┆ --- ┆ f64 ┆ sqm ┆ shi ┆ f64 ┆ ect ┆ ect ┆ --- ┆ eci ┆ ion │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ --- ┆ --- ┆ s ┆ ty ┆ ┆ nam ┆ --- ┆ f64 ┆ --- ┆ f64 ┆ (sq ┆ ┆ --- ┆ f64 ┆ ┆ --- ┆ p ┆ ┆ _sm ┆ _la ┆ f64 ┆ fie ┆ --- │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ f64 ┆ --- ┆ Reg ┆ ┆ e ┆ f64 ┆ ┆ f64 ┆ ┆ m) ┆ ┆ f64 ┆ ┆ ┆ f64 ┆ --- ┆ ┆ all ┆ rge ┆ ┆ d ┆ f64 │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ ist ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ ┆ --- ┆ --- ┆ ┆ --- ┆ │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ er ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ f64 ┆ ┆ f64 ┆ │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ --- ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ f64 ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "╞═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╪═════╡\n", + "│ OnT ┆ 0.2 ┆ 5.1 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 95. ┆ 0.0 ┆ 0.0 ┆ 0.1 ┆ 100 ┆ 100 ┆ 11. ┆ 0.0 ┆ 0.0 ┆ 60. ┆ 87. ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 0.1 ┆ 87. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │\n", + "│ heM ┆ ┆ ┆ ┆ ┆ ┆ ┆ 9 ┆ ┆ ┆ ┆ .0 ┆ .0 ┆ 2 ┆ ┆ ┆ 9 ┆ 0 ┆ ┆ ┆ .0 ┆ ┆ 5 ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ark ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ et ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ Rig ┆ 0.8 ┆ 2.4 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 95. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 100 ┆ 14. ┆ 0.0 ┆ 0.0 ┆ 58. ┆ 46. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 47. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │\n", + "│ htm ┆ ┆ ┆ ┆ ┆ ┆ ┆ 1 ┆ ┆ ┆ ┆ .0 ┆ .0 ┆ 2 ┆ ┆ ┆ 8 ┆ 9 ┆ ┆ ┆ ┆ ┆ 1 ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ ove ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "│ Zoo ┆ 33. ┆ 36. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 0.0 ┆ 40. ┆ 40. ┆ 70. ┆ 61. ┆ 50. ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 17. ┆ 0.0 ┆ 0.0 ┆ 100 ┆ 4.9 ┆ 17. ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 ┆ 0.0 │\n", + "│ pla ┆ 7 ┆ 1 ┆ ┆ ┆ ┆ ┆ .0 ┆ ┆ 1 ┆ 2 ┆ 7 ┆ 5 ┆ 2 ┆ ┆ ┆ .0 ┆ 0 ┆ ┆ ┆ .0 ┆ ┆ 0 ┆ ┆ ┆ ┆ ┆ ┆ ┆ │\n", + "└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "value_cols_c = [c for c in clean.columns if c != \"provider\"]\n", + "miss_c = (\n", + " clean.group_by(\"provider\")\n", + " .agg([missing_count(c, clean.schema[c]) for c in value_cols_c] + [pl.len().alias(\"__rows\")])\n", + " .sort(\"provider\")\n", + ")\n", + "miss_c.select([\"provider\"] + [(pl.col(c) / pl.col(\"__rows\") * 100).round(1).alias(c) for c in value_cols_c])" + ] + }, + { + "cell_type": "markdown", + "id": "9f0b901b", + "metadata": {}, + "source": [ + "**Price / floor-area sanity restored** (firm-price view excludes shared-ownership):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ad59e2d6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.952084Z", + "iopub.status.busy": "2026-06-03T07:34:31.951936Z", + "iopub.status.idle": "2026-06-03T07:34:31.967287Z", + "shell.execute_reply": "2026-06-03T07:34:31.966952Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Residential, non-shared rows: 109,662\n", + "Impossible tiny areas remaining (area<20 & beds>=2): 0\n", + "Max £/sqm raw 410,959 -> clean 212,963\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "shape: (9, 4)
statisticAsking priceTotal floor area (sqm)Asking price per sqm
strf64f64f64
"count"109645.056075.056075.0
"null_count"17.053587.053587.0
"mean"924734.079256113.3916779186.075381
"std"1.5915e6106.0752796356.803435
"min"500.012.435.0
"25%"375000.060.05644.0
"50%"550000.082.07317.0
"75%"875000.0126.110496.0
"max"1e81893.1212963.0
" + ], + "text/plain": [ + "shape: (9, 4)\n", + "┌────────────┬───────────────┬────────────────────────┬──────────────────────┐\n", + "│ statistic ┆ Asking price ┆ Total floor area (sqm) ┆ Asking price per sqm │\n", + "│ --- ┆ --- ┆ --- ┆ --- │\n", + "│ str ┆ f64 ┆ f64 ┆ f64 │\n", + "╞════════════╪═══════════════╪════════════════════════╪══════════════════════╡\n", + "│ count ┆ 109645.0 ┆ 56075.0 ┆ 56075.0 │\n", + "│ null_count ┆ 17.0 ┆ 53587.0 ┆ 53587.0 │\n", + "│ mean ┆ 924734.079256 ┆ 113.391677 ┆ 9186.075381 │\n", + "│ std ┆ 1.5915e6 ┆ 106.075279 ┆ 6356.803435 │\n", + "│ min ┆ 500.0 ┆ 12.4 ┆ 35.0 │\n", + "│ 25% ┆ 375000.0 ┆ 60.0 ┆ 5644.0 │\n", + "│ 50% ┆ 550000.0 ┆ 82.0 ┆ 7317.0 │\n", + "│ 75% ┆ 875000.0 ┆ 126.1 ┆ 10496.0 │\n", + "│ max ┆ 1e8 ┆ 1893.1 ┆ 212963.0 │\n", + "└────────────┴───────────────┴────────────────────────┴──────────────────────┘" ] }, "metadata": {}, @@ -296,35 +1872,23 @@ " white-space: pre-wrap;\n", "}\n", "\n", - "shape: (19, 5)
providercolumnna_countprovider_rowsna_pct
strstru32u32f64
"OnTheMarket""Listing date"8585100.0
"OnTheMarket""Property number or name"8585100.0
"OnTheMarket""UPRN"8585100.0
"OnTheMarket""Extracted postcode"848598.82
"OnTheMarket""Asking price per sqm"828596.47
"OnTheMarket""Total floor area (sqm)"828596.47
"Rightmove""Property number or name"9999100.0
"Rightmove""UPRN"9999100.0
"Rightmove""Extracted postcode"989998.99
"Rightmove""Asking price per sqm"349934.34
"Rightmove""Total floor area (sqm)"349934.34
"Rightmove""Leasehold/Freehold"179917.17
"Zoopla""Extracted postcode"5858100.0
"Zoopla""Listing date"5858100.0
"Zoopla""UPRN"255843.1
"Zoopla""Leasehold/Freehold"145824.14
"Zoopla""Property number or name"115818.97
"Zoopla""Asking price per sqm"5588.62
"Zoopla""Total floor area (sqm)"5588.62
" + "shape: (7, 3)
price_basisrowsmedian_price
stru32f64
"firm"69358575000.0
"auction_guide"23851575000.0
"floor"17258500000.0
"share"1404146250.0
"other"494452500.0
"from"221632500.0
"unpriced"19null
" ], "text/plain": [ - "shape: (19, 5)\n", - "┌─────────────┬─────────────────────────┬──────────┬───────────────┬────────┐\n", - "│ provider ┆ column ┆ na_count ┆ provider_rows ┆ na_pct │\n", - "│ --- ┆ --- ┆ --- ┆ --- ┆ --- │\n", - "│ str ┆ str ┆ u32 ┆ u32 ┆ f64 │\n", - "╞═════════════╪═════════════════════════╪══════════╪═══════════════╪════════╡\n", - "│ OnTheMarket ┆ Listing date ┆ 85 ┆ 85 ┆ 100.0 │\n", - "│ OnTheMarket ┆ Property number or name ┆ 85 ┆ 85 ┆ 100.0 │\n", - "│ OnTheMarket ┆ UPRN ┆ 85 ┆ 85 ┆ 100.0 │\n", - "│ OnTheMarket ┆ Extracted postcode ┆ 84 ┆ 85 ┆ 98.82 │\n", - "│ OnTheMarket ┆ Asking price per sqm ┆ 82 ┆ 85 ┆ 96.47 │\n", - "│ OnTheMarket ┆ Total floor area (sqm) ┆ 82 ┆ 85 ┆ 96.47 │\n", - "│ Rightmove ┆ Property number or name ┆ 99 ┆ 99 ┆ 100.0 │\n", - "│ Rightmove ┆ UPRN ┆ 99 ┆ 99 ┆ 100.0 │\n", - "│ Rightmove ┆ Extracted postcode ┆ 98 ┆ 99 ┆ 98.99 │\n", - "│ Rightmove ┆ Asking price per sqm ┆ 34 ┆ 99 ┆ 34.34 │\n", - "│ Rightmove ┆ Total floor area (sqm) ┆ 34 ┆ 99 ┆ 34.34 │\n", - "│ Rightmove ┆ Leasehold/Freehold ┆ 17 ┆ 99 ┆ 17.17 │\n", - "│ Zoopla ┆ Extracted postcode ┆ 58 ┆ 58 ┆ 100.0 │\n", - "│ Zoopla ┆ Listing date ┆ 58 ┆ 58 ┆ 100.0 │\n", - "│ Zoopla ┆ UPRN ┆ 25 ┆ 58 ┆ 43.1 │\n", - "│ Zoopla ┆ Leasehold/Freehold ┆ 14 ┆ 58 ┆ 24.14 │\n", - "│ Zoopla ┆ Property number or name ┆ 11 ┆ 58 ┆ 18.97 │\n", - "│ Zoopla ┆ Asking price per sqm ┆ 5 ┆ 58 ┆ 8.62 │\n", - "│ Zoopla ┆ Total floor area (sqm) ┆ 5 ┆ 58 ┆ 8.62 │\n", - "└─────────────┴─────────────────────────┴──────────┴───────────────┴────────┘" + "shape: (7, 3)\n", + "┌───────────────┬───────┬──────────────┐\n", + "│ price_basis ┆ rows ┆ median_price │\n", + "│ --- ┆ --- ┆ --- │\n", + "│ str ┆ u32 ┆ f64 │\n", + "╞═══════════════╪═══════╪══════════════╡\n", + "│ firm ┆ 69358 ┆ 575000.0 │\n", + "│ auction_guide ┆ 23851 ┆ 575000.0 │\n", + "│ floor ┆ 17258 ┆ 500000.0 │\n", + "│ share ┆ 1404 ┆ 146250.0 │\n", + "│ other ┆ 494 ┆ 452500.0 │\n", + "│ from ┆ 221 ┆ 632500.0 │\n", + "│ unpriced ┆ 19 ┆ null │\n", + "└───────────────┴───────┴──────────────┘" ] }, "metadata": {}, @@ -332,54 +1896,37 @@ } ], "source": [ - "def missing_expr(column: str, dtype: pl.DataType) -> pl.Expr:\n", - " missing = pl.col(column).is_null()\n", - " if dtype in (pl.Float32, pl.Float64):\n", - " missing = missing | pl.col(column).is_nan().fill_null(False)\n", - " return missing.sum().alias(column)\n", + "firm = clean.filter(~pl.col(\"is_shared_ownership\") & pl.col(\"is_residential\"))\n", + "print(f\"Residential, non-shared rows: {firm.height:,}\")\n", + "print(f\"Impossible tiny areas remaining (area<20 & beds>=2): \"\n", + " f\"{clean.filter((pl.col('Total floor area (sqm)') < 20) & (pl.col('Bedrooms').fill_null(0) >= 2)).height}\")\n", + "print(f\"Max £/sqm raw {raw['Asking price per sqm'].max():,} -> clean {clean['Asking price per sqm'].max():,}\")\n", + "display(firm.select(\"Asking price\", \"Total floor area (sqm)\", \"Asking price per sqm\").describe())\n", "\n", - "\n", - "value_columns = [column for column in df.columns if column != \"provider\"]\n", - "missing_by_provider = (\n", - " df.group_by(\"provider\")\n", - " .agg([missing_expr(column, df.schema[column]) for column in value_columns])\n", - " .sort(\"provider\")\n", - ")\n", - "\n", - "missing_long = (\n", - " missing_by_provider.unpivot(\n", - " index=\"provider\",\n", - " variable_name=\"column\",\n", - " value_name=\"na_count\",\n", - " )\n", - " .join(df.group_by(\"provider\").len().rename({\"len\": \"provider_rows\"}), on=\"provider\")\n", - " .filter(pl.col('na_count') > 0)\n", - " .with_columns((pl.col(\"na_count\") / pl.col(\"provider_rows\") * 100).round(2).alias(\"na_pct\"))\n", - " .sort([\"provider\", \"na_count\", \"column\"], descending=[False, True, False])\n", - ")\n", - "\n", - "display(missing_by_provider)\n", - "display(missing_long)" + "# price_basis composition\n", + "display(clean.group_by(\"price_basis\").agg(\n", + " pl.len().alias(\"rows\"), pl.col(\"Asking price\").median().alias(\"median_price\")\n", + ").sort(\"rows\", descending=True))" ] }, { "cell_type": "markdown", - "id": "24de8b4c", + "id": "e833c578", "metadata": {}, "source": [ - "## Map" + "**Categoricals normalised** — sub-type variants collapsed, qualifier casing merged:" ] }, { "cell_type": "code", - "execution_count": 4, - "id": "cadef70a", + "execution_count": null, + "id": "6a20db99", "metadata": { "execution": { - "iopub.execute_input": "2026-05-17T10:23:48.014374Z", - "iopub.status.busy": "2026-05-17T10:23:48.014290Z", - "iopub.status.idle": "2026-05-17T10:23:49.319476Z", - "shell.execute_reply": "2026-05-17T10:23:49.319211Z" + "iopub.execute_input": "2026-06-03T07:34:31.968094Z", + "iopub.status.busy": "2026-06-03T07:34:31.968011Z", + "iopub.status.idle": "2026-06-03T07:34:31.981256Z", + "shell.execute_reply": "2026-06-03T07:34:31.980922Z" } }, "outputs": [ @@ -387,3640 +1934,142 @@ "name": "stdout", "output_type": "stream", "text": [ - "Mapped rows: 242\n" + "Property sub-type distinct: raw 79 -> clean 63\n", + "Price-qualifier casing-duplicate buckets remaining: 0\n" ] }, { "data": { - "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, - "data": [ - { - "customdata": [ - [ - "Stockwell Road, London", - 310000, - 1, - 1, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/89076063#/?channel=RES_BUY", - 51.471079, - -0.121569 - ], - [ - "Stockwell Park Crescent II, London", - 2595000, - 4, - 5, - "Semi-Detached", - "https://www.rightmove.co.uk/properties/174610352#/?channel=RES_BUY", - 51.47115, - -0.11817 - ], - [ - "Groveway, Stockwell", - 2295000, - 3, - 6, - "Detached", - "https://www.rightmove.co.uk/properties/174282005#/?channel=RES_BUY", - 51.473247, - -0.114727 - ], - [ - "Stockwell Park Road, London", - 2250000, - 2, - 4, - "Semi-Detached", - "https://www.rightmove.co.uk/properties/170781485#/?channel=RES_BUY", - 51.472141, - -0.116603 - ], - [ - "St Johns Buildings, London", - 2100000, - 2, - 2, - "Terraced", - "https://www.rightmove.co.uk/properties/174206714#/?channel=RES_BUY", - 51.46428, - -0.11247 - ], - [ - "Stockwell Park Road, London", - 2100000, - 3, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/88435821#/?channel=RES_BUY", - 51.47089, - -0.116765 - ], - [ - "Crewdson Road, London", - 1900000, - 3, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/174798002#/?channel=RES_BUY", - 51.478768, - -0.113111 - ], - [ - "Stockwell Park Crescent, Stockwell, London", - 1695000, - 3, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/88376967#/?channel=RES_BUY", - 51.47091, - -0.11805 - ], - [ - "Stockwell Park Road, London", - 1695000, - 3, - 5, - "Semi-Detached", - "https://www.rightmove.co.uk/properties/167893637#/?channel=RES_BUY", - 51.474381, - -0.119192 - ], - [ - "Stockwell Park Crescent, Stockwell", - 1695000, - 2, - 2, - "Semi-Detached", - "https://www.rightmove.co.uk/properties/173668991#/?channel=RES_BUY", - 51.470774, - -0.11778 - ], - [ - "Hargwyne Street, London", - 1650000, - 3, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/174050450#/?channel=RES_BUY", - 51.465546, - -0.121041 - ], - [ - "Burnley Road, London", - 1595000, - 2, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/88367181#/?channel=RES_BUY", - 51.470986, - -0.119194 - ], - [ - "Clapham Road, Stockwell", - 1550000, - 3, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/172178081#/?channel=RES_BUY", - 51.475262, - -0.118914 - ], - [ - "Brixton Road, Brixton High Street", - 1550000, - 1, - 8, - "Terraced", - "https://www.rightmove.co.uk/properties/88139847#/?channel=RES_BUY", - 51.465359, - -0.114329 - ], - [ - "Trinity Gardens, LONDON", - 1500000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/174274454#/?channel=RES_BUY", - 51.461726, - -0.118903 - ], - [ - "St. Michael's Road, London", - 1495000, - 3, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/88502766#/?channel=RES_BUY", - 51.471604, - -0.119671 - ], - [ - "Lorn Road, Stockwell", - 1475000, - 2, - 3, - "Semi-Detached", - "https://www.rightmove.co.uk/properties/172265342#/?channel=RES_BUY", - 51.472272, - -0.113846 - ], - [ - "Stockwell Green, Stockwell, London", - 1450000, - 2, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/173813414#/?channel=RES_BUY", - 51.4678, - -0.1199 - ], - [ - "Ferndale Road, London", - 1400000, - 2, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/87958638#/?channel=RES_BUY", - 51.46318, - -0.12043 - ], - [ - "Stirling Road, Stockwell", - 1365000, - 4, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/160476977#/?channel=RES_BUY", - 51.468018, - -0.126212 - ], - [ - "Dalyell Road, London", - 1350000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/174526448#/?channel=RES_BUY", - 51.467377, - -0.120725 - ], - [ - "Hargwyne Street, London", - 1350000, - 3, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/88118943#/?channel=RES_BUY", - 51.464718, - -0.120788 - ], - [ - "Sidney Road, Stockwell", - 1350000, - 2, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/173691437#/?channel=RES_BUY", - 51.469995, - -0.116842 - ], - [ - "Tasman Road, Clapham North", - 1300000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/88674900#/?channel=RES_BUY", - 51.464666, - -0.125111 - ], - [ - "Tasman Road, Clapham", - 1300000, - 2, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/166381670#/?channel=RES_BUY", - 51.464662, - -0.125115 - ], - [ - "Akerman Road, Brixton, London", - 950000, - 1, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/174585656#/?channel=RES_BUY", - 51.4723, - -0.10713 - ], - [ - "Kimberley Road, Clapham North", - 1295000, - 3, - 5, - "Detached", - "https://www.rightmove.co.uk/properties/170576258#/?channel=RES_BUY", - 51.467839, - -0.122975 - ], - [ - "Clapham Road, London", - 1250000, - 0, - 7, - "Detached", - "https://www.rightmove.co.uk/properties/172845386#/?channel=RES_BUY", - 51.47241, - -0.12187 - ], - [ - "Savoy Mews, Clapham, London", - 1250000, - 3, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/171967202#/?channel=RES_BUY", - 51.467846, - -0.129214 - ], - [ - "Kay Road, London", - 1225000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/159519872#/?channel=RES_BUY", - 51.46829, - -0.12247 - ], - [ - "Stirling Road, London", - 1200000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/174773513#/?channel=RES_BUY", - 51.467893, - -0.126016 - ], - [ - "Ferndale Road, London", - 1175000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/88182951#/?channel=RES_BUY", - 51.463223, - -0.11965 - ], - [ - "Grantham Road, Stockwell", - 1175000, - 3, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/87587604#/?channel=RES_BUY", - 51.468085, - -0.125018 - ], - [ - "Lothian Road, Oval", - 1150000, - 2, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/168423935#/?channel=RES_BUY", - 51.476299, - -0.102795 - ], - [ - "Clapham Place, 340A Clapham Road, London", - 1100000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/162737807#/?channel=RES_BUY", - 51.469463, - -0.126614 - ], - [ - "Archer Mews, Clapham", - 1100000, - 2, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/162027752#/?channel=RES_BUY", - 51.466859, - -0.127675 - ], - [ - "Hargwyne Street, Stockwell", - 1050000, - 3, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/88674894#/?channel=RES_BUY", - 51.46622, - -0.121542 - ], - [ - "Argyll Close", - 1050000, - 3, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/88123392#/?channel=RES_BUY", - 51.467369, - -0.120287 - ], - [ - "Trinity Gardens, London", - 1000000, - 1, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/166982441#/?channel=RES_BUY", - 51.461843, - -0.118482 - ], - [ - "Printers Road, London", - 1000000, - 3, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/88736202#/?channel=RES_BUY", - 51.475519, - -0.117579 - ], - [ - "Printers Road, London", - 1000000, - 3, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/171963581#/?channel=RES_BUY", - 51.475422, - -0.117396 - ], - [ - "Combermere Road", - 1000000, - 4, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/174512360#/?channel=RES_BUY", - 51.466953, - -0.118676 - ], - [ - "Chantrey Road, Brixton, London", - 1000000, - 2, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/87722028#/?channel=RES_BUY", - 51.46524, - -0.11785 - ], - [ - "Caldwell Street, Stockwell", - 995000, - 2, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/89089584#/?channel=RES_BUY", - 51.477238, - -0.116819 - ], - [ - "Offley Road, Oval, London", - 950000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/87978744#/?channel=RES_BUY", - 51.4801, - -0.11306 - ], - [ - "Clapham Road, London", - 950000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174253709#/?channel=RES_BUY", - 51.46955, - -0.12494 - ], - [ - "Burgoyne Road, Brixton, London", - 950000, - 1, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/88915212#/?channel=RES_BUY", - 51.46626, - -0.11924 - ], - [ - "Akerman Road, Brixton", - 950000, - 1, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/174043499#/?channel=RES_BUY", - 51.472355, - -0.107133 - ], - [ - "Trinity Gardens, Brixton, London", - 950000, - 1, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/87721950#/?channel=RES_BUY", - 51.46184, - -0.11885 - ], - [ - "Dalyell Road, Stockwell", - 950000, - 2, - 5, - "Terraced", - "https://www.rightmove.co.uk/properties/173248592#/?channel=RES_BUY", - 51.46484, - -0.11861 - ], - [ - "Cowley Road, London", - 500000, - 1, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174943268#/?channel=RES_BUY", - 51.472811, - -0.110448 - ], - [ - "Burgoyne Road, Brixton", - 950000, - 1, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/171244910#/?channel=RES_BUY", - 51.466514, - -0.119357 - ], - [ - "Kay Road", - 950000, - 1, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/88786803#/?channel=RES_BUY", - 51.468173, - -0.123544 - ], - [ - "Edithna Street, Clapham", - 949000, - 2, - 2, - "Terraced", - "https://www.rightmove.co.uk/properties/173407493#/?channel=RES_BUY", - 51.467536, - -0.124279 - ], - [ - "Landor Road, Stockwell", - 925000, - 3, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174241487#/?channel=RES_BUY", - 51.465761, - -0.127271 - ], - [ - "Cancell Road, Stockwell", - 925000, - 2, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/172063247#/?channel=RES_BUY", - 51.47675, - -0.10812 - ], - [ - "Combermere Road, Brixton", - 900000, - 1, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/174829754#/?channel=RES_BUY", - 51.466761, - -0.118967 - ], - [ - "Stockwell Green, Stockwell, London", - 900000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/87758454#/?channel=RES_BUY", - 51.46797, - -0.11961 - ], - [ - "Lingham Street, Stockwell", - 900000, - 2, - 2, - "Detached", - "https://www.rightmove.co.uk/properties/174459077#/?channel=RES_BUY", - 51.468202, - -0.121805 - ], - [ - "Dalyell Road, Clapham", - 900000, - 1, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/174751187#/?channel=RES_BUY", - 51.467378, - -0.120699 - ], - [ - "Hillyard Street, Stockwell, London", - 895000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/169772384#/?channel=RES_BUY", - 51.474009, - -0.113644 - ], - [ - "Fairbairn Green, Oval, London", - 850000, - 2, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/87977643#/?channel=RES_BUY", - 51.47451, - -0.10695 - ], - [ - "Tindal Street", - 850000, - 2, - 4, - "Terraced", - "https://www.rightmove.co.uk/properties/145850135#/?channel=RES_BUY", - 51.476302, - -0.104166 - ], - [ - "Hargwyne Street, Brixton", - 825000, - 1, - 3, - "Detached", - "https://www.rightmove.co.uk/properties/88033488#/?channel=RES_BUY", - 51.465427, - -0.121403 - ], - [ - "Gresham Road, London", - 825000, - 1, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173616209#/?channel=RES_BUY", - 51.463844, - -0.108902 - ], - [ - "Cancell Road, London", - 800000, - 1, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/167464373#/?channel=RES_BUY", - 51.476811, - -0.107076 - ], - [ - "Clapham Place, 340A Clapham Road, London", - 800000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/167067521#/?channel=RES_BUY", - 51.46931, - -0.126446 - ], - [ - "Kimberley Road, London", - 800000, - 1, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/166294310#/?channel=RES_BUY", - 51.46846, - -0.123807 - ], - [ - "Dalyell Road, London", - 800000, - 1, - 3, - "Terraced", - "https://www.rightmove.co.uk/properties/174671783#/?channel=RES_BUY", - 51.464537, - -0.1186 - ], - [ - "Vassall Road, London", - 780000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/169024502#/?channel=RES_BUY", - 51.477882, - -0.104541 - ], - [ - "Groveway, Stockwell", - 765000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173589443#/?channel=RES_BUY", - 51.473073, - -0.113506 - ], - [ - "Stane Grove, London", - 750000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/172439786#/?channel=RES_BUY", - 51.468326, - -0.128228 - ], - [ - "Robsart Street", - 750000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/89043525#/?channel=RES_BUY", - 51.470458, - -0.114754 - ], - [ - "Broomgrove Road, Stockwell", - 750000, - 2, - 4, - "Detached", - "https://www.rightmove.co.uk/properties/87785412#/?channel=RES_BUY", - 51.468367, - -0.117416 - ], - [ - "Landor Road, Clapham", - 525000, - 1, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88178850#/?channel=RES_BUY", - 51.465864, - -0.126485 - ], - [ - "Southey Road, London", - 750000, - 1, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88953351#/?channel=RES_BUY", - 51.476104, - -0.114177 - ], - [ - "Stirling Road, Clapham North", - 750000, - 1, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/172541237#/?channel=RES_BUY", - 51.467923, - -0.125572 - ], - [ - "Brixton Road, London", - 750000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88436790#/?channel=RES_BUY", - 51.473018, - -0.112407 - ], - [ - "Lorn Road, London", - 735000, - 1, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174008492#/?channel=RES_BUY", - 51.47212, - -0.11497 - ], - [ - "Tregothnan Road, Clapham", - 735000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174955157#/?channel=RES_BUY", - 51.467534, - -0.124866 - ], - [ - "Clapham Road", - 725000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173512085#/?channel=RES_BUY", - 51.465962, - -0.129116 - ], - [ - "Fairbairn Green, Oval, London", - 725000, - 1, - 4, - "Semi-Detached", - "https://www.rightmove.co.uk/properties/88014204#/?channel=RES_BUY", - 51.47451, - -0.10695 - ], - [ - "Minet Road, London", - 725000, - 1, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88412274#/?channel=RES_BUY", - 51.47043, - -0.10541 - ], - [ - "Stockwell Road", - 720000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88606029#/?channel=RES_BUY", - 51.465621, - -0.116586 - ], - [ - "Dundas Road, London", - 720000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/171205478#/?channel=RES_BUY", - 51.472982, - -0.109654 - ], - [ - "Tasman Road, Clapham North", - 700000, - 1, - 2, - "Terraced", - "https://www.rightmove.co.uk/properties/162538115#/?channel=RES_BUY", - 51.465724, - -0.125134 - ], - [ - "Hubert Grove, LONDON", - 700000, - 2, - 4, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174476441#/?channel=RES_BUY", - 51.464897, - -0.123706 - ], - [ - "Hackford Road, Oval, London", - 700000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88664931#/?channel=RES_BUY", - 51.47546, - -0.11555 - ], - [ - "Stockwell Park Road, Stockwell", - 699950, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/171128921#/?channel=RES_BUY", - 51.47055, - -0.116184 - ], - [ - "Brixton Road, Stockwell", - 699950, - 1, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88155525#/?channel=RES_BUY", - 51.473284, - -0.112372 - ], - [ - "Clapham Road, Clapham", - 699950, - 1, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88186095#/?channel=RES_BUY", - 51.468123, - -0.128106 - ], - [ - "Landor Road, Clapham, London", - 695000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173546969#/?channel=RES_BUY", - 51.466263, - -0.121044 - ], - [ - "Coal Lane", - 685000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173694455#/?channel=RES_BUY", - 51.463058, - -0.109573 - ], - [ - "Stockwell Park Walk", - 675000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173953706#/?channel=RES_BUY", - 51.46635, - -0.11388 - ], - [ - "Clapham Road, London", - 675000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88306662#/?channel=RES_BUY", - 51.468123, - -0.128106 - ], - [ - "Mayflower Road, Clapham, London", - 675000, - 1, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/174082844#/?channel=RES_BUY", - 51.467313, - -0.125793 - ], - [ - "Offley Road, Oval, Londobn", - 675000, - 2, - 2, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/88010451#/?channel=RES_BUY", - 51.480175, - -0.112983 - ], - [ - "St Lawrence Way, Brixton, London", - 675000, - 2, - 6, - "Terraced", - "https://www.rightmove.co.uk/properties/87975399#/?channel=RES_BUY", - 51.4715, - -0.10996 - ], - [ - "Kimberley Road, Clapham", - 670000, - 2, - 3, - "Flats/Maisonettes", - "https://www.rightmove.co.uk/properties/173861306#/?channel=RES_BUY", - 51.468039, - -0.123683 - ] - ], - "hovertemplate": "%{hovertext}

provider=Rightmove
Address per Property Register=%{customdata[0]}
Asking price=%{customdata[1]:,.0f}
Bathrooms=%{customdata[2]}
Bedrooms=%{customdata[3]}
Property type=%{customdata[4]}
Listing URL=%{customdata[5]}", - "hovertext": [ - "SW9 9EU", - "SW9 0DG", - "SW9 0AR", - "SW9 0DD", - "SW9 7QH", - "SW9 0DB", - "SW9 0LJ", - "SW9 0DG", - "SW9 0RU", - "SW9 0DG", - "SW9 9RF", - "SW9 0SW", - "SW9 0QQ", - "SW9 7AW", - "SW9 8DR", - "SW9 0SN", - "SW9 6LN", - "SW9 9HZ", - "SW9 8AH", - "SW9 9EF", - "SW9 9SD", - "SW9 9RQ", - "SW9 0TP", - "SW9 9LZ", - "SW9 9LZ", - "SW9 6TF", - "SW9 9DG", - "SW9 0QH", - "SW9 9AT", - "SW9 9DE", - "SW9 9EF", - "SW9 8AG", - "SW9 9ED", - "SW9 6UB", - "SW9 9FA", - "SW9 9BU", - "SW9 9RE", - "SW9 9QL", - "SW9 8DR", - "SW9 0BH", - "SW9 0BH", - "SW9 9QG", - "SW9 9TE", - "SW9 0HU", - "SW9 0LS", - "SW9 9BN", - "SW9 9QJ", - "SW9 6TF", - "SW9 8DR", - "SW9 9UT", - "SW9 6BW", - "SW9 9QS", - "SW9 9DH", - "SW9 9JR", - "SW9 9LN", - "SW9 6HP", - "SW9 9QD", - "SW9 9HX", - "SW9 9JL", - "SW9 9SD", - "SW9 0PL", - "SW9 7RR", - "SW9 6UP", - "SW9 9RF", - "SW9 7JE", - "SW9 6GA", - "SW9 9FA", - "SW9 9DH", - "SW9 9UU", - "SW9 6JA", - "SW9 6AR", - "SW4 6JL", - "SW9 0BP", - "SW9 9TL", - "SW9 9RT", - "SW9 0PD", - "SW9 9EE", - "SW9 6LW", - "SW9 0AD", - "SW9 9JX", - "SW9 9RX", - "SW9 7RR", - "SE5 9HY", - "SW9 9TB", - "SW9 6BZ", - "SW9 9PE", - "SW9 9PD", - "SW9 0RH", - "SW9 0DH", - "SW9 6LP", - "SW9 9FY", - "SW9 9RE", - "SW9 8FZ", - "SW9 0FF", - "SW9 9FY", - "SW9 9JY", - "SW9 0LS", - "SW9 6NP", - "SW9 9DH" - ], - "lat": { - "bdata": "jGoRUUy8SUBVwaikTrxJQA73kVuTvElA5QzFHW+8SUDsF+yGbbtJQLgBnx9GvElAiC8TRUi9SUAArmTHRrxJQAFsQIS4vElAT+eKUkK8SUBOZOYCl7tJQNtv7URJvElACoSdYtW8SUAb8zrikLtJQEDbatYZu0lA1lQWhV28SUCDaK1oc7xJQM6I0t7gu0lApRR0e0m7SUA8+IkD6LtJQIjzcALTu0lAZ+4h4Xu7SUBKJNHLKLxJQBXI7Cx6u0lA09heC3q7SUBN845TdLxJQIxl+iXiu0lA1SZO7ne8SUA/yLJg4rtJQJ2FPe3wu0lAf4P26uO7SUCk4CnkSrtJQMRfkzXqu0lACg+aXfe8SUBN1xNdF7xJQPBrJAnCu0lA3V7SGK27SUAFFVW/0rtJQHpx4qsdu0lANdB8zt28SUBD5sqg2rxJQHHiqx3Fu0lAQWX8+4y7SUBINIEiFr1JQKFns+pzvUlAHOviNhq8SUBrt11orrtJQBGN7iB2vElACf63kh27SUCz74rgf7tJQDMYIxKFvElAJZASu7a7SUBj78UX7btJQPHZOjjYu0lASmBzDp67SUAbL90kBr1JQC0GD9O+u0lAK8HicOa7SUD99QoL7rtJQFhv1ArTu0lAPYGwU6y8SUD/z2G+vLxJQHuCxHb3vElAc9anHJO7SUAROX09X7tJQMCvkSQIvUlAx9eeWRK8SUD5vU1/9rtJQBdky/J1u0lAbazEPCu9SUBwz/OnjbxJQOnuOhvyu0lAHxK+9ze8SUBIwylz87tJQB8xem6hu0lAVb/S+fC8SUDqBZ/m5LtJQK01lNqLvElAzeSbbW68SUBQ4nMn2LtJQOGWj6Sku0lA/89hvry8SUBVh9wMN7xJQFiqC3iZu0lAYMyWrIq8SUAtexLYnLtJQBeBsb6Bu0lAMEeP39u8SUAAkX77OrxJQCvc8pGUvElAscBXdOu7SUDcKoiBrrtJQFkTC3xFu0lAqz5XW7G7SUCxwFd067tJQHH/kenQu0lArK3YX3a9SUAxCKwcWrxJQFMgs7Pou0lA", - "dtype": "f8" - }, - "legendgroup": "Rightmove", - "lon": { - "bdata": "vRk1XyUfv784Sl6dY0C+v27b96i/Xr2/n5Cdt7HZvb9798d71cq8v79DUaBP5L2/A1/Rrdf0vL/x9EpZhji+v4Pg8e1dg76/EHUfgNQmvr+DonkAi/y+v+4h4Xt/g76/81oJ3SVxvr+XAPxTqkS9vyFzZVBtcL6/6pRHN8Kivr9YycfuAiW9v6hXyjLEsb6/TRB1H4DUvr+obi7+tifAv/JBz2bV576/uM6/Xfbrvr94msx4W+m9v3FXryKjA8C/3ZiesMQDwL+UpGsm32y7v+zAOSNKe7+/pg9dUN8yv79auReYFYrAvwq6vaQxWr+/A+li00ohwL/+ZffkYaG+v2WmtP6WAMC/4jsx68VQur+AnZs24zTAv4EmwoanV8C/jSYXY2Adv7+uSiL7IMu+v/InKhvWVL6/OUayR6gZvr8sZK4Mqg2+v9Dx0eKMYb6/z2bV52orvr8gKo2Y2ee9v8RafAqA8by/XFX2XRH8v7+fAmA8g4a+v7aGUnsRbbu/dy0hH/Rsvr/ogvqWOV2+v4ma6PNRRry/xXWMKy6Ovr9J9Z1flKC/v55flKC/0L+/81Zdh2pKwL8gJAuYwK27v52gTQ6fdL6/kElGzsKevr90QX3LnC6/v3jvqDEh5r6/yvli78UXvb+ppE5AE2G7vzHqWnufqrq/xeOiWkQUv7/cD3hgAOG7vzS+Ly5Vabu/zuFa7WEvwL8wEATI0LG/v807TtGRXL6/sFQX8DLDur+4V+atug69vwA7N23GacC/ns4VpYRgvb9j8gaY+Q6+v6mfNxWpMMC/kpT0MLQ6vb8/i6VIvhLAv7Zq14S0xry/H2gFhqxuvb/G4GHaN/e/v4j2sYLfhsC/qaROQBNhu79A3qtWJvy6v4rkK4GU2L2/Ribg10gSvL+dD88SZATAv2moUUgyq7+/TYQNT6+Uvb/bhlEQPL69v9Zx/FBpxLy/pu81BMdlwL+lhGBVvfy+v7ZMhuP5DLy/gSGrWz0nvb+m7zUEx2XAv8ZpiCr8GcC/DAQBMnTsvL+8P96rVia8vxE4Emiwqb+/", - "dtype": "f8" - }, - "marker": { - "color": "#2563eb", - "opacity": 0.65, - "size": 5 - }, - "mode": "markers", - "name": "Rightmove", - "showlegend": true, - "subplot": "map", - "type": "scattermap" - }, - { - "customdata": [ - [ - "Bedford Road, London", - 525000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19209711/", - 51.463636, - -0.129102 - ], - [ - "Padfield Road, London", - 275000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19522441/", - 51.466129, - -0.100233 - ], - [ - "Lord Holland Lane, Myatts Fields South, London", - 600000, - 1, - 4, - "Terraced", - "https://www.onthemarket.com/details/18847208/", - 51.471775, - -0.108913 - ], - [ - "Mostyn Road, London", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19305103/", - 51.473026, - -0.107904 - ], - [ - "Hackford Road, London", - 475000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19601952/", - 51.475374, - -0.115372 - ], - [ - "Clapham Road, London", - 225000, - 1, - 0, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19601638/", - 51.476212, - -0.117911 - ], - [ - "Coldharbour Lane, Brixton, London", - 575000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19601141/", - 51.463969, - -0.105579 - ], - [ - "Stockwell Road", - 435000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19599310/", - 51.466426, - -0.116437 - ], - [ - "Coldharbour Lane", - 450000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19599313/", - 51.46566, - -0.102355 - ], - [ - "Stockwell Park Walk, London", - 500000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18060975/", - 51.466421103788, - -0.114496210643 - ], - [ - "20 Fergusson Mews, London", - 650000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19266851/", - 51.469851, - -0.126918 - ], - [ - "Stockwell Road, Stockwell, London", - 450000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19167472/", - 51.466385, - -0.116535 - ], - [ - "Milles Square, Brixton, London", - 340000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19592532/", - 51.462364, - -0.1109 - ], - [ - "Railton Road, London", - 500000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19591254/", - 51.46006, - -0.110269 - ], - [ - "Hemberton Road, London", - 550000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19591126/", - 51.466742, - -0.126297 - ], - [ - "Vassall Road, London", - 339950, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19151834/", - 51.478151, - -0.104252 - ], - [ - "Lingham Street, Stockwell, London", - 525000, - 2, - 3, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19586498/", - 51.469424, - -0.122658 - ], - [ - "Carney Place, London", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19585964/", - 51.462768, - -0.111009 - ], - [ - "Brixton Road, Brixton, London", - 450000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19585218/", - 51.46536, - -0.114303 - ], - [ - "Willington Road, Clapham North, London", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19584647/", - 51.465275, - -0.126386 - ], - [ - "Cleveland House, Hackford Road, London", - 515000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19584203/", - 51.477508, - -0.115029 - ], - [ - "Durand Gardens, London", - 650000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19307508/", - 51.473924, - -0.117196 - ], - [ - "Electric Avenue, Brixton, London", - 500000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19581719/", - 51.462391, - -0.114278 - ], - [ - "Dover Mansions, Brixton, London", - 575000, - 2, - 3, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19580922/", - 51.464257, - -0.113427 - ], - [ - "Aytoun Road, London", - 465000, - 2, - 4, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19145304/", - 51.468933, - -0.116185 - ], - [ - "Stockwell Green", - 475000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18470548/", - 51.467529, - -0.118678 - ], - [ - "Benedict Road, London", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18676093/", - 51.466685, - -0.116216 - ], - [ - "Draymans Court, Stockwell, London", - 475000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19341068/", - 51.467534, - -0.118677 - ], - [ - "Myatt Road, London", - 325000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19567307/", - 51.477081, - -0.103866 - ], - [ - "Loughborough Park", - 500000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19097631/", - 51.458717, - -0.106738 - ], - [ - "Loughborough Estate, London", - 340000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18622635/", - 51.46826160116, - -0.10570123622231 - ], - [ - "Stane Grove, Clapham North, London", - 500000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19563052/", - 51.468519, - -0.128499 - ], - [ - "Loughborough Road, London", - 500000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18682120/", - 51.470658, - -0.108302 - ], - [ - "Stane Grove, London", - 550000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19288520/", - 51.468518, - -0.128499 - ], - [ - "Turner Close, London", - 470000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19558448/", - 51.476714, - -0.101937 - ], - [ - "Brighton Terrace, Brixton, London", - 475000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19306574/", - 51.461645, - -0.117062 - ], - [ - "Fenwick Place", - 599950, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19554481/", - 51.464473, - -0.12876 - ], - [ - "Loughborough Road", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19553677/", - 51.470695, - -0.108771 - ], - [ - "Eythorne Road, London", - 575000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19552463/", - 51.475345, - -0.108351 - ], - [ - "Handforth Road, Oval, London", - 500000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19069586/", - 51.479485, - -0.113059 - ], - [ - "20 Fergusson Mews, Stockwell", - 695000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19547590/", - 51.470019, - -0.126753 - ], - [ - "Landor Road, Clapham, London", - 550000, - 2, - 2, - "Other", - "https://www.onthemarket.com/details/19275567/", - 51.467004, - -0.122097 - ], - [ - "Cormont Road, Camberwell, London", - 325000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19544734/", - 51.473042, - -0.106046 - ], - [ - "Stockwell Road, London", - 425000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19543368/", - 51.466384, - -0.116535 - ], - [ - "Calais Street, London", - 695000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19539822/", - 51.475455, - -0.103167 - ], - [ - "Clapham Road, Stockwell, London", - 300000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19312175/", - 51.477909, - -0.115701 - ], - [ - "Stane Grove, London", - 450000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19538231/", - 51.468519, - -0.128499 - ], - [ - "Stockwell Road", - 465000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19538198/", - 51.466529, - -0.117086 - ], - [ - "Bolton Crescent, London", - 450000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19523846/", - 51.480484, - -0.10806 - ], - [ - "Handforth Road, London", - 550000, - 2, - 3, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19506302/", - 51.4793089, - -0.1138433 - ], - [ - "Acre Lane, London", - 600000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19234257/", - 51.460529, - -0.11754 - ], - [ - "Brixton Road, LONDON", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19043584/", - 51.479824, - -0.111801 - ], - [ - "Bramah Road, London", - 525000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19521511/", - 51.473773, - -0.10913 - ], - [ - "Carney Place, London", - 400000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19125735/", - 51.462428, - -0.110765 - ], - [ - "Liberty Street, London", - 225000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18847282/", - 51.475833, - -0.11632 - ], - [ - "Liberty Street, London", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18850207/", - 51.475833, - -0.11632 - ], - [ - "Clapham Road, London", - 400000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19517131/", - 51.4807822, - -0.1131921 - ], - [ - "Edithna Street", - 500000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18944836/", - 51.467754, - -0.124359 - ], - [ - "Brighton Terrace, London", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19515033/", - 51.461928, - -0.117304 - ], - [ - "Offenham Road, London", - 498000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19513494/", - 51.475892, - -0.109228 - ], - [ - "Camberwell New Road, London", - 750000, - 2, - 2, - "Terraced", - "https://www.onthemarket.com/details/19512902/", - 51.47739791870117, - -0.10183600336313248 - ], - [ - "Wynne Road, Brixton, London", - 300000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/17925213/", - 51.469385, - -0.113518 - ], - [ - "Hanover Gardens London", - 1290000, - 1, - 3, - "Terraced", - "https://www.onthemarket.com/details/18150560/", - 51.4815756, - -0.1134328 - ], - [ - "St John's Crescent, London", - 520000, - 1, - 3, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19509501/", - 51.466075, - -0.111577 - ], - [ - "Rumsey Road, London", - 590000, - 2, - 3, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19043064/", - 51.466866, - -0.116459 - ], - [ - "Offley Road, London", - 450000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19503694/", - 51.479936, - -0.112276 - ], - [ - "Huxley House, Stockwell Road, Stockwell, London", - 435000, - 1, - 3, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19502011/", - 51.470333, - -0.121643 - ], - [ - "Stane Grove, Clapham, London", - 500000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18534657/", - 51.468264, - -0.127765 - ], - [ - "Salisbury Place, London", - 325000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19494191/", - 51.477335, - -0.103078 - ], - [ - "Hackford Road, London", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19201991/", - 51.47546, - -0.11501 - ], - [ - "Hillyard Street, London", - 250000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19306826/", - 51.473951, - -0.115205 - ], - [ - "Hackford Road, London", - 525000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19489525/", - 51.475637, - -0.115318 - ], - [ - "Robsart Street", - 640000, - 2, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19488047/", - 51.470149, - -0.114532 - ], - [ - "Crewdson Road, LONDON", - 525000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19487822/", - 51.47869, - -0.112493 - ], - [ - "Salisbury Place, Oval, London", - 325000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19483122/", - 51.477336, - -0.103052 - ], - [ - "Burton Road, London", - 475000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19357782/", - 51.471575, - -0.105481 - ], - [ - "Cromwell Road, London", - 325000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18085145/", - 51.4761439, - -0.1061119 - ], - [ - "Ferndale Road, London", - 625000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19235836/", - 51.463174, - -0.12694 - ], - [ - "Coldharbour Lane, Brixton, London", - 400000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19478961/", - 51.464527, - -0.103943 - ], - [ - "Camberwell New Road, London", - 1350000, - 4, - 5, - "Detached", - "https://www.onthemarket.com/details/19476834/", - 51.478599, - -0.104555 - ], - [ - "Cormont Road, London", - 1395000, - 2, - 4, - "Detached", - "https://www.onthemarket.com/details/19476578/", - 51.472499, - -0.105408 - ], - [ - "Fyfield Road, London", - 425000, - 1, - 1, - "Terraced", - "https://www.onthemarket.com/details/19311666/", - 51.466176, - -0.109734 - ], - [ - "Landor Road, Stockwell", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/19330855/", - 51.4667874, - -0.1226492 - ], - [ - "Cormont Road, London", - 1700000, - 2, - 4, - "Detached", - "https://www.onthemarket.com/details/19474066/", - 51.47264, - -0.105378 - ], - [ - "Holcombe House, Landor Road, Clapham", - 475000, - 1, - 2, - "Flats/Maisonettes", - "https://www.onthemarket.com/details/18716390/", - 51.465374, - -0.127617 - ] - ], - "hovertemplate": "%{hovertext}

provider=OnTheMarket
Address per Property Register=%{customdata[0]}
Asking price=%{customdata[1]:,.0f}
Bathrooms=%{customdata[2]}
Bedrooms=%{customdata[3]}
Property type=%{customdata[4]}
Listing URL=%{customdata[5]}", - "hovertext": [ - "SW4 7EF", - "SE5 9AA", - "SW9 6NN", - "SW9 6BF", - "SW9 0RG", - "SW9 0HP", - "SW9 8SE", - "SW9 9FX", - "SW9 8RU", - "SW9 0FH", - "SW4 6BF", - "SW9 9FX", - "SW9 8GA", - "SE24 0LW", - "SW9 9LE", - "SW9 6JB", - "SW9 9HE", - "SW9 8GF", - "SW9 7AW", - "SW9 9NF", - "SW9 0EN", - "SW9 0PP", - "SW9 8JX", - "SW9 7QF", - "SW9 0UE", - "SW9 9QE", - "SW9 0FS", - "SW9 9QE", - "SW9 6XE", - "SW9 8FS", - "SW9 7SP", - "SW9 9AL", - "SW9 7TB", - "SW9 9AL", - "SW9 6UQ", - "SW9 8DL", - "SW9 9PG", - "SW9 7JX", - "SW9 7BN", - "SW9 0LP", - "SW4 6QU", - "SW9 9JB", - "SE5 9RE", - "SW9 9FX", - "SW9 6UB", - "SW9 0HS", - "SW9 9AL", - "SW9 9TG", - "SE5 0RZ", - "SW9 0LU", - "SW2 5RS", - "SW9 6BT", - "SW9 6FJ", - "SW9 8GA", - "SW9 0EF", - "SW9 0EF", - "SW9 0JP", - "SW9 9EB", - "SW9 8DH", - "SW9 7DX", - "SE5 0RR", - "SW9 0BB", - "SE11 5TL", - "SW9 7LZ", - "SW9 0FR", - "SW9 0LR", - "SW9 9EY", - "SW9 9FY", - "SW9 6UW", - "SW9 0RE", - "SW9 0NB", - "SW9 0RG", - "SW9 0FA", - "SW9 6BP", - "SW9 6UW", - "SW9 6TG", - "SW9 7BG", - "SW4 7SF", - "SW9 8SG", - "SE5 0SU", - "SE5 9RA", - "SW9 7HW", - "SW9 9NT", - "SE5 9RA", - "SW9 9NS" - ], - "lat": { - "bdata": "x5+obFi7SUDNW3UdqrtJQAMJih9jvElALxSwHYy8SUAyryMO2bxJQDv7yoP0vElAza0QVmO7SUCGAODYs7tJQBaHM7+au0lAg3fNr7O7SUAXf9sTJLxJQCgs8YCyu0lAg2xZvi67SUBQGf8+47pJQLfVrDO+u0lAXMZNDTS9SUCP+usVFrxJQFLRWPs7u0lA626e6pC7SUC9UpYhjrtJQAjKbfsevUlADmWoiqm8SUB8e9egL7tJQDT4+8Vsu0lA8YEd/wW8SUA+d4L917tJQFJEhlW8u0lAUOJzJ9i7SUCARX79EL1JQEOPGD23uklAN3oD/++7SUD+Rjtu+LtJQObMdoU+vElALsvXZfi7SUDOxd/2BL1JQFOu8C4Xu0lAAHDs2XO7SUADste7P7xJQJeo3hrYvElAFva0w1+9SUDTvySVKbxJQPSMfcnGu0lANdHno4y8SUBXsI14srtJQB7cnbXbvElAZrtCHyy9SUD+Rjtu+LtJQFvR5ji3u0lAKSDtf4C9SUBDF3n+Wb1JQAfuQJ3yuklA/+px32q9SUApXfqXpLxJQJpgONcwu0lAxa2CGOi8SUDFrYIY6LxJQN2/aEWKvUlAXknyXN+7SUCojep0ILtJQMo2cAfqvElAAAAAYBu9SUDSHcTOFLxJQJpG7kSkvUlA2T15WKi7SUCjztxDwrtJQCcW+IpuvUlAYp0q3zO8SUBz8iIT8LtJQDoeM1AZvUlAMEeP39u8SUAIdCZtqrxJQD/iV6zhvElAoZ+p1y28SUAMdsO2Rb1JQAuallgZvUlAPE7RkVy8SUBOWIdI8rxJQMMtH0lJu0lA843onnW7SUD8cma7Qr1JQEQy5Nh6vElADRe5p6u7SUCql4Swv7tJQAZkr3d/vElAUDQPYJG7SUA=", - "dtype": "f8" - }, - "legendgroup": "OnTheMarket", - "lon": { - "bdata": "jpHsEWqGwL8v3/qw3qi5v633G+244bu/oIobt5ifu7/Oxd/2BIm9vymy1lBqL76/WvYksDkHu7+nWguz0M69vzIDlfHvM7q/ykQ6qJ9Pvb+FCaNZ2T7Av0zg1t081b2/wFsgQfFjvL9UO8PUljq8v9ycSgaAKsC/T+eKUkKwur+lv5fCg2a/vzbJj/gVa7y/Ha7VHvZCvb98DFacai3Avw1yF2GKcr2/CtY4m44Avr9Z/KawUkG9v5S/e0eNCb2/kSdJ10y+vb88M8FwrmG+v5mdRe9UwL2/hpLJqZ1hvr//lCpR9pa6vwCOPXsuU7u/Qbq4eDwPu79Ly0i9p3LAv3dlFwyuubu/S8tIvadywL8kmkARixi6v9C2mnXG972/CTiEKjV7wL/E0sCPati7v0oofSHkvLu/D7qEQ2/xvL/kvtU6cTnAv/eQ8L2/Qb+/OiLfpdQlu79M4NbdPNW9vz/EBgsnabq/nE8dq5Sevb9Ly0i9p3LAv97H0RxZ+b2/fXkB9tGpu785ZF6i1SS9v4LK+PcZF76/Dfyohv2evL/kMQOV8e+7v9CbilQYW7y/gufewyXHvb+C597DJce9vz9iqk8o+ry/eJj2zf3Vv7/LorCLoge+v4m3zr9d9ru/AAAAoOwRur8/4IEBhA+9v4AwS5buCb2/3Vz8bU+QvL9JKlPMQdC9v50tILQevry/VI7J4v4jv797ZkmAmlrAvwDl795RY7q/jIS2nEtxvb8g71UrE369v27fo/56hb2/2nBYGvhRvb/TZwdcV8y8v4aSyamdYbq/tXBZhc0Au791ZzdEJiq7v1bxRuaRP8C/t+ulKQKcur+jHqLRHcS6v9ScvMgE/Lq/IF9CBYcXvL+XbOEe8GW/v4LHt3cN+rq/5fG0/MBVwL8=", - "dtype": "f8" - }, - "marker": { - "color": "#16a34a", - "opacity": 0.65, - "size": 5 - }, - "mode": "markers", - "name": "OnTheMarket", - "showlegend": true, - "subplot": "map", - "type": "scattermap" - }, - { - "customdata": [ - [ - "1, Caldwell Street, Stockwell", - 995000, - 2, - 4, - "Other", - "https://www.zoopla.co.uk/for-sale/details/73326946/", - 51.477238, - -0.116819 - ], - [ - "Flat 27, Addington House,, Stockwell Road, Stockwell, London", - 350000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/72554891/", - 51.468221, - -0.118798 - ], - [ - "1, Stockwell Park Road, London", - 1695000, - 3, - 5, - "Semi-Detached", - "https://www.zoopla.co.uk/for-sale/details/69392186/", - 51.474381, - -0.119192 - ], - [ - "Flat 11 Beale House, 45, Lingham Street, Stockwell, London", - 525000, - 2, - 3, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73306789/", - 51.469424, - -0.122658 - ], - [ - "17, Houghton Square, London", - 500000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73302918/", - 51.46795, - -0.129002 - ], - [ - "Flat 4,, Cleveland House, Hackford Road, London", - 515000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73302851/", - 51.477508, - -0.115029 - ], - [ - "chpk0226649, Hemberton Road, Clapham North, London", - 475000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/71215394/", - 51.466593, - -0.125681 - ], - [ - "Flat 59, 151, Stockwell Road", - 465000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73250562/", - 51.466529, - -0.117086 - ], - [ - "First Floor Flat, 70, Fenwick Place", - 599950, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73268982/", - 51.464473, - -0.12876 - ], - [ - "Flat C, 58, Loughborough Road", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73268027/", - 51.470695, - -0.108771 - ], - [ - "Flat 7, 5, Eythorne Road, Oval", - 575000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73266802/", - 51.475345, - -0.108351 - ], - [ - "chpk3677610, Yates Court, Stockwell, London", - 475000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73261921/", - 51.472181, - -0.12142 - ], - [ - "Beam, Lambeth SW9, New home", - 580000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/73247019/", - 51.476628, - -0.10787 - ], - [ - "Beam, Lambeth SW9, New home", - 595000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/73246935/", - 51.476628, - -0.10787 - ], - [ - "Stockwell Road, Brixton SW9, New home", - 575000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/73239981/", - 51.4674179004739, - -0.122737777251185 - ], - [ - "Flat 17, Draymans Court, 41, Stockwell Green, London", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73239240/", - 51.467439, - -0.118432 - ], - [ - "72, Hargwyne Street, Stockwell", - 1050000, - 3, - 4, - "Other", - "https://www.zoopla.co.uk/for-sale/details/73231221/", - 51.46622, - -0.121542 - ], - [ - "Flat 5, 18, Brighton Terrace, London", - 375000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73225986/", - 51.461928, - -0.117304 - ], - [ - "Flat 3, 1, Offenham Road, Oval", - 498000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73224383/", - 51.475892, - -0.109228 - ], - [ - "Handforth Road, London", - 550000, - 2, - 3, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73216028/", - 51.479309, - -0.113843 - ], - [ - "First Floor Flat, 3, Crewdson Road, Oval", - 525000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73195760/", - 51.47869, - -0.112493 - ], - [ - "chpk3632024, Hillyard Street, Oval, London", - 475000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73184722/", - 51.474134, - -0.114802 - ], - [ - "74, Stockwell Park Road, London", - 2100000, - 3, - 4, - "Terraced", - "https://www.zoopla.co.uk/for-sale/details/73183355/", - 51.47089, - -0.116765 - ], - [ - "Flat 12, 5, Eythorne Road, Oval", - 435000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73178440/", - 51.475345, - -0.108351 - ], - [ - "Flat 7, 162, Brixton Road, London", - 340000, - 1, - 1, - "Other", - "https://www.zoopla.co.uk/for-sale/details/73177281/", - 51.475028, - -0.113055 - ], - [ - "12, Burnley Road, London", - 1595000, - 2, - 5, - "Terraced", - "https://www.zoopla.co.uk/for-sale/details/73170265/", - 51.470986, - -0.119194 - ], - [ - "16 Leda Court, Hackford Rd, Oval", - 425000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/71247456/", - 51.477172, - -0.11466 - ], - [ - "Flat 8, 90, Clapham Road, Oval", - 300000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/72100650/", - 51.478889, - -0.115641 - ], - [ - "Flat 14, 27, Hillyard Street, Oval", - 385000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/72779518/", - 51.474212, - -0.114897 - ], - [ - "Flat 1 27, Hillyard Street, Oval, London", - 525000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/72779557/", - 51.474262, - -0.114798 - ], - [ - "Amberley Court, Angell Road, London", - 390000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/72978875/", - 51.467494, - -0.107537 - ], - [ - "118c, Landor Road, Clapham North", - 400000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73160783/", - 51.466708, - -0.122739 - ], - [ - "chpk4025655, Milles Square, Brixton, London", - 500000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73162720/", - 51.462364, - -0.110925 - ], - [ - "Clapham Road, Oval, London", - 550000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73163252/", - 51.480763, - -0.113092 - ], - [ - "31a, Tunstall Road, Brixton, London", - 599950, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/71394847/", - 51.462399, - -0.117013 - ], - [ - "Flat 7, 376, Clapham Road, London", - 675000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/73157265/", - 51.468123, - -0.128106 - ], - [ - "4, Rathgar Road, London", - 425000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/64039312/", - 51.466029, - -0.102351 - ], - [ - "Flat 14, Vincent Court, Stockwell, London", - 350000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/66790369/", - 51.47413, - -0.115666 - ], - [ - "Top floor, 62, Hemberton Road, Clapham", - 600000, - 1, - 3, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/66921521/", - 51.466328, - -0.126008 - ], - [ - "Zachary House, Lett Road, London", - 440000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/67561303/", - 51.476216, - -0.117323 - ], - [ - "Beam, Oval SW9, New home", - 580000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/68885348/", - 51.476417, - -0.108425 - ], - [ - "Cancell Road, London SW9, New home", - 630000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/68885354/", - 51.476417, - -0.108425 - ], - [ - "86B, Landor Road, Stockwell", - 450000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69092765/", - 51.465884, - -0.125154 - ], - [ - "Beam, Oval SW9, New home", - 638000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/69141398/", - 51.476417, - -0.108425 - ], - [ - "Beam, Oval SW9, New home", - 580000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/69141898/", - 51.476417, - -0.108425 - ], - [ - "Beam, Oval SW9, New home", - 595000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/new-homes/details/69141934/", - 51.476417, - -0.108425 - ], - [ - "Flat 8, Newson House, Brixton", - 101250, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69345262/", - 51.458477, - -0.105774 - ], - [ - "Flat 2,, Haverfield House, 31 Clapham Road, London", - 445000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69477612/", - 51.480039, - -0.114014 - ], - [ - "29, Rupert Gardens, Stockwell", - 325000, - 1, - 4, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69802039/", - 51.467677, - -0.104615 - ], - [ - "London, London", - 190000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69841147/", - 51.470153, - -0.115445 - ], - [ - "chpk2498574, Hopton House, Brixton, London", - 400000, - 1, - 3, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69909713/", - 51.468284, - -0.106102 - ], - [ - "70, Hargwyne Street, Brixton", - 530000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69917093/", - 51.465846, - -0.121333 - ], - [ - "Flat 1, 9, Dundas Road, London", - 650000, - 2, - 3, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/69969115/", - 51.472982, - -0.10968 - ], - [ - "25, Fitzpatrick Road, London", - 625000, - 1, - 3, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/70045361/", - 51.475681, - -0.10581 - ], - [ - "361, Clapham Road, London", - 650000, - 1, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/70056401/", - 51.467428, - -0.127914 - ], - [ - "Flat 9, Ferndale Road, London", - 295000, - 1, - 1, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/70256633/", - 51.462776, - -0.122019 - ], - [ - "Flat 7, 2, Offenham Road, Oval, London", - 570000, - 2, - 2, - "Flats/Maisonettes", - "https://www.zoopla.co.uk/for-sale/details/70308376/", - 51.476, - -0.109 - ], - [ - "2A, Tasman Road, Clapham North", - 700000, - 1, - 2, - "Terraced", - "https://www.zoopla.co.uk/for-sale/details/70335716/", - 51.465724, - -0.125134 - ] - ], - "hovertemplate": "%{hovertext}

provider=Zoopla
Address per Property Register=%{customdata[0]}
Asking price=%{customdata[1]:,.0f}
Bathrooms=%{customdata[2]}
Bedrooms=%{customdata[3]}
Property type=%{customdata[4]}
Listing URL=%{customdata[5]}", - "hovertext": [ - "SW9 0HD", - "SW9 0TZ", - "SW9 0AP", - "SW9 9HG", - "SW9 9AN", - "SW9 0ET", - "SW9 9LF", - "SW9 9FX", - "SW9 9NW", - "SW9 7SB", - "SW9 7RH", - "SW9 9AU", - "SW9 6HP", - "SW9 6HP", - "SW9 9FX", - "SW9 9QE", - "SW9 9RJ", - "SW9 8DG", - "SW9 7BN", - "SW9 0LL", - "SW9 0LH", - "SW9 0NG", - "SW9 0DA", - "SW9 7RH", - "SW9 6AU", - "SW9 0SH", - "SW9 0EU", - "SW9 0JJ", - "SW9 0NG", - "SW9 0NG", - "SW9 7HL", - "SW9 9NT", - "SW9 8GA", - "SW9 0JP", - "SW9 8BZ", - "SW9 9AR", - "SW9 7EL", - "SW9 0QU", - "SW9 9LJ", - "SW9 0AF", - "SW9 6HN", - "SW9 6HN", - "SW9 9PE", - "SW9 6HN", - "SW9 6HN", - "SW9 6HN", - "SW9 8FU", - "SW9 0JD", - "SW9 7TL", - "SW9 0BF", - "SW9 7SP", - "SW9 9RG", - "SW9 6BZ", - "SW9 7AZ", - "SW9 9BT", - "SW4 7RU", - "SW9 7BN", - "SW9 9LT" - ], - "lat": { - "bdata": "SDSBIha9SUB0Jm2q7rtJQAFsQIS4vElAj/rrFRa8SUDkFB3J5btJQAjKbfsevUlAcsXFUbm7SUBb0eY4t7tJQABw7Nlzu0lAA7LXuz+8SUCXqN4a2LxJQHNlUG1wvElAzy10JQK9SUDPLXQlAr1JQNkMilnUu0lA/u+ICtW7SUDdXtIYrbtJQKiN6nQgu0lAyjZwB+q8SUDY1k//Wb1JQAx2w7ZFvUlA+fVDbLC8SUC4AZ8fRrxJQJeo3hrYvElAl1eut828SUDbb+1ESbxJQJFI2/gTvUlAA7UYPEy9SUB1r5P6srxJQCfeAZ60vElAwono19a7SUAKZHYWvbtJQINsWb4uu0lAPRBZpIm9SUD/WfPjL7tJQLHAV3Tru0lAaf6Y1qa7SUC4BrZKsLxJQMOayqKwu0lAfepYpfS8SUAUIXU7+7xJQBQhdTv7vElAZt0/FqK7SUAUIXU7+7xJQBQhdTv7vElAFCF1O/u8SUDue9Rfr7pJQPvm/upxvUlAswsG19y7SUDjjjf5LbxJQLqe6Lrwu0lAeHx716C7SUBgzJasirxJQA8qcR3jvElACp5CrtS7SUDVr3Q+PLtJQLByaJHtvElALXsS2Jy7SUA=", - "dtype": "f8" - }, - "legendgroup": "Zoopla", - "lon": { - "bdata": "ICqNmNnnvb+DiNS0i2m+v4Pg8e1dg76/pb+XwoNmv78GLo81I4PAvw1yF2GKcr2/+kFdpFAWwL/ex9EcWfm9vwk4hCo1e8C/xNLAj2rYu79KKH0h5Ly7v9qPFJFhFb+/djI4Sl6du792MjhKXp27v/sORzO+a7+/acTMPo9Rvr+NJhdjYB2/v8uisIuiB76/ibfOv132u7825+CZ0CS9v9NnB1xXzLy/u/CD86ljvb+/Q1GgT+S9v0oofSHkvLu/NzemJyzxvL/uIeF7f4O+v9HLKJZbWr2/+KQTCaaavb8+lGjJ42m9v+NtpddmY72/0CUceouHu781mfG20mu/v4QNT6+UZby/gnFw6ZjzvL/+8zRgkPS9v6bvNQTHZcC/WoC21awzur+9VkJ3SZy9vyxmhLcHIcC/S5ARUOEIvr/gnBGlvcG7v+CcEaW9wbu/uFZ72AsFwL/gnBGlvcG7v+CcEaW9wbu/4JwRpb3Bu7/uYMQ+ARS7v7xASYEFML2/R8mrcwzIur+vmXyzzY29vwZKCiyAKbu/BvLs8q0Pv7/AeAYN/RO8v4P6ljldFru/batZZ3xfwL+JmX0eozy/v4GVQ4ts57u/nQ/PEmQEwL8=", - "dtype": "f8" - }, - "marker": { - "color": "#dc2626", - "opacity": 0.65, - "size": 5 - }, - "mode": "markers", - "name": "Zoopla", - "showlegend": true, - "subplot": "map", - "type": "scattermap" - } - ], - "layout": { - "height": 800, - "legend": { - "title": { - "text": "Provider" - }, - "tracegroupgap": 0 - }, - "map": { - "center": { - "lat": 51.47029892778563, - "lon": -0.11608637408049434 - }, - "domain": { - "x": [ - 0, - 1 - ], - "y": [ - 0, - 1 - ] - }, - "style": "open-street-map", - "zoom": 9 - }, - "mapbox": { - "center": { - "lat": 51.47029892778563, - "lon": -0.11608637408049434 - }, - "zoom": 9 - }, - "margin": { - "b": 0, - "l": 0, - "r": 0, - "t": 45 - }, - "template": { - "data": { - "bar": [ - { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, - "marker": { - "line": { - "color": "#E5ECF6", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "bar" - } - ], - "barpolar": [ - { - "marker": { - "line": { - "color": "#E5ECF6", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "barpolar" - } - ], - "carpet": [ - { - "aaxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "white", - "linecolor": "white", - "minorgridcolor": "white", - "startlinecolor": "#2a3f5f" - }, - "baxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "white", - "linecolor": "white", - "minorgridcolor": "white", - "startlinecolor": "#2a3f5f" - }, - "type": "carpet" - } - ], - "choropleth": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "choropleth" - } - ], - "contour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "contour" - } - ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmap" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" - } - ], - "histogram2d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2d" - } - ], - "histogram2dcontour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2dcontour" - } - ], - "mesh3d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "mesh3d" - } - ], - "parcoords": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "parcoords" - } - ], - "pie": [ - { - "automargin": true, - "type": "pie" - } - ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], - "scatter3d": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatter3d" - } - ], - "scattercarpet": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattercarpet" - } - ], - "scattergeo": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergeo" - } - ], - "scattergl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergl" - } - ], - "scattermap": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermap" - } - ], - "scattermapbox": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermapbox" - } - ], - "scatterpolar": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolar" - } - ], - "scatterpolargl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolargl" - } - ], - "scatterternary": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterternary" - } - ], - "surface": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "surface" - } - ], - "table": [ - { - "cells": { - "fill": { - "color": "#EBF0F8" - }, - "line": { - "color": "white" - } - }, - "header": { - "fill": { - "color": "#C8D4E3" - }, - "line": { - "color": "white" - } - }, - "type": "table" - } - ] - }, - "layout": { - "annotationdefaults": { - "arrowcolor": "#2a3f5f", - "arrowhead": 0, - "arrowwidth": 1 - }, - "autotypenumbers": "strict", - "coloraxis": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "colorscale": { - "diverging": [ - [ - 0, - "#8e0152" - ], - [ - 0.1, - "#c51b7d" - ], - [ - 0.2, - "#de77ae" - ], - [ - 0.3, - "#f1b6da" - ], - [ - 0.4, - "#fde0ef" - ], - [ - 0.5, - "#f7f7f7" - ], - [ - 0.6, - "#e6f5d0" - ], - [ - 0.7, - "#b8e186" - ], - [ - 0.8, - "#7fbc41" - ], - [ - 0.9, - "#4d9221" - ], - [ - 1, - "#276419" - ] - ], - "sequential": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "sequentialminus": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ] - }, - "colorway": [ - "#636efa", - "#EF553B", - "#00cc96", - "#ab63fa", - "#FFA15A", - "#19d3f3", - "#FF6692", - "#B6E880", - "#FF97FF", - "#FECB52" - ], - "font": { - "color": "#2a3f5f" - }, - "geo": { - "bgcolor": "white", - "lakecolor": "white", - "landcolor": "#E5ECF6", - "showlakes": true, - "showland": true, - "subunitcolor": "white" - }, - "hoverlabel": { - "align": "left" - }, - "hovermode": "closest", - "mapbox": { - "style": "light" - }, - "paper_bgcolor": "white", - "plot_bgcolor": "#E5ECF6", - "polar": { - "angularaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "bgcolor": "#E5ECF6", - "radialaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - } - }, - "scene": { - "xaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - }, - "yaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - }, - "zaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - } - }, - "shapedefaults": { - "line": { - "color": "#2a3f5f" - } - }, - "ternary": { - "aaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "baxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "bgcolor": "#E5ECF6", - "caxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - } - }, - "title": { - "x": 0.05 - }, - "xaxis": { - "automargin": true, - "gridcolor": "white", - "linecolor": "white", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "white", - "zerolinewidth": 2 - }, - "yaxis": { - "automargin": true, - "gridcolor": "white", - "linecolor": "white", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "white", - "zerolinewidth": 2 - } - } - }, - "title": { - "text": "Buy listings by source" - } - } - } + "text/html": [ + "
\n", + "shape: (15, 2)
Property sub-typecount
stru32
"Flat"64390
"Terraced"10532
"Semi-Detached"10170
"Detached"7610
"End Of Terrace"3747
"House"3590
"Maisonette"3196
"Studio"1503
"Retirement Property"891
"Ground Flat"889
"Unknown"842
"Bungalow"797
"Penthouse"735
"Detached Bungalow"577
"Town House"523
" + ], + "text/plain": [ + "shape: (15, 2)\n", + "┌─────────────────────┬───────┐\n", + "│ Property sub-type ┆ count │\n", + "│ --- ┆ --- │\n", + "│ str ┆ u32 │\n", + "╞═════════════════════╪═══════╡\n", + "│ Flat ┆ 64390 │\n", + "│ Terraced ┆ 10532 │\n", + "│ Semi-Detached ┆ 10170 │\n", + "│ Detached ┆ 7610 │\n", + "│ End Of Terrace ┆ 3747 │\n", + "│ House ┆ 3590 │\n", + "│ Maisonette ┆ 3196 │\n", + "│ Studio ┆ 1503 │\n", + "│ Retirement Property ┆ 891 │\n", + "│ Ground Flat ┆ 889 │\n", + "│ Unknown ┆ 842 │\n", + "│ Bungalow ┆ 797 │\n", + "│ Penthouse ┆ 735 │\n", + "│ Detached Bungalow ┆ 577 │\n", + "│ Town House ┆ 523 │\n", + "└─────────────────────┴───────┘" + ] }, "metadata": {}, "output_type": "display_data" } ], "source": [ - "map_df = (\n", - " df.filter(\n", - " pl.col(\"lat\").is_not_null()\n", - " & pl.col(\"lon\").is_not_null()\n", - " & pl.col(\"lat\").is_between(49, 56)\n", - " & pl.col(\"lon\").is_between(-1, 1)\n", - " )\n", - " .select(\n", - " \"provider\",\n", - " \"lat\",\n", - " \"lon\",\n", - " \"Postcode\",\n", - " \"Address per Property Register\",\n", - " \"Asking price\",\n", - " \"Bathrooms\",\n", - " \"Bedrooms\",\n", - " \"Property type\",\n", - " \"Listing URL\",\n", - " )\n", - " .to_pandas()\n", + "print(\"Property sub-type distinct: raw\", raw[\"Property sub-type\"].n_unique(),\n", + " \" -> clean\", clean[\"Property sub-type\"].n_unique())\n", + "casing = (\n", + " clean.filter(pl.col(\"Price qualifier\").is_not_null())\n", + " .with_columns(pl.col(\"Price qualifier\").str.to_lowercase().alias(\"n\"))\n", + " .group_by(\"n\").agg(pl.col(\"Price qualifier\").n_unique().alias(\"variants\"))\n", + " .filter(pl.col(\"variants\") > 1).height\n", ")\n", + "print(\"Price-qualifier casing-duplicate buckets remaining:\", casing)\n", + "display(clean[\"Property sub-type\"].value_counts(sort=True).head(15))" + ] + }, + { + "cell_type": "markdown", + "id": "94ac0585", + "metadata": {}, + "source": [ + "**Before/after — key metrics chart:**" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7aed7d0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-06-03T07:34:31.981991Z", + "iopub.status.busy": "2026-06-03T07:34:31.981907Z", + "iopub.status.idle": "2026-06-03T07:34:32.146617Z", + "shell.execute_reply": "2026-06-03T07:34:32.146178Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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6686dOhQsZ0vbh0JEJQaL730kqZPny5PT0+98sorRepj8ODB2rdv33VHf0hX5yiYNWuWHn/8cXl5eWnhwoXXnSvkmu7du2vixIkaNGiQPD09VbNmTS1cuFCSVLduXY0ePVotW7aUr6+v9uzZwxdA3JZBgwZp3bp15rk5bsWwYcNUtmxZLVq0SO+8847Kli2r119/XZJUoUIFffvtt5o2bZo8PDy0ePFifffdd+ZHxdq1a6dJkyapc+fOqlSpkrKzsy1mPp84caIaNmyomjVrKiwsTN27d7dYjWbRokWKjIy87qNnwK1wdHTUihUrtHTpUnl5eem9997TE088ka9dXl6eZs6cqcDAQHl6emrevHn6/PPPJUm1a9fWgAEDVKNGDXl6eurQoUO3FMP999+vxYsXa/z48fL09FTjxo3NH6QfffRROTo6qkKFCqpevfrtnzDueSEhIfr66681duxYVahQQe+//75WrVolb2/vIvX322+/qUWLFipXrpwefPBBPffccwoPD5dU8GeuOXPm6MMPP1T58uW1atUqi89GHTp00EsvvaTOnTsrKChI1atXv25cxX0euLvY29vr+++/V0JCgqpVqyZfX1/NmjVLkjRw4EBVq1ZNAQEBqlOnjpo0aWKx7+eff67q1avLzc1NY8eO1ddffy1nZ+dbOn6FChW0bNkyTZo0SV5eXlq9erUeeeSR6342GTp0qHr06KGmTZuqbt266ty5s0X9888/Lw8PD/n5+alHjx7q379/vj4GDhyobt26KTAwUPb29uY/MAUGBmr16tWaOXOmfH195e/vr5deeklZWVnFdr64dSaDcfqwIZcuXZKfn5+OHz+uwMBAa4cDFNmkSZPk6OhoXhK6tEtPT1dYWJi2b99uHg0FAABgbQ0bNtTo0aM1YMCAYu87ODhYs2fP1iOPPFLsfaNkONy8CXB3MAxDM2fO1COPPELyA3e9661XX1q5uLjoyJEj1g4DAADc43744Qc1adJEbm5umjNnjo4fP37dVcRw7yEBApuQlZWlChUqyM/PjxmUAQAAgHvUrl271L9/f2VmZqpmzZpavnw5o1NhxiMwAAAAAADA5jEJKgAAAAAAsHkkQAAAAAAAgM0jAQIAAAAAAGweCRAAAAAAAGDzSIAAAAAAAACbRwIEAAAAAADYPBIgAAAAAADA5pEAAQAAAAAANo8ECAAAAAAAsHkkQAAAAAAAgM37/wAY2sBGdPAC/QAAAABJRU5ErkJggg==", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "labels = [\"impossible\\ntiny area\", \"£/sqm max\\n(÷1000)\", \"sub-type\\ndistinct\", \"0-bed\\nnon-studio\", \"qualifier\\ncasing dupes\"]\n", + "before = [\n", + " raw.filter((pl.col(\"Total floor area (sqm)\") < 20) & (pl.col(\"Bedrooms\") >= 2)).height,\n", + " raw[\"Asking price per sqm\"].max() / 1000,\n", + " raw[\"Property sub-type\"].n_unique(),\n", + " raw.filter((pl.col(\"Bedrooms\") == 0) & (pl.col(\"Property sub-type\") != \"Studio\")).height,\n", + " 6,\n", + "]\n", + "after = [\n", + " clean.filter((pl.col(\"Total floor area (sqm)\") < 20) & (pl.col(\"Bedrooms\").fill_null(0) >= 2)).height,\n", + " clean[\"Asking price per sqm\"].max() / 1000,\n", + " clean[\"Property sub-type\"].n_unique(),\n", + " clean.filter((pl.col(\"Bedrooms\") == 0) & (pl.col(\"Property sub-type\") != \"Studio\")).height,\n", + " 0,\n", + "]\n", + "x = np.arange(len(labels)); w = 0.38\n", + "fig, ax = plt.subplots(figsize=(10, 3.6))\n", + "b1 = ax.bar(x - w / 2, before, w, label=\"raw\", color=\"#dc2626\")\n", + "b2 = ax.bar(x + w / 2, after, w, label=\"clean\", color=\"#16a34a\")\n", + "ax.set_xticks(x); ax.set_xticklabels(labels, fontsize=8)\n", + "ax.set_yscale(\"symlog\"); ax.set_ylabel(\"value (symlog)\")\n", + "ax.set_title(\"Before / after cleanup\"); ax.legend()\n", + "for bars in (b1, b2):\n", + " ax.bar_label(bars, fmt=\"%.0f\", fontsize=7, padding=2)\n", + "plt.tight_layout(); plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "f680553a", + "metadata": {}, + "source": [ + "## Caveats\n", "\n", - "print(f\"Mapped rows: {len(map_df):,}\")\n", - "\n", - "fig = px.scatter_map(\n", - " map_df,\n", - " lat=\"lat\",\n", - " lon=\"lon\",\n", - " color=\"provider\",\n", - " color_discrete_map={\n", - " \"Rightmove\": \"#2563eb\",\n", - " \"OnTheMarket\": \"#16a34a\",\n", - " \"Zoopla\": \"#dc2626\",\n", - " \"Unknown\": \"#6b7280\",\n", - " },\n", - " hover_name=\"Postcode\",\n", - " hover_data={\n", - " \"Address per Property Register\": True,\n", - " \"Asking price\": \":,.0f\",\n", - " \"Bathrooms\": True,\n", - " \"Bedrooms\": True,\n", - " \"Property type\": True,\n", - " \"Listing URL\": True,\n", - " \"lat\": False,\n", - " \"lon\": False,\n", - " },\n", - " zoom=9,\n", - " height=800,\n", - " title=\"Buy listings by source\",\n", - ")\n", - "fig.update_traces(marker={\"size\": 5, \"opacity\": 0.65})\n", - "fig.update_layout(\n", - " map_style=\"open-street-map\",\n", - " legend_title_text=\"Provider\",\n", - " margin={\"l\": 0, \"r\": 0, \"t\": 45, \"b\": 0},\n", - ")\n", - "fig.show()" + "- **`clean` keeps every row** (issues are flagged, not dropped) so it stays comparable to `raw`; use the\n", + " `is_residential` / `is_shared_ownership` / `floor_area_suspect_*` / `location_precision` flags to filter.\n", + "- **Shared-ownership full prices cannot be recovered** here — those rows are flagged and excluded from\n", + " `£/sqm`, but their `Asking price` is still a share. Likewise **suspiciously-large areas are flagged,\n", + " not auto-converted** from sq ft (the conversion factor isn't certain per-row).\n", + "- **De-dup is heuristic** (`lat,lon,price,beds`); it can over-collapse distinct units sharing a\n", + " development-centroid coordinate and under-collapse cross-portal dupes with differing prices. Treat\n", + " `dedup` as an analytics view and `raw` as the source of record.\n", + "- The cleanest *upstream* fixes belong in the `finder` pipeline (`storage.py` / per-portal transforms):\n", + " correct the beds/living-rooms column, normalise qualifiers, add a beds-aware area gate, emit a\n", + " precision flag, and resolve property identity across sources." ] } ], diff --git a/docker-compose.yml b/docker-compose.yml index f5cc036..e43bca7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,6 +32,9 @@ services: - ./property-data:/app/data:ro - ./finder/data:/app/finder-data:ro environment: + # Fallback only — the binary uses jemalloc as its global allocator + # (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas. + MALLOC_ARENA_MAX: "2" POCKETBASE_URL: http://pocketbase:8090 POCKETBASE_ADMIN_EMAIL: *pb-email POCKETBASE_ADMIN_PASSWORD: *pb-password diff --git a/finder/Dockerfile b/finder/Dockerfile new file mode 100644 index 0000000..bb5f2b5 --- /dev/null +++ b/finder/Dockerfile @@ -0,0 +1,25 @@ +# Finder scraper image. Runs via docker-compose sharing the media_gluetun VPN +# network namespace; the source tree is bind-mounted at runtime, so this image +# only needs the Python deps. The venv lives OUTSIDE the bind-mount target +# (/opt/venv) so the mount doesn't shadow it. +FROM python:3.12-slim + +ENV UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /app/finder + +# Install dependencies into /opt/venv (cached layer; project code is mounted at runtime). +COPY pyproject.toml uv.lock ./ +RUN uv sync --no-install-project --frozen + +# Source is bind-mounted over /app/finder by compose. `uv run` uses /opt/venv. +CMD ["sleep", "infinity"] diff --git a/finder/constants.py b/finder/constants.py index 82a834b..17e6938 100644 --- a/finder/constants.py +++ b/finder/constants.py @@ -6,7 +6,9 @@ REPO_DIR = FINDER_DIR.parent DATA_DIR = Path(os.environ.get("DATA_DIR", str(FINDER_DIR / "data"))) ARCGIS_PATH = Path( - os.environ.get("ARCGIS_PATH", str(REPO_DIR / "property-data" / "arcgis_data.parquet")) + os.environ.get( + "ARCGIS_PATH", str(REPO_DIR / "property-data" / "arcgis_data.parquet") + ) ) PAGE_SIZE = 24 DELAY_BETWEEN_PAGES = 0.3 @@ -19,6 +21,19 @@ MAX_BEDROOMS = 20 # sanity cap — values above this are almost certainly parsi TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead" SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search" RIGHTMOVE_BASE = "https://www.rightmove.co.uk" +# Detail page (plain HTTPS GET, no Cloudflare). Its window.__PAGE_MODEL embeds +# propertyData.address.{outcode,incode}, which together form the property's TRUE +# full postcode — the search API only exposes the outcode. {id} is the numeric +# listing id from the search response. +RIGHTMOVE_DETAIL_URL = "https://www.rightmove.co.uk/properties/{id}" + +# The Rightmove search API gives only an outcode-level display address, so the +# true full postcode is recovered from each listing's detail page (see +# finder/rightmove.py::parse_detail_postcode). One extra GET per listing is a +# big throughput increase over the ~1000-result-per-outcode search, so detail +# fetching is gated and capped per outcode (mirrors ZOOPLA_* below). Default ON. +RIGHTMOVE_FETCH_DETAILS = True # fetch detail pages for true per-listing postcodes +RIGHTMOVE_MAX_DETAILS_PER_OUTCODE = 4000 # max detail-page fetches per outcode # OnTheMarket ONTHEMARKET_BASE = "https://www.onthemarket.com" @@ -26,6 +41,41 @@ ONTHEMARKET_BASE = "https://www.onthemarket.com" # Zoopla ZOOPLA_BASE = "https://www.zoopla.co.uk" +# Zoopla search cards only carry an outcode-level address, so the full postcode +# and precise coordinates are scraped from each listing's detail page. These +# bound that extra work (see finder/zoopla.py and finder/scraper.py). +ZOOPLA_FETCH_DETAILS = True # fetch detail pages for precise per-listing postcodes +ZOOPLA_MAX_DETAILS_PER_OUTCODE = 4000 # max detail-page fetches per outcode +ZOOPLA_DETAIL_GOTO_TIMEOUT_MS = 1500000 # per detail-page navigation timeout +# Fraction of a single outcode's wall-clock budget (ZOOPLA_OUTCODE_TIMEOUT_SECONDS) +# spent fetching details; the remainder is reserved for search pagination so +# detail fetches can never trip the timeout and discard collected listings. +ZOOPLA_DETAIL_BUDGET_FRACTION = 0.6 + +# Gluetun VPN. Network endpoints are env-overridable because they are +# deployment-specific: when finder runs in a SEPARATE container they use the +# `gluetun` hostname (defaults below); when finder SHARES gluetun's network +# namespace (docker-compose.yml, network_mode container:media_gluetun) they +# become localhost and GLUETUN_PROXY is empty (the shared netns already tunnels +# all traffic, so no HTTP proxy is needed). +# GLUETUN_PROXY="" (empty) => direct connection (no proxy); used in shared-netns. +GLUETUN_PROXY = os.environ.get("GLUETUN_PROXY", "http://gluetun:8888") or None +GLUETUN_CONTROL_URL = os.environ.get("GLUETUN_CONTROL_URL", "http://gluetun:8000") +GLUETUN_API_KEY = "My8AbvnKhfyFdRhpTVfoTfa5DkAMmg8K" +# Egress-IP rotations to try per Cloudflare challenge. Keep at 0 for Zoopla: +# rotating among Gluetun's datacenter IPs doesn't clear Cloudflare and would +# rotate away from the IP a cleared Cloudflare session was bound to, voiding it. +# Raise only with residential IPs where rotation helps. +GLUETUN_MAX_ROTATIONS = 0 # max egress-IP rotations per Cloudflare challenge + +# Zoopla fetcher: "flaresolverr" (default) solves Cloudflare via the FlareSolverr +# sidecar (docker-compose.yml) and needs no display/VNC — verified to return the +# RSC flight stream with postcode + coordinates; "camoufox" drives a local +# anti-fingerprint browser (needs an interactive solve on datacenter IPs). +ZOOPLA_FETCHER = os.environ.get("ZOOPLA_FETCHER", "flaresolverr") +FLARESOLVERR_URL = os.environ.get("FLARESOLVERR_URL", "http://gluetun:8191/v1") +FLARESOLVERR_MAX_TIMEOUT_MS = 120000 # per-request solve budget; first solve is slow + # Greater London-ish postcode areas. This intentionally uses broad area # prefixes so a manual scrape can include central/inner London plus common # outer-London and near-London outcodes without maintaining a long borough list. diff --git a/finder/docker-compose.yml b/finder/docker-compose.yml new file mode 100644 index 0000000..af87d52 --- /dev/null +++ b/finder/docker-compose.yml @@ -0,0 +1,57 @@ +# Finder scraper + FlareSolverr, both sharing the EXISTING media_gluetun VPN +# container's network namespace. Everything egresses through the VPN, and +# FlareSolverr solves Zoopla's Cloudflare automatically (no VNC needed). +# +# Prerequisites: +# - The `media_gluetun` container (qmcgaw/gluetun) is running on this host. +# It is managed by a different compose; it is referenced here as external +# via network_mode "container:media_gluetun". +# - Because these services share gluetun's netns, they reach each other and +# gluetun on localhost (flaresolverr :8191, gluetun control :8000) and need +# NO published ports (which is exactly why this avoids the dev-container +# port-forwarding pain). +# +# Usage: +# cd finder +# docker compose up -d --build flaresolverr finder # start the sidecars +# docker compose exec finder uv run python main.py --source zoopla --outcodes SW9 --test +# docker compose exec finder uv run python main.py --source all # full run +# docker compose down +# +# NOTE: a manually-started `finder_flaresolverr` container from testing must be +# removed first (`docker rm -f finder_flaresolverr`) to avoid a name clash. + +services: + flaresolverr: + image: ghcr.io/flaresolverr/flaresolverr:latest + container_name: finder_flaresolverr + network_mode: "container:media_gluetun" + environment: + LOG_LEVEL: info + TZ: Europe/London + restart: unless-stopped + + finder: + build: + context: . + dockerfile: Dockerfile + image: finder-scraper:latest + container_name: finder_scraper + network_mode: "container:media_gluetun" + depends_on: + - flaresolverr + volumes: + - .:/app/finder # live-mounted finder source + - ../property-data:/app/property-data:ro # ARCGIS postcode data + working_dir: /app/finder + environment: + # Shared netns: sidecars are on localhost, and the netns already tunnels + # all traffic through the VPN, so no HTTP proxy is used. + ZOOPLA_FETCHER: flaresolverr + FLARESOLVERR_URL: http://localhost:8191/v1 + GLUETUN_CONTROL_URL: http://localhost:8000 + GLUETUN_PROXY: "" # empty => direct (shared netns already tunnels) + DATA_DIR: /app/finder/data + ARCGIS_PATH: /app/property-data/arcgis_data.parquet + restart: "no" + command: ["sleep", "infinity"] # stays up; run scrapes via `docker compose exec` diff --git a/finder/flaresolverr.py b/finder/flaresolverr.py new file mode 100644 index 0000000..dd91222 --- /dev/null +++ b/finder/flaresolverr.py @@ -0,0 +1,91 @@ +"""FlareSolverr client — fetch Cloudflare-protected pages as rendered HTML. + +FlareSolverr (https://github.com/FlareSolverr/FlareSolverr) drives an +undetected browser to pass Cloudflare's challenge and returns the fully +rendered HTML. It runs as a sidecar service (see docker-compose.yml) sharing +the Gluetun VPN network namespace, so its browser egresses through the VPN. + +Verified working against Zoopla's managed Turnstile on a datacenter VPN IP, +provided a reused session and a generous maxTimeout (~120s) — the first +challenge solve is slow, subsequent requests on the warm session are fast. +""" + +import logging + +import httpx + +from constants import FLARESOLVERR_MAX_TIMEOUT_MS, FLARESOLVERR_URL + +log = logging.getLogger("flaresolverr") + + +class FlareSolverrError(Exception): + """Raised when FlareSolverr cannot fetch/solve a URL.""" + + +class FlareSolverrSession: + """A reusable FlareSolverr browser session (context manager). + + Reusing one session keeps the cleared Cloudflare cookies warm across + requests, so only the first fetch pays the full challenge-solve cost.""" + + def __init__( + self, + url: str = FLARESOLVERR_URL, + session: str = "finder", + max_timeout_ms: int = FLARESOLVERR_MAX_TIMEOUT_MS, + ) -> None: + self._url = url + self._session = session + self._max_timeout = max_timeout_ms + # Read timeout must comfortably exceed maxTimeout (FlareSolverr blocks + # for up to maxTimeout while solving before responding). + self._client = httpx.Client(timeout=httpx.Timeout(self._max_timeout / 1000 + 30)) + self._active = False + + def _post(self, payload: dict) -> dict: + try: + resp = self._client.post(self._url, json=payload) + resp.raise_for_status() + data = resp.json() + except (httpx.HTTPError, ValueError) as exc: + raise FlareSolverrError( + f"FlareSolverr request to {self._url} failed: {exc}" + ) from exc + if data.get("status") != "ok": + raise FlareSolverrError( + f"FlareSolverr {payload.get('cmd')} failed: {data.get('message')}" + ) + return data + + def __enter__(self) -> "FlareSolverrSession": + # Start from a clean session (ignore destroy errors for a fresh name). + try: + self._post({"cmd": "sessions.destroy", "session": self._session}) + except FlareSolverrError: + pass + self._post({"cmd": "sessions.create", "session": self._session}) + self._active = True + log.info("FlareSolverr session %r ready at %s", self._session, self._url) + return self + + def get(self, url: str) -> str: + """Fetch a URL through FlareSolverr; return the solved HTML.""" + data = self._post( + { + "cmd": "request.get", + "session": self._session, + "url": url, + "maxTimeout": self._max_timeout, + } + ) + solution = data.get("solution") or {} + return solution.get("response", "") or "" + + def __exit__(self, *exc_info) -> None: + if self._active: + try: + self._post({"cmd": "sessions.destroy", "session": self._session}) + except FlareSolverrError as exc: + log.debug("FlareSolverr session destroy failed: %s", exc) + self._client.close() diff --git a/finder/gdal-ecw/Dockerfile b/finder/gdal-ecw/Dockerfile new file mode 100644 index 0000000..d272d22 --- /dev/null +++ b/finder/gdal-ecw/Dockerfile @@ -0,0 +1,53 @@ +# GDAL with ECW (read) support, for decoding Environment Agency Vertical Aerial +# Photography in the satellite-highres pipeline (pipeline/download/satellite_highres.py). +# +# EA VAP ships as ECW **v2** rasters, which are readable by the open-source +# libecwj2 3.3 SDK -- the same library the official OSGeo image uses when built +# with WITH_ECW=yes. We therefore avoid the proprietary, login-gated Hexagon +# ERDAS ECW/JP2 SDK (which is only needed for ECW v3) and its licensing +# restrictions entirely. +# +# We build only the ECW driver as a GDAL *plugin* on top of the official runtime +# image (no full GDAL rebuild). The plugin's GDAL sources are pinned to the exact +# commit reported by the base image so libgdal and the plugin stay ABI-compatible. +# +# Build: docker build -t perfect-postcode/gdal-ecw:latest docker/gdal-ecw +# Verify: docker run --rm perfect-postcode/gdal-ecw:latest gdalinfo --formats | grep -i ECW + +FROM ghcr.io/osgeo/gdal:ubuntu-full-latest + +ARG LIBECWJ2_URL=https://github.com/rouault/libecwj2-3.3-builds/releases/download/v1/install-libecwj2-3.3-ubuntu-20.04.tar.gz + +RUN apt-get update && apt-get install -y --no-install-recommends \ + cmake g++ make git curl ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Open-source ECW v2 SDK (extracts to /opt/libecwj2-3.3) + make its libs loadable. +RUN curl --retry 3 --retry-all-errors --retry-delay 3 -fsSL -o /tmp/libecwj2.tar.gz "$LIBECWJ2_URL" \ + && tar -C / -xzf /tmp/libecwj2.tar.gz \ + && rm -f /tmp/libecwj2.tar.gz \ + && (cd /opt/libecwj2-3.3/lib && for so in *.so*; do \ + ln -sf "/opt/libecwj2-3.3/lib/$so" "/usr/lib/x86_64-linux-gnu/$so"; \ + done) \ + && ldconfig + +# Build the ECW driver plugin against the base image's exact GDAL sources. +RUN set -eux; \ + GDAL_COMMIT="$(gdalinfo --version | sed -nE 's/.*-([0-9a-f]{8,}).*/\1/p')"; \ + test -n "$GDAL_COMMIT"; \ + echo "Building ECW plugin for GDAL commit ${GDAL_COMMIT}"; \ + mkdir -p /tmp/gdal && cd /tmp/gdal && git init -q; \ + git fetch --depth 1 -q https://github.com/OSGeo/gdal.git "$GDAL_COMMIT"; \ + git checkout -q FETCH_HEAD; \ + cmake -S frmts/ecw -B /tmp/ecw-build \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_PREFIX_PATH=/usr \ + -DECW_ROOT=/opt/libecwj2-3.3; \ + cmake --build /tmp/ecw-build -j"$(nproc)"; \ + PLUGIN_DIR=/usr/lib/x86_64-linux-gnu/gdalplugins; \ + mkdir -p "$PLUGIN_DIR"; \ + find /tmp/ecw-build -name 'gdal_ECW*.so' -exec cp {} "$PLUGIN_DIR/" \; ; \ + rm -rf /tmp/gdal /tmp/ecw-build + +# Fail the build if the driver is not actually available. +RUN gdalinfo --formats | grep -iq 'ECW.*rw' && echo "ECW driver OK" diff --git a/finder/http_client.py b/finder/http_client.py index b803c84..3f7aaa0 100644 --- a/finder/http_client.py +++ b/finder/http_client.py @@ -5,7 +5,7 @@ import time import httpx from fake_useragent import UserAgent -from constants import MAX_RETRIES, RETRY_BASE_DELAY +from constants import GLUETUN_PROXY, MAX_RETRIES, RETRY_BASE_DELAY log = logging.getLogger("rightmove") @@ -15,10 +15,12 @@ _ua = UserAgent( def make_client() -> httpx.Client: + # Route through the Gluetun HTTP proxy (VPN egress) when configured. return httpx.Client( timeout=30, headers={"User-Agent": _ua.random, "Accept": "application/json"}, follow_redirects=True, + proxy=GLUETUN_PROXY or None, ) diff --git a/finder/main.py b/finder/main.py index e6ce585..15b6811 100644 --- a/finder/main.py +++ b/finder/main.py @@ -57,6 +57,16 @@ def parse_args() -> argparse.Namespace: default=DATA_DIR, help=f"Directory for parquet output. Defaults to {DATA_DIR}.", ) + parser.add_argument( + "--outcodes", + type=str, + default=None, + help=( + "Comma-separated outcodes to scrape (e.g. 'SW9' or 'SW9,E14,BR1') " + "instead of the full Greater London set. Must fall within the " + "London-ish areas; takes precedence over --test/--limit-outcodes." + ), + ) parser.add_argument( "--limit-outcodes", type=int, @@ -116,17 +126,32 @@ def main() -> int: from scraper import ( build_postcode_coords, build_postcode_index, + filter_londonish_outcodes, load_outcodes, run_scrape, ) - outcodes = load_outcodes() - if args.test and args.limit_outcodes is None: - preferred = [outcode for outcode in TEST_OUTCODES if outcode in set(outcodes)] - if preferred: - outcodes = preferred - if args.limit_outcodes is not None: - outcodes = outcodes[: args.limit_outcodes] + if args.outcodes is not None: + requested = [code.strip().upper() for code in args.outcodes.split(",") if code.strip()] + if not requested: + raise SystemExit("--outcodes was empty") + outcodes = filter_londonish_outcodes(requested) + dropped = sorted(set(requested) - set(outcodes)) + if dropped: + log.warning("Ignoring outcodes outside the Greater London-ish areas: %s", ", ".join(dropped)) + if not outcodes: + raise SystemExit( + "None of the requested outcodes are within the Greater London-ish areas " + f"({', '.join(requested)})." + ) + else: + outcodes = load_outcodes() + if args.test and args.limit_outcodes is None: + preferred = [outcode for outcode in TEST_OUTCODES if outcode in set(outcodes)] + if preferred: + outcodes = preferred + if args.limit_outcodes is not None: + outcodes = outcodes[: args.limit_outcodes] if not outcodes: raise SystemExit("No Greater London-ish outcodes loaded; nothing to scrape.") diff --git a/finder/onthemarket.py b/finder/onthemarket.py index 7a96e4e..00b90c4 100644 --- a/finder/onthemarket.py +++ b/finder/onthemarket.py @@ -10,6 +10,30 @@ Each rendered page contains 30 listings under `humanised-property-type`, `features` (a list where the first element is typically `"Tenure: "`), and `details-url`. Pagination is via `?page=N`; the loop terminates when `paginationControls.next` is null. + +Postcodes +--------- +The search card exposes only an *outcode*-level address (e.g. "Padfield Road, +London, SE5") and a map pin, so the old behaviour derived the postcode from the +nearest postcode to that pin — a guess that frequently lands on a neighbouring +unit (the pin can sit on the wrong side of a street boundary). + +Each *detail* page (`/details/{id}/`) is a plain HTTPS GET whose `__NEXT_DATA__` +embeds the property's analytics dataLayer at +`props.initialReduxState.metadata.dataLayer`, which carries the property's own +`postcode` (full unit postcode, e.g. "SE5 9AA") keyed to this listing by +`property-id`. Crucially this is NOT the agent's office postcode — that lives +separately at `…property.agent.postcode` ("SE5 8RS" for the same listing) and +is the classic trap when blindly scanning the page for a postcode. We read the +dataLayer postcode, verify `property-id` matches the listing, and accept it only +when its outcode agrees with the coordinate-nearest postcode (via +``resolve_listing_postcode``) — exactly the trust rule the other scrapers use. +Measured over a sample of real listings this yields a trustworthy, usually +exact-unit postcode for ~11/12 listings; the rest safely fall back to the +coordinate-nearest postcode. + +Detail fetching costs one extra HTTPS GET per listing, so it is gated behind +``OTM_FETCH_DETAILS`` and capped at ``OTM_MAX_DETAILS_PER_OUTCODE`` per outcode. """ import json @@ -31,14 +55,26 @@ from spatial import PostcodeSpatialIndex from transform import ( clean_listing_address, extract_full_postcode, + extract_outcode, fix_coords, map_property_type, normalize_sub_type, parse_display_size, + resolve_listing_postcode, ) log = logging.getLogger("rightmove") +# Detail-page postcode recovery (see module docstring). When enabled, each +# listing's detail page is fetched so its analytics dataLayer postcode — the +# property's own full unit postcode — can replace the coordinate-nearest guess. +# Bounded per outcode so a large outcode can't balloon into unbounded extra +# HTTPS GETs. Kept at parity with the Rightmove/Zoopla detail caps (400) so a +# typical outcode's listings all get their real postcode rather than a +# coordinate-nearest guess. +OTM_FETCH_DETAILS = True +OTM_MAX_DETAILS_PER_OUTCODE = 400 + _NEXT_DATA_RE = re.compile( r'', re.DOTALL, @@ -51,6 +87,11 @@ _HTML_HEADERS = { "Accept-Language": "en-GB,en;q=0.9", } +# listingId -> recovered full postcode (or None). Failures are cached too so a +# broken or postcode-less detail page is not re-fetched within a run (the same +# listing can reappear across overlapping outcode searches). +_detail_postcode_cache: dict[str, str | None] = {} + def _fetch_page_json(client: httpx.Client, outcode: str, page_num: int) -> dict | None: """GET one search-results page and return the embedded __NEXT_DATA__ JSON. @@ -119,6 +160,116 @@ def _fetch_page_json(client: httpx.Client, outcode: str, page_num: int) -> dict return None +def parse_detail_postcode(html: str, listing_id: str | None = None) -> str | None: + """Extract the property's own full postcode from an OnTheMarket detail page. + + Pure and network-free so it is unit-testable: callers pass `page.content()` + / the GET body and this does the parsing. + + The postcode lives in the analytics dataLayer embedded in `__NEXT_DATA__` at + ``props.initialReduxState.metadata.dataLayer.postcode`` and is the + property's own unit postcode (e.g. "SE5 9AA"). It is deliberately NOT the + agent's office postcode, which sits separately at + ``…property.agent.postcode`` — the trap when scanning a detail page for "a" + postcode. When ``listing_id`` is given, the dataLayer's ``property-id`` must + match it, guaranteeing we read this listing's postcode and not a stray one. + + Returns a normalized full postcode (e.g. "SE5 9AA") or ``None`` when the + page has no usable property postcode. Trust (outcode-vs-coordinates + agreement) is enforced later in ``transform_property``. + """ + if not html: + return None + + match = _NEXT_DATA_RE.search(html) + if not match: + return None + try: + data = json.loads(match.group(1)) + except json.JSONDecodeError: + return None + + try: + data_layer = data["props"]["initialReduxState"]["metadata"]["dataLayer"] + except (KeyError, TypeError): + return None + if not isinstance(data_layer, dict): + return None + + # Guard against reading a different listing's postcode: the dataLayer is the + # property's own analytics payload, so its property-id must match. + if listing_id is not None: + page_id = data_layer.get("property-id") + if page_id is not None and str(page_id) != str(listing_id): + return None + + raw_postcode = data_layer.get("postcode") + if not isinstance(raw_postcode, str): + return None + return extract_full_postcode(raw_postcode) + + +def _fetch_detail_postcode( + client: httpx.Client, details_url: str, listing_id: str +) -> str | None: + """GET one listing's detail page and return its dataLayer postcode (or None). + + Results (including failures) are cached by listing id so a listing that + reappears across overlapping outcode searches is fetched at most once. Plain + HTTPS GET — OnTheMarket detail pages have no Cloudflare challenge. Network / + parse errors degrade gracefully to None so the caller falls back to the + coordinate-nearest postcode. + """ + if listing_id in _detail_postcode_cache: + return _detail_postcode_cache[listing_id] + + full_url = ( + ONTHEMARKET_BASE + details_url + if details_url and not details_url.startswith("http") + else details_url + ) + result: str | None = None + if full_url: + for attempt in range(MAX_RETRIES): + try: + resp = client.get( + full_url, headers=_HTML_HEADERS, follow_redirects=True + ) + except ( + httpx.ConnectError, + httpx.ReadTimeout, + httpx.WriteTimeout, + httpx.PoolTimeout, + ) as exc: + delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1) + log.warning( + "%s from %s, retry %d/%d in %.1fs", + type(exc).__name__, full_url, attempt + 1, MAX_RETRIES, delay, + ) + time.sleep(delay) + continue + + if resp.status_code == 200: + result = parse_detail_postcode(resp.text, listing_id) + break + if resp.status_code in (429, 500, 502, 503, 504): + delay = RETRY_BASE_DELAY * (2**attempt) + random.uniform(0, 1) + log.warning( + "HTTP %d from %s, retry %d/%d in %.1fs", + resp.status_code, full_url, attempt + 1, MAX_RETRIES, delay, + ) + time.sleep(delay) + continue + log.debug( + "OnTheMarket detail %s returned HTTP %d (no postcode)", + listing_id, resp.status_code, + ) + break + + _detail_postcode_cache[listing_id] = result + return result + + def _parse_price(price_value) -> int: """Parse a formatted price string like '£450,000' into an integer. Returns 0 for POA/auction/null values.""" @@ -166,9 +317,19 @@ def _extract_floor_area(features: list) -> float | None: def transform_property( - raw: dict, pc_index: PostcodeSpatialIndex + raw: dict, + pc_index: PostcodeSpatialIndex, + detail_postcode: str | None = None, ) -> dict | None: - """Transform a raw OnTheMarket listing dict into our output schema.""" + """Transform a raw OnTheMarket listing dict into our output schema. + + ``detail_postcode`` is the property's own full postcode recovered from its + detail page (see ``parse_detail_postcode`` / ``_fetch_detail_postcode``), + or ``None`` when no detail fetch was done / no postcode was found. When + present and trustworthy (its outcode agrees with the coordinate-nearest + postcode) it supersedes the coordinate guess and is labelled + ``"detail_address"``. + """ loc = raw.get("location") or {} raw_lat = loc.get("lat") raw_lng = loc.get("lon") @@ -184,8 +345,29 @@ def transform_property( return None raw_address = raw.get("address", "") or "" extracted_postcode = extract_full_postcode(raw_address) - postcode = extracted_postcode or inferred_postcode - postcode_source = "address" if extracted_postcode else "coordinates" + + # Prefer the property's own detail-page postcode when we have one and it is + # trustworthy. The detail postcode is a full unit postcode (better than the + # coordinate-nearest guess and than the usually outcode-only card address), + # but a stale/mislabelled value would silently override the spatially + # correct one, so apply the same outcode-agreement trust rule the address + # postcode uses: keep it only when its outcode matches the + # coordinate-nearest postcode's outcode. + detail_postcode = extract_full_postcode(detail_postcode) + if detail_postcode and extract_outcode(detail_postcode) == extract_outcode( + inferred_postcode + ): + postcode, postcode_source = detail_postcode, "detail_address" + else: + if detail_postcode: + log.debug( + "OnTheMarket %s: rejecting detail postcode %s " + "(outcode mismatch with inferred %s)", + raw.get("id", "?"), detail_postcode, inferred_postcode, + ) + postcode, postcode_source = resolve_listing_postcode( + extracted_postcode, inferred_postcode + ) raw_beds = raw.get("bedrooms") or 0 raw_baths = raw.get("bathrooms") or 0 @@ -223,6 +405,10 @@ def transform_property( "Inferred postcode": inferred_postcode, "Listing raw address": raw_address, "Address per Property Register": clean_listing_address(raw_address), + # OnTheMarket search JSON exposes only a street-level address; no UPRN + # or house number/name is available without a detail-page fetch. + "UPRN": None, + "Property number or name": None, "Leasehold/Freehold": _extract_tenure(features), "Property type": map_property_type(sub_type), "Property sub-type": normalize_sub_type(sub_type), @@ -242,10 +428,17 @@ def search_outcode( pc_index: PostcodeSpatialIndex, max_properties: int | None = None, ) -> list[dict]: - """Paginate through OnTheMarket sale results for one outcode.""" + """Paginate through OnTheMarket sale results for one outcode. + + When ``OTM_FETCH_DETAILS`` is enabled, up to + ``OTM_MAX_DETAILS_PER_OUTCODE`` listings per outcode have their detail page + fetched for the property's own postcode (see ``_fetch_detail_postcode``); + the rest fall back to the coordinate-nearest postcode. + """ properties: list[dict] = [] seen_ids: set[str] = set() page_num = 1 + details_fetched = 0 while True: data = _fetch_page_json(client, outcode, page_num) @@ -269,8 +462,22 @@ def search_outcode( if listing_id and listing_id in seen_ids: continue seen_ids.add(listing_id) + + detail_postcode = None + if OTM_FETCH_DETAILS and listing_id: + # Cached lookups are free; only fresh GETs count toward the cap + # and incur the inter-request delay. + cached = listing_id in _detail_postcode_cache + if cached or details_fetched < OTM_MAX_DETAILS_PER_OUTCODE: + detail_postcode = _fetch_detail_postcode( + client, raw.get("details-url") or "", listing_id + ) + if not cached: + details_fetched += 1 + time.sleep(DELAY_BETWEEN_PAGES) + try: - transformed = transform_property(raw, pc_index) + transformed = transform_property(raw, pc_index, detail_postcode) except Exception as exc: log.warning( "OnTheMarket %s property %s failed to transform: %s", diff --git a/finder/rightmove.py b/finder/rightmove.py index 883c68a..956b73d 100644 --- a/finder/rightmove.py +++ b/finder/rightmove.py @@ -1,4 +1,6 @@ +import json import logging +import re import time import httpx @@ -6,12 +8,15 @@ import httpx from constants import ( PAGE_SIZE, DELAY_BETWEEN_PAGES, + RIGHTMOVE_DETAIL_URL, + RIGHTMOVE_FETCH_DETAILS, + RIGHTMOVE_MAX_DETAILS_PER_OUTCODE, SEARCH_URL, TYPEAHEAD_URL, ) from http_client import fetch_with_retry from spatial import PostcodeSpatialIndex -from transform import transform_property +from transform import extract_full_postcode, normalize_postcode, transform_property log = logging.getLogger("rightmove") @@ -23,6 +28,176 @@ outcode_cache: dict[str, str] = {} _MAX_INDEX = 1008 +# --------------------------------------------------------------------------- +# Detail-page postcode extraction +# --------------------------------------------------------------------------- +# +# The search API (_paginate) only returns an outcode-level `displayAddress` +# (e.g. "Akerman Road, Brixton, London, SW9") — never the full postcode. Each +# listing's detail page, however, embeds the property's OWN full postcode in a +# `window.__PAGE_MODEL` script as `propertyData.address.{outcode, incode}` +# (e.g. outcode "SW9" + incode "0HD" → "SW9 0HD"), independently corroborated by +# `propertyData.propertyUrls.similarPropertiesUrl` ("/property-for-sale/SW9-0HD.html"). +# This is the property's own postcode, NOT a nearest station/school: the +# `nearestStations`/`nearestAirports` arrays carry only names + distances, no +# postcodes, and the address outcode always matches the searched outcode. +# Recon over 24 live listings across SW9/E1/M1/LS6/E20 (incl. APPROXIMATE_POINT +# new-builds) found the full postcode present 100% of the time. There is no +# UPRN or house-number field anywhere in propertyData, so those stay None. +# +# __PAGE_MODEL is a "devalue"-style flattened object graph: its `data` field is +# a JSON STRING holding a flat array where every integer inside a container is +# an index reference into that same array (so the graph can dedupe). We +# brace-match the (large, deeply-nested) object literal — a non-greedy regex +# cannot — then rehydrate the reference graph before reading the address. + +_PAGE_MODEL_RE = re.compile(r"window\.__PAGE_MODEL\s*=\s*") + + +def _extract_page_model_literal(html: str) -> str | None: + """Return the `{...}` object literal assigned to window.__PAGE_MODEL. + + Brace-matches with string/escape awareness so embedded braces and quotes in + string values don't end the match early. Returns None when absent.""" + marker = _PAGE_MODEL_RE.search(html) + if not marker: + return None + start = marker.end() + if start >= len(html) or html[start] != "{": + return None + depth = 0 + in_str = False + esc = False + for j in range(start, len(html)): + ch = html[j] + if in_str: + if esc: + esc = False + elif ch == "\\": + esc = True + elif ch == '"': + in_str = False + elif ch == '"': + in_str = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return html[start : j + 1] + return None + + +def _rehydrate(flat: list) -> object: + """Resolve a devalue-style flattened reference array into a nested object. + + Index 0 is the root; every int inside a dict/list is an index back into + ``flat``. Memoised so shared/cyclic references resolve once.""" + cache: dict[int, object] = {} + + def resolve(idx: int) -> object: + if not isinstance(idx, int) or idx < 0 or idx >= len(flat): + return None + if idx in cache: + return cache[idx] + node = flat[idx] + if isinstance(node, dict): + out: dict = {} + cache[idx] = out + for key, value in node.items(): + out[key] = resolve(value) if isinstance(value, int) else value + return out + if isinstance(node, list): + arr: list = [] + cache[idx] = arr + for value in node: + arr.append(resolve(value) if isinstance(value, int) else value) + return arr + cache[idx] = node + return node + + return resolve(0) + + +def parse_detail_postcode(html: str) -> str | None: + """Extract a Rightmove property's TRUE full postcode from its detail HTML. + + Pure and network-free so it is unit-testable: callers pass the page HTML. + Reads ``propertyData.address.outcode`` + ``.incode`` from window.__PAGE_MODEL + and returns a normalised full postcode (e.g. "SW9 0HD"), or None when the + page has no parseable address (the property location wrapper can be empty — + the caller then keeps the coordinate fallback). The returned outcode is + re-validated against the joined postcode so a malformed incode is dropped. + """ + if not html: + return None + literal = _extract_page_model_literal(html) + if not literal: + return None + try: + outer = json.loads(literal) + flat = json.loads(outer["data"]) + except (ValueError, KeyError, TypeError): + return None + if not isinstance(flat, list) or not flat: + return None + + root = _rehydrate(flat) + if not isinstance(root, dict): + return None + property_data = root.get("propertyData") + if not isinstance(property_data, dict): + return None + address = property_data.get("address") + if not isinstance(address, dict): + return None + + outcode = address.get("outcode") + incode = address.get("incode") + if not isinstance(outcode, str) or not isinstance(incode, str): + return None + outcode, incode = outcode.strip(), incode.strip() + if not outcode or not incode: + return None + + # Round-trip through the shared postcode validator/normaliser: this both + # canonicalises spacing and rejects an outcode/incode pair that doesn't form + # a structurally-valid UK postcode. + return extract_full_postcode(normalize_postcode(f"{outcode} {incode}")) + + +# listingId -> true full postcode (or None when unavailable). Failures are +# cached too, so a broken/duplicate listing is fetched at most once per run (the +# same listing can reappear across overlapping outcode searches). +_detail_postcode_cache: dict[str, str | None] = {} + + +def _fetch_detail_postcode(client: httpx.Client, property_id: str) -> str | None: + """GET a listing detail page and return its true full postcode (or None). + + Results (including failures) are cached by listing id. The detail page is a + plain HTML GET — no Cloudflare, unlike Zoopla — so a single httpx call + suffices; any error degrades gracefully to the coordinate fallback.""" + if not property_id: + return None + if property_id in _detail_postcode_cache: + return _detail_postcode_cache[property_id] + + postcode: str | None = None + url = RIGHTMOVE_DETAIL_URL.format(id=property_id) + try: + resp = client.get(url, headers={"Accept": "text/html"}) + if resp.status_code == 200: + postcode = parse_detail_postcode(resp.text) + else: + log.debug("Rightmove detail %s returned HTTP %d", url, resp.status_code) + except httpx.HTTPError as exc: + log.debug("Rightmove detail fetch failed %s: %s", url, exc) + + _detail_postcode_cache[property_id] = postcode + return postcode + + def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None: """Look up Rightmove's internal ID for an outcode via typeahead API.""" if outcode in outcode_cache: @@ -44,6 +219,31 @@ def resolve_outcode_id(client: httpx.Client, outcode: str) -> str | None: return None +def _detail_postcode_for( + client: httpx.Client, + prop: dict, + fetch_details: bool, + detail_budget: dict, +) -> str | None: + """Look up a listing's true postcode, honouring the per-outcode fetch cap. + + Cached listings are always served (they cost neither a cap slot nor a GET); + a fresh fetch is made only while ``detail_budget['remaining'] > 0``.""" + if not fetch_details: + return None + property_id = str(prop.get("id") or "") + if not property_id: + return None + if property_id in _detail_postcode_cache: + return _detail_postcode_cache[property_id] + if detail_budget["remaining"] <= 0: + return None + detail_budget["remaining"] -= 1 + postcode = _fetch_detail_postcode(client, property_id) + time.sleep(DELAY_BETWEEN_PAGES) + return postcode + + def _paginate( client: httpx.Client, outcode_id: str, @@ -51,11 +251,19 @@ def _paginate( channel_cfg: dict, pc_index: PostcodeSpatialIndex, max_properties: int | None = None, + fetch_details: bool = False, + detail_cap: int = 0, ) -> tuple[list[dict], int]: - """Paginate through search results. Returns (properties, result_count).""" + """Paginate through search results. Returns (properties, result_count). + + When ``fetch_details`` is set, up to ``detail_cap`` listings per outcode have + their detail page fetched for the property's TRUE full postcode (see + ``parse_detail_postcode``); the rest fall back to coordinate-derived + postcodes.""" properties = [] index = 0 result_count = 0 + detail_budget = {"remaining": detail_cap} while True: params = { @@ -82,7 +290,12 @@ def _paginate( for prop in raw_props: try: - transformed = transform_property(prop, outcode, pc_index) + detail_postcode = _detail_postcode_for( + client, prop, fetch_details, detail_budget + ) + transformed = transform_property( + prop, outcode, pc_index, detail_postcode=detail_postcode + ) except Exception as exc: log.warning( "Rightmove %s/%s property %s failed to transform: %s", @@ -127,7 +340,12 @@ def search_outcode( pc_index: PostcodeSpatialIndex, max_properties: int | None = None, ) -> list[dict]: - """Paginate through unfiltered sale results for one outcode+channel.""" + """Paginate through unfiltered sale results for one outcode+channel. + + Each listing's detail page is fetched for the property's TRUE full postcode + (gated by ``RIGHTMOVE_FETCH_DETAILS`` and capped per outcode by + ``RIGHTMOVE_MAX_DETAILS_PER_OUTCODE``); listings beyond the cap keep the + coordinate-derived postcode.""" properties, _ = _paginate( client, outcode_id, @@ -135,6 +353,8 @@ def search_outcode( channel_cfg, pc_index, max_properties=max_properties, + fetch_details=RIGHTMOVE_FETCH_DETAILS, + detail_cap=RIGHTMOVE_MAX_DETAILS_PER_OUTCODE, ) if max_properties is not None and len(properties) >= max_properties: diff --git a/finder/scraper.py b/finder/scraper.py index 1111577..25ddf90 100644 --- a/finder/scraper.py +++ b/finder/scraper.py @@ -15,6 +15,10 @@ from constants import ( DATA_DIR, DELAY_BETWEEN_OUTCODES, LONDON_OUTCODE_PREFIXES, + ZOOPLA_DETAIL_BUDGET_FRACTION, + ZOOPLA_FETCH_DETAILS, + ZOOPLA_FETCHER, + ZOOPLA_MAX_DETAILS_PER_OUTCODE, ) from http_client import make_client @@ -371,6 +375,36 @@ def _zoopla_outcode_timeout_seconds() -> int: return timeout +def _zoopla_detail_cap() -> int: + """Max detail-page fetches per outcode (0 disables detail fetching). + + Zoopla search cards only expose an outcode-level address, so the full + postcode/coordinates come from each listing's detail page. The cap bounds + the extra page loads so an outcode stays within ZOOPLA_OUTCODE_TIMEOUT_SECONDS + (the per-outcode SIGALRM budget covers the detail fetches too). Configure via + ZOOPLA_FETCH_DETAILS / ZOOPLA_MAX_DETAILS_PER_OUTCODE in constants.py.""" + return ZOOPLA_MAX_DETAILS_PER_OUTCODE if ZOOPLA_FETCH_DETAILS else 0 + + +def _open_zoopla_detail_tab(page, detail_cap: int): + """Open a second tab on the same context for detail-page fetches. + + Sharing the persistent context means the detail tab inherits the search + tab's Cloudflare clearance cookies. Returns None when detail fetching is + disabled or the tab cannot be created (the scrape then degrades to + outcode-level postcodes rather than failing).""" + if detail_cap <= 0: + return None + try: + return page.context.new_page() + except Exception as exc: + log.warning( + "Zoopla detail tab unavailable (%s); using outcode-level postcodes", + _exception_detail(exc), + ) + return None + + @contextmanager def _wall_clock_timeout(seconds: int, label: str): """SIGALRM-based wall-clock guard (POSIX). Raises OutcodeTimeout on expiry. @@ -438,6 +472,50 @@ def _close_zoopla_browser(browser, label: str) -> None: log.warning("%s browser force-close failed: %s", label, _exception_detail(exc)) +def _scrape_zoopla_flaresolverr( + outcodes: list[str], + pc_index: PostcodeSpatialIndex, + pc_coords: dict[str, tuple[float, float]], + results: dict[str, list[dict]], + errors: list[str], + max_properties_per_source: int | None, +) -> None: + """Scrape Zoopla via the FlareSolverr sidecar (no browser/VNC).""" + from flaresolverr import FlareSolverrError, FlareSolverrSession + from zoopla_flaresolverr import search_outcode as fs_search_outcode + + try: + session = FlareSolverrSession(session="zoopla") + session.__enter__() + except FlareSolverrError as exc: + errors.append(f"zoopla: FlareSolverr unavailable: {exc}") + log.warning("Zoopla skipped: FlareSolverr unavailable: %s", exc) + return + + try: + for outcode in outcodes: + remaining = _source_remaining(results, "zoopla", max_properties_per_source) + if remaining == 0: + log.info("Zoopla cap reached") + return + try: + props, _ = fs_search_outcode( + outcode, + pc_index, + pc_coords, + session, + max_properties=remaining, + detail_cap=ZOOPLA_MAX_DETAILS_PER_OUTCODE, + ) + added = _store_properties(results, "zoopla", props, max_properties_per_source) + log.info("Zoopla %s: +%d", outcode, added) + except Exception as exc: # noqa: BLE001 - one outcode must not kill the run + _record_error(errors, "zoopla", outcode, exc) + time.sleep(DELAY_BETWEEN_OUTCODES) + finally: + session.__exit__(None, None, None) + + def _scrape_zoopla( outcodes: list[str], pc_index: PostcodeSpatialIndex, @@ -446,6 +524,12 @@ def _scrape_zoopla( errors: list[str], max_properties_per_source: int | None, ) -> None: + if ZOOPLA_FETCHER == "flaresolverr": + _scrape_zoopla_flaresolverr( + outcodes, pc_index, pc_coords, results, errors, max_properties_per_source + ) + return + try: browser, page = _launch_zoopla_with_retries() except Exception as exc: @@ -454,6 +538,12 @@ def _scrape_zoopla( return outcode_timeout = _zoopla_outcode_timeout_seconds() + detail_cap = _zoopla_detail_cap() + detail_page = _open_zoopla_detail_tab(page, detail_cap) + # Spend at most a fraction of each outcode's budget on detail fetches so the + # SIGALRM guard never trips mid-outcode and discards already-collected + # search listings; the rest is left for search pagination and transform. + detail_budget_seconds = max(10.0, outcode_timeout * ZOOPLA_DETAIL_BUDGET_FRACTION) try: for outcode in outcodes: @@ -470,6 +560,9 @@ def _scrape_zoopla( pc_index, pc_coords, max_properties=None, + detail_page=detail_page, + detail_cap=detail_cap, + detail_budget_seconds=detail_budget_seconds, ) added = _store_properties( results, @@ -496,6 +589,8 @@ def _scrape_zoopla( _close_zoopla_browser(browser, f"zoopla {outcode}") try: browser, page = _launch_zoopla_with_retries() + # The old context (and its detail tab) is gone; reopen one. + detail_page = _open_zoopla_detail_tab(page, detail_cap) log.info("Zoopla %s retrying with fresh browser", outcode) except Exception as relaunch_exc: _record_error(errors, "zoopla", outcode, relaunch_exc) @@ -503,6 +598,11 @@ def _scrape_zoopla( time.sleep(DELAY_BETWEEN_OUTCODES) finally: + if detail_page is not None: + try: + detail_page.close() + except Exception: + pass _close_zoopla_browser(browser, "zoopla final") diff --git a/finder/storage.py b/finder/storage.py index 6c6822e..13008eb 100644 --- a/finder/storage.py +++ b/finder/storage.py @@ -126,6 +126,14 @@ def write_parquet(properties: list[dict], path: Path) -> None: "Address per Property Register": [ p["Address per Property Register"] for p in properties ], + # UPRN (when the scraper recovered it) keys an exact listing->EPC + # join; Property number or name is the house identifier for the + # Price-Paid address join. Both are None for sources/listings without + # a detail-page fetch. + "UPRN": [p.get("UPRN") for p in properties], + "Property number or name": [ + p.get("Property number or name") for p in properties + ], "Leasehold/Freehold": [p["Leasehold/Freehold"] for p in properties], "Property type": [p["Property type"] for p in properties], "Property sub-type": [p["Property sub-type"] for p in properties], @@ -149,6 +157,8 @@ def write_parquet(properties: list[dict], path: Path) -> None: "Inferred postcode": pl.Utf8, "Listing raw address": pl.Utf8, "Address per Property Register": pl.Utf8, + "UPRN": pl.Utf8, + "Property number or name": pl.Utf8, "Leasehold/Freehold": pl.Utf8, "Property type": pl.Utf8, "Property sub-type": pl.Utf8, diff --git a/finder/test_onthemarket.py b/finder/test_onthemarket.py new file mode 100644 index 0000000..4e0b3dd --- /dev/null +++ b/finder/test_onthemarket.py @@ -0,0 +1,206 @@ +"""Tests for the OnTheMarket scraper's detail-page postcode recovery. + +`parse_detail_postcode` is pure (takes the detail-page HTML, returns a postcode +or None), so these tests use a trimmed but faithful copy of a real OnTheMarket +detail page's `__NEXT_DATA__` payload. The fixture mirrors the live structure: +the property's own postcode lives in the analytics dataLayer +(`props.initialReduxState.metadata.dataLayer.postcode`) while the agent's office +postcode sits separately under `…property.agent.postcode` — the trap we must not +fall into. +""" + +import json + +import onthemarket +from onthemarket import parse_detail_postcode, transform_property + + +class _StubIndex: + """Minimal stand-in for PostcodeSpatialIndex returning a fixed postcode.""" + + def __init__(self, postcode: str | None): + self._postcode = postcode + + def nearest(self, lat: float, lng: float) -> str | None: + return self._postcode + + +def _detail_html( + *, + property_id: int = 19522441, + datalayer_postcode: str = "SE5 9AA", + agent_postcode: str = "SE5 8RS", +) -> str: + """Build detail-page HTML with a real-shaped __NEXT_DATA__ payload.""" + next_data = { + "props": { + "initialReduxState": { + "metadata": { + "dataLayer": { + "page-type": "details-section", + "property-type": "homes", + # The property's own unit postcode. + "postcode": datalayer_postcode, + "property-id": property_id, + "price": "275,000", + "addressline_2": "Padfield Road", + } + }, + "property": { + "displayAddress": "Padfield Road, London, SE5", + "location": {"lon": -0.100233, "lat": 51.466129}, + # The agent block carries the AGENT'S office postcode — the + # trap. parse_detail_postcode must not return this. + "agent": { + "address": "29 Denmark Hill, Camberwell\nLondon\nSE5 8RS", + "postcode": agent_postcode, + }, + }, + } + } + } + payload = json.dumps(next_data) + return ( + "" + '" + ) + + +# --------------------------------------------------------------------------- +# parse_detail_postcode +# --------------------------------------------------------------------------- + + +def test_parse_returns_property_postcode_not_agent(): + html = _detail_html(datalayer_postcode="SE5 9AA", agent_postcode="SE5 8RS") + assert parse_detail_postcode(html, "19522441") == "SE5 9AA" + + +def test_parse_normalizes_spacing(): + html = _detail_html(datalayer_postcode="se59aa") + assert parse_detail_postcode(html, "19522441") == "SE5 9AA" + + +def test_parse_ignores_mismatched_property_id(): + # dataLayer postcode belongs to property 19522441; asking for a different + # listing id must refuse to return it. + html = _detail_html(property_id=19522441) + assert parse_detail_postcode(html, "99999999") is None + + +def test_parse_accepts_when_no_listing_id_given(): + html = _detail_html(datalayer_postcode="SE5 9AA") + assert parse_detail_postcode(html, None) == "SE5 9AA" + + +def test_parse_handles_missing_postcode(): + html = _detail_html(datalayer_postcode="") + assert parse_detail_postcode(html, "19522441") is None + + +def test_parse_handles_no_next_data(): + assert parse_detail_postcode("no script here", "1") is None + + +def test_parse_handles_empty_html(): + assert parse_detail_postcode("", "1") is None + + +def test_parse_handles_malformed_json(): + html = ( + '' + ) + assert parse_detail_postcode(html, "1") is None + + +def test_parse_handles_missing_datalayer(): + next_data = {"props": {"initialReduxState": {"metadata": {}}}} + html = ( + '" + ) + assert parse_detail_postcode(html, "1") is None + + +# --------------------------------------------------------------------------- +# transform_property — detail postcode wiring + trust rule +# --------------------------------------------------------------------------- + + +_RAW_LISTING = { + "id": "19522441", + "address": "Padfield Road, London, SE5", + "location": {"lon": -0.100233, "lat": 51.466129}, + "bedrooms": 2, + "bathrooms": 1, + "price": "£275,000", + "humanised-property-type": "Apartment", + "features": ["Tenure: Leasehold (99 years remaining)"], + "details-url": "/details/19522441/", +} + + +def test_transform_uses_trusted_detail_postcode(): + # Detail postcode SE5 9AA, coordinate-nearest SE5 1AA: same outcode -> trust + # the (more precise) detail postcode and label it detail_address. + index = _StubIndex("SE5 1AA") + out = transform_property(_RAW_LISTING, index, detail_postcode="SE5 9AA") + assert out is not None + assert out["Postcode"] == "SE5 9AA" + assert out["Postcode source"] == "detail_address" + + +def test_transform_rejects_detail_postcode_on_outcode_mismatch(): + # Detail postcode SW9 6BZ but coordinate-nearest is SE5 1AA: different + # outcode -> reject the detail postcode, fall back to coordinate logic. + index = _StubIndex("SE5 1AA") + out = transform_property(_RAW_LISTING, index, detail_postcode="SW9 6BZ") + assert out is not None + assert out["Postcode"] == "SE5 1AA" + assert out["Postcode source"] == "coordinates" + + +def test_transform_without_detail_postcode_uses_coordinates(): + index = _StubIndex("SE5 1AA") + out = transform_property(_RAW_LISTING, index, detail_postcode=None) + assert out is not None + assert out["Postcode"] == "SE5 1AA" + assert out["Postcode source"] == "coordinates" + # No UPRN / house number is recoverable from OnTheMarket. + assert out["UPRN"] is None + assert out["Property number or name"] is None + + +def test_transform_detail_postcode_via_search_address_outcode(): + # When the card address already carries a full postcode that agrees with the + # coordinates, the existing "address" source still wins absent a detail + # postcode — detail recovery never regresses that path. + raw = dict(_RAW_LISTING, address="Padfield Road, London, SE5 1AA") + index = _StubIndex("SE5 1AA") + out = transform_property(raw, index, detail_postcode=None) + assert out["Postcode"] == "SE5 1AA" + assert out["Postcode source"] == "address" + + +# --------------------------------------------------------------------------- +# _fetch_detail_postcode caching (no real network) +# --------------------------------------------------------------------------- + + +def test_fetch_detail_postcode_is_cached(monkeypatch): + onthemarket._detail_postcode_cache.clear() + onthemarket._detail_postcode_cache["19522441"] = "SE5 9AA" + + def _boom(*args, **kwargs): # pragma: no cover - must never be called + raise AssertionError("network was hit despite a cached value") + + # Any httpx use would explode; the cache hit must short-circuit first. + result = onthemarket._fetch_detail_postcode( + client=type("C", (), {"get": _boom})(), + details_url="/details/19522441/", + listing_id="19522441", + ) + assert result == "SE5 9AA" + onthemarket._detail_postcode_cache.clear() diff --git a/finder/test_rightmove.py b/finder/test_rightmove.py new file mode 100644 index 0000000..2ea382b --- /dev/null +++ b/finder/test_rightmove.py @@ -0,0 +1,113 @@ +"""Tests for the Rightmove detail-page postcode extractor. + +The search API only returns an outcode-level ``displayAddress``; the property's +TRUE full postcode lives on its detail page inside ``window.__PAGE_MODEL`` as +``propertyData.address.{outcode, incode}``. ``parse_detail_postcode`` recovers +it. These tests build a faithful __PAGE_MODEL: a devalue-style flattened object +graph whose ``data`` field is a JSON STRING of a flat array where every integer +inside a container is an index reference into that same array. +""" + +import json + +from rightmove import _extract_page_model_literal, parse_detail_postcode + + +def _page_model_html(flat: list, *, encoding: str = "json") -> str: + """Wrap a flattened object-graph array in a realistic detail-page \n" + "" + ) + + +# A faithful slice of a real listing: root -> propertyData -> address, with a +# decoy nearestStations array (which carries NO postcodes on the live page) to +# prove the parser anchors on the property's own address, not a nearby POI. +_FLAT_SW9 = [ + {"propertyData": 1}, # 0: root + { + "id": "89089584", + "address": 2, + "location": 4, + "nearestStations": 6, + }, # 1: propertyData + { + "displayAddress": "Caldwell Street, Stockwell", + "countryCode": "GB", + "ukCountry": "England", + "outcode": "SW9", + "incode": "0HD", + }, # 2: address + None, # 3: filler + { + "latitude": 51.477238, + "longitude": -0.116819, + "pinType": "ACCURATE_POINT", + }, # 4: location + None, # 5: filler + [7, 8], # 6: nearestStations (references) + {"name": "Oval Station", "distance": 0.36}, # 7: station, no postcode + {"name": "Stockwell Station", "distance": 0.41}, # 8: station, no postcode +] + + +def test_parses_full_postcode_from_outcode_and_incode() -> None: + html = _page_model_html(_FLAT_SW9) + assert parse_detail_postcode(html) == "SW9 0HD" + + +def test_extract_page_model_literal_brace_matches_nested_object() -> None: + # The literal must include the whole nested object, not stop at the first + # closing brace inside the escaped data string. + html = _page_model_html(_FLAT_SW9) + literal = _extract_page_model_literal(html) + assert literal is not None + assert literal.startswith("{") and literal.endswith("}") + # Round-trips back to a dict with the expected top-level keys. + assert set(json.loads(literal)) == {"data", "encoding"} + + +def test_normalises_unspaced_incode() -> None: + flat = [dict(node) if isinstance(node, dict) else node for node in _FLAT_SW9] + flat[2] = {**_FLAT_SW9[2], "outcode": "e20", "incode": "1fh"} + assert parse_detail_postcode(_page_model_html(flat)) == "E20 1FH" + + +def test_returns_none_when_address_missing() -> None: + # The location wrapper can be empty/absent on some listings; the caller then + # keeps the coordinate fallback, so we must return None (not raise). + flat = [ + {"propertyData": 1}, + {"id": "1", "location": 2}, + {"latitude": 51.5, "longitude": -0.1}, + ] + assert parse_detail_postcode(_page_model_html(flat)) is None + + +def test_returns_none_when_incode_blank() -> None: + flat = [dict(node) if isinstance(node, dict) else node for node in _FLAT_SW9] + flat[2] = {**_FLAT_SW9[2], "incode": ""} + assert parse_detail_postcode(_page_model_html(flat)) is None + + +def test_returns_none_for_non_postcode_pair() -> None: + # A structurally-invalid outcode/incode pair is rejected by the validator. + flat = [dict(node) if isinstance(node, dict) else node for node in _FLAT_SW9] + flat[2] = {**_FLAT_SW9[2], "outcode": "NOTAPC", "incode": "ZZ"} + assert parse_detail_postcode(_page_model_html(flat)) is None + + +def test_returns_none_without_page_model() -> None: + assert parse_detail_postcode("") is None + assert parse_detail_postcode("no model") is None + # Malformed JSON in the data field degrades gracefully. + broken = '' + assert parse_detail_postcode(broken) is None diff --git a/finder/test_transform.py b/finder/test_transform.py index c90296b..1da0a7e 100644 --- a/finder/test_transform.py +++ b/finder/test_transform.py @@ -1,13 +1,19 @@ from transform import ( + build_register_address, clean_listing_address, extract_full_postcode, + extract_outcode, + resolve_listing_postcode, transform_property, ) class StubPostcodeIndex: + def __init__(self, postcode: str = "SW1A 9ZZ") -> None: + self._postcode = postcode + def nearest(self, lat: float, lng: float) -> str: - return "SW1A 9ZZ" + return self._postcode def test_extract_full_postcode_normalizes_spacing() -> None: @@ -24,6 +30,46 @@ def test_clean_listing_address_removes_postcode_and_outcode_suffixes() -> None: assert clean_listing_address("Kings Avenue, Bromley") == "Kings Avenue, Bromley" +def test_build_register_address_prepends_house_number_or_name() -> None: + # House number/name prepended, with the trailing outcode/postcode stripped. + assert ( + build_register_address("South Street, Bromley BR1", "12") + == "12, South Street, Bromley" + ) + assert ( + build_register_address("Riverside, Martham NR29", "Martham Mill") + == "Martham Mill, Riverside, Martham" + ) + # No number/name -> identical to the plain cleaned address. + assert build_register_address("Kings Avenue, Bromley", None) == "Kings Avenue, Bromley" + # Already starts with the number/name -> no duplication. + assert ( + build_register_address("12 South Street, Bromley", "12") + == "12 South Street, Bromley" + ) + # Empty/whitespace number/name is ignored. + assert build_register_address("Kings Avenue, Bromley", " ") == "Kings Avenue, Bromley" + + +def test_extract_outcode() -> None: + assert extract_outcode("SW1A 2AA") == "SW1A" + assert extract_outcode("n4 2ha") == "N4" + assert extract_outcode("SW1A2AA") == "SW1A" + assert extract_outcode(None) is None + assert extract_outcode("") is None + + +def test_resolve_listing_postcode() -> None: + # Outcode matches -> trust the more precise extracted postcode. + assert resolve_listing_postcode("SW1A 2AA", "SW1A 9ZZ") == ("SW1A 2AA", "address") + # Outcode mismatch -> fall back to the spatially-correct inferred postcode. + assert resolve_listing_postcode("E14 9SS", "SW1A 9ZZ") == ("SW1A 9ZZ", "coordinates") + # Well-formed but fabricated postcode in a different outcode is rejected. + assert resolve_listing_postcode("ZZ9 9ZZ", "SW1A 9ZZ") == ("SW1A 9ZZ", "coordinates") + # No extracted postcode -> inferred is authoritative. + assert resolve_listing_postcode(None, "SW1A 9ZZ") == ("SW1A 9ZZ", "coordinates") + + def test_rightmove_transform_prefers_postcode_from_display_address() -> None: prop = { "id": "123", @@ -46,3 +92,84 @@ def test_rightmove_transform_prefers_postcode_from_display_address() -> None: assert result["Inferred postcode"] == "SW1A 9ZZ" assert result["Listing raw address"] == "Flat 2, 10 Downing Street, SW1A 2AA" assert result["Address per Property Register"] == "Flat 2, 10 Downing Street" + + +def test_rightmove_transform_rejects_postcode_from_wrong_outcode() -> None: + prop = { + "id": "124", + "location": {"latitude": 51.5, "longitude": -0.1}, + "price": {"amount": 750000, "displayPrices": []}, + "propertySubType": "Terraced", + "bedrooms": 3, + "bathrooms": 1, + "keyFeatures": [], + "propertyUrl": "/properties/124", + # Address postcode is in a different outcode than the coordinate-nearest one. + "displayAddress": "10 Downing Street, E14 9SS", + } + + result = transform_property(prop, "SW1A", StubPostcodeIndex()) + + assert result is not None + # The spatially-correct inferred postcode wins over the mismatching extracted one. + assert result["Postcode"] == "SW1A 9ZZ" + assert result["Postcode source"] == "coordinates" + assert result["Extracted postcode"] == "E14 9SS" + + +def _rightmove_prop() -> dict: + return { + "id": "200", + "location": {"latitude": 51.5, "longitude": -0.1}, + "price": {"amount": 750000, "displayPrices": []}, + "propertySubType": "Terraced", + "bedrooms": 3, + "bathrooms": 1, + "keyFeatures": [], + "propertyUrl": "/properties/200", + # Search API only ever exposes the outcode in the display address. + "displayAddress": "Caldwell Street, Stockwell, SW9", + } + + +def test_rightmove_transform_prefers_detail_postcode() -> None: + # The detail page's true full postcode (same outcode as the location) is + # preferred over the coordinate-nearest guess. + result = transform_property( + _rightmove_prop(), + "SW9", + StubPostcodeIndex("SW9 7AA"), + detail_postcode="SW9 0HD", + ) + + assert result is not None + assert result["Postcode"] == "SW9 0HD" + assert result["Postcode source"] == "detail_address" + # The coordinate inference is still surfaced separately. + assert result["Inferred postcode"] == "SW9 7AA" + + +def test_rightmove_transform_rejects_detail_postcode_from_wrong_outcode() -> None: + # A detail postcode whose outcode disagrees with the location must not + # relocate the listing; the coordinate postcode wins instead. + result = transform_property( + _rightmove_prop(), + "SW9", + StubPostcodeIndex("SW9 7AA"), + detail_postcode="E14 9SS", + ) + + assert result is not None + assert result["Postcode"] == "SW9 7AA" + assert result["Postcode source"] == "coordinates" + + +def test_rightmove_transform_without_detail_keeps_coordinate_logic() -> None: + # No detail postcode -> behaviour is unchanged (coordinate-nearest). + result = transform_property( + _rightmove_prop(), "SW9", StubPostcodeIndex("SW9 7AA") + ) + + assert result is not None + assert result["Postcode"] == "SW9 7AA" + assert result["Postcode source"] == "coordinates" diff --git a/finder/test_zoopla.py b/finder/test_zoopla.py new file mode 100644 index 0000000..228e21b --- /dev/null +++ b/finder/test_zoopla.py @@ -0,0 +1,288 @@ +from zoopla import _detail_cache_key, parse_detail_geo, transform_property + + +def test_detail_cache_key_uses_listing_id() -> None: + assert _detail_cache_key("/for-sale/details/59888978/") == "59888978" + assert _detail_cache_key("https://www.zoopla.co.uk/for-sale/details/59888978/") == "59888978" + # No id in the URL -> fall back to the URL itself as the key. + assert _detail_cache_key("/for-sale/property/br1/") == "/for-sale/property/br1/" + + +class StubPostcodeIndex: + """Spatial index stub whose nearest-lookup returns a fixed postcode.""" + + def __init__(self, postcode: str = "BR1 2AB") -> None: + self._postcode = postcode + + def nearest(self, lat: float, lng: float) -> str: + return self._postcode + + +# London-ish postcodes with coordinates, plus the Norfolk sample used by the +# verified detail-page snippet (well inside the England bounds check). +PC_COORDS = { + "BR1 2AB": (51.40, 0.01), + "SW1A 1AA": (51.50, -0.14), + "NR29 4RG": (52.716014, 1.614495), +} + +# Verified RSC `location` object (listing 59888978), as it appears escaped inside +# a self.__next_f flight chunk in page.content(). +_LOCATION_ESCAPED = ( + '' +) + + +def test_parse_detail_geo_location_object_escaped() -> None: + geo = parse_detail_geo(_LOCATION_ESCAPED, search_outcode="NR29") + assert geo == { + "lat": 52.716014, + "lng": 1.614495, + "postcode": "NR29 4RG", + "outcode": "NR29", + "source": "detail_location", + "uprn": "10023461458", + "number_or_name": "Martham Mill", + # No `address` twin in this snippet, so there is no full street address. + "full_address": None, + } + + +def test_parse_detail_geo_location_object_unescaped() -> None: + html = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":52.716014,"longitude":1.614495},' + '"uprn":"10023461458","postalCode":"NR29 4RG"}' + ) + geo = parse_detail_geo(html) + assert geo is not None + assert geo["source"] == "detail_location" + assert geo["postcode"] == "NR29 4RG" + + +def test_parse_detail_geo_address_twin() -> None: + html = ( + '"address":{"fullAddress":"Riverside, Martham NR29",' + '"latitude":52.716014,"longitude":1.614495,' + '"outcode":"NR29","postcode":"NR29 4RG","uprn":"10023461458"}' + ) + geo = parse_detail_geo(html) + assert geo is not None + assert geo["source"] == "detail_address_obj" + assert (geo["lat"], geo["lng"], geo["postcode"]) == (52.716014, 1.614495, "NR29 4RG") + assert geo["uprn"] == "10023461458" + assert geo["full_address"] == "Riverside, Martham NR29" + + +def test_parse_detail_geo_merges_location_uprn_with_address_full_address() -> None: + # Real detail pages carry both wrappers: the `location` object holds the + # uprn + house number/name, the `address` twin holds the full street + # address. They share a uprn, so the twin's fullAddress is attached. + html = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":52.716014,"longitude":1.614495},' + '"uprn":"10023461458","postalCode":"NR29 4RG",' + '"propertyNumberOrName":"Martham Mill"}' + '"address":{"fullAddress":"Riverside, Martham NR29",' + '"latitude":52.716014,"longitude":1.614495,' + '"outcode":"NR29","postcode":"NR29 4RG","uprn":"10023461458"}' + ) + geo = parse_detail_geo(html) + assert geo is not None + assert geo["source"] == "detail_location" + assert geo["uprn"] == "10023461458" + assert geo["number_or_name"] == "Martham Mill" + assert geo["full_address"] == "Riverside, Martham NR29" + + +def test_parse_detail_geo_does_not_borrow_comparable_full_address() -> None: + # The only `address` twin on the page belongs to a different uprn (a + # comparable listing). With a uprn to match on, an unrelated twin is never + # borrowed — full_address stays None rather than grabbing the wrong street. + html = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":52.716014,"longitude":1.614495},' + '"uprn":"10023461458","postalCode":"NR29 4RG"}' + '"address":{"fullAddress":"Some Comparable, Elsewhere EN2",' + '"latitude":51.65,"longitude":-0.08,"uprn":"99999999"}' + ) + geo = parse_detail_geo(html) + assert geo is not None + assert geo["uprn"] == "10023461458" + assert geo["full_address"] is None + + +def test_parse_detail_geo_ignores_poi_coordinates() -> None: + # A charger POI (its coordinates NOT wrapped in a "location" object) followed + # by the property's own "location" wrapper. Anchoring on the wrapper means + # the POI's coordinates are ignored and the property's are returned. + poi = ( + '"name":"Martham Community Centre","numberOfConnectors":2,' + '"postcode":"NR29 4SN","coordinates":{"latitude":52.699379,"longitude":1.62921}' + ) + prop = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":52.716014,"longitude":1.614495},' + '"uprn":"10023461458","postalCode":"NR29 4RG"}' + ) + geo = parse_detail_geo(poi + prop) + assert geo is not None + assert geo["source"] == "detail_location" + # The property's coords win, not the community centre's. + assert (geo["lat"], geo["lng"]) == (52.716014, 1.614495) + assert geo["postcode"] == "NR29 4RG" + + +def test_parse_detail_geo_prefers_location_matching_search_outcode() -> None: + # Page embeds two location objects (e.g. a comparable then the property). + # With a search outcode, the one in that outcode is preferred; without one, + # the first (document order = primary listing) is returned. + comparable = ( + '"location":{"outcode":"EN2",' + '"coordinates":{"latitude":51.65,"longitude":-0.08},' + '"postalCode":"EN2 6SN"}' + ) + target = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":52.716014,"longitude":1.614495},' + '"postalCode":"NR29 4RG"}' + ) + geo = parse_detail_geo(comparable + target, search_outcode="NR29") + assert geo is not None and geo["postcode"] == "NR29 4RG" + geo_first = parse_detail_geo(comparable + target) + assert geo_first is not None and geo_first["postcode"] == "EN2 6SN" + + +def test_parse_detail_geo_rejects_out_of_england() -> None: + html = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":10.0,"longitude":10.0},' + '"uprn":"1","postalCode":"NR29 4RG"}' + ) + assert parse_detail_geo(html) is None + + +def test_parse_detail_geo_drops_inconsistent_postcode() -> None: + # postalCode outcode (AB12) disagrees with the object's own outcode (NR29): + # keep the coordinates, drop the untrustworthy postcode. + html = ( + '"location":{"outcode":"NR29",' + '"coordinates":{"latitude":52.716014,"longitude":1.614495},' + '"uprn":"1","postalCode":"AB12 3CD"}' + ) + geo = parse_detail_geo(html) + assert geo is not None + assert geo["lat"] == 52.716014 + assert geo["postcode"] is None + + +def test_parse_detail_geo_returns_none_for_garbage() -> None: + assert parse_detail_geo("no data here") is None + assert parse_detail_geo("") is None + # Coordinates that are not inside a property location/address wrapper (e.g. + # only an unwrapped POI) yield nothing — safe degradation to the outcode. + assert parse_detail_geo('"name":"X","coordinates":{"latitude":51.5,"longitude":-0.1}') is None + + +def _raw(**overrides) -> dict: + raw = { + "id": "123", + "url": "/for-sale/details/123/", + "address": "South Street, Bromley BR1", + "price": 500000, + "beds": 2, + "baths": 1, + "property_type": "Flat", + } + raw.update(overrides) + return raw + + +def test_transform_uses_detail_coordinates_with_agreeing_postcode() -> None: + detail = {"lat": 51.401, "lng": 0.011, "postcode": "BR1 3CD", "outcode": "BR1"} + result = transform_property( + _raw(), StubPostcodeIndex("BR1 2AB"), PC_COORDS, search_outcode="BR1", detail=detail + ) + assert result is not None + # Extracted detail postcode agrees with the coordinate-nearest outcode -> trusted. + assert result["Postcode"] == "BR1 3CD" + assert result["Postcode source"] == "detail_address" + assert result["Inferred postcode"] == "BR1 2AB" + assert (result["lat"], result["lon"]) == (51.401, 0.011) + + +def test_transform_uses_nearest_when_detail_postcode_mismatches() -> None: + detail = {"lat": 51.401, "lng": 0.011, "postcode": "E14 9SS", "outcode": "E14"} + result = transform_property( + _raw(), StubPostcodeIndex("BR1 2AB"), PC_COORDS, search_outcode="BR1", detail=detail + ) + assert result is not None + # Mismatching detail postcode is rejected in favour of the spatial value. + assert result["Postcode"] == "BR1 2AB" + assert result["Postcode source"] == "detail_coordinates" + + +def test_transform_geocodes_detail_postcode_without_coordinates() -> None: + detail = {"lat": None, "lng": None, "postcode": "SW1A 1AA", "outcode": "SW1A"} + result = transform_property( + _raw(), StubPostcodeIndex(), PC_COORDS, search_outcode="BR1", detail=detail + ) + assert result is not None + assert result["Postcode"] == "SW1A 1AA" + assert result["Postcode source"] == "detail_address" + assert (result["lat"], result["lon"]) == PC_COORDS["SW1A 1AA"] + + +def test_transform_without_detail_falls_back_to_search_outcode() -> None: + # No detail, address has no recognizable outcode -> coarse search-outcode centroid. + result = transform_property( + _raw(address="A street with no postcode"), + StubPostcodeIndex(), + PC_COORDS, + search_outcode="BR1", + detail=None, + ) + assert result is not None + assert result["Postcode"] == "BR1 2AB" + assert result["Postcode source"] == "search_outcode" + # No detail page -> no UPRN / house number recovered. + assert result["UPRN"] is None + assert result["Property number or name"] is None + + +def test_transform_emits_uprn_and_house_numbered_address_from_detail() -> None: + detail = { + "lat": 51.401, + "lng": 0.011, + "postcode": "BR1 3CD", + "outcode": "BR1", + "uprn": "100023461458", + "number_or_name": "12", + "full_address": "South Street, Bromley BR1", + } + result = transform_property( + _raw(), StubPostcodeIndex("BR1 2AB"), PC_COORDS, search_outcode="BR1", detail=detail + ) + assert result is not None + assert result["UPRN"] == "100023461458" + assert result["Property number or name"] == "12" + # The detail full address replaces the outcode-level card address, and the + # house number is prepended for a near-exact Property Register match. + assert result["Listing raw address"] == "South Street, Bromley BR1" + assert result["Address per Property Register"] == "12, South Street, Bromley" + + +def test_transform_ignores_out_of_england_detail_coords() -> None: + detail = {"lat": 10.0, "lng": 10.0, "postcode": "ZZ9 9ZZ", "outcode": "ZZ9"} + result = transform_property( + _raw(), StubPostcodeIndex("BR1 2AB"), PC_COORDS, search_outcode="BR1", detail=detail + ) + assert result is not None + # Bad detail coords are discarded; falls through to the address outcode (BR1). + assert result["Postcode source"] == "address_outcode" + assert 49 <= result["lat"] <= 56 diff --git a/finder/transform.py b/finder/transform.py index 8c1f357..49986ba 100644 --- a/finder/transform.py +++ b/finder/transform.py @@ -205,6 +205,41 @@ def extract_full_postcode(text: str | None) -> str | None: return normalize_postcode(match.group(1)) +def extract_outcode(postcode: str | None) -> str | None: + """Return the outward code (district) of a UK postcode, e.g. 'SW1A 1AA' → 'SW1A'.""" + if not postcode: + return None + normalized = normalize_postcode(postcode) + outcode = normalized.split(" ", 1)[0] + return outcode or None + + +def resolve_listing_postcode( + extracted_postcode: str | None, inferred_postcode: str +) -> tuple[str, str]: + """Pick the authoritative postcode for a listing, returning (postcode, source). + + The address-extracted postcode is more precise than the coordinate-nearest one, + but it is only trustworthy when it agrees with the location: a stale, mistyped or + well-formed-but-fabricated postcode (e.g. 'ZZ9 9ZZ') would otherwise silently + override the spatially-correct value. Since the spatial index only supports + nearest-lookup, accept the extracted postcode only when its outcode matches the + inferred (coordinate-nearest) postcode's outcode; otherwise fall back to the + inferred one, which is always a real, plausibly-correct postcode. + """ + if extracted_postcode and extract_outcode(extracted_postcode) == extract_outcode( + inferred_postcode + ): + return extracted_postcode, "address" + if extracted_postcode: + log.debug( + "Rejecting extracted postcode %s (outcode mismatch with inferred %s)", + extracted_postcode, + inferred_postcode, + ) + return inferred_postcode, "coordinates" + + def clean_listing_address(address: str | None) -> str: """Remove postcode/outcode suffixes from listing display addresses. @@ -222,10 +257,48 @@ def clean_listing_address(address: str | None) -> str: return cleaned.strip(" ,") +def build_register_address( + raw_address: str | None, number_or_name: str | None = None +) -> str: + """Build a Property Register-style address, prepending the house number/name. + + Listing display addresses are usually street-level ("South Street, Bromley") + because the portals hide the exact unit. When a scraper can recover the + property's own number or name (e.g. Zoopla detail pages expose + ``propertyNumberOrName`` = "12" or "Martham Mill"), prepend it so the address + carries the house identifier that the EPC/Price-Paid register addresses also + use — turning a fuzzy street match into a near-exact one. Falls back to the + plain cleaned address when no number/name is available. + """ + cleaned = clean_listing_address(raw_address) + if not number_or_name: + return cleaned + number_or_name = number_or_name.strip() + if not number_or_name: + return cleaned + # Avoid duplicating a number/name the display address already starts with. + if cleaned.lower().startswith(number_or_name.lower()): + return cleaned + return f"{number_or_name}, {cleaned}" if cleaned else number_or_name + + def transform_property( - prop: dict, outcode: str, pc_index: PostcodeSpatialIndex + prop: dict, + outcode: str, + pc_index: PostcodeSpatialIndex, + detail_postcode: str | None = None, ) -> dict | None: - """Transform a raw Rightmove property dict into our output schema.""" + """Transform a raw Rightmove property dict into our output schema. + + ``detail_postcode`` is the property's TRUE full postcode recovered from its + detail page (see ``rightmove.parse_detail_postcode``); the search API itself + only exposes the outcode-level ``displayAddress``. When supplied and it + agrees with the coordinate-nearest postcode's outcode, it is preferred over + the coordinate guess and recorded with source ``"detail_address"``. A + detail postcode whose outcode disagrees with the location is discarded in + favour of the spatially-correct coordinate postcode, so a stale or wrong + detail value can never silently relocate a listing. + """ loc = prop.get("location") if not loc: return None @@ -268,8 +341,25 @@ def transform_property( return None raw_address = prop.get("displayAddress", "") or "" extracted_postcode = extract_full_postcode(raw_address) - postcode = extracted_postcode or inferred_postcode - postcode_source = "address" if extracted_postcode else "coordinates" + + # Prefer the detail page's true full postcode when it agrees with the + # location; otherwise fall back to the (display-address-or-coordinate) logic. + detail_full = extract_full_postcode(detail_postcode) + if detail_full and extract_outcode(detail_full) == extract_outcode( + inferred_postcode + ): + postcode, postcode_source = detail_full, "detail_address" + else: + if detail_full: + log.debug( + "Rejecting Rightmove detail postcode %s (outcode mismatch with " + "inferred %s)", + detail_full, + inferred_postcode, + ) + postcode, postcode_source = resolve_listing_postcode( + extracted_postcode, inferred_postcode + ) property_url = prop.get("propertyUrl") or "" if not isinstance(property_url, str): @@ -291,6 +381,9 @@ def transform_property( "Inferred postcode": inferred_postcode, "Listing raw address": raw_address, "Address per Property Register": clean_listing_address(raw_address), + # Rightmove's displayAddress is street-level; no UPRN/house number. + "UPRN": None, + "Property number or name": None, "Leasehold/Freehold": extract_tenure(prop.get("tenure")), "Property type": map_property_type(sub_type), "Property sub-type": normalize_sub_type(sub_type), diff --git a/finder/zoopla.py b/finder/zoopla.py index d36bc21..6cffe5f 100644 --- a/finder/zoopla.py +++ b/finder/zoopla.py @@ -32,16 +32,24 @@ import httpx from constants import ( DATA_DIR, DELAY_BETWEEN_PAGES, + GLUETUN_API_KEY, + GLUETUN_CONTROL_URL, + GLUETUN_MAX_ROTATIONS, + GLUETUN_PROXY, MAX_BEDROOMS, PROPERTY_TYPE_MAP, ZOOPLA_BASE, + ZOOPLA_DETAIL_GOTO_TIMEOUT_MS, ) from spatial import PostcodeSpatialIndex from transform import ( - clean_listing_address, + build_register_address, extract_full_postcode, + extract_outcode, + fix_coords, normalize_sub_type, parse_int_value, + resolve_listing_postcode, validate_floor_area, ) @@ -468,27 +476,20 @@ def _challenge_timeout_seconds() -> int: # cookies (bound to the previous IP), then reload and re-check the challenge. -_GLUETUN_API_KEY = "My8AbvnKhfyFdRhpTVfoTfa5DkAMmg8K" - - def _gluetun_base_url() -> str: - return os.environ.get("GLUETUN_URL", "http://gluetun:8000").rstrip("/") + return GLUETUN_CONTROL_URL.rstrip("/") def _gluetun_api_key() -> str | None: - return _GLUETUN_API_KEY + return GLUETUN_API_KEY def _gluetun_max_rotations() -> int: - raw = os.environ.get("GLUETUN_MAX_ROTATIONS", "3") - try: - value = int(raw) - except ValueError as exc: - raise ValueError("GLUETUN_MAX_ROTATIONS must be an integer") from exc - return max(value, 0) + return max(GLUETUN_MAX_ROTATIONS, 0) def _gluetun_client() -> httpx.Client: + # Talks to the control server directly (not through the VPN proxy). headers = {} api_key = _gluetun_api_key() if api_key: @@ -694,10 +695,19 @@ def launch_browser(): profile_dir.mkdir(parents=True, exist_ok=True) _remove_stale_profile_locks(profile_dir) + # Route the browser through the Gluetun VPN proxy when configured. (geoip + # fingerprint alignment is intentionally not enabled: it needs the optional + # camoufox[geoip] extra and would spoof to the VPN exit's country, which + # fights the en-GB locale unless the exit is in the UK.) + proxy_options: dict = {} + if GLUETUN_PROXY: + proxy_options = {"proxy": {"server": GLUETUN_PROXY}} + log.info( - "Launching Camoufox browser for Zoopla (headless=%s, profile=%s)...", + "Launching Camoufox browser for Zoopla (headless=%s, profile=%s, proxy=%s)...", headless_mode, profile_dir, + GLUETUN_PROXY or "direct", ) camoufox = Camoufox( headless=headless_mode, @@ -705,6 +715,7 @@ def launch_browser(): user_data_dir=str(profile_dir), locale=["en-GB", "en"], enable_cache=True, + **proxy_options, ) raw_browser = camoufox.__enter__() browser = _ManagedCamoufoxBrowser(camoufox, raw_browser) @@ -926,13 +937,47 @@ def _paginate( page, total_results: int, max_properties: int | None = None, + fetch_detail=None, + detail_cap: int = 0, + detail_state: dict | None = None, + detail_deadline: float | None = None, ) -> list[dict]: """Extract listings from all pages of search results. Page 1 is already loaded. For subsequent pages, follow Zoopla's rendered next link when present, otherwise advance via the pn=N URL parameter while - the advertised result count says more listings remain.""" + the advertised result count says more listings remain. + + When ``fetch_detail`` is supplied, each listing has its detail page fetched + (up to ``detail_cap`` fresh loads per outcode, counted in the shared + ``detail_state`` dict, and only until ``detail_deadline``) and the parsed + geo stored under ``listing['_detail']`` for ``transform_property``. The + detail page is the only source of the listing's UPRN, full street address + and precise postcode, so it is fetched even when the search card already + pins a full postcode. Cached detail results are always attached but cost + neither a cap slot nor a delay.""" + + def _maybe_fetch(listing: dict) -> None: + if fetch_detail is None or detail_state is None: + return + url = listing.get("url", "") + cached = _detail_cache_key(url) in _detail_cache + if not cached: + # Fresh loads are bounded by the per-outcode cap and the wall-clock + # deadline so detail fetching never starves the SIGALRM budget that + # also guards the search pagination for this outcode. + if detail_state["fetched"] >= detail_cap: + return + if detail_deadline is not None and time.monotonic() >= detail_deadline: + return + listing["_detail"] = fetch_detail(url) + if not cached: + detail_state["fetched"] += 1 + time.sleep(DELAY_BETWEEN_PAGES) + all_listings = _extract_listings(page) + for listing in all_listings: + _maybe_fetch(listing) if max_properties is not None and len(all_listings) >= max_properties: return all_listings[:max_properties] @@ -984,6 +1029,7 @@ def _paginate( if listing["id"] not in seen_ids: seen_ids.add(listing["id"]) all_listings.append(listing) + _maybe_fetch(listing) new_count += 1 if max_properties is not None and len(all_listings) >= max_properties: return all_listings[:max_properties] @@ -1053,6 +1099,214 @@ def _extract_outcode(text: str) -> str | None: return None +# --------------------------------------------------------------------------- +# Detail-page geocoding +# --------------------------------------------------------------------------- +# +# Zoopla search result cards only expose an outcode-level display address (e.g. +# "South Street, Bromley BR1"); the full postcode and precise coordinates exist +# only on each listing's detail page (/for-sale/details/{id}/). The detail page +# is a Next.js App Router route whose React Server Components flight stream +# embeds the property's own location object, e.g. +# "location":{"outcode":"NR29","coordinates":{"latitude":52.716,"longitude":1.614}, +# "uprn":"10023461458","postalCode":"NR29 4RG",...} +# plus a twin "address":{"fullAddress":...,"latitude":...,"longitude":..., +# "outcode":...,"postcode":...,"uprn":...} feeding the map widgets. +# Nearby points of interest (stations, schools, EV chargers) and comparable +# listings carry their own "coordinates" too, but never inside the property's +# own "location" / "address":{"fullAddress" wrapper — so the wrapper, not a +# loose coordinates object, is what we anchor on (see parse_detail_geo). + +# listingId -> parsed detail dict (or None). Failures are cached too, so a +# broken listing is not re-fetched within a run (the same listing reappears +# across overlapping outcode searches). +_detail_cache: dict[str, dict | None] = {} + +_LISTING_ID_RE = re.compile(r"/details/(\d+)/?") + +# The property's own location is carried by a `"location":{...}` wrapper and a +# twin `"address":{"fullAddress":...}` widget object. We anchor on those +# wrappers (and capture their full object body, which contains exactly one +# nested object — `coordinates`) rather than scanning for loose coordinate +# objects: nearby points of interest (stations/schools/EV chargers) and +# comparable/"similar" listings also embed coordinates, but never inside the +# property's own `"location"` / `"address":{"fullAddress"` wrapper, so the +# wrapper is the discriminator. Field order and an optional `uprn` are tolerated. +_DETAIL_LOCATION_RE = re.compile(r'"location":\{((?:[^{}]|\{[^{}]*\})*)\}') +_DETAIL_ADDRESS_RE = re.compile(r'"address":\{"fullAddress":"([^"]*)"((?:[^{}]|\{[^{}]*\})*)\}') +_DETAIL_COORDS_IN_BODY_RE = re.compile( + r'"coordinates":\{"latitude":(-?\d+\.\d+),"longitude":(-?\d+\.\d+)\}' +) +_DETAIL_LATLNG_IN_BODY_RE = re.compile( + r'"latitude":(-?\d+\.\d+),"longitude":(-?\d+\.\d+)' +) +_DETAIL_OUTCODE_IN_BODY_RE = re.compile(r'"outcode":"([A-Z0-9]+)"') +# The location object spells it "postalCode"; the address twin uses "postcode". +_DETAIL_POSTCODE_IN_BODY_RE = re.compile(r'"(?:postalCode|postcode)":"([A-Z0-9 ]+)"') +# The UPRN (Unique Property Reference Number) appears in both the location and +# address objects and is the linchpin for an exact listing->EPC join (EPC open +# data is ~99% UPRN-keyed). propertyNumberOrName carries the house number/name +# (e.g. "12", "Martham Mill") only in the location object. +_DETAIL_UPRN_IN_BODY_RE = re.compile(r'"uprn":"(\d+)"') +_DETAIL_NUMBER_OR_NAME_IN_BODY_RE = re.compile(r'"propertyNumberOrName":"([^"]*)"') + + +def parse_detail_geo(html: str, search_outcode: str | None = None) -> dict | None: + """Extract the property's own coordinates/postcode from a Zoopla detail page. + + Pure and browser-free: the live browser only produces the HTML string + (``page.content()``); this does the parsing so it is unit-testable. + + Returns ``{"lat", "lng", "postcode", "outcode", "source", "uprn", + "number_or_name", "full_address"}`` (every field except the coordinates may + be ``None``) or ``None`` when no property location wrapper is found. The + ``uprn`` enables an exact listing->EPC join; ``number_or_name`` (house + number/name) and ``full_address`` give a register-style address for the + Price Paid join. + Coordinates are bounds-checked to England and a postcode is kept only when + it agrees with its own object's outcode. ``search_outcode``, when given, is + used only as a tie-break to pick the right ``location`` object on pages that + also embed comparable listings. See module docstring for the data model.""" + if not html: + return None + + # RSC flight strings are embedded as escaped JS string literals, so quotes + # and slashes arrive escaped; normalize them so the regexes match. + buf = html.replace('\\"', '"').replace("\\u002F", "/").replace("\\/", "/") + + def in_england(lat: float, lng: float) -> tuple[float, float] | None: + lat, lng = fix_coords(lat, lng) + if 49 <= lat <= 56 and -7 <= lng <= 2: + return lat, lng + return None + + def build(body: str, coords, source: str, full_address: str | None = None) -> dict: + # outcode and postcode are read from the SAME object body as the coords, + # so the postcode is self-consistent; drop it only if it somehow isn't. + outcode_match = _DETAIL_OUTCODE_IN_BODY_RE.search(body) + outcode = outcode_match.group(1) if outcode_match else None + postcode_match = _DETAIL_POSTCODE_IN_BODY_RE.search(body) + postcode = extract_full_postcode(postcode_match.group(1)) if postcode_match else None + if postcode and outcode and extract_outcode(postcode) != outcode.upper(): + postcode = None + uprn_match = _DETAIL_UPRN_IN_BODY_RE.search(body) + number_match = _DETAIL_NUMBER_OR_NAME_IN_BODY_RE.search(body) + number_or_name = number_match.group(1).strip() if number_match else None + return { + "lat": coords[0], + "lng": coords[1], + "postcode": postcode, + "outcode": outcode, + "source": source, + "uprn": uprn_match.group(1) if uprn_match else None, + "number_or_name": number_or_name or None, + "full_address": full_address, + } + + def attach_full_address(result: dict | None) -> dict | None: + # The house-numbered street address lives in the `address` map-widget + # twin, not the `location` wrapper we anchor coordinates on. Pull it from + # the twin that shares this property's uprn; when there is no uprn to + # disambiguate, fall back to the first twin (document order = primary + # listing), but never guess a twin when a uprn exists and none matches — + # that would risk grabbing a comparable listing's address. + if result is None or result.get("full_address"): + return result + target = result.get("uprn") + first = None + for match in _DETAIL_ADDRESS_RE.finditer(buf): + full_address = match.group(1) or None + if full_address is None: + continue + if first is None: + first = full_address + uprn_match = _DETAIL_UPRN_IN_BODY_RE.search(match.group(2)) + if target and uprn_match and uprn_match.group(1) == target: + result["full_address"] = full_address + return result + if target is None: + result["full_address"] = first + return result + + # Strategy 1 — the property's own `location` wrapper (authoritative). Take + # the first match (the primary listing precedes any comparables in the + # flight stream), but prefer one whose outcode matches the searched outcode. + first_location = None + for match in _DETAIL_LOCATION_RE.finditer(buf): + body = match.group(1) + coords_match = _DETAIL_COORDS_IN_BODY_RE.search(body) + if not coords_match: + continue + coords = in_england(float(coords_match.group(1)), float(coords_match.group(2))) + if not coords: + continue + candidate = build(body, coords, "detail_location") + if first_location is None: + first_location = candidate + if ( + search_outcode + and candidate["outcode"] + and candidate["outcode"].upper() == search_outcode.upper() + ): + return attach_full_address(candidate) + if first_location is not None: + return attach_full_address(first_location) + + # Strategy 2 — the `address` map-widget twin (same coordinates, backup). + for match in _DETAIL_ADDRESS_RE.finditer(buf): + full_address = match.group(1) or None + body = match.group(2) + latlng_match = _DETAIL_LATLNG_IN_BODY_RE.search(body) + if not latlng_match: + continue + coords = in_england(float(latlng_match.group(1)), float(latlng_match.group(2))) + if coords: + return build(body, coords, "detail_address_obj", full_address=full_address) + + return None + + +def _detail_cache_key(listing_url: str) -> str: + """Cache key for a listing detail page — its numeric id when present.""" + id_match = _LISTING_ID_RE.search(listing_url) + return id_match.group(1) if id_match else listing_url + + +def _fetch_listing_detail( + detail_page, + listing_url: str, + search_outcode: str | None = None, +) -> dict | None: + """Load a listing detail page and return its parsed geo dict (or None). + + Results (including failures) are cached by listingId. Ordinary navigation + and extraction errors are swallowed so the caller can fall back to + outcode-level resolution, but TurnstileError is allowed to propagate so the + scraper's "Cloudflare ends the run" contract still holds. The goto timeout + is kept short so one slow detail page can't eat the per-outcode budget.""" + cache_key = _detail_cache_key(listing_url) + if cache_key in _detail_cache: + return _detail_cache[cache_key] + + url = listing_url if listing_url.startswith("http") else ZOOPLA_BASE + listing_url + result: dict | None = None + try: + detail_page.goto( + url, wait_until="domcontentloaded", timeout=ZOOPLA_DETAIL_GOTO_TIMEOUT_MS + ) + _ensure_not_challenged(detail_page) + html = detail_page.content() + result = parse_detail_geo(html, search_outcode=search_outcode) + except TurnstileError: + raise + except Exception as exc: + log.debug("Zoopla detail fetch failed %s: %s", url, _exception_detail(exc)) + result = None + + _detail_cache[cache_key] = result + return result + + def _map_property_type(raw_type: str | None) -> str: """Map Zoopla property type text to canonical type.""" if not raw_type: @@ -1109,28 +1363,64 @@ def transform_property( pc_index: PostcodeSpatialIndex, pc_coords: dict[str, tuple[float, float]], search_outcode: str | None = None, + detail: dict | None = None, ) -> dict | None: """Transform a raw Zoopla listing dict into the standard output schema. - Zoopla search cards do not include coordinates, so we resolve lat/lng - from postcodes extracted from the address text.""" + Zoopla search cards only expose an outcode-level address, so precise + location comes from the listing's detail page (see ``parse_detail_geo`` / + ``_fetch_listing_detail``), passed in as ``detail``. When detail-page + coordinates are available we resolve the nearest postcode via the spatial + index — mirroring rightmove/onthemarket — and only fall back to the coarse + outcode centroid when no detail location could be obtained.""" price = parse_int_value(raw.get("price")) or 0 address = raw.get("address", "") or "" - # Resolve postcode and coordinates from address extracted_postcode = extract_full_postcode(address) - postcode = extracted_postcode - postcode_source = "address" if extracted_postcode else None + detail = detail or {} + detail_postcode = extract_full_postcode(detail.get("postcode")) + # Detail-page address fields: the UPRN keys an exact EPC join, and the + # full street address / house number-or-name beat the outcode-level card + # address for the Price-Paid join. All three are absent unless the detail + # page was fetched, so every consumer must tolerate None. + detail_uprn = detail.get("uprn") or None + detail_full_address = detail.get("full_address") or None + detail_number_or_name = detail.get("number_or_name") or None + + postcode = postcode_source = inferred_postcode = None lat = lng = None - if postcode: - coords = pc_coords.get(postcode) - if coords: - lat, lng = coords + # (A) Best: detail-page coordinates -> nearest postcode (authoritative). + detail_lat, detail_lng = detail.get("lat"), detail.get("lng") + if detail_lat is not None and detail_lng is not None: + fixed_lat, fixed_lng = fix_coords(detail_lat, detail_lng) + if 49 <= fixed_lat <= 56 and -7 <= fixed_lng <= 2: + nearest = pc_index.nearest(fixed_lat, fixed_lng) + if nearest: + lat, lng, inferred_postcode = fixed_lat, fixed_lng, nearest + candidate = detail_postcode or extracted_postcode + postcode, resolved_source = resolve_listing_postcode(candidate, nearest) + postcode_source = ( + "detail_address" + if resolved_source == "address" + else "detail_coordinates" + ) + # (B) Detail-page postcode without usable coordinates -> geocode it. + if lat is None and detail_postcode and detail_postcode in pc_coords: + lat, lng = pc_coords[detail_postcode] + postcode = inferred_postcode = detail_postcode + postcode_source = "detail_address" + + # (C) Full postcode in the search-card address -> geocode it. + if lat is None and extracted_postcode and extracted_postcode in pc_coords: + lat, lng = pc_coords[extracted_postcode] + postcode = extracted_postcode + postcode_source = "address" + + # (D) Last resort: coarse outcode-level centroid (loses per-listing precision). if lat is None: - # Try outcode-level fallback from address text addr_outcode = _extract_outcode(address) if addr_outcode: result = _resolve_outcode_coords(addr_outcode, pc_coords) @@ -1138,7 +1428,6 @@ def transform_property( postcode, lat, lng = result postcode_source = "address_outcode" - # Final fallback: use the outcode we know we're searching if lat is None and search_outcode: result = _resolve_outcode_coords(search_outcode, pc_coords) if result: @@ -1188,9 +1477,17 @@ def transform_property( "Postcode": postcode, "Postcode source": postcode_source or "unknown", "Extracted postcode": extracted_postcode, - "Inferred postcode": postcode if postcode_source != "address" else None, - "Listing raw address": address, - "Address per Property Register": clean_listing_address(address), + "Inferred postcode": ( + inferred_postcode + if inferred_postcode is not None + else (postcode if postcode_source != "address" else None) + ), + "Listing raw address": detail_full_address or address, + "Address per Property Register": build_register_address( + detail_full_address or address, detail_number_or_name + ), + "UPRN": detail_uprn, + "Property number or name": detail_number_or_name, "Leasehold/Freehold": raw.get("tenure") or None, "Property type": _map_property_type(raw.get("property_type")), "Property sub-type": normalize_sub_type(raw.get("property_type")), @@ -1215,6 +1512,9 @@ def search_outcode( pc_index: PostcodeSpatialIndex, pc_coords: dict[str, tuple[float, float]], max_properties: int | None = None, + detail_page=None, + detail_cap: int = 0, + detail_budget_seconds: float | None = None, ) -> tuple[list[dict], str | None]: """Search Zoopla for properties in one outcode. @@ -1222,6 +1522,12 @@ def search_outcode( search flow, extracts listings from rendered DOM, and transforms to the standard output schema. + When ``detail_page`` (a second browser tab) and a positive ``detail_cap`` + are supplied, up to ``detail_cap`` listings per outcode have their detail + page fetched for a precise postcode (see ``_fetch_listing_detail``). + ``detail_budget_seconds`` caps the wall-clock time spent fetching details so + the per-outcode timeout that also guards search pagination is never starved. + Returns (properties, search_url). Raises TurnstileError if Cloudflare blocks us mid-session. @@ -1231,12 +1537,25 @@ def search_outcode( total_results = _get_result_count(page) + fetch_detail = None + detail_deadline = None + if detail_page is not None and detail_cap > 0: + fetch_detail = lambda url: _fetch_listing_detail( # noqa: E731 + detail_page, url, search_outcode=outcode + ) + if detail_budget_seconds is not None: + detail_deadline = time.monotonic() + detail_budget_seconds + # Always try extraction even if result count is 0 — the count regex may # not match Zoopla's current text format, but listings may still be in DOM raw_listings = _paginate( page, total_results, max_properties=max_properties, + fetch_detail=fetch_detail, + detail_cap=detail_cap, + detail_state={"fetched": 0}, + detail_deadline=detail_deadline, ) if not raw_listings: if total_results > 0: @@ -1252,7 +1571,11 @@ def search_outcode( for raw in raw_listings: try: transformed = transform_property( - raw, pc_index, pc_coords, search_outcode=outcode + raw, + pc_index, + pc_coords, + search_outcode=outcode, + detail=raw.get("_detail"), ) except Exception as exc: log.warning( diff --git a/finder/zoopla_flaresolverr.py b/finder/zoopla_flaresolverr.py new file mode 100644 index 0000000..f3e6860 --- /dev/null +++ b/finder/zoopla_flaresolverr.py @@ -0,0 +1,164 @@ +"""Zoopla scraping via FlareSolverr (no browser/VNC needed). + +FlareSolverr solves Zoopla's Cloudflare and returns the rendered HTML, which +still contains the React Server Components flight stream — so the existing pure +parsers work unchanged: + - the search page yields the outcode's listing detail URLs, and + - each detail page's flight stream carries the property's location object + (postcode + coordinates) that ``parse_detail_geo`` extracts, plus the + listing fields (price/beds/baths/tenure/floor area) parsed here. + +Verified live (2026-05-30) against Zoopla through the Gluetun VPN: a warm +FlareSolverr session solves the SW9 search + detail pages and the flight data +is present (e.g. detail 73326946 -> SW9 0HD @ 51.477238,-0.116819). + +This is selected by constants.ZOOPLA_FETCHER == "flaresolverr"; the Camoufox +path in zoopla.py remains for ZOOPLA_FETCHER == "camoufox". +""" + +import logging +import re +import time + +from constants import DELAY_BETWEEN_PAGES, ZOOPLA_BASE +from flaresolverr import FlareSolverrError, FlareSolverrSession +from spatial import PostcodeSpatialIndex +from zoopla import _url_with_page, parse_detail_geo, transform_property + +log = logging.getLogger("zoopla") + +# Safety bound on how many search-result pages to walk per outcode. +_MAX_SERP_PAGES = 60 + +_DETAIL_PATH_RE = re.compile(r"/(?:for-sale|new-homes)/details/\d+/") +_LISTING_ID_RE = re.compile(r"/details/(\d+)/") + + +def _int(pattern: str, buf: str) -> int | None: + match = re.search(pattern, buf) + return int(match.group(1)) if match else None + + +def parse_detail_listing(html: str) -> dict: + """Extract the non-location listing fields from a Zoopla detail page. + + Mirrors the fields the Camoufox SERP-card extractor produced, read from the + detail page's flight stream (validated against real Zoopla detail HTML). + All fields are best-effort; missing ones default to None so a listing with + a known location is still emitted.""" + buf = html.replace('\\"', '"').replace("\\/", "/") + + price = _int(r'"internalValue":(\d+)', buf) + if price is None: + price = _int(r'"priceUnformatted":(\d+)', buf) + + tenure_match = re.search(r'"tenure":"([a-zA-Z]+)"', buf) + tenure = tenure_match.group(1).title() if tenure_match else None + + # Address + property type come from the page , e.g. + # "Caldwell Street, Stockwell SW9, 4 bed property for sale, £995,000 - Zoopla" + address = None + property_type = None + title_match = re.search(r'"children":"([^"]*? for sale[^"]*?)"', buf) + if title_match: + title = title_match.group(1) + addr_match = re.match(r"(.+?),\s*\d+\s*bed", title) + if addr_match: + address = addr_match.group(1).strip() + type_match = re.search(r"\d+\s*bed\s+([\w\s-]+?)\s+for sale", title) + if type_match: + property_type = type_match.group(1).strip() + explicit_type = re.search(r'"propertyType":"([^"]+)"', buf) + if explicit_type: + property_type = explicit_type.group(1) + + return { + "price": price, + "beds": _int(r'"numBedrooms":(\d+)', buf), + "baths": _int(r'"numBaths":(\d+)', buf), + "receptions": _int(r'"numLivingRooms":(\d+)', buf), + "floor_area_sqft": _int(r'"sizeSqft":(\d+)', buf), + "tenure": tenure, + "property_type": property_type, + "address": address, + } + + +def _enumerate_detail_paths(fs: FlareSolverrSession, outcode: str, limit: int | None) -> list[str]: + """Walk the outcode's search-result pages and collect listing detail paths.""" + base = f"{ZOOPLA_BASE}/for-sale/property/{outcode.lower()}/?q={outcode}&search_source=home" + seen: list[str] = [] + seen_ids: set[str] = set() + for page_num in range(1, _MAX_SERP_PAGES + 1): + url = base if page_num == 1 else _url_with_page(base, page_num) + html = fs.get(url) + new = 0 + for path in _DETAIL_PATH_RE.findall(html): + id_match = _LISTING_ID_RE.search(path) + listing_id = id_match.group(1) if id_match else path + if listing_id in seen_ids: + continue + seen_ids.add(listing_id) + seen.append(path) + new += 1 + if limit is not None and len(seen) >= limit: + return seen + if new == 0: + break + time.sleep(DELAY_BETWEEN_PAGES) + return seen + + +def search_outcode( + outcode: str, + pc_index: PostcodeSpatialIndex, + pc_coords: dict[str, tuple[float, float]], + fs: FlareSolverrSession, + max_properties: int | None = None, + detail_cap: int = 0, + detail_budget_seconds: float | None = None, +) -> tuple[list[dict], str | None]: + """Scrape one outcode via FlareSolverr. Returns (properties, search_url). + + Every listing's detail page is fetched (that is where the postcode lives), + so the effective listing count is bounded by both ``max_properties`` and + ``detail_cap``; ``detail_budget_seconds`` caps wall-clock time on details.""" + limit = detail_cap if detail_cap and detail_cap > 0 else None + if max_properties is not None: + limit = max_properties if limit is None else min(limit, max_properties) + + base = f"{ZOOPLA_BASE}/for-sale/property/{outcode.lower()}/?q={outcode}&search_source=home" + paths = _enumerate_detail_paths(fs, outcode, limit) + if not paths: + return [], base + + deadline = (time.monotonic() + detail_budget_seconds) if detail_budget_seconds else None + properties: list[dict] = [] + dropped = 0 + for path in paths: + if deadline is not None and time.monotonic() >= deadline: + log.info("Zoopla %s: detail-fetch budget reached after %d", outcode, len(properties)) + break + id_match = _LISTING_ID_RE.search(path) + listing_id = id_match.group(1) if id_match else path + try: + html = fs.get(ZOOPLA_BASE + path) + geo = parse_detail_geo(html, search_outcode=outcode) + raw = {"id": listing_id, "url": path, **parse_detail_listing(html)} + prop = transform_property( + raw, pc_index, pc_coords, search_outcode=outcode, detail=geo + ) + except FlareSolverrError as exc: + log.warning("Zoopla %s detail %s fetch failed: %s", outcode, listing_id, exc) + prop = None + except Exception as exc: # noqa: BLE001 - never let one listing kill the outcode + log.warning("Zoopla %s detail %s transform failed: %s", outcode, listing_id, exc) + prop = None + if prop: + properties.append(prop) + else: + dropped += 1 + time.sleep(DELAY_BETWEEN_PAGES) + + log.info("Zoopla %s: %d listings (%d dropped)", outcode, len(properties), dropped) + return properties, base diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6865f07..b3a23be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -81,6 +81,15 @@ function isProtectedPage(page: Page): boolean { return page === 'account' || page === 'saved'; } +function isSharedDashboardUrl(): boolean { + const share = new URLSearchParams(window.location.search).get('share'); + return !!share && /^[a-z0-9]{1,20}$/i.test(share); +} + +function isAuthRequiredRoute(page: Page): boolean { + return isProtectedPage(page) || (page === 'dashboard' && !isSharedDashboardUrl()); +} + function buildPageUrl(page: Page, inviteCode?: string, search = '', hash = ''): string { const normalizedHash = normalizeHash(hash); return `${pageToPath(page, inviteCode)}${search}${normalizedHash ? `#${normalizedHash}` : ''}`; @@ -235,6 +244,7 @@ export default function App() { const postAuthCheckoutReturnPathRef = useRef<string | null>(null); const authCompletedRef = useRef(false); const [licenseSuccessStatus, setLicenseSuccessStatus] = useState<LicenseSuccessStatus>('hidden'); + const [dashboardReady, setDashboardReady] = useState(false); // Keep a ref to the latest refreshAuth so the mount-only startup effect always // calls the current implementation without re-running when the callback identity changes. @@ -266,7 +276,7 @@ export default function App() { if (!completed) { setPostAuthIntent(null); postAuthCheckoutReturnPathRef.current = null; - if (isProtectedPage(activePageRef.current)) { + if (isAuthRequiredRoute(activePageRef.current)) { window.history.replaceState({ page: 'home', hash: '' }, '', '/'); setRouteHash(''); setActivePage('home'); @@ -517,7 +527,10 @@ export default function App() { } }, [activePage, fetchSearches]); - const isAuthRequiredPage = activePage === 'account' || activePage === 'saved'; + const isAuthRequiredPage = + activePage === 'account' || + activePage === 'saved' || + (activePage === 'dashboard' && !mapUrlState.share); useEffect(() => { if (authLoading) return; if (isAuthRequiredPage && !user) { @@ -530,6 +543,13 @@ export default function App() { const [exportState, setExportState] = useState<ExportState | null>(null); + useEffect(() => { + if (activePage !== 'dashboard' || !user) { + setDashboardReady(false); + setExportState(null); + } + }, [activePage, user]); + if ((isScreenshotMode || isOgMode) && inviteCode) { return ( <Suspense fallback={<PageFallback />}> @@ -584,8 +604,9 @@ export default function App() { onPageChange={navigateTo} theme={theme} onToggleTheme={toggleTheme} - exportState={activePage === 'dashboard' ? exportState : null} + exportState={activePage === 'dashboard' && user ? exportState : null} dashboardParams={activePage === 'dashboard' ? dashboardParams : ''} + dashboardActionsDisabled={activePage === 'dashboard' && !dashboardReady} onSaveSearch={ activePage === 'dashboard' && user ? editingSearch @@ -675,6 +696,7 @@ export default function App() { onNavigateTo={navigateTo} onExportStateChange={setExportState} onDashboardParamsChange={setDashboardParams} + onDashboardReadyChange={setDashboardReady} isMobile={isMobile} initialTravelTime={mapUrlState.travelTime} initialPostcode={mapUrlState.postcode} diff --git a/frontend/src/components/account/AccountPage.tsx b/frontend/src/components/account/AccountPage.tsx index a14113e..0c18e12 100644 --- a/frontend/src/components/account/AccountPage.tsx +++ b/frontend/src/components/account/AccountPage.tsx @@ -461,6 +461,24 @@ interface ShareLinkListItem { created: string; } +function latestPendingInviteUrls(invites: InviteListItem[]): Record<string, string> { + const latestByType: Record<string, { url: string; createdMs: number }> = {}; + + for (const invite of invites) { + if (invite.used || !invite.url) continue; + + const createdMs = Date.parse(invite.created) || 0; + const existing = latestByType[invite.invite_type]; + if (!existing || createdMs > existing.createdMs) { + latestByType[invite.invite_type] = { url: invite.url, createdMs }; + } + } + + return Object.fromEntries( + Object.entries(latestByType).map(([type, invite]) => [type, invite.url]) + ); +} + function InviteTable({ invites, loading, @@ -673,7 +691,16 @@ function InviteSection({ user }: { user: AuthUser }) { const res = await fetch(apiUrl('invites'), authHeaders()); assertOk(res, 'Fetch invites'); const data = await res.json(); - setInviteHistory(data.invites); + const invites: InviteListItem[] = Array.isArray(data.invites) ? data.invites : []; + setInviteHistory(invites); + const pendingInviteUrls = latestPendingInviteUrls(invites); + setInviteUrl((prev) => { + const next = { ...prev }; + for (const [type, url] of Object.entries(pendingInviteUrls)) { + if (!next[type]) next[type] = url; + } + return next; + }); } catch { // Silent — non-critical } finally { diff --git a/frontend/src/components/map/LocationSearch.test.tsx b/frontend/src/components/map/LocationSearch.test.tsx index 5f871a3..7b5774a 100644 --- a/frontend/src/components/map/LocationSearch.test.tsx +++ b/frontend/src/components/map/LocationSearch.test.tsx @@ -8,8 +8,11 @@ const RECENT_SEARCHES_STORAGE_KEY = 'perfect-postcode.locationSearch.recent'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ - t: (key: string) => - key === 'locationSearch.placeholder' ? 'Search places or postcodes...' : key, + t: (key: string) => { + if (key === 'locationSearch.placeholder') return 'Search places or postcodes...'; + if (key === 'locationSearch.noResults') return 'No matching places or postcodes'; + return key; + }, }), })); @@ -226,6 +229,170 @@ describe('LocationSearch', () => { ); }); + it('selects the first place suggestion with Enter when none is highlighted', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: string | URL | Request) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname === '/api/places') { + return Promise.resolve( + jsonResponse({ + places: [ + { + type: 'place', + name: 'London', + slug: 'london', + place_type: 'city', + lat: 51.5074, + lon: -0.1278, + }, + ], + postcodes: [], + addresses: [], + }) + ); + } + if (url.pathname === '/api/nearest-postcode') { + return Promise.resolve( + jsonResponse({ + postcode: 'SW1A 1AA', + latitude: 51.501, + longitude: -0.141, + geometry: postcodeGeometry, + }) + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }) + ); + + const onFlyTo = vi.fn(); + const onLocationSearched = vi.fn(); + render(<LocationSearch onFlyTo={onFlyTo} onLocationSearched={onLocationSearched} />); + + const input = screen.getByRole('textbox'); + fireEvent.change(input, { target: { value: 'London' } }); + + await screen.findByRole('button', { name: 'London' }); + fireEvent.keyDown(input, { key: 'Enter' }); + + await waitFor(() => { + expect(onLocationSearched).toHaveBeenCalledTimes(1); + }); + expect(onFlyTo).toHaveBeenCalledWith(51.5074, -0.1278, 10); + expect(onLocationSearched).toHaveBeenCalledWith({ + postcode: 'SW1A 1AA', + geometry: postcodeGeometry, + latitude: 51.501, + longitude: -0.141, + zoom: 10, + markerLatitude: 51.5074, + markerLongitude: -0.1278, + }); + }); + + it('preserves the server unified ordering and sends the viewport centre', async () => { + const fetchMock = vi.fn((input: string | URL | Request) => { + const url = new URL(String(input), 'http://localhost'); + if (url.pathname === '/api/places') { + return Promise.resolve( + jsonResponse({ + places: [], + postcodes: [], + addresses: [], + // Intentionally out of "bucket" order: an address outranks a place. The hook must + // render them in this server order rather than re-bucketing or re-filtering. + results: [ + { + type: 'address', + address: '1 High Street', + postcode: 'CR0 1AA', + lat: 51.37, + lon: -0.1, + score: 930, + }, + { + type: 'place', + name: 'Croydon', + slug: 'croydon', + place_type: 'town', + lat: 51.37, + lon: -0.1, + score: 880, + }, + ], + }) + ); + } + return Promise.resolve(new Response(null, { status: 404 })); + }); + vi.stubGlobal('fetch', fetchMock); + + render( + <LocationSearch + onFlyTo={vi.fn()} + onLocationSearched={vi.fn()} + getViewportCenter={() => ({ lat: 51.5, lng: -0.12 })} + /> + ); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'high street' } }); + + // The address (first in the server list) renders before the place, unfiltered. + const firstResult = await screen.findByText('1 High Street'); + const place = screen.getByText('Croydon'); + expect( + firstResult.compareDocumentPosition(place) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + + const placesCall = fetchMock.mock.calls.find(([input]) => + String(input).includes('/api/places') + ); + const calledUrl = new URL(String(placesCall?.[0]), 'http://localhost'); + expect(calledUrl.searchParams.get('lat')).toBe('51.5'); + expect(calledUrl.searchParams.get('lng')).toBe('-0.12'); + }); + + it('shows an empty state for invalid place queries (legacy server, no results key)', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve( + jsonResponse({ + places: [], + postcodes: [], + addresses: [], + }) + ) + ) + ); + + render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: '!!!!zzzzzz!!!!' } }); + + await waitFor(() => { + expect(screen.getByText('No matching places or postcodes')).toBeTruthy(); + }); + }); + + it('shows the empty state when the new server returns an empty results array', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve(jsonResponse({ places: [], postcodes: [], addresses: [], results: [] })) + ) + ); + + render(<LocationSearch onFlyTo={vi.fn()} onLocationSearched={vi.fn()} />); + + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'zzzznowhere' } }); + + await waitFor(() => { + expect(screen.getByText('No matching places or postcodes')).toBeTruthy(); + }); + }); + it('keeps only the three most recent local searches', async () => { vi.stubGlobal( 'fetch', diff --git a/frontend/src/components/map/LocationSearch.tsx b/frontend/src/components/map/LocationSearch.tsx index d8adbd3..0f2caa4 100644 --- a/frontend/src/components/map/LocationSearch.tsx +++ b/frontend/src/components/map/LocationSearch.tsx @@ -4,7 +4,11 @@ import type { MapFlyToOptions, PostcodeGeometry } from '../../types'; import { authHeaders, isAbortError } from '../../lib/api'; import { POSTCODE_SEARCH_ZOOM } from '../../lib/consts'; import { useIsMobile } from '../../hooks/useIsMobile'; -import { useLocationSearch, type SearchResult } from '../../hooks/useLocationSearch'; +import { + useLocationSearch, + type SearchResult, + type ViewportCenter, +} from '../../hooks/useLocationSearch'; import { PlaceSearchInput } from '../ui/PlaceSearchInput'; import { LocateIcon } from '../ui/icons/LocateIcon'; import { SearchIcon } from '../ui/icons/SearchIcon'; @@ -44,6 +48,12 @@ const ZOOM_FOR_TYPE: Record<string, number> = { locality: 14, hamlet: 15, isolated_dwelling: 16, + street: 16, + university: 15, + park: 15, + attraction: 16, + hospital: 16, + retail: 15, }; const DEV_CURRENT_LOCATION = { @@ -56,6 +66,7 @@ export default function LocationSearch({ onLocationSearched, onCurrentLocationFound, onMouseEnter, + getViewportCenter, className = '', inputClassName, }: { @@ -63,11 +74,13 @@ export default function LocationSearch({ onLocationSearched?: (postcode: SearchedLocation | null) => void; onCurrentLocationFound?: (lat: number, lng: number) => void; onMouseEnter?: () => void; + /** Returns the current map centre so search ranking can bias toward the visible area. */ + getViewportCenter?: () => ViewportCenter | null; className?: string; inputClassName?: string; }) { const { t } = useTranslation(); - const search = useLocationSearch(); + const search = useLocationSearch(undefined, getViewportCenter); const [error, setError] = useState<string | null>(null); const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState(false); @@ -333,6 +346,8 @@ export default function LocationSearch({ onSelect={selectResult} loading={loading} placeholder={t('locationSearch.placeholder')} + ariaLabel={t('locationSearch.searchLabel')} + name="location-search" size="sm" inputClassName={ inputClassName ?? diff --git a/frontend/src/components/map/Map.tsx b/frontend/src/components/map/Map.tsx index 91a487f..423cf1b 100644 --- a/frontend/src/components/map/Map.tsx +++ b/frontend/src/components/map/Map.tsx @@ -606,12 +606,13 @@ function OverlayTileLayers({ const showTrees = activeOverlays.has('trees-outside-woodlands'); const showPropertyBorders = activeOverlays.has('property-borders'); - // Restrict the heatmap to the selected crime types. When every type is - // selected we omit the filter entirely so all features contribute. - const crimeFilter = - activeCrimeTypes.size >= CRIME_TYPE_VALUES.length - ? undefined - : ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]]; + // Restrict the heatmap to the selected crime types. This must always be a + // concrete expression: passing `filter={undefined}` makes react-map-gl call + // map.addLayer({filter: undefined}), which MapLibre rejects at validation + // ("filter: array expected, undefined found"), so the layer is never created + // and the heatmap stays blank until a later setFilter call. An `in` over the + // selected types matches everything when all 14 are selected. + const crimeFilter = ['in', ['get', 'crime_type'], ['literal', Array.from(activeCrimeTypes)]]; return ( <> @@ -936,6 +937,10 @@ export default memo(function Map({ dimensions.width < DESKTOP_TOP_CARDS_ROW_MIN_MAP_WIDTH; const showLocationSearch = !hideLocationSearch && !hideDesktopTopCardsForWidth; const showLegend = !hideLegend && !hideDesktopTopCardsForWidth; + const getViewportCenter = useCallback(() => { + const center = mapRef.current?.getCenter(); + return center ? { lat: center.lat, lng: center.lng } : null; + }, []); const desktopTopCardsLayoutClass = stackDesktopTopCards ? 'flex-col items-start' : 'items-start justify-between'; @@ -1098,6 +1103,7 @@ export default memo(function Map({ onLocationSearched={onLocationSearched} onCurrentLocationFound={onCurrentLocationFound} onMouseEnter={handleMouseLeave} + getViewportCenter={getViewportCenter} className={DESKTOP_TOP_CARD_CLASS} inputClassName={DESKTOP_LOCATION_SEARCH_INPUT_CLASS} /> diff --git a/frontend/src/components/map/MapPage.tsx b/frontend/src/components/map/MapPage.tsx index ca43d52..3d7df16 100644 --- a/frontend/src/components/map/MapPage.tsx +++ b/frontend/src/components/map/MapPage.tsx @@ -91,6 +91,7 @@ export default function MapPage({ onNavigateTo, onExportStateChange, onDashboardParamsChange, + onDashboardReadyChange, screenshotMode, ogMode, isMobile = false, @@ -642,6 +643,23 @@ export default function MapPage({ onDashboardParamsChange?.(dashboardParams); }, [dashboardParams, onDashboardParamsChange]); + const dashboardReady = + !initialLoading && + !mapData.loading && + !mapData.licenseRequired && + mapData.bounds != null && + mapData.currentView != null; + + useEffect(() => { + onDashboardReadyChange?.(dashboardReady); + }, [dashboardReady, onDashboardReadyChange]); + + useEffect(() => { + return () => { + onDashboardReadyChange?.(false); + }; + }, [onDashboardReadyChange]); + useEffect(() => { if (mapData.licenseRequired) trackEvent('Upgrade Modal Shown'); }, [mapData.licenseRequired]); @@ -830,8 +848,8 @@ export default function MapPage({ </button> <button onClick={() => onUpdateEdit?.(dashboardParams)} - disabled={savingSearch} - className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5" + disabled={savingSearch || !dashboardReady} + className="shrink-0 cursor-pointer px-2.5 py-1 rounded text-xs font-medium bg-teal-600 text-white hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5" > {savingSearch ? t('savedPage.updating') : t('common.update')} </button> diff --git a/frontend/src/components/map/MobileDrawer.test.tsx b/frontend/src/components/map/MobileDrawer.test.tsx new file mode 100644 index 0000000..a7a626c --- /dev/null +++ b/frontend/src/components/map/MobileDrawer.test.tsx @@ -0,0 +1,107 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import MobileDrawer from './MobileDrawer'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +const originalSetPointerCapture = HTMLElement.prototype.setPointerCapture; + +function renderDrawer(onClose = vi.fn()) { + const view = render( + <MobileDrawer + onClose={onClose} + renderArea={() => <div>Area content</div>} + renderProperties={() => <div>Properties content</div>} + tab="area" + onTabChange={vi.fn()} + /> + ); + const handle = view.container.querySelector('[data-mobile-drawer-drag-handle]'); + const root = view.container.querySelector('[data-tutorial="right-pane"]'); + const panel = view.container.querySelector('[data-tutorial="right-pane"] > div:last-child'); + + if (!(handle instanceof HTMLElement)) throw new Error('Expected drawer drag handle'); + if (!(root instanceof HTMLElement)) throw new Error('Expected drawer root'); + if (!(panel instanceof HTMLElement)) throw new Error('Expected drawer panel'); + + return { ...view, handle, onClose, panel, root }; +} + +describe('MobileDrawer', () => { + beforeEach(() => { + HTMLElement.prototype.setPointerCapture = vi.fn(); + }); + + afterEach(() => { + cleanup(); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: originalSetPointerCapture, + }); + }); + + it('lowers and stays open when swiped down from the handle', () => { + const { handle, onClose, panel } = renderDrawer(); + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 120 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 230 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientY: 230 }); + + expect(onClose).not.toHaveBeenCalled(); + expect(panel.style.transform).toBe('translateY(110px)'); + }); + + it('can be raised again after being lowered', () => { + const { handle, onClose, panel } = renderDrawer(); + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 120 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 230 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientY: 230 }); + + fireEvent.pointerDown(handle, { pointerId: 2, clientY: 230 }); + fireEvent.pointerMove(handle, { pointerId: 2, clientY: 170 }); + fireEvent.pointerUp(handle, { pointerId: 2, clientY: 170 }); + + expect(onClose).not.toHaveBeenCalled(); + expect(panel.style.transform).toBe('translateY(50px)'); + }); + + it('keeps the close control reachable when dragged down far', () => { + const { handle, panel } = renderDrawer(); + + Object.defineProperty(panel, 'offsetHeight', { + configurable: true, + value: 200, + }); + + fireEvent.pointerDown(handle, { pointerId: 1, clientY: 120 }); + fireEvent.pointerMove(handle, { pointerId: 1, clientY: 420 }); + fireEvent.pointerUp(handle, { pointerId: 1, clientY: 420 }); + + expect(panel.style.transform).toBe('translateY(96px)'); + }); + + it('leaves the rest of the mobile map usable while the panel is open', () => { + const { panel, root } = renderDrawer(); + const spacer = root.firstElementChild; + + if (!(spacer instanceof HTMLElement)) throw new Error('Expected drawer spacer'); + + expect(root.className).toContain('pointer-events-none'); + expect(panel.className).toContain('pointer-events-auto'); + expect(spacer.className).not.toContain('bg-black'); + }); + + it('closes from the close button', () => { + const { onClose } = renderDrawer(); + + fireEvent.click(screen.getByLabelText('mobileDrawer.closeDrawer')); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/map/MobileDrawer.tsx b/frontend/src/components/map/MobileDrawer.tsx index 58a6718..39bdf6c 100644 --- a/frontend/src/components/map/MobileDrawer.tsx +++ b/frontend/src/components/map/MobileDrawer.tsx @@ -103,7 +103,10 @@ export default function MobileDrawer({ }, []); return ( - <div data-tutorial="right-pane" className="pointer-events-none fixed inset-0 z-50 flex flex-col"> + <div + data-tutorial="right-pane" + className="pointer-events-none fixed inset-0 z-50 flex flex-col" + > <div className="h-[10%] shrink-0" aria-hidden="true" /> {/* Panel — bottom 90% */} diff --git a/frontend/src/components/map/POIPane.tsx b/frontend/src/components/map/POIPane.tsx index 472aa03..b4ecc2f 100644 --- a/frontend/src/components/map/POIPane.tsx +++ b/frontend/src/components/map/POIPane.tsx @@ -186,7 +186,7 @@ export default function POIPane({ </div> {!isCollapsed && ( <div className="px-3 py-2"> - <PillGroup> + <PillGroup wrap> {group.categories.map((category) => { const logo = getPoiCategoryLogoUrl(category); return ( diff --git a/frontend/src/components/map/map-page/DesktopMapPage.tsx b/frontend/src/components/map/map-page/DesktopMapPage.tsx index 4f37e3a..3f6bc08 100644 --- a/frontend/src/components/map/map-page/DesktopMapPage.tsx +++ b/frontend/src/components/map/map-page/DesktopMapPage.tsx @@ -269,7 +269,10 @@ export function DesktopMapPage({ </div> )} {poiPaneOpen && ( - <div className="absolute bottom-28 right-4 z-10 flex h-[60vh] min-h-0 w-80 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"> + <div + className="absolute bottom-28 right-4 z-10 flex min-h-0 w-80 max-w-[calc(100%_-_2rem)] flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900" + style={{ height: 'min(30rem, calc(100vh - 10rem))' }} + > {poiPane} </div> )} diff --git a/frontend/src/components/map/map-page/MobileMapPage.tsx b/frontend/src/components/map/map-page/MobileMapPage.tsx index 845f531..40b8114 100644 --- a/frontend/src/components/map/map-page/MobileMapPage.tsx +++ b/frontend/src/components/map/map-page/MobileMapPage.tsx @@ -132,6 +132,10 @@ export function MobileMapPage({ upgradeModal, editingBar, }: MobileMapPageProps) { + const floatingPaneAvailableHeight = `max(12rem, calc(100dvh - ${Math.ceil( + bottomScreenInset + )}px - 7rem))`; + return ( <div className="flex-1 overflow-hidden relative"> <LoadingOverlay show={initialLoading} /> @@ -219,7 +223,13 @@ export function MobileMapPage({ )} {poiPaneOpen && ( - <div className="absolute top-24 right-3 left-3 z-20 flex h-[45dvh] min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900"> + <div + className="absolute top-24 right-3 left-3 z-20 flex min-h-0 flex-col overflow-hidden rounded-lg border border-warm-200 bg-white shadow-xl dark:border-warm-700 dark:bg-warm-900" + style={{ + height: `min(22rem, ${floatingPaneAvailableHeight})`, + maxHeight: floatingPaneAvailableHeight, + }} + > {poiPane} </div> )} diff --git a/frontend/src/components/map/map-page/types.ts b/frontend/src/components/map/map-page/types.ts index 23a841a..51e0335 100644 --- a/frontend/src/components/map/map-page/types.ts +++ b/frontend/src/components/map/map-page/types.ts @@ -39,6 +39,7 @@ export interface MapPageProps { onNavigateTo: (page: Page, hash?: string, infoFeature?: string) => void; onExportStateChange?: (state: ExportState) => void; onDashboardParamsChange?: (params: string) => void; + onDashboardReadyChange?: (ready: boolean) => void; screenshotMode?: boolean; ogMode?: boolean; isMobile?: boolean; diff --git a/frontend/src/components/map/map-page/useExportController.ts b/frontend/src/components/map/map-page/useExportController.ts index bd2bc65..d372edf 100644 --- a/frontend/src/components/map/map-page/useExportController.ts +++ b/frontend/src/components/map/map-page/useExportController.ts @@ -192,9 +192,7 @@ export function useExportController({ const detail = err instanceof Error && err.message.trim() ? ` ${err.message}` : ''; showExportNotice({ kind: 'error', - message: timedOut - ? t('header.exportTimedOut') - : `${t('header.exportFailed')}${detail}`, + message: timedOut ? t('header.exportTimedOut') : `${t('header.exportFailed')}${detail}`, }); } finally { window.clearTimeout(timeoutId); diff --git a/frontend/src/components/ui/AuthModal.tsx b/frontend/src/components/ui/AuthModal.tsx index 2c8577c..b4ce606 100644 --- a/frontend/src/components/ui/AuthModal.tsx +++ b/frontend/src/components/ui/AuthModal.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useId } from 'react'; import { useTranslation } from 'react-i18next'; import { CloseIcon } from './icons/CloseIcon'; import { GoogleIcon } from './icons/GoogleIcon'; @@ -36,6 +36,9 @@ export default function AuthModal({ const [password, setPassword] = useState(''); const [resetSent, setResetSent] = useState(false); const dialogRef = useModalA11y(); + const fieldId = useId(); + const emailInputId = `${fieldId}-email`; + const passwordInputId = `${fieldId}-password`; useEffect(() => { trackEvent('Auth Modal Open', { tab: initialTab }); @@ -194,14 +197,20 @@ export default function AuthModal({ {/* Email form */} <form onSubmit={handleSubmit} className="space-y-4"> <div> - <label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"> + <label + htmlFor={emailInputId} + className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1" + > {t('auth.email')} </label> <input + id={emailInputId} + name="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required + autoComplete="email" className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" placeholder={t('auth.emailPlaceholder')} /> @@ -209,15 +218,21 @@ export default function AuthModal({ {view !== 'forgot' && ( <div> - <label className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1"> + <label + htmlFor={passwordInputId} + className="block text-sm font-medium text-warm-700 dark:text-warm-300 mb-1" + > {t('auth.password')} </label> <input + id={passwordInputId} + name="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={8} + autoComplete={view === 'register' ? 'new-password' : 'current-password'} className="w-full px-3 py-2 text-sm rounded border border-warm-200 dark:border-warm-700 bg-white dark:bg-warm-800 text-navy-950 dark:text-white placeholder-warm-400 dark:placeholder-warm-500 outline-none focus:ring-2 ring-teal-400 dark:ring-teal-500" placeholder={ view === 'register' diff --git a/frontend/src/components/ui/ExportMenu.tsx b/frontend/src/components/ui/ExportMenu.tsx index ae3ea39..2edd0cf 100644 --- a/frontend/src/components/ui/ExportMenu.tsx +++ b/frontend/src/components/ui/ExportMenu.tsx @@ -78,11 +78,7 @@ export default function ExportMenu({ open, exporting, onClose, onExport }: Expor } else { inputRefs.current[idx + 1]?.focus(); } - } else if ( - e.key === 'Backspace' && - postcodes[idx] === '' && - postcodes.length > 1 - ) { + } else if (e.key === 'Backspace' && postcodes[idx] === '' && postcodes.length > 1) { e.preventDefault(); focusIndexRef.current = Math.max(0, idx - 1); removeAt(idx); @@ -98,11 +94,7 @@ export default function ExportMenu({ open, exporting, onClose, onExport }: Expor return ( <> - <div - className="fixed inset-0 bg-black/50 z-[90]" - onClick={onClose} - aria-hidden="true" - /> + <div className="fixed inset-0 bg-black/50 z-[90]" onClick={onClose} aria-hidden="true" /> <div role="dialog" aria-modal="true" diff --git a/frontend/src/components/ui/FeatureLabel.tsx b/frontend/src/components/ui/FeatureLabel.tsx index cbdf916..3335537 100644 --- a/frontend/src/components/ui/FeatureLabel.tsx +++ b/frontend/src/components/ui/FeatureLabel.tsx @@ -68,9 +68,7 @@ export function FeatureLabel({ {GroupIcon && <GroupIcon className={iconClass} />} {translatedDesc ? ( <div className="min-w-0"> - <div className={`flex ${wrap ? 'items-start' : 'items-center'} gap-1`}> - {nameContent} - </div> + <div className={`flex ${wrap ? 'items-start' : 'items-center'} gap-1`}>{nameContent}</div> <span className="text-xs text-warm-400 dark:text-warm-500 block">{translatedDesc}</span> </div> ) : ( diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index c5b4015..0e4dc5a 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -78,6 +78,7 @@ export default function Header({ onToggleTheme, exportState, dashboardParams, + dashboardActionsDisabled = false, onSaveSearch, savingSearch, editingSearch, @@ -96,6 +97,7 @@ export default function Header({ onToggleTheme: () => void; exportState: HeaderExportState | null; dashboardParams: string; + dashboardActionsDisabled?: boolean; onSaveSearch: (() => void) | null; savingSearch: boolean; editingSearch: EditingSearchState | null; @@ -116,6 +118,7 @@ export default function Header({ () => window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY).matches ); const useSidebarNav = isMobile || (activePage === 'dashboard' && isDashboardTabletSidebarWidth); + const dashboardActionsBlocked = activePage === 'dashboard' && (!user || dashboardActionsDisabled); useEffect(() => { const mql = window.matchMedia(DASHBOARD_TABLET_SIDEBAR_QUERY); @@ -139,6 +142,10 @@ export default function Header({ if (!useSidebarNav) setMenuOpen(false); }, [useSidebarNav]); + useEffect(() => { + if (dashboardActionsBlocked) setExportMenuOpen(false); + }, [dashboardActionsBlocked]); + const doCopy = useCallback((text: string) => { copyToClipboard(text, () => { setCopied(true); @@ -147,6 +154,7 @@ export default function Header({ }, []); const handleShare = useCallback(async () => { + if (dashboardActionsBlocked) return; const params = activePage === 'dashboard' ? dashboardParams : window.location.search.replace(/^\?/, ''); if (!params) { @@ -167,7 +175,7 @@ export default function Header({ } finally { setSharing(false); } - }, [activePage, dashboardParams, doCopy, i18n.language]); + }, [activePage, dashboardActionsBlocked, dashboardParams, doCopy, i18n.language]); const navLink = (page: Page, e: React.MouseEvent, hash?: string) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; @@ -206,8 +214,8 @@ export default function Header({ </button> <button onClick={onUpdateEdit} - disabled={savingSearch} - className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5" + disabled={savingSearch || dashboardActionsBlocked} + className="cursor-pointer px-3 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5" > {savingSearch && <SpinnerIcon className="w-4 h-4 animate-spin" />} {savingSearch ? t('savedPage.updating') : t('common.update')} @@ -216,14 +224,16 @@ export default function Header({ </div> )} {/* Left: Logo + nav */} - <div className="flex items-center gap-4"> + <div className="flex min-w-0 items-center gap-4"> <a href="/" - className="flex cursor-pointer items-center gap-2 hover:opacity-80 transition-opacity" + className="flex min-w-0 cursor-pointer items-center gap-2 hover:opacity-80 transition-opacity" onClick={(e) => navLink('home', e)} > - <LogoIcon className="w-5 h-5 text-teal-400" /> - <span className="text-lg font-semibold text-teal-300">{t('header.appName')}</span> + <LogoIcon className="w-5 h-5 shrink-0 text-teal-400" /> + <span className="max-w-[9rem] truncate whitespace-nowrap text-lg font-semibold text-teal-300 sm:max-w-none"> + {t('header.appName')} + </span> </a> {/* Desktop nav */} @@ -266,14 +276,14 @@ export default function Header({ </div> {/* Right side */} - <div className="flex items-center gap-2 ml-auto"> + <div className="ml-auto flex shrink-0 items-center gap-2"> {/* Desktop-only dashboard actions */} - {!useSidebarNav && activePage === 'dashboard' && ( + {!useSidebarNav && activePage === 'dashboard' && user && ( <> <button onClick={handleShare} - disabled={sharing} - className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50" + disabled={sharing || dashboardActionsBlocked} + className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50" > {sharing ? ( <> @@ -295,8 +305,8 @@ export default function Header({ {exportState && ( <button onClick={() => setExportMenuOpen(true)} - disabled={exportState.exporting} - className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50" + disabled={exportState.exporting || dashboardActionsBlocked} + className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50" title={t('header.exportToExcel')} > <DownloadIcon className="w-4 h-4" /> @@ -306,8 +316,8 @@ export default function Header({ {onSaveSearch && !editingSearch && ( <button onClick={onSaveSearch} - disabled={savingSearch} - className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:opacity-50" + disabled={savingSearch || dashboardActionsBlocked} + className="flex cursor-pointer items-center gap-1.5 px-3 py-1.5 rounded bg-navy-800 hover:bg-navy-700 transition-colors text-sm disabled:cursor-not-allowed disabled:opacity-50" > {savingSearch ? ( <SpinnerIcon className="w-4 h-4 animate-spin" /> @@ -363,7 +373,7 @@ export default function Header({ {useSidebarNav && !user && ( <button onClick={onRegisterClick} - className="cursor-pointer px-4 py-1.5 rounded bg-teal-600 hover:bg-teal-700 transition-colors text-sm font-semibold" + className="flex h-8 max-w-[8.5rem] shrink-0 cursor-pointer items-center justify-center truncate whitespace-nowrap rounded bg-teal-600 px-2.5 text-xs font-semibold leading-none transition-colors hover:bg-teal-700 sm:max-w-none sm:px-3 sm:text-sm" > {t('header.createAccount')} </button> @@ -410,6 +420,7 @@ export default function Header({ onToggleTheme={onToggleTheme} exportState={exportState} onOpenExportMenu={() => setExportMenuOpen(true)} + dashboardActionsDisabled={dashboardActionsBlocked} onSaveSearch={onSaveSearch} savingSearch={savingSearch} isEditingSearch={!!editingSearch} diff --git a/frontend/src/components/ui/MobileMenu.tsx b/frontend/src/components/ui/MobileMenu.tsx index 798a81a..9aa354e 100644 --- a/frontend/src/components/ui/MobileMenu.tsx +++ b/frontend/src/components/ui/MobileMenu.tsx @@ -20,6 +20,7 @@ interface MobileMenuProps { onToggleTheme: () => void; exportState: HeaderExportState | null; onOpenExportMenu: () => void; + dashboardActionsDisabled?: boolean; onSaveSearch: (() => void) | null; savingSearch: boolean; isEditingSearch: boolean; @@ -41,6 +42,7 @@ export default function MobileMenu({ onToggleTheme, exportState, onOpenExportMenu, + dashboardActionsDisabled = false, onSaveSearch, savingSearch, isEditingSearch, @@ -101,7 +103,7 @@ export default function MobileMenu({ </a> ); - const dashboardActions = activePage === 'dashboard' && ( + const dashboardActions = activePage === 'dashboard' && user && ( <div className="px-2 py-2 border-b border-navy-700"> <div className="grid grid-cols-2 gap-2"> <button @@ -109,7 +111,7 @@ export default function MobileMenu({ onShare(); onClose(); }} - disabled={sharing} + disabled={sharing || dashboardActionsDisabled} className={dashboardActionClass} > {sharing ? ( @@ -127,8 +129,8 @@ export default function MobileMenu({ onClose(); onOpenExportMenu(); }} - disabled={exportState.exporting} className={dashboardActionClass} + disabled={exportState.exporting || dashboardActionsDisabled} > <DownloadIcon className="w-4 h-4" /> {exportState.exporting ? t('header.exporting') : t('header.exportLabel')} @@ -140,7 +142,7 @@ export default function MobileMenu({ onSaveSearch(); onClose(); }} - disabled={savingSearch} + disabled={savingSearch || dashboardActionsDisabled} className={dashboardActionClass} > {savingSearch ? ( @@ -199,7 +201,7 @@ export default function MobileMenu({ </button> {/* Language selector */} - <div className="flex max-w-full gap-1 overflow-x-auto overflow-y-hidden px-3 pb-1 scrollbar-hide"> + <div className="grid max-w-full grid-cols-3 gap-1 px-3 pb-1"> {SUPPORTED_LANGUAGES.map((lang) => ( <button key={lang.code} @@ -208,7 +210,7 @@ export default function MobileMenu({ localStorage.setItem('language', lang.code); void changeAppLanguage(lang.code); }} - className={`flex-none min-w-[2.5rem] flex cursor-pointer items-center justify-center gap-1.5 px-2 py-1.5 rounded text-sm ${ + className={`flex min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded px-2 py-1.5 text-sm ${ i18n.language === lang.code ? 'bg-navy-700 text-white font-medium' : 'text-warm-400 hover:bg-navy-800 hover:text-white' diff --git a/frontend/src/components/ui/PillGroup.tsx b/frontend/src/components/ui/PillGroup.tsx index 08faecc..0c9122a 100644 --- a/frontend/src/components/ui/PillGroup.tsx +++ b/frontend/src/components/ui/PillGroup.tsx @@ -3,12 +3,17 @@ import type { ReactNode } from 'react'; interface PillGroupProps { children: ReactNode; className?: string; + wrap?: boolean; } -export function PillGroup({ children, className = '' }: PillGroupProps) { +export function PillGroup({ children, className = '', wrap = false }: PillGroupProps) { return ( <div - className={`flex min-w-0 max-w-full flex-nowrap gap-1.5 overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible ${className}`} + className={`flex min-w-0 max-w-full gap-1.5 ${ + wrap + ? 'flex-wrap overflow-x-visible' + : 'flex-nowrap overflow-x-auto overscroll-x-contain touch-pan-x touch-pan-y scrollbar-hide md:flex-wrap md:overflow-x-visible' + } ${className}`} > {children} </div> diff --git a/frontend/src/components/ui/PlaceSearchInput.tsx b/frontend/src/components/ui/PlaceSearchInput.tsx index 449e246..eed23ed 100644 --- a/frontend/src/components/ui/PlaceSearchInput.tsx +++ b/frontend/src/components/ui/PlaceSearchInput.tsx @@ -1,5 +1,6 @@ import { useRef } from 'react'; import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; import type React from 'react'; import type { SearchResult } from '../../hooks/useLocationSearch'; import { useDropdownPosition } from '../../hooks/useDropdownPosition'; @@ -13,6 +14,7 @@ interface SearchHook { activeIndex: number; setActiveIndex: (idx: number) => void; open: boolean; + searching?: boolean; handleInputChange: (value: string) => void; handleKeyDown: (e: React.KeyboardEvent, onSelect: (result: SearchResult) => void) => void; showEmptySearches: () => void; @@ -23,6 +25,8 @@ interface PlaceSearchInputProps { onSelect: (result: SearchResult) => void; loading?: boolean; placeholder?: string; + ariaLabel?: string; + name?: string; size?: 'sm' | 'xs'; inputClassName?: string; inputRef?: React.Ref<HTMLInputElement>; @@ -35,19 +39,28 @@ export function PlaceSearchInput({ onSelect, loading, placeholder, + ariaLabel, + name, size = 'sm', inputClassName, inputRef, onInputChange, portal, }: PlaceSearchInputProps) { + const { t } = useTranslation(); const sm = size === 'sm'; const iconSize = sm ? 'w-4 h-4' : 'w-3 h-3'; const spinnerSize = sm ? 'w-4 h-4' : 'w-3 h-3'; const wrapperRef = useRef<HTMLDivElement>(null); const dropdownPos = useDropdownPosition(wrapperRef, portal ? search.open : false); - const showDropdown = search.open && search.results.length > 0; + const showEmptyResults = + search.open && + !search.searching && + search.query.trim().length >= 2 && + search.results.length === 0; + const showDropdown = search.open && (search.results.length > 0 || showEmptyResults); + const showSpinner = loading || search.searching; const dropdown = showDropdown && ( <div @@ -64,57 +77,66 @@ export function PlaceSearchInput({ : undefined } > - {search.results.map((result, idx) => ( - <button - key={ - result.type === 'postcode' - ? `pc-${result.label}` - : result.type === 'address' - ? `addr-${result.postcode}-${result.address}-${result.lat}` - : `pl-${result.name}-${result.lat}` - } - type="button" - className={`w-full text-left flex items-center cursor-pointer ${ - sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs' - } ${ - idx === search.activeIndex - ? 'bg-teal-50 dark:bg-teal-900/30' - : 'hover:bg-warm-50 dark:hover:bg-warm-700' - }`} - onMouseEnter={() => search.setActiveIndex(idx)} - onMouseDown={(e) => { - e.preventDefault(); - onSelect(result); - }} + {showEmptyResults ? ( + <div + className={`text-warm-500 dark:text-warm-400 ${sm ? 'px-3 py-2 text-sm' : 'px-2 py-1.5 text-xs'}`} + role="status" > - {result.type === 'postcode' ? ( - <> - <SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> - <span className="text-warm-700 dark:text-warm-200">{result.label}</span> - </> - ) : result.type === 'address' ? ( - <> - <HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> - <span className="min-w-0 text-warm-700 dark:text-warm-200"> - <span className="block truncate">{result.address}</span> - <span className="block truncate text-warm-400 dark:text-warm-500"> - {result.postcode} + {t('locationSearch.noResults')} + </div> + ) : ( + search.results.map((result, idx) => ( + <button + key={ + result.type === 'postcode' + ? `pc-${result.label}` + : result.type === 'address' + ? `addr-${result.postcode}-${result.address}-${result.lat}` + : `pl-${result.name}-${result.lat}` + } + type="button" + className={`w-full text-left flex items-center cursor-pointer ${ + sm ? 'px-3 py-2 gap-2 text-sm' : 'px-2 py-1.5 gap-1.5 text-xs' + } ${ + idx === search.activeIndex + ? 'bg-teal-50 dark:bg-teal-900/30' + : 'hover:bg-warm-50 dark:hover:bg-warm-700' + }`} + onMouseEnter={() => search.setActiveIndex(idx)} + onMouseDown={(e) => { + e.preventDefault(); + onSelect(result); + }} + > + {result.type === 'postcode' ? ( + <> + <SearchIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> + <span className="text-warm-700 dark:text-warm-200">{result.label}</span> + </> + ) : result.type === 'address' ? ( + <> + <HouseIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> + <span className="min-w-0 text-warm-700 dark:text-warm-200"> + <span className="block truncate">{result.address}</span> + <span className="block truncate text-warm-400 dark:text-warm-500"> + {result.postcode} + </span> </span> - </span> - </> - ) : ( - <> - <MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> - <span className="text-warm-700 dark:text-warm-200"> - {result.name} - {result.city && ( - <span className="text-warm-400 dark:text-warm-500"> ({result.city})</span> - )} - </span> - </> - )} - </button> - ))} + </> + ) : ( + <> + <MapPinIcon className={`${iconSize} text-warm-400 dark:text-warm-500 shrink-0`} /> + <span className="text-warm-700 dark:text-warm-200"> + {result.name} + {result.city && ( + <span className="text-warm-400 dark:text-warm-500"> ({result.city})</span> + )} + </span> + </> + )} + </button> + )) + )} </div> ); @@ -123,6 +145,7 @@ export function PlaceSearchInput({ <input ref={inputRef} type="text" + name={name} value={search.query} onChange={(e) => { search.handleInputChange(e.target.value); @@ -132,11 +155,12 @@ export function PlaceSearchInput({ search.showEmptySearches(); }} onKeyDown={(e) => search.handleKeyDown(e, onSelect)} + aria-label={ariaLabel ?? placeholder} placeholder={placeholder} className={inputClassName} /> - {loading && ( + {showSpinner && ( <div className={`absolute right-2 top-1/2 -translate-y-1/2 ${spinnerSize} border-2 border-warm-300 dark:border-warm-600 border-t-teal-500 rounded-full animate-spin`} /> diff --git a/frontend/src/components/ui/SearchInput.tsx b/frontend/src/components/ui/SearchInput.tsx index 7ed83c8..7e399dc 100644 --- a/frontend/src/components/ui/SearchInput.tsx +++ b/frontend/src/components/ui/SearchInput.tsx @@ -4,18 +4,27 @@ interface SearchInputProps { value: string; onChange: (value: string) => void; placeholder?: string; + ariaLabel?: string; className?: string; } -export function SearchInput({ value, onChange, placeholder, className = '' }: SearchInputProps) { +export function SearchInput({ + value, + onChange, + placeholder, + ariaLabel, + className = '', +}: SearchInputProps) { const { t } = useTranslation(); + const inputPlaceholder = placeholder ?? t('common.search'); return ( <input type="text" value={value} onChange={(e) => onChange(e.target.value)} - placeholder={placeholder ?? t('common.search')} + placeholder={inputPlaceholder} + aria-label={ariaLabel ?? inputPlaceholder} className={`w-full px-2 py-1 text-sm border rounded bg-white dark:bg-navy-800 dark:text-warm-200 border-warm-200 dark:border-navy-700 placeholder-warm-400 dark:placeholder-warm-500 focus:outline-none focus:ring-1 focus:ring-teal-400 ${className}`} /> ); diff --git a/frontend/src/hooks/useHexagonSelection.ts b/frontend/src/hooks/useHexagonSelection.ts index 425158a..ae53464 100644 --- a/frontend/src/hooks/useHexagonSelection.ts +++ b/frontend/src/hooks/useHexagonSelection.ts @@ -144,12 +144,7 @@ export function useHexagonSelection({ ); const fetchPostcodeStats = useCallback( - async ( - postcode: string, - signal?: AbortSignal, - includeFilters = true, - fields?: string[] - ) => { + async (postcode: string, signal?: AbortSignal, includeFilters = true, fields?: string[]) => { const params = new URLSearchParams({ postcode }); const filterStr = includeFilters ? buildFilterString(filters, features) : ''; if (filterStr) params.append('filters', filterStr); diff --git a/frontend/src/hooks/useListingLayers.ts b/frontend/src/hooks/useListingLayers.ts index 1d4871e..75da1de 100644 --- a/frontend/src/hooks/useListingLayers.ts +++ b/frontend/src/hooks/useListingLayers.ts @@ -13,6 +13,7 @@ const LISTING_CLUSTER_MAX_ZOOM = 24; const LISTING_CLUSTER_POPUP_LIMIT = 30; const LISTING_SPIDERFY_LIMIT = 12; const TILE_SIZE = 512; +const PRICE_LABEL_CHARACTER_SET = '£0123456789.kM'; interface SingleListingPopupInfo { mode: 'single'; @@ -472,6 +473,7 @@ export function useListingLayers({ listings, zoom, isDark }: UseListingLayersPro outlineWidth: 3, outlineColor: isDark ? [10, 10, 10, 220] : [255, 255, 255, 230], fontSettings: { sdf: true }, + characterSet: PRICE_LABEL_CHARACTER_SET, sizeUnits: 'pixels', sizeMinPixels: 10, sizeMaxPixels: 14, diff --git a/frontend/src/hooks/useLocationSearch.ts b/frontend/src/hooks/useLocationSearch.ts index 675f206..05def85 100644 --- a/frontend/src/hooks/useLocationSearch.ts +++ b/frontend/src/hooks/useLocationSearch.ts @@ -154,16 +154,110 @@ function searchResultKey(result: SearchResult): string { return `place:${result.slug}`; } -export function useLocationSearch(mode?: string) { +/** A category-tagged, scored result from the unified `/api/places` ranking. */ +type UnifiedResultDTO = + | { type: 'postcode'; label: string; score: number } + | { type: 'address'; address: string; postcode: string; lat: number; lon: number; score: number } + | { + type: 'place'; + name: string; + slug: string; + place_type: string; + lat: number; + lon: number; + city?: string; + score: number; + }; + +interface PlacesApiResponse { + places: PlaceResult[]; + postcodes?: string[]; + addresses?: AddressResult[]; + /** Preferred: a single relevance-ordered list across all categories. */ + results?: UnifiedResultDTO[]; +} + +function isNonNull<T>(value: T | null): value is T { + return value !== null; +} + +function unifiedToSearchResult(result: UnifiedResultDTO): SearchResult | null { + if (result.type === 'postcode') { + return { type: 'postcode', label: result.label }; + } + if (result.type === 'address') { + return { + type: 'address', + address: result.address, + postcode: result.postcode, + lat: result.lat, + lon: result.lon, + }; + } + if (result.type === 'place') { + return { + type: 'place', + name: result.name, + slug: result.slug, + place_type: result.place_type, + lat: result.lat, + lon: result.lon, + city: result.city === 'City of London' ? 'London' : result.city, + }; + } + return null; +} + +/** Legacy ordering for servers that predate the unified `results` list: positional buckets, + * re-filtered locally. Retained only as a fallback. */ +function legacyCombineResults(json: PlacesApiResponse, trimmed: string): SearchResult[] { + const placeResults = json.places.map((p) => ({ + type: 'place' as const, + name: p.name, + slug: p.slug, + place_type: p.place_type, + lat: p.lat, + lon: p.lon, + city: p.city === 'City of London' ? 'London' : p.city, + })); + const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode'); + const otherPlaceResults = placeResults.filter((result) => result.place_type !== 'outcode'); + const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({ + type: 'postcode' as const, + label: postcode, + })); + const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({ + type: 'address' as const, + address: address.address, + postcode: address.postcode, + lat: address.lat, + lon: address.lon, + })); + const containsHouseNumber = /\d/.test(trimmed); + return filterResultsForQuery( + containsHouseNumber + ? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults] + : [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults], + trimmed + ); +} + +export type ViewportCenter = { lat: number; lng: number }; + +export function useLocationSearch(mode?: string, getViewportCenter?: () => ViewportCenter | null) { const [query, setQuery] = useState(''); const [results, setResults] = useState<SearchResult[]>([]); const [recentSearches, setRecentSearches] = useState<SearchResult[]>(readRecentSearches); const [activeIndex, setActiveIndex] = useState(-1); const [open, setOpen] = useState(false); + const [searching, setSearching] = useState(false); const abortRef = useRef<AbortController | null>(null); const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const latestQueryRef = useRef(''); const lastResultsRef = useRef<SearchResult[]>([]); + // Held in a ref so a non-memoized callback from the parent doesn't churn handleInputChange. + const getViewportCenterRef = useRef(getViewportCenter); + getViewportCenterRef.current = getViewportCenter; const handleInputChange = useCallback( (value: string) => { @@ -176,6 +270,7 @@ export function useLocationSearch(mode?: string) { const trimmed = value.trim(); if (!trimmed) { + setSearching(false); setResults(recentSearches); lastResultsRef.current = []; setOpen(recentSearches.length > 0); @@ -183,6 +278,7 @@ export function useLocationSearch(mode?: string) { } if (!mode && looksLikePostcode(trimmed)) { + setSearching(false); const postcodeResults: SearchResult[] = [ { type: 'postcode', label: normalizePostcode(trimmed) }, ]; @@ -192,6 +288,7 @@ export function useLocationSearch(mode?: string) { } if (trimmed.length < 2) { + setSearching(false); setResults([]); setOpen(false); return; @@ -200,6 +297,7 @@ export function useLocationSearch(mode?: string) { const locallyFilteredResults = filterResultsForQuery(lastResultsRef.current, trimmed); setResults(locallyFilteredResults); setOpen(locallyFilteredResults.length > 0); + setSearching(true); debounceRef.current = setTimeout(async () => { const controller = new AbortController(); @@ -207,55 +305,46 @@ export function useLocationSearch(mode?: string) { try { const params = new URLSearchParams({ q: trimmed }); if (mode) params.set('mode', mode); + const center = getViewportCenterRef.current?.(); + if (center) { + params.set('lat', String(center.lat)); + params.set('lng', String(center.lng)); + } const res = await fetch( `/api/places?${params}`, authHeaders({ signal: controller.signal }) ); - if (!res.ok) return; - const json: { - places: PlaceResult[]; - postcodes?: string[]; - addresses?: AddressResult[]; - } = await res.json(); - const placeResults = json.places.map((p) => ({ - type: 'place' as const, - name: p.name, - slug: p.slug, - place_type: p.place_type, - lat: p.lat, - lon: p.lon, - city: p.city === 'City of London' ? 'London' : p.city, - })); - const outcodeResults = placeResults.filter((result) => result.place_type === 'outcode'); - const otherPlaceResults = placeResults.filter( - (result) => result.place_type !== 'outcode' - ); - const postcodeResults: SearchResult[] = (json.postcodes ?? []).map((postcode) => ({ - type: 'postcode' as const, - label: postcode, - })); - const addressResults: SearchResult[] = (json.addresses ?? []).map((address) => ({ - type: 'address' as const, - address: address.address, - postcode: address.postcode, - lat: address.lat, - lon: address.lon, - })); - const containsHouseNumber = /\d/.test(trimmed); + if (!res.ok) { + if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) { + setResults([]); + setOpen(true); + } + return; + } + const json: PlacesApiResponse = await res.json(); const combinedResults = ( - containsHouseNumber - ? [...outcodeResults, ...postcodeResults, ...addressResults, ...otherPlaceResults] - : [...outcodeResults, ...postcodeResults, ...otherPlaceResults, ...addressResults] + Array.isArray(json.results) + ? json.results.map(unifiedToSearchResult).filter(isNonNull) + : legacyCombineResults(json, trimmed) ).slice(0, 20); if (controller.signal.aborted || latestQueryRef.current.trim() !== trimmed) { return; } lastResultsRef.current = combinedResults; - const matchingResults = filterResultsForQuery(combinedResults, trimmed); - setResults(matchingResults); - setOpen(matchingResults.length > 0); + // Trust the server's unified ranking — re-filtering here previously dropped valid + // alias and partial-postcode matches. The optimistic pre-fetch path still filters. + setResults(combinedResults); + setOpen(true); } catch (err) { logNonAbortError('places search', err); + if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) { + setResults([]); + setOpen(true); + } + } finally { + if (!controller.signal.aborted && latestQueryRef.current.trim() === trimmed) { + setSearching(false); + } } }, 200); }, @@ -264,7 +353,7 @@ export function useLocationSearch(mode?: string) { const showEmptySearches = useCallback(() => { if (latestQueryRef.current.trim()) { - setOpen(results.length > 0); + setOpen(results.length > 0 || latestQueryRef.current.trim().length >= 2); return; } @@ -278,6 +367,7 @@ export function useLocationSearch(mode?: string) { const clear = useCallback(() => { setQuery(''); latestQueryRef.current = ''; + setSearching(false); setResults([]); lastResultsRef.current = []; setOpen(false); @@ -308,6 +398,8 @@ export function useLocationSearch(mode?: string) { e.preventDefault(); if (activeIndex >= 0 && activeIndex < results.length) { onSelect(results[activeIndex]); + } else if (results.length > 0) { + onSelect(results[0]); } else if (looksLikePostcode(query)) { onSelect({ type: 'postcode', label: normalizePostcode(query) }); } @@ -332,6 +424,7 @@ export function useLocationSearch(mode?: string) { activeIndex, setActiveIndex, open, + searching, setOpen, handleInputChange, handleKeyDown, diff --git a/frontend/src/hooks/useTravelTime.test.ts b/frontend/src/hooks/useTravelTime.test.ts index 06073de..81ac3b9 100644 --- a/frontend/src/hooks/useTravelTime.test.ts +++ b/frontend/src/hooks/useTravelTime.test.ts @@ -178,7 +178,11 @@ describe('resolveTransitVariant', () => { describe('parseServerMode', () => { it('round-trips the four toggle-reachable variants', () => { - expect(parseServerMode('transit')).toEqual({ mode: 'transit', noChange: false, noBuses: false }); + expect(parseServerMode('transit')).toEqual({ + mode: 'transit', + noChange: false, + noBuses: false, + }); expect(parseServerMode('transit-no-bus')).toEqual({ mode: 'transit', noChange: false, @@ -198,8 +202,16 @@ describe('parseServerMode', () => { it('parses non-transit base modes', () => { expect(parseServerMode('car')).toEqual({ mode: 'car', noChange: false, noBuses: false }); - expect(parseServerMode('bicycle')).toEqual({ mode: 'bicycle', noChange: false, noBuses: false }); - expect(parseServerMode('walking')).toEqual({ mode: 'walking', noChange: false, noBuses: false }); + expect(parseServerMode('bicycle')).toEqual({ + mode: 'bicycle', + noChange: false, + noBuses: false, + }); + expect(parseServerMode('walking')).toEqual({ + mode: 'walking', + noChange: false, + noBuses: false, + }); }); it('returns null for variants the UI cannot represent (no silent broadening)', () => { diff --git a/frontend/src/hooks/useTravelTime.ts b/frontend/src/hooks/useTravelTime.ts index 243e907..b913525 100644 --- a/frontend/src/hooks/useTravelTime.ts +++ b/frontend/src/hooks/useTravelTime.ts @@ -138,7 +138,15 @@ export function useTravelTime(initial?: TravelTimeInitial) { const handleAddEntry = useCallback((mode: TransportMode) => { setEntries((prev) => [ ...prev, - { mode, slug: '', label: '', timeRange: null, useBest: false, noChange: false, noBuses: false }, + { + mode, + slug: '', + label: '', + timeRange: null, + useBest: false, + noChange: false, + noBuses: false, + }, ]); }, []); diff --git a/frontend/src/i18n/descriptions.ts b/frontend/src/i18n/descriptions.ts index 941da85..31a279b 100644 --- a/frontend/src/i18n/descriptions.ts +++ b/frontend/src/i18n/descriptions.ts @@ -22,7 +22,8 @@ const descriptions: Record<string, Record<string, string>> = { 'Est. price per sqm': 'Prix actuel estimé rapporté à la surface totale', 'Estimated monthly rent': 'Loyer mensuel privé moyen dans le secteur', 'Total floor area (sqm)': 'Surface intérieure relevée lors du diagnostic EPC', - 'Number of bedrooms & living rooms': 'Nombre de pièces habitables relevé lors du diagnostic EPC', + 'Number of bedrooms & living rooms': + 'Nombre de pièces habitables relevé lors du diagnostic EPC', 'Construction year': 'Année de construction estimée à partir de l’EPC', 'Date of last transaction': 'Date de la vente la plus récente enregistrée au Land Registry', 'Former council house': 'Indique si le bien a déjà été répertorié comme logement social', @@ -30,7 +31,8 @@ const descriptions: Record<string, Record<string, string>> = { 'Potential energy rating': 'Classe énergétique EPC possible si toutes les améliorations recommandées étaient réalisées', 'Interior height (m)': 'Hauteur intérieure moyenne relevée lors du diagnostic EPC', - 'Street tree density percentile': 'Percentile estimé de couverture arborée autour du code postal', + 'Street tree density percentile': + 'Percentile estimé de couverture arborée autour du code postal', 'Within conservation area': 'Indique si le point représentatif du code postal se situe dans une zone de conservation désignée', 'Listed building': @@ -67,7 +69,8 @@ const descriptions: Record<string, Record<string, string>> = { 'Moyenne annuelle des violences et infractions sexuelles dans le secteur', 'Burglary (avg/yr)': 'Moyenne annuelle des cambriolages dans le secteur', 'Robbery (avg/yr)': 'Moyenne annuelle des vols avec violence dans le secteur', - 'Vehicle crime (avg/yr)': 'Moyenne annuelle des infractions liées aux véhicules dans le secteur', + 'Vehicle crime (avg/yr)': + 'Moyenne annuelle des infractions liées aux véhicules dans le secteur', 'Anti-social behaviour (avg/yr)': 'Moyenne annuelle des comportements antisociaux dans le secteur', 'Criminal damage and arson (avg/yr)': @@ -85,12 +88,11 @@ const descriptions: Record<string, Record<string, string>> = { '% White': 'Part de la population s’identifiant comme blanche', '% South Asian': 'Part de la population s’identifiant comme sud-asiatique', '% Black': 'Part de la population s’identifiant comme noire', - '% East Asian': 'Part de la population s’identifiant comme est-asiatique', + '% East/SE Asian': 'Part de la population s’identifiant comme est/sud-est asiatique', '% Mixed': 'Part de la population s’identifiant comme métisse ou de plusieurs groupes ethniques', '% Other': 'Part de la population s’identifiant comme appartenant à un autre groupe ethnique', - 'Voter turnout (%)': - 'Part des électeurs inscrits ayant voté aux élections générales de 2024', + 'Voter turnout (%)': 'Part des électeurs inscrits ayant voté aux élections générales de 2024', '% Labour': 'Part des voix travaillistes aux élections générales de 2024', '% Conservative': 'Part des voix conservatrices aux élections générales de 2024', '% Liberal Democrat': 'Part des voix libérales-démocrates aux élections générales de 2024', @@ -98,8 +100,10 @@ const descriptions: Record<string, Record<string, string>> = { '% Green': 'Part des voix vertes aux élections générales de 2024', '% Other parties': 'Part cumulée des voix de tous les autres partis et indépendants', 'Distance to nearest park (km)': 'Distance au parc ou espace vert le plus proche', - 'Noise (dB)': 'Niveau maximal de bruit des transports près du code postal, en décibels (Lden)', - 'Max available download speed (Mbps)': 'Débit descendant haut débit maximal disponible au code postal', + 'Noise (dB)': + 'Le plus élevé des bruits routier, ferroviaire ou aérien près du code postal, en décibels (Lden). Angleterre uniquement ; vide = hors zone cartographiée, pas forcément calme.', + 'Max available download speed (Mbps)': + 'Débit descendant haut débit maximal disponible au code postal', Schools: 'Écoles primaires et secondaires notées à proximité', 'Specific crimes': 'Filtrer une seule catégorie d’infractions de rue à la fois', Ethnicities: 'Part de la population par groupe ethnique', @@ -152,8 +156,7 @@ const descriptions: Record<string, Record<string, string>> = { 'Education, Skills and Training Score': 'Benachteiligungsperzentil für Bildung, Kompetenzen und Ausbildung (höher = weniger benachteiligt)', 'Income Score': 'Einkommensbenachteiligungsperzentil (höher = weniger benachteiligt)', - 'Employment Score': - 'Beschäftigungsbenachteiligungsperzentil (höher = weniger benachteiligt)', + 'Employment Score': 'Beschäftigungsbenachteiligungsperzentil (höher = weniger benachteiligt)', 'Health Deprivation and Disability Score': 'Perzentil für gesundheitliche Benachteiligung und Behinderung (höher = bessere Ergebnisse)', 'Housing Conditions Score': 'Wohnbedingungen-Perzentil (höher = bessere Bedingungen)', @@ -185,7 +188,7 @@ const descriptions: Record<string, Record<string, string>> = { '% White': 'Anteil der Personen, die sich als weiß identifizieren', '% South Asian': 'Anteil der Personen, die sich als südasiatisch identifizieren', '% Black': 'Anteil der Personen, die sich als schwarz identifizieren', - '% East Asian': 'Anteil der Personen, die sich als ostasiatisch identifizieren', + '% East/SE Asian': 'Anteil der Personen, die sich als ost-/südostasiatisch identifizieren', '% Mixed': 'Anteil der Personen, die sich als gemischt oder mehreren ethnischen Gruppen zugehörig identifizieren', '% Other': 'Anteil der Personen, die sich einer anderen ethnischen Gruppe zuordnen', @@ -198,7 +201,8 @@ const descriptions: Record<string, Record<string, string>> = { '% Green': 'Stimmenanteil der Grünen bei der Parlamentswahl 2024', '% Other parties': 'Kombinierter Stimmenanteil aller anderen Parteien und Unabhängigen', 'Distance to nearest park (km)': 'Entfernung zum nächsten Park oder Grünfläche', - 'Noise (dB)': 'Maximaler Verkehrslärmpegel in der Nähe des Postcodes in Dezibel (Lden)', + 'Noise (dB)': + 'Lautester von Straßen-, Bahn- oder Fluglärm in der Nähe des Postcodes in Dezibel (Lden). Nur England; leer = nicht kartiert, nicht unbedingt leise.', 'Max available download speed (Mbps)': 'Maximal verfügbare Breitband-Downloadgeschwindigkeit am Postcode', Schools: 'Bewertete Grundschulen und weiterführende Schulen in der Nähe', @@ -262,7 +266,7 @@ const descriptions: Record<string, Record<string, string>> = { '% White': '白人人口比例', '% South Asian': '南亚裔人口比例', '% Black': '黑人人口比例', - '% East Asian': '东亚裔人口比例', + '% East/SE Asian': '东亚/东南亚裔人口比例', '% Mixed': '混血或多族裔人口比例', '% Other': '其他族裔人口比例', 'Voter turnout (%)': '2024 年大选中登记选民的投票率', @@ -273,7 +277,8 @@ const descriptions: Record<string, Record<string, string>> = { '% Green': '2024 年大选中绿党得票率', '% Other parties': '所有其他政党和独立候选人的综合得票率', 'Distance to nearest park (km)': '到最近公园或绿地的距离', - 'Noise (dB)': '该邮编附近最高交通噪音水平(Lden,分贝)', + 'Noise (dB)': + '该邮编附近道路、铁路或机场中最高的噪音水平(Lden,分贝)。仅英格兰;空白表示未覆盖,不一定安静。', 'Max available download speed (Mbps)': '该邮编可用的最高宽带下载速度', Schools: '附近有评级的小学和中学', 'Specific crimes': '一次筛选一种街面犯罪类别', @@ -347,7 +352,7 @@ const descriptions: Record<string, Record<string, string>> = { '% White': 'श्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत', '% South Asian': 'दक्षिण एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत', '% Black': 'अश्वेत के रूप में पहचान करने वाली आबादी का प्रतिशत', - '% East Asian': 'पूर्वी एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत', + '% East/SE Asian': 'पूर्वी/दक्षिण-पूर्वी एशियाई के रूप में पहचान करने वाली आबादी का प्रतिशत', '% Mixed': 'मिश्रित या कई जातीय समूहों से पहचान करने वाली आबादी का प्रतिशत', '% Other': 'अन्य जातीय समूह के रूप में पहचान करने वाली आबादी का प्रतिशत', 'Voter turnout (%)': '2024 आम चुनाव में मतदान करने वाले पंजीकृत मतदाताओं का प्रतिशत', @@ -358,7 +363,8 @@ const descriptions: Record<string, Record<string, string>> = { '% Green': '2024 आम चुनाव में ग्रीन पार्टी का मत-प्रतिशत', '% Other parties': 'बाकी सभी पार्टियों और निर्दलीयों का संयुक्त मत-प्रतिशत', 'Distance to nearest park (km)': 'निकटतम पार्क या हरित क्षेत्र तक दूरी', - 'Noise (dB)': 'पोस्टकोड पर सड़क शोर स्तर, डेसीबल (Lden) में', + 'Noise (dB)': + 'पोस्टकोड के पास सड़क, रेल या हवाई अड्डे के शोर में सबसे अधिक, डेसीबल (Lden) में। केवल इंग्लैंड; खाली = मैप नहीं किया गया, जरूरी नहीं कि शांत हो।', 'Max available download speed (Mbps)': 'पोस्टकोड पर उपलब्ध अधिकतम डाउनलोड गति', Schools: 'पास के रेटेड प्राइमरी और सेकेंडरी स्कूल', 'Specific crimes': 'एक समय में एक सड़क-स्तर अपराध श्रेणी से फिल्टर करें', @@ -438,7 +444,7 @@ const descriptions: Record<string, Record<string, string>> = { '% White': 'A fehérként azonosított lakosság aránya', '% South Asian': 'A dél-ázsiaiként azonosított lakosság aránya', '% Black': 'A feketeként azonosított lakosság aránya', - '% East Asian': 'A kelet-ázsiaiként azonosított lakosság aránya', + '% East/SE Asian': 'A kelet-/délkelet-ázsiaiként azonosított lakosság aránya', '% Mixed': 'A vegyes vagy több etnikai csoporthoz tartozóként azonosított lakosság aránya', '% Other': 'Az egyéb etnikai csoportba tartozóként azonosított lakosság aránya', 'Voter turnout (%)': @@ -450,7 +456,8 @@ const descriptions: Record<string, Record<string, string>> = { '% Green': 'A Zöld Párt szavazataránya a 2024-es parlamenti választáson', '% Other parties': 'Az összes többi párt és független jelölt összesített szavazataránya', 'Distance to nearest park (km)': 'Távolság a legközelebbi parkig vagy zöldterületig', - 'Noise (dB)': 'Közúti zajszint az irányítószámnál decibelben (Lden)', + 'Noise (dB)': + 'Az út-, vasúti vagy repülőtéri zaj közül a leghangosabb az irányítószámnál, decibelben (Lden). Csak Anglia; üres = nem térképezett, nem feltétlenül csendes.', 'Max available download speed (Mbps)': 'Az irányítószámnál elérhető maximális szélessávú letöltési sebesség', Schools: 'Közeli minősített általános és középiskolák', diff --git a/frontend/src/i18n/details.ts b/frontend/src/i18n/details.ts index e1179ad..578433b 100644 --- a/frontend/src/i18n/details.ts +++ b/frontend/src/i18n/details.ts @@ -8,7 +8,7 @@ export const details: Record<string, Record<string, string>> = { 'Property type': 'Données HM Land Registry Price Paid et certificats EPC. Maison individuelle, maison jumelée, maison mitoyenne (tous les sous-types de maisons en rangée), appartement/maisonette, ou autre type (bungalows, park homes, etc.).', 'Leasehold/Freehold': - "Données HM Land Registry Price Paid. Freehold signifie que vous possédez le bâtiment et le terrain sur lequel il se trouve. Leasehold signifie que vous possédez le bâtiment mais pas le terrain : vous détenez un bail accordé par le freeholder pour une durée déterminée.", + 'Données HM Land Registry Price Paid. Freehold signifie que vous possédez le bâtiment et le terrain sur lequel il se trouve. Leasehold signifie que vous possédez le bâtiment mais pas le terrain : vous détenez un bail accordé par le freeholder pour une durée déterminée.', 'Last known price': "Le dernier prix de vente enregistré pour ce bien provenant des données HM Land Registry Price Paid. Couvre les ventes résidentielles en Angleterre. Peut dater de plusieurs années si le bien n'a pas été vendu récemment.", 'Estimated current price': @@ -72,7 +72,7 @@ export const details: Record<string, Record<string, string>> = { 'Serious crime (avg/yr)': "Somme annuelle des violences, robberies, burglaries et possessions d'armes dans un rayon de 50 m du code postal, comptée à partir des points de criminalité street-level de police.uk (anonymisés et rattachés à des points cartographiques proches). Fournit un indicateur unique de criminalité grave.", 'Minor crime (avg/yr)': - "Somme annuelle des comportements antisociaux, shoplifting, vols de vélos et autres infractions de moindre gravité dans un rayon de 50 m du code postal, comptée à partir des points de criminalité street-level de police.uk (anonymisés et rattachés à des points cartographiques proches). Fournit un indicateur unique de criminalité mineure.", + 'Somme annuelle des comportements antisociaux, shoplifting, vols de vélos et autres infractions de moindre gravité dans un rayon de 50 m du code postal, comptée à partir des points de criminalité street-level de police.uk (anonymisés et rattachés à des points cartographiques proches). Fournit un indicateur unique de criminalité mineure.', 'Violence and sexual offences (avg/yr)': 'Nombre moyen annuel de violences et infractions sexuelles dans un rayon de 50 m du code postal, d’après les données de criminalité street-level de police.uk. Inclut les agressions, le harcèlement et les infractions sexuelles.', 'Burglary (avg/yr)': @@ -109,8 +109,8 @@ export const details: Record<string, Record<string, string>> = { "Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Indien, Pakistanais, Bangladais ou toute autre origine asiatique.", '% Black': "Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Noir, Noir britannique, Caribéen ou Africain.", - '% East Asian': - "Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Chinois.", + '% East/SE Asian': + "Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Chinois ou d'une autre origine est/sud-est asiatique.", '% Mixed': "Provient du Census 2021. Pourcentage de la population de l'autorité locale s'identifiant comme Mixte ou appartenant à plusieurs groupes ethniques (Blanc et Noir caribéen, Blanc et Noir africain, Blanc et Asiatique, ou tout autre fond mixte ou multiple).", '% Other': @@ -132,7 +132,7 @@ export const details: Record<string, Record<string, string>> = { 'Distance to nearest park (km)': "Distance à vol d'oiseau en kilomètres entre le code postal et l'entrée de parc la plus proche. Couvre les parcs publics, jardins, terrains de sport et aires de jeux. Utilise les points d'accès du jeu de données OS Open Greenspace, afin que les biens en bordure d'un grand parc affichent bien une courte distance.", 'Noise (dB)': - "Niveau maximal de bruit routier, ferroviaire ou aérien en décibels (Lden, moyenne pondérée sur 24 heures) d'après le Strategic Noise Mapping Round 4 de Defra (2022). Modélisé à 4 m au-dessus du sol sur une grille de 10 m et échantillonné comme la cellule de 10 m la plus bruyante autour du point représentatif du code postal. Au-dessus d'environ 55 dB, le bruit est généralement perceptible ; au-dessus d'environ 70 dB, l'OMS le considère comme nocif.", + "Niveau maximal de bruit routier, ferroviaire ou aérien en décibels (Lden, moyenne pondérée sur 24 heures) d'après le Strategic Noise Mapping Round 4 de Defra (2022). Modélisé à 4 m au-dessus du sol sur une grille de 10 m et échantillonné comme la cellule de 10 m la plus bruyante autour du point représentatif du code postal. Au-dessus d'environ 55 dB, le bruit est généralement perceptible ; au-dessus d'environ 70 dB, l'OMS le considère comme nocif. Couvre l'Angleterre uniquement ; une valeur vide signifie l'absence de données cartographiées (pas forcément calme).", 'Max available download speed (Mbps)': "Débit descendant maximal de haut débit fixe disponible auprès de n'importe quel fournisseur, d'après Ofcom Connected Nations 2025. Il s'agit du maximum théorique, pas des débits réellement obtenus. 10 Mbps = basique, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.", Schools: @@ -255,8 +255,8 @@ export const details: Record<string, Record<string, string>> = { 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Indisch, Pakistanisch, Bangladeschisch oder mit sonstigem asiatischen Hintergrund identifiziert.', '% Black': 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Schwarz, Schwarz-Britisch, Karibisch oder Afrikanisch identifiziert.', - '% East Asian': - 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Chinesisch identifiziert.', + '% East/SE Asian': + 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als Chinesisch oder einer anderen ost-/südostasiatischen Herkunft identifiziert.', '% Mixed': 'Aus dem Census 2021. Prozentsatz der Bevölkerung der Gemeinde, die sich als gemischt oder mit mehreren ethnischen Zugehörigkeiten identifiziert (Weiß und Schwarzkaribisch, Weiß und Schwarzafrikanisch, Weiß und Asiatisch oder sonstiger gemischter Hintergrund).', '% Other': @@ -278,7 +278,7 @@ export const details: Record<string, Record<string, string>> = { 'Distance to nearest park (km)': 'Luftlinienentfernung in Kilometern vom Postleitzahlenzentrum zum nächsten Parkeingang. Umfasst öffentliche Parks, Gärten, Sportplätze und Spielbereiche. Verwendet Zugangspunktstandorte aus dem OS Open Greenspace-Datensatz, sodass Immobilien an der Grenze eines großen Parks korrekt eine kurze Entfernung anzeigen.', 'Noise (dB)': - 'Straßenlärmpegel in Dezibel (Lden, ein 24-Stunden-gewichteter Durchschnitt) aus Defras Strategic Noise Mapping Round 4 (2022). Modelliert in 4 m Höhe über dem Boden auf einem 10-m-Raster. Über ~55 dB ist in der Regel wahrnehmbar; über ~70 dB gilt laut WHO als gesundheitsschädlich.', + 'Straßenlärmpegel in Dezibel (Lden, ein 24-Stunden-gewichteter Durchschnitt) aus Defras Strategic Noise Mapping Round 4 (2022). Modelliert in 4 m Höhe über dem Boden auf einem 10-m-Raster. Über ~55 dB ist in der Regel wahrnehmbar; über ~70 dB gilt laut WHO als gesundheitsschädlich. Nur England; ein leerer Wert bedeutet keine kartierten Daten (nicht unbedingt leise).', 'Max available download speed (Mbps)': 'Maximale verfügbare Festnetz-Download-Geschwindigkeit von einem beliebigen Anbieter, aus Ofcom Connected Nations 2025. Gibt die theoretische Höchstgeschwindigkeit an, keine tatsächlich erreichten Geschwindigkeiten. 10 Mbps = Basis, 30 = schnell, 100+ = ultraschnell, 1000 = Gigabit.', Schools: @@ -398,7 +398,7 @@ export const details: Record<string, Record<string, string>> = { '% South Asian': '来自2021年Census。地方政府人口中认同为印度人、巴基斯坦人、孟加拉国人或其他亚洲背景的百分比。', '% Black': '来自2021年Census。地方政府人口中认同为黑人、英国黑人、加勒比人或非洲人的百分比。', - '% East Asian': '来自2021年Census。地方政府人口中认同为华人的百分比。', + '% East/SE Asian': '来自2021年Census。地方政府人口中认同为华人或其他东亚/东南亚裔的百分比。', '% Mixed': '来自2021年 Census。地方政府人口中认同为混血或多种族群体(白人与黑人加勒比裔、白人与黑人非洲裔、白人与亚洲裔,或其他混血或多种族背景)的百分比。', '% Other': @@ -416,7 +416,7 @@ export const details: Record<string, Record<string, string>> = { 'Distance to nearest park (km)': '从邮政编码到最近公园入口的直线距离(km)。涵盖公共公园、花园、运动场和游乐场地。使用 OS Open Greenspace 数据集中的出入口位置,因此紧邻大型公园的房产可正确显示较短距离。', 'Noise (dB)': - '来自Defra战略噪声图第4轮(2022年)的道路噪声水平,单位为分贝(Lden,24小时加权平均值)。在地面以上4m、10m网格间距处建模。一般而言,超过约55 dB可明显感知;超过约70 dB被世卫组织认定为有害。', + '来自Defra战略噪声图第4轮(2022年)的道路噪声水平,单位为分贝(Lden,24小时加权平均值)。在地面以上4m、10m网格间距处建模。一般而言,超过约55 dB可明显感知;超过约70 dB被世卫组织认定为有害。仅覆盖英格兰;空值表示无映射数据(不一定安静)。', 'Max available download speed (Mbps)': '来自Ofcom Connected Nations 2025的任意运营商可提供的最大固定宽带下载速度。代表理论最大值,而非实际达到的速度。10 Mbps为基础级,30为超快级,100+为极速级,1000为千兆级。', Schools: @@ -539,8 +539,8 @@ export const details: Record<string, Record<string, string>> = { 'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को भारतीय, पाकिस्तानी, बांग्लादेशी या किसी अन्य एशियाई पृष्ठभूमि के रूप में पहचानता है.', '% Black': 'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को अश्वेत, अश्वेत ब्रिटिश, कैरिबियाई या अफ्रीकी के रूप में पहचानता है.', - '% East Asian': - 'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को चीनी के रूप में पहचानता है.', + '% East/SE Asian': + 'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को चीनी या अन्य पूर्वी/दक्षिण-पूर्वी एशियाई के रूप में पहचानता है.', '% Mixed': 'Census 2021 से. स्थानीय प्राधिकरण की आबादी का प्रतिशत जो खुद को मिश्रित या कई जातीय समूहों (श्वेत और अश्वेत कैरिबियाई, श्वेत और अश्वेत अफ्रीकी, श्वेत और एशियाई या अन्य मिश्रित/बहुजातीय पृष्ठभूमि) के रूप में पहचानता है.', '% Other': @@ -562,7 +562,7 @@ export const details: Record<string, Record<string, string>> = { 'Distance to nearest park (km)': 'पोस्टकोड से निकटतम पार्क प्रवेश तक सीधी रेखा में दूरी, किलोमीटर में. इसमें सार्वजनिक पार्क, बगीचे, खेल मैदान और खेल स्थान शामिल हैं. OS Open Greenspace डेटा सेट के प्रवेश-बिंदु स्थानों का उपयोग करता है, इसलिए बड़े पार्क के किनारे स्थित संपत्तियां सही कम दूरी दिखाती हैं.', 'Noise (dB)': - 'Defra Strategic Noise Mapping Round 4 (2022) से सड़क-शोर स्तर, डेसीबल में (Lden, 24-घंटे भारित औसत). जमीन से 4 m ऊपर 10 m ग्रिड पर मॉडल किया गया. लगभग 55 dB से ऊपर शोर आमतौर पर महसूस होता है; लगभग 70 dB से ऊपर WHO इसे हानिकारक मानता है.', + 'Defra Strategic Noise Mapping Round 4 (2022) से सड़क-शोर स्तर, डेसीबल में (Lden, 24-घंटे भारित औसत). जमीन से 4 m ऊपर 10 m ग्रिड पर मॉडल किया गया. लगभग 55 dB से ऊपर शोर आमतौर पर महसूस होता है; लगभग 70 dB से ऊपर WHO इसे हानिकारक मानता है. केवल इंग्लैंड को कवर करता है; खाली मान का अर्थ है कोई मैप किया गया डेटा नहीं (जरूरी नहीं कि शांत हो).', 'Max available download speed (Mbps)': 'Ofcom Connected Nations 2025 से किसी भी प्रदाता द्वारा उपलब्ध अधिकतम स्थिर ब्रॉडबैंड डाउनलोड गति. यह सैद्धांतिक अधिकतम दिखाता है, वास्तविक प्राप्त गति नहीं. 10 Mbps = बुनियादी, 30 = तेज, 100+ = अत्यंत तेज, 1000 = गीगाबिट.', Schools: @@ -685,8 +685,8 @@ export const details: Record<string, Record<string, string>> = { 'A 2021-es Census alapján. A helyi hatóság területén indiai, pakisztáni, bangladesi vagy bármely más ázsiai háttérként azonosított népesség százaléka.', '% Black': 'A 2021-es Census alapján. A helyi hatóság területén fekete, brit fekete, karibi vagy afrikai háttérként azonosított népesség százaléka.', - '% East Asian': - 'A 2021-es Census alapján. A helyi hatóság területén kínaiként azonosított népesség százaléka.', + '% East/SE Asian': + 'A 2021-es Census alapján. A helyi hatóság területén kínai vagy más kelet-/délkelet-ázsiai származásúként azonosított népesség százaléka.', '% Mixed': 'A 2021-es Census alapján. A helyi hatóság területén vegyes vagy többes etnikai csoportként (fehér és fekete karibi, fehér és fekete afrikai, fehér és ázsiai, vagy bármely más vegyes vagy többes háttér) azonosított népesség százaléka.', '% Other': @@ -708,7 +708,7 @@ export const details: Record<string, Record<string, string>> = { 'Distance to nearest park (km)': 'Légvonalbeli távolság kilométerben az irányítószámtól a legközelebbi park bejáratáig. Magában foglalja a közparkokat, kerteket, játszótereket és szabadidős területeket. Az OS Open Greenspace adatkészlet hozzáférési pont helyszíneit használja, így a nagy park szomszédságában lévő ingatlanok helyesen rövid távolságot mutatnak.', 'Noise (dB)': - 'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett a WHO károsnak minősíti.', + 'Közúti zajszint decibel (Lden, 24 órás súlyozott átlag) értékben, a Defra Stratégiai Zajtérképezés 4. fordulójából (2022). 4 m magasságban, 10 m-es rácson modellezve. ~55 dB felett általában érzékelhető; ~70 dB felett a WHO károsnak minősíti. Csak Angliát fedi le; az üres érték nem térképezett adatot jelent (nem feltétlenül csendes).', 'Max available download speed (Mbps)': 'Bármely szolgáltatótól elérhető maximális rögzített szélessávú letöltési sebesség, az Ofcom Connected Nations 2025 adataiból. Az elméleti maximumot jelöli, nem a valós sebességet. 10 Mbps = alapszintű, 30 = szupergyors, 100+ = ultragyors, 1000 = gigabites.', Schools: diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index 89e3fc2..b4fdb4e 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -214,22 +214,19 @@ const de: Translations = { 'Relax one constraint at a time': 'Lockere eine Einschränkung nach der anderen', 'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.': 'Wenn die Suche zu eng wird, lockere einen einzelnen Filter und sieh, welche Postcodes wieder auftauchen. So werden Kompromisse sichtbar statt geraten.', - 'Turn vague areas into specific postcodes': - 'Verwandle vage Gebiete in konkrete Postcodes', + 'Turn vague areas into specific postcodes': 'Verwandle vage Gebiete in konkrete Postcodes', 'Broad town or borough searches hide large differences between streets. Perfect Postcode helps you move from a general area to postcodes that satisfy your hard requirements.': 'Breite Stadt- oder Borough-Suchen verdecken große Unterschiede zwischen Straßen. Perfect Postcode hilft dir, von einem allgemeinen Gebiet zu Postcodes zu gelangen, die deine Muss-Kriterien erfüllen.', 'Keep trade-offs visible': 'Halte Kompromisse sichtbar', 'When there are too many or too few matches, adjust one constraint at a time and see exactly which postcodes reappear. That makes compromises explicit instead of relying on guesswork.': 'Wenn es zu viele oder zu wenige Treffer gibt, ändere jeweils eine Anforderung und sieh genau, welche Postcodes wieder auftauchen. So werden Kompromisse sichtbar statt geraten.', - 'Why postcode-level comparison matters': - 'Warum Vergleiche auf Postcode-Ebene wichtig sind', + 'Why postcode-level comparison matters': 'Warum Vergleiche auf Postcode-Ebene wichtig sind', 'Two nearby postcodes can differ on schools, road noise, transport access, property mix, and price. Comparing at postcode level reduces the chance of treating a whole town as one uniform market.': 'Zwei nahegelegene Postcodes können sich bei Schulen, Straßenlärm, Verkehrsanbindung, Immobilienmix und Preis stark unterscheiden. Der Vergleich auf Postcode-Ebene verhindert, dass eine ganze Stadt wie ein einheitlicher Markt behandelt wird.', 'How to use the results': 'So nutzt du die Ergebnisse', 'Treat matching postcodes as a research queue: check live listings, visit streets, confirm schools and admissions, and review current official sources.': 'Behandle passende Postcodes wie eine Recherche-Liste: Prüfe Live-Inserate, geh durch die Straßen, bestätige Schulen und Admissions und nutze aktuelle offizielle Quellen.', - 'Can I save a postcode property search?': - 'Kann ich eine Postcode-Immobiliensuche speichern?', + 'Can I save a postcode property search?': 'Kann ich eine Postcode-Immobiliensuche speichern?', 'Yes. Licensed users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.': 'Ja. Lizenzierte Nutzer können Suchen speichern und später darauf zurückgreifen. Gespeicherte Suchen sind für Auswahllisten und Vergleichsnotizen gedacht.', 'Can I search without knowing the area?': 'Kann ich suchen, ohne die Gegend zu kennen?', @@ -268,8 +265,7 @@ const de: Translations = { 'Pendeln nach Postcode, nicht nur nach Ortsname', 'Two streets in the same town can have very different station access, road routes, and public transport options. Postcode-level travel-time filtering keeps that difference visible.': 'Zwei Straßen in derselben Stadt können sehr unterschiedliche Bahnhofsanbindungen, Straßenrouten und ÖPNV-Optionen haben. Fahrzeitfilter auf Postcode-Ebene halten diesen Unterschied sichtbar.', - 'Balance journey time with the rest of the move': - 'Fahrzeit mit dem restlichen Umzug abwägen', + 'Balance journey time with the rest of the move': 'Fahrzeit mit dem restlichen Umzug abwägen', 'A fast commute only helps if the area also fits your budget, housing needs, school preferences, safety threshold, broadband requirement, and tolerance for road noise.': 'Ein kurzer Arbeitsweg hilft nur, wenn die Gegend auch zu Budget, Wohnbedarf, Schulwünschen, Sicherheitsgefühl, Breitbandbedarf und Lärmtoleranz passt.', 'How travel-time filters should be interpreted': 'Wie Fahrzeitfilter zu verstehen sind', @@ -320,8 +316,7 @@ const de: Translations = { 'Die Schulqualität ist ein Teil der engeren Auswahl', 'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.': 'Mit Perfect Postcode vergleichst du Schuldaten in der Nähe mit den anderen praktischen Faktoren eines Familienumzugs: Platz, Preis, Pendelweg, Parks, Sicherheit und lokale Infrastruktur.', - 'Check catchments before making decisions': - 'Catchments vor Entscheidungen prüfen', + 'Check catchments before making decisions': 'Catchments vor Entscheidungen prüfen', 'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.': 'Admissions-Regeln und Catchment-Grenzen können sich ändern. Nutze Schuldaten auf Postcode-Ebene, um vielversprechende Gebiete zu finden, und prüfe aktuelle Details bei Schule oder Local Authority.', 'How to treat school filters': 'Wie du Schulfilter einordnest', @@ -474,12 +469,10 @@ const de: Translations = { 'Make commute constraints explicit': 'Mache Pendelanforderungen klar', 'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.': 'Wenn die Erreichbarkeit des Zentrums, eines Bahnhofs, Krankenhauses, einer Universität oder eines Business Parks wichtig ist, nutze zuerst Fahrzeitfilter und vergleiche dann die übrigen Postcodes anhand der Immobiliendaten.', - 'Compare value, not just headline price': - 'Wert vergleichen, nicht nur den Angebotspreis', + 'Compare value, not just headline price': 'Wert vergleichen, nicht nur den Angebotspreis', 'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.': 'Nutze Preis-, Immobilientyp- und Wohnflächenfilter gemeinsam. So kannst du günstigere Gebiete von Gebieten unterscheiden, in denen einfach kleinere oder andere Immobilien stehen.', - 'Screen environmental and local-service signals': - 'Umwelt- und Infrastruktur-Signale prüfen', + 'Screen environmental and local-service signals': 'Umwelt- und Infrastruktur-Signale prüfen', 'Road noise, parks, broadband, crime, and amenities can affect whether a property works day to day. Use them as screening criteria before booking viewings.': 'Straßenlärm, Parks, Breitband, Kriminalität und Infrastruktur können entscheiden, ob eine Immobilie im Alltag funktioniert. Nutze sie als Screening-Kriterien, bevor du Besichtigungen buchst.', 'Can I use this for commuter villages around Bristol?': @@ -604,8 +597,7 @@ const de: Translations = { logIn: 'Anmelden', createAccount: 'Konto erstellen', resetPassword: 'Passwort zurücksetzen', - valueProp: - 'Speichere Suchen, merke Immobilien und erstelle eine Shortlist passender Gebiete.', + valueProp: 'Speichere Suchen, merke Immobilien und erstelle eine Shortlist passender Gebiete.', continueWithGoogle: 'Weiter mit Google', email: 'E-Mail', emailPlaceholder: 'name@beispiel.de', @@ -916,6 +908,7 @@ const de: Translations = { // ── Location Search ──────────────────────────────── locationSearch: { placeholder: 'Orte oder Postcodes suchen...', + noResults: 'Keine passenden Orte oder Postcodes', postcodeNotFound: 'Postcode nicht gefunden', lookupFailed: 'Suche fehlgeschlagen', searchLabel: 'Orte oder Postcodes suchen', @@ -1518,7 +1511,7 @@ const de: Translations = { '% White': '% weiß', '% South Asian': '% südasiatisch', '% Black': '% schwarz', - '% East Asian': '% ostasiatisch', + '% East/SE Asian': '% ost-/südostasiatisch', '% Mixed': '% gemischt', '% Other': '% Sonstige', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index f59f382..a8b2eee 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -892,6 +892,7 @@ const en = { // ── Location Search ──────────────────────────────── locationSearch: { placeholder: 'Search places or postcodes...', + noResults: 'No matching places or postcodes', postcodeNotFound: 'Postcode not found', lookupFailed: 'Lookup failed', searchLabel: 'Search places or postcodes', @@ -1484,7 +1485,7 @@ const en = { '% White': '% White', '% South Asian': '% South Asian', '% Black': '% Black', - '% East Asian': '% East Asian', + '% East/SE Asian': '% East/SE Asian', '% Mixed': '% Mixed', '% Other': '% Other', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index bd502cc..918d22d 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -322,8 +322,7 @@ const fr: Translations = { 'La qualité des écoles n’est qu’une partie de la sélection', 'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.': 'Perfect Postcode vous aide à comparer les données des écoles à proximité avec les autres contraintes pratiques d’un déménagement familial : espace, prix, trajet, parcs, sécurité et services locaux.', - 'Check catchments before making decisions': - 'Vérifier les catchment areas avant de décider', + 'Check catchments before making decisions': 'Vérifier les catchment areas avant de décider', 'Admissions rules and catchment boundaries can change. Use postcode-level school data to find promising areas, then verify current admissions details with the school or local authority.': 'Les règles d’admission et les limites de catchment peuvent changer. Utilisez les données scolaires par code postal pour repérer des secteurs prometteurs, puis vérifiez les admissions à jour auprès de l’école ou de l’autorité locale.', 'How to treat school filters': 'Comment traiter les filtres scolaires', @@ -382,8 +381,7 @@ const fr: Translations = { 'Ce qu’une vérification du code postal ne peut pas prouver', 'It can’t confirm the condition of a home, future development, legal title, lender requirements, or current street-level experience. Those still need direct checks.': 'Il ne peut pas confirmer l’état d’un logement, les futurs projets, le titre de propriété, les exigences du prêteur ou le ressenti actuel dans la rue. Ces points demandent encore des vérifications directes.', - 'Can I use the checker before a viewing?': - 'Puis-je utiliser cet outil avant une visite ?', + 'Can I use the checker before a viewing?': 'Puis-je utiliser cet outil avant une visite ?', 'Yes. That’s one of the main use cases: screen the postcode first, then decide whether the viewing is worth the time.': 'Oui. C’est l’un des principaux cas d’utilisation : examinez d’abord le code postal, puis décidez si la visite vaut le temps consacré.', 'Does the checker include exact property condition?': @@ -482,8 +480,7 @@ const fr: Translations = { 'Compare value, not just headline price': 'Comparez la valeur, pas seulement le prix affiché', 'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.': 'Utilisez ensemble les filtres de prix, de type de bien et de surface. Cela distingue les secteurs vraiment moins chers de ceux qui comptent simplement des logements plus petits ou différents.', - 'Screen environmental and local-service signals': - 'Examiner environnement et services locaux', + 'Screen environmental and local-service signals': 'Examiner environnement et services locaux', 'Road noise, parks, broadband, crime, and amenities can affect whether a property works day to day. Use them as screening criteria before booking viewings.': 'Le bruit routier, les parcs, le haut débit, la criminalité et les services de proximité peuvent affecter la vie quotidienne dans un logement. Utilisez-les comme critères de tri avant de réserver des visites.', 'Can I use this for commuter villages around Bristol?': @@ -924,6 +921,7 @@ const fr: Translations = { // ── Location Search ──────────────────────────────── locationSearch: { placeholder: 'Rechercher des lieux ou codes postaux...', + noResults: 'Aucun lieu ni code postal correspondant', postcodeNotFound: 'Code postal introuvable', lookupFailed: 'Échec de la recherche', searchLabel: 'Rechercher des lieux ou codes postaux', @@ -1530,7 +1528,7 @@ const fr: Translations = { '% White': '% Blancs', '% South Asian': '% Sud-Asiatiques', '% Black': '% Noirs', - '% East Asian': '% Est-Asiatiques', + '% East/SE Asian': '% est/sud-est asiatique', '% Mixed': '% Métis', '% Other': '% Autres', diff --git a/frontend/src/i18n/locales/hi.ts b/frontend/src/i18n/locales/hi.ts index e252cf3..175eb45 100644 --- a/frontend/src/i18n/locales/hi.ts +++ b/frontend/src/i18n/locales/hi.ts @@ -205,7 +205,8 @@ const hi: Translations = { 'Relax one constraint at a time': 'एक बार में एक constraint ढीला करें', 'When the search becomes too narrow, loosen a single filter and watch which postcodes reappear. This makes compromise explicit instead of relying on guesswork.': 'जब search बहुत संकरी हो जाए, तो एक फ़िल्टर ढीला करें और देखें कौन से पोस्टकोड वापस आते हैं. इससे guesswork के बजाय compromise साफ दिखता है.', - 'Turn vague areas into specific postcodes': 'धुंधले area ideas को specific postcodes में बदलें', + 'Turn vague areas into specific postcodes': + 'धुंधले area ideas को specific postcodes में बदलें', 'Broad town or borough searches hide large differences between streets. Perfect Postcode helps you move from a general area to postcodes that satisfy your hard requirements.': 'Town या borough-level searches सड़कों के बीच बड़े फर्क छिपा देती हैं. Perfect Postcode आपको general area से उन पोस्टकोड तक ले जाता है जो आपकी hard requirements पूरी करते हैं.', 'Keep trade-offs visible': 'Trade-offs साफ रखें', @@ -217,7 +218,8 @@ const hi: Translations = { 'How to use the results': 'परिणामों का उपयोग कैसे करें', 'Treat matching postcodes as a research queue: check live listings, visit streets, confirm schools and admissions, and review current official sources.': 'Matching postcodes को research queue की तरह लें: live listings देखें, streets visit करें, schools और admissions confirm करें, और current official sources check करें.', - 'Can I save a postcode property search?': 'क्या मैं postcode property search सेव कर सकता हूँ?', + 'Can I save a postcode property search?': + 'क्या मैं postcode property search सेव कर सकता हूँ?', 'Yes. Licensed users can save searches and return to them later. Saved searches are designed for shortlists and comparison notes.': 'हाँ. Licensed users searches सेव कर सकते हैं और बाद में वहीं लौट सकते हैं. Saved searches shortlist और comparison notes के लिए बनाई गई हैं.', 'Can I search without knowing the area?': 'क्या area जाने बिना search कर सकता हूँ?', @@ -231,8 +233,7 @@ const hi: Translations = { 'Greater Manchester के आसपास broad search को narrow करने के लिए regional guide.', 'Start a postcode search': 'Postcode search शुरू करें', 'Commute property search': 'Commute के हिसाब से प्रॉपर्टी खोजें', - 'Search for places to live by commute time': - 'Commute time के हिसाब से रहने की जगहें खोजें', + 'Search for places to live by commute time': 'Commute time के हिसाब से रहने की जगहें खोजें', 'Commute property search - Find places to live by travel time': 'Commute property search - travel time से रहने की जगहें खोजें', 'Filter postcodes by commute time, then compare price, schools, safety, broadband, road noise, parks and property data on one map.': @@ -260,8 +261,7 @@ const hi: Translations = { 'Journey time को move की बाकी जरूरतों से balance करें', 'A fast commute only helps if the area also fits your budget, housing needs, school preferences, safety threshold, broadband requirement, and tolerance for road noise.': 'Fast commute तभी मदद करता है जब area आपके बजट, housing needs, school preferences, safety threshold, ब्रॉडबैंड जरूरत और road noise tolerance से भी match करता हो.', - 'How travel-time filters should be interpreted': - 'Travel-time filters को कैसे पढ़ें', + 'How travel-time filters should be interpreted': 'Travel-time filters को कैसे पढ़ें', 'Travel-time modelling is useful for comparing areas consistently. Before committing, check current timetables, disruption patterns, parking, cycling conditions, and walking routes.': 'Travel-time modelling इलाकों की consistent comparison के लिए useful है. Commit करने से पहले current timetables, disruption patterns, parking, cycling conditions और walking routes जरूर check करें.', 'Why commute filters are combined with property data': @@ -305,7 +305,8 @@ const hi: Translations = { 'Verify admissions before deciding': 'फैसले से पहले admissions verify करें', 'School data can point to promising areas, but admissions rules and catchments can change. Confirm current arrangements with schools and local authorities.': 'School data promising areas दिखा सकता है, लेकिन admissions rules और catchments बदल सकते हैं. Schools और local authorities से current arrangements confirm करें.', - 'School quality is one part of the shortlist': 'School quality shortlist का सिर्फ एक हिस्सा है', + 'School quality is one part of the shortlist': + 'School quality shortlist का सिर्फ एक हिस्सा है', 'Perfect Postcode helps you compare nearby school data with the other practical constraints that shape a family move: space, price, commute, parks, safety, and local services.': 'Perfect Postcode nearby school data को उन practical constraints के साथ compare करने में मदद करता है जो family move को shape करती हैं: space, price, commute, parks, safety और local services.', 'Check catchments before making decisions': 'फैसले से पहले catchments check करें', @@ -317,7 +318,8 @@ const hi: Translations = { 'Family trade-offs to compare': 'Compare करने लायक family trade-offs', 'Combine schools with parks, road noise, crime, property size, commute, broadband, and price so the shortlist reflects the whole move.': 'Schools को parks, road noise, crime, property size, commute, broadband और price के साथ मिलाएं ताकि shortlist पूरा move दिखाए.', - 'Does this show school catchment guarantees?': 'क्या यह school catchment guarantee दिखाता है?', + 'Does this show school catchment guarantees?': + 'क्या यह school catchment guarantee दिखाता है?', 'No. It helps identify promising areas, but catchments and admissions must be verified with the school or local authority.': 'नहीं. यह promising areas पहचानने में मदद करता है, लेकिन catchments और admissions school या local authority से verify करने होंगे.', 'Can I combine school filters with parks and safety?': @@ -396,8 +398,7 @@ const hi: Translations = { 'Compare price with property type': 'Price को property type के साथ compare करें', 'Median prices alone can be misleading if the local property mix changes. Add property type, tenure, floor area, and price filters so similar areas are compared fairly.': 'अगर local property mix बदलता है, तो सिर्फ median prices misleading हो सकती हैं. Similar areas की fair comparison के लिए property type, tenure, floor area और price filters जोड़ें.', - 'Keep family and environment trade-offs visible': - 'Family और environment trade-offs साफ रखें', + 'Keep family and environment trade-offs visible': 'Family और environment trade-offs साफ रखें', 'Layer school context, parks, road noise, broadband, and crime signals on top of the property filters. That makes it easier to decide which compromises are acceptable.': 'Property filters के ऊपर school context, parks, road noise, broadband और crime signals layer करें. इससे तय करना आसान होता है कि कौन से compromises acceptable हैं.', 'Can Perfect Postcode tell me the best area in Birmingham?': @@ -460,8 +461,7 @@ const hi: Translations = { 'Make commute constraints explicit': 'Commute constraints साफ करें', 'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.': 'अगर centre, station, hospital, university या business park तक access मायने रखती है, तो पहले travel-time filters लगाएं और फिर बचे postcodes को property data से compare करें.', - 'Compare value, not just headline price': - 'सिर्फ headline price नहीं, value compare करें', + 'Compare value, not just headline price': 'सिर्फ headline price नहीं, value compare करें', 'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.': 'Price, property type और floor-area filters साथ इस्तेमाल करें. इससे सच में lower-cost areas को उन areas से अलग करना आसान होता है जहां बस छोटे या अलग homes हैं.', 'Screen environmental and local-service signals': @@ -876,6 +876,7 @@ const hi: Translations = { locationSearch: { placeholder: 'स्थान या पोस्टकोड खोजें...', + noResults: 'कोई मिलती-जुलती जगह या पोस्टकोड नहीं मिला', postcodeNotFound: 'पोस्टकोड नहीं मिला', lookupFailed: 'खोज विफल रही', searchLabel: 'स्थान या पोस्टकोड खोजें', @@ -1430,7 +1431,7 @@ const hi: Translations = { '% White': '% श्वेत', '% South Asian': '% दक्षिण एशियाई', '% Black': '% अश्वेत', - '% East Asian': '% पूर्वी एशियाई', + '% East/SE Asian': '% पूर्वी/दक्षिण-पूर्वी एशियाई', '% Mixed': '% मिश्रित', '% Other': '% अन्य', 'Voter turnout (%)': 'मतदाता भागीदारी (%)', diff --git a/frontend/src/i18n/locales/hu.ts b/frontend/src/i18n/locales/hu.ts index f9baa31..35f0282 100644 --- a/frontend/src/i18n/locales/hu.ts +++ b/frontend/src/i18n/locales/hu.ts @@ -471,8 +471,7 @@ const hu: Translations = { 'Make commute constraints explicit': 'Tedd egyértelművé az ingázási korlátokat', 'If access to the centre, a station, hospital, university, or business park matters, use travel-time filters first and then compare the remaining postcodes by property data.': 'Ha fontos a központ, állomás, kórház, egyetem vagy business park elérése, először használd az utazási idő szűrőit, majd hasonlítsd össze a fennmaradó irányítószámokat ingatlanadatok alapján.', - 'Compare value, not just headline price': - 'Az értéket nézd, ne csak a kiemelt árat', + 'Compare value, not just headline price': 'Az értéket nézd, ne csak a kiemelt árat', 'Use price, property type, and floor-area filters together. This helps distinguish lower-cost areas from areas that simply contain smaller or different homes.': 'Használd együtt az ár-, ingatlantípus- és alapterület-szűrőket. Ez segít megkülönböztetni az olcsóbb területeket azoktól, ahol egyszerűen kisebb vagy eltérő otthonok vannak.', 'Screen environmental and local-service signals': @@ -910,6 +909,7 @@ const hu: Translations = { // ── Location Search ──────────────────────────────── locationSearch: { placeholder: 'Helyek vagy irányítószámok keresése...', + noResults: 'Nincs egyező hely vagy irányítószám', postcodeNotFound: 'Irányítószám nem található', lookupFailed: 'A keresés sikertelen', searchLabel: 'Helyek vagy irányítószámok keresése', @@ -1511,7 +1511,7 @@ const hu: Translations = { '% White': '% fehér', '% South Asian': '% dél-ázsiai', '% Black': '% fekete', - '% East Asian': '% kelet-ázsiai', + '% East/SE Asian': '% kelet-/dél-kelet-ázsiai', '% Mixed': '% vegyes', '% Other': '% egyéb', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index ff854e8..0e8c41d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -133,7 +133,8 @@ const zh: Translations = { '按邮编筛选历史成交价与当前估值。', 'Compare value with commute, schools, broadband, crime, noise, and amenities.': '把房价与通勤、学校、宽带、治安、噪音和周边设施放在一起比较。', - 'Build a shortlist before spending weekends on viewings.': '把周末花在看房前,先整理出候选名单。', + 'Build a shortlist before spending weekends on viewings.': + '把周末花在看房前,先整理出候选名单。', 'Find postcodes that fit the budget before listings appear': '抢在房源上架之前,先锁定符合预算的邮编', 'Start with a maximum price and property type, then colour the map by price per square metre or estimated current price. This helps reveal areas where similar homes have historically traded within reach, even when there are no live listings today.': @@ -850,6 +851,7 @@ const zh: Translations = { // ── Location Search ──────────────────────────────── locationSearch: { placeholder: '搜索地点或邮编...', + noResults: '未找到匹配的地点或邮编', postcodeNotFound: '未找到该邮编', lookupFailed: '查询失败', searchLabel: '搜索地点或邮编', @@ -1073,7 +1075,8 @@ const zh: Translations = { dsConservationAreasUse: '英格兰指定保护区边界。用于标记邮编代表点是否位于保护区内。', dsListedBuildingsName: 'Historic England 受保护建筑', dsListedBuildingsOrigin: 'Historic England 英格兰国家遗产名录', - dsListedBuildingsUse: '英格兰受保护建筑点位记录。用于标记地址似乎与附近受保护建筑条目匹配的房产。', + dsListedBuildingsUse: + '英格兰受保护建筑点位记录。用于标记地址似乎与附近受保护建筑条目匹配的房产。', dsNaptanName: 'NaPTAN(公共交通站点)', dsNaptanOrigin: 'Department for Transport', dsNaptanUse: '英格兰各地铁路、公交、地铁/有轨电车、渡轮和机场的站点位置。', @@ -1427,7 +1430,7 @@ const zh: Translations = { '% White': '% 白人', '% South Asian': '% 南亚裔', '% Black': '% 黑人', - '% East Asian': '% 东亚裔', + '% East/SE Asian': '% 东亚/东南亚裔', '% Mixed': '% 混血', '% Other': '% 其他', diff --git a/frontend/src/lib/color-opacity.ts b/frontend/src/lib/color-opacity.ts new file mode 100644 index 0000000..bac9ebf --- /dev/null +++ b/frontend/src/lib/color-opacity.ts @@ -0,0 +1,11 @@ +export const DEFAULT_COLOR_OPACITY = 1; +export const MIN_COLOR_OPACITY = 0.1; + +export function normalizeColorOpacity(value: number | null | undefined): number { + if (value == null || !Number.isFinite(value)) return DEFAULT_COLOR_OPACITY; + return Math.min(1, Math.max(MIN_COLOR_OPACITY, value)); +} + +export function colorOpacityToPercent(value: number): number { + return Math.round(normalizeColorOpacity(value) * 100); +} diff --git a/frontend/src/lib/consts.ts b/frontend/src/lib/consts.ts index 0328579..d996ce2 100644 --- a/frontend/src/lib/consts.ts +++ b/frontend/src/lib/consts.ts @@ -267,7 +267,7 @@ export const STACKED_GROUPS: Record< { label: 'Ethnic composition', unit: '%', - components: ['% White', '% South Asian', '% East Asian', '% Black', '% Mixed', '% Other'], + components: ['% White', '% South Asian', '% East/SE Asian', '% Black', '% Mixed', '% Other'], }, { label: 'Political vote share', @@ -384,13 +384,6 @@ export const ENUM_COLOR_OVERRIDES: Record<string, Record<string, [number, number F: [239, 68, 68], G: [126, 34, 206], }, - 'Max available download speed (Mbps)': { - '10': [107, 114, 128], - '30': [245, 158, 11], - '100': [59, 130, 246], - '300': [20, 184, 166], - '1000': [34, 197, 94], - }, }; /** @@ -451,7 +444,7 @@ export const STACKED_SEGMENT_COLORS: Record<string, string> = { 'Other crime (avg/yr)': '#6b7280', '% White': '#3b82f6', '% South Asian': '#f97316', - '% East Asian': '#eab308', + '% East/SE Asian': '#eab308', '% Black': '#8b5cf6', '% Mixed': '#14b8a6', '% Other': '#6b7280', diff --git a/frontend/src/lib/crime-types.ts b/frontend/src/lib/crime-types.ts new file mode 100644 index 0000000..ff1f8d9 --- /dev/null +++ b/frontend/src/lib/crime-types.ts @@ -0,0 +1,35 @@ +// Street-crime categories carried by the `crime_hotspots` vector tiles in the +// `crime_type` feature property. The `value` strings must match the police.uk +// "Crime type" values exactly (see pipeline/transform/crime_hotspot_tiles.py), +// because they are used directly in the MapLibre heatmap `filter` expression. +// `label` is a shorter, human-friendly name for the overlay-selector checkboxes. + +export interface CrimeTypeDef { + value: string; + label: string; +} + +export const CRIME_TYPES: readonly CrimeTypeDef[] = [ + { value: 'Violence and sexual offences', label: 'Violence & sexual offences' }, + { value: 'Anti-social behaviour', label: 'Anti-social behaviour' }, + { value: 'Criminal damage and arson', label: 'Criminal damage & arson' }, + { value: 'Public order', label: 'Public order' }, + { value: 'Shoplifting', label: 'Shoplifting' }, + { value: 'Vehicle crime', label: 'Vehicle crime' }, + { value: 'Burglary', label: 'Burglary' }, + { value: 'Other theft', label: 'Other theft' }, + { value: 'Theft from the person', label: 'Theft from the person' }, + { value: 'Bicycle theft', label: 'Bicycle theft' }, + { value: 'Drugs', label: 'Drugs' }, + { value: 'Robbery', label: 'Robbery' }, + { value: 'Possession of weapons', label: 'Possession of weapons' }, + { value: 'Other crime', label: 'Other crime' }, +] as const; + +export const CRIME_TYPE_VALUES: readonly string[] = CRIME_TYPES.map((c) => c.value); + +const CRIME_TYPE_VALUE_SET = new Set<string>(CRIME_TYPE_VALUES); + +export function isCrimeTypeValue(value: string): boolean { + return CRIME_TYPE_VALUE_SET.has(value); +} diff --git a/frontend/src/lib/ethnicity-filter.ts b/frontend/src/lib/ethnicity-filter.ts index 597a7c3..0c1bb93 100644 --- a/frontend/src/lib/ethnicity-filter.ts +++ b/frontend/src/lib/ethnicity-filter.ts @@ -6,7 +6,7 @@ export const ETHNICITIES_FILTER_KEY_PREFIX = `${ETHNICITIES_FILTER_NAME}:`; export const ETHNICITY_FEATURE_NAMES = [ '% White', '% South Asian', - '% East Asian', + '% East/SE Asian', '% Black', '% Mixed', '% Other', diff --git a/frontend/src/lib/feature-icons.tsx b/frontend/src/lib/feature-icons.tsx index 52d21c4..19ed953 100644 --- a/frontend/src/lib/feature-icons.tsx +++ b/frontend/src/lib/feature-icons.tsx @@ -367,7 +367,7 @@ const FEATURE_ICON_PATHS: Record<string, ReactNode> = { <path d="M16 3.13a4 4 0 010 7.75" /> </> ), - '% East Asian': ( + '% East/SE Asian': ( <> <path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" /> <circle cx="9" cy="7" r="4" /> diff --git a/pipeline/check_travel_times.py b/pipeline/check_travel_times.py index 79683e2..6771e2d 100644 --- a/pipeline/check_travel_times.py +++ b/pipeline/check_travel_times.py @@ -2,9 +2,12 @@ A travel-time parquet file is considered corrupted when the R5 routing computation failed or was interrupted, leaving either zero rows or only -the origin postcode. We detect this by comparing each file's row count -against a per-mode threshold derived from the 5th-percentile of all files -in that mode. Files at or below 1 row are always flagged. +the origin postcode. We detect this by an absolute, structural criterion: +a file is corrupt only when it is unreadable or has a row count at or below +CORRUPT_ROW_FLOOR. Per-mode percentile/median/range figures are reported +for context only — they never drive the deletable set, so repeated runs +(including with --delete) are idempotent and never erode legitimate +small-catchment (rural/island) origins. Duplicates arise when places.parquet is rebuilt between R5 runs — each place gets a new numeric index prefix, so the skip-completed logic @@ -13,7 +16,6 @@ file per slug and removes the rest. Usage: uv run python pipeline/check_travel_times.py [--travel-times property-data/travel-times] - [--threshold-pct 5] [--delete] [--dedup] """ @@ -28,6 +30,12 @@ from pathlib import Path import polars as pl +# Absolute row-count floor for corruption: a readable file with this many +# rows or fewer holds at most the origin postcode (R5 failed/interrupted). +# This is a structural threshold, NOT a population percentile, so repeated +# runs are idempotent and never delete a fresh fraction of valid files. +CORRUPT_ROW_FLOOR = 1 + @dataclass class BadFile: @@ -74,10 +82,14 @@ def percentile(values: list[int], pct: float) -> float: return s[lo] + frac * (s[hi] - s[lo]) -def find_bad_files( - base_dir: Path, threshold_pct: float -) -> tuple[list[BadFile], dict[str, dict]]: - """Scan all modes and return bad files + per-mode stats.""" +def find_bad_files(base_dir: Path) -> tuple[list[BadFile], dict[str, dict]]: + """Scan all modes and return bad files + per-mode stats. + + A file is "bad" (deletable) only by an absolute structural criterion: + it is unreadable (rows < 0) or holds at most the origin postcode + (rows <= CORRUPT_ROW_FLOOR). The p5/median/min/max figures are computed + purely for reporting and do NOT influence the deletable set. + """ bad: list[BadFile] = [] stats: dict[str, dict] = {} @@ -93,15 +105,14 @@ def find_bad_files( if not row_counts: continue - p5 = percentile(row_counts, threshold_pct) + # Reporting statistics only — these never decide what gets deleted. + p5 = percentile(row_counts, 5) median = percentile(row_counts, 50) - # Threshold: max of 1 and the chosen percentile — ensures we always - # catch files with 0-1 rows even if p5 is 0 (e.g. walking mode). - threshold = max(1, int(p5)) mode_bad = [] for filename, slug, rows in entries: - if rows <= threshold: + # Corrupt = unreadable, or at/below the absolute origin-only floor. + if rows < 0 or rows <= CORRUPT_ROW_FLOOR: bf = BadFile(mode=mode, filename=filename, slug=slug, rows=rows) mode_bad.append(bf) bad.append(bf) @@ -110,7 +121,7 @@ def find_bad_files( "total": len(entries), "errors": errors, "bad": len(mode_bad), - "threshold": threshold, + "floor": CORRUPT_ROW_FLOOR, "p5": p5, "median": median, "min": min(row_counts), @@ -169,16 +180,13 @@ def main() -> None: default=Path("property-data/travel-times"), help="Path to travel-times directory", ) - parser.add_argument( - "--threshold-pct", - type=float, - default=5, - help="Percentile below which files are flagged (default: 5th)", - ) parser.add_argument( "--delete", action="store_true", - help="Delete corrupted files (so R5 will recompute them)", + help=( + "Delete corrupted files (unreadable or " + f"<= {CORRUPT_ROW_FLOOR} row) so R5 will recompute them" + ), ) parser.add_argument( "--dedup", @@ -192,18 +200,20 @@ def main() -> None: sys.exit(1) # --- Corruption check --- - bad_files, stats = find_bad_files(args.travel_times, args.threshold_pct) + bad_files, stats = find_bad_files(args.travel_times) print("=== Per-mode summary ===\n") + # Floor is the absolute deletion threshold; p5/median/range are reporting + # context only and do not affect which files are flagged as corrupt. print( - f"{'Mode':<10} {'Total':>6} {'Bad':>5} {'Threshold':>10} {'Median':>8} {'Range':>20}" + f"{'Mode':<10} {'Total':>6} {'Bad':>5} {'Floor':>6} {'P5':>8} {'Median':>8} {'Range':>20}" ) - print("-" * 65) + print("-" * 71) for mode, s in sorted(stats.items()): rng = f"{s['min']:,}–{s['max']:,}" print( - f"{mode:<10} {s['total']:>6} {s['bad']:>5} {s['threshold']:>10,} " - f"{s['median']:>8,.0f} {rng:>20}" + f"{mode:<10} {s['total']:>6} {s['bad']:>5} {s['floor']:>6,} " + f"{s['p5']:>8,.0f} {s['median']:>8,.0f} {rng:>20}" ) if bad_files: diff --git a/pipeline/download/arcgis.py b/pipeline/download/arcgis.py index 418d9bf..588d5c1 100644 --- a/pipeline/download/arcgis.py +++ b/pipeline/download/arcgis.py @@ -4,27 +4,24 @@ import polars as pl from pathlib import Path from pipeline.local_temp import local_tmp_dir -from pipeline.utils import download, extract_zip +from pipeline.utils import code_col_overrides, download, extract_zip URL = "https://www.arcgis.com/sharing/rest/content/items/36b718ad00de49afb9ad364f8b815b9e/data" def convert_to_parquet(data_path: Path, parquet_path: Path) -> None: - # Classification code columns (ruc21ind, oac11ind, imd20ind) look numeric - # in early rows but contain string codes like "UN1" (Unclassified) later - # on. Force them to String to avoid mid-stream dtype inference failures. - # Note: NSPL renames these year suffixes as new releases roll in (e.g. - # Feb 2026 bumped oac from oac21ind → oac11ind, imd from imd19ind → - # imd20ind), so keep this dict in sync with the current CSV headers — - # polars silently ignores overrides for missing columns, masking drift. + # Classification code columns (e.g. ruc21ind, oac11ind, imd20ind) look + # numeric in early rows but contain string codes like "UN1" (Unclassified) + # later on. Force them to String to avoid mid-stream dtype inference + # failures. NSPL renames these year suffixes each release, and polars + # silently ignores overrides for missing columns, so match on the + # suffix-free stem (read from the header) rather than hard-coding suffixes. + csv_path = data_path / "Data/NSPL_FEB_2026_UK.csv" + names = pl.scan_csv(csv_path).collect_schema().names() df = pl.scan_csv( - data_path / "Data/NSPL_FEB_2026_UK.csv", + csv_path, try_parse_dates=True, - schema_overrides={ - "ruc21ind": pl.String, - "oac11ind": pl.String, - "imd20ind": pl.String, - }, + schema_overrides=code_col_overrides(names), ) print(f"Columns: {df.collect_schema().names()}") parquet_path.parent.mkdir(parents=True, exist_ok=True) diff --git a/pipeline/download/broadband.py b/pipeline/download/broadband.py index dda2098..dc2b18b 100644 --- a/pipeline/download/broadband.py +++ b/pipeline/download/broadband.py @@ -51,8 +51,16 @@ def _obtain_zip(dest: Path) -> None: def convert_to_parquet(extract_dir: Path, parquet_path: Path) -> None: - # Find CSV files in the extracted directory - csv_files = list(extract_dir.rglob("*.csv")) + # Find CSV files in the extracted directory. The zip ships two sibling + # dirs with identical headers: postcode_files/ (all premises) and + # postcode_res_files/ (residential only). Take all-premises only so each + # postcode_space appears once; matching on the dir name keeps this + # resilient to the date-stamped top-level folder prefix. + csv_files = [ + f + for f in extract_dir.rglob("*.csv") + if "postcode_res_files" not in f.parts + ] if not csv_files: raise FileNotFoundError(f"No CSV files found in {extract_dir}") diff --git a/pipeline/download/ethnicity.py b/pipeline/download/ethnicity.py index 5fb9272..6e31ee3 100644 --- a/pipeline/download/ethnicity.py +++ b/pipeline/download/ethnicity.py @@ -36,7 +36,8 @@ GEOGRAPHY_CODE_REPLACEMENTS = { def _ethnicity_percentages(df: pl.DataFrame) -> pl.DataFrame: # Use the detailed 19+1 breakdown to get sub-categories for Asian ethnicity, - # then aggregate back to the broad groups plus South Asian / East Asian split. + # then aggregate back to the broad groups plus a South Asian / East/SE Asian + # split (Indian/Pakistani/Bangladeshi vs Chinese + other East/SE Asian). detailed = df.filter( (pl.col("Ethnicity_type") == "ONS 2021 19+1") & (pl.col("Ethnicity") != "All") ) @@ -53,9 +54,13 @@ def _ethnicity_percentages(df: pl.DataFrame) -> pl.DataFrame: "Indian": "South Asian", "Pakistani": "South Asian", "Bangladeshi": "South Asian", - "Any Other Asian Background": "South Asian", - # East Asian - "Chinese": "East Asian", + # East / Southeast Asian. The ONS "Any Other Asian Background" bucket is + # predominantly East/Southeast Asian (Filipino, Vietnamese, Thai, + # Japanese, Korean, ...) rather than South Asian, so route it here rather + # than inflating "% South Asian". The split is approximate (the ONS + # bucket also holds some South Asian groups such as Sri Lankan/Nepalese). + "Chinese": "East/SE Asian", + "Any Other Asian Background": "East/SE Asian", # Black "Black African": "Black", "Black Caribbean": "Black", diff --git a/pipeline/download/inspire.py b/pipeline/download/inspire.py index c32e531..64981e2 100644 --- a/pipeline/download/inspire.py +++ b/pipeline/download/inspire.py @@ -4,7 +4,10 @@ Downloads GML files for all local authorities from the INSPIRE download page. Each ZIP contains a GML file with title extent polygons for that authority. Source: https://use-land-property-data.service.gov.uk/datasets/inspire/download -License: INSPIRE End User Licence +License: Open Government Licence v3.0 (since 1 July 2020, under the PSGA). + Requires HM Land Registry + Ordnance Survey (AC0000851063) attribution; see + the conditions page at the source URL. Boundaries are indicative "general + boundaries", not the legal extent of title. """ import argparse diff --git a/pipeline/download/median_age.py b/pipeline/download/median_age.py index 4471ce8..1831d34 100644 --- a/pipeline/download/median_age.py +++ b/pipeline/download/median_age.py @@ -43,6 +43,47 @@ AGE_BANDS = [ (85, 5), # Aged 85 years and over ] +# Canonical NOMIS TS007A (C2021_AGE_19_NAME) band labels, in the SAME order as +# AGE_BANDS. Index i here corresponds to AGE_BANDS[i]; we validate the pivot +# output against this set and use it (not positional string parsing) to order +# the columns, so a stray/relabelled/missing band fails loudly instead of +# silently mis-aligning counts against the wrong lower bound. +EXPECTED_BAND_NAMES = [ + "Aged 0 to 4 years", + "Aged 5 to 9 years", + "Aged 10 to 14 years", + "Aged 15 to 19 years", + "Aged 20 to 24 years", + "Aged 25 to 29 years", + "Aged 30 to 34 years", + "Aged 35 to 39 years", + "Aged 40 to 44 years", + "Aged 45 to 49 years", + "Aged 50 to 54 years", + "Aged 55 to 59 years", + "Aged 60 to 64 years", + "Aged 65 to 69 years", + "Aged 70 to 74 years", + "Aged 75 to 79 years", + "Aged 80 to 84 years", + "Aged 85 years and over", +] +assert len(EXPECTED_BAND_NAMES) == len(AGE_BANDS), ( + "EXPECTED_BAND_NAMES and AGE_BANDS must stay aligned 1:1" +) + +# NOMIS sometimes labels a band with a wording variant that denotes the SAME +# age range (e.g. "Aged 4 years and under" for ages 0-4, "Aged 90 years and +# over" wording for the top band). Map such known-equivalent labels back to the +# canonical name BEFORE validation so a real band change still fails loudly, +# but a cosmetic relabel of an identical range does not block the build. +BAND_NAME_ALIASES = { + "Aged 4 years and under": "Aged 0 to 4 years", +} +assert set(BAND_NAME_ALIASES.values()) <= set(EXPECTED_BAND_NAMES), ( + "BAND_NAME_ALIASES must map to canonical EXPECTED_BAND_NAMES" +) + def compute_median_age(counts: list[int]) -> float: """Compute median age from five-year band counts using linear interpolation.""" @@ -62,6 +103,62 @@ def compute_median_age(counts: list[int]) -> float: return float("nan") +def _bands_to_median_table(pivoted: pl.DataFrame) -> pl.DataFrame: + """Validate the pivoted age-band columns, then compute median age per LSOA. + + The pivot must contain exactly the canonical NOMIS TS007A bands; a + missing/extra/relabelled band would otherwise silently mis-align counts + against the wrong AGE_BANDS lower bound, so we fail loudly instead. + """ + # Normalise known-equivalent NOMIS label variants to their canonical name + # before validating (renaming onto an already-present canonical column would + # collide, so polars raises loudly in that genuinely ambiguous case). + rename_map = { + c: BAND_NAME_ALIASES[c] for c in pivoted.columns if c in BAND_NAME_ALIASES + } + if rename_map: + pivoted = pivoted.rename(rename_map) + + # Validate the pivoted age-band columns against the canonical NOMIS set + # BEFORE computing anything. + band_cols = [c for c in pivoted.columns if c != "GEOGRAPHY_CODE"] + found = set(band_cols) + expected = set(EXPECTED_BAND_NAMES) + if found != expected: + missing = sorted(expected - found) + unexpected = sorted(found - expected) + raise ValueError( + "Census age-band columns do not match the expected NOMIS TS007A bands.\n" + f" expected {len(EXPECTED_BAND_NAMES)} bands, found {len(band_cols)}\n" + f" missing: {missing}\n" + f" unexpected: {unexpected}\n" + "Refusing to compute medians against misaligned bands." + ) + + # Use the canonical order (guaranteed aligned with AGE_BANDS), not positional + # string parsing, and treat a null band (zero-population) as 0 rather than + # crashing on sum(). + band_cols = list(EXPECTED_BAND_NAMES) + pivoted = pivoted.with_columns(pl.col(band_cols).fill_null(0)) + + print(f"Age bands found: {len(band_cols)}") + print(f" First: {band_cols[0]}") + print(f" Last: {band_cols[-1]}") + + rows = pivoted.select("GEOGRAPHY_CODE", *band_cols).to_dicts() + medians = [] + for row in rows: + counts = [row[col] for col in band_cols] + median = compute_median_age(counts) + medians.append( + {"lsoa21": row["GEOGRAPHY_CODE"], "median_age": round(median, 1)} + ) + + return pl.DataFrame(medians).with_columns( + pl.col("median_age").cast(pl.Float32), + ) + + def download_and_convert(output_path: Path) -> None: print("Downloading Census 2021 age by five-year bands from NOMIS...") frames = [] @@ -94,29 +191,7 @@ def download_and_convert(output_path: Path) -> None: values="OBS_VALUE", ) - # Extract age band columns in order and compute median - # NOMIS returns band names like "Aged 0 to 4 years", "Aged 85 years and over" - band_cols = [c for c in pivoted.columns if c != "GEOGRAPHY_CODE"] - # Sort by the lower bound of each band - band_cols.sort(key=lambda c: int(c.split()[1])) - - print(f"Age bands found: {len(band_cols)}") - print(f" First: {band_cols[0]}") - print(f" Last: {band_cols[-1]}") - - # Compute median age per LSOA - rows = pivoted.select("GEOGRAPHY_CODE", *band_cols).to_dicts() - medians = [] - for row in rows: - counts = [row[col] for col in band_cols] - median = compute_median_age(counts) - medians.append( - {"lsoa21": row["GEOGRAPHY_CODE"], "median_age": round(median, 1)} - ) - - result = pl.DataFrame(medians).with_columns( - pl.col("median_age").cast(pl.Float32), - ) + result = _bands_to_median_table(pivoted) print(f"England LSOAs: {result.height}") print( diff --git a/pipeline/download/naptan.py b/pipeline/download/naptan.py index 6c80de5..3dac4c7 100644 --- a/pipeline/download/naptan.py +++ b/pipeline/download/naptan.py @@ -17,7 +17,12 @@ TUBE_STATION_MERGE_RADIUS_DEGREES = 0.01 STOP_TYPES = { "AIR": "Airport", + # Ferry: FER/FBT are the terminal/berth nodes; FTD is a docking entrance. + "FER": "Ferry", + "FBT": "Ferry", "FTD": "Ferry", + # Rail: RLY is the station node; RSE is a station entrance. + "RLY": "Rail station", "RSE": "Rail station", "BCT": "Bus stop", "BCE": "Bus station", @@ -26,6 +31,16 @@ STOP_TYPES = { "MET": "Tube station", } +# Stop types that are access/entrance nodes rather than the primary station or +# terminal node. During dedup the primary node (e.g. RLY/FER) wins so a station +# with both a station node and entrances yields one POI at the station node. +ENTRANCE_STOP_TYPES = {"RSE", "FTD"} + +# Categories whose entrances/variants are merged into a single station-level POI +# by normalized name + area (like Tube stations), so an RLY node and its RSE +# entrances collapse to one POI at the station node. +STATION_MERGE_CATEGORIES = {TUBE_STATION_CATEGORY, "Rail station", "Ferry"} + OUTPUT_COLUMNS = ["id", "name", "category", "lat", "lng"] @@ -97,7 +112,9 @@ def _empty_output_frame() -> pl.DataFrame: ) -def station_name_score(name: str) -> tuple[int, int]: +def station_name_score(name: str, entrance: bool = False) -> tuple[int, int, int]: + # Prefer the primary station/terminal node over an entrance, then a name + # without a transport-mode suffix, then the shorter name. lower = name.lower() suffix_penalty = int( lower.endswith( @@ -112,7 +129,7 @@ def station_name_score(name: str) -> tuple[int, int]: ) ) ) - return (suffix_penalty, len(name)) + return (int(entrance), suffix_penalty, len(name)) @dataclass @@ -122,6 +139,7 @@ class StationAccumulator: category: str lat_sum: float lng_sum: float + entrance: bool = False count: int = 1 @property @@ -143,9 +161,13 @@ class StationAccumulator: self.count += 1 name = str(row["name"] or "") - if station_name_score(name) < station_name_score(self.name): + entrance = bool(row.get("entrance")) + if station_name_score(name, entrance) < station_name_score( + self.name, self.entrance + ): self.id = str(row["id"] or "") self.name = name + self.entrance = entrance def _station_from_row(row: dict[str, object]) -> StationAccumulator: @@ -155,19 +177,23 @@ def _station_from_row(row: dict[str, object]) -> StationAccumulator: category=str(row["category"] or ""), lat_sum=float(row["lat"]), lng_sum=float(row["lng"]), + entrance=bool(row.get("entrance")), ) -def _deduplicate_tube_stations(df: pl.DataFrame) -> pl.DataFrame: +def _deduplicate_station_areas(df: pl.DataFrame) -> pl.DataFrame: if len(df) == 0: return _empty_output_frame() selected: list[StationAccumulator] = [] - groups: dict[str, list[int]] = {} + groups: dict[tuple[str, str], list[int]] = {} for row in df.iter_rows(named=True): - station_key = canonical_station_name(str(row["name"] or "")) - if not station_key: + # Key by category so different modes sharing a name/area (e.g. a rail + # station and a ferry terminal) are not merged into one POI. + category = str(row["category"] or "") + station_key = (category, canonical_station_name(str(row["name"] or ""))) + if not station_key[1]: selected.append(_station_from_row(row)) continue @@ -198,7 +224,7 @@ def _deduplicate_tube_stations(df: pl.DataFrame) -> pl.DataFrame: ).select(OUTPUT_COLUMNS) -def _deduplicate_non_tube_stops(df: pl.DataFrame) -> pl.DataFrame: +def _deduplicate_local_stops(df: pl.DataFrame) -> pl.DataFrame: if len(df) == 0: return _empty_output_frame() @@ -218,7 +244,10 @@ def _deduplicate_non_tube_stops(df: pl.DataFrame) -> pl.DataFrame: .select(OUTPUT_COLUMNS) ) if len(no_loc) > 0: - frames.append(no_loc.select(OUTPUT_COLUMNS)) + # Stops with no locality can't be deduped by locality, so merge genuine + # co-located duplicates (same name+category within the same small area) + # via the station-area logic, while keeping distinct far-apart stops. + frames.append(_deduplicate_station_areas(no_loc)) if not frames: return _empty_output_frame() @@ -227,14 +256,20 @@ def _deduplicate_non_tube_stops(df: pl.DataFrame) -> pl.DataFrame: def deduplicate_naptan(df: pl.DataFrame) -> pl.DataFrame: - """Deduplicate NaPTAN stops, with station-level merging for Tube POIs.""" - tube = df.filter(pl.col("category") == TUBE_STATION_CATEGORY) - other = df.filter(pl.col("category") != TUBE_STATION_CATEGORY) + """Deduplicate NaPTAN stops, merging station/terminal entrances by area. + + Tube, rail and ferry POIs are merged to one record per station by + normalized name + area, with the primary station/terminal node (e.g. RLY, + FER) winning over an entrance node (RSE, FTD). Other stops are deduplicated + by exact name+category+locality. + """ + station = df.filter(pl.col("category").is_in(list(STATION_MERGE_CATEGORIES))) + other = df.filter(~pl.col("category").is_in(list(STATION_MERGE_CATEGORIES))) return pl.concat( [ - _deduplicate_non_tube_stops(other), - _deduplicate_tube_stations(tube), + _deduplicate_local_stops(other), + _deduplicate_station_areas(station), ] ).select(OUTPUT_COLUMNS) @@ -263,6 +298,7 @@ def download_naptan(output: Path) -> None: pl.col("Latitude").alias("lat"), pl.col("Longitude").alias("lng"), pl.col("NptgLocalityCode").alias("locality"), + pl.col("StopType").is_in(list(ENTRANCE_STOP_TYPES)).alias("entrance"), ) ) diff --git a/pipeline/download/noise.py b/pipeline/download/noise.py index 660ce30..5811511 100644 --- a/pipeline/download/noise.py +++ b/pipeline/download/noise.py @@ -83,11 +83,32 @@ NATIVE_RESOLUTION = 10 # Request pixel resolution in metres. RESOLUTION = NATIVE_RESOLUTION +# Defra encodes TRUE "no data" with this sentinel (NOT 0.0). A 0.0 cell that is +# otherwise inside the raster means "modelled below the lowest reporting band", +# i.e. genuinely quiet — see noise_overlay_tiles.py:167. +NOISE_NODATA_SENTINEL = np.float32(-96.0) + +# Lowest modelled Defra Lden reporting band (dB). Verified against the actual +# rasters: the minimum positive in-coverage value is 40.0 dB with NO values in +# (0, 40) — below the band, cells are encoded as 0.0 (genuinely quiet). We floor +# in-coverage cells to 40.0 so a below-band 0.0 surfaces as "we know it's quiet" +# (~40 dB) instead of collapsing to null ("we don't know"), WITHOUT inflating the +# ~35% of genuine 40-44.99 dB readings that a 45.0 floor would wrongly bump to 45. +# NB: 45.0 is the overlay's lowest *paint* stop (noise_overlay_tiles. +# NOISE_COLOR_STOPS[0]) — a rendering threshold, not the data's reporting floor. +NOISE_QUIET_FLOOR_DB = np.float32(40.0) + # The pipeline has postcode representative points rather than complete unit # polygons here. Use a small local footprint and take the maximum 10m cell so # postcode-level noise is not understated by centroid rounding. POSTCODE_NOISE_RADIUS_M = 50 +# Adjacent download tiles must overlap by at least the sampling radius so every +# postcode's 50m max-window is fully contained in at least one tile. Without +# this, a loud pixel just across a tile seam is invisible to a postcode on the +# far side, under-reporting noise near seams. +TILE_OVERLAP_M = POSTCODE_NOISE_RADIUS_M + # Retry/split behaviour for slow Defra WCS requests. Some 100km eastern tiles # intermittently return 504s; smaller fallback requests usually succeed. MAX_RETRIES = 3 @@ -245,8 +266,12 @@ def _download_tile( except ( NoGeoTiffError, httpx.HTTPStatusError, - httpx.TimeoutException, - httpx.ConnectError, + # TransportError is the superset of TimeoutException, ConnectError, + # ReadError and ProtocolError — including RemoteProtocolError, raised + # when the WCS server closes the connection mid-stream ("incomplete + # chunked read"). All are transient; retry/split rather than letting + # one flaky tile crash the whole raster download. + httpx.TransportError, ) as e: last_error = e if attempt < MAX_RETRIES: @@ -287,6 +312,31 @@ def _download_tile( return [], [(min_e, min_n, max_e, max_n)] +def _generate_tiles( + min_e: int, + max_e: int, + min_n: int, + max_n: int, + tile_size: int, + overlap_m: int, + step: int, +) -> list[Tile]: + """Generate download tile bboxes stepping by ``step`` but extending each + tile's far edge by ``overlap_m`` so neighbours overlap. + + Overlapping neighbours guarantee that every postcode's POSTCODE_NOISE_RADIUS_M + sampling window is fully contained in at least one tile, so a loud pixel near + a seam is never lost (the sampler takes np.fmax across tiles). + """ + tiles: list[Tile] = [] + for tile_min_e in range(min_e, max_e, step): + for tile_min_n in range(min_n, max_n, step): + tile_max_e = min(tile_min_e + tile_size + overlap_m, BNG_MAX_E) + tile_max_n = min(tile_min_n + tile_size + overlap_m, BNG_MAX_N) + tiles.append((tile_min_e, tile_min_n, tile_max_e, tile_max_n)) + return tiles + + def download_raster( tile_dir: Path, wcs_base: str, @@ -296,12 +346,9 @@ def download_raster( allow_missing_tiles: bool = False, ) -> list[Path]: """Download noise GeoTIFF raster covering England, returning paths to saved files.""" - tiles = [] - for min_e in range(BNG_MIN_E, BNG_MAX_E, TILE_SIZE): - for min_n in range(BNG_MIN_N, BNG_MAX_N, TILE_SIZE): - max_e = min(min_e + TILE_SIZE, BNG_MAX_E) - max_n = min(min_n + TILE_SIZE, BNG_MAX_N) - tiles.append((min_e, min_n, max_e, max_n)) + tiles = _generate_tiles( + BNG_MIN_E, BNG_MAX_E, BNG_MIN_N, BNG_MAX_N, TILE_SIZE, TILE_OVERLAP_M, TILE_SIZE + ) print( f"[{label}] Downloading {len(tiles)} tiles at {RESOLUTION}m resolution ({MAX_WORKERS} workers)..." @@ -385,14 +432,23 @@ def sample_noise_at_postcodes( if len(candidate_indices) == 0: continue + # Defra rasters encode TRUE nodata as the -96.0 sentinel (and + # occasionally non-finite / dataset.nodata); genuinely quiet ground + # below the model's lowest reporting band is encoded as 0.0. Only + # the former is "we don't know" — the latter is a real "we know it's + # quiet" reading and must not collapse to null. So treat ONLY true + # nodata as -inf (it never wins a max and never counts as coverage), + # and clamp every in-coverage cell up to NOISE_QUIET_FLOOR_DB so a + # below-threshold 0.0 surfaces as the documented quiet floor. grid = dataset.read(1).astype(np.float32, copy=False) - invalid = ~np.isfinite(grid) | (grid == 0) + nodata = ~np.isfinite(grid) | np.isclose( + grid, NOISE_NODATA_SENTINEL, rtol=1e-5, atol=1e-5 + ) if dataset.nodata is not None: - invalid |= np.isclose( + nodata |= np.isclose( grid, np.float32(dataset.nodata), rtol=1e-5, atol=1e-5 ) - grid = grid.copy() - grid[invalid] = -np.inf + grid = np.where(nodata, -np.inf, np.maximum(grid, NOISE_QUIET_FLOOR_DB)) if filter_size > 1: grid = maximum_filter( grid, size=filter_size, mode="constant", cval=-np.inf @@ -412,12 +468,15 @@ def sample_noise_at_postcodes( sampled_indices = candidate_indices[in_bounds] sampled = grid[rows[in_bounds], cols[in_bounds]] - valid = sampled != -np.inf - if not np.any(valid): + # A finite sample means at least one in-coverage cell sat in the + # window (quiet -> floor, or louder). -inf means the whole window was + # true nodata, so the postcode stays uncovered (null) for this tile. + covered = np.isfinite(sampled) + if not np.any(covered): continue - sampled_indices = sampled_indices[valid] - sampled = sampled[valid] + sampled_indices = sampled_indices[covered] + sampled = sampled[covered] existing = noise_db[sampled_indices] noise_db[sampled_indices] = np.where( np.isnan(existing), sampled, np.maximum(existing, sampled) diff --git a/pipeline/download/ofsted.py b/pipeline/download/ofsted.py index 62979c5..f226053 100644 --- a/pipeline/download/ofsted.py +++ b/pipeline/download/ofsted.py @@ -1,4 +1,5 @@ import argparse +import io import tempfile import polars as pl from pathlib import Path @@ -14,10 +15,12 @@ URL = "https://assets.publishing.service.gov.uk/media/69c5269b4a06660f0854427b/M def convert_to_parquet(csv_path: Path, parquet_path: Path) -> None: print("Reading CSV...") + # The gov.uk source is cp1252-encoded; decode explicitly so non-ASCII + # school names are not corrupted (see gias.py for the same approach). + text = csv_path.read_bytes().decode("cp1252") df = pl.read_csv( - csv_path, + io.StringIO(text), infer_schema_length=10000, - encoding="utf8-lossy", null_values=["NULL", "Not applicable"], ) diff --git a/pipeline/download/places.py b/pipeline/download/places.py index 2a70c95..6b5d875 100644 --- a/pipeline/download/places.py +++ b/pipeline/download/places.py @@ -42,6 +42,41 @@ SEARCH_PLACE_TYPES = { "island", } TRAVEL_DESTINATION_PLACE_TYPES = {"city"} + +# Named OSM highways worth surfacing as searchable streets (N). Service roads, footways, +# cycleways and motorways are deliberately excluded. +SEARCHABLE_HIGHWAY_TYPES = { + "residential", + "unclassified", + "tertiary", + "tertiary_link", + "secondary", + "secondary_link", + "primary", + "primary_link", + "trunk", + "living_street", + "pedestrian", +} + +# High-value named POIs (M) lifted from uk_pois.parquet into the gazetteer, mapped from the +# OSM "key/value" category onto a search place_type. Everyday shops/amenities are excluded. +HIGH_VALUE_POI_CATEGORIES = { + "leisure/park": "park", + "leisure/garden": "park", + "leisure/nature_reserve": "park", + "leisure/common": "park", + "tourism/attraction": "attraction", + "tourism/theme_park": "attraction", + "tourism/zoo": "attraction", + "tourism/museum": "attraction", + "tourism/gallery": "attraction", + "amenity/hospital": "hospital", + "healthcare/hospital": "hospital", + "shop/mall": "retail", + "shop/department_store": "retail", +} + ENGLAND_COUNTRY_CODE = "E92000001" LONDON_REGION_CODE = "E12000007" LONDON_LAD_PREFIX = "E09" @@ -49,6 +84,38 @@ LONDON_COUNTY_CODES = {"E13000001", "E13000002"} DISPLAY_CITY_NEAREST_POSTCODE_MAX_M = 3_000 WGS84_TO_BNG = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) +# England British National Grid (EPSG:27700) bounding box, with margin. ONS NSPL stores +# postcodes that have no grid reference at the Null-Island sentinel lat=99.999999, +# long=0.000000, whose paired easting/northing collapse to the grid origin (0, 0) (or +# inf). Requiring coordinates inside this box drops the sentinel from every index, so an +# active postcode lacking a grid ref can never become a false nearest neighbour. +ENGLAND_BNG_MIN_EAST = 50_000.0 +ENGLAND_BNG_MAX_EAST = 660_000.0 +ENGLAND_BNG_MIN_NORTH = 0.0 +ENGLAND_BNG_MAX_NORTH = 660_000.0 + + +def _valid_wgs84_expr() -> pl.Expr: + """Rows with a real lat/long inside England (drops the ONS lat=99.999999, long=0.0 + no-grid-reference sentinel and any nulls), so they never enter a coordinate index.""" + return ( + pl.col("lat").is_not_null() + & pl.col("long").is_not_null() + & pl.col("lat").is_between(ENGLAND_BBOX_SOUTH, ENGLAND_BBOX_NORTH) + & pl.col("long").is_between(ENGLAND_BBOX_WEST, ENGLAND_BBOX_EAST) + ) + + +def _valid_bng_expr() -> pl.Expr: + """Rows with a real easting/northing inside England (drops the (0, 0) grid-origin / + inf paired with the ONS no-grid-reference sentinel and any nulls).""" + return ( + pl.col("east1m").is_not_null() + & pl.col("north1m").is_not_null() + & pl.col("east1m").is_between(ENGLAND_BNG_MIN_EAST, ENGLAND_BNG_MAX_EAST) + & pl.col("north1m").is_between(ENGLAND_BNG_MIN_NORTH, ENGLAND_BNG_MAX_NORTH) + ) + # Suffixes to strip from raw station names before appending the typed suffix. _STATION_STRIP = ( " tube station", @@ -240,11 +307,165 @@ def _slugify_name(name: str) -> str: return re.sub(r"\s+", "-", slug).strip("-") +def _street_centroid(coords: list[tuple[float, float]]) -> tuple[float, float] | None: + """Average (lat, lon) of a way's vertices.""" + if not coords: + return None + count = len(coords) + lat = sum(lat for lat, _ in coords) / count + lon = sum(lon for _, lon in coords) / count + return lat, lon + + +def _normalize_street_name(name: str) -> str: + """Grouping key for a street name: collapse whitespace, lowercase.""" + return re.sub(r"\s+", " ", name).strip().lower() + + +def _outcode_of_postcode(postcode: str) -> str: + """Outward code (everything before the space) of a postcode, e.g. 'NW1' from 'NW1 6XE'.""" + return postcode.split(" ", 1)[0] if postcode else "" + + +def _outcode_tree(postcodes_path: Path) -> tuple[cKDTree, list[str]]: + """Build a nearest-neighbour index from postcode coordinates to their outcode, so each + street can be tagged with the outcode it sits in (used to disambiguate same-named roads).""" + df = ( + pl.read_parquet( + postcodes_path, columns=["pcds", "lat", "long", "ctry25cd", "doterm"] + ) + .filter((pl.col("ctry25cd") == ENGLAND_COUNTRY_CODE) & pl.col("doterm").is_null()) + .filter(_valid_wgs84_expr()) + ) + coords = np.column_stack( + [df["lat"].to_numpy().astype(np.float64), df["long"].to_numpy().astype(np.float64)] + ) + outcodes = [_outcode_of_postcode(pc) for pc in df["pcds"].to_list()] + return cKDTree(coords), outcodes + + +def _build_street_places( + streets: list[dict], + tree: cKDTree, + outcodes: list[str], +) -> list[dict]: + """Group street segments by (normalized name, outcode), averaging centroids, so a road that + OSM splits into many segments becomes one searchable result per outcode it passes through.""" + if not streets: + return [] + + coords = np.array([[street["lat"], street["lon"]] for street in streets], dtype=np.float64) + _, indices = tree.query(coords) + + grouped: dict[tuple[str, str], dict] = {} + for street, postcode_idx in zip(streets, indices): + outcode = outcodes[postcode_idx] + key = (_normalize_street_name(street["name"]), outcode) + entry = grouped.get(key) + if entry is None: + grouped[key] = { + "name": street["name"], + "lat_sum": street["lat"], + "lon_sum": street["lon"], + "count": 1, + } + else: + entry["lat_sum"] += street["lat"] + entry["lon_sum"] += street["lon"] + entry["count"] += 1 + + places = [] + for entry in grouped.values(): + count = entry["count"] + places.append( + { + "name": entry["name"], + "place_type": "street", + "lat": entry["lat_sum"] / count, + "lon": entry["lon_sum"] / count, + "population": 0, + "travel_destination": False, + "display_city": None, + } + ) + return sorted(places, key=lambda place: place["name"].lower()) + + +def _poi_dedup_key(name: str, place_type: str, lat: float, lon: float) -> tuple: + """Geographic de-dup key: round(.,2) is ~1.1km lat / ~0.7km UK lon. + + Coarse enough to collapse the SAME physical POI mapped twice a few metres + apart, fine enough to keep genuinely distinct same-named POIs in different + towns (e.g. "Victoria Park" in London vs Bristol). + """ + return (name.lower(), place_type, round(lat, 2), round(lon, 2)) + + +def _pois_to_places(pois: pl.DataFrame) -> list[dict]: + """Map high-value named POIs onto gazetteer place rows (M), de-duplicated by (name, type, coords).""" + if pois.is_empty(): + return [] + + seen: set[tuple] = set() + places: list[dict] = [] + for row in pois.iter_rows(named=True): + place_type = HIGH_VALUE_POI_CATEGORIES.get(str(row.get("category", ""))) + if place_type is None: + continue + name = str(row.get("name") or "").strip() + if len(name) < 3: + continue + lat = float(row["lat"]) + lon = float(row["lng"]) + key = _poi_dedup_key(name, place_type, lat, lon) + if key in seen: + continue + seen.add(key) + places.append( + { + "name": name, + "place_type": place_type, + "lat": lat, + "lon": lon, + "population": 0, + "travel_destination": False, + "display_city": None, + } + ) + return places + + +def _append_high_value_pois(places: list[dict], pois_path: Path) -> int: + pois = pl.read_parquet(pois_path, columns=["name", "category", "lat", "lng"]) + new_places = _pois_to_places(pois) + existing = { + _poi_dedup_key( + str(place["name"]), place["place_type"], place["lat"], place["lon"] + ) + for place in places + } + added = 0 + for place in new_places: + key = _poi_dedup_key( + place["name"], place["place_type"], place["lat"], place["lon"] + ) + if key in existing: + continue + places.append(place) + existing.add(key) + added += 1 + return added + + def _postcode_lookup(postcodes_path: Path) -> dict[str, tuple[float, float]]: - df = pl.read_parquet( - postcodes_path, - columns=["pcds", "lat", "long", "ctry25cd", "doterm"], - ).filter((pl.col("ctry25cd") == ENGLAND_COUNTRY_CODE) & pl.col("doterm").is_null()) + df = ( + pl.read_parquet( + postcodes_path, + columns=["pcds", "lat", "long", "ctry25cd", "doterm"], + ) + .filter((pl.col("ctry25cd") == ENGLAND_COUNTRY_CODE) & pl.col("doterm").is_null()) + .filter(_valid_wgs84_expr()) + ) return { _normalize_postcode(postcode): (float(lat), float(lon)) for postcode, lat, lon in df.select(["pcds", "lat", "long"]).iter_rows() @@ -266,6 +487,19 @@ def _display_city_from_tags(tags: dict[str, str]) -> str | None: return None +def _parse_population(pop_str: str) -> int: + """Robustly parse OSM population tags that may carry grouping separators, + decimals, or surrounding text ("12,345", "5 000", "12345.0", "approx 5000"). + """ + # Take the integer part before any decimal point, then the first run of + # digits ignoring grouping separators (commas/spaces) and other annotations. + match = re.search(r"\d[\d,\s]*", pop_str.split(".", 1)[0]) + if match is None: + return 0 + digits = re.sub(r"\D", "", match.group(0)) + return int(digits) if digits else 0 + + def _is_london_admin_expr() -> pl.Expr: return ( (pl.col("rgn25cd") == LONDON_REGION_CODE) @@ -289,7 +523,7 @@ def _london_postcode_tree(postcodes_path: Path) -> tuple[cKDTree, np.ndarray]: .filter( (pl.col("ctry25cd") == ENGLAND_COUNTRY_CODE) & pl.col("doterm").is_null() ) - .filter(pl.col("east1m").is_not_null() & pl.col("north1m").is_not_null()) + .filter(_valid_bng_expr()) .with_columns(_is_london_admin_expr().alias("is_london")) .select("east1m", "north1m", "is_london") ) @@ -466,11 +700,15 @@ def _append_naptan_dlr_stations(places: list[dict], naptan_path: Path) -> int: class PlaceHandler(osmium.SimpleHandler): - def __init__(self, progress: tqdm, england_polygon) -> None: + def __init__( + self, progress: tqdm, england_polygon, *, collect_streets: bool = False + ) -> None: super().__init__() self._progress = progress self.places: list[dict] = [] + self.streets: list[dict] = [] self._england = england_polygon + self._collect_streets = collect_streets def _add( self, @@ -513,11 +751,7 @@ class PlaceHandler(osmium.SimpleHandler): if not name: return - pop_str = tags.get("population", "") - try: - population = int(pop_str) - except ValueError: - population = 0 + population = _parse_population(tags.get("population", "")) # place=* nodes place_type = tags.get("place") @@ -551,6 +785,39 @@ class PlaceHandler(osmium.SimpleHandler): ) return + def way(self, w: osmium.osm.Way) -> None: + """Collect named, searchable highways as raw segments (grouped into streets later).""" + if not self._collect_streets: + return + self._progress.update(1) + if w.tags.get("highway") not in SEARCHABLE_HIGHWAY_TYPES: + return + name = w.tags.get("name:en", w.tags.get("name", "")) + if not name: + return + + # Way node refs expose resolved .lat/.lon directly (locations=True); accessing them + # raises InvalidLocationError when a node's location is missing from the index. + coords: list[tuple[float, float]] = [] + for node in w.nodes: + try: + coords.append((node.lat, node.lon)) + except osmium.InvalidLocationError: + continue + + centroid = _street_centroid(coords) + if centroid is None: + return + lat, lon = centroid + if not ( + ENGLAND_BBOX_SOUTH <= lat <= ENGLAND_BBOX_NORTH + and ENGLAND_BBOX_WEST <= lon <= ENGLAND_BBOX_EAST + ): + return + if not self._england.contains(Point(lon, lat)): + return + self.streets.append({"name": name, "lat": lat, "lon": lon}) + def main() -> None: parser = argparse.ArgumentParser(description="Extract place names from OSM PBF") @@ -578,15 +845,28 @@ def main() -> None: "--postcodes", type=Path, help=( - "Postcode parquet used to geocode OfS university contact postcodes " - "and assign Greater London display labels" + "Postcode parquet used to geocode OfS university contact postcodes, assign " + "Greater London display labels, and tag streets with their outcode" ), ) + parser.add_argument( + "--pois", + type=Path, + help="Optional uk_pois.parquet; high-value named POIs are added to the gazetteer", + ) + parser.add_argument( + "--include-streets", + action="store_true", + help="Extract named highways as searchable streets (requires --postcodes)", + ) args = parser.parse_args() pbf_file = args.pbf england_polygon = load_england_polygon(args.boundary) + if args.include_streets and not args.postcodes: + raise ValueError("--postcodes is required with --include-streets") + print("Extracting search place nodes + railway stations") with tqdm( unit=" elements", @@ -595,10 +875,21 @@ def main() -> None: smoothing=0.05, mininterval=1.0, ) as progress: - handler = PlaceHandler(progress, england_polygon) + handler = PlaceHandler( + progress, england_polygon, collect_streets=args.include_streets + ) handler.apply_file(str(pbf_file), locations=True) print(f"Extracted {len(handler.places):,} place nodes") + if args.include_streets: + print(f"Collected {len(handler.streets):,} named street segments") + tree, outcodes = _outcode_tree(args.postcodes) + street_places = _build_street_places(handler.streets, tree, outcodes) + handler.places.extend(street_places) + print(f"Added {len(street_places):,} grouped streets") + if args.pois: + added = _append_high_value_pois(handler.places, args.pois) + print(f"Added {added:,} high-value POIs from {args.pois}") if args.naptan: added = _append_naptan_dlr_stations(handler.places, args.naptan) print(f"Added {added:,} DLR station destinations from NaPTAN") diff --git a/pipeline/download/satellite_highres.py b/pipeline/download/satellite_highres.py new file mode 100644 index 0000000..0de2d72 --- /dev/null +++ b/pipeline/download/satellite_highres.py @@ -0,0 +1,503 @@ +"""Build a high-resolution England aerial PMTiles archive from EA Vertical Aerial Photography. + +The Environment Agency / Defra Vertical Aerial Photography (VAP) archive is open +(OGL v3.0) RGB orthophotography at 10-50 cm, distributed as 5 km ECW tiles on the +British National Grid. There is no public imagery tile service, so we mirror the +Sentinel-2 ``satellite.pmtiles`` approach: query the Defra survey download API for +an area of interest, pick the best RGB capture per OS tile, download and decode the +ECW rasters, re-tile them into Web-Mercator raster tiles, and bake a single PMTiles +archive that the server stacks *over* the Sentinel-2 base where coverage exists. + +ECW decoding needs a GDAL build that includes the (free, read-only) ERDAS ECW/JP2 +SDK, which is not present in the rasterio wheel. The mosaic + tiling step therefore +runs inside a GDAL-with-ECW Docker image (see ``docker/gdal-ecw/Dockerfile``); the +rest of the pipeline is plain Python plus the ``pmtiles`` CLI. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import sqlite3 +import subprocess +import tempfile +import urllib.error +import urllib.request +import zipfile +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + +from pipeline.download.tiles import ensure_pmtiles_cli +from pipeline.local_temp import local_tmp_dir + +# Defra Data Services Platform survey download API (reverse-engineered from the +# environment.data.gov.uk/survey front-end; no official API is documented). +SEARCH_URL = ( + "https://environment.data.gov.uk/backend/catalog/api/tiles/collections/survey/search" +) +SURVEY_PAGE_URL = "https://environment.data.gov.uk/survey" +# Static public key baked into the survey page JS. May rotate -- we try to scrape a +# fresh one from the page and only fall back to this literal. +DEFAULT_SUBSCRIPTION_KEY = "dspui" +SUBSCRIPTION_KEY_RE = re.compile(r"subscription-key=([A-Za-z0-9]+)") + +# True-colour RGB product only (skip IRRGB near-infra-red and Night Time variants). +VAP_RGB_PRODUCT = "vertical_aerial_photography_tiles_rgb" + +# Greater London bounding box (lon/lat). The API only returns tiles where coverage +# exists, so a generous bbox is fine -- it does not force blank downloads. +DEFAULT_AOI: dict = { + "type": "Polygon", + "coordinates": [ + [ + [-0.55, 51.25], + [0.30, 51.25], + [0.30, 51.70], + [-0.55, 51.70], + [-0.55, 51.25], + ] + ], +} + +DEFAULT_MIN_ZOOM = 14 +DEFAULT_MAX_ZOOM = 19 +# GDAL image with the ECW driver. The official OSGeo image does not ship ECW, so +# this defaults to the locally-built image from docker/gdal-ecw/Dockerfile. +DEFAULT_GDAL_IMAGE = "perfect-postcode/gdal-ecw:latest" +USER_AGENT = "perfect-postcode-satellite-highres/1.0" +ATTRIBUTION_TEMPLATE = ( + "Environment Agency Vertical Aerial Photography - " + "© Environment Agency copyright and/or database right {year}. " + "All rights reserved. Licensed under the Open Government Licence v3.0." +) + + +@dataclass(frozen=True) +class VapTile: + """One survey download record from the Defra search API.""" + + product_id: str + year: int + resolution_m: float + os_tile_id: str + uri: str + label: str + + +def parse_search_results(payload: dict) -> list[VapTile]: + """Turn a raw search-API JSON payload into typed records.""" + tiles: list[VapTile] = [] + for result in payload.get("results", []): + try: + tiles.append( + VapTile( + product_id=result["product"]["id"], + year=int(result["year"]["id"]), + resolution_m=float(result["resolution"]["id"]), + os_tile_id=result["tile"]["id"], + uri=result["uri"], + label=result.get("label", ""), + ) + ) + except (KeyError, TypeError, ValueError): + # Skip malformed records rather than failing the whole search. + continue + return tiles + + +def select_best_rgb_tiles(tiles: list[VapTile]) -> list[VapTile]: + """Pick one RGB capture per OS tile: finest resolution, then latest year. + + Pure function -- the unit test exercises this against a real-shaped payload. + """ + best: dict[str, VapTile] = {} + for tile in tiles: + if tile.product_id != VAP_RGB_PRODUCT: + continue + current = best.get(tile.os_tile_id) + if current is None or _is_better(tile, current): + best[tile.os_tile_id] = tile + return [best[key] for key in sorted(best)] + + +def _is_better(candidate: VapTile, incumbent: VapTile) -> bool: + """Finer resolution wins; ties broken by the most recent survey year.""" + if candidate.resolution_m != incumbent.resolution_m: + return candidate.resolution_m < incumbent.resolution_m + return candidate.year > incumbent.year + + +def _http_get(url: str, timeout: float) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=timeout) as response: + return response.read() + + +def resolve_subscription_key(explicit: str | None, timeout: float = 30.0) -> str: + """Use an explicit key, else scrape the survey page JS, else the known default.""" + if explicit: + return explicit + try: + page = _http_get(SURVEY_PAGE_URL, timeout).decode("utf-8", "ignore") + match = SUBSCRIPTION_KEY_RE.search(page) + if match: + return match.group(1) + # The key usually lives in a referenced JS chunk; scan the largest one. + for chunk in re.findall(r'src="(/_next/static/[^"]+\.js)"', page): + js = _http_get(f"https://environment.data.gov.uk{chunk}", timeout) + match = SUBSCRIPTION_KEY_RE.search(js.decode("utf-8", "ignore")) + if match: + return match.group(1) + except (urllib.error.URLError, TimeoutError, ConnectionError) as err: + print(f"Could not scrape subscription key ({err}); using default", flush=True) + return DEFAULT_SUBSCRIPTION_KEY + + +def search_vap_tiles(aoi: dict, timeout: float = 60.0) -> list[VapTile]: + """POST the area-of-interest polygon and return the RGB tiles to download.""" + body = json.dumps(aoi).encode("utf-8") + req = urllib.request.Request( + SEARCH_URL, + data=body, + headers={ + "Content-Type": "application/geo+json", + "Referer": SURVEY_PAGE_URL, + "User-Agent": USER_AGENT, + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=timeout) as response: + payload = json.load(response) + selected = select_best_rgb_tiles(parse_search_results(payload)) + print( + f"Search returned {payload.get('count', 0)} records; " + f"selected {len(selected)} RGB tile(s)", + flush=True, + ) + return selected + + +def _download_and_extract( + tile: VapTile, ecw_dir: Path, key: str, timeout: float, retries: int +) -> list[Path]: + """Download one survey zip and extract its ECW raster(s).""" + url = f"{tile.uri}?subscription-key={key}" + zip_path = ecw_dir / f"{tile.os_tile_id}.zip" + for attempt in range(retries + 1): + try: + with urllib.request.urlopen( + urllib.request.Request(url, headers={"User-Agent": USER_AGENT}), + timeout=timeout, + ) as response, zip_path.open("wb") as out: + shutil.copyfileobj(response, out, length=1 << 20) + break + except (urllib.error.URLError, TimeoutError, ConnectionError) as err: + if attempt == retries: + raise RuntimeError(f"Failed to download {url}: {err}") from err + extracted: list[Path] = [] + with zipfile.ZipFile(zip_path) as archive: + for member in archive.infolist(): + if member.is_dir() or not member.filename.lower().endswith(".ecw"): + continue + target = ecw_dir / f"{tile.os_tile_id}_{Path(member.filename).name}" + with archive.open(member) as src, target.open("wb") as dst: + shutil.copyfileobj(src, dst, length=1 << 20) + extracted.append(target) + zip_path.unlink(missing_ok=True) + if not extracted: + print(f" {tile.os_tile_id}: no ECW in archive (skipped)", flush=True) + return extracted + + +def download_tiles( + tiles: list[VapTile], + ecw_dir: Path, + key: str, + max_workers: int, + timeout: float, + retries: int, +) -> list[Path]: + """Download every selected tile concurrently; return all extracted ECW paths.""" + ecw_dir.mkdir(parents=True, exist_ok=True) + ecw_paths: list[Path] = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = { + executor.submit( + _download_and_extract, tile, ecw_dir, key, timeout, retries + ): tile + for tile in tiles + } + done = 0 + for future in as_completed(futures): + tile = futures[future] + ecw_paths.extend(future.result()) + done += 1 + print( + f"Downloaded {done}/{len(tiles)} tiles " + f"(latest: {tile.os_tile_id} {tile.resolution_m}m {tile.year})", + flush=True, + ) + return ecw_paths + + +def _build_tiles_with_gdal( + work_dir: Path, + gdal_image: str, + min_zoom: int, + max_zoom: int, + jobs: int, + webp_quality: int, +) -> Path: + """Mosaic the ECW rasters and emit XYZ WebP tiles inside the GDAL-with-ECW image. + + Returns the host path of the generated ``xyz`` directory. We use lossy WebP with + an alpha channel: ~6x smaller than lossless PNG for photographic imagery while + keeping transparency, so coverage gaps stay see-through and the Sentinel-2 base + shows through them. + """ + xyz_dir = work_dir / "xyz" + # EA "RGB" ECWs are 4-band RGBA (band 4 is a constant-255 validity/alpha mask), + # so we build a plain 4-band VRT (no -addalpha, which would make a 5th band and + # exceed PNG's 4-band limit). We then: + # * force EPSG:27700 -- the pixels are already British National Grid, and the + # EPSG code lets PROJ apply the OSTN15 datum shift (grid ships in the image) + # for metre-accurate reprojection to Web Mercator; + # * label band 4 as alpha so gdal2tiles writes transparent PNGs. Inter-block + # gaps the VRT fills with 0 then read as alpha=0 (transparent), letting the + # Sentinel-2 base show through wherever VAP coverage is missing. + script = ( + "set -euo pipefail; " + "cd /work; " + "gdalbuildvrt -resolution highest mosaic.vrt ecw/*.ecw; " + "gdal_edit.py -a_srs EPSG:27700 " + "-colorinterp_1 red -colorinterp_2 green -colorinterp_3 blue " + "-colorinterp_4 alpha mosaic.vrt; " + f"gdal2tiles.py --xyz --zoom={min_zoom}-{max_zoom} " + f"--processes={jobs} --resampling=average --webviewer=none " + f"--tiledriver=WEBP --webp-quality={webp_quality} " + "mosaic.vrt xyz" + ) + subprocess.run( + [ + "docker", + "run", + "--rm", + "-v", + f"{work_dir.resolve()}:/work", + gdal_image, + "bash", + "-c", + script, + ], + check=True, + ) + if not xyz_dir.exists(): + raise RuntimeError("gdal2tiles produced no output directory") + return xyz_dir + + +def _pack_xyz_to_mbtiles( + xyz_dir: Path, + mbtiles_path: Path, + bounds: tuple[float, float, float, float], + min_zoom: int, + max_zoom: int, + attribution: str, +) -> int: + """Pack a gdal2tiles XYZ WebP directory into an MBTiles SQLite file (TMS rows).""" + if mbtiles_path.exists(): + mbtiles_path.unlink() + conn = sqlite3.connect(mbtiles_path) + try: + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + conn.execute("CREATE TABLE metadata (name TEXT, value TEXT)") + conn.execute( + "CREATE TABLE tiles (zoom_level INTEGER, tile_column INTEGER, " + "tile_row INTEGER, tile_data BLOB)" + ) + conn.execute( + "CREATE UNIQUE INDEX tile_index ON tiles " + "(zoom_level, tile_column, tile_row)" + ) + conn.executemany( + "INSERT INTO metadata (name, value) VALUES (?, ?)", + [ + ("name", "EA Vertical Aerial Photography"), + ("type", "overlay"), + ("version", "1"), + ("description", "Environment Agency high-resolution aerial imagery"), + ("format", "webp"), + ("attribution", attribution), + ("bounds", ",".join(f"{value:.6f}" for value in bounds)), + ("minzoom", str(min_zoom)), + ("maxzoom", str(max_zoom)), + ], + ) + inserted = 0 + for zoom_dir in sorted(xyz_dir.iterdir()): + if not zoom_dir.is_dir() or not zoom_dir.name.isdigit(): + continue + zoom = int(zoom_dir.name) + for col_dir in zoom_dir.iterdir(): + if not col_dir.is_dir() or not col_dir.name.isdigit(): + continue + col = int(col_dir.name) + for tile_file in col_dir.glob("*.webp"): + if not tile_file.stem.isdigit(): + continue + row = int(tile_file.stem) + tms_row = (1 << zoom) - 1 - row + conn.execute( + "INSERT OR REPLACE INTO tiles VALUES (?, ?, ?, ?)", + (zoom, col, tms_row, tile_file.read_bytes()), + ) + inserted += 1 + if inserted % 5000 == 0: + conn.commit() + print(f" packed {inserted:,} tiles", flush=True) + conn.commit() + finally: + conn.close() + return inserted + + +def build_satellite_highres_tiles( + output_path: Path, + pmtiles_bin: Path, + pmtiles_version: str, + aoi: dict, + min_zoom: int, + max_zoom: int, + gdal_image: str, + subscription_key: str | None, + max_workers: int, + timeout: float, + retries: int, + jobs: int, + webp_quality: int, +) -> None: + if min_zoom > max_zoom: + raise ValueError("--min-zoom must be <= --max-zoom") + + output_path.parent.mkdir(parents=True, exist_ok=True) + ensure_pmtiles_cli(pmtiles_bin, pmtiles_version) + + tiles = search_vap_tiles(aoi) + if not tiles: + raise RuntimeError("No RGB Vertical Aerial Photography tiles for the AOI") + key = resolve_subscription_key(subscription_key) + attribution = ATTRIBUTION_TEMPLATE.format(year=max(tile.year for tile in tiles)) + + with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as tmp: + work_dir = Path(tmp) + ecw_dir = work_dir / "ecw" + ecw_paths = download_tiles( + tiles, ecw_dir, key, max_workers, timeout, retries + ) + if not ecw_paths: + raise RuntimeError("No ECW rasters were extracted from the downloads") + + xyz_dir = _build_tiles_with_gdal( + work_dir, gdal_image, min_zoom, max_zoom, jobs, webp_quality + ) + + mbtiles_path = work_dir / "satellite_highres.mbtiles" + bounds = _aoi_bounds(aoi) + inserted = _pack_xyz_to_mbtiles( + xyz_dir, mbtiles_path, bounds, min_zoom, max_zoom, attribution + ) + if inserted == 0: + raise RuntimeError("Tiling produced no tiles to pack") + print(f"Packed {inserted:,} tiles into MBTiles", flush=True) + + subprocess.run( + [str(pmtiles_bin), "convert", str(mbtiles_path), str(output_path), "--force"], + check=True, + ) + + size_mb = output_path.stat().st_size / (1024 * 1024) + print(f"Wrote {output_path} ({size_mb:.1f} MB) -- {attribution}", flush=True) + + +def _aoi_bounds(aoi: dict) -> tuple[float, float, float, float]: + coords = [point for ring in aoi["coordinates"] for point in ring] + lons = [point[0] for point in coords] + lats = [point[1] for point in coords] + return min(lons), min(lats), max(lons), max(lats) + + +def _load_aoi(path: Path | None) -> dict: + if path is None: + return DEFAULT_AOI + data = json.loads(path.read_text()) + if data.get("type") == "FeatureCollection": + return data["features"][0]["geometry"] + if data.get("type") == "Feature": + return data["geometry"] + return data + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--pmtiles-bin", type=Path, default=Path("property-data/pmtiles")) + parser.add_argument("--pmtiles-version", default="1.22.3") + parser.add_argument( + "--aoi-geojson", + type=Path, + default=None, + help="GeoJSON Polygon/Feature/FeatureCollection for the area of interest " + "(default: Greater London)", + ) + parser.add_argument("--min-zoom", type=int, default=DEFAULT_MIN_ZOOM) + parser.add_argument("--max-zoom", type=int, default=DEFAULT_MAX_ZOOM) + parser.add_argument( + "--gdal-image", + default=DEFAULT_GDAL_IMAGE, + help="Docker image with a GDAL that has the ECW driver", + ) + parser.add_argument( + "--subscription-key", + default=None, + help="Override the Defra survey API key (default: scrape, then 'dspui')", + ) + parser.add_argument("--max-workers", type=int, default=4) + parser.add_argument("--timeout", type=float, default=600.0) + parser.add_argument("--retries", type=int, default=3) + parser.add_argument( + "--jobs", + type=int, + default=8, + help="Parallel processes for gdal2tiles", + ) + parser.add_argument( + "--webp-quality", + type=int, + default=85, + help="WebP tile quality (1-100); lower is smaller", + ) + args = parser.parse_args() + + build_satellite_highres_tiles( + output_path=args.output, + pmtiles_bin=args.pmtiles_bin, + pmtiles_version=args.pmtiles_version, + aoi=_load_aoi(args.aoi_geojson), + min_zoom=args.min_zoom, + max_zoom=args.max_zoom, + gdal_image=args.gdal_image, + subscription_key=args.subscription_key, + max_workers=max(1, args.max_workers), + timeout=args.timeout, + retries=max(0, args.retries), + jobs=max(1, args.jobs), + webp_quality=args.webp_quality, + ) + + +if __name__ == "__main__": + main() diff --git a/pipeline/download/test_ethnicity.py b/pipeline/download/test_ethnicity.py index e428b33..d83b8b4 100644 --- a/pipeline/download/test_ethnicity.py +++ b/pipeline/download/test_ethnicity.py @@ -35,3 +35,31 @@ def test_ethnicity_percentages_recombines_predecessor_lads_by_population(): assert cumberland.select("% White", "% South Asian").to_dicts() == [ {"% White": 45.0, "% South Asian": 55.0} ] + + +def test_ethnicity_routes_any_other_asian_to_east_se_asian(): + """'Any Other Asian Background' and 'Chinese' both fold into '% East/SE Asian' + (not '% South Asian'), fixing the East/SE Asian undercount.""" + rows = [ + { + "Geography_code": "E06000001", + "Ethnicity_type": "ONS 2021 19+1", + "Ethnicity": ethnicity, + "Ethnic Population": pop, + "Value1": 0.0, + } + for ethnicity, pop in [ + ("Chinese", 30), + ("Any Other Asian Background", 20), + ("Indian", 50), + ] + ] + + result = _ethnicity_percentages(pl.DataFrame(rows)) + area = result.filter(pl.col("Geography_code") == "E06000001") + + assert "% East/SE Asian" in result.columns + assert "% East Asian" not in result.columns + assert area.select("% East/SE Asian", "% South Asian").to_dicts() == [ + {"% East/SE Asian": 50.0, "% South Asian": 50.0} + ] diff --git a/pipeline/download/test_median_age.py b/pipeline/download/test_median_age.py new file mode 100644 index 0000000..7d322b7 --- /dev/null +++ b/pipeline/download/test_median_age.py @@ -0,0 +1,90 @@ +import math + +import polars as pl +import pytest + +from pipeline.download import median_age +from pipeline.download.median_age import ( + AGE_BANDS, + EXPECTED_BAND_NAMES, + compute_median_age, +) + + +def test_expected_band_names_align_with_age_bands(): + assert len(EXPECTED_BAND_NAMES) == len(AGE_BANDS) + + +def test_compute_median_age_interpolates_within_median_band(): + # All weight in the 30-34 band -> median is the band midpoint via linear + # interpolation: 30 + ((50 - 0) / 100) * 5 = 32.5. + counts = [0] * len(AGE_BANDS) + counts[6] = 100 # "Aged 30 to 34 years" + assert compute_median_age(counts) == pytest.approx(32.5) + + # 50 below the median band, 100 inside the 35-39 band holding the median. + # half = 75; cumulative before band 7 = 50; 35 + ((75 - 50) / 100) * 5 = 36.25. + counts = [0] * len(AGE_BANDS) + counts[0] = 50 # below the median band + counts[7] = 100 # "Aged 35 to 39 years" holds the median + assert compute_median_age(counts) == pytest.approx(36.25) + + +def test_compute_median_age_empty_lsoa_is_nan(): + assert math.isnan(compute_median_age([0] * len(AGE_BANDS))) + + +def _pivoted(band_to_counts: dict[str, list]) -> pl.DataFrame: + """Build a pivot-shaped frame: GEOGRAPHY_CODE + one column per band.""" + n = len(next(iter(band_to_counts.values()))) + data = {"GEOGRAPHY_CODE": [f"E0100000{i}" for i in range(n)]} + data.update(band_to_counts) + return pl.DataFrame(data) + + +def test_null_band_count_is_treated_as_zero_not_crash(): + # One LSOA has a null in the 85+ band (NOMIS can return null for a band with + # zero people). It must be coerced to 0, not raise TypeError in sum(). With + # all 100 people in the 30-34 band the median is the band midpoint, 32.5. + counts_by_band = {name: [0] for name in EXPECTED_BAND_NAMES} + counts_by_band["Aged 30 to 34 years"] = [100] + counts_by_band["Aged 85 years and over"] = [None] + pivoted = _pivoted(counts_by_band) + + table = median_age._bands_to_median_table(pivoted) + + assert table.height == 1 + assert table["median_age"][0] == pytest.approx(32.5) + + +def test_equivalent_band_label_alias_is_accepted(): + # NOMIS relabelled the first band "Aged 4 years and under" (same as ages + # 0-4). It must be normalised to the canonical name and used as band 0, not + # rejected. All 100 people in that band -> median in the 0-4 range: 2.5. + counts_by_band = {name: [0] for name in EXPECTED_BAND_NAMES} + counts_by_band["Aged 4 years and under"] = counts_by_band.pop("Aged 0 to 4 years") + counts_by_band["Aged 4 years and under"] = [100] + pivoted = _pivoted(counts_by_band) + + table = median_age._bands_to_median_table(pivoted) + + assert table.height == 1 + assert table["median_age"][0] == pytest.approx(2.5) + + +def test_missing_band_raises_clear_error(): + counts_by_band = {name: [10] for name in EXPECTED_BAND_NAMES} + del counts_by_band["Aged 85 years and over"] + pivoted = _pivoted(counts_by_band) + + with pytest.raises(ValueError, match=r"do not match the expected NOMIS"): + median_age._bands_to_median_table(pivoted) + + +def test_relabelled_band_raises_clear_error(): + counts_by_band = {name: [10] for name in EXPECTED_BAND_NAMES} + counts_by_band["Total"] = counts_by_band.pop("Aged 85 years and over") + pivoted = _pivoted(counts_by_band) + + with pytest.raises(ValueError, match=r"unexpected:"): + median_age._bands_to_median_table(pivoted) diff --git a/pipeline/download/test_naptan.py b/pipeline/download/test_naptan.py index 5c5875e..172a561 100644 --- a/pipeline/download/test_naptan.py +++ b/pipeline/download/test_naptan.py @@ -89,3 +89,92 @@ def test_deduplicate_naptan_does_not_merge_missing_locality_bus_stops(): result = deduplicate_naptan(df) assert len(result) == 2 + + +def test_deduplicate_naptan_merges_colocated_missing_locality_bus_stations(): + # Two NaPTAN records for the same bus station with no locality, co-located + # within the merge area, are a true duplicate and collapse to one POI. + df = pl.DataFrame( + { + "id": ["a", "b"], + "name": ["Victoria Bus Station", "Victoria Bus Station"], + "category": ["Bus station", "Bus station"], + "lat": [51.4952, 51.4953], + "lng": [-0.1441, -0.1440], + "locality": [None, None], + } + ) + + result = deduplicate_naptan(df) + + assert len(result) == 1 + assert result["name"][0] == "Victoria Bus Station" + assert result["category"][0] == "Bus station" + assert result["lat"][0] == pytest.approx((51.4952 + 51.4953) / 2) + + +def test_deduplicate_naptan_keeps_rail_station_with_only_station_node(): + # Aberdare's only NaPTAN record is an RLY station node (StopType "RLY"). + df = pl.DataFrame( + { + "id": ["aberdare-rly"], + "name": ["Aberdare Rail Station"], + "category": ["Rail station"], + "lat": [51.7155], + "lng": [-3.4438], + "locality": ["ABERDARE"], + "entrance": [False], + } + ) + + result = deduplicate_naptan(df) + + assert len(result) == 1 + assert result["name"][0] == "Aberdare Rail Station" + assert result["category"][0] == "Rail station" + + +def test_deduplicate_naptan_merges_rail_entrances_into_station_node(): + # A station node (RLY) and its two entrance nodes (RSE) collapse to a single + # "Rail station" POI represented by the station node, not an entrance. + df = pl.DataFrame( + { + "id": ["clapham-rly", "clapham-rse-a", "clapham-rse-b"], + "name": [ + "Clapham Junction Rail Station", + "Clapham Junction Rail Station", + "Clapham Junction Rail Station", + ], + "category": ["Rail station", "Rail station", "Rail station"], + "lat": [51.4642, 51.4644, 51.4640], + "lng": [-0.1705, -0.1702, -0.1708], + "locality": ["CLAPHAM", "CLAPHAM", "CLAPHAM"], + "entrance": [False, True, True], + } + ) + + result = deduplicate_naptan(df) + + assert len(result) == 1 + assert result["id"][0] == "clapham-rly" + assert result["category"][0] == "Rail station" + + +def test_deduplicate_naptan_does_not_merge_rail_and_ferry_in_same_area(): + # Different transport modes sharing a name/area stay as separate POIs. + df = pl.DataFrame( + { + "id": ["harbour-rail", "harbour-ferry"], + "name": ["Harbour Station", "Harbour Station"], + "category": ["Rail station", "Ferry"], + "lat": [51.5, 51.5001], + "lng": [-0.1, -0.1001], + "locality": ["HARBOUR", "HARBOUR"], + "entrance": [False, False], + } + ) + + result = deduplicate_naptan(df).sort("category") + + assert len(result) == 2 + assert result["category"].to_list() == ["Ferry", "Rail station"] diff --git a/pipeline/download/test_noise.py b/pipeline/download/test_noise.py index 2bd5d5e..b7c159b 100644 --- a/pipeline/download/test_noise.py +++ b/pipeline/download/test_noise.py @@ -2,14 +2,32 @@ import httpx import numpy as np import pytest import rasterio +from rasterio.io import MemoryFile from rasterio.transform import from_origin from pipeline.download import noise +def _tiny_geotiff_bytes() -> bytes: + data = np.array([[55]], dtype=np.uint8) + with MemoryFile() as memfile: + with memfile.open( + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:27700", + transform=from_origin(0, 1, 1, 1), + ) as dataset: + dataset.write(data, 1) + return bytes(memfile.getbuffer()) + + def test_download_tile_splits_after_retries(monkeypatch, tmp_path): monkeypatch.setattr(noise, "MAX_RETRIES", 1) monkeypatch.setattr(noise, "MIN_TILE_SIZE", 50) + tile_bytes = _tiny_geotiff_bytes() def fake_fetch_tile_bytes( wcs_base, @@ -22,7 +40,7 @@ def test_download_tile_splits_after_retries(monkeypatch, tmp_path): ): if max_e - min_e > 50 or max_n - min_n > 50: raise httpx.TimeoutException("too large") - return b"II*\x00fake-tiff" + return tile_bytes monkeypatch.setattr(noise, "_fetch_tile_bytes", fake_fetch_tile_bytes) @@ -107,6 +125,205 @@ def test_download_raster_raises_on_missing_strict_tiles(monkeypatch, tmp_path): noise.download_raster(tmp_path, "base", "coverage", "Road") +def test_generate_tiles_neighbours_overlap_by_radius(): + tile_size = 20_000 + overlap = noise.POSTCODE_NOISE_RADIUS_M + tiles = noise._generate_tiles( + 0, 60_000, 0, 60_000, tile_size, overlap, tile_size + ) + + by_origin = {(min_e, min_n): (max_e, max_n) for min_e, min_n, max_e, max_n in tiles} + + # Horizontally adjacent tiles must overlap by >= overlap. + for (min_e, min_n), (max_e, _max_n) in by_origin.items(): + right_origin = (min_e + tile_size, min_n) + if right_origin in by_origin: + assert max_e - right_origin[0] >= overlap + + # Vertically adjacent tiles must overlap by >= overlap. + for (min_e, min_n), (_max_e, max_n) in by_origin.items(): + up_origin = (min_e, min_n + tile_size) + if up_origin in by_origin: + assert max_n - up_origin[1] >= overlap + + +def test_generate_tiles_clamps_to_grid_extent(): + tile_size = 20_000 + overlap = noise.POSTCODE_NOISE_RADIUS_M + tiles = noise._generate_tiles( + noise.BNG_MAX_E - tile_size, + noise.BNG_MAX_E, + noise.BNG_MAX_N - tile_size, + noise.BNG_MAX_N, + tile_size, + overlap, + tile_size, + ) + # The final (top-right) tile cannot extend past the England extent even + # though the overlap would otherwise push it beyond. + for _min_e, _min_n, max_e, max_n in tiles: + assert max_e <= noise.BNG_MAX_E + assert max_n <= noise.BNG_MAX_N + + +def _write_geotiff(path, data, left, top, resolution, nodata): + with rasterio.open( + path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:27700", + transform=from_origin(left, top, resolution, resolution), + nodata=nodata, + ) as dataset: + dataset.write(data, 1) + + +def test_sample_noise_recovers_value_across_overlapping_seam(monkeypatch, tmp_path): + monkeypatch.setattr(noise, "POSTCODE_NOISE_RADIUS_M", 50) + monkeypatch.setattr(noise, "RESOLUTION", 10) + + # Two download tiles share a vertical seam at easting=100. _generate_tiles + # decides their real footprints: with the overlap fix the LEFT tile extends + # past the seam by POSTCODE_NOISE_RADIUS_M and thus covers a loud cell that + # physically sits just across the seam. + tile_size = 100 + overlap = noise.POSTCODE_NOISE_RADIUS_M + tiles = noise._generate_tiles(0, 200, 0, 100, tile_size, overlap, tile_size) + by_origin = { + (min_e, min_n): (max_e, max_n) for min_e, min_n, max_e, max_n in tiles + } + left_min_e, left_min_n = 0, 0 + left_max_e, left_max_n = by_origin[(left_min_e, left_min_n)] + # Overlap fix is what makes the left tile reach across the seam. + assert left_max_e > 100 + + # The loud 70 dB cell centre is at easting 105 (just across the seam) and + # the postcode point is at easting 75 in the left tile, within 50m of it. + res = noise.RESOLUTION + width = int((left_max_e - left_min_e) // res) + height = int((left_max_n - left_min_n) // res) + left_data = np.zeros((height, width), dtype=np.float32) + loud_row = height - 1 - int((25 - left_min_n) // res) # northing ~25 + loud_col = int((105 - left_min_e) // res) # easting ~105 + left_data[loud_row, loud_col] = 70.0 + _write_geotiff( + tmp_path / "left.tif", left_data, left_min_e, left_max_n, res, nodata=0 + ) + + # The right tile holds the same loud cell but the postcode point is NOT + # inside it, so without overlap the value would be lost for that point. + right_min_e, right_min_n = 100, 0 + right_max_e, right_max_n = by_origin[(right_min_e, right_min_n)] + rwidth = int((right_max_e - right_min_e) // res) + rheight = int((right_max_n - right_min_n) // res) + right_data = np.zeros((rheight, rwidth), dtype=np.float32) + right_data[rheight - 1 - int((25 - right_min_n) // res), 0] = 70.0 + _write_geotiff( + tmp_path / "right.tif", right_data, right_min_e, right_max_n, res, nodata=0 + ) + + result = noise.sample_noise_at_postcodes( + [tmp_path / "left.tif", tmp_path / "right.tif"], + easting=np.array([75.0]), + northing=np.array([25.0]), + label="Road", + col_name="road_noise_lden_db", + ) + + assert result.to_list() == [70.0] + + +def test_sample_noise_distinguishes_nodata_from_in_coverage_quiet( + monkeypatch, tmp_path +): + monkeypatch.setattr(noise, "POSTCODE_NOISE_RADIUS_M", 0) + monkeypatch.setattr(noise, "RESOLUTION", 10) + + # Defra encodes TRUE nodata as the -96.0 sentinel; genuinely quiet ground + # below the lowest reporting band is 0.0. With a 0m radius each postcode + # reads exactly one cell, so we can pin behaviour per cell: + # -96.0 sentinel -> null ("we don't know") + # 0.0 in-coverage -> NOISE_QUIET_FLOOR_DB ("we know it's quiet") + # 65.0 -> 65.0 (a real modelled reading) + data = np.array( + [ + [-96.0, 0.0, 65.0], + ], + dtype=np.float32, + ) + _write_geotiff(tmp_path / "noise.tif", data, 0, 10, 10, nodata=-96.0) + + result = noise.sample_noise_at_postcodes( + [tmp_path / "noise.tif"], + # Cell centres at easting 5 (nodata), 15 (quiet 0.0), 25 (loud 65). + easting=np.array([5.0, 15.0, 25.0]), + northing=np.array([5.0, 5.0, 5.0]), + label="Road", + col_name="road_noise_lden_db", + ) + + assert result.to_list() == [None, float(noise.NOISE_QUIET_FLOOR_DB), 65.0] + + +def test_sample_noise_preserves_genuine_reading_above_quiet_floor(monkeypatch, tmp_path): + monkeypatch.setattr(noise, "POSTCODE_NOISE_RADIUS_M", 0) + monkeypatch.setattr(noise, "RESOLUTION", 10) + + # The lowest Defra reporting band is 40.0 dB; genuine readings populate + # [40, ~80]. A genuine in-coverage reading at or just above the floor must be + # PRESERVED, not clamped UP to the floor — only true-quiet 0.0 is floored. A + # quiet floor set too high (e.g. 45) would inflate the ~35% of real 40-44.99 + # dB readings; this pins that they survive unchanged. + floor = float(noise.NOISE_QUIET_FLOOR_DB) + data = np.array( + [ + [42.0, floor, 0.0], + ], + dtype=np.float32, + ) + _write_geotiff(tmp_path / "noise.tif", data, 0, 10, 10, nodata=-96.0) + + result = noise.sample_noise_at_postcodes( + [tmp_path / "noise.tif"], + # Cell centres at easting 5 (42 dB), 15 (floor), 25 (quiet 0.0). + easting=np.array([5.0, 15.0, 25.0]), + northing=np.array([5.0, 5.0, 5.0]), + label="Road", + col_name="road_noise_lden_db", + ) + + # 42 preserved (NOT raised to the floor), floor preserved, 0.0 -> floor. + assert result.to_list() == [42.0, floor, floor] + # The floor must sit at/below the lowest genuine reading so nothing inflates. + assert floor <= 42.0 + + +def test_sample_noise_nodata_window_stays_null(monkeypatch, tmp_path): + monkeypatch.setattr(noise, "POSTCODE_NOISE_RADIUS_M", 15) + monkeypatch.setattr(noise, "RESOLUTION", 10) + + # A postcode whose entire 3x3 max-window is the -96.0 sentinel must remain + # null: no in-coverage cell was read, so "quiet" must NOT be inferred. + data = np.full((5, 5), -96.0, dtype=np.float32) + data[4, 4] = 70.0 # one loud cell, far from the nodata corner + _write_geotiff(tmp_path / "noise.tif", data, 0, 50, 10, nodata=-96.0) + + result = noise.sample_noise_at_postcodes( + [tmp_path / "noise.tif"], + # Top-left point: its 3x3 window is cells (rows 0-1, cols 0-1) = all -96. + easting=np.array([5.0]), + northing=np.array([45.0]), + label="Road", + col_name="road_noise_lden_db", + ) + + assert result.to_list() == [None] + + def test_sample_noise_at_postcodes_uses_local_maximum(monkeypatch, tmp_path): monkeypatch.setattr(noise, "POSTCODE_NOISE_RADIUS_M", 15) monkeypatch.setattr(noise, "RESOLUTION", 10) diff --git a/pipeline/download/test_places.py b/pipeline/download/test_places.py index 058ac34..48c9bae 100644 --- a/pipeline/download/test_places.py +++ b/pipeline/download/test_places.py @@ -1,15 +1,24 @@ +import numpy as np import polars as pl from pyproj import Transformer +from scipy.spatial import cKDTree from pipeline.download.places import ( _assign_london_display_city, + _build_street_places, _display_city_from_tags, _is_dlr_station, _is_tram_station, + _london_postcode_tree, _naptan_dlr_stations, + _normalize_street_name, _ofs_universities, + _outcode_of_postcode, + _outcode_tree, + _pois_to_places, _select_university_name, _station_display_name, + _street_centroid, ) WGS84_TO_BNG = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) @@ -168,6 +177,109 @@ def test_ofs_universities_extracts_university_title_rows_with_postcode_coords(): ] +def test_street_centroid_averages_vertices(): + assert _street_centroid([(51.0, -0.1), (53.0, -0.3)]) == (52.0, -0.2) + assert _street_centroid([]) is None + + +def test_normalize_street_name_and_outcode(): + assert _normalize_street_name(" High Street ") == "high street" + assert _outcode_of_postcode("NW1 6XE") == "NW1" + assert _outcode_of_postcode("") == "" + + +def test_build_street_places_groups_segments_by_name_and_outcode(): + # Two postcodes: NW1 (north) and CR0 (south). + tree = cKDTree(np.array([[51.53, -0.14], [51.37, -0.10]], dtype=np.float64)) + outcodes = ["NW1", "CR0"] + + streets = [ + {"name": "High Street", "lat": 51.531, "lon": -0.141}, # NW1 + {"name": "High Street", "lat": 51.529, "lon": -0.139}, # NW1 (same road, 2nd segment) + {"name": "High Street", "lat": 51.371, "lon": -0.101}, # CR0 (different road, same name) + {"name": "Baker Street", "lat": 51.5305, "lon": -0.1405}, # NW1 + ] + + places = _build_street_places(streets, tree, outcodes) + + # 3 distinct streets: High Street/NW1 (2 segments merged), High Street/CR0, Baker Street/NW1. + assert len(places) == 3 + assert all(place["place_type"] == "street" for place in places) + + nw1_high = next( + place + for place in places + if place["name"] == "High Street" and place["lat"] > 51.5 + ) + assert nw1_high["lat"] == (51.531 + 51.529) / 2 + assert nw1_high["lon"] == (-0.141 + -0.139) / 2 + # The same-named CR0 road stays separate. + assert any( + place["name"] == "High Street" and place["lat"] < 51.4 for place in places + ) + + +def test_pois_to_places_keeps_high_value_named_pois_only(): + pois = pl.DataFrame( + { + "name": ["Hyde Park", "St Thomas' Hospital", "Joe's Cafe", "Hyde Park", ""], + "category": [ + "leisure/park", + "amenity/hospital", + "amenity/cafe", # everyday amenity → excluded + "leisure/park", # duplicate → excluded + "leisure/park", # empty name → excluded + ], + "lat": [51.507, 51.498, 51.5, 51.507, 51.6], + "lng": [-0.165, -0.118, -0.1, -0.165, -0.2], + } + ) + + places = _pois_to_places(pois) + + assert [(place["name"], place["place_type"]) for place in places] == [ + ("Hyde Park", "park"), + ("St Thomas' Hospital", "hospital"), + ] + assert all(place["travel_destination"] is False for place in places) + + +def test_pois_to_places_keeps_distinct_same_named_pois(): + # Two genuinely distinct POIs sharing a name, far apart (London vs Bristol). + pois = pl.DataFrame( + { + "name": ["Victoria Park", "Victoria Park"], + "category": ["leisure/park", "leisure/park"], + "lat": [51.54, 51.46], + "lng": [-0.04, -2.60], + } + ) + + places = _pois_to_places(pois) + + assert len(places) == 2 + assert {(place["lat"], place["lon"]) for place in places} == { + (51.54, -0.04), + (51.46, -2.60), + } + + +def test_pois_to_places_still_dedupes_colocated(): + # The same physical POI mapped twice a few metres apart collapses to one. + pois = pl.DataFrame( + { + "name": ["Victoria Park", "Victoria Park"], + "category": ["leisure/park", "leisure/park"], + "lat": [51.5400, 51.5401], + "lng": [-0.0400, -0.0399], + } + ) + + places = _pois_to_places(pois) + + assert len(places) == 1 + + def test_display_city_from_tags_uses_explicit_london_context(): assert _display_city_from_tags({"is_in": "Croydon, London, UK"}) == "London" assert _display_city_from_tags({"is_in": "Croydon, Cambridgeshire, UK"}) is None @@ -216,3 +328,52 @@ def test_assign_london_display_city_uses_nearest_active_postcode_admin(tmp_path) assert assigned == 2 assert [place["display_city"] for place in places] == ["London", "London", None] + + +def test_no_grid_reference_sentinel_is_excluded_from_coordinate_trees(tmp_path): + # ONS NSPL stores postcodes with no grid reference at the Null-Island sentinel + # lat=99.999999, long=0.0, whose paired BNG coords collapse to the (0, 0) origin. + # Such an active postcode must never enter the nearest-neighbour indexes. + sentinel = { + "pcds": "ZZ99 9ZZ", + "lat": 99.999999, + "long": 0.0, + "doterm": None, + "ctry25cd": "E92000001", + "east1m": 0, + "north1m": 0, + "rgn25cd": "E12000007", + "lad25cd": "E09000008", + "cty25cd": "E13000002", + } + croydon_easting, croydon_northing = WGS84_TO_BNG.transform(-0.101793, 51.371273) + real = { + "pcds": "CR0 1SZ", + "lat": 51.371273, + "long": -0.101793, + "doterm": None, + "ctry25cd": "E92000001", + "east1m": int(round(croydon_easting)), + "north1m": int(round(croydon_northing)), + "rgn25cd": "E12000007", + "lad25cd": "E09000008", + "cty25cd": "E13000002", + } + postcodes = tmp_path / "postcodes.parquet" + pl.DataFrame([sentinel, real]).write_parquet(postcodes) + + # lat/long outcode tree: only the real postcode survives, so a London-area query + # cannot be tagged with the sentinel's (empty) outcode. + tree, outcodes = _outcode_tree(postcodes) + assert tree.n == 1 + assert outcodes == ["CR0"] + _, idx = tree.query([[51.371273, -0.101793]]) + assert outcodes[idx[0]] == "CR0" + + # BNG London tree: only the real postcode survives, so the (0, 0) origin can never + # be the nearest neighbour of a real place. + bng_tree, london_flags = _london_postcode_tree(postcodes) + assert bng_tree.n == 1 + assert london_flags.tolist() == [True] + _, bng_idx = bng_tree.query([[croydon_easting, croydon_northing]]) + assert bng_idx[0] == 0 diff --git a/pipeline/download/test_satellite_highres.py b/pipeline/download/test_satellite_highres.py new file mode 100644 index 0000000..dc84fa3 --- /dev/null +++ b/pipeline/download/test_satellite_highres.py @@ -0,0 +1,97 @@ +from pipeline.download import satellite_highres +from pipeline.download.satellite_highres import ( + VapTile, + parse_search_results, + select_best_rgb_tiles, +) + + +def _result(product: str, year: str, resolution: str, tile: str) -> dict: + """One search-API record in the real response shape.""" + return { + "product": {"id": product, "label": product}, + "year": {"id": year, "label": year}, + "resolution": {"id": resolution, "label": f"{resolution}m"}, + "tile": {"id": tile, "label": tile}, + "label": f"{product}-{year}-{resolution}m-{tile}", + "uri": ( + "https://environment.data.gov.uk/tiles/collections/survey/" + f"{product}/{year}/{resolution}/{tile}" + ), + } + + +# Mirrors a real Greater-London response: RGB at 0.4m (2008) and 0.1m (2011), +# plus Night Time and LIDAR products that must be ignored. +SAMPLE_PAYLOAD = { + "count": 6, + "results": [ + _result("vertical_aerial_photography_tiles_rgb", "2008", "0.4", "TQ2575"), + _result("vertical_aerial_photography_tiles_night_time", "2012", "0.2", "TQ2575"), + _result("lidar_composite_dtm", "2022", "1", "TQ2575"), + # TQ3080 has two RGB captures: a finer-but-older and a coarser-but-newer. + _result("vertical_aerial_photography_tiles_rgb", "2008", "0.1", "TQ3080"), + _result("vertical_aerial_photography_tiles_rgb", "2011", "0.25", "TQ3080"), + _result("vertical_aerial_photography_tiles_irrgb", "2012", "0.5", "TQ3080"), + ], +} + + +def test_parse_search_results_skips_malformed_records() -> None: + payload = { + "results": [ + _result("vertical_aerial_photography_tiles_rgb", "2008", "0.4", "TQ2575"), + {"product": {"id": "broken"}}, # missing year/resolution/tile/uri + ] + } + tiles = parse_search_results(payload) + assert len(tiles) == 1 + assert tiles[0] == VapTile( + product_id="vertical_aerial_photography_tiles_rgb", + year=2008, + resolution_m=0.4, + os_tile_id="TQ2575", + uri="https://environment.data.gov.uk/tiles/collections/survey/" + "vertical_aerial_photography_tiles_rgb/2008/0.4/TQ2575", + label="vertical_aerial_photography_tiles_rgb-2008-0.4m-TQ2575", + ) + + +def test_select_best_rgb_filters_non_rgb_products() -> None: + selected = select_best_rgb_tiles(parse_search_results(SAMPLE_PAYLOAD)) + assert {tile.product_id for tile in selected} == { + satellite_highres.VAP_RGB_PRODUCT + } + + +def test_select_best_rgb_one_tile_per_os_square() -> None: + selected = select_best_rgb_tiles(parse_search_results(SAMPLE_PAYLOAD)) + assert sorted(tile.os_tile_id for tile in selected) == ["TQ2575", "TQ3080"] + + +def test_select_best_rgb_prefers_finest_resolution_then_latest_year() -> None: + selected = { + tile.os_tile_id: tile + for tile in select_best_rgb_tiles(parse_search_results(SAMPLE_PAYLOAD)) + } + # TQ2575: only one RGB capture. + assert selected["TQ2575"].resolution_m == 0.4 + # TQ3080: finest resolution (0.1m) wins even though it is the older survey. + assert selected["TQ3080"].resolution_m == 0.1 + assert selected["TQ3080"].year == 2008 + + +def test_select_best_rgb_breaks_resolution_ties_by_year() -> None: + tiles = [ + VapTile(satellite_highres.VAP_RGB_PRODUCT, 2009, 0.25, "TQ0101", "u", "a"), + VapTile(satellite_highres.VAP_RGB_PRODUCT, 2018, 0.25, "TQ0101", "u", "b"), + VapTile(satellite_highres.VAP_RGB_PRODUCT, 2015, 0.25, "TQ0101", "u", "c"), + ] + selected = select_best_rgb_tiles(tiles) + assert len(selected) == 1 + assert selected[0].year == 2018 + + +def test_select_best_rgb_empty_when_no_rgb() -> None: + payload = {"results": [_result("lidar_composite_dtm", "2022", "1", "TQ2575")]} + assert select_best_rgb_tiles(parse_search_results(payload)) == [] diff --git a/pipeline/download/test_transit_network.py b/pipeline/download/test_transit_network.py new file mode 100644 index 0000000..c50e654 --- /dev/null +++ b/pipeline/download/test_transit_network.py @@ -0,0 +1,79 @@ +"""Tests for transit_network GTFS processing.""" + +import zipfile +from pathlib import Path + +import pytest + +from pipeline.download.transit_network import convert_high_freq_to_frequency_based + + +def _write_gtfs(path: Path, *, stop_times: str) -> None: + """Write a minimal GTFS zip with one metro route and several trips.""" + routes = "route_id,route_type\nR1,1\n" + trips = "trip_id,route_id,direction_id,service_id\n" + "".join( + f"T{i},R1,0,S1\n" for i in range(1, 7) + ) + with zipfile.ZipFile(path, "w") as z: + z.writestr("routes.txt", routes) + z.writestr("trips.txt", trips) + z.writestr("stop_times.txt", stop_times) + + +def _one_based_stop_times() -> str: + """Six trips, 1-based stop_sequence (1,2,...), 5-minute headway.""" + header = "trip_id,stop_sequence,departure_time,stop_id\n" + rows = [] + # First departures 06:00, 06:05, ... (300s = 5 min headway, well under 15 min) + for i in range(6): + trip = f"T{i + 1}" + first_dep = 6 * 3600 + i * 300 + h, m = divmod(first_dep, 3600) + m, s = divmod(m, 60) + # First stop has stop_sequence 1 (NOT 0); second stop sequence 2. + rows.append(f"{trip},1,{h:02d}:{m:02d}:{s:02d},STOP_A\n") + later = first_dep + 120 + h2, m2 = divmod(later, 3600) + m2, s2 = divmod(m2, 60) + rows.append(f"{trip},2,{h2:02d}:{m2:02d}:{s2:02d},STOP_B\n") + return header + "".join(rows) + + +def test_one_based_stop_sequence_is_converted(tmp_path: Path) -> None: + """First stop selection must use the minimum stop_sequence, not literal "0". + + With 1-based stop_sequence the old code (keyed on stop_sequence == "0") found + zero first stops and produced an empty frequencies.txt. The fix selects the + minimum stop_sequence per trip, so the high-frequency group is converted. + """ + src = tmp_path / "in.zip" + dst = tmp_path / "out.zip" + _write_gtfs(src, stop_times=_one_based_stop_times()) + + convert_high_freq_to_frequency_based(src, dst) + + with zipfile.ZipFile(dst, "r") as z: + freq = z.read("frequencies.txt").decode("utf-8") + + freq_rows = [r for r in freq.splitlines()[1:] if r.strip()] + # The single high-frequency group must produce exactly one frequency entry. + assert len(freq_rows) == 1, freq + trip_id, start_time, end_time, headway_secs, _exact = freq_rows[0].split(",") + # Template trip is the earliest departure (T1 at 06:00) starting at first stop. + assert start_time == "06:00:00" + # Median headway of 300s rounds to a 300s headway entry. + assert headway_secs == "300" + + +def test_raises_when_no_first_stops_found(tmp_path: Path) -> None: + """A non-empty target trip set with unparseable stop_sequence is loud, not silent.""" + src = tmp_path / "in.zip" + dst = tmp_path / "out.zip" + bad = ( + "trip_id,stop_sequence,departure_time,stop_id\n" + "T1,not_a_number,06:00:00,STOP_A\n" + ) + _write_gtfs(src, stop_times=bad) + + with pytest.raises(RuntimeError, match="no first stops"): + convert_high_freq_to_frequency_based(src, dst) diff --git a/pipeline/download/transit_network.py b/pipeline/download/transit_network.py index a7d384b..bbb576f 100644 --- a/pipeline/download/transit_network.py +++ b/pipeline/download/transit_network.py @@ -307,9 +307,14 @@ def convert_high_freq_to_frequency_based( print(f" Found {len(trip_group_key)} trips on target routes") - # Step 3: Get first departure time and first stop for each target trip + # Step 3: Get first departure time and first stop for each target trip. + # GTFS only requires stop_sequence to be strictly increasing per trip; it + # is NOT required to start at 0 (1-based numbering is common, and BODS is + # consumed raw here without renumbering). So pick the row with the minimum + # stop_sequence per trip rather than keying off the literal "0". trip_first_dep: dict[str, int] = {} trip_first_stop: dict[str, str] = {} + trip_min_seq: dict[str, int] = {} with zin.open("stop_times.txt") as f: cols = _parse_csv_line(f.readline()) trip_id_idx = cols.index("trip_id") @@ -323,11 +328,25 @@ def convert_high_freq_to_frequency_based( trip_id = parts[trip_id_idx].strip('"') if trip_id not in trip_group_key: continue - if parts[seq_idx].strip('"') == "0": - dep_secs = _parse_gtfs_time(parts[dep_idx]) - if dep_secs is not None: - trip_first_dep[trip_id] = dep_secs - trip_first_stop[trip_id] = parts[stop_id_idx].strip('"') + try: + seq = int(parts[seq_idx].strip('"')) + except ValueError: + continue + if trip_id in trip_min_seq and seq >= trip_min_seq[trip_id]: + continue + dep_secs = _parse_gtfs_time(parts[dep_idx]) + if dep_secs is None: + continue + trip_min_seq[trip_id] = seq + trip_first_dep[trip_id] = dep_secs + trip_first_stop[trip_id] = parts[stop_id_idx].strip('"') + + if trip_group_key and not trip_first_dep: + raise RuntimeError( + "convert_high_freq_to_frequency_based found no first stops for " + f"{len(trip_group_key)} target trips; stop_times.txt may be malformed " + "or stop_sequence parsing failed" + ) # Step 4: Group trips by (route, direction, service, first_stop) and compute headways groups: dict[tuple[str, ...], list[tuple[str, int]]] = defaultdict(list) diff --git a/pipeline/download/uprn_lookup.py b/pipeline/download/uprn_lookup.py index 3f2c804..dcedc0f 100644 --- a/pipeline/download/uprn_lookup.py +++ b/pipeline/download/uprn_lookup.py @@ -14,7 +14,7 @@ from pathlib import Path import polars as pl from pipeline.local_temp import local_tmp_dir -from pipeline.utils import download, extract_zip +from pipeline.utils import code_col_overrides, download, extract_zip URL = "https://www.arcgis.com/sharing/rest/content/items/4e0b4b3fbc2540caae27e7be532e61be/data" @@ -34,16 +34,16 @@ def find_csvs(extract_path: Path) -> list[Path]: def convert_to_parquet(csv_paths: list[Path], parquet_path: Path) -> None: # Some regional files infer different types for the same column (e.g. - # ruc21ind is String in most but Int64 in YH). Read all code columns as - # String to avoid schema mismatches. - CODE_COLS = { - "ruc21ind": pl.String, - "oac21ind": pl.String, - "imd19ind": pl.String, - } + # ruc21ind is String in most but Int64 in YH), and string codes like "UN1" + # appear deep in the data. Read all classification-index code columns as + # String to avoid schema mismatches. NSUL renames the year suffixes each + # release and polars silently ignores overrides for missing columns, so + # match on the suffix-free stem (from the header) rather than hard-coding. + names = pl.scan_csv(csv_paths[0]).collect_schema().names() + code_cols = code_col_overrides(names) df = pl.concat( [ - pl.scan_csv(p, try_parse_dates=True, schema_overrides=CODE_COLS) + pl.scan_csv(p, try_parse_dates=True, schema_overrides=code_cols) for p in csv_paths ] ) diff --git a/pipeline/test_validate_outputs.py b/pipeline/test_validate_outputs.py index e61b9f9..5b93e2a 100644 --- a/pipeline/test_validate_outputs.py +++ b/pipeline/test_validate_outputs.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import zipfile import polars as pl @@ -7,6 +8,32 @@ import polars as pl from pipeline.validate_outputs import main +def polygon(offset=0): + x = float(offset) + return { + "type": "Polygon", + "coordinates": [ + [(x, 0.0), (x + 0.001, 0.0), (x + 0.001, 0.001), (x, 0.001), (x, 0.0)] + ], + } + + +def write_boundary(path, postcodes, geometries=None): + units = path / "units" + units.mkdir(parents=True) + features = [ + { + "type": "Feature", + "properties": {"postcodes": postcode}, + "geometry": (geometries[index] if geometries else polygon(index)), + } + for index, postcode in enumerate(postcodes) + ] + (units / "AA1.geojson").write_text( + json.dumps({"type": "FeatureCollection", "features": features}) + ) + + def test_validates_parquet_file_and_zip(tmp_path, monkeypatch): parquet_path = tmp_path / "data.parquet" file_path = tmp_path / "plain.txt" @@ -59,3 +86,310 @@ def test_rejects_missing_and_empty_outputs(tmp_path, monkeypatch, capsys): assert "empty file" in stderr assert "missing" in stderr assert "no files matched" in stderr + + +def test_validates_postcode_boundary_matches(tmp_path, monkeypatch): + postcodes_path = tmp_path / "postcodes.parquet" + boundaries_path = tmp_path / "postcode_boundaries" + pl.DataFrame({"postcode": ["AA1 1AA", "AA1 1AB"]}).write_parquet(postcodes_path) + write_boundary(boundaries_path, ["AA1 1AA", "AA1 1AB"]) + + monkeypatch.setattr( + "sys.argv", + [ + "validate_outputs", + "--postcode-boundary-match", + f"{postcodes_path}::{boundaries_path}", + ], + ) + + assert main() == 0 + + +def test_rejects_postcode_boundary_mismatch(tmp_path, monkeypatch, capsys): + postcodes_path = tmp_path / "postcodes.parquet" + boundaries_path = tmp_path / "postcode_boundaries" + pl.DataFrame({"postcode": ["AA1 1AA", "AA1 1AB"]}).write_parquet(postcodes_path) + write_boundary(boundaries_path, ["AA1 1AA", "AA1 1AC"]) + + monkeypatch.setattr( + "sys.argv", + [ + "validate_outputs", + "--postcode-boundary-match", + f"{postcodes_path}::{boundaries_path}", + ], + ) + + assert main() == 1 + stderr = capsys.readouterr().err + assert "missing boundaries" in stderr + assert "boundary postcodes are absent" in stderr + + +def test_rejects_invalid_postcode_boundary_features(tmp_path, monkeypatch, capsys): + postcodes_path = tmp_path / "postcodes.parquet" + boundaries_path = tmp_path / "postcode_boundaries" + units = boundaries_path / "units" + units.mkdir(parents=True) + pl.DataFrame({"postcode": ["AA1 1AA"]}).write_parquet(postcodes_path) + bowtie = { + "type": "Polygon", + "coordinates": [[(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)]], + } + features = [ + { + "type": "Feature", + "properties": {"postcodes": "AA1 1AA"}, + "geometry": polygon(), + }, + { + "type": "Feature", + "properties": {"postcodes": "AA1 1AA"}, + "geometry": polygon(1), + }, + {"type": "Feature", "properties": {}, "geometry": polygon(2)}, + {"type": "Feature", "properties": {"postcodes": "AA1 1AB"}, "geometry": None}, + {"type": "Feature", "properties": {"postcodes": "AA1 1AC"}, "geometry": bowtie}, + ] + (units / "AA1.geojson").write_text( + json.dumps({"type": "FeatureCollection", "features": features}) + ) + + monkeypatch.setattr( + "sys.argv", + [ + "validate_outputs", + "--postcode-boundary-match", + f"{postcodes_path}::{boundaries_path}", + ], + ) + + assert main() == 1 + stderr = capsys.readouterr().err + assert "duplicate boundary postcode features" in stderr + assert "missing properties.postcodes" in stderr + assert "missing or empty geometry" in stderr + assert "invalid boundary geometries" in stderr + + +def test_validates_active_english_arcgis_boundary_matches(tmp_path, monkeypatch): + arcgis_path = tmp_path / "arcgis.parquet" + boundaries_path = tmp_path / "postcode_boundaries" + pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB", "CF1 1AA"], + "ctry25cd": ["E92000001", "E92000001", "W92000004"], + "doterm": [None, "2020-01-01", None], + } + ).write_parquet(arcgis_path) + write_boundary(boundaries_path, ["AA1 1AA"]) + + monkeypatch.setattr( + "sys.argv", + [ + "validate_outputs", + "--active-postcode-boundary-match", + f"{arcgis_path}::{boundaries_path}", + ], + ) + + assert main() == 0 + + +def test_rejects_active_english_arcgis_boundary_mismatch(tmp_path, monkeypatch, capsys): + arcgis_path = tmp_path / "arcgis.parquet" + boundaries_path = tmp_path / "postcode_boundaries" + pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB", "CF1 1AA"], + "ctry25cd": ["E92000001", "E92000001", "W92000004"], + "doterm": [None, None, None], + } + ).write_parquet(arcgis_path) + write_boundary(boundaries_path, ["AA1 1AA", "CF1 1AA"]) + + monkeypatch.setattr( + "sys.argv", + [ + "validate_outputs", + "--active-postcode-boundary-match", + f"{arcgis_path}::{boundaries_path}", + ], + ) + + assert main() == 1 + stderr = capsys.readouterr().err + assert "active English postcodes" in stderr + assert "not active English postcodes" in stderr + + +def _write_postcode_features(path, rows): + pl.DataFrame(rows).write_parquet(path) + + +def test_validates_postcode_features_valid(tmp_path, monkeypatch): + path = tmp_path / "postcode.parquet" + _write_postcode_features( + path, + { + "Postcode": ["AA1 1AA", "BB1 1BB"], + "lat": [51.5, 53.4], + "lon": [-0.1, -2.2], + "ctry25cd": ["E92000001", "E92000001"], + "% White": [80.0, 55.0], + }, + ) + monkeypatch.setattr("sys.argv", ["validate", "--postcode-features", str(path)]) + assert main() == 0 + + +def test_rejects_contaminated_postcode_features(tmp_path, monkeypatch, capsys): + path = tmp_path / "postcode.parquet" + _write_postcode_features( + path, + { + "Postcode": ["AA1 1AA", "AA1 1AA", "CF10 1AA"], # duplicate AA1 1AA + "lat": [51.5, 51.5, None], # Welsh row has null coord + "lon": [-0.1, -0.1, None], + "ctry25cd": ["E92000001", "E92000001", "W92000004"], + "% White": [80.0, 150.0, 90.0], # 150 out of [0,100] + }, + ) + monkeypatch.setattr("sys.argv", ["validate", "--postcode-features", str(path)]) + assert main() == 1 + err = capsys.readouterr().err + assert "not unique" in err + assert "E92000001" in err # country contamination + assert "out-of-England" in err or "lat/lon" in err + assert "[0, 100]" in err + + +def test_postcode_universe_rejects_missing(tmp_path, monkeypatch, capsys): + arcgis_path = tmp_path / "arcgis.parquet" + postcodes_path = tmp_path / "postcode.parquet" + pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB", "AA1 1AC"], + "ctry25cd": ["E92000001", "E92000001", "E92000001"], + "doterm": [None, None, None], + } + ).write_parquet(arcgis_path) + # Only 1 of the 3 active English postcodes is present, all otherwise valid. + _write_postcode_features( + postcodes_path, + { + "Postcode": ["AA1 1AA"], + "lat": [51.5], + "lon": [-0.1], + "ctry25cd": ["E92000001"], + "% White": [80.0], + }, + ) + monkeypatch.setattr( + "sys.argv", + [ + "validate", + "--postcode-universe", + f"{arcgis_path}::{postcodes_path}", + ], + ) + assert main() == 1 + err = capsys.readouterr().err + assert "missing" in err + assert "2" in err # 2 of the 3 active postcodes are absent + + +def test_postcode_universe_accepts_exact_match(tmp_path, monkeypatch): + arcgis_path = tmp_path / "arcgis.parquet" + postcodes_path = tmp_path / "postcode.parquet" + pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB"], + "ctry25cd": ["E92000001", "E92000001"], + "doterm": [None, None], + } + ).write_parquet(arcgis_path) + _write_postcode_features( + postcodes_path, + { + "Postcode": ["AA1 1AA", "AA1 1AB"], + "lat": [51.5, 53.4], + "lon": [-0.1, -2.2], + "ctry25cd": ["E92000001", "E92000001"], + "% White": [80.0, 55.0], + }, + ) + monkeypatch.setattr( + "sys.argv", + [ + "validate", + "--postcode-universe", + f"{arcgis_path}::{postcodes_path}", + ], + ) + assert main() == 0 + + +def test_validates_properties_subset(tmp_path, monkeypatch): + postcode = tmp_path / "postcode.parquet" + properties = tmp_path / "properties.parquet" + pl.DataFrame({"Postcode": ["AA1 1AA", "BB1 1BB"]}).write_parquet(postcode) + pl.DataFrame( + {"Postcode": ["AA1 1AA"], "Last known price": [250_000]} + ).write_parquet(properties) + monkeypatch.setattr( + "sys.argv", + ["validate", "--properties-subset", f"{properties}::{postcode}"], + ) + assert main() == 0 + + +def test_rejects_orphan_properties(tmp_path, monkeypatch, capsys): + postcode = tmp_path / "postcode.parquet" + properties = tmp_path / "properties.parquet" + pl.DataFrame({"Postcode": ["AA1 1AA"]}).write_parquet(postcode) + pl.DataFrame( + {"Postcode": ["CC1 1CC"], "Last known price": [-5]} # orphan + negative price + ).write_parquet(properties) + monkeypatch.setattr( + "sys.argv", + ["validate", "--properties-subset", f"{properties}::{postcode}"], + ) + assert main() == 1 + err = capsys.readouterr().err + assert "absent from" in err + assert "non-positive" in err + + +def test_validates_price_index_allows_zero_n_pairs(tmp_path, monkeypatch): + path = tmp_path / "price_index.parquet" + pl.DataFrame( + { + "sector": ["A1 1", "A1 1", "B2 2"], + "type_group": ["All", "Detached", "All"], + "year": [2024, 2024, 2024], + "log_index": [0.5, 0.4, 0.0], + "n_pairs": [100, 0, 0], # zero n_pairs is a legitimate fallback + } + ).write_parquet(path) + monkeypatch.setattr("sys.argv", ["validate", "--price-index", str(path)]) + assert main() == 0 + + +def test_rejects_price_index_nonfinite_and_duplicate(tmp_path, monkeypatch, capsys): + path = tmp_path / "price_index.parquet" + pl.DataFrame( + { + "sector": ["A1 1", "A1 1"], + "type_group": ["All", "All"], # duplicate (sector, type_group, year) + "year": [2024, 2024], + "log_index": [float("inf"), 0.3], # non-finite + "n_pairs": [10, 10], + } + ).write_parquet(path) + monkeypatch.setattr("sys.argv", ["validate", "--price-index", str(path)]) + assert main() == 1 + err = capsys.readouterr().err + assert "non-finite" in err + assert "not unique" in err diff --git a/pipeline/transform/crime.py b/pipeline/transform/crime.py index 6cc5784..c62db81 100644 --- a/pipeline/transform/crime.py +++ b/pipeline/transform/crime.py @@ -28,6 +28,17 @@ MINOR_CRIME_TYPES = ( "Other crime", ) +# Legacy police.uk crime-type names (pre-2014 taxonomy) mapped to their closest +# current equivalent. Without this, ~1.9M incidents from 2010-2013 ("Violent +# crime", "Public disorder and weapons") are unrecognised and silently dropped, +# which understates pre-2013 serious crime and creates an artificial 2012->2013 +# step in the by-year series. Applied with `.replace` (not `.replace_strict`) so +# unmapped current types pass through unchanged. +LEGACY_CRIME_TYPE_ALIASES = { + "Violent crime": "Violence and sexual offences", + "Public disorder and weapons": "Public order", +} + def find_street_crime_csvs(crime_dir: Path) -> tuple[list[Path], int]: csvs = sorted(crime_dir.rglob("*.csv")) @@ -84,11 +95,14 @@ def transform_crime( f"({valid_months[0]} to {valid_months[-1]})" ) - # Count monthly incidents, then annualise over every valid month in the dataset. - # `_weight` (≤1) comes from the LSOA 2011→2021 lookup: 2011 LSOAs that split - # into N 2021 LSOAs contribute 1/N of their count to each child, since we - # don't know which child a given incident actually belonged to. - yearly_counts = ( + # Annualise each year separately (count_in_year * 12 / months_in_year), then + # take the simple mean of those per-year rates over the years each type is + # present. This makes the headline equal the average of the by-year chart bars + # (_write_crime_by_year) instead of a month-weighted pooled rate, mirroring + # crime_spatial._write_avg_yr. `_weight` (≤1) comes from the LSOA 2011→2021 + # lookup: 2011 LSOAs that split into N 2021 LSOAs contribute 1/N of their count + # to each child, since we don't know which child an incident actually belonged to. + filtered = ( df.filter( valid_month_expr & pl.col("LSOA code").is_not_null() @@ -96,14 +110,31 @@ def transform_crime( & pl.col("Crime type").is_not_null() & (pl.col("Crime type") != "") ) - .group_by("LSOA code", "Month", "Crime type") - .agg((pl.col("_weight").first() * pl.len()).alias("count")) - .group_by("LSOA code", "Crime type") - .agg( - (pl.col("count").sum() / pl.lit(valid_month_count) * 12) - .round(1) - .alias("yearly_avg") + .with_columns( + pl.col("Month").str.slice(0, 4).cast(pl.Int32).alias("year"), + pl.col("Crime type").replace(LEGACY_CRIME_TYPE_ALIASES), ) + ) + + # Months observed *anywhere* in the dataset for each year (annualisation + # denominator), matching the by-year output's per-year scaling. + months_per_year = filtered.group_by("year").agg( + pl.col("Month").n_unique().alias("months_in_year") + ) + + yearly_counts = ( + filtered.group_by("LSOA code", "year", "Crime type", "Month") + .agg((pl.col("_weight").first() * pl.len()).alias("count")) + .group_by("LSOA code", "year", "Crime type") + .agg(pl.col("count").sum().alias("count")) + .join(months_per_year, on="year") + .with_columns( + (pl.col("count") * 12.0 / pl.col("months_in_year")).alias("per_year") + ) + # Mean of the per-year annualised rates over the years the type is present + # (only years with rows are grouped here, so this is the correct x-span). + .group_by("LSOA code", "Crime type") + .agg(pl.col("per_year").mean().round(1).alias("yearly_avg")) .collect(engine="streaming") ) if yearly_counts.is_empty(): @@ -147,7 +178,10 @@ def _write_crime_by_year( & (pl.col("LSOA code") != "") & pl.col("Crime type").is_not_null() & (pl.col("Crime type") != "") - ).with_columns(pl.col("Month").str.slice(0, 4).cast(pl.Int32).alias("year")) + ).with_columns( + pl.col("Month").str.slice(0, 4).cast(pl.Int32).alias("year"), + pl.col("Crime type").replace(LEGACY_CRIME_TYPE_ALIASES), + ) # Months observed *anywhere* in the dataset for each year (annualisation denominator). # Using crime-type-specific months would over-scale years where a rare type appears diff --git a/pipeline/transform/crime_hotspot_tiles.py b/pipeline/transform/crime_hotspot_tiles.py index 36aebce..45baa0c 100644 --- a/pipeline/transform/crime_hotspot_tiles.py +++ b/pipeline/transform/crime_hotspot_tiles.py @@ -17,7 +17,7 @@ from pathlib import Path import polars as pl from pipeline.local_temp import local_tmp_dir -from pipeline.transform.crime import find_street_crime_csvs +from pipeline.transform.crime import LEGACY_CRIME_TYPE_ALIASES, find_street_crime_csvs def _latest_months(crime_dir: Path, month_count: int) -> list[str]: @@ -46,8 +46,21 @@ def _require_tippecanoe() -> str: return executable -def _write_geojsonseq(csvs: list[Path], output_path: Path) -> int: - df = ( +def _write_geojsonseq(csvs: list[Path], output_path: Path) -> tuple[int, int]: + """Write one weighted GeoJSON point per distinct (anchor, month, type). + + Returns ``(feature_count, incident_count)``. police.uk snaps every incident + to a shared "map point" anchor, so many incidents land on the exact same + coordinate. Collapsing them into one feature carrying ``count`` (the number + of incidents) keeps the per-crime-type and per-month filters intact while + turning each hotspot into a single high-weight point. That matters because + tippecanoe's ``--drop-densest-as-needed`` thins *feature density*, not + weight: with one feature per row the busiest streets were silently deleted; + with one weighted feature per anchor those hotspots survive and the dropped + detail is only redundant duplicate points. The heatmap reads ``count`` as + its weight. + """ + grouped = ( pl.scan_csv( csvs, schema_overrides={ @@ -67,11 +80,19 @@ def _write_geojsonseq(csvs: list[Path], output_path: Path) -> int: .drop_nulls(["lon", "lat"]) .filter(pl.col("lon").is_between(-9.5, 5.0)) .filter(pl.col("lat").is_between(49.0, 57.0)) + # Canonicalise any legacy pre-2014 type names so the heatmap's crime_type + # values always match the frontend's canonical filter list (a no-op for + # the recent months this overlay normally covers). + .with_columns(pl.col("crime_type").replace(LEGACY_CRIME_TYPE_ALIASES)) + .group_by("lon", "lat", "month", "crime_type") + .len() + .rename({"len": "count"}) .collect(engine="streaming") ) + incident_count = int(grouped["count"].sum()) with output_path.open("w") as file: - for row in df.iter_rows(named=True): + for row in grouped.iter_rows(named=True): feature = { "type": "Feature", "geometry": { @@ -79,15 +100,15 @@ def _write_geojsonseq(csvs: list[Path], output_path: Path) -> int: "coordinates": [row["lon"], row["lat"]], }, "properties": { - "count": 1, - "weight": 1, + "count": row["count"], + "weight": row["count"], "month": row["month"], "crime_type": row["crime_type"], }, } file.write(json.dumps(feature, separators=(",", ":")) + "\n") - return df.height + return grouped.height, incident_count def build_crime_hotspot_tiles( @@ -104,9 +125,10 @@ def build_crime_hotspot_tiles( with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as tmp: ndjson_path = Path(tmp) / "crime_hotspots.geojsonseq" - feature_count = _write_geojsonseq(csvs, ndjson_path) + feature_count, incident_count = _write_geojsonseq(csvs, ndjson_path) print( - f"Writing {feature_count:,} approximate crime heatmap points " + f"Writing {feature_count:,} weighted crime heatmap points " + f"({incident_count:,} incidents) " f"from {min(selected_months)} to {max(selected_months)}" ) diff --git a/pipeline/transform/crime_spatial.py b/pipeline/transform/crime_spatial.py new file mode 100644 index 0000000..4b54623 --- /dev/null +++ b/pipeline/transform/crime_spatial.py @@ -0,0 +1,475 @@ +"""Aggregate police.uk street crime to postcodes by spatial proximity. + +Instead of attributing each incident to its published LSOA code, this transform +counts the anonymised incident *points* that fall within ``buffer_m`` (default +100m) of each postcode's boundary polygon (the polygon buffered outward). A point +inside several overlapping buffers counts for each postcode -- the same +multiplicity the tree-density filter uses for features near more than one +postcode. The wide 100m buffer deliberately smooths police.uk's snap-to-grid +coordinates, which would otherwise make the count hypersensitive to which side of +a narrow line a shared "map point" anchor happened to land on. + +Counts are **area-normalised**: each postcode's count is divided by its buffered +catchment area and rescaled by the median catchment area, so the metric reflects +crime *density* rather than how much ground the buffer sweeps (a median-sized +catchment is left unchanged; a large rural postcode is no longer inflated simply +for covering more of the map). Normalising by the buffered area -- the region +that actually collects points -- rather than the raw polygon keeps tiny unit +postcodes from being over-inflated by the fixed buffer-ring floor. The headline +``"{type} (avg/yr)"`` is the simple mean of the per-year annualised counts, so it +equals the average of the by-year chart bars. + +Outputs mirror the old LSOA transform's shape but are keyed on ``postcode``: + +* ``crime_by_postcode.parquet`` -- ``postcode`` + ``"{type} (avg/yr)"`` columns. +* ``crime_by_postcode_by_year.parquet`` -- ``postcode`` + ``"{type} (by year)"`` + nested ``list[struct{year, count}]`` columns, with Serious/Minor rollups. + +Caveat: police.uk coordinates are snapped to a fixed set of anonymous "map +points", not true locations, and a share of rows have no coordinate at all +(dropped here). Spatial totals are therefore fuzzier than the old LSOA-tagged +counts -- by design, not a regression. +""" + +from __future__ import annotations + +import argparse +import re +import sys +from pathlib import Path + +import numpy as np +import polars as pl +import shapely +from pyproj import Transformer + +from pipeline.transform.crime import ( + LEGACY_CRIME_TYPE_ALIASES, + MINOR_CRIME_TYPES, + SERIOUS_CRIME_TYPES, + find_street_crime_csvs, +) +from pipeline.transform.postcode_boundaries.loader import load_postcode_polygons + +# Serious types first so column order is stable and self-documenting. +ALL_CRIME_TYPES: tuple[str, ...] = SERIOUS_CRIME_TYPES + MINOR_CRIME_TYPES + +DEFAULT_BUFFER_M = 100.0 +MONTH_DIR_RE = re.compile(r"^\d{4}-\d{2}$") + +# Generous GB bounds; points outside fall in no English postcode anyway, but +# filtering first keeps the WGS84->BNG transform out of its undefined region. +LON_BOUNDS = (-9.5, 2.5) +LAT_BOUNDS = (49.0, 61.5) + +# Read CSVs in chunks of files to bound peak memory while keeping the STRtree +# query vectorised over a useful number of points. +_CSV_BATCH = 64 + + +def _month_calendar(csvs: list[Path]) -> tuple[list[int], dict[int, int], int]: + """Derive annualisation denominators from the monthly directory names. + + Each police.uk file lives under ``{crime_dir}/{YYYY-MM}/...`` and holds that + month's incidents, so the set of month directories is the set of observed + months. Returns the sorted distinct years, months-observed-per-year, and the + total month count (the avg/yr denominator). + """ + months = sorted( + {path.parent.name for path in csvs if MONTH_DIR_RE.fullmatch(path.parent.name)} + ) + if not months: + raise ValueError("No valid YYYY-MM month directories found among crime CSVs") + + months_in_year: dict[int, int] = {} + for month in months: + year = int(month[:4]) + months_in_year[year] = months_in_year.get(year, 0) + 1 + + years = sorted(months_in_year) + return years, months_in_year, len(months) + + +def _build_tree( + polygons: np.ndarray, buffer_m: float +) -> tuple[np.ndarray, shapely.STRtree]: + """Buffer postcode polygons outward by ``buffer_m`` and index them. + + Buffer index == postcode index. Geometries that fail to buffer are replaced + with an empty polygon so the index stays aligned; they simply never match. + """ + buffers = shapely.buffer(polygons, buffer_m, quad_segs=8) + broken = shapely.is_missing(buffers) | ~shapely.is_valid(buffers) + if broken.any(): + print(f" {int(broken.sum()):,} postcode buffers unusable; left empty") + buffers[broken] = shapely.from_wkt("POLYGON EMPTY") + return buffers, shapely.STRtree(buffers) + + +def _accumulate_counts( + csvs: list[Path], + tree: shapely.STRtree, + type_to_idx: dict[str, int], + year_to_idx: dict[int, int], + transformer: Transformer, + counts: np.ndarray, +) -> None: + """Stream the crime CSVs, counting points-in-buffer per (postcode, type, year).""" + schema = { + "Longitude": pl.Float64, + "Latitude": pl.Float64, + "Month": pl.Utf8, + "Crime type": pl.Utf8, + } + years = list(year_to_idx) + total_points = 0 + total_matches = 0 + total_dropped = 0 + unknown_type_counts: dict[str, int] = {} + + for start in range(0, len(csvs), _CSV_BATCH): + batch = csvs[start : start + _CSV_BATCH] + frame = ( + pl.scan_csv( + batch, + schema_overrides=schema, + ignore_errors=True, + ) + .select("Longitude", "Latitude", "Month", "Crime type") + # strict=False: a single malformed Month drops only that row instead + # of aborting the whole build (a non-numeric year becomes null and is + # filtered out by the year membership check below). + .with_columns( + pl.col("Month").str.slice(0, 4).cast(pl.Int32, strict=False).alias("year") + ) + .filter( + pl.col("Longitude").is_not_null() + & pl.col("Latitude").is_not_null() + & pl.col("Longitude").is_between(*LON_BOUNDS) + & pl.col("Latitude").is_between(*LAT_BOUNDS) + & pl.col("Crime type").is_not_null() + & (pl.col("Crime type") != "") + & pl.col("year").is_in(years) + ) + # Canonicalise legacy pre-2014 crime-type names ("Violent crime", + # "Public disorder and weapons") to their current equivalents before + # indexing, so ~1.9M historical incidents are counted instead of + # dropped. `.replace` leaves current types unchanged. + .with_columns(pl.col("Crime type").replace(LEGACY_CRIME_TYPE_ALIASES)) + # Map crime types to indices with default=None so an unrecognised + # type yields a null index we can *report* rather than silently drop + # (the legacy LSOA path surfaced unknown types via its dynamic pivot). + .with_columns( + pl.col("Crime type") + .replace_strict(type_to_idx, default=None, return_dtype=pl.Int32) + .alias("tidx"), + pl.col("year") + .replace_strict(year_to_idx, return_dtype=pl.Int32) + .alias("yidx"), + ) + .select("Longitude", "Latitude", "Crime type", "tidx", "yidx") + .collect(engine="streaming") + ) + + if frame.height == 0: + continue + + unknown = frame.filter(pl.col("tidx").is_null()) + if unknown.height: + for name, cnt in unknown.group_by("Crime type").len().iter_rows(): + unknown_type_counts[name] = unknown_type_counts.get(name, 0) + cnt + frame = frame.filter(pl.col("tidx").is_not_null()) + if frame.height == 0: + continue + + lon = frame["Longitude"].to_numpy() + lat = frame["Latitude"].to_numpy() + tidx = frame["tidx"].to_numpy() + yidx = frame["yidx"].to_numpy() + + x, y = transformer.transform(lon, lat) + finite = np.isfinite(x) & np.isfinite(y) + total_dropped += int((~finite).sum()) + if not finite.any(): + continue + x, y, tidx, yidx = x[finite], y[finite], tidx[finite], yidx[finite] + total_points += x.size + + points = shapely.points(x, y) + point_index, postcode_index = tree.query(points, predicate="intersects") + if point_index.size: + np.add.at( + counts, + (postcode_index, tidx[point_index], yidx[point_index]), + 1, + ) + total_matches += point_index.size + + print( + f" files {start + len(batch):,}/{len(csvs):,}: " + f"{total_points:,} located points, {total_matches:,} postcode matches" + ) + + if total_dropped: + print(f"Dropped {total_dropped:,} points outside the BNG transform domain") + if unknown_type_counts: + total_unknown = sum(unknown_type_counts.values()) + listed = ", ".join( + f"{name!r} ({cnt:,})" + for name, cnt in sorted( + unknown_type_counts.items(), key=lambda kv: kv[1], reverse=True + ) + ) + print( + f"WARNING: dropped {total_unknown:,} incidents with crime types not in " + f"ALL_CRIME_TYPES (taxonomy is stale -- update SERIOUS/MINOR_CRIME_TYPES): " + f"{listed}", + file=sys.stderr, + ) + + +def _rollup_long( + long: pl.DataFrame, types: tuple[str, ...], rollup_name: str +) -> pl.DataFrame: + """Sum per-year annualised counts across ``types`` into a single rollup.""" + return ( + long.filter(pl.col("Crime type").is_in(list(types))) + .group_by("postcode", "year") + .agg(pl.col("count").sum().round(1).alias("count")) + .with_columns(pl.lit(rollup_name).alias("Crime type")) + .select("postcode", "Crime type", "year", "count") + ) + + +def _write_avg_yr( + postcodes: np.ndarray, + counts: np.ndarray, + years: list[int], + months_in_year: dict[int, int], + norm: np.ndarray, + output_path: Path, +) -> None: + """Write ``postcode`` + ``"{type} (avg/yr)"`` density-normalised averages. + + The headline figure is the **simple mean of the per-year annualised counts** + (each year scaled to a 12-month equivalent), so it equals the average of the + by-year chart bars instead of a month-weighted pooled rate. Each postcode's + value is then multiplied by ``norm`` (median_area / buffered catchment area) + so the metric is a density rather than a footprint-inflated raw count. + """ + months = np.array([months_in_year[year] for year in years], dtype=np.float64) + per_year = counts.astype(np.float64) * 12.0 / months[None, None, :] + # Average over the years *this postcode* actually has incidents of *this + # type* -- the same per-(postcode, type) x-span the by-year chart plots + # (server-rs/.../crime_by_year.rs), so the headline equals the mean of the + # by-year bars. Dividing by a global years-present count (years a type + # appeared anywhere in England) would deflate postcodes whose incidents + # cluster in only a few years of the ~13-year window. + years_present = np.clip((counts > 0).sum(axis=2), 1, None).astype(np.float64) + avg = per_year.sum(axis=2) / years_present # (n_postcodes, n_types) + avg = np.round(avg * norm[:, None], 1).astype(np.float32) + + data: dict[str, np.ndarray] = {"postcode": postcodes} + for type_idx, name in enumerate(ALL_CRIME_TYPES): + data[f"{name} (avg/yr)"] = avg[:, type_idx] + + # Serious/Minor rollup headlines, computed the SAME way as the by-year rollup + # bars (_write_by_year/_rollup_long): sum the rollup's types per year, then + # average over the years in which ANY of those types occurred. This keeps the + # headline equal to the mean of the "Serious/Minor crime (by year)" bars. + # Summing the per-type avg/yr values instead (as the merge previously did) + # divides each type by its OWN years-present and overstates the rollup when a + # postcode's serious/minor types occur in disjoint years. + for rollup_name, rollup_types in ( + ("Serious crime", SERIOUS_CRIME_TYPES), + ("Minor crime", MINOR_CRIME_TYPES), + ): + rollup_idx = [ALL_CRIME_TYPES.index(name) for name in rollup_types] + rollup_counts = counts[:, rollup_idx, :].sum(axis=1) # (n_postcodes, n_years) + rollup_per_year = per_year[:, rollup_idx, :].sum(axis=1) + rollup_years_present = np.clip( + (rollup_counts > 0).sum(axis=1), 1, None + ).astype(np.float64) + rollup_avg = rollup_per_year.sum(axis=1) / rollup_years_present + data[f"{rollup_name} (avg/yr)"] = np.round(rollup_avg * norm, 1).astype( + np.float32 + ) + + output_path.parent.mkdir(parents=True, exist_ok=True) + pl.DataFrame(data).write_parquet(output_path, compression="zstd") + print(f"Wrote postcode crime averages: {output_path}") + + +def _write_by_year( + postcodes: np.ndarray, + counts: np.ndarray, + years: list[int], + months_in_year: dict[int, int], + norm: np.ndarray, + output_path: Path, +) -> None: + """Write nested ``"{type} (by year)"`` series plus Serious/Minor rollups. + + Per-year counts are area-normalised by the same ``norm`` (median_area / + buffered catchment area) factor applied to the avg/yr headline, so the chart + bars and the headline figure remain mutually consistent. + """ + months = np.array([months_in_year[year] for year in years], dtype=np.float64) + annual = np.round( + counts.astype(np.float64) * 12.0 / months[None, None, :] * norm[:, None, None], + 1, + ) + + pc_i, ty_i, yr_i = np.nonzero(counts) + if pc_i.size == 0: + raise ValueError("No crime points matched any postcode buffer") + + type_names = np.array(ALL_CRIME_TYPES, dtype=object) + year_values = np.array(years, dtype=np.int32) + long = pl.DataFrame( + { + "postcode": postcodes[pc_i], + "Crime type": type_names[ty_i], + "year": year_values[yr_i], + "count": annual[pc_i, ty_i, yr_i].astype(np.float32), + } + ) + + serious = _rollup_long(long, SERIOUS_CRIME_TYPES, "Serious crime") + minor = _rollup_long(long, MINOR_CRIME_TYPES, "Minor crime") + combined = pl.concat([long, serious, minor]) + + by_type = ( + combined.sort("year") + .group_by("postcode", "Crime type") + .agg(pl.struct("year", "count").alias("series")) + ) + wide = by_type.pivot(on="Crime type", index="postcode", values="series") + type_cols = [c for c in wide.columns if c != "postcode"] + wide = wide.rename({col: f"{col} (by year)" for col in type_cols}) + + output_path.parent.mkdir(parents=True, exist_ok=True) + wide.write_parquet(output_path, compression="zstd") + print(f"Wrote postcode crime by-year series: {output_path} {wide.shape}") + + +def transform_crime_spatial( + crime_dir: Path, + boundaries_dir: Path, + output_path: Path, + by_year_output_path: Path, + buffer_m: float = DEFAULT_BUFFER_M, + max_postcodes: int | None = None, + max_files: int | None = None, +) -> None: + csvs, ignored_csv_count = find_street_crime_csvs(crime_dir) + if not csvs: + raise FileNotFoundError(f"No street crime CSV files found in {crime_dir}") + if max_files is not None: + csvs = csvs[:max_files] + + years, months_in_year, valid_month_count = _month_calendar(csvs) + print( + f"Found {len(csvs):,} street crime CSVs across {valid_month_count} months " + f"({years[0]}-{years[-1]})" + + (f" (ignored {ignored_csv_count} non-street CSVs)" if ignored_csv_count else "") + ) + + postcodes, polygons = load_postcode_polygons(boundaries_dir, max_postcodes) + + print(f"Buffering {len(postcodes):,} postcode polygons by {buffer_m:g}m...") + buffers, tree = _build_tree(polygons, buffer_m) + + # Area-normalisation factor (median_area / catchment_area): divides out the + # size of each postcode's catchment so the count measures crime density, not + # how much ground the buffer sweeps. We normalise by the *buffered* area -- + # the region that actually collects points -- rather than the raw polygon, so + # a tiny unit postcode isn't over-inflated by the fixed buffer-ring floor. + # Buffers are in EPSG:27700, so shapely.area is in m^2. + areas = shapely.area(buffers).astype(np.float64) + usable_area = np.isfinite(areas) & (areas > 0) + if not usable_area.any(): + raise ValueError("No postcode buffers have a positive area to normalise by") + median_area = float(np.median(areas[usable_area])) + norm = np.zeros(len(postcodes), dtype=np.float64) + norm[usable_area] = median_area / areas[usable_area] + print( + f"Area-normalising to median catchment area {median_area:,.0f} m^2 " + f"({int(usable_area.sum()):,}/{len(areas):,} postcodes have usable area)" + ) + + type_to_idx = {name: idx for idx, name in enumerate(ALL_CRIME_TYPES)} + year_to_idx = {year: idx for idx, year in enumerate(years)} + counts = np.zeros((len(postcodes), len(ALL_CRIME_TYPES), len(years)), dtype=np.int32) + + transformer = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) + _accumulate_counts(csvs, tree, type_to_idx, year_to_idx, transformer, counts) + + _write_avg_yr(postcodes, counts, years, months_in_year, norm, output_path) + _write_by_year(postcodes, counts, years, months_in_year, norm, by_year_output_path) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Count police.uk crime points within 50m of each postcode boundary" + ) + parser.add_argument( + "--input", + type=Path, + default=Path("property-data/crime"), + help="Directory containing police.uk street crime CSVs", + ) + parser.add_argument( + "--boundaries", + type=Path, + default=Path("property-data/postcode_boundaries/units"), + help="Directory of per-district postcode boundary GeoJSONs", + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Output parquet: postcode + '{type} (avg/yr)' columns", + ) + parser.add_argument( + "--output-by-year", + type=Path, + required=True, + help="Output parquet: postcode + nested '{type} (by year)' columns", + ) + parser.add_argument( + "--buffer-m", + type=float, + default=DEFAULT_BUFFER_M, + help="Outward buffer (metres) added to each postcode boundary", + ) + parser.add_argument( + "--max-postcodes", + type=int, + default=None, + help="Testing only: process the first N postcodes", + ) + parser.add_argument( + "--max-files", + type=int, + default=None, + help="Testing only: process the first N monthly CSV files", + ) + args = parser.parse_args() + + if args.buffer_m <= 0: + raise SystemExit("--buffer-m must be greater than zero") + + transform_crime_spatial( + crime_dir=args.input, + boundaries_dir=args.boundaries, + output_path=args.output, + by_year_output_path=args.output_by_year, + buffer_m=args.buffer_m, + max_postcodes=args.max_postcodes, + max_files=args.max_files, + ) + + +if __name__ == "__main__": + main() diff --git a/pipeline/transform/join_epc_pp.py b/pipeline/transform/join_epc_pp.py index 7ceccce..b20b8de 100644 --- a/pipeline/transform/join_epc_pp.py +++ b/pipeline/transform/join_epc_pp.py @@ -18,14 +18,53 @@ from ..utils import ( normalize_postcode_key, ) - pl.Config.set_tbl_cols(-1) RATING_RANK = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7} MIN_PRICE = 50_000 + +# Plausible construction-year range; band-derived years outside it (e.g. OCR +# noise like 1012 or 2202) are nulled rather than published. +MIN_BUILD_YEAR = 1700 +MAX_BUILD_YEAR = 2030 + + +def epc_band_to_year(band: pl.Expr) -> pl.Expr: + """Map an EPC construction age band to a single representative build year. + + EPC age bands are ranges (e.g. ``1950-1966``); we use the band MIDPOINT + (1958) rather than the lower bound, which previously biased every band-derived + year ~10-15 years too young. Open-ended lower bands (``before 1900``) are too + wide to pin to a year and return null. Single-year / ``... onwards`` bands use + that year. Already-numeric inputs (a year produced by an earlier call) pass + through unchanged. Years outside [MIN_BUILD_YEAR, MAX_BUILD_YEAR] are nulled. + """ + text = ( + band.cast(pl.Utf8) + .str.replace("England and Wales: ", "") + .str.replace(" onwards", "") + ) + low = text.str.extract(r"(\d{4})", 1).cast(pl.Int32, strict=False) + high = text.str.extract(r"(\d{4})\D+(\d{4})", 2).cast(pl.Int32, strict=False) + year = ( + pl.when(text.str.starts_with("before ")) + .then(None) + .when(high.is_not_null()) + .then(((low + high) / 2).round(0).cast(pl.Int32)) + .otherwise(low) + ) + return ( + pl.when((year >= MIN_BUILD_YEAR) & (year <= MAX_BUILD_YEAR)) + .then(year) + .otherwise(None) + .cast(pl.UInt16, strict=False) + ) + + EPC_SOURCE_COLUMNS = [ "address", "postcode", + "uprn", "current_energy_rating", "potential_energy_rating", "property_type", @@ -57,6 +96,8 @@ def _select_epc_columns(raw: pl.LazyFrame) -> pl.LazyFrame: raw.select( _clean_string("address").alias("epc_address"), _clean_string("postcode").str.to_uppercase().alias("epc_postcode"), + # UPRN keys an exact listing->EPC join downstream (~99% populated). + _clean_string("uprn").alias("uprn"), _clean_string("current_energy_rating") .str.to_uppercase() .alias("current_energy_rating"), @@ -65,7 +106,14 @@ def _select_epc_columns(raw: pl.LazyFrame) -> pl.LazyFrame: .alias("potential_energy_rating"), _clean_string("property_type").alias("epc_property_type"), _clean_string("built_form").alias("built_form"), - _clean_string("inspection_date").alias("inspection_date"), + # Parse to a real Date once (unparseable/blank -> null) so dedup can + # sort newest-first with nulls_last and _event_year can use dt.year(); + # a lexicographic string sort would let a null/garbled date win under + # Polars' default nulls-first descending order. EPC inspection dates + # are ISO (YYYY-MM-DD). + _clean_string("inspection_date") + .str.to_date(format="%Y-%m-%d", strict=False) + .alias("inspection_date"), _clean_number("total_floor_area", pl.Float64).alias("total_floor_area"), _clean_number("number_habitable_rooms", pl.Int16).alias( "number_habitable_rooms" @@ -206,9 +254,11 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat normalize_postcode_key(pl.col("epc_postcode")).alias("_epc_match_postcode"), ) - # Dedup fork: keep latest certificate per property (existing logic) + # Dedup fork: keep latest certificate per property. inspection_date is a typed + # Date (see _select_epc_columns); nulls_last keeps a real-dated cert ahead of a + # null/unparseable-dated one so the genuinely newest certificate is chosen. epc = ( - epc_base.sort("inspection_date", descending=True) + epc_base.sort("inspection_date", descending=True, nulls_last=True) .group_by("_epc_match_address", "_epc_match_postcode") .first() .drop("tenure") @@ -262,11 +312,7 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat ) .filter(pl.col("_event").is_not_null()) .with_columns( - pl.col("inspection_date") - .cast(pl.String) - .str.slice(0, 4) - .cast(pl.Int32) - .alias("_event_year"), + pl.col("inspection_date").dt.year().cast(pl.Int32).alias("_event_year"), ) .group_by("_epc_match_address", "_epc_match_postcode") .agg( @@ -324,6 +370,16 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat } duration_map = {"F": "Freehold", "L": "Leasehold"} + # price >= MIN_PRICE and ppd_category == "A" (standard open-market sale) are + # VALUE-QUALITY filters: they gate the price aggregations only. Category B + # entries (repossessions, bulk/portfolio, power-of-sale transfers) and sub-MIN + # sales must not pollute latest_price / historical_prices (and the downstream + # price-per-sqm feature), but they MUST still count for first_transfer_date / + # old_new so a new-build's genuine earliest transfer year is preserved. + price_ok = pl.col("price") >= MIN_PRICE + category_ok = pl.col("ppd_category") == "A" + quality_ok = price_ok & category_ok + price_paid = ( pl.scan_parquet(price_paid_path) .select( @@ -340,9 +396,9 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat "town_city", pl.col("duration").replace(duration_map), "old_new", + "ppd_category", ) .filter(pl.col("pp_property_type") != "Other") - .filter(pl.col("price") >= MIN_PRICE) .with_columns( pl.concat_str( [pl.col("saon"), pl.col("paon"), pl.col("street")], @@ -367,18 +423,26 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat pl.col("postcode").last(), pl.col("_pp_match_address").last(), pl.col("_pp_match_postcode").last(), + # Price aggregations are restricted to quality-passing sales. pl.struct( pl.col("date_of_transfer").dt.year().alias("year"), pl.col("date_of_transfer").dt.month().cast(pl.UInt8).alias("month"), "price", - ).alias("historical_prices"), + ) + .filter(quality_ok) + .alias("historical_prices"), pl.col("pp_property_type").last(), pl.col("duration").last(), - pl.col("price").last().alias("latest_price"), - pl.col("date_of_transfer").last(), + pl.col("price").filter(quality_ok).last().alias("latest_price"), + pl.col("date_of_transfer").filter(quality_ok).last(), + # first_transfer_date / old_new reflect the genuine earliest transfer + # over the full per-group transaction stream (not value-filtered). pl.col("date_of_transfer").first().alias("first_transfer_date"), pl.col("old_new").first(), ) + # Preserve the property universe: previously a property needed >=1 sale + # >=MIN_PRICE to form a group, so drop groups with no quality-passing sale. + .filter(pl.col("latest_price").is_not_null()) ) print("Price paid dataset") @@ -407,13 +471,7 @@ def _run(epc_path: Path, price_paid_path: Path, output_path: Path, temp_dir: Pat # For new-builds (old_new == "Y"), use the first transaction date year as # the exact construction date; otherwise fall back to the EPC age band. - epc_band_year = ( - pl.col("construction_age_band") - .str.replace("England and Wales: ", "") - .str.replace(" onwards", "") - .str.extract(r"(\d{4})", 1) - .cast(pl.UInt16, strict=False) - ) + epc_band_year = epc_band_to_year(pl.col("construction_age_band")) transfer_year = ( pl.col("first_transfer_date").dt.year().cast(pl.UInt16, strict=False) ) diff --git a/pipeline/transform/merge.py b/pipeline/transform/merge.py index 5dc21b0..d844572 100644 --- a/pipeline/transform/merge.py +++ b/pipeline/transform/merge.py @@ -17,7 +17,11 @@ from shapely.strtree import STRtree from thefuzz import fuzz from pipeline.local_temp import local_tmp_dir -from pipeline.transform.join_epc_pp import _scan_epc_certificates +from pipeline.transform.join_epc_pp import _scan_epc_certificates, epc_band_to_year +from pipeline.transform.price_estimation.knn import ( + MAX_COMPARABLE_PSM, + MIN_COMPARABLE_PSM, +) from pipeline.utils.fuzzy_join import ( normalize_address_key, normalize_postcode_key, @@ -48,7 +52,7 @@ _AREA_COLUMNS = [ "lon", # Runtime provenance for deciding whether missing coordinates are skippable. "ctry25cd", - # Keyed lookup for postcode-level side tables (e.g. crime time series). + # Join key for LSOA-level side tables (e.g. median age). "lsoa21", # Deprivation "Income Score", @@ -59,7 +63,7 @@ _AREA_COLUMNS = [ "Air Quality and Road Safety Score", # Ethnicity "% South Asian", - "% East Asian", + "% East/SE Asian", "% Black", "% Mixed", "% White", @@ -81,8 +85,6 @@ _AREA_COLUMNS = [ "Other crime (avg/yr)", "Serious crime (avg/yr)", "Minor crime (avg/yr)", - "Serious crime per 1k residents (avg/yr)", - "Minor crime per 1k residents (avg/yr)", # Amenities "Number of restaurants within 2km", "Number of grocery shops and supermarkets within 2km", @@ -118,6 +120,66 @@ TREE_DENSITY_FEATURE = "Street tree density percentile" _POSTCODE_TREE_DENSITY_PERCENTILE_RE = re.compile( r"^Tree canopy density percentile within \d+m$" ) +_FINAL_DROP_COLUMNS = [ + "inspection_date", + "_bedrooms", + "LSOA name (2021)", + "Local Authority District code (2024)", + "Local Authority District name (2024)", + "Wider Barriers Sub-domain Score", + "Geographical Barriers Sub-domain Score", + "Adult Skills Sub-domain Score", + "Children and Young People Sub-domain Score", + "Crime Score", + "Living Environment Score", + "Index of Multiple Deprivation (IMD) Score", + "Income Deprivation Affecting Older People (IDAOPI) Score (rate)", + "Income Deprivation Affecting Children Index (IDACI) Score (rate)", + "Barriers to Housing and Services Score", + "oa21", + "pcon", + "epc_property_type", + "pp_property_type", + "built_form", +] +_FINAL_RENAME_COLUMNS = { + "date_of_transfer": "Date of last transaction", + "construction_age_band": "Construction year", + "is_construction_date_approximate": "Is construction date approximate", + "Income Score (rate)": "Income Score", + "Employment Score (rate)": "Employment Score", + "Indoors Sub-domain Score": "Housing Conditions Score", + "Outdoors Sub-domain Score": "Air Quality and Road Safety Score", + "pp_address": "Address per Property Register", + "epc_address": "Address per EPC", + "postcode": "Postcode", + "duration": "Leasehold/Freehold", + "current_energy_rating": "Current energy rating", + "potential_energy_rating": "Potential energy rating", + "total_floor_area": "Total floor area (sqm)", + "property_type": "Property type", + "restaurants_2km": "Number of restaurants within 2km", + "groceries_2km": "Number of grocery shops and supermarkets within 2km", + "latest_price": "Last known price", + "number_habitable_rooms": "Number of bedrooms & living rooms", + "noise_lden_db": "Noise (dB)", + "good_primary_5km": "Good+ primary schools within 5km", + "good_secondary_5km": "Good+ secondary schools within 5km", + "good_primary_2km": "Good+ primary schools within 2km", + "good_secondary_2km": "Good+ secondary schools within 2km", + "outstanding_primary_5km": "Outstanding primary schools within 5km", + "outstanding_secondary_5km": "Outstanding secondary schools within 5km", + "outstanding_primary_2km": "Outstanding primary schools within 2km", + "outstanding_secondary_2km": "Outstanding secondary schools within 2km", + "max_download_speed": "Max available download speed (Mbps)", + "serious_crime_avg_yr": "Serious crime (avg/yr)", + "minor_crime_avg_yr": "Minor crime (avg/yr)", + "mean_monthly_rent": "Estimated monthly rent", + "floor_height": "Interior height (m)", + "was_council_house": "Former council house", + "median_age": "Median age", + "turnout_pct": "Voter turnout (%)", +} _RENT_SOURCE_UNAVAILABLE_LADS = { # ONS PIPR does not publish LAD-level private-rent estimates for these # small authorities. Keep rent null there, but fail on any other LAD miss. @@ -709,6 +771,219 @@ def _validate_property_postcodes(df: pl.DataFrame) -> None: ) +def _active_english_postcode_area(arcgis_raw: pl.LazyFrame) -> pl.LazyFrame: + """Return the supported postcode universe with geography join keys.""" + return ( + arcgis_raw.filter(pl.col("ctry25cd") == "E92000001") + .filter(pl.col("doterm").is_null()) + .select( + pl.col("pcds").alias("postcode"), + "lat", + pl.col("long").alias("lon"), + "ctry25cd", + pl.col("lsoa21cd").alias("lsoa21"), + pl.col("oa21cd").alias("oa21"), + pl.col("pcon24cd").alias("pcon"), + ) + .drop_nulls(["postcode"]) + .unique(["postcode"]) + ) + + +def _remap_terminated_postcodes( + wide: pl.LazyFrame, postcode_mapping: pl.LazyFrame +) -> pl.LazyFrame: + return ( + wide.join( + postcode_mapping, + left_on="postcode", + right_on="old_postcode", + how="left", + ) + .with_columns( + pl.coalesce("new_postcode", "postcode").alias("postcode"), + ) + .drop("new_postcode") + ) + + +def _dedupe_collapsed_properties(wide: pl.LazyFrame) -> pl.LazyFrame: + """Keep one row per (postcode, pp_address) — the most-recent transaction. + + The terminated-postcode remap can map two distinct postcodes onto one active + successor, collapsing the same physical address onto a single + (postcode, pp_address) key with conflicting sale records. Keep the row with + the latest date_of_transfer so the headline price/date reflect the most + recent transaction; genuinely distinct addresses (a different pp_address) are + untouched. pp_address is non-null here (join_epc_pp filters it), so the key + never merges unrelated rows. + """ + return wide.sort( + "date_of_transfer", descending=True, nulls_last=True + ).unique(subset=["postcode", "pp_address"], keep="first", maintain_order=True) + + +def _filter_to_active_english_postcodes( + wide: pl.LazyFrame, active_postcodes: pl.LazyFrame +) -> pl.LazyFrame: + return wide.join(active_postcodes, on="postcode", how="semi") + + +def _join_area_side_tables( + base: pl.LazyFrame, + *, + iod: pl.LazyFrame, + ethnicity: pl.LazyFrame, + crime: pl.LazyFrame, + median_age: pl.LazyFrame, + election: pl.LazyFrame, + poi_counts: pl.LazyFrame, + noise: pl.LazyFrame, + school_proximity: pl.LazyFrame, + conservation_areas: pl.LazyFrame, + tree_density: pl.LazyFrame | None, + broadband: pl.LazyFrame, +) -> pl.LazyFrame: + base = base.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left") + base = base.join( + ethnicity, + left_on="Local Authority District code (2024)", + right_on="Geography_code", + how="left", + ) + + # Crime is counted spatially per postcode (incidents within 50m of the + # postcode boundary), so it joins on postcode rather than LSOA. crime_spatial + # precomputes the Serious/Minor headline rollups as the mean of the by-year + # rollup bars; read those straight through (renamed to the internal columns + # _finalize_merged_columns expects) rather than re-summing the per-type + # avg/yr columns — summing divides each type by its OWN years-present and + # overstates the rollup when types differ in coverage. A postcode absent from + # the crime table keeps null rollups via the left join (no fabricated zero); + # the per-type avg/yr columns pass through unchanged for display. + base = base.join(crime, on="postcode", how="left").rename( + { + "Serious crime (avg/yr)": "serious_crime_avg_yr", + "Minor crime (avg/yr)": "minor_crime_avg_yr", + } + ) + + base = base.join(median_age, on="lsoa21", how="left") + base = base.join(election, on="pcon", how="left") + base = base.join(poi_counts, on="postcode", how="left") + base = base.join(noise, on="postcode", how="left") + base = base.join(school_proximity, on="postcode", how="left") + base = base.join(conservation_areas, on="postcode", how="left").with_columns( + pl.col(CONSERVATION_AREA_FEATURE).fill_null("No") + ) + if tree_density is not None: + base = base.join(tree_density, on="postcode", how="left") + + # Broadband is the one side table sourced straight from a third-party CSV + # (Ofcom `postcode_space`) rather than from a sibling pcds-keyed pipeline + # step, so its postcode may drift in spacing/casing from the NSPL `pcds` + # base key. Normalize BOTH sides to the same canonical pcds form (reusing + # `_canonical_postcode_expr`, exactly as the listing/EPC re-anchor joins do) + # before joining, otherwise a real postcode silently misses and its + # `max_download_speed` reads as null "no data" downstream. Re-aggregate on + # the canonical key so two raw spellings collapsing to one key can't fan out + # the base; drop a null canonical key so an unparseable Ofcom row joins + # nothing rather than matching a null-key base row. + broadband_canonical = ( + broadband.with_columns( + _canonical_postcode_expr("bb_postcode").alias("_bb_canonical_postcode") + ) + .drop_nulls("_bb_canonical_postcode") + .group_by("_bb_canonical_postcode") + .agg(pl.col("max_download_speed").max()) + ) + return ( + base.with_columns( + _canonical_postcode_expr("postcode").alias("_base_canonical_postcode") + ) + .join( + broadband_canonical, + left_on="_base_canonical_postcode", + right_on="_bb_canonical_postcode", + how="left", + ) + .drop("_base_canonical_postcode") + ) + + +def _finalize_merged_columns(frame: pl.LazyFrame) -> pl.LazyFrame: + return frame.drop(_FINAL_DROP_COLUMNS, strict=False).rename( + _FINAL_RENAME_COLUMNS, strict=False + ) + + +def _area_columns_from(columns: list[str]) -> list[str]: + return [ + c for c in columns if c in _AREA_COLUMNS or _is_dynamic_poi_metric_column(c) + ] + + +def _property_columns_from(columns: list[str]) -> list[str]: + return [ + c + for c in columns + if (c not in _AREA_COLUMNS and not _is_dynamic_poi_metric_column(c)) + or c == "Postcode" + ] + + +def _validate_postcode_feature_output( + postcode_df: pl.DataFrame, expected_postcode_count: int +) -> None: + required = {"Postcode", "lat", "lon", "ctry25cd"} + missing = sorted(required - set(postcode_df.columns)) + if missing: + raise ValueError(f"Postcode feature output missing columns: {missing}") + + unique_count = postcode_df["Postcode"].n_unique() + if ( + postcode_df.height != expected_postcode_count + or unique_count != expected_postcode_count + ): + raise ValueError( + "Postcode feature output no longer matches the active England " + "postcode universe: " + f"rows={postcode_df.height:,}, unique={unique_count:,}, " + f"expected={expected_postcode_count:,}" + ) + + invalid = postcode_df.filter( + pl.col("Postcode").is_null() + | (pl.col("Postcode").cast(pl.Utf8).str.strip_chars() == "") + | pl.col("lat").is_null() + | pl.col("lon").is_null() + | pl.col("ctry25cd").is_null() + | (pl.col("ctry25cd") != "E92000001") + ) + if invalid.height > 0: + sample = ( + invalid.select("Postcode", "ctry25cd", "lat", "lon").head(10).to_dicts() + ) + raise ValueError( + "Postcode feature output contains unsupported or ungeocoded rows: " + f"{invalid.height} rows. Sample: {sample}" + ) + + +def _split_normal_outputs( + df: pl.DataFrame, + postcode_features: pl.DataFrame, + *, + expected_postcode_count: int, +) -> tuple[pl.DataFrame, pl.DataFrame]: + postcode_df = postcode_features.select( + _area_columns_from(postcode_features.columns) + ) + _validate_postcode_feature_output(postcode_df, expected_postcode_count) + properties_df = df.select(_property_columns_from(df.columns)) + return postcode_df, properties_df + + # Map listings-parquet source columns to the `_actual_*` overlay columns # carried alongside the wide frame through the postcode-keyed joins. After the # rest of the pipeline finalises, listing rows pick their canonical dashboard @@ -742,16 +1017,13 @@ _PROPERTY_TYPE_VALUES = [ "Other", ] _EPC_RATING_VALUES = ["A", "B", "C", "D", "E", "F", "G"] -_PROPERTY_MATCH_MIN_SCORE_WITH_NUMBERS = 82.0 -_PROPERTY_MATCH_MIN_SCORE_WITHOUT_NUMBERS = 96.0 -_PROPERTY_MATCH_MIN_ADDRESS_SCORE_WITH_NUMBERS = 82 -_PROPERTY_MATCH_MIN_ADDRESS_SCORE_WITHOUT_NUMBERS = 96 -_PROPERTY_MATCH_MIN_MARGIN = 4.0 -_DIRECT_EPC_MATCH_MIN_SCORE_WITH_NUMBERS = 82.0 -_DIRECT_EPC_MATCH_MIN_SCORE_WITHOUT_NUMBERS = 96.0 -_DIRECT_EPC_MATCH_MIN_MARGIN = 4.0 -_DIRECT_EPC_NEARBY_RADIUS_M = 500.0 -_DIRECT_EPC_NEAREST_POSTCODES = 40 +# Listings are matched to EPC certificates and Price-Paid properties first by +# UPRN (exact) and otherwise by fuzzy street-address similarity within the same +# postcode. A house number in the listing address is the strong disambiguator, +# so a numbered listing may match on a lower street-similarity score than a +# number-less one (which must match the street almost exactly to be trusted). +_LISTING_MATCH_MIN_SCORE_WITH_NUMBERS = 82 +_LISTING_MATCH_MIN_SCORE_WITHOUT_NUMBERS = 90 _DIRECT_EPC_COLUMNS: tuple[tuple[str, pl.DataType], ...] = ( ("_direct_epc_address", pl.Utf8), ("_direct_current_energy_rating", pl.Utf8), @@ -764,7 +1036,7 @@ _DIRECT_EPC_COLUMNS: tuple[tuple[str, pl.DataType], ...] = ( ("_direct_was_council_house", pl.Utf8), ("_direct_epc_match_status", pl.Utf8), ("_direct_epc_match_score", pl.Float32), - ("_direct_epc_match_margin", pl.Float32), + ("_direct_epc_match_method", pl.Utf8), ) _DIRECT_EPC_RAW_COLUMN_MAP = { "epc_address": "_direct_epc_address", @@ -830,54 +1102,10 @@ def _canonical_epc_property_type_expr() -> pl.Expr: def _construction_year_expr(column: str = "construction_age_band") -> pl.Expr: - return ( - pl.col(column) - .cast(pl.Utf8) - .str.replace("England and Wales: ", "") - .str.replace(" onwards", "") - .str.extract(r"(\d{4})", 1) - .cast(pl.UInt16, strict=False) - ) - - -def _ratio_bonus( - left: float | int | None, right: float | int | None, pct: float, cap: float -) -> float: - if left is None or right is None: - return 0.0 - try: - left_f = float(left) - right_f = float(right) - except (TypeError, ValueError): - return 0.0 - if left_f <= 0 or right_f <= 0: - return 0.0 - rel = abs(left_f - right_f) / max(left_f, right_f) - if rel > pct: - return 0.0 - return cap * (1.0 - rel / pct) - - -def _rooms_bonus(left: int | None, right: int | None) -> float: - if left is None or right is None: - return 0.0 - try: - diff = abs(int(left) - int(right)) - except (TypeError, ValueError): - return 0.0 - if diff == 0: - return 4.0 - if diff == 1: - return 2.0 - return 0.0 - - -def _enum_bonus( - left: str | None, right: str | None, *, exact: float, mismatch: float -) -> float: - if not left or not right: - return 0.0 - return exact if left == right else mismatch + # Use the shared band->midpoint-year mapping so the direct-EPC / listings + # path matches join_epc_pp (band midpoint, not lower bound; 'before 1900' and + # implausible years -> null). Already-numeric inputs pass through unchanged. + return epc_band_to_year(pl.col(column)) def _address_score(query: str, candidate: str | None) -> int: @@ -893,9 +1121,86 @@ def _has_number(address: str | None) -> bool: return bool(address and _NUMBER_RE.search(address)) -def _load_listings_for_merge( - listings_path: Path, arcgis_path: Path -) -> pl.DataFrame: +def _normalize_uprn(value: object) -> str | None: + """Canonical UPRN string (digits only) or None. + + UPRNs arrive as strings or ints from the scraper / EPC register; normalise + so a listing UPRN and an EPC/property UPRN compare equal regardless of dtype + or stray whitespace. A float (e.g. a NaN-bearing column read as Float) is + rejected unless it is an exact integer, so "123.0"/"1.5e11" can never be + silently mangled into a bogus all-digits key. + """ + if value is None: + return None + if isinstance(value, float): + if not value.is_integer(): + return None + value = int(value) + digits = re.sub(r"\D", "", str(value)) + return digits or None + + +def _best_listing_match( + listing_uprn: str | None, + query: str | None, + uprn_index: dict[str, dict], + bucket_candidates: list[dict], + addressed_fields: list[str], +) -> tuple[dict, float, str, str | None] | None: + """Pick the best candidate for a listing. + + Matching is, in order: (1) an exact UPRN equality against the global + ``uprn_index`` (postcode-independent, so it is robust even when the + listing's postcode is slightly off); (2) failing that, the highest + fuzzy street-address similarity within the listing's own postcode bucket. + No property-attribute heuristics are used — a house number in the listing + address gates the fuzzy match (`_numbers_compatible`) and lowers the score + threshold; a number-less address must match the street almost exactly. + + ``addressed_fields`` names the candidate columns to fuzzy-match against (a + candidate may carry both a register and an EPC address). Returns + ``(candidate, score, method, matched_field)`` or None. ``method`` is + "uprn" or "address"; ``matched_field`` is the winning address column (or + None for a UPRN match). + """ + if listing_uprn: + hit = uprn_index.get(listing_uprn) + if hit is not None: + return hit, 100.0, "uprn", None + + if not query: + return None + + listing_has_numbers = _has_number(query) + best: dict | None = None + best_score = 0 + best_field: str | None = None + for candidate in bucket_candidates: + for field in addressed_fields: + address = candidate.get(field) + if not address: + continue + if listing_has_numbers and not _numbers_compatible(query, address): + continue + score = _address_score(query, address) + if score > best_score: + best_score = score + best = candidate + best_field = field + + if best is None: + return None + threshold = ( + _LISTING_MATCH_MIN_SCORE_WITH_NUMBERS + if listing_has_numbers + else _LISTING_MATCH_MIN_SCORE_WITHOUT_NUMBERS + ) + if best_score < threshold: + return None + return best, float(best_score), "address", best_field + + +def _load_listings_for_merge(listings_path: Path, arcgis_path: Path) -> pl.DataFrame: """Read the listings parquet and prepare it for the wide-frame merge. Output is keyed by `_listing_idx` and carries: @@ -908,6 +1213,35 @@ def _load_listings_for_merge( raw = pl.scan_parquet(listings_path).with_row_index("_listing_idx") postcode_mapping = build_postcode_mapping(arcgis_path).lazy() + # UPRN is only present on scraped listings that carry it (Zoopla detail + # pages); tolerate its absence so older parquets and test fixtures still + # load. Digits-only so it compares equal to the EPC register's UPRN. + if "UPRN" in raw.collect_schema().names(): + # Mirror `_normalize_uprn` exactly so the listing key compares equal to + # the candidate-side key for every dtype. For a Float UPRN we must + # stringify via its integer form (100023336956.0 -> "100023336956"), + # otherwise stripping non-digits from "100023336956.0" yields a bogus + # trailing-zero key ("1000233369560") that never collides; and a + # non-integral float (e.g. 1.5) must be rejected rather than mangled. + uprn_col = pl.col("UPRN") + if raw.collect_schema()["UPRN"].is_float(): + integral = uprn_col.cast(pl.Int64, strict=False) + uprn_digits = ( + pl.when(integral == uprn_col) + .then(integral.cast(pl.Utf8).str.replace_all(r"\D", "")) + .otherwise(None) + ) + else: + uprn_digits = uprn_col.cast(pl.Utf8).str.replace_all(r"\D", "") + listing_uprn_expr = ( + pl.when(uprn_digits.str.len_chars() > 0) + .then(uprn_digits) + .otherwise(None) + .alias("_listing_uprn") + ) + else: + listing_uprn_expr = pl.lit(None, dtype=pl.Utf8).alias("_listing_uprn") + # Listings parquets occasionally carry Float NaNs (e.g. floor area). Polars # treats NaN as distinct from null and the downstream `latest_price / # total_floor_area` cast to Int32 explodes on a NaN, so we normalise floats @@ -936,12 +1270,14 @@ def _load_listings_for_merge( "postcode" ), pl.col("Address per Property Register").alias("pp_address"), + listing_uprn_expr, *overlay, ) .select( "_listing_idx", "postcode", "pp_address", + "_listing_uprn", *[dst for _src, dst, _dt in _LISTING_OVERLAY_SOURCES], ) .collect(engine="streaming") @@ -972,7 +1308,6 @@ def _empty_direct_epc_matches() -> pl.DataFrame: def _load_direct_epc_candidates( epc_path: Path, - arcgis_path: Path, listing_outcodes: list[str], temp_dir: Path, ) -> pl.DataFrame: @@ -982,9 +1317,12 @@ def _load_direct_epc_candidates( "_direct_epc_match_postcode": pl.Utf8, "_direct_epc_outcode": pl.Utf8, "_direct_epc_canonical_property_type": pl.Utf8, - "_direct_epc_east": pl.Float64, - "_direct_epc_north": pl.Float64, - **{column: dtype for column, dtype in _DIRECT_EPC_COLUMNS if column.startswith("_direct_")}, + "_direct_epc_uprn": pl.Utf8, + **{ + column: dtype + for column, dtype in _DIRECT_EPC_COLUMNS + if column.startswith("_direct_") + }, } if not listing_outcodes: return pl.DataFrame(schema=schema) @@ -1016,14 +1354,8 @@ def _load_direct_epc_candidates( .with_columns(pl.lit("Yes").alias("_direct_was_council_house")) ) - arcgis = pl.scan_parquet(arcgis_path).select( - normalize_postcode_key(pl.col("pcds")).alias("_direct_epc_match_postcode"), - pl.col("east1m").alias("_direct_epc_east"), - pl.col("north1m").alias("_direct_epc_north"), - ) - return ( - epc_base.sort("inspection_date", descending=True) + epc_base.sort("inspection_date", descending=True, nulls_last=True) .group_by("_direct_epc_match_address", "_direct_epc_match_postcode") .first() .join( @@ -1031,7 +1363,6 @@ def _load_direct_epc_candidates( on=["_direct_epc_match_address", "_direct_epc_match_postcode"], how="left", ) - .join(arcgis, on="_direct_epc_match_postcode", how="left") .with_columns( _canonical_epc_property_type_expr().alias( "_direct_epc_canonical_property_type" @@ -1046,10 +1377,9 @@ def _load_direct_epc_candidates( .otherwise(None) .alias("_direct_potential_energy_rating"), pl.col("epc_address").alias("_direct_epc_address"), + pl.col("uprn").alias("_direct_epc_uprn"), pl.col("total_floor_area").alias("_direct_total_floor_area"), - pl.col("number_habitable_rooms").alias( - "_direct_number_habitable_rooms" - ), + pl.col("number_habitable_rooms").alias("_direct_number_habitable_rooms"), pl.col("floor_height").alias("_direct_floor_height"), pl.col("_direct_was_council_house").fill_null("No"), ) @@ -1066,8 +1396,7 @@ def _load_direct_epc_candidates( "_direct_epc_match_postcode", "_direct_epc_outcode", "_direct_epc_canonical_property_type", - "_direct_epc_east", - "_direct_epc_north", + "_direct_epc_uprn", "_direct_epc_address", "_direct_current_energy_rating", "_direct_potential_energy_rating", @@ -1083,7 +1412,14 @@ def _load_direct_epc_candidates( def _listing_match_frame(listings: pl.DataFrame) -> pl.DataFrame: - match = listings.with_columns( + """Add the normalised address/postcode/outcode keys used to match listings. + + Listings are matched to EPC certificates and properties by UPRN and by + fuzzy street address within their (now accurate, detail-page-sourced) + postcode — never by coordinate proximity — so no projected easting/northing + is computed here. `_listing_uprn` flows through from the loaded listings. + """ + return listings.with_columns( normalize_address_key(pl.col("pp_address")).alias("_listing_match_address"), normalize_postcode_key(pl.col("postcode")).alias("_listing_match_postcode"), ).with_columns( @@ -1092,25 +1428,8 @@ def _listing_match_frame(listings: pl.DataFrame) -> pl.DataFrame: .alias("_listing_outcode") ) - if match.is_empty(): - return match.with_columns( - pl.Series("_listing_east", [], dtype=pl.Float64), - pl.Series("_listing_north", [], dtype=pl.Float64), - ) - transformer = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) - east, north = transformer.transform( - match["_actual_lon"].to_numpy(), match["_actual_lat"].to_numpy() - ) - return match.with_columns( - pl.Series("_listing_east", east, dtype=pl.Float64), - pl.Series("_listing_north", north, dtype=pl.Float64), - ) - - -def _optional_lazy_col( - schema: pl.Schema, column: str, dtype: pl.DataType -) -> pl.Expr: +def _optional_lazy_col(schema: pl.Schema, column: str, dtype: pl.DataType) -> pl.Expr: if column in schema: return pl.col(column).cast(dtype, strict=False).alias(column) return pl.lit(None, dtype=dtype).alias(column) @@ -1122,8 +1441,7 @@ def _listing_property_match_schema() -> dict[str, pl.DataType]: "_matched_postcode": pl.Utf8, "_matched_pp_address": pl.Utf8, "_property_match_score": pl.Float32, - "_property_match_address_score": pl.Int32, - "_property_match_margin": pl.Float32, + "_property_match_method": pl.Utf8, "_property_match_field": pl.Utf8, } @@ -1139,11 +1457,8 @@ def _property_match_candidate_frame(wide: pl.LazyFrame) -> pl.DataFrame: pl.col("postcode").cast(pl.Utf8).alias("postcode"), pl.col("pp_address").cast(pl.Utf8).alias("pp_address"), _optional_lazy_col(schema, "epc_address", pl.Utf8), - _optional_lazy_col(schema, "pp_property_type", pl.Utf8), - _optional_lazy_col(schema, "duration", pl.Utf8), - _optional_lazy_col(schema, "total_floor_area", pl.Float64), - _optional_lazy_col(schema, "number_habitable_rooms", pl.Int16), - _optional_lazy_col(schema, "latest_price", pl.Int64), + # UPRN keys the exact match; present once epc_pp is rebuilt with it. + _optional_lazy_col(schema, "uprn", pl.Utf8), ) .with_row_index("_property_row") .with_columns( @@ -1167,110 +1482,52 @@ def _property_match_candidate_frame(wide: pl.LazyFrame) -> pl.DataFrame: ) -def _property_candidates_by_postcode( - candidates: pl.DataFrame, -) -> dict[str, list[dict]]: +def _index_candidates( + candidates: pl.DataFrame, postcode_key: str, uprn_key: str +) -> tuple[dict[str, list[dict]], dict[str, dict]]: + """Index candidate rows for matching, in a single pass over the frame. + + Returns ``(postcode_buckets, uprn_index)``. The postcode buckets drive the + fuzzy street-address match; the UPRN index drives the exact match and is + postcode-independent, so it still resolves when a listing's postcode is + slightly off. + """ buckets: dict[str, list[dict]] = {} + uprn_index: dict[str, dict] = {} for row in candidates.iter_rows(named=True): - postcode = row.get("_property_match_postcode") + postcode = row.get(postcode_key) if postcode: buckets.setdefault(postcode, []).append(row) - return buckets + uprn = _normalize_uprn(row.get(uprn_key)) + if uprn and uprn not in uprn_index: + uprn_index[uprn] = row + return buckets, uprn_index def _best_listing_property_candidate( - listing: dict, candidates: list[dict] + listing: dict, uprn_index: dict[str, dict], candidates: list[dict] ) -> dict | None: - query = listing.get("_listing_match_address") - if not query: - return None - - listing_has_numbers = _has_number(query) - scored: list[tuple[float, int, dict, str]] = [] - for candidate in candidates: - register_address = candidate.get("_property_match_address") - epc_address = candidate.get("_property_epc_match_address") - register_numbers_compatible = bool( - register_address and _numbers_compatible(query, register_address) - ) - epc_numbers_compatible = bool( - epc_address and _numbers_compatible(query, epc_address) - ) - if not (register_numbers_compatible or epc_numbers_compatible): - continue - - register_score = _address_score(query, register_address) - epc_score = _address_score(query, epc_address) - base_score = max(register_score, epc_score) - if base_score == 0: - continue - - score = float(base_score) - score += _enum_bonus( - listing.get("_actual_property_type"), - candidate.get("pp_property_type"), - exact=7.0, - mismatch=-8.0, - ) - score += _enum_bonus( - listing.get("_actual_leasehold_freehold"), - candidate.get("duration"), - exact=3.0, - mismatch=-3.0, - ) - score += _ratio_bonus( - listing.get("_actual_total_floor_area"), - candidate.get("total_floor_area"), - pct=0.15, - cap=8.0, - ) - score += _rooms_bonus( - listing.get("_actual_number_habitable_rooms"), - candidate.get("number_habitable_rooms"), - ) - score += _ratio_bonus( - listing.get("_actual_asking_price"), - candidate.get("latest_price"), - pct=0.25, - cap=3.0, - ) - matched_field = ( - "pp_address" if register_score >= epc_score else "epc_address" - ) - scored.append((score, base_score, candidate, matched_field)) - - if not scored: - return None - scored.sort(key=lambda item: item[0], reverse=True) - top = scored[0] - runner_up = scored[1][0] if len(scored) > 1 else None - margin = top[0] - runner_up if runner_up is not None else top[0] - score_threshold = ( - _PROPERTY_MATCH_MIN_SCORE_WITH_NUMBERS - if listing_has_numbers - else _PROPERTY_MATCH_MIN_SCORE_WITHOUT_NUMBERS + result = _best_listing_match( + listing.get("_listing_uprn"), + listing.get("_listing_match_address"), + uprn_index, + candidates, + ["_property_match_address", "_property_epc_match_address"], ) - address_threshold = ( - _PROPERTY_MATCH_MIN_ADDRESS_SCORE_WITH_NUMBERS - if listing_has_numbers - else _PROPERTY_MATCH_MIN_ADDRESS_SCORE_WITHOUT_NUMBERS - ) - if ( - top[0] < score_threshold - or top[1] < address_threshold - or margin < _PROPERTY_MATCH_MIN_MARGIN - ): + if result is None: return None - - candidate = top[2] + candidate, score, method, field = result + matched_field = { + "_property_match_address": "pp_address", + "_property_epc_match_address": "epc_address", + }.get(field, method) return { "_listing_idx": listing["_listing_idx"], "_matched_postcode": candidate.get("postcode"), "_matched_pp_address": candidate.get("pp_address"), - "_property_match_score": round(top[0], 1), - "_property_match_address_score": top[1], - "_property_match_margin": round(margin, 1), - "_property_match_field": top[3], + "_property_match_score": round(score, 1), + "_property_match_method": method, + "_property_match_field": matched_field, } @@ -1280,23 +1537,32 @@ def _match_listing_properties( if listing_matches.is_empty() or property_candidates.is_empty(): return _empty_listing_property_matches() - buckets = _property_candidates_by_postcode(property_candidates) + buckets, uprn_index = _index_candidates( + property_candidates, "_property_match_postcode", "uprn" + ) best_matches = [] for listing in listing_matches.iter_rows(named=True): postcode = listing.get("_listing_match_postcode") - if not postcode: - continue - match = _best_listing_property_candidate(listing, buckets.get(postcode, [])) + bucket = buckets.get(postcode, []) if postcode else [] + match = _best_listing_property_candidate(listing, uprn_index, bucket) if match is not None: best_matches.append(match) if not best_matches: return _empty_listing_property_matches() + # When two listings claim the same property, keep the most authoritative + # match: an exact UPRN match always wins over a fuzzy address match (both can + # score 100, so method must break the tie before score and listing index). matches = pl.DataFrame(best_matches, schema=_listing_property_match_schema()) return ( matches.sort( - ["_property_match_score", "_listing_idx"], descending=[True, False] + [ + pl.col("_property_match_method") == "uprn", + "_property_match_score", + "_listing_idx", + ], + descending=[True, True, False], ) .unique( ["_matched_postcode", "_matched_pp_address"], @@ -1307,133 +1573,19 @@ def _match_listing_properties( ) -def _epc_candidates_by_postcode(candidates: pl.DataFrame) -> dict[str, list[dict]]: - buckets: dict[str, list[dict]] = {} - for row in candidates.iter_rows(named=True): - postcode = row.get("_direct_epc_match_postcode") - if postcode: - buckets.setdefault(postcode, []).append(row) - return buckets - - -def _epc_postcode_tree( - candidates: pl.DataFrame, -) -> tuple[cKDTree | None, list[str]]: - postcode_points = ( - candidates.select( - "_direct_epc_match_postcode", - "_direct_epc_east", - "_direct_epc_north", - ) - .drop_nulls() - .filter( - pl.col("_direct_epc_east").is_finite() - & pl.col("_direct_epc_north").is_finite() - ) - .unique("_direct_epc_match_postcode") +def _best_direct_epc_candidate( + listing: dict, uprn_index: dict[str, dict], candidates: list[dict] +) -> dict | None: + result = _best_listing_match( + listing.get("_listing_uprn"), + listing.get("_listing_match_address"), + uprn_index, + candidates, + ["_direct_epc_match_address"], ) - if postcode_points.is_empty(): - return None, [] - coords = np.column_stack( - [ - postcode_points["_direct_epc_east"].to_numpy(), - postcode_points["_direct_epc_north"].to_numpy(), - ] - ) - return cKDTree(coords), postcode_points["_direct_epc_match_postcode"].to_list() - - -def _candidate_postcodes_for_listing( - listing: dict, - postcode_tree: cKDTree | None, - postcode_values: list[str], -) -> list[str]: - postcodes: list[str] = [] - exact = listing.get("_listing_match_postcode") - if exact: - postcodes.append(exact) - - if postcode_tree is None: - return postcodes - - east = listing.get("_listing_east") - north = listing.get("_listing_north") - try: - east_f = float(east) - north_f = float(north) - except (TypeError, ValueError): - return postcodes - if not np.isfinite(east_f) or not np.isfinite(north_f): - return postcodes - - k = min(_DIRECT_EPC_NEAREST_POSTCODES, len(postcode_values)) - distances, indices = postcode_tree.query( - [east_f, north_f], - k=k, - distance_upper_bound=_DIRECT_EPC_NEARBY_RADIUS_M, - ) - distances = np.atleast_1d(distances) - indices = np.atleast_1d(indices) - seen = set(postcodes) - for distance, idx in zip(distances, indices, strict=False): - if not np.isfinite(distance) or idx >= len(postcode_values): - continue - postcode = postcode_values[int(idx)] - if postcode not in seen: - postcodes.append(postcode) - seen.add(postcode) - return postcodes - - -def _best_direct_epc_candidate(listing: dict, candidates: list[dict]) -> dict | None: - query = listing.get("_listing_match_address") - if not query: + if result is None: return None - - listing_has_numbers = _has_number(query) - scored: list[tuple[float, int, dict]] = [] - for candidate in candidates: - address = candidate.get("_direct_epc_match_address") - if listing_has_numbers and not _numbers_compatible(query, address or ""): - continue - base_score = _address_score(query, address) - if base_score == 0: - continue - - score = float(base_score) - score += _enum_bonus( - listing.get("_actual_property_type"), - candidate.get("_direct_epc_canonical_property_type"), - exact=6.0, - mismatch=-6.0, - ) - score += _ratio_bonus( - listing.get("_actual_total_floor_area"), - candidate.get("_direct_total_floor_area"), - pct=0.12, - cap=8.0, - ) - score += _rooms_bonus( - listing.get("_actual_number_habitable_rooms"), - candidate.get("_direct_number_habitable_rooms"), - ) - scored.append((score, base_score, candidate)) - - if not scored: - return None - scored.sort(key=lambda item: item[0], reverse=True) - top = scored[0] - runner_up = scored[1][0] if len(scored) > 1 else None - margin = top[0] - runner_up if runner_up is not None else top[0] - threshold = ( - _DIRECT_EPC_MATCH_MIN_SCORE_WITH_NUMBERS - if listing_has_numbers - else _DIRECT_EPC_MATCH_MIN_SCORE_WITHOUT_NUMBERS - ) - if top[0] < threshold or margin < _DIRECT_EPC_MATCH_MIN_MARGIN: - return None - - candidate = top[2] + candidate, score, method, _field = result return { "_listing_idx": listing["_listing_idx"], "_direct_epc_address": candidate.get("_direct_epc_address"), @@ -1452,8 +1604,8 @@ def _best_direct_epc_candidate(listing: dict, candidates: list[dict]) -> dict | ), "_direct_was_council_house": candidate.get("_direct_was_council_house"), "_direct_epc_match_status": "matched", - "_direct_epc_match_score": round(top[0], 1), - "_direct_epc_match_margin": round(margin, 1), + "_direct_epc_match_score": round(score, 1), + "_direct_epc_match_method": method, } @@ -1463,25 +1615,14 @@ def _match_direct_epc( if listing_matches.is_empty() or epc_candidates.is_empty(): return _empty_direct_epc_matches() - buckets = _epc_candidates_by_postcode(epc_candidates) - postcode_tree, postcode_values = _epc_postcode_tree(epc_candidates) - + buckets, uprn_index = _index_candidates( + epc_candidates, "_direct_epc_match_postcode", "_direct_epc_uprn" + ) matches = [] for listing in listing_matches.iter_rows(named=True): - candidate_postcodes = _candidate_postcodes_for_listing( - listing, postcode_tree, postcode_values - ) - candidate_rows: list[dict] = [] - seen_rows: set[int] = set() - for postcode in candidate_postcodes: - for candidate in buckets.get(postcode, []): - row = candidate.get("_direct_epc_row") - if row in seen_rows: - continue - candidate_rows.append(candidate) - if row is not None: - seen_rows.add(row) - match = _best_direct_epc_candidate(listing, candidate_rows) + postcode = listing.get("_listing_match_postcode") + bucket = buckets.get(postcode, []) if postcode else [] + match = _best_direct_epc_candidate(listing, uprn_index, bucket) if match is not None: matches.append(match) @@ -1493,7 +1634,6 @@ def _match_direct_epc( def _enrich_listings_with_direct_epc( listings: pl.DataFrame, epc_path: Path | None, - arcgis_path: Path, ) -> pl.DataFrame: if epc_path is None: return _ensure_direct_epc_columns(listings) @@ -1513,7 +1653,7 @@ def _enrich_listings_with_direct_epc( prefix="direct_listing_epc_", dir=local_tmp_dir() ) as tmpdir: epc_candidates = _load_direct_epc_candidates( - epc_path, arcgis_path, listing_outcodes, Path(tmpdir) + epc_path, listing_outcodes, Path(tmpdir) ) print(f"Direct listing EPC candidates: {epc_candidates.height}") direct_matches = _match_direct_epc(listing_matches, epc_candidates) @@ -1528,9 +1668,23 @@ def _enrich_listings_with_direct_epc( def _coalesce_direct_epc_columns(wide: pl.LazyFrame) -> pl.LazyFrame: + def _coalesced(raw_column: str, direct_column: str) -> pl.Expr: + coalesce = pl.coalesce(pl.col(raw_column), pl.col(direct_column)) + # The raw property-level value is fill_null("No") upstream, so a plain + # coalesce lets a non-null "No" override a directly-matched listing + # "Yes". "Former council house" should fire if EITHER side says so. + if raw_column == "was_council_house": + return ( + pl.when((pl.col(raw_column) == "Yes") | (pl.col(direct_column) == "Yes")) + .then(pl.lit("Yes")) + .otherwise(coalesce) + .alias(raw_column) + ) + return coalesce.alias(raw_column) + return wide.with_columns( [ - pl.coalesce(pl.col(raw_column), pl.col(direct_column)).alias(raw_column) + _coalesced(raw_column, direct_column) for raw_column, direct_column in _DIRECT_EPC_RAW_COLUMN_MAP.items() ] ) @@ -1604,7 +1758,7 @@ def _integrate_listings( """ listings = _load_listings_for_merge(listings_path, arcgis_path) print(f"Listings loaded: {listings.height}") - listings = _enrich_listings_with_direct_epc(listings, epc_path, arcgis_path) + listings = _enrich_listings_with_direct_epc(listings, epc_path) overlay_columns = [dst for _src, dst, _dt in _LISTING_OVERLAY_SOURCES] listing_attachment_columns = [ @@ -1660,6 +1814,14 @@ def _finalize_listings(df: pl.DataFrame) -> pl.DataFrame: """Project the post-rename wide frame down to enriched-listing rows.""" df = df.filter(pl.col(_LISTING_FLAG_COLUMN).is_not_null()) + # A matched listing's overlay attaches to every wide row sharing its + # (postcode, pp_address). The terminated-postcode remap can collapse several + # distinct wide rows onto one such key, which would otherwise emit one duplicate + # listing per collapsed row. Each listing matches exactly one (postcode, + # pp_address) and each seed row carries a unique URL, so keeping a single row per + # listing URL collapses only that fan-out and never merges distinct listings. + df = df.unique(subset=[_LISTING_FLAG_COLUMN], keep="first", maintain_order=True) + df = df.with_columns( pl.col("_actual_listing_url").alias("Listing URL"), pl.col("_actual_listing_date").alias("Listing date"), @@ -1750,7 +1912,6 @@ def _build( broadband_path: Path, conservation_areas_path: Path, rental_prices_path: Path, - lsoa_population_path: Path, median_age_path: Path, election_results_path: Path, tree_density_postcodes_path: Path | None = None, @@ -1779,27 +1940,22 @@ def _build( | (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2) ) - # Remap terminated postcodes to nearest active successor + # Remap terminated postcodes to nearest active successor before filtering to + # the supported active-English postcode universe. Historical properties from + # terminated English postcodes are retained under their successor postcode. postcode_mapping = build_postcode_mapping(arcgis_path) - wide = ( - wide.join( - postcode_mapping.lazy(), - left_on="postcode", - right_on="old_postcode", - how="left", - ) - .with_columns( - pl.coalesce("new_postcode", "postcode").alias("postcode"), - ) - .drop("new_postcode") - ) - + wide = _remap_terminated_postcodes(wide, postcode_mapping.lazy()) + # The remap can collapse two terminated postcodes onto one active successor, + # duplicating a physical address's (postcode, pp_address) key; keep only the + # most-recent transaction per address before the per-postcode joins. + wide = _dedupe_collapsed_properties(wide) arcgis_raw = pl.scan_parquet(arcgis_path) - postcode_country = arcgis_raw.select( - pl.col("pcds").alias("postcode"), - pl.col("ctry25cd"), - ).unique(["postcode"]) - wide = wide.join(postcode_country, on="postcode", how="left") + arcgis = _active_english_postcode_area(arcgis_raw) + active_postcodes = arcgis.select("postcode").unique() + active_postcode_count = ( + active_postcodes.select(pl.len()).collect(engine="streaming").item() + ) + wide = _filter_to_active_english_postcodes(wide, active_postcodes) if listed_buildings_path is not None: active_postcodes_for_listed = ( @@ -1830,39 +1986,84 @@ def _build( arcgis_path, epc_path=actual_listings_epc_path, ) + wide = _filter_to_active_english_postcodes(wide, active_postcodes) wide = wide.with_columns(pl.col(LISTED_BUILDING_FEATURE).fill_null("No")) - arcgis = ( - arcgis_raw.filter(pl.col("ctry25cd") == "E92000001") # England only - .filter(pl.col("doterm").is_null()) # Active postcodes only - # NSPL Feb 2026 renamed geographic code columns to {field}{year}cd. - # Alias them back to the short canonical names used across the - # pipeline so downstream joins don't need to know about NSPL's - # versioning scheme. - .select( - pl.col("pcds").alias("postcode"), - "lat", - pl.col("long").alias("lon"), - pl.col("lsoa21cd").alias("lsoa21"), - pl.col("oa21cd").alias("oa21"), - pl.col("pcon24cd").alias("pcon"), - ) - ) + # NSPL Feb 2026 renamed geographic code columns to {field}{year}cd. + # `_active_english_postcode_area` aliases them back to the short canonical + # names used across the pipeline so downstream joins don't need to know + # about NSPL's versioning scheme. wide = wide.join(arcgis, on="postcode", how="left") + postcode_area = arcgis iod = pl.scan_parquet(iod_path).with_columns( *(_less_deprived_percentile_expr(c) for c in _IOD_PERCENTILE_COLUMNS) ) - wide = wide.join(iod, left_on="lsoa21", right_on="LSOA code (2021)", how="left") - ethnicity = pl.scan_parquet(ethnicity_path) - wide = wide.join( - ethnicity, - left_on="Local Authority District code (2024)", - right_on="Geography_code", - how="left", + crime = pl.scan_parquet(crime_path) + median_age = pl.scan_parquet(median_age_path) + election = pl.scan_parquet(election_results_path) + poi_counts = pl.scan_parquet(poi_proximity_path) + noise_cols = ["road_noise_lden_db", "rail_noise_lden_db", "airport_noise_lden_db"] + noise = ( + pl.scan_parquet(noise_path) + .with_columns( + # NaN → null so max_horizontal ignores missing instead of propagating NaN + *[pl.col(c).fill_nan(None) for c in noise_cols], + ) + .with_columns( + pl.max_horizontal(*noise_cols).alias("noise_lden_db"), + ) + .select("postcode", "noise_lden_db") ) + school_proximity = pl.scan_parquet(school_proximity_path) + conservation_areas = _conservation_area_by_postcode( + arcgis.select("postcode", "lat", "lon"), conservation_areas_path + ) + tree_density = None + if tree_density_postcodes_path is not None: + tree_density = _tree_density_by_postcode(tree_density_postcodes_path) + + # Broadband: derive max available download speed tier per postcode from + # Ofcom availability percentages. Tiers: Gigabit ≥1000, UFBB ≥300, + # UFBB(100) ≥100, SFBB ≥30 Mbps. Stored as a numeric (UInt16) Mbps value so + # it sorts/filters correctly; null (not a fabricated 10) when no availability + # tier is present, so "no data" is distinguishable from a genuine 10 Mbps. + broadband = ( + pl.scan_parquet(broadband_path) + .select( + pl.col("postcode_space").alias("bb_postcode"), + pl.when(pl.col("Gigabit availability (% premises)") > 0) + .then(1000) + .when(pl.col("UFBB availability (% premises)") > 0) + .then(300) + .when(pl.col("UFBB (100Mbit/s) availability (% premises)") > 0) + .then(100) + .when(pl.col("SFBB availability (% premises)") > 0) + .then(30) + .otherwise(None) + .cast(pl.UInt16) + .alias("max_download_speed"), + ) + .group_by("bb_postcode") + .agg(pl.col("max_download_speed").max()) + ) + area_side_tables = { + "iod": iod, + "ethnicity": ethnicity, + "crime": crime, + "median_age": median_age, + "election": election, + "poi_counts": poi_counts, + "noise": noise, + "school_proximity": school_proximity, + "conservation_areas": conservation_areas, + "tree_density": tree_density, + "broadband": broadband, + } + wide = _join_area_side_tables(wide, **area_side_tables) + postcode_area = _join_area_side_tables(postcode_area, **area_side_tables) # Derive bedroom count: habitable rooms - 1 (assuming 1 reception room), clipped to 0..4 wide = wide.with_columns( @@ -1881,103 +2082,6 @@ def _build( how="left", ) - crime = pl.scan_parquet(crime_path) - wide = wide.join(crime, left_on="lsoa21", right_on="LSOA code", how="left") - - wide = wide.with_columns( - pl.sum_horizontal( - "Violence and sexual offences (avg/yr)", - "Robbery (avg/yr)", - "Burglary (avg/yr)", - "Possession of weapons (avg/yr)", - ).alias("serious_crime_avg_yr"), - pl.sum_horizontal( - "Anti-social behaviour (avg/yr)", - "Criminal damage and arson (avg/yr)", - "Shoplifting (avg/yr)", - "Bicycle theft (avg/yr)", - "Theft from the person (avg/yr)", - "Other theft (avg/yr)", - "Vehicle crime (avg/yr)", - "Public order (avg/yr)", - "Drugs (avg/yr)", - "Other crime (avg/yr)", - ).alias("minor_crime_avg_yr"), - ) - - lsoa_pop = pl.scan_parquet(lsoa_population_path) - wide = wide.join(lsoa_pop, on="lsoa21", how="left") - wide = wide.with_columns( - pl.when(pl.col("population") > 0) - .then((pl.col("serious_crime_avg_yr") / pl.col("population") * 1000).round(1)) - .alias("serious_crime_per_1k"), - pl.when(pl.col("population") > 0) - .then((pl.col("minor_crime_avg_yr") / pl.col("population") * 1000).round(1)) - .alias("minor_crime_per_1k"), - ).drop("population") - - median_age = pl.scan_parquet(median_age_path) - wide = wide.join(median_age, on="lsoa21", how="left") - - election = pl.scan_parquet(election_results_path) - wide = wide.join(election, on="pcon", how="left") - - poi_counts = pl.scan_parquet(poi_proximity_path) - wide = wide.join(poi_counts, on="postcode", how="left") - - noise_cols = ["road_noise_lden_db", "rail_noise_lden_db", "airport_noise_lden_db"] - noise = ( - pl.scan_parquet(noise_path) - .with_columns( - # NaN → null so max_horizontal ignores missing instead of propagating NaN - *[pl.col(c).fill_nan(None) for c in noise_cols], - ) - .with_columns( - pl.max_horizontal(*noise_cols).alias("noise_lden_db"), - ) - .select("postcode", "noise_lden_db") - ) - wide = wide.join(noise, on="postcode", how="left") - - school_proximity = pl.scan_parquet(school_proximity_path) - wide = wide.join(school_proximity, on="postcode", how="left") - - conservation_areas = _conservation_area_by_postcode( - arcgis.select("postcode", "lat", "lon"), conservation_areas_path - ) - wide = wide.join(conservation_areas, on="postcode", how="left").with_columns( - pl.col(CONSERVATION_AREA_FEATURE).fill_null("No") - ) - - if tree_density_postcodes_path is not None: - tree_density = _tree_density_by_postcode(tree_density_postcodes_path) - wide = wide.join(tree_density, on="postcode", how="left") - - # Broadband: derive max available download speed tier per postcode from - # Ofcom availability percentages. Tiers: Gigabit ≥1000, UFBB ≥300, - # UFBB(100) ≥100, SFBB ≥30 Mbps. Stored as string enum. - broadband = ( - pl.scan_parquet(broadband_path) - .select( - pl.col("postcode_space").alias("bb_postcode"), - pl.when(pl.col("Gigabit availability (% premises)") > 0) - .then(1000) - .when(pl.col("UFBB availability (% premises)") > 0) - .then(300) - .when(pl.col("UFBB (100Mbit/s) availability (% premises)") > 0) - .then(100) - .when(pl.col("SFBB availability (% premises)") > 0) - .then(30) - .otherwise(10) - .cast(pl.UInt16) - .alias("max_download_speed"), - ) - .group_by("bb_postcode") - .agg(pl.col("max_download_speed").max()) - .with_columns(pl.col("max_download_speed").cast(pl.Utf8)) - ) - wide = wide.join(broadband, left_on="postcode", right_on="bb_postcode", how="left") - # Derive property_type: prefer EPC data, fall back to price-paid. # For Houses, use built_form (e.g. Semi-Detached, Mid-Terrace) for finer detail. bad_built_form = pl.col("built_form").is_null() | pl.col("built_form").is_in( @@ -2010,114 +2114,51 @@ def _build( .alias("property_type") ) - wide = ( - wide.with_columns( - pl.when(pl.col("duration") == "U") - .then(None) - .otherwise(pl.col("duration")) - .alias("duration"), - pl.when(pl.col("current_energy_rating") == "INVALID!") - .then(None) - .otherwise(pl.col("current_energy_rating")) - .alias("current_energy_rating"), + wide = wide.with_columns( + pl.when(pl.col("duration") == "U") + .then(None) + .otherwise(pl.col("duration")) + .alias("duration"), + pl.when(pl.col("current_energy_rating") == "INVALID!") + .then(None) + .otherwise(pl.col("current_energy_rating")) + .alias("current_energy_rating"), + ).with_columns( + # Null out implausible per-sqm values (outside the kNN comparable band): + # bulk/block transactions divided by a single unit's floor area otherwise + # produce figures up to ~£1.5M/sqm. + pl.when( + (pl.col("total_floor_area") > MIN_FLOOR_AREA_M2) + & ( + (pl.col("latest_price") / pl.col("total_floor_area")) + .is_between(MIN_COMPARABLE_PSM, MAX_COMPARABLE_PSM) + ) ) - .with_columns( - (pl.col("latest_price") / pl.col("total_floor_area")) - .round(0) - .cast(pl.Int32) - .alias("Price per sqm"), - ) - .drop( - "inspection_date", - "_bedrooms", - "LSOA name (2021)", - "Local Authority District code (2024)", - "Local Authority District name (2024)", - "Wider Barriers Sub-domain Score", - "Geographical Barriers Sub-domain Score", - "Adult Skills Sub-domain Score", - "Children and Young People Sub-domain Score", - "Crime Score", - "Living Environment Score", - "Index of Multiple Deprivation (IMD) Score", - "Income Deprivation Affecting Older People (IDAOPI) Score (rate)", - "Income Deprivation Affecting Children Index (IDACI) Score (rate)", - "Barriers to Housing and Services Score", - "oa21", - "pcon", - "epc_property_type", - "pp_property_type", - "built_form", - ) - .rename( - { - "date_of_transfer": "Date of last transaction", - "construction_age_band": "Construction year", - "is_construction_date_approximate": "Is construction date approximate", - "Income Score (rate)": "Income Score", - "Employment Score (rate)": "Employment Score", - "Indoors Sub-domain Score": "Housing Conditions Score", - "Outdoors Sub-domain Score": "Air Quality and Road Safety Score", - "pp_address": "Address per Property Register", - "epc_address": "Address per EPC", - "postcode": "Postcode", - "duration": "Leasehold/Freehold", - "current_energy_rating": "Current energy rating", - "potential_energy_rating": "Potential energy rating", - "total_floor_area": "Total floor area (sqm)", - "property_type": "Property type", - "restaurants_2km": "Number of restaurants within 2km", - "groceries_2km": "Number of grocery shops and supermarkets within 2km", - "latest_price": "Last known price", - "number_habitable_rooms": "Number of bedrooms & living rooms", - "noise_lden_db": "Noise (dB)", - "good_primary_5km": "Good+ primary schools within 5km", - "good_secondary_5km": "Good+ secondary schools within 5km", - "good_primary_2km": "Good+ primary schools within 2km", - "good_secondary_2km": "Good+ secondary schools within 2km", - "outstanding_primary_5km": "Outstanding primary schools within 5km", - "outstanding_secondary_5km": "Outstanding secondary schools within 5km", - "outstanding_primary_2km": "Outstanding primary schools within 2km", - "outstanding_secondary_2km": "Outstanding secondary schools within 2km", - "max_download_speed": "Max available download speed (Mbps)", - "serious_crime_avg_yr": "Serious crime (avg/yr)", - "minor_crime_avg_yr": "Minor crime (avg/yr)", - "serious_crime_per_1k": "Serious crime per 1k residents (avg/yr)", - "minor_crime_per_1k": "Minor crime per 1k residents (avg/yr)", - "mean_monthly_rent": "Estimated monthly rent", - "floor_height": "Interior height (m)", - "was_council_house": "Former council house", - "median_age": "Median age", - "turnout_pct": "Voter turnout (%)", - } + .then( + (pl.col("latest_price") / pl.col("total_floor_area")).round(0).cast(pl.Int32) ) + .otherwise(None) + .alias("Price per sqm"), ) + wide = _finalize_merged_columns(wide) + postcode_area = _finalize_merged_columns(postcode_area) print("Collecting with streaming engine...") - df = wide.collect(engine="streaming") if mode == "listings": + df = wide.collect(engine="streaming") enriched_listings = _finalize_listings(df) _validate_property_postcodes(enriched_listings) print(f"Enriched listings rows: {enriched_listings.height}") return _BuildResult(listings=enriched_listings) + df, postcode_features = pl.collect_all([wide, postcode_area], engine="streaming") _validate_property_postcodes(df) - # Split into postcode-level and property-level dataframes - area_cols = [ - c for c in df.columns if c in _AREA_COLUMNS or _is_dynamic_poi_metric_column(c) - ] - postcode_df = df.select(area_cols).group_by("Postcode").first() + postcode_df, properties_df = _split_normal_outputs( + df, postcode_features, expected_postcode_count=active_postcode_count + ) print(f"Postcode rows: {postcode_df.height} (unique postcodes)") - - property_cols = [ - c - for c in df.columns - if (c not in _AREA_COLUMNS and not _is_dynamic_poi_metric_column(c)) - or c == "Postcode" - ] - properties_df = df.select(property_cols) print(f"Property rows: {properties_df.height}") return _BuildResult(postcode=postcode_df, properties=properties_df) @@ -2189,12 +2230,6 @@ def main(): required=True, help="ONS rental prices by LA and bedroom count parquet file", ) - parser.add_argument( - "--lsoa-population", - type=Path, - required=True, - help="Census 2021 population by LSOA parquet file", - ) parser.add_argument( "--median-age", type=Path, @@ -2279,7 +2314,6 @@ def main(): broadband_path=args.broadband, conservation_areas_path=args.conservation_areas, rental_prices_path=args.rental_prices, - lsoa_population_path=args.lsoa_population, median_age_path=args.median_age, election_results_path=args.election_results, tree_density_postcodes_path=args.tree_density_postcodes, diff --git a/pipeline/transform/noise_overlay_tiles.py b/pipeline/transform/noise_overlay_tiles.py index 6ca8a40..671c9c8 100644 --- a/pipeline/transform/noise_overlay_tiles.py +++ b/pipeline/transform/noise_overlay_tiles.py @@ -164,19 +164,39 @@ def _read_noise_tile( for info in candidates: with rasterio.open(info.path) as source: + # The Defra rasters encode genuine "quiet / below threshold" as the + # value 0.0 (only -96.0 is true nodata). Mask both BEFORE + # reprojecting so resampling never blends a 0 cell into an adjacent + # loud corridor and fabricates a halo of intermediate dB. + # + # Lden values are dB (a logarithmic scale), so bilinear resampling + # would arithmetically average neighbouring dB cells, which is + # acoustically wrong (it diluted a 75 dB peak to ~53 dB in tests) + # and inconsistent with the postcode sampler. Use Resampling.max: + # it preserves peak corridors, never invents an intermediate dB + # between a masked (NaN) quiet cell and a loud one, and mirrors the + # max semantics of sample_noise_at_postcodes. + src_arr = source.read(1).astype(np.float32) + nodata = source.nodata + invalid = ~np.isfinite(src_arr) | (src_arr <= 0) + if nodata is not None: + invalid |= np.isclose( + src_arr, np.float32(nodata), rtol=1e-5, atol=1e-5 + ) + src_arr = np.where(invalid, np.float32("nan"), src_arr) tile = np.full((tile_size, tile_size), np.nan, dtype=np.float32) reproject( - source=rasterio.band(source, 1), + source=src_arr, destination=tile, src_transform=source.transform, src_crs=source.crs, - src_nodata=source.nodata if source.nodata is not None else 0, + src_nodata=float("nan"), dst_transform=from_bounds( left, bottom, right, top, tile_size, tile_size ), dst_crs=WEB_MERCATOR_CRS, dst_nodata=np.nan, - resampling=Resampling.bilinear, + resampling=Resampling.max, ) tile[~np.isfinite(tile) | (tile <= 0)] = np.nan @@ -376,7 +396,7 @@ def main() -> None: "--pmtiles-bin", type=Path, default=Path("property-data/pmtiles") ) parser.add_argument("--pmtiles-version", default="1.22.3") - parser.add_argument("--min-zoom", type=int, default=13) + parser.add_argument("--min-zoom", type=int, default=12) parser.add_argument("--max-zoom", type=int, default=14) parser.add_argument("--tile-size", type=int, default=256) args = parser.parse_args() diff --git a/pipeline/transform/poi_proximity.py b/pipeline/transform/poi_proximity.py index 8d970fb..d062397 100644 --- a/pipeline/transform/poi_proximity.py +++ b/pipeline/transform/poi_proximity.py @@ -12,11 +12,19 @@ from pipeline.utils.poi_counts import count_pois_per_postcode, min_distance_per_ # POI category groups for proximity counting (2km radius). # Names must match the friendly names produced by transform_poi.py / naptan.py. +# "groceries" is filled in dynamically by _groceries_categories() because the +# GEOLYTIX dataset stores the brand (e.g. "Tesco", "Aldi") in `category` rather +# than the literal "Supermarket"; counting only the OSM strings here severely +# understates the metric. See _groceries_categories below. POI_GROUPS_2KM = { "restaurants": ["Restaurant", "Fast Food"], - "groceries": ["Greengrocer", "Supermarket", "Convenience Store"], } +# POI group whose members are counted for the static "groceries" 2km metric. +# Covers both the OSM grocery categories (Supermarket, Convenience Store, +# Greengrocer, ...) and the GEOLYTIX brand categories (Tesco, Aldi, ...). +GROCERIES_GROUP = "Groceries" + # OS Open Greenspace function types used for park counts and distance calculation. # Uses the authoritative OS dataset instead of OSM point POIs for better coverage # of green spaces that are only mapped as polygons in OSM. @@ -41,6 +49,26 @@ def _poi_category_slug(category: str) -> str: return slug or "poi" +def _groceries_categories(pois: pl.DataFrame) -> list[str]: + """Return the distinct `category` values for the Groceries group. + + `count_pois_per_postcode` matches POIs on `category`, but the authoritative + GEOLYTIX grocery dataset stores the brand name there (e.g. "Tesco", "Aldi") + with group "Groceries"; it never emits the literal "Supermarket". Collecting + every Groceries category captures both the OSM strings and the brand names. + """ + if "group" not in pois.columns: + raise ValueError("POI dataframe must include a 'group' column") + return ( + pois.filter(pl.col("group") == GROCERIES_GROUP) + .select("category") + .unique() + .sort("category") + .to_series() + .to_list() + ) + + def _build_poi_category_groups( pois: pl.DataFrame, ) -> tuple[dict[str, list[str]], dict[str, str]]: @@ -122,9 +150,15 @@ def main(): pois = pl.read_parquet(args.pois) poi_category_groups, poi_display_names = _build_poi_category_groups(pois) - # Count static amenity groups within 2km. + # Count static amenity groups within 2km. "groceries" is matched against + # every Groceries category (OSM strings + GEOLYTIX brand names) so that + # postcodes ringed by GEOLYTIX-only chains (Tesco, Aldi, ...) are counted. + groups_2km = { + **POI_GROUPS_2KM, + "groceries": _groceries_categories(pois), + } counts_2km = count_pois_per_postcode( - postcodes, pois, groups=POI_GROUPS_2KM, radius_km=2 + postcodes, pois, groups=groups_2km, radius_km=2 ) # Dynamic amenity filters: nearest distance plus counts within 2km and 5km for diff --git a/pipeline/transform/postcode_boundaries/README.md b/pipeline/transform/postcode_boundaries/README.md index 15d96e3..01d6181 100644 --- a/pipeline/transform/postcode_boundaries/README.md +++ b/pipeline/transform/postcode_boundaries/README.md @@ -37,11 +37,11 @@ Pre-allocates numpy arrays at 25M capacity and grows by 1.5x if needed (using in **Loading** (`inspire.py:load_inspire`): Bboxes and offsets are loaded into RAM (~1.1GB). Coords are memory-mapped — the OS pages them in on demand from the ~3GB file, never loading the whole thing. -**Candidate retrieval** (`inspire.py:get_inspire_candidates`): Given an OA's bounding box, performs a vectorized numpy overlap test against all 24M INSPIRE bboxes — four comparisons broadcast across the entire array. Typically matches 10-500 parcels per OA. Only those matches are materialized as Shapely Polygon objects by reading their coordinate slice from the memory-mapped file. Invalid polygons are repaired with `make_valid`. +**Candidate retrieval** (`inspire.py:InspireIndex`): A uniform 1km grid index is built once over the 24M parcel bboxes (`build_inspire_index`). Each OA lookup (`InspireIndex.candidates`) gathers parcels from the cells its bounding box covers plus a small overflow list of parcels larger than one cell, then applies the exact bbox overlap test — O(cells + candidates) instead of an O(24M) scan per OA (the old linear scan was ~4h of the run on its own). The candidate set and order are identical to the scan. Typically matches 10-500 parcels per OA, materialized as Shapely Polygon objects by reading their coordinate slice from the memory-mapped file; invalid polygons are repaired with `make_valid`. ### Phase 3: Processing OAs -The main loop in `__main__.py` iterates through every OA that has both a boundary polygon and UPRNs. For each OA, it retrieves the OA's UPRN points and postcodes. +The main loop in `__main__.py` (`_process_oas`) iterates through every OA that has both a boundary polygon and UPRNs. For each OA, it retrieves the OA's UPRN points and postcodes. OAs are independent, so the loop fans out across CPU cores with a `fork` process pool (`--workers`, default all CPUs): workers share the big read-only inputs (INSPIRE arrays + coords mmap, UPRN arrays, OA geometries) copy-on-write and return WKB-encoded fragments. Workers slice the UPRN data from plain numpy/Arrow arrays (`extract_uprn_arrays`) rather than polars, avoiding the fork-after-threads hazard of polars' thread pool. Fragment order doesn't affect the output (`merge_fragments` unions per postcode), so the parallel result is identical to single-process. **Fast path**: If every UPRN in the OA shares the same postcode, the entire OA polygon is assigned to that postcode. No geometry computation needed. This covers the majority of OAs (~70-80%). @@ -77,9 +77,9 @@ The output of `process_oa` is `list[(postcode, polygon)]` — the per-OA fragmen ### Phase 4: Merging and writing -**Fragment merging** (`output.py:merge_fragments`): Groups all fragments by postcode, unions them. If the result is a MultiPolygon (meaning the postcode has disconnected pieces — either from spanning OAs with a gap, or algorithm artifacts), applies a 5m buffer-then-unbuffer to close tiny gaps from floating-point mismatches at OA boundary edges. If still a MultiPolygon after that, keeps only the largest polygon — postcodes are contiguous delivery routes, so detached fragments are artifacts. +**Fragment merging** (`output.py:merge_fragments`): Groups all fragments by postcode, unions them. If the result is a MultiPolygon (meaning the postcode has disconnected pieces — either from spanning OAs with a gap, or algorithm artifacts), applies a 5m buffer-then-unbuffer to close tiny gaps from floating-point mismatches at OA boundary edges. If still a MultiPolygon after that, keeps the largest part **plus any other part ≥ `_MIN_DETACHED_PART_AREA` (100 m²)** (`_keep_polygon_parts`); only sub-100 m² noise slivers are dropped. Keeping substantial detached parts matters because a postcode genuinely split across an OA seam (by a railway, river, or main road wider than the 5m buffer) would otherwise lose a chunk — measured at ~1.8% of merged area left as uncovered gaps (often 3000–5000 m² building blocks) before this change. -**GeoJSON output** (`output.py:write_district_geojson`): Groups postcodes by district (the outward code, e.g. `SW1A` from `SW1A 1AA`). For each district, converts every postcode polygon from BNG to WGS84 using pyproj, simplifies with 1m tolerance (Douglas-Peucker), rounds coordinates to 6 decimal places (~0.1m precision), and writes a single `{district}.geojson` FeatureCollection. Each Feature has `postcodes` (formatted like `"SW1A 1AA"`) and `mapit_code` (no space: `"SW1A1AA"`) in its properties. +**GeoJSON output** (`output.py:write_district_geojson`): Two passes. Pass 1 converts every postcode from BNG to WGS84 (pyproj), simplifies with 1m tolerance (Douglas-Peucker), and snaps to 6 decimal places (~0.1m precision); multi-part postcodes become `MultiPolygon` (`to_wgs84_geojson_multi`, each part handled independently), single-part stay `Polygon`. The whole set is then made a **partition** (`_resolve_overlaps`): each postcode is trimmed by the union of its higher-priority overlapping neighbours, where **priority = ascending area** (smaller postcodes win contested ground). That single rule handles both seam overlap *and* containment — an enclosed postcode is always smaller than its container, so it keeps its area while the container gets a hole (the query uses both the `overlaps` and `contains` predicates, since `overlaps` alone excludes containment). This runs last, so nothing re-introduces overlap; a postcode that would be emptied keeps its original geometry, so no active postcode is dropped. Pass 2 groups postcodes by district (the outward code, e.g. `SW1A` from `SW1A 1AA`), rounds coordinates to 6dp, and writes a `{district}.geojson` FeatureCollection. Each Feature has `postcodes` (formatted like `"SW1A 1AA"`) and `mapit_code` (no space: `"SW1A1AA"`) in its properties. ## Memory architecture @@ -103,10 +103,10 @@ Key design choices: ## Key invariants -1. **Every square meter of every OA is assigned to exactly one postcode** — the combination of INSPIRE claiming + Voronoi fills the entire OA, and overlap resolution ensures no double-counting +1. **No two postcodes cover the same ground in the output** — within an OA the INSPIRE claiming + Voronoi tile it with no overlap, and a final `_resolve_overlaps` partition pass removes the thin overlap strips that the merge buffer + per-postcode simplification introduce across OA seams (measured residual overlap ~0.01% of area) 2. **Every postcode that exists in the UPRN data gets a polygon** — unless all its UPRNs share coordinates with another postcode's UPRNs (handled by jitter) or it has zero UPRNs 3. **Postcode polygons never extend outside their OA(s)** — all geometry is clipped to OA boundaries -4. **Output is always single Polygon, never MultiPolygon** — the largest-polygon extraction in both `merge_fragments` and `to_wgs84_geojson` ensures this +4. **A postcode split across an OA seam keeps all its substantial parts** — `merge_fragments` keeps every part ≥ 100 m² and the output is emitted as a `MultiPolygon` (the Rust server `postcodes.rs` and `loader.py` both parse MultiPolygon); only sub-100 m² noise slivers are dropped ## Module structure diff --git a/pipeline/transform/postcode_boundaries/__main__.py b/pipeline/transform/postcode_boundaries/__main__.py index 9e2450c..c48b664 100644 --- a/pipeline/transform/postcode_boundaries/__main__.py +++ b/pipeline/transform/postcode_boundaries/__main__.py @@ -1,12 +1,21 @@ import argparse +import multiprocessing as mp +import os from pathlib import Path +import numpy as np +import shapely from shapely.geometry import MultiPolygon, Polygon from tqdm import tqdm +from .fragments_cache import ( + fragments_cache_is_fresh, + load_fragments, + save_fragments, +) from .inspire import ( + build_inspire_index, cache_inspire, - get_inspire_candidates, inspire_cache_exists, load_inspire, ) @@ -14,39 +23,156 @@ from .memory import release_memory from .oa_boundaries import load_oa_boundaries from .output import merge_fragments, write_district_geojson from .process_oa import process_oa -from .uprn import get_oa_uprns, load_uprns +from .uprn import extract_uprn_arrays, get_oa_uprns_arrays, load_uprns + +Fragment = tuple[str, Polygon | MultiPolygon] -def main() -> None: - parser = argparse.ArgumentParser( - description="Generate postcode boundary polygons from OA + INSPIRE + UPRN data" - ) - parser.add_argument("--uprn", type=Path, required=True, help="UPRN lookup parquet") - parser.add_argument( - "--oa-boundaries", type=Path, required=True, help="OA boundaries GeoPackage" - ) - parser.add_argument( - "--inspire", type=Path, required=True, help="INSPIRE ZIP directory" - ) - parser.add_argument("--output", type=Path, required=True, help="Output directory") - parser.add_argument( - "--limit", type=int, default=0, help="Process only first N OAs (0=all)" - ) - parser.add_argument( - "--greenspace", - type=Path, - default=None, - help="Greenspace/water parquet for boundary trimming (optional)", - ) - args = parser.parse_args() +def _oa_fragments( + oa_code, oa_geoms, east, north, postcodes_arr, offsets, index +) -> tuple[list[Fragment], bool]: + """Process one OA into ``(postcode, geometry)`` fragments. + Returns ``(fragments, is_single)``; ``is_single`` flags the single-postcode + fast path. Shared by the sequential and parallel drivers so both produce + identical output. Any failure is re-raised tagged with the OA code so a single + bad OA is attributable instead of an anonymous worker abort hours in. + """ + try: + oa_geom = oa_geoms[oa_code] + points, postcodes = get_oa_uprns_arrays( + east, north, postcodes_arr, offsets, oa_code + ) + if len(set(postcodes)) == 1: + return [(postcodes[0], oa_geom)], True + candidates = index.candidates(oa_geom.bounds) + return process_oa(oa_geom, points, postcodes, candidates), False + except Exception as exc: + raise RuntimeError(f"Failed processing OA {oa_code}: {exc!r}") from exc + + +# Worker-shared state. Populated in the parent before the pool forks; children +# inherit it copy-on-write (the numpy/Arrow buffers + coords mmap stay shared, +# never duplicated per worker). Read-only in workers. +_WORKER_STATE: dict = {} + + +def _process_oa_chunk(oa_codes: list[str]): + """Worker: turn a chunk of OA codes into WKB-encoded fragments. + + Geometries are returned as WKB (compact and lossless) rather than pickled + Shapely objects, to keep the IPC payload small. + """ + state = _WORKER_STATE + frags: list[Fragment] = [] + single = 0 + for oa_code in oa_codes: + oa_frags, is_single = _oa_fragments( + oa_code, + state["oa_geoms"], + state["east"], + state["north"], + state["postcodes"], + state["offsets"], + state["index"], + ) + frags.extend(oa_frags) + single += is_single + + if frags: + pcs = [pc for pc, _ in frags] + wkb = shapely.to_wkb(np.array([g for _, g in frags], dtype=object)) + else: + pcs, wkb = [], np.empty(0, dtype=object) + return pcs, wkb, single, len(oa_codes) + + +def _resolve_workers(requested: int) -> int: + """Worker count: the explicit value if >0, otherwise all available CPUs.""" + if requested and requested > 0: + return requested + try: + return max(1, len(os.sched_getaffinity(0))) + except AttributeError: + return max(1, os.cpu_count() or 1) + + +def _process_oas( + oa_codes, oa_geoms, east, north, postcodes_arr, offsets, index, workers +) -> tuple[list[Fragment], int]: + """Drive Phase 3 over every OA, fanning out across `workers` processes. + + OAs are independent, so the loop parallelises cleanly. ``fork`` lets workers + share the big read-only inputs (INSPIRE arrays + coords mmap, UPRN arrays, OA + geometries) copy-on-write instead of duplicating ~2GB each. Fragment order + does not affect the result (``merge_fragments`` unions per postcode), so + chunks are collected as they finish. Returns ``(fragments, single_count)``. + """ + all_fragments: list[Fragment] = [] + single_count = 0 + + if workers <= 1 or "fork" not in mp.get_all_start_methods(): + for oa_code in tqdm( + oa_codes, desc="Processing OAs", unit="OA", smoothing=0.01, miniters=100 + ): + oa_frags, is_single = _oa_fragments( + oa_code, oa_geoms, east, north, postcodes_arr, offsets, index + ) + all_fragments.extend(oa_frags) + single_count += is_single + return all_fragments, single_count + + _WORKER_STATE.update( + oa_geoms=oa_geoms, + east=east, + north=north, + postcodes=postcodes_arr, + offsets=offsets, + index=index, + ) + # Many small contiguous chunks → dynamic load balancing across workers (rural + # OAs cost far more than urban ones) while preserving mmap read locality. + chunk_size = max(1, len(oa_codes) // (workers * 16)) + chunks = [oa_codes[i : i + chunk_size] for i in range(0, len(oa_codes), chunk_size)] + print(f" Parallel: {workers} workers, {len(chunks)} chunks of ~{chunk_size} OAs") + + ctx = mp.get_context("fork") + try: + with ctx.Pool(processes=workers) as pool: + with tqdm( + total=len(oa_codes), desc="Processing OAs", unit="OA", smoothing=0.01 + ) as bar: + for pcs, wkb, single, n_oas in pool.imap_unordered( + _process_oa_chunk, chunks + ): + if len(wkb): + all_fragments.extend(zip(pcs, shapely.from_wkb(wkb))) + single_count += single + bar.update(n_oas) + finally: + # Drop references so Phase 4 doesn't keep the big inputs alive. + _WORKER_STATE.clear() + return all_fragments, single_count + + +def build_fragments(args: argparse.Namespace) -> list[Fragment]: + """Run Phases 1-3: load data, parse INSPIRE, process every OA into fragments. + + Returns the full ``(postcode, geometry)`` fragment list. The large + intermediate structures (OA/UPRN/INSPIRE arrays) are locals here, so they are + freed as soon as this function returns -- before the fragments are cached or + merged. + """ # Phase 1: Load all data print("=" * 60) print("Phase 1: Loading data") print("=" * 60) oa_geoms = load_oa_boundaries(args.oa_boundaries) - uprn_df, uprn_offsets = load_uprns(args.uprn) + uprn_df, uprn_offsets = load_uprns(args.uprn, args.arcgis) + # Convert UPRNs to fork-shareable numpy/Arrow arrays so parallel workers never + # call polars (avoids the fork-after-threads hazard of its rayon pool). + uprn_east, uprn_north, uprn_postcodes = extract_uprn_arrays(uprn_df) # Phase 2: Parse/load INSPIRE print() @@ -58,6 +184,7 @@ def main() -> None: if not inspire_cache_exists(inspire_cache_dir): cache_inspire(args.inspire, inspire_cache_dir) inspire_bboxes, inspire_offsets, inspire_coords = load_inspire(inspire_cache_dir) + inspire_index = build_inspire_index(inspire_bboxes, inspire_offsets, inspire_coords) # Phase 3: Process OAs print() @@ -77,42 +204,86 @@ def main() -> None: print(f" Skipped (no UPRNs): {skipped_no_uprn}") print(f" Skipped (no boundary): {skipped_no_boundary}") - all_fragments: list[tuple[str, Polygon | MultiPolygon]] = [] - single_count = 0 - multi_count = 0 - - for oa_code in tqdm( + # --limit is a debug mode → force deterministic single-process. + workers = 1 if args.limit > 0 else _resolve_workers(args.workers) + all_fragments, single_count = _process_oas( oa_codes_with_data, - desc="Processing OAs", - unit="OA", - smoothing=0.01, - miniters=100, - ): - oa_geom = oa_geoms[oa_code] - points, postcodes = get_oa_uprns(uprn_df, uprn_offsets, oa_code) - - if len(set(postcodes)) == 1: - # Fast path: entire OA = one postcode - all_fragments.append((postcodes[0], oa_geom)) - single_count += 1 - continue - - # Get INSPIRE candidates via bbox pre-filter - candidates = get_inspire_candidates( - oa_geom.bounds, inspire_bboxes, inspire_offsets, inspire_coords - ) - - fragments = process_oa(oa_geom, points, postcodes, candidates) - all_fragments.extend(fragments) - multi_count += 1 + oa_geoms, + uprn_east, + uprn_north, + uprn_postcodes, + uprn_offsets, + inspire_index, + workers, + ) + multi_count = len(oa_codes_with_data) - single_count print(f"\n Single-postcode OAs (fast path): {single_count}") print(f" Multi-postcode OAs (INSPIRE+Voronoi): {multi_count}") print(f" Total fragments: {len(all_fragments)}") - # Free data no longer needed - del oa_geoms, uprn_df, uprn_offsets - del inspire_bboxes, inspire_offsets, inspire_coords + return all_fragments + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Generate postcode boundary polygons from OA + INSPIRE + UPRN data" + ) + parser.add_argument("--uprn", type=Path, required=True, help="UPRN lookup parquet") + parser.add_argument( + "--arcgis", + type=Path, + default=None, + help="Optional ArcGIS postcode parquet used to remap terminated postcodes", + ) + parser.add_argument( + "--oa-boundaries", type=Path, required=True, help="OA boundaries GeoPackage" + ) + parser.add_argument( + "--inspire", type=Path, required=True, help="INSPIRE ZIP directory" + ) + parser.add_argument("--output", type=Path, required=True, help="Output directory") + parser.add_argument( + "--limit", type=int, default=0, help="Process only first N OAs (0=all)" + ) + parser.add_argument( + "--workers", + type=int, + default=0, + help="Parallel worker processes for OA processing (0=all CPUs, 1=sequential)", + ) + parser.add_argument( + "--greenspace", + type=Path, + default=None, + help="Greenspace/water parquet for boundary trimming (optional)", + ) + args = parser.parse_args() + + fragments_cache = args.output / "fragments_cache.parquet" + # Phase 3 depends only on these inputs; greenspace is applied later (Phase 4), + # so a greenspace change must not invalidate the fragment cache. + fragment_inputs = [args.uprn, args.arcgis, args.oa_boundaries, args.inspire] + # --limit yields a partial fragment set; never read or write the shared cache. + use_cache = args.limit == 0 + + if use_cache and fragments_cache_is_fresh(fragments_cache, fragment_inputs): + print("=" * 60) + print("Phase 3 cache hit — loading fragments (skipping Phases 1-3)") + print("=" * 60) + all_fragments = load_fragments(fragments_cache) + print( + f" Loaded {len(all_fragments):,} cached fragments from {fragments_cache}" + ) + else: + all_fragments = build_fragments(args) + if use_cache: + # Persist the expensive Phase-3 output before the cheap-but-fragile + # merge/write so any failure there resumes in seconds, not ~10 hours. + save_fragments(fragments_cache, all_fragments) + print(f" Cached {len(all_fragments):,} fragments to {fragments_cache}") + + # Free Phase-1-3 intermediates (build_fragments' locals) back to the OS. release_memory() # Phase 4: Merge and write @@ -139,6 +310,12 @@ def main() -> None: file_count = write_district_geojson(merged, args.output) print(f"\n Wrote {file_count} district GeoJSON files to {args.output / 'units'}") + + # The cache exists only to survive a crash between Phase 3 and a clean write. + # Now that the output is complete, drop it so a later input change can never + # be served from a stale cache. + if use_cache: + fragments_cache.unlink(missing_ok=True) print("Done!") diff --git a/pipeline/transform/postcode_boundaries/fragments_cache.py b/pipeline/transform/postcode_boundaries/fragments_cache.py new file mode 100644 index 0000000..5fa5b4d --- /dev/null +++ b/pipeline/transform/postcode_boundaries/fragments_cache.py @@ -0,0 +1,79 @@ +"""Persist Phase-3 output (the per-postcode fragments) so a crash in the later +merge/write phases can resume in seconds instead of re-running the ~10-hour OA +loop. + +Phase 3 turns OA boundaries + INSPIRE parcels + UPRN points into ~1.8M +``(postcode, geometry)`` fragments held only in memory. Everything after it +(merge, simplify, GeoJSON write) is cheap but failure-prone -- a single +degenerate postcode used to abort the whole run *after* those 10 hours. Caching +the fragments to disk decouples the expensive computation from the fragile +output stage. + +Fragments are stored as one parquet file with two columns: ``postcode`` +(string) and ``wkb`` (binary Shapely WKB). Writes are atomic (temp file + +``os.replace``) so an interrupted write never leaves a cache that passes the +freshness check. The cache is validated against its upstream inputs by mtime: if +any input is newer than the cache it is treated as stale and ignored, mirroring +make's own freshness logic. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +import numpy as np +import polars as pl +import shapely +from shapely.geometry.base import BaseGeometry + +Fragment = tuple[str, BaseGeometry] + + +def _tmp_path(cache_path: Path) -> Path: + return cache_path.parent / (cache_path.name + ".tmp") + + +def fragments_cache_is_fresh( + cache_path: Path, inputs: list[Path | None] +) -> bool: + """True if ``cache_path`` exists and is newer than every input that exists. + + A missing input is ignored (it cannot have changed the cache); a ``None`` + input is skipped. Any existing input newer than the cache marks it stale. + """ + if not cache_path.exists(): + return False + cache_mtime = cache_path.stat().st_mtime + for inp in inputs: + if inp is None: + continue + path = Path(inp) + if path.exists() and path.stat().st_mtime > cache_mtime: + return False + return True + + +def save_fragments(cache_path: Path, fragments: list[Fragment]) -> None: + """Atomically write ``(postcode, geometry)`` fragments to a parquet cache.""" + postcodes = [pc for pc, _ in fragments] + geoms = np.array([geom for _, geom in fragments], dtype=object) + wkb = shapely.to_wkb(geoms) + + frame = pl.DataFrame( + {"postcode": postcodes, "wkb": list(wkb)}, + schema={"postcode": pl.Utf8, "wkb": pl.Binary}, + ) + + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp = _tmp_path(cache_path) + frame.write_parquet(tmp, compression="zstd") + os.replace(tmp, cache_path) + + +def load_fragments(cache_path: Path) -> list[Fragment]: + """Read fragments written by :func:`save_fragments` back into memory.""" + frame = pl.read_parquet(cache_path) + postcodes = frame["postcode"].to_list() + geoms = shapely.from_wkb(frame["wkb"].to_list()) + return list(zip(postcodes, geoms)) diff --git a/pipeline/transform/postcode_boundaries/geometry.py b/pipeline/transform/postcode_boundaries/geometry.py new file mode 100644 index 0000000..3fd85a4 --- /dev/null +++ b/pipeline/transform/postcode_boundaries/geometry.py @@ -0,0 +1,123 @@ +"""Robust GEOS overlay helpers. + +Overlay operations (union, difference, intersection) can raise a +``GEOSException`` — most often ``TopologyException: side location conflict``, +``Ring edge missing``, or ``found non-noded intersection`` — on geometries that +contain near-coincident or near-degenerate edges, or that are individually +invalid. The robust remedy is a *fixed-precision* overlay: GEOS's OverlayNG +engine, handed a grid size, nodes every edge onto that grid and finishes where +the full-precision overlay cannot. + +Getting the fallback to actually survive takes three rules, each of which we +learned the hard way from a crash: + +1. **Never precision-reduce with the default mode.** ``set_precision``'s default + ``valid_output`` (and ``keep_collapsed``) mode runs its *own* noding pass that + re-raises the very ``side location conflict`` we are trying to escape. We push + the grid into the overlay via the ``grid_size`` argument instead — where + OverlayNG nodes robustly — and only ever call ``set_precision`` in + ``pointwise`` mode (pure coordinate rounding, which cannot raise). +2. **Validate first.** ``make_valid`` repairs the self-intersections (bow-ties, + pinches) that make GEOS choke, so the overlay starts from an OGC-valid shape. +3. **Keep only polygonal parts.** ``make_valid`` of a spiky polygon routinely + returns a *mixed-dimension* ``GeometryCollection`` (a polygon plus a dangling + line), and OverlayNG rejects mixed-dimension input with + ``IllegalArgumentException: Overlay input is mixed-dimension``. Every input + here represents an area, so the line/point debris is meaningless noise and is + dropped before the overlay. + +The default fallback grid is 0.1 mm in the metre-based working CRS +(EPSG:27700), far below survey resolution and the ``MIN_GEOM_AREA`` threshold +downstream, so it cannot crush a postcode-scale shape. Callers operating in a +different CRS (e.g. the WGS84 output stage, where the same 1e-4 grid would be +~11 m) pass a ``grid`` matched to that CRS. The whole fallback runs ONLY after +the exact operation has already failed, so normal output is bit-for-bit +unchanged. +""" + +import shapely +from shapely import GEOSException, make_valid, set_precision +from shapely.geometry import Polygon +from shapely.ops import unary_union + +# 0.1 mm in metres — well below MIN_GEOM_AREA (0.01 m^2) and survey resolution. +_SNAP_GRID = 1e-4 + +_EMPTY = Polygon() + + +def _poly_valid(geom, grid): + """Return an OGC-valid, polygonal-only version of ``geom``. + + Repairs invalidity with ``make_valid`` and discards any non-polygonal debris + (dangling lines/points) it leaves behind, since OverlayNG rejects + mixed-dimension ``GeometryCollection`` input. The result is always a + ``Polygon``/``MultiPolygon`` (possibly empty), safe to feed to a + fixed-precision overlay. + """ + g = geom if geom.is_valid else make_valid(geom) + if g.geom_type in ("Polygon", "MultiPolygon"): + return g + if g.geom_type == "GeometryCollection": + polys = [ + p + for p in g.geoms + if p.geom_type in ("Polygon", "MultiPolygon") and not p.is_empty + ] + if not polys: + return _EMPTY + if len(polys) == 1: + return polys[0] + # Dissolve on the grid (robust) rather than building a possibly-invalid + # MultiPolygon of overlapping parts. + return shapely.union_all(polys, grid_size=grid) + return _EMPTY # line / point only + + +def _snap_poly_valid(geom, grid): + """Coordinate-round onto ``grid`` (``pointwise`` never raises), then clean.""" + return _poly_valid(set_precision(geom, grid, mode="pointwise"), grid) + + +def safe_union(geoms, grid=_SNAP_GRID): + """``unary_union`` that survives GEOS robustness failures.""" + try: + return unary_union(geoms) + except GEOSException: + pass + cleaned = [_poly_valid(g, grid) for g in geoms] + try: + return shapely.union_all(cleaned, grid_size=grid) + except GEOSException: + snapped = [_snap_poly_valid(g, grid) for g in geoms] + return shapely.union_all(snapped, grid_size=grid) + + +def safe_difference(a, b, grid=_SNAP_GRID): + """``a.difference(b)`` that survives GEOS robustness failures.""" + try: + return a.difference(b) + except GEOSException: + pass + a2, b2 = _poly_valid(a, grid), _poly_valid(b, grid) + try: + return shapely.difference(a2, b2, grid_size=grid) + except GEOSException: + return shapely.difference( + _snap_poly_valid(a2, grid), _snap_poly_valid(b2, grid), grid_size=grid + ) + + +def safe_intersection(a, b, grid=_SNAP_GRID): + """``a.intersection(b)`` that survives GEOS robustness failures.""" + try: + return a.intersection(b) + except GEOSException: + pass + a2, b2 = _poly_valid(a, grid), _poly_valid(b, grid) + try: + return shapely.intersection(a2, b2, grid_size=grid) + except GEOSException: + return shapely.intersection( + _snap_poly_valid(a2, grid), _snap_poly_valid(b2, grid), grid_size=grid + ) diff --git a/pipeline/transform/postcode_boundaries/greenspace.py b/pipeline/transform/postcode_boundaries/greenspace.py index 898474b..2d81c8f 100644 --- a/pipeline/transform/postcode_boundaries/greenspace.py +++ b/pipeline/transform/postcode_boundaries/greenspace.py @@ -5,9 +5,10 @@ from pathlib import Path import polars as pl from shapely import wkb from shapely.geometry import MultiPolygon, Polygon -from shapely.ops import unary_union from shapely.strtree import STRtree +from .geometry import safe_difference, safe_union + def load_greenspace(path: Path) -> tuple[STRtree, list]: """Load greenspace parquet and build an STRtree spatial index. @@ -51,8 +52,8 @@ def subtract_greenspace( if not intersecting: return postcode_geom - green_union = unary_union(intersecting) - result = postcode_geom.difference(green_union) + green_union = safe_union(intersecting) + result = safe_difference(postcode_geom, green_union) if result.is_empty: return postcode_geom diff --git a/pipeline/transform/postcode_boundaries/inspire.py b/pipeline/transform/postcode_boundaries/inspire.py index 515fa8c..ae2bf67 100644 --- a/pipeline/transform/postcode_boundaries/inspire.py +++ b/pipeline/transform/postcode_boundaries/inspire.py @@ -112,44 +112,130 @@ def load_inspire( return bboxes, offsets, coords_mmap -def get_inspire_candidates( - oa_bounds: tuple[float, float, float, float], +# Grid cell size (m) for the parcel spatial index. The median parcel is ~25 m +# and the 99th percentile ~540 m, so almost every parcel fits inside a single +# 1 km cell; the ~0.4% larger than a cell go to an overflow list tested on every +# query. +_GRID_CELL_SIZE = 1000.0 + + +class InspireIndex: + """Uniform-grid spatial index over INSPIRE parcel bounding boxes. + + The per-OA candidate lookup used to linear-scan all ~24M bboxes (O(N) per + OA, ~4 h total over the country). This indexes parcels by grid cell so each + lookup is O(cells_spanned + candidates). Parcels no larger than one cell are + bucketed by their bbox min-corner cell in a CSR layout (parcel indices sorted + by cell id, located with ``searchsorted``); the few parcels larger than a + cell are kept in an overflow array tested directly on every query. An exact + bbox test then runs on the gathered subset and the result is sorted, so the + candidate set -- and its order -- is byte-for-byte identical to the old scan. + """ + + def __init__( + self, + bboxes: np.ndarray, + offsets: np.ndarray, + coords_mmap: np.memmap, + cell_size: float = _GRID_CELL_SIZE, + ) -> None: + self._bboxes = bboxes + self._offsets = offsets + self._coords = coords_mmap + self._cell_size = cell_size + self._origin_x = float(bboxes[:, 0].min()) + self._origin_y = float(bboxes[:, 1].min()) + # Flattened cell id is ``cx * _ny + cy``; +2 leaves a guard row so the + # query's one-cell low-edge widening can never collide with cx-1. + self._ny = int((bboxes[:, 1].max() - self._origin_y) // cell_size) + 2 + + width = bboxes[:, 2] - bboxes[:, 0] + height = bboxes[:, 3] - bboxes[:, 1] + small = np.where((width <= cell_size) & (height <= cell_size))[0] + self._oversized = np.where((width > cell_size) | (height > cell_size))[0] + self._oversized_bb = bboxes[self._oversized] + + cx = ((bboxes[small, 0] - self._origin_x) // cell_size).astype(np.int64) + cy = ((bboxes[small, 1] - self._origin_y) // cell_size).astype(np.int64) + cell_id = cx * self._ny + cy + order = np.argsort(cell_id, kind="stable") + self._sorted_cells = cell_id[order] + self._cell_parcels = small[order] + + def candidate_indices(self, oa_bounds: tuple[float, float, float, float]) -> np.ndarray: + """Parcel indices whose bbox overlaps ``oa_bounds`` (ascending order).""" + min_e, min_n, max_e, max_n = oa_bounds + cs = self._cell_size + # A small parcel (<= one cell) overlapping the OA has its min-corner no + # more than one cell below/left of the OA bbox, so widen the low edges by + # a cell. This keeps the lookup free of false negatives. + gx0 = int((min_e - cs - self._origin_x) // cs) + gx1 = int((max_e - self._origin_x) // cs) + gy_lo = int((min_n - cs - self._origin_y) // cs) + gy_hi = int((max_n - self._origin_y) // cs) + + parts = [] + ob = self._oversized_bb + if len(ob): + mo = ( + (ob[:, 2] >= min_e) + & (ob[:, 0] <= max_e) + & (ob[:, 3] >= min_n) + & (ob[:, 1] <= max_n) + ) + if mo.any(): + parts.append(self._oversized[mo]) + + for gx in range(gx0, gx1 + 1): + base = gx * self._ny + lo = np.searchsorted(self._sorted_cells, base + gy_lo, "left") + hi = np.searchsorted(self._sorted_cells, base + gy_hi, "right") + if hi > lo: + parts.append(self._cell_parcels[lo:hi]) + + if not parts: + return np.empty(0, dtype=np.int64) + cand = np.concatenate(parts) + cb = self._bboxes[cand] + mask = ( + (cb[:, 2] >= min_e) + & (cb[:, 0] <= max_e) + & (cb[:, 3] >= min_n) + & (cb[:, 1] <= max_n) + ) + # Sort so the candidate order matches the old full np.where scan exactly. + return np.sort(cand[mask]) + + def candidates( + self, oa_bounds: tuple[float, float, float, float] + ) -> list[Polygon]: + """INSPIRE polygons overlapping an OA, built from the mmap on demand. + + Builds Shapely objects only for matches (typically 10-500 per OA). + """ + candidates = [] + for i in self.candidate_indices(oa_bounds): + byte_offset = self._offsets[i, 0] + n_pts = self._offsets[i, 1] + float_offset = byte_offset // 8 # float64 = 8 bytes + coords = self._coords[float_offset : float_offset + n_pts * 2].reshape(-1, 2) + poly = Polygon(coords) + if not poly.is_valid: + poly = make_valid(poly) + if poly.geom_type == "MultiPolygon": + poly = max(poly.geoms, key=lambda g: g.area) + elif poly.geom_type != "Polygon": + continue + if not poly.is_empty: + candidates.append(poly) + return candidates + + +def build_inspire_index( bboxes: np.ndarray, offsets: np.ndarray, coords_mmap: np.memmap, -) -> list[Polygon]: - """Get INSPIRE polygons overlapping an OA via bbox pre-filter. - - Builds Shapely objects only for matches (typically 10-500 per OA). - Reads coordinate data on-demand from memory-mapped file. - """ - min_e, min_n, max_e, max_n = oa_bounds - - # Vectorized bbox overlap test - mask = ( - (bboxes[:, 2] >= min_e) - & (bboxes[:, 0] <= max_e) - & (bboxes[:, 3] >= min_n) - & (bboxes[:, 1] <= max_n) - ) - idxs = np.where(mask)[0] - if len(idxs) == 0: - return [] - - # Build Shapely polygons only for candidates (coords from mmap) - candidates = [] - for i in idxs: - byte_offset = offsets[i, 0] - n_pts = offsets[i, 1] - float_offset = byte_offset // 8 # float64 = 8 bytes - coords = coords_mmap[float_offset : float_offset + n_pts * 2].reshape(-1, 2) - poly = Polygon(coords) - if not poly.is_valid: - poly = make_valid(poly) - if poly.geom_type == "MultiPolygon": - poly = max(poly.geoms, key=lambda g: g.area) - elif poly.geom_type != "Polygon": - continue - if not poly.is_empty: - candidates.append(poly) - return candidates + cell_size: float = _GRID_CELL_SIZE, +) -> InspireIndex: + """Build the grid spatial index used for per-OA candidate retrieval.""" + return InspireIndex(bboxes, offsets, coords_mmap, cell_size) diff --git a/pipeline/transform/postcode_boundaries/loader.py b/pipeline/transform/postcode_boundaries/loader.py new file mode 100644 index 0000000..c495fac --- /dev/null +++ b/pipeline/transform/postcode_boundaries/loader.py @@ -0,0 +1,105 @@ +"""Load per-district postcode boundary GeoJSONs as EPSG:27700 polygons. + +The postcode-boundary pipeline (:mod:`output`) writes one WGS84 GeoJSON per +postcode district under ``units/{district}.geojson``, each feature carrying a +``postcodes`` (full unit string, e.g. "AL1 1AG") property. Spatial transforms +that test points against postcode geometry want those polygons back in British +National Grid (EPSG:27700) so buffers/distances are in metres. + +:func:`load_postcode_polygons` reads the files, reprojects WGS84→27700, repairs +invalid rings, and returns parallel ``(postcodes, polygons)`` arrays sorted by +postcode so callers can use the array index as a stable postcode id -- the same +"buffer index == postcode index" convention used by ``tree_density``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import numpy as np +import shapely +from pyproj import Transformer + + +def _read_district( + path: Path, transformer: Transformer +) -> tuple[np.ndarray, np.ndarray]: + """Return (postcodes, polygons_27700) for one district GeoJSON.""" + with path.open() as file: + collection = json.load(file) + + features = collection.get("features", []) + if not features: + return np.empty(0, dtype=object), np.empty(0, dtype=object) + + postcodes = np.array( + [feature["properties"]["postcodes"] for feature in features], dtype=object + ) + geom_json = np.array( + [json.dumps(feature["geometry"]) for feature in features], dtype=object + ) + geoms = shapely.from_geojson(geom_json) + + # Reproject every vertex in a single pyproj call, then rebuild the polygons. + coords = shapely.get_coordinates(geoms) + if coords.size: + x, y = transformer.transform(coords[:, 0], coords[:, 1]) + geoms = shapely.set_coordinates(geoms, np.column_stack([x, y])) + + invalid = ~shapely.is_valid(geoms) + if invalid.any(): + geoms[invalid] = shapely.make_valid(geoms[invalid]) + + return postcodes, geoms + + +def load_postcode_polygons( + units_dir: Path, max_postcodes: int | None = None +) -> tuple[np.ndarray, np.ndarray]: + """Load all postcode polygons under ``units_dir`` reprojected to EPSG:27700. + + Returns ``(postcodes, polygons)`` parallel object arrays sorted by postcode. + ``max_postcodes`` (testing) keeps only the lexicographically-first N + postcodes, reading just enough district files to reach the cap. + """ + units_dir = Path(units_dir) + files = sorted(units_dir.glob("*.geojson")) + if not files: + raise FileNotFoundError(f"No postcode-boundary GeoJSONs found in {units_dir}") + + transformer = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) + postcode_chunks: list[np.ndarray] = [] + geom_chunks: list[np.ndarray] = [] + total = 0 + for path in files: + postcodes, geoms = _read_district(path, transformer) + if len(postcodes) == 0: + continue + postcode_chunks.append(postcodes) + geom_chunks.append(geoms) + total += len(postcodes) + if max_postcodes is not None and total >= max_postcodes: + break + + if not postcode_chunks: + raise ValueError(f"No postcode features found in {units_dir}") + + postcodes = np.concatenate(postcode_chunks) + geoms = np.concatenate(geom_chunks) + + # Stable postcode order makes "index == postcode id" deterministic; dedupe + # defensively (a postcode lives in exactly one district file). + order = np.argsort(postcodes, kind="stable") + postcodes = postcodes[order] + geoms = geoms[order] + _, first = np.unique(postcodes, return_index=True) + postcodes = postcodes[first] + geoms = geoms[first] + + if max_postcodes is not None and len(postcodes) > max_postcodes: + postcodes = postcodes[:max_postcodes] + geoms = geoms[:max_postcodes] + + print(f"Loaded {len(postcodes):,} postcode polygons from {units_dir}") + return postcodes, geoms diff --git a/pipeline/transform/postcode_boundaries/output.py b/pipeline/transform/postcode_boundaries/output.py index 7aa010c..fa881fc 100644 --- a/pipeline/transform/postcode_boundaries/output.py +++ b/pipeline/transform/postcode_boundaries/output.py @@ -1,13 +1,18 @@ import json +import shutil from collections import defaultdict from pathlib import Path +import numpy as np from pyproj import Transformer -from shapely import make_valid -from shapely.geometry import MultiPolygon, Polygon -from shapely.ops import unary_union +from shapely import STRtree, make_valid, set_precision +from shapely.errors import GEOSException +from shapely.geometry import MultiPolygon, Polygon, box, mapping, shape +from shapely.ops import transform as transform_geometry from tqdm import tqdm +from .geometry import safe_difference, safe_union + _to_wgs84 = None @@ -18,65 +23,171 @@ def _get_to_wgs84(): return _to_wgs84 +def _largest_polygonal(geom) -> Polygon | None: + if geom is None or geom.is_empty: + return None + if not geom.is_valid: + geom = make_valid(geom) + if geom.geom_type == "Polygon": + return geom + if geom.geom_type == "MultiPolygon": + return max(geom.geoms, key=lambda g: g.area) + if geom.geom_type == "GeometryCollection": + polygons = [ + polygon + for part in geom.geoms + if (polygon := _largest_polygonal(part)) is not None + ] + if polygons: + return max(polygons, key=lambda g: g.area) + return None + + +# Output coordinate grid (~0.11 m at UK latitudes). Polygons whose extent is +# below this in any direction snap to empty during serialization. +_OUTPUT_PRECISION_DEG = 0.000001 +# Minimal BNG buffer used to rescue sub-grid slivers into a representable +# footprint. A near-zero-area Voronoi/INSPIRE spike (e.g. three almost-collinear +# vertices) would otherwise vanish at output precision; since every *active* +# postcode must keep a boundary (validate_outputs enforces this with zero +# tolerance), we fatten it just enough to survive snapping rather than drop it. +_MIN_FOOTPRINT_BUFFER_M = 0.5 + + +def _snap_to_wgs84_geojson(geom_bng: Polygon | MultiPolygon) -> dict | None: + """Transform a BNG polygon to WGS84, snap to output precision, validate. + + Validates the *serialized* GeoJSON dict (via a ``shape()`` round-trip), not + just the intermediate Shapely object: coordinate snapping during + serialization can otherwise leave a self-intersecting ring that only shows up + once the feature is read back from disk. Returns ``None`` if the geometry + collapses to empty (a sub-grid sliver). + """ + transformer = _get_to_wgs84() + wgs84 = transform_geometry(transformer.transform, geom_bng) + try: + wgs84 = set_precision(wgs84, _OUTPUT_PRECISION_DEG, mode="valid_output") + except GEOSException: + # Precision snapping can fail on pathological geometries; fall back to a + # plain validity repair without coordinate snapping. + wgs84 = make_valid(wgs84) + wgs84 = _largest_polygonal(wgs84) + if wgs84 is None: + return None + + geojson_dict = mapping(wgs84) + + # The geometry that actually reaches disk is the GeoJSON dict, so validate + # *that* (not the pre-serialization object) and repair if needed. + round_trip = shape(geojson_dict) + if round_trip.is_empty or not round_trip.is_valid: + round_trip = _largest_polygonal(make_valid(round_trip)) + if round_trip is None or round_trip.is_empty: + return None + geojson_dict = mapping(round_trip) + + return geojson_dict + + +def _rescue_footprint(geom_bng) -> dict | None: + """Fatten a degenerate BNG geometry into a representable footprint and snap.""" + footprint = _largest_polygonal(geom_bng.buffer(_MIN_FOOTPRINT_BUFFER_M)) + if footprint is None: + return None + return _snap_to_wgs84_geojson(footprint) + + def to_wgs84_geojson( geom: Polygon | MultiPolygon, tolerance: float = 1.0 ) -> dict | None: - """Simplify geometry in BNG, convert to WGS84, return GeoJSON dict.""" - if geom.is_empty: + """Simplify geometry in BNG, convert to WGS84, return a valid GeoJSON dict. + + A few thousand postcodes reduce to a sub-grid sliver that snaps to empty at + output precision. Dropping them would leave an active postcode with no + boundary (validate_outputs rejects that with zero tolerance), so instead they + are fattened into a minimal footprint at the right location: first by buffering + the (often elongated) sliver itself, then -- for fully-degenerate input -- a + small disc around ``representative_point()``, which lies inside any non-empty + geometry. ``None`` is returned only for a genuinely empty input. + """ + if geom is None or geom.is_empty: return None - simplified = geom.simplify(tolerance, preserve_topology=True) - if simplified.is_empty: + cleaned = _largest_polygonal(geom) + if cleaned is not None: + simplified = _largest_polygonal( + cleaned.simplify(tolerance, preserve_topology=True) + ) + if simplified is None: + simplified = cleaned + # Normal path; if snapping erases a thin sliver, fatten its real shape. + result = _snap_to_wgs84_geojson(simplified) + if result is None: + result = _rescue_footprint(simplified) + if result is not None: + return result + + # Universal fallback for input too degenerate to clean or fatten in place. + return _rescue_footprint(geom.representative_point()) + + +def to_wgs84_geojson_multi( + geom: Polygon | MultiPolygon, tolerance: float = 1.0 +) -> dict | None: + """Convert a (possibly multi-part) postcode geometry to a GeoJSON dict, + preserving every part. Each part is simplified/snapped/rescued independently + via :func:`to_wgs84_geojson`; the result is a ``Polygon`` for a single part or + a ``MultiPolygon`` for several. ``None`` only if every part is degenerate. + """ + parts = list(geom.geoms) if geom.geom_type == "MultiPolygon" else [geom] + part_dicts = [d for part in parts if (d := to_wgs84_geojson(part, tolerance))] + if not part_dicts: return None - - transformer = _get_to_wgs84() - - def transform_ring(coords): - xs, ys = zip(*coords) - lons, lats = transformer.transform(list(xs), list(ys)) - return [(round(lon, 6), round(lat, 6)) for lon, lat in zip(lons, lats)] - - def transform_polygon(poly): - exterior = transform_ring(poly.exterior.coords) - holes = [transform_ring(h.coords) for h in poly.interiors] - return [exterior] + holes - - # Force single Polygon — postcodes are contiguous delivery routes - if simplified.geom_type == "MultiPolygon": - simplified = max(simplified.geoms, key=lambda g: g.area) - elif simplified.geom_type == "GeometryCollection": - polys = [ - g for g in simplified.geoms if g.geom_type in ("Polygon", "MultiPolygon") - ] - if not polys: - return None - simplified = max(polys, key=lambda g: g.area) - if simplified.geom_type == "MultiPolygon": - simplified = max(simplified.geoms, key=lambda g: g.area) - - if simplified.geom_type != "Polygon" or simplified.is_empty: - return None - + if len(part_dicts) == 1: + return part_dicts[0] return { - "type": "Polygon", - "coordinates": transform_polygon(simplified), + "type": "MultiPolygon", + "coordinates": [pd["coordinates"] for pd in part_dicts], } +# Interior holes from the INSPIRE+Voronoi+make_valid chain are small artifacts and +# get filled. A hole at least this large is likely a genuinely enclosed postcode +# (kept, so we never solidify over a neighbour); the de-overlap pass is the real +# guarantee, this is defence-in-depth. +_MAX_ARTIFACT_HOLE_AREA = 1000.0 + + +def _fill_small_holes(poly: Polygon) -> Polygon: + kept = [r for r in poly.interiors if Polygon(r).area >= _MAX_ARTIFACT_HOLE_AREA] + return Polygon(poly.exterior, kept) + + def _fill_holes(geom): - """Remove all interior rings (holes) from a polygon or multipolygon.""" + """Fill small artifact interior rings; keep large (real-enclosed) holes.""" if geom.geom_type == "Polygon": - return Polygon(geom.exterior) + return _fill_small_holes(geom) elif geom.geom_type == "MultiPolygon": - return MultiPolygon([Polygon(p.exterior) for p in geom.geoms]) + return MultiPolygon([_fill_small_holes(p) for p in geom.geoms]) return geom -def _largest_polygon(geom): - """Extract the largest polygon from a MultiPolygon.""" - if geom.geom_type == "MultiPolygon": - return max(geom.geoms, key=lambda g: g.area) - return geom +# A postcode genuinely split across an OA seam (by a railway, river, or main road +# wider than the merge buffer) arrives here as a MultiPolygon. Keeping only the +# largest part used to discard the rest, leaving ~1.8% of merged area as uncovered +# gaps (often 3000-5000 m² building blocks). Keep every part at least this big; +# smaller detached bits are Voronoi/clipping noise and are still dropped. +_MIN_DETACHED_PART_AREA = 100.0 + + +def _keep_polygon_parts(geom): + """Keep all MultiPolygon parts >= _MIN_DETACHED_PART_AREA (largest if none).""" + if geom.geom_type != "MultiPolygon": + return geom + parts = [g for g in geom.geoms if g.area >= _MIN_DETACHED_PART_AREA] + if not parts: + parts = [max(geom.geoms, key=lambda g: g.area)] + return parts[0] if len(parts) == 1 else MultiPolygon(parts) def merge_fragments( @@ -97,19 +208,24 @@ def merge_fragments( merged = {} for pc, parts in by_postcode.items(): - combined = unary_union(parts) + combined = safe_union(parts) if combined.is_empty: continue if not combined.is_valid: combined = make_valid(combined) - # Close tiny gaps between adjacent OA boundary edges (float mismatches) + # Close tiny gaps between adjacent OA boundary edges (float mismatches). + # The closing can erode a tiny MultiPolygon (e.g. a postcode with only a + # sliver fragment) to nothing, which would leave the postcode with no + # geometry at all — keep the un-closed shape if that happens. if combined.geom_type == "MultiPolygon": - combined = combined.buffer(5.0).buffer(-5.0) - if not combined.is_valid: - combined = make_valid(combined) - # Postcodes are contiguous delivery routes — keep only the largest - # polygon; small detached fragments are algorithm artifacts - combined = _largest_polygon(combined) + closed = combined.buffer(5.0).buffer(-5.0) + if not closed.is_valid: + closed = make_valid(closed) + if not closed.is_empty: + combined = closed + # Keep the postcode whole: the largest part plus any other substantial + # part (a genuine railway/river split), dropping only tiny noise slivers. + combined = _keep_polygon_parts(combined) # Remove artifact interior holes from INSPIRE+Voronoi+make_valid chain combined = _fill_holes(combined) # Subtract parks/water if provided @@ -118,8 +234,12 @@ def merge_fragments( pre_green = combined combined = subtract_greenspace(combined, greenspace_tree, greenspace_geoms) - combined = _largest_polygon(combined) - combined = _fill_holes(combined) + combined = _keep_polygon_parts(combined) + # Do NOT _fill_holes here: interior holes carved by the greenspace + # subtraction (lakes, enclosed parks) are intentional, not artifacts. + # Filling them would re-add the removed area and negate the + # subtraction. Artifact holes from the INSPIRE+Voronoi+make_valid + # chain were already removed by the _fill_holes above (pre-subtraction). # Revert if subtraction + fragment selection lost >90% of area if pre_green.area > 0 and combined.area / pre_green.area < 0.1: combined = pre_green @@ -127,15 +247,218 @@ def merge_fragments( return merged +def _polygonal(geom): + """Return only the polygonal part(s) of a geometry, or None if none remain.""" + if geom is None or geom.is_empty: + return None + if geom.geom_type in ("Polygon", "MultiPolygon"): + return geom + if geom.geom_type == "GeometryCollection": + polys = [ + g + for g in geom.geoms + if g.geom_type in ("Polygon", "MultiPolygon") and not g.is_empty + ] + if not polys: + return None + # Both callers run on WGS84-degree output geometry, so the robustness + # fallback snaps on the 1e-6° grid (~0.11 m), not geometry.py's metre + # default — a coarse metre grid would obliterate a degree-scale shape. + merged = safe_union(polys, grid=_OUTPUT_PRECISION_DEG) + return merged if not merged.is_empty else None + return None + + +def _resolve_overlaps( + items: list[tuple[str, Polygon | MultiPolygon]], +) -> list[tuple[str, Polygon | MultiPolygon]]: + """Make the postcode polygons a partition: no two cover the same ground. + + Overlap appears at OA seams (the 5m merge buffer expands each postcode + independently), from simplifying each postcode on its own, and as genuine + containment (a postcode fully enclosed by another). Each postcode is trimmed + by the union of its higher-priority overlapping neighbours, where **priority = + ascending area**: a smaller postcode wins contested ground. That single rule + handles both cases correctly — an enclosed postcode is always smaller than its + container, so it keeps its area while the container gets a hole (a `overlaps` + query alone would miss containment entirely). Run last, on the final output + geometries, so nothing re-introduces overlap afterwards. A postcode that would + be emptied keeps its original geometry, so an active postcode is never dropped. + """ + geoms = [g for _, g in items] + n = len(geoms) + if n < 2: + return items + + # rank[i]: 0 = highest priority (smallest area). Postcode string breaks ties + # for determinism. + rank = { + idx: r + for r, idx in enumerate( + sorted(range(n), key=lambda i: (geoms[i].area, items[i][0])) + ) + } + + tree = STRtree(geoms) + arr = np.array(geoms, dtype=object) + pairs: set[tuple[int, int]] = set() + # "overlaps" gives partial overlaps; "contains" gives containment (which + # "overlaps" excludes) — together they cover every 2-D overlap without the + # edge-touch explosion a plain "intersects" query would add. + for predicate in ("overlaps", "contains"): + qsrc, qtgt = tree.query(arr, predicate=predicate) + for s, t in zip(qsrc.tolist(), qtgt.tolist()): + if s != t: + pairs.add((s, t) if s < t else (t, s)) + + # For each loser (lower priority) the higher-priority neighbours to subtract. + higher: dict[int, list[int]] = defaultdict(list) + for a, b in pairs: + winner, loser = (a, b) if rank[a] < rank[b] else (b, a) + higher[loser].append(winner) + + out = list(geoms) + # Process losers from highest priority down, so every subtracted neighbour is + # already finalised. + for i in sorted(higher, key=lambda idx: rank[idx]): + # These geometries are WGS84 degrees already snapped to output precision, + # so the robustness fallback snaps on the same 1e-6° grid (~0.11 m) rather + # than geometry.py's metre-CRS default. A raw difference can still raise a + # "side location conflict" on near-coincident OA-seam edges; the + # fixed-precision overlay noded them away. + cut = safe_union([out[j] for j in higher[i]], grid=_OUTPUT_PRECISION_DEG) + trimmed = safe_difference(out[i], cut, grid=_OUTPUT_PRECISION_DEG) + if not trimmed.is_valid: + trimmed = make_valid(trimmed) + # Keep all polygonal parts: these geometries are in WGS84 degrees, so an + # area threshold here would wrongly drop everything but the largest part + # and re-open the very gaps the seam fix closed. + trimmed = _polygonal(trimmed) + if trimmed is not None and not trimmed.is_empty: + out[i] = trimmed + return [(pc, out[i]) for i, (pc, _) in enumerate(items)] + + +def _round_coords(coords, ndigits=6): + if coords and isinstance(coords[0], (int, float)): + return [round(coords[0], ndigits), round(coords[1], ndigits)] + return [_round_coords(c, ndigits) for c in coords] + + +def _snap_polygonal(geom, grid): + """Re-node ``geom`` onto ``grid`` (``valid_output``) → polygonal-only, or None. + + ``set_precision`` ``valid_output`` runs an OverlayNG noding pass that places + every vertex on a multiple of ``grid`` *and* fixes the topology, so a plain + coordinate round of the result is exact (no two distinct vertices can land in + the same cell). Falls back to ``make_valid`` if precision-reduction raises. + """ + try: + snapped = set_precision(geom, grid, mode="valid_output") + except GEOSException: + snapped = make_valid(geom) + return _polygonal(snapped if snapped.is_valid else make_valid(snapped)) + + +# A square this many output-grid cells on a side, used as the last-resort +# footprint when snapping erases a sub-grid sliver. ~10 cells (≈0.7–1.1 m at UK +# latitudes) is invisible at map scale yet survives the 1e-6° snap as a valid, +# 4-corner ring. +_FOOTPRINT_GRID_CELLS = 5 + + +def _grid_footprint(geom): + """A tiny grid-aligned square at ``geom``'s representative point, snapped valid. + + Last line of defence so an *active* postcode never vanishes: the de-overlap + pass can shave a small (e.g. co-located, non-geographic) postcode down to a + sub-grid sliver that disappears when snapped to output precision. Rather than + drop it, place a minimal valid footprint at its location. The tiny overlap + this re-creates with the neighbour that trimmed it is harmless — the output + partition is best-effort, a missing boundary is a hard validation failure. + """ + try: + point = geom.representative_point() + except GEOSException: + return None + half = _OUTPUT_PRECISION_DEG * _FOOTPRINT_GRID_CELLS + square = box(point.x - half, point.y - half, point.x + half, point.y + half) + return _snap_polygonal(square, _OUTPUT_PRECISION_DEG) + + +def _geojson_geometry(geom) -> dict | None: + """Serialize a WGS84 polygon/multipolygon to a *valid* 6dp GeoJSON dict, or None. + + The coordinates are snapped onto the 1e-6° output grid with a re-noding pass + BEFORE the 6dp rounding, not by the round alone. ``_resolve_overlaps`` leaves + thin overlap-sliver triangles with full-precision (off-grid) vertices at OA + seams; a bare round-to-6dp collapses those into degenerate rings (GEOS "Too + few points") and pinches rings into self-intersections that pass the + pre-rounding validity check but fail once the feature is read back from disk. + Snapping with ``valid_output`` nodes the geometry onto the grid so the round + that follows lands on exact 1e-6 multiples and cannot reintroduce invalidity. + + ``None`` only for a genuinely empty/degenerate-with-no-location input; a + non-empty geometry that snaps to a sub-grid sliver is rescued into a minimal + grid footprint rather than dropped (an active postcode must keep a boundary). + """ + geom = _polygonal(geom if geom.is_valid else make_valid(geom)) + if geom is None or geom.is_empty: + return None + snapped = _snap_polygonal(geom, _OUTPUT_PRECISION_DEG) + if snapped is None or snapped.is_empty: + snapped = _grid_footprint(geom) + if snapped is None or snapped.is_empty: + return None + gj = mapping(snapped) + out = {"type": gj["type"], "coordinates": _round_coords(gj["coordinates"])} + # Defence-in-depth: re-validate the dict that actually reaches disk. Snapping + # makes the round exact, so this should already hold; repair once more on the + # grid if a pathological vertex still pinches a ring. + if not shape(out).is_valid: + snapped = _snap_polygonal(shape(out), _OUTPUT_PRECISION_DEG) + if snapped is None or snapped.is_empty: + return None + gj = mapping(snapped) + out = {"type": gj["type"], "coordinates": _round_coords(gj["coordinates"])} + return out + + def write_district_geojson( postcodes: dict[str, Polygon | MultiPolygon], output_dir: Path ) -> int: - """Group postcodes by district, write GeoJSON files. Returns file count.""" + """Group postcodes by district, write GeoJSON files. Returns file count. + + Before writing, the postcode polygons are converted to their final WGS84 form + and made a partition (overlaps removed) so the output never has two postcodes + covering the same ground. + """ units_dir = output_dir / "units" - units_dir.mkdir(parents=True, exist_ok=True) + tmp_units_dir = output_dir / "units.tmp" + output_dir.mkdir(parents=True, exist_ok=True) + if tmp_units_dir.exists(): + shutil.rmtree(tmp_units_dir) + tmp_units_dir.mkdir(parents=True) + + skipped: list[str] = [] + + # Pass 1: convert every postcode to its final WGS84 geometry (simplify, snap, + # sliver-rescue, multi-part preserved). Sorted → deterministic de-overlap + # priority. to_wgs84_geojson_multi returns None only for a genuinely empty + # input, which is skipped and reported rather than aborting a multi-hour run. + converted: list[tuple[str, Polygon | MultiPolygon]] = [] + for pc in sorted(postcodes): + gj = to_wgs84_geojson_multi(postcodes[pc]) + if gj is None: + skipped.append(pc) + continue + converted.append((pc, shape(gj))) + + # Remove overlap strips so the output is a clean partition. + converted = _resolve_overlaps(converted) by_district: dict[str, list[tuple[str, Polygon | MultiPolygon]]] = defaultdict(list) - for pc, geom in postcodes.items(): + for pc, geom in converted: parts = pc.split() district = parts[0] if parts else pc[:4] by_district[district].append((pc, geom)) @@ -146,17 +469,17 @@ def write_district_geojson( ): features = [] for pc, geom in sorted(entries, key=lambda x: x[0]): - geojson_geom = to_wgs84_geojson(geom) + geojson_geom = _geojson_geometry(geom) if geojson_geom is None: + skipped.append(pc) continue - mapit_code = pc.replace(" ", "") features.append( { "type": "Feature", "geometry": geojson_geom, "properties": { "postcodes": pc, - "mapit_code": mapit_code, + "mapit_code": pc.replace(" ", ""), }, } ) @@ -165,9 +488,20 @@ def write_district_geojson( continue collection = {"type": "FeatureCollection", "features": features} - out_path = units_dir / f"{district}.geojson" + out_path = tmp_units_dir / f"{district}.geojson" with open(out_path, "w") as f: json.dump(collection, f, separators=(",", ":")) file_count += 1 + if skipped: + preview = ", ".join(skipped[:10]) + suffix = " …" if len(skipped) > 10 else "" + print( + f" Skipped {len(skipped)} postcode(s) with degenerate (sub-grid) " + f"geometry: {preview}{suffix}" + ) + + if units_dir.exists(): + shutil.rmtree(units_dir) + tmp_units_dir.replace(units_dir) return file_count diff --git a/pipeline/transform/postcode_boundaries/process_oa.py b/pipeline/transform/postcode_boundaries/process_oa.py index 14aae67..4404560 100644 --- a/pipeline/transform/postcode_boundaries/process_oa.py +++ b/pipeline/transform/postcode_boundaries/process_oa.py @@ -3,13 +3,23 @@ from collections import Counter, defaultdict import numpy as np from scipy.spatial import cKDTree from shapely import STRtree, make_valid -from shapely.geometry import MultiPolygon, Polygon -from shapely.ops import unary_union +from shapely.geometry import MultiPoint, MultiPolygon, Point, Polygon +from .geometry import safe_difference, safe_intersection, safe_union from .voronoi import compute_voronoi_regions MIN_GEOM_AREA = 0.01 +# Minimal footprint (BNG metres) for a postcode whose UPRN seed wins no area in a +# crowded multi-postcode OA — its Voronoi cell ∩ remaining collapses below +# MIN_GEOM_AREA, or its seed sits inside an INSPIRE parcel wholly claimed by a +# co-located postcode. Every *active* postcode must keep a boundary +# (validate_outputs is zero-tolerance), so it gets a small disc at its true seed +# location. ~28 m² clears MIN_GEOM_AREA and output snapping; the overlap it +# creates with the area's winner is resolved at the output stage, where the +# smaller postcode wins the contested ground (see output._resolve_overlaps). +_MIN_SEED_FOOTPRINT_M = 3.0 + def process_oa( oa_geom: Polygon | MultiPolygon, @@ -36,10 +46,12 @@ def process_oa( # Compute remaining area if claimed: - all_claimed = unary_union(list(claimed.values())) + all_claimed = safe_union(list(claimed.values())) all_claimed = _clean_polygonal(all_claimed) remaining = ( - valid_oa.difference(all_claimed) if all_claimed is not None else valid_oa + safe_difference(valid_oa, all_claimed) + if all_claimed is not None + else valid_oa ) remaining = _clean_polygonal(remaining) else: @@ -60,13 +72,54 @@ def process_oa( fragments = [] for pc, parts in result.items(): - merged = _clean_polygonal(unary_union(parts)) + merged = _clean_polygonal(safe_union(parts)) if merged is not None: fragments.append((pc, merged)) + # Every postcode with a UPRN seed in this OA must keep at least a minimal + # footprint — in a dense OA (a block of flats with hundreds of distinct + # postcodes) a single-seed postcode's cell can collapse below MIN_GEOM_AREA or + # be fully absorbed by a co-located postcode's INSPIRE parcel, producing no + # fragment, and an active postcode must never be dropped. + orphans = unique_pcs - {pc for pc, _ in fragments} + if orphans: + fragments.extend(_seed_footprints(orphans, points, postcodes, valid_oa)) + return fragments +def _seed_footprints( + orphans: set[str], + points: np.ndarray, + postcodes: list[str], + valid_oa: Polygon | MultiPolygon, +) -> list[tuple[str, Polygon | MultiPolygon]]: + """Give each orphan postcode a minimal disc footprint at its UPRN seed(s). + + Orphans are postcodes with a UPRN in this OA that nonetheless won no area in + the INSPIRE/Voronoi partition. Each keeps a small disc at its true location, + clipped to the OA; the overlap with the area's winner is resolved at output. + """ + by_pc: dict[str, list] = defaultdict(list) + for i, pc in enumerate(postcodes): + if pc in orphans: + by_pc[pc].append(points[i]) + + out: list[tuple[str, Polygon | MultiPolygon]] = [] + for pc, pts in by_pc.items(): + arr = np.asarray(pts, dtype=np.float64) + seed = Point(arr[0]) if len(arr) == 1 else MultiPoint(arr) + disc = seed.buffer(_MIN_SEED_FOOTPRINT_M) + clipped = _clean_polygonal(safe_intersection(disc, valid_oa)) + if clipped is None: + # Seed on/near the OA edge: keep the unclipped disc so the postcode + # still gets a footprint at its location rather than no boundary. + clipped = _clean_polygonal(disc) + if clipped is not None: + out.append((pc, clipped)) + return out + + def _claim_inspire_parcels( valid_oa: Polygon | MultiPolygon, points: np.ndarray, @@ -85,19 +138,42 @@ def _claim_inspire_parcels( uprn_pts = shp_points(points) pt_idx, cand_idx = cand_tree.query(uprn_pts, predicate="within") - # First priority: parcels that physically contain UPRNs. Majority vote - # resolves blocks of flats or overlapping parcel data. + # First priority: parcels that physically contain UPRNs. A parcel holding + # UPRNs from a single postcode goes wholly to that postcode. A parcel shared + # by several postcodes (a block of flats spanning postcodes, or overlapping + # parcel data) is split between them via a sub-Voronoi over their own UPRNs + # clipped to the parcel — so EVERY contained postcode keeps part of the + # parcel. A bare majority vote would hand the whole parcel to one winner and + # leave the losers' UPRNs trapped inside claimed land, dropping them from + # both this claim and the `remaining` polygon handed to Voronoi downstream. cand_postcodes: dict[int, list[str]] = defaultdict(list) + cand_point_idx: dict[int, list[int]] = defaultdict(list) for pi, ci in zip(pt_idx, cand_idx): cand_postcodes[ci].append(postcodes[pi]) + cand_point_idx[ci].append(pi) + points_f64 = points.astype(np.float64, copy=False) contained_parts: dict[str, list] = defaultdict(list) contained_scores: Counter[str] = Counter() for ci, pc_list in cand_postcodes.items(): pc_counts = Counter(pc_list) - winner, votes = pc_counts.most_common(1)[0] - contained_parts[winner].append(parcels[ci]) - contained_scores[winner] += votes + if len(pc_counts) == 1: + winner = next(iter(pc_counts)) + contained_parts[winner].append(parcels[ci]) + contained_scores[winner] += pc_counts[winner] + continue + # Shared parcel: sub-Voronoi over the contained UPRNs so each postcode + # present keeps a fragment instead of being absorbed by the winner. + sub_idx = cand_point_idx[ci] + sub_points = points_f64[sub_idx] + sub_postcodes = [postcodes[pi] for pi in sub_idx] + for pc, geom in compute_voronoi_regions( + sub_points, sub_postcodes, parcels[ci] + ).items(): + cleaned = _clean_polygonal(geom) + if cleaned is not None: + contained_parts[pc].append(cleaned) + contained_scores[pc] += pc_counts[pc] contained_claimed = _merge_parts_by_postcode(contained_parts) contained_claims = sorted( @@ -109,7 +185,6 @@ def _claim_inspire_parcels( # each to the nearest UPRN/postcode so parcel boundaries carry more of the # visible postcode shape; Voronoi is then limited to roads, parks, water, and # any other non-parcel gaps. - points_f64 = points.astype(np.float64, copy=False) contained_union = _union_claims(contained_claims) nearest_tree = cKDTree(points_f64) nearest_parts: dict[str, list] = defaultdict(list) @@ -119,7 +194,7 @@ def _claim_inspire_parcels( assignable = parcel if contained_union is not None: - assignable = assignable.difference(contained_union) + assignable = safe_difference(assignable, contained_union) for part in _polygon_parts(assignable): part = _clean_polygonal(part) if part is None: @@ -147,7 +222,7 @@ def _prepare_inspire_parcels( continue if not geom.intersects(valid_oa): continue - clipped = _clean_polygonal(geom.intersection(valid_oa)) + clipped = _clean_polygonal(safe_intersection(geom, valid_oa)) if clipped is not None: parcels.append(clipped) return parcels @@ -177,7 +252,7 @@ def _merge_parts_by_postcode( ) -> dict[str, Polygon | MultiPolygon]: merged: dict[str, Polygon | MultiPolygon] = {} for pc, parts in parts_by_postcode.items(): - geom = _clean_polygonal(unary_union(parts)) + geom = _clean_polygonal(safe_union(parts)) if geom is not None: merged[pc] = geom return merged @@ -188,7 +263,7 @@ def _union_claims( ) -> Polygon | MultiPolygon | None: if not claims: return None - return _clean_polygonal(unary_union([geom for _, geom in claims])) + return _clean_polygonal(safe_union([geom for _, geom in claims])) def _resolve_ordered_claims( @@ -202,11 +277,11 @@ def _resolve_ordered_claims( if geom is None: continue if used is not None: - geom = _clean_polygonal(geom.difference(used)) + geom = _clean_polygonal(safe_difference(geom, used)) if geom is None: continue resolved_parts[pc].append(geom) - used = _clean_polygonal(geom if used is None else unary_union([used, geom])) + used = _clean_polygonal(geom if used is None else safe_union([used, geom])) return _merge_parts_by_postcode(resolved_parts) @@ -235,11 +310,11 @@ def _extract_polygonal(geom) -> Polygon | MultiPolygon | None: return None if len(polys) == 1: return polys[0] - return MultiPolygon( - [ - p - for g in polys - for p in (g.geoms if g.geom_type == "MultiPolygon" else [g]) - ] - ) + # Union (not bare MultiPolygon construction): make_valid can emit + # overlapping polygonal parts, and a MultiPolygon of overlapping parts is + # invalid — it double-counts area and makes the next `.difference()` raise + # a TopologyException that aborts the OA (and, in parallel mode, the + # worker). safe_union merges them into a valid geometry. + merged = safe_union(polys) + return merged if not merged.is_empty else None return None diff --git a/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py b/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py index 99df1ce..760ac72 100644 --- a/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py +++ b/pipeline/transform/postcode_boundaries/test_postcode_boundaries.py @@ -3,16 +3,31 @@ Each test targets a specific bug or edge case identified during code review. """ +import json + import numpy as np import polars as pl import pytest from shapely.geometry import MultiPolygon, Polygon, box from shapely.ops import unary_union +from .fragments_cache import ( + fragments_cache_is_fresh, + load_fragments, + save_fragments, +) +from .__main__ import _oa_fragments, _process_oas +from .inspire import build_inspire_index from .oa_boundaries import parse_gpkg_geometry from .greenspace import subtract_greenspace -from .output import _fill_holes, merge_fragments, to_wgs84_geojson -from .process_oa import _extract_polygonal, process_oa +from .output import ( + _fill_holes, + merge_fragments, + to_wgs84_geojson, + to_wgs84_geojson_multi, + write_district_geojson, +) +from .process_oa import MIN_GEOM_AREA, _extract_polygonal, process_oa from .uprn import get_oa_uprns, load_uprns from .voronoi import _equal_split_fallback, compute_voronoi_regions @@ -121,6 +136,156 @@ class TestWhitespacePostcodes: loaded_df, _ = load_uprns(path) assert len(loaded_df) == 0 + def test_non_english_oas_excluded(self, tmp_path): + df = pl.DataFrame( + { + "GRIDGB1E": [500010, 300010], + "GRIDGB1N": [180010, 220010], + "PCDS": ["AA1 1AA", "CF1 1AA"], + "OA21CD": ["E00000001", "W00000001"], + } + ) + path = tmp_path / "uprn.parquet" + df.write_parquet(path) + + loaded_df, offsets = load_uprns(path) + + assert set(offsets) == {"E00000001"} + assert loaded_df["PCDS"].to_list() == ["AA1 1AA"] + + def test_terminated_postcodes_are_remapped(self, tmp_path): + uprns = pl.DataFrame( + { + "GRIDGB1E": [500010], + "GRIDGB1N": [180010], + "PCDS": ["aa1 1aa"], + "OA21CD": ["E00000001"], + } + ) + uprn_path = tmp_path / "uprn.parquet" + uprns.write_parquet(uprn_path) + arcgis = pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB"], + "east1m": [500010, 500030], + "north1m": [180010, 180020], + "oa21cd": ["E00000001", "E00000001"], + "doterm": ["2020-01-01", None], + "ctry25cd": ["E92000001", "E92000001"], + } + ) + arcgis_path = tmp_path / "arcgis.parquet" + arcgis.write_parquet(arcgis_path) + + loaded_df, _offsets = load_uprns(uprn_path, arcgis_path) + + assert loaded_df["PCDS"].to_list() == ["AA1 1AB"] + + def test_remapped_terminated_postcode_adopts_successor_oa(self, tmp_path): + """When a terminated postcode is remapped to its active successor, the + remapped seed point must carry the SUCCESSOR's OA (and coords), not the + terminated postcode's original OA. Pre-fix the row kept OA21CD of the + terminated postcode, seeding the successor into an OA it doesn't belong + to and splitting its boundary across OAs.""" + # Terminated AA1 1AA sits in OA E00000001. Its nearest active successor + # AA1 1AB lives in a DIFFERENT OA (E00000002) far away. + uprns = pl.DataFrame( + { + "GRIDGB1E": [500010], + "GRIDGB1N": [180010], + "PCDS": ["AA1 1AA"], + "OA21CD": ["E00000001"], + } + ) + uprn_path = tmp_path / "uprn.parquet" + uprns.write_parquet(uprn_path) + arcgis = pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB"], + "east1m": [500010, 500030], + "north1m": [180010, 180020], + # AA1 1AA terminated → only AA1 1AB is an active successor, and + # it belongs to a different OA than the terminated postcode. + "oa21cd": ["E00000001", "E00000002"], + "doterm": ["2020-01-01", None], + "ctry25cd": ["E92000001", "E92000001"], + } + ) + arcgis_path = tmp_path / "arcgis.parquet" + arcgis.write_parquet(arcgis_path) + + loaded_df, offsets = load_uprns(uprn_path, arcgis_path) + + # The remapped point must be grouped under the successor's OA, not the + # terminated postcode's OA. + assert "E00000002" in offsets, "Successor OA missing — remap kept old OA" + assert "E00000001" not in offsets, ( + "Remapped point still lives in the terminated postcode's OA" + ) + points, postcodes = get_oa_uprns(loaded_df, offsets, "E00000002") + assert postcodes == ["AA1 1AB"] + # It should also adopt the successor's authoritative coordinates. + assert points.tolist() == [[500030.0, 180020.0]] + + def test_arcgis_filters_to_active_english_postcodes(self, tmp_path): + uprns = pl.DataFrame( + { + "GRIDGB1E": [500010, 500020], + "GRIDGB1N": [180010, 180020], + "PCDS": ["AA1 1AA", "CF1 1AA"], + "OA21CD": ["E00000001", "E00000001"], + } + ) + uprn_path = tmp_path / "uprn.parquet" + uprns.write_parquet(uprn_path) + arcgis = pl.DataFrame( + { + "pcds": ["AA1 1AA", "CF1 1AA"], + "east1m": [500010, 300010], + "north1m": [180010, 220010], + "oa21cd": ["E00000001", "W00000001"], + "doterm": [None, None], + "ctry25cd": ["E92000001", "W92000004"], + } + ) + arcgis_path = tmp_path / "arcgis.parquet" + arcgis.write_parquet(arcgis_path) + + loaded_df, _offsets = load_uprns(uprn_path, arcgis_path) + + assert loaded_df["PCDS"].to_list() == ["AA1 1AA"] + + def test_arcgis_adds_centroid_seed_for_active_postcode_without_uprn(self, tmp_path): + uprns = pl.DataFrame( + { + "GRIDGB1E": [500010], + "GRIDGB1N": [180010], + "PCDS": ["AA1 1AA"], + "OA21CD": ["E00000001"], + } + ) + uprn_path = tmp_path / "uprn.parquet" + uprns.write_parquet(uprn_path) + arcgis = pl.DataFrame( + { + "pcds": ["AA1 1AA", "BB1 1BB"], + "east1m": [500010, 510000], + "north1m": [180010, 190000], + "oa21cd": ["E00000001", "E00000002"], + "doterm": [None, None], + "ctry25cd": ["E92000001", "E92000001"], + } + ) + arcgis_path = tmp_path / "arcgis.parquet" + arcgis.write_parquet(arcgis_path) + + loaded_df, offsets = load_uprns(uprn_path, arcgis_path) + + assert set(loaded_df["PCDS"].to_list()) == {"AA1 1AA", "BB1 1BB"} + points, postcodes = get_oa_uprns(loaded_df, offsets, "E00000002") + assert postcodes == ["BB1 1BB"] + assert points.tolist() == [[510000.0, 190000.0]] + # --------------------------------------------------------------------------- # Bug 3: Voronoi deduplication is first-seen-wins @@ -176,6 +341,65 @@ class TestVoronoiDeduplication: assert "B" in result, "Postcode B missing with int64 coords" +class TestVoronoiCoincidentClusterNotCrushed: + """3+ postcodes at one coordinate must each keep a real cell. + + Pre-fix, the first coincident postcode stayed unjittered at the exact + cluster centre; with other seeds in the OA its Voronoi cell was squeezed + below MIN_GEOM_AREA, so _clean_polygonal dropped that active postcode + downstream. The fix spreads coincident postcodes onto a small regular + polygon (equal wedges), so none is crushed. + """ + + def test_coincident_cluster_plus_outer_seed_no_postcode_crushed(self): + # A block of flats: 4 distinct postcodes share one building coordinate, + # plus one other postcode elsewhere in the OA. Pre-fix, the centre seed's + # cell collapsed to ~0.0001 m^2 (< MIN_GEOM_AREA) and the postcode was + # dropped; every postcode must now keep a non-degenerate cell. + boundary = box(0, 0, 1000, 1000) + points = np.array( + [ + [500, 500], # A — coincident + [500, 500], # B — coincident + [500, 500], # C — coincident + [500, 500], # D — coincident + [100, 100], # OUT — elsewhere in the OA + ], + dtype=np.float64, + ) + postcodes = ["A", "B", "C", "D", "OUT"] + result = compute_voronoi_regions(points, postcodes, boundary) + for pc in postcodes: + assert pc in result, f"Postcode {pc} was dropped" + assert result[pc].area > MIN_GEOM_AREA, ( + f"Postcode {pc} cell {result[pc].area} <= MIN_GEOM_AREA" + ) + + def test_coincident_cluster_partitions_into_fair_wedges(self, square_boundary): + # N postcodes sharing one coordinate split the surrounding area into + # roughly equal wedges (regular-polygon seeds), none degenerate. + points = np.array([[500050, 180050]] * 5, dtype=np.float64) + postcodes = ["A", "B", "C", "D", "E"] + result = compute_voronoi_regions(points, postcodes, square_boundary) + fair_share = square_boundary.area / len(postcodes) + for pc in postcodes: + assert pc in result, f"Postcode {pc} was dropped" + # Each wedge is a meaningful fraction of its fair share (not crushed). + assert result[pc].area > 0.3 * fair_share, ( + f"Postcode {pc} cell {result[pc].area} far below fair share {fair_share}" + ) + + def test_two_coincident_split_is_fair(self, square_boundary): + """Regression: two postcodes at one coordinate split ~50/50.""" + points = np.array([[500050, 180050], [500050, 180050]], dtype=np.float64) + postcodes = ["A", "B"] + result = compute_voronoi_regions(points, postcodes, square_boundary) + assert "A" in result and "B" in result + total = result["A"].area + result["B"].area + assert result["A"].area / total > 0.4 + assert result["B"].area / total > 0.4 + + # --------------------------------------------------------------------------- # Bug 4: Voronoi collinear fallback gives everything to first postcode # --------------------------------------------------------------------------- @@ -406,7 +630,9 @@ class TestProcessOAInspireParcelAssignment: ) postcodes = ["A", "B"] - fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[left, right]) + fragments = process_oa( + oa_geom, points, postcodes, inspire_candidates=[left, right] + ) frag_dict = dict(fragments) assert "A" in frag_dict and "B" in frag_dict @@ -450,7 +676,9 @@ class TestProcessOAInspireParcelAssignment: ) postcodes = ["A", "B"] - fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[left, right]) + fragments = process_oa( + oa_geom, points, postcodes, inspire_candidates=[left, right] + ) frag_dict = dict(fragments) assert "A" in frag_dict and "B" in frag_dict @@ -495,11 +723,83 @@ class TestProcessOAInspireParcelAssignment: ) postcodes = ["A", "B"] - fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[straddling]) + fragments = process_oa( + oa_geom, points, postcodes, inspire_candidates=[straddling] + ) for _, geom in fragments: assert geom.difference(oa_geom).area < 0.01 + def test_shared_parcel_keeps_every_contained_postcode(self): + """A single parcel containing UPRNs for [A, A, B] must yield a fragment + for BOTH A and B. Pre-fix the majority winner (A) claimed the whole + parcel, excluding it from `remaining`, so B's UPRNs were trapped inside + claimed land and B vanished entirely (no fragment).""" + oa_geom = box(0, 0, 100, 100) + parcel = box(0, 0, 100, 100) # one parcel covering the whole OA + points = np.array( + [ + [20, 50], # postcode A + [30, 50], # postcode A (majority) + [80, 50], # postcode B (minority — would be dropped pre-fix) + ] + ) + postcodes = ["A", "A", "B"] + + fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[parcel]) + frag_dict = dict(fragments) + + assert "A" in frag_dict, "Majority postcode A must keep a fragment" + assert "B" in frag_dict, "Minority postcode B must not be dropped" + assert frag_dict["A"].area > 0 + assert frag_dict["B"].area > 0 + # The split must partition the parcel without overlap. + assert frag_dict["A"].intersection(frag_dict["B"]).area < 0.01 + + +class TestProcessOASeedFootprintGuarantee: + """Every postcode with a UPRN seed in a multi-postcode OA must produce a + fragment, even when its partition cell collapses below MIN_GEOM_AREA — an + active postcode must never be dropped (validate_outputs is zero-tolerance).""" + + def test_collapsed_voronoi_cells_rescued_as_footprints(self): + # OA just above MIN_GEOM_AREA; a 2-way Voronoi split leaves each cell + # (~0.0098 m²) below MIN_GEOM_AREA, so both would be dropped without rescue. + oa_geom = box(0, 0, 0.14, 0.14) # 0.0196 m² + points = np.array([[0.035, 0.07], [0.105, 0.07]]) + postcodes = ["AA1 1AA", "AA1 1AB"] + + fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[]) + got = {pc for pc, _ in fragments} + assert got == {"AA1 1AA", "AA1 1AB"}, f"dropped: {set(postcodes) - got}" + for _, geom in fragments: + assert geom.is_valid + assert geom.area > MIN_GEOM_AREA + + def test_seed_footprint_sits_on_the_uprn(self): + from shapely.geometry import Point + + from .process_oa import _seed_footprints + + oa_geom = box(0, 0, 1000, 1000) + points = np.array([[500.0, 500.0]]) + out = _seed_footprints({"AA1 1AA"}, points, ["AA1 1AA"], oa_geom) + + assert len(out) == 1 + pc, geom = out[0] + assert pc == "AA1 1AA" + assert geom.is_valid and geom.area > MIN_GEOM_AREA + assert geom.contains(Point(500, 500)) + + def test_only_orphans_get_footprints(self): + # A wins real area; B's lone seed collapses. Only B should be rescued, and + # A's genuine (large) fragment must be untouched by the rescue. + oa_geom = box(0, 0, 0.14, 0.14) + points = np.array([[0.07, 0.07], [0.07, 0.07], [0.105, 0.07]]) + postcodes = ["AA1 1AA", "AA1 1AA", "AA1 1AB"] + fragments = process_oa(oa_geom, points, postcodes, inspire_candidates=[]) + assert {pc for pc, _ in fragments} == {"AA1 1AA", "AA1 1AB"} + # --------------------------------------------------------------------------- # _extract_polygonal helper @@ -539,6 +839,21 @@ class TestExtractPolygonal: assert _extract_polygonal(LineString([(0, 0), (1, 1)])) is None + def test_overlapping_collection_unioned_to_valid(self): + """A GeometryCollection with OVERLAPPING polygons must be unioned into a + VALID geometry (not a raw MultiPolygon, which would be invalid and crash + the next .difference()), and must not double-count the overlap area.""" + from shapely.geometry import GeometryCollection + + a = box(0, 0, 100, 100) + b = box(50, 50, 150, 150) # overlaps a by 50x50 + result = _extract_polygonal(GeometryCollection([a, b])) + assert result is not None + assert result.is_valid + assert result.area == pytest.approx(unary_union([a, b]).area) + # And the formerly-crashing op now works: + assert result.difference(box(0, 0, 10, 10)).is_valid + # --------------------------------------------------------------------------- # Edge case: merge_fragments handles single-OA postcodes @@ -607,6 +922,22 @@ class TestToWgs84Geojson: assert lon_dp <= 6, f"Longitude {lon_s} has {lon_dp} decimal places" assert lat_dp <= 6, f"Latitude {lat_s} has {lat_dp} decimal places" + def test_write_district_geojson_replaces_stale_units(self, tmp_path): + stale_units = tmp_path / "units" + stale_units.mkdir() + (stale_units / "ZZ1.geojson").write_text( + json.dumps({"type": "FeatureCollection", "features": []}) + ) + + file_count = write_district_geojson( + {"AA1 1AA": box(530000, 180000, 530100, 180100)}, tmp_path + ) + + assert file_count == 1 + assert not (stale_units / "ZZ1.geojson").exists() + written = json.loads((stale_units / "AA1.geojson").read_text()) + assert written["features"][0]["properties"]["postcodes"] == "AA1 1AA" + # --------------------------------------------------------------------------- # Edge case: parse_gpkg_geometry rejects unknown envelope types @@ -630,12 +961,12 @@ class TestParseGpkgGeometry: class TestFillHoles: - """_fill_holes must remove all interior holes from polygons.""" + """_fill_holes fills small artifact holes but keeps large (real-enclosed) ones.""" - def test_polygon_with_hole(self): - """A polygon with an interior ring should become a solid polygon.""" + def test_small_artifact_hole_filled(self): + """A small (<1000 m²) interior ring is an artifact and gets filled.""" outer = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)] - hole = [(30, 30), (70, 30), (70, 70), (30, 70), (30, 30)] + hole = [(40, 40), (60, 40), (60, 60), (40, 60), (40, 40)] # 20x20 = 400 m² poly_with_hole = Polygon(outer, [hole]) assert len(list(poly_with_hole.interiors)) == 1 result = _fill_holes(poly_with_hole) @@ -643,6 +974,15 @@ class TestFillHoles: assert len(list(result.interiors)) == 0 assert result.area == pytest.approx(Polygon(outer).area) + def test_large_hole_kept(self): + """A large (>=1000 m²) hole is likely a real enclosed postcode — keep it.""" + outer = [(0, 0), (100, 0), (100, 100), (0, 100), (0, 0)] + hole = [(20, 20), (80, 20), (80, 80), (20, 80), (20, 20)] # 60x60 = 3600 m² + poly_with_hole = Polygon(outer, [hole]) + result = _fill_holes(poly_with_hole) + assert len(list(result.interiors)) == 1 + assert result.area == pytest.approx(10000 - 3600) + def test_multipolygon_with_holes(self): """A MultiPolygon where each part has holes should have all holes removed.""" outer1 = [(0, 0), (50, 0), (50, 50), (0, 50), (0, 0)] @@ -760,3 +1100,639 @@ class TestSubtractGreenspace: result = subtract_greenspace(postcode, tree, geoms) # 80% < 90% cap, so subtraction should happen assert result.area == pytest.approx(2000, rel=0.01) + + +class TestToWgs84GeojsonValidity: + """to_wgs84_geojson must emit GeoJSON that round-trips to a valid geometry.""" + + def test_geojson_round_trips_to_valid_geometry(self): + from shapely.geometry import shape + + geojson = to_wgs84_geojson(box(530000, 180000, 530100, 180100)) + assert geojson is not None + rt = shape(geojson) + assert not rt.is_empty + assert rt.is_valid + + def test_written_district_features_are_all_valid(self, tmp_path): + from shapely.geometry import shape + + postcodes = { + "AA1 1AA": box(530000, 180000, 530100, 180100), + "AA1 1AB": MultiPolygon( + [ + box(530200, 180000, 530250, 180050), + box(530200, 180060, 530250, 180110), + ] + ), + } + assert write_district_geojson(postcodes, tmp_path) == 1 + collection = json.loads((tmp_path / "units" / "AA1.geojson").read_text()) + for feature in collection["features"]: + geom = shape(feature["geometry"]) + assert geom.is_valid + assert not geom.is_empty + + +class TestGreenspaceHolePreserved: + """Interior holes carved by greenspace subtraction must survive merge_fragments + (the post-subtraction _fill_holes that previously negated them was removed).""" + + def test_interior_lake_hole_survives_merge_fragments(self): + from shapely.strtree import STRtree + + postcode = box(0, 0, 100, 100) # 10000 sqm + lake = box(30, 30, 70, 70) # 1600 sqm fully-interior hole (16% removal) + result = merge_fragments( + [("TEST1", postcode)], + greenspace_tree=STRtree([lake]), + greenspace_geoms=[lake], + ) + merged = result["TEST1"] + assert len(list(merged.interiors)) == 1 + assert merged.area == pytest.approx(10000 - 1600, rel=0.05) + + +# --------------------------------------------------------------------------- +# merge_fragments keeps substantial detached parts (no OA-seam coverage gaps) +# --------------------------------------------------------------------------- + + +class TestKeepDetachedParts: + """A postcode split across an OA seam (railway/river) must keep both parts + instead of dropping all but the largest, which left ~1.8% uncovered gaps.""" + + def test_far_apart_parts_both_kept(self): + # Two 50x50m blocks 30m apart — wider than the 10m merge buffer. + a = box(0, 0, 50, 50) # 2500 m² + b = box(80, 0, 130, 50) # 2500 m², 30m gap + geom = merge_fragments([("AA1 1AA", a), ("AA1 1AA", b)])["AA1 1AA"] + assert geom.geom_type == "MultiPolygon" + assert len(geom.geoms) == 2 + assert geom.area == pytest.approx(5000, rel=0.01) + + def test_tiny_noise_part_dropped(self): + main = box(0, 0, 100, 100) # 10000 m² + noise = box(200, 200, 205, 205) # 25 m² < 100 m² threshold + geom = merge_fragments([("AA1 1AA", main), ("AA1 1AA", noise)])["AA1 1AA"] + assert geom.geom_type == "Polygon" + assert geom.area == pytest.approx(10000, rel=0.01) + + +class TestMultiPolygonOutput: + """to_wgs84_geojson_multi / the writer must emit MultiPolygon for split + postcodes (the Rust server + loader already parse MultiPolygon).""" + + def test_multipolygon_preserves_all_parts(self): + from shapely.geometry import shape + + mp = MultiPolygon( + [ + box(530000, 180000, 530100, 180100), + box(531000, 180000, 531100, 180100), + ] + ) + gj = to_wgs84_geojson_multi(mp) + assert gj["type"] == "MultiPolygon" + assert len(gj["coordinates"]) == 2 + rt = shape(gj) + assert rt.is_valid and not rt.is_empty + assert len(rt.geoms) == 2 + + def test_single_part_stays_polygon(self): + gj = to_wgs84_geojson_multi(box(530000, 180000, 530100, 180100)) + assert gj["type"] == "Polygon" + + def test_writer_emits_multipolygon_feature(self, tmp_path): + mp = MultiPolygon( + [ + box(530000, 180000, 530100, 180100), + box(531000, 180000, 531100, 180100), + ] + ) + assert write_district_geojson({"AA1 1AA": mp}, tmp_path) == 1 + coll = json.loads((tmp_path / "units" / "AA1.geojson").read_text()) + assert coll["features"][0]["geometry"]["type"] == "MultiPolygon" + + +class TestOutputPartition: + """The writer must emit a partition: overlapping postcodes are made disjoint + (no two cover the same ground) without dropping an active postcode.""" + + def test_overlapping_postcodes_made_disjoint(self, tmp_path): + from shapely.geometry import shape + + a = box(530000, 180000, 530100, 180100) + b = box(530090, 180000, 530200, 180100) # overlaps `a` in a 10m strip + assert a.intersection(b).area > 0 # precondition: they overlap + + write_district_geojson({"AA1 1AA": a, "AA1 1AB": b}, tmp_path) + coll = json.loads((tmp_path / "units" / "AA1.geojson").read_text()) + geoms = { + f["properties"]["postcodes"]: shape(f["geometry"]) + for f in coll["features"] + } + assert set(geoms) == {"AA1 1AA", "AA1 1AB"} # neither dropped + # Disjoint interiors (share at most an edge). + assert geoms["AA1 1AA"].intersection(geoms["AA1 1AB"]).area == pytest.approx( + 0.0, abs=1e-12 + ) + assert all(g.area > 0 for g in geoms.values()) + + def test_enclosed_postcode_makes_container_a_donut(self, tmp_path): + """A postcode fully INSIDE another must stay disjoint: the smaller (inner) + keeps its area, the container gets a hole. A plain `overlaps` query misses + containment, so this is the regression guard for that fix.""" + from shapely.geometry import shape + + outer = box(530000, 180000, 530300, 180300) # 90,000 m² + inner = box(530100, 180100, 530200, 180200) # 10,000 m², fully inside outer + assert outer.contains(inner) # precondition + + write_district_geojson({"AA1 1AA": outer, "AA1 1AB": inner}, tmp_path) + coll = json.loads((tmp_path / "units" / "AA1.geojson").read_text()) + geoms = { + f["properties"]["postcodes"]: shape(f["geometry"]) + for f in coll["features"] + } + assert set(geoms) == {"AA1 1AA", "AA1 1AB"} # neither dropped + assert geoms["AA1 1AA"].intersection(geoms["AA1 1AB"]).area == pytest.approx( + 0.0, abs=1e-12 + ) + # Container is now a donut around the enclosed postcode. + assert geoms["AA1 1AA"].geom_type == "Polygon" + assert len(list(geoms["AA1 1AA"].interiors)) == 1 + assert geoms["AA1 1AB"].area > 0 + + +class TestGeojsonGeometrySliverValidity: + """_geojson_geometry must emit geometry that is still valid after the final 6dp + rounding. A sub-grid overlap sliver (left by _resolve_overlaps' full-precision + difference) must be snapped away on the output grid rather than collapsed by a + naive round into a degenerate ('Too few points') / self-intersecting ring that + only fails once read back from disk.""" + + def test_subgrid_sliver_does_not_produce_invalid_ring(self): + from shapely.geometry import shape + from shapely.validation import explain_validity + + from .output import _geojson_geometry + + main = box(-0.34, 51.74, -0.33, 51.75) # ~degree-scale, valid + # A thin triangle whose three vertices all round to the same 1e-6 cell. + sliver = Polygon( + [ + (-0.3400284, 51.7505061), + (-0.3400280, 51.7505060), + (-0.3400281, 51.7505063), + ] + ) + assert sliver.is_valid + + gj = _geojson_geometry(MultiPolygon([main, sliver])) + assert gj is not None + rt = shape(gj) + assert rt.is_valid, explain_validity(rt) + assert not rt.is_empty + # The real (main) area is preserved; only the degenerate sliver is dropped. + assert rt.area == pytest.approx(main.area, rel=1e-3) + + def test_pure_sliver_is_rescued_not_written_invalid(self): + from shapely.geometry import shape + + from .output import _OUTPUT_PRECISION_DEG, _geojson_geometry + + # A sub-grid sliver (extent < 1e-6° in one dimension) that snaps to empty: + # it must be rescued into a minimal valid footprint at its location, never + # written invalid and never dropped (an active postcode keeps a boundary). + g = _OUTPUT_PRECISION_DEG + sliver = Polygon( + [ + (-0.30, 51.75), + (-0.30 + 5 * g, 51.75), + (-0.30 + 5 * g, 51.75 + 0.2 * g), + (-0.30, 51.75 + 0.1 * g), + ] + ) + gj = _geojson_geometry(sliver) + assert gj is not None # rescued, not dropped + rt = shape(gj) + assert rt.is_valid and not rt.is_empty + assert rt.distance(sliver) == pytest.approx(0.0, abs=1e-4) + + +class TestColocatedPostcodesAllRetained: + """Co-located non-geographic postcodes (e.g. AL1 9xx) have heavily-overlapping + tiny footprints; the de-overlap pass trims most to sub-grid slivers. None may + be dropped — every active postcode must keep a (valid, non-empty) boundary.""" + + def test_overlapping_tiny_footprints_none_dropped(self, tmp_path): + from shapely.geometry import Point, shape + + # 12 ~3 m discs within ~1 m of each other → after de-overlap most collapse + # below output precision. + postcodes = {} + for i in range(12): + e = 514000.0 + (i % 4) * 0.3 + n = 206000.0 + (i // 4) * 0.3 + postcodes[f"AL1 9{chr(65 + i)}{chr(65 + i)}"] = Point(e, n).buffer(3.0) + + write_district_geojson(postcodes, tmp_path) + coll = json.loads((tmp_path / "units" / "AL1.geojson").read_text()) + written = {f["properties"]["postcodes"] for f in coll["features"]} + assert written == set(postcodes), f"dropped: {set(postcodes) - written}" + for feature in coll["features"]: + geom = shape(feature["geometry"]) + assert geom.is_valid and not geom.is_empty + + +class TestSafeOverlayHelpers: + """The robust overlay helpers retry on a fixed-precision grid after a + GEOSException (e.g. ``side location conflict`` from near-coincident OA-seam + edges). The grid is a caller-supplied parameter: metres for the BNG stages, + 1e-6 degrees for the WGS84 output stage — so the same helper serves both + without crushing degree-scale shapes on the metre default.""" + + def test_grid_param_honored_on_clean_inputs(self): + from . import geometry + + a = box(0, 0, 10, 10) + b = box(5, 0, 15, 10) + # No exception → exact result, identical regardless of the fallback grid. + assert geometry.safe_difference(a, b, grid=1e-6).equals(a.difference(b)) + assert geometry.safe_union([a, b], grid=1e-6).equals(unary_union([a, b])) + assert geometry.safe_intersection(a, b, grid=1e-6).equals(a.intersection(b)) + + def test_difference_falls_back_to_fixed_precision_overlay(self, monkeypatch): + from shapely import GEOSException + from shapely.geometry.base import BaseGeometry + + from . import geometry + + a = box(0, 0, 10, 10) + b = box(5, 0, 15, 10) + + def _boom(self, other, *args, **kwargs): + raise GEOSException("forced side location conflict") + + # Patch the .difference() *method*; the fallback uses the top-level + # shapely.difference() function, so it still completes. + monkeypatch.setattr(BaseGeometry, "difference", _boom) + result = geometry.safe_difference(a, b, grid=1e-6) + assert result.is_valid + assert result.area == pytest.approx(50.0) # left half of `a` + + def test_union_falls_back_to_fixed_precision_overlay(self, monkeypatch): + from shapely import GEOSException + + from . import geometry + + a = box(0, 0, 10, 10) + b = box(5, 0, 15, 10) + + def _boom(_geoms): + raise GEOSException("forced robustness failure") + + monkeypatch.setattr(geometry, "unary_union", _boom) + result = geometry.safe_union([a, b], grid=1e-6) + assert result.is_valid + assert result.area == pytest.approx(150.0) # 100 + 100 - 50 overlap + + # A self-intersecting bow-tie: invalid. set_precision()'s DEFAULT + # ('valid_output') mode runs its own noding pass that re-raises + # 'side location conflict' on this — which is exactly how the production + # crash happened (the fallback re-raised the error it was meant to absorb). + _BOWTIE = Polygon([(0, 0), (1e-5, 1e-5), (1e-5, 0), (0, 1e-5), (0, 0)]) + # make_valid() of this spiky ring returns a mixed-dimension + # GeometryCollection (polygon + dangling line); OverlayNG rejects that with + # 'Overlay input is mixed-dimension', so the fallback must strip the debris. + _SPIKY = Polygon( + [(0, 0), (10, 0), (10, 10), (5, 5), (5.0000001, 5), (0, 10), (0, 0), (15, -5), (0, 0)] + ) + + def test_difference_fallback_survives_invalid_input(self, monkeypatch): + """Regression for the production crash: the fallback must not reduce + precision with set_precision's default valid_output mode, which re-raises + 'side location conflict' on an invalid geometry.""" + from shapely import GEOSException + from shapely.geometry.base import BaseGeometry + + from . import geometry + + assert not self._BOWTIE.is_valid # precondition + + def _boom(self, other, *a, **k): + raise GEOSException("forced side location conflict") + + monkeypatch.setattr(BaseGeometry, "difference", _boom) + result = geometry.safe_difference(self._BOWTIE, box(0, 0, 5e-6, 5e-6), grid=1e-6) + assert result.is_valid + assert result.geom_type in ("Polygon", "MultiPolygon") + + def test_difference_fallback_survives_make_valid_geometrycollection( + self, monkeypatch + ): + """Regression: make_valid can yield a mixed-dimension GeometryCollection + that OverlayNG rejects; the fallback must keep only polygonal parts.""" + from shapely import GEOSException, make_valid + from shapely.geometry.base import BaseGeometry + + from . import geometry + + assert make_valid(self._SPIKY).geom_type == "GeometryCollection" # precondition + + def _boom(self, other, *a, **k): + raise GEOSException("forced side location conflict") + + monkeypatch.setattr(BaseGeometry, "difference", _boom) + result = geometry.safe_difference(self._SPIKY, box(2, 2, 8, 8), grid=1e-4) + assert result.is_valid + assert result.geom_type in ("Polygon", "MultiPolygon") + assert result.area > 0 + + def test_intersection_fallback_survives_make_valid_geometrycollection( + self, monkeypatch + ): + from shapely import GEOSException + from shapely.geometry.base import BaseGeometry + + from . import geometry + + def _boom(self, other, *a, **k): + raise GEOSException("forced side location conflict") + + monkeypatch.setattr(BaseGeometry, "intersection", _boom) + result = geometry.safe_intersection(self._SPIKY, box(2, 2, 8, 8), grid=1e-4) + assert result.is_valid + assert result.geom_type in ("Polygon", "MultiPolygon") + assert result.area > 0 + + def test_union_fallback_survives_invalid_and_collection_inputs(self, monkeypatch): + """The union fallback must absorb both an invalid bow-tie and a + make_valid-becomes-GeometryCollection input without raising.""" + from shapely import GEOSException + + from . import geometry + + def _boom(_geoms): + raise GEOSException("forced robustness failure") + + monkeypatch.setattr(geometry, "unary_union", _boom) + result = geometry.safe_union( + [self._BOWTIE, self._SPIKY, box(20, 20, 30, 30)], grid=1e-4 + ) + assert result.is_valid + assert result.geom_type in ("Polygon", "MultiPolygon") + + def test_helpers_never_raise_on_empty_inputs(self): + """Degenerate/empty inputs must not abort a multi-hour run.""" + from . import geometry + + empty = Polygon() + a = box(0, 0, 10, 10) + assert geometry.safe_difference(empty, a).is_empty + assert geometry.safe_difference(a, empty).equals(a) + assert geometry.safe_intersection(empty, a).is_empty + assert geometry.safe_union([]).is_empty + assert geometry.safe_union([empty]).is_empty + + +# --------------------------------------------------------------------------- +# InspireIndex must return the same candidates as a brute-force bbox scan +# --------------------------------------------------------------------------- + + +class TestInspireIndex: + """The grid index replaces a per-OA linear scan of all parcel bboxes; it must + return an identical candidate set (and order) so Phase 3 output is unchanged.""" + + @staticmethod + def _brute(bboxes, box): + e0, n0, e1, n1 = box + mask = ( + (bboxes[:, 2] >= e0) + & (bboxes[:, 0] <= e1) + & (bboxes[:, 3] >= n0) + & (bboxes[:, 1] <= n1) + ) + return np.where(mask)[0] + + def test_matches_brute_force_over_random_queries(self): + rng = np.random.default_rng(0) + x = rng.uniform(0, 10000, 5000) + y = rng.uniform(0, 10000, 5000) + w = rng.uniform(1, 60, 5000) # all <= 500m cell → CSR path + h = rng.uniform(1, 60, 5000) + bboxes = np.column_stack([x, y, x + w, y + h]).astype(np.float64) + idx = build_inspire_index(bboxes, None, None, cell_size=500.0) + + for _ in range(400): + cx, cy = rng.uniform(0, 10000), rng.uniform(0, 10000) + sz = float(rng.choice([30.0, 200.0, 1000.0, 3000.0])) + box = (cx, cy, cx + sz, cy + sz) + got = idx.candidate_indices(box) + expected = np.sort(self._brute(bboxes, box)) + assert np.array_equal(got, expected) + + def test_oversized_parcel_is_found(self): + # A parcel larger than a cell goes to the overflow list, not the grid; + # a query deep inside it (away from the small parcels) must still find it. + bboxes = np.array( + [ + [0.0, 0.0, 5000.0, 5000.0], # 5km parcel >> 500m cell + [100.0, 100.0, 120.0, 120.0], + [4000.0, 4000.0, 4020.0, 4020.0], + ] + ) + idx = build_inspire_index(bboxes, None, None, cell_size=500.0) + box = (2000.0, 2000.0, 2050.0, 2050.0) + got = idx.candidate_indices(box) + assert 0 in got + assert np.array_equal(got, np.sort(self._brute(bboxes, box))) + + def test_no_overlap_returns_empty(self): + bboxes = np.array([[0.0, 0.0, 10.0, 10.0], [20.0, 20.0, 30.0, 30.0]]) + idx = build_inspire_index(bboxes, None, None, cell_size=500.0) + assert len(idx.candidate_indices((100.0, 100.0, 110.0, 110.0))) == 0 + + +# --------------------------------------------------------------------------- +# Parallel OA processing must match the sequential result exactly +# --------------------------------------------------------------------------- + + +class TestParallelProcessing: + """_process_oas across workers must produce the same fragments as workers=1. + Uses single-postcode OAs (fast path), so it exercises the chunking + WKB + round-trip + fork machinery without needing INSPIRE data.""" + + @staticmethod + def _inputs(n_oas=60): + import pyarrow as pa + + oa_geoms = { + f"E{i:08d}": box(i * 100.0, 0.0, i * 100.0 + 50.0, 50.0) + for i in range(n_oas) + } + codes = sorted(oa_geoms) + east, north, pcs = [], [], [] + offsets = {} + pos = 0 + for i, code in enumerate(codes): + east += [i * 100.0 + 10.0, i * 100.0 + 20.0] + north += [10.0, 20.0] + pcs += [f"AA{i % 5} {i % 9}AA"] * 2 # one postcode per OA → fast path + offsets[code] = (pos, pos + 2) + pos += 2 + return ( + codes, + oa_geoms, + np.array(east), + np.array(north), + pa.array(pcs, type=pa.large_string()), + offsets, + ) + + @staticmethod + def _norm(frags): + return sorted((pc, geom.wkb_hex) for pc, geom in frags) + + def test_parallel_matches_sequential(self): + codes, oa, east, north, pcs, offs = self._inputs() + seq, s1 = _process_oas(codes, oa, east, north, pcs, offs, None, workers=1) + par, s2 = _process_oas(codes, oa, east, north, pcs, offs, None, workers=3) + assert len(seq) == len(codes) # one fragment per single-postcode OA + assert s1 == s2 == len(codes) + assert self._norm(seq) == self._norm(par) + + def test_oa_failure_is_tagged_with_oa_code(self): + """A failure inside per-OA processing must re-raise with the OA code, so a + single bad OA is attributable instead of an anonymous worker abort.""" + # Missing OA in the geoms dict → KeyError, wrapped with the OA code. + with pytest.raises(RuntimeError, match="E00099999"): + _oa_fragments("E00099999", {}, None, None, None, {}, None) + + +class TestDegenerateGeometryHandling: + """Every active postcode must keep a boundary (validate_outputs is strict), + so a sub-grid sliver is fattened rather than dropped. A genuinely empty + geometry is skipped without aborting the whole write (the 10h regression).""" + + # Three near-collinear vertices in BNG: bbox ~28m x 7m but area ~0.04 m², + # i.e. AL10 0TU. Without the rescue it snaps to empty at output precision. + SLIVER = Polygon( + [(523045.34, 209625.56), (523040.47, 209624.33), (523017.0, 209618.42)] + ) + + def test_sliver_is_rescued_to_valid_geometry(self): + from shapely.geometry import shape + + result = to_wgs84_geojson(self.SLIVER) + assert result is not None, "sliver must be rescued, not dropped" + rt = shape(result) + assert not rt.is_empty + assert rt.is_valid + + def test_collinear_zero_area_input_is_rescued(self): + """A zero-area collinear 'polygon' (can't be cleaned to a polygon) must + still be rescued via the representative-point fallback, not dropped.""" + from shapely.geometry import shape + + degenerate = Polygon( + [(523000, 209600), (523010, 209600), (523020, 209600), (523000, 209600)] + ) + assert degenerate.area == 0.0 + result = to_wgs84_geojson(degenerate) + assert result is not None, "degenerate input must be rescued, not dropped" + rt = shape(result) + assert not rt.is_empty + assert rt.is_valid + + def test_sliver_postcode_present_in_output(self, tmp_path): + postcodes = { + "AA1 1AA": box(530000, 180000, 530100, 180100), + "AA1 1AB": self.SLIVER, # must survive + } + file_count = write_district_geojson(postcodes, tmp_path) + assert file_count == 1 + collection = json.loads((tmp_path / "units" / "AA1.geojson").read_text()) + written = {f["properties"]["postcodes"] for f in collection["features"]} + assert written == {"AA1 1AA", "AA1 1AB"} + + def test_empty_geometry_skipped_not_raised(self, tmp_path): + # The last-resort safety net: an unrescuable (empty) geometry is skipped + # so one bad postcode can never abort a multi-hour run. + postcodes = { + "AA1 1AA": box(530000, 180000, 530100, 180100), + "AA1 1AB": Polygon(), # genuinely empty + } + file_count = write_district_geojson(postcodes, tmp_path) + assert file_count == 1 + collection = json.loads((tmp_path / "units" / "AA1.geojson").read_text()) + written = {f["properties"]["postcodes"] for f in collection["features"]} + assert written == {"AA1 1AA"} + + +# --------------------------------------------------------------------------- +# fragments_cache round-trips Phase 3 output and validates freshness +# --------------------------------------------------------------------------- + + +class TestFragmentsCache: + """Persisting Phase 3 lets a crashed run resume without the ~10h OA loop.""" + + def test_round_trip_preserves_postcodes_and_geometry(self, tmp_path): + fragments = [ + ("AA1 1AA", box(0, 0, 100, 100)), + ("AA1 1AB", box(200, 200, 250, 260)), + # A postcode spanning multiple OAs appears as repeated entries. + ("AA1 1AA", box(100, 0, 150, 100)), + ("AA1 1AC", MultiPolygon([box(0, 0, 10, 10), box(20, 20, 30, 30)])), + ] + cache = tmp_path / "fragments_cache.parquet" + save_fragments(cache, fragments) + loaded = load_fragments(cache) + + assert [pc for pc, _ in loaded] == [pc for pc, _ in fragments] + for (_, original), (_, restored) in zip(fragments, loaded): + assert restored.equals(original) + + def test_save_is_atomic_no_tmp_left_behind(self, tmp_path): + cache = tmp_path / "fragments_cache.parquet" + save_fragments(cache, [("AA1 1AA", box(0, 0, 1, 1))]) + assert cache.exists() + assert not (tmp_path / "fragments_cache.parquet.tmp").exists() + + def test_missing_cache_is_not_fresh(self, tmp_path): + cache = tmp_path / "fragments_cache.parquet" + inp = tmp_path / "uprn.parquet" + inp.write_text("x") + assert fragments_cache_is_fresh(cache, [inp]) is False + + def test_cache_newer_than_inputs_is_fresh(self, tmp_path): + import os + + inp = tmp_path / "uprn.parquet" + inp.write_text("x") + cache = tmp_path / "fragments_cache.parquet" + cache.write_text("c") + os.utime(inp, (1_000, 1_000)) + os.utime(cache, (2_000, 2_000)) + assert fragments_cache_is_fresh(cache, [inp, None]) is True + + def test_cache_older_than_any_input_is_stale(self, tmp_path): + import os + + inp = tmp_path / "oa.gpkg" + inp.write_text("x") + cache = tmp_path / "fragments_cache.parquet" + cache.write_text("c") + os.utime(cache, (1_000, 1_000)) + os.utime(inp, (2_000, 2_000)) # input touched after the cache + assert fragments_cache_is_fresh(cache, [inp]) is False + + def test_missing_input_is_ignored(self, tmp_path): + cache = tmp_path / "fragments_cache.parquet" + cache.write_text("c") + # arcgis is optional/absent — it cannot have invalidated the cache. + assert fragments_cache_is_fresh(cache, [tmp_path / "absent.parquet"]) is True diff --git a/pipeline/transform/postcode_boundaries/uprn.py b/pipeline/transform/postcode_boundaries/uprn.py index a1f4bdc..d7d19b8 100644 --- a/pipeline/transform/postcode_boundaries/uprn.py +++ b/pipeline/transform/postcode_boundaries/uprn.py @@ -4,11 +4,45 @@ import numpy as np import polars as pl from pipeline.local_temp import local_tmp_dir +from pipeline.utils.postcode_mapping import build_postcode_mapping from .memory import release_memory -def load_uprns(uprn_path: Path) -> tuple[pl.DataFrame, dict[str, tuple[int, int]]]: +def _canonical_postcode_expr(name: str) -> pl.Expr: + return pl.col(name).str.strip_chars().str.to_uppercase() + + +def _active_english_arcgis_postcodes(arcgis_path: Path) -> pl.LazyFrame: + return ( + pl.read_parquet( + arcgis_path, + columns=["pcds", "east1m", "north1m", "oa21cd", "ctry25cd", "doterm"], + ) + .lazy() + .filter(pl.col("ctry25cd") == "E92000001") + .filter(pl.col("doterm").cast(pl.Utf8).is_null()) + .select( + _canonical_postcode_expr("pcds").alias("PCDS"), + pl.col("east1m").cast(pl.Float64).alias("GRIDGB1E"), + pl.col("north1m").cast(pl.Float64).alias("GRIDGB1N"), + pl.col("oa21cd").alias("OA21CD"), + ) + .filter( + pl.col("PCDS").is_not_null() + & (pl.col("PCDS") != "") + & pl.col("GRIDGB1E").is_not_null() + & pl.col("GRIDGB1N").is_not_null() + & pl.col("OA21CD").is_not_null() + & pl.col("OA21CD").str.starts_with("E") + ) + .unique("PCDS") + ) + + +def load_uprns( + uprn_path: Path, arcgis_path: Path | None = None +) -> tuple[pl.DataFrame, dict[str, tuple[int, int]]]: """Load UPRNs as a sorted polars DataFrame with OA offset lookup. Returns (df, offsets) where offsets[oa_code] = (start_row, end_row). @@ -17,29 +51,87 @@ def load_uprns(uprn_path: Path) -> tuple[pl.DataFrame, dict[str, tuple[int, int] import tempfile print("Loading UPRN lookup...") + mapping = None + active_postcode_points = None + if arcgis_path is not None: + mapping = ( + build_postcode_mapping(arcgis_path) + .with_columns( + _canonical_postcode_expr("old_postcode").alias("old_postcode"), + _canonical_postcode_expr("new_postcode").alias("new_postcode"), + ) + .unique("old_postcode") + ) + active_postcode_points = _active_english_arcgis_postcodes(arcgis_path) # Sort via streaming sink to avoid polars doubling memory during in-memory sort with tempfile.NamedTemporaryFile( suffix=".parquet", delete=False, dir=local_tmp_dir() ) as tmp: tmp_path = Path(tmp.name) - ( + uprns = ( pl.scan_parquet(uprn_path) .select("GRIDGB1E", "GRIDGB1N", "PCDS", "OA21CD") - .filter(~pl.col("OA21CD").str.starts_with("S")) + .filter(pl.col("OA21CD").str.starts_with("E")) .filter(pl.col("GRIDGB1E").is_not_null() & pl.col("GRIDGB1N").is_not_null()) - .with_columns(pl.col("PCDS").str.strip_chars()) + .with_columns(_canonical_postcode_expr("PCDS").alias("PCDS")) .filter(pl.col("PCDS").is_not_null() & (pl.col("PCDS") != "")) - .sort("OA21CD") - .sink_parquet(tmp_path) ) + + if mapping is not None and mapping.height > 0: + # Remap terminated postcodes to their nearest active successor. The + # successor generally lives in a DIFFERENT OA (and at different grid + # coordinates), so the remapped point must adopt the successor's + # authoritative OA/coords — keeping the terminated postcode's original + # OA would seed the successor into an OA it doesn't belong to, splitting + # its boundary across OAs. Genuine (non-remapped) UPRN rows keep their + # own OA, since a live postcode can legitimately span several OAs. + uprns = uprns.join( + mapping.lazy(), left_on="PCDS", right_on="old_postcode", how="left" + ).with_columns(pl.col("new_postcode").is_not_null().alias("_remapped")) + if active_postcode_points is not None: + successor_oa = active_postcode_points.rename( + { + "PCDS": "new_postcode", + "GRIDGB1E": "_succ_e", + "GRIDGB1N": "_succ_n", + "OA21CD": "_succ_oa", + } + ) + uprns = uprns.join(successor_oa, on="new_postcode", how="left").with_columns( + pl.when("_remapped") + .then(pl.col("_succ_e")) + .otherwise(pl.col("GRIDGB1E")) + .alias("GRIDGB1E"), + pl.when("_remapped") + .then(pl.col("_succ_n")) + .otherwise(pl.col("GRIDGB1N")) + .alias("GRIDGB1N"), + pl.when("_remapped") + .then(pl.col("_succ_oa")) + .otherwise(pl.col("OA21CD")) + .alias("OA21CD"), + ) + uprns = uprns.with_columns( + pl.coalesce("new_postcode", "PCDS").alias("PCDS") + ).select("GRIDGB1E", "GRIDGB1N", "PCDS", "OA21CD") + + if active_postcode_points is not None: + active_postcodes = active_postcode_points.select("PCDS").unique() + uprns = uprns.join(active_postcodes, on="PCDS", how="semi") + missing_active = active_postcode_points.join( + uprns.select("PCDS").unique(), on="PCDS", how="anti" + ).select("GRIDGB1E", "GRIDGB1N", "PCDS", "OA21CD") + uprns = pl.concat([uprns, missing_active], how="vertical_relaxed") + + uprns.sort("OA21CD").sink_parquet(tmp_path) release_memory() # Read the sorted data — only one copy in memory (~2GB) df = pl.read_parquet(tmp_path) tmp_path.unlink() n = len(df) - print(f" Loaded {n:,} UPRNs (England & Wales)") + print(f" Loaded {n:,} UPRNs (England)") # Compute OA group offsets using polars (avoids 37M Python string creation) boundary_df = ( @@ -86,3 +178,37 @@ def get_oa_uprns( ) postcodes = sub["PCDS"].to_list() return points, postcodes + + +def extract_uprn_arrays(df: pl.DataFrame): + """Convert the UPRN DataFrame to fork-shareable numpy/Arrow arrays. + + Returns ``(east, north, postcodes)``: two float64 ndarrays and a contiguous + pyarrow string Array. Multiprocessing workers slice these per OA via + :func:`get_oa_uprns_arrays` **without touching polars**, which avoids the + fork-after-threads deadlock hazard of polars' rayon pool. Being plain + numpy/Arrow buffers (not millions of Python objects), they are shared by + ``fork`` copy-on-write rather than duplicated ~1GB per worker. + """ + import pyarrow as pa + + east = np.ascontiguousarray(df["GRIDGB1E"].to_numpy(), dtype=np.float64) + north = np.ascontiguousarray(df["GRIDGB1N"].to_numpy(), dtype=np.float64) + postcodes = df["PCDS"].to_arrow() + if isinstance(postcodes, pa.ChunkedArray): + postcodes = postcodes.combine_chunks() + return east, north, postcodes + + +def get_oa_uprns_arrays( + east: np.ndarray, + north: np.ndarray, + postcodes, + offsets: dict[str, tuple[int, int]], + oa_code: str, +) -> tuple[np.ndarray, list[str]]: + """Like :func:`get_oa_uprns`, but slices the fork-shareable arrays from + :func:`extract_uprn_arrays` (no polars), so it is safe to call in workers.""" + s, e = offsets[oa_code] + points = np.column_stack([east[s:e], north[s:e]]) + return points, postcodes.slice(s, e - s).to_pylist() diff --git a/pipeline/transform/postcode_boundaries/voronoi.py b/pipeline/transform/postcode_boundaries/voronoi.py index 21ce654..939d3c9 100644 --- a/pipeline/transform/postcode_boundaries/voronoi.py +++ b/pipeline/transform/postcode_boundaries/voronoi.py @@ -4,7 +4,8 @@ import numpy as np from scipy.spatial import QhullError, Voronoi from shapely import make_valid from shapely.geometry import MultiPolygon, Polygon -from shapely.ops import unary_union + +from .geometry import safe_intersection, safe_union def compute_voronoi_regions( @@ -20,33 +21,48 @@ def compute_voronoi_regions( # Convert to float64 so sub-metre jitter isn't truncated. points = points.astype(np.float64) - # Deduplicate points, keeping one per (location, postcode) pair. - # Multiple postcodes at the same coordinate each get their own point, - # jittered by a tiny offset (0.01m) so Voronoi can distinguish them. - # Coords are rounded to mm precision for stable hashing — UPRN inputs are - # already integer metres, but the float64 cast can introduce ULP noise. - GOLDEN_ANGLE = np.pi * (3.0 - np.sqrt(5.0)) + # Deduplicate points, keeping one per (location, postcode) pair. Coords are + # rounded to mm precision for stable hashing — UPRN inputs are already integer + # metres, but the float64 cast can introduce ULP noise. + # + # Where several DISTINCT postcodes share one coordinate, jitter ALL of them + # onto a small regular polygon (equal 0.01m radius, equally spaced by angle) + # so their Voronoi cells become equal wedges and NONE is crushed. Leaving any + # seed at the centre — or innermost on a spiral — squeezes its cell below + # MIN_GEOM_AREA, which _clean_polygonal then drops downstream, silently losing + # an active postcode. Seeds at a UNIQUE coordinate are left exactly on their + # UPRN (no perturbation of normal Voronoi output). Coords are rounded to mm + # for stable hashing (the float64 cast can add ULP noise). + rounded_coords = [ + (round(float(points[i, 0]), 3), round(float(points[i, 1]), 3)) + for i in range(len(points)) + ] + coord_postcodes: dict[tuple[float, float], set[str]] = defaultdict(set) + for coord, pc in zip(rounded_coords, postcodes): + coord_postcodes[coord].add(pc) + seen: dict[tuple[float, float, str], bool] = {} unique_pts = [] unique_pcs = [] coord_counts: dict[tuple[float, float], int] = defaultdict(int) for i in range(len(points)): - coord = (round(float(points[i, 0]), 3), round(float(points[i, 1]), 3)) + coord = rounded_coords[i] key = (coord[0], coord[1], postcodes[i]) if key not in seen: seen[key] = True - jitter_idx = coord_counts[coord] - coord_counts[coord] += 1 - if jitter_idx == 0: - unique_pts.append(points[i].copy()) - else: - # Golden-angle spacing distributes any number of jittered - # points evenly around (and outward from) the original coord. + count = len(coord_postcodes[coord]) + if count > 1: + # Coincident cluster: equally-spaced regular polygon -> equal + # Voronoi wedges, so every postcode here keeps a fair share. + jitter_idx = coord_counts[coord] + coord_counts[coord] += 1 + angle = 2.0 * np.pi * jitter_idx / count jittered = points[i].copy() - angle = jitter_idx * GOLDEN_ANGLE jittered[0] += 0.01 * np.cos(angle) jittered[1] += 0.01 * np.sin(angle) unique_pts.append(jittered) + else: + unique_pts.append(points[i].copy()) unique_pcs.append(postcodes[i]) if len(unique_pts) == 1: @@ -96,13 +112,13 @@ def compute_voronoi_regions( poly = Polygon(vertices) if not poly.is_valid: poly = make_valid(poly) - clipped = poly.intersection(boundary) + clipped = safe_intersection(poly, boundary) if not clipped.is_empty: pc_polys[unique_pcs[i]].append(clipped) result = {} for pc, parts in pc_polys.items(): - merged = unary_union(parts) + merged = safe_union(parts) if not merged.is_empty: result[pc] = merged return result @@ -128,7 +144,7 @@ def _equal_split_fallback( (min_x, strip_max_y), ] ) - clipped = boundary.intersection(strip) + clipped = safe_intersection(boundary, strip) if not clipped.is_empty: result[pc] = clipped return result diff --git a/pipeline/transform/price_estimation/backtest.py b/pipeline/transform/price_estimation/backtest.py index 0687387..7ac68fc 100644 --- a/pipeline/transform/price_estimation/backtest.py +++ b/pipeline/transform/price_estimation/backtest.py @@ -11,9 +11,9 @@ from pathlib import Path import numpy as np import polars as pl +from pipeline.transform.price_estimation.estimate import guarded_blend_estimates from pipeline.transform.price_estimation.index import build_index from pipeline.transform.price_estimation.knn import ( - KNN_BLEND_WEIGHT, build_knn_pool, knn_median_psm, ) @@ -67,6 +67,16 @@ def extract_test_set(input_path: Path) -> pl.DataFrame: .struct.field("price") .alias("input_price"), ) + .with_columns( + # Date of the input (second-to-last) sale, used by the kNN leakage + # filter to exclude the target property's own prior sale from its + # comparables. Built from year+month (day defaults to the 1st). + pl.date( + pl.col("input_year").cast(pl.Int32), + pl.col("input_month").cast(pl.Int32), + 1, + ).alias("input_date"), + ) .with_columns( ( pl.col("actual_year").cast(pl.Float64) @@ -105,7 +115,10 @@ def predict(test: pl.DataFrame, index: pl.DataFrame) -> pl.DataFrame: .clip(-MAX_LOG_ADJUSTMENT, MAX_LOG_ADJUSTMENT) .exp() ) - .fill_null(pl.col("input_price").cast(pl.Float64)) + # Keep null when the index can't be interpolated, matching production + # (estimate.py ships null there). compute_metrics filters to finite + # positive predictions, so these rows correctly drop from the Index n + # rather than silently degrading to the Naive prediction. .alias("predicted"), ) return test @@ -255,13 +268,12 @@ def main(): f" kNN estimates: {n_knn:,} of {len(test):,} ({n_knn / len(test) * 100:.1f}%)" ) - # Blend: (1-w)*index + w*kNN where both available + # Blend with the exact shipped estimator (stability gate + last-price cap + + # null-when-no-index) so the "Blended" stage reflects production accuracy. + # input_price is the backtest equivalent of production's "Last known price". index_est = test["predicted"].to_numpy().astype(np.float64) - knn_valid = np.isfinite(knn_est) & (knn_est > 0) - blended = np.where( - knn_valid & np.isfinite(index_est), - (1 - KNN_BLEND_WEIGHT) * index_est + KNN_BLEND_WEIGHT * knn_est, - np.where(np.isfinite(index_est), index_est, knn_est), + blended = guarded_blend_estimates( + index_est, knn_est, test["input_price"].cast(pl.Float64).to_numpy() ) actual = test["actual_price"].to_numpy().astype(np.float64) diff --git a/pipeline/transform/price_estimation/estimate.py b/pipeline/transform/price_estimation/estimate.py index a66f1d9..fb0af8e 100644 --- a/pipeline/transform/price_estimation/estimate.py +++ b/pipeline/transform/price_estimation/estimate.py @@ -18,6 +18,8 @@ import polars as pl from pipeline.transform.price_estimation.knn import ( KNN_BLEND_WEIGHT, + MAX_COMPARABLE_PSM, + MIN_COMPARABLE_PSM, build_knn_pool, knn_median_psm, ) @@ -31,7 +33,13 @@ from pipeline.transform.price_estimation.utils import ( MAX_KNN_TO_INDEX_RATIO = 2.0 MIN_KNN_TO_INDEX_RATIO = 0.5 -MAX_ESTIMATE_TO_LAST_PRICE_RATIO = 6.0 +# Cap the final estimate at this multiple of the last known price as a guard +# against data errors. Set to ~exp(MAX_LOG_ADJUSTMENT) (~20x) so it is +# consistent with the log-index clip already applied to the index move: many +# UK sectors legitimately grew >6x since the 1990s (e.g. parts of inner London +# 12-14x), so the previous 6x cap truncated genuine appreciation rather than +# only catching outliers. +MAX_ESTIMATE_TO_LAST_PRICE_RATIO = 20.0 def guarded_blend_estimates( @@ -222,11 +230,22 @@ def main(): ).height print(f" kNN blended: {n_blended:,} of {n_estimated:,} estimates") - # Derive estimated price per sqm where both estimated price and floor area exist + # Derive estimated price per sqm where both estimated price and floor area + # exist. Null out values outside the plausibility band [MIN_COMPARABLE_PSM, + # MAX_COMPARABLE_PSM] (the same band the kNN pool uses): extreme values come + # from bulk/block transactions or floor-area errors and are not meaningful + # per-unit prices. + _est_psm = pl.col("Estimated current price") / pl.col("Total floor area (sqm)") df = df.with_columns( - (pl.col("Estimated current price") / pl.col("Total floor area (sqm)")) - .round(0) - .cast(pl.Int32, strict=False) + pl.when( + pl.col("Estimated current price").is_not_null() + & pl.col("Total floor area (sqm)").is_not_null() + & (pl.col("Total floor area (sqm)") > 0) + & (_est_psm >= MIN_COMPARABLE_PSM) + & (_est_psm <= MAX_COMPARABLE_PSM) + ) + .then(_est_psm.round(0).cast(pl.Int32, strict=False)) + .otherwise(None) .alias("Est. price per sqm"), ) diff --git a/pipeline/transform/price_estimation/index.py b/pipeline/transform/price_estimation/index.py index a07bc0b..e667ae3 100644 --- a/pipeline/transform/price_estimation/index.py +++ b/pipeline/transform/price_estimation/index.py @@ -19,11 +19,13 @@ from tqdm import tqdm from pipeline.transform.price_estimation.shrinkage import ( blend_dicts, hierarchical_shrinkage, + lift_onto_parent, shrink_dicts, spatial_smooth, ) from pipeline.transform.price_estimation.utils import ( CURRENT_YEAR, + TEMPORAL_SMOOTHNESS_LAMBDA, TYPE_GROUPS, build_hedonic_features, extract_centroids, @@ -165,12 +167,64 @@ def solve_robust_index( cols_arr = np.concatenate([col2[mask2], col1[mask1]]) signs_arr = np.concatenate([np.ones(mask2.sum()), -np.ones(mask1.sum())]) + # Temporal smoothness prior: penalise curvature in the year betas with a + # second-difference penalty lambda * (d2 beta / dt2)^2, encoded as extra + # least-squares rows (sqrt(lambda) * [w0, w1, w2] against a zero target). + # The weights are the CALENDAR-SPACING-AWARE second-derivative coefficients + # for the consecutive triple (y0, y1, y2), so gap years are not treated as + # adjacent: a multi-year gap relaxes the penalty (correctly preserving a + # genuine level jump) instead of forcing a smooth ramp. For unit spacing + # (1, 1) these reduce to [1, -2, 1], leaving contiguous cells unchanged. + # This damps single-year index spikes without flattening genuine trends. + # Betas are ordered by calendar year; the baseline year (min_year, implicit + # beta=0) has no column, so the penalty spans the non-baseline years only. + # For cells with <3 betas there is no curvature to penalise and the solve is + # unchanged. + n_pen = 0 + pen_rows_arr = pen_cols_arr = np.empty(0, dtype=np.int64) + pen_vals_arr = pen_b = np.empty(0, dtype=np.float64) + if TEMPORAL_SMOOTHNESS_LAMBDA > 0 and n_cols >= 3: + sqrt_lambda = float(np.sqrt(TEMPORAL_SMOOTHNESS_LAMBDA)) + years_sorted = sorted(year_to_col) + cols_by_year = [year_to_col[y] for y in years_sorted] + n_pen = n_cols - 2 + pen_rows = np.repeat(n + np.arange(n_pen), 3) + pen_cols = np.empty(n_pen * 3, dtype=np.int64) + pen_vals = np.empty(n_pen * 3, dtype=np.float64) + for k in range(n_pen): + pen_cols[3 * k : 3 * k + 3] = ( + cols_by_year[k], + cols_by_year[k + 1], + cols_by_year[k + 2], + ) + y0, y1, y2 = years_sorted[k], years_sorted[k + 1], years_sorted[k + 2] + w0 = 2.0 / ((y1 - y0) * (y2 - y0)) + w1 = -2.0 / ((y1 - y0) * (y2 - y1)) + w2 = 2.0 / ((y2 - y1) * (y2 - y0)) + pen_vals[3 * k : 3 * k + 3] = ( + sqrt_lambda * w0, + sqrt_lambda * w1, + sqrt_lambda * w2, + ) + pen_rows_arr = pen_rows.astype(np.int64) + pen_cols_arr = pen_cols + pen_vals_arr = pen_vals + pen_b = np.zeros(n_pen, dtype=np.float64) + n_total_rows = n + n_pen + weights = base_weights.copy() for _ in range(IRLS_ITERATIONS): data = signs_arr * weights[rows_arr] - A = csc_matrix((data, (rows_arr, cols_arr)), shape=(n, n_cols)) - b = log_ratios * weights + if n_pen: + all_data = np.concatenate([data, pen_vals_arr]) + all_rows = np.concatenate([rows_arr, pen_rows_arr]) + all_cols = np.concatenate([cols_arr, pen_cols_arr]) + b = np.concatenate([log_ratios * weights, pen_b]) + else: + all_data, all_rows, all_cols = data, rows_arr, cols_arr + b = log_ratios * weights + A = csc_matrix((all_data, (all_rows, all_cols)), shape=(n_total_rows, n_cols)) betas = lsqr(A, b, atol=1e-10, btol=1e-10)[0] # Residuals @@ -211,7 +265,11 @@ def compute_indices_for_level(pairs: pl.DataFrame, group_col: str): idx = solve_robust_index(y1, y2, lr, w) if idx: indices[key] = idx - n_pairs[key] = len(y1) + # Count only information-bearing pairs: same-year (year1==year2) and + # baseline-baseline pairs cancel in the sparse solve and contribute + # zero information to the annual index, so including them would + # inflate the shrinkage weight n/(n+k) and under-shrink noisy sectors. + n_pairs[key] = int(np.count_nonzero(y2 != y1)) return indices, n_pairs @@ -392,9 +450,17 @@ def build_index( f" {len(area_idx)} areas, {len(district_idx)} districts, {len(sector_idx)} sectors" ) - # Shrinkage: national -> hedonic first, then hierarchical + # Shrinkage: national -> hedonic first, then hierarchical. Each cell is + # anchored to log-index 0 at its OWN earliest year (solve_robust_index), + # so cells with shorter histories sit on a later origin than their wider + # parents. Before each blend we lift the child onto its parent's base at + # the child's first year (lift_onto_parent) -- otherwise combining them + # key-by-key averages level-incompatible numbers. The hedonic fallback is + # anchored at the global min_year, so it serves as the base for national. print(" Applying shrinkage...") - national_shrunk = shrink_dicts(national_idx, hedonic_idx, national_n) + national_shrunk = shrink_dicts( + lift_onto_parent(national_idx, hedonic_idx), hedonic_idx, national_n + ) sector_shrunk = hierarchical_shrinkage( sector_idx, sector_n, @@ -407,6 +473,7 @@ def build_index( sector_to_dist, dist_to_area, shrink_dicts, + lift_onto_parent, ) # Spatial smoothing diff --git a/pipeline/transform/price_estimation/knn.py b/pipeline/transform/price_estimation/knn.py index 0c18157..50c3b18 100644 --- a/pipeline/transform/price_estimation/knn.py +++ b/pipeline/transform/price_estimation/knn.py @@ -142,6 +142,20 @@ def _sale_identity_matches( target_price: float, target_sale_date: int, ) -> np.ndarray: + """Mark pool comparables that are (almost certainly) the target's own sale. + + properties.parquet has no per-property id, so a sale is identified by the + proxy tuple (postcode, price within 0.5, sale_date) to keep a target's own + prior sale out of its comparable set (leakage prevention). + + Limitation: new-build / bulk blocks sell many DISTINCT properties in one + postcode on the same day at the same price, so all such siblings collide on + this proxy and are excluded together. This is intentional conservative + over-exclusion: it guarantees no leakage at the cost of occasionally + dropping legitimate same-(postcode, price, date) siblings. The effect is + bounded (~1.8% of the pool) and a precise fix would require a per-property + id that the data does not carry. + """ if not target_postcode or not np.isfinite(target_price) or target_sale_date < 0: return np.zeros(len(pool_postcodes), dtype=bool) return ( @@ -166,6 +180,16 @@ def knn_median_psm( PSM is at the reference date used when building the pool. NaN where not computable (missing coords, unknown type, too few neighbors). + + Coordinate limitation: lat/lon come from postcode.parquet (one centroid per + postcode), so every property within a postcode is co-located. For a dense + postcode the "k nearest" therefore degenerates into an arbitrary + same-postcode subset whose membership is decided by KDTree index order + rather than true proximity. No property-level coordinates exist to fix this, + so the kNN signal is treated as a weak, noisy prior: the downstream guarded + blend (guarded_blend_estimates) only blends kNN when it is close to the + index estimate and otherwise discards it, bounding the impact of this + degeneracy. The result is deterministic for a fixed pool order. """ n = len(lat) result = np.full(n, np.nan) diff --git a/pipeline/transform/price_estimation/shrinkage.py b/pipeline/transform/price_estimation/shrinkage.py index bfd977f..a74d5e3 100644 --- a/pipeline/transform/price_estimation/shrinkage.py +++ b/pipeline/transform/price_estimation/shrinkage.py @@ -13,6 +13,68 @@ SPATIAL_NEIGHBORS = 5 SPATIAL_BLEND_K = 30 +def _base_value(index: dict[int, float], base_year: int) -> float: + """Value of an index dict at `base_year`, with forward/back-fill for gaps. + + Each repeat-sales dict is anchored to 0 at its OWN earliest year, so its + values are log-levels relative to that origin. To express it on a common + origin we need its value at the shared `base_year`: + - exact hit: use it directly; + - base_year before the dict's history: back-fill, i.e. the earliest known + value (which is 0.0 by construction). We cannot observe the level move + between the global base and a later-starting cell, so we assume none, + matching forward_fill's back-fill convention; + - base_year inside a gap / after history: forward-fill the most recent + prior value. + """ + if base_year in index: + return index[base_year] + years = sorted(index) + if not years or base_year < years[0]: + return index[years[0]] if years else 0.0 + prior = [y for y in years if y <= base_year] + return index[prior[-1]] + + +def lift_onto_parent( + child: dict[int, float], parent: dict[int, float] +) -> dict[int, float]: + """Lift a child index onto its parent's base before blending the two. + + solve_robust_index anchors every cell to log-index 0 at its OWN earliest + year, so a cell with a shorter history sits on a later origin than its + (wider) parent. Combining them key-by-key would average level-incompatible + numbers (a sector measured from 2008 blended with a district measured from + 1996). We add the parent's accumulated level at the child's first year, so + ``child[start] == parent[start]``: the child's own year-to-year moves are + layered on top of the parent's growth up to that point -- the same + assumption shrinkage already makes for years the child lacks. + + Re-basing on each cell's OWN earliest year (rather than the global base, + which the child cannot observe) is what makes this effective: subtracting + the child's value at the global base is always 0 and changes nothing. + + The shift is a single constant added to every year of the child, so the + child's own year-to-year differences are preserved. PRECONDITION for the + downstream estimate to be unaffected within the child's range: the parent's + year coverage must be a superset of the child's. This holds throughout + build_index, where each parent aggregates a superset of its children's sale + pairs, so shrink_dicts blends every child year against a present parent year + and the constant shift cancels in a within-range (current - sale) difference; + only comparisons that span the child's start year (e.g. a sale predating the + cell's own data) change. If a caller violates the precondition (a child year + the parent lacks), shrink_dicts passes that year through unshrunk and the + cancellation no longer holds. + """ + if not child or not parent: + return child + child_start = min(child) + offset = _base_value(parent, child_start) - child[child_start] + if offset == 0.0: + return child + return {y: v + offset for y, v in child.items()} + + def shrink_dicts(raw: dict, parent: dict, n: int) -> dict: """Shrink dict values toward parent using n/(n+k) weighting. @@ -39,30 +101,40 @@ def hierarchical_shrinkage( sector_to_dist: dict[str, str], dist_to_area: dict[str, str], shrink_fn: Callable[[V, V, int], V], + lift_fn: Callable[[V, V], V] | None = None, ) -> dict[str, V]: """Top-down hierarchical shrinkage: area->top, district->area, sector->district. `top_level` is the ultimate fallback value (e.g. national shrunk toward hedonic, or just national). `shrink_fn(raw, parent, n)` blends raw toward parent. + `lift_fn(raw, parent)`, if given, re-bases raw onto its parent before blending + (see lift_onto_parent); pass None for category-keyed dicts where re-basing is + meaningless. """ + + def combine(raw: V, parent: V, n: int) -> V: + if lift_fn is not None: + raw = lift_fn(raw, parent) + return shrink_fn(raw, parent, n) + # Area -> top level area_shrunk = {} for area, val in area_vals.items(): - area_shrunk[area] = shrink_fn(val, top_level, area_n[area]) + area_shrunk[area] = combine(val, top_level, area_n[area]) # District -> area district_shrunk = {} for dist, val in district_vals.items(): a = dist_to_area.get(dist, "") parent = area_shrunk.get(a, top_level) - district_shrunk[dist] = shrink_fn(val, parent, district_n[dist]) + district_shrunk[dist] = combine(val, parent, district_n[dist]) # Sector -> district sector_shrunk = {} for sec, val in sector_vals.items(): d = sector_to_dist.get(sec, "") parent = district_shrunk.get(d, top_level) - sector_shrunk[sec] = shrink_fn(val, parent, sector_n[sec]) + sector_shrunk[sec] = combine(val, parent, sector_n[sec]) # Fill sectors without their own values for sec in all_sectors: @@ -96,8 +168,11 @@ def spatial_smooth( for i, sec in enumerate(sectors_with_coords): n = counts.get(sec, 0) self_w = n / (n + SPATIAL_BLEND_K) - if self_w > 0.95: - continue # enough data, skip smoothing + if self_w > 0.90: + # Enough data, skip smoothing. Relaxed from 0.95 so higher-volume + # cells (n ~270-570) that still carry single-year noise get a light + # spatial blend, complementing the temporal smoothness prior. + continue dists, idxs = tree.query(scaled_coords[i], k=SPATIAL_NEIGHBORS + 1) # Skip self (index 0, distance ~0) diff --git a/pipeline/transform/price_estimation/test_index.py b/pipeline/transform/price_estimation/test_index.py new file mode 100644 index 0000000..4cc9e8e --- /dev/null +++ b/pipeline/transform/price_estimation/test_index.py @@ -0,0 +1,135 @@ +import numpy as np +import polars as pl + +from pipeline.transform.price_estimation import index as index_mod +from pipeline.transform.price_estimation.index import ( + compute_indices_for_level, + solve_robust_index, +) + + +def _pairs_from_path(true_levels: dict[int, float]): + """Build adjacent-year repeat-sale pairs that exactly trace a known path. + + Each consecutive pair's log_ratio is the difference of the true log-levels, + so the solver should recover the levels exactly (relative to the min year). + """ + years = sorted(true_levels) + y1, y2, lr, w = [], [], [], [] + for a, b in zip(years[:-1], years[1:]): + y1.append(a) + y2.append(b) + lr.append(true_levels[b] - true_levels[a]) + w.append(1.0) + return ( + np.array(y1, dtype=np.int32), + np.array(y2, dtype=np.int32), + np.array(lr, dtype=np.float64), + np.array(w, dtype=np.float64), + ) + + +def test_solver_recovers_contiguous_path(): + """A contiguous price path is recovered as log-levels relative to min_year. + + Proves the IRLS solver is correct (and unchanged) for contiguous data: the + spacing-aware penalty reduces to the standard [1,-2,1] for unit spacing. + """ + years = range(2010, 2021) + true = {y: 0.04 * (y - 2010) for y in years} # smooth (zero curvature) ramp + # Replicate each adjacent pair so MIN_PAIRS is comfortably met. + y1, y2, lr, w = _pairs_from_path(true) + y1 = np.tile(y1, 3) + y2 = np.tile(y2, 3) + lr = np.tile(lr, 3) + w = np.tile(w, 3) + + idx = solve_robust_index(y1, y2, lr, w) + + assert idx[2010] == 0.0 # baseline anchor + for y in years: + assert abs(idx[y] - (true[y] - true[2010])) < 1e-3 + + +def test_gap_spanning_level_jump_is_not_smoothed_into_a_ramp(): + """FIX #5: a sharp true level jump across a multi-year gap is preserved. + + Coverage is 2000,2001,2002 then 2015,2016 with cross-gap pairs encoding a + sharp jump at the gap. The uniform [1,-2,1] curvature penalty treats + (beta_2002, beta_2015, beta_2016) as three adjacent years and over-penalizes + the genuine level jump, biasing beta_2015 down toward a smooth ramp. The + spacing-aware second difference relaxes the penalty across the gap. + """ + # True log-levels relative to min_year (2000 anchored at 0). + true = { + 2000: 0.0, + 2001: 0.05, + 2002: 0.10, + 2015: 1.10, # sharp +1.0 jump across the gap + 2016: 1.15, + } + + y1, y2, lr, w = [], [], [], [] + + def add(a, b, n=4): + for _ in range(n): + y1.append(a) + y2.append(b) + lr.append(true[b] - true[a]) + w.append(1.0) + + # In-segment adjacent pairs. + add(2000, 2001) + add(2001, 2002) + add(2015, 2016) + # Cross-gap pairs consistent with the sharp jump. + add(2002, 2015) + add(2002, 2016) + + y1 = np.array(y1, dtype=np.int32) + y2 = np.array(y2, dtype=np.int32) + lr = np.array(lr, dtype=np.float64) + w = np.array(w, dtype=np.float64) + + # Use a strong penalty to make the smoothing bias obvious. + original = index_mod.TEMPORAL_SMOOTHNESS_LAMBDA + index_mod.TEMPORAL_SMOOTHNESS_LAMBDA = 1.0 + try: + idx = solve_robust_index(y1, y2, lr, w) + finally: + index_mod.TEMPORAL_SMOOTHNESS_LAMBDA = original + + assert idx[2000] == 0.0 # baseline anchor + # beta_2015 must stay near its true post-gap level, not get dragged down by a + # spurious curvature penalty that treats the gap as a single-year step. + assert abs(idx[2015] - true[2015]) < 0.05 + + +def test_n_pairs_counts_only_cross_year_pairs(): + """FIX #12: same-year pairs carry zero index information and must not inflate + the shrinkage weight; n_pairs counts only cross-year (year2 != year1) pairs.""" + rows = [] + + def add_pairs(group, year1, year2, n): + for _ in range(n): + rows.append( + { + "grp": group, + "year1": year1, + "year2": year2, + "log_ratio": 0.03 * (year2 - year1), + "weight": 1.0, + } + ) + + # 8 genuine cross-year pairs spanning enough years for a valid solve, plus 3 + # zero-information same-year pairs that must not be counted. + add_pairs("g", 2010, 2011, 4) + add_pairs("g", 2011, 2012, 4) + add_pairs("g", 2012, 2012, 3) # same-year, zero info + + pairs = pl.DataFrame(rows) + indices, n_pairs = compute_indices_for_level(pairs, "grp") + + assert "g" in indices + assert n_pairs["g"] == 8 # not 11 diff --git a/pipeline/transform/price_estimation/test_knn.py b/pipeline/transform/price_estimation/test_knn.py index fc4ad80..3b147be 100644 --- a/pipeline/transform/price_estimation/test_knn.py +++ b/pipeline/transform/price_estimation/test_knn.py @@ -71,9 +71,49 @@ def test_knn_excludes_same_sale_and_uses_stable_comparables(): ), ) + # The five 900k same-postcode siblings share the target's (postcode, price, + # date) identity proxy, so they are all excluded as comparables, leaving the + # 200k/80sqm = 2_500 PSM neighbours. Removing same-identity siblings is an + # INTENTIONAL conservative leakage-prevention tradeoff (no per-property id + # exists to distinguish a target's own resale from a distinct bulk-block + # sibling sold same-day at the same price), not ideal behaviour -- see the + # _sale_identity_matches docstring. assert psm[0] == 2_500.0 +def test_knn_median_psm_is_deterministic(): + """Reproducibility guard (BUG #6): within-postcode neighbours are co-located + (one centroid per postcode), so the kNN result for dense postcodes depends on + an arbitrary same-postcode subset. That is acceptable, but it MUST be stable: + two identical calls against the same trees/inputs return identical output, so + future refactors cannot silently introduce run-to-run nondeterminism.""" + sale_date = date(2026, 1, 1) + rows = [ + { + "Postcode": "AA1 1AA", + "Property type": "Detached", + "lat": 51.5000 + i * 0.00001, + "lon": -0.1000, + "Total floor area (sqm)": 80.0, + "Last known price": 200_000.0 + i * 1_000.0, + "Date of last transaction": sale_date, + } + for i in range(40) + ] + df = pl.DataFrame(rows) + trees = build_knn_pool(df.lazy(), _flat_index(), 2026.0) + + args = dict( + lat=np.array([51.5000, 51.5002]), + lon=np.array([-0.1000, -0.1000]), + type_groups=np.array(["Detached", "Detached"]), + ) + first = knn_median_psm(trees, **args) + second = knn_median_psm(trees, **args) + + assert np.array_equal(first, second) + + def test_guarded_blend_routes_unstable_knn_to_index_and_caps_uplift(): blended = guarded_blend_estimates( index_est=np.array([120_000.0, 1_000_000.0]), @@ -81,8 +121,21 @@ def test_guarded_blend_routes_unstable_knn_to_index_and_caps_uplift(): last_prices=np.array([100_000.0, 100_000.0]), ) + # Property 0: unstable kNN (>2x index) is dropped, index estimate kept. assert blended[0] == 120_000.0 - assert blended[1] == 600_000.0 + # Property 1: a 10x uplift over the last price is legitimate appreciation and + # is no longer truncated (cap raised from 6x to 20x). + assert blended[1] == 1_000_000.0 + + +def test_guarded_blend_caps_uplift_at_20x_last_price(): + # 50x index estimate over the last price is capped at the 20x ceiling. + blended = guarded_blend_estimates( + index_est=np.array([5_000_000.0]), + knn_est=np.array([np.nan]), + last_prices=np.array([100_000.0]), + ) + assert blended[0] == 2_000_000.0 # 100_000 * 20 def test_bungalow_is_not_a_dead_price_index_type_group(): @@ -92,3 +145,50 @@ def test_bungalow_is_not_a_dead_price_index_type_group(): assert "Bungalow" not in TYPE_GROUPS assert df["type_group"].to_list() == [None, None] + + +def test_temporal_regularization_damps_curvature_without_breaking_solve(): + """The second-difference prior reduces year-to-year curvature and keeps the + index well-formed (all years present, finite, contiguous).""" + from pipeline.transform.price_estimation import index as index_mod + + years = np.arange(2010, 2021) + true = {y: 0.04 * (y - 2010) for y in years} + y1, y2, lr, w = [], [], [], [] + for y in years[:-1]: # adjacent-year pairs following a smooth trend + y1.append(y) + y2.append(y + 1) + lr.append(true[y + 1] - true[y]) + w.append(1.0) + # A spurious single-year jump at 2015 (poorly identified curvature spike). + y1.append(2014) + y2.append(2015) + lr.append(0.5) + w.append(1.0) + y1, y2 = np.array(y1), np.array(y2) + lr, w = np.array(lr, float), np.array(w, float) + + def solve(lmbda): + original = index_mod.TEMPORAL_SMOOTHNESS_LAMBDA + index_mod.TEMPORAL_SMOOTHNESS_LAMBDA = lmbda + try: + return index_mod.solve_robust_index(y1, y2, lr, w) + finally: + index_mod.TEMPORAL_SMOOTHNESS_LAMBDA = original + + unregularised = solve(0.0) + regularised = solve(0.2) + + # Index is well-formed for both. + assert set(regularised) == set(range(2010, 2021)) + assert all(np.isfinite(v) for v in regularised.values()) + assert regularised[2010] == 0.0 # baseline year pinned to 0 + + def max_curvature(d): + betas = np.array([d[y] for y in sorted(d)]) + return float(np.abs(np.diff(betas, 2)).max()) + + # Regularisation strictly reduces curvature, and never flattens the genuine + # uptrend (the index still rises end to end). + assert max_curvature(regularised) < max_curvature(unregularised) + assert regularised[2020] > regularised[2010] diff --git a/pipeline/transform/price_estimation/test_shrinkage.py b/pipeline/transform/price_estimation/test_shrinkage.py new file mode 100644 index 0000000..5c8bed5 --- /dev/null +++ b/pipeline/transform/price_estimation/test_shrinkage.py @@ -0,0 +1,117 @@ +"""Regression tests for parent-base lifting before hierarchical blending. + +solve_robust_index anchors every repeat-sales cell to log-index 0 at its OWN +earliest year, so a cell with a shorter history sits on a later origin than its +(wider) parent. shrink_dicts / blend_dicts combine dicts key-by-key, so a child +must first be lifted onto its parent's base at the child's first year, or the +blend averages level-incompatible numbers (fix5-index-base-year). + +Note: re-anchoring each cell to the *global* base year is a no-op on real data +(a cell anchored to 0 at its own earliest year already reads 0 there, and the +global base is never later), which is why the fix lifts onto the *parent* at the +child's own start year instead. +""" + +from pipeline.transform.price_estimation.shrinkage import ( + hierarchical_shrinkage, + lift_onto_parent, + shrink_dicts, +) +from pipeline.transform.price_estimation.utils import SHRINKAGE_K + + +def test_lift_rebases_late_starting_child_onto_parent(): + """A child anchored at its own later start year is lifted to the parent's level there.""" + parent = {1996: 0.0, 2008: 0.80, 2016: 1.20, 2024: 1.50} + # Sector with its own repeat-sales data only from 2016, anchored at 2016 = 0. + sector = {2016: 0.0, 2024: 0.20} + + lifted = lift_onto_parent(sector, parent) + + # child[start] now equals the parent's accumulated level at that year. + assert abs(lifted[2016] - parent[2016]) < 1e-12 # 1.20 + assert abs(lifted[2024] - (parent[2016] + 0.20)) < 1e-12 # 1.40 + # Pure constant shift: the child's own year-to-year move is preserved. + assert abs((lifted[2024] - lifted[2016]) - (sector[2024] - sector[2016])) < 1e-12 + + +def test_lift_is_noop_when_child_starts_at_parent_base(): + """A child whose earliest year is the parent's base (value 0) is unchanged.""" + parent = {1996: 0.0, 2008: 0.80, 2016: 1.20} + child = {1996: 0.0, 2008: 0.75, 2016: 1.10} + assert lift_onto_parent(child, parent) == child + + +def test_lift_handles_empty_inputs(): + assert lift_onto_parent({}, {2000: 0.0}) == {} + assert lift_onto_parent({2000: 0.0}, {}) == {2000: 0.0} + + +def test_lift_fixes_estimate_spanning_child_start_but_not_within_range(): + """The lift corrects comparisons that span the cell's start year, and ONLY those. + + A property sold in 2008 (before the sector's own data begins in 2016) and + valued in 2024: pre-lift the shrunk index mixes a 2016-based sector level + with 1996-based parent levels and badly understates the move. Comparisons + wholly inside the sector's own range (2016->2024) are unchanged, because the + lift is a pure constant shift that cancels in a within-cell difference. + """ + parent = {1996: 0.0, 2008: 0.80, 2016: 1.20, 2024: 1.50} + sector = {2016: 0.0, 2024: 0.20} # own data starts 2016 + n = 30 + w = n / (n + SHRINKAGE_K) + + raw = shrink_dicts(sector, parent, n) # pre-fix: blend without lifting + fixed = shrink_dicts(lift_onto_parent(sector, parent), parent, n) + + # Within the sector's own range the lift changes nothing. + assert abs((fixed[2024] - fixed[2016]) - (raw[2024] - raw[2016])) < 1e-12 + + # 2008 is parent-only in both (sector absent), so both read parent[2008]. + assert abs(raw[2008] - parent[2008]) < 1e-12 + assert abs(fixed[2008] - parent[2008]) < 1e-12 + + raw_move = raw[2024] - raw[2008] + fixed_move = fixed[2024] - fixed[2008] + # Hand-computed: raw[2024] = w*0.20 + (1-w)*1.50; fixed[2024] = w*1.40 + (1-w)*1.50. + assert abs(raw_move - ((w * 0.20 + (1 - w) * 1.50) - 0.80)) < 1e-12 + assert abs(fixed_move - ((w * 1.40 + (1 - w) * 1.50) - 0.80)) < 1e-12 + # The fix raises the spanning move by exactly the parent growth to the + # sector's start year that the raw blend dropped (weighted by w). + assert abs((fixed_move - raw_move) - w * parent[2016]) < 1e-12 + # Fixed move is close to the true area-level move (0.70); raw badly understates it. + assert abs(fixed_move - 0.70) < 0.2 + assert raw_move < 0.4 * fixed_move + + +def test_hierarchical_shrinkage_lift_fn_only_changes_spanning_comparisons(): + """Integration: passing lift_fn re-bases a late-starting sector via its parent chain.""" + top = {1996: 0.0, 2008: 0.80, 2016: 1.20, 2024: 1.50} + sector = {"AB1 1": {2016: 0.0, 2024: 0.20}} + sector_n = {"AB1 1": 300} + # No own area/district indices -> the sector shrinks straight toward `top`. + base_args = ( + sector, + sector_n, + {}, + {}, + {}, + {}, + top, + ["AB1 1"], + {"AB1 1": "AB1"}, + {"AB1": "AB"}, + shrink_dicts, + ) + + without_lift = hierarchical_shrinkage(*base_args)["AB1 1"] + with_lift = hierarchical_shrinkage(*base_args, lift_onto_parent)["AB1 1"] + + # Within the sector's own range: identical (pure constant shift cancels). + assert abs( + (with_lift[2024] - with_lift[2016]) - (without_lift[2024] - without_lift[2016]) + ) < 1e-12 + # Spanning the sector's start year: the lift raises the 2008->2024 move. + assert (with_lift[2024] - with_lift[2008]) > ( + without_lift[2024] - without_lift[2008] + ) + 0.1 diff --git a/pipeline/transform/price_estimation/utils.py b/pipeline/transform/price_estimation/utils.py index fff4bd0..bdb80d3 100644 --- a/pipeline/transform/price_estimation/utils.py +++ b/pipeline/transform/price_estimation/utils.py @@ -22,6 +22,13 @@ FLAT_TYPES = ["Flats/Maisonettes"] TYPE_GROUPS = ["Detached", "Semi-Detached", "Terraced", "Flats"] SHRINKAGE_K = 50 +# Temporal regularization for the repeat-sales index: a second-difference +# (curvature) penalty lambda * sum((beta_t - 2*beta_{t-1} + beta_{t-2})^2) added +# to the IRLS solve. A mild penalty damps single-year index spikes (which would +# otherwise distort the estimate of any property whose last sale landed on a +# noisy year) without flattening genuine multi-year trends. +TEMPORAL_SMOOTHNESS_LAMBDA = 0.05 + def type_group_expr(): """Polars expression: Property type -> type_group.""" diff --git a/pipeline/transform/property_border_tiles.py b/pipeline/transform/property_border_tiles.py new file mode 100644 index 0000000..3ed764d --- /dev/null +++ b/pipeline/transform/property_border_tiles.py @@ -0,0 +1,138 @@ +"""Build PMTiles polygon tiles for the INSPIRE property-border overlay. + +Reads the HM Land Registry INSPIRE Index Polygons (per-local-authority GML ZIPs +in EPSG:27700), reprojects each parcel to WGS84, and tiles the outlines with +tippecanoe. The dashboard serves the resulting archive through +``/api/overlays/property-borders`` and renders it as thin outlines only at the +postcode zoom level. + +The same ZIPs are already downloaded for postcode-boundary generation; this +target re-uses :func:`parse_inspire_zip` to stay self-contained and is wired to +the ``$(INSPIRE_STAMP)`` make dependency rather than the boundary cache. + +Data: HM Land Registry INSPIRE Index Polygons, Open Government Licence v3.0. +Boundaries are indicative "general boundaries", not the legal extent of title. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import tempfile +from pathlib import Path + +import numpy as np +import shapely +from pyproj import Transformer +from shapely.geometry import Polygon +from tqdm import tqdm + +from pipeline.local_temp import local_tmp_dir +from pipeline.transform.postcode_boundaries.inspire import parse_inspire_zip + + +def _require_tippecanoe() -> str: + executable = shutil.which("tippecanoe") + if executable is None: + raise RuntimeError( + "tippecanoe is required to build property border PMTiles. " + "Install tippecanoe and rerun this target." + ) + return executable + + +def _write_property_geojsonseq(inspire_dir: Path, output_path: Path) -> int: + """Stream INSPIRE parcels to a WGS84 GeoJSONSeq file, one feature per line. + + Features carry no properties — the overlay only draws outlines, so dropping + attributes keeps the tiles as small as possible. Reprojection and GeoJSON + encoding are vectorised per ZIP (one local authority) to bound memory while + staying in shapely's C path. + """ + to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True) + zip_files = sorted(inspire_dir.glob("*.zip")) + if not zip_files: + raise RuntimeError(f"No INSPIRE ZIP files found in {inspire_dir}") + + feature_count = 0 + with output_path.open("w") as file: + for zip_path in tqdm(zip_files, desc="INSPIRE ZIPs", unit="file"): + rings = parse_inspire_zip(zip_path) # list of Nx2 (easting, northing) + if not rings: + continue + + geoms = np.array([Polygon(coords) for coords in rings], dtype=object) + # interleaved=False → transform(x, y) called once with full arrays. + geoms = shapely.transform(geoms, to_wgs84.transform, interleaved=False) + + for geometry_json in shapely.to_geojson(geoms): + file.write('{"type":"Feature","properties":{},"geometry":') + file.write(geometry_json) + file.write("}\n") + feature_count += 1 + + return feature_count + + +def build_property_border_tiles( + inspire_dir: Path, + output_path: Path, + min_zoom: int, + max_zoom: int, +) -> None: + tippecanoe = _require_tippecanoe() + output_path.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as tmp: + ndjson_path = Path(tmp) / "property_borders.geojsonseq" + feature_count = _write_property_geojsonseq(inspire_dir, ndjson_path) + print(f"Writing {feature_count:,} INSPIRE parcel polygons") + + subprocess.run( + [ + tippecanoe, + "--force", + "--output", + str(output_path), + "--layer", + "property_borders", + "--minimum-zoom", + str(min_zoom), + "--maximum-zoom", + str(max_zoom), + # Borders are only meaningful at street level; thin the densest + # tiles at low zoom but keep full geometry at max zoom. + "--drop-smallest-as-needed", + "--simplify-only-low-zooms", + "--extend-zooms-if-still-dropping", + "--temporary-directory", + tmp, + str(ndjson_path), + ], + check=True, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--inspire", type=Path, required=True, help="INSPIRE ZIP directory" + ) + parser.add_argument( + "--output", type=Path, required=True, help="Output .pmtiles path" + ) + parser.add_argument("--min-zoom", type=int, default=12) + parser.add_argument("--max-zoom", type=int, default=16) + args = parser.parse_args() + + build_property_border_tiles( + inspire_dir=args.inspire, + output_path=args.output, + min_zoom=args.min_zoom, + max_zoom=args.max_zoom, + ) + + +if __name__ == "__main__": + main() diff --git a/pipeline/transform/school_proximity.py b/pipeline/transform/school_proximity.py index e83c1f7..49e6edf 100644 --- a/pipeline/transform/school_proximity.py +++ b/pipeline/transform/school_proximity.py @@ -15,6 +15,66 @@ SCHOOL_GROUPS = { } +def classify_good_plus_schools(ofsted: pl.DataFrame) -> pl.DataFrame: + """Label good+/outstanding primary & secondary schools for proximity counts. + + Derives a grade ("1" = outstanding, "2" = good) and a proximity ``category``, + returning a ``(postcode, category)`` frame. + + Schools with a recent GRADED inspection carry a 1-4 grade in "Latest OEIF + overall effectiveness" (OEIF = the previous Ofsted Education Inspection + Framework). A large and growing share of schools were last inspected under an + UNGRADED (Section 8) inspection or the post-2024 report-card framework, so + that column is null/"Not judged" for them even when they are demonstrably + good — their status lives in "Ungraded inspection overall outcome" ("School + remains Good"/"School remains Outstanding", incl. "(Concerns)"/"(Improving)" + variants). Filtering on the graded column alone dropped ~7,000 genuinely + good/outstanding schools. We fall back to the ungraded outcome, but ONLY when + there is no usable graded result (null/"Not judged"), so a genuine grade 3/4 + is never overridden. + """ + # Cast to Utf8 so the string predicates below are well-defined even if a + # column happens to be entirely null (read back as a Null dtype). + oeif = pl.col("Latest OEIF overall effectiveness").cast(pl.Utf8, strict=False) + ungraded = pl.col("Ungraded inspection overall outcome").cast(pl.Utf8, strict=False) + no_usable_grade = oeif.is_null() | (oeif == "Not judged") + graded = ( + ofsted.filter(pl.col("Ofsted phase").is_in(["Primary", "Secondary"])) + .with_columns( + pl.when(oeif.is_in(["1", "2"])) + .then(oeif) + .when( + no_usable_grade + & ungraded.str.starts_with("School remains Outstanding") + ) + .then(pl.lit("1")) + .when(no_usable_grade & ungraded.str.starts_with("School remains Good")) + .then(pl.lit("2")) + .otherwise(None) + .alias("_ofsted_grade") + ) + .filter(pl.col("_ofsted_grade").is_not_null()) + ) + # Good+ groups include both grade variants; outstanding groups count grade 1. + return graded.with_columns( + pl.when(pl.col("Ofsted phase") == "Primary") + .then( + pl.when(pl.col("_ofsted_grade") == "1") + .then(pl.lit("outstanding_primary")) + .otherwise(pl.lit("good_primary")) + ) + .otherwise( + pl.when(pl.col("_ofsted_grade") == "1") + .then(pl.lit("outstanding_secondary")) + .otherwise(pl.lit("good_secondary")) + ) + .alias("category") + ).select( + pl.col("Postcode").alias("postcode"), + "category", + ) + + def main(): parser = argparse.ArgumentParser( description="Count good+ and outstanding primary/secondary schools near each postcode" @@ -30,42 +90,14 @@ def main(): ) args = parser.parse_args() - # Load Ofsted data: filter to good+ (1, 2) primary/secondary schools. - # Post-2025 reform the single "Overall effectiveness" grade was retired; - # the legacy 1–4 scale is now carried forward under "Latest OEIF overall - # effectiveness" (OEIF = the previous Ofsted Education Inspection - # Framework). The new report-card columns use text judgements instead. - ofsted = pl.read_parquet(args.ofsted).filter( - pl.col("Ofsted phase").is_in(["Primary", "Secondary"]) - & pl.col("Latest OEIF overall effectiveness").is_in(["1", "2"]) - ) + ofsted = classify_good_plus_schools(pl.read_parquet(args.ofsted)) if ofsted.is_empty(): raise ValueError("No good+ primary/secondary Ofsted schools found") print(f"Good+ schools: {len(ofsted):,}") print( "Outstanding schools: " - f"{ofsted.filter(pl.col('Latest OEIF overall effectiveness') == '1').height:,}" - ) - - # Assign category based on phase and rating. Good+ groups include both - # category variants; outstanding groups count grade 1 only. - ofsted = ofsted.with_columns( - pl.when(pl.col("Ofsted phase") == "Primary") - .then( - pl.when(pl.col("Latest OEIF overall effectiveness") == "1") - .then(pl.lit("outstanding_primary")) - .otherwise(pl.lit("good_primary")) - ) - .otherwise( - pl.when(pl.col("Latest OEIF overall effectiveness") == "1") - .then(pl.lit("outstanding_secondary")) - .otherwise(pl.lit("good_secondary")) - ) - .alias("category") - ).select( - pl.col("Postcode").alias("postcode"), - "category", + f"{ofsted.filter(pl.col('category').str.starts_with('outstanding')).height:,}" ) # Join with arcgis to get lat/lng for each school's postcode diff --git a/pipeline/transform/test_crime.py b/pipeline/transform/test_crime.py index 438f2ce..90f83b6 100644 --- a/pipeline/transform/test_crime.py +++ b/pipeline/transform/test_crime.py @@ -158,6 +158,53 @@ def test_transform_crime_writes_by_year_output(tmp_path): assert serious[2024] == 12.0 +def test_transform_crime_headline_is_mean_of_per_year_bars(tmp_path): + """The avg/yr headline must equal the average of the by-year chart bars, i.e. + the simple mean of each year's annualised count -- NOT a month-weighted pooled + rate. They diverge when years have uneven partial-month coverage.""" + crime_dir = tmp_path / "crime" + jan23 = crime_dir / "2023-01" + jan24 = crime_dir / "2024-01" + feb24 = crime_dir / "2024-02" + for d in (jan23, jan24, feb24): + d.mkdir(parents=True) + + header = "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location,LSOA code,LSOA name,Crime type,Last outcome category,Context" + # 2023: 6 burglaries in 1 month -> 6 * 12 / 1 = 72/yr. + (jan23 / "2023-01-test-force-street.csv").write_text( + "\n".join( + [header] + + [ + f"{i},2023-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U," + for i in range(1, 7) + ] + ) + + "\n" + ) + # 2024: 2 burglaries across 2 months -> 2 * 12 / 2 = 12/yr. + (jan24 / "2024-01-test-force-street.csv").write_text( + "\n".join([header, "7,2024-01,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,"]) + "\n" + ) + (feb24 / "2024-02-test-force-street.csv").write_text( + "\n".join([header, "8,2024-02,F,F,-0.1,51.5,X,E01000001,L,Burglary,U,"]) + "\n" + ) + + output = tmp_path / "crime.parquet" + by_year_output = tmp_path / "crime_by_year.parquet" + transform_crime(crime_dir, output, by_year_output) + + # Mean of per-year bars = (72 + 12) / 2 = 42.0. + # The old pooled rate (8 incidents / 3 months * 12 = 32.0) would be wrong. + avg = pl.read_parquet(output).to_dicts()[0] + assert avg["Burglary (avg/yr)"] == 42.0 + + by_year = pl.read_parquet(by_year_output).row(0, named=True) + burglary = {p["year"]: p["count"] for p in by_year["Burglary (by year)"]} + assert burglary == {2023: 72.0, 2024: 12.0} + # Headline equals the mean of the bars it summarises. + assert avg["Burglary (avg/yr)"] == sum(burglary.values()) / len(burglary) + + def test_transform_crime_fails_without_valid_months(tmp_path): crime_dir = tmp_path / "crime" month_dir = crime_dir / "2024-01" @@ -226,3 +273,44 @@ def test_transform_crime_applies_lsoa_2011_to_2021_lookup(tmp_path): assert burglaries["E01000050"] == [{"year": 2024, "count": 12.0}] assert burglaries["E01000051"] == [{"year": 2024, "count": 12.0}] assert burglaries["E01000099"] == [{"year": 2024, "count": 12.0}] + + +def test_transform_crime_maps_legacy_crime_types(tmp_path): + """Pre-2014 police.uk type names are aliased to current equivalents instead + of being dropped.""" + crime_dir = tmp_path / "crime" + month_dir = crime_dir / "2013-01" + month_dir.mkdir(parents=True) + + header = "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location,LSOA code,LSOA name,Crime type,Last outcome category,Context" + (month_dir / "2013-01-test-force-street.csv").write_text( + "\n".join( + [ + header, + "1,2013-01,Test Force,Test Force,-0.1,51.5,On or near X,E01000001,L,Violent crime,Under investigation,", + "2,2013-01,Test Force,Test Force,-0.1,51.5,On or near X,E01000001,L,Public disorder and weapons,Under investigation,", + "3,2013-01,Test Force,Test Force,-0.1,51.5,On or near X,E01000001,L,Burglary,Under investigation,", + ] + ) + + "\n" + ) + + output = tmp_path / "crime.parquet" + by_year_output = tmp_path / "crime_by_year.parquet" + transform_crime(crime_dir, output, by_year_output) + + row = pl.read_parquet(output).to_dicts()[0] + # Single month -> annualised x12. Legacy names mapped to current columns. + assert row["Violence and sexual offences (avg/yr)"] == 12.0 + assert row["Public order (avg/yr)"] == 12.0 + assert row["Burglary (avg/yr)"] == 12.0 + # The legacy names must NOT survive as their own columns. + assert "Violent crime (avg/yr)" not in row + assert "Public disorder and weapons (avg/yr)" not in row + + by_year = pl.read_parquet(by_year_output).row(0, named=True) + serious = {p["year"]: p["count"] for p in by_year["Serious crime (by year)"]} + # Serious = Violence and sexual offences (12) + Burglary (12) = 24 + assert serious[2013] == 24.0 + minor = {p["year"]: p["count"] for p in by_year["Minor crime (by year)"]} + assert minor[2013] == 12.0 # Public order diff --git a/pipeline/transform/test_crime_hotspot_tiles.py b/pipeline/transform/test_crime_hotspot_tiles.py new file mode 100644 index 0000000..098173d --- /dev/null +++ b/pipeline/transform/test_crime_hotspot_tiles.py @@ -0,0 +1,52 @@ +import json + +from pipeline.transform.crime_hotspot_tiles import _write_geojsonseq + +_HEADER = ( + "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location," + "LSOA code,LSOA name,Crime type,Last outcome category,Context" +) + + +def _row(lon, lat, month, crime_type): + return f",{month},F,F,{lon},{lat},On or near X,E01000001,L,{crime_type},U," + + +def _write_csv(path, rows): + path.write_text("\n".join([_HEADER, *rows]) + "\n") + + +def test_write_geojsonseq_collapses_shared_anchors_into_weighted_features(tmp_path): + csv = tmp_path / "2024-01-test-street.csv" + _write_csv( + csv, + [ + # Two incidents snapped to the exact same anchor/month/type -> one + # feature with count=2. + _row(-0.1, 51.5, "2024-01", "Burglary"), + _row(-0.1, 51.5, "2024-01", "Burglary"), + # Same coord, different crime type -> kept separate (per-type filter). + _row(-0.1, 51.5, "2024-01", "Robbery"), + # Out of bounds -> dropped entirely. + _row(-0.1, 80.0, "2024-01", "Burglary"), + # Missing coordinate -> dropped entirely. + _row("", "", "2024-01", "Burglary"), + ], + ) + + out = tmp_path / "hotspots.geojsonseq" + feature_count, incident_count = _write_geojsonseq([csv], out) + + features = [json.loads(line) for line in out.read_text().splitlines()] + assert feature_count == 2 + assert incident_count == 3 # 2 burglaries + 1 robbery, in-bounds only + + by_type = {f["properties"]["crime_type"]: f["properties"] for f in features} + # The busy anchor is a single feature carrying its full incident weight, + # so tippecanoe's density thinning can no longer silently erase it. + assert by_type["Burglary"]["count"] == 2 + assert by_type["Burglary"]["weight"] == 2 + assert by_type["Robbery"]["count"] == 1 + # Geometry preserved as [lon, lat]. + assert by_type["Burglary"]["count"] == 2 + assert all(f["geometry"]["coordinates"] == [-0.1, 51.5] for f in features) diff --git a/pipeline/transform/test_crime_spatial.py b/pipeline/transform/test_crime_spatial.py new file mode 100644 index 0000000..d368e77 --- /dev/null +++ b/pipeline/transform/test_crime_spatial.py @@ -0,0 +1,413 @@ +import json + +import numpy as np +import polars as pl +import pytest +import shapely +from pyproj import Transformer + +from pipeline.transform.crime_spatial import transform_crime_spatial +from pipeline.transform.postcode_boundaries.loader import load_postcode_polygons + +_TO_WGS84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True) + +_CSV_HEADER = ( + "Crime ID,Month,Reported by,Falls within,Longitude,Latitude,Location," + "LSOA code,LSOA name,Crime type,Last outcome category,Context" +) + + +def _bng_to_wgs84(x: float, y: float) -> tuple[float, float]: + lon, lat = _TO_WGS84.transform(x, y) + return lon, lat + + +def _square_feature(postcode: str, x0: float, y0: float, x1: float, y1: float) -> dict: + ring = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + coords = [list(_bng_to_wgs84(x, y)) for x, y in ring] + return { + "type": "Feature", + "properties": {"postcodes": postcode, "mapit_code": postcode.replace(" ", "")}, + "geometry": {"type": "Polygon", "coordinates": [coords]}, + } + + +def _write_boundaries(units_dir, features_by_district: dict[str, list[dict]]) -> None: + units_dir.mkdir(parents=True) + for district, features in features_by_district.items(): + collection = {"type": "FeatureCollection", "features": features} + (units_dir / f"{district}.geojson").write_text(json.dumps(collection)) + + +def _crime_row(month: str, x, y, crime_type: str) -> str: + if x is None or y is None: + lon, lat = "", "" + else: + lon, lat = _bng_to_wgs84(x, y) + return f",{month},F,F,{lon},{lat},On or near X,E01000001,L,{crime_type},U," + + +def _write_month(crime_dir, month: str, rows: list[str]) -> None: + month_dir = crime_dir / month + month_dir.mkdir(parents=True) + body = "\n".join([_CSV_HEADER, *rows]) + "\n" + (month_dir / f"{month}-test-force-street.csv").write_text(body) + + +def test_buffer_overlap_counts_for_each_postcode(tmp_path): + units = tmp_path / "units" + # A and B sit 70m apart; their +50m buffers overlap in x in [1030, 1060]. + _write_boundaries( + units, + { + "AB1": [ + _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), + _square_feature("AB1 1AB", 1080, 1000, 1090, 1010), + _square_feature("AB1 1AC", 5000, 5000, 5010, 5010), + ] + }, + ) + + crime = tmp_path / "crime" + _write_month( + crime, + "2024-01", + [ + # In the overlap: 35m east of A, 35m west of B -> counts for both. + _crime_row("2024-01", 1045, 1005, "Burglary"), + # 49m east of C's edge -> inside C's buffer. + _crime_row("2024-01", 5059, 5005, "Robbery"), + # 51m east of C's edge -> outside every buffer. + _crime_row("2024-01", 5061, 5005, "Robbery"), + # No coordinate -> dropped entirely. + _crime_row("2024-01", None, None, "Anti-social behaviour"), + ], + ) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + # Pin the 50m buffer the geometry above was designed around (the production + # default is now 100m). The three squares are equal-area, so area + # normalisation leaves the counts unchanged. + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + rows = { + r["postcode"]: r + for r in pl.read_parquet(output).to_dicts() + } + # Single month -> annualised x12. + assert rows["AB1 1AA"]["Burglary (avg/yr)"] == 12.0 + assert rows["AB1 1AB"]["Burglary (avg/yr)"] == 12.0 + assert rows["AB1 1AA"]["Robbery (avg/yr)"] == 0.0 + # Only the 49m robbery counts for C; the 51m one and the blank row do not. + assert rows["AB1 1AC"]["Robbery (avg/yr)"] == 12.0 + assert rows["AB1 1AC"]["Burglary (avg/yr)"] == 0.0 + # Anti-social behaviour had no coordinate -> nobody gets it. + assert all(r["Anti-social behaviour (avg/yr)"] == 0.0 for r in rows.values()) + + +def test_by_year_annualises_and_rolls_up(tmp_path): + units = tmp_path / "units" + _write_boundaries( + units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} + ) + + crime = tmp_path / "crime" + # Point at the centre of AB1 1AA, well inside its buffer. + _write_month( + crime, + "2023-01", + [ + _crime_row("2023-01", 1005, 1005, "Burglary"), + _crime_row("2023-01", 1005, 1005, "Robbery"), + ], + ) + _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Burglary")]) + _write_month( + crime, + "2024-02", + [ + _crime_row("2024-02", 1005, 1005, "Burglary"), + _crime_row("2024-02", 1005, 1005, "Anti-social behaviour"), + ], + ) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + by_year_df = pl.read_parquet(by_year) + assert by_year_df.height == 1 + cols = set(by_year_df.columns) + assert {"Burglary (by year)", "Serious crime (by year)", "Minor crime (by year)"} <= cols + + row = by_year_df.row(0, named=True) + burglary = sorted(row["Burglary (by year)"], key=lambda r: r["year"]) + # 2023: 1 burglary in 1 month -> 12/yr; 2024: 2 in 2 months -> 12/yr. + assert burglary == [ + {"year": 2023, "count": 12.0}, + {"year": 2024, "count": 12.0}, + ] + serious = {p["year"]: p["count"] for p in row["Serious crime (by year)"]} + # 2023 serious = Burglary(12) + Robbery(12) = 24; 2024 = Burglary(12). + assert serious[2023] == 24.0 + assert serious[2024] == 12.0 + + +def test_area_normalisation_divides_out_buffered_catchment(tmp_path): + # Three postcodes of increasing footprint, each with exactly one incident in + # its buffer. Normalisation rescales by median_catchment / buffered_area, so + # the smallest scores highest and the median-sized one is unchanged -- i.e. + # the metric is a density. Dividing by the *buffered* catchment (not the raw + # polygon) means the fixed buffer-ring floor keeps the spread gentle, so the + # tiniest postcode is not blown up out of proportion. + units = tmp_path / "units" + _write_boundaries( + units, + { + "AB1": [ + _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), # 10x10 + _square_feature("AB1 1AB", 3000, 3000, 3010, 3020), # 10x20 (median) + _square_feature("AB1 1AC", 5000, 5000, 5020, 5020), # 20x20 + ] + }, + ) + + crime = tmp_path / "crime" + _write_month( + crime, + "2024-01", + [ + _crime_row("2024-01", 1005, 1005, "Burglary"), + _crime_row("2024-01", 3005, 3010, "Burglary"), + _crime_row("2024-01", 5010, 5010, "Burglary"), + ], + ) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + # Re-derive the expected values from the same buffered catchment areas: each + # postcode is 12/yr before normalisation, then x (median_buf / buffered_area). + postcodes, polygons = load_postcode_polygons(units) + buf_area = { + pc: float(shapely.area(shapely.buffer(poly, 50.0, quad_segs=8))) + for pc, poly in zip(postcodes, polygons) + } + median_buf = float(np.median(list(buf_area.values()))) + expected = {pc: 12.0 * median_buf / buf_area[pc] for pc in buf_area} + + rows = {r["postcode"]: r for r in pl.read_parquet(output).to_dicts()} + for pc, exp in expected.items(): + assert rows[pc]["Burglary (avg/yr)"] == pytest.approx(exp, abs=0.1) + + # Median catchment unchanged; ordering is by inverse buffered area, but the + # buffer-ring floor keeps the spread far below the ~4x raw-area ratio. + assert rows["AB1 1AB"]["Burglary (avg/yr)"] == pytest.approx(12.0, abs=0.05) + small = rows["AB1 1AA"]["Burglary (avg/yr)"] + big = rows["AB1 1AC"]["Burglary (avg/yr)"] + assert small > 12.0 > big + assert small / big < 1.5 + + # by-year series carries the same normalisation. + by_year_df = pl.read_parquet(by_year) + small_row = by_year_df.filter(pl.col("postcode") == "AB1 1AA").row(0, named=True) + assert small_row["Burglary (by year)"] == [ + {"year": 2024, "count": pytest.approx(expected["AB1 1AA"], abs=0.1)} + ] + + +def test_avg_yr_is_simple_mean_of_year_bars(tmp_path): + # Uneven month coverage across years: 2023 has 1 month (2 incidents -> 24/yr), + # 2024 has 2 months (2 incidents -> 12/yr). The headline must be the *simple* + # mean of the bars (24+12)/2 = 18, not the month-weighted pooled rate + # (4 incidents / 3 months * 12 = 16). + units = tmp_path / "units" + _write_boundaries( + units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} + ) + + crime = tmp_path / "crime" + _write_month( + crime, + "2023-01", + [ + _crime_row("2023-01", 1005, 1005, "Burglary"), + _crime_row("2023-01", 1005, 1005, "Burglary"), + ], + ) + _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Burglary")]) + _write_month(crime, "2024-02", [_crime_row("2024-02", 1005, 1005, "Burglary")]) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + avg = pl.read_parquet(output).row(0, named=True) + assert avg["Burglary (avg/yr)"] == pytest.approx(18.0, abs=0.05) + + row = pl.read_parquet(by_year).row(0, named=True) + bars = {p["year"]: p["count"] for p in row["Burglary (by year)"]} + assert bars == {2023: pytest.approx(24.0, abs=0.05), 2024: pytest.approx(12.0, abs=0.05)} + + +def test_serious_rollup_avg_yr_equals_mean_of_rollup_bars(tmp_path): + # Two SERIOUS types occur in DISJOINT years for one postcode: Burglary only in + # 2014, Robbery only in 2024 (each a single full month -> 12/yr). The headline + # "Serious crime (avg/yr)" must equal the mean of the "Serious crime (by year)" + # bars (which span the UNION of years any serious type occurred), NOT the sum + # of the per-type means. Summing per-type means divides each type by its OWN + # years-present (1 each) -> 12 + 12 = 24; the consistent rollup divides the + # per-year serious total by the years any serious type occurred (2) -> 12. + units = tmp_path / "units" + _write_boundaries( + units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} + ) + + crime = tmp_path / "crime" + _write_month(crime, "2014-01", [_crime_row("2014-01", 1005, 1005, "Burglary")]) + _write_month(crime, "2024-01", [_crime_row("2024-01", 1005, 1005, "Robbery")]) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + avg = pl.read_parquet(output).row(0, named=True) + # The precomputed rollup headline exists and equals the mean of the bars (12), + # not the sum of the per-type avg/yr values (Burglary 12 + Robbery 12 = 24). + assert "Serious crime (avg/yr)" in avg + assert avg["Burglary (avg/yr)"] == pytest.approx(12.0, abs=0.05) + assert avg["Robbery (avg/yr)"] == pytest.approx(12.0, abs=0.05) + assert avg["Serious crime (avg/yr)"] == pytest.approx(12.0, abs=0.05) + + serious_bars = { + p["year"]: p["count"] + for p in pl.read_parquet(by_year).row(0, named=True)["Serious crime (by year)"] + } + assert serious_bars == { + 2014: pytest.approx(12.0, abs=0.05), + 2024: pytest.approx(12.0, abs=0.05), + } + mean_of_bars = sum(serious_bars.values()) / len(serious_bars) + assert avg["Serious crime (avg/yr)"] == pytest.approx(mean_of_bars, abs=0.05) + + +def test_avg_yr_denominator_is_per_postcode_not_global(tmp_path): + # P (AB1 1AA) has burglaries only in its single most-recent year (2024); Q + # (AB1 1AB), far away, has a burglary in 2014. The type therefore spans TWO + # distinct years across all postcodes, but only ONE year for P. The headline + # must divide by P's own years-present (1), equalling its single by-year bar + # (24/yr) -- not by the global span (2), which would deflate it to 12/yr. + # The two squares are equal-area, so area normalisation leaves counts as-is. + units = tmp_path / "units" + _write_boundaries( + units, + { + "AB1": [ + _square_feature("AB1 1AA", 1000, 1000, 1010, 1010), + _square_feature("AB1 1AB", 5000, 5000, 5010, 5010), + ] + }, + ) + + crime = tmp_path / "crime" + # P: 2 burglaries in a single 2024 month -> 24/yr bar, present in 1 year. + _write_month( + crime, + "2024-01", + [ + _crime_row("2024-01", 1005, 1005, "Burglary"), + _crime_row("2024-01", 1005, 1005, "Burglary"), + ], + ) + # Q: 1 burglary in a far-back 2014 month -> widens the type's global span to + # two years without adding any incident to P. + _write_month(crime, "2014-01", [_crime_row("2014-01", 5005, 5005, "Burglary")]) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + rows = {r["postcode"]: r for r in pl.read_parquet(output).to_dicts()} + by_year_rows = { + r["postcode"]: r for r in pl.read_parquet(by_year).to_dicts() + } + + # P's headline equals the simple mean of its own bars (just the 2024 bar). + p_bars = {p["year"]: p["count"] for p in by_year_rows["AB1 1AA"]["Burglary (by year)"]} + assert p_bars == {2024: pytest.approx(24.0, abs=0.05)} + # Per-postcode denominator (1) -> 24.0. The old global denominator (2 years + # across all postcodes) would have deflated this to 12.0. + assert rows["AB1 1AA"]["Burglary (avg/yr)"] == pytest.approx(24.0, abs=0.05) + assert rows["AB1 1AA"]["Burglary (avg/yr)"] == pytest.approx( + sum(p_bars.values()) / len(p_bars), abs=0.05 + ) + + # Q likewise: its sole 2014 bar -> 12/yr, divided by its own 1 year = 12.0. + q_bars = {p["year"]: p["count"] for p in by_year_rows["AB1 1AB"]["Burglary (by year)"]} + assert q_bars == {2014: pytest.approx(12.0, abs=0.05)} + assert rows["AB1 1AB"]["Burglary (avg/yr)"] == pytest.approx(12.0, abs=0.05) + + +def test_unknown_crime_type_is_dropped_with_warning(tmp_path, capsys): + units = tmp_path / "units" + _write_boundaries( + units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} + ) + + crime = tmp_path / "crime" + _write_month( + crime, + "2024-01", + [ + _crime_row("2024-01", 1005, 1005, "Burglary"), + _crime_row("2024-01", 1005, 1005, "Cyber fraud"), + ], + ) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + columns = pl.read_parquet(output).columns + # The unknown type is dropped (no column for it) but a warning is emitted. + assert "Cyber fraud (avg/yr)" not in columns + assert "Burglary (avg/yr)" in columns + err = capsys.readouterr().err + assert "Cyber fraud" in err + assert "WARNING" in err + + +def test_legacy_crime_types_are_mapped(tmp_path): + """Pre-2014 crime-type names are aliased to current equivalents in the + spatial transform instead of being dropped as unknown types.""" + units = tmp_path / "units" + _write_boundaries( + units, {"AB1": [_square_feature("AB1 1AA", 1000, 1000, 1010, 1010)]} + ) + + crime = tmp_path / "crime" + _write_month( + crime, + "2013-01", + [ + _crime_row("2013-01", 1005, 1005, "Violent crime"), + _crime_row("2013-01", 1005, 1005, "Public disorder and weapons"), + ], + ) + + output = tmp_path / "crime_by_postcode.parquet" + by_year = tmp_path / "crime_by_postcode_by_year.parquet" + transform_crime_spatial(crime, units, output, by_year, buffer_m=50.0) + + row = pl.read_parquet(output).to_dicts()[0] + # Single postcode -> area-norm factor 1.0; single month/year -> x12. + assert row["Violence and sexual offences (avg/yr)"] == 12.0 + assert row["Public order (avg/yr)"] == 12.0 + + by_year_row = pl.read_parquet(by_year).row(0, named=True) + assert by_year_row["Violence and sexual offences (by year)"] == [ + {"year": 2013, "count": 12.0} + ] + assert by_year_row["Public order (by year)"] == [{"year": 2013, "count": 12.0}] diff --git a/pipeline/transform/test_join_epc_pp.py b/pipeline/transform/test_join_epc_pp.py index f4a52b3..bd78db0 100644 --- a/pipeline/transform/test_join_epc_pp.py +++ b/pipeline/transform/test_join_epc_pp.py @@ -24,6 +24,7 @@ def _row(**overrides: str) -> dict[str, str]: row = { "address": "1 Example Street", "postcode": " aa1 1aa ", + "uprn": "100012345678", "current_energy_rating": "c", "potential_energy_rating": "b", "property_type": "House", @@ -52,11 +53,12 @@ def test_scan_epc_certificates_supports_legacy_uppercase_csv(tmp_path: Path): { "epc_address": "1 Example Street", "epc_postcode": "AA1 1AA", + "uprn": "100012345678", "current_energy_rating": "C", "potential_energy_rating": "B", "epc_property_type": "House", "built_form": "Mid-Terrace", - "inspection_date": "2024-01-02", + "inspection_date": date(2024, 1, 2), "total_floor_area": 84.5, "number_habitable_rooms": None, "floor_height": 2.4, @@ -147,6 +149,7 @@ def test_run_joins_domestic_zip_with_price_paid(tmp_path: Path): "town_city": ["Exampletown", "Exampletown"], "duration": ["F", "F"], "old_new": ["N", "N"], + "ppd_category": ["A", "A"], } ).write_parquet(price_paid_path) @@ -167,7 +170,8 @@ def test_run_joins_domestic_zip_with_price_paid(tmp_path: Path): "epc_address": "1 Example Street", "current_energy_rating": "C", "total_floor_area": 85.0, - "construction_age_band": 1950, + # Band midpoint of 1950-1966, not the lower bound. + "construction_age_band": 1958, "was_council_house": "Yes", } ] @@ -175,6 +179,65 @@ def test_run_joins_domestic_zip_with_price_paid(tmp_path: Path): assert df.get_column("historical_prices").list.len().to_list() == [2] +def test_run_dedup_prefers_valid_dated_cert_over_garbled_date(tmp_path: Path): + # Two certificates for the same property. The cert with the garbled, + # unparseable inspection_date must NOT be chosen as "latest": a string sort + # nulls-first would have picked it, attaching a stale rating/floor area. The + # valid-dated cert wins, so its rating ("C") and floor area (85) survive. + zip_path = tmp_path / "domestic-csv.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + csv_buffer = io.StringIO() + writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS) + writer.writeheader() + writer.writerows( + [ + _row( + current_energy_rating="c", + inspection_date="2024-01-01", + total_floor_area="85", + ), + # Same property; an unparseable date (OCR/garbled). Under a raw + # string descending sort "not-a-date" outranks the ISO date and + # wins the dedup, but as a null Date it loses. + _row( + current_energy_rating="g", + inspection_date="not-a-date", + total_floor_area="40", + ), + ] + ) + archive.writestr("certificates-2024.csv", csv_buffer.getvalue()) + + price_paid_path = tmp_path / "price-paid.parquet" + pl.DataFrame( + { + "price": [250_000], + "date_of_transfer": [date(2024, 2, 3)], + "property_type": ["T"], + "postcode": ["AA1 1AA"], + "paon": ["1"], + "saon": [None], + "street": ["Example Street"], + "locality": [None], + "town_city": ["Exampletown"], + "duration": ["F"], + "old_new": ["N"], + "ppd_category": ["A"], + } + ).write_parquet(price_paid_path) + + output_path = tmp_path / "epc-pp.parquet" + _run(zip_path, price_paid_path, output_path, tmp_path) + + df = pl.read_parquet(output_path) + + assert df.height == 1 + # The valid-dated cert's facts are kept; the garbled-date cert is NOT chosen. + assert df.select("current_energy_rating", "total_floor_area").to_dicts() == [ + {"current_energy_rating": "C", "total_floor_area": 85.0} + ] + + def test_run_excludes_price_paid_rows_without_full_postcode(tmp_path: Path): zip_path = tmp_path / "domestic-csv.zip" with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: @@ -198,6 +261,7 @@ def test_run_excludes_price_paid_rows_without_full_postcode(tmp_path: Path): "town_city": ["Exampletown", "Exampletown"], "duration": ["F", "F"], "old_new": ["N", "N"], + "ppd_category": ["A", "A"], } ).write_parquet(price_paid_path) @@ -232,6 +296,7 @@ def test_run_does_not_attach_epc_facts_to_low_score_address_match(tmp_path: Path "town_city": ["Exampletown"], "duration": ["F"], "old_new": ["N"], + "ppd_category": ["A"], } ).write_parquet(price_paid_path) @@ -254,3 +319,113 @@ def test_run_does_not_attach_epc_facts_to_low_score_address_match(tmp_path: Path "current_energy_rating": None, } ] + + +def test_run_excludes_category_b_sales_from_price_aggregations(tmp_path: Path): + # Category B entries (repossessions, bulk/portfolio, power-of-sale) must not + # pollute latest_price / historical_prices, but the property still survives + # via its standard Category A sales. + zip_path = tmp_path / "domestic-csv.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + csv_buffer = io.StringIO() + writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS) + writer.writeheader() + writer.writerow(_row()) + archive.writestr("certificates-2024.csv", csv_buffer.getvalue()) + + price_paid_path = tmp_path / "price-paid.parquet" + pl.DataFrame( + { + "price": [200_000, 250_000, 5_000_000], + "date_of_transfer": [date(2020, 2, 3), date(2022, 2, 3), date(2024, 2, 3)], + "property_type": ["T", "T", "T"], + "postcode": ["AA1 1AA", "AA1 1AA", "AA1 1AA"], + "paon": ["1", "1", "1"], + "saon": [None, None, None], + "street": ["Example Street", "Example Street", "Example Street"], + "locality": [None, None, None], + "town_city": ["Exampletown", "Exampletown", "Exampletown"], + "duration": ["F", "F", "F"], + "old_new": ["N", "N", "N"], + # The latest (5M) sale is a Category B bulk/portfolio transfer. + "ppd_category": ["A", "A", "B"], + } + ).write_parquet(price_paid_path) + + output_path = tmp_path / "epc-pp.parquet" + _run(zip_path, price_paid_path, output_path, tmp_path) + + df = pl.read_parquet(output_path) + + assert df.height == 1 + # Only the two Category A sales survive; the 5M Category B transfer is dropped. + assert df.get_column("latest_price").to_list() == [250_000] + assert df.get_column("historical_prices").list.len().to_list() == [2] + + +def test_run_new_build_keeps_early_first_transfer_when_sub_min_price(tmp_path: Path): + # A new-build whose earliest sale is below MIN_PRICE must still take that early + # year as its EXACT construction date, while latest_price uses only the + # quality-passing (>=MIN_PRICE) sale. + zip_path = tmp_path / "domestic-csv.zip" + with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: + csv_buffer = io.StringIO() + writer = csv.DictWriter(csv_buffer, fieldnames=EPC_SOURCE_COLUMNS) + writer.writeheader() + writer.writerow(_row()) + archive.writestr("certificates-2024.csv", csv_buffer.getvalue()) + + price_paid_path = tmp_path / "price-paid.parquet" + pl.DataFrame( + { + "price": [30_000, 300_000], + "date_of_transfer": [date(2015, 2, 3), date(2022, 2, 3)], + "property_type": ["T", "T"], + "postcode": ["AA1 1AA", "AA1 1AA"], + "paon": ["1", "1"], + "saon": [None, None], + "street": ["Example Street", "Example Street"], + "locality": [None, None], + "town_city": ["Exampletown", "Exampletown"], + "duration": ["F", "F"], + "old_new": ["Y", "Y"], + "ppd_category": ["A", "A"], + } + ).write_parquet(price_paid_path) + + output_path = tmp_path / "epc-pp.parquet" + _run(zip_path, price_paid_path, output_path, tmp_path) + + df = pl.read_parquet(output_path) + + assert df.height == 1 + # Construction year is the genuine earliest transfer (2015), flagged EXACT, + # even though that sale is below MIN_PRICE. + assert df.get_column("construction_age_band").to_list() == [2015] + assert df.get_column("is_construction_date_approximate").to_list() == [0] + # latest_price uses only the >=MIN_PRICE sale; the sub-MIN sale is excluded. + assert df.get_column("latest_price").to_list() == [300_000] + assert df.get_column("historical_prices").list.len().to_list() == [1] + + +def test_epc_band_to_year_uses_midpoint_and_clamps(): + import polars as pl + + from pipeline.transform.join_epc_pp import epc_band_to_year + + df = pl.DataFrame( + { + "b": [ + "England and Wales: 1950-1966", # midpoint 1958 + "1900-1929", # midpoint 1914 + "England and Wales: before 1900", # too wide -> null + "2012 onwards", # single year + "1012", # implausible -> null + "2202", # implausible -> null + None, # null -> null + "1958", # already-numeric-as-string -> pass through + ] + } + ) + years = df.select(epc_band_to_year(pl.col("b")).alias("y"))["y"].to_list() + assert years == [1958, 1914, None, 2012, None, None, None, 1958] diff --git a/pipeline/transform/test_merge.py b/pipeline/transform/test_merge.py index 2597730..3d4589e 100644 --- a/pipeline/transform/test_merge.py +++ b/pipeline/transform/test_merge.py @@ -10,11 +10,18 @@ from pipeline.transform.merge import ( LISTED_BUILDING_FEATURE, TREE_DENSITY_FEATURE, _LISTING_OVERLAY_SOURCES, + _active_english_postcode_area, _build_unmatched_listing_seed_rows, _canonical_postcode_expr, + _coalesce_direct_epc_columns, + _dedupe_collapsed_properties, + _filter_to_active_english_postcodes, + _join_area_side_tables, _finalize_listings, _integrate_listings, _match_direct_epc, + _match_listing_properties, + _normalize_uprn, _is_dynamic_poi_metric_column, _less_deprived_percentile_expr, _load_conservation_area_geometries, @@ -22,8 +29,11 @@ from pipeline.transform.merge import ( _matched_listed_building_flags, _postcode_conservation_area_flags, _postcode_listed_building_candidates, + _remap_terminated_postcodes, + _split_normal_outputs, _tree_density_by_postcode, _validate_lad_source_coverage, + _validate_postcode_feature_output, _validate_property_postcodes, ) @@ -68,6 +78,275 @@ def test_conservation_area_feature_is_area_level() -> None: assert CONSERVATION_AREA_FEATURE in _AREA_COLUMNS +def test_crime_columns_are_spatial_counts_not_per_capita() -> None: + # Crime is now a raw spatial count per postcode; the per-1k-residents + # variants were dropped along with the LSOA population denominator. + assert "Serious crime (avg/yr)" in _AREA_COLUMNS + assert "Minor crime (avg/yr)" in _AREA_COLUMNS + assert "Serious crime per 1k residents (avg/yr)" not in _AREA_COLUMNS + assert "Minor crime per 1k residents (avg/yr)" not in _AREA_COLUMNS + + +def test_active_english_postcode_area_filters_to_active_england() -> None: + arcgis = pl.DataFrame( + { + "pcds": ["AA1 1AA", "AA1 1AB", "CF1 1AA"], + "ctry25cd": ["E92000001", "E92000001", "W92000004"], + "doterm": [None, "2020-01-01", None], + "lat": [51.0, 51.1, 52.0], + "long": [-0.1, -0.2, -3.0], + "lsoa21cd": ["L1", "L2", "L3"], + "oa21cd": ["O1", "O2", "O3"], + "pcon24cd": ["P1", "P2", "P3"], + } + ) + + result = _active_english_postcode_area(arcgis.lazy()).collect() + + assert result.to_dicts() == [ + { + "postcode": "AA1 1AA", + "lat": 51.0, + "lon": -0.1, + "ctry25cd": "E92000001", + "lsoa21": "L1", + "oa21": "O1", + "pcon": "P1", + } + ] + + +def test_remap_then_active_filter_keeps_terminated_english_properties() -> None: + wide = pl.DataFrame( + { + "postcode": ["OLD 1AA", "NEW 1AA", "CF1 1AA"], + "row_id": [1, 2, 3], + } + ).lazy() + mapping = pl.DataFrame( + {"old_postcode": ["OLD 1AA"], "new_postcode": ["NEW 1AA"]} + ).lazy() + active_postcodes = pl.DataFrame({"postcode": ["NEW 1AA"]}).lazy() + + result = ( + _filter_to_active_english_postcodes( + _remap_terminated_postcodes(wide, mapping), active_postcodes + ) + .collect() + .sort("row_id") + ) + + assert result.to_dicts() == [ + {"postcode": "NEW 1AA", "row_id": 1}, + {"postcode": "NEW 1AA", "row_id": 2}, + ] + + +def test_split_normal_outputs_uses_postcode_feature_universe() -> None: + df = pl.DataFrame( + { + "Postcode": ["AA1 1AA"], + "Address per Property Register": ["1 Example Road"], + "Last known price": [250_000], + "lat": [51.0], + "lon": [-0.1], + "ctry25cd": ["E92000001"], + "lsoa21": ["L1"], + } + ) + postcode_features = pl.DataFrame( + { + "Postcode": ["AA1 1AA", "BB1 1BB"], + "lat": [51.0, 52.0], + "lon": [-0.1, -0.2], + "ctry25cd": ["E92000001", "E92000001"], + "lsoa21": ["L1", "L2"], + "Distance to nearest amenity (Park) (km)": [0.3, 0.8], + } + ) + + postcode_df, properties_df = _split_normal_outputs( + df, postcode_features, expected_postcode_count=2 + ) + + assert postcode_df["Postcode"].to_list() == ["AA1 1AA", "BB1 1BB"] + assert "Distance to nearest amenity (Park) (km)" in postcode_df.columns + assert properties_df.to_dicts() == [ + { + "Postcode": "AA1 1AA", + "Address per Property Register": "1 Example Road", + "Last known price": 250_000, + } + ] + + +def test_postcode_feature_validation_rejects_unsupported_or_ungeocoded_rows() -> None: + postcode_df = pl.DataFrame( + { + "Postcode": ["AA1 1AA", "CF1 1AA"], + "lat": [51.0, None], + "lon": [-0.1, None], + "ctry25cd": ["E92000001", "W92000004"], + } + ) + + with pytest.raises(ValueError, match="unsupported or ungeocoded"): + _validate_postcode_feature_output(postcode_df, expected_postcode_count=2) + + +def test_postcode_feature_validation_rejects_wrong_count() -> None: + # The universe-size invariant: the postcode feature output must contain + # EXACTLY the active-England universe. Too few rows (silently dropped + # postcodes) and too many / duplicated rows (a join fan-out) must both fail, + # so neither a truncated build nor a one-to-many join can ship. + too_few = pl.DataFrame( + { + "Postcode": ["AA1 1AA"], + "lat": [51.0], + "lon": [-0.1], + "ctry25cd": ["E92000001"], + } + ) + with pytest.raises(ValueError, match="active England postcode universe"): + _validate_postcode_feature_output(too_few, expected_postcode_count=2) + + too_many = pl.DataFrame( + { + "Postcode": ["AA1 1AA", "BB1 1BB", "CC1 1CC"], + "lat": [51.0, 52.0, 53.0], + "lon": [-0.1, -0.2, -0.3], + "ctry25cd": ["E92000001"] * 3, + } + ) + with pytest.raises(ValueError, match="active England postcode universe"): + _validate_postcode_feature_output(too_many, expected_postcode_count=2) + + # Right row count but a duplicated key (n_unique < height) -- the signature of + # a join fan-out. + duplicated = pl.DataFrame( + { + "Postcode": ["AA1 1AA", "AA1 1AA"], + "lat": [51.0, 51.0], + "lon": [-0.1, -0.1], + "ctry25cd": ["E92000001", "E92000001"], + } + ) + with pytest.raises(ValueError, match="active England postcode universe"): + _validate_postcode_feature_output(duplicated, expected_postcode_count=2) + + +def test_join_area_side_tables_does_not_fan_out_on_unique_keys() -> None: + # Soundness: with side tables unique on their join key, the per-postcode + # feature joins emit exactly one row per postcode (no fan-out). A fan-out here + # would inflate the postcode universe above the active-England count -- the + # failure the universe assertion above is the backstop for. + base = pl.LazyFrame( + { + "postcode": ["AA1 1AA", "BB2 2BB"], + "lsoa21": ["E01000001", "E01000002"], + "Local Authority District code (2024)": ["E09000001", "E09000002"], + "pcon": ["E14000001", "E14000002"], + } + ) + + def _by_postcode(extra: dict) -> pl.LazyFrame: + return pl.LazyFrame({"postcode": ["AA1 1AA", "BB2 2BB"], **extra}) + + crime = pl.LazyFrame( + { + "postcode": ["AA1 1AA", "BB2 2BB"], + "Serious crime (avg/yr)": [1.0, 2.0], + "Minor crime (avg/yr)": [3.0, 4.0], + } + ) + joined = _join_area_side_tables( + base, + iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}), + ethnicity=pl.LazyFrame({"Geography_code": ["E09000001", "E09000002"]}), + crime=crime, + median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}), + election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}), + poi_counts=_by_postcode({}), + noise=_by_postcode({}), + school_proximity=_by_postcode({}), + conservation_areas=_by_postcode({CONSERVATION_AREA_FEATURE: ["Yes", "No"]}), + tree_density=None, + broadband=pl.LazyFrame( + { + "bb_postcode": ["AA1 1AA", "BB2 2BB"], + "max_download_speed": pl.Series([100, 300], dtype=pl.UInt16), + } + ), + ).collect() + + # One row per postcode in -> one row out; the universe is not inflated. + assert joined.height == 2 + assert sorted(joined["postcode"].to_list()) == ["AA1 1AA", "BB2 2BB"] + + +def test_join_area_side_tables_normalizes_broadband_postcode_key() -> None: + # Broadband comes straight from Ofcom's CSV, so its postcode can drift in + # spacing/casing from the NSPL `pcds` base key. Both sides must be reduced + # to the same canonical form so a real postcode populates + # `max_download_speed` instead of silently missing the left join. + base = pl.LazyFrame( + { + "postcode": ["AB1 2CD", "EF3 4GH"], + "lsoa21": ["E01000001", "E01000002"], + "Local Authority District code (2024)": ["E09000001", "E09000002"], + "pcon": ["E14000001", "E14000002"], + } + ) + + def _by_postcode(extra: dict) -> pl.LazyFrame: + return pl.LazyFrame({"postcode": ["AB1 2CD", "EF3 4GH"], **extra}) + + crime = pl.LazyFrame( + { + "postcode": ["AB1 2CD", "EF3 4GH"], + "Serious crime (avg/yr)": [1.0, 2.0], + "Minor crime (avg/yr)": [3.0, 4.0], + } + ) + # AB1 2CD arrives lowercase + un-spaced; EF3 4GH arrives under two distinct + # raw spellings that canonicalize to one key (the max speed must win, with + # no fan-out of the base row). + broadband = pl.LazyFrame( + { + "bb_postcode": ["ab1 2cd", "ef34gh", "EF3 4GH"], + "max_download_speed": pl.Series([300, 30, 1000], dtype=pl.UInt16), + } + ) + joined = _join_area_side_tables( + base, + iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}), + ethnicity=pl.LazyFrame({"Geography_code": ["E09000001", "E09000002"]}), + crime=crime, + median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}), + election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}), + poi_counts=_by_postcode({}), + noise=_by_postcode({}), + school_proximity=_by_postcode({}), + conservation_areas=_by_postcode({CONSERVATION_AREA_FEATURE: ["Yes", "No"]}), + tree_density=None, + broadband=broadband, + ).collect() + + # No fan-out: still one row per base postcode. + assert joined.height == 2 + speeds = dict( + zip(joined["postcode"].to_list(), joined["max_download_speed"].to_list()) + ) + # Spacing/casing drift still joins. + assert speeds["AB1 2CD"] == 300 + # Two raw spellings collapse to one canonical key; the max wins. + assert speeds["EF3 4GH"] == 1000 + # The temporary canonical join key is not leaked into the output schema. + assert "_base_canonical_postcode" not in joined.columns + assert "_bb_canonical_postcode" not in joined.columns + assert "bb_postcode" not in joined.columns + + def test_listed_building_feature_is_property_level() -> None: assert LISTED_BUILDING_FEATURE not in _AREA_COLUMNS @@ -383,6 +662,25 @@ def test_load_listings_for_merge_canonicalises_and_exposes_overlay_columns( assert loaded["_actual_lat"].to_list() == [51.5] +def test_load_listings_for_merge_uprn_key_matches_normalize_uprn(tmp_path) -> None: + # A Float UPRN (e.g. read from a NaN-bearing parquet column) must produce + # the same digits-only key as `_normalize_uprn` on the candidate side, so + # the exact UPRN match is not lost. Naively stringifying "100023336956.0" + # and stripping non-digits would yield "1000233369560" (a bogus trailing + # zero) which never collides with the candidate key "100023336956". + listings_path = tmp_path / "listings.parquet" + arcgis_path = tmp_path / "arcgis.parquet" + _sample_listings_frame().with_columns( + pl.lit(100023336956.0, dtype=pl.Float64).alias("UPRN") + ).write_parquet(listings_path) + _stub_arcgis(arcgis_path) + + loaded = _load_listings_for_merge(listings_path, arcgis_path) + + assert loaded["_listing_uprn"].to_list() == [_normalize_uprn(100023336956.0)] + assert loaded["_listing_uprn"].to_list() == ["100023336956"] + + def test_build_unmatched_listing_seed_rows_fills_property_shape_fields( tmp_path, ) -> None: @@ -407,9 +705,7 @@ def test_build_unmatched_listing_seed_rows_fills_property_shape_fields( ) unmatched_idxs = listings.select("_listing_idx") - seed = _build_unmatched_listing_seed_rows( - unmatched_idxs, listings, template_schema - ) + seed = _build_unmatched_listing_seed_rows(unmatched_idxs, listings, template_schema) assert seed.height == 1 assert seed["postcode"].to_list() == ["SW1A 1AA"] @@ -471,71 +767,323 @@ def test_build_unmatched_listing_seed_rows_uses_direct_epc_fallbacks( assert seed["was_council_house"].to_list() == ["No"] -def test_match_direct_epc_considers_nearby_postcodes() -> None: - listing_matches = pl.DataFrame( - { - "_listing_idx": [0], - "_listing_match_address": ["1 EXAMPLE ROAD"], - "_listing_match_postcode": ["AA11AA"], - "_listing_east": [1000.0], - "_listing_north": [1000.0], - "_actual_property_type": ["Terraced"], - "_actual_total_floor_area": [100.0], - "_actual_number_habitable_rooms": [4], - }, - schema={ - "_listing_idx": pl.UInt32, - "_listing_match_address": pl.Utf8, - "_listing_match_postcode": pl.Utf8, - "_listing_east": pl.Float64, - "_listing_north": pl.Float64, - "_actual_property_type": pl.Utf8, - "_actual_total_floor_area": pl.Float64, - "_actual_number_habitable_rooms": pl.Int16, - }, - ) - epc_candidates = pl.DataFrame( - { - "_direct_epc_row": [0], - "_direct_epc_match_address": ["1 EXAMPLE ROAD"], - "_direct_epc_match_postcode": ["BB11BB"], - "_direct_epc_east": [1020.0], - "_direct_epc_north": [1010.0], - "_direct_epc_canonical_property_type": ["Terraced"], - "_direct_epc_address": ["1, Example Road"], - "_direct_current_energy_rating": ["C"], - "_direct_potential_energy_rating": ["B"], - "_direct_total_floor_area": [101.0], - "_direct_number_habitable_rooms": [4], - "_direct_floor_height": [2.5], - "_direct_construction_age_band": [1930], - "_direct_is_construction_date_approximate": [1], - "_direct_was_council_house": ["No"], - }, - schema={ - "_direct_epc_row": pl.UInt32, - "_direct_epc_match_address": pl.Utf8, - "_direct_epc_match_postcode": pl.Utf8, - "_direct_epc_east": pl.Float64, - "_direct_epc_north": pl.Float64, - "_direct_epc_canonical_property_type": pl.Utf8, - "_direct_epc_address": pl.Utf8, - "_direct_current_energy_rating": pl.Utf8, - "_direct_potential_energy_rating": pl.Utf8, - "_direct_total_floor_area": pl.Float64, - "_direct_number_habitable_rooms": pl.Int16, - "_direct_floor_height": pl.Float64, - "_direct_construction_age_band": pl.UInt16, - "_direct_is_construction_date_approximate": pl.UInt8, - "_direct_was_council_house": pl.Utf8, - }, +_DIRECT_EPC_CANDIDATE_SCHEMA = { + "_direct_epc_row": pl.UInt32, + "_direct_epc_match_address": pl.Utf8, + "_direct_epc_match_postcode": pl.Utf8, + "_direct_epc_outcode": pl.Utf8, + "_direct_epc_canonical_property_type": pl.Utf8, + "_direct_epc_uprn": pl.Utf8, + "_direct_epc_address": pl.Utf8, + "_direct_current_energy_rating": pl.Utf8, + "_direct_potential_energy_rating": pl.Utf8, + "_direct_total_floor_area": pl.Float64, + "_direct_number_habitable_rooms": pl.Int16, + "_direct_floor_height": pl.Float64, + "_direct_construction_age_band": pl.UInt16, + "_direct_is_construction_date_approximate": pl.UInt8, + "_direct_was_council_house": pl.Utf8, +} + +_LISTING_MATCH_SCHEMA = { + "_listing_idx": pl.UInt32, + "_listing_match_address": pl.Utf8, + "_listing_match_postcode": pl.Utf8, + "_listing_uprn": pl.Utf8, +} + + +def _direct_epc_candidates(rows: list[dict]) -> pl.DataFrame: + base = { + "_direct_epc_row": 0, + "_direct_epc_match_address": "1 EXAMPLE ROAD", + "_direct_epc_match_postcode": "AA11AA", + "_direct_epc_outcode": "AA1", + "_direct_epc_canonical_property_type": "Terraced", + "_direct_epc_uprn": None, + "_direct_epc_address": "1, Example Road", + "_direct_current_energy_rating": "C", + "_direct_potential_energy_rating": "B", + "_direct_total_floor_area": 101.0, + "_direct_number_habitable_rooms": 4, + "_direct_floor_height": 2.5, + "_direct_construction_age_band": 1930, + "_direct_is_construction_date_approximate": 1, + "_direct_was_council_house": "No", + } + return pl.DataFrame( + [{**base, **row} for row in rows], schema=_DIRECT_EPC_CANDIDATE_SCHEMA ) - matches = _match_direct_epc(listing_matches, epc_candidates) + +def _listing_matches(rows: list[dict]) -> pl.DataFrame: + base = { + "_listing_idx": 0, + "_listing_match_address": "1 EXAMPLE ROAD", + "_listing_match_postcode": "AA11AA", + "_listing_uprn": None, + } + return pl.DataFrame([{**base, **row} for row in rows], schema=_LISTING_MATCH_SCHEMA) + + +def test_match_direct_epc_matches_by_uprn_across_postcodes() -> None: + # UPRN is matched globally (not within a postcode bucket), so a listing + # whose detail-page postcode is slightly off still resolves to the right + # EPC certificate by its UPRN. + matches = _match_direct_epc( + _listing_matches( + [{"_listing_uprn": "100000000001", "_listing_match_postcode": "ZZ99ZZ"}] + ), + _direct_epc_candidates( + [ + { + "_direct_epc_uprn": "100000000001", + "_direct_epc_match_postcode": "AA11AA", + } + ] + ), + ) assert matches.height == 1 - assert matches["_listing_idx"].to_list() == [0] assert matches["_direct_epc_address"].to_list() == ["1, Example Road"] + assert matches["_direct_epc_match_method"].to_list() == ["uprn"] + + +def test_match_direct_epc_matches_by_address_in_same_postcode() -> None: + matches = _match_direct_epc( + _listing_matches([{"_listing_match_address": "1 EXAMPLE ROAD"}]), + _direct_epc_candidates([{"_direct_epc_match_address": "1 EXAMPLE ROAD"}]), + ) + + assert matches.height == 1 + assert matches["_direct_epc_address"].to_list() == ["1, Example Road"] + assert matches["_direct_epc_match_method"].to_list() == ["address"] + + +def test_normalize_uprn_handles_types_and_floats() -> None: + assert _normalize_uprn(None) is None + assert _normalize_uprn("") is None + assert _normalize_uprn(" 100012345678 ") == "100012345678" + assert _normalize_uprn(100012345678) == "100012345678" + # An integral float normalises to its digits, NOT "1230". + assert _normalize_uprn(123.0) == "123" + # Non-integral / NaN floats are rejected rather than mangled. + assert _normalize_uprn(1.5) is None + assert _normalize_uprn(float("nan")) is None + + +def test_coalesce_direct_epc_was_council_house_prefers_yes() -> None: + # The raw property value is fill_null("No") upstream, so a plain coalesce + # would let a non-null "No" override a directly-matched listing "Yes". + # "Former council house" should fire if EITHER side says "Yes". + none_col = [None] * 5 + wide = pl.LazyFrame( + { + "was_council_house": ["No", "Yes", "No", None, None], + "_direct_was_council_house": ["Yes", "No", None, "Yes", None], + # An unrelated direct-EPC column keeps the plain-coalesce behaviour. + "current_energy_rating": [None, "C", "D", None, None], + "_direct_current_energy_rating": ["B", "A", None, "E", None], + # _coalesce_direct_epc_columns coalesces every pair in + # _DIRECT_EPC_RAW_COLUMN_MAP, so the rest must be present too. + "epc_address": none_col, + "_direct_epc_address": none_col, + "potential_energy_rating": none_col, + "_direct_potential_energy_rating": none_col, + "total_floor_area": none_col, + "_direct_total_floor_area": none_col, + "number_habitable_rooms": none_col, + "_direct_number_habitable_rooms": none_col, + "floor_height": none_col, + "_direct_floor_height": none_col, + "construction_age_band": none_col, + "_direct_construction_age_band": none_col, + "is_construction_date_approximate": none_col, + "_direct_is_construction_date_approximate": none_col, + } + ) + + result = _coalesce_direct_epc_columns(wide).collect() + + assert result["was_council_house"].to_list() == ["Yes", "Yes", "No", "Yes", None] + # Plain coalesce (raw wins when non-null) is untouched for other columns. + assert result["current_energy_rating"].to_list() == ["B", "C", "D", "E", None] + + +def test_join_area_side_tables_preserves_missing_crime_as_null() -> None: + # The crime table is LEFT-joined per postcode; a postcode absent from it + # must NOT be fabricated as "zero crime" (the safest value). The Serious/Minor + # rollups are precomputed in crime_spatial (the mean of the by-year rollup + # bars), so the merge reads them straight through; a missing postcode leaves + # them null. + base = pl.LazyFrame( + { + "postcode": ["AA1 1AA", "BB2 2BB"], + "lsoa21": ["E01000001", "E01000002"], + "Local Authority District code (2024)": ["E09000001", "E09000002"], + "pcon": ["E14000001", "E14000002"], + } + ) + + def _by_postcode(extra: dict) -> pl.LazyFrame: + return pl.LazyFrame({"postcode": ["AA1 1AA", "BB2 2BB"], **extra}) + + # Crime is present only for AA1 1AA; BB2 2BB is absent from the table. The + # rollup headlines are precomputed values (deliberately NOT the per-type sum, + # which would be 10.0 each) so this test proves the merge consumes the + # precomputed column rather than re-summing per-type columns. + crime = pl.LazyFrame( + { + "postcode": ["AA1 1AA"], + "Violence and sexual offences (avg/yr)": [1.0], + "Robbery (avg/yr)": [2.0], + "Burglary (avg/yr)": [3.0], + "Possession of weapons (avg/yr)": [4.0], + "Anti-social behaviour (avg/yr)": [1.0], + "Criminal damage and arson (avg/yr)": [1.0], + "Shoplifting (avg/yr)": [1.0], + "Bicycle theft (avg/yr)": [1.0], + "Theft from the person (avg/yr)": [1.0], + "Other theft (avg/yr)": [1.0], + "Vehicle crime (avg/yr)": [1.0], + "Public order (avg/yr)": [1.0], + "Drugs (avg/yr)": [1.0], + "Other crime (avg/yr)": [1.0], + "Serious crime (avg/yr)": [7.5], + "Minor crime (avg/yr)": [4.2], + } + ) + + joined = _join_area_side_tables( + base, + iod=pl.LazyFrame({"LSOA code (2021)": ["E01000001", "E01000002"]}), + ethnicity=pl.LazyFrame({"Geography_code": ["E09000001", "E09000002"]}), + crime=crime, + median_age=pl.LazyFrame({"lsoa21": ["E01000001", "E01000002"]}), + election=pl.LazyFrame({"pcon": ["E14000001", "E14000002"]}), + poi_counts=_by_postcode({}), + noise=_by_postcode({}), + school_proximity=_by_postcode({}), + conservation_areas=_by_postcode({CONSERVATION_AREA_FEATURE: ["Yes", "No"]}), + tree_density=None, + broadband=pl.LazyFrame( + { + "bb_postcode": ["AA1 1AA", "BB2 2BB"], + "max_download_speed": pl.Series([100, 300], dtype=pl.UInt16), + } + ), + ).collect() + + by_postcode = { + row["postcode"]: row + for row in joined.select( + "postcode", "serious_crime_avg_yr", "minor_crime_avg_yr" + ).iter_rows(named=True) + } + # Present postcode: rollups are the precomputed headline values, read through + # unchanged (NOT the per-type sum of 10.0). + assert by_postcode["AA1 1AA"]["serious_crime_avg_yr"] == 7.5 + assert by_postcode["AA1 1AA"]["minor_crime_avg_yr"] == 4.2 + # Missing postcode: rollups stay null rather than fabricating 0.0. + assert by_postcode["BB2 2BB"]["serious_crime_avg_yr"] is None + assert by_postcode["BB2 2BB"]["minor_crime_avg_yr"] is None + + +def test_dedupe_collapsed_properties_keeps_most_recent_per_address() -> None: + # The terminated-postcode remap can merge two distinct postcodes onto one + # active successor, collapsing the same physical address onto a single + # (postcode, pp_address) key with conflicting sale records. The dedup must + # keep exactly one row per (postcode, pp_address) -- the most recent + # transaction -- and must not collapse genuinely distinct addresses. + from datetime import datetime + + wide = pl.LazyFrame( + { + "postcode": ["SW3 3JY", "SW3 3JY", "SW3 3JY"], + "pp_address": ["45 ELYSTAN PLACE", "45 ELYSTAN PLACE", "9 OTHER ROAD"], + "date_of_transfer": [ + datetime(1990, 1, 1), + datetime(2015, 6, 1), + datetime(2000, 1, 1), + ], + "latest_price": [1_587_700, 4_500_000, 250_000], + } + ) + + out = _dedupe_collapsed_properties(wide).collect() + + # One row per (postcode, pp_address): the two ELYSTAN PLACE rows collapse to one. + assert out.height == 2 + assert out.select(["postcode", "pp_address"]).is_unique().all() + by_addr = {r["pp_address"]: r for r in out.iter_rows(named=True)} + # The kept ELYSTAN PLACE row is the most recent transaction (2015 @ 4.5M), + # not an arbitrary one. + assert by_addr["45 ELYSTAN PLACE"]["date_of_transfer"] == datetime(2015, 6, 1) + assert by_addr["45 ELYSTAN PLACE"]["latest_price"] == 4_500_000 + # A genuinely distinct address in the same postcode is untouched. + assert by_addr["9 OTHER ROAD"]["latest_price"] == 250_000 + + +def _property_candidates(rows: list[dict]) -> pl.DataFrame: + base = { + "postcode": "AA1 1AA", + "pp_address": "1 Example Road", + "_property_match_postcode": "AA11AA", + "_property_match_address": "1 EXAMPLE ROAD", + "_property_epc_match_address": "1 EXAMPLE ROAD", + "uprn": None, + } + return pl.DataFrame( + [{**base, **row} for row in rows], + schema={ + "postcode": pl.Utf8, + "pp_address": pl.Utf8, + "_property_match_postcode": pl.Utf8, + "_property_match_address": pl.Utf8, + "_property_epc_match_address": pl.Utf8, + "uprn": pl.Utf8, + }, + ) + + +def test_match_listing_properties_uprn_wins_dedup_tie() -> None: + # Two listings claim the same property: one by UPRN, one by exact address + # (both score 100). The UPRN match must win even though it has the higher + # _listing_idx (which would otherwise break the tie the wrong way). + listings = _listing_matches( + [ + { + "_listing_idx": 5, + "_listing_uprn": "100000000001", + "_listing_match_address": "SOMETHING ELSE", + }, + { + "_listing_idx": 1, + "_listing_uprn": None, + "_listing_match_address": "1 EXAMPLE ROAD", + }, + ] + ) + matches = _match_listing_properties( + listings, _property_candidates([{"uprn": "100000000001"}]) + ) + + assert matches.height == 1 + assert matches["_listing_idx"].to_list() == [5] + assert matches["_property_match_method"].to_list() == ["uprn"] + + +def test_match_direct_epc_does_not_match_other_postcode_without_uprn() -> None: + # Matching is by postcode/UPRN/street — never by coordinate proximity — so a + # same-street EPC in a different postcode with no shared UPRN is skipped. + matches = _match_direct_epc( + _listing_matches([{"_listing_match_postcode": "AA11AA"}]), + _direct_epc_candidates( + [{"_direct_epc_match_postcode": "BB22BB", "_direct_epc_uprn": None}] + ), + ) + + assert matches.height == 0 def test_integrate_listings_attaches_overlay_by_matched_property_key(tmp_path) -> None: @@ -588,11 +1136,72 @@ def test_integrate_listings_attaches_overlay_by_matched_property_key(tmp_path) - assert other["_actual_listing_url"].to_list() == [None] -def test_integrate_listings_rejects_low_confidence_no_number_match(tmp_path) -> None: +def test_integrate_listings_matches_by_uprn_over_address(tmp_path) -> None: + # The listing's address deliberately does not match the property's, but the + # shared UPRN drives an exact match anyway (UPRN beats fuzzy street). listings_path = tmp_path / "listings.parquet" arcgis_path = tmp_path / "arcgis.parquet" _sample_listings_frame().with_columns( - pl.lit("Rose Cottage High Street").alias("Address per Property Register"), + pl.lit("Totally Different Road").alias("Address per Property Register"), + pl.lit("100000000009").alias("UPRN"), + ).write_parquet(listings_path) + _stub_arcgis(arcgis_path) + wide = pl.DataFrame( + { + "postcode": ["SW1A 1AA"], + "pp_address": ["1 Example Road"], + "uprn": ["100000000009"], + "pp_property_type": ["Terraced"], + "duration": ["Freehold"], + "total_floor_area": [90.0], + "number_habitable_rooms": [4], + "latest_price": [600_000], + "epc_address": ["1 Example Road"], + "current_energy_rating": ["C"], + "potential_energy_rating": ["B"], + "floor_height": [2.4], + "construction_age_band": [1930], + "is_construction_date_approximate": [1], + "was_council_house": ["No"], + }, + schema={ + "postcode": pl.Utf8, + "pp_address": pl.Utf8, + "uprn": pl.Utf8, + "pp_property_type": pl.Utf8, + "duration": pl.Utf8, + "total_floor_area": pl.Float64, + "number_habitable_rooms": pl.Int16, + "latest_price": pl.Int64, + "epc_address": pl.Utf8, + "current_energy_rating": pl.Utf8, + "potential_energy_rating": pl.Utf8, + "floor_height": pl.Float64, + "construction_age_band": pl.UInt16, + "is_construction_date_approximate": pl.UInt8, + "was_council_house": pl.Utf8, + }, + ) + + integrated = _integrate_listings( + wide.lazy(), listings_path, arcgis_path, epc_path=None + ).collect() + + matched = integrated.filter(pl.col("pp_address") == "1 Example Road") + # The listing overlay attached to the UPRN-matched property row. + assert matched["_actual_listing_url"].to_list() == ["https://example.test/abc"] + # No spurious seed row for the listing's (non-matching) address. + assert "Totally Different Road" not in integrated["pp_address"].to_list() + + +def test_integrate_listings_seeds_listing_with_unmatched_street(tmp_path) -> None: + # A number-less listing whose street is not the property's street (and which + # shares no UPRN) must not be force-matched onto it; it becomes its own seed + # row instead of stamping the wrong property's overlay. + listings_path = tmp_path / "listings.parquet" + arcgis_path = tmp_path / "arcgis.parquet" + _sample_listings_frame().with_columns( + pl.lit("Juniper Crescent").alias("Address per Property Register"), ).write_parquet(listings_path) _stub_arcgis(arcgis_path) wide = pl.DataFrame( @@ -635,7 +1244,7 @@ def test_integrate_listings_rejects_low_confidence_no_number_match(tmp_path) -> ).collect() existing = integrated.filter(pl.col("pp_address") == "Old Cottage High Street") - seed = integrated.filter(pl.col("pp_address") == "Rose Cottage High Street") + seed = integrated.filter(pl.col("pp_address") == "Juniper Crescent") assert existing["_actual_listing_url"].to_list() == [None] assert seed["_actual_listing_url"].to_list() == ["https://example.test/abc"] @@ -731,3 +1340,77 @@ def test_finalize_listings_promotes_overlay_columns_and_filters_to_listing_rows( # Overlay scaffolding is dropped. for src, dst, _dt in _LISTING_OVERLAY_SOURCES: assert dst not in finalized.columns, src + + +def test_finalize_listings_dedupes_fanned_out_listing_rows() -> None: + # The terminated-postcode remap can collapse two distinct wide rows onto the same + # (postcode, pp_address), so a single matched listing attaches to both. Finalize + # must emit one row per listing URL, not one per collapsed wide row. + df = pl.DataFrame( + { + "Postcode": ["SW1A 1AA", "SW1A 1AA"], + "Address per Property Register": ["1 Example Road", "1 Example Road"], + "Address per EPC": ["1 Example Road", "1 Example Road"], + "Date of last transaction": [1990.0, 1995.0], + "lat": [51.5, 51.5], + "lon": [-0.1, -0.1], + "Total floor area (sqm)": [100.0, 95.0], + "Number of bedrooms & living rooms": [3, 3], + "Property type": ["Terraced", "Terraced"], + "Leasehold/Freehold": ["Leasehold", "Leasehold"], + "Last known price": [500_000, 480_000], + "Street tree density percentile": [42.0, 42.0], + # Same listing URL on both collapsed rows — the fan-out to fix. + "_actual_listing_url": ["url0", "url0"], + "_actual_asking_price": [600_000, 600_000], + "_actual_asking_price_per_sqm": [5_000, 5_000], + "_actual_listing_date": [None, None], + "_actual_listing_status": ["For sale", "For sale"], + "_actual_listing_features": [["Garden"], ["Garden"]], + "_actual_bedrooms": [3, 3], + "_actual_bathrooms": [1, 1], + "_actual_price_qualifier": ["", ""], + "_actual_property_sub_type": ["Mid-Terrace", "Mid-Terrace"], + "_actual_lat": [51.51, 51.51], + "_actual_lon": [-0.11, -0.11], + "_actual_total_floor_area": [110.0, 110.0], + "_actual_number_habitable_rooms": [4, 4], + "_actual_property_type": ["Terraced", "Terraced"], + "_actual_leasehold_freehold": ["Freehold", "Freehold"], + }, + schema={ + "Postcode": pl.Utf8, + "Address per Property Register": pl.Utf8, + "Address per EPC": pl.Utf8, + "Date of last transaction": pl.Float64, + "lat": pl.Float64, + "lon": pl.Float64, + "Total floor area (sqm)": pl.Float64, + "Number of bedrooms & living rooms": pl.Int16, + "Property type": pl.Utf8, + "Leasehold/Freehold": pl.Utf8, + "Last known price": pl.Int64, + "Street tree density percentile": pl.Float32, + "_actual_listing_url": pl.Utf8, + "_actual_asking_price": pl.Int64, + "_actual_asking_price_per_sqm": pl.Int32, + "_actual_listing_date": pl.Datetime("us"), + "_actual_listing_status": pl.Utf8, + "_actual_listing_features": pl.List(pl.Utf8), + "_actual_bedrooms": pl.Int32, + "_actual_bathrooms": pl.Int32, + "_actual_price_qualifier": pl.Utf8, + "_actual_property_sub_type": pl.Utf8, + "_actual_lat": pl.Float64, + "_actual_lon": pl.Float64, + "_actual_total_floor_area": pl.Float64, + "_actual_number_habitable_rooms": pl.Int16, + "_actual_property_type": pl.Utf8, + "_actual_leasehold_freehold": pl.Utf8, + }, + ) + + finalized = _finalize_listings(df) + + assert finalized.height == 1 + assert finalized["Listing URL"].to_list() == ["url0"] diff --git a/pipeline/transform/test_noise_overlay_tiles.py b/pipeline/transform/test_noise_overlay_tiles.py new file mode 100644 index 0000000..149a08a --- /dev/null +++ b/pipeline/transform/test_noise_overlay_tiles.py @@ -0,0 +1,110 @@ +import numpy as np +import rasterio +from rasterio.transform import from_origin +from rasterio.warp import transform_bounds + +from pipeline.transform import noise_overlay_tiles +from pipeline.transform.noise_overlay_tiles import RasterInfo, _read_noise_tile + + +def _write_corridor_raster(path, nodata=-96.0): + """A small EPSG:27700 raster: a column of 70 dB cells adjacent to genuine + 0.0 (quiet) cells. Bilinear blending of the 0 cells would fabricate a halo + of intermediate dB values between 0 and 70.""" + # 8x8 grid: leftmost two columns are 70 dB, the rest are genuine quiet 0.0. + data = np.zeros((8, 8), dtype=np.float32) + data[:, 0:2] = 70.0 + # Place one true nodata cell to make sure it is also masked out. + data[0, 7] = nodata + + # 10m cells anchored somewhere inside England's BNG extent. + left = 300_000.0 + top = 300_080.0 + transform = from_origin(left, top, 10.0, 10.0) + with rasterio.open( + path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:27700", + transform=transform, + nodata=nodata, + ) as dataset: + dataset.write(data, 1) + return path + + +def test_read_noise_tile_does_not_fabricate_halo(tmp_path): + raster_path = _write_corridor_raster(tmp_path / "corridor.tif") + + with rasterio.open(raster_path) as dataset: + bounds_27700 = dataset.bounds + bounds_mercator = transform_bounds( + dataset.crs, + noise_overlay_tiles.WEB_MERCATOR_CRS, + *bounds_27700, + densify_pts=21, + ) + + info = RasterInfo(path=raster_path, bounds_mercator=bounds_mercator) + + # Render at high resolution so any bilinear halo would surface as + # intermediate dB values along the corridor/quiet seam. + tile_size = 64 + tile = _read_noise_tile([info], bounds_mercator, tile_size) + + finite = tile[np.isfinite(tile)] + # Every finite cell must be the genuine corridor value (~70). There must be + # NO fabricated halo strictly between 0 and 70. + halo = finite[(finite > 0.0) & (finite < 70.0 - 1e-3)] + assert halo.size == 0, f"fabricated halo values present: {np.unique(halo)}" + # Sanity: the corridor itself must still be rendered. + assert finite.size > 0 + assert np.all(finite >= 70.0 - 1e-3) + + +def test_read_noise_tile_preserves_peak_under_downsample(tmp_path): + # 8x8 EPSG:27700 raster: a single loud 75 dB cell in a 50 dB field. + # Downsampling into a smaller tile with bilinear would dilute the peak + # (arithmetic dB averaging); Resampling.max must keep the worst-case dB. + data = np.full((8, 8), 50.0, dtype=np.float32) + data[4, 4] = 75.0 + transform = from_origin(300_000.0, 300_080.0, 10.0, 10.0) + raster_path = tmp_path / "peak.tif" + with rasterio.open( + raster_path, + "w", + driver="GTiff", + height=data.shape[0], + width=data.shape[1], + count=1, + dtype=data.dtype, + crs="EPSG:27700", + transform=transform, + nodata=-96.0, + ) as dataset: + dataset.write(data, 1) + + with rasterio.open(raster_path) as dataset: + bounds_mercator = transform_bounds( + dataset.crs, + noise_overlay_tiles.WEB_MERCATOR_CRS, + *dataset.bounds, + densify_pts=21, + ) + + info = RasterInfo(path=raster_path, bounds_mercator=bounds_mercator) + + # Render the 8x8 source into a 4x4 tile: this downsamples, so bilinear + # would average the 75 dB peak away. + tile = _read_noise_tile([info], bounds_mercator, 4) + finite = tile[np.isfinite(tile)] + + assert finite.size > 0 + # The loud peak must survive the downsample (max, not arithmetic mean). + assert finite.max() >= 75.0 - 1e-3, f"peak diluted to {finite.max()}" + # Max resampling must never invent a value louder than the source. + assert finite.max() <= 75.0 + 1e-3 diff --git a/pipeline/transform/test_poi_proximity.py b/pipeline/transform/test_poi_proximity.py index e584d18..9ba233f 100644 --- a/pipeline/transform/test_poi_proximity.py +++ b/pipeline/transform/test_poi_proximity.py @@ -1,9 +1,44 @@ import polars as pl from pipeline.transform.poi_proximity import ( + POI_GROUPS_2KM, _build_poi_category_groups, _dynamic_poi_metric_renames, + _groceries_categories, ) +from pipeline.utils.poi_counts import count_pois_per_postcode + + +def test_groceries_2km_counts_geolytix_brand_categories() -> None: + """The static groceries 2km count must include GEOLYTIX brand POIs. + + GEOLYTIX stores the brand (e.g. "Tesco") in `category` with group + "Groceries" and never emits the literal "Supermarket"; matching only the + OSM strings counts the supermarket but drops the brand store. + """ + postcodes = pl.DataFrame( + { + "postcode": ["SW1A 1AA"], + "lat": [51.5010], + "lon": [-0.1416], + } + ) + pois = pl.DataFrame( + { + "category": ["Tesco", "Supermarket"], + "group": ["Groceries", "Groceries"], + "lat": [51.5011, 51.5012], + "lng": [-0.1417, -0.1418], + } + ) + + groups_2km = {**POI_GROUPS_2KM, "groceries": _groceries_categories(pois)} + result = count_pois_per_postcode(postcodes, pois, groups=groups_2km, radius_km=2) + + # Both the GEOLYTIX brand ("Tesco") and the OSM "Supermarket" must count. + # Pre-fix the static list was ["Greengrocer", "Supermarket", "Convenience + # Store"], so "Tesco" was dropped and this was 1. + assert result["groceries_2km"][0] == 2 def test_dynamic_poi_groups_include_requested_categories_only() -> None: diff --git a/pipeline/transform/test_school_proximity.py b/pipeline/transform/test_school_proximity.py new file mode 100644 index 0000000..9d94b8d --- /dev/null +++ b/pipeline/transform/test_school_proximity.py @@ -0,0 +1,82 @@ +import polars as pl + +from pipeline.transform.school_proximity import classify_good_plus_schools + + +def _school(phase, oeif, ungraded, postcode="AA1 1AA"): + return { + "Postcode": postcode, + "Ofsted phase": phase, + "Latest OEIF overall effectiveness": oeif, + "Ungraded inspection overall outcome": ungraded, + } + + +def _classify(rows): + result = classify_good_plus_schools(pl.DataFrame(rows)) + return {(r["postcode"], r["category"]) for r in result.to_dicts()} + + +def test_legacy_oeif_grades_1_and_2_are_kept(): + rows = [ + _school("Primary", "1", None, "AA1 1AA"), + _school("Primary", "2", None, "AA1 1AB"), + _school("Secondary", "1", None, "AA1 1AC"), + _school("Secondary", "2", None, "AA1 1AD"), + ] + assert _classify(rows) == { + ("AA1 1AA", "outstanding_primary"), + ("AA1 1AB", "good_primary"), + ("AA1 1AC", "outstanding_secondary"), + ("AA1 1AD", "good_secondary"), + } + + +def test_grades_3_and_4_are_excluded(): + rows = [_school("Primary", "3", None), _school("Primary", "4", None)] + assert _classify(rows) == set() + + +def test_ungraded_remains_good_is_recovered_when_no_graded_result(): + # Null and "Not judged" OEIF fall back to the ungraded outcome. + rows = [ + _school("Primary", None, "School remains Good", "AA1 1AA"), + _school("Secondary", "Not judged", "School remains Outstanding", "AA1 1AB"), + # "(Concerns)"/"(Improving)" variants are still good+. + _school("Primary", None, "School remains Good (Concerns) - S5 Next", "AA1 1AC"), + _school( + "Secondary", + None, + "School remains Outstanding (Concerns) - S5 Next", + "AA1 1AD", + ), + ] + assert _classify(rows) == { + ("AA1 1AA", "good_primary"), + ("AA1 1AB", "outstanding_secondary"), + ("AA1 1AC", "good_primary"), + ("AA1 1AD", "outstanding_secondary"), + } + + +def test_ungraded_non_good_outcomes_are_excluded(): + rows = [ + _school("Primary", None, "Some aspects not as strong"), + _school("Primary", None, "Standards maintained"), + _school("Primary", None, None), + ] + assert _classify(rows) == set() + + +def test_genuine_grade_3_is_not_overridden_by_stale_remains_good(): + # A real grade 3 must not be promoted by an ungraded "remains Good". + rows = [_school("Primary", "3", "School remains Good")] + assert _classify(rows) == set() + + +def test_non_primary_secondary_phases_excluded(): + rows = [ + _school("Nursery", "1", None), + _school("Not applicable", "2", None), + ] + assert _classify(rows) == set() diff --git a/pipeline/transform/test_transform_poi.py b/pipeline/transform/test_transform_poi.py index c9a067f..023d464 100644 --- a/pipeline/transform/test_transform_poi.py +++ b/pipeline/transform/test_transform_poi.py @@ -1,6 +1,113 @@ +import json + import polars as pl -from pipeline.transform.transform_poi import transform_grocery_retail_points +from pipeline.transform.transform_poi import ( + _load_ofsted_ratings, + _school_icon_category_expr, + transform, + transform_grocery_retail_points, +) + + +def _write_boundary(tmp_path): + """A FeatureCollection whose single feature covers the London-area test + coords used by the transform() fixtures, so in_england_mask keeps them.""" + boundary_path = tmp_path / "england.geojson" + coords = [[-1.0, 51.0], [1.0, 51.0], [1.0, 52.0], [-1.0, 52.0], [-1.0, 51.0]] + boundary_path.write_text( + json.dumps( + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": {}, + "geometry": {"type": "Polygon", "coordinates": [coords]}, + } + ], + } + ) + ) + return boundary_path + + +def _write_transform_inputs(tmp_path, raw_pois: pl.DataFrame): + """Materialise the parquet inputs transform() requires around a given raw + OSM POIs frame. NaPTAN / grocery / GIAS / Ofsted are minimal but valid.""" + input_path = tmp_path / "pois.parquet" + raw_pois.write_parquet(input_path) + + naptan_path = tmp_path / "naptan.parquet" + pl.DataFrame( + { + "id": ["naptan-1"], + "name": ["Test Rail Station"], + "category": ["Rail station"], + "lat": [51.51], + "lng": [-0.13], + } + ).write_parquet(naptan_path) + + grocery_path = tmp_path / "grocery.parquet" + pl.DataFrame( + { + "id": list(range(1, 6)), + "retailer": ["Tesco"] * 5, + "fascia": ["Tesco"] * 5, + "store_name": [f"Tesco Test {i}" for i in range(1, 6)], + "long_wgs": [-0.14] * 5, + "lat_wgs": [51.52] * 5, + } + ).write_parquet(grocery_path) + + gias_path = tmp_path / "gias.parquet" + pl.DataFrame( + { + "urn": [1001], + "name": ["Test Primary School"], + "phase": ["Primary"], + "type": ["Community school"], + "type_group": ["Local authority maintained schools"], + "age_range": ["4–11"], + "gender": ["Mixed"], + "religious_character": [None], + "admissions_policy": ["Comprehensive"], + "nursery_provision": ["No"], + "sixth_form": ["No"], + "capacity": [200], + "pupils": [180], + "fsm_percent": [12.5], + "trust": [None], + "address": ["1 Test Street"], + "postcode": ["E1 1AA"], + "local_authority": ["Test LA"], + "website": [None], + "telephone": ["02012345678"], + "head_name": ["Jane Doe"], + "lat": [51.53], + "lng": [-0.12], + } + ).write_parquet(gias_path) + + ofsted_path = tmp_path / "ofsted.parquet" + pl.DataFrame( + { + "URN": [1001], + "Latest OEIF overall effectiveness": ["2"], + "Ungraded inspection overall outcome": [None], + } + ).write_parquet(ofsted_path) + + boundary_path = _write_boundary(tmp_path) + return { + "input_path": input_path, + "naptan_path": naptan_path, + "boundary_path": boundary_path, + "grocery_retail_points_path": grocery_path, + "gias_path": gias_path, + "ofsted_path": ofsted_path, + } def test_transform_grocery_retail_points_outputs_chain_categories(): @@ -112,6 +219,33 @@ def test_transform_grocery_retail_points_merges_cooperative_societies(): ] +def test_transform_grocery_retail_points_pools_small_coop_societies_before_cutoff(): + # Each Co-op society has <5 in-England stores; only after normalising to the + # shared "Co-op" brand do they clear MIN_GROCERY_CHAIN_LOCATIONS together. + societies = [ + "Central England Co-operative", + "Lincolnshire Co-operative", + "The Southern Co-operative", + "Midcounties Co-operative", + "Heart of England Co-operative", + ] + raw = pl.DataFrame( + { + "id": list(range(1, len(societies) + 1)), + "retailer": societies, + "fascia": ["The Co-operative Food"] * len(societies), + "store_name": [f"Co-op Test {i}" for i in range(1, len(societies) + 1)], + "long_wgs": [-0.141] * len(societies), + "lat_wgs": [51.515] * len(societies), + } + ) + + pois = transform_grocery_retail_points(raw) + + assert pois.height == len(societies) + assert pois["category"].unique().to_list() == ["Co-op"] + + def test_transform_grocery_retail_points_accepts_base_fascias(): raw = pl.DataFrame( { @@ -177,3 +311,163 @@ def test_transform_grocery_retail_points_includes_unmapped_chains_with_five_loca {"id": "glx-4", "category": "Tian Tian", "icon_category": "Tian Tian"}, {"id": "glx-5", "category": "Tian Tian", "icon_category": "Tian Tian"}, ] + + +def test_load_ofsted_ratings_falls_back_to_ungraded_outcome(tmp_path): + # URNs 1-4: graded results map straight through. URNs 5-6: no usable graded + # grade (null/"Not judged") but a good/outstanding ungraded outcome, incl. + # the "(Concerns)"/"(Improving)" suffixes. URN 7: genuinely "Not judged". + # URN 8: a real grade 3 must NOT be overridden by an ungraded outcome. + ofsted_path = tmp_path / "ofsted.parquet" + pl.DataFrame( + { + "URN": [1, 2, 3, 4, 5, 6, 7, 8], + "Latest OEIF overall effectiveness": [ + "1", + "2", + "3", + "4", + None, + "Not judged", + "Not judged", + "3", + ], + "Ungraded inspection overall outcome": [ + None, + None, + None, + None, + "School remains Outstanding", + "School remains Good (Concerns)", + None, + "School remains Outstanding", + ], + } + ).write_parquet(ofsted_path) + + ratings = ( + _load_ofsted_ratings(ofsted_path) + .collect() + .sort("urn") + .to_dicts() + ) + + assert ratings == [ + {"urn": 1, "ofsted_rating": "Outstanding"}, + {"urn": 2, "ofsted_rating": "Good"}, + {"urn": 3, "ofsted_rating": "Requires improvement"}, + {"urn": 4, "ofsted_rating": "Inadequate"}, + {"urn": 5, "ofsted_rating": "Outstanding"}, + {"urn": 6, "ofsted_rating": "Good"}, + {"urn": 7, "ofsted_rating": "Not judged"}, + {"urn": 8, "ofsted_rating": "Requires improvement"}, + ] + + +def test_school_icon_category_handles_one_sided_age_ranges(): + # gias._format_age_range emits "up to {high}", "{low}+" and "{low}–{high}". + # All three (plus null) must classify, not fall through to "School". + df = pl.DataFrame( + { + "phase": [None, None, None, None, None], + "type_group": [None, None, None, None, None], + # "up to 5" -> nursery; "16+" -> sixth form; "3–18" -> all-through; + # "4–11" -> primary; null age_range with null phase -> "School". + "age_range": ["up to 5", "16+", "3–18", "4–11", None], + }, + # Production reads these from a scanned parquet as String; an all-null + # Python list would otherwise infer the Null dtype and break .str ops. + schema_overrides={ + "phase": pl.String, + "type_group": pl.String, + "age_range": pl.String, + }, + ) + + categories = df.select( + _school_icon_category_expr().alias("category") + )["category"].to_list() + + assert categories == [ + "Nursery school", + "Sixth form", + "All-through school", + "Primary school", + "School", + ] + + +def test_transform_dedupes_multi_tag_pois(tmp_path): + # One OSM object can carry several tag keys that map to the SAME friendly + # category, so pois.py emits one raw row per key with the SAME id. + # "amenity/pharmacy" and "shop/chemist" both map to "Pharmacy". + raw = pl.DataFrame( + { + "id": ["n42", "n42"], + "name": ["Boots", "Boots"], + "category": ["amenity/pharmacy", "shop/chemist"], + "lat": [51.50, 51.50], + "lng": [-0.10, -0.10], + } + ) + inputs = _write_transform_inputs(tmp_path, raw) + + out = transform(**inputs).collect() + + # No (id, category) pair appears more than once. + assert out.group_by("id", "category").len()["len"].max() == 1 + # The single physical pharmacy is present exactly once. + pharmacies = out.filter( + (pl.col("id") == "n42") & (pl.col("category") == "Pharmacy") + ) + assert pharmacies.height == 1 + + +def test_osm_supermarkets_dropped(tmp_path): + # GEOLYTIX is authoritative for supermarkets; an OSM "shop/supermarket" row + # must not flow through as a second Groceries/Supermarket pin. A + # complementary grocery category (Convenience Store) must still survive. + raw = pl.DataFrame( + { + "id": ["n1", "n2"], + "name": ["Some Supermarket", "Corner Shop"], + "category": ["shop/supermarket", "shop/convenience"], + "lat": [51.50, 51.51], + "lng": [-0.10, -0.11], + } + ) + inputs = _write_transform_inputs(tmp_path, raw) + + out = transform(**inputs).collect() + + osm_supermarkets = out.filter( + (pl.col("group") == "Groceries") & (pl.col("category") == "Supermarket") + ) + assert osm_supermarkets.height == 0 + # Complementary OSM grocery category survives. + convenience = out.filter(pl.col("category") == "Convenience Store") + assert convenience.height == 1 + + +def test_transform_output_unique_per_id_category(tmp_path): + # Soundness: the full transform() output has at most one row per + # (id, category) overall, across every source. + raw = pl.DataFrame( + { + "id": ["n42", "n42", "n7", "n8"], + "name": ["Boots", "Boots", "St Mary's", "St Mary's"], + "category": [ + "amenity/pharmacy", + "shop/chemist", + "amenity/place_of_worship", + "building/church", + ], + "lat": [51.50, 51.50, 51.55, 51.55], + "lng": [-0.10, -0.10, -0.15, -0.15], + } + ) + inputs = _write_transform_inputs(tmp_path, raw) + + out = transform(**inputs).collect() + + assert out.group_by("id", "category").len()["len"].max() == 1 diff --git a/pipeline/transform/test_tree_density.py b/pipeline/transform/test_tree_density.py index a6e1b06..45d0ee5 100644 --- a/pipeline/transform/test_tree_density.py +++ b/pipeline/transform/test_tree_density.py @@ -1,19 +1,154 @@ +import math +import zipfile from pathlib import Path +import numpy as np import polars as pl +import pyogrio import pytest +import shapely from pipeline.transform.tree_density import ( - STREET_TREE_COVERAGE_COL, - STREET_TREE_DENSITY_COL, + _accumulate_clipped_area, _coverage_percentile_expr, + _finalize_metrics, + _geometry_column, + _layers, _metric_columns, + _nfi_dataset_path, + _postcode_buffers, _postcode_density_percentile_col, + _safe_extract_zip_dir, _with_postcode_density_percentiles, - _write_street_rollups, ) +def test_accumulate_clipped_area_adds_only_in_buffer_overlap() -> None: + radius_m = 50 + points = pl.DataFrame({"postcode": ["A", "B"], "x": [0.0, 1000.0], "y": [0.0, 0.0]}) + circles, tree = _postcode_buffers(points, radius_m) + buffer_area = math.pi * radius_m * radius_m + + # A large square centred on postcode A fully covers A's buffer circle. + canopy_area = np.zeros(2) + big = shapely.box(-500, -500, 500, 500) # 1,000,000 sqm parcel + _accumulate_clipped_area(np.array([big], dtype=object), circles, tree, canopy_area) + # Only the clipped circle area is added (the 32-gon buffer approximates the + # circle to ~1%), NOT the full 1,000,000 sqm polygon. + assert canopy_area[0] == pytest.approx(buffer_area, rel=1e-2) + assert canopy_area[0] <= buffer_area # never exceeds the true buffer area + assert canopy_area[1] == 0.0 # postcode B is 1km away, no overlap + + # A large parcel that only slivers into B's circle must add only the sliver, + # not its full area -- the failure mode a centroid/full-area path could not avoid. + canopy_area = np.zeros(2) + sliver = shapely.box(1040, -500, 2000, 500) # left edge 10m inside B's circle + _accumulate_clipped_area( + np.array([sliver], dtype=object), circles, tree, canopy_area + ) + assert canopy_area[0] == 0.0 + assert 0.0 < canopy_area[1] < buffer_area # tiny segment, far below 1M sqm + + +def test_accumulate_clipped_area_drops_missing_and_empty_geometry() -> None: + radius_m = 50 + points = pl.DataFrame({"postcode": ["A"], "x": [0.0], "y": [0.0]}) + circles, tree = _postcode_buffers(points, radius_m) + + canopy_area = np.zeros(1) + geoms = np.array( + [None, shapely.from_wkt("POLYGON EMPTY"), shapely.box(-10, -10, 10, 10)], + dtype=object, + ) + # A None and an empty geometry must be skipped, not crash, and only the real + # 400 sqm box is accumulated (it is fully inside the buffer). + _accumulate_clipped_area(geoms, circles, tree, canopy_area) + assert canopy_area[0] == pytest.approx(400.0) + + +def test_accumulate_clipped_area_survives_invalid_polygon() -> None: + """A self-intersecting external polygon (TOW/NFI data is occasionally invalid) + must not abort the batched overlay with 'TopologyException: side location + conflict'; its repaired in-buffer area is still accumulated.""" + radius_m = 50 + points = pl.DataFrame({"postcode": ["A"], "x": [0.0], "y": [0.0]}) + circles, tree = _postcode_buffers(points, radius_m) + + # Bow-tie centred on A: self-intersecting => invalid. The raw batched + # shapely.intersection raises 'side location conflict' on it; make_valid splits + # it into two triangles of total area 200, fully inside A's radius-50 buffer. + bowtie = shapely.Polygon([(-10, -10), (10, 10), (10, -10), (-10, 10), (-10, -10)]) + assert not shapely.is_valid(bowtie) # precondition + with pytest.raises(shapely.errors.GEOSException): # documents the raw hazard + shapely.intersection( + np.array([bowtie], dtype=object), np.array([circles[0]], dtype=object) + ) + + canopy_area = np.zeros(1) + _accumulate_clipped_area(np.array([bowtie], dtype=object), circles, tree, canopy_area) + assert canopy_area[0] == pytest.approx(200.0, rel=1e-3) + + +def test_robust_intersection_area_recovers_from_overlay_failure(monkeypatch) -> None: + """The batched-overlay fallback must absorb a GEOSException from the fast path + and recover (validate + retry), returning the correct per-pair areas. Version + independent: the fast-path failure is forced rather than data-dependent.""" + from pipeline.transform import tree_density + + real_intersection = shapely.intersection + calls = {"n": 0} + + def flaky(a, b, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: # fail only the first (fast-path) call + raise shapely.errors.GEOSException("forced side location conflict") + return real_intersection(a, b, **kwargs) + + monkeypatch.setattr(tree_density.shapely, "intersection", flaky) + + a = np.array([shapely.box(0, 0, 10, 10), shapely.box(0, 0, 4, 4)], dtype=object) + b = np.array([shapely.box(0, 0, 6, 6), shapely.box(0, 0, 2, 2)], dtype=object) + out = tree_density._robust_intersection_area(a, b) + assert calls["n"] >= 2 # fast path failed -> fallback path executed + assert out.tolist() == pytest.approx([36.0, 4.0]) + + +def test_accumulate_clipped_area_height_weighted_by_overlap() -> None: + radius_m = 50 + points = pl.DataFrame({"postcode": ["A"], "x": [0.0], "y": [0.0]}) + circles, tree = _postcode_buffers(points, radius_m) + + canopy_area = np.zeros(1) + height_weighted_sum = np.zeros(1) + height_weight = np.zeros(1) + geoms = np.array( + [ + shapely.box(-10, -10, 0, 0), # 100 sqm, fully inside + shapely.box(0, 0, 20, 20), # 400 sqm, fully inside + shapely.box(-5, 0, 0, 5), # 25 sqm, NaN height -> ignored for height + ], + dtype=object, + ) + height = np.array([5.0, 10.0, np.nan]) + + _accumulate_clipped_area( + geoms, + circles, + tree, + canopy_area, + height=height, + height_weighted_sum=height_weighted_sum, + height_weight=height_weight, + ) + + # All three clipped areas count toward canopy; only the finite-height ones + # contribute to the area-weighted mean height. + assert canopy_area[0] == pytest.approx(525.0) + assert height_weight[0] == pytest.approx(500.0) + mean_height = height_weighted_sum[0] / height_weight[0] + assert mean_height == pytest.approx((5.0 * 100 + 10.0 * 400) / 500) # 9.0 + + def test_coverage_percentile_expr_ranks_higher_coverage_higher() -> None: df = pl.DataFrame({"coverage": [0.0, 5.0, 10.0, None]}) @@ -24,76 +159,142 @@ def test_coverage_percentile_expr_ranks_higher_coverage_higher() -> None: assert result["percentile"].to_list() == [0.0, 50.0, 100.0, None] -def test_coverage_percentile_expr_uses_exact_scale_endpoints() -> None: +def test_coverage_percentile_expr_uses_tie_consistent_average_rank() -> None: + # Tied extremes share their mean rank instead of being pinned to exact 0/100, + # so the whole scale runs on one consistent average-rank formula. df = pl.DataFrame({"coverage": [0.0, 0.0, 5.0, 10.0, 10.0]}) result = df.lazy().with_columns( _coverage_percentile_expr("coverage", "percentile") ).collect() - assert result["percentile"].to_list() == [0.0, 0.0, 50.0, 100.0, 100.0] + assert result["percentile"].to_list() == [12.5, 12.5, 50.0, 87.5, 87.5] -def test_street_rollup_percentiles_are_ranked_over_raw_street_coverage( - tmp_path: Path, -) -> None: +def test_coverage_percentile_expr_all_equal_is_neutral_midpoint() -> None: + all_equal = pl.DataFrame({"coverage": [5.0, 5.0, 5.0]}) + single = pl.DataFrame({"coverage": [7.0]}) + with_null = pl.DataFrame({"coverage": [None, 5.0, 5.0, 5.0]}) + + def percentiles(df: pl.DataFrame) -> list: + return ( + df.lazy() + .with_columns(_coverage_percentile_expr("coverage", "percentile")) + .collect()["percentile"] + .to_list() + ) + + assert percentiles(all_equal) == [50.0, 50.0, 50.0] + assert percentiles(single) == [50.0] + assert percentiles(with_null) == [None, 50.0, 50.0, 50.0] + + +def test_finalize_metrics_caps_density_keeps_raw_area_and_weights_height() -> None: radius_m = 50 - density_col, area_col, count_col, height_col = _metric_columns(radius_m) + buffer_area = math.pi * radius_m * radius_m + density_col, area_col, height_col = _metric_columns(radius_m) + + points = pl.DataFrame({"postcode": ["AA1 1AA", "AA1 1AB", "AA1 1AC"]}) + canopy_area = np.array([0.0, buffer_area * 0.5, buffer_area * 2.0]) + # Postcode 0: no height samples -> null. Postcode 1: area-weighted mean = 5. + height_weighted_sum = np.array([0.0, 500.0, 0.0]) + height_weight = np.array([0.0, 100.0, 0.0]) + + metrics = _finalize_metrics( + points, canopy_area, height_weighted_sum, height_weight, radius_m + ) + + assert metrics[density_col].to_list() == [0.0, 50.0, 100.0] # capped at 100 + # area_col is the raw clipped accumulation, intentionally uncapped. + assert metrics[area_col].to_list() == pytest.approx( + [0.0, round(buffer_area * 0.5, 1), round(buffer_area * 2.0, 1)] + ) + assert metrics[height_col].to_list() == [None, 5.0, None] + # The mixed-unit feature-count column has been removed entirely. + assert "Tree features within 50m" not in metrics.columns + assert set(metrics.columns) == {"postcode", density_col, area_col, height_col} + + +def test_postcode_density_percentiles_rank_over_density() -> None: + radius_m = 50 + density_col, area_col, height_col = _metric_columns(radius_m) percentile_col = _postcode_density_percentile_col(radius_m) - postcode_metrics = _with_postcode_density_percentiles( + metrics = _with_postcode_density_percentiles( pl.DataFrame( { "postcode": ["AA1 1AA", "AA1 1AB", "AA1 1AC"], density_col: [10.0, 30.0, 50.0], area_col: [100.0, 300.0, 500.0], - count_col: [1, 3, 5], height_col: [4.0, 6.0, 8.0], } ), radius_m, ) - price_paid = pl.DataFrame( - { - "postcode": ["AA1 1AA", "AA1 1AA", "AA1 1AB", "AA1 1AC"], - "paon": ["1", "2", "3", "4"], - "saon": ["", "", "", ""], - "street": ["Oak Road", "Oak Road", "Oak Road", "Elm Street"], - "locality": ["", "", "", ""], - "town_city": ["Test Town", "Test Town", "Test Town", "Test Town"], - "district": ["Test District"] * 4, - "county": ["Test County"] * 4, - "date_of_transfer": [ - "2024-01-01", - "2024-01-02", - "2024-01-03", - "2024-01-04", - ], - } + assert percentile_col in metrics.columns + assert metrics[percentile_col].to_list() == [0.0, 50.0, 100.0] + + +def test_safe_extract_zip_dir_rejects_path_traversal(tmp_path: Path) -> None: + malicious = tmp_path / "evil.zip" + with zipfile.ZipFile(malicious, "w") as archive: + archive.writestr("../escape.txt", "pwned") + + with pytest.raises(ValueError, match="Unsafe path"): + _safe_extract_zip_dir(malicious, tmp_path / "extract", force=True) + + +def test_safe_extract_zip_dir_extracts_benign_archive(tmp_path: Path) -> None: + benign = tmp_path / "ok.zip" + with zipfile.ZipFile(benign, "w") as archive: + archive.writestr("data/x.txt", "hello") + + extract_dir = tmp_path / "extract" + result = _safe_extract_zip_dir(benign, extract_dir, force=True) + assert result == extract_dir + assert (extract_dir / "data" / "x.txt").read_text() == "hello" + + +def test_geometry_column_resolution() -> None: + assert _geometry_column({"geometry_name": "SHAPE"}, ["MEANHT", "SHAPE"]) == "SHAPE" + assert _geometry_column({}, ["a", "wkb_geometry", "b"]) == "wkb_geometry" + assert _geometry_column({"geometry_name": None}, ["x", "geom"]) == "geom" + assert _geometry_column({}, ["a", "b", "c"]) == "c" # last-column fallback + + +def _zip_with_shapefiles(zip_path: Path, names: list[str]) -> None: + with zipfile.ZipFile(zip_path, "w") as archive: + for name in names: + archive.writestr(name, "") + + +def test_nfi_dataset_path_requires_exactly_one_shapefile(tmp_path: Path) -> None: + multi = tmp_path / "multi.zip" + _zip_with_shapefiles(multi, ["a.shp", "b.shp"]) + with pytest.raises(ValueError, match="exactly one shapefile"): + _nfi_dataset_path(multi, tmp_path / "multi_x", force_extract=True, use_vsizip=False) + + none = tmp_path / "none.zip" + _zip_with_shapefiles(none, ["readme.txt"]) + with pytest.raises(FileNotFoundError): + _nfi_dataset_path(none, tmp_path / "none_x", force_extract=True, use_vsizip=False) + + one = tmp_path / "one.zip" + _zip_with_shapefiles(one, ["woodland.shp", "woodland.dbf"]) + resolved = _nfi_dataset_path( + one, tmp_path / "one_x", force_extract=True, use_vsizip=False ) - price_paid_path = tmp_path / "price-paid.parquet" - output_streets = tmp_path / "streets.parquet" - output_addresses = tmp_path / "addresses.parquet" - price_paid.write_parquet(price_paid_path) + assert resolved.endswith("woodland.shp") - _write_street_rollups( - postcode_metrics=postcode_metrics, - price_paid_path=price_paid_path, - output_streets=output_streets, - output_addresses=output_addresses, - radius_m=radius_m, + +def test_layers_selection_and_unknown(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + pyogrio, + "list_layers", + lambda _path: [("L1", "Polygon"), ("L2", "Polygon")], ) - - streets = pl.read_parquet(output_streets).sort("street") - addresses = pl.read_parquet(output_addresses) - - assert streets["street"].to_list() == ["Elm Street", "Oak Road"] - assert streets[STREET_TREE_COVERAGE_COL].to_list() == pytest.approx([50.0, 16.7]) - assert streets.select("street", STREET_TREE_DENSITY_COL).rows() == [ - ("Elm Street", 100.0), - ("Oak Road", 0.0), - ] - assert percentile_col in addresses.columns - assert STREET_TREE_COVERAGE_COL in addresses.columns - assert STREET_TREE_DENSITY_COL in addresses.columns + assert _layers("ignored", None) == ["L1", "L2"] + assert _layers("ignored", ("L2",)) == ["L2"] + with pytest.raises(ValueError, match="Unknown TOW layer"): + _layers("ignored", ("L3",)) diff --git a/pipeline/transform/transform_poi.py b/pipeline/transform/transform_poi.py index 72703aa..7bfa304 100644 --- a/pipeline/transform/transform_poi.py +++ b/pipeline/transform/transform_poi.py @@ -6,6 +6,10 @@ import polars as pl from pipeline.utils.england_geometry import in_england_mask DROP_CATEGORIES = { + # GEOLYTIX Grocery Retail Points is the authoritative supermarket source + # (transform_grocery_retail_points), so drop OSM supermarkets to avoid + # double-counting each store as both a GEOLYTIX brand and an OSM "Supermarket". + "shop/supermarket", # Street furniture & infrastructure "amenity/advice", "amenity/atm", @@ -364,14 +368,6 @@ _CATEGORIES: list[tuple[str, str, str, list[str]]] = [ "leisure/yes", ], ), - ( - "Groceries", - "Supermarket", - "🛒", - [ - "shop/supermarket", - ], - ), ( "Groceries", "Convenience Store", @@ -1289,22 +1285,27 @@ def transform_grocery_retail_points( ) df = df.filter(pl.Series(mask)) - eligible_retailers = ( - df.group_by("retailer") + # Normalise to the display brand FIRST so the ~16 Co-op society retailer + # names pool into one "Co-op" before the chain-eligibility cutoff; otherwise + # small societies (<MIN_GROCERY_CHAIN_LOCATIONS stores each) get dropped. + df = df.with_columns( + pl.col("retailer") + .map_elements(normalize_grocery_retailer, return_dtype=pl.String) + .alias("category") + ) + eligible_categories = ( + df.group_by("category") .len() .filter(pl.col("len") >= min_chain_locations) - .select("retailer") + .select("category") ) - df = df.join(eligible_retailers, on="retailer", how="semi") + df = df.join(eligible_categories, on="category", how="semi") return df.with_columns( pl.concat_str([pl.lit("glx-"), pl.col("id")]).alias("id"), pl.coalesce(["store_name", "fascia", "retailer"]) .str.replace_all("''", "'") .alias("name"), - pl.col("retailer") - .map_elements(normalize_grocery_retailer, return_dtype=pl.String) - .alias("category"), pl.struct(["fascia", "retailer"]) .map_elements( lambda row: normalize_grocery_icon_category(row["fascia"], row["retailer"]), @@ -1338,10 +1339,16 @@ def _school_icon_category_expr() -> pl.Expr: # GIAS phase mixes casing ("Middle deemed Primary" vs "Middle deemed # primary") so we normalise before matching. phase = pl.col("phase").str.to_lowercase() - # age_range is "<min>–<max>" using an em-dash; both ends may be missing. - age_parts = pl.col("age_range").str.split_exact("–", 1) - min_age = age_parts.struct.field("field_0").cast(pl.Int32, strict=False) - max_age = age_parts.struct.field("field_1").cast(pl.Int32, strict=False) + # gias._format_age_range emits three shapes: "<low>–<high>" (em-dash), + # "up to <high>" (high-only) and "<low>+" (low-only). Extract the leading + # integer as low and the trailing integer as high, then suppress the wrong + # end for the one-sided shapes so they don't collapse to a single bound. + age = pl.col("age_range") + leading = age.str.extract(r"^\s*(\d+)", 1).cast(pl.Int32, strict=False) + trailing = age.str.extract(r"(\d+)\s*$", 1).cast(pl.Int32, strict=False) + # "up to N": no low bound; "N+": no high bound. + min_age = pl.when(age.str.starts_with("up to")).then(None).otherwise(leading) + max_age = pl.when(age.str.ends_with("+")).then(None).otherwise(trailing) return ( pl.when(pl.col("type_group") == "Universities") .then(pl.lit("University")) @@ -1386,9 +1393,16 @@ OFSTED_OEIF_LABELS = { def _load_ofsted_ratings(ofsted_path: Path) -> pl.LazyFrame: """Project the latest OEIF effectiveness grade to a human-readable label, keyed by URN so it can be joined onto the GIAS register. Grades 1-4 map to - the conventional Ofsted labels; "Not judged" (post-2025 reform schools that - only have a report card) is preserved verbatim; null grades drop out.""" + the conventional Ofsted labels; when there is no usable graded result + (null/"Not judged", e.g. schools last seen under the post-2024 ungraded + report-card framework) we fall back to "Ungraded inspection overall outcome" + so genuinely good/outstanding schools aren't dropped — mirroring + school_proximity.classify_good_plus_schools. Remaining nulls drop out.""" grade_col = pl.col("Latest OEIF overall effectiveness") + # See school_proximity: the ungraded outcome carries "School remains Good"/ + # "School remains Outstanding" (with optional "(Concerns)"/"(Improving)" + # suffixes) when the graded column is null/"Not judged". + ungraded = pl.col("Ungraded inspection overall outcome").cast(pl.Utf8, strict=False) label = ( pl.when(grade_col == "1") .then(pl.lit(OFSTED_OEIF_LABELS["1"])) @@ -1398,6 +1412,10 @@ def _load_ofsted_ratings(ofsted_path: Path) -> pl.LazyFrame: .then(pl.lit(OFSTED_OEIF_LABELS["3"])) .when(grade_col == "4") .then(pl.lit(OFSTED_OEIF_LABELS["4"])) + .when(ungraded.str.starts_with("School remains Outstanding")) + .then(pl.lit(OFSTED_OEIF_LABELS["1"])) + .when(ungraded.str.starts_with("School remains Good")) + .then(pl.lit(OFSTED_OEIF_LABELS["2"])) .when(grade_col == "Not judged") .then(pl.lit("Not judged")) .otherwise(None) @@ -1512,6 +1530,14 @@ def transform( pl.col("category").replace_strict(emoji_mapping).alias("emoji"), ) + # A single OSM object can carry several tag keys that map to the same + # friendly category (e.g. amenity/pharmacy + shop/chemist -> "Pharmacy"), + # which pois.py emits as multiple raw rows sharing one id. Collapse those + # duplicates so they don't inflate downstream proximity counts; rows sharing + # an id with DIFFERENT categories are preserved. Other sources are + # pre-deduplicated. + lf = lf.unique(subset=["id", "category"], keep="first", maintain_order=True) + naptan_df = pl.scan_parquet(naptan_path).collect() mask = in_england_mask( boundary_path, diff --git a/pipeline/transform/tree_density.py b/pipeline/transform/tree_density.py index 146e7d2..75ba03e 100644 --- a/pipeline/transform/tree_density.py +++ b/pipeline/transform/tree_density.py @@ -1,10 +1,28 @@ -"""Derive street-scale tree density metrics from Forest Research TOW data. +"""Derive postcode-scale tree density metrics from Forest Research TOW + NFI data. The Forest Research Trees Outside Woodland release is an Esri File Geodatabase inside property-data/FR_TOW_V1_ALL.zip. This transformer computes a compact -postcode-level metric from the tree polygons, then optionally rolls that up to -Price Paid street names so the dashboard can answer "what is this address's -street like?" without loading the full geodatabase at runtime. +postcode-level metric from the tree polygons so the dashboard can answer "how +green is this postcode?" without loading the full geodatabase at runtime. + +Every postcode centroid is expanded into a radius-r buffer ("extended area"). +Both TOW tree crowns and National Forest Inventory (NFI) woodland parcels are +accumulated by *true buffer-clipped intersection area*: only the part of each +polygon that falls inside a postcode's buffer is counted, never the area that +spills outside it. A crown straddling the buffer edge therefore contributes only +its inside portion, and a parcel reaching into the buffer from outside is still +counted -- no polygon can saturate a postcode from mere proximity. + +TOW only covers trees *outside* woodland, so the NFI woodland layer is the +geometric complement of TOW and is optionally unioned in. The two products are +*assumed disjoint*: clipped TOW crown area and clipped NFI woodland area are +summed into the same per-postcode accumulator, so any spatial overlap between a +TOW crown and an NFI parcel (boundary slop where "groups of trees" meet +"woodland") would be double-counted. The final density is capped at 100% and +_finalize_metrics logs how many postcodes exceed 100% raw coverage, which is a +direct symptom of such overlap (or of overlapping crowns within one buffer); if +that count is material the products are not disjoint and the NFI clip should be +taken against the complement of TOW. """ from __future__ import annotations @@ -19,19 +37,22 @@ import numpy as np import polars as pl import pyogrio import shapely -from scipy.spatial import cKDTree -DEFAULT_TOW_TYPES = ("Lone Tree", "Group of Trees") TOW_GDB_NAME = "FR_TOW_V1_ALL.gdb" -STREET_TREE_DENSITY_COL = "Street tree density percentile" -STREET_TREE_COVERAGE_COL = "Street tree coverage (%)" POSTCODE_DENSITY_COL = "Tree canopy density within {radius}m (%)" POSTCODE_DENSITY_PERCENTILE_COL = "Tree canopy density percentile within {radius}m" POSTCODE_AREA_COL = "Tree canopy area within {radius}m (sqm)" -POSTCODE_COUNT_COL = "Tree features within {radius}m" POSTCODE_HEIGHT_COL = "Mean TOW height within {radius}m (m)" +# National Forest Inventory (NFI) woodland — the geometric complement of TOW. +# NFI ships as a zipped shapefile of woodland parcels (>=0.5 ha) in EPSG:27700. +# Field names are from the NFI Woodland England 2022 release; re-check on bumps. +NFI_CATEGORY_COL = "CATEGORY" +NFI_WOODLAND_VALUE = "Woodland" +NFI_TYPE_COL = "IFT_IOA" +NFI_AREA_HA_COL = "Area_ha" + def _safe_extract_zip(zip_path: Path, extract_dir: Path, force: bool) -> Path: """Extract the TOW zip and return the extracted .gdb path.""" @@ -83,12 +104,71 @@ def _tow_dataset_path( return str(_safe_extract_zip(zip_path, extract_dir, force_extract)) -def _where_for_tow_types(tow_types: tuple[str, ...] | None) -> str | None: - if not tow_types: - return None - escaped = [tow_type.replace("'", "''") for tow_type in tow_types] - values = ", ".join(f"'{tow_type}'" for tow_type in escaped) - return f"Woodland_Type IN ({values})" +def _safe_extract_zip_dir(zip_path: Path, extract_dir: Path, force: bool) -> Path: + """Extract an arbitrary zip into extract_dir and return the directory.""" + if extract_dir.exists() and not force: + print(f"Using existing extraction directory: {extract_dir}") + return extract_dir + if extract_dir.exists(): + shutil.rmtree(extract_dir) + + tmp_dir = extract_dir.with_name(f".{extract_dir.name}.tmp") + if tmp_dir.exists(): + shutil.rmtree(tmp_dir) + tmp_dir.mkdir(parents=True) + + root = tmp_dir.resolve() + print(f"Extracting {zip_path} to {extract_dir}...") + with zipfile.ZipFile(zip_path) as archive: + for member in archive.infolist(): + target = (tmp_dir / member.filename).resolve() + if root != target and root not in target.parents: + raise ValueError(f"Unsafe path in zip archive: {member.filename}") + if member.is_dir(): + target.mkdir(parents=True, exist_ok=True) + continue + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, target.open("wb") as dest: + shutil.copyfileobj(source, dest, length=1024 * 1024) + + tmp_dir.rename(extract_dir) + print(f"Extracted archive: {extract_dir}") + return extract_dir + + +def _nfi_dataset_path( + zip_path: Path, extract_dir: Path, force_extract: bool, use_vsizip: bool +) -> str: + """Resolve the NFI woodland shapefile path, extracting the zip if needed. + + Raises if the archive contains zero or more than one shapefile rather than + silently picking one, so an ambiguous NFI release fails loudly instead of + accumulating canopy from the wrong layer. + """ + if use_vsizip: + return f"/vsizip/{zip_path.resolve()}" + extracted = _safe_extract_zip_dir(zip_path, extract_dir, force_extract) + shapefiles = sorted(extracted.rglob("*.shp")) + if not shapefiles: + raise FileNotFoundError(f"No .shp found inside {zip_path}") + if len(shapefiles) > 1: + names = ", ".join(path.name for path in shapefiles) + raise ValueError( + f"Expected exactly one shapefile inside {zip_path}, found {len(shapefiles)} " + f"({names}); cannot unambiguously pick the NFI woodland layer" + ) + return str(shapefiles[0]) + + +def _geometry_column(metadata: dict, column_names: list[str]) -> str: + """Resolve the geometry column name from pyogrio Arrow metadata.""" + geometry_name = metadata.get("geometry_name") + if geometry_name: + return str(geometry_name) + for name in ("wkb_geometry", "geometry", "geom", "SHAPE"): + if name in column_names: + return name + return column_names[-1] def _postcode_points(arcgis_path: Path, max_postcodes: int | None) -> pl.DataFrame: @@ -123,11 +203,10 @@ def _layers(dataset_path: str, selected_layers: tuple[str, ...] | None) -> list[ return [layer for layer in available if layer in selected_layers] -def _metric_columns(radius_m: int) -> tuple[str, str, str, str]: +def _metric_columns(radius_m: int) -> tuple[str, str, str]: return ( POSTCODE_DENSITY_COL.format(radius=radius_m), POSTCODE_AREA_COL.format(radius=radius_m), - POSTCODE_COUNT_COL.format(radius=radius_m), POSTCODE_HEIGHT_COL.format(radius=radius_m), ) @@ -137,20 +216,23 @@ def _postcode_density_percentile_col(radius_m: int) -> str: def _coverage_percentile_expr(column: str, alias: str) -> pl.Expr: - """Rank higher tree coverage higher on a 0-100 England-wide percentile scale.""" + """Rank tree coverage on a 0-100 England-wide percentile scale. + + A single tie-consistent average-rank formula is used for every value so the + scale is internally consistent end to end: tied values share their mean rank, + so the lowest coverage maps toward 0 and the highest toward 100 only when they + are not themselves tied. An all-equal (or single-value) column has no spread + and maps to the neutral midpoint (50). + """ value = pl.col(column).fill_nan(None) non_null_count = value.count() rank = value.rank("average") return ( pl.when(value.is_null()) .then(None) - .when(value == value.min()) - .then(0.0) - .when(value == value.max()) - .then(100.0) .when(non_null_count > 1) .then(((rank - 1) / (non_null_count - 1) * 100).round(1)) - .otherwise(100.0) + .otherwise(50.0) .cast(pl.Float32) .alias(alias) ) @@ -159,7 +241,7 @@ def _coverage_percentile_expr(column: str, alias: str) -> pl.Expr: def _with_postcode_density_percentiles( postcode_metrics: pl.DataFrame, radius_m: int ) -> pl.DataFrame: - density_col, _area_col, _count_col, _height_col = _metric_columns(radius_m) + density_col, _area_col, _height_col = _metric_columns(radius_m) return postcode_metrics.with_columns( _coverage_percentile_expr( density_col, @@ -168,34 +250,125 @@ def _with_postcode_density_percentiles( ) -def _accumulate_tree_metrics( +def _postcode_buffers( + points: pl.DataFrame, radius_m: int +) -> tuple[np.ndarray, shapely.STRtree]: + """Build a radius-r circle for every postcode plus an STRtree over them. + + Circle index == postcode index, so an STRtree match resolves directly to the + postcode accumulator slot. + """ + xy = points.select("x", "y").to_numpy() + circles = shapely.buffer(shapely.points(xy), radius_m, quad_segs=8) + return circles, shapely.STRtree(circles) + + +# 0.1 mm in the BNG working CRS (EPSG:27700) — far below survey resolution; the +# same grid the postcode_boundaries overlay uses. +_OVERLAY_GRID_M = 1e-4 + + +def _robust_intersection_area(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Vectorized ``area(a[i] ∩ b[i])`` that survives GEOS robustness failures. + + External Forest Research TOW/NFI polygons are occasionally invalid + (self-intersections), and a single bad polygon makes the batched + ``shapely.intersection`` raise ``TopologyException: side location conflict``, + aborting the whole run. The fast path is the raw batched overlay — unchanged, + full-speed, when the data is clean — and only a failure triggers repair. + + The repair deliberately uses a *plain* overlay rather than the fixed-precision + (``grid_size``) one: ``make_valid`` can emit a mixed-dimension + ``GeometryCollection`` (a polygon plus a dangling line), which OverlayNG + rejects with ``Overlay input is mixed-dimension`` — whereas a plain overlay + accepts it, and its non-polygonal debris has zero area and is dropped by the + ``clipped_area > 0`` filter downstream anyway. A final pointwise coordinate + snap (which never raises) collapses the near-coincident edges behind any + residual full-precision robustness failure. + """ + try: + return shapely.area(shapely.intersection(a, b)) + except shapely.errors.GEOSException: + pass + a = shapely.make_valid(a) + b = shapely.make_valid(b) + try: + return shapely.area(shapely.intersection(a, b)) + except shapely.errors.GEOSException: + a = shapely.make_valid(shapely.set_precision(a, _OVERLAY_GRID_M, mode="pointwise")) + b = shapely.make_valid(shapely.set_precision(b, _OVERLAY_GRID_M, mode="pointwise")) + return shapely.area(shapely.intersection(a, b)) + + +def _accumulate_clipped_area( + geoms: np.ndarray, + circles: np.ndarray, + tree: shapely.STRtree, + canopy_area: np.ndarray, + height: np.ndarray | None = None, + height_weighted_sum: np.ndarray | None = None, + height_weight: np.ndarray | None = None, +) -> None: + """Add each polygon's in-buffer overlap area to every postcode it intersects. + + Only area(polygon ∩ circle) is accumulated -- never the area of the polygon + that falls outside the postcode's extended buffer -- so a crown straddling + the buffer edge contributes only its inside portion and a large parcel cannot + saturate a postcode from mere proximity. When ``height`` is supplied the mean + feature height is accumulated weighted by that same clipped overlap area. + """ + keep = ~shapely.is_missing(geoms) & ~shapely.is_empty(geoms) + geoms = geoms[keep] + if height is not None: + height = height[keep] + if geoms.size == 0: + return + + # query(predicate="intersects") over the circle STRtree returns exactly the + # (polygon, circle) pairs whose clipped overlap can be positive -- i.e. the + # polygon overlaps that postcode's radius-r buffer. + geom_index, postcode_index = tree.query(geoms, predicate="intersects") + if geom_index.size == 0: + return + + clipped_area = _robust_intersection_area( + geoms[geom_index], circles[postcode_index] + ) + positive = clipped_area > 0 + geom_index = geom_index[positive] + postcode_index = postcode_index[positive] + clipped_area = clipped_area[positive] + + np.add.at(canopy_area, postcode_index, clipped_area) + + if height is not None: + feature_height = height[geom_index] + finite = np.isfinite(feature_height) + if finite.any(): + np.add.at( + height_weighted_sum, + postcode_index[finite], + feature_height[finite] * clipped_area[finite], + ) + np.add.at(height_weight, postcode_index[finite], clipped_area[finite]) + + +def _accumulate_tow_metrics( dataset_path: str, - points: pl.DataFrame, - radius_m: int, - tow_types: tuple[str, ...] | None, + circles: np.ndarray, + tree: shapely.STRtree, + canopy_area: np.ndarray, + height_weighted_sum: np.ndarray, + height_weight: np.ndarray, batch_size: int, layer_names: tuple[str, ...] | None, max_features_per_layer: int | None, - workers: int, -) -> pl.DataFrame: - xy = points.select("x", "y").to_numpy() - tree = cKDTree(xy) - n_points = points.height - - canopy_area = np.zeros(n_points, dtype=np.float64) - feature_count = np.zeros(n_points, dtype=np.uint32) - height_weighted_sum = np.zeros(n_points, dtype=np.float64) - height_weight = np.zeros(n_points, dtype=np.float64) - - where = _where_for_tow_types(tow_types) +) -> None: layers = _layers(dataset_path, layer_names) print(f"Processing {len(layers)} TOW layer(s): {', '.join(layers)}") - if where: - print(f"TOW type filter: {where}") - columns = ["Woodland_Type", "TOW_Area_M", "MEANHT"] + columns = ["MEANHT"] total_features_seen = 0 - total_features_used = 0 for layer in layers: info = pyogrio.read_info(dataset_path, layer=layer) @@ -206,10 +379,9 @@ def _accumulate_tree_metrics( dataset_path, layer=layer, columns=columns, - where=where, batch_size=batch_size, use_pyarrow=True, - ) as (_meta, reader): + ) as (meta, reader): for batch_index, batch in enumerate(reader, start=1): if max_features_per_layer is not None: remaining = max_features_per_layer - layer_features_seen @@ -221,85 +393,112 @@ def _accumulate_tree_metrics( layer_features_seen += batch.num_rows total_features_seen += batch.num_rows names = batch.schema.names - area = np.asarray( - batch.column(names.index("TOW_Area_M")).to_numpy(zero_copy_only=False), - dtype=np.float64, - ) + geometry_column = _geometry_column(meta, names) height = np.asarray( batch.column(names.index("MEANHT")).to_numpy(zero_copy_only=False), dtype=np.float64, ) geometry = np.asarray( - batch.column(names.index("SHAPE")).to_numpy(zero_copy_only=False), + batch.column(names.index(geometry_column)).to_numpy( + zero_copy_only=False + ), dtype=object, ) - - valid = np.isfinite(area) & (area > 0) - if not valid.any(): - continue - - geometry = geometry[valid] - area = area[valid] - height = height[valid] - - centroids = shapely.centroid(shapely.from_wkb(geometry)) - x = shapely.get_x(centroids) - y = shapely.get_y(centroids) - valid_xy = np.isfinite(x) & np.isfinite(y) - if not valid_xy.any(): - continue - - x = x[valid_xy] - y = y[valid_xy] - area = area[valid_xy] - height = height[valid_xy] - - nearby = tree.query_ball_point( - np.column_stack((x, y)), radius_m, workers=workers + _accumulate_clipped_area( + shapely.from_wkb(geometry), + circles, + tree, + canopy_area, + height=height, + height_weighted_sum=height_weighted_sum, + height_weight=height_weight, ) - lengths = np.fromiter( - (len(postcode_indexes) for postcode_indexes in nearby), - dtype=np.int32, - count=len(nearby), - ) - matching_features = lengths > 0 - if matching_features.any(): - postcode_indexes = np.concatenate( - [indexes for indexes in nearby if indexes] - ).astype(np.int64, copy=False) - feature_indexes = np.repeat( - np.flatnonzero(matching_features), lengths[matching_features] - ) - np.add.at(canopy_area, postcode_indexes, area[feature_indexes]) - np.add.at(feature_count, postcode_indexes, 1) - - feature_height = height[feature_indexes] - valid_height = np.isfinite(feature_height) - if valid_height.any(): - height_area = area[feature_indexes][valid_height] - np.add.at( - height_weighted_sum, - postcode_indexes[valid_height], - feature_height[valid_height] * height_area, - ) - np.add.at( - height_weight, - postcode_indexes[valid_height], - height_area, - ) - - total_features_used += len(area) if batch_index == 1 or batch_index % 25 == 0: - print( - f" batch {batch_index:,}: " - f"{total_features_seen:,} rows read, " - f"{total_features_used:,} features with usable centroids" - ) + print(f" batch {batch_index:,}: {total_features_seen:,} rows read") - density_col, area_col, count_col, height_col = _metric_columns(radius_m) + +def _accumulate_nfi_metrics( + dataset_path: str, + circles: np.ndarray, + tree: shapely.STRtree, + canopy_area: np.ndarray, + batch_size: int, + max_nfi_features: int | None, +) -> None: + layers = _layers(dataset_path, None) + print(f"Processing {len(layers)} NFI layer(s): {', '.join(layers)}") + + # Density only needs the woodland flag + geometry; area is clipped from the + # postcode buffer, not read from the file. + columns = [NFI_CATEGORY_COL] + features_seen = 0 + + for layer in layers: + with pyogrio.open_arrow( + dataset_path, + layer=layer, + columns=columns, + batch_size=batch_size, + use_pyarrow=True, + ) as (meta, reader): + for batch_index, batch in enumerate(reader, start=1): + if max_nfi_features is not None: + remaining = max_nfi_features - features_seen + if remaining <= 0: + break + if batch.num_rows > remaining: + batch = batch.slice(0, remaining) + + features_seen += batch.num_rows + names = batch.schema.names + geometry_column = _geometry_column(meta, names) + category = np.asarray( + batch.column(names.index(NFI_CATEGORY_COL)).to_numpy( + zero_copy_only=False + ), + dtype=object, + ) + geometry = np.asarray( + batch.column(names.index(geometry_column)).to_numpy( + zero_copy_only=False + ), + dtype=object, + ) + geoms = shapely.from_wkb(geometry) + _accumulate_clipped_area( + geoms[category == NFI_WOODLAND_VALUE], + circles, + tree, + canopy_area, + ) + if batch_index == 1 or batch_index % 25 == 0: + print(f" NFI batch {batch_index:,}: {features_seen:,} rows read") + + +def _finalize_metrics( + points: pl.DataFrame, + canopy_area: np.ndarray, + height_weighted_sum: np.ndarray, + height_weight: np.ndarray, + radius_m: int, +) -> pl.DataFrame: + n_points = points.height + density_col, area_col, height_col = _metric_columns(radius_m) buffer_area = math.pi * radius_m * radius_m - density_pct = np.minimum(canopy_area / buffer_area * 100.0, 100.0) + raw_density = canopy_area / buffer_area * 100.0 + density_pct = np.minimum(raw_density, 100.0) + + # Symptom of the assumed-disjoint TOW/NFI union being violated (or of + # overlapping crowns inside one buffer): clipped areas alone cannot exceed the + # buffer unless polygons overlap. Surface it rather than hide it behind the cap. + over_count = int(np.count_nonzero(raw_density > 100.0)) + if over_count: + print( + f" note: {over_count:,} postcode(s) exceeded 100% raw canopy and were " + "capped — indicates overlapping TOW/NFI canopy within the buffer" + ) + mean_height = np.divide( height_weighted_sum, height_weight, @@ -312,7 +511,6 @@ def _accumulate_tree_metrics( "postcode": points["postcode"], area_col: canopy_area.round(1).astype(np.float32), density_col: density_pct.round(1).astype(np.float32), - count_col: feature_count.astype(np.uint32), height_col: np.round(mean_height, 1).astype(np.float32), } ).with_columns( @@ -320,181 +518,9 @@ def _accumulate_tree_metrics( ) -def _clean_key_expr(column: str) -> pl.Expr: - return ( - pl.col(column) - .fill_null("") - .str.to_uppercase() - .str.replace_all(r"[^A-Z0-9]+", " ") - .str.replace_all(r"\s+", " ") - .str.strip_chars() - ) - - -def _latest_price_paid_addresses(price_paid_path: Path) -> pl.LazyFrame: - return ( - pl.scan_parquet(price_paid_path) - .select( - pl.col("postcode").str.strip_chars().str.to_uppercase().alias("postcode"), - "paon", - "saon", - "street", - "locality", - "town_city", - "district", - "county", - "date_of_transfer", - ) - .filter(pl.col("postcode").is_not_null()) - .filter(pl.col("street").is_not_null()) - .filter(_clean_key_expr("street") != "") - .with_columns( - pl.concat_str( - [pl.col("saon"), pl.col("paon"), pl.col("street")], - separator=" ", - ignore_nulls=True, - ) - .str.replace_all(r"\s+", " ") - .str.strip_chars() - .alias("pp_address"), - ) - .filter(pl.col("pp_address").is_not_null()) - .sort("date_of_transfer") - .group_by("postcode", "pp_address", maintain_order=True) - .agg( - pl.col("street").last(), - pl.col("locality").last(), - pl.col("town_city").last(), - pl.col("district").last(), - pl.col("county").last(), - ) - .with_columns( - pl.concat_str( - [ - _clean_key_expr("street"), - _clean_key_expr("town_city"), - _clean_key_expr("district"), - _clean_key_expr("county"), - ], - separator="|", - ).alias("street_key") - ) - ) - - -def _weighted_mean_expr(column: str, weight: str) -> pl.Expr: - valid = pl.col(column).is_not_null() & ~pl.col(column).is_nan() - numerator = pl.when(valid).then(pl.col(column) * pl.col(weight)).sum() - denominator = pl.when(valid).then(pl.col(weight)).sum() - return pl.when(denominator > 0).then(numerator / denominator).otherwise(None) - - -def _write_street_rollups( - postcode_metrics: pl.DataFrame, - price_paid_path: Path, - output_streets: Path | None, - output_addresses: Path | None, - radius_m: int, -) -> None: - if output_streets is None and output_addresses is None: - return - - density_col, area_col, count_col, height_col = _metric_columns(radius_m) - metrics = postcode_metrics.lazy() - addresses = _latest_price_paid_addresses(price_paid_path).join( - metrics, on="postcode", how="inner" - ) - - per_postcode = ( - addresses.group_by( - "street_key", - "postcode", - "street", - "locality", - "town_city", - "district", - "county", - ) - .agg( - pl.len().alias("address_count"), - pl.col(density_col).first(), - pl.col(area_col).first(), - pl.col(count_col).first(), - pl.col(height_col).first(), - ) - .collect() - ) - - streets = ( - per_postcode.lazy() - .group_by("street_key") - .agg( - pl.col("street").first(), - pl.col("locality").first(), - pl.col("town_city").first(), - pl.col("district").first(), - pl.col("county").first(), - pl.col("postcode").n_unique().alias("postcode_count"), - pl.col("address_count").sum().alias("address_count"), - _weighted_mean_expr(density_col, "address_count") - .round(1) - .cast(pl.Float32) - .alias(STREET_TREE_COVERAGE_COL), - _weighted_mean_expr(area_col, "address_count") - .round(1) - .cast(pl.Float32) - .alias(f"Street average {area_col}"), - _weighted_mean_expr(count_col, "address_count") - .round(1) - .cast(pl.Float32) - .alias(f"Street average {count_col}"), - _weighted_mean_expr(height_col, "address_count") - .round(1) - .cast(pl.Float32) - .alias(f"Street average {height_col}"), - ) - .with_columns( - _coverage_percentile_expr( - STREET_TREE_COVERAGE_COL, - STREET_TREE_DENSITY_COL, - ) - ) - .sort("street_key") - .collect() - ) - - if output_addresses is not None: - output_addresses.parent.mkdir(parents=True, exist_ok=True) - address_output = addresses.join( - streets.lazy().select( - "street_key", - STREET_TREE_COVERAGE_COL, - STREET_TREE_DENSITY_COL, - ), - on="street_key", - how="left", - ) - address_output.sink_parquet(output_addresses, compression="zstd") - print(f"Wrote address tree-density join: {output_addresses}") - - if output_streets is not None: - output_streets.parent.mkdir(parents=True, exist_ok=True) - streets.write_parquet(output_streets, compression="zstd") - print(f"Wrote street tree-density rollup: {output_streets}") - - -def _parse_csv_arg(value: str | None) -> tuple[str, ...] | None: - if value is None: - return None - if value.lower() == "all": - return None - parts = tuple(part.strip() for part in value.split(",") if part.strip()) - return parts or None - - def main() -> None: parser = argparse.ArgumentParser( - description="Build postcode and street tree-density metrics from FR_TOW_V1_ALL.zip" + description="Build postcode-level tree-density metrics from FR_TOW_V1_ALL.zip" ) parser.add_argument( "--tow-zip", @@ -518,46 +544,35 @@ def main() -> None: action="store_true", help="Read the geodatabase directly from the zip instead of extracting it", ) + parser.add_argument( + "--nfi-zip", + type=Path, + default=Path("property-data/NFI_WOODLAND_ENGLAND.zip"), + help="Optional NFI woodland shapefile zip to union with TOW (skipped if absent)", + ) + parser.add_argument( + "--nfi-extract-dir", + type=Path, + default=Path("property-data/nfi_woodland_england"), + help="Directory where the NFI zip is extracted", + ) parser.add_argument( "--arcgis", type=Path, default=Path("property-data/arcgis_data.parquet"), help="Postcode centroid parquet with east1m/north1m columns", ) - parser.add_argument( - "--price-paid", - type=Path, - default=None, - help="Optional Price Paid parquet used to roll postcode metrics up to streets", - ) parser.add_argument( "--output-postcodes", type=Path, required=True, help="Output postcode-level tree-density parquet", ) - parser.add_argument( - "--output-streets", - type=Path, - default=None, - help="Optional output street-level tree-density parquet", - ) - parser.add_argument( - "--output-addresses", - type=Path, - default=None, - help="Optional output address/street join parquet keyed by postcode and pp_address", - ) parser.add_argument( "--radius-m", type=int, default=50, - help="Radius around each postcode centroid used as the street-scale buffer", - ) - parser.add_argument( - "--tow-types", - default=",".join(DEFAULT_TOW_TYPES), - help='Comma-separated Woodland_Type values to include, or "all"', + help="Radius around each postcode centroid used as the extended buffer", ) parser.add_argument( "--layers", @@ -570,12 +585,6 @@ def main() -> None: default=65_536, help="Arrow batch size for reading TOW features", ) - parser.add_argument( - "--workers", - type=int, - default=-1, - help="Worker count passed to scipy cKDTree.query_ball_point", - ) parser.add_argument( "--max-postcodes", type=int, @@ -588,11 +597,14 @@ def main() -> None: default=None, help="Testing only: process at most N TOW features per layer", ) + parser.add_argument( + "--max-nfi-features", + type=int, + default=None, + help="Testing only: process at most N NFI woodland features", + ) args = parser.parse_args() - if (args.output_streets or args.output_addresses) and args.price_paid is None: - raise SystemExit("--price-paid is required when writing street/address outputs") - if args.radius_m <= 0: raise SystemExit("--radius-m must be greater than zero") @@ -600,18 +612,48 @@ def main() -> None: args.tow_zip, args.extract_dir, args.force_extract, args.use_vsizip ) points = _postcode_points(args.arcgis, args.max_postcodes) - tow_types = _parse_csv_arg(args.tow_types) layer_names = _parse_csv_arg(args.layers) - postcode_metrics = _accumulate_tree_metrics( + n_points = points.height + canopy_area = np.zeros(n_points, dtype=np.float64) + height_weighted_sum = np.zeros(n_points, dtype=np.float64) + height_weight = np.zeros(n_points, dtype=np.float64) + + circles, tree = _postcode_buffers(points, args.radius_m) + + _accumulate_tow_metrics( dataset_path=dataset_path, - points=points, - radius_m=args.radius_m, - tow_types=tow_types, + circles=circles, + tree=tree, + canopy_area=canopy_area, + height_weighted_sum=height_weighted_sum, + height_weight=height_weight, batch_size=args.batch_size, layer_names=layer_names, max_features_per_layer=args.max_features_per_layer, - workers=args.workers, + ) + + if args.nfi_zip is not None and args.nfi_zip.exists(): + nfi_path = _nfi_dataset_path( + args.nfi_zip, args.nfi_extract_dir, args.force_extract, args.use_vsizip + ) + _accumulate_nfi_metrics( + dataset_path=nfi_path, + circles=circles, + tree=tree, + canopy_area=canopy_area, + batch_size=args.batch_size, + max_nfi_features=args.max_nfi_features, + ) + elif args.nfi_zip is not None: + print(f"NFI zip not found, skipping woodland union: {args.nfi_zip}") + + postcode_metrics = _finalize_metrics( + points, + canopy_area, + height_weighted_sum, + height_weight, + args.radius_m, ) postcode_metrics = _with_postcode_density_percentiles( postcode_metrics, args.radius_m @@ -621,14 +663,14 @@ def main() -> None: postcode_metrics.write_parquet(args.output_postcodes, compression="zstd") print(f"\nWrote postcode tree-density metrics: {args.output_postcodes}") - if args.price_paid is not None: - _write_street_rollups( - postcode_metrics=postcode_metrics, - price_paid_path=args.price_paid, - output_streets=args.output_streets, - output_addresses=args.output_addresses, - radius_m=args.radius_m, - ) + +def _parse_csv_arg(value: str | None) -> tuple[str, ...] | None: + if value is None: + return None + if value.lower() == "all": + return None + parts = tuple(part.strip() for part in value.split(",") if part.strip()) + return parts or None if __name__ == "__main__": diff --git a/pipeline/transform/tree_overlay_tiles.py b/pipeline/transform/tree_overlay_tiles.py index 56ef6ee..761277d 100644 --- a/pipeline/transform/tree_overlay_tiles.py +++ b/pipeline/transform/tree_overlay_tiles.py @@ -1,4 +1,4 @@ -"""Build PMTiles polygon tiles for the Trees Outside Woodland overlay.""" +"""Build PMTiles polygon tiles for the Trees Outside Woodland + NFI overlay.""" from __future__ import annotations @@ -16,10 +16,14 @@ from pyproj import Transformer from pipeline.local_temp import local_tmp_dir from pipeline.transform.tree_density import ( - DEFAULT_TOW_TYPES, + NFI_AREA_HA_COL, + NFI_CATEGORY_COL, + NFI_TYPE_COL, + NFI_WOODLAND_VALUE, + _geometry_column, _layers, + _nfi_dataset_path, _tow_dataset_path, - _where_for_tow_types, ) @@ -55,17 +59,13 @@ def _number_or_none(value) -> float | int | None: def _write_tree_geojsonseq( dataset_path: str, output_path: Path, - tow_types: tuple[str, ...], batch_size: int, layer_names: tuple[str, ...] | None, max_features_per_layer: int | None, ) -> int: to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True) - where = _where_for_tow_types(tow_types) layers = _layers(dataset_path, layer_names) print(f"Processing {len(layers)} TOW layer(s): {', '.join(layers)}") - if where: - print(f"TOW type filter: {where}") columns = [ "TOW_ID", @@ -88,10 +88,9 @@ def _write_tree_geojsonseq( dataset_path, layer=layer, columns=columns, - where=where, batch_size=batch_size, use_pyarrow=True, - ) as (_meta, reader): + ) as (meta, reader): for batch in reader: if max_features_per_layer is not None: remaining = max_features_per_layer - layer_features_seen @@ -102,6 +101,7 @@ def _write_tree_geojsonseq( layer_features_seen += batch.num_rows names = batch.schema.names + geometry_column = _geometry_column(meta, names) area = np.asarray( batch.column(names.index("TOW_Area_M")).to_numpy( zero_copy_only=False @@ -109,7 +109,7 @@ def _write_tree_geojsonseq( dtype=np.float64, ) geometry = np.asarray( - batch.column(names.index("SHAPE")).to_numpy( + batch.column(names.index(geometry_column)).to_numpy( zero_copy_only=False ), dtype=object, @@ -136,6 +136,7 @@ def _write_tree_geojsonseq( for idx, geometry_json in zip(valid_indexes, geometries_json): properties = { + "source": "tow", "tow_id": str(tow_id[idx]) if tow_id is not None else "", "woodland_type": ( str(woodland_type[idx]) @@ -176,11 +177,105 @@ def _write_tree_geojsonseq( return feature_count +def _append_nfi_geojsonseq( + dataset_path: str, + output_path: Path, + batch_size: int, + max_nfi_features: int | None, +) -> int: + """Append NFI woodland polygons to the same GeoJSONSeq as the TOW features.""" + to_wgs84 = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True) + layers = _layers(dataset_path, None) + print(f"Processing {len(layers)} NFI layer(s): {', '.join(layers)}") + + columns = [NFI_CATEGORY_COL, NFI_TYPE_COL, NFI_AREA_HA_COL] + feature_count = 0 + features_seen = 0 + + with output_path.open("a") as file: + for layer in layers: + with pyogrio.open_arrow( + dataset_path, + layer=layer, + columns=columns, + batch_size=batch_size, + use_pyarrow=True, + ) as (meta, reader): + for batch in reader: + if max_nfi_features is not None: + remaining = max_nfi_features - features_seen + if remaining <= 0: + break + if batch.num_rows > remaining: + batch = batch.slice(0, remaining) + + features_seen += batch.num_rows + names = batch.schema.names + geometry_column = _geometry_column(meta, names) + category = np.asarray( + batch.column(names.index(NFI_CATEGORY_COL)).to_numpy( + zero_copy_only=False + ), + dtype=object, + ) + geometry = np.asarray( + batch.column(names.index(geometry_column)).to_numpy( + zero_copy_only=False + ), + dtype=object, + ) + valid = category == NFI_WOODLAND_VALUE + if not valid.any(): + continue + + woodland_type = _column_or_none(batch, names, NFI_TYPE_COL) + area_ha = _column_or_none(batch, names, NFI_AREA_HA_COL) + + geometries = shapely.from_wkb(geometry[valid]) + geometries = shapely.transform( + geometries, + to_wgs84.transform, + interleaved=False, + ) + geometries_json = shapely.to_geojson(geometries) + valid_indexes = np.flatnonzero(valid) + + for idx, geometry_json in zip(valid_indexes, geometries_json): + area_sqm = ( + _number_or_none(area_ha[idx] * 10000.0) + if area_ha is not None + else None + ) + properties = { + "source": "nfi", + "tow_id": "", + "woodland_type": ( + str(woodland_type[idx]) + if woodland_type is not None + else "" + ), + "area_sqm": area_sqm, + "mean_height_m": None, + "min_height_m": None, + "max_height_m": None, + "lidar_year": None, + "source_layer": layer, + } + feature = { + "type": "Feature", + "geometry": json.loads(geometry_json), + "properties": properties, + } + file.write(json.dumps(feature, separators=(",", ":")) + "\n") + feature_count += 1 + + return feature_count + + def build_tree_overlay_tiles( tow_zip: Path, output_path: Path, extract_dir: Path, - tow_types: tuple[str, ...], batch_size: int, layer_names: tuple[str, ...] | None, max_features_per_layer: int | None, @@ -188,6 +283,9 @@ def build_tree_overlay_tiles( max_zoom: int, force_extract: bool, use_vsizip: bool, + nfi_zip: Path | None = None, + nfi_extract_dir: Path = Path("property-data/nfi_woodland_england"), + max_nfi_features: int | None = None, ) -> None: tippecanoe = _require_tippecanoe() dataset_path = _tow_dataset_path(tow_zip, extract_dir, force_extract, use_vsizip) @@ -198,13 +296,26 @@ def build_tree_overlay_tiles( feature_count = _write_tree_geojsonseq( dataset_path, ndjson_path, - tow_types, batch_size, layer_names, max_features_per_layer, ) print(f"Writing {feature_count:,} TOW polygon features") + if nfi_zip is not None and nfi_zip.exists(): + nfi_path = _nfi_dataset_path( + nfi_zip, nfi_extract_dir, force_extract, use_vsizip + ) + nfi_count = _append_nfi_geojsonseq( + nfi_path, + ndjson_path, + batch_size, + max_nfi_features, + ) + print(f"Writing {nfi_count:,} NFI woodland polygon features") + elif nfi_zip is not None: + print(f"NFI zip not found, skipping woodland union: {nfi_zip}") + subprocess.run( [ tippecanoe, @@ -217,7 +328,7 @@ def build_tree_overlay_tiles( str(min_zoom), "--maximum-zoom", str(max_zoom), - "--drop-smallest-as-needed", + "--coalesce-smallest-as-needed", "--extend-zooms-if-still-dropping", "--temporary-directory", tmp, @@ -237,26 +348,32 @@ def main() -> None: default=Path("property-data/fr_tow_v1_all"), help="Directory used to extract the FileGDB", ) - parser.add_argument( - "--tow-type", - action="append", - dest="tow_types", - help="Woodland_Type to include; repeatable. Defaults to TOW outside-woodland classes.", - ) parser.add_argument("--batch-size", type=int, default=50_000) parser.add_argument("--layer", action="append", dest="layers") parser.add_argument("--max-features-per-layer", type=int) - parser.add_argument("--min-zoom", type=int, default=15) + parser.add_argument("--min-zoom", type=int, default=12) parser.add_argument("--max-zoom", type=int, default=17) parser.add_argument("--force-extract", action="store_true") parser.add_argument("--use-vsizip", action="store_true") + parser.add_argument( + "--nfi-zip", + type=Path, + default=None, + help="Optional NFI woodland shapefile zip to union into the overlay", + ) + parser.add_argument( + "--nfi-extract-dir", + type=Path, + default=Path("property-data/nfi_woodland_england"), + help="Directory used to extract the NFI zip", + ) + parser.add_argument("--max-nfi-features", type=int) args = parser.parse_args() build_tree_overlay_tiles( tow_zip=args.tow_zip, output_path=args.output, extract_dir=args.extract_dir, - tow_types=tuple(args.tow_types or DEFAULT_TOW_TYPES), batch_size=args.batch_size, layer_names=tuple(args.layers) if args.layers else None, max_features_per_layer=args.max_features_per_layer, @@ -264,6 +381,9 @@ def main() -> None: max_zoom=args.max_zoom, force_extract=args.force_extract, use_vsizip=args.use_vsizip, + nfi_zip=args.nfi_zip, + nfi_extract_dir=args.nfi_extract_dir, + max_nfi_features=args.max_nfi_features, ) diff --git a/pipeline/utils/__init__.py b/pipeline/utils/__init__.py index 74596c6..043291b 100644 --- a/pipeline/utils/__init__.py +++ b/pipeline/utils/__init__.py @@ -5,6 +5,7 @@ from .fuzzy_join import ( normalize_postcode_key, ) from .haversine import haversine_km, haversine_km_expr +from .nspl_schema import code_col_overrides from .poi_counts import count_pois_per_postcode from .postcode_mapping import build_postcode_mapping @@ -16,6 +17,7 @@ __all__ = [ "normalize_postcode_key", "haversine_km", "haversine_km_expr", + "code_col_overrides", "count_pois_per_postcode", "build_postcode_mapping", ] diff --git a/pipeline/utils/fuzzy_join.py b/pipeline/utils/fuzzy_join.py index 2870739..d638bc7 100644 --- a/pipeline/utils/fuzzy_join.py +++ b/pipeline/utils/fuzzy_join.py @@ -13,7 +13,12 @@ from pipeline.local_temp import local_tmp_dir _NUMBER_RE = re.compile(r"\d+") _POSTCODE_RE = r"^[A-Z]{1,2}\d[A-Z\d]?\d[A-Z]{2}$" -MIN_FUZZY_SCORE = 60 +# A house number is a strong disambiguator, so a numbered, number-compatible +# pair may match on a lower address-similarity score than a number-less one +# (named houses / flats by building name), which must match almost exactly to +# be trusted. Mirrors merge.py's listings convention. +MIN_FUZZY_SCORE = 82 +MIN_FUZZY_SCORE_WITHOUT_NUMBERS = 90 def normalize_address_key(s: pl.Expr) -> pl.Expr: @@ -47,6 +52,7 @@ def fuzzy_join_on_postcode( left_postcode_col: str, right_postcode_col: str, min_score: int = MIN_FUZZY_SCORE, + min_score_without_numbers: int = MIN_FUZZY_SCORE_WITHOUT_NUMBERS, ) -> pl.LazyFrame: """Fuzzy join two LazyFrames by matching addresses within postcode buckets. @@ -120,7 +126,12 @@ def fuzzy_join_on_postcode( # Build tasks for each postcode bucket tasks = [ - (left_entries, right_by_postcode[postcode], min_score) + ( + left_entries, + right_by_postcode[postcode], + min_score, + min_score_without_numbers, + ) for postcode, left_entries in left_by_postcode.items() if postcode in right_by_postcode ] @@ -201,16 +212,23 @@ def _numbers_compatible(a: str, b: str) -> bool: def _score_bucket( - args: tuple[list[tuple[int, str]], list[tuple[int, str]], int], + args: tuple[list[tuple[int, str]], list[tuple[int, str]], int, int], ) -> list[tuple[int, int, int]]: """Score all address pairs within a single postcode bucket.""" - left_entries, right_entries, min_score = args + left_entries, right_entries, min_score, min_score_without_numbers = args pairs = [] for left_row, left_address in left_entries: for right_row, right_address in right_entries: if not _numbers_compatible(left_address, right_address): continue score = fuzz.token_sort_ratio(left_address, right_address) - if score >= min_score: + # Number-less pairs (named houses, building-name flats) lack the + # house-number disambiguator, so require a near-exact match. + threshold = ( + min_score + if _NUMBER_RE.search(left_address) or _NUMBER_RE.search(right_address) + else min_score_without_numbers + ) + if score >= threshold: pairs.append((score, left_row, right_row)) return pairs diff --git a/pipeline/utils/nspl_schema.py b/pipeline/utils/nspl_schema.py new file mode 100644 index 0000000..4e972ed --- /dev/null +++ b/pipeline/utils/nspl_schema.py @@ -0,0 +1,30 @@ +"""Suffix-agnostic schema overrides for ONS NSPL/NSUL classification columns. + +NSPL/NSUL embed the source year in classification-index column names +(e.g. ruc21ind, oac11ind, imd20ind) and ONS bumps those suffixes with each +release. These columns look numeric in early rows but contain string codes +like "UN1" (Unclassified) further down, so they must be forced to String +before scanning — otherwise polars infers Int64 and crashes mid-stream. + +Hard-coding the year suffix is fragile: polars silently ignores overrides for +columns that don't exist, so a renamed suffix would no-op and reintroduce the +crash. Match on the suffix-free stem instead. +""" + +import re + +import polars as pl + +# Matches ruc/oac/imd classification-index columns regardless of year suffix: +# ruc21ind, oac11ind, imd20ind, imd25ind, oacind, ... (case-insensitive). +CODE_COL_PATTERN = re.compile(r"^(ruc|oac|imd)\d*ind$", re.IGNORECASE) + + +def code_col_overrides(names: list[str]) -> dict[str, type[pl.String]]: + """Build a String schema-override dict for NSPL/NSUL code columns. + + Given the column names of an NSPL/NSUL CSV, return overrides forcing every + RUC/OAC/IMD classification-index column to String, independent of the year + suffix ONS happens to use this release. + """ + return {name: pl.String for name in names if CODE_COL_PATTERN.match(name)} diff --git a/pipeline/utils/postcode_mapping.py b/pipeline/utils/postcode_mapping.py index 6023ef6..08fab17 100644 --- a/pipeline/utils/postcode_mapping.py +++ b/pipeline/utils/postcode_mapping.py @@ -6,6 +6,16 @@ import numpy as np import polars as pl from scipy.spatial import cKDTree +# Maximum distance (in OS National Grid metres) a terminated postcode may be from its +# nearest active successor to be remapped. Beyond this we treat the postcode as having no +# legitimate successor (e.g. demolished/redeveloped land) rather than re-homing it onto a +# geometrically-nearest-but-unrelated postcode on a different street/estate/LSOA, which +# would pollute the successor's crime/deprivation/school/noise/rent and price stats. +# 1km is conservative: it keeps legitimate adjacent remaps while dropping gross +# misattributions; dropped postcodes keep their terminated code and fall out at the +# active-postcode filter downstream (the honest outcome confirmed by the merge audit). +MAX_REMAP_DISTANCE_M = 1000.0 + def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame: """Build a mapping from terminated England postcodes to their nearest active postcode. @@ -13,7 +23,11 @@ def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame: Uses OS National Grid coordinates (east1m, north1m) which are Cartesian metres, so Euclidean distance via cKDTree gives accurate results without projection. """ - arcgis = pl.scan_parquet(arcgis_path).filter(pl.col("ctry25cd") == "E92000001") + arcgis = ( + pl.scan_parquet(arcgis_path) + .filter(pl.col("ctry25cd") == "E92000001") + .with_columns(pl.col("doterm").cast(pl.Utf8).alias("doterm")) + ) active = ( arcgis.filter(pl.col("doterm").is_null()) @@ -46,18 +60,30 @@ def build_postcode_mapping(arcgis_path: Path) -> pl.DataFrame: ) tree = cKDTree(active_coords) - distances, indices = tree.query(terminated_coords) + distances, indices = tree.query( + terminated_coords, distance_upper_bound=MAX_REMAP_DISTANCE_M + ) + + # cKDTree returns distance=inf and index==len(active) for points with no neighbour + # within the bound. Drop those terminated postcodes rather than gather an out-of-range + # index; they keep their terminated code and fall out at the active-postcode filter. + within_bound = np.isfinite(distances) + dropped = int((~within_bound).sum()) active_postcodes = active["pcds"] mapping = pl.DataFrame( { - "old_postcode": terminated["pcds"], - "new_postcode": active_postcodes.gather(indices), + "old_postcode": terminated["pcds"].filter(pl.Series(within_bound)), + "new_postcode": active_postcodes.gather(indices[within_bound]), } ) + kept_distances = distances[within_bound] print( - f"Postcode mapping: max distance = {distances.max():.0f}m, median = {np.median(distances):.0f}m" + f"Postcode mapping: {dropped} terminated postcodes dropped (> {MAX_REMAP_DISTANCE_M:.0f}m), " + f"max distance = {kept_distances.max():.0f}m, median = {np.median(kept_distances):.0f}m" + if kept_distances.size + else f"Postcode mapping: {dropped} terminated postcodes dropped (> {MAX_REMAP_DISTANCE_M:.0f}m), none remapped" ) return mapping diff --git a/pipeline/utils/test_fuzzy_join.py b/pipeline/utils/test_fuzzy_join.py index 1632739..28d0dfc 100644 --- a/pipeline/utils/test_fuzzy_join.py +++ b/pipeline/utils/test_fuzzy_join.py @@ -134,6 +134,91 @@ def test_fuzzy_join_on_postcode_rejects_blank_and_invalid_match_keys(): ] +def test_fuzzy_join_rejects_mid_score_number_less_match(): + # "THE COACH HOUSE" vs "THE OLD COACH HOUSE" scores 88 via token_sort_ratio: + # above the old MIN_FUZZY_SCORE of 60 (so it used to falsely match) but below + # the number-less threshold of 90, so it must NOT match now. + left = pl.LazyFrame( + { + "left_address": ["The Coach House"], + "left_postcode": ["AB1 2CD"], + } + ) + right = pl.LazyFrame( + { + "right_address": ["The Old Coach House"], + "right_postcode": ["AB1 2CD"], + } + ) + + result = fuzzy_join_on_postcode( + left=left, + right=right, + left_address_col="left_address", + right_address_col="right_address", + left_postcode_col="left_postcode", + right_postcode_col="right_postcode", + ).collect() + + assert result["right_address"].to_list() == [None] + + +def test_fuzzy_join_matches_numbered_pair_at_baseline_threshold(): + # "10 ACACIA AVENUE" vs "FLAT A 10 ACACIA AVENUE" scores exactly 82 and the + # house number is compatible, so the numbered baseline (>= 82) still matches. + left = pl.LazyFrame( + { + "left_address": ["10 Acacia Avenue"], + "left_postcode": ["AB1 2CD"], + } + ) + right = pl.LazyFrame( + { + "right_address": ["Flat A, 10 Acacia Avenue"], + "right_postcode": ["AB1 2CD"], + } + ) + + result = fuzzy_join_on_postcode( + left=left, + right=right, + left_address_col="left_address", + right_address_col="right_address", + left_postcode_col="left_postcode", + right_postcode_col="right_postcode", + ).collect() + + assert result["right_address"].to_list() == ["Flat A, 10 Acacia Avenue"] + + +def test_fuzzy_join_matches_high_score_number_less_pair(): + # A number-less pair that clears the 90 threshold (here an exact token match, + # score 100) must still match. + left = pl.LazyFrame( + { + "left_address": ["The Old Rectory"], + "left_postcode": ["AB1 2CD"], + } + ) + right = pl.LazyFrame( + { + "right_address": ["THE OLD RECTORY"], + "right_postcode": ["AB1 2CD"], + } + ) + + result = fuzzy_join_on_postcode( + left=left, + right=right, + left_address_col="left_address", + right_address_col="right_address", + left_postcode_col="left_postcode", + right_postcode_col="right_postcode", + ).collect() + + assert result["right_address"].to_list() == ["THE OLD RECTORY"] + + def test_normalize_postcode_key_requires_full_postcode(): df = pl.DataFrame( { diff --git a/pipeline/utils/test_nspl_schema.py b/pipeline/utils/test_nspl_schema.py new file mode 100644 index 0000000..40acbe8 --- /dev/null +++ b/pipeline/utils/test_nspl_schema.py @@ -0,0 +1,57 @@ +import polars as pl + +from pipeline.utils.nspl_schema import CODE_COL_PATTERN, code_col_overrides + + +def test_matches_current_and_renamed_suffixes(): + names = ["pcd", "ruc21ind", "oac11ind", "imd20ind", "lat", "long"] + overrides = code_col_overrides(names) + assert overrides == { + "ruc21ind": pl.String, + "oac11ind": pl.String, + "imd20ind": pl.String, + } + + +def test_catches_future_suffix_bump(): + # The regression: ONS bumps imd20ind -> imd25ind. A hard-coded dict would + # no-op here; the stem match must still catch it. + names = ["pcd", "ruc25ind", "oac21ind", "imd25ind"] + overrides = code_col_overrides(names) + assert set(overrides) == {"ruc25ind", "oac21ind", "imd25ind"} + assert all(dtype is pl.String for dtype in overrides.values()) + + +def test_is_case_insensitive(): + overrides = code_col_overrides(["RUC21IND", "Oac11Ind", "IMD20ind"]) + assert set(overrides) == {"RUC21IND", "Oac11Ind", "IMD20ind"} + + +def test_matches_suffixless_stem(): + assert set(code_col_overrides(["rucind", "oacind", "imdind"])) == { + "rucind", + "oacind", + "imdind", + } + + +def test_ignores_unrelated_columns(): + names = [ + "pcd", + "laua", + "imdscore", # not an *ind column + "indicator", # ends in nothing relevant, no ruc/oac/imd stem + "ruc21indx", # extra trailing char -> not a code column + "xruc21ind", # leading char -> not anchored at start + ] + assert code_col_overrides(names) == {} + + +def test_empty_names(): + assert code_col_overrides([]) == {} + + +def test_pattern_anchored(): + assert CODE_COL_PATTERN.match("imd25ind") + assert not CODE_COL_PATTERN.match("oac11indicator") + assert not CODE_COL_PATTERN.match("region_imd20ind") diff --git a/pipeline/utils/test_postcode_mapping.py b/pipeline/utils/test_postcode_mapping.py new file mode 100644 index 0000000..3ed0c0f --- /dev/null +++ b/pipeline/utils/test_postcode_mapping.py @@ -0,0 +1,38 @@ +import polars as pl + +from pipeline.utils.postcode_mapping import ( + MAX_REMAP_DISTANCE_M, + build_postcode_mapping, +) + + +def test_remap_drops_terminated_postcodes_beyond_distance_cap(tmp_path): + # One active England postcode at the origin. + # One terminated postcode 100m away (legitimate adjacent remap -> mapped). + # One terminated postcode 5km away (gross misattribution -> dropped). + arcgis = pl.DataFrame( + { + "pcds": ["AB1 1AA", "AB1 1AB", "ZZ9 9ZZ"], + "ctry25cd": ["E92000001", "E92000001", "E92000001"], + "doterm": [None, "202001", "202001"], + "east1m": [500000.0, 500100.0, 505000.0], + "north1m": [200000.0, 200000.0, 200000.0], + } + ) + arcgis_path = tmp_path / "arcgis.parquet" + arcgis.write_parquet(arcgis_path) + + mapping = build_postcode_mapping(arcgis_path) + + # The nearby terminated postcode is remapped onto the active one. + assert mapping.filter(pl.col("old_postcode") == "AB1 1AB")[ + "new_postcode" + ].to_list() == ["AB1 1AA"] + + # The far (5km > 1km cap) terminated postcode must NOT appear in the mapping. + assert "ZZ9 9ZZ" not in mapping["old_postcode"].to_list() + assert mapping.height == 1 + + +def test_max_remap_distance_constant_is_one_kilometre(): + assert MAX_REMAP_DISTANCE_M == 1000.0 diff --git a/pipeline/validate_outputs.py b/pipeline/validate_outputs.py index 031a24d..8dc2d59 100644 --- a/pipeline/validate_outputs.py +++ b/pipeline/validate_outputs.py @@ -3,11 +3,14 @@ from __future__ import annotations import argparse +import json import sys import zipfile from pathlib import Path import polars as pl +from shapely.geometry import shape +from shapely.validation import explain_validity def _failures_for_file(path: Path) -> list[str]: @@ -76,6 +79,22 @@ def _split_glob(spec: str) -> tuple[Path, str]: return Path(base), pattern +def _split_pair(spec: str, label: str) -> tuple[Path, Path]: + if "::" not in spec: + raise argparse.ArgumentTypeError(f"{spec!r} must use LEFT::RIGHT for {label}") + left, right = spec.split("::", 1) + if not left or not right: + raise argparse.ArgumentTypeError(f"{spec!r} must include both paths") + return Path(left), Path(right) + + +def _canonical_postcode(value: object) -> str: + compact = "".join(str(value).split()).upper() + if len(compact) >= 5: + return f"{compact[:-3]} {compact[-3:]}" + return compact + + def _matched_files(spec: str) -> tuple[Path, str, list[Path]]: base, pattern = _split_glob(spec) if not base.exists(): @@ -105,6 +124,448 @@ def _failures_for_zip_glob(spec: str) -> list[str]: return failures +def _postcode_column(columns: list[str]) -> str | None: + for name in ("postcode", "Postcode", "pcds", "PCDS"): + if name in columns: + return name + return None + + +def _parquet_postcodes(path: Path) -> set[str]: + schema = pl.scan_parquet(path).collect_schema() + column = _postcode_column(schema.names()) + if column is None: + raise ValueError(f"{path}: missing postcode column") + values = ( + pl.scan_parquet(path) + .select(pl.col(column).drop_nulls().unique()) + .collect() + .get_column(column) + .to_list() + ) + return { + _canonical_postcode(value) for value in values if _canonical_postcode(value) + } + + +def _active_english_arcgis_postcodes(path: Path) -> set[str]: + schema = pl.scan_parquet(path).collect_schema() + required = {"pcds", "ctry25cd", "doterm"} + missing = sorted(required - set(schema.names())) + if missing: + raise ValueError(f"{path}: missing ArcGIS postcode columns: {missing}") + values = ( + pl.read_parquet(path, columns=["pcds", "ctry25cd", "doterm"]) + .lazy() + .filter(pl.col("ctry25cd") == "E92000001") + .filter(pl.col("doterm").cast(pl.Utf8).is_null()) + .select(pl.col("pcds").drop_nulls().unique()) + .collect() + .get_column("pcds") + .to_list() + ) + return { + _canonical_postcode(value) for value in values if _canonical_postcode(value) + } + + +def _format_samples(samples: list[str]) -> str: + return "; ".join(samples[:10]) + + +def _boundary_postcode_scan(path: Path) -> tuple[set[str], list[str]]: + units_dir = path / "units" if (path / "units").is_dir() else path + postcodes: set[str] = set() + seen: dict[str, str] = {} + failures: list[str] = [] + missing_postcode_samples: list[str] = [] + missing_geometry_samples: list[str] = [] + non_polygon_samples: list[str] = [] + invalid_geometry_samples: list[str] = [] + duplicate_samples: list[str] = [] + missing_postcode_count = 0 + missing_geometry_count = 0 + non_polygon_count = 0 + invalid_geometry_count = 0 + duplicate_count = 0 + + for geojson_path in sorted(units_dir.glob("*.geojson")): + try: + with geojson_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except Exception as exc: + failures.append(f"{geojson_path}: unreadable GeoJSON: {exc}") + continue + + for idx, feature in enumerate(data.get("features", [])): + label = f"{geojson_path.name} feature {idx}" + properties = feature.get("properties") or {} + value = properties.get("postcodes") + postcode = _canonical_postcode(value) if value is not None else "" + if not postcode: + missing_postcode_count += 1 + if len(missing_postcode_samples) < 10: + missing_postcode_samples.append(label) + else: + if postcode in seen: + duplicate_count += 1 + if len(duplicate_samples) < 10: + duplicate_samples.append( + f"{postcode} in {seen[postcode]} and {label}" + ) + else: + seen[postcode] = label + postcodes.add(postcode) + + geometry_data = feature.get("geometry") + if geometry_data is None: + missing_geometry_count += 1 + if len(missing_geometry_samples) < 10: + missing_geometry_samples.append(f"{postcode or label}") + continue + try: + geom = shape(geometry_data) + except Exception as exc: + invalid_geometry_count += 1 + if len(invalid_geometry_samples) < 10: + invalid_geometry_samples.append(f"{postcode or label}: {exc}") + continue + if geom.is_empty: + missing_geometry_count += 1 + if len(missing_geometry_samples) < 10: + missing_geometry_samples.append(f"{postcode or label}: empty") + elif geom.geom_type not in {"Polygon", "MultiPolygon"}: + non_polygon_count += 1 + if len(non_polygon_samples) < 10: + non_polygon_samples.append(f"{postcode or label}: {geom.geom_type}") + elif not geom.is_valid: + invalid_geometry_count += 1 + if len(invalid_geometry_samples) < 10: + invalid_geometry_samples.append( + f"{postcode or label}: {explain_validity(geom)}" + ) + + if missing_postcode_count: + failures.append( + f"{path}: {missing_postcode_count:,} boundary features are missing " + f"properties.postcodes; sample: {_format_samples(missing_postcode_samples)}" + ) + if duplicate_count: + failures.append( + f"{path}: {duplicate_count:,} duplicate boundary postcode features; " + f"sample: {_format_samples(duplicate_samples)}" + ) + if missing_geometry_count: + failures.append( + f"{path}: {missing_geometry_count:,} boundary features are missing or empty " + f"geometry; sample: {_format_samples(missing_geometry_samples)}" + ) + if non_polygon_count: + failures.append( + f"{path}: {non_polygon_count:,} boundary features are not polygonal; " + f"sample: {_format_samples(non_polygon_samples)}" + ) + if invalid_geometry_count: + failures.append( + f"{path}: {invalid_geometry_count:,} invalid boundary geometries; " + f"sample: {_format_samples(invalid_geometry_samples)}" + ) + return postcodes, failures + + +def _boundary_postcodes(path: Path) -> set[str]: + postcodes, failures = _boundary_postcode_scan(path) + if failures: + raise ValueError("; ".join(failures)) + return postcodes + + +def _sample(values: set[str]) -> str: + return ", ".join(sorted(values)[:10]) + + +def _failures_for_postcode_boundary_match(spec: str) -> list[str]: + parquet_path, boundaries_path = _split_pair(spec, "postcode boundary matching") + failures = _failures_for_parquet(parquet_path) + _failures_for_dir(boundaries_path) + if failures: + return failures + + try: + parquet_postcodes = _parquet_postcodes(parquet_path) + boundary_postcodes, boundary_failures = _boundary_postcode_scan(boundaries_path) + except Exception as exc: + return [ + f"{parquet_path} / {boundaries_path}: postcode match check failed: {exc}" + ] + + failures = list(boundary_failures) + if not boundary_postcodes: + failures.append(f"{boundaries_path}: no boundary postcodes found") + + missing_boundaries = parquet_postcodes - boundary_postcodes + orphan_boundaries = boundary_postcodes - parquet_postcodes + if missing_boundaries: + failures.append( + f"{boundaries_path}: {len(missing_boundaries):,} postcodes from {parquet_path} " + f"are missing boundaries; sample: {_sample(missing_boundaries)}" + ) + if orphan_boundaries: + failures.append( + f"{boundaries_path}: {len(orphan_boundaries):,} boundary postcodes are absent from " + f"{parquet_path}; sample: {_sample(orphan_boundaries)}" + ) + return failures + + +def _failures_for_active_postcode_boundary_match(spec: str) -> list[str]: + arcgis_path, boundaries_path = _split_pair( + spec, "active postcode boundary matching" + ) + failures = _failures_for_parquet(arcgis_path) + _failures_for_dir(boundaries_path) + if failures: + return failures + + try: + active_postcodes = _active_english_arcgis_postcodes(arcgis_path) + boundary_postcodes, boundary_failures = _boundary_postcode_scan(boundaries_path) + except Exception as exc: + return [ + f"{arcgis_path} / {boundaries_path}: active postcode boundary check failed: {exc}" + ] + + failures = list(boundary_failures) + if not boundary_postcodes: + failures.append(f"{boundaries_path}: no boundary postcodes found") + + missing_boundaries = active_postcodes - boundary_postcodes + orphan_boundaries = boundary_postcodes - active_postcodes + if missing_boundaries: + failures.append( + f"{boundaries_path}: {len(missing_boundaries):,} active English postcodes " + f"from {arcgis_path} are missing boundaries; sample: {_sample(missing_boundaries)}" + ) + if orphan_boundaries: + failures.append( + f"{boundaries_path}: {len(orphan_boundaries):,} boundary postcodes are not " + f"active English postcodes in {arcgis_path}; sample: {_sample(orphan_boundaries)}" + ) + return failures + + +def _failures_for_postcode_universe(spec: str) -> list[str]: + """Validate that a postcode-features parquet's postcode set is exactly the + active-English NSPL/ArcGIS universe. Guards against a truncated or stale + postcode.parquet (e.g. an interrupted merge that wrote only a fraction of the + ~1.49M rows, all otherwise valid) silently passing the build gate, since + `_failures_for_postcode_features` only checks per-row validity, not the count. + """ + arcgis_path, postcodes_path = _split_pair(spec, "postcode universe") + failures = _failures_for_parquet(arcgis_path) + _failures_for_parquet( + postcodes_path + ) + if failures: + return failures + + try: + active = _active_english_arcgis_postcodes(arcgis_path) + got = _parquet_postcodes(postcodes_path) + except Exception as exc: + return [ + f"{arcgis_path} / {postcodes_path}: postcode universe check failed: {exc}" + ] + + failures = [] + if len(got) != len(active): + failures.append( + f"{postcodes_path}: postcode count {len(got):,} != active-English NSPL " + f"universe {len(active):,} (from {arcgis_path})" + ) + + missing = active - got + extra = got - active + if missing: + failures.append( + f"{postcodes_path}: {len(missing):,} active English postcodes from " + f"{arcgis_path} are missing; sample: {_sample(missing)}" + ) + if extra: + failures.append( + f"{postcodes_path}: {len(extra):,} postcodes are not active English " + f"postcodes in {arcgis_path}; sample: {_sample(extra)}" + ) + return failures + + +def _failures_for_postcode_features(path: Path) -> list[str]: + """Validate the postcode feature output: unique Postcode, non-null lat/lon + inside the England bbox, ctry25cd == E92000001, and every '% ' column in + [0, 100]. Mirrors the in-build invariant (merge._validate_postcode_feature_output) + so a stale/contaminated file on disk cannot pass `make`. + """ + failures = _failures_for_parquet(path) + if failures: + return failures + + try: + names = pl.scan_parquet(path).collect_schema().names() + required = {"Postcode", "lat", "lon", "ctry25cd"} + missing = sorted(required - set(names)) + if missing: + return [f"{path}: postcode features missing required columns: {missing}"] + + pct_cols = [c for c in names if c.startswith("% ")] + df = ( + pl.scan_parquet(path) + .select(["Postcode", "lat", "lon", "ctry25cd", *pct_cols]) + .collect() + ) + except Exception as exc: + return [f"{path}: postcode features validation failed: {exc}"] + + height = df.height + if df["Postcode"].n_unique() != height: + failures.append( + f"{path}: Postcode is not unique " + f"({height - df['Postcode'].n_unique():,} duplicate rows)" + ) + + # England bounding box (generous): lat 49.5-60N, lon -8 to 2.5E. + bad_coords = df.filter( + pl.col("lat").is_null() + | pl.col("lon").is_null() + | ~pl.col("lat").is_between(49.5, 60.0) + | ~pl.col("lon").is_between(-8.0, 2.5) + ) + if bad_coords.height: + sample = bad_coords.get_column("Postcode").head(10).to_list() + failures.append( + f"{path}: {bad_coords.height:,} rows have null or out-of-England " + f"lat/lon; sample: {_format_samples(sample)}" + ) + + bad_country = df.filter(pl.col("ctry25cd") != "E92000001") + if bad_country.height: + sample = bad_country.get_column("Postcode").head(10).to_list() + failures.append( + f"{path}: {bad_country.height:,} rows have ctry25cd != 'E92000001' " + f"(non-England contamination); sample: {_format_samples(sample)}" + ) + + for col in pct_cols: + out_of_range = df.filter( + pl.col(col).is_not_null() & ~pl.col(col).is_between(0.0, 100.0) + ).height + if out_of_range: + failures.append( + f"{path}: {col!r} has {out_of_range:,} values outside [0, 100]" + ) + + return failures + + +def _failures_for_properties_subset(spec: str) -> list[str]: + """Validate that every properties Postcode exists in the postcode feature + table (no orphan properties) and that numeric price columns are positive.""" + properties_path, postcode_path = _split_pair(spec, "properties subset") + failures = _failures_for_parquet(properties_path) + _failures_for_parquet( + postcode_path + ) + if failures: + return failures + + try: + postcode_set = _parquet_postcodes(postcode_path) + property_set = _parquet_postcodes(properties_path) + except Exception as exc: + return [f"{properties_path} / {postcode_path}: subset check failed: {exc}"] + + orphans = property_set - postcode_set + if orphans: + failures.append( + f"{properties_path}: {len(orphans):,} property postcodes are absent from " + f"{postcode_path}; sample: {_sample(orphans)}" + ) + + # Positivity check for genuine numeric price columns only (skip nested/list + # columns like historical_prices, which contain "price" in the name). + try: + schema = pl.scan_parquet(properties_path).collect_schema() + numeric = { + pl.Int8, pl.Int16, pl.Int32, pl.Int64, + pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64, + pl.Float32, pl.Float64, + } + price_cols = [ + c + for c, dtype in schema.items() + if ("price" in c.lower() or "rent" in c.lower()) and dtype in numeric + ] + for col in price_cols: + bad = ( + pl.scan_parquet(properties_path) + .filter(pl.col(col).is_not_null() & (pl.col(col) <= 0)) + .select(pl.len()) + .collect() + .item() + ) + if bad: + failures.append( + f"{properties_path}: {col!r} has {bad:,} non-positive values" + ) + except Exception as exc: + failures.append(f"{properties_path}: price positivity check failed: {exc}") + + return failures + + +def _failures_for_price_index(path: Path) -> list[str]: + """Validate price_index.parquet structural integrity: required columns, a + finite non-null log_index, and unique (sector, type_group, year) keys. + + n_pairs == 0 is intentionally NOT treated as a failure: those rows are + legitimate hedonic/shrinkage fallbacks for sectors with too few repeat-sale + pairs. + """ + failures = _failures_for_parquet(path) + if failures: + return failures + + try: + names = pl.scan_parquet(path).collect_schema().names() + required = {"sector", "type_group", "year", "log_index", "n_pairs"} + missing = sorted(required - set(names)) + if missing: + return [f"{path}: price index missing required columns: {missing}"] + + stats = ( + pl.scan_parquet(path) + .select( + pl.len().alias("n"), + pl.col("log_index").null_count().alias("null_log"), + (~pl.col("log_index").is_finite()).sum().alias("nonfinite_log"), + pl.struct("sector", "type_group", "year").n_unique().alias("unique_keys"), + ) + .collect() + .row(0, named=True) + ) + except Exception as exc: + return [f"{path}: price index validation failed: {exc}"] + + if stats["null_log"]: + failures.append(f"{path}: {stats['null_log']:,} rows have null log_index") + if stats["nonfinite_log"]: + failures.append( + f"{path}: {stats['nonfinite_log']:,} rows have non-finite log_index" + ) + if stats["unique_keys"] != stats["n"]: + failures.append( + f"{path}: (sector, type_group, year) is not unique " + f"({stats['n'] - stats['unique_keys']:,} duplicate rows)" + ) + + return failures + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--file", action="append", default=[], type=Path) @@ -123,6 +584,53 @@ def main() -> int: default=[], help="Require at least one readable zip matching BASE::PATTERN", ) + parser.add_argument( + "--postcode-boundary-match", + action="append", + default=[], + help="Require postcode parquet keys to exactly match boundary GeoJSON postcodes: PARQUET::DIR", + ) + parser.add_argument( + "--active-postcode-boundary-match", + action="append", + default=[], + help=( + "Require active English ArcGIS postcodes to exactly match boundary " + "GeoJSON postcodes: ARCGIS_PARQUET::DIR" + ), + ) + parser.add_argument( + "--postcode-features", + action="append", + default=[], + type=Path, + help=( + "Validate a postcode feature parquet: unique Postcode, non-null " + "lat/lon in England, ctry25cd=E92000001, '% ' columns in [0,100]" + ), + ) + parser.add_argument( + "--postcode-universe", + action="append", + default=[], + help=( + "Require postcode parquet keys to equal the active-English NSPL " + "universe: ARCGIS::POSTCODES" + ), + ) + parser.add_argument( + "--properties-subset", + action="append", + default=[], + help="Require properties postcodes to be a subset of postcode keys: PROPERTIES::POSTCODE", + ) + parser.add_argument( + "--price-index", + action="append", + default=[], + type=Path, + help="Validate price_index.parquet: finite log_index and unique (sector,type_group,year)", + ) args = parser.parse_args() failures: list[str] = [] @@ -138,6 +646,18 @@ def main() -> int: failures.extend(_failures_for_glob(spec)) for spec in args.zip_glob: failures.extend(_failures_for_zip_glob(spec)) + for spec in args.postcode_boundary_match: + failures.extend(_failures_for_postcode_boundary_match(spec)) + for spec in args.active_postcode_boundary_match: + failures.extend(_failures_for_active_postcode_boundary_match(spec)) + for path in args.postcode_features: + failures.extend(_failures_for_postcode_features(path)) + for spec in args.postcode_universe: + failures.extend(_failures_for_postcode_universe(spec)) + for spec in args.properties_subset: + failures.extend(_failures_for_properties_subset(spec)) + for path in args.price_index: + failures.extend(_failures_for_price_index(path)) if failures: print("Output validation failed:", file=sys.stderr) diff --git a/probe_bin b/probe_bin new file mode 100755 index 0000000..e89bbf9 Binary files /dev/null and b/probe_bin differ diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 51b98b0..4aa3069 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -3880,6 +3880,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "bytes", "clap", "h3o", "hex", @@ -3900,6 +3901,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", + "tikv-jemallocator", "tokio", "tower", "tower-http", @@ -5253,6 +5255,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "tilejson" version = "0.4.3" diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 7a718cf..bb6b3b4 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -33,8 +33,15 @@ sha2 = "0.11" hex = "0.4" tower = { version = "0.5", features = ["limit"] } libc = "0.2" +bytes = "1" sentry = { version = "0.46.0", default-features = false, features = ["backtrace", "contexts", "debug-images", "panic", "reqwest", "rustls", "tracing", "tower-http", "tower-axum-matched-path"] } +# jemalloc returns freed memory to the OS far more aggressively than glibc malloc +# (which strands freed rayon/Polars load buffers across many per-CPU arenas, inflating +# steady-state RSS). Decay is configured via `malloc_conf` in main.rs. +[target.'cfg(not(target_env = "msvc"))'.dependencies] +tikv-jemallocator = { version = "0.6", features = ["unprefixed_malloc_on_supported_platforms"] } + [lints.clippy] min_ident_chars = "warn" diff --git a/server-rs/src/data.rs b/server-rs/src/data.rs index 3cc65c1..e941e80 100644 --- a/server-rs/src/data.rs +++ b/server-rs/src/data.rs @@ -37,7 +37,9 @@ where pub use actual_listings::{ActualListing, ActualListingData}; pub use crime_by_year::CrimeByYearData; -pub use places::{normalize_search_text, PlaceData}; +pub use places::{ + compute_trigrams, normalize_search_text, place_alias_tokens, trigram_similarity, PlaceData, +}; pub use poi::{resolve_poi_category_filter, POICategoryGroup, POIData, SchoolMetadata}; pub use postcodes::{OutcodeData, PostcodeData}; pub use property::{ diff --git a/server-rs/src/data/crime_by_year.rs b/server-rs/src/data/crime_by_year.rs index 2c99c1e..cba947f 100644 --- a/server-rs/src/data/crime_by_year.rs +++ b/server-rs/src/data/crime_by_year.rs @@ -120,7 +120,7 @@ impl CrimeByYearData { .list() .with_context(|| format!("Column '{col_name}' is not a list"))?; - for row in 0..row_count { + for (row, postcode) in postcode_values.iter().enumerate().take(row_count) { let Some(inner) = list_ca.get_as_series(row) else { continue; }; @@ -163,7 +163,7 @@ impl CrimeByYearData { points.sort_by_key(|p| p.year); series_by_postcode - .entry(postcode_values[row].clone()) + .entry(postcode.clone()) .or_default() .push(PostcodeCrimeSeries { type_idx: type_idx as u16, diff --git a/server-rs/src/data/places.rs b/server-rs/src/data/places.rs index cc9579c..4d4744a 100644 --- a/server-rs/src/data/places.rs +++ b/server-rs/src/data/places.rs @@ -4,10 +4,16 @@ use anyhow::Context; use polars::frame::DataFrame; use polars::lazy::frame::LazyFrame; use polars::prelude::*; +use rustc_hash::FxHashMap; use tracing::info; use crate::utils::InternedColumn; +/// Upper bound on place rows scored per query (candidate sets are normally far smaller). +const PLACE_CANDIDATE_LIMIT: usize = 50_000; +const PLACE_PREFIX_MIN_LEN: usize = 2; +const PLACE_PREFIX_MAX_LEN: usize = 6; + pub struct PlaceData { pub name: Vec<String>, pub name_lower: Vec<String>, @@ -19,6 +25,13 @@ pub struct PlaceData { pub lon: Vec<f32>, pub city: Vec<Option<String>>, pub travel_destination: Vec<bool>, + /// Inverted index from an alias token to the (ascending) place rows containing it. Lets place + /// search gather candidates instead of scanning all ~1M+ rows per keystroke. + token_index: FxHashMap<String, Vec<u32>>, + /// Prefix → indexed tokens, for matching a partially-typed final word. + token_prefix_index: FxHashMap<String, Vec<String>>, + /// Trigram → fuzzy-eligible rows (settlements/stations only), for bounded typo matching. + fuzzy_trigram_index: FxHashMap<u32, Vec<u32>>, } #[derive(Clone, Copy)] @@ -168,6 +181,148 @@ pub fn normalize_search_text(text: &str) -> String { result } +/// Tokens across all of a place's search aliases (split on word and alias separators), +/// for token-AND matching where every query word must prefix-match some place token. +pub fn place_alias_tokens(search_text: &str) -> impl Iterator<Item = &str> { + search_text + .split([' ', '|']) + .filter(|token| !token.is_empty()) +} + +fn trigram_hash(first: char, second: char, third: char) -> u32 { + let mut hash = 2_166_136_261u32; + for ch in [first, second, third] { + hash = (hash ^ (ch as u32)).wrapping_mul(16_777_619); + } + hash +} + +/// Sorted, de-duplicated padded character trigrams of `text`, for Jaccard fuzzy matching. +pub fn compute_trigrams(text: &str) -> Vec<u32> { + let norm = normalize_search_text(text); + if norm.is_empty() { + return Vec::new(); + } + let chars: Vec<char> = [' ', ' '] + .into_iter() + .chain(norm.chars()) + .chain(std::iter::once(' ')) + .collect(); + let mut grams: Vec<u32> = chars + .windows(3) + .map(|window| trigram_hash(window[0], window[1], window[2])) + .collect(); + grams.sort_unstable(); + grams.dedup(); + grams +} + +/// Intersect two ascending-sorted row-id slices. +fn intersect_sorted(left: &[u32], right: &[u32]) -> Vec<u32> { + let mut out = Vec::new(); + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => i += 1, + std::cmp::Ordering::Greater => j += 1, + std::cmp::Ordering::Equal => { + out.push(left[i]); + i += 1; + j += 1; + } + } + } + out +} + +/// Union two ascending-sorted row-id slices (deduplicated, stays sorted). +fn union_sorted(left: &[u32], right: &[u32]) -> Vec<u32> { + let mut out = Vec::with_capacity(left.len() + right.len()); + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => { + out.push(left[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + out.push(right[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + out.push(left[i]); + i += 1; + j += 1; + } + } + } + out.extend_from_slice(&left[i..]); + out.extend_from_slice(&right[j..]); + out +} + +/// Distinct indexable tokens (len ≥ 2) across all of a place's search aliases. ASCII because +/// `normalize_search_text` already dropped non-alphanumerics, so prefix byte-slicing is safe. +fn place_index_tokens(search_text: &str) -> Vec<String> { + let mut tokens: Vec<String> = place_alias_tokens(search_text) + .filter(|token| token.len() >= 2) + .map(ToString::to_string) + .collect(); + tokens.sort_unstable(); + tokens.dedup(); + tokens +} + +fn build_place_prefix_index( + token_index: &FxHashMap<String, Vec<u32>>, +) -> FxHashMap<String, Vec<String>> { + let mut prefix_index: FxHashMap<String, Vec<String>> = FxHashMap::default(); + for token in token_index.keys() { + let max_len = token.len().min(PLACE_PREFIX_MAX_LEN); + for len in PLACE_PREFIX_MIN_LEN..=max_len { + prefix_index + .entry(token[..len].to_string()) + .or_default() + .push(token.clone()); + } + } + for tokens in prefix_index.values_mut() { + tokens.sort_unstable(); + tokens.dedup(); + } + prefix_index +} + +/// Whether a place type participates in fuzzy (typo) matching. Settlements/stations/universities +/// do; the ~1M streets and POIs do not (people rarely misspell a road and it keeps fuzzy bounded). +fn is_fuzzy_eligible_type(place_type: &str) -> bool { + !matches!( + place_type, + "street" | "park" | "attraction" | "hospital" | "retail" + ) +} + +/// Jaccard similarity between two sorted trigram sets (0.0–1.0). +pub fn trigram_similarity(left: &[u32], right: &[u32]) -> f32 { + if left.is_empty() || right.is_empty() { + return 0.0; + } + let (mut i, mut j, mut intersection) = (0, 0, 0usize); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => i += 1, + std::cmp::Ordering::Greater => j += 1, + std::cmp::Ordering::Equal => { + intersection += 1; + i += 1; + j += 1; + } + } + } + let union = left.len() + right.len() - intersection; + intersection as f32 / union as f32 +} + fn replace_token(text: &str, from: &str, to: &str) -> Option<String> { let mut changed = false; let replaced: Vec<&str> = text @@ -191,15 +346,31 @@ fn push_alias(aliases: &mut Vec<String>, alias: String) { } } +/// Bidirectional token abbreviations expanded into search aliases so a query typed either +/// way matches (e.g. "gt missenden" ↔ "Great Missenden", "mt" ↔ "Mount"). +const PLACE_TOKEN_ALIASES: &[(&str, &str)] = &[ + ("st", "saint"), + ("saint", "st"), + ("mt", "mount"), + ("mount", "mt"), + ("gt", "great"), + ("great", "gt"), + ("lt", "little"), + ("little", "lt"), + ("upr", "upper"), + ("upper", "upr"), + ("lwr", "lower"), + ("lower", "lwr"), +]; + fn build_search_text(name: &str, place_type: &str) -> String { let primary = normalize_search_text(name); let mut aliases = vec![primary.clone()]; - if let Some(alias) = replace_token(&primary, "st", "saint") { - push_alias(&mut aliases, alias); - } - if let Some(alias) = replace_token(&primary, "saint", "st") { - push_alias(&mut aliases, alias); + for (from, to) in PLACE_TOKEN_ALIASES { + if let Some(alias) = replace_token(&primary, from, to) { + push_alias(&mut aliases, alias); + } } if place_type == "station" { @@ -391,6 +562,26 @@ impl PlaceData { fallback_city }; + // Build the place search index: an inverted token index over all rows (so the per-query + // cost scales with matched candidates, not the ~1M-row corpus), plus a trigram index over + // only fuzzy-eligible rows for bounded typo matching. + let mut token_index: FxHashMap<String, Vec<u32>> = FxHashMap::default(); + let mut fuzzy_trigram_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default(); + for idx in 0..row_count { + for token in place_index_tokens(&name_search[idx]) { + token_index.entry(token).or_default().push(idx as u32); + } + if is_fuzzy_eligible_type(&place_type_raw[idx]) { + for trigram in compute_trigrams(&name[idx]) { + fuzzy_trigram_index + .entry(trigram) + .or_default() + .push(idx as u32); + } + } + } + let token_prefix_index = build_place_prefix_index(&token_index); + let with_pop = population.iter().filter(|&&pop| pop > 0).count(); let with_city = city.iter().filter(|c| c.is_some()).count(); info!( @@ -398,6 +589,8 @@ impl PlaceData { types = place_type.values.len(), with_population = with_pop, with_city = with_city, + tokens = token_index.len(), + fuzzy_trigrams = fuzzy_trigram_index.len(), "Place data loaded" ); @@ -412,14 +605,261 @@ impl PlaceData { lon, city, travel_destination, + token_index, + token_prefix_index, + fuzzy_trigram_index, }) } + + /// Candidate place rows for the query content tokens: intersect the posting lists of words + /// typed in full; if none matched an indexed token exactly, seed from the smallest + /// prefix-expanded list (so a partially-typed final word still works). Bounded by + /// `PLACE_CANDIDATE_LIMIT`. + pub fn place_candidate_rows(&self, tokens: &[&str]) -> Vec<u32> { + let mut exact: Vec<&[u32]> = tokens + .iter() + .filter_map(|token| self.token_index.get(*token).map(Vec::as_slice)) + .collect(); + + let mut rows = if exact.is_empty() { + self.place_prefix_seed(tokens) + } else { + exact.sort_by_key(|posting| posting.len()); + let mut acc = exact[0].to_vec(); + for posting in &exact[1..] { + if acc.is_empty() { + break; + } + acc = intersect_sorted(&acc, posting); + } + acc + }; + rows.truncate(PLACE_CANDIDATE_LIMIT); + rows + } + + fn place_prefix_seed(&self, tokens: &[&str]) -> Vec<u32> { + let mut best: Option<Vec<u32>> = None; + for token in tokens { + if token.len() < PLACE_PREFIX_MIN_LEN { + continue; + } + let key = &token[..token.len().min(PLACE_PREFIX_MAX_LEN)]; + let Some(indexed) = self.token_prefix_index.get(key) else { + continue; + }; + let mut union: Vec<u32> = Vec::new(); + for indexed_token in indexed { + if !indexed_token.starts_with(token) { + continue; + } + if let Some(rows) = self.token_index.get(indexed_token) { + union = if union.is_empty() { + rows.clone() + } else { + union_sorted(&union, rows) + }; + } + } + if !union.is_empty() + && best + .as_ref() + .is_none_or(|current| union.len() < current.len()) + { + best = Some(union); + } + } + best.unwrap_or_default() + } + + /// Fuzzy-eligible rows sharing enough trigrams with the query to be worth Jaccard scoring. + /// Bounded by the (small) fuzzy trigram index rather than scanning every place. + pub fn fuzzy_candidate_rows(&self, query_trigrams: &[u32]) -> Vec<u32> { + if query_trigrams.is_empty() { + return Vec::new(); + } + let mut counts: FxHashMap<u32, u16> = FxHashMap::default(); + for trigram in query_trigrams { + if let Some(rows) = self.fuzzy_trigram_index.get(trigram) { + for &row in rows { + *counts.entry(row).or_default() += 1; + } + } + } + let min_shared = (((query_trigrams.len() as f32) * 0.4).ceil() as u16).max(1); + counts + .into_iter() + .filter_map(|(row, shared)| (shared >= min_shared).then_some(row)) + .collect() + } +} + +#[cfg(test)] +impl PlaceData { + /// Build a minimal PlaceData from (name, place_type) pairs for index tests. + fn from_names<S: AsRef<str>>(rows: &[(S, S)]) -> Self { + let name: Vec<String> = rows.iter().map(|(nm, _)| nm.as_ref().to_string()).collect(); + let place_type_raw: Vec<String> = + rows.iter().map(|(_, pt)| pt.as_ref().to_string()).collect(); + let name_lower: Vec<String> = name.iter().map(|nm| nm.to_lowercase()).collect(); + let name_search: Vec<String> = name + .iter() + .zip(&place_type_raw) + .map(|(nm, pt)| build_search_text(nm, pt)) + .collect(); + let mut token_index: FxHashMap<String, Vec<u32>> = FxHashMap::default(); + let mut fuzzy_trigram_index: FxHashMap<u32, Vec<u32>> = FxHashMap::default(); + for idx in 0..name.len() { + for token in place_index_tokens(&name_search[idx]) { + token_index.entry(token).or_default().push(idx as u32); + } + if is_fuzzy_eligible_type(&place_type_raw[idx]) { + for trigram in compute_trigrams(&name[idx]) { + fuzzy_trigram_index + .entry(trigram) + .or_default() + .push(idx as u32); + } + } + } + let token_prefix_index = build_place_prefix_index(&token_index); + let len = name.len(); + PlaceData { + name, + name_lower, + name_search, + place_type: InternedColumn::build(&place_type_raw), + type_rank: place_type_raw.iter().map(|pt| type_rank(pt)).collect(), + population: vec![0; len], + lat: vec![0.0; len], + lon: vec![0.0; len], + city: vec![None; len], + travel_destination: vec![false; len], + token_index, + token_prefix_index, + fuzzy_trigram_index, + } + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn place_index_tokens_dedup_and_min_length() { + // "a" is too short; aliases split on " | ". + assert_eq!( + place_index_tokens("st albans | saint albans"), + vec!["albans".to_string(), "saint".to_string(), "st".to_string()] + ); + } + + #[test] + fn place_candidate_rows_intersect_and_prefix_seed() { + let pd = PlaceData::from_names(&[ + ("Camden", "suburb"), + ("Camden Town", "suburb"), + ("Camden Market", "attraction"), + ("Manchester", "city"), + ("Manchester Piccadilly", "station"), + ]); + + // Full word → posting list (Camden, Camden Town, Camden Market). + let camden = pd.place_candidate_rows(&["camden"]); + assert_eq!(camden, vec![0, 1, 2]); + + // Two full words intersect to rows containing BOTH (Camden Town only). + let camden_town = pd.place_candidate_rows(&["camden", "town"]); + assert_eq!(camden_town, vec![1]); + + // A partially-typed final word with no exact token seeds from the prefix index. + let piccad = pd.place_candidate_rows(&["piccad"]); + assert_eq!(piccad, vec![4]); + + // No match → empty. + assert!(pd.place_candidate_rows(&["zzzz"]).is_empty()); + } + + // Run with: cargo test --release bench_place_search -- --ignored --nocapture + #[test] + #[ignore] + fn bench_place_search_at_one_million_rows() { + let roads = [ + "High Street", + "Station Road", + "Church Lane", + "Victoria Road", + "Mill Lane", + "Park Avenue", + "Queens Road", + "Kings Road", + ]; + let mut rows: Vec<(String, String)> = Vec::with_capacity(1_000_000); + for i in 0..1_000_000usize { + // Vary the name so the index resembles ~1M distinct (street, area) rows. + rows.push(( + format!("{} {}", roads[i % roads.len()], i % 4000), + "street".into(), + )); + } + rows.push(("London".into(), "city".into())); + let pd = PlaceData::from_names(&rows); + + let start = std::time::Instant::now(); + let mut hits = 0usize; + for _ in 0..50 { + let candidates = pd.place_candidate_rows(&["high", "street"]); + for row in candidates { + let idx = row as usize; + if place_search_test_score(&pd, idx, "high street", &["high", "street"]).is_some() { + hits += 1; + } + } + } + let per_query = start.elapsed() / 50; + println!( + "indexed place search over {} rows: {:?}/query ({} hits)", + pd.name.len(), + per_query, + hits / 50 + ); + // The old full O(N) scan measured ~36ms here; candidate-based must be far under that. + assert!(per_query.as_millis() < 10, "per_query was {per_query:?}"); + } + + /// Mirrors the route's per-candidate match check for the bench. + fn place_search_test_score( + pd: &PlaceData, + idx: usize, + query_search: &str, + query_tokens: &[&str], + ) -> Option<f32> { + let search_text = &pd.name_search[idx]; + if query_tokens.iter().all(|qt| { + place_alias_tokens(search_text) + .any(|t| t == *qt || (qt.len() >= 2 && t.starts_with(qt))) + }) { + Some(640.0) + } else if pd.name_lower[idx] == query_search { + Some(1000.0) + } else { + None + } + } + + #[test] + fn fuzzy_candidate_rows_finds_typos_only_for_eligible_rows() { + let pd = PlaceData::from_names(&[ + ("London", "city"), + ("Baker Street", "street"), // not fuzzy-eligible + ]); + let typo = compute_trigrams("Londn"); + let candidates = pd.fuzzy_candidate_rows(&typo); + assert!(candidates.contains(&0)); // London (city) is reachable by fuzzy + assert!(!candidates.contains(&1)); // streets are excluded from the fuzzy index + } + fn test_city_rows() -> [(&'static str, f32, f32, u32); 5] { [ ("London", 51.507_446, -0.1277653, 8_908_083), @@ -470,6 +910,29 @@ mod tests { assert!(build_search_text("Shadwell DLR station", "station").contains("shadwell station")); } + #[test] + fn search_text_expands_directional_and_size_abbreviations() { + assert!(build_search_text("Great Missenden", "village").contains("gt missenden")); + assert!(build_search_text("Mount Pleasant", "suburb").contains("mt pleasant")); + assert!(build_search_text("Little Venice", "suburb").contains("lt venice")); + } + + #[test] + fn trigram_similarity_is_high_for_typos_and_low_for_unrelated() { + let london = compute_trigrams("London"); + let typo = compute_trigrams("Londn"); + let other = compute_trigrams("Manchester"); + assert!(trigram_similarity(&london, &typo) >= 0.4); + assert!(trigram_similarity(&london, &other) < 0.2); + assert!((trigram_similarity(&london, &london) - 1.0).abs() < 1e-6); + } + + #[test] + fn place_alias_tokens_split_across_aliases() { + let tokens: Vec<&str> = place_alias_tokens("kings cross | kings x").collect(); + assert_eq!(tokens, vec!["kings", "cross", "kings", "x"]); + } + #[test] fn travel_destination_types_match_legacy_places() { assert!(is_travel_destination_type("city")); diff --git a/server-rs/src/data/poi.rs b/server-rs/src/data/poi.rs index 0fb3b50..7f7d024 100644 --- a/server-rs/src/data/poi.rs +++ b/server-rs/src/data/poi.rs @@ -398,7 +398,7 @@ fn build_school_meta( let mut idx = vec![u32::MAX; row_count]; let mut meta = Vec::new(); - for row in 0..row_count { + for (row, meta_idx) in idx.iter_mut().enumerate().take(row_count) { let type_group_val = fetch_str(&type_group, row); let type_val = fetch_str(&r#type, row); // type_group is present for every GIAS row, so use it as the sentinel @@ -406,7 +406,7 @@ fn build_school_meta( if type_group_val.is_none() && type_val.is_none() { continue; } - idx[row] = meta.len() as u32; + *meta_idx = meta.len() as u32; meta.push(SchoolMetadata { phase: fetch_str(&phase, row), r#type: type_val, diff --git a/server-rs/src/data/property.rs b/server-rs/src/data/property.rs index 141661b..dd04645 100644 --- a/server-rs/src/data/property.rs +++ b/server-rs/src/data/property.rs @@ -10,8 +10,10 @@ use rustc_hash::{FxHashMap, FxHashSet}; use crate::consts::{H3_PRECOMPUTE_MAX, HISTOGRAM_BINS, NAN_U16, QUANT_SCALE}; use crate::features::{self, Bounds}; -const ADDRESS_SEARCH_CANDIDATE_LIMIT: usize = 50_000; -const ADDRESS_SEARCH_MAX_POSTINGS_PER_TOKEN: usize = 250_000; +/// Upper bound on rows scored per query. Intersection keeps most candidate sets far below +/// this; only a single very common road word (e.g. "high") approaches it, and the in-area +/// priority sort keeps a refined query's matches ahead of the cut. +const ADDRESS_SEARCH_CANDIDATE_LIMIT: usize = 150_000; const ADDRESS_SEARCH_PREFIX_MIN_LEN: usize = 4; const ADDRESS_SEARCH_PREFIX_MAX_LEN: usize = 8; const NO_POI_METRIC_ROW: u32 = u32::MAX; @@ -162,6 +164,11 @@ struct AddressTermGroup { #[derive(Debug)] struct AddressQuery { full_postcode: Option<String>, + /// Compact uppercase outward code (optionally with a sector digit) recovered when the + /// user appended a partial postcode like "NW1" or "NW1 6". Used as an additive ranking + /// bias, never as a hard filter — so the disambiguating hint is honoured without + /// excluding the same road in other areas. + postcode_area: Option<String>, text_groups: Vec<AddressTermGroup>, numeric_terms: Vec<String>, candidate_terms: Vec<String>, @@ -442,6 +449,138 @@ fn build_address_prefix_index( prefix_index } +/// Intersect two ascending-sorted row-id slices. +fn intersect_sorted(left: &[u32], right: &[u32]) -> Vec<u32> { + let mut out = Vec::new(); + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => i += 1, + std::cmp::Ordering::Greater => j += 1, + std::cmp::Ordering::Equal => { + out.push(left[i]); + i += 1; + j += 1; + } + } + } + out +} + +/// Union two ascending-sorted row-id slices (deduplicated, stays sorted). +fn union_sorted(left: &[u32], right: &[u32]) -> Vec<u32> { + let mut out = Vec::with_capacity(left.len() + right.len()); + let (mut i, mut j) = (0, 0); + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => { + out.push(left[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + out.push(right[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + out.push(left[i]); + i += 1; + j += 1; + } + } + } + out.extend_from_slice(&left[i..]); + out.extend_from_slice(&right[j..]); + out +} + +/// An ordinal like "1st", "2nd", "3rd", "21st" — part of the street name ("2nd Avenue"), not a +/// house-number prefix. +fn is_ordinal_token(token: &str) -> bool { + let split = token.len().saturating_sub(2); + let (digits, suffix) = token.split_at(split); + !digits.is_empty() + && digits.chars().all(|ch| ch.is_ascii_digit()) + && matches!(suffix, "st" | "nd" | "rd" | "th") +} + +/// Leading address tokens that denote a unit/house number rather than the street itself. +fn is_house_prefix_token(token: &str) -> bool { + if is_ordinal_token(token) { + return false; + } + matches!( + token, + "flat" | "fl" | "apartment" | "apt" | "unit" | "no" | "block" | "floor" | "room" + ) || token.len() == 1 + || token.chars().all(|ch| ch.is_ascii_digit()) + || (token.chars().next().is_some_and(|ch| ch.is_ascii_digit()) + && token.chars().any(|ch| ch.is_ascii_alphabetic())) +} + +/// Street-level key for an address: drops the leading house-number / flat prefix so that +/// "12 Baker Street" and "5 Baker Street" collapse to a single street entry. +fn street_key(address: &str) -> String { + let tokens = tokenize_address_text(address); + let mut start = 0; + while start < tokens.len() && is_house_prefix_token(&tokens[start]) { + start += 1; + } + if start >= tokens.len() { + return tokens.join(" "); + } + tokens[start..].join(" ") +} + +/// Road-type words. Their presence (with no house number) marks a road browse, which we +/// collapse to one result per street. +const ROAD_TYPE_TOKENS: &[&str] = &[ + "street", + "st", + "road", + "rd", + "lane", + "ln", + "avenue", + "ave", + "close", + "cl", + "drive", + "dr", + "way", + "court", + "ct", + "crescent", + "cres", + "place", + "terrace", + "terr", + "grove", + "gardens", + "gdns", + "walk", + "row", + "square", + "sq", + "hill", + "parade", + "mews", + "embankment", + "broadway", + "boulevard", + "blvd", +]; + +fn query_has_road_type(query: &str) -> bool { + tokenize_address_text(query) + .iter() + .any(|token| ROAD_TYPE_TOKENS.contains(&token.as_str())) +} + +/// The outward code (everything before the space) of a canonical postcode. +fn outcode_of(postcode: &str) -> &str { + postcode.split(' ').next().unwrap_or(postcode) +} + fn parse_address_query(query: &str) -> AddressQuery { let tokens = tokenize_address_text(query); let (full_postcode, postcode_token_indices) = extract_full_postcode(&tokens) @@ -449,12 +588,45 @@ fn parse_address_query(query: &str) -> AddressQuery { .unwrap_or((None, Vec::new())); let skip_postcode_tokens: FxHashSet<usize> = postcode_token_indices.into_iter().collect(); + + // Recover an appended partial postcode (outcode, or outcode + sector digit) as a ranking + // bias rather than discarding it — but only from the TRAILING position, so a leading road + // designation like "A4 Great West Road" is not mistaken for an area refinement. + let mut postcode_area: Option<String> = None; + let mut consumed_partial_tokens: FxHashSet<usize> = FxHashSet::default(); + if full_postcode.is_none() && !tokens.is_empty() { + let last = tokens.len() - 1; + if !skip_postcode_tokens.contains(&last) { + let sector_digit = + tokens[last].len() == 1 && tokens[last].chars().all(|ch| ch.is_ascii_digit()); + if last >= 1 + && sector_digit + && !skip_postcode_tokens.contains(&(last - 1)) + && looks_like_postcode_fragment(&tokens[last - 1]) + { + postcode_area = Some(format!( + "{}{}", + tokens[last - 1].to_ascii_uppercase(), + tokens[last] + )); + consumed_partial_tokens.insert(last); + consumed_partial_tokens.insert(last - 1); + } else if looks_like_postcode_fragment(&tokens[last]) { + postcode_area = Some(tokens[last].to_ascii_uppercase()); + consumed_partial_tokens.insert(last); + } + } + } + let mut text_groups = Vec::new(); let mut numeric_terms = Vec::new(); let mut candidate_terms = Vec::new(); for (idx, token) in tokens.iter().enumerate() { - if skip_postcode_tokens.contains(&idx) || looks_like_postcode_fragment(token) { + if skip_postcode_tokens.contains(&idx) + || consumed_partial_tokens.contains(&idx) + || looks_like_postcode_fragment(token) + { continue; } @@ -486,6 +658,7 @@ fn parse_address_query(query: &str) -> AddressQuery { AddressQuery { full_postcode, + postcode_area, text_groups, numeric_terms, candidate_terms, @@ -897,9 +1070,15 @@ impl PropertyData { &self.address_search_token_keys[offset..offset + length] } - /// Search individual property addresses. Full postcode queries use a direct row index; - /// free-text queries use a small inverted index over distinctive address tokens. - pub fn search_addresses(&self, query: &str, limit: usize) -> Vec<usize> { + /// Search individual property addresses, returning `(row, score)` ranked best-first. + /// + /// Candidate rows come from intersecting the posting lists of the distinctive words the + /// user typed in full (so "Cherry Hinton Road" narrows to rows containing both), unioned + /// with the exact-postcode rows when a complete postcode is present (so a postcode is a + /// boost, not an all-or-nothing gate). An appended partial postcode keeps in-area rows + /// ahead of the candidate cut and adds a scoring bias. With a road-type word and no house + /// number, results collapse to one row per street. + pub fn search_addresses(&self, query: &str, limit: usize) -> Vec<(usize, i32)> { if limit == 0 { return Vec::new(); } @@ -912,25 +1091,45 @@ impl PropertyData { return Vec::new(); } - let candidate_rows: Vec<u32> = if let Some(postcode) = parsed.full_postcode.as_deref() { - self.postcode_interner + let mut candidate_rows = self.address_candidate_rows(&parsed.candidate_terms); + + // A complete postcode contributes its rows too, instead of replacing the road match. + if let Some(postcode) = parsed.full_postcode.as_deref() { + if let Some(rows) = self + .postcode_interner .get(postcode) .and_then(|key| self.postcode_row_index.get(&key)) - .map(|rows| rows.to_vec()) - .unwrap_or_default() - } else if let Some(rows) = self.best_address_token_rows(&parsed.candidate_terms) { - rows.iter() - .take(ADDRESS_SEARCH_CANDIDATE_LIMIT) - .copied() - .collect() - } else { - Vec::new() - }; + { + candidate_rows = if candidate_rows.is_empty() { + rows.clone() + } else { + union_sorted(&candidate_rows, rows) + }; + } + } if candidate_rows.is_empty() { return Vec::new(); } + // When the user appended a partial postcode, keep in-area rows ahead of the cut so the + // refinement still surfaces even for very common roads. Single pass (stable partition) so + // the postcode check — which allocates — runs exactly once per candidate. + if let Some(area) = parsed.postcode_area.as_deref() { + let mut in_area = Vec::new(); + let mut others = Vec::new(); + for &row in &candidate_rows { + if self.row_postcode_in_area(row as usize, area) { + in_area.push(row); + } else { + others.push(row); + } + } + in_area.extend(others); + candidate_rows = in_area; + } + candidate_rows.truncate(ADDRESS_SEARCH_CANDIDATE_LIMIT); + let mut scored: Vec<(i32, usize, usize)> = candidate_rows .into_iter() .filter_map(|row| { @@ -948,18 +1147,29 @@ impl PropertyData { .then(left.2.cmp(&right.2)) }); + // Collapse a road browse (road-type word, no house number) to one row per street. + let collapse_streets = parsed.numeric_terms.is_empty() && query_has_road_type(query); + let mut seen = FxHashSet::default(); let mut results = Vec::with_capacity(limit); - for (_, _, row) in scored { + for (score, _, row) in scored { let address = self.address(row).trim(); if address.is_empty() { continue; } - let key = format!("{}\n{}", address.to_ascii_lowercase(), self.postcode(row)); + let key = if collapse_streets { + format!( + "{}\n{}", + street_key(address), + outcode_of(self.postcode(row)) + ) + } else { + format!("{}\n{}", address.to_ascii_lowercase(), self.postcode(row)) + }; if !seen.insert(key) { continue; } - results.push(row); + results.push((row, score)); if results.len() == limit { break; } @@ -968,36 +1178,75 @@ impl PropertyData { results } - fn best_address_token_rows(&self, terms: &[String]) -> Option<&[u32]> { - let mut best: Option<&[u32]> = None; - - for term in terms { - if let Some(rows) = self.address_token_index.get(term) { - if best.is_none_or(|current| rows.len() < current.len()) { - best = Some(rows.as_slice()); - } - continue; - } - - if term.len() < 4 { - continue; - } - - if let Some(tokens) = self.address_prefix_index.get(address_prefix_key(term)) { - for token in tokens { - if !token.starts_with(term) { - continue; - } - if let Some(rows) = self.address_token_index.get(token) { - if best.is_none_or(|current| rows.len() < current.len()) { - best = Some(rows.as_slice()); - } - } - } + /// True when the row's postcode begins with the compact partial-postcode `area` + /// (e.g. "NW1" or "NW16" matches "NW1 6XE"). + fn row_postcode_in_area(&self, row: usize, area: &str) -> bool { + let mut compact = String::new(); + for ch in self.postcode(row).chars() { + if !ch.is_whitespace() { + compact.push(ch.to_ascii_uppercase()); } } + compact.starts_with(area) + } - best + /// Candidate rows for the distinctive query words. Words typed in full intersect by their + /// exact posting lists (precise); a still-being-typed final word with no exact match seeds + /// from the smallest prefix-expanded posting list (so partial typing keeps working). + fn address_candidate_rows(&self, terms: &[String]) -> Vec<u32> { + let mut exact: Vec<&[u32]> = terms + .iter() + .filter_map(|term| self.address_token_index.get(term).map(Vec::as_slice)) + .collect(); + + if !exact.is_empty() { + exact.sort_by_key(|rows| rows.len()); + let mut acc = exact[0].to_vec(); + for rows in &exact[1..] { + if acc.is_empty() { + break; + } + acc = intersect_sorted(&acc, rows); + } + return acc; + } + + self.prefix_seed_rows(terms) + } + + /// Seed rows from the smallest prefix-expanded term — used only when no word matched an + /// indexed token exactly (i.e. the user is still typing the final word). + fn prefix_seed_rows(&self, terms: &[String]) -> Vec<u32> { + let mut best: Option<Vec<u32>> = None; + for term in terms { + if term.len() < ADDRESS_SEARCH_PREFIX_MIN_LEN { + continue; + } + let Some(tokens) = self.address_prefix_index.get(address_prefix_key(term)) else { + continue; + }; + let mut union: Vec<u32> = Vec::new(); + for token in tokens { + if !token.starts_with(term) { + continue; + } + if let Some(rows) = self.address_token_index.get(token) { + union = if union.is_empty() { + rows.clone() + } else { + union_sorted(&union, rows) + }; + } + } + if !union.is_empty() + && best + .as_ref() + .is_none_or(|current| union.len() < current.len()) + { + best = Some(union); + } + } + best.unwrap_or_default() } fn address_match_score(&self, row: usize, parsed: &AddressQuery) -> Option<i32> { @@ -1037,6 +1286,12 @@ impl PropertyData { if numeric_matches == parsed.numeric_terms.len() && numeric_matches > 0 { score += 50; } + // Additive bias (never a filter) when the row sits in the appended partial postcode. + if let Some(area) = parsed.postcode_area.as_deref() { + if self.row_postcode_in_area(row, area) { + score += 400; + } + } Some(score) } @@ -1969,16 +2224,23 @@ impl PropertyData { } } } - let address_token_count_before_prune = address_token_index.len(); - address_token_index.retain(|_, rows| rows.len() <= ADDRESS_SEARCH_MAX_POSTINGS_PER_TOKEN); + // Keep every distinctive token: common road words ("high", "church", "station") are + // exactly what people search, and dropping them made those roads unsearchable while a + // prefix fallback surfaced the wrong street ("Highbury" for "High"). The candidate scan + // is bounded per query instead (ADDRESS_SEARCH_CANDIDATE_LIMIT), and stop words are + // already excluded from the index, so the largest posting lists stay modest. + let max_postings = address_token_index + .values() + .map(Vec::len) + .max() + .unwrap_or(0); let address_prefix_index = build_address_prefix_index(&address_token_index); let address_search_interner = address_search_rodeo.into_reader(); let address_postings_count: usize = address_token_index.values().map(Vec::len).sum(); tracing::info!( tokens = address_token_index.len(), prefixes = address_prefix_index.len(), - pruned_tokens = - address_token_count_before_prune.saturating_sub(address_token_index.len()), + max_postings_per_token = max_postings, postings = address_postings_count, row_tokens = address_search_token_keys.len(), "Address search index built" @@ -2340,6 +2602,79 @@ mod tests { assert_eq!(parsed.candidate_terms, vec!["downing".to_string()]); } + #[test] + fn address_query_recovers_appended_partial_postcode_as_bias() { + let parsed = parse_address_query("Baker Street NW1"); + assert_eq!(parsed.full_postcode, None); + assert_eq!(parsed.postcode_area.as_deref(), Some("NW1")); + // The road words are still searchable; the postcode fragment did not consume them. + assert_eq!(parsed.candidate_terms, vec!["baker".to_string()]); + assert!(parsed.numeric_terms.is_empty()); + } + + #[test] + fn address_query_recovers_outcode_plus_sector_without_a_phantom_house_number() { + let parsed = parse_address_query("High Street CR0 2"); + assert_eq!(parsed.postcode_area.as_deref(), Some("CR02")); + // The lone sector digit must not be treated as a house number. + assert!(parsed.numeric_terms.is_empty()); + assert_eq!(parsed.candidate_terms, vec!["high".to_string()]); + } + + #[test] + fn full_postcode_takes_precedence_over_partial_bias() { + let parsed = parse_address_query("Baker Street NW1 6XE"); + assert_eq!(parsed.full_postcode.as_deref(), Some("NW1 6XE")); + assert_eq!(parsed.postcode_area, None); + } + + #[test] + fn intersect_and_union_sorted_row_ids() { + assert_eq!( + intersect_sorted(&[1, 2, 3, 5], &[2, 3, 4, 5]), + vec![2, 3, 5] + ); + assert_eq!(intersect_sorted(&[1, 2], &[3, 4]), Vec::<u32>::new()); + assert_eq!(union_sorted(&[1, 3, 5], &[2, 3, 4]), vec![1, 2, 3, 4, 5]); + assert_eq!(union_sorted(&[], &[2, 4]), vec![2, 4]); + } + + #[test] + fn street_key_collapses_house_numbers_and_flats() { + assert_eq!(street_key("12 Baker Street"), "baker street"); + assert_eq!(street_key("5 Baker Street"), "baker street"); + assert_eq!(street_key("Flat 2, 10 Downing Street"), "downing street"); + assert_eq!(street_key("221B Baker Street"), "baker street"); + } + + #[test] + fn street_key_keeps_ordinal_street_names() { + // Ordinals are part of the street name, not a house-number prefix. + assert_eq!(street_key("2nd Avenue"), "2nd avenue"); + assert_eq!(street_key("12 3rd Avenue"), "3rd avenue"); + assert!(is_ordinal_token("21st")); + assert!(!is_ordinal_token("21")); + assert!(!is_ordinal_token("221b")); + } + + #[test] + fn postcode_area_recovered_only_from_the_trailing_position() { + // A leading road designation must NOT be taken as an area refinement. + let parsed = parse_address_query("A4 Great West Road"); + assert_eq!(parsed.postcode_area, None); + // A genuine trailing outcode still is. + let trailing = parse_address_query("Great West Road W4"); + assert_eq!(trailing.postcode_area.as_deref(), Some("W4")); + } + + #[test] + fn road_type_detection() { + assert!(query_has_road_type("high street")); + assert!(query_has_road_type("acacia avenue")); + assert!(!query_has_road_type("acacia")); + assert!(!query_has_road_type("london")); + } + #[test] fn address_query_parsing_keeps_partial_terms_for_row_matching() { let parsed = parse_address_query("settlers cour"); diff --git a/server-rs/src/features.rs b/server-rs/src/features.rs index 25bd6cb..74b74f9 100644 --- a/server-rs/src/features.rs +++ b/server-rs/src/features.rs @@ -202,7 +202,7 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ }, step: 1.0, description: "Maximum transport noise level near the postcode in decibels (Lden)", - detail: "Maximum road, rail, or airport noise level in decibels (Lden, a 24-hour weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.", + detail: "Loudest of road, rail, or airport noise in decibels (Lden, a 24-hour day-evening-night weighted average) from Defra's Strategic Noise Mapping Round 4 (2022). Covers England only; rail noise dominates the value at ~120k postcodes and airport noise at ~4k. Modelled at 4m above ground on a 10m grid and sampled as the maximum 10m cell around the postcode representative point. Blank means no mapped data in the source (Wales, Scotland and areas away from major roads/railways/airports all return blank) — not necessarily quiet. Above ~55 dB is typically noticeable; above ~70 dB is considered harmful by the WHO.", source: "noise", prefix: "", suffix: " dB", @@ -832,14 +832,14 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ absolute: false, }), Feature::Numeric(FeatureConfig { - name: "% East Asian", + name: "% East/SE Asian", bounds: Bounds::Fixed { min: 0.0, max: 100.0, }, step: 0.1, - description: "Percentage of population identifying as East Asian", - detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese.", + description: "Percentage of population identifying as East or Southeast Asian", + detail: "From the 2021 Census. Percentage of the local authority population identifying as Chinese, Vietnamese, Filipino, Thai, or any other East or Southeast Asian background.", source: "ethnicity", prefix: "", suffix: "%", @@ -987,12 +987,20 @@ pub static FEATURE_GROUPS: &[FeatureGroup] = &[ FeatureGroup { name: "Amenities", features: &[ - Feature::Enum(EnumFeatureConfig { + Feature::Numeric(FeatureConfig { name: "Max available download speed (Mbps)", - order: Some(&["10", "30", "100", "300", "1000"]), + bounds: Bounds::Fixed { + min: 10.0, + max: 1000.0, + }, + step: 1.0, description: "Maximum broadband download speed available at the postcode", - detail: "Maximum fixed broadband download speed available from any provider, from Ofcom Connected Nations 2025. Represents theoretical maximum, not achieved speeds. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit.", + detail: "Maximum fixed broadband download speed available from any provider, from Ofcom Connected Nations 2025. Represents theoretical maximum, not achieved speeds. 10 Mbps = basic, 30 = superfast, 100+ = ultrafast, 1000 = gigabit. Null where no availability data is published.", source: "broadband", + prefix: "", + suffix: " Mbps", + raw: true, + absolute: true, }), ], }, diff --git a/server-rs/src/main.rs b/server-rs/src/main.rs index b1293fc..ac2275c 100644 --- a/server-rs/src/main.rs +++ b/server-rs/src/main.rs @@ -19,6 +19,25 @@ mod routes; mod state; pub mod utils; +// Use jemalloc as the global allocator. glibc malloc keeps freed memory in many +// per-CPU arenas and rarely returns it to the OS, so the transient buffers from +// the rayon/Polars-parallel data load stay resident and inflate steady-state RSS. +// jemalloc's decay-based purging (configured below) hands those pages back. +#[cfg(not(target_env = "msvc"))] +#[global_allocator] +static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +// Return dirty/muzzy pages to the OS ~1s after they go idle, and run a background +// thread to do so proactively (so RSS drops after the startup load peak without +// waiting for the next allocation). Read by jemalloc at startup via the +// `malloc_conf` symbol (unprefixed on Linux). Can be overridden by the +// `_RJEM_MALLOC_CONF` / `MALLOC_CONF` env var. +#[cfg(not(target_env = "msvc"))] +#[allow(non_upper_case_globals)] +#[used] +#[export_name = "malloc_conf"] +pub static malloc_conf: &[u8] = b"background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:1000\0"; + use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -507,8 +526,7 @@ async fn main() -> anyhow::Result<()> { "property_borders.pmtiles", ); - let noise_overlay_reader = - init_required_tile_reader("Noise", &noise_overlay_tiles).await?; + let noise_overlay_reader = init_required_tile_reader("Noise", &noise_overlay_tiles).await?; let satellite_reader = init_required_tile_reader("Satellite", &satellite_tiles).await?; let satellite_highres_reader = init_required_tile_reader("Satellite high-res", &satellite_highres_tiles).await?; diff --git a/server-rs/src/routes/export.rs b/server-rs/src/routes/export.rs index 505e939..ae5f588 100644 --- a/server-rs/src/routes/export.rs +++ b/server-rs/src/routes/export.rs @@ -208,7 +208,7 @@ struct ParsedPostcodeList { fn parse_postcode_list( raw: &str, state: &crate::state::AppState, -) -> Result<ParsedPostcodeList, axum::response::Response> { +) -> Result<ParsedPostcodeList, (StatusCode, String)> { let mut entries: Vec<(usize, String)> = Vec::new(); let mut unknown: Vec<String> = Vec::new(); let mut seen: FxHashSet<usize> = FxHashSet::default(); @@ -229,8 +229,7 @@ fn parse_postcode_list( "Too many postcodes; at most {} are supported per export", MAX_EXPORT_POSTCODES ), - ) - .into_response()); + )); } match state.postcode_data.postcode_to_idx.get(&normalized) { Some(&pc_idx) if seen.insert(pc_idx) => { @@ -245,8 +244,7 @@ fn parse_postcode_list( return Err(( StatusCode::BAD_REQUEST, "No valid postcodes supplied".to_string(), - ) - .into_response()); + )); } Ok(ParsedPostcodeList { entries, unknown }) @@ -296,7 +294,9 @@ pub async fn get_export( // Two modes: bounds-based (default) and explicit postcode list. let postcode_list = match params.postcodes.as_deref() { - Some(raw) if !raw.trim().is_empty() => Some(parse_postcode_list(raw, &state)?), + Some(raw) if !raw.trim().is_empty() => { + Some(parse_postcode_list(raw, &state).map_err(IntoResponse::into_response)?) + } _ => None, }; let is_postcode_mode = postcode_list.is_some(); diff --git a/server-rs/src/routes/places.rs b/server-rs/src/routes/places.rs index 430517e..491a854 100644 --- a/server-rs/src/routes/places.rs +++ b/server-rs/src/routes/places.rs @@ -2,14 +2,26 @@ use std::sync::Arc; use axum::extract::{Query, State}; use axum::response::Json; +use rustc_hash::FxHashSet; use serde::{Deserialize, Serialize}; use tracing::info; use crate::api_error::ApiError; use crate::consts::PLACES_LIMIT; -use crate::data::{normalize_search_text, slugify}; +use crate::data::{ + compute_trigrams, normalize_search_text, place_alias_tokens, slugify, trigram_similarity, +}; use crate::state::SharedState; +/// Trailing connective words dropped from a place query so "fish and chips" matches a place +/// stored (after `&` is normalized away) as "fish chips". +const QUERY_STOP_WORDS: &[&str] = &["and", "the", "of"]; + +/// Minimum trigram similarity for a fuzzy place match. +const FUZZY_MIN_SIMILARITY: f32 = 0.42; +/// Run the (linear) fuzzy pass only when the exact passes found fewer than this. +const FUZZY_TRIGGER_BELOW: usize = 3; + #[derive(Serialize)] pub struct PlaceResult { name: String, @@ -29,6 +41,43 @@ pub struct AddressResult { lon: f32, } +/// A single, category-tagged, relevance-scored result. The frontend renders these in order, +/// so ranking is unified across places, outcodes, postcodes and addresses instead of the old +/// fixed positional bucketing. +#[derive(Serialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum UnifiedResult { + Place { + name: String, + slug: String, + place_type: String, + lat: f32, + lon: f32, + #[serde(skip_serializing_if = "Option::is_none")] + city: Option<String>, + score: f32, + }, + Postcode { + label: String, + score: f32, + }, + Address { + address: String, + postcode: String, + lat: f32, + lon: f32, + score: f32, + }, +} + +fn unified_score(result: &UnifiedResult) -> f32 { + match result { + UnifiedResult::Place { score, .. } + | UnifiedResult::Postcode { score, .. } + | UnifiedResult::Address { score, .. } => *score, + } +} + #[derive(Serialize)] pub struct PlacesResponse { places: Vec<PlaceResult>, @@ -36,6 +85,9 @@ pub struct PlacesResponse { postcodes: Vec<String>, #[serde(skip_serializing_if = "Vec::is_empty")] addresses: Vec<AddressResult>, + /// Unified, relevance-ordered results. Preferred by the frontend; the arrays above remain + /// for backward compatibility. + results: Vec<UnifiedResult>, } #[derive(Deserialize)] @@ -44,6 +96,9 @@ pub struct PlacesParams { q: String, /// If set, only return places that have travel time data for this mode. mode: Option<String>, + /// Optional map-viewport centre used to bias ranking toward what the user is looking at. + lat: Option<f32>, + lng: Option<f32>, } fn compact_postcode_query(query: &str) -> String { @@ -93,6 +148,131 @@ fn postcode_starts_with_compact(postcode: &str, compact_query: &str) -> bool { current.is_none() } +fn is_postcode_fragmentish(token: &str) -> bool { + (2..=4).contains(&token.len()) + && token + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_alphabetic()) + && token.chars().any(|ch| ch.is_ascii_digit()) + && token.chars().all(|ch| ch.is_ascii_alphanumeric()) +} + +/// Peel a trailing geographic refinement (outcode, or outcode + sector digit) off the query. +/// "camden nw1" → ("camden", Some("NW1")); the core matches the place, the refinement biases +/// ranking and drives the outcode/postcode lists — instead of breaking the match entirely. +fn split_geographic_refinement(query: &str) -> (String, Option<String>) { + let words: Vec<&str> = query.split_whitespace().collect(); + if words.len() < 2 { + return (query.to_string(), None); + } + let last = words[words.len() - 1]; + if words.len() >= 3 && last.len() == 1 && last.chars().all(|ch| ch.is_ascii_digit()) { + let prev = words[words.len() - 2]; + if is_postcode_fragmentish(prev) { + let area = format!("{}{}", prev.to_ascii_uppercase(), last); + return (words[..words.len() - 2].join(" "), Some(area)); + } + } + if is_postcode_fragmentish(last) { + return ( + words[..words.len() - 1].join(" "), + Some(last.to_ascii_uppercase()), + ); + } + (query.to_string(), None) +} + +/// Content words of a place query, dropping connectives so "fish and chips" matches "Fish & Chips". +fn query_content_tokens(query_search: &str) -> Vec<&str> { + query_search + .split(' ') + .filter(|token| !token.is_empty() && !QUERY_STOP_WORDS.contains(token)) + .collect() +} + +/// Base relevance tier for a place, or None if it does not match at all. +fn place_base_score( + search_text: &str, + name_lower: &str, + query_search: &str, + query_lower: &str, + query_tokens: &[&str], +) -> Option<f32> { + if query_search.is_empty() { + return None; + } + + let mut exact = name_lower == query_lower; + let mut prefix = name_lower.starts_with(query_lower); + for alias in search_text.split(" | ") { + if alias == query_search { + exact = true; + } + if alias.starts_with(query_search) { + prefix = true; + } + } + if exact { + return Some(1000.0); + } + if prefix { + return Some(820.0); + } + + if !query_tokens.is_empty() { + let all_covered = query_tokens.iter().all(|query_token| { + place_alias_tokens(search_text).any(|token| { + token == *query_token || (query_token.len() >= 2 && token.starts_with(query_token)) + }) + }); + if all_covered { + return Some(640.0); + } + } + + None +} + +/// Small additive bonuses: more important place types and bigger populations rank higher. +fn place_modifiers(type_rank: u8, population: u32) -> f32 { + let type_bonus = f32::from(6u8.saturating_sub(type_rank)) * 8.0; + let pop_bonus = (population as f32 + 1.0).ln() * 4.0; + type_bonus + pop_bonus.min(64.0) +} + +/// Distance-decay bonus toward the viewport / refinement centre. Capped below the gap between +/// match tiers so it reorders within a tier and breaks ties without overriding exact matches. +fn proximity_bonus(center: Option<(f32, f32)>, lat: f32, lon: f32) -> f32 { + let Some((center_lat, center_lon)) = center else { + return 0.0; + }; + let dlat = lat - center_lat; + let dlon = (lon - center_lon) * center_lat.to_radians().cos(); + let dist = (dlat * dlat + dlon * dlon).sqrt(); + 160.0 * (-dist / 0.3).exp() +} + +/// Map an address match's raw specificity score onto the unified scale. +fn address_unified_score(raw: i32) -> f32 { + 460.0 + raw.min(1000) as f32 * 0.47 +} + +/// Resolve the outcode a compact partial postcode sits in (e.g. "NW16" → "nw1"), trying +/// progressively shorter prefixes against the known outcode set. Returns its index. +fn resolve_outcode_idx(name_lower: &[String], area: &str) -> Option<usize> { + let area_lower = area.to_lowercase(); + let mut len = area_lower.len(); + while len >= 2 { + let candidate = &area_lower[..len]; + if let Some(idx) = name_lower.iter().position(|name| name == candidate) { + return Some(idx); + } + len -= 1; + } + None +} + pub async fn get_places( State(shared): State<Arc<SharedState>>, Query(params): Query<PlacesParams>, @@ -106,154 +286,229 @@ pub async fn get_places( let limit = PLACES_LIMIT; let mode_filter = params.mode; + let viewport = match (params.lat, params.lng) { + (Some(lat), Some(lng)) => Some((lat, lng)), + _ => None, + }; - let places = tokio::task::spawn_blocking(move || { + let response = tokio::task::spawn_blocking(move || { let t0 = std::time::Instant::now(); - let query_lower = query.to_lowercase(); - let query_search = normalize_search_text(&query); let pd = &state.place_data; let od = &state.outcode_data; let postcode_data = &state.postcode_data; let tt_store = &state.travel_time_store; let property_data = &state.data; - // Linear scan — ~50-100k rows, <1ms - // Tuple: (row_idx, is_exact, is_prefix, type_rank, population, name_len, slug) - let mut matches: Vec<(usize, bool, bool, u8, u32, usize, String)> = pd - .name_search - .iter() - .enumerate() - .filter_map(|(idx, search_text)| { - if query_search.is_empty() || !search_text.contains(&query_search) { - return None; - } - let slug = slugify(&pd.name[idx]); + // Peel any appended outcode/partial-postcode so the place text matches on the core + // words while the refinement biases ranking and drives the outcode/postcode lists. + let (split_query, refinement) = split_geographic_refinement(&query); + // Only honour the refinement when it resolves to a real outcode; otherwise (e.g. "the o2", + // where "o2" looks postcode-ish but is not an outcode) treat the whole query as place text. + let refinement_outcode = refinement + .as_deref() + .and_then(|area| resolve_outcode_idx(&od.name_lower, area)); + let place_query = if refinement.is_some() && refinement_outcode.is_none() { + query.clone() + } else { + split_query + }; + let query_search = normalize_search_text(&place_query); + let query_lower = place_query.to_lowercase(); + let query_tokens = query_content_tokens(&query_search); - // If mode filter is set, keep the historical travel destination set only. - if let Some(ref mode) = mode_filter { - if !pd.travel_destination[idx] || !tt_store.has_destination(mode, &slug) { - return None; - } - } + // Bias centre: explicit viewport, else the resolved refinement outcode's centroid. + let bias_center = viewport.or_else(|| refinement_outcode.map(|idx| od.centroids[idx])); - let is_exact = search_text - .split(" | ") - .any(|alias| alias == query_search || pd.name_lower[idx] == query_lower); - let is_prefix = search_text - .split(" | ") - .any(|alias| alias.starts_with(&query_search)) - || pd.name_lower[idx].starts_with(&query_lower); - Some(( - idx, - is_exact, - is_prefix, - pd.type_rank[idx], - pd.population[idx], - pd.name[idx].len(), - slug, - )) + // ---- Places: candidate rows from the inverted token index, then exact/prefix/token-AND + // scoring — bounded by matched candidates, not the ~1M-row corpus. Fuzzy fallback uses the + // (small) trigram index over fuzzy-eligible rows only. + let mut place_results: Vec<(f32, PlaceResult)> = Vec::new(); + let mut matched_place_idx: FxHashSet<usize> = FxHashSet::default(); + let make_place = |idx: usize| PlaceResult { + name: pd.name[idx].clone(), + slug: slugify(&pd.name[idx]), + place_type: pd.place_type.get(idx).to_string(), + lat: pd.lat[idx], + lon: pd.lon[idx], + city: pd.city[idx].clone(), + }; + let passes_mode = |idx: usize| { + mode_filter.as_ref().is_none_or(|mode| { + pd.travel_destination[idx] + && tt_store.has_destination(mode, &slugify(&pd.name[idx])) }) - .collect(); + }; - // Sort: exact first, then prefix, then type rank asc, then population desc, then name length asc - matches.sort_unstable_by(|lhs, rhs| { - rhs.1 - .cmp(&lhs.1) - .then(rhs.2.cmp(&lhs.2)) - .then(lhs.3.cmp(&rhs.3)) - .then(rhs.4.cmp(&lhs.4)) - .then(lhs.5.cmp(&rhs.5)) - }); + for row in pd.place_candidate_rows(&query_tokens) { + let idx = row as usize; + let Some(base) = place_base_score( + &pd.name_search[idx], + &pd.name_lower[idx], + &query_search, + &query_lower, + &query_tokens, + ) else { + continue; + }; + if !passes_mode(idx) { + continue; + } + let score = base + + place_modifiers(pd.type_rank[idx], pd.population[idx]) + + proximity_bonus(bias_center, pd.lat[idx], pd.lon[idx]); + matched_place_idx.insert(idx); + place_results.push((score, make_place(idx))); + } - matches.truncate(limit); + // Fuzzy (trigram) fallback only when the exact passes were thin and the query is long + // enough to be discriminating. + if place_results.len() < FUZZY_TRIGGER_BELOW && query_search.len() >= 4 { + let query_trigrams = compute_trigrams(&place_query); + for row in pd.fuzzy_candidate_rows(&query_trigrams) { + let idx = row as usize; + if matched_place_idx.contains(&idx) || !passes_mode(idx) { + continue; + } + let similarity = + trigram_similarity(&query_trigrams, &compute_trigrams(&pd.name[idx])); + if similarity < FUZZY_MIN_SIMILARITY { + continue; + } + let score = 280.0 + + similarity * 120.0 + + place_modifiers(pd.type_rank[idx], pd.population[idx]) + + proximity_bonus(bias_center, pd.lat[idx], pd.lon[idx]); + matched_place_idx.insert(idx); + place_results.push((score, make_place(idx))); + } + } - let mut results: Vec<PlaceResult> = matches - .iter() - .map(|(idx, .., slug)| PlaceResult { - name: pd.name[*idx].clone(), - slug: slug.clone(), - place_type: pd.place_type.get(*idx).to_string(), - lat: pd.lat[*idx], - lon: pd.lon[*idx], - city: pd.city[*idx].clone(), - }) - .collect(); - - // Also search outcodes (skip when mode filter is set — outcodes aren't travel destinations) - if mode_filter.is_none() { - let query_upper = query_lower.to_uppercase(); - let mut outcode_results: Vec<PlaceResult> = od - .name_lower - .iter() - .enumerate() - .filter_map(|(idx, name)| { - if !name.starts_with(&query_lower) { - return None; - } - let is_exact = name.len() == query_lower.len(); - Some((idx, is_exact)) - }) - .collect::<Vec<_>>() - .into_iter() - .map(|(idx, _is_exact)| PlaceResult { + // ---- Outcodes (skipped under a mode filter) ---- + let push_outcode = |results: &mut Vec<(f32, PlaceResult)>, idx: usize, base: f32| { + let (clat, clon) = od.centroids[idx]; + results.push(( + base + proximity_bonus(bias_center, clat, clon), + PlaceResult { name: od.names[idx].clone(), slug: od.names[idx].to_lowercase(), place_type: "outcode".to_string(), - lat: od.centroids[idx].0, - lon: od.centroids[idx].1, + lat: clat, + lon: clon, city: od.cities[idx].clone(), - }) - .collect(); - - // Sort outcodes: exact first, then by name length (shorter = broader area) - outcode_results.sort_unstable_by(|a, b| { - let a_exact = a.name.eq_ignore_ascii_case(&query_upper); - let b_exact = b.name.eq_ignore_ascii_case(&query_upper); - b_exact.cmp(&a_exact).then(a.name.len().cmp(&b.name.len())) - }); - - // Prepend outcode results (up to 3) before place results, keeping total ≤ limit - outcode_results.truncate(3); - let place_slots = limit.saturating_sub(outcode_results.len()); - results.truncate(place_slots); - outcode_results.append(&mut results); - results = outcode_results; + }, + )); + }; + if mode_filter.is_none() { + if let Some(idx) = refinement_outcode { + // A refinement ("camden nw1") resolves to exactly one outcode — no NW10/NW11 noise. + push_outcode(&mut place_results, idx, 980.0); + } else if looks_like_postcode_prefix(&query) { + // A bare postcode-prefix query ("e1") lists matching outcodes (e1, e10, e11, ...). + let area_lower = compact_postcode_query(&query).to_lowercase(); + for idx in 0..od.names.len() { + let name = &od.name_lower[idx]; + let is_exact = *name == area_lower; + if !(name.starts_with(&area_lower) || area_lower.starts_with(name.as_str())) { + continue; + } + push_outcode( + &mut place_results, + idx, + if is_exact { 980.0 } else { 760.0 }, + ); + } + } } - let postcodes: Vec<String> = if mode_filter.is_none() && looks_like_postcode_prefix(&query) - { - let compact_query = compact_postcode_query(&query); - postcode_data - .postcodes - .iter() - .filter(|postcode| postcode_starts_with_compact(postcode, &compact_query)) - .filter(|postcode| !property_data.rows_for_postcode(postcode).is_empty()) - .take(limit) - .cloned() - .collect() - } else { - Vec::new() - }; + place_results.sort_by(|left, right| right.0.total_cmp(&left.0)); + place_results.truncate(limit); - let addresses: Vec<AddressResult> = if mode_filter.is_none() { - property_data - .search_addresses(&query, limit) - .into_iter() - .map(|row| AddressResult { - address: property_data.address(row).trim().to_string(), - postcode: property_data.postcode(row).to_string(), - lat: property_data.lat[row], - lon: property_data.lon[row], - }) - .collect() - } else { - Vec::new() - }; + // ---- Postcodes (full-postcode prefix list) ---- + let mut postcode_results: Vec<(f32, String)> = Vec::new(); + if mode_filter.is_none() && looks_like_postcode_prefix(&query) { + let compact_query = compact_postcode_query(&query); + for postcode in &postcode_data.postcodes { + if !postcode_starts_with_compact(postcode, &compact_query) { + continue; + } + if property_data.rows_for_postcode(postcode).is_empty() { + continue; + } + let compact_pc: String = + postcode.chars().filter(|ch| !ch.is_whitespace()).collect(); + let score = if compact_pc == compact_query { + 960.0 + } else { + 900.0 + }; + postcode_results.push((score, postcode.clone())); + if postcode_results.len() >= limit { + break; + } + } + } + postcode_results.sort_by(|left, right| right.0.total_cmp(&left.0)); + + // ---- Addresses ---- + let mut address_results: Vec<(f32, AddressResult)> = Vec::new(); + if mode_filter.is_none() { + for (row, raw) in property_data.search_addresses(&query, limit) { + let lat = property_data.lat[row]; + let lon = property_data.lon[row]; + let score = address_unified_score(raw) + proximity_bonus(bias_center, lat, lon); + address_results.push(( + score, + AddressResult { + address: property_data.address(row).trim().to_string(), + postcode: property_data.postcode(row).to_string(), + lat, + lon, + }, + )); + } + } + address_results.sort_by(|left, right| right.0.total_cmp(&left.0)); + + // ---- Unified merge: one relevance-ordered list across every source ---- + let mut unified: Vec<UnifiedResult> = Vec::new(); + for (score, place) in &place_results { + unified.push(UnifiedResult::Place { + name: place.name.clone(), + slug: place.slug.clone(), + place_type: place.place_type.clone(), + lat: place.lat, + lon: place.lon, + city: place.city.clone(), + score: *score, + }); + } + for (score, postcode) in &postcode_results { + unified.push(UnifiedResult::Postcode { + label: postcode.clone(), + score: *score, + }); + } + for (score, address) in &address_results { + unified.push(UnifiedResult::Address { + address: address.address.clone(), + postcode: address.postcode.clone(), + lat: address.lat, + lon: address.lon, + score: *score, + }); + } + unified.sort_by(|left, right| unified_score(right).total_cmp(&unified_score(left))); + unified.truncate(limit); + + let places: Vec<PlaceResult> = place_results.into_iter().map(|(_, p)| p).collect(); + let postcodes: Vec<String> = postcode_results.into_iter().map(|(_, p)| p).collect(); + let addresses: Vec<AddressResult> = address_results.into_iter().map(|(_, a)| a).collect(); let elapsed = t0.elapsed(); info!( query = query.as_str(), - results = results.len(), + results = unified.len(), + places = places.len(), postcodes = postcodes.len(), addresses = addresses.len(), scanned = pd.name_lower.len(), @@ -262,16 +517,17 @@ pub async fn get_places( "GET /api/places" ); - (results, postcodes, addresses) + PlacesResponse { + places, + postcodes, + addresses, + results: unified, + } }) .await .map_err(|error| ApiError::Internal(error.to_string()))?; - Ok(Json(PlacesResponse { - places: places.0, - postcodes: places.1, - addresses: places.2, - })) + Ok(Json(response)) } #[cfg(test)] @@ -293,4 +549,88 @@ mod tests { assert!(postcode_starts_with_compact("SW1A 1AA", "SW1A1")); assert!(!postcode_starts_with_compact("SW1A 1AA", "SW1A2")); } + + #[test] + fn refinement_splits_off_trailing_outcode() { + assert_eq!( + split_geographic_refinement("camden nw1"), + ("camden".to_string(), Some("NW1".to_string())) + ); + assert_eq!( + split_geographic_refinement("high street cr0 2"), + ("high street".to_string(), Some("CR02".to_string())) + ); + // A bare outcode is not split (handled by the outcode/postcode path directly). + assert_eq!( + split_geographic_refinement("e14"), + ("e14".to_string(), None) + ); + // No trailing postcode → unchanged. + assert_eq!( + split_geographic_refinement("baker street"), + ("baker street".to_string(), None) + ); + } + + #[test] + fn query_tokens_drop_connectives() { + assert_eq!( + query_content_tokens("fish and chips"), + vec!["fish", "chips"] + ); + assert_eq!(query_content_tokens("isle of dogs"), vec!["isle", "dogs"]); + } + + fn base(search: &str, query: &str) -> Option<f32> { + let q = normalize_search_text(query); + let tokens = query_content_tokens(&q); + place_base_score(search, search, &q, &query.to_lowercase(), &tokens) + } + + #[test] + fn place_match_tiers_order_exact_above_prefix_above_token_and() { + let exact = base("camden", "camden").unwrap(); + let prefix = base("camden town", "camden").unwrap(); + let token_and = base("camden market", "market camden").unwrap(); + assert!(exact > prefix); + assert!(prefix > token_and); + // A reordered multi-word query still matches via token-AND. + assert!(base("manchester piccadilly", "piccadilly manchester").is_some()); + // Pure infix substrings no longer match (candidates are token-based): "ford" must not + // surface "Stratford" — that was the old population-dominated noise. + assert!(base("stratford", "ford").is_none()); + // Appended noise that matches nothing yields no match (the route strips postcodes first). + assert!(base("camden", "camden zzzz").is_none()); + } + + #[test] + fn address_full_postcode_outranks_an_outcode_prefix() { + // raw 1200 ≈ road + full postcode + number; outcode prefix base is 760. + assert!(address_unified_score(1200) > 760.0); + // a road-only address (raw 200) ranks below an outcode prefix. + assert!(address_unified_score(200) < 760.0); + assert!(address_unified_score(1200) > address_unified_score(200)); + } + + #[test] + fn proximity_bonus_decays_and_never_flips_match_tiers() { + let here = proximity_bonus(Some((51.5, -0.1)), 51.5, -0.1); + let far = proximity_bonus(Some((51.5, -0.1)), 53.5, -2.0); + assert!(here > far); + assert!(here <= 160.0); + // Smaller than the 180-pt gap between exact (1000) and prefix (820). + assert!(here < 180.0); + assert_eq!(proximity_bonus(None, 51.5, -0.1), 0.0); + } + + #[test] + fn resolve_outcode_idx_handles_sectorised_area_and_unknown() { + let names = vec!["nw1".to_string(), "e14".to_string()]; + // "NW16" → outcode NW1 (strips the sector digit); "E14" → exact. + assert_eq!(resolve_outcode_idx(&names, "NW16"), Some(0)); + assert_eq!(resolve_outcode_idx(&names, "E14"), Some(1)); + // A postcode-ish token that is not a real outcode resolves to nothing (folds back). + assert_eq!(resolve_outcode_idx(&names, "O2"), None); + assert_eq!(resolve_outcode_idx(&names, "ZZ9"), None); + } } diff --git a/server-rs/src/routes/stats.rs b/server-rs/src/routes/stats.rs index cd9eb75..ec50ede 100644 --- a/server-rs/src/routes/stats.rs +++ b/server-rs/src/routes/stats.rs @@ -282,17 +282,23 @@ pub fn compute_crime_by_year( for &row in matching_rows { let postcode = data.postcode(row); - let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) else { - continue; - }; - // For every type the postcode reports, add its per-year counts. - // For types it doesn't report, treat the row as contributing 0 — so we - // bump the row count for *every* known type below. - for series in series_list { - let acc = &mut per_type_year_sums[series.type_idx as usize]; - for point in &series.points { - *acc.entry(point.year).or_insert(0.0) += point.count as f64; + // A postcode absent from the by-year table has no recorded crime within + // 50m, so it contributes 0 to every type's per-year sum. It must still be + // counted in the denominator: the matching `(avg/yr)` stat counts those + // same zero-crime postcodes as 0.0 (crime_by_postcode.parquet has a dense + // row for every boundary postcode), so excluding them here would compute + // the chart over a smaller population and report a higher magnitude than + // the headline. Property postcodes are guaranteed to be boundary + // postcodes by the postcode-boundary-match validation, so "absent" means + // genuinely zero-crime, not missing data. + if let Some(series_list) = crime_by_year.series_by_postcode.get(postcode) { + // For every type the postcode reports, add its per-year counts. + for series in series_list { + let acc = &mut per_type_year_sums[series.type_idx as usize]; + for point in &series.points { + *acc.entry(point.year).or_insert(0.0) += point.count as f64; + } } } for c in per_type_row_counts.iter_mut() { diff --git a/server-rs/src/routes/tiles.rs b/server-rs/src/routes/tiles.rs index b9efefd..bd79b5b 100644 --- a/server-rs/src/routes/tiles.rs +++ b/server-rs/src/routes/tiles.rs @@ -1,13 +1,64 @@ +use std::os::unix::fs::FileExt; use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::{header, StatusCode}; use axum::response::{IntoResponse, Response}; -use pmtiles::{AsyncPmTilesReader, MmapBackend, TileCoord}; +use bytes::Bytes; +use pmtiles::{ + AsyncBackend, AsyncPmTilesReader, BackendResponse, HashMapCache, PmtError, PmtResult, TileCoord, +}; use serde::Deserialize; use tracing::warn; -pub type TileReader = AsyncPmTilesReader<MmapBackend>; +/// PMTiles archives are read straight from disk with positional `pread` calls +/// instead of being memory-mapped. With an mmap backend every touched tile page +/// is attributed to the process RSS (~21 GB across all tilesets); with `pread` +/// the file contents stay in the (reclaimable) kernel page cache and only the +/// small per-request buffer lives in the process. A [`HashMapCache`] keeps the +/// archive's directory entries in memory so tile lookups don't re-read and +/// re-decompress directory pages from disk on every request. +pub type TileReader = AsyncPmTilesReader<FileBackend, HashMapCache>; + +/// An [`AsyncBackend`] that serves byte ranges from a local file via `pread`. +pub struct FileBackend { + file: Arc<std::fs::File>, + len: u64, +} + +impl FileBackend { + pub fn open(path: &std::path::Path) -> std::io::Result<Self> { + let file = std::fs::File::open(path)?; + let len = file.metadata()?.len(); + Ok(Self { + file: Arc::new(file), + len, + }) + } +} + +impl AsyncBackend for FileBackend { + async fn read(&self, offset: usize, length: usize) -> PmtResult<BackendResponse> { + let available = (self.len as usize).saturating_sub(offset); + let read_len = length.min(available); + if read_len == 0 { + return Ok(BackendResponse::new(Bytes::new())); + } + + let file = Arc::clone(&self.file); + // pread is blocking; keep it off the async runtime's worker threads. + let buf = tokio::task::spawn_blocking(move || { + let mut buf = vec![0u8; read_len]; + file.read_exact_at(&mut buf, offset as u64)?; + std::io::Result::Ok(buf) + }) + .await + .map_err(|err| PmtError::Reading(std::io::Error::other(err)))? + .map_err(PmtError::Reading)?; + + Ok(BackendResponse::new(Bytes::from(buf))) + } +} pub async fn get_tile( State(reader): State<Arc<TileReader>>, @@ -260,7 +311,7 @@ fn build_style(is_dark: bool, layers: &[serde_json::Value], tile_url: &str) -> s } pub async fn init_tile_reader(path: &std::path::Path) -> anyhow::Result<TileReader> { - let backend = MmapBackend::try_from(path).await?; - let reader = AsyncPmTilesReader::try_from_source(backend).await?; + let backend = FileBackend::open(path)?; + let reader = AsyncPmTilesReader::try_from_cached_source(backend, HashMapCache::default()).await?; Ok(reader) } diff --git a/video/src/storyboard.ts b/video/src/storyboard.ts index 6b5051c..1f45a4a 100644 --- a/video/src/storyboard.ts +++ b/video/src/storyboard.ts @@ -549,7 +549,7 @@ function createRecordingStoryboard( 'Good+ primary schools within 2km': [1, 10], 'Noise (dB)': [50, 70], 'Street tree density percentile': [25, 100], - 'Max available download speed (Mbps)': ['100', '300', '1000'], + 'Max available download speed (Mbps)': [100, 1000], }, // Travel-time filters returned by the AI stub. Slug matches the real // /api/travel-destinations?mode=transit response.