How to Find the Areas Nobody Can Reach Within a Travel Time

Problem statement

A service promises a response within a travel time โ€” a fire engine in eight minutes, a clinic within twenty, a depot delivery in an hour โ€” and you need to know who is outside that promise, how many of them there are, and where they live.

The usual first attempt is a buffer around each facility. It produces a number that looks reasonable and a map that is wrong. Measured on Chittenden County, Vermont, a 5 km straight-line buffer around 48 fire stations said 4.4% of the population was uncovered; driving times on the road network said 4.1%. The totals almost agree. The people do not: 2,815 residents the buffer marked as covered are more than eight minutes away by road, and 3,335 it marked as uncovered are inside eight minutes.

This guide computes network travel time from every facility to every census block, turns thresholds into population and area shares, and extracts the gaps as ranked patches โ€” all of it in 0.3 seconds on a county of 2,749 blocks and a 9,799-node drive network.

Quick answer

Run one multi-source shortest-path search from all facilities at once, read off the time at each block's nearest node, and threshold:

import numpy as np
from scipy.sparse.csgraph import dijkstra
from scipy.spatial import cKDTree

tree = cKDTree(node_xy)                                   # network nodes, projected metres
_, station_nodes = tree.query(stations_xy)
snap_m, block_nodes = tree.query(block_points_xy)

seconds = dijkstra(travel_time_csr, directed=True,        # facility -> everywhere
                   indices=np.unique(station_nodes), min_only=True)
minutes = seconds[block_nodes] / 60

beyond = minutes > 8
print(f"{pop[beyond].sum() / pop.sum():.1%} of people, "
      f"{land[beyond].sum() / land.sum():.1%} of land beyond 8 minutes")

Measured: 4.1% of the county's 168,323 people โ€” 6,941 residents โ€” and 25.5% of its land were more than eight minutes' drive from the nearest station. The search itself took 0.003 s, because min_only=True solves one problem from all 48 stations instead of 48 separate ones.

Bar chart of the share of population and of land beyond 4, 8 and 12 minutes from a fire station.
A gap map coloured by area shows mostly empty hills; weight by people before you rank anything.

Step-by-step solution

1. Build a travel-time graph once

Load a drive network, add speeds and travel times, keep the largest strongly connected component, and store it as a sparse matrix of seconds between nodes:

import osmnx as ox
import networkx as nx
import numpy as np
import scipy.sparse as sp

G = ox.project_graph(ox.load_graphml("drive.graphml"), to_crs=32145)
G = ox.add_edge_travel_times(ox.add_edge_speeds(G))
G = G.subgraph(max(nx.strongly_connected_components(G), key=len)).copy()

nodes = list(G.nodes)
index = {n: i for i, n in enumerate(nodes)}
best = {}
for u, v, d in G.edges(data=True):
    key = (index[u], index[v])
    best[key] = min(best.get(key, np.inf), d["travel_time"])
rows, cols = zip(*best)
csr = sp.csr_matrix((list(best.values()), (rows, cols)), shape=(len(nodes), len(nodes)))

The county plus a 10 km margin gave 9,799 nodes and 22,563 one-way arcs after dropping 36 nodes outside the main component. Parallel edges must be reduced to their minimum, not summed โ€” csr_matrix adds duplicates by default.

Check the speeds you have been given. OSMnx imputes missing maxspeed from the mean for each road type, and here the median edge speed came out at exactly 40.6 km/h, the imputed value for the most common class. The travel times are a model, and the thresholds inherit its assumptions.

2. Include the facilities just outside your boundary

People near a county line are served across it. Measured with only the 29 stations inside the county, 1.8% of people and 12.3% of land were beyond ten minutes. Adding the 19 stations within 10 km outside the county brought that to 1.5% and 10.6%. Buffer the facility search, then report on the study area.

3. Search from all facilities at once

The question is "how far is the nearest facility", so one multi-source search answers it:

seconds = dijkstra(csr, directed=True, indices=np.unique(station_nodes), min_only=True)

Direction matters on a directed graph. For an emergency response the trip runs from the station to the home, which is csr as it is. For people travelling to a service, search on the transpose, csr.T.

4. Snap blocks to the network and decide about the last leg

A block's representative point is rarely on a road. Snapping it to the nearest node gives a network time, but the walk or drive to that node is not in it:

minutes_net = seconds[block_nodes] / 60
minutes_leg = (seconds[block_nodes] + snap_m / (30 / 3.6)) / 60   # last leg at 30 km/h

Measured, adding the last leg raised the population beyond eight minutes from 4.1% to 7.0%. That is a large change for a detail, and it is not random: the blocks in the gaps had a median snapping distance of 436 m, against 143 m for all blocks. The places that are far from a station are also the places far from a road.

5. Report population and area at several thresholds

threshold  people beyond   share    land beyond
  4 min        52,734      31.3%       73.2%
  6 min        19,118      11.4%       51.3%
  8 min         6,941       4.1%       25.5%
 10 min         2,514       1.5%       10.6%
 12 min         1,050       0.6%        3.3%
 15 min            89       0.1%        0.0%

Two things stand out. Area and population tell different stories โ€” at four minutes, 73% of the land but 31% of the people. And the curve is steep: going from eight to ten minutes cuts the uncovered population by almost two-thirds. A single threshold hides both.

6. Compare with a straight-line buffer, once, to see what it gets wrong

from scipy.spatial import cKDTree

straight_m, _ = cKDTree(stations_xy).query(block_points_xy)
buffer_says_uncovered = straight_m > 5000
network_says_uncovered = minutes_net > 8

The cross-tabulation of blocks:

                    buffer: covered   buffer: uncovered
network: โ‰ค 8 min         2,406               121
network: > 8 min            72               150

Of the 343 blocks at least one method calls uncovered, only 150 are uncovered by both. The buffer is not a conservative approximation; it is a different map.

7. Turn gap blocks into patches and rank them

import geopandas as gpd

gaps = blocks[network_says_uncovered]
merged = gaps.geometry.buffer(1).union_all()
patches = gpd.GeoDataFrame(geometry=list(getattr(merged, "geoms", [merged])), crs=blocks.crs)
patches["people"] = [gaps.loc[gaps.intersects(p), "POP20"].sum() for p in patches.geometry]
patches = patches.sort_values("people", ascending=False)

At eight minutes the county's gaps formed 34 patches. The five most populated held 50.6% of everyone beyond eight minutes โ€” the largest had 1,083 people over 48 kmยฒ โ€” and 5 patches had nobody living in them at all. That ranking, not the county-wide percentage, is what a new station would be sited against.

Grid comparing blocks classified as covered or uncovered by a 5 km buffer and by an 8 minute network travel time.
Similar totals, different people: the buffer and the network disagree about 193 blocks.

Code examples

Example 1 โ€” nearest-facility travel time for every block

import numpy as np
from scipy.sparse.csgraph import dijkstra
from scipy.spatial import cKDTree


def travel_time_to_nearest(csr, node_xy, facility_xy, point_xy,
                           direction="from_facility", last_leg_kmh=None):
    """Minutes from the nearest facility to each point (or to it, with direction='to_facility')."""
    tree = cKDTree(node_xy)
    _, fac_nodes = tree.query(facility_xy)
    snap_m, pt_nodes = tree.query(point_xy)
    graph = csr if direction == "from_facility" else csr.T.tocsr()
    seconds = dijkstra(graph, directed=True, indices=np.unique(fac_nodes), min_only=True)
    t = seconds[pt_nodes]
    if last_leg_kmh:
        t = t + snap_m / (last_leg_kmh / 3.6)
    unreachable = np.isinf(t).sum()
    if unreachable:
        print(f"warning: {unreachable} points cannot be reached โ€” check graph connectivity")
    return t / 60, snap_m

Example 2 โ€” a coverage table at several thresholds

import pandas as pd


def coverage_table(minutes, population, land_area, thresholds=(4, 6, 8, 10, 12, 15)):
    """People and land beyond each travel-time threshold."""
    rows = []
    for thr in thresholds:
        beyond = minutes > thr
        rows.append({
            "threshold_min": thr,
            "people_beyond": int(population[beyond].sum()),
            "share_people": population[beyond].sum() / population.sum(),
            "share_land": land_area[beyond].sum() / land_area.sum(),
        })
    table = pd.DataFrame(rows)
    print(table.to_string(index=False, formatters={
        "share_people": "{:.1%}".format, "share_land": "{:.1%}".format}))
    return table

Run on the county's blocks with network minutes:

 threshold_min  people_beyond share_people share_land
             4          52734        31.3%      73.2%
             6          19118        11.4%      51.3%
             8           6941         4.1%      25.5%
            10           2514         1.5%      10.6%
            12           1050         0.6%       3.3%
            15             89         0.1%       0.0%

Example 3 โ€” gap patches, ranked by the people in them

import geopandas as gpd


def gap_patches(blocks, minutes, threshold, pop_col="POP20"):
    """Dissolve adjacent blocks beyond the threshold into patches and rank them."""
    gaps = blocks.loc[minutes > threshold].copy()
    if gaps.empty:
        return gpd.GeoDataFrame(columns=["people", "km2", "geometry"], crs=blocks.crs)
    merged = gaps.geometry.buffer(1).union_all()          # 1 m closes slivers between blocks
    parts = list(getattr(merged, "geoms", [merged]))
    patches = gpd.GeoDataFrame(geometry=parts, crs=blocks.crs)
    joined = gpd.sjoin(gaps[[pop_col, "geometry"]].set_geometry(gaps.representative_point()),
                       patches, predicate="within")
    patches["people"] = joined.groupby("index_right")[pop_col].sum().reindex(patches.index, fill_value=0)
    patches["km2"] = patches.area / 1e6
    patches = patches.sort_values("people", ascending=False).reset_index(drop=True)
    top5 = patches.head(5)["people"].sum() / max(patches["people"].sum(), 1)
    print(f"{len(patches)} patches beyond {threshold} min; the 5 largest hold {top5:.1%} of those people; "
          f"{(patches['people'] == 0).sum()} are empty")
    return patches

Assigning people through representative points rather than intersects means a block touching two patches is counted once.

Explanation

Why one search is enough

Dijkstra's algorithm from a single source finds the shortest time to every node. With several sources and min_only=True, SciPy seeds all of them at distance zero and runs once, which gives each node its time from the nearest source โ€” exactly the question a coverage analysis asks. On this network that was 0.003 s, against a full originโ€“destination matrix that would repeat the search for every station and then take a minimum.

Why the buffer gets the total right and the map wrong

A straight-line radius assumes the road network is equally dense in every direction. It is not: valleys channel roads, lakes and ridges force detours, and fast roads stretch reach along their length. Those errors run both ways โ€” a village on a highway 6 km out is covered, a hamlet 3 km away up a dead-end valley road is not โ€” so they partly cancel in the county total. At the level of the block, where the decision is made, 193 blocks changed side.

Why the last leg matters most in the gaps

The network time starts at a node, and the distance from a home to that node is invisible to it. In a town that distance is a few tens of metres. In the rural blocks that make up the gaps, the representative point can be hundreds of metres from the nearest mapped road โ€” median 436 m here โ€” because blocks are large and roads few. So including or excluding the last leg changes the answer most exactly where the answer matters. Decide explicitly, and state which you used.

Why area and population diverge

Census blocks are drawn around people, so rural blocks are large and nearly empty while town blocks are small and dense. The land beyond four minutes was 73% of the county; the population beyond four minutes, 31%. A map of uncovered area is dominated by forest and farmland. A ranking by uncovered people is dominated by the edges of villages. Both are legitimate; mixing them is not.

Five steps from a travel-time graph to ranked gap patches, with the measured value at each step.
The only slow part of the workflow is building the graph; every threshold after that is free.

Edge cases or notes

  • Travel time is not response time. A fire service adds call handling and turnout; subtract them from the standard before thresholding.
  • Imputed speeds are averages. Where maxspeed is missing, every road of a type gets the same speed; calibrate against observed times if the thresholds are contractual.
  • Directed graphs are asymmetric. One-way systems make from-facility and to-facility times differ; choose the direction the service actually travels.
  • Unreachable nodes return inf. Keep the largest strongly connected component, or points on isolated fragments silently count as beyond every threshold.
  • OSM facility coverage varies. A missing station creates a gap that does not exist; check the facility list against an official one before publishing.
  • Blocks with no land are lake or river. Drop them (ALAND20 > 0) before computing land shares.
  • Time of day is not modelled. Free-flow speeds understate peak travel times, so a gap at 8 minutes free-flow is wider at rush hour.
  • Snapping to the nearest node can cross a river. For large rural blocks, check that the snapped node is on the same side as the homes.

FAQ

How do I find areas outside a travel time from any facility?

Run one multi-source Dijkstra search from all facilities on a travel-time graph, read the time at each block's nearest node, and select blocks above the threshold. Measured on a county network, the search took 0.003 s.

Is a buffer good enough for a coverage analysis?

For a county total it can be close โ€” 4.4% against 4.1% here โ€” but it misclassifies individual areas. A 5 km buffer called 2,815 people covered who were more than eight minutes away by road.

Should I include facilities outside my study area?

Yes. With only in-county stations, 1.8% of people were beyond ten minutes; adding the 19 stations just outside the boundary brought it to 1.5%.

Does the distance from a home to the nearest road matter?

In the gaps, a lot. Adding that last leg raised the share beyond eight minutes from 4.1% to 7.0%, because uncovered blocks were a median 436 m from a road node.

Should I report area or population?

Both, separately. At four minutes 73.2% of the land but 31.3% of the people were uncovered, and ranking gaps by area sends attention to empty forest.

How do I prioritise the gaps?

Dissolve adjacent uncovered blocks into patches and rank them by population. Here the five largest of 34 patches held half of everyone beyond eight minutes.