How to Measure Access to Services with the Two-Step Floating Catchment Method

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 212,225 residents of every populated census block in Chittenden County, Vermont, 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.5% of them to 5.821 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.471.
  • 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.

Diagram of the two steps: each pharmacy's supply divided by the population within its catchment, then each home's ratios summed over the pharmacies within its catchment.
Step one spreads each facility across the people who can reach it; step two collects those shares at each home.

Step-by-step solution

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,513 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,513 ร— 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,872 people within reach and the busiest 86,484. 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.414, the 10th percentile 0.267 and the 90th 2.378, and 9.5% 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.317   1.338    0.000-2.809     31.4%         0.84
 10 min     1.333   1.414    0.267-2.378      9.5%         0.63
 15 min     1.367   1.531    0.385-1.871      3.0%         0.44
 20 min     1.404   1.562    0.819-1.738      0.5%         0.28

Larger catchments smooth the scores towards the regional ratio โ€” 27 pharmacies for 212,225 people is 1.272 per 10,000 โ€” and shrink the share with none. They also reorder places: rank correlations across county blocks were 0.641 between 5 and 10 minutes, 0.710 between 10 and 15, and 0.471 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.316, left 3.0% with no access, conserved supply exactly, and ranked blocks much as the basic 10-minute version did (Spearman 0.937).

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.9 minutes to the nearest pharmacy. The next lowest had 0.105 and 0.126 pharmacies per 10,000, and the highest 2.585.

Bar chart of the share of county residents with zero 2SFCA access and the variation of scores for catchments of 5, 10, 15 and 20 minutes.
The catchment size sets how many people score zero and how different places look.

Code examples

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.414, 10th-90th 0.267-2.378, no access 9.5%

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.317, median 1.338, 10th-90th 0.000-2.809, no access 31.4%
10 min: mean 1.333, median 1.414, 10th-90th 0.267-2.378, no access 9.5%
15 min: mean 1.367, median 1.531, 10th-90th 0.385-1.871, no access 3.0%
20 min: mean 1.404, median 1.562, 10th-90th 0.819-1.738, no access 0.5%
rank agreement 10 vs 20 min: 0.471

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.316, 10th-90th 0.297-2.266, no access 3.0%
rank agreement with the basic 10-minute scores: 0.937
             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.

Explanation

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.272 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.

Two panels comparing the 2SFCA supply check with demand for the county and 10 km around it and with county demand only; both pass.
Leaving out the people beyond the boundary inflated access by 15.9% without failing the check.

Edge cases or notes

  • 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.

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.4% to 0.5%, 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.0% of residents with no access, against 9.5% 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.