Siting New Clinics With P-Median Optimization
This guide, part of Facility Capacity Allocation Models, covers location–allocation for a common public health decision: where to put a small number of new clinics.
Problem Context & Constraints
A county has funding for three new community health clinics and a shortlist of 40 candidate sites — county-owned buildings, leased storefronts, school-based options. Planners want the sites that bring the most people closest to care. Picking by intuition tends to favour visible neighbourhoods and politically active ones; picking by optimisation makes the objective explicit and the trade-offs visible.
The p-median problem chooses p sites from the candidates to minimise the total demand-weighted travel time from every demand point to its nearest open site. With demand weighted by the population in need — uninsured residents, Medicaid enrollees, or a composite need index — the solution puts clinics where they shorten the most need-weighted trips. It is a classic integer program: a binary variable for each candidate (open or not) and a binary variable for each demand–site assignment.
Three constraints shape a usable model. Existing facilities must be included as fixed open sites, or the model will re-site them. Coverage floors — no one in need more than a set time from a clinic — express equity goals that minimising the average alone will not guarantee. And capacity, where a single site cannot absorb unlimited demand, turns the problem into a capacitated p-median, which assigns demand to sites under their capacity rather than simply to the nearest.
Prerequisites
python3.11pulp2.8.0 with the bundled CBC solver,numpy1.26.4,pandas2.2.2- Demand points — block groups or tracts — with a need weight such as uninsured population
- Candidate sites vetted for feasibility: zoning, lease availability, transit access
- Existing clinic locations
- A travel time matrix from every demand point to every candidate and existing site, built as in Caching OSRM Route Matrices for Large Batches
Step-by-Step Solution
Formulate the p-median with existing sites fixed open, add an optional maximum travel time constraint, solve with CBC, and compare the result against the status quo and against simple heuristics.
# P-median clinic siting with fixed existing sites and a coverage floor.
# Pinned: pulp==2.8.0, numpy==1.26.4, pandas==2.2.2
import logging
import numpy as np
import pandas as pd
import pulp
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("pmedian")
def solve_pmedian(T: pd.DataFrame, weight: pd.Series, existing: list[str], p_new: int,
max_minutes: float | None = None, time_limit: int = 600) -> dict:
"""T: minutes, rows = demand ids, columns = site ids (candidates + existing)."""
I, J = list(T.index), list(T.columns)
candidates = [j for j in J if j not in existing]
# Prune assignments that can never be optimal or are forbidden by the floor.
allowed = {(i, j) for i in I for j in J
if max_minutes is None or T.at[i, j] <= max_minutes}
uncoverable = [i for i in I if not any((i, j) in allowed for j in J)]
if uncoverable:
raise ValueError(f"{len(uncoverable)} demand points have no site within {max_minutes} min")
m = pulp.LpProblem("p_median", pulp.LpMinimize)
y = pulp.LpVariable.dicts("open", J, cat="Binary")
x = pulp.LpVariable.dicts("assign", list(allowed), lowBound=0, upBound=1)
m += pulp.lpSum(weight[i] * T.at[i, j] * x[(i, j)] for (i, j) in allowed)
for i in I:
m += pulp.lpSum(x[(i, j)] for j in J if (i, j) in allowed) == 1
for (i, j) in allowed:
m += x[(i, j)] <= y[j]
for j in existing:
m += y[j] == 1
m += pulp.lpSum(y[j] for j in candidates) == p_new
status = m.solve(pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit))
opened = [j for j in candidates if y[j].value() > 0.5]
assign = {i: max((j for j in J if (i, j) in allowed), key=lambda j: x[(i, j)].value() or 0)
for i in I}
obj = pulp.value(m.objective)
log.info("status=%s new_sites=%s objective=%.0f", pulp.LpStatus[status], opened, obj)
return {"opened": opened, "assign": assign, "objective": obj,
"status": pulp.LpStatus[status]}
def evaluate(T, weight, open_sites) -> dict:
nearest = T[open_sites].min(axis=1).to_numpy()
w = (weight.reindex(T.index) / weight.sum()).to_numpy()
order = np.argsort(nearest)
p90 = nearest[order][np.searchsorted(np.cumsum(w[order]), 0.9)]
return {"mean_min": float((nearest * w).sum()),
"p90_min": float(p90),
"share_over_30": float(w[nearest > 30].sum())}
Assignment variables are continuous here, not binary. For the uncapacitated p-median, the optimal solution assigns each demand point wholly to its nearest open site anyway, so relaxing the assignment variables is exact and makes the problem much faster to solve. For a capacitated version, where demand can be split across sites, the assignments must be made binary if split assignment is not acceptable.
The evaluate function reports more than the objective. The mean travel time is what the p-median minimises; the 90th percentile and the share of need beyond 30 minutes show what happens to the worst-served, which is where public health equity goals usually sit.
Validation & Edge Cases
Solver status. Always check the status. “Optimal” means the solution is proven best; “Not Solved” with a time limit means CBC stopped with the best solution found so far and an optimality gap. Report the gap. For a few hundred demand points and 40 candidates, CBC normally proves optimality in seconds; thousands of demand points with hundreds of candidates may need a longer limit or aggregation.
Infeasible coverage floors. If some demand points have no candidate or existing site within the floor, the model is infeasible. The code detects this before solving and lists them; the planning answer is either to add candidates in that area or to relax the floor there explicitly.
Sensitivity to p. Solve for p from one to five and plot the objective. The marginal gain of each additional clinic usually falls steeply; showing the curve helps decision-makers see whether a fourth site is worth seeking funding for.
Near-optimal alternatives. Several site combinations are often within one or two percent of the optimum. Those alternatives matter because a planner may reject the optimal site for reasons outside the model — a lease falls through, a neighbourhood objects. Enumerate near-optimal solutions by adding a constraint that excludes the optimum and re-solving, and present the top few.
Demand weights. Weighting by total population and by uninsured population produce different answers. The choice encodes the policy goal, so it should be agreed with program leadership before the model runs, not chosen afterwards to match a preferred site.
Travel mode. Clinics for low-income populations are often reached by transit or on foot. A drive-time matrix will favour sites near highways. Build the matrix in the mode the target population uses, or in a mix weighted by vehicle availability, using Building GTFS Travel-Time Matrices for Clinic Access.
Candidate lists that pre-decide the answer. The optimiser can only choose among the sites it is given. If candidates cluster in one part of the county because that is where the facilities department owns buildings, the solution will too. Review the candidate map for gaps before solving, and where a high-need area has no candidate, add a notional site there to learn how much the missing option costs — even if it cannot be built, the difference is useful evidence for acquiring one.
Capacity. Without a capacity constraint, a site in a dense area may be assigned more patients than it can see. Add capacities per site and move to a capacitated formulation, as in Balancing Facility Loads With Linear Assignment.
INFO pmedian status=Optimal new_sites=['C07', 'C22', 'C31'] objective=412880
INFO pmedian floor=30 min: status=Optimal new_sites=['C07', 'C19', 'C31'] objective=438215
Compliance Notes
Record the candidate list and how it was vetted, the demand geography and need weight, the travel time matrix source, mode and date, the constraints, solver version, status and gap, the chosen sites, near-optimal alternatives, and the evaluation metrics for each.
Siting decisions are public decisions. Publish the objective, the weights and the constraints in plain language with the result, so residents can see that the choice followed stated goals. Where the final decision departs from the model’s recommendation, record why; the model supports the decision, it does not make it.
Related Topics
- Facility Capacity Allocation Models — the parent guide.
- Modeling Hospital Bed Capacity With Network Analysis — capacity on the supply side.
- Comparing 2SFCA Variants for Provider Supply — measuring access before and after new sites open.