How to Build Voronoi Service Areas Around Facilities in Python

Problem statement

A Voronoi service area gives each facility the territory that is closer to it, in a straight line, than to any other facility. It is the quickest way to split a region between facilities and count who each one serves. The geometry takes milliseconds; the answer depends on three decisions around it โ€” which facilities go in, what the areas are clipped to, and how people are counted โ€” and on whether straight lines are a fair stand-in for travel.

Measured with the 39 supermarkets in and within 10 km of Chittenden County, Vermont, and the county's 168,323 residents:

  • Building and clipping the 38 areas that reach the county's land took 42 milliseconds.
  • 10 of those areas belonged to supermarkets outside the county, and 4,149 residents lived in them.
  • Counting people by block point rather than by block area moved 4,751 residents (2.8%) between areas.
  • For 26.5% of residents another supermarket was nearer by road than the one whose area they lived in.

Quick answer

Build the cells in input order, attach the facilities' attributes, clip to land, and join people by location:

import geopandas as gpd
import shapely

cells = shapely.voronoi_polygons(shapely.MultiPoint(stores.geometry.to_list()),
                                 extend_to=land.buffer(10_000), ordered=True)
areas = gpd.GeoDataFrame(stores.drop(columns="geometry"), geometry=list(cells.geoms), crs=stores.crs).clip(land)
people = gpd.sjoin(homes, areas[["geometry"]], predicate="within").groupby("index_right").POP20.sum()

stores holds one projected point per facility (including those just outside the study area), land is the study area without open water, and homes holds one point per populated census block with its population in POP20.

Flow from mixed OSM outlines and points to one point per facility, facilities beyond the boundary added, an ordered Voronoi diagram, clipping to land and counting people.
The diagram itself is one line; the steps around it decide whether the areas mean anything.

Step-by-step solution

1. Reduce every facility to one point

OpenStreetMap mixes building outlines and points: there were 40 supermarket features, 24 outlines and 16 points. Take a representative point of each outline โ€” always inside the shape, unlike a centroid โ€” and drop points that fall inside an outline, which are the same shop mapped twice. That left 39 supermarkets. Check for facilities very close together too: 2 of them had another supermarket within 50 m, and exact duplicates make ordered=True fail (see fixing Voronoi polygons that extend forever or miss the study area).

2. Include facilities beyond the study area

People near a boundary shop across it. 11 of the 39 supermarkets were outside the county but within 10 km, and 10 of them claimed part of the county's land. Leaving them out would have put 128 blocks, holding 2.5% of residents, in the area of a more distant supermarket inside the county.

3. Build the diagram in a projected CRS, in input order

Use a projected coordinate system so that "closer" means closer on the ground โ€” the county data here are in EPSG:32145, Vermont State Plane in metres. shapely.voronoi_polygons with ordered=True returns the cells in the order of the input points, so cell i belongs to facility i; every cell contained its own supermarket. extend_to stretches the outer cells past the study area instead of stopping at the envelope of the points.

4. Clip to land

The county polygon covers 1,606 kmยฒ, or 1,534 kmยฒ once census blocks that are entirely water are removed. Clip to the land version, or the lakeside areas count open water as territory. After clipping, 38 areas remained, with a median of 18.0 kmยฒ and a range from 0.21 kmยฒ to 201.2 kmยฒ.

5. Count the people in each area

A block point inside an area is the fast count: the spatial join took 4 ms. Areal weighting shares each block's population by the fraction of the block's area in each service area, and took 323 ms. The two agree for most areas, but 452 blocks were split between areas and the point count moved 4,751 residents โ€” 2.8% of the county โ€” relative to area weighting. Small areas feel it most: no block point fell in the area of Mac's Market, which held 21 people by area. By point count, the median area held 3,788 residents and the largest, Jake's ONE Market's, 14,012.

6. Report what the areas hide

A service area says nothing about how far its residents are from its facility. The population-weighted median straight-line distance to the area's supermarket was 1,537 m, the 90th percentile 4,857 m and the longest 12,062 m.

7. Check the areas against travel time

Voronoi areas assume straight lines. Assign each block to its nearest supermarket by drive time instead and compare. 566 of the 2,241 blocks, holding 26.5% of residents, were nearer by road to a different supermarket. For most of them the difference was small โ€” a median of 0.53 minutes โ€” but the 90th percentile was 3.60 minutes and the worst 18.6. Store totals moved more than that suggests: 27,646 residents changed store, and 20 of the 38 areas gained or lost more than a quarter of their population.

supermarket              area km2   people (points)   people (area)   people (by road)
Jake's ONE Market           7.5         14,012           13,933           10,110
City Market                 3.7         13,658           14,334           15,025
Shaw's                     33.7         12,279           11,797           15,654
Hannaford                  31.1         10,020           10,027           10,246
Hannaford                 126.8          9,884           10,056            9,472
Shelburne Supermarket     167.1          8,310            8,341            9,828
Bar chart comparing the population of six supermarket service areas counted by Voronoi area and by nearest supermarket by road.
The same homes, assigned by straight line and by road: Shaw's gains more than a quarter, Jake's ONE Market loses more than a quarter.

Code examples

Example 1 โ€” one point per facility

import geopandas as gpd
import numpy as np
import pandas as pd


def facility_points(features, tolerance=1.0):
    """Outlines to representative points; drop points inside an outline and near-exact duplicates."""
    outlines = features[features.geom_type.isin(["Polygon", "MultiPolygon"])].copy()
    points = features[features.geom_type == "Point"]
    inside = gpd.sjoin(points, outlines[["geometry"]], predicate="within").index.unique()
    outlines["geometry"] = outlines.geometry.representative_point()
    out = pd.concat([outlines, points.drop(index=inside)], ignore_index=True)
    snapped = pd.DataFrame(np.round(np.column_stack([out.geometry.x, out.geometry.y]) / tolerance))
    repeated = snapped.duplicated().to_numpy()
    print(f"{len(features)} features -> {len(out) - repeated.sum()} points "
          f"(dropped: {len(inside)} inside an outline, {repeated.sum()} duplicates)")
    return gpd.GeoDataFrame(out[~repeated], geometry="geometry", crs=features.crs).reset_index(drop=True)

On the supermarket layer, in EPSG:32145, it printed 40 features -> 39 points (dropped: 1 inside an outline, 0 duplicates).

Example 2 โ€” areas and their populations

import shapely


def service_areas(facilities, land, reach=10_000):
    cells = shapely.voronoi_polygons(shapely.MultiPoint(facilities.geometry.to_list()),
                                     extend_to=land.buffer(reach), ordered=True)
    areas = gpd.GeoDataFrame(facilities.drop(columns="geometry"), geometry=list(cells.geoms), crs=facilities.crs)
    areas = areas.clip(land)
    areas["km2"] = areas.area / 1e6
    return areas


def people_by_area(areas, blocks, population="POP20"):
    """Share each block's population by the fraction of its area inside each service area."""
    b = blocks[[population, "geometry"]].assign(block_m2=blocks.area)
    pieces = gpd.overlay(b, areas[["geometry"]].reset_index(names="area_id"), how="intersection", keep_geom_type=True)
    pieces["people"] = pieces[population] * pieces.area / pieces.block_m2
    return pieces.groupby("area_id").people.sum().reindex(areas.index, fill_value=0)


land = county.difference(blocks_all[(blocks_all.ALAND20 == 0) & (blocks_all.AWATER20 > 0)].union_all())
areas = service_areas(stores, land)
areas["people"] = people_by_area(areas, populated_blocks)
print(f"{len(areas)} areas, {areas.people.sum():,.0f} people; median area {areas.km2.median():.1f} km2, "
      f"median population {areas.people.median():,.0f}")
38 areas, 168,323 people; median area 18.0 km2, median population 3,711

By area weighting the median area held 3,711 people, against 3,788 by block point. areas keeps the facilities' index, so its labels match rows of stores, whatever order clip returns them in.

Example 3 โ€” the same homes by road

def weighted_quantile(values, weights, q):
    order = np.argsort(values)
    cum = np.cumsum(weights[order]) / weights.sum()
    return float(values[order][np.searchsorted(cum, q)])


def compare_with_drive_time(homes, areas, minutes, population="POP20"):
    """minutes: travel time from each home (row) to each facility (column, in the facilities' order)."""
    joined = gpd.sjoin(homes, areas[["geometry"]], predicate="within", how="left")
    by_area = joined[~joined.index.duplicated()].index_right.to_numpy(dtype=int)
    by_road = minutes.argmin(axis=1)
    w = homes[population].to_numpy(dtype=float)
    moved = by_area != by_road
    extra = minutes[np.arange(len(minutes)), by_area] - minutes.min(axis=1)
    print(f"nearer by road to another facility: {w[moved].sum() / w.sum():.1%} of people; extra minutes for them: "
          f"median {weighted_quantile(extra[moved], w[moved], 0.5):.2f}, "
          f"90th percentile {weighted_quantile(extra[moved], w[moved], 0.9):.2f}, longest {extra.max():.1f}")
    return pd.DataFrame({"voronoi": np.bincount(by_area, weights=w, minlength=minutes.shape[1]),
                         "by_road": np.bincount(by_road, weights=w, minlength=minutes.shape[1])})


people = compare_with_drive_time(homes, areas, minutes).loc[areas.index]
change = (people.by_road - people.voronoi).abs() / people.voronoi.clip(lower=1)
print(f"areas whose population changes by more than 25%: {(change > 0.25).sum()} of {len(people)}")
nearer by road to another facility: 26.5% of people; extra minutes for them: median 0.53, 90th percentile 3.60, longest 18.6
areas whose population changes by more than 25%: 20 of 38

minutes came from one shortest-path search per supermarket on the drive network, as in measuring distance to the nearest facility.

Explanation

What a Voronoi area assumes

That people use the facility nearest in a straight line, that every facility pulls equally, and that there is no capacity. Those assumptions make the areas a clean partition โ€” every point belongs to exactly one โ€” which is why they are useful for maps and territories, and why they are a weak model of behaviour.

Why block points and block areas disagree

A block point stands for everyone in the block. Where blocks are small, the error is small and cancels between neighbours; where blocks are large and rural, a single point can fall in one area while most of the houses are in another. The difference is largest for the smallest areas, where a single block is a large share of the total.

Why road distance disagrees with straight lines

Roads bend around water and hills, bridges are few, and the road network is denser in some directions than others. Road distance to the nearest pharmacy in the same county was a median 1.46 times the straight-line distance. A lake between a home and the nearest store in a straight line can make a store on the same shore the faster choice.

When straight lines are good enough

For a first division of a region, a sales-territory map, or anywhere facilities are dense and the road network is a regular grid, Voronoi areas are quick and defensible. For questions about access or travel burden โ€” who is poorly served, how long trips are โ€” assign by network travel time instead.

Table of checks on Voronoi service areas: outside stores left out, block points against block areas, nearest supermarket by road, and area populations by road.
Boundary and counting choices moved a few per cent of residents; road distance moved a quarter.

Edge cases or notes

  • Exact duplicate points with ordered=True raise GEOSException: Multiple input coordinates in cell; remove them first.
  • GeoSeries.voronoi_polygons does not preserve order in geopandas 1.1; only 2.6% of its cells contained the supermarket in the same row, so attach attributes with a spatial join.
  • Latitudeโ€“longitude coordinates move boundaries. Build in a projected CRS.
  • Size and brand are ignored. Use a Huff model when attractiveness matters.
  • Empty areas are real. A facility can win no populated block at all.
  • A network Voronoi is just nearest by travel time. Assign homes to their fastest facility; there is no polygon step.
  • Closed or planned facilities change every neighbour. Rebuild the whole diagram, not one cell.

FAQ

What is a Voronoi service area?

The part of a region closer, in a straight line, to one facility than to any other. Around 39 supermarkets, the 38 areas reaching Chittenden County's land had a median size of 18.0 kmยฒ.

How do I keep Voronoi cells matched to the right facility?

Pass ordered=True to shapely's voronoi_polygons, which returns cells in input order, or attach attributes afterwards with a spatial join between cells and facility points.

Should I include facilities outside my study area?

Yes. Ten supermarkets outside the county claimed part of its land, and leaving them out put 2.5% of residents in the area of a more distant store.

How should I count the population of each service area?

Areal weighting of block polygons is the safer count; block points are faster. On the county's areas the point count moved 2.8% of residents between areas.

Are Voronoi areas the same as nearest by road?

No. For 26.5% of residents a different supermarket was nearer by road, usually by under a minute but by up to 18.6 minutes.

When should I use drive time instead of Voronoi areas?

Whenever the question is about travel or access, or the road network is irregular. Twenty of the 38 supermarket areas changed population by more than a quarter when homes were assigned by road.