Source code: Location-allocation in Python: p-median, p-center and maximal covering (MCLP) with spopt
Runnable Python scripts, requirements and console output for the tutorial “Location-allocation in Python: p-median, p-center and maximal covering (MCLP)…
Last updated
These are the exact scripts used to produce every figure and number in Location-allocation in Python: p-median, p-center and maximal covering (MCLP) with spopt. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- location_allocation.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 location_allocation.pylocation_allocation.py
"""Location-allocation in Python with spopt: p-median, p-center and the maximal covering locationproblem (MCLP) for siting urgent-care or mobile clinic bases in Cornwall, on OSMnx drive times.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/location-allocation-python/Part 2 of "Healthcare accessibility analysis in Python". From Part 1 (spatial-accessibility-2sfca-python)it reuses the cached OSMnx drive graph, data/cornwall_drive.graphml, and the Geofabrik extract. IfPart 1 has not been run, this script downloads the data and builds the same graph itself.
Data: ONS Census 2021 TS001 via Nomis and ONS Open Geography boundaries and centroids (OGL v3);OpenStreetMap via Geofabrik and OSMnx (ODbL)."""import jsonimport sysimport timeimport warningsimport zipfilefrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport geopandas as gpdimport requestsimport osmnx as oximport osmiumimport shapelyimport pulpfrom scipy.sparse import csr_matrixfrom scipy.sparse.csgraph import dijkstrafrom spopt.locate import PMedian, PCenter, MCLP, LSCPimport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.lines import Line2Dfrom matplotlib.collections import LineCollection
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9, "figure.facecolor": "white", "axes.facecolor": "white"})
DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)PART1 = Path("../spatial-accessibility-2sfca-python/data") # Part 1's cache, used if presentCRS = "EPSG:27700"LAD = "E06000052" # Cornwall (not the Isles of Scilly)BUFFER_M = 25_000 # same graph extent as Part 1SNAP_KMH = 20P = 6 # number of new sites in the main comparisonRADIUS = 15 # MCLP service standard, minutes (free-flow drive time)P_RANGE = range(1, 13)THIN_M = 2_000 # keep at most one candidate building per 2 kmUA = {"User-Agent": "TechnicalGIS-tutorial (https://www.technicalgis.com)"}CBC = lambda: pulp.PULP_CBC_CMD(msg=False, timeLimit=600)
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"LAD_URL = f"{ONS}/Local_Authority_Districts_May_2026_Boundaries_UK_BGC/FeatureServer/0/query"OA_URL = f"{ONS}/Output_Areas_2021_EW_BGC_V2/FeatureServer/0/query"OA_PWC_URL = f"{ONS}/OA_December_2021_EW_PWC_V4/FeatureServer/0/query"LSOA_PWC_URL = f"{ONS}/LSOA_PopCentroids_EW_2021_V4/FeatureServer/0/query"TS001 = "https://www.nomisweb.co.uk/output/census/2021/census2021-ts001.zip"GEOFABRIK = [f"https://download.geofabrik.de/europe/united-kingdom/england/{c}-latest.osm.pbf" for c in ("cornwall", "devon")]NOT_DRIVE = {"abandoned", "bridleway", "bus_guideway", "construction", "corridor", "cycleway", "elevator", "escalator", "footway", "no", "path", "pedestrian", "planned", "platform", "proposed", "raceway", "razed", "rest_area", "service", "services", "steps", "track"}NOT_SERVICE = {"alley", "driveway", "emergency_access", "parking", "parking_aisle", "private"}CANDIDATE_AMENITY = {"community_centre", "townhall", "village_hall"}
def section(title): print(f"\n=== {title} ===")
def cached(name): """A file from Part 1's data folder if it exists there, otherwise this tutorial's own data folder.""" return PART1 / name if (PART1 / name).exists() else DATA / name
def download(url, path): if not path.exists(): r = requests.get(url, timeout=600, headers=UA) r.raise_for_status() path.write_bytes(r.content) return path
def arcgis(url, path, **params): """Query an ArcGIS FeatureServer layer page by page and cache the result as GeoJSON.""" if not path.exists(): feats, offset = [], 0 while True: q = {"outFields": "*", "outSR": 27700, "f": "geojson", "resultOffset": offset, "resultRecordCount": 2000, "where": "1=1", **params} r = requests.post(url, data=q, timeout=300, headers=UA) r.raise_for_status() page = r.json()["features"] feats += page if len(page) < 2000: break offset += len(page) path.write_text(json.dumps({"type": "FeatureCollection", "features": feats})) return gpd.read_file(path).set_crs(CRS, allow_override=True)
def envelope(x0, y0, x1, y1): return {"geometry": f"{x0},{y0},{x1},{y1}", "geometryType": "esriGeometryEnvelope", "inSR": 27700, "spatialRel": "esriSpatialRelIntersects"}
def drivable(tags): """The same rules as OSMnx's network_type="drive" filter, applied to a way's tags.""" hw = tags.get("highway") return (hw is not None and hw not in NOT_DRIVE and tags.get("area") != "yes" and tags.get("motor_vehicle") != "no" and tags.get("motorcar") != "no" and tags.get("access") != "private" and tags.get("service") not in NOT_SERVICE)
def build_drive_graph(area, folder): """Part 1's graph builder: drive network for `area` from Geofabrik extracts, read by OSMnx.""" poly = gpd.GeoSeries([area], crs=CRS).to_crs(4326).iloc[0] osm_file = folder / "drive_network.osm" keep_nodes, ways = set(), {} for url in GEOFABRIK: pbf = download(url, folder / url.rsplit("/", 1)[1]) for w in osmium.FileProcessor(str(pbf), osmium.osm.NODE | osmium.osm.WAY).with_locations(): if not w.is_way() or w.id in ways or not drivable(w.tags): continue xy = np.array([(n.lon, n.lat) for n in w.nodes]) if shapely.contains_xy(poly, xy[:, 0], xy[:, 1]).any(): ways[w.id] = ([n.ref for n in w.nodes], dict(w.tags)) keep_nodes.update(n.ref for n in w.nodes) written = set() with osmium.SimpleWriter(str(osm_file), overwrite=True) as writer: for url in GEOFABRIK: for n in osmium.FileProcessor(str(folder / url.rsplit("/", 1)[1]), osmium.osm.NODE): if n.id in keep_nodes and n.id not in written: writer.add_node(n) written.add(n.id) for wid, (refs, tags) in sorted(ways.items()): writer.add_way(osmium.osm.mutable.Way(id=wid, version=1, nodes=refs, tags=tags)) G = ox.graph_from_xml(osm_file, simplify=True) G = ox.routing.add_edge_speeds(G) G = ox.routing.add_edge_travel_times(G) G = ox.truncate.largest_component(G, strongly=True) return ox.project_graph(G, to_crs=CRS)
def read_zip_csv(path, name_part): with zipfile.ZipFile(path) as z: name = next(n for n in z.namelist() if name_part in n and n.endswith(".csv")) return pd.read_csv(z.open(name), low_memory=False, encoding="utf-8-sig")
def osm_places(pbf, out): """Community buildings and hospitals from an OSM extract: nodes, and ways at the mean of their nodes.""" if not out.exists(): rows = [] for o in osmium.FileProcessor(str(pbf), osmium.osm.NODE | osmium.osm.WAY).with_locations(): amenity = o.tags.get("amenity") kind = ("candidate" if amenity in CANDIDATE_AMENITY else "hospital" if amenity == "hospital" and o.tags.get("healthcare") != "clinic" else None) if kind is None: continue if o.is_node(): lon, lat = o.location.lon, o.location.lat else: xy = np.array([(n.lon, n.lat) for n in o.nodes]) lon, lat = xy.mean(axis=0) rows.append({"osm": f"{'n' if o.is_node() else 'w'}{o.id}", "kind": kind, "amenity": amenity, "name": o.tags.get("name", ""), "lon": lon, "lat": lat}) df = pd.DataFrame(rows) gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df.lon, df.lat), crs=4326).to_crs(CRS).to_file(out) return gpd.read_file(out)
def thin(gdf, min_dist): """Greedy thinning: keep a point only if it is at least min_dist from every point already kept.""" gdf = gdf.assign(named=gdf.name.fillna("").ne("")).sort_values(["named", "osm"], ascending=[False, True]) kept, xy = [], gdf.get_coordinates().to_numpy() for i in range(len(gdf)): if not kept or np.min(np.hypot(*(xy[kept] - xy[i]).T)) >= min_dist: kept.append(i) return gdf.iloc[kept].drop(columns="named").reset_index(drop=True)
section("versions")print(f"python {sys.version.split()[0]} | spopt {version('spopt')} | pulp {version('pulp')} | " f"osmnx {version('osmnx')} | geopandas {version('geopandas')} | numpy {version('numpy')}")print(f"CBC available through PuLP: {pulp.PULP_CBC_CMD().available()}")
# ---------------------------------------------------------------- Step 1: demand (Cornwall LSOAs)section("demand")lads = arcgis(LAD_URL, cached("lads.geojson"), **envelope(130000, 0, 300000, 140000))cornwall = lads[lads.LAD26CD == LAD].geometry.iloc[0]devon_lads = lads[lads.intersects(cornwall.buffer(BUFFER_M)) & (lads.LAD26CD != LAD)]oas = arcgis(OA_URL, cached("cornwall_oas.geojson"), where="LSOA21NM LIKE 'Cornwall %'")ts001 = download(TS001, cached("census2021-ts001.zip"))col = {"geography code": "code", "Residence type: Total; measures: Value": "pop"}lsoa = arcgis(LSOA_PWC_URL, DATA / "cornwall_lsoa_pwc.geojson", **envelope(*cornwall.bounds))lsoa = lsoa[lsoa.LSOA21CD.isin(oas.LSOA21CD)]lsoa = lsoa.merge(read_zip_csv(ts001, "-lsoa.csv").rename(columns=col)[["code", "pop"]], left_on="LSOA21CD", right_on="code").drop(columns="code").reset_index(drop=True)print(f"{len(lsoa)} Cornwall LSOAs, {lsoa['pop'].sum():,} residents (Census 2021), " f"median {lsoa['pop'].median():,.0f} per LSOA")oa = arcgis(OA_PWC_URL, cached("oa_pwc.geojson"), **envelope(*cornwall.buffer(BUFFER_M).bounds))oa = oa[oa.OA21CD.isin(oas.OA21CD)].merge( read_zip_csv(ts001, "-oa.csv").rename(columns=col)[["code", "pop"]], left_on="OA21CD", right_on="code")oa = oa.reset_index(drop=True)print(f"for the problem-size benchmark: {len(oa):,} Cornwall OAs, {oa['pop'].sum():,} residents")
# ---------------------------------------------------------------- Step 2: candidate sitessection("candidate sites (OpenStreetMap)")pbf = cached("cornwall-latest.osm.pbf")if not pbf.exists(): download(GEOFABRIK[0], pbf)places = osm_places(pbf, DATA / "osm_places.gpkg")places = places[places.within(cornwall)]print("OSM features in Cornwall:", places.groupby(["kind", "amenity"]).size().to_dict())# amenity=hospital also catches a surgery, military medical centres and a WW2 site; keep names ending# "Hospital" and drop the one private hospital, leaving Cornwall's NHS acute and community hospitalshosp = places[(places.kind == "hospital") & places.name.str.endswith("Hospital") & (places.name != "The Duchy Hospital")]hosp = thin(hosp, 500).sort_values("name").reset_index(drop=True)print(f"NHS hospital sites kept: {', '.join(hosp.name)}")cand = thin(places[places.kind == "candidate"], THIN_M)print(f"{len(cand)} candidate community buildings after {THIN_M / 1000:.0f} km thinning " f"(from {(places.kind == 'candidate').sum()}); {len(hosp)} existing hospital sites")
# ---------------------------------------------------------------- Step 3: travel-time matricessection("travel times")t0 = time.time()graph_file = cached("cornwall_drive.graphml")if not graph_file.exists(): print("Part 1's graph not found: building it from Geofabrik extracts (a few minutes)") ox.save_graphml(build_drive_graph(cornwall.buffer(BUFFER_M), DATA), graph_file)G = ox.load_graphml(graph_file)print(f"graph from {graph_file.as_posix()}: {G.number_of_nodes():,} nodes, {G.number_of_edges():,} edges; " f"{time.time() - t0:.0f} s")nodes = list(G.nodes)pos = {n: i for i, n in enumerate(nodes)}edges = pd.DataFrame([(pos[a], pos[b], d["travel_time"]) for a, b, d in G.edges(data=True)], columns=["u", "v", "t"])edges = edges.groupby(["u", "v"], as_index=False).t.min()WT = csr_matrix((edges.t, (edges.u, edges.v)), shape=(len(nodes), len(nodes))).T.tocsr()
def snap(points): ids, dist = ox.distance.nearest_nodes(G, points.geometry.x, points.geometry.y, return_dist=True) return np.array([pos[i] for i in ids]), np.asarray(dist) / 1000 / SNAP_KMH * 60
def minutes(demand, sites): """Drive time in minutes from every demand point (rows) to every site (columns).""" d_node, d_hop = snap(demand) s_node, s_hop = snap(sites) to_site = dijkstra(WT, directed=True, indices=s_node) / 60 return to_site[:, d_node].T + d_hop[:, None] + s_hop[None, :]
sites = pd.concat([cand.assign(kind="candidate"), hosp.assign(kind="hospital")], ignore_index=True)t0 = time.time()T_all = minutes(lsoa, sites)T_oa_all = minutes(oa, sites)print(f"LSOA x site matrix {T_all.shape} and OA x site matrix {T_oa_all.shape} in {time.time() - t0:.1f} s; " f"unreachable pairs: {np.isinf(T_all).sum() + np.isinf(T_oa_all).sum()}")is_c = (sites.kind == "candidate").to_numpy()T, T_oa, T_h = T_all[:, is_c], T_oa_all[:, is_c], T_all[:, ~is_c]w, w_oa = lsoa["pop"].to_numpy(float), oa["pop"].to_numpy(float)
def evaluate(open_cols, M=T, wt=w): """Population-weighted mean, worst and coverage, each person using the nearest open site.""" t = M[:, open_cols].min(axis=1) return {"mean_min": np.average(t, weights=wt), "max_min": t.max(), "covered_pct": wt[t <= RADIUS].sum() / wt.sum() * 100}
def solve(kind, p, M=T, wt=w, predefined=None): """Build and solve one spopt model with CBC; return the open columns and the solve time.""" kw = {} if predefined is None else {"predefined_facilities_arr": predefined} if kind == "p-median": m = PMedian.from_cost_matrix(M, wt, p_facilities=p, **kw) elif kind == "p-center": m = PCenter.from_cost_matrix(M, p_facilities=p, **kw) else: m = MCLP.from_cost_matrix(M, wt, service_radius=RADIUS, p_facilities=p, **kw) t0 = time.perf_counter() m = m.solve(CBC()) secs = time.perf_counter() - t0 status = pulp.LpStatus[m.problem.status] opened = [j for j, clients in enumerate(m.fac2cli) if len(clients)] if kind != "mclp" else \ [j for j in range(M.shape[1]) if m.fac_vars[j].value() > 0.5] return sorted(opened), secs, status, m.problem.numVariables(), m.problem.numConstraints()
def pcenter_by_covering(M, p): """Exact p-center by bisection on the radius: the smallest r whose set-covering problem (LSCP) needs no more than p sites. Each LSCP is a small, fast model; the direct p-center model is not.""" r = np.unique(M[M >= M.min(axis=1).max()]) # every row must reach some site, so r >= this lo, hi, best, n_solves = 0, len(r) - 1, None, 0 t0 = time.perf_counter() while lo <= hi: mid = (lo + hi) // 2 m = LSCP.from_cost_matrix(M, service_radius=r[mid]).solve(CBC()) n_solves += 1 opened = [j for j in range(M.shape[1]) if m.fac_vars[j].value() > 0.5] if len(opened) <= p: best, hi = opened, mid - 1 else: lo = mid + 1 return sorted(best), time.perf_counter() - t0, n_solves
existing = evaluate(np.arange(T_h.shape[1]), M=T_h)print(f"existing {len(hosp)} hospital sites: mean {existing['mean_min']:.1f} min | worst {existing['max_min']:.1f} min | " f"{existing['covered_pct']:.1f}% within {RADIUS} min")
# ---------------------------------------------------------------- Step 4: the three models at p = Psection(f"p-median, p-center and MCLP with p = {P} (greenfield)")MODELS = ["p-median", "p-center", "mclp"]sol = {}for k in MODELS: opened, secs, status, nv, nc = solve(k, P) sol[k] = opened e = evaluate(opened) print(f"{k:8s} {status} in {secs:5.1f} s ({nv:,} variables, {nc:,} constraints) | mean {e['mean_min']:.1f} min | " f"worst {e['max_min']:.1f} min | {e['covered_pct']:.1f}% within {RADIUS} min") print(" sites: " + "; ".join(cand.name[j] or cand.osm[j] for j in opened))opened, secs, n = pcenter_by_covering(T, P)print(f"p-center by LSCP bisection: worst {evaluate(opened)['max_min']:.2f} min " f"(direct model {evaluate(sol['p-center'])['max_min']:.2f} min) from {n} set-covering solves in {secs:.1f} s")shared = set(sol["p-median"]) & set(sol["p-center"]) & set(sol["mclp"])print(f"sites chosen by all three models: {len(shared)}")
# ---------------------------------------------------------------- Step 5: trade-off as p variessection(f"trade-off curves, p = {P_RANGE.start}-{P_RANGE.stop - 1}")rows = []for p in P_RANGE: for k in MODELS: if k == "p-center": opened, secs, _ = pcenter_by_covering(T, p) status = "Optimal" else: opened, secs, status, *_ = solve(k, p) rows.append({"model": k, "p": p, "secs": secs, "status": status, **evaluate(opened)})curve = pd.DataFrame(rows)curve.to_csv(OUT / "tradeoff.csv", index=False)print(curve.pivot(index="p", columns="model", values=["mean_min", "max_min", "covered_pct"]).round(1).to_string())st = curve.groupby("model").secs.agg(["median", "max"]).round(2)print("solve time (s) by model (p-center = LSCP bisection):", st.to_dict(orient="index"))gain = curve[curve.model == "p-median"].set_index("p").mean_minprint("p-median: minutes saved per extra site: " + ", ".join(f"{p}->{p + 1}: {gain[p] - gain[p + 1]:.2f}" for p in list(P_RANGE)[:-1]))
# ---------------------------------------------------------------- Step 6: problem sizesection("problem size: LSOAs vs output areas")bench = []for k in MODELS: for label, M, wt in (("LSOA", T, w), ("OA", T_oa, w_oa)): if k == "p-center": # the direct model took minutes on LSOAs; use the covering bisection here opened, secs, n = pcenter_by_covering(M, P) status, nv, nc, note = "Optimal", np.nan, np.nan, f"{n} LSCPs" else: opened, secs, status, nv, nc = solve(k, P, M=M, wt=wt) note = f"{nv / 1000:,.0f}k vars" if nv >= 1000 else f"{nv:.0f} vars" e = evaluate(opened, M=T_oa, wt=w_oa) # judge every solution on the finer OA demand bench.append({"model": k, "demand": label, "rows": M.shape[0], "variables": nv, "constraints": nc, "note": note, "secs": secs, "status": status, **e})bench = pd.DataFrame(bench)print(bench.round(2).to_string(index=False))
# ---------------------------------------------------------------- Step 7: adding to the existing networksection(f"adding 3 sites to the {len(hosp)} existing hospital sites")T_ext = np.hstack([T_h, T])fixed = np.arange(T_h.shape[1])ext = {}for k in ("p-median", "mclp"): opened, secs, status, *_ = solve(k, len(hosp) + 3, M=T_ext, predefined=fixed) new = [j - len(hosp) for j in opened if j >= len(hosp)] ext[k] = new e = evaluate(opened, M=T_ext) print(f"{k:8s} {status} in {secs:.1f} s | mean {e['mean_min']:.1f} min (was {existing['mean_min']:.1f}) | " f"worst {e['max_min']:.1f} min (was {existing['max_min']:.1f}) | {e['covered_pct']:.1f}% within {RADIUS} min " f"(was {existing['covered_pct']:.1f}%) | new: " + "; ".join(cand.name[j] or cand.osm[j] for j in new))
# ---------------------------------------------------------------- Figuressection("figures")XLIM, YLIM = (cornwall.bounds[0] - 3000, cornwall.bounds[2] + 3000), (cornwall.bounds[1] - 3000, cornwall.bounds[3] + 3000)COL = {"p-median": "#1543b0", "p-center": "#d95f02", "mclp": "#1b9e77"}LABEL = {"p-median": "p-median", "p-center": "p-center", "mclp": f"MCLP ({RADIUS} min)"}
def frame(ax, title): devon_lads.boundary.plot(ax=ax, color="#9aa0a0", linewidth=0.5) gpd.GeoSeries([cornwall]).plot(ax=ax, color="#f4f4f0", edgecolor="#333333", linewidth=0.6, zorder=0) ax.set_xlim(XLIM); ax.set_ylim(YLIM); ax.set_axis_off(); ax.set_title(title, fontsize=10) sx, sy = XLIM[0] + 3000, YLIM[0] + 3000 ax.plot([sx, sx + 20000], [sy, sy], color="black", lw=2) ax.text(sx + 10000, sy + 2500, "20 km", ha="center", fontsize=8)
# Figure 1: demand and candidatesfig, ax = plt.subplots(figsize=(8, 6))frame(ax, f"Demand ({len(lsoa)} LSOA population-weighted centroids) and candidate sites")lsoa.plot(ax=ax, markersize=lsoa["pop"] / 60, color="#6baed6", alpha=0.6, edgecolor="none")cand.plot(ax=ax, marker="^", markersize=10, color="#444444", zorder=3)hosp.plot(ax=ax, marker="P", markersize=45, color="#c51b7d", edgecolor="white", linewidth=0.5, zorder=4)ax.legend(handles=[ Line2D([], [], marker="o", ls="", color="#6baed6", markersize=7, label="LSOA residents (size = population)"), Line2D([], [], marker="^", ls="", color="#444444", markersize=5, label=f"Candidate community buildings ({len(cand)})"), Line2D([], [], marker="P", ls="", color="#c51b7d", markersize=8, label=f"Existing hospital sites in OSM ({len(hosp)})")], loc="lower right", fontsize=8, frameon=True)fig.savefig(OUT / "01-demand-and-candidates.png"); plt.close(fig)
# Figure 2: the three solutions at p = P with allocation lineslxy = lsoa.get_coordinates().to_numpy()cxy = cand.get_coordinates().to_numpy()fig, axes = plt.subplots(1, 3, figsize=(15, 4.8))for ax, k in zip(axes, MODELS): opened = np.array(sol[k]) near = opened[T[:, opened].argmin(axis=1)] t = T[np.arange(len(lsoa)), near] segs = np.stack([lxy, cxy[near]], axis=1) ax.add_collection(LineCollection(segs, colors=np.where(t <= RADIUS, "#9ecae1", "#fb6a4a"), linewidths=0.5)) e = evaluate(opened) frame(ax, f"{LABEL[k]}: mean {e['mean_min']:.1f} min, worst {e['max_min']:.1f} min,\n" f"{e['covered_pct']:.0f}% of residents within {RADIUS} min") ax.scatter(lxy[:, 0], lxy[:, 1], s=lsoa["pop"] / 150, color="#555555", zorder=3) ax.scatter(cxy[opened, 0], cxy[opened, 1], s=110, marker="*", color=COL[k], edgecolor="black", lw=0.6, zorder=5)fig.legend(handles=[Line2D([], [], color="#9ecae1", label=f"LSOA within {RADIUS} min of its nearest site"), Line2D([], [], color="#fb6a4a", label=f"LSOA more than {RADIUS} min away"), Line2D([], [], marker="*", ls="", color="#555555", markersize=10, label="chosen site")], loc="lower center", ncol=3, fontsize=8, frameon=False, bbox_to_anchor=(0.5, 0.07))fig.suptitle(f"Where to put {P} sites among {len(cand)} candidates: three objectives, three answers", y=1.02)fig.savefig(OUT / "02-solutions-p6.png"); plt.close(fig)
# Figure 3: metrics of each solution, plus the existing hospital networkmetrics = pd.DataFrame({LABEL[k].replace(" (", "\n("): evaluate(sol[k]) for k in MODELS} | {f"Existing {len(hosp)}\nhospital sites": existing}).Tfig, axes = plt.subplots(1, 3, figsize=(12, 3.6))colours = [COL[k] for k in MODELS] + ["#c51b7d"]for ax, (c, lab) in zip(axes, [("mean_min", "Population-weighted mean time (min)"), ("max_min", "Worst-served LSOA (min)"), ("covered_pct", f"Residents within {RADIUS} min (%)")]): bars = ax.bar(metrics.index, metrics[c], color=colours) ax.bar_label(bars, fmt="%.1f", fontsize=8) ax.set_title(lab, fontsize=9); ax.tick_params(axis="x", labelsize=7); ax.grid(axis="y", alpha=0.3) ax.set_ylim(0, metrics[c].max() * 1.15)fig.suptitle(f"Each model wins on its own objective (p = {P} new sites)", y=1.03)fig.savefig(OUT / "03-solution-metrics.png"); plt.close(fig)
# Figure 4: trade-off curves as p variesfig, axes = plt.subplots(1, 3, figsize=(13, 3.8))for ax, (c, lab) in zip(axes, [("mean_min", "Population-weighted mean time (min)"), ("max_min", "Worst-served LSOA (min)"), ("covered_pct", f"Residents within {RADIUS} min (%)")]): for k in MODELS: g = curve[curve.model == k] ax.plot(g.p, g[c], marker="o", ms=3.5, color=COL[k], label=LABEL[k]) ax.axhline(existing[c], color="#c51b7d", ls=":", lw=1.2, label=f"existing {len(hosp)} hospital sites") ax.set_xlabel("Number of sites, p"); ax.set_title(lab, fontsize=9); ax.grid(alpha=0.3) ax.set_xticks(list(P_RANGE))axes[0].legend(fontsize=8)fig.suptitle("Trade-off curves: diminishing returns from each extra site", y=1.03)fig.savefig(OUT / "04-tradeoff-curves.png"); plt.close(fig)
# Figure 5: solve time and problem sizefig, axes = plt.subplots(1, 2, figsize=(11, 3.8), gridspec_kw={"width_ratios": [1.4, 1]})for k in MODELS: g = curve[curve.model == k] axes[0].plot(g.p, g.secs, marker="o", ms=3.5, color=COL[k], label=LABEL[k])axes[0].set_yscale("log"); axes[0].set_xlabel("Number of sites, p"); axes[0].set_ylabel("CBC solve time (s, log scale)")axes[0].set_title(f"Solve time, {len(lsoa)} LSOAs x {len(cand)} candidates", fontsize=9)axes[0].set_xticks(list(P_RANGE)); axes[0].grid(alpha=0.3, which="both"); axes[0].legend(fontsize=8)xb = np.arange(len(MODELS))for i, (label, hatch) in enumerate((("LSOA", ""), ("OA", "//"))): g = bench[bench.demand == label].set_index("model").loc[MODELS] bars = axes[1].bar(xb + (i - 0.5) * 0.38, g.secs, 0.38, color=[COL[k] for k in MODELS], hatch=hatch, edgecolor="white", label=f"{label}s ({g.rows.iloc[0]:,} demand points)") axes[1].bar_label(bars, labels=g.note.tolist(), fontsize=6.5, rotation=90, padding=2)axes[1].set_yscale("log"); axes[1].set_xticks(xb, [LABEL[k] for k in MODELS])axes[1].set_ylabel("CBC solve time (s, log scale)"); axes[1].set_ylim(top=bench.secs.max() * 30)axes[1].set_title(f"Problem size at p = {P}: LSOAs vs output areas", fontsize=9)axes[1].legend(fontsize=7, loc="upper left"); axes[1].grid(axis="y", alpha=0.3, which="both")fig.savefig(OUT / "05-solve-time.png"); plt.close(fig)
# Figure 6: adding 3 sites to the existing networkhxy = hosp.get_coordinates().to_numpy()fig, axes = plt.subplots(1, 2, figsize=(11, 4.6))for ax, k in zip(axes, ("p-median", "mclp")): opened = list(range(len(hosp))) + [j + len(hosp) for j in ext[k]] t = T_ext[:, opened].min(axis=1) e = evaluate(opened, M=T_ext) frame(ax, f"{LABEL[k]}: 3 new sites added to {len(hosp)} hospitals\nmean {e['mean_min']:.1f} min " f"(was {existing['mean_min']:.1f})\n{e['covered_pct']:.0f}% within {RADIUS} min " f"(was {existing['covered_pct']:.0f}%)") sc = ax.scatter(lxy[:, 0], lxy[:, 1], c=t, s=lsoa["pop"] / 80, cmap="YlOrRd", vmin=0, vmax=40, zorder=3) ax.scatter(hxy[:, 0], hxy[:, 1], marker="P", s=45, color="#c51b7d", edgecolor="white", lw=0.5, zorder=4) ax.scatter(cxy[ext[k], 0], cxy[ext[k], 1], marker="*", s=180, color=COL[k], edgecolor="black", lw=0.6, zorder=5)cb = fig.colorbar(sc, ax=axes, shrink=0.7, pad=0.01, extend="max")cb.set_label("Drive time to nearest site (min)")axes[0].legend(handles=[Line2D([], [], marker="P", ls="", color="#c51b7d", markersize=8, label="existing hospital site"), Line2D([], [], marker="*", ls="", color="#555555", markersize=11, label="new site")], loc="lower right", fontsize=7)fig.savefig(OUT / "06-existing-plus-new.png"); plt.close(fig)print("saved " + ", ".join(sorted(p.name for p in OUT.glob("*.png"))))