From 1d5191d3e6dde7bfdd7b72584c6ba04ef0a38724 Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 25 Aug 2025 14:26:52 -0700 Subject: [PATCH 1/2] replaced spopt/route --- spopt/route/engine.py | 333 ++++++++++++++++----------------------- spopt/route/heuristic.py | 134 ++++++++++++---- spopt/route/utils.py | 17 +- 3 files changed, 244 insertions(+), 240 deletions(-) diff --git a/spopt/route/engine.py b/spopt/route/engine.py index 636c8551..2f0ad789 100644 --- a/spopt/route/engine.py +++ b/spopt/route/engine.py @@ -1,224 +1,163 @@ -try: - import osrm - has_bindings = True -except (ImportError,ModuleNotFoundError) as e: - has_bindings = False +import routingpy +from routingpy import OSRM, Valhalla + import os import numpy import requests import warnings import geopandas +import pandas import shapely from sklearn import metrics -# TODO: needs to be configurable by site -_OSRM_DATABASE_FILE = "" -def build_route_table(demand_sites, candidate_depots, cost='distance', http=not has_bindings, database_path=_OSRM_DATABASE_FILE, port=5000): - """ - Build a route table using OSRM, either over http or over py-osrm bindings - """ - if isinstance(demand_sites, (geopandas.GeoSeries, geopandas.GeoDataFrame)): - demand_sites = demand_sites.geometry.get_coordinates().values - if isinstance(candidate_depots, (geopandas.GeoSeries, geopandas.GeoDataFrame)): - candidate_depots = candidate_depots.geometry.get_coordinates().values - if cost not in ("distance", "duration", "both"): - raise ValueError(f"cost option '{cost}' not one of the supported options, ('distance', 'duration', 'both')") - if http: - try: - distances, durations = _build_route_table_http(demand_sites, candidate_depots, cost=cost, port=port) - except (requests.ConnectionError, requests.JSONDecodeError): - warnings.warn( - "Failed to connect to routing engine... using haversine distance" - " and (d/500)**.75 for durations" - ) - distances = metrics.pairwise_distances( - numpy.fliplr(numpy.deg2rad(demand_sites)), - numpy.fliplr(numpy.deg2rad(candidate_depots)), - metric="haversine" - ) * 6371000 - durations = numpy.ceil((distances / 10) ** .75) - else: - distances, durations = _build_route_table_pyosrm( - demand_sites, candidate_depots, database_path=database_path - ) - for D in (distances, durations): - if D is None: - continue - n_row, n_col = D.shape - assert n_row == len(candidate_depots) - assert n_col == len(demand_sites) - no_route_available = numpy.isnan(D) - D[no_route_available] = D[~no_route_available].sum() - if cost == 'distance': - return distances - elif cost == 'duration': - return durations - elif cost == 'both': - return distances, durations +def build_specific_route(waypoints, + return_durations=True, + routing=None, + **kwargs): + + ''' + Parameters + ---------- + + waypoints : list, required + A list of coordinate pairs between which to path a vehicular route. + The coordinates are also expected as a list. + e.g.: [[-6.2288162, 53.365756], [-6.2652379, 53.330686]]. + + return_durations : boolean, required + Set to ``True`` to return durations for each leg. + Default is ``True``. -def build_specific_route(waypoints, port=5000, http=not has_bindings, return_durations=True, database_path=_OSRM_DATABASE_FILE): - """ - Build a route over the road network from each waypoint to each other waypoint. If the routing engine is not found, this builds straight-line - routes, and measures their duration as a nonlinear function of the - haversine distance between input points. - """ - if isinstance(waypoints, (geopandas.GeoSeries, geopandas.GeoDataFrame)): - waypoints = waypoints.geometry.get_coordinates().values - if http: - try: - out = _build_specific_route_http(waypoints, port=port, return_durations=return_durations) - except (requests.ConnectionError, requests.JSONDecodeError): - warnings.warn( - "Failed to connect to routing engine... constructed routes" - " will be straight lines and may not follow the road network." - ) - route = shapely.LineString(waypoints) - prep_points = numpy.fliplr(numpy.deg2rad(waypoints)) - durations = [ - (metrics.pairwise.haversine_distances([prep_points[i]], [prep_points[i+1]]) - * 637000 / 10)**.75 - for i in range(len(prep_points)-1) - ] - out = (route, durations) if return_durations else route - else: - route = _build_specific_route_pyosrm(waypoints, database_path=database_path, return_durations=return_durations) - if return_durations: - route, durations = out - return route, durations - else: - route = out - return route + routing : dictionary, optional + Specifies which engine and associated parameters to utilize for the request. + Supported engines: + OSRM - Open Source Routing Machine -def _build_specific_route_http(waypoints, return_durations=True, port=5000): + Returns + ------- - # TODO: needs to be configurable by site - baseurl = f"http://127.0.0.1:{int(port)}/route/v1/driving/" + route_shape : geometry + A linestring reflecting the shortest path between the inputed waypoints. - point_string = ";".join( - map( - lambda x: "{},{}".format(*x), - waypoints, + leg_duration : numpy.array + An array of the durations on each leg of the route. + ''' + engine = routing if routing is not None else kwargs.get("routing", None) + + if isinstance(engine, OSRM): + directions = engine.directions( + locations=waypoints, + geometries='geojson', + annotations=True ) - ) + + elif isinstance(engine, Valhalla): + profile = kwargs.get("profile") + directions = engine.directions( + locations=waypoints, + geometries='geojson', + annotations=True, + profile=profile + ) + + elif engine is None: + route_shape = shapely.LineString(waypoints) + prep_points = numpy.fliplr(numpy.deg2rad(numpy.array(waypoints, dtype=float))) + leg_durations = numpy.array([ + (metrics.pairwise.haversine_distances([prep_points[i]], [prep_points[i + 1]]) * 637000 / 10) + for i in range(len(prep_points) - 1) + ]) - request_url = ( - baseurl - + point_string - + "?" - + "steps=true" - + "&" - + f"geometries=geojson" - + "&" - + "annotations=true" - ) - routes = requests.get(request_url).json()['routes'] - assert len(routes) == 1 - route = routes[0] - #sub_coordinates = numpy.empty(shape=(0,2)) - route_shape = shapely.geometry.shape(route['geometry']) - leg_durations = numpy.array([leg['duration'] for leg in route['legs']]) - """ - for leg_i, leg in enumerate(route['legs']): - durations[i] = leg['duration'] - for steps in leg['steps']: - assert steps['geometry']['type'] == "LineString" - sub_coordinates = numpy.row_stack((sub_coordinates, - numpy.asarray(steps['geometry']['coordinates'])[:-1] - )) - """ - #route_shape = shapely.LineString(sub_coordinates) - numpy.testing.assert_array_equal( - shapely.get_num_geometries(route_shape), - numpy.ones((len(waypoints),)) - ) + else: + raise ValueError(f"Unsupported routing engine type: {type(engine)}") + + if isinstance (engine, (OSRM, Valhalla)): + route_coords = directions.geometry # List of (lon, lat) + route_shape = shapely.LineString(route_coords) + legs = directions.raw['routes'][0]['legs'] + leg_durations = numpy.array([leg['duration'] for leg in legs]) + numpy.testing.assert_array_equal( + len(legs), + len(waypoints) - 1 + ) + if return_durations: return route_shape, leg_durations else: return route_shape -def _build_specific_route_pyosrm(waypoints, database_path=_OSRM_DATABASE_FILE, return_durations=False): - raise NotImplementedError() -def _build_route_table_http(demand_sites, candidate_depots, cost='distance', port=5000): - """ - Build a route table using the http interface to the OSRM engine - """ - request_url = _create_route_request(demand_sites, candidate_depots, cost=cost, port=port) - request = requests.get(request_url) - content = request.json() - if cost == 'distance': - D = numpy.asarray(content["distances"]).astype(float) - output = (D,None) - elif cost == 'duration': - D = numpy.asarray(content["durations"]).astype(float) - output = (None,D) - elif cost == 'both': - distances = numpy.asarray(content["distances"]).astype(float) - durations = numpy.asarray(content["durations"]).astype(float) - output = (distances, durations) - else: - raise ValueError(f"cost option '{cost}' not one of the supported options, ('distance', 'duration', 'both')") - return output +def build_route_table(demand_sites, + candidate_depots, + cost='distance', + **kwargs): + + ''' + parameters: + demand_sites = a list of coordinates pairs for clients. The coordinates are also expected + as a list, e.g.: [[-6.2288162, 53.365756], [-6.2652379, 53.330686]] + + candidate_depots = a list of coordinate pairs for depot(s).The coordinates are also expected + as a list, e.g.: [[-6.2288162, 53.365756] + + returns tuple (distance_matrix, duration_matrix) + + ''' + engine = kwargs.get("routing", None) + routing_kws = kwargs.get("routing_kws", {}) + + candidate_series = pandas.Series([tuple(coord) for coord in candidate_depots]) + demand_series = pandas.Series([tuple(coord) for coord in demand_sites]) + all_points = pandas.concat((candidate_series, demand_series)).reset_index(drop=True) + + if engine is None: + # no engine is provided + + warnings.warn( + "Failed to connect to routing engine... using haversine distance" + "durations = 0" + ) + + coords_array = numpy.array(all_points.to_list(), dtype=float) # needed for deg2rad + distances = metrics.pairwise_distances( + numpy.fliplr(numpy.deg2rad(coords_array)), + numpy.fliplr(numpy.deg2rad(coords_array)), + metric="haversine" + ) * 6371000 + durations = numpy.zeros_like(distances) # set all durations to match distances (0) -def _create_route_request(demand_sites, candidate_depots, cost='distance', port=5000): - point_string = ";".join( - map( - lambda x: "{},{}".format(*x), - numpy.row_stack((candidate_depots, demand_sites)), - ) - ) - n_demands = len(demand_sites) - n_supplys = len(candidate_depots) - source_string = "sources=" + ";".join(numpy.arange(n_supplys).astype(str)) - destination_string = "destinations=" + ";".join( - numpy.arange(n_supplys, n_demands + n_supplys).astype(str) - ) - # TODO: needs to be configurable by site - baseurl = f"http://127.0.0.1:{int(port)}/table/v1/driving/" - if cost=='distance': - annotation = "&annotations=distance" - elif cost=='duration': - annotation = "&annotations=duration" - elif cost=='both': - annotation = "&annotations=duration,distance" else: - annotation = "" + # engine is provided + + # Set annotation type + if cost == 'distance': + annotations = ['distance'] + elif cost == 'duration': + annotations = ['duration'] + elif cost == 'both': + annotations = ['distance', 'duration'] + else: + raise ValueError(f"Unsupported cost type '{cost}'") - request_url = ( - baseurl - + point_string - + "?" - + source_string - + "&" - + destination_string - + annotation - + "&exclude=ferry" - ) - return request_url + if isinstance(engine, OSRM): + result = engine.matrix( + locations=all_points, + annotations=annotations, + ) + + elif isinstance(engine, Valhalla): + profile = routing_kws.get("profile") + print(f'routing profile: {profile}') + result = engine.matrix( + locations=all_points, + annotations=annotations, + profile=profile + ) + # Parse outputs + distances = numpy.asarray(result.distances).astype(float) if 'distance' in annotations else None + durations = numpy.asarray(result.durations).astype(float) if 'duration' in annotations else None -def _build_route_table_pyosrm(demand_sites, candidate_depots, database_path=_OSRM_DATABASE_FILE): - """ - build a route table using py-osrm - https://github.com/gis-ops/py-osrm - """ - engine = osrm.OSRM( - storage_config=database_path, - use_shared_memory=False - ) - n_demands = len(demand_sites) - n_supplys = len(candidate_depots) - query_params = osrm.TableParameters( # noqa: F821 - coordinates=[ - (float(lon), float(lat)) - for (lon, lat) - in numpy.row_stack((demand_sites, candidate_depots)) - ], - sources=list(numpy.arange(n_demands)), - destinations=list(numpy.arange(n_demands, n_demands + n_supplys)), - annotations=["distance"], - ) - res = engine.Table(query_params) - return numpy.asarray(res["distances"]).astype(float).T + return (distances, durations) diff --git a/spopt/route/heuristic.py b/spopt/route/heuristic.py index 6be3e0b5..36c22b81 100644 --- a/spopt/route/heuristic.py +++ b/spopt/route/heuristic.py @@ -2,7 +2,7 @@ import numpy import pandas import shapely - +from sklearn import metrics import pyvrp from . import engine from . import utils @@ -54,7 +54,7 @@ def add_truck_type( time_windows=None, fixed_cost=None, cost_per_meter=None, - cost_per_minute=30 / 60, + cost_per_minute=None, # needs to change based on the router. silently ignore if none is passed max_duration=pandas.Timedelta(hours=8, minutes=00), max_distance=None, ): @@ -324,7 +324,8 @@ def add_clients( ) return self - def solve(self, stop=pyvrp.stop.NoImprovement(1e6), *args, **kwargs): + def solve(self, stop=pyvrp.stop.NoImprovement(1e6), routing=None, routing_kws={}, *args, **kwargs): + """ Solve a LastMile() instance according to the existing specification. @@ -334,6 +335,16 @@ def solve(self, stop=pyvrp.stop.NoImprovement(1e6), *args, **kwargs): A stopping rule that governs when the simulation will be ended. Set to terminate solving after one million iterations with no improvement. + routing : routingpy.routers + One of several routing API clients provided by the routingpy package. + + routing_kws : dict + Keyword arguments that are passed to the provided routingpy service. + Example usage is to pass the baseurl where the OSRM backend docker container is running e.g.: + `routing={"base_url": "http://localhost:5000"}`. + Other services require different keywords - see the routingpy documentation: + https://github.com/mthh/routingpy/tree/master + Returns ------- @@ -341,23 +352,44 @@ def solve(self, stop=pyvrp.stop.NoImprovement(1e6), *args, **kwargs): as the routes and stops found to routes_ and stops_, respectively Notes - ----- + other arguments and keyword arguments are passed directly to the pyvrp.Model.solve() method """ + self.routing_ = None + self.routing_kws_ = {} + if (not hasattr(self, "clients_")) | (not hasattr(self, "trucks_")): - raise SpecificationError( - "must assign both clients and trucks to" " solve a problem instance." - ) - all_lonlats = numpy.row_stack( - (self.depot_location, shapely.get_coordinates(self.clients_.geometry)) + raise SpecificationError("must assign both clients and trucks to" " solve a problem instance.") + + all_lonlats = numpy.vstack( + [self.depot_location] + list(shapely.get_coordinates(self.clients_.geometry)) ) - self._setup_graph(all_lonlats=all_lonlats) + + # if engine is provided + routing_kws = {} if routing_kws is None else dict(routing_kws) + if routing is not None: + base_url = routing_kws.pop('base_url', None) + self.routing_kws_ = routing_kws + self.routing_ = routing(base_url=base_url) if base_url else routing(**routing_kws) + print(f'routing engine is defined as: {self.routing_}') + self._setup_graph(all_lonlats=all_lonlats, routing=self.routing_, routing_kws=self.routing_kws_) + + # if no engine is provided + else: + self._setup_graph(all_lonlats=all_lonlats) + + self.result_ = self.model.solve(stop=stop, *args, **kwargs) self.routes_, self.stops_ = utils.routes_and_stops( - self.result_.best, self.model, self.clients_, self.depot_location, cost_unit=self.cost_unit + self.result_.best, + self.model, + self.clients_, + self.depot_location, + cost_unit=self.cost_unit, + routing=self.routing_, + **self.routing_kws_ ) - return self - + solve.__doc__ = pyvrp.Model.solve.__doc__ def explore(self): @@ -370,7 +402,9 @@ def explore(self): "route_name", categorical=True, tiles="CartoDB positron" ) stops_for_map = self.stops_.copy() - stops_for_map["eta"] = self.stops_.eta.astype(str) + for col in stops_for_map.columns: + if col != "geometry": + stops_for_map[col] = self.stops_[col].astype(str) stops_for_map.explore( "route_name", m=m, @@ -384,7 +418,7 @@ def explore(self): ).explore(m=m, color="black", marker_type="marker") return m - def _setup_graph(self, all_lonlats): + def _setup_graph(self, all_lonlats, *args, **kwargs): """ This sets up the graph pertaining to an inputted set of longitude and latitude coordinates. @@ -394,25 +428,55 @@ def _setup_graph(self, all_lonlats): the restricted and the base profiles, then update the model with an edge for each profile. """ - raw_distances, raw_durations = engine.build_route_table( - all_lonlats, all_lonlats, cost="both" - ) - # how many minutes does it take to get from place to place? - durations_by_block = numpy.ceil(raw_durations / 60) - ##### WARNING!!!!!!! THIS IS A BUG IN OSRM #5855 - durations = numpy.clip(durations_by_block, 0, durations_by_block.max()) - distances = numpy.clip(raw_distances, 0, raw_distances.max()).round(0) - - duration_df = pandas.DataFrame( - durations, - index=[self.depot_name] + self.clients_.index.tolist(), - columns=[self.depot_name] + self.clients_.index.tolist(), - ) - distance_df = pandas.DataFrame( - distances, - index=[self.depot_name] + self.clients_.index.tolist(), - columns=[self.depot_name] + self.clients_.index.tolist(), - ) + if hasattr(self, "routing_") and self.routing_: + + raw_distances, raw_durations = engine.build_route_table( + demand_sites=shapely.get_coordinates(self.clients_.geometry), + candidate_depots=[self.depot_location], + cost="both", + routing=self.routing_, + routing_kws=self.routing_kws_ + ) + + # how many minutes does it take to get from place to place? + durations_by_block = numpy.ceil(raw_durations / 60) + durations = numpy.clip(durations_by_block, 0, durations_by_block.max()) + distances = numpy.clip(raw_distances, 0, raw_distances.max()).round(0) + + duration_df = pandas.DataFrame( + durations, + index=[self.depot_name] + self.clients_.index.tolist(), + columns=[self.depot_name] + self.clients_.index.tolist(), + ) + distance_df = pandas.DataFrame( + distances, + index=[self.depot_name] + self.clients_.index.tolist(), + columns=[self.depot_name] + self.clients_.index.tolist(), + ) + + else: + raw_distances, raw_durations = engine.build_route_table( + demand_sites=shapely.get_coordinates(self.clients_.geometry), + candidate_depots=[self.depot_location], + cost="both", + ) + + # Do I need durations_by_block when using euclidean distances? + # durations_by_block = numpy.ceil(raw_durations / 60) + durations = numpy.clip(raw_durations, 0, raw_durations.max()) + distances = numpy.clip(raw_distances, 0, raw_distances.max()).round(0) + + duration_df = pandas.DataFrame( + durations, + index=[self.depot_name] + self.clients_.index.tolist(), + columns=[self.depot_name] + self.clients_.index.tolist(), + ) + distance_df = pandas.DataFrame( + distances, + index=[self.depot_name] + self.clients_.index.tolist(), + columns=[self.depot_name] + self.clients_.index.tolist(), + ) + for source_ix, source in enumerate(self.model.locations): for sink_ix, sink in enumerate(self.model.locations): self.model.add_edge( @@ -461,4 +525,4 @@ def writer(df, filename): filestem = self.depot_name.replace(" ", "_") writer(self.routes_, filestem+"_routes") writer(self.stops_, filestem+"_stops") - self.explore().save(filestem+"_map.html") \ No newline at end of file + self.explore().save(filestem+"_map.html") diff --git a/spopt/route/utils.py b/spopt/route/utils.py index 79ed3e3f..a8e0a419 100644 --- a/spopt/route/utils.py +++ b/spopt/route/utils.py @@ -1,6 +1,6 @@ +from spopt.route import engine import numpy import pandas -import routing import copy import geopandas import shapely @@ -117,7 +117,8 @@ def routes_and_stops( model, target_geoms, depot_location, - cost_unit=1e-4 + cost_unit=1e-4, + **kwargs ): """ Calculate route geometries and stop etas/waypoint numbers from an input @@ -250,14 +251,14 @@ def timedelta_from_visits( route_obj = route_lut[name] group = group.sort_values("stop_number") coordinates = shapely.get_coordinates(group.geometry) - shape, durations = routing.build_specific_route( + shape, durations = engine.build_specific_route( numpy.vstack( ( depot_location, coordinates, depot_location ) - ) + ), routing=kwargs.get("routing", None) ) route_truck_type = route_obj.vehicle_type() truck_obj = model.vehicle_types[route_truck_type] @@ -281,9 +282,9 @@ def timedelta_from_visits( ) * cost_unit, deptime, rettime, - round(route_obj.duration() / truck_obj.max_duration * 100, 2), - round(route_obj.delivery() / truck_obj.capacity * 100, 2), - round(route_obj.distance() / truck_obj.max_distance * 100, 2), + round(float(route_obj.duration()) / truck_obj.max_duration * 100, 2), + round(float(route_obj.delivery()[0]) / truck_obj.capacity[0] * 100, 2), + round(float(route_obj.distance()) / truck_obj.max_distance * 100, 2), shape )) @@ -511,4 +512,4 @@ def build_clients( group=cg_ ) clients.extend([c1, c2]) - return clients \ No newline at end of file + return clients From 6b8d38feb72c442d209567739b9e9550ee88de5f Mon Sep 17 00:00:00 2001 From: Dylan Date: Mon, 25 Aug 2025 14:31:56 -0700 Subject: [PATCH 2/2] move route.ipynb to notebooks directory --- notebooks/route.ipynb | 481 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 notebooks/route.ipynb diff --git a/notebooks/route.ipynb b/notebooks/route.ipynb new file mode 100644 index 00000000..8c08bb92 --- /dev/null +++ b/notebooks/route.ipynb @@ -0,0 +1,481 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f8ec5749-b4bd-4d44-82e3-3a96bda20d0c", + "metadata": {}, + "source": [ + "# Solving Vehicle Routing Problems (VRPs) using the `spopt.Route` module\n", + "*Authors:* [Dylan Skrah](https://github.com/fiendskrah), [Germano Barcelos](https://github.com/gegen07), [Levi J. Wolf](https://github.com/ljwolf)" + ] + }, + { + "cell_type": "markdown", + "id": "4740a7b3-3ae5-44eb-9c85-88231775e5f8", + "metadata": {}, + "source": [ + "This notebook will demonstrate how to set up and solve vehicle routing problems using the `spopt.Route` module. This module wraps around the [`routingpy`](https://github.com/mthh/routingpy) library for routing services and the [PyVRP](https://github.com/PyVRP/PyVRP) for specific solve functions. " + ] + }, + { + "cell_type": "markdown", + "id": "8b9bb5a3-e8eb-4f01-bea0-23aee80b6bc1", + "metadata": {}, + "source": [ + "## Setting up the OSRM backend service" + ] + }, + { + "cell_type": "markdown", + "id": "d97cd6d2-8904-4c4c-9740-a3308faa2653", + "metadata": {}, + "source": [ + "### Docker container\n", + "We suggest using the OSRM backend as a routing service. This module was developed with this backend considered as the default. Because `spopt.Route` invokes the routingpy library, other services supported by routingpy should function as expected, though minor changes to the codebase may be required. See the following table for which services have been confirmed functional." + ] + }, + { + "cell_type": "markdown", + "id": "7c83f485-b6e7-460c-958e-d03b9aaf7d13", + "metadata": {}, + "source": [ + "| Router | Required Keywords | API key? | Requires Backend? | confirmed functional | \n", + "|--------------|----------|----------| ---------------- | ------------------- |\n", + "| OSRM | base-url | no | yes | yes | \n", + "| Valhalla | base-url, profile | no | yes | no | \n", + "| HereMaps | | Yes | | no |\n", + "| Google | | Yes | | no |\n", + "| Graphhopper | | Yes | | no |\n", + "| Mapbox OSRM | | Yes | | no |\n", + "| OpenRouteService | | Yes | | no |\n", + "| OpenTripPlanner | | | | N/A (`matrix` not implemented) | " + ] + }, + { + "cell_type": "markdown", + "id": "a2eeb5ba-fea8-4b8c-85b7-e3a28ed00ea0", + "metadata": {}, + "source": [ + "OSRM exposes their backend for easy requests using a docker image. While this can be a little tedious to set up on your local machine, this allows the `route` module to quickly identify shortest routes between points of interest and solve VRP problems. Find the latest release of the docker image here: [osrm-backend github repository](https://github.com/Project-OSRM/osrm-backend)." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "195f8ea9-e5db-47d3-9962-55d24ca482ac", + "metadata": {}, + "source": [ + "### Preliminary data pre-processing\n", + "A required preliminary step is to obtain the 'raw' data for the area in which you are operating. This takes the form of `.pbf` files, which can be obtained from the [geofabrik](https://download.geofabrik.de/_) portal. In our case, we obtain the [Ireland and Northern Ireland](https://download.geofabrik.de/europe/ireland-and-northern-ireland.html) `.pbf` file. Once downloaded, the `.pbf` needs to be processed using a series of extraction, partitioning, and customization commands. This is easiest to do as a shell script. The version of this script for this example can be found in this [gist](https://gist.github.com/fiendskrah/f4d267ee7298ff9d0a9feb387b051b39). it looks like this:\n", + "\n", + "```bash\n", + "docker run -t -v \"${PWD}:/data\" ghcr.io/project-osrm/osrm-backend osrm-extract -p /opt/car.lua /data/ireland-and-northern-ireland-latest.osm.pbf || echo \"osrm build failed\"\n", + "docker run -t -v \"${PWD}:/data\" ghcr.io/project-osrm/osrm-backend osrm-partition /data/ireland-and-northern-ireland-latest.osrm || echo \"osrm-partition failed\"\n", + "docker run -t -v \"${PWD}:/data\" ghcr.io/project-osrm/osrm-backend osrm-customize /data/ireland-and-northern-ireland-latest.osrm || echo \"osrm-customize failed\"\n", + "echo \"osrm server can now be started\"\n", + "```\n", + "\n", + "This script will create several additional files from the `.pbf` that you downloaded, which are required for identifying shortest routes and solving the VRP using real street network data." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "9e81dc36-747e-4069-9047-856013406e55", + "metadata": {}, + "source": [ + "### Activate the backend service\n", + "After the pre-processing steps are completed, the OSRM backend docker container can be activated. This container should be 'spun up' as a service, meaning it holds a port on your machine available to hear requests and send those requests to the OSRM servers to obtain routes, distances, and durations. The `spopt.Route` module will then take those data and solve the defined VRP. Once you have the docker image in your directory, start the service by running the following command in your terminal: \n", + "\n", + "```bash\n", + "docker run -t -i -p 5000:5000 -v \"${PWD}:/data\" ghcr.io/project-osrm/osrm-backend osrm-routed --algorithm mld --max-table-size 9999999 /data/ireland-and-northern-ireland-latest.osrm\n", + "```\n", + "\n", + "Let's breakdown this command. This part:\n", + "\n", + "```bash\n", + "docker run -t -i -p 5000:5000 -v \"${PWD}:/data\" ghcr.io/project-osrm/osrm-backend osrm-routed\n", + "```\n", + "\n", + "says we're connecting to the docker service on the 5000 port. We're porting into the data directory of the backend and starting the osrm-routed service. \n", + "\n", + "This part:\n", + " \n", + "```bash\n", + "--algorithm mld --max-table-size 9999999 /data/ireland-and-northern-ireland-latest.osrm\n", + "```\n", + "\n", + "says that we're going to use the MLD (Multi-Level Dijkstra) algorithm to identify the most optimal routes in our problem. the `--max-table-size 99999999` argument indicates we are increasing the rate limitations for problem size, which is quite low by default. Finally, `data/ireland-and-northern-ireland-latest.osrm` is where our processed data file is, which tells OSRM where we're trying to operate. \n", + "\n", + "After running this final command, the service is activated and listening for requests in your terminal. " + ] + }, + { + "cell_type": "markdown", + "id": "49934f22-8914-4dcb-88a1-eba090c28180", + "metadata": {}, + "source": [ + "## Set up the Vehicle Routing Problem\n", + "Our example VRP is a delivery application where all the pubs in Dublin, Ireland need to be supplied their allotment of Guinness barrels." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6c23477b-bb12-44c3-97b8-14a80c4cafee", + "metadata": {}, + "outputs": [], + "source": [ + "import geopandas as gpd\n", + "import pandas, numpy, pyvrp, sys\n", + "\n", + "sys.path.insert(0, '/home/dylan/projects/gsoc2025/spopt/') # active development; may need to be edited for your local branch. delete after PR is merged.\n", + "\n", + "import spopt\n", + "print(spopt.__file__)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7692dbc6-9c14-4c7e-8129-be96b4dc2579", + "metadata": { + "scrolled": true + }, + "outputs": [], + "source": [ + "from spopt.route import engine, heuristic, utils" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "32fce62a-b642-4749-9c84-7846ce08140f", + "metadata": {}, + "outputs": [], + "source": [ + "from spopt.route.heuristic import LastMile\n", + "from pyvrp import stop" + ] + }, + { + "cell_type": "markdown", + "id": "fc7c65b9-4156-47d3-9316-6e3cb9072b70", + "metadata": {}, + "source": [ + "### Trucks\n", + "In the cell below, we define a DataFrame quantifying the available fleet of trucks. Each row represents a different truck type, identified by size (`namesize`) and fuel type (`namefuel`). The `capacity` column indicates how much the truck can carry. `fixed_cost` is the base cost of using the truck, regardless of how far it travels. `cost_per_meter` and `cost_per_minute` represent variable costs that depend on distance and travel time. The `n_truck` column tells us how many of each truck type are available. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c5c9c91a-531e-40c2-ab4a-69ac992f17d8", + "metadata": {}, + "outputs": [], + "source": [ + "trucks = pandas.DataFrame(\n", + " [['big', 'lng', 2000, 280, .004, .50, 5],\n", + " ['big', 'electric', 2000, 480, .002, .50, 5],\n", + " ['med', 'lng', 800, 280*.66, .0001, .63, 10],\n", + " ['med', 'electric', 800, 480*.66, .004, .50, 10],\n", + " ['smo', 'lng', 400, 280*0.4, .002, .50, 20],\n", + " ['smo', 'electric', 400, 480*0.4, .0001, .63, 20],\n", + " ],\n", + " columns = [\n", + " 'namesize', 'namefuel', 'capacity', \n", + " 'fixed_cost', 'cost_per_meter', 'cost_per_minute', 'n_truck'\n", + " ]\n", + ")" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "id": "229e2291-a5c3-4c9b-9c19-31fe5e00d213", + "metadata": {}, + "source": [ + "### Clients and depot(s)\n", + "Our example application uses one central depot, but additional depots can be specified. This file contains pubs all across Dublin, stored in a GeoJSON file and read into a GeoDataFrame using GeoPandas. Each row in this table represents a location, either a pub (client) or the Guinness Storehouse (the depot, at index 0), with associated geographic coordinates and attributes relevant to the routing problem." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c6c2dc1f-ba3f-4f4b-8b19-c487c9e6bb21", + "metadata": {}, + "outputs": [], + "source": [ + "gdf = gpd.read_file('/home/dylan/projects/gsoc2025/spopt/notebooks/gsoc2025/data/dublinpubs.geojson')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "95a0eda5-6c6f-478e-9f8f-aacb67b3ecf1", + "metadata": {}, + "outputs": [], + "source": [ + "gdf.shape" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "76f00660-473a-4ecd-a63d-dd53773635fd", + "metadata": {}, + "outputs": [], + "source": [ + "clients = gdf.iloc[1:,:].reset_index(drop=True)\n", + "clients = clients.set_index(clients.osmid.astype(str))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5174df2e-c3a1-44b1-aabd-a01adad6366c", + "metadata": {}, + "outputs": [], + "source": [ + "clients.head()" + ] + }, + { + "cell_type": "markdown", + "id": "910aab60-2078-4604-b937-752a75f82eb1", + "metadata": {}, + "source": [ + "Clients have associated `demand` and `supply` values, which represent how many kegs needs to be delivered to or picked up from that site. The `geometry` column stores the location as a geographic point. Importantly, the gdf is indexed using the `osmid`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "067ecdf3-a2b2-43b5-9392-d2df43b071fc", + "metadata": {}, + "outputs": [], + "source": [ + "depot = gdf.iloc[0,:]" + ] + }, + { + "cell_type": "markdown", + "id": "59b1f506-08af-44e4-96f5-7b699daae353", + "metadata": {}, + "source": [ + "We extract the first row as the depot, which serves as the start and end point for all vehicle routes. The remaining rows are identified as clients and indexed by their unique IDs from Openstreetmap." + ] + }, + { + "cell_type": "markdown", + "id": "763c8e80-de3f-4ba3-a674-5d330b8ee835", + "metadata": {}, + "source": [ + "### Initialize LastMile object\n", + "Now we have all the tools to set up and solve a Vehicle Routing Problem. First, we initalize the problem, setting the depot location and optionally setting the operating hours." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "57d98434-9ba6-40a3-a713-bc78c55aea4d", + "metadata": {}, + "outputs": [], + "source": [ + "print('initializing model')\n", + "m = LastMile(\n", + " depot_location=(depot.longitude.item(), depot.latitude.item()),\n", + " depot_open=pandas.Timestamp(\"2030-01-02 07:00:00\"),\n", + " depot_close=pandas.Timestamp(\"2030-01-02 20:00:00\"),\n", + " depot_name=depot['name'],\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "d80acdee-88be-4522-90c0-af72a7f42a03", + "metadata": {}, + "source": [ + "Then, we add the clients to be serviced in the routing problem:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b6c5ebd3-79f1-4d38-ad19-ffba7c819c77", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"adding clients\")\n", + "m.add_clients(\n", + " locations = clients.geometry, \n", + " delivery = clients.demand,\n", + " pickup = clients.supply,\n", + " time_windows=None,\n", + " service_times=(numpy.log(clients.demand)**2).astype(int)\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "1cf8cf26-8dec-456c-b138-0d5deca6a065", + "metadata": {}, + "source": [ + "Lastly, we add the available delivery vehicles to the model object." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "359f9638-e80b-4ffc-9f50-21f38ab4abe3", + "metadata": {}, + "outputs": [], + "source": [ + "print(\"adding trucks\")\n", + "m.add_trucks_from_frame(\n", + " trucks, \n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "e1465478-7384-4f37-a774-12f0128b15bb", + "metadata": {}, + "source": [ + "## Solve the VRP\n", + "\n", + "All that's left to do is specify the routing engine and initalize the solve. We need to import the associated module from the `routingpy` library:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1c36fbd9-8225-400c-ade5-85e0b4f27599", + "metadata": {}, + "outputs": [], + "source": [ + "from routingpy import OSRM" + ] + }, + { + "cell_type": "markdown", + "id": "7b074e89-a580-48b4-8451-4d3c958a8927", + "metadata": {}, + "source": [ + "Finally, call the solve method, specifying the imported module with the `routing` keyword, and pass any required keywords for the engine using hte `routing_kws` dictionary. For OSRM, all that's required is the base_url where the docker container is listening for requests (described above)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "55e5bb50-75b7-4d4b-bd41-80ed8d2141b5", + "metadata": {}, + "outputs": [], + "source": [ + "m.solve(stop=pyvrp.stop.MaxRuntime(60), routing=OSRM, routing_kws={\"base_url\": \"http://localhost:5000\"})" + ] + }, + { + "cell_type": "markdown", + "id": "5f8cb6ee-7fb8-46c1-9e84-96ee357cd527", + "metadata": {}, + "source": [ + "With the problem solved, we can now write outputs. The `write_result` method produces 3 files:\n", + "\n", + "- `routes.csv`: An overview of routes produced by the solution\n", + "- `stops.csv`: Detailed information about each stop along each route\n", + "- `map.html`: An html map displaying the solution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6d5a7b2b-46b3-4740-bdee-64a93741cf43", + "metadata": {}, + "outputs": [], + "source": [ + "m.write_result(\"osrm\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bf578f28-5e3e-4674-80d7-46b2a14ae6d0", + "metadata": {}, + "outputs": [], + "source": [ + "routes = gpd.read_file('osrm_routes.csv')\n", + "stops = gpd.read_file('osrm_stops.csv')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d2920f84-1b8e-4b65-b2c7-94d2d31a432a", + "metadata": {}, + "outputs": [], + "source": [ + "routes" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75e1c171-abd9-402c-9d03-0aeb18cbe42f", + "metadata": {}, + "outputs": [], + "source": [ + "stops" + ] + }, + { + "cell_type": "markdown", + "id": "95611ade-0e71-4945-accb-a4deb16e34dd", + "metadata": {}, + "source": [ + "Additionally, `spopt.Route` provides support for cases where no routing engine is passed to the solver. In this case, haversine distances are used in place of road data, and the resulting solutions should be interpreted cautiously. " + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9cc2bc3a-54a6-4b3f-8c40-5cc9640c6895", + "metadata": {}, + "outputs": [], + "source": [ + "m.solve(stop=pyvrp.stop.MaxRuntime(60))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5e98e559-6020-4d7e-80da-5f54b1fc64f4", + "metadata": {}, + "outputs": [], + "source": [ + "m.write_result(\"no-engine\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}