diff --git a/examples/README.md b/examples/README.md index a6bcc05..6b26e1c 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 @@ -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 @@ -299,6 +328,9 @@ handle certain data formats. Find an outline of these options here: - `--stats_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 @@ -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/ diff --git a/examples/gallery/README.md b/examples/gallery/README.md index 3d8dff6..165c8f8 100644 --- a/examples/gallery/README.md +++ b/examples/gallery/README.md @@ -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 \ @@ -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 diff --git a/nextplot/main.py b/nextplot/main.py index d4a85f5..2f53ecc 100755 --- a/nextplot/main.py +++ b/nextplot/main.py @@ -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, diff --git a/nextplot/osrm.py b/nextplot/osrm.py new file mode 100644 index 0000000..64ee1e7 --- /dev/null +++ b/nextplot/osrm.py @@ -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") diff --git a/nextplot/route.py b/nextplot/route.py index f9b847a..63bd80d 100644 --- a/nextplot/route.py +++ b/nextplot/route.py @@ -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') @@ -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, @@ -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. @@ -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 @@ -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, @@ -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 @@ -670,7 +677,6 @@ def plot( route_animation_color, start_end_markers, custom_map_tile, - rk_distance, ) # Save map @@ -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}
" + if route.leg_distances is not None: + rk_text += f"Route cost (rk/osrm): {sum(route.leg_distances):.2f} km
" + if route.leg_durations is not None: + rk_text += f"Route duration (rk/osrm): {sum(route.leg_durations):.2f} s
" popup_text = folium.Html( "

" + f"Route: {route_idx+1} / {route_count}
" @@ -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))), ] ) diff --git a/nextplot/routingkit.py b/nextplot/routingkit.py index 3690d53..56585b5 100644 --- a/nextplot/routingkit.py +++ b/nextplot/routingkit.py @@ -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): @@ -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( diff --git a/nextplot/types.py b/nextplot/types.py index 9ba0cfd..1975223 100644 --- a/nextplot/types.py +++ b/nextplot/types.py @@ -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]: """ diff --git a/pyproject.toml b/pyproject.toml index 16291d4..6789fcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/requirements-dev.txt b/requirements-dev.txt index eda8ad4..047fdf4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -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