Skip to content

Commit 19124ac

Browse files
committed
Update materials for creating a directed graph from street network
1 parent 66ef4d7 commit 19124ac

3 files changed

Lines changed: 717 additions & 13 deletions

File tree

source/part2/chapter-08/index.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,5 @@
66
:caption: Sections:
77

88
nb/0-learning-objectives.ipynb
9-
8.1 Introduction to network analysis <nb/00-introduction-to-spatial-network-analysis.ipynb>
10-
8.2 Spatial network analysis <nb/01-spatial-network-analysis.ipynb>
9+
8.1 Representing geographic data as networks <nb/00-introduction-to-spatial-network-analysis.ipynb>
10+
8.2 Introduction to spatial network analysis <nb/01-spatial-network-analysis.ipynb>

source/part2/chapter-08/md/00-introduction-to-spatial-network-analysis.md

Lines changed: 179 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ In this first example, we will construct a simple graph using the `networkx` lib
5050
```python
5151
import networkx as nx
5252
import matplotlib.pyplot as plt
53+
from shapely import Point
5354

5455
G = nx.Graph()
5556
G
@@ -345,9 +346,11 @@ What is the path length and route from `e` to `a` using the directed graph?
345346
```
346347

347348
<!-- #region editable=true slideshow={"slide_type": ""} -->
348-
## Creating a graph from LineStrings
349+
## Creating a graph from geometries
349350
<!-- #endregion -->
350351

352+
### Undirected graph using a GeoDataFrame
353+
351354
<!-- #region editable=true slideshow={"slide_type": ""} -->
352355
Data was obtained from Digiroad
353356
<!-- #endregion -->
@@ -368,6 +371,168 @@ streets.head()
368371
streets.plot();
369372
```
370373

374+
```python
375+
def gdf_to_graph(gdf):
376+
"""Creates a NetworkX Graph from GeoDataFrame consisting of LineString objects.
377+
378+
Parameters
379+
----------
380+
381+
gdf : GeoDataFrame
382+
GeoDataFrame containing the LineString data.
383+
384+
"""
385+
386+
# Create the NetworkX graph
387+
graph = nx.Graph()
388+
389+
# Generate edge dictionary
390+
for edge in gdf.itertuples():
391+
coords = edge.geometry.coords
392+
393+
# Get first and last node of the edge (excluding vertices)
394+
first, last = coords[0], coords[-1]
395+
396+
# Edge attributes
397+
edge_attr = edge._asdict()
398+
399+
graph.add_edge(first, last, **edge_attr)
400+
401+
# Generate node attributes
402+
node_attrs = {node: {"coords": node, "x": node[0], "y": node[1]} for node in graph.nodes}
403+
nx.set_node_attributes(graph, node_attrs)
404+
405+
# Relabel the indices
406+
graph = nx.convert_node_labels_to_integers(graph)
407+
408+
# Add some useful attributes
409+
graph.graph['crs'] = gdf.crs
410+
411+
return graph
412+
```
413+
414+
Next we will break it down few steps at a time to understand what happens here. Let's start by investigating what happens inside the loop:
415+
416+
```python
417+
graph = nx.Graph()
418+
419+
for edge in streets.itertuples():
420+
# Get the coordinates
421+
coords = edge.geometry.coords
422+
423+
# Get first and last node of the edge (excluding vertices)
424+
first, last = coords[0], coords[-1]
425+
426+
# Get the edge attributes
427+
edge_attributes = edge._asdict()
428+
429+
# Add to the graph
430+
graph.add_edge(first, last, **edge_attributes)
431+
break
432+
```
433+
434+
Now we iterated over one edge in our street network and stopped the loop to be able to investigate what our variables contain. The `coords` variable contain the coordinates of all the vertices in the first edge of our street network:
435+
436+
```python
437+
list(coords)
438+
```
439+
440+
As we can see, there are 3 vertices in this `LineString` object representing a given street. When constructing the graph topology, we only care about the `nodes` of the street segment geometry (i.e. the first and last coordinates of the geometry). This means that in case there are vertices between the nodes, those will not be taken into consideration in the topology of the graph (unless specifically needed for some special use case). Thus, the network topology itself does not need to have the full geometry of the street segments for it to work.
441+
442+
```python
443+
# Create a GeoDataFrame out of the vertices
444+
vertices = gpd.GeoDataFrame(geometry=[Point(coordpair) for coordpair in coords])
445+
446+
fig, ax = plt.subplots()
447+
streets.iloc[0:1].plot(ax=ax)
448+
vertices.plot(ax=ax, color=["r", "b", "r"])
449+
```
450+
451+
_**Figure 8.X.** Only the nodes (in red) will be used to construct the edge for a given network topology._
452+
453+
Considering only the nodes and ignoring the vertices has also benefits as doing this reduces the size of the graph and makes it faster to run any analyses on it. Thus, we only take the first and last coordinate-pair of the edge geometry which we will use as nodes:
454+
455+
```python
456+
print("First node:", first)
457+
print("Last node:", last)
458+
```
459+
460+
Although the network topology only considers the nodes, this does not mean that you would loose the actual geometries of the street network, as we can still store the full geometry as an edge attribute of our graph. The `edge_attributes` variable contains all the associated information from the given row in our `GeoDataFrame` as a dictionary:
461+
462+
```python
463+
edge_attributes
464+
```
465+
466+
When we call the `graph.add_edge(first, last, **edge_attributes)`, we add this edge to the given `graph` in which the `**edge_attributes` command unpacks the values of the dictionary and inserts them as attributes for the given edge. Thus, when we investigate the contents of the edges at this point in time, we will see that the actual `geometry` is also stored for the edge:
467+
468+
```python
469+
graph.edges.data()
470+
```
471+
472+
At this point, you might wonder what happened with the `nodes` as we did not specifically add them to the graph in a similar manner as in our earlier examples? We can investigate how the `nodes` look like at this stage:
473+
474+
```python
475+
graph.nodes.data()
476+
```
477+
478+
As we can see, `networkx` actually adds the nodes automatically to the graph when we call the `.add_edge()` method based on the nodes provided to construct a given edge. However, as we can see from the nodes' data above, these nodes do not contain any information about the nodes in the nodes attributes as it is only an empty dictionary at this stage. This is something that we can handle afterwards as it is possible to set the node attributes also after the topology has been constructed based on the edges alone. To do this, we can e.g. parse the coordinates of the nodes and store that information as node attributes using the `nx.set_node_attributes()` as follows:
479+
480+
```python
481+
# Create a dictionary that contain the node attributes
482+
node_attrs = {node: {"coords": node, "x": node[0], "y": node[1]} for node in graph.nodes}
483+
nx.set_node_attributes(graph, node_attrs)
484+
```
485+
486+
```python
487+
graph.nodes.data()
488+
```
489+
490+
As we can see, now the `nodes` of our graph includes three attributes that provide information about the location of the nodes: `coords`, `x` and `y`.
491+
492+
Finally, you might have noticed that the `ids` for the nodes in our graph are quite cumbersome as they basically represent the exact coordinates of the nodes. Luckily, it is easy to relabel the node ids into a format that is easier to use and understand, using simple integer values as the ids. We can do this by using the `nx.convert_node_labels_to_integers()` function as follows:
493+
494+
```python
495+
graph = nx.convert_node_labels_to_integers(graph)
496+
```
497+
498+
```python
499+
graph.edges.data()
500+
```
501+
502+
```python
503+
graph.nodes.data()
504+
```
505+
506+
As we can see, now the ids for the nodes were altered from long coordinate tuples into simple integers, such as `0` and `1`, which are much easier to understand and use if you e.g. want to select specific node from the graph.
507+
508+
As a very last thing in our `gdf_to_graph()` function, we add a custom attribute to our graph where we store the coordinate reference system information of the input `GeoDataFrame` which can be useful information when using the given graph for analysis with other datasets:
509+
510+
```python
511+
graph.graph["crs"] = streets.crs
512+
graph.graph["crs"]
513+
```
514+
515+
That's it! This is how you can create an undirected graph based on a given `GeoDataFrame` that consists of `LineString` objects. The input data we used here represents streets, but the input data can basically be about anything as long as the geometries of the input data are represented as `LineString` objects and the data itself does have a network-like structure. In a similar manner, you could represent e.g. rivers, pipelines, power lines, social networks etc.
516+
517+
Let's finally use our `gdf_to_graph()` function and create a full network topology based on our `streets` `GeoDataFrame`:
518+
519+
```python
520+
G = gdf_to_graph(streets)
521+
522+
positions = {node: (attrs["x"], attrs["y"]) for node, attrs in G.nodes.data()}
523+
524+
nx.draw(G,
525+
pos=positions,
526+
node_color="red",
527+
node_size=0.5,
528+
)
529+
```
530+
531+
### Directed graph using a GeoDataFrame
532+
533+
534+
Now as we have learned how to create a simple undirected graph based on `LineString` geometries, we will continue and expand the previous example to construct a directed graph topology that considers the permitted direction of movement along the streets. When working with street network data and analyzing e.g. the travel times or distances by car, it is necessary to take into consideration one-way streets as those are extremely common especially in larger cities. On these streets, a person can only drive to one direction, and if you would need to travel to opposite direction, making an U-turn is not possible but you would need to find another path using other streets of the network. Thus, understandingly one-way streets can have significant influence on the optimal routes between given locations that need to be considered when doing network analysis. Otherwise, our analyses and results will likely provide incorrect and unrealistic results that could even cause dangerous situations if e.g. a car navigator would guide you to a one-way street where the traffic flows against your travel direction.
535+
371536
The `direction` column includes information about the allowed direction of the traffic flow, i.e. whether the traffic is permitted in both directions or whether it is a oneway street. In this street network dataset the values are coded as shown in Table 8.1.
372537

373538

@@ -403,18 +568,15 @@ def gdf_to_directed_graph(gdf, direction='direction', both_ways=2, against=3, al
403568
Value specifying that the road is drivable along the digitizing direction.
404569
405570
"""
406-
import networkx as nx
407571

408572
# Create the NetworkX graph
409573
graph = nx.MultiDiGraph()
410-
411-
columns = list(gdf.columns)
412574

413575
# Generate edge dictionary
414576
for edge in gdf.itertuples():
415577
coords = edge.geometry.coords
416578

417-
# Get first and last coordinates (drop possible Z information)
579+
# Get first and last node of the edge (excluding vertices and possible Z coordinate)
418580
first, last = coords[0][:2], coords[-1][:2]
419581

420582
# Edge attributes
@@ -500,6 +662,18 @@ streets_cleaned = neatnet.remove_interstitial_nodes(streets)
500662
streets_cleaned.shape
501663
```
502664

665+
```python
666+
net = ox.graph_from_place(query=["Helsinki", "Espoo"])
667+
```
668+
669+
```python
670+
edges = ox.graph_to_gdfs(net, nodes=False)
671+
```
672+
673+
```python
674+
edges.plot(figsize=(30,30), linewidth=0.5)
675+
```
676+
503677
```python
504678

505679
```

0 commit comments

Comments
 (0)