Skip to content

Commit

Permalink
Merge pull request #13 from nextmv-io/merschformann/osrm-support
Browse files Browse the repository at this point in the history
Adds OSRM Support for enhanced route queries
  • Loading branch information
merschformann authored Oct 17, 2024
2 parents 68560a8 + f89bec8 commit a20bc78
Show file tree
Hide file tree
Showing 9 changed files with 211 additions and 28 deletions.
46 changes: 40 additions & 6 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,43 @@ This can be changed via the `--output_image` & `--output_map` parameters.
The map plot should look like [this](https://nextmv-io.github.io/nextplot/plots/dortmund-route):
![dortmund-route.json.html.png](https://nextmv-io.github.io/nextplot/plots/dortmund-route/dortmund-route.json.html.png)

## Route plotting with routingkit support
## Route plotting with OSRM support

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

### Pre-requisites
### Pre-requisites for OSRM

1. Install [go-routingkit](go-routingkit) standalone:
1. Spin up an OSRM server with a suitable region and profile. Follow the
[steps][osrm-install] provided by OSRM to get started.

### Plot route paths via OSRM

The command is similar to the one above, but specifies some extra options (refer
to the full list [below](#additional-information)). The `osrm_host` option
activates OSRM driven plotting.

```bash
nextplot route \
--input_route data/kyoto-route.json \
--jpath_route "vehicles[*].route" \
--jpath_x "position.lon" \
--jpath_y "position.lat" \
--output_map kyoto-route.html \
--output_image kyoto-route.png \
--osrm_host http://localhost:5000
```

## Route plotting with RoutingKit support

Another option to plot routes is to use the [go-routingkit][go-rk] library which
comes with a standalone binary. This approach does not need a running server,
but takes longer to compute the routes (as it needs to preprocess the osm file
on each run).

### Pre-requisites for RoutingKit

1. Install [go-routingkit][go-rk] standalone:

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

### Plot route paths
### Plot route paths via RoutingKit

The command is similar to the one above, but specifies some extra options (refer
to the full list [below](#additional-information)). The `rk_osm` option
Expand Down Expand Up @@ -299,6 +328,9 @@ handle certain data formats. Find an outline of these options here:
- `--stats_file <path-to-file>`:
If provided, statistics will be written to the given file in addition to
stdout.
- `osrm_host` (route only):
Host of the OSRM server to be used for routing. If provided, routes will be
generated via OSRM. Example: `http://localhost:5000`.
- `rk_bin` (route only):
Path to the [go-routingkit][go-rk] standalone binary. Alternatively,
`routingkit` command will be used at default (requires go-routingkit
Expand All @@ -315,5 +347,7 @@ handle certain data formats. Find an outline of these options here:
[go-rk]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit
[go-rk-install]: https://github.com/nextmv-io/go-routingkit/tree/stable/cmd/routingkit#install
[osrm]: https://project-osrm.org/
[osrm-install]: https://github.com/Project-OSRM/osrm-backend?tab=readme-ov-file#quick-start
[custom-layers]: http://leaflet-extras.github.io/leaflet-providers/preview/
[folium-tiles]: https://deparkes.co.uk/2016/06/10/folium-map-tiles/
8 changes: 6 additions & 2 deletions examples/gallery/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,10 @@ suitable region file via:
wget -N http://download.geofabrik.de/north-america/us/texas-latest.osm.pbf
```

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

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

![fleet-cloud-comparison.png](https://nextmv-io.github.io/nextplot/gallery/fleet-cloud-comparison/fleet-cloud-comparison.png)

[osrm-steps]: ../README.md#route-plotting-with-osrm-support
1 change: 1 addition & 0 deletions nextplot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ def entry_point():
weight_points=args.weight_points,
no_points=args.no_points,
start_end_markers=args.start_end_markers,
osrm_host=args.osrm_host,
rk_osm=args.rk_osm,
rk_bin=args.rk_bin,
rk_profile=args.rk_profile,
Expand Down
115 changes: 115 additions & 0 deletions nextplot/osrm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import dataclasses
import sys
import urllib.parse

import polyline
import requests

from nextplot import common, types

TRAVEL_SPEED = 10 # assuming 10m/s travel speed for missing segments and snapping


@dataclasses.dataclass
class OsrmRouteRequest:
positions: list[types.Position]


@dataclasses.dataclass
class OsrRouteResponse:
paths: list[list[types.Position]]
distances: list[float]
durations: list[float]
zero_distance: bool = False


def query_route(
endpoint: str,
route: OsrmRouteRequest,
) -> OsrRouteResponse:
"""
Queries a route from the OSRM server.
"""
# Encode positions as polyline string to better handle large amounts of positions
polyline_str = polyline.encode([(p.lat, p.lon) for p in route.positions])

# Assemble request
url_base = urllib.parse.urljoin(endpoint, "route/v1/driving/")
url = urllib.parse.urljoin(url_base, f"polyline({polyline_str})?overview=full&geometries=polyline&steps=true")

# Query OSRM
try:
response = requests.get(url)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"Error querying OSRM at {url_base}:", e)
sys.exit(1)
result = response.json()
if result["code"] != "Ok":
raise Exception("OSRM returned an error:", result["message"])
if len(result["routes"]) == 0:
raise Exception(f"No route found for {route.positions}")

# Process all legs
all_zero_distances = True
legs, distances, durations = [], [], []
for idx, leg in enumerate(result["routes"][0]["legs"]):
# Combine all steps into a single path
path = []
for step in leg["steps"]:
path.extend(polyline.decode(step["geometry"]))
# Remove subsequent identical points
path = [path[0]] + [p for i, p in enumerate(path[1:], 1) if path[i] != path[i - 1]]
# Convert to Position objects
path = [types.Position(lon=lon, lat=lat, desc=None) for lat, lon in path]
# Add start and end
path = [route.positions[idx]] + path + [route.positions[idx + 1]]
# Extract distance and duration
distance = leg["distance"] / 1000.0 # OSRM return is in meters, convert to km
duration = leg["duration"]
# Make sure we are finding any routes
if distance > 0:
all_zero_distances = False
# Add duration for start and end
start_distance = common.haversine(path[0], route.positions[idx])
end_distance = common.haversine(path[-1], route.positions[idx + 1])
distance += start_distance + end_distance
duration += start_distance / TRAVEL_SPEED + end_distance / TRAVEL_SPEED
# Append to list
legs.append(path)
distances.append(distance)
durations.append(duration)

# Warn if number of legs does not match number of positions
if len(legs) != len(route.positions) - 1:
print(f"Warning: number of legs ({len(legs)}) does not match number of positions ({len(route.positions)} - 1)")

# Extract route
return OsrRouteResponse(paths=legs, distances=distances, durations=durations, zero_distance=all_zero_distances)


def query_routes(
endpoint: str,
routes: list[types.Route],
) -> list[OsrRouteResponse]:
"""
Queries multiple routes from the OSRM server.
param str endpoint: URL of the OSRM server.
param list[OsrmRouteRequest] routes: List of routes to query.
return: List of route results.
"""

# Query all routes
reqs = [OsrmRouteRequest(positions=route.points) for route in routes]
zero_distance_routes = 0
for r, req in enumerate(reqs):
result = query_route(endpoint, req)
routes[r].legs = result.paths
routes[r].leg_distances = result.distances
routes[r].leg_durations = result.durations
if result.zero_distance:
zero_distance_routes += 1
if zero_distance_routes > 0:
print(f"Warning: {zero_distance_routes} / {len(routes)} routes have zero distance according to OSRM")
48 changes: 32 additions & 16 deletions nextplot/route.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import plotly.graph_objects as go
from folium import plugins

from . import common, routingkit, types
from . import common, osrm, routingkit, types

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

Expand Down Expand Up @@ -175,6 +175,13 @@ def arguments(parser):
action="store_true",
help="indicates whether to add start and end markers",
)
parser.add_argument(
"--osrm_host",
type=str,
nargs="?",
default=None,
help="host and port of the OSRM server (e.g. 'http://localhost:5000')",
)
parser.add_argument(
"--rk_bin",
type=str,
Expand Down Expand Up @@ -334,7 +341,6 @@ def create_map(
route_animation_color: str,
start_end_markers: bool,
custom_map_tile: list[str],
rk_distance: bool,
) -> folium.Map:
"""
Plots the given routes on a folium map.
Expand Down Expand Up @@ -369,8 +375,6 @@ def create_map(
omit_end,
route_direction,
route_animation_color,
1.0 / 1000.0 if rk_distance else 1.0 / 1000.0,
"km" if rk_distance else "s",
)

# Plot points
Expand Down Expand Up @@ -514,6 +518,7 @@ def plot(
weight_points: float,
no_points: bool,
start_end_markers: bool,
osrm_host: str,
rk_osm: str,
rk_bin: str,
rk_profile: routingkit.RoutingKitProfile,
Expand Down Expand Up @@ -600,8 +605,10 @@ def plot(
route.points[i].distance = length
route.length = length

# Determine route shapes (if routingkit is available)
if rk_osm:
# Determine route shapes (if osrm or routingkit are available)
if osrm_host:
osrm.query_routes(osrm_host, routes)
elif rk_osm:
routingkit.query_routes(rk_bin, rk_osm, routes, rk_profile, rk_distance)

# Dump some stats
Expand Down Expand Up @@ -670,7 +677,6 @@ def plot(
route_animation_color,
start_end_markers,
custom_map_tile,
rk_distance,
)

# Save map
Expand Down Expand Up @@ -737,15 +743,15 @@ def plot_map_route(
omit_end: bool,
direction: types.RouteDirectionIndicator = types.RouteDirectionIndicator.none,
animation_bg_color: str = "FFFFFF",
rk_factor: float = None,
rk_unit: str = None,
):
"""
Plots a route on the given map.
"""
rk_text = ""
if route.legs is not None:
rk_text = f"Route cost (routingkit): {sum(route.leg_costs) * rk_factor:.2f} {rk_unit}</br>"
if route.leg_distances is not None:
rk_text += f"Route cost (rk/osrm): {sum(route.leg_distances):.2f} km</br>"
if route.leg_durations is not None:
rk_text += f"Route duration (rk/osrm): {sum(route.leg_durations):.2f} s</br>"
popup_text = folium.Html(
"<p>"
+ f"Route: {route_idx+1} / {route_count}</br>"
Expand Down Expand Up @@ -838,13 +844,23 @@ def statistics(
types.Stat("nunassigned", "Unassigned stops", sum([len(g) for g in unassigned])),
]

if all((r.legs is not None) for r in routes):
costs = [sum(r.leg_costs) for r in routes]
if all((r.leg_distances is not None) for r in routes):
costs = [sum(r.leg_distances) for r in routes]
stats.extend(
[
types.Stat("distances_max", "RK/OSRM distances (max)", max(costs)),
types.Stat("distances_min", "RK/OSRM distances (min)", min(costs)),
types.Stat("distances_avg", "RK/OSRM distances (avg)", sum(costs) / float(len(routes))),
]
)

if all((r.leg_durations is not None) for r in routes):
durations = [sum(r.leg_durations) for r in routes]
stats.extend(
[
types.Stat("costs_max", "RK costs (max)", max(costs)),
types.Stat("costs_min", "RK costs (min)", min(costs)),
types.Stat("costs_avg", "RK costs (avg)", sum(costs) / float(len(routes))),
types.Stat("durations_max", "RK/OSRM durations (max)", max(durations)),
types.Stat("durations_min", "RK/OSRM durations (min)", min(durations)),
types.Stat("durations_avg", "RK/OSRM durations (avg)", sum(durations) / float(len(routes))),
]
)

Expand Down
16 changes: 13 additions & 3 deletions nextplot/routingkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,8 @@ def query_routes(
# Clear any previously existing information
for route in routes:
route.legs = None
route.leg_costs = None
route.leg_distances = None
route.leg_durations = None

# Add results to routes
for i, path in enumerate(paths):
Expand Down Expand Up @@ -115,13 +116,22 @@ def query_routes(
end_cost /= travel_speed
cost += start_cost + end_cost

# RK uses milliseconds and meters, convert to seconds and kilometers (same factor)
cost /= 1000.0

# Add leg to route
if route.legs is None:
route.legs = [leg]
route.leg_costs = [cost]
if distance:
route.leg_distances = [cost]
else:
route.leg_durations = [cost]
else:
route.legs.append(leg)
route.leg_costs.append(cost)
if distance:
route.leg_distances.append(cost)
else:
route.leg_durations.append(cost)


def query(
Expand Down
3 changes: 2 additions & 1 deletion nextplot/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ class Route:
def __init__(self, points: list[Position]):
self.points = points
self.legs = None
self.leg_costs = 0
self.leg_distances = None
self.leg_durations = None

def to_points(self, omit_start: bool, omit_end: bool) -> list[Position]:
"""
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"kaleido>=0.2.1",
"numpy>=1.22.3",
"plotly>=5.7.0",
"polyline>=2.0.2",
"scipy>=1.8.0",
]
description = "Tools for plotting routes, clusters and more from JSON"
Expand Down
1 change: 1 addition & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ jsonpath_ng==1.6.1
kaleido==0.2.1
numpy==1.26.4
plotly==5.21.0
polyline==2.0.2
scipy==1.13.0
pytest==7.1.1
imagehash==4.3.1
Expand Down

0 comments on commit a20bc78

Please sign in to comment.