Skip to content

Coverage Zones

Coverage zones show areas that can be reached from each of multiple source points within a certain time or distance limit using a transport network.
They are built by calculating reachability per point, generating Voronoi polygons, and optionally clipping them to a boundary.


The library supports several methods for generating coverage zones:

Coverage using transport graph

Uses a full routing engine to determine reachable areas per point, then builds zones.

objectnat.get_graph_coverage(gdf_to, nx_graph, weight_type, weight_value_cutoff=None, zone=None)

Calculate coverage zones from source points through a graph network using Dijkstra's algorithm and Voronoi diagrams.

The function works by
  1. Finding nearest graph nodes for each input point
  2. Calculating all reachable nodes within cutoff distance using Dijkstra
  3. Creating Voronoi polygons around graph nodes
  4. Combining reachability information with Voronoi cells
  5. Clipping results to specified zone boundary

Parameters:

Name Type Description Default
gdf_to GeoDataFrame

Source points to which coverage is calculated.

required
nx_graph Graph

NetworkX graph representing the transportation network.

required
weight_type Literal['time_min', 'length_meter']

Edge attribute to use as weight for path calculations.

required
weight_value_cutoff float

Maximum weight value for path calculations (e.g., max travel time/distance).

None
zone GeoDataFrame

Boundary polygon to clip the resulting coverage zones. If None, concave hull of reachable nodes will be used.

None

Returns:

Type Description
GeoDataFrame

GeoDataFrame with coverage zones polygons, each associated with its source point, returns in the same CRS as original gdf_from.

Notes
  • The graph must have a valid CRS attribute in its graph properties
  • MultiGraph/MultiDiGraph inputs will be converted to simple Graph/DiGraph
Source code in src\objectnat\methods\coverage_zones\graph_coverage.py
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
def get_graph_coverage(
    gdf_to: gpd.GeoDataFrame,
    nx_graph: nx.Graph,
    weight_type: Literal["time_min", "length_meter"],
    weight_value_cutoff: float = None,
    zone: gpd.GeoDataFrame = None,
):
    """
    Calculate coverage zones from source points through a graph network using Dijkstra's algorithm
    and Voronoi diagrams.

    The function works by:
        1. Finding nearest graph nodes for each input point
        2. Calculating all reachable nodes within cutoff distance using Dijkstra
        3. Creating Voronoi polygons around graph nodes
        4. Combining reachability information with Voronoi cells
        5. Clipping results to specified zone boundary

    Parameters:
        gdf_to (gpd.GeoDataFrame):
            Source points to which coverage is calculated.
        nx_graph (nx.Graph):
            NetworkX graph representing the transportation network.
        weight_type (Literal["time_min", "length_meter"]):
            Edge attribute to use as weight for path calculations.
        weight_value_cutoff (float):
            Maximum weight value for path calculations (e.g., max travel time/distance).
        zone (gpd.GeoDataFrame):
            Boundary polygon to clip the resulting coverage zones. If None, concave hull of reachable nodes will be used.

    Returns:
        (gpd.GeoDataFrame):
            GeoDataFrame with coverage zones polygons, each associated with its source point, returns in the same CRS
            as original gdf_from.

    Notes:
        - The graph must have a valid CRS attribute in its graph properties
        - MultiGraph/MultiDiGraph inputs will be converted to simple Graph/DiGraph
    """
    original_crs = gdf_to.crs
    try:
        local_crs = nx_graph.graph["crs"]
    except KeyError as exc:
        raise ValueError("Graph does not have crs attribute") from exc

    try:
        points = gdf_to.copy()
        points.to_crs(local_crs, inplace=True)
    except CRSError as e:
        raise CRSError(f"Graph crs ({local_crs}) has invalid format.") from e

    nx_graph, reversed_graph = reverse_graph(nx_graph, weight_type)

    points.geometry = points.representative_point()

    _, nearest_nodes = get_closest_nodes_from_gdf(points, nx_graph)

    points["nearest_node"] = nearest_nodes

    nearest_paths = nx.multi_source_dijkstra_path(
        reversed_graph, nearest_nodes, weight=weight_type, cutoff=weight_value_cutoff
    )
    reachable_nodes = list(nearest_paths.keys())
    graph_points = pd.DataFrame(
        data=[{"node": node, "geometry": Point(data["x"], data["y"])} for node, data in nx_graph.nodes(data=True)]
    ).set_index("node")
    nearest_nodes = pd.DataFrame(
        data=[path[0] for path in nearest_paths.values()], index=reachable_nodes, columns=["node_to"]
    )
    graph_nodes_gdf = gpd.GeoDataFrame(
        graph_points.merge(nearest_nodes, left_index=True, right_index=True, how="left"),
        geometry="geometry",
        crs=local_crs,
    )
    graph_nodes_gdf["node_to"] = graph_nodes_gdf["node_to"].fillna("non_reachable")
    voronois = gpd.GeoDataFrame(geometry=graph_nodes_gdf.voronoi_polygons(), crs=local_crs)
    graph_nodes_gdf = graph_nodes_gdf[graph_nodes_gdf["node_to"] != "non_reachable"]
    zone_coverages = voronois.sjoin(graph_nodes_gdf).dissolve(by="node_to").reset_index().drop(columns=["node"])
    zone_coverages = zone_coverages.merge(
        points.drop(columns="geometry"), left_on="node_to", right_on="nearest_node", how="inner"
    ).reset_index(drop=True)
    zone_coverages.drop(columns=["node_to", "nearest_node"], inplace=True)
    if zone is None:
        zone = concave_hull(graph_nodes_gdf[~graph_nodes_gdf["node_to"].isna()].union_all(), ratio=0.5)
    else:
        zone = zone.to_crs(local_crs)
    return zone_coverages.clip(zone).to_crs(original_crs)

coverage_zones_time_10min coverage_zones_distance_600m


Coverage using radius only

Generates fixed-radius buffers per point without routing, clipped via Voronoi.

objectnat.get_radius_coverage(gdf_from, radius, resolution=32)

Calculate radius-based coverage zones using Voronoi polygons.

Parameters:

Name Type Description Default
gdf_from GeoDataFrame

Source points for which coverage zones are calculated.

required
radius float

Maximum coverage radius in meters.

required
resolution int

Number of segments used to approximate quarter-circle in buffer (default=32).

32

Returns:

Type Description
GeoDataFrame

GeoDataFrame with smoothed coverage zone polygons in the same CRS as original gdf_from.

Notes
  • Automatically converts to local UTM CRS for accurate distance measurements
  • Final zones are slightly contracted then expanded for smoothing effect
Source code in src\objectnat\methods\coverage_zones\radius_voronoi_coverage.py
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def get_radius_coverage(gdf_from: gpd.GeoDataFrame, radius: float, resolution: int = 32):
    """
    Calculate radius-based coverage zones using Voronoi polygons.

    Parameters:
        gdf_from (gpd.GeoDataFrame):
            Source points for which coverage zones are calculated.
        radius (float):
            Maximum coverage radius in meters.
        resolution (int):
            Number of segments used to approximate quarter-circle in buffer (default=32).

    Returns:
        (gpd.GeoDataFrame):
            GeoDataFrame with smoothed coverage zone polygons in the same CRS as original gdf_from.

    Notes:
        - Automatically converts to local UTM CRS for accurate distance measurements
        - Final zones are slightly contracted then expanded for smoothing effect
    """
    original_crs = gdf_from.crs
    local_crs = gdf_from.estimate_utm_crs()
    gdf_from = gdf_from.to_crs(local_crs)
    bounds = gdf_from.buffer(radius).union_all()
    coverage_polys = gpd.GeoDataFrame(geometry=gdf_from.voronoi_polygons().clip(bounds, keep_geom_type=True))
    coverage_polys = coverage_polys.sjoin(gdf_from)
    coverage_polys["area"] = coverage_polys.area
    coverage_polys["buffer"] = np.pow(coverage_polys["area"], 1 / 3)
    coverage_polys.geometry = coverage_polys.buffer(-coverage_polys["buffer"], resolution=1, join_style="mitre").buffer(
        coverage_polys["buffer"] * 0.9, resolution=resolution
    )
    coverage_polys.drop(columns=["buffer", "area"], inplace=True)
    return coverage_polys.to_crs(original_crs)

coverage_zones_distance_radius_voronoi


Stepped graph coverage

Creates stepped zones (e.g., 5, 10, 15 minutes) using the full transport graph per point.

objectnat.get_stepped_graph_coverage(gdf_to, nx_graph, weight_type, step_type, weight_value_cutoff=None, zone=None, step=None)

Calculate stepped coverage zones from source points through a graph network using Dijkstra's algorithm and Voronoi-based or buffer-based isochrone steps.

This function combines graph-based accessibility with stepped isochrone logic. It: 1. Finds nearest graph nodes for each input point 2. Computes reachability for increasing weights (e.g. time or distance) in defined steps 3. Generates Voronoi-based or separate buffer zones around network nodes 4. Aggregates zones into stepped coverage layers 5. Optionally clips results to a boundary zone

Parameters:

Name Type Description Default
gdf_to GeoDataFrame

Source points from which stepped coverage is calculated.

required
nx_graph Graph

NetworkX graph representing the transportation network.

required
weight_type Literal['time_min', 'length_meter']

Type of edge weight to use for path calculation: - "time_min": Edge travel time in minutes - "length_meter": Edge length in meters

required
step_type Literal['voronoi', 'separate']

Method for generating stepped zones: - "voronoi": Stepped zones based on Voronoi polygons around graph nodes - "separate": Independent buffer zones per step

required
weight_value_cutoff float

Maximum weight value (e.g., max travel time or distance) to limit the coverage extent.

None
zone GeoDataFrame

Optional boundary polygon to clip resulting stepped zones. If None, concave hull of reachable area is used.

None
step float

Step interval for coverage zone construction. Defaults to: - 100 meters for distance-based weight - 1 minute for time-based weight

None

Returns:

Type Description
GeoDataFrame

GeoDataFrame with polygons representing stepped coverage zones for each input point, annotated by step range.

Notes
  • Input graph must have a valid CRS defined.
  • MultiGraph or MultiDiGraph inputs will be simplified to Graph/DiGraph.
  • Designed for accessibility and spatial equity analyses over multimodal networks.
Source code in src\objectnat\methods\coverage_zones\stepped_coverage.py
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
def get_stepped_graph_coverage(
    gdf_to: gpd.GeoDataFrame,
    nx_graph: nx.Graph,
    weight_type: Literal["time_min", "length_meter"],
    step_type: Literal["voronoi", "separate"],
    weight_value_cutoff: float = None,
    zone: gpd.GeoDataFrame = None,
    step: float = None,
):
    """
    Calculate stepped coverage zones from source points through a graph network using Dijkstra's algorithm
    and Voronoi-based or buffer-based isochrone steps.

    This function combines graph-based accessibility with stepped isochrone logic. It:
        1. Finds nearest graph nodes for each input point
        2. Computes reachability for increasing weights (e.g. time or distance) in defined steps
        3. Generates Voronoi-based or separate buffer zones around network nodes
        4. Aggregates zones into stepped coverage layers
        5. Optionally clips results to a boundary zone

    Parameters:
        gdf_to (gpd.GeoDataFrame):
            Source points from which stepped coverage is calculated.
        nx_graph (nx.Graph):
            NetworkX graph representing the transportation network.
        weight_type (Literal["time_min", "length_meter"]):
            Type of edge weight to use for path calculation:
            - "time_min": Edge travel time in minutes
            - "length_meter": Edge length in meters
        step_type (Literal["voronoi", "separate"]):
            Method for generating stepped zones:
            - "voronoi": Stepped zones based on Voronoi polygons around graph nodes
            - "separate": Independent buffer zones per step
        weight_value_cutoff (float, optional):
            Maximum weight value (e.g., max travel time or distance) to limit the coverage extent.
        zone (gpd.GeoDataFrame, optional):
            Optional boundary polygon to clip resulting stepped zones. If None, concave hull of reachable area is used.
        step (float, optional):
            Step interval for coverage zone construction. Defaults to:
                - 100 meters for distance-based weight
                - 1 minute for time-based weight

    Returns:
        (gpd.GeoDataFrame): GeoDataFrame with polygons representing stepped coverage zones for each input point,
            annotated by step range.

    Notes:
        - Input graph must have a valid CRS defined.
        - MultiGraph or MultiDiGraph inputs will be simplified to Graph/DiGraph.
        - Designed for accessibility and spatial equity analyses over multimodal networks.
    """
    if step is None:
        if weight_type == "length_meter":
            step = 100
        else:
            step = 1
    original_crs = gdf_to.crs
    try:
        local_crs = nx_graph.graph["crs"]
    except KeyError as exc:
        raise ValueError("Graph does not have crs attribute") from exc

    try:
        points = gdf_to.copy()
        points.to_crs(local_crs, inplace=True)
    except CRSError as e:
        raise CRSError(f"Graph crs ({local_crs}) has invalid format.") from e

    nx_graph, reversed_graph = reverse_graph(nx_graph, weight_type)

    points.geometry = points.representative_point()

    distances, nearest_nodes = get_closest_nodes_from_gdf(points, nx_graph)

    points["nearest_node"] = nearest_nodes
    points["distance"] = distances

    dist = nx.multi_source_dijkstra_path_length(
        reversed_graph, nearest_nodes, weight=weight_type, cutoff=weight_value_cutoff
    )

    graph_points = pd.DataFrame(
        data=[{"node": node, "geometry": Point(data["x"], data["y"])} for node, data in nx_graph.nodes(data=True)]
    )

    nearest_nodes = pd.DataFrame.from_dict(dist, orient="index", columns=["dist"]).reset_index()

    graph_nodes_gdf = gpd.GeoDataFrame(
        graph_points.merge(nearest_nodes, left_on="node", right_on="index", how="left").reset_index(drop=True),
        geometry="geometry",
        crs=local_crs,
    )
    graph_nodes_gdf.drop(columns=["index", "node"], inplace=True)
    if weight_value_cutoff is None:
        weight_value_cutoff = max(nearest_nodes["dist"])
    if step_type == "voronoi":
        graph_nodes_gdf["dist"] = np.minimum(np.ceil(graph_nodes_gdf["dist"] / step) * step, weight_value_cutoff)
        voronois = gpd.GeoDataFrame(geometry=graph_nodes_gdf.voronoi_polygons(), crs=local_crs)
        zone_coverages = voronois.sjoin(graph_nodes_gdf).dissolve(by="dist", as_index=False, dropna=False)
        zone_coverages = zone_coverages[["dist", "geometry"]].explode(ignore_index=True)
        if zone is None:
            zone = concave_hull(graph_nodes_gdf[~graph_nodes_gdf["node_to"].isna()].union_all(), ratio=0.5)
        else:
            zone = zone.to_crs(local_crs)
        zone_coverages = zone_coverages.clip(zone).to_crs(original_crs)
    else:  # step_type == 'separate':
        speed = 83.33  # TODO HARDCODED WALK SPEED
        weight_value = weight_value_cutoff
        zone_coverages = create_separated_dist_polygons(graph_nodes_gdf, weight_value, weight_type, step, speed)
        if zone is not None:
            zone = zone.to_crs(local_crs)
            zone_coverages = zone_coverages.clip(zone).to_crs(original_crs)
    return zone_coverages

stepped_coverage_zones_separate stepped_coverage_zones_voronoi