Fixing Accessibility Scores That Are Wrong Near the Study Area Edge
Problem statement
Accessibility scores computed from the road network, facilities and population inside a study area are wrong near its edge. The nearest facility may be just over the line; the fastest road to an inside facility may leave the area; and a facility's catchment contains people who were never loaded. The symptom is a ring of apparently poorly served places around the boundary โ and sometimes a ring of apparently excellent service, if the inputs were cut inconsistently.
Measured with supermarket accessibility for the 168,323 residents of Chittenden County, Vermont, against a calculation with the network, the 40 supermarkets and the population for 10 km around the county:
- Within 2 km of the county's land boundary, the county-only nearest-store time was 30.2% too long: 11.15 minutes against 8.56.
- The county-only two-step floating catchment (2SFCA) score there was 58.6% too low, and 14.9% of all residents had a score off by 10% or more.
- Adding the outside supermarkets but not the outside population made it 327.6% too high near the edge.
- A 2 km margin fixed nearest-store times to within 3.1% but left the 2SFCA score 19.8% low.
- Along the county's 67 km of boundary in Lake Champlain there was almost nothing to fix.
Quick answer
Extend all three inputs โ network, facilities and population โ beyond the study area by at least the reach of the largest catchment, compute, and keep only the study area's results:
truncated = with_margin(graph, node_xy, stores, blocks, county, residents, margin_m=0)
buffered = with_margin(graph, node_xy, stores, blocks, county, residents, margin_m=10_000)
with_margin (Example 1) keeps the road nodes, stores and population blocks within the margin, computes nearest-store time and 2SFCA, and returns scores for the study area's residents only. Compare the two before trusting either.
Step-by-step solution
1. Confirm that the errors follow the boundary
Compute the scores twice โ county-only, and with a generous margin โ and compare them by distance to the boundary. For residents nearer the county's land boundary than its lake shore:
distance to land edge nearest store: county-only vs 10 km margin 2SFCA: county-only vs 10 km margin
0-2 km 11.15 vs 8.56 min (+30.2%) 0.599 vs 1.449 (-58.6%)
2-5 km 7.70 vs 7.19 min 0.709 vs 1.238
5-10 km 4.91 vs 4.85 min 1.000 vs 1.182
The nearest-store error faded within about 5 km. The 2SFCA error reached further, because a 15-minute catchment reaches further than the nearest store. County-wide, the population-weighted mean 2SFCA score was 1.723 county-only against 1.793 with the margin.
2. Separate land edges from water edges
The county boundary is 201 km long: 135 km on land and 67 km in Lake Champlain. Across the lake there are no roads or shops within reach, so the margin changes nothing there: for residents 2โ10 km from the shore, nearest-store times were identical with and without the margin, and 2SFCA scores differed by under 5%. Split the boundary before deciding where a margin is needed (Example 3).
3. Extend the network, the facilities and the population together
With a 10 km margin the road network grew from 6,482 to 9,799 nodes, the supermarkets from 29 to 40, and the population blocks from 2,241 to 3,452. Each matters for a different reason: the network for routes that leave the area, the facilities for nearer options across the line, and the population for the competition in each facility's catchment.
4. Do not extend only some of the inputs
Each partial fix was measured against the full 10 km margin, within 2 km of the land edge:
inputs extended by 10 km nearest store 2SFCA near edge 2SFCA county mean
none (county only) +30.2% -58.6% -3.9%
network only +28.7% -55.0% -3.9%
network + population +28.7% -59.5% -5.1%
network + facilities 0.0% +327.6% +16.0%
network + facilities + population 0.0% 0.0% 0.0%
Adding the outside facilities fixed nearest-store times but, without the people who also use them, made those facilities look almost empty โ so everyone near the boundary appeared to have them nearly to themselves.
5. Choose the margin from the catchment
A margin narrower than the catchment's reach can make things worse. With a 2 km margin the nearest-store error near the edge fell to 3.1% but the 2SFCA error stayed at โ19.8%; with 5 km it was โ29.2%, because the wider ring added population without adding any more supermarkets. Across all residents, 9.5% had a 2SFCA score off by 10% or more with a 2 km margin and 4.5% with 5 km. For a 15-minute drive catchment, 10 km was the widest margin the network data here allowed, so treat it as the reference rather than proof that nothing changes further out.
6. Report only the study area, and say what margin was used
Scores for places inside the margin are themselves edge-affected. Keep them for the calculation and drop them from the results, and state the margin with the map.
Code examples
Example 1 โ scores with a margin
import numpy as np
import shapely
from scipy.sparse.csgraph import connected_components, dijkstra
from scipy.spatial import cKDTree
def accessibility(graph, node_xy, stores, demand, residents, catchment=15):
"""Nearest-store minutes and 2SFCA (stores per 10,000) for residents; graph weights in seconds."""
tree = cKDTree(node_xy)
s = tree.query(np.column_stack([stores.geometry.x, stores.geometry.y]))[1]
d = tree.query(np.column_stack([demand.geometry.x, demand.geometry.y]))[1]
r = tree.query(np.column_stack([residents.geometry.x, residents.geometry.y]))[1]
unique, inverse = np.unique(s, return_inverse=True)
to_store = dijkstra(graph.T.tocsr(), directed=True, indices=unique)[inverse] / 60 # stores x nodes
load = (to_store[:, d] <= catchment) @ demand.POP20.to_numpy(float)
ratio = np.divide(1.0, load, out=np.zeros(len(load)), where=load > 0)
reach = to_store[:, r].T
return reach.min(axis=1), ((reach <= catchment) * ratio).sum(axis=1) * 10_000
def with_margin(graph, node_xy, stores, blocks, study_area, residents, margin_m, **kwargs):
"""Keep network nodes, stores and population blocks within margin_m of the study area."""
near = shapely.distance(shapely.points(node_xy), study_area) <= margin_m
idx = np.flatnonzero(near)
_, label = connected_components(graph[idx][:, idx], directed=True, connection="strong")
keep = idx[label == np.bincount(label).argmax()]
def inside(gdf):
return gdf[shapely.distance(gdf.geometry.values, study_area) <= margin_m]
return accessibility(graph[keep][:, keep], node_xy[keep], inside(stores), inside(blocks), residents, **kwargs)
graph is a sparse matrix of road travel times in seconds, node_xy the node coordinates, stores and blocks point layers covering the study area and the widest margin, and residents the study area's own blocks. Keeping the largest strongly connected component stops a trimmed network from stranding homes on fragments.
Example 2 โ errors by distance to the edge
def edge_bias(residents, edge, other_edge, truncated, buffered, bands=(0, 2, 5, 10)):
km = shapely.distance(residents.geometry.values, edge) / 1000
nearer = km <= shapely.distance(residents.geometry.values, other_edge) / 1000
w = residents.POP20.to_numpy(float)
for lo, hi in zip(bands[:-1], bands[1:]):
m = nearer & (km >= lo) & (km < hi)
a, b = np.average(truncated[m], weights=w[m]), np.average(buffered[m], weights=w[m])
print(f"{lo}-{hi} km: {m.sum()} blocks, truncated {a:.3f} vs buffered {b:.3f} ({a / b - 1:+.1%})")
near_0, tsfca_0 = with_margin(graph, node_xy, stores, blocks, county, residents, 0)
near_10, tsfca_10 = with_margin(graph, node_xy, stores, blocks, county, residents, 10_000)
edge_bias(residents, land_edge, shore, near_0, near_10)
edge_bias(residents, land_edge, shore, tsfca_0, tsfca_10)
0-2 km: 158 blocks, truncated 11.146 vs buffered 8.563 (+30.2%)
2-5 km: 209 blocks, truncated 7.700 vs buffered 7.186 (+7.2%)
5-10 km: 322 blocks, truncated 4.912 vs buffered 4.855 (+1.2%)
0-2 km: 158 blocks, truncated 0.599 vs buffered 1.449 (-58.6%)
2-5 km: 209 blocks, truncated 0.709 vs buffered 1.238 (-42.7%)
5-10 km: 322 blocks, truncated 1.000 vs buffered 1.182 (-15.4%)
The first three lines are nearest-store minutes, the last three 2SFCA scores; land_edge and shore come from Example 3. Swapping the two edges gives the lake-shore bands, where the 2โ5 km and 5โ10 km nearest-store times were identical.
Example 3 โ split the boundary into land and water
def split_boundary(study_area, water, tolerance=300):
"""Boundary pieces away from water (land edge) and along it (shore)."""
wet = water.buffer(tolerance)
return study_area.boundary.difference(wet), study_area.boundary.intersection(wet)
lake = water_polygons[water_polygons.area > 50e6].union_all()
land_edge, shore = split_boundary(county, lake)
print(f"boundary {county.boundary.length / 1000:.0f} km: land {land_edge.length / 1000:.0f} km, "
f"shore {shore.length / 1000:.0f} km")
The 300 m tolerance absorbs the small gap between a boundary drawn in the lake and the mapped water polygon; the 50 kmยฒ filter keeps only Lake Champlain.
Explanation
Why nearest-facility errors stay near the boundary
A home's nearest facility can only be outside the study area if the boundary is closer than every inside facility. A few kilometres in, there is almost always a nearer facility on the inside, so the error stops. Here it was 30% within 2 km of the land boundary and about 1% at 5โ10 km.
Why catchment measures are affected further in
2SFCA divides each facility's supply by all the people within its catchment. A facility 10 km inside the boundary still has part of its 15-minute catchment across the line, and the people there were never counted โ and a home's own catchment can include facilities across the line. Both errors extend as far as the catchment reaches.
Why partial fixes can be worse than none
Supply and demand have to be cut consistently. Facilities without their outside users look underused, so their ratios, and the scores of every home that reaches them, rise. People without their outside facilities look underserved. A margin narrower than the catchment includes some of each in proportions that depend on where the facilities happen to be.
When truncation is right
If a service is only available to residents โ a county clinic, a school district โ people across the line cannot use it and outside facilities are not options for residents. Then the right inputs are residents and eligible facilities, with the full road network for routing. Decide which case applies before adding a margin.
Edge cases or notes
- Borders with different data sources need the same population and facility definitions on both sides.
- Water is not always a barrier. Ferries and bridges connect shores; check the network.
- International and state borders can restrict use even where roads connect.
- Gravity measures are affected like 2SFCA, since distant facilities still contribute.
- Trimmed networks create stranded nodes. Keep the largest strongly connected component after trimming.
- Maps of margins need care. Do not publish the margin's own scores as if they were reliable.
- The margin is a parameter. Report it, and show that a wider margin does not change the result where data allow.
Internal links
- How to measure access to services with the two-step floating catchment method โ the catchment measure tested here
- Accessibility measures explained: nearest, cumulative and gravity โ which measures reach how far
- How to count the population within reach of each facility โ demand beyond the boundary
- How to measure distance to the nearest facility for every home โ nearest-facility times
- How to find the areas nobody can reach within a travel time โ gaps that may be edge artefacts
- Fixing an OSMnx graph that is disconnected โ stranded nodes after trimming
- How to build Voronoi service areas around facilities in Python โ the same boundary problem for assignment
- How to measure network distance for many originโdestination pairs โ the travel-time matrix
FAQ
What is an edge effect in accessibility analysis?
Error near a study area's boundary caused by leaving out the roads, facilities and people beyond it. Within 2 km of Chittenden County's land boundary, county-only nearest-supermarket times were 30.2% too long.
How wide should the buffer around my study area be?
At least as wide as the largest catchment or travel threshold reaches. A 2 km margin fixed nearest-store times but left 2SFCA scores near the edge 19.8% too low; the 10 km reference margin was needed for 15-minute catchments.
Do I need to include population outside the study area?
Yes, for any measure that shares facilities between people. Adding outside facilities without outside population made 2SFCA scores near the edge 327.6% too high.
Why are my 2SFCA scores wrong further from the edge than nearest-facility times?
Because a catchment reaches further than the nearest facility. County-only 2SFCA scores were still 15% low 5 to 10 km from the land boundary, where nearest-store times were almost right.
Does a lake or coastline cause edge effects?
Much less, when nothing is reachable across it. Along the county's lake shore, nearest-store times were unchanged and 2SFCA scores moved by under 5%.
Should I always add a buffer?
Not if only residents may use the service. Then outside facilities are not real options, and a margin would overstate access.