From a0f19ff9bc7a5c7e5daa9b26f27e01a5a56a8461 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Wed, 18 Sep 2024 04:25:49 +0200 Subject: [PATCH 01/16] Adding slack release notifications --- .github/workflows/publish.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a0b0de2..3191bd2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -126,3 +126,16 @@ jobs: with: repository-url: https://test.pypi.org/legacy/ packages-dir: ./dist + + notify: + runs-on: ubuntu-latest + needs: publish + if: ${{ needs.publish.result == 'success' && inputs.IS_PRE_RELEASE == false }} + steps: + - name: notify slack + run: | + if [ ${{ inputs.IS_PRE_RELEASE }} = true ]; then + exit 0 # don't notify slack for pre-releases + fi + export DATA="{\"text\":\"Release notification - nextplot ${{ inputs.VERSION }} (see / )\"}" + curl -X POST -H 'Content-type: application/json' --data "$DATA" ${{ secrets.SLACK_WEBHOOK_URL }} From 01b4a3a6747abd9dd2ab671cafc9a05f0bf80e0e Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Wed, 18 Sep 2024 04:59:26 +0200 Subject: [PATCH 02/16] Simplifying notification, fixing secret name --- .github/workflows/publish.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3191bd2..bdfe839 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -134,8 +134,5 @@ jobs: steps: - name: notify slack run: | - if [ ${{ inputs.IS_PRE_RELEASE }} = true ]; then - exit 0 # don't notify slack for pre-releases - fi export DATA="{\"text\":\"Release notification - nextplot ${{ inputs.VERSION }} (see / )\"}" - curl -X POST -H 'Content-type: application/json' --data "$DATA" ${{ secrets.SLACK_WEBHOOK_URL }} + curl -X POST -H 'Content-type: application/json' --data "$DATA" ${{ secrets.SLACK_URL_MISSION_CONTROL }} From 16ded79a694d01df0475b9f00fd1b14fdf26adf6 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Tue, 1 Oct 2024 22:23:25 +0200 Subject: [PATCH 03/16] Renaming publish workflow to release for harmonization --- .github/workflows/publish.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index bdfe839..b08b358 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,4 +1,5 @@ -name: publish +name: release +run-name: Release ${{ inputs.VERSION }} (pre-release - ${{ inputs.IS_PRE_RELEASE }}) by @${{ github.actor }} from ${{ github.ref_name }} on: workflow_dispatch: @@ -79,7 +80,7 @@ jobs: run: python -m build working-directory: ./nextplot - publish: # This job is used to publish the release to PyPI/TestPyPI + release: # This job is used to publish the release to PyPI/TestPyPI runs-on: ubuntu-latest needs: bump strategy: @@ -129,8 +130,8 @@ jobs: notify: runs-on: ubuntu-latest - needs: publish - if: ${{ needs.publish.result == 'success' && inputs.IS_PRE_RELEASE == false }} + needs: release + if: ${{ needs.release.result == 'success' && inputs.IS_PRE_RELEASE == false }} steps: - name: notify slack run: | From 3bf6969d1c10d159c138387e55b4c2fb99832fe2 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Tue, 1 Oct 2024 22:39:41 +0200 Subject: [PATCH 04/16] Renaming publish workflow to release workflow --- .github/workflows/{publish.yml => release.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{publish.yml => release.yml} (100%) diff --git a/.github/workflows/publish.yml b/.github/workflows/release.yml similarity index 100% rename from .github/workflows/publish.yml rename to .github/workflows/release.yml From 68560a80a39a8c1cb43378034be1c8b41e854887 Mon Sep 17 00:00:00 2001 From: nextmv-bot Date: Tue, 1 Oct 2024 20:49:14 +0000 Subject: [PATCH 05/16] Bump version to v0.1.6-dev.0 --- nextplot/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextplot/__about__.py b/nextplot/__about__.py index c606576..e07870a 100644 --- a/nextplot/__about__.py +++ b/nextplot/__about__.py @@ -1 +1 @@ -__version__ = "v0.1.5" +__version__ = "v0.1.6.dev0" From a30e89ed781afaaecc745eba7c1eb993821a287d Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Tue, 15 Oct 2024 23:55:26 +0200 Subject: [PATCH 06/16] Adding support for osrm paths --- nextplot/main.py | 1 + nextplot/osrm.py | 115 +++++++++++++++++++++++++++++++++++++++++ nextplot/route.py | 48 +++++++++++------ nextplot/routingkit.py | 16 ++++-- nextplot/types.py | 3 +- 5 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 nextplot/osrm.py 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]: """ From a8461b69f2e4c2d8638ab8b867f1adda55436410 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Wed, 16 Oct 2024 00:08:18 +0200 Subject: [PATCH 07/16] Adding missing polyline dependency --- pyproject.toml | 1 + requirements-dev.txt | 1 + 2 files changed, 2 insertions(+) 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 From f89bec8a2d4dda952648f0d2f1fce267beed5768 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Wed, 16 Oct 2024 00:32:42 +0200 Subject: [PATCH 08/16] Add steps for using OSRM to readme --- examples/README.md | 46 +++++++++++++++++++++++++++++++++----- examples/gallery/README.md | 8 +++++-- 2 files changed, 46 insertions(+), 8 deletions(-) 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 From 18d2a948b8f0e643976c57998c14503f0b9c3404 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Thu, 24 Oct 2024 14:42:28 +0200 Subject: [PATCH 09/16] Use fallback when dealing with OSRM noroute errors --- nextplot/osrm.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/nextplot/osrm.py b/nextplot/osrm.py index 64ee1e7..4ea05d2 100644 --- a/nextplot/osrm.py +++ b/nextplot/osrm.py @@ -21,6 +21,7 @@ class OsrRouteResponse: distances: list[float] durations: list[float] zero_distance: bool = False + no_route: bool = False def query_route( @@ -40,9 +41,26 @@ def query_route( # Query OSRM try: response = requests.get(url) + # If no route was found, use as-the-crow-flies fallback + if response.status_code == 400 and response.json()["code"] == "NoRoute": + print( + f"Warning: OSRM was unable to find a route for {[(p.lat, p.lon) for p in route.positions]}" + + "(lat,lon ordering), using as-the-crow-flies fallback" + ) + paths, distances, durations = [], [], [] + for f, t in zip(route.positions, route.positions[1:], strict=False): + paths.append( + [types.Position(lon=f.lon, lat=f.lat, desc=None), types.Position(lon=t.lon, lat=t.lat, desc=None)] + ) + distances.append(common.haversine(f, t)) + durations.append(common.haversine(f, t) / TRAVEL_SPEED) + return OsrRouteResponse(paths=paths, distances=distances, durations=durations, no_route=True) + # Make sure we are not getting an error response.raise_for_status() except requests.exceptions.RequestException as e: print(f"Error querying OSRM at {url_base}:", e) + if response: + print(response.text) sys.exit(1) result = response.json() if result["code"] != "Ok": @@ -103,7 +121,7 @@ def query_routes( # Query all routes reqs = [OsrmRouteRequest(positions=route.points) for route in routes] - zero_distance_routes = 0 + zero_distance_routes, no_route_routes = 0, 0 for r, req in enumerate(reqs): result = query_route(endpoint, req) routes[r].legs = result.paths @@ -111,5 +129,9 @@ def query_routes( routes[r].leg_durations = result.durations if result.zero_distance: zero_distance_routes += 1 + if result.no_route: + no_route_routes += 1 if zero_distance_routes > 0: print(f"Warning: {zero_distance_routes} / {len(routes)} routes have zero distance according to OSRM") + if no_route_routes > 0: + print(f"Warning: {no_route_routes} / {len(routes)} routes could not be found by OSRM") From a9676feee673c4210102dbe62e518ebddb1baa62 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Thu, 24 Oct 2024 15:55:56 +0200 Subject: [PATCH 10/16] Fixing typo --- nextplot/osrm.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/nextplot/osrm.py b/nextplot/osrm.py index 4ea05d2..c45b5b0 100644 --- a/nextplot/osrm.py +++ b/nextplot/osrm.py @@ -16,7 +16,7 @@ class OsrmRouteRequest: @dataclasses.dataclass -class OsrRouteResponse: +class OsrmRouteResponse: paths: list[list[types.Position]] distances: list[float] durations: list[float] @@ -27,7 +27,7 @@ class OsrRouteResponse: def query_route( endpoint: str, route: OsrmRouteRequest, -) -> OsrRouteResponse: +) -> OsrmRouteResponse: """ Queries a route from the OSRM server. """ @@ -54,7 +54,7 @@ def query_route( ) distances.append(common.haversine(f, t)) durations.append(common.haversine(f, t) / TRAVEL_SPEED) - return OsrRouteResponse(paths=paths, distances=distances, durations=durations, no_route=True) + return OsrmRouteResponse(paths=paths, distances=distances, durations=durations, no_route=True) # Make sure we are not getting an error response.raise_for_status() except requests.exceptions.RequestException as e: @@ -103,13 +103,13 @@ def query_route( 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) + return OsrmRouteResponse(paths=legs, distances=distances, durations=durations, zero_distance=all_zero_distances) def query_routes( endpoint: str, routes: list[types.Route], -) -> list[OsrRouteResponse]: +) -> list[OsrmRouteResponse]: """ Queries multiple routes from the OSRM server. From e73a47be01979b3f2cad5011c52e43a9cb69599a Mon Sep 17 00:00:00 2001 From: nextmv-bot Date: Thu, 24 Oct 2024 15:33:12 +0000 Subject: [PATCH 11/16] Bump version to v0.1.6 --- nextplot/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextplot/__about__.py b/nextplot/__about__.py index e07870a..229160c 100644 --- a/nextplot/__about__.py +++ b/nextplot/__about__.py @@ -1 +1 @@ -__version__ = "v0.1.6.dev0" +__version__ = "v0.1.6" From 689fe65e7de446aea4bdbc73e061b1385d895063 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Mon, 28 Oct 2024 17:03:13 +0100 Subject: [PATCH 12/16] Making nextplot compatible with more forms of nextroute output --- nextplot/route.py | 119 ++++++++++++++++++++++++++++++---------------- 1 file changed, 77 insertions(+), 42 deletions(-) diff --git a/nextplot/route.py b/nextplot/route.py index 63bd80d..6c0e645 100644 --- a/nextplot/route.py +++ b/nextplot/route.py @@ -1,6 +1,9 @@ +import dataclasses import json +from collections.abc import Callable import folium +import jsonpath_ng import plotly.graph_objects as go from folium import plugins @@ -14,28 +17,19 @@ # ==================== Pre-configured plot profiles +@dataclasses.dataclass class RoutePlotProfile: """ Pre-configured plot profiles for routes. """ - def __init__( - self, - jpath_route: str = "", - jpath_pos: str = "", - jpath_x: str = "", - jpath_y: str = "", - jpath_unassigned: str = "", - jpath_unassigned_x: str = "", - jpath_unassigned_y: str = "", - ): - self.jpath_route = jpath_route - self.jpath_pos = jpath_pos - self.jpath_x = jpath_x - self.jpath_y = jpath_y - self.jpath_unassigned = jpath_unassigned - self.jpath_unassigned_x = jpath_unassigned_x - self.jpath_unassigned_y = jpath_unassigned_y + jpath_route: str = "" + jpath_pos: str = "" + jpath_x: str = "" + jpath_y: str = "" + jpath_unassigned: str = "" + jpath_unassigned_x: str = "" + jpath_unassigned_y: str = "" def __str__(self): return ( @@ -50,6 +44,28 @@ def __str__(self): ) +@dataclasses.dataclass +class MultiRoutePlotProfile: + """ + Multiple pre-configured plot profiles selected via given input tests. + """ + + profiles: list[tuple[Callable[[dict, dict], bool], RoutePlotProfile]] = dataclasses.field(default_factory=list) + fail_message: str = "No suitable profile found for plotting." + + def unwrap(self, content_route: dict, content_pos: dict) -> RoutePlotProfile: + """ + Tests the given data against the profiles and returns the first matching profile. + """ + for test, profile in self.profiles: + if test(content_route, content_pos): + return profile + raise Exception(self.fail_message) + + def __str__(self): + return "MultiRoutePlotProfile(" + ", ".join([f"{p[0]}: {p[1]}" for p in self.profiles]) + ")" + + # ==================== Route mode argument definition @@ -219,14 +235,8 @@ def arguments(parser): def parse( input_route: str, - jpath_route: str, - jpath_unassigned: str, - jpath_unassigned_x: str, - jpath_unassigned_y: str, input_pos: str, - jpath_pos: str, - jpath_x: str, - jpath_y: str, + profile: MultiRoutePlotProfile | RoutePlotProfile, ) -> tuple[list[list[types.Position]], list[list[types.Position]]]: """ Parses the route data from the file(s). @@ -234,6 +244,17 @@ def parse( # Load json data content_route, content_pos = common.load_data(input_route, input_pos) + # Dynamically set profile, if given + if isinstance(profile, MultiRoutePlotProfile): + profile = profile.unwrap(json.loads(content_route), json.loads(content_pos)) + jpath_route = profile.jpath_route + jpath_pos = profile.jpath_pos + jpath_x = profile.jpath_x + jpath_y = profile.jpath_y + jpath_unassigned = profile.jpath_unassigned + jpath_unassigned_x = profile.jpath_unassigned_x + jpath_unassigned_y = profile.jpath_unassigned_y + # Extract routes points = common.extract_position_groups( content_route, @@ -553,17 +574,7 @@ def plot( profile = nextroute_profile() # Parse data - points, unassigned = parse( - input_route, - profile.jpath_route, - profile.jpath_unassigned, - profile.jpath_unassigned_x, - profile.jpath_unassigned_y, - input_pos, - profile.jpath_pos, - profile.jpath_x, - profile.jpath_y, - ) + points, unassigned = parse(input_route, input_pos, profile) # Quit on no points if len(points) <= 0: @@ -691,13 +702,37 @@ def nextroute_profile() -> RoutePlotProfile: """ Returns the nextroute profile. """ - return RoutePlotProfile( - jpath_route="solutions[-1].vehicles[*].route", - jpath_x="stop.location.lon", - jpath_y="stop.location.lat", - jpath_unassigned="solutions[-1].unplanned[*]", - jpath_unassigned_x="location.lon", - jpath_unassigned_y="location.lat", + base_paths = [ + "solutions[-1]", + "solution", + "output.solutions[-1]", + "output.solution", + ] + + def make_path_exists(path): + def path_exists(content_route, path): + matches = jsonpath_ng.parse(path).find(content_route) + return len(list(matches)) > 0 + + return lambda content_route, _: path_exists(content_route, path) + + return MultiRoutePlotProfile( + [ + ( + make_path_exists(p), + RoutePlotProfile( + jpath_route=f"{p}.vehicles[*].route", + jpath_x="stop.location.lon", + jpath_y="stop.location.lat", + jpath_unassigned=f"{p}.unplanned[*]", + jpath_unassigned_x="location.lon", + jpath_unassigned_y="location.lat", + ), + ) + for p in base_paths + ], + "Input data does not match any known profile for nextroute plotting. Routes are expected at one of:\n" + + "\n".join(f"{p}.vehicles[*].route" for p in base_paths), ) From af67550a93895a78491f86f5164bcf827d140a90 Mon Sep 17 00:00:00 2001 From: nextmv-bot Date: Thu, 31 Oct 2024 16:04:21 +0000 Subject: [PATCH 13/16] Bump version to v0.1.7 --- nextplot/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextplot/__about__.py b/nextplot/__about__.py index 229160c..c7f5b95 100644 --- a/nextplot/__about__.py +++ b/nextplot/__about__.py @@ -1 +1 @@ -__version__ = "v0.1.6" +__version__ = "v0.1.7" From 78516ac6d1cb6647a1e4de2bd0a5a02535e07b84 Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Thu, 23 Jan 2025 21:19:12 +0100 Subject: [PATCH 14/16] Fix jpath based nested geojson plotting --- nextplot/geojson.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/nextplot/geojson.py b/nextplot/geojson.py index 113781f..371155c 100644 --- a/nextplot/geojson.py +++ b/nextplot/geojson.py @@ -104,14 +104,15 @@ def parse( Parses the geojson data object(s) from the file(s). """ # Load json data - content_geojson, _ = common.load_data(input_geojson, "") + content, _ = common.load_data(input_geojson, "") + json_content = json.loads(content) # Extract geojsons if jpath_geojson: expression = jsonpath_ng.parse(jpath_geojson) - geojsons = [json.loads(match.value) for match in expression.find(content_geojson)] + geojsons = [match.value for match in expression.find(json_content)] else: - geojsons = [json.loads(content_geojson)] + geojsons = [json_content] return geojsons From edb03251ca09a903152ea077d4ad5ba09f0a450b Mon Sep 17 00:00:00 2001 From: Marius Merschformann Date: Fri, 24 Jan 2025 01:37:54 +0100 Subject: [PATCH 15/16] Adding nested geojson test case --- tests/test_cli.py | 18 ++ tests/testdata/geojson-nested-data.json | 39 +++ .../testdata/geojson-nested-data.json.golden | 0 .../geojson-nested-data.json.map.html.golden | 226 ++++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 tests/testdata/geojson-nested-data.json create mode 100644 tests/testdata/geojson-nested-data.json.golden create mode 100644 tests/testdata/geojson-nested-data.json.map.html.golden diff --git a/tests/test_cli.py b/tests/test_cli.py index 621d2ad..93d522f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -480,6 +480,23 @@ def test_map_plot_cli_geojson(): _run_geojson_test(test) +def test_map_plot_cli_geojson_nested(): + test = GeoJSONTest( + "geojson", + [ + "geojson", + "--input_geojson", + os.path.join(DATA_DIR, "geojson-nested-data.json"), + "--jpath_geojson", + "assets[*].content", + ], + os.path.join(OUTPUT_DIR, "geojson-nested-data.json.map.html"), + os.path.join(DATA_DIR, "geojson-nested-data.json.golden"), + os.path.join(DATA_DIR, "geojson-nested-data.json.map.html.golden"), + ) + _run_geojson_test(test) + + def test_progression_plot_cli_fleet_cloud_comparison(): test = ProgressionTest( "fleet-cloud-comparison", @@ -507,5 +524,6 @@ def test_progression_plot_cli_fleet_cloud_comparison(): test_map_plot_cli_paris_point() test_map_plot_cli_paris_route_indexed() test_map_plot_cli_geojson() + test_map_plot_cli_geojson_nested() test_progression_plot_cli_fleet_cloud_comparison() print("Everything passed") diff --git a/tests/testdata/geojson-nested-data.json b/tests/testdata/geojson-nested-data.json new file mode 100644 index 0000000..dc234b9 --- /dev/null +++ b/tests/testdata/geojson-nested-data.json @@ -0,0 +1,39 @@ +{ + "assets": [ + { + "type": "geojson", + "content": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-74.076, 4.598] + }, + "properties": {} + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [-75.1649, 39.9525] + }, + "properties": {} + } + ] + } + }, + { + "type": "geojson", + "content": { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [7.6281, 51.962] + }, + "properties": {} + } + } + ] +} diff --git a/tests/testdata/geojson-nested-data.json.golden b/tests/testdata/geojson-nested-data.json.golden new file mode 100644 index 0000000..e69de29 diff --git a/tests/testdata/geojson-nested-data.json.map.html.golden b/tests/testdata/geojson-nested-data.json.map.html.golden new file mode 100644 index 0000000..74b6708 --- /dev/null +++ b/tests/testdata/geojson-nested-data.json.map.html.golden @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ + + + \ No newline at end of file From c2356734796cce0c95d8d006fabeb7f1ade33502 Mon Sep 17 00:00:00 2001 From: nextmv-bot Date: Wed, 29 Jan 2025 21:45:47 +0000 Subject: [PATCH 16/16] Bump version to v0.1.8 --- nextplot/__about__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nextplot/__about__.py b/nextplot/__about__.py index c7f5b95..b67ea67 100644 --- a/nextplot/__about__.py +++ b/nextplot/__about__.py @@ -1 +1 @@ -__version__ = "v0.1.7" +__version__ = "v0.1.8"