Continuity graph analysis

Apart from creating graphs from individual street stegments, you can create graphs based on identified continuity strokes.

import geopandas as gpd
import momepy
import neatnet
import networkx as nx
import osmnx as ox

Given the momepy.COINS object, you can generate a graph representation based on strokes and their intersections.

Generate strokes and continuity graph

Load the example street network.

gdf = gpd.read_file(momepy.datasets.get_path("bubenec"), layer="streets")
gdf.plot(figsize=(10, 10)).set_axis_off()
../../_images/25d6dde6dd0975e26f7d19c38b8d4fb00b35c8bcf8dffbb899048522f7ecb691.png

COINS allows to find strokes, that are sets of edges with a natural continuity:

coins = momepy.COINS(gdf)
gdf.plot(
    coins.stroke_attribute(), figsize=(10, 10), categorical=True, legend=True
).set_axis_off()
../../_images/cb36031bda555421d7ab8b9e736da3664abd55d3f44e953e2063326ab7839a38.png

From this, you can create a continuity graph, where each stroke is a node. If two strokes intersect, we add an edge between the two strokes:

continuity_graph = momepy.coins_to_nx(coins)

You can visualise the graph by mapping nodes to a point on the continuity stroke.

ax = (
    coins.stroke_gdf()
    .reset_index()  # reset to get stroke_group as a column
    .plot("stroke_group", categorical=True, legend=True, figsize=(10, 10))
)
nx.draw(
    continuity_graph,
    {
        # get positions of nodes as representative points of strokes
        n: geom.representative_point().coords[0]
        for n, geom in nx.get_node_attributes(
            continuity_graph, "geometry"
        ).items()
    },
    node_size=15,
    style="dashed",
    ax=ax,
)
../../_images/372081ce0f91f46ac2ff818078ae4b7a82e34b5090056a00143f09cded9b2a5a.png

This is a specific graph that holds some additional attributes, useful for visualisation of computation. Like any other momepy-generated graph, it can be converted back to GeoDataFrames using momepy.nx_to_gdf.

strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes represents the individual strokes.

strokes.head()
edge_indices geometry stroke_length connectivity nodeID
0 [0, 5, 17, 20, 21] LINESTRING (1603278.899 6463669.186, 1603283.7... 839.566684 0 0
1 [1, 16, 19, 33] LINESTRING (1603077.5 6464475.323, 1603085.515... 759.090043 0 1
2 [2, 4, 14, 15, 22, 23] LINESTRING (1603537.194 6464558.112, 1603557.6... 744.757934 0 2
3 [3, 7, 24, 25, 26, 31] LINESTRING (1603706.388 6464617.784, 1603705.7... 1019.709508 0 3
4 [6, 8, 9] LINESTRING (1603413.206 6464228.73, 1603274.45... 562.246691 0 4

While edges, represent the connections. Given there’s no geometry associated, it is returned as a pandas DataFrame.

edges.head()
angles number_connections node_start node_end
0 [62.30218235695145, 63.647466378271766] 2 0 2
1 [36.134980718680964] 1 0 9
2 [29.396028363390094] 1 0 4
3 [89.74560192447649, 89.75267804978041] 2 0 1
4 [89.42915166171285, 89.56623033507174] 2 0 5

Computing metrics on the continuity graph

Using the continuity graph, you can compute several metrics. Some are classical metrics, already implemented in Networkx. For instance, the degree shows us the number of edges for each node – in the case of the continuity graph, where nodes represent strokes and edges represent intersections between strokes, the (stroke) degree indicates the number of each stroke’s intersections with other strokes:

nx.set_node_attributes(
    continuity_graph, dict(nx.degree(continuity_graph)), "stroke_degree"
)
strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes.plot("stroke_degree", figsize=(12, 12), legend=True).set_axis_off()
../../_images/99f84b22162852bc594a3ac85747f86f74d56275d21efae99df45c8143e13ddc.png

Instead of the maximum length travelled across the street network, the diameter of a continuity graph is the maximum number of “turns” one has to do to travel across the street network. By turns we mean a change of stroke: since strokes are following the natural continuity of the edges, staying on the same stroke is equivalent to going as straight as possible.

nx.diameter(continuity_graph)
3

In this case, the diameter of the continuity graph is 3, meaning that we can reach any place from any other place by making at most 3 “turns”.

In a similar way, we can compute centrality metrics. A high betweenness stroke will be one used to travel the simplest paths, meaning the path with the least amount of “turns”.

nx.set_node_attributes(
    continuity_graph,
    dict(nx.betweenness_centrality(continuity_graph)),
    "stroke_betweenness",
)

strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes.plot("stroke_betweenness", figsize=(12, 12), legend=True).set_axis_off()
../../_images/18e9bf70ee8d71bd5ecc983da59cb8d243327856470c8e516268efbfeef27495.png

In the plot above, you can see that the stroke at the bottom of the street network is a hub of high betweenness: when starting out from this stroke, you can reach all but three other strokes in the network in one single turn.

If you want to move the information back to original street segments, you can map it using the edge_indices column.

gdf["stroke_betweenness"] = strokes.explode("edge_indices").set_index(
    "edge_indices"
)["stroke_betweenness"]
gdf.head()
geometry stroke_id stroke_betweenness
0 LINESTRING (1603585.64 6464428.774, 1603413.20... 0 0.136574
1 LINESTRING (1603268.502 6464060.781, 1603296.8... 1 0.087963
2 LINESTRING (1603607.303 6464181.853, 1603592.8... 2 0.046296
3 LINESTRING (1603678.97 6464477.215, 1603675.68... 3 0.067130
4 LINESTRING (1603537.194 6464558.112, 1603557.6... 2 0.046296

On a continuity graph, closeness centrality is equivalent to the integration metric in Space Syntax:

nx.set_node_attributes(
    continuity_graph,
    dict(nx.closeness_centrality(continuity_graph)),
    "stroke_closeness",
)
strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes.plot("stroke_closeness", figsize=(12, 12), legend=True).set_axis_off()
../../_images/742172fcfd681f1d4c0d3dca4aee576b350ce2dc9d8dd83c8ebf1555d950ce4e.png

On top of classical metrics that can all be computed on the continuity graph, momepy defines several new metrics for strokes.

Stroke access

Access is defined as the difference between the connectivity (the number of street segments intersecting with a stroke) and the degree (the number of strokes intersecting with another stroke). A simple illustration is that for a T-intersection, the degree and the connectivity is the same, while for an X-intersection, connectivity is higher than degree.

continuity_graph = momepy.stroke_access(continuity_graph)
strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes.plot("stroke_access", figsize=(12, 12), legend=True).set_axis_off()
../../_images/30e474bb719cc03b688cc9193352d22c4831a36f40876b294d19c5672c0938f3.png

Stroke spacing

Spacing is the average length between connections, dividing the total length of the stroke by its connectivity.

continuity_graph = momepy.stroke_spacing(continuity_graph)
strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes.plot("stroke_spacing", figsize=(12, 12), legend=True).set_axis_off()
../../_images/993b55ee64fb860b640c266d98de3e561d407ef89295a9b89fba69f3ad24ece7.png

Stroke orthogonality

Orthogonality is the average sine of the minimum angles between the stroke and its connections. It varies between 0 and 1, from low to right angles.

continuity_graph = momepy.stroke_orthogonality(continuity_graph)
strokes, edges = momepy.nx_to_gdf(continuity_graph)

strokes.plot(
    "stroke_orthogonality", figsize=(12, 12), legend=True
).set_axis_off()
../../_images/3b8f79bbacae265aed092622ba1e8472fb06d1877933725c0fc6047139a53cea.png

Using OpenStreetMap data

streets_graph = ox.graph_from_place(
    "Vicenza, Vicenza, Italy", network_type="drive", retain_all=False
)
streets_graph = ox.projection.project_graph(streets_graph)

streets = ox.graph_to_gdfs(
    ox.convert.to_undirected(streets_graph),
    nodes=False,
    edges=True,
    node_geometry=False,
    fill_edge_geometry=True,
)

# simplify street network with the neatnet package
streets = neatnet.neatify(streets)
/home/runner/micromamba/envs/documentation/lib/python3.14/site-packages/neatnet/simplify.py:589: UserWarning: Could not create a connection as it would lead outside of the artifact.
  nx_gx_cluster(
streets.plot(figsize=(10, 10), linewidth=0.2).set_axis_off()
../../_images/a34b9fd032a9c4635408dc75d263988bc77df78292dfb604a9e2eb66ae4825b0.png
continuity = momepy.COINS(streets)
continuity_graph = momepy.coins_to_nx(continuity)

Measure generic graph-metrics.

nx.set_node_attributes(
    continuity_graph,
    dict(nx.closeness_centrality(continuity_graph)),
    "stroke_closeness",
)

Or continuity-based ones.

continuity_graph = momepy.stroke_access(continuity_graph)

Convert back to GeoDataFrame.

strokes, edges = momepy.nx_to_gdf(continuity_graph)

And visualise.

strokes.plot("stroke_closeness", figsize=(12, 12), legend=True).set_axis_off()
../../_images/2cd959b283e261235d9ba6166d1638fbc61707f8d2eaec621d578d8cf8df4b53.png
strokes.plot(
    "stroke_access", figsize=(12, 12), legend=True, cmap="viridis_r"
).set_axis_off()
../../_images/a92ae6b2ea2c4be169d7a37dc54345d8f4a89ab5915cf4461450a05c26b8ef98.png

References

For COINS, refer to Tripathy, P., Rao, P., Balakrishnan, K., & Malladi, T. 2021. An open-source tool to extract natural continuity and hierarchy of urban street networks. Environment and Planning B: Urban Analytics and City Science, 48(8), 2188-2205. https://doi.org/10.1177/2399808320967680

For the geometric metrics on the dual graph, refer to El Gouj, H., Rincón-Acosta, C. and Lagesse, C., 2022. Urban morphogenesis analysis based on geohistorical road data. Applied Network Science, 7(1), p.6 https://doi.org/10.1007/s41109-021-00440-0..