This problem originated from a blog post I wrote for DataCamp on graph optimization here. The algorithm I sketched out there for solving the Chinese Problem on the Sleeping Giant state park trail network has since been formalized into the postman_problems python library. I’ve also added the Rural Postman solver that is implemented here.
So the three main enhancements in this post from the original DataCamp article and my second iteration published here updating to networkx 2.0 are:
-
OpenStreetMap for graph data and visualization.
-
Implementing the Rural Postman algorithm to consider optional edges.
-
Leveraging the postman_problems library.
This code, notebook and data for this post can be found in the postman_problems_examples repo.
The motivation and background around this problem is written up more thoroughly in the previous posts and postman_problems.
RPP Solution Route Animation
Here’s the full route animation. More details here. Kudos to my sister @laurabrooks for coding this up!
Table of Contents
1 |
|
Create Graph from OSM
1 |
|
<class ‘networkx.classes.digraph.DiGraph’>
1 |
|
Adding distance to OSM graph
Using the haversine formula to calculate distance between each edge.
1 |
|
Create graph of required trails only
A simple heuristic with a couple tweaks is all we need to create the graph with required edges:
-
Keep any edge with ‘Trail’ in the name attribute.
-
Manually remove the handful of trails that are not part of the required Giant Master route.
1 |
|
Viz Sleeping Giant Trails
All trails required for the Giant Master:
1 |
|
Connect Edges
In order to run the RPP algorithm from postman_problems, the required edges of the graph must form a single connected component. We’re almost there with the Sleeping Giant trail map as-is, so we’ll just connect a few components manually.
Here’s an example of a few floating components (southwest corner of park):
OpenStreetMap makes finding these edge (way) IDs simple. Once grabbing the ?
cursor, you can click on any edge to retrieve IDs and attributes.
Define OSM edges to add and remove from graph
1 |
|
Add attributes for supplementary edges
1 |
|
Ensuring that we’re left with one single connected component:
len(list(nx.connected_components(g_t)))
1 |
|
fig, ax = plt.subplots(figsize=(1,12))
edges
pos = {k: (g_t.node[k].get(‘lon’), g_t.node[k].get(‘lat’)) for k in g_t.nodes()} nx.draw_networkx_edges(g_t, pos, width=3.0, edge_color=’black’, alpha=0.6)
nodes (intersections and dead-ends)
pos_x = {k: (g_t.node[k][‘lon’], g_t.node[k][‘lat’]) for k in g_t.nodes() if (g_t.degree(k)==1) | (g_t.degree(k)>2)} nx.draw_networkx_nodes(g_t, pos_x, nodelist=pos_x.keys(), node_size=35.0, node_color=’red’, alpha=0.9)
mplleaflet.save_html(fig, ‘maps/trails_only_intersections.html’)
1 |
|
name2color = { ‘Green Trail’: ‘green’, ‘Quinnipiac Trail’: ‘blue’, ‘Tower Trail’: ‘black’, ‘Yellow Trail’: ‘yellow’, ‘Red Square Trail’: ‘red’, ‘White/Blue Trail Link’: ‘lightblue’, ‘Orange Trail’: ‘orange’, ‘Mount Carmel Avenue’: ‘black’, ‘Violet Trail’: ‘violet’, ‘blue Trail’: ‘blue’, ‘Red Triangle Trail’: ‘red’, ‘Blue Trail’: ‘blue’, ‘Blue/Violet Trail Link’: ‘purple’, ‘Red Circle Trail’: ‘red’, ‘White Trail’: ‘gray’, ‘Red Diamond Trail’: ‘red’, ‘Yellow/Green Trail Link’: ‘yellowgreen’, ‘Nature Trail’: ‘forestgreen’, ‘Red Hexagon Trail’: ‘red’, None: ‘black’ }
1 |
|
Check distance
This is strikingly close (within 0.25 miles) to what I calculated manually with some guess work from the SG trail map on the first pass at this problem here, before leveraging OSM.
print(‘ miles of required trail.’.format(sum([e[2][‘distance’]/1609.34 for e in g_t.edges(data=True)])))
1 |
|
Contract Edges
We could run the RPP algorithm on the graph as-is with >5000 edges. However, we can simplify computation by contracting edges into logical trail segments first. More details on the intuition and methodology in the 50 states post.
print(‘Number of edges in trail graph: {}’.format(len(g_t.edges())))
1 |
|
intialize contracted graph
g_tc = nx.MultiGraph()
add contracted edges to graph
for ce in contract_edges(g_t, ‘distance’): start_node, end_node, distance, path = ce
contracted_edge = { ‘start_node’: start_node, ‘end_node’: end_node, ‘distance’: distance, ‘name’: g[path[0]][path[1]].get(‘name’), ‘required’: 1, ‘path’: path }
g_tc.add_edge(start_node, end_node, **contracted_edge) g_tc.node[start_node][‘lat’] = g.node[start_node][‘lat’] g_tc.node[start_node][‘lon’] = g.node[start_node][‘lon’] g_tc.node[end_node][‘lat’] = g.node[end_node][‘lat’] g_tc.node[end_node][‘lon’] = g.node[end_node][‘lon’]
1 |
|
Number of edges in contracted trail graoh: 124
1 |
|
create list with edge attributes and “from” & “to” nodes
tmp = [] for e in g_tc.edges(data=True): tmpi = e[2].copy() # so we don’t mess w original graph tmpi[‘start_node’] = e[0] tmpi[‘end_node’] = e[1] tmp.append(tmpi)
create dataframe w node1 and node2 in order
eldf = pd.DataFrame(tmp) eldf = eldf[[‘start_node’, ‘end_node’] + list(set(eldf.columns)-{‘start_node’, ‘end_node’})]
create edgelist mock CSV
elfn = create_mock_csv_from_dataframe(eldf)
1 |
|
CPP Stats
(distances in meters)
1 |
|
OrderedDict([(‘distance_walked’, 54522.949121342645), (‘distance_doublebacked’, 13383.36715945256), (‘distance_walked_once’, 41139.581961890086), (‘distance_walked_optional’, 0), (‘distance_walked_required’, 54522.949121342645), (‘edges_walked’, 170), (‘edges_doublebacked’, 46), (‘edges_walked_once’, 124), (‘edges_walked_optional’, 0), (‘edges_walked_required’, 170)])
print(‘Miles in CPP solution: {:0.2f}’.format(cpp_stats[‘distance_walked’]/1609.34))
1 |
|
Solve RPP
With the CPP as benchmark, let’s see how well we do when we allow for optional edges in the route.
1 |
|
CPU times: user 1min 39s, sys: 1.08 s, total: 1min 40s Wall time: 1min 42s
1 |
|
Counter({0: 3034, 1: 124})
1 |
|
create mockfilename
elfn = create_mock_csv_from_dataframe(dfrpp)
1 |
|
CPU times: user 5.81 s, sys: 59.6 ms, total: 5.87 s Wall time: 5.99 s
1 |
|
rpp_stats = calculate_postman_solution_stats(circuit_rpp) rpp_stats
1 |
|
Leveraging the optional roads and trails, we’re able to shave about 3 miles off the CPP route. Total mileage checks in at 30.71, just under a 50K (30.1 miles).
print(‘Miles in RPP solution: {:0.2f}’.format(rpp_stats[‘distance_walked’]/1609.34))
1 |
|
Viz RPP Solution
1 |
|
Create graph from RPP solution
1 |
|
Viz: RPP optional edges
The RPP algorithm picks up some logical shortcuts using the optional trails and a couple short stretches of road.
-
black: required trails
-
blue: optional trails and roads
1 |
|
Viz: RPP edges counts
1 |
|
Edge walks per color:
black: 1 magenta: 2
1 |
|
Create geojson solution
Used for the D3 route animation at the beginning of the post (also here). More details here.
1 |
|
with open(‘circuit_rpp.geojson’,’w’) as f: json.dump(geojson, f) ```