=== versions === python 3.12.0 | geopandas 1.2.0 | shapely 2.2.0 (GEOS 3.14.1) | pyogrio 0.13.0 | pandas 3.0.6 === Step 1: polygons (ONS) and points (NaPTAN, OpenStreetMap), all in EPSG:27700 === boroughs 10 rows Polygon CRS EPSG:27700 wards 215 rows Polygon CRS EPSG:27700 LSOAs 2021 1,702 rows Polygon CRS EPSG:27700 bus stops 13,187 rows Point CRS EPSG:27700 schools (OSM) 1,387 rows Point CRS EPSG:27700 Census 2021 residents in the LSOAs: 2,867,719 === Step 2: point in polygon - which ward is each bus stop in? === ATCOCode CommonName WD24NM LAD24NM index_right 0 1800ANIN0A1 Ashton-under-Lyne Interchange St Peter's Tameside 105.0 1 1800ANIN0B1 Ashton-under-Lyne Interchange St Peter's Tameside 105.0 2 1800ANIN0C1 Ashton-under-Lyne Interchange St Peter's Tameside 105.0 3 1800EB00011 Piccadilly Station Piccadilly Manchester 26.0 rows in: 13,187 rows out: 13,187 stops matched to no ward: 3 unmatched stops are in: {'Middle Healey': 1, 'Stanley Green': 1, 'Orrell Post': 1} wards: 215, stops per ward: median 58, min 16, max 132 densest wards: WD24NM LAD24NM n_stops area_km2 stops_per_km2 Deansgate Manchester 84 2.0 42.4 Piccadilly Manchester 81 1.9 41.8 Old Moat Manchester 52 1.8 28.9 sparsest wards: WD24NM LAD24NM n_stops area_km2 stops_per_km2 Norden Rochdale 62 28.3 2.2 Saddleworth South Oldham 74 32.6 2.3 Cadishead & Lower Irlam Salford 38 16.0 2.4 stops per borough: {'Manchester': 2116, 'Wigan': 1721, 'Bolton': 1510, 'Stockport': 1238, 'Oldham': 1209, 'Tameside': 1206, 'Rochdale': 1172, 'Salford': 1076, 'Trafford': 998, 'Bury': 938} === Step 3: predicates - points vs polygons, and polygons vs polygons === stops within wards: 13,184 pairs stops intersects wards: 13,184 pairs wards contains stops: 13,184 pairs (same pairs, other way round) valid predicates: ['contains', 'contains_properly', 'covered_by', 'covers', 'crosses', 'dwithin', 'intersects', 'overlaps', 'touches', 'within'] LSOAs intersects wards: 4,148 pairs, 1,702 LSOAs matched, 1,421 matched to 2+ wards LSOAs overlaps wards: 3,885 pairs, 1,439 LSOAs matched, 1,421 matched to 2+ wards LSOAs touches wards: 0 pairs, 0 LSOAs matched, 0 matched to 2+ wards LSOAs within wards: 263 pairs, 263 LSOAs matched, 0 matched to 2+ wards LSOAs covered_by wards: 263 pairs, 263 LSOAs matched, 0 matched to 2+ wards LSOAs rep. point within wards: 1,702 pairs, 1,702 LSOAs matched, 0 matched to 2+ wards overlay check: 666 of 1,702 LSOAs have under 99% of their area in one ward (they genuinely straddle a ward boundary); 1,036 sit in one ward apart from slivers === Step 4: how='left' / 'inner' / 'right', and stops per 1,000 residents by LSOA === how=inner : 13,183 rows, geometry from the left frame (Point) how=left : 13,187 rows, geometry from the left frame (Point) how=right : 13,213 rows, geometry from the right frame (Polygon) LSOAs with no bus stop at all: 30 of 1,702 (home to 45,828 residents) stops per 1,000 residents: median 4.2, Greater Manchester overall 4.6 === Step 5: sjoin_nearest - the nearest bus stop to every school === OSM schools inside the ten boroughs: 1,386 sjoin_nearest, no limit: 1,386 rows for 1,386 schools in 16 ms (ties add rows: 0) distance to the nearest stop: median 127 m, 90th pct 234 m, max 947 m furthest: name LAD24NM CommonName dist_m Changing Lives Trafford Ackers Farm 947.0 Emmanuel Holcombe Church of England Primary School Bury Garnett Street 675.0 Holly Mount Roman Catholic Primary School, Bury Bury Shepherd Street 662.0 max_distance=400: 19 schools have no stop within 400 m (1.4%), returned with NaN instead of a far-away match time with max_distance: 15 ms stops within 400 m of each school (buffer + sjoin): median 10 === Step 6: clip and overlay - areas more than 400 m from a bus stop === gpd.clip(stops, Manchester): 2,116 stops in 9 ms; sjoin gives 2,116 overlay(how='difference') in 5.7 s: 417.0 km2 of 1,276.0 km2 (32.7%) is more than 400 m in a straight line from a bus stop LSOAs with more than half their area beyond 400 m: 87 (home to 140,438 residents) === Step 7: timing - naive loop vs vectorised vs spatial index === 13,187 stops x 215 wards; all methods give identical ward codes: True the naive loop ran 1,408,999 contains tests (107 per stop on average) naive Python loop 0.526 s 1.0x the loop's speed naive loop, prepared polygons 0.318 s 1.7x the loop's speed shapely.contains per ward (prepared) 0.104 s 5.1x the loop's speed STRtree on wards, query stops 0.116 s 4.6x the loop's speed STRtree on stops, query wards 0.021 s 24.5x the loop's speed gpd.sjoin(predicate='within') 0.024 s 21.5x the loop's speed building an STRtree for 13,187 stops: 2.4 ms === figures === saved: 01-bus-stops-per-ward.png, 02-predicates.png, 03-sjoin-nearest-schools.png, 04-overlay-400m-catchment.png, 05-timing.png