Skip to content

Commit a20bc78

Browse files
Merge pull request #13 from nextmv-io/merschformann/osrm-support
Adds OSRM Support for enhanced route queries
2 parents 68560a8 + f89bec8 commit a20bc78

File tree

9 files changed

+211
-28
lines changed

9 files changed

+211
-28
lines changed

examples/README.md

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -86,14 +86,43 @@ This can be changed via the `--output_image` & `--output_map` parameters.
8686
The map plot should look like [this](https://nextmv-io.github.io/nextplot/plots/dortmund-route):
8787
![dortmund-route.json.html.png](https://nextmv-io.github.io/nextplot/plots/dortmund-route/dortmund-route.json.html.png)
8888

89-
## Route plotting with routingkit support
89+
## Route plotting with OSRM support
9090

91-
Next, we're gonna plot routes using the road network. We do this with the
92-
support of [go-routingkit](go-routingkit).
91+
Next, we will plot routes using the road network. We do this with the support of
92+
[OSRM][osrm]. Make sure a server with a suitable region and profile is running.
9393

94-
### Pre-requisites
94+
### Pre-requisites for OSRM
9595

96-
1. Install [go-routingkit](go-routingkit) standalone:
96+
1. Spin up an OSRM server with a suitable region and profile. Follow the
97+
[steps][osrm-install] provided by OSRM to get started.
98+
99+
### Plot route paths via OSRM
100+
101+
The command is similar to the one above, but specifies some extra options (refer
102+
to the full list [below](#additional-information)). The `osrm_host` option
103+
activates OSRM driven plotting.
104+
105+
```bash
106+
nextplot route \
107+
--input_route data/kyoto-route.json \
108+
--jpath_route "vehicles[*].route" \
109+
--jpath_x "position.lon" \
110+
--jpath_y "position.lat" \
111+
--output_map kyoto-route.html \
112+
--output_image kyoto-route.png \
113+
--osrm_host http://localhost:5000
114+
```
115+
116+
## Route plotting with RoutingKit support
117+
118+
Another option to plot routes is to use the [go-routingkit][go-rk] library which
119+
comes with a standalone binary. This approach does not need a running server,
120+
but takes longer to compute the routes (as it needs to preprocess the osm file
121+
on each run).
122+
123+
### Pre-requisites for RoutingKit
124+
125+
1. Install [go-routingkit][go-rk] standalone:
97126

98127
```bash
99128
go install github.com/nextmv-io/go-routingkit/cmd/routingkit@latest
@@ -106,7 +135,7 @@ support of [go-routingkit](go-routingkit).
106135
wget -N http://download.geofabrik.de/asia/japan/kansai-latest.osm.pbf
107136
```
108137

109-
### Plot route paths
138+
### Plot route paths via RoutingKit
110139

111140
The command is similar to the one above, but specifies some extra options (refer
112141
to the full list [below](#additional-information)). The `rk_osm` option
@@ -299,6 +328,9 @@ handle certain data formats. Find an outline of these options here:
299328
- `--stats_file <path-to-file>`:
300329
If provided, statistics will be written to the given file in addition to
301330
stdout.
331+
- `osrm_host` (route only):
332+
Host of the OSRM server to be used for routing. If provided, routes will be
333+
generated via OSRM. Example: `http://localhost:5000`.
302334
- `rk_bin` (route only):
303335
Path to the [go-routingkit][go-rk] standalone binary. Alternatively,
304336
`routingkit` command will be used at default (requires go-routingkit
@@ -315,5 +347,7 @@ handle certain data formats. Find an outline of these options here:
315347
316348
[go-rk]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit
317349
[go-rk-install]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit#install
350+
[osrm]: https://project-osrm.org/
351+
[osrm-install]: https://github.com/Project-OSRM/osrm-backend?tab=readme-ov-file#quick-start
318352
[custom-layers]: http://leaflet-extras.github.io/leaflet-providers/preview/
319353
[folium-tiles]: https://deparkes.co.uk/2016/06/10/folium-map-tiles/

examples/gallery/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,10 @@ suitable region file via:
121121
wget -N http://download.geofabrik.de/north-america/us/texas-latest.osm.pbf
122122
```
123123

124-
This route plot uses routingkit for plotting road paths. Furthermore, unassigned
125-
points are plotted in addition to the route stops.
124+
This route plot uses routingkit for plotting road paths. Alternatively, spin up
125+
a local OSRM server and use the `--osrm_host` flag to use it (see
126+
[osrm-steps][osrm-steps]). Furthermore, unassigned points are plotted in
127+
addition to the route stops.
126128

127129
```bash
128130
nextplot route \
@@ -270,3 +272,5 @@ Interactive result: [link](https://nextmv-io.github.io/nextplot/gallery/fleet-cl
270272
Image result:
271273

272274
![fleet-cloud-comparison.png](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud-comparison/fleet-cloud-comparison.png)
275+
276+
[osrm-steps]: ../README.md#route-plotting-with-osrm-support

nextplot/main.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ def entry_point():
8383
weight_points=args.weight_points,
8484
no_points=args.no_points,
8585
start_end_markers=args.start_end_markers,
86+
osrm_host=args.osrm_host,
8687
rk_osm=args.rk_osm,
8788
rk_bin=args.rk_bin,
8889
rk_profile=args.rk_profile,

nextplot/osrm.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import dataclasses
2+
import sys
3+
import urllib.parse
4+
5+
import polyline
6+
import requests
7+
8+
from nextplot import common, types
9+
10+
TRAVEL_SPEED = 10 # assuming 10m/s travel speed for missing segments and snapping
11+
12+
13+
@dataclasses.dataclass
14+
class OsrmRouteRequest:
15+
positions: list[types.Position]
16+
17+
18+
@dataclasses.dataclass
19+
class OsrRouteResponse:
20+
paths: list[list[types.Position]]
21+
distances: list[float]
22+
durations: list[float]
23+
zero_distance: bool = False
24+
25+
26+
def query_route(
27+
endpoint: str,
28+
route: OsrmRouteRequest,
29+
) -> OsrRouteResponse:
30+
"""
31+
Queries a route from the OSRM server.
32+
"""
33+
# Encode positions as polyline string to better handle large amounts of positions
34+
polyline_str = polyline.encode([(p.lat, p.lon) for p in route.positions])
35+
36+
# Assemble request
37+
url_base = urllib.parse.urljoin(endpoint, "route/v1/driving/")
38+
url = urllib.parse.urljoin(url_base, f"polyline({polyline_str})?overview=full&geometries=polyline&steps=true")
39+
40+
# Query OSRM
41+
try:
42+
response = requests.get(url)
43+
response.raise_for_status()
44+
except requests.exceptions.RequestException as e:
45+
print(f"Error querying OSRM at {url_base}:", e)
46+
sys.exit(1)
47+
result = response.json()
48+
if result["code"] != "Ok":
49+
raise Exception("OSRM returned an error:", result["message"])
50+
if len(result["routes"]) == 0:
51+
raise Exception(f"No route found for {route.positions}")
52+
53+
# Process all legs
54+
all_zero_distances = True
55+
legs, distances, durations = [], [], []
56+
for idx, leg in enumerate(result["routes"][0]["legs"]):
57+
# Combine all steps into a single path
58+
path = []
59+
for step in leg["steps"]:
60+
path.extend(polyline.decode(step["geometry"]))
61+
# Remove subsequent identical points
62+
path = [path[0]] + [p for i, p in enumerate(path[1:], 1) if path[i] != path[i - 1]]
63+
# Convert to Position objects
64+
path = [types.Position(lon=lon, lat=lat, desc=None) for lat, lon in path]
65+
# Add start and end
66+
path = [route.positions[idx]] + path + [route.positions[idx + 1]]
67+
# Extract distance and duration
68+
distance = leg["distance"] / 1000.0 # OSRM return is in meters, convert to km
69+
duration = leg["duration"]
70+
# Make sure we are finding any routes
71+
if distance > 0:
72+
all_zero_distances = False
73+
# Add duration for start and end
74+
start_distance = common.haversine(path[0], route.positions[idx])
75+
end_distance = common.haversine(path[-1], route.positions[idx + 1])
76+
distance += start_distance + end_distance
77+
duration += start_distance / TRAVEL_SPEED + end_distance / TRAVEL_SPEED
78+
# Append to list
79+
legs.append(path)
80+
distances.append(distance)
81+
durations.append(duration)
82+
83+
# Warn if number of legs does not match number of positions
84+
if len(legs) != len(route.positions) - 1:
85+
print(f"Warning: number of legs ({len(legs)}) does not match number of positions ({len(route.positions)} - 1)")
86+
87+
# Extract route
88+
return OsrRouteResponse(paths=legs, distances=distances, durations=durations, zero_distance=all_zero_distances)
89+
90+
91+
def query_routes(
92+
endpoint: str,
93+
routes: list[types.Route],
94+
) -> list[OsrRouteResponse]:
95+
"""
96+
Queries multiple routes from the OSRM server.
97+
98+
param str endpoint: URL of the OSRM server.
99+
param list[OsrmRouteRequest] routes: List of routes to query.
100+
101+
return: List of route results.
102+
"""
103+
104+
# Query all routes
105+
reqs = [OsrmRouteRequest(positions=route.points) for route in routes]
106+
zero_distance_routes = 0
107+
for r, req in enumerate(reqs):
108+
result = query_route(endpoint, req)
109+
routes[r].legs = result.paths
110+
routes[r].leg_distances = result.distances
111+
routes[r].leg_durations = result.durations
112+
if result.zero_distance:
113+
zero_distance_routes += 1
114+
if zero_distance_routes > 0:
115+
print(f"Warning: {zero_distance_routes} / {len(routes)} routes have zero distance according to OSRM")

nextplot/route.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import plotly.graph_objects as go
55
from folium import plugins
66

7-
from . import common, routingkit, types
7+
from . import common, osrm, routingkit, types
88

99
# ==================== This file contains route plotting code (mode: 'route')
1010

@@ -175,6 +175,13 @@ def arguments(parser):
175175
action="store_true",
176176
help="indicates whether to add start and end markers",
177177
)
178+
parser.add_argument(
179+
"--osrm_host",
180+
type=str,
181+
nargs="?",
182+
default=None,
183+
help="host and port of the OSRM server (e.g. 'http://localhost:5000')",
184+
)
178185
parser.add_argument(
179186
"--rk_bin",
180187
type=str,
@@ -334,7 +341,6 @@ def create_map(
334341
route_animation_color: str,
335342
start_end_markers: bool,
336343
custom_map_tile: list[str],
337-
rk_distance: bool,
338344
) -> folium.Map:
339345
"""
340346
Plots the given routes on a folium map.
@@ -369,8 +375,6 @@ def create_map(
369375
omit_end,
370376
route_direction,
371377
route_animation_color,
372-
1.0 / 1000.0 if rk_distance else 1.0 / 1000.0,
373-
"km" if rk_distance else "s",
374378
)
375379

376380
# Plot points
@@ -514,6 +518,7 @@ def plot(
514518
weight_points: float,
515519
no_points: bool,
516520
start_end_markers: bool,
521+
osrm_host: str,
517522
rk_osm: str,
518523
rk_bin: str,
519524
rk_profile: routingkit.RoutingKitProfile,
@@ -600,8 +605,10 @@ def plot(
600605
route.points[i].distance = length
601606
route.length = length
602607

603-
# Determine route shapes (if routingkit is available)
604-
if rk_osm:
608+
# Determine route shapes (if osrm or routingkit are available)
609+
if osrm_host:
610+
osrm.query_routes(osrm_host, routes)
611+
elif rk_osm:
605612
routingkit.query_routes(rk_bin, rk_osm, routes, rk_profile, rk_distance)
606613

607614
# Dump some stats
@@ -670,7 +677,6 @@ def plot(
670677
route_animation_color,
671678
start_end_markers,
672679
custom_map_tile,
673-
rk_distance,
674680
)
675681

676682
# Save map
@@ -737,15 +743,15 @@ def plot_map_route(
737743
omit_end: bool,
738744
direction: types.RouteDirectionIndicator = types.RouteDirectionIndicator.none,
739745
animation_bg_color: str = "FFFFFF",
740-
rk_factor: float = None,
741-
rk_unit: str = None,
742746
):
743747
"""
744748
Plots a route on the given map.
745749
"""
746750
rk_text = ""
747-
if route.legs is not None:
748-
rk_text = f"Route cost (routingkit): {sum(route.leg_costs) * rk_factor:.2f} {rk_unit}</br>"
751+
if route.leg_distances is not None:
752+
rk_text += f"Route cost (rk/osrm): {sum(route.leg_distances):.2f} km</br>"
753+
if route.leg_durations is not None:
754+
rk_text += f"Route duration (rk/osrm): {sum(route.leg_durations):.2f} s</br>"
749755
popup_text = folium.Html(
750756
"<p>"
751757
+ f"Route: {route_idx+1} / {route_count}</br>"
@@ -838,13 +844,23 @@ def statistics(
838844
types.Stat("nunassigned", "Unassigned stops", sum([len(g) for g in unassigned])),
839845
]
840846

841-
if all((r.legs is not None) for r in routes):
842-
costs = [sum(r.leg_costs) for r in routes]
847+
if all((r.leg_distances is not None) for r in routes):
848+
costs = [sum(r.leg_distances) for r in routes]
849+
stats.extend(
850+
[
851+
types.Stat("distances_max", "RK/OSRM distances (max)", max(costs)),
852+
types.Stat("distances_min", "RK/OSRM distances (min)", min(costs)),
853+
types.Stat("distances_avg", "RK/OSRM distances (avg)", sum(costs) / float(len(routes))),
854+
]
855+
)
856+
857+
if all((r.leg_durations is not None) for r in routes):
858+
durations = [sum(r.leg_durations) for r in routes]
843859
stats.extend(
844860
[
845-
types.Stat("costs_max", "RK costs (max)", max(costs)),
846-
types.Stat("costs_min", "RK costs (min)", min(costs)),
847-
types.Stat("costs_avg", "RK costs (avg)", sum(costs) / float(len(routes))),
861+
types.Stat("durations_max", "RK/OSRM durations (max)", max(durations)),
862+
types.Stat("durations_min", "RK/OSRM durations (min)", min(durations)),
863+
types.Stat("durations_avg", "RK/OSRM durations (avg)", sum(durations) / float(len(routes))),
848864
]
849865
)
850866

nextplot/routingkit.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ def query_routes(
8383
# Clear any previously existing information
8484
for route in routes:
8585
route.legs = None
86-
route.leg_costs = None
86+
route.leg_distances = None
87+
route.leg_durations = None
8788

8889
# Add results to routes
8990
for i, path in enumerate(paths):
@@ -115,13 +116,22 @@ def query_routes(
115116
end_cost /= travel_speed
116117
cost += start_cost + end_cost
117118

119+
# RK uses milliseconds and meters, convert to seconds and kilometers (same factor)
120+
cost /= 1000.0
121+
118122
# Add leg to route
119123
if route.legs is None:
120124
route.legs = [leg]
121-
route.leg_costs = [cost]
125+
if distance:
126+
route.leg_distances = [cost]
127+
else:
128+
route.leg_durations = [cost]
122129
else:
123130
route.legs.append(leg)
124-
route.leg_costs.append(cost)
131+
if distance:
132+
route.leg_distances.append(cost)
133+
else:
134+
route.leg_durations.append(cost)
125135

126136

127137
def query(

nextplot/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,8 @@ class Route:
105105
def __init__(self, points: list[Position]):
106106
self.points = points
107107
self.legs = None
108-
self.leg_costs = 0
108+
self.leg_distances = None
109+
self.leg_durations = None
109110

110111
def to_points(self, omit_start: bool, omit_end: bool) -> list[Position]:
111112
"""

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"kaleido>=0.2.1",
2020
"numpy>=1.22.3",
2121
"plotly>=5.7.0",
22+
"polyline>=2.0.2",
2223
"scipy>=1.8.0",
2324
]
2425
description = "Tools for plotting routes, clusters and more from JSON"

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ jsonpath_ng==1.6.1
55
kaleido==0.2.1
66
numpy==1.26.4
77
plotly==5.21.0
8+
polyline==2.0.2
89
scipy==1.13.0
910
pytest==7.1.1
1011
imagehash==4.3.1

0 commit comments

Comments
 (0)