How to Measure Access to Services with the Two-Step Floating Catchment Method
On this page
Problem statement
Counting the pharmacies within 10 minutes of a home treats a pharmacy shared by 80,000 people the same as one shared by 6,000. The two-step floating catchment area method (2SFCA) accounts for that competition. Step one divides each facility's supply by the population that can reach it; step two adds up those ratios for every facility a home can reach. The result reads as supply per head of population β pharmacies per 10,000 people here.
Measured for 27 pharmacies and the 209,041 residents of every populated Vermont census block in Chittenden County and 10 km around it, with 10-minute drive catchments:
- County residents had a population-weighted mean of 1.333 pharmacies per 10,000 people, from zero for 9.7% of them to 5.115 in the best-served block.
- Access Γ population summed over every block came to exactly 27, the number of pharmacies β the check that the calculation is right.
- The catchment size reshaped the map. The rank correlation between the 10-minute and 20-minute versions was 0.487.
- Leaving out the population beyond the county raised the county's mean by 15.9%.
Quick answer
import numpy as np
W = (minutes <= 10).astype(float) # homes x facilities: 1 inside the catchment
ratio = supply / (W.T @ population) # step 1: supply per person within reach of each facility
access = W @ ratio # step 2: sum of the ratios within reach of each home
assert np.isclose((access * population).sum(), supply.sum())
minutes is a homes Γ facilities travel-time matrix, population the people in each home unit β including those beyond the study area β and supply one value per facility. A facility with nobody within reach divides by zero; the function in Example 1 handles it.
What you get β from The data used on this page
3,452 populated blocks (2,241 in the county), 209,041 people; 27 pharmacies
Supply, demand, a matrix and two summing steps
1. Define supply and demand
Supply is what each facility offers: pharmacists, opening hours, beds, appointments. Without staffing data, each pharmacy here counts as 1, so the score is pharmacies per head. Demand is population per small area: 3,452 populated census blocks, 2,241 of them in the county and the rest within 10 km of it, because pharmacies near the boundary serve people on both sides.
2. Build the travel-time matrix
One shortest-path search per pharmacy gives a 3,452 Γ 27 matrix of drive times. Everything that follows is matrix arithmetic on it: the full 2SFCA took well under a millisecond.
3. Step one: a ratio for every facility
Mark each homeβpharmacy pair inside the catchment and sum the population within reach of each pharmacy. With 10-minute catchments, the median pharmacy had 60,643 people within reach and the busiest 86,255. Each pharmacy's ratio is its supply divided by that population.
4. Step two: sum the ratios at every home
A home's access is the sum of the ratios of the pharmacies within its catchment. Scale it for reading β per 10,000 people here. For county residents the population-weighted median was 1.415, the 10th percentile 0.267 and the 90th 2.387, and 9.7% had no pharmacy within 10 minutes at all.
5. Check that supply is conserved
Multiply access by population and add it up: the answer must equal the total supply of the facilities that have anyone within reach. Here it was 27.0000 for 27 pharmacies. A different total means a mismatch between the two steps β a different threshold, a transposed matrix, or rows and columns out of order. The check cannot catch missing demand: computed with county residents only, the total was 26 β one pharmacy outside the county had no county resident within 10 minutes β and the check still passed while the county's mean rose from 1.333 to 1.545.
6. Test the catchment size
The catchment is a choice, and it changes the answer:
catchment mean median 10th-90th no access variation (CV)
5 min 1.316 1.361 0.000-2.732 31.6% 0.84
10 min 1.333 1.413 0.265-2.387 9.7% 0.63
15 min 1.367 1.542 0.384-1.881 3.1% 0.44
20 min 1.402 1.567 0.786-1.748 0.6% 0.29
Larger catchments smooth the scores towards the regional ratio β 27 pharmacies for 209,041 people is 1.292 per 10,000 β and shrink the share with none. They also reorder places: rank correlations across county blocks were 0.640 between 5 and 10 minutes, 0.708 between 10 and 15, and 0.487 between 10 and 20.
7. Soften the catchment edge
Basic 2SFCA treats a pharmacy 9 minutes away the same as one 1 minute away, and one 11 minutes away as absent. The enhanced version (E2SFCA) weights travel-time zones instead: 1.00 for 0β5 minutes, 0.68 for 5β10 and 0.22 for 10β15, the weights proposed by Luo and Qi. It gave a county mean of 1.347 and a median of 1.310, left 3.1% with no access, conserved supply exactly, and ranked blocks much as the basic 10-minute version did (Spearman 0.938).
8. Aggregate for reporting
Report population-weighted means for areas people recognise. Of the county's 40 census tracts, one had no pharmacy within 10 minutes of any resident: 1,963 people, in blocks with a median drive of 16.4 minutes to the nearest pharmacy. The next lowest had 0.105 and 0.126 pharmacies per 10,000, and the highest 2.494.
Pharmacist access and how much the catchment matters
The data used on this page
Chittenden County, Vermont, and 10 km around it. Demand is the 2020 Census: every populated Vermont block from the TIGER/Line 2020 blocks within 10 km of the county, as a representative point with its POP20. New York blocks across Lake Champlain are left out: no road inside the margin reaches them, and snapping them to the nearest node would put them on the wrong shore. Pharmacies are OpenStreetMap amenity=pharmacy within 10 km of the county. Drive times come from OpenStreetMap's drive network for the county plus 10.5 km with OSMnx's imputed free-flow speeds, road-only between nearest nodes; a block with no road route to a pharmacy simply has none within reach. Overpass via the maps.mail.ru mirror while overpass-api.de was refusing connections. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import requests
import urllib.request
import zipfile
import geopandas as gpd
import numpy as np
import osmnx as ox
import scipy.sparse as sp
import shapely
from scipy.sparse.csgraph import dijkstra
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-two-step-floating-catchment-python-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
CRS = 32145 # NAD83 / Vermont, metres
if not os.path.exists("tl_2020_50_tabblock20.zip"):
urllib.request.urlretrieve("https://www2.census.gov/geo/tiger/TIGER2020/TABBLOCK20/"
"tl_2020_50_tabblock20.zip", "tl_2020_50_tabblock20.zip")
vt_blocks = gpd.read_file("tl_2020_50_tabblock20.zip").to_crs(CRS)
county = vt_blocks[vt_blocks.COUNTYFP20 == "007"].union_all() # Chittenden
blocks = vt_blocks[vt_blocks.POP20 > 0].copy()
blocks["geometry"] = blocks.geometry.representative_point()
blocks = blocks[shapely.distance(blocks.geometry.values, county) <= 10_000].reset_index(drop=True)
in_county = (blocks.COUNTYFP20 == "007").to_numpy()
population = blocks["POP20"].to_numpy(float)
reach = gpd.GeoSeries([county.buffer(10_500)], crs=CRS).to_crs(4326).iloc[0]
pharmacies = ox.features_from_polygon(reach, tags={"amenity": "pharmacy"}).to_crs(CRS)
pharmacies["geometry"] = pharmacies.geometry.representative_point()
pharmacies = pharmacies[shapely.distance(pharmacies.geometry.values, county) <= 10_000].reset_index()
G = ox.add_edge_travel_times(ox.add_edge_speeds(ox.graph_from_polygon(reach, network_type="drive")))
G = ox.project_graph(G, to_crs=CRS)
index = {n: i for i, n in enumerate(G.nodes)}
secs = {}
for u, v, d in G.edges(data=True): # shortest of any parallel edges
secs[(index[u], index[v])] = min(secs.get((index[u], index[v]), np.inf), d["travel_time"])
rows, cols = zip(*secs)
R = sp.csr_array((list(secs.values()), (rows, cols)), shape=(len(index), len(index))).T.tocsr()
p_idx = [index[n] for n in ox.distance.nearest_nodes(G, pharmacies.geometry.x, pharmacies.geometry.y)]
b_idx = [index[n] for n in ox.distance.nearest_nodes(G, blocks.geometry.x, blocks.geometry.y)]
minutes = dijkstra(R, directed=True, indices=p_idx)[:, b_idx].T / 60 # blocks x pharmacies
print(f"{len(blocks):,} populated blocks ({in_county.sum():,} in the county), {population.sum():,.0f} people; "
f"{len(pharmacies)} pharmacies")
3,452 populated blocks (2,241 in the county), 209,041 people; 27 pharmacies
Example 1 β 2SFCA with a supply check
import numpy as np
def two_step_fca(minutes, population, supply, weight):
"""minutes: homes x facilities. weight: turns travel times into catchment weights between 0 and 1."""
W = weight(minutes)
demand = W.T @ population
ratio = np.divide(supply, demand, out=np.zeros(len(supply)), where=demand > 0)
access = W @ ratio
served = supply[demand > 0].sum()
assert np.isclose((access * population).sum(), served), "supply is not conserved"
return access
def summarise(access, population, per=10_000):
a = access * per
order = np.argsort(a)
cum = np.cumsum(population[order]) / population.sum()
def quantile(q):
return a[order][np.searchsorted(cum, q)]
print(f"mean {(a * population).sum() / population.sum():.3f}, median {quantile(0.5):.3f}, "
f"10th-90th {quantile(0.1):.3f}-{quantile(0.9):.3f}, "
f"no access {population[a == 0].sum() / population.sum():.1%}")
supply = np.ones(minutes.shape[1])
access = two_step_fca(minutes, population, supply, lambda t: (t <= 10).astype(float))
summarise(access[in_county], population[in_county])
mean 1.333, median 1.415, 10th-90th 0.267-2.387, no access 9.7%
The whole matrix goes into the calculation and only the county's rows come out, so the pharmacies' catchments include the people beyond the county line.
Example 2 β how much the catchment matters
from scipy.stats import spearmanr
results = {}
for limit in (5, 10, 15, 20):
results[limit] = two_step_fca(minutes, population, supply, lambda t, limit=limit: (t <= limit).astype(float))[in_county]
print(f"{limit:2d} min: ", end="")
summarise(results[limit], population[in_county])
print(f"rank agreement 10 vs 20 min: {spearmanr(results[10], results[20])[0]:.3f}")
5 min: mean 1.316, median 1.361, 10th-90th 0.000-2.732, no access 31.6%
10 min: mean 1.333, median 1.415, 10th-90th 0.267-2.387, no access 9.7%
15 min: mean 1.367, median 1.542, 10th-90th 0.384-1.881, no access 3.1%
20 min: mean 1.402, median 1.567, 10th-90th 0.786-1.747, no access 0.7%
rank agreement 10 vs 20 min: 0.487
The limit=limit default argument fixes each threshold inside its lambda; without it every lambda would see the last value of the loop.
Example 3 β enhanced zones and tract summaries
def zones(t):
return np.select([t <= 5, t <= 10, t <= 15], [1.0, 0.68, 0.22], 0.0)
enhanced = two_step_fca(minutes, population, supply, zones)
summarise(enhanced[in_county], population[in_county])
print(f"rank agreement with the basic 10-minute scores: {spearmanr(enhanced[in_county], results[10])[0]:.3f}")
county_blocks = blocks[in_county].assign(access=access[in_county] * 10_000)
county_blocks["weighted"] = county_blocks.access * county_blocks.POP20
tracts = county_blocks.groupby(county_blocks.GEOID20.str[:11]).agg(people=("POP20", "sum"), weighted=("weighted", "sum"))
tracts["access"] = tracts.weighted / tracts.people
print(tracts.sort_values("access")[["people", "access"]].head(3).round(3).to_string())
mean 1.347, median 1.310, 10th-90th 0.290-2.271, no access 3.1%
rank agreement with the basic 10-minute scores: 0.938
people access
GEOID20
50007003503 1963 0.000
50007003000 4167 0.105
50007002800 5104 0.126
blocks is the table of home units in the same row order as minutes, with each block's GEOID; its first 11 characters identify the tract.
Example 4 β the other figures quoted above
W10 = (minutes <= 10).astype(float)
reach = W10.T @ population
print(f"people within 10 min of a pharmacy: median {np.median(reach):,.0f}, busiest {reach.max():,.0f}; "
f"best-served county block {10_000 * access[in_county].max():.3f}")
print(f"supply check: {(access * population).sum():.4f} for {len(supply)} pharmacies; "
f"regional ratio {10_000 * supply.sum() / population.sum():.3f} per 10,000 ({population.sum():,.0f} people)")
own = two_step_fca(minutes[in_county], population[in_county], supply, lambda t: (t <= 10).astype(float))
print(f"county residents only: total {(own * population[in_county]).sum():.4f}; county mean "
f"{10_000 * np.average(own, weights=population[in_county]):.3f} against "
f"{10_000 * np.average(access[in_county], weights=population[in_county]):.3f}")
pc = population[in_county]
for limit in (5, 10, 15, 20):
a = results[limit]
mean = np.average(a, weights=pc)
cv = np.sqrt(np.average((a - mean) ** 2, weights=pc)) / mean
print(f"{limit:2d} min: coefficient of variation {cv:.2f}")
for a, b in ((5, 10), (10, 15), (10, 20)):
print(f"Spearman {a} vs {b} min: {spearmanr(results[a], results[b])[0]:.3f}")
zero_tract = tracts.index[tracts.access == 0]
in_zero = county_blocks.GEOID20.str[:11].isin(zero_tract).to_numpy()
near = minutes[in_county].min(axis=1)[in_zero]
w0 = county_blocks.POP20.to_numpy()[in_zero]
order = np.argsort(near)
print(f"tracts with no access: {len(zero_tract)}; population-weighted median drive to the nearest pharmacy "
f"{near[order][np.searchsorted(np.cumsum(w0[order]) / w0.sum(), .5)]:.1f} min; "
f"highest tract {tracts.access.max():.3f}")
people within 10 min of a pharmacy: median 60,643, busiest 86,255; best-served county block 5.115
supply check: 27.0000 for 27 pharmacies; regional ratio 1.292 per 10,000 (209,041 people)
county residents only: total 26.0000; county mean 1.545 against 1.333
5 min: coefficient of variation 0.84
10 min: coefficient of variation 0.63
15 min: coefficient of variation 0.44
20 min: coefficient of variation 0.29
Spearman 5 vs 10 min: 0.640
Spearman 10 vs 15 min: 0.708
Spearman 10 vs 20 min: 0.487
tracts with no access: 1; population-weighted median drive to the nearest pharmacy 16.4 min; highest tract 2.494
What the score means, and its cliffs
What the number means
Each home's score is the supply available to it after sharing every reachable facility with everyone else who can reach it. A score of 1.333 per 10,000 means the typical resident has, in effect, one pharmacy for every 7,500 people competing for the same pharmacies. It is a ratio, not a count and not a time.
Why the average is close to the regional ratio
Supply is conserved: the scores are a redistribution of the 27 pharmacies over the population. Averaged over everyone in the full study area, the population-weighted mean is exactly total supply over total population. County residents came out slightly above the regional 1.292 because the pharmacies are concentrated in the county.
Why binary catchments create cliffs
A pharmacy counts fully at 9.9 minutes and not at all at 10.1. Homes on either side of that line get very different scores, and a small change to the threshold moves many homes across it at once β which is why the 5-minute version left 31.4% with nothing and correlated only 0.641 with the 10-minute version. Distance-decay weights smooth the cliff without abandoning the method.
How it compares with simpler measures
Across county blocks, basic 10-minute 2SFCA had a rank correlation of 0.828 with the number of pharmacies within 10 minutes and 0.723 with the nearest-pharmacy time; the enhanced version correlated 0.801 with nearest time. The measures agree on the broad pattern and disagree about the places where many people compete for a few facilities.
Unreached facilities, supply units and direction
- Facilities with nobody within reach contribute nothing. Their supply drops out of the check; report them.
- Supply units drive interpretation. Pharmacies, pharmacists and opening hours give different maps.
- Population beyond the boundary is required, and so are facilities beyond it β see fixing accessibility scores that are wrong near the study area edge.
- Travel direction matters. Use home-to-facility times for trips to a service.
- Unreachable homes get zero, which is correct; do not drop them from the population.
- Demand is counted in several catchments. That is how competition is represented, not an error.
- Variants exist. Three-step and modified versions adjust for people choosing between nearby facilities.
Internal links
- Accessibility measures explained: nearest, cumulative and gravity β the simpler measures 2SFCA extends
- How to count the population within reach of each facility β the catchment populations of step one
- Fixing accessibility scores that are wrong near the study area edge β why the margin matters
- How to measure distance to the nearest facility for every home β nearest-facility times
- How to measure network distance for many originβdestination pairs β the travel-time matrix
- Catchment areas explained: buffers, isochrones and Voronoi compared β defining a catchment
- How to find the areas nobody can reach within a travel time β the zero-access places
- Census geographies explained: blocks, tracts, output areas and why they nest β aggregating blocks to tracts
FAQ
What is the two-step floating catchment method?
An accessibility measure that divides each facility's supply by the population within its catchment, then sums those ratios for each home. For 27 pharmacies and 10-minute drives, Chittenden County residents averaged 1.333 pharmacies per 10,000 people.
How do I check a 2SFCA calculation?
Multiply each home's access by its population and add it up. The total must equal the supply of the facilities that have anyone within reach β 27 for 27 pharmacies here.
What catchment size should I use for 2SFCA?
One that matches how far people travel for the service, and then test others. Between 5 and 20 minutes the share of county residents with no access fell from 31.6% to 0.7%, and rankings changed substantially.
What is the difference between 2SFCA and E2SFCA?
E2SFCA weights facilities by travel-time zone instead of counting everything inside the catchment equally. With weights of 1.00, 0.68 and 0.22 it left 3.1% of residents with no access, against 9.7% for basic 10-minute catchments.
Do I need population outside my study area?
Yes. Computed with county residents only, the county's mean access rose from 1.333 to 1.545 per 10,000, because pharmacies near the boundary appeared to serve fewer people than they do.
Why do so many homes score zero?
Because no facility is within the catchment of those homes. Zero is a meaningful result for a binary catchment; a larger catchment or distance-decay weights reduce it.