"""Download planned/pipeline development sites for the "new developments" layer. This is the forward-looking "where new homes are coming" signal that complements the EPC new-build + Land Registry "first sale" delivery signals already in the pipeline. Two national, Open Government Licence v3.0 sources are merged into a single coordinate-keyed parquet that the Rust server serves as a map layer: - MHCLG Brownfield Land register (statutory LPA brownfield registers, normalised) https://www.planning.data.gov.uk/dataset/brownfield-land - Homes England Land Hub (public land being disposed for development) https://www.gov.uk/government/publications/homes-england-land-hub Output schema (one row per site): lat, lon, source, name, min_dwellings, max_dwellings, planning_status, permission_type, permission_date, hectares, local_authority, url """ import argparse import math import re import tempfile from pathlib import Path import httpx import polars as pl from shapely.geometry import shape from pipeline.local_temp import local_tmp_dir from pipeline.utils.download import download BROWNFIELD_URL = "https://files.planning.data.gov.uk/dataset/brownfield-land.parquet" HOMES_ENGLAND_URL = ( "https://services-eu1.arcgis.com/yo0w4PgP4XL49bfF/arcgis/rest/services/" "Homes_England_Land_Hub_Sites/FeatureServer/0/query" ) # Output column order; doubles as the polars schema so empty inputs still produce # a well-typed frame. The Rust loader (data/developments.rs) reads these names. SCHEMA: dict[str, pl.DataType] = { "lat": pl.Float64, "lon": pl.Float64, "source": pl.String, "name": pl.String, "min_dwellings": pl.Int32, "max_dwellings": pl.Int32, "planning_status": pl.String, "permission_type": pl.String, "permission_date": pl.String, "hectares": pl.Float64, "local_authority": pl.String, "url": pl.String, } _COORD = r"-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?" _POINT_RE = re.compile(rf"POINT\s*\(\s*({_COORD})\s+({_COORD})\s*\)", re.IGNORECASE) # 1 acre = 0.404686 hectares. The Homes England feed reports area in acres. _ACRES_TO_HECTARES = 0.404686 # Great Britain bounding box, used to reject mis-ordered or projected coordinates # (e.g. a WKT written "lat lon", or stray British National Grid eastings). _LON_MIN, _LON_MAX = -9.0, 2.1 _LAT_MIN, _LAT_MAX = 49.0, 61.1 def _first(row: dict, *keys: str): """First present, non-None value among ``keys`` (case-sensitive).""" for key in keys: if key in row and row[key] is not None: return row[key] return None def _clean_str(value) -> str | None: if value is None: return None text = str(value).strip() return text or None def _to_int(value) -> int | None: if value is None: return None try: number = float(value) except (TypeError, ValueError): return None if not math.isfinite(number): # NaN or +/-inf (int(inf) raises OverflowError) return None return int(number) def _to_float(value) -> float | None: if value is None: return None try: number = float(value) except (TypeError, ValueError): return None if not math.isfinite(number): # NaN or +/-inf return None return number def _valid_lonlat(lon: float, lat: float) -> bool: return _LON_MIN <= lon <= _LON_MAX and _LAT_MIN <= lat <= _LAT_MAX def _point_lonlat(row: dict) -> tuple[float, float] | None: """Extract (lon, lat) from a brownfield row's WKT point or lat/lon columns.""" wkt = _clean_str(_first(row, "point")) if wkt: match = _POINT_RE.match(wkt) if match: lon, lat = float(match.group(1)), float(match.group(2)) if _valid_lonlat(lon, lat): return lon, lat lon = _to_float(_first(row, "longitude", "long", "lng")) lat = _to_float(_first(row, "latitude", "lat")) if lon is not None and lat is not None and _valid_lonlat(lon, lat): return lon, lat return None def _geometry_centroid(geom) -> tuple[float, float] | None: """Centroid (lon, lat) of a GeoJSON geometry already in WGS84.""" if not geom: return None try: centroid = shape(geom).centroid except (ValueError, TypeError, AttributeError): return None if centroid.is_empty: return None lon, lat = float(centroid.x), float(centroid.y) return (lon, lat) if _valid_lonlat(lon, lat) else None def _has_dwellings(min_d: int | None, max_d: int | None) -> bool: return (min_d is not None and min_d > 0) or (max_d is not None and max_d > 0) def _brownfield_url(entity) -> str | None: """Canonical per-site page on the Planning Data platform. The register's own ``site-plan-url`` is unreliable (many LPAs point every row at a single generic register landing page), so we link to the stable, always-per-site entity page instead. """ entity_id = _to_int(entity) if entity_id is None: return None return f"https://www.planning.data.gov.uk/entity/{entity_id}" def _is_residential(use: str | None, capacity: int | None) -> bool: """Keep Homes England sites that will deliver homes.""" if capacity is not None and capacity > 0: return True if use is None: return False lowered = use.lower() return any(token in lowered for token in ("resid", "housing", "mixed", "dwelling")) def _frame_from_rows(rows: list[dict]) -> pl.DataFrame: return pl.DataFrame(rows, schema=SCHEMA, orient="row") def brownfield_to_frame(raw: pl.DataFrame) -> pl.DataFrame: """Normalise the brownfield-land register to the unified development schema. Drops sites that have left the register (a non-empty ``end-date`` means the site was built out or withdrawn) and sites with no residential capacity, so the layer shows pipeline housing rather than every parcel ever registered. """ rows: list[dict] = [] for row in raw.iter_rows(named=True): lonlat = _point_lonlat(row) if lonlat is None: continue if _clean_str(_first(row, "end-date", "end_date")): continue min_d = _to_int(_first(row, "minimum-net-dwellings", "minimum_net_dwellings")) max_d = _to_int(_first(row, "maximum-net-dwellings", "maximum_net_dwellings")) if not _has_dwellings(min_d, max_d): continue lon, lat = lonlat rows.append( { "lat": lat, "lon": lon, "source": "brownfield", "name": _clean_str(_first(row, "site-address", "name")), "min_dwellings": min_d, "max_dwellings": max_d, "planning_status": _clean_str( _first(row, "planning-permission-status") ), "permission_type": _clean_str(_first(row, "planning-permission-type")), "permission_date": _clean_str(_first(row, "planning-permission-date")), "hectares": _to_float(_first(row, "hectares")), "local_authority": _clean_str(_first(row, "organisation")), "url": _brownfield_url(_first(row, "entity")), } ) return _frame_from_rows(rows) def homes_england_to_frame(features: list[dict]) -> pl.DataFrame: """Normalise Homes England Land Hub GeoJSON features to the unified schema.""" rows: list[dict] = [] for feature in features: props = feature.get("properties") or {} lonlat = _geometry_centroid(feature.get("geometry")) if lonlat is None: continue capacity = _to_int( _first(props, "Housing_Capacity", "HousingCapacity", "Units", "Homes") ) use = _clean_str(_first(props, "Proposed_Use")) if not _is_residential(use, capacity): continue lon, lat = lonlat # The feed reports area in acres (`Gross_Area__Acres_`); convert to hectares # so the column is consistent with the brownfield register. acres = _to_float(_first(props, "Gross_Area__Acres_")) hectares = round(acres * _ACRES_TO_HECTARES, 4) if acres is not None else None rows.append( { "lat": lat, "lon": lon, "source": "homes-england", "name": _clean_str(_first(props, "Parcel_Name", "Site_Reference")), "min_dwellings": None, "max_dwellings": capacity, "planning_status": _clean_str(_first(props, "Planning_Status")), "permission_type": None, "permission_date": None, "hectares": hectares, "local_authority": _clean_str(_first(props, "Local_Authority")), # The Land Hub exposes no stable per-site page; leave the link unset # rather than pointing at a generic landing page. "url": None, } ) return _frame_from_rows(rows) def _fetch_homes_england() -> list[dict]: params = { "where": "1=1", "outFields": "*", "outSR": "4326", "f": "geojson", "resultRecordCount": "2000", } response = httpx.get( HOMES_ENGLAND_URL, params=params, follow_redirects=True, timeout=120 ) response.raise_for_status() payload = response.json() if payload.get("exceededTransferLimit"): print( " WARNING: Homes England query hit the transfer limit; " "only the first page of sites was fetched." ) return payload.get("features", []) or [] def main() -> None: parser = argparse.ArgumentParser( description="Download brownfield + Homes England development sites" ) parser.add_argument( "--output", type=Path, required=True, help="Output parquet file path" ) args = parser.parse_args() with tempfile.TemporaryDirectory(dir=local_tmp_dir()) as cache_dir: brownfield_path = Path(cache_dir) / "brownfield-land.parquet" print("Downloading MHCLG brownfield land register...") download(BROWNFIELD_URL, brownfield_path) brownfield = brownfield_to_frame(pl.read_parquet(brownfield_path)) print(f" Brownfield: {brownfield.height} residential sites") print("Downloading Homes England Land Hub...") homes_england = homes_england_to_frame(_fetch_homes_england()) print(f" Homes England: {homes_england.height} residential sites") combined = pl.concat([brownfield, homes_england]).filter( pl.col("lat").is_not_null() & pl.col("lon").is_not_null() ) args.output.parent.mkdir(parents=True, exist_ok=True) combined.write_parquet(args.output, compression="zstd") size_kb = args.output.stat().st_size / 1024 print(f"Saved {combined.height} development sites to {args.output} ({size_kb:.0f} KB)") if __name__ == "__main__": main()