Getting Started

cityseer is a collection of computational tools for fine-grained street-network and land-use analysis, useful for assessing the morphological precursors to vibrant neighbourhoods.

cityseer is underpinned by network-based methods that have been developed from the ground-up for localised urban analysis at the pedestrian scale, with the intention of providing contextually specific metrics for any given streetfront location. It can be used to compute a variety of node or segment-based centrality methods, landuse accessibility and mixed-use measures, and statistical aggregations. Aggregations are computed dynamically — directly over the network while taking into account the direction of approach — and can incorporate spatial impedances and network decomposition to further accentuate spatial precision.

The use of python facilitates interaction with popular computational tools for network manipulation (e.g. networkX), geospatial data processing (e.g. shapely, etc.), Open Street Map workflows with OSMnx, and interaction with the numpy stack of scientific packages. The underlying algorithms are are implemented in numba JIT compiled code so that the methods can be applied to large decomposed networks. In-out convenience methods are provided for interfacing with networkX and graph cleaning tools aid the incorporation of messier network representations such as those derived from Open Street Map.

The github repository is available at github.com/benchmark-urbanism/cityseer-api. Associated papers introducing the package and demonstrating the forms of analysis that can be done with it are available at arXiv.

Installation

cityseer is a python package that can be installed with pip:

pip install cityseer

Code tests are run against python 3.9, though the code base will generally be compatible with other recent versions of Python 3.

A notebook of this guide can be found at google colaboratory.

Quickstart

cityseer revolves around networks (graphs). If you’re comfortable with numpy and abstract data handling, then the underlying data structures can be created and manipulated directly. However, it is generally more convenient to sketch the graph using NetworkX and to let cityseer take care of initialising and converting the graph for you.

# any networkX MultiGraph with 'x' and 'y' node attributes will do
# here we'll use the cityseer mock module to generate an example networkX graph
import networkx as nx
from cityseer.tools import mock, graphs, plot

G = mock.mock_graph()
print(nx.info(G), '\n')
# let's plot the network
plot.plot_nX(G, labels=True, node_size=80, dpi=100)

An example graphAn example graph.

The tools.graphs module contains a collection of convenience functions for the preparation and conversion of networkXMultiGraphs, i.e. undirected graphs allowing for parallel edges. These functions are designed to work with raw shapelyLinestring geometries that have been assigned to the edge (link) geom attributes. The benefit to this approach is that the geometry of the network is decoupled from the topology: the topology is consequently free from distortions which would otherwise confound centrality and other metrics.

There are generally two scenarios when creating a street network graph:

  1. In the ideal case, if you have access to a high-quality street network dataset – which keeps the topology of the network separate from the geometry of the streets – then you would construct the network based on the topology while assigning the roadway geometries to the respective edges spanning the nodes. OS Open Roads is a good example of this type of dataset. Assigning the geometries to an edge involves A) casting the geometry to a shapelyLineString, and B) assigning this geometry to the respective edge by adding the LineString geometry as a geom attribute. e.g. G.add_edge(start_node, end_node, geom=a_linestring_geom).

  2. In reality, most data-sources are not this refined and will represent roadway geometries by adding additional nodes to the network. For a variety of reasons, this is not ideal and you may want to follow the Graph Cleaning guide; in these cases, the graphs.nX_simple_geoms method can be used to generate the street geometries, after which several methods can be applied to clean and prepare the graph. For example, nX_wgs_to_utm aids coordinate conversions; nX_remove_dangling_nodes removes remove roadway stubs, nX_remove_filler_nodes strips-out filler nodes, and nX_consolidate_nodes assists in cleaning-up the network.

Example

Here, we’ll walk through a high-level overview showing how to use cityseer. You can provide your own shapely geometries if available; else, you can auto-infer simple geometries from the start to end node of each network edge, which works well for graphs where nodes have been used to inscribe roadway geometries.

G = graphs.nX_simple_geoms(G)
plot.plot_nX(G, labels=True, node_size=80, plot_geoms=True, dpi=100)

An example graphA graph with inferred geometries. In this case the geometries are all exactly straight.

We have now inferred geometries for each edge, meaning that each edge now has an associated LineString geometry. Any further manipulation of the graph using the cityseer.graph module will retain and further manipulate these geometries in-place.

Once the geoms are readied, we can use tools such as nX_decompose for generating granular graph representations and nX_to_dual for casting a primal graph representation to its dual.

G_decomp = graphs.nX_decompose(G, 50)
plot.plot_nX(G_decomp, plot_geoms=True, labels=False, dpi=100)

An example decomposed graphA decomposed graph.

# optionally cast to a dual network
G_dual = graphs.nX_to_dual(G)
# here we are plotting the newly decomposed graph (blue) against the original graph (red)
plot.plot_nX_primal_or_dual(G, G_dual, plot_geoms=False, dpi=100)

An example dual graphA dual graph (blue) plotted against the primal source graph (red). In this case, the true geometry has not been plotted so that the dual graph is easily delineated from the primal graph.

Network Layers

The networkX graph can now be transformed into a NetworkLayer by invoking NetworkLayerFromNX. Network layers are used for network centrality computations and also provide the backbone for subsequent landuse and statistical aggregations. They must be initialised with a set of distances dmaxd_{max} specifying the maximum network-distance thresholds at which the local centrality methods will terminate.

The NetworkLayer.node_centrality and NetworkLayer.segment_centrality methods wrap underlying numba optimised functions that compute a range of centrality methods. All selected measures and distance thresholds are computed simultaneously to reduce the amount of time required for multi-variable and multi-scalar workflows. The results of the computations will be written to the NetworkLayer class, and can be accessed at the NetworkLayer.metrics property. It is also possible to extract the data to a python dictionary through use of the NetworkLayer.metrics_to_dict method, or to simply convert the network — data and all — back into a networkX layer with the NetworkLayer.to_networkX method.

from cityseer.metrics import networks
# create a Network layer from the networkX graph
N = networks.NetworkLayerFromNX(G_decomp, distances=[200, 400, 800, 1600])
# the underlying method allows the computation of various centralities simultaneously, e.g.
N.segment_centrality(measures=['segment_harmonic', 'segment_betweenness'])

Data Layers

A DataLayer represents data points. A DataLayer can be assigned to a NetworkLayer, which means that each data point will be associated with the two closest network nodes — one in either direction — based on the closest adjacent street edge. This enables cityseer to use dynamic spatial aggregation methods that more accurately describes distances from the perspective of pedestrians travelling over the network, and relative to the direction of approach.

from cityseer.metrics import layers
# a mock data dictionary representing the 'x', 'y' attributes for data points
data_dict = mock.mock_data_dict(G_decomp, random_seed=25)
print(data_dict[0], data_dict[1], 'etc.')
# generate a data layer
D = layers.DataLayerFromDict(data_dict)
# assign to the prior Network Layer
# max_dist represents the farthest to search for adjacent street edges
D.assign_to_network(N, max_dist=400)
# let's plot the assignments
plot.plot_assignment(N, D, dpi=100)

DataLayer assigned to NetworkLayerData points assigned to a Network Layer.

DataLayer assigned to a decomposed NetworkLayerData assignment becomes more precise on a decomposed Network Layer.

Once the data has been assigned, the DataLayer.compute_landuses method is used for the calculation of mixed-use and land-use accessibility measures whereas DataLayer.compute_stats can likewise be used for statistical measures. As with the centrality methods, the measures are all computed simultaneously (and for all distances); however, simpler stand-alone methods are also available, including DataLayer.hill_diversity, DataLayer.hill_branch_wt_diversity, and DataLayer.compute_accessibilities.

Landuse labels can be used to generate mixed-use and land-use accessibility measures. Let’s create mock landuse labels for the points in our data dictionary and compute mixed-uses and land-use accessibilities:

landuse_labels = mock.mock_categorical_data(len(data_dict), random_seed=25)
print(landuse_labels)
# example easy-wrapper method for computing mixed-uses
D.hill_branch_wt_diversity(landuse_labels, qs=[0, 1, 2])
# example easy-wrapper method for computing accessibilities
# the keys correspond to keys present in the landuse data
# for which accessibilities will be computed
D.compute_accessibilities(landuse_labels, accessibility_keys=['a', 'c'])
# or compute multiple measures at once, e.g.:
D.compute_landuses(landuse_labels,
                     mixed_use_keys=['hill', 'hill_branch_wt', 'shannon'],
                     accessibility_keys=['a', 'c'],
                     qs=[0, 1, 2])

We can do the same thing with numerical data. Let’s generate some mock numerical data:

mock_valuations_data = mock.mock_numerical_data(len(data_dict), random_seed=25)
print(mock_valuations_data)
# compute max, min, mean, mean-weighted, variance, and variance-weighted
D.compute_stats('valuations', mock_valuations_data)

The data is aggregated and computed over the street network relative to the NetworkLayer (i.e. street) nodes. The mixed-use, accessibility, and statistical aggregations can therefore be compared directly to centrality computations from the same locations, and can be correlated or otherwise compared. The outputs of the calculations are written to the corresponding node indices in the same NetworkLayer.metrics dictionary used for centrality methods, and will be categorised by the respective keys and parameters.

# access the data arrays at the respective keys, e.g.
distance_idx = 800  # any of the initialised distances
q_idx = 0  # q index: any of the invoked q parameters
# centrality
print('centrality keys:', list(N.metrics['centrality'].keys()))
print('distance keys:', list(N.metrics['centrality']['segment_harmonic'].keys()))
print(N.metrics['centrality']['segment_harmonic'][distance_idx][:4])
# mixed-uses
print('mixed-use keys:', list(N.metrics['mixed_uses'].keys()))
# here we are indexing in to the specified q_idx, distance_idx
print(N.metrics['mixed_uses']['hill_branch_wt'][q_idx][distance_idx][:4])
# statistical keys can be retrieved the same way:
print('stats keys:', list(N.metrics['stats'].keys()))
print('valuations keys:', list(N.metrics['stats']['valuations'].keys()))
print('valuations weighted by 1600m decay:', N.metrics['stats']['valuations']['mean_weighted'][1600][:4])
# the data can also be convert back to a NetworkX graph
G_metrics = N.to_networkX()
print(nx.info(G_metrics))
# the data arrays are unpacked accordingly
print(G_metrics.nodes[0]['metrics']['centrality']['segment_betweenness'][200])
# and can also be extracted to a dictionary:
G_dict = N.metrics_to_dict()
print(G_dict[0]['centrality']['segment_betweenness'][200])

The data can then be passed to data analysis or plotting methods. For example, the tools.plot module can be used to plot the segmentised harmonic closeness centrality and mixed uses for the above mock data:

# plot centrality
from matplotlib import colors
segment_harmonic_vals = []
mixed_uses_vals = []
for node, data in G_metrics.nodes(data=True):
    segment_harmonic_vals.append(data['metrics']['centrality']['segment_harmonic'][800])
    mixed_uses_vals.append(data['metrics']['mixed_uses']['hill_branch_wt'][0][400])
# custom colourmap
cmap = colors.LinearSegmentedColormap.from_list('cityseer', ['#64c1ff', '#d32f2f'])
# normalise the values
segment_harmonic_vals = colors.Normalize()(segment_harmonic_vals)
# cast against the colour map
segment_harmonic_cols = cmap(segment_harmonic_vals)
# plot segment_harmonic
plot.plot_nX(G_metrics, labels=False, node_colour=segment_harmonic_cols, dpi=100)

Example centrality plot800m segmentised harmonic centrality.

# plot distance-weighted hill mixed uses
mixed_uses_vals = colors.Normalize()(mixed_uses_vals)
mixed_uses_cols = cmap(mixed_uses_vals)
plot.plot_assignment(N, D, node_colour=mixed_uses_cols, data_labels=landuse_labels, dpi=100)

Example mixed-use plot400m distance-weighted mixed-uses.