Source code: Point pattern analysis in Python: quadrats, nearest neighbour and Ripley's K on Camden's street trees
Runnable Python scripts, requirements and console output for the tutorial “Point pattern analysis in Python: quadrats, nearest neighbour and Ripley's K…
Last updated
These are the exact scripts used to produce every figure and number in Point pattern analysis in Python: quadrats, nearest neighbour and Ripley's K on Camden's street trees. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- point_pattern_analysis.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython point_pattern_analysis.pypoint_pattern_analysis.py
"""Point pattern analysis in Python: quadrat test, Clark-Evans, and Ripley's G, F, K and L functionswith Monte Carlo envelopes, on London plane trees in the London Borough of Camden.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/point-pattern-analysis-python/Data: Trees In Camden (London Borough of Camden, OGL v3), ONS local authority boundary (OGL v3)."""import sysimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport geopandas as gpdimport shapelyimport requestsimport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.lines import Line2Dfrom matplotlib.patches import Patchfrom scipy import statsfrom scipy.spatial import cKDTreeimport pointpatsfrom pointpats import random as pp_random
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)SEED = 42NSIM = 199 # CSR simulations: a pointwise min/max envelope then has alpha = 2 / (199 + 1) = 0.01CRS = "EPSG:27700" # British National Grid, metresSPECIES = "London plane"LAD = "E09000007" # Camden
TREES_URL = "https://opendata.camden.gov.uk/api/views/csqp-kdss/rows.csv?accessType=DOWNLOAD"ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"LAD_URL = f"{ONS}/Local_Authority_Districts_May_2026_Boundaries_UK_BFC/FeatureServer/0/query"
BLUE, ORANGE, VERMILION, GREY = "#0072B2", "#E69F00", "#D55E00", "#9a9a9a" # Okabe-Ito
def download(url, path, **params): if not path.exists(): r = requests.get(url, params=params or None, timeout=600, headers={"User-Agent": "TechnicalGIS-tutorial"}) r.raise_for_status() path.write_bytes(r.content) return path
def section(title): print(f"\n=== {title} ===")
def fmt_p(p): return "< 1e-300" if p < 1e-300 else f"= {p:.2g}"
section("versions")print(f"python {sys.version.split()[0]} | pointpats {version('pointpats')} | geopandas {version('geopandas')} | " f"shapely {version('shapely')} | scipy {version('scipy')} | numpy {version('numpy')}")
# ---------------------------------------------------------------- Step 1: the data and the study windowsection("Camden trees")df = pd.read_csv(download(TREES_URL, DATA / "trees_in_camden.csv"), low_memory=False)snapshot = pd.to_datetime(df["Last Uploaded"], format="%d/%m/%Y %I:%M:%S %p").max()print(f"{len(df):,} tree records (snapshot uploaded {snapshot:%d %B %Y})")df = df[df.Easting > 0]print(f"{len(df):,} with coordinates")trees = gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.Easting, df.Northing), crs=CRS)
boundary = gpd.read_file(download(LAD_URL, DATA / "camden_lad.geojson", where=f"LAD26CD='{LAD}'", outFields="LAD26CD,LAD26NM", outSR=27700, f="geojson")).set_crs(CRS, allow_override=True)W = boundary.geometry.iloc[0]shapely.prepare(W)trees = trees[shapely.contains_xy(W, trees.Easting, trees.Northing)]print(f"{len(trees):,} inside the borough boundary ({W.area / 1e6:.2f} km2, perimeter {W.length / 1e3:.1f} km)")print("by contract area:", trees["Contract Area"].value_counts().to_dict())
planes = trees[trees["Common Name"] == SPECIES]n_raw = len(planes)planes = planes.drop_duplicates(["Easting", "Northing"])xy = np.column_stack([planes.Easting, planes.Northing]).astype(float)n = len(xy)print(f"{SPECIES}: {n_raw:,} records, {n_raw - n} at duplicate coordinates dropped -> n = {n:,}")print(f" by contract area: {planes['Contract Area'].value_counts().to_dict()}")
# all council-tree locations: the population that random labelling (Step 6) draws fromall_xy = np.unique(np.column_stack([trees.Easting, trees.Northing]).astype(float), axis=0)print(f"all council trees at distinct locations: {len(all_xy):,}")
# A second, data-driven window: the parts of Camden within 100 m of any council tree. It removes# Hampstead Heath, Regent's Park and other land where the council does not manage trees.support = shapely.intersection(W, shapely.union_all(shapely.buffer(shapely.points(all_xy), 100, quad_segs=4)))shapely.prepare(support)xmin, ymin, xmax, ymax = W.boundsbbox_area = np.ptp(xy[:, 0]) * np.ptp(xy[:, 1])print(f"windows: borough {W.area / 1e6:.2f} km2 | within 100 m of a council tree {support.area / 1e6:.2f} km2 | " f"bounding box of the planes {bbox_area / 1e6:.2f} km2")lam = n / W.areaprint(f"intensity: {lam * 1e4:.2f} planes per hectare ({lam * 1e6:.0f} per km2)")
fig, axes = plt.subplots(1, 2, figsize=(11, 5.4))for ax in axes: boundary.boundary.plot(ax=ax, color="black", linewidth=0.8) ax.set_axis_off(); ax.set_aspect("equal")axes[0].scatter(all_xy[:, 0], all_xy[:, 1], s=0.4, color=GREY, label="other council trees", rasterized=True)axes[0].scatter(xy[:, 0], xy[:, 1], s=1.6, color=VERMILION, label=f"{SPECIES} (n = {n:,})", rasterized=True)axes[0].legend(loc="lower left", markerscale=6, frameon=False)axes[0].set_title("Council-managed trees in Camden")axes[0].plot([xmin + 200, xmin + 1200], [ymax - 300, ymax - 300], color="black", lw=2)axes[0].text(xmin + 700, ymax - 220, "1 km", ha="center", fontsize=8)gpd.GeoSeries([support], crs=CRS).plot(ax=axes[1], color="#cfe3f2", edgecolor=BLUE, linewidth=0.6)x0, y0 = xy.min(axis=0); x1, y1 = xy.max(axis=0)axes[1].plot([x0, x1, x1, x0, x0], [y0, y0, y1, y1, y0], color=ORANGE, linewidth=1.2, linestyle="--")axes[1].scatter(xy[:, 0], xy[:, 1], s=1.2, color=VERMILION, rasterized=True)axes[1].text(527300, 186600, "Hampstead\nHeath", ha="center", fontsize=8, color="#444444")axes[1].text(528400, 182750, "Regent's\nPark", ha="center", fontsize=8, color="#444444")axes[1].legend(handles=[Line2D([], [], color="black", lw=0.8, label=f"borough boundary ({W.area / 1e6:.1f} km²)"), Patch(facecolor="#cfe3f2", edgecolor=BLUE, label=f"within 100 m of a council tree ({support.area / 1e6:.1f} km²)"), Line2D([], [], color=ORANGE, lw=1.2, ls="--", label=f"bounding box of the planes ({bbox_area / 1e6:.1f} km²)")], loc="lower left", frameon=False, fontsize=8)axes[1].set_title("Three possible study windows")fig.tight_layout()fig.savefig(OUT / "01-camden-planes-windows.png")plt.close(fig)
# ---------------------------------------------------------------- Step 2: quadrat counts and a chi-squared testsection("quadrat test (500 m squares)")
def quadrats(size): """Square cells clipped to the borough; slivers under a quarter of a full cell are dropped.""" cells = [shapely.box(x, y, x + size, y + size) for x in np.arange(xmin, xmax, size) for y in np.arange(ymin, ymax, size)] g = gpd.GeoDataFrame(geometry=gpd.GeoSeries(cells, crs=CRS).intersection(W), crs=CRS) return g[g.area >= 0.25 * size * size].reset_index(drop=True)
pts = gpd.GeoDataFrame(geometry=gpd.points_from_xy(xy[:, 0], xy[:, 1]), crs=CRS)
def quadrat_test(grid): grid["count"] = gpd.sjoin(pts, grid, predicate="within").groupby("index_right").size().reindex(grid.index, fill_value=0) grid["expected"] = grid["count"].sum() / grid.area.sum() * grid.area # proportional to each clipped cell's area chi2 = (((grid["count"] - grid["expected"]) ** 2) / grid["expected"]).sum() return chi2, len(grid) - 1, stats.chi2.sf(chi2, len(grid) - 1)
Q = 500grid = quadrats(Q)chi2, dof, p_chi2 = quadrat_test(grid)full = grid.area > 0.999 * Q * Qvmr = grid.loc[full, "count"].var() / grid.loc[full, "count"].mean()print(f"{len(grid)} cells ({full.sum()} complete squares), {grid['count'].sum():,} planes counted, " f"{(grid['count'] == 0).sum()} empty cells")print(f"chi2 = {chi2:,.1f}, df = {dof}, p {fmt_p(p_chi2)}")print(f"variance/mean ratio on complete squares: {vmr:.2f} (1 under CSR, > 1 clustered, < 1 regular)")for qs in (250, 1000): # the verdict is stable across cell sizes, but the statistic is not c2, d2, p2 = quadrat_test(quadrats(qs)) print(f" {qs:>4} m squares: {d2 + 1} cells, chi2 = {c2:,.1f}, df = {d2}, p {fmt_p(p2)}")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.8), gridspec_kw={"width_ratios": [1.15, 1]})grid.plot(column="count", ax=axes[0], cmap="viridis", edgecolor="white", linewidth=0.3, legend=True, legend_kwds={"label": "London planes per 500 m cell", "shrink": 0.75})boundary.boundary.plot(ax=axes[0], color="black", linewidth=0.6)axes[0].set_axis_off()axes[0].set_title(f"Quadrat counts: χ² = {chi2:,.0f} on {dof} df, p < 0.001")counts = grid.loc[full, "count"].to_numpy()mu = counts.mean()edges = np.arange(0, counts.max() + 10, 5)axes[1].hist(counts, bins=edges, color=BLUE, alpha=0.8, label=f"observed ({full.sum()} complete cells)")expected_bins = np.diff(stats.poisson.cdf(edges - 1, mu)) * len(counts) # P(lo <= X < hi) for each 5-count binaxes[1].step(edges[:-1], expected_bins, where="post", color=VERMILION, lw=2, label=f"Poisson, mean {mu:.1f} (CSR)")axes[1].set_xlabel("London planes in a 500 m cell"); axes[1].set_ylabel("number of cells")axes[1].set_title(f"Too many empty and too many full cells (variance/mean = {vmr:.1f})")axes[1].legend(frameon=False)fig.tight_layout()fig.savefig(OUT / "02-quadrat-counts.png")plt.close(fig)
# ---------------------------------------------------------------- Step 3: Clark-Evans nearest-neighbour indexsection("Clark-Evans nearest-neighbour index")nnd = cKDTree(xy).query(xy, k=2)[0][:, 1]A, P = W.area, W.lengthexp_naive = 0.5 / np.sqrt(n / A)se_naive = 0.26136 / np.sqrt(n * n / A)R = nnd.mean() / exp_naivez = (nnd.mean() - exp_naive) / se_naive# Donnelly (1978): expected NND and its standard error corrected for the window's perimeterexp_don = 0.5 * np.sqrt(A / n) + (0.0514 + 0.041 / np.sqrt(n)) * P / nse_don = np.sqrt(0.0703 * A / n ** 2 + 0.037 * P * np.sqrt(A / n ** 5))z_don = (nnd.mean() - exp_don) / se_donprint(f"mean NND {nnd.mean():.1f} m, median {np.median(nnd):.1f} m, " f"{(nnd < 5).mean():.1%} under 5 m, {(nnd < 15).mean():.1%} under 15 m")print(f"CSR expectation 0.5/sqrt(lambda) = {exp_naive:.1f} m -> R = {R:.3f}, z = {z:.1f}, " f"p {fmt_p(2 * stats.norm.sf(abs(z)))}")print(f"Donnelly edge-corrected expectation {exp_don:.1f} m -> R = {nnd.mean() / exp_don:.3f}, z = {z_don:.1f}")
# CSR in the borough window with pointpats: NSIM patterns of n points eachsims = pp_random.poisson(W, size=(n, NSIM), rng=SEED)sim_mean_nnd = np.array([cKDTree(s).query(s, k=2)[0][:, 1].mean() for s in sims])p_ce = (1 + (sim_mean_nnd <= nnd.mean()).sum()) / (NSIM + 1)print(f"Monte Carlo: mean NND under CSR in the borough {sim_mean_nnd.mean():.1f} m " f"(range {sim_mean_nnd.min():.1f}-{sim_mean_nnd.max():.1f}); one-sided p = {p_ce:.3f} (floor {1 / (NSIM + 1):.3f})")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.0))axes[0].hist(nnd, bins=np.arange(0, 121, 2), color=BLUE, alpha=0.85)axes[0].axvline(exp_naive, color=VERMILION, lw=2, label=f"CSR expectation {exp_naive:.0f} m")axes[0].axvline(nnd.mean(), color="black", lw=1.5, ls="--", label=f"observed mean {nnd.mean():.0f} m")axes[0].set_xlabel("distance to nearest other London plane (m)"); axes[0].set_ylabel("trees")axes[0].set_title(f"Nearest-neighbour distances (n = {n:,}; {(nnd > 120).mean():.1%} beyond 120 m not shown)")axes[0].legend(frameon=False)axes[1].hist(sim_mean_nnd, bins=20, color=GREY, label=f"{NSIM} CSR simulations in the borough")axes[1].axvline(nnd.mean(), color="black", lw=1.5, ls="--", label=f"observed {nnd.mean():.1f} m")axes[1].set_xlabel("mean nearest-neighbour distance (m)"); axes[1].set_ylabel("simulations")axes[1].set_title(f"Clark-Evans R = {R:.2f}: far more clustered than CSR")axes[1].legend(frameon=False, loc="upper center")fig.tight_layout()fig.savefig(OUT / "03-nearest-neighbour.png")plt.close(fig)
# ---------------------------------------------------------------- Step 4: G, F, K and L with border correctiondef ratio_border(values, b, r): """Border-corrected (reduced-sample) estimate of P(value <= r) using only points at least r from the edge: #{value <= r <= b} / #{b >= r}.""" ok = values <= b num = np.searchsorted(np.sort(values[ok]), r, "right") - np.searchsorted(np.sort(b[ok]), r, "left") den = len(b) - np.searchsorted(np.sort(b), r, "left") return num / np.maximum(den, 1)
def g_func(pts, b, r): return ratio_border(cKDTree(pts).query(pts, k=2)[0][:, 1], b, r)
def f_func(pts, grid_pts, grid_b, r): return ratio_border(cKDTree(pts).query(grid_pts, k=1)[0], grid_b, r)
def k_func(pts, b, r, area, correct=True): """Ripley's K. correct=True: border correction, so each point counts only at distances it can 'see' in full.""" pairs = cKDTree(pts).query_pairs(r.max(), output_type="ndarray") d = np.hypot(*(pts[pairs[:, 0]] - pts[pairs[:, 1]]).T) lam_hat = len(pts) / area if not correct: return 2 * np.searchsorted(np.sort(d), r, "right") / (len(pts) * lam_hat) i = np.concatenate([pairs[:, 0], pairs[:, 1]]); dd = np.concatenate([d, d]) bi = b[i]; ok = dd <= bi num = np.searchsorted(np.sort(dd[ok]), r, "right") - np.searchsorted(np.sort(bi[ok]), r, "left") m = len(b) - np.searchsorted(np.sort(b), r, "left") return num / (np.maximum(m, 1) * lam_hat)
def l_minus_r(K, r): return np.sqrt(K / np.pi) - r
def edge_dist(pts, window): return shapely.distance(shapely.points(pts), window.boundary)
section("G, F, K and L functions (border correction, CSR envelopes)")r_g = np.arange(0, 81, 1.0)r_f = np.arange(0, 401, 5.0)r_k = np.arange(0, 501, 5.0)b_obs = edge_dist(xy, W)gx, gy = np.meshgrid(np.arange(xmin + 10, xmax, 20), np.arange(ymin + 10, ymax, 20))gpts = np.column_stack([gx.ravel(), gy.ravel()])gpts = gpts[shapely.contains_xy(W, gpts[:, 0], gpts[:, 1])]gb = edge_dist(gpts, W)print(f"{len(gpts):,} F-function sample points on a 20 m grid; " f"{(b_obs >= 500).sum():,} of {n:,} planes are at least 500 m from the boundary")
obs = {"G": g_func(xy, b_obs, r_g), "F": f_func(xy, gpts, gb, r_f), "K": k_func(xy, b_obs, r_k, A)}sim = {"G": [], "F": [], "K": [], "K_raw": []}for s in sims: bs = edge_dist(s, W) sim["G"].append(g_func(s, bs, r_g)); sim["F"].append(f_func(s, gpts, gb, r_f)) sim["K"].append(k_func(s, bs, r_k, A)); sim["K_raw"].append(k_func(s, bs, r_k, A, correct=False))sim = {key: np.array(v) for key, v in sim.items()}L_obs, L_sim = l_minus_r(obs["K"], r_k), l_minus_r(sim["K"], r_k)
def report(name, r, o, s): above = r[o > s.max(axis=0)]; below = r[o < s.min(axis=0)] span = lambda a: f"{a.min():.0f}-{a.max():.0f} m" if len(a) else "none" print(f"{name}: above the envelope at {span(above)} ({len(above)} of {len(r)} distances), below at {span(below)}")
for name, r in (("G", r_g), ("F", r_f), ("K", r_k)): report(name, r, obs[name], sim[name])for rr in (10, 50, 100, 200, 400): i = np.searchsorted(r_k, rr) print(f" r = {rr:>3} m: L(r) - r = {L_obs[i]:6.1f} m (CSR envelope {L_sim[:, i].min():5.1f} to {L_sim[:, i].max():5.1f})")i = np.searchsorted(r_g, 15)print(f"G(15 m) = {obs['G'][i]:.2f} (CSR envelope {sim['G'][:, i].min():.3f}-{sim['G'][:, i].max():.3f})")i = np.searchsorted(r_f, 100)print(f"F(100 m) = {obs['F'][i]:.2f} (CSR envelope {sim['F'][:, i].min():.2f}-{sim['F'][:, i].max():.2f})")
fig, axes = plt.subplots(2, 2, figsize=(10.5, 8))panels = [(r_g, obs["G"], sim["G"], "G(r): nearest-neighbour distances", "G(r)"), (r_f, obs["F"], sim["F"], "F(r): empty-space distances", "F(r)"), (r_k, obs["K"] / 1e6, sim["K"] / 1e6, "K(r): pairs within r", "K(r) (km²)"), (r_k, L_obs, L_sim, "L(r) − r: variance-stabilised K", "L(r) − r (m)")]for ax, (r, o, s, title, ylab) in zip(axes.ravel(), panels): ax.fill_between(r, s.min(axis=0), s.max(axis=0), color=GREY, alpha=0.45, lw=0, label=f"CSR envelope ({NSIM} sims)") ax.plot(r, s.mean(axis=0), color=GREY, lw=1, ls="--", label="CSR mean") ax.plot(r, o, color=VERMILION, lw=2, label=f"London planes (n = {n:,})") ax.set_title(title); ax.set_xlabel("distance r (m)"); ax.set_ylabel(ylab) ax.grid(alpha=0.3)axes[0, 0].legend(frameon=False, loc="lower right")fig.suptitle("Border-corrected summary functions with pointwise CSR envelopes (Camden borough window)", y=1.0)fig.tight_layout()fig.savefig(OUT / "04-g-f-k-l-envelopes.png")plt.close(fig)
# ---------------------------------------------------------------- Step 5: the window and the edge correction mattersection("window shape and edge correction")_, l_pp = pointpats.l(xy, support=r_k, linearized=True) # pointpats: bounding-box area, no edge correctionL_raw = l_minus_r(k_func(xy, b_obs, r_k, A, correct=False), r_k)b_sup = edge_dist(xy, support)L_sup = l_minus_r(k_func(xy, b_sup, r_k, support.area), r_k)m_sup = np.array([(b_sup >= rr).sum() for rr in r_k])L_sup[m_sup < 100] = np.nan # too few planes far enough from the holes in this window to estimate Kprint(f"planes at least r from the window edge: borough {(b_obs >= 250).sum():,} at 250 m, {(b_obs >= 500).sum():,} at 500 m | " f"support window {(b_sup >= 250).sum():,} at 250 m, {(b_sup >= 500).sum():,} at 500 m " f"(curve dropped beyond {r_k[m_sup >= 100].max():.0f} m)")L_raw_sim = l_minus_r(sim["K_raw"], r_k)for rr in (100, 250, 500): i = np.searchsorted(r_k, rr) print(f"r = {rr} m: L - r = {l_pp[i]:6.1f} (pointpats, bounding box) | {L_raw[i]:6.1f} (borough, no correction) | " f"{L_obs[i]:6.1f} (borough, border) | {L_sup[i]:6.1f} (100 m support window, border)") print(f" CSR mean in the borough: {L_raw_sim[:, i].mean():6.1f} without correction, " f"{L_sim[:, i].mean():5.1f} with border correction")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))axes[0].plot(r_k, l_pp, color=ORANGE, lw=2, label=f"bounding box, {bbox_area / 1e6:.1f} km² (pointpats.l)")axes[0].plot(r_k, L_obs, color=VERMILION, lw=2, label=f"borough, {A / 1e6:.1f} km²")axes[0].plot(r_k, L_sup, color=BLUE, lw=2, label=f"near a council tree, {support.area / 1e6:.1f} km²")axes[0].axhline(0, color="black", lw=0.8)axes[0].set_title("Same trees, different windows")axes[0].set_xlabel("distance r (m)"); axes[0].set_ylabel("L(r) − r (m)")axes[0].legend(frameon=False, fontsize=8); axes[0].grid(alpha=0.3)axes[1].fill_between(r_k, L_raw_sim.min(axis=0), L_raw_sim.max(axis=0), color=ORANGE, alpha=0.35, lw=0, label="CSR envelope, no edge correction")axes[1].fill_between(r_k, L_sim.min(axis=0), L_sim.max(axis=0), color=BLUE, alpha=0.35, lw=0, label="CSR envelope, border correction")axes[1].axhline(0, color="black", lw=0.8, label="theoretical CSR value")axes[1].set_title("Ignoring edges biases the CSR benchmark downwards")axes[1].set_xlabel("distance r (m)"); axes[1].set_ylabel("L(r) − r (m)")axes[1].legend(frameon=False, fontsize=8, loc="lower left"); axes[1].grid(alpha=0.3)fig.tight_layout()fig.savefig(OUT / "05-window-edge-effects.png")plt.close(fig)
# ---------------------------------------------------------------- Step 6: a global test, and a better null hypothesisdef mad_test(obs_curve, sim_curves): """Maximum absolute deviation test (Diggle 1986; Loosmore and Ford 2006) over all distances at once.""" t_obs = np.abs(obs_curve - sim_curves.mean(axis=0)).max() # each simulation is compared with the mean of the others, so it is treated exactly like the data total = sim_curves.sum(axis=0) t_sim = np.array([np.abs(s - (total - s) / (len(sim_curves) - 1)).max() for s in sim_curves]) return t_obs, t_sim, (1 + (t_sim >= t_obs).sum()) / (len(t_sim) + 1)
section("global MAD test against CSR")t_obs, t_sim, p_mad = mad_test(L_obs, L_sim)print(f"MAD on L(r) - r, 0-500 m: observed {t_obs:.1f} m; CSR simulations {np.median(t_sim):.1f} m median, " f"{t_sim.max():.1f} m max; p = {p_mad:.3f}")# how often does a pointwise envelope 'reject' for a pattern that really is CSR? (each simulation against the rest)outside = [((L_sim[j] > np.delete(L_sim, j, 0).max(axis=0)) | (L_sim[j] < np.delete(L_sim, j, 0).min(axis=0))).any() for j in range(NSIM)]print(f"pointwise envelope check: {np.mean(outside):.0%} of CSR patterns leave the envelope of the other " f"{NSIM - 1} at one or more of {len(r_k)} distances")
section("random labelling: are planes more clustered than council trees in general?")rng = np.random.default_rng(SEED)b_all = edge_dist(all_xy, W)L_rl = []for _ in range(NSIM): idx = rng.choice(len(all_xy), size=n, replace=False) L_rl.append(l_minus_r(k_func(all_xy[idx], b_all[idx], r_k, A), r_k))L_rl = np.array(L_rl)L_all = l_minus_r(k_func(all_xy, b_all, r_k, A), r_k)report("L - r vs random labelling", r_k, L_obs, L_rl)for rr in (20, 50, 100, 200, 400): i = np.searchsorted(r_k, rr) print(f" r = {rr:>3} m: planes {L_obs[i]:6.1f} m | all council trees {L_all[i]:6.1f} m | " f"random-label envelope {L_rl[:, i].min():6.1f} to {L_rl[:, i].max():6.1f}")t_obs_rl, t_sim_rl, p_mad_rl = mad_test(L_obs, L_rl)print(f"MAD test vs random labelling: observed {t_obs_rl:.1f} m; simulations median {np.median(t_sim_rl):.1f} m, " f"max {t_sim_rl.max():.1f} m; p = {p_mad_rl:.3f}")for lo, hi in ((0, 100), (100, 500)): sel = (r_k >= lo) & (r_k <= hi) _, _, p_band = mad_test(L_obs[sel], L_rl[:, sel]) print(f" restricted to {lo}-{hi} m: p = {p_band:.3f}")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2))axes[0].fill_between(r_k, L_sim.min(axis=0), L_sim.max(axis=0), color=GREY, alpha=0.45, lw=0, label="CSR envelope")axes[0].fill_between(r_k, L_rl.min(axis=0), L_rl.max(axis=0), color=BLUE, alpha=0.35, lw=0, label=f"random labelling envelope ({NSIM} draws)")axes[0].plot(r_k, L_all, color=BLUE, lw=1.2, ls="--", label=f"all council trees (n = {len(all_xy):,})")axes[0].plot(r_k, L_obs, color=VERMILION, lw=2, label="London planes")axes[0].set_xlabel("distance r (m)"); axes[0].set_ylabel("L(r) − r (m)")axes[0].set_title("Planes against two null hypotheses")axes[0].legend(frameon=False, fontsize=8, loc="lower right", bbox_to_anchor=(1, 0.13)); axes[0].grid(alpha=0.3)bins = np.linspace(0, max(t_obs, t_obs_rl, t_sim.max(), t_sim_rl.max()) * 1.05, 50)axes[1].hist(t_sim, bins=bins, color=GREY, alpha=0.8, label="CSR simulations")axes[1].hist(t_sim_rl, bins=bins, color=BLUE, alpha=0.6, label="random-labelling simulations")axes[1].axvline(t_obs, color="black", lw=1.5, ls="--", label=f"planes vs CSR: {t_obs:.0f} m (p = {p_mad:.3f})")axes[1].axvline(t_obs_rl, color=VERMILION, lw=2, label=f"planes vs random labelling: {t_obs_rl:.0f} m (p = {p_mad_rl:.3f})")axes[1].set_xlabel("maximum absolute deviation of L(r) − r over 0–500 m (m)"); axes[1].set_ylabel("simulations")axes[1].set_title(f"Global MAD test (p = {1 / (NSIM + 1):.3f} is the smallest possible)")axes[1].legend(frameon=False, fontsize=8)fig.tight_layout()fig.savefig(OUT / "06-random-labelling-mad.png")plt.close(fig)
planes[["Identifier", "Common Name", "Contract Area", "Height In Metres", "Spread In Metres", "geometry"]].to_file( OUT / "camden_london_planes.gpkg")gpd.GeoDataFrame({"window": ["borough", "support_100m"]}, geometry=[W, support], crs=CRS).to_file( OUT / "camden_windows.gpkg")print(f"\nwrote {OUT / 'camden_london_planes.gpkg'} and {OUT / 'camden_windows.gpkg'}")