From e97825e9a5958977ad5374d8087efb3730164832 Mon Sep 17 00:00:00 2001 From: Ondrej Sykora Date: Wed, 25 Oct 2023 11:20:18 +0000 Subject: [PATCH] Updated the two_step_routing library and the Colab notebook. Changes include: - added a new two_step_routing.Planner option to include travelModel and travelDurationMultiple in the transitions in the merged solution. This option is off by default. - added code to compute route metrics in the merged plan. For now, this is used only for individual routes, the aggregated metrics for the whole plan are not updated (yet). - Added instructions how to run the Colab notebook with a local runtime (via Docker) to the notebook. - Added time travel stats to the notebook + cleaned up their computation. - Refactored parking location issue detection in the notebook (no functional changed), - More refactorings and new code in preparation for a third+fourth solve phases (refinement of the local and global model). For now, these changes are there in preparation, and they do not change the behavior of the solver. --- .../two_step_routing/cfr-json-analysis.ipynb | 214 +++-- examples/two_step_routing/cfr_json.py | 402 ++++++++- examples/two_step_routing/cfr_json_test.py | 707 ++++++++++++++++ examples/two_step_routing/two_step_routing.py | 792 ++++++++++++++---- .../two_step_routing/two_step_routing_main.py | 15 +- .../two_step_routing/two_step_routing_test.py | 702 ++++++++++++++++ 6 files changed, 2566 insertions(+), 266 deletions(-) diff --git a/examples/two_step_routing/cfr-json-analysis.ipynb b/examples/two_step_routing/cfr-json-analysis.ipynb index 4aa9ca34..cf718741 100644 --- a/examples/two_step_routing/cfr-json-analysis.ipynb +++ b/examples/two_step_routing/cfr-json-analysis.ipynb @@ -11,6 +11,9 @@ }, { "cell_type": "markdown", + "metadata": { + "id": "RrvDGML6NlWW" + }, "source": [ "## License\n", "\n", @@ -18,10 +21,16 @@ "\n", "Use of this source code is governed by an MIT-style license that can be found\n", "in the LICENSE file or at https://opensource.org/licenses/MIT." - ], + ] + }, + { + "cell_type": "markdown", "metadata": { - "id": "RrvDGML6NlWW" - } + "id": "qZIL_uwHMAsg" + }, + "source": [ + "## Using the notebook" + ] }, { "cell_type": "markdown", @@ -29,36 +38,48 @@ "id": "HvdsDlH_R6TH" }, "source": [ - "## Prerequisities\n", - "\n", - "* If you're not familiar with Colab notebooks, check out the\n", - " [Welcome to Colaboratory](https://colab.research.google.com/notebooks/intro.ipynb)\n", - " notebook first for a tutorial.\n", - "* We have tested the notebook with the public Colab runtime. It may also work\n", - " with a\n", - " [local runtime](https://research.google.com/colaboratory/local-runtimes.html),\n", - " but this has not been tested yet.\n", - "\n", - "## How to use the colab\n", + "### Prerequisities\n", + "\n", + "If you're not familiar with Colab notebooks, check out the\n", + "[Welcome to Colaboratory](https://colab.research.google.com/notebooks/intro.ipynb)\n", + "notebook first for a tutorial.\n", + "\n", + "To run the cells in the notebook, you need a runtime:\n", + "\n", + "* We regularly test the notebook with the public hosted runtime. This should\n", + " be sufficient for experiments and to analyze smaller scenarios.\n", + "* If the hosted runtime is too slow or you need to process large amount of\n", + " data,\n", + " [running a local runtime](https://research.google.com/colaboratory/local-runtimes.html)\n", + " might be a better option. To use a local runtime with this notebook, start\n", + " the local runtime with `sudo docker run -p 127.0.0.1:9000:8080 -e\n", + " COLAB_KERNEL_MANAGER_PROXY_PORT=9000\n", + " us-docker.pkg.dev/colab-images/public/runtime` and then follow the rest of\n", + " the instructions from the\n", + " [local runtime guide](https://research.google.com/colaboratory/local-runtimes.html).\n", + " As of 2023-10-23, using the `COLAB_KERNEL_MANAGER_PROXY_PORT` option is\n", + " needed to make file upload work correctly.\n", + "\n", + "### How to use the colab\n", "\n", "1. Once you're connected to a runtime, the first setp is to run the cells in\n", " the \"Imports, helper functions\" section to initialize the notebook. You can\n", - " re-run the cell \"Import everything, ...\" at any time to quickly remove all\n", - " loaded scenarios from the notebook.\n", + " re-run the cell \"Define helper functions, ...\" at any time to quickly remove\n", + " all loaded scenarios from the notebook.\n", "\n", "2. Once the notebook is initialized, you will be able to add CFR scenarios and\n", " solutions to the notebook to analyze them. The easiest way is to upload\n", " either ZIP files from the fleet routing app or the scenario/solution JSON\n", " file pairs through the form in the section\n", - " [Upload scenarios and solutions](#scrollTo=G6mXfeDxgA4M&line=1&uniqifier=1).\n", + " [Upload scenarios and solutions](#scrollTo=G6mXfeDxgA4M\u0026line=1\u0026uniqifier=1).\n", "\n", "3. Now you have data to analyze. Run any other cell in the notebook to walk\n", " through the data.\n", "\n", - "## Don't panic!\n", + "### Don't panic!\n", "\n", "If you have any questions or run into issues with using the colab, contact\n", - "ondrasej at google dot com." + "ondrasej at google dot com.\n" ] }, { @@ -70,30 +91,16 @@ "## Imports, helper functions (run these first)" ] }, - { - "cell_type": "code", - "source": [ - "# @title Pull the CFR libraries from GitHub (run this once)\n", - "\n", - "!git clone https://github.com/google/cfr" - ], - "metadata": { - "cellView": "form", - "id": "vG20dqFKAPpE" - }, - "execution_count": null, - "outputs": [] - }, { "cell_type": "code", "execution_count": null, "metadata": { "cellView": "form", - "id": "ukVuR1MgMp4y" + "id": "vG20dqFKAPpE" }, "outputs": [], "source": [ - "# @title Import everything, define helper functions, reset data structures\n", + "# @title Import everything (run this once)\n", "\n", "import collections\n", "from collections.abc import Callable, Mapping, Sequence, Set\n", @@ -115,8 +122,21 @@ "import ipywidgets\n", "import pandas as pd\n", "\n", + "!git clone https://github.com/google/cfr\n", "from cfr.examples.two_step_routing import cfr_json\n", - "from cfr.examples.two_step_routing import two_step_routing\n", + "from cfr.examples.two_step_routing import two_step_routing\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "cellView": "form", + "id": "ukVuR1MgMp4y" + }, + "outputs": [], + "source": [ + "# @title Define helper functions, reset data structures\n", "\n", "\n", "# Increase the default limit on the number of rows in a DataTable. The default\n", @@ -144,26 +164,27 @@ " the solution. Note that parking locations that are not visited by any\n", " vehicle do not appear in the solution and by consequence, do not appear in\n", " this set.\n", - " vehicles_by_parking: The set of vehicles that visit a particular parking\n", - " location. The key of the mapping is the parking location tag, the value is\n", - " the set of vehicle indices.\n", + " vehicles_by_parking: For each parking tag, contains a mapping from vehicle\n", + " indices to the list of indices of the visits made by this vehicle.\n", " consecutive_visits: The per-vehicle list of consecutive visits to a parking\n", " location. The key of the mapping is the vehicle index, values are lists of\n", - " parking location tags of parkings that are visited right after another\n", - " visit to the same parking location. Note that if a certain parking\n", - " location is visited more than twice in a row, it will appear in the list\n", - " multiple times.\n", + " visits to a parking location. Each element of this list is a pair\n", + " (parking_tag, visit_index) such that\n", + " `shipments_by_parking[parking_tag][visit_index]` is the visit that\n", + " generated the entry.\n", " non_consecutive_visits: The per-vehicle list of non-consecutive visits to a\n", " parking location. The format is the same as for consecutive_visits.\n", - " shipments_by_parking: The set of shipments that visit a parking location.\n", - " Note that this mapping contains only shipments that are performed in the\n", - " plan.\n", + " shipments_by_parking: The list of parking location visits, indexed by the\n", + " parking tag. The value is a list of lists of shipment indices. Each\n", + " element of the outer list corresponds to one visit to the parking location\n", + " and the elements of the inner list are the shipments delivered during this\n", + " visit.\n", " \"\"\"\n", "\n", " all_parking_tags: Set[str]\n", - " vehicles_by_parking: Mapping[str, Set[int]]\n", - " consecutive_visits: Mapping[int, Sequence[str]]\n", - " non_consecutive_visits: Mapping[int, Sequence[str]]\n", + " vehicles_by_parking: Mapping[str, Mapping[int, Sequence[int]]]\n", + " consecutive_visits: Mapping[int, Sequence[tuple[str, int]]]\n", + " non_consecutive_visits: Mapping[int, Sequence[tuple[str, int]]]\n", " shipments_by_parking: Mapping[str, Sequence[Sequence[int]]]\n", "\n", "\n", @@ -488,7 +509,9 @@ " functools.partial(collections.defaultdict, int)\n", " )\n", " # The set of vehicles that are used to serve the given parking.\n", - " vehicles_by_parking = collections.defaultdict(set)\n", + " vehicles_by_parking = collections.defaultdict(\n", + " functools.partial(collections.defaultdict, list)\n", + " )\n", "\n", " vehicle_consecutive_visits = collections.defaultdict(list)\n", " vehicle_non_consecutive_visits = collections.defaultdict(list)\n", @@ -522,19 +545,23 @@ " f\" parking {current_parking_tag!r}\"\n", " )\n", " current_parking_tag = arrival_tag\n", + " parking_visit_index = len(shipments_by_parking[arrival_tag])\n", + " parking_visit_tuple = (arrival_tag, parking_visit_index)\n", "\n", " parking_vehicles = vehicles_by_parking[arrival_tag]\n", - " if (\n", - " vehicle_index in parking_vehicles\n", - " and parking_tag_left_in_previous_visit != arrival_tag\n", - " ):\n", - " # This is a non-consecutive visit to this parking by this vehicle.\n", - " vehicle_non_consecutive_visits[vehicle_index].append(arrival_tag)\n", - " elif parking_tag_left_in_previous_visit == arrival_tag:\n", - " vehicle_consecutive_visits[vehicle_index].append(arrival_tag)\n", + " if parking_tag_left_in_previous_visit == arrival_tag:\n", + " # This is a consecutive visit to the parking location.\n", + " vehicle_consecutive_visits[vehicle_index].append(parking_visit_tuple)\n", + " elif vehicle_index in parking_vehicles:\n", + " # parking_tag_left_in_previous_visit != arrival_tag holds because of\n", + " # the previous if statement. This is a non-consecutive visit to this\n", + " # parking by this vehicle.\n", + " vehicle_non_consecutive_visits[vehicle_index].append(\n", + " parking_visit_tuple\n", + " )\n", "\n", " visits_by_vehicle[vehicle_label][arrival_tag] += 1\n", - " parking_vehicles.add(vehicle_index)\n", + " parking_vehicles[vehicle_index].append(parking_visit_index)\n", " shipments_by_parking[arrival_tag].append([])\n", "\n", " if (\n", @@ -561,7 +588,7 @@ " value: cfr_json.TimeString | None, default: str\n", ") -> str:\n", " \"\"\"Returns a formatted timestamp or `default`, if `value` is `None`.\"\"\"\n", - " # TODO(ondrasej): If the global span of the scenario is <= 24 hours, do not\n", + " # TODO(ondrasej): If the global span of the scenario is \u003c= 24 hours, do not\n", " # show the date. Also, normalize all timestamps to the same timezone and do\n", " # not show the timezone suffix.\n", " if value is not None:\n", @@ -598,13 +625,22 @@ " )" ] }, + { + "cell_type": "markdown", + "metadata": { + "id": "1UyAZd59TLc2" + }, + "source": [ + "## Upload scenarios and solutions" + ] + }, { "cell_type": "markdown", "metadata": { "id": "G6mXfeDxgA4M" }, "source": [ - "### Upload scenarios and solutions" + "### Upload via browser" ] }, { @@ -809,7 +845,7 @@ "\n", "def _apply_substitutions(pattern, substitutions):\n", " num_wildcards = pattern.count(\"*\")\n", - " if num_wildcards > 0:\n", + " if num_wildcards \u003e 0:\n", " if num_wildcards != len(substitutions):\n", " raise ValueError(\n", " \"The number of substitutions in the pattern does not match the number\"\n", @@ -988,11 +1024,19 @@ " max_working_hours = datetime.timedelta()\n", " actual_working_hours = datetime.timedelta()\n", "\n", + " num_time_travel = 0\n", + " num_hard_time_travel = 0\n", " for vehicle, route in zip(vehicles, routes, strict=True):\n", " max_working_hours += cfr_json.get_vehicle_max_working_hours(\n", " scenario.model, vehicle\n", " )\n", " actual_working_hours += cfr_json.get_vehicle_actual_working_hours(route)\n", + " num_time_travel += cfr_json.get_num_decreasing_visit_times(\n", + " scenario.model, route, consider_visit_duration=True\n", + " )\n", + " num_hard_time_travel += cfr_json.get_num_decreasing_visit_times(\n", + " scenario.model, route, consider_visit_duration=False\n", + " )\n", "\n", " return {\n", " \"# vehicles\": len(vehicles),\n", @@ -1001,6 +1045,8 @@ " \"actual working time %\": (\n", " f\"{100 * actual_working_hours / max_working_hours:.2f} %\"\n", " ),\n", + " \"# soft time travel\": str(num_time_travel),\n", + " \"# time travel\": str(num_hard_time_travel),\n", " }\n", "\n", "\n", @@ -1102,7 +1148,8 @@ " \"actual working time\": \"\",\n", " \"# consecutive visits\": \"\",\n", " \"# non-consecutive visits\": \"\",\n", - " \"num time travel\": \"\",\n", + " \"# time travel\": \"\",\n", + " \"# soft time travel\": \"\",\n", " }\n", " visits = route.get(\"visits\", ())\n", " if not visits:\n", @@ -1111,23 +1158,12 @@ " data.append(row)\n", " continue\n", "\n", - " last_visit_end = _DATETIME_MAX_UTC\n", - " num_time_travel = 0\n", - " for visit in visits:\n", - " shipment_index = visit.get(\"shipmentIndex\", 0)\n", - " shipment = shipments[shipment_index]\n", - " is_pickup = visit.get(\"isPickup\", False)\n", - " visit_request_index = visit.get(\"visitRequestIndex\", 0)\n", - " visit_type = \"pickups\" if is_pickup else \"deliveries\"\n", - " visit_request = shipment[visit_type][visit_request_index]\n", - " visit_duration = cfr_json.parse_duration_string(\n", - " visit_request.get(\"duration\", \"0s\")\n", - " )\n", - "\n", - " visit_time = cfr_json.parse_time_string(visit[\"startTime\"])\n", - " if visit_time < last_visit_end:\n", - " num_time_travel += 1\n", - " last_visit_end = visit_time + visit_duration\n", + " num_time_travel = cfr_json.get_num_decreasing_visit_times(\n", + " scenario.model, route, False\n", + " )\n", + " num_soft_time_travel = cfr_json.get_num_decreasing_visit_times(\n", + " scenario.model, route, True\n", + " )\n", "\n", " start_time = cfr_json.parse_time_string(route[\"vehicleStartTime\"])\n", " end_time = cfr_json.parse_time_string(route[\"vehicleEndTime\"])\n", @@ -1167,7 +1203,8 @@ " if non_consecutive_visits is not None\n", " else \"\"\n", " )\n", - " row[\"num time travel\"] = str(num_time_travel)\n", + " row[\"# time travel\"] = str(num_time_travel)\n", + " row[\"# soft time travel\"] = str(num_soft_time_travel)\n", " data.append(row)\n", " return data\n", "\n", @@ -1307,7 +1344,7 @@ "\n", " def add_breaks_before_timestamp(timestamp):\n", " nonlocal next_break_start\n", - " while next_break_start < timestamp:\n", + " while next_break_start \u003c timestamp:\n", " current_break = breaks.pop(0)\n", " data.append({\n", " \"shipment\": \"\",\n", @@ -1402,7 +1439,7 @@ " return [{\n", " \"# distinct visited parkings\": len(parking_data.all_parking_tags),\n", " \"# parkings served by multiple vehicles\": sum(\n", - " int(len(parking_vehicles) > 1)\n", + " int(len(parking_vehicles) \u003e 1)\n", " for parking_vehicles in parking_data.vehicles_by_parking.values()\n", " ),\n", " \"# ping-pongs\": sum(\n", @@ -1546,11 +1583,16 @@ ], "metadata": { "colab": { - "private_outputs": true, - "provenance": [], "collapsed_sections": [ "q7PVQWUsf3iF" - ] + ], + "last_runtime": { + "build_target": "", + "kind": "local" + }, + "name": "CFR JSON request/response analysis", + "private_outputs": true, + "provenance": [] }, "kernelspec": { "display_name": "Python 3", diff --git a/examples/two_step_routing/cfr_json.py b/examples/two_step_routing/cfr_json.py index 283314c2..8bcd92ec 100644 --- a/examples/two_step_routing/cfr_json.py +++ b/examples/two_step_routing/cfr_json.py @@ -175,23 +175,6 @@ class ShipmentModel(TypedDict, total=False): globalEndTime: TimeString -class OptimizeToursRequest(TypedDict, total=False): - """Represents the JSON CFR request.""" - - label: str - model: ShipmentModel - parent: str - timeout: DurationString - searchMode: int - allowLargeDeadlineDespiteInterruptionRisk: bool - internalParameters: str - - considerRoadTraffic: bool - - populatePolylines: bool - populateTransitionPolylines: bool - - class Visit(TypedDict, total=False): """Represents a single visit on a route in the JSON CFR results.""" @@ -200,6 +183,7 @@ class Visit(TypedDict, total=False): startTime: TimeString detour: str isPickup: bool + visitRequestIndex: int class EncodedPolyline(TypedDict, total=False): @@ -212,18 +196,34 @@ class Transition(TypedDict, total=False): """Represents a single transition on a route in the JSON CFR results.""" travelDuration: DurationString - travelDistanceMeters: int + travelDistanceMeters: float + delayDuration: DurationString waitDuration: DurationString + breakDuration: DurationString totalDuration: DurationString startTime: TimeString routePolyline: EncodedPolyline + # The following two fields are optionally added to the merged routes by the + # two_step_routing library, but they are not part of the official CFR API, and + # they may break conversion of the JSON data to a proto or cause input + # validation when used with tools that do not recognize them. + travelMode: int + travelDurationMultiple: float + class AggregatedMetrics(TypedDict, total=False): """Represents aggregated route metrics in the JSON CFR results.""" performedShipmentCount: int + performedMandatoryShipmentCount: int + travelDuration: DurationString + waitDuration: DurationString + delayDuration: DurationString + breakDuration: DurationString + visitDuration: DurationString totalDuration: DurationString + travelDistanceMeters: float class Break(TypedDict): @@ -260,6 +260,22 @@ class SkippedShipment(TypedDict, total=False): label: str +class OptimizeToursRequest(TypedDict, total=False): + """Represents the JSON CFR request.""" + + allowLargeDeadlineDespiteInterruptionRisk: bool + considerRoadTraffic: bool + injectedFirstSolutionRoutes: list[ShipmentRoute] + internalParameters: str + label: str + model: ShipmentModel + parent: str + populatePolylines: bool + populateTransitionPolylines: bool + searchMode: int + timeout: DurationString + + class OptimizeToursResponse(TypedDict, total=False): """Represents the JSON CFR result.""" @@ -374,6 +390,43 @@ def combined_load_demands(shipments: Collection[Shipment]) -> dict[str, Load]: ) +def get_shipments(model: ShipmentModel) -> Sequence[Shipment]: + """Returns the list of shipments of a model or an empty sequence.""" + return model.get("shipments", ()) + + +def get_routes(response: OptimizeToursResponse) -> Sequence[ShipmentRoute]: + """Returns the list of routes from a response or an empty sequence.""" + return response.get("routes", ()) + + +def get_visits(route: ShipmentRoute) -> Sequence[Visit]: + """Returns the list of visits on a route or an empty sequence.""" + return route.get("visits", ()) + + +def get_transitions(route: ShipmentRoute) -> Sequence[Transition]: + """Returns the list of transitions on a route or an empty sequence.""" + return route.get("transitions", ()) + + +def get_visit_request(model: ShipmentModel, visit: Visit) -> VisitRequest: + """Returns the visit request used in `visit`.""" + shipment_index = visit.get("shipmentIndex", 0) + shipment = model["shipments"][shipment_index] + visit_request_index = visit.get("visitRequestIndex", 0) + is_pickup = visit.get("isPickup", False) + visit_requests = shipment["pickups"] if is_pickup else shipment["deliveries"] + return visit_requests[visit_request_index] + + +def get_visit_request_duration( + visit_request: VisitRequest, +) -> datetime.timedelta: + """Returns the duration of a visit on a route.""" + return parse_duration_string(visit_request.get("duration")) + + def get_global_start_time(model: ShipmentModel) -> datetime.datetime: """Returns the global start time of `model`.""" global_start_time = model.get("globalStartTime") @@ -467,7 +520,7 @@ def get_shipment_earliest_pickup( for pickup in pickups: pickup_start = get_time_windows_start(model, pickup.get("timeWindows")) if include_duration: - pickup_start += parse_duration_string(pickup.get("duration", "0s")) + pickup_start += get_visit_request_duration(pickup) earliest_pickup_start = min(earliest_pickup_start, pickup_start) return earliest_pickup_start @@ -573,7 +626,7 @@ def get_vehicle_actual_working_hours( route: ShipmentRoute, ) -> datetime.timedelta: """Returns the duration of the given route, minus all the breaks.""" - visits = route.get("visits", ()) + visits = get_visits(route) if not visits: # Unused vehicle. return datetime.timedelta() @@ -590,6 +643,192 @@ def get_vehicle_actual_working_hours( return working_hours +def update_route_start_end_time_from_transitions( + route: ShipmentRoute, remove_delay_at_end: DurationString | None +) -> None: + """Updates start and end time of `route` based on times from transitions. + + Sets `vehicleStartTime` to the start of the first transition. Sets + `vehicleEndTime` to the end time of the last transition. + + When `remove_delay_at_end` is not None, removes this amount of time from the + delay duration of the last transition. This is reflected both in the vehicle + end time set by this function, but also the last transition of the route is + itself modified. + + Args: + route: The route to be modified. + remove_delay_at_end: An optional delay duration that is subtracted from the + last transition. It is an error if the last transition does not have at + least this amount of delay. + + Raises: + ValueError: When the route is empty or when `remove_delay_at_end` is greater + than the delay duration of the last transition of the route. + """ + transitions = route.get("transitions", ()) + if not transitions: + raise ValueError("The route is empty") + + route["vehicleStartTime"] = transitions[0]["startTime"] + + last_transition = transitions[-1] + last_transition_start_time = parse_time_string(last_transition["startTime"]) + last_transition_total_duration = parse_duration_string( + last_transition.get("totalDuration") + ) + if remove_delay_at_end is not None: + removed_delay = parse_duration_string(remove_delay_at_end) + last_transition_delay_duration = parse_duration_string( + last_transition.get("delayDuration") + ) + if last_transition_delay_duration < removed_delay: + raise ValueError( + "The delay duration of the last transition is smaller than" + " remove_delay_at_end." + ) + last_transition["delayDuration"] = as_duration_string( + last_transition_delay_duration - removed_delay + ) + last_transition_total_duration -= removed_delay + last_transition["totalDuration"] = as_duration_string( + last_transition_total_duration + ) + route["vehicleEndTime"] = as_time_string( + last_transition_start_time + last_transition_total_duration + ) + + +def recompute_route_metrics( + model: ShipmentModel, route: ShipmentRoute, check_consistency: bool = True +) -> None: + """Updates aggregate metrics of a route from its transitions and visits. + + Args: + model: The model, to which the route belongs. + route: The route to update. + check_consistency: When True, also checks the consistency of the computed + times and timestamps. + """ + visits = get_visits(route) + if not visits: + route.pop("metrics", None) + return + + shipments = get_shipments(model) + performed_shipments = set() + performed_mandatory_shipments = set() + + travel_distance_meters = 0 + route_travel_duration = datetime.timedelta(0) + route_delay_duration = datetime.timedelta(0) + route_wait_duration = datetime.timedelta(0) + route_break_duration = datetime.timedelta(0) + route_visit_duration = datetime.timedelta(0) + route_total_duration = datetime.timedelta(0) + + for visit in visits: + shipment_index = visit.get("shipmentIndex", 0) + performed_shipments.add(shipment_index) + shipment = shipments[shipment_index] + if shipment.get("penaltyCost") is None: + performed_mandatory_shipments.add(shipment_index) + + visit_duration = get_visit_request_duration(get_visit_request(model, visit)) + route_visit_duration += visit_duration + route_total_duration += visit_duration + + for transition in get_transitions(route): + travel_distance_meters += transition.get("travelDistanceMeters", 0) + route_travel_duration += parse_duration_string( + transition.get("travelDuration") + ) + route_delay_duration += parse_duration_string( + transition.get("delayDuration") + ) + route_break_duration += parse_duration_string( + transition.get("breakDuration") + ) + route_wait_duration += parse_duration_string(transition.get("waitDuration")) + route_total_duration += parse_duration_string( + transition.get("totalDuration") + ) + + if check_consistency: + if ( + route_total_duration + != route_travel_duration + + route_delay_duration + + route_wait_duration + + route_break_duration + + route_visit_duration + ): + raise ValueError( + "The durations in the transitions and visits are inconsistent." + ) + # Check that the total time corresponds to the difference between vehicle + # start and end times. + start_time = parse_time_string(route["vehicleStartTime"]) + end_time = parse_time_string(route["vehicleEndTime"]) + if route_total_duration != end_time - start_time: + raise ValueError( + "The total duration is inconsistent with vehicle start and end times" + ) + + route["metrics"] = { + "performedShipmentCount": len(performed_shipments), + "performedMandatoryShipmentCount": len(performed_mandatory_shipments), + "travelDuration": as_duration_string(route_travel_duration), + "travelDistanceMeters": travel_distance_meters, + "waitDuration": as_duration_string(route_wait_duration), + "delayDuration": as_duration_string(route_delay_duration), + "breakDuration": as_duration_string(route_break_duration), + "visitDuration": as_duration_string(route_visit_duration), + "totalDuration": as_duration_string(route_total_duration), + } + + +def get_num_decreasing_visit_times( + model: ShipmentModel, + route: ShipmentRoute, + consider_visit_duration: bool, +) -> int: + """Computes the number of occurrences of decreasing visit time on a route. + + A decreasing visit time happens when the start time of visit N is smaller than + the start time of visit N - 1 + the duration of visit N - 1. This is typically + a consequence of injecting live traffic information into a solution, where a + previously feasible solution becomes infeasible due to increased travel times. + + Args: + model: The model in which the computation is done. + route: The route for which the computation is done. + consider_visit_duration: When True, the start of visit N is compared with + the start of visit N - 1 + its duration; when False, only the starts of + both visits are compared. + + Returns: + The number of occurrences of decreasing visit time on the route. + """ + visits = get_visits(route) + if not visits: + return 0 + last_visit_time = parse_time_string(route["vehicleStartTime"]) + num_decreasing_visit_times = 0 + for visit in visits: + visit_time = parse_time_string(visit["startTime"]) + if visit_time < last_visit_time: + num_decreasing_visit_times += 1 + last_visit_time = visit_time + if consider_visit_duration: + last_visit_time += get_visit_request_duration( + get_visit_request(model, visit) + ) + if parse_time_string(route["vehicleEndTime"]) < last_visit_time: + num_decreasing_visit_times += 1 + return num_decreasing_visit_times + + def update_time_string( time_string: TimeString, delta: datetime.timedelta ) -> TimeString: @@ -627,18 +866,23 @@ def as_time_string(timestamp: datetime.datetime) -> TimeString: return date_string -def parse_duration_string(duration: DurationString) -> datetime.timedelta: +def parse_duration_string( + duration: DurationString | None, +) -> datetime.timedelta: """Parses the duration string and converts it to a timedelta. Args: - duration: The duration in the string format "{number_of_seconds}s". + duration: The duration in the string format "{number_of_seconds}s" or None. Returns: - The duration as a timedelta object. + The duration as a timedelta object. Returns a zero timedelta if `duration` + is None. Raises: ValueError: When the duration string does not have the right format. """ + if duration is None: + return datetime.timedelta(0) if not duration.endswith("s"): raise ValueError(f"Unexpected duration string format: '{duration}'") seconds = float(duration[:-1]) @@ -748,6 +992,72 @@ def decode_polyline(encoded_polyline: str) -> Sequence[LatLng]: return lat_lngs +def _get_route_polyline_points( + transition: Transition, +) -> Sequence[LatLng] | None: + route_polyline = transition.get("routePolyline") + if route_polyline is None: + return None + polyline_points = route_polyline.get("points") + if polyline_points is None: + return None + return decode_polyline(polyline_points) + + +def merge_polylines_from_transitions( + transitions: Sequence[Transition], +) -> EncodedPolyline | None: + """Returns an encoded polyline that merges polylines from `transitions`. + + The merged polyline is a polyline that contains points from all the polylines + of `transitions`, in the order in which they appear in `transitions`. Removes + duplicate points from the merged polylines (i.e. it is safe that the end of + the polyline of transitions[i] has the same coordinates as the start of the + polyline of transitions[i+1]). + + Requires that either all transitions with non-zero traveled distance have a + polyline (and in this case returns a merged polyline) or that none has it (and + returns None). + + Args: + transitions: The sequence of transitions to merge polylines from. + + Returns: + When all transitions have a polyline, returns an encoded merged polyline for + all the transitions. When neither of them has it, returns None. + + Raises: + ValueError: When some but not all transitions with non-zero traveled + distance have a polyline. + """ + merged_points: list[LatLng] = [] + num_present_polylines = 0 + num_absent_polylines = 0 + for transition in transitions: + route_points = _get_route_polyline_points(transition) + transition_distance = transition.get("travelDistanceMeters", 0) + if route_points is None and transition_distance == 0: + # When the next visit is at the same location, there is no polyline even + # if all other transitions have one. Just move on to the next transition. + continue + if route_points is None: + num_absent_polylines += 1 + continue + assert route_points is not None + num_present_polylines += 1 + for lat_lng in route_points: + if not merged_points or merged_points[-1] != lat_lng: + merged_points.append(lat_lng) + if num_present_polylines > 0 and num_absent_polylines > 0: + raise ValueError( + "Either all transitions with non-zero traveled distance must have a" + " polyline or none may have it." + ) + if not merged_points: + return None + return {"points": encode_polyline(merged_points)} + + def make_optional_time_window( start_time: TimeString | None, end_time: TimeString | None ) -> TimeWindow | None: @@ -1004,7 +1314,7 @@ def make_vehicle( def get_all_visit_tags(model: ShipmentModel) -> Set[str]: """Returns the set of all visit tags that appear in the model.""" tags = set() - for shipment in model.get("shipments", ()): + for shipment in get_shipments(model): pickups = shipment.get("pickups", ()) deliveries = shipment.get("deliveries", ()) for visit in itertools.chain(pickups, deliveries): @@ -1055,12 +1365,52 @@ def make_all_shipments_optional( if get_num_items is None: get_num_items = lambda _: 1 - for shipment in model.get("shipments", ()): + for shipment in get_shipments(model): if "penaltyCost" not in shipment: num_items = get_num_items(shipment) shipment["penaltyCost"] = num_items * cost +def relax_allowed_vehicle_indices(shipment: Shipment, cost: float) -> None: + """Relaxes the hard vehicle-shipment constraints in the model. + + When `cost > 0`, replaces the hard constraints with equivalent soft + constraints where the cost of violating the vehicle-shipment constraint is + `cost`. When `cost == 0`, just removes `allowedVehicleIndices` from the model. + + Args: + shipment: The shipment in which the allowed vehicle indices are relaxed. + cost: The cost of violating a vehicle-shipment constraint. + + Raises: + ValueError: When `cost < 0`. + """ + if cost < 0: + raise ValueError("cost must be non-negative.") + allowed_vehicles = shipment.get("allowedVehicleIndices") + shipment.pop("allowedVehicleIndices", None) + if allowed_vehicles is None or cost == 0: + return + costs_per_vehicle = shipment.get("costsPerVehicle", ()) + costs_per_vehicle_indices = shipment.get("costsPerVehicleIndices", ()) + all_vehicles_have_cost = costs_per_vehicle and not costs_per_vehicle_indices + if all_vehicles_have_cost: + costs_per_vehicle_indices = range(len(costs_per_vehicle)) + costs_per_vehicle_map = collections.defaultdict( + float, zip(costs_per_vehicle_indices, costs_per_vehicle) + ) + for vehicle in allowed_vehicles: + costs_per_vehicle_map[vehicle] += cost + # NOTE(ondrasej): The following relies on Python's deterministic iteration + # order in dicts, where both keys() and values() iterate return the items from + # the dict in insertion order. + if all_vehicles_have_cost: + shipment["costsPerVehicle"] = list(costs_per_vehicle_map.values()) + else: + shipment["costsPerVehicleIndices"] = list(costs_per_vehicle_map.keys()) + shipment["costsPerVehicle"] = list(costs_per_vehicle_map.values()) + + def remove_load_limits(model: ShipmentModel) -> None: """Removes load limits from all vehicles in the model.""" vehicles = model.get("vehicles", ()) @@ -1091,7 +1441,7 @@ def remove_pickups(model: ShipmentModel) -> None: """ global_start = get_global_start_time(model) - for shipment_index, shipment in enumerate(model.get("shipments", ())): + for shipment_index, shipment in enumerate(get_shipments(model)): pickups = shipment.get("pickups") deliveries = shipment.get("deliveries") if not pickups or not deliveries: diff --git a/examples/two_step_routing/cfr_json_test.py b/examples/two_step_routing/cfr_json_test.py index 727914ec..bc367144 100644 --- a/examples/two_step_routing/cfr_json_test.py +++ b/examples/two_step_routing/cfr_json_test.py @@ -5,6 +5,7 @@ import copy import datetime +from typing import Sequence import unittest from . import cfr_json @@ -397,6 +398,138 @@ def test_some_shipments(self): ) +class GetShipmentsTest(unittest.TestCase): + """Tests for get_shipments.""" + + def test_no_shipments(self): + self.assertSequenceEqual(cfr_json.get_shipments({}), ()) + + def test_empty_shipments(self): + self.assertSequenceEqual(cfr_json.get_shipments({"shipments": []}), ()) + + def test_some_shipments(self): + shipments = ({"label": "S001"}, {"label": "S002"}, {"label": "S003"}) + model: cfr_json.ShipmentModel = { + "shipments": list(shipments), + } + self.assertSequenceEqual(cfr_json.get_shipments(model), shipments) + + +class GetRoutesTest(unittest.TestCase): + """Tests for get_routes.""" + + def test_no_routes(self): + self.assertSequenceEqual(cfr_json.get_routes({}), ()) + + def test_empty_routes(self): + self.assertSequenceEqual(cfr_json.get_routes({"routes": []}), ()) + + def test_some_routes(self): + routes = ({"vehicleIndex": 0}, {"vehicleIndex": 1}) + self.assertSequenceEqual( + cfr_json.get_routes({ + "routes": list(routes), + }), + routes, + ) + + +class GetVisitsTest(unittest.TestCase): + """Tests for get_visits.""" + + def test_no_route(self): + self.assertSequenceEqual(cfr_json.get_visits({}), ()) + + def test_empty_visits(self): + self.assertSequenceEqual(cfr_json.get_visits({"visits": []}), ()) + + def test_some_visits(self): + visits = ({"shipmentIndex": 0}, {"shipmentIndex": 1}) + self.assertSequenceEqual( + cfr_json.get_visits({"visits": list(visits)}), visits + ) + + +class GetTransitionsTest(unittest.TestCase): + """Tests for get_transitions.""" + + def test_no_route(self): + self.assertSequenceEqual(cfr_json.get_transitions({}), ()) + + def test_empty_transitions(self): + self.assertSequenceEqual(cfr_json.get_transitions({"transitions": []}), ()) + + def test_some_transitions(self): + transitions = ( + {"startTime": "2023-10-20T12:00:00Z"}, + {"startTime": "2023-10-20T14:00:01Z"}, + ) + self.assertSequenceEqual( + cfr_json.get_transitions({"transitions": list(transitions)}), + transitions, + ) + + +class GetVisitRequestTest(unittest.TestCase): + """Tests for get_visit_request.""" + + def test_default_everything(self): + visit_request: cfr_json.VisitRequest = {} + model: cfr_json.ShipmentModel = { + "shipments": [{"deliveries": [visit_request]}] + } + visit: cfr_json.Visit = {} + self.assertIs(cfr_json.get_visit_request(model, visit), visit_request) + + def test_multiple_pickups(self): + model: cfr_json.ShipmentModel = { + "shipments": [{"pickups": [{"duration": "30s"}, {"duration": "123s"}]}] + } + visit: cfr_json.Visit = { + "shipmentIndex": 0, + "isPickup": True, + "visitRequestIndex": 1, + } + self.assertEqual( + cfr_json.get_visit_request(model, visit), + {"duration": "123s"}, + ) + + def test_multiple_deliveries(self): + model: cfr_json.ShipmentModel = { + "shipments": [ + {"label": "S001"}, + { + "label": "S002", + "deliveries": [{"duration": "10s"}, {"duration": "30s"}], + }, + ] + } + visit: cfr_json.Visit = { + "shipmentIndex": 1, + "isPickup": False, + } + self.assertEqual( + cfr_json.get_visit_request(model, visit), + {"duration": "10s"}, + ) + + +class GetVisitRequestDurationTest(unittest.TestCase): + """Tests for get_visit_request_duration.""" + + def test_no_duration(self): + self.assertEqual( + cfr_json.get_visit_request_duration({}), datetime.timedelta(0) + ) + + def test_some_duration(self): + self.assertEqual( + cfr_json.get_visit_request_duration({"duration": "123s"}), + datetime.timedelta(seconds=123), + ) + + class GetGlobalStartTimeTest(unittest.TestCase): """Tests for get_global_start_time.""" @@ -899,6 +1032,182 @@ def test_route_with_breaks(self): ) +class GetNumDecreasingVisitTimesTest(unittest.TestCase): + """Tests for get_num_decreasing_visit_times.""" + + _MODEL: cfr_json.ShipmentModel = { + "shipments": [ + {"deliveries": [{"duration": "120s"}]}, + { + "deliveries": [{"duration": "600s"}, {"duration": "30s"}], + "pickups": [{"duration": "150s"}, {}], + }, + {"pickups": [{"duration": "30s"}]}, + ] + } + + def test_empty_route(self): + self.assertEqual(cfr_json.get_num_decreasing_visit_times({}, {}, False), 0) + self.assertEqual(cfr_json.get_num_decreasing_visit_times({}, {}, True), 0) + + def test_only_start_and_end_non_decreasing(self): + # NOTE(ondrasej): The case when the end time is before the start time can't + # happen in a valid CFR response. + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), + 0, + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), + 0, + ) + + def test_non_decreasing_visit_times(self): + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + "visits": [ + {"shipmentIndex": 0, "startTime": "2023-10-10T11:02:00Z"}, + { + "shipmentIndex": 2, + "isPickup": True, + "startTime": "2023-10-10T11:04:00Z", + }, + { + "shipmentIndex": 1, + "isPickup": True, + "visitRequestIndex": 1, + "startTime": "2023-10-10T11:06:00Z", + }, + { + "shipmentIndex": 1, + "isPickup": False, + "visitRequestIndex": 1, + "startTime": "2023-10-10T11:07:00Z", + }, + ], + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), 0 + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), 0 + ) + + def test_decreasing_relative_to_vehicle_start(self): + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + "visits": [ + {"shipmentIndex": 0, "startTime": "2023-10-10T10:59:00Z"}, + ], + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), 1 + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), 1 + ) + + def test_decreasing_relative_to_vehicle_end(self): + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + "visits": [ + {"shipmentIndex": 0, "startTime": "2023-10-10T12:15:00Z"}, + ], + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), 1 + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), 1 + ) + + def test_decreasing_relative_to_vehicle_end_only_with_duration(self): + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + "visits": [ + {"shipmentIndex": 0, "startTime": "2023-10-10T11:59:00Z"}, + ], + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), 1 + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), 0 + ) + + def test_decreasing_only_with_duration(self): + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + "visits": [ + {"shipmentIndex": 0, "startTime": "2023-10-10T11:02:00Z"}, + { + "shipmentIndex": 2, + "isPickup": True, + "startTime": "2023-10-10T11:04:00Z", + }, + { + "shipmentIndex": 1, + "isPickup": True, + "visitRequestIndex": 0, + "startTime": "2023-10-10T11:06:00Z", + }, + { + "shipmentIndex": 1, + "isPickup": False, + "visitRequestIndex": 1, + "startTime": "2023-10-10T11:07:00Z", + }, + ], + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), 1 + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), 0 + ) + + def test_decreasing_multiple_times(self): + route: cfr_json.ShipmentRoute = { + "vehicleStartTime": "2023-10-10T11:00:00Z", + "vehicleEndTime": "2023-10-10T12:00:00Z", + "visits": [ + {"shipmentIndex": 0, "startTime": "2023-10-10T10:59:59Z"}, + { + "shipmentIndex": 2, + "isPickup": True, + "startTime": "2023-10-10T11:00:10Z", + }, + { + "shipmentIndex": 1, + "isPickup": True, + "visitRequestIndex": 0, + "startTime": "2023-10-10T11:00:00Z", + }, + { + "shipmentIndex": 1, + "isPickup": False, + "visitRequestIndex": 1, + "startTime": "2023-10-10T11:07:00Z", + }, + ], + } + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, True), 3 + ) + self.assertEqual( + cfr_json.get_num_decreasing_visit_times(self._MODEL, route, False), 2 + ) + + class ParseTimeStringTest(unittest.TestCase): """Tests for parse_time_string.""" @@ -971,6 +1280,236 @@ def test_with_timezone(self): ) +class UpdateRouteStartEndTimeFromTransitionsTest(unittest.TestCase): + """Tests for update_route_start_end_time_from_transitions.""" + + maxDiff = None + + def test_empty_route(self): + with self.assertRaisesRegex(ValueError, "The route is empty"): + cfr_json.update_route_start_end_time_from_transitions({}, None) + with self.assertRaisesRegex(ValueError, "The route is empty"): + cfr_json.update_route_start_end_time_from_transitions({}, "30s") + + def test_no_removed_delay(self): + input_route: cfr_json.ShipmentRoute = { + "transitions": [ + {"startTime": "2023-10-17T13:00:00Z", "totalDuration": "120s"}, + {"startTime": "2023-10-17T13:02:00Z", "totalDuration": "30s"}, + {"startTime": "2023-10-17T13:02:30Z", "totalDuration": "180s"}, + ] + } + route = copy.deepcopy(input_route) + cfr_json.update_route_start_end_time_from_transitions(route, None) + self.assertEqual( + route, + { + "transitions": [ + {"startTime": "2023-10-17T13:00:00Z", "totalDuration": "120s"}, + {"startTime": "2023-10-17T13:02:00Z", "totalDuration": "30s"}, + {"startTime": "2023-10-17T13:02:30Z", "totalDuration": "180s"}, + ], + "vehicleStartTime": "2023-10-17T13:00:00Z", + "vehicleEndTime": "2023-10-17T13:05:30Z", + }, + ) + + def test_with_removed_delay(self): + input_route: cfr_json.ShipmentRoute = { + "transitions": [ + {"startTime": "2023-10-17T13:00:00Z", "totalDuration": "120s"}, + {"startTime": "2023-10-17T13:02:00Z", "totalDuration": "30s"}, + { + "startTime": "2023-10-17T13:02:30Z", + "totalDuration": "180s", + "delayDuration": "60s", + }, + ] + } + route = copy.deepcopy(input_route) + cfr_json.update_route_start_end_time_from_transitions(route, "30s") + self.assertEqual( + route, + { + "transitions": [ + {"startTime": "2023-10-17T13:00:00Z", "totalDuration": "120s"}, + {"startTime": "2023-10-17T13:02:00Z", "totalDuration": "30s"}, + { + "startTime": "2023-10-17T13:02:30Z", + "totalDuration": "150s", + "delayDuration": "30s", + }, + ], + "vehicleStartTime": "2023-10-17T13:00:00Z", + "vehicleEndTime": "2023-10-17T13:05:00Z", + }, + ) + + def test_not_enough_delay_to_remove(self): + route: cfr_json.ShipmentRoute = { + "transitions": [ + {"startTime": "2023-10-17T13:00:00Z", "totalDuration": "120s"}, + {"startTime": "2023-10-17T13:02:00Z", "totalDuration": "30s"}, + {"startTime": "2023-10-17T13:02:30Z", "totalDuration": "180s"}, + ] + } + with self.assertRaisesRegex( + ValueError, "delay duration of the last transition" + ): + cfr_json.update_route_start_end_time_from_transitions(route, "30s") + + +class RecomputeRouteMetricsTest(unittest.TestCase): + """Tests for recompute_route_metrics.""" + + maxDiff = None + + _MODEL = { + "shipments": [ + { + "deliveries": [{"duration": "15s"}], + "label": "S001", + }, + { + "deliveries": [ + {"duration": "45s"}, + {"duration": "5s"}, + ], + "penaltyCost": 100, + "label": "S002", + }, + { + "pickups": [{"duration": "300s"}], + "deliveries": [{"duration": "120s"}], + "label": "S003", + }, + { + "pickups": [{}], + "label": "S004", + }, + ], + "vehicles": [{ + "label": "V001", + }], + "globalStartTime": "2023-10-19T22:00:00.000Z", + "globalEndTime": "2023-10-20T22:00:00.000Z", + } + _ROUTE: cfr_json.ShipmentRoute = { + "vehicleIndex": 0, + "vehicleLabel": "V001", + "vehicleStartTime": "2023-10-19T22:00:00.000Z", + "vehicleEndTime": "2023-10-19T22:21:23.000Z", + "visits": [ + { + "shipmentIndex": 1, + "isPickup": False, + "visitRequestIndex": 1, + "startTime": "2023-10-19T22:01:58.000Z", + "detour": "0s", + "shipmentLabel": "S002", + }, + { + "shipmentIndex": 0, + "isPickup": False, + "visitRequestIndex": 0, + "startTime": "2023-10-19T22:02:19.000Z", + "detour": "6s", + "shipmentLabel": "S001", + }, + { + "shipmentIndex": 2, + "isPickup": True, + "visitRequestIndex": 0, + "startTime": "2023-10-19T22:05:02.000Z", + "detour": "21s", + "shipmentLabel": "S003", + }, + { + "shipmentIndex": 3, + "isPickup": True, + "visitRequestIndex": 0, + "startTime": "2023-10-19T22:12:37.000Z", + "detour": "512s", + "shipmentLabel": "S004", + }, + { + "shipmentIndex": 2, + "isPickup": False, + "visitRequestIndex": 0, + "startTime": "2023-10-19T22:17:47.000Z", + "detour": "0s", + "shipmentLabel": "S003", + }, + ], + "transitions": [ + { + "travelDuration": "118s", + "travelDistanceMeters": 360, + "waitDuration": "0s", + "totalDuration": "118s", + "startTime": "2023-10-19T22:00:00.000Z", + }, + { + "travelDuration": "16s", + "travelDistanceMeters": 51, + "waitDuration": "0s", + "totalDuration": "16s", + "startTime": "2023-10-19T22:02:03.000Z", + }, + { + "travelDuration": "148s", + "travelDistanceMeters": 557, + "waitDuration": "0s", + "totalDuration": "148s", + "startTime": "2023-10-19T22:02:34.000Z", + }, + { + "travelDuration": "155s", + "travelDistanceMeters": 635, + "waitDuration": "0s", + "totalDuration": "155s", + "startTime": "2023-10-19T22:10:02.000Z", + }, + { + "travelDuration": "310s", + "travelDistanceMeters": 1079, + "waitDuration": "0s", + "totalDuration": "310s", + "startTime": "2023-10-19T22:12:37.000Z", + }, + { + "travelDuration": "96s", + "travelDistanceMeters": 353, + "waitDuration": "0s", + "totalDuration": "96s", + "startTime": "2023-10-19T22:19:47.000Z", + }, + ], + "routeTotalCost": 24.418333333333333, + } + _EXPECTED_METRICS: cfr_json.AggregatedMetrics = { + "performedShipmentCount": 4, + "travelDuration": "843s", + "waitDuration": "0s", + "delayDuration": "0s", + "breakDuration": "0s", + "visitDuration": "440s", + "totalDuration": "1283s", + "travelDistanceMeters": 3035, + "performedMandatoryShipmentCount": 3, + } + + def test_empty_route(self): + route = {} + cfr_json.recompute_route_metrics(self._MODEL, route) + self.assertEqual(route, {}) + + def test_non_empty_route(self): + route = copy.deepcopy(self._ROUTE) + cfr_json.recompute_route_metrics(self._MODEL, route) + self.assertEqual(route["metrics"], self._EXPECTED_METRICS) + + class UpdateTimeStringTest(unittest.TestCase): """Tests of update_time_string.""" @@ -1007,6 +1546,9 @@ def test_invalid_amount(self): cfr_json.parse_duration_string("ABCs") def test_valid_parse(self): + self.assertEqual( + cfr_json.parse_duration_string(None), datetime.timedelta(seconds=0) + ) self.assertEqual( cfr_json.parse_duration_string("0s"), datetime.timedelta(seconds=0), @@ -1101,6 +1643,101 @@ def test_incomplete_varint(self): cfr_json.decode_polyline("_p~iF~ps") +class MergePolylinesFromTransitionsTest(unittest.TestCase): + """Tests for merge_polylines_from_transitions.""" + + maxDiff = None + + def test_no_transitions(self): + self.assertIsNone(cfr_json.merge_polylines_from_transitions(())) + + def test_no_polylines(self): + transitions: Sequence[cfr_json.Transition] = ( + {"travelDistanceMeters": 120}, + {"travelDistanceMeters": 50}, + {"travelDistanceMeters": 0}, + {"travelDistanceMeters": 32}, + ) + self.assertIsNone(cfr_json.merge_polylines_from_transitions(transitions)) + + def test_with_some_polylines(self): + points = ( + {"latitude": 38.5, "longitude": -120.2}, + {"latitude": 40.7, "longitude": -120.95}, + {"latitude": 40.7, "longitude": -122.31}, + {"latitude": 40.4, "longitude": -122.31}, + {"latitude": 43.252, "longitude": -126.453}, + ) + transitions: Sequence[cfr_json.Transition] = ( + {"routePolyline": {"points": cfr_json.encode_polyline(points[0:2])}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[2:3])}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[3:])}}, + ) + self.assertEqual( + cfr_json.merge_polylines_from_transitions(transitions), + {"points": cfr_json.encode_polyline(points)}, + ) + + def test_with_some_polylines_and_zero_travel(self): + points = ( + {"latitude": 38.5, "longitude": -120.2}, + {"latitude": 40.7, "longitude": -120.95}, + {"latitude": 40.7, "longitude": -122.31}, + {"latitude": 40.4, "longitude": -122.31}, + {"latitude": 43.252, "longitude": -126.453}, + ) + transitions: Sequence[cfr_json.Transition] = ( + {"routePolyline": {"points": cfr_json.encode_polyline(points[0:2])}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[2:3])}}, + {"routePolyline": {}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[3:])}}, + ) + self.assertEqual( + cfr_json.merge_polylines_from_transitions(transitions), + {"points": cfr_json.encode_polyline(points)}, + ) + + def test_with_some_polylines_but_not_all(self): + points = ( + {"latitude": 38.5, "longitude": -120.2}, + {"latitude": 40.7, "longitude": -120.95}, + {"latitude": 40.7, "longitude": -122.31}, + {"latitude": 40.4, "longitude": -122.31}, + {"latitude": 43.252, "longitude": -126.453}, + ) + transitions: Sequence[cfr_json.Transition] = ( + {"routePolyline": {"points": cfr_json.encode_polyline(points[0:2])}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[2:3])}}, + {"travelDistanceMeters": 123}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[3:])}}, + ) + with self.assertRaisesRegex( + ValueError, "Either all transitions with non-zero traveled distance" + ): + cfr_json.merge_polylines_from_transitions(transitions) + + def test_with_some_polylines_and_duplicate_points(self): + points = ( + {"latitude": 38.5, "longitude": -120.2}, + {"latitude": 40.7, "longitude": -120.95}, + {"latitude": 40.7, "longitude": -122.31}, + {"latitude": 40.4, "longitude": -122.31}, + {"latitude": 43.252, "longitude": -126.453}, + ) + transitions: Sequence[cfr_json.Transition] = ( + # NOTE(ondrasej): In the code below, the index ranges overlap and the + # end of each transition polyline is the start of the following + # transition polyline. + {"routePolyline": {"points": cfr_json.encode_polyline(points[0:3])}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[2:3])}}, + {"routePolyline": {"points": cfr_json.encode_polyline(points[2:])}}, + ) + self.assertEqual( + cfr_json.merge_polylines_from_transitions(transitions), + {"points": cfr_json.encode_polyline(points)}, + ) + + class MakeAllShipmentsOptional(unittest.TestCase): """Tests for make_all_shipments_optional.""" @@ -1174,6 +1811,76 @@ def test_with_existing_optional_shipments(self): ) +class RelaxAllowedVehicleIndicesTest(unittest.TestCase): + """Tests for relax_allowed_vehicle_indices.""" + + maxDiff = None + + def test_negative_cost(self): + with self.assertRaises(ValueError): + cfr_json.relax_allowed_vehicle_indices({}, -1) + + def test_no_allowed_vehicle_indices(self): + shipment: cfr_json.Shipment = {"label": "S002"} + cfr_json.relax_allowed_vehicle_indices(shipment, 100) + self.assertEqual(shipment, {"label": "S002"}) + + def test_zero_cost(self): + shipment: cfr_json.Shipment = { + "label": "S001", + "allowedVehicleIndices": [0, 1, 2, 3], + } + cfr_json.relax_allowed_vehicle_indices(shipment, 0) + self.assertEqual(shipment, {"label": "S001"}) + + def test_with_cost_and_no_existing_costs(self): + shipment: cfr_json.Shipment = { + "label": "S003", + "allowedVehicleIndices": [2, 3, 10], + } + cfr_json.relax_allowed_vehicle_indices(shipment, 10) + self.assertEqual( + shipment, + { + "label": "S003", + "costsPerVehicle": [10, 10, 10], + "costsPerVehicleIndices": [2, 3, 10], + }, + ) + + def test_with_cost_and_existing_costs_with_indices(self): + shipment: cfr_json.Shipment = { + "label": "S003", + "allowedVehicleIndices": [2, 3, 5], + "costsPerVehicle": [100, 300, 400], + "costsPerVehicleIndices": [1, 3, 4], + } + cfr_json.relax_allowed_vehicle_indices(shipment, 10) + self.assertEqual( + shipment, + { + "label": "S003", + "costsPerVehicle": [100, 310, 400, 10, 10], + "costsPerVehicleIndices": [1, 3, 4, 2, 5], + }, + ) + + def test_with_cost_and_existing_costs_without_indices(self): + shipment: cfr_json.Shipment = { + "label": "S003", + "allowedVehicleIndices": [2, 3], + "costsPerVehicle": [100, 200, 300, 400, 500], + } + cfr_json.relax_allowed_vehicle_indices(shipment, 10) + self.assertEqual( + shipment, + { + "label": "S003", + "costsPerVehicle": [100, 200, 310, 410, 500], + }, + ) + + class RemoveLoadLimitstest(unittest.TestCase): """Tests for remove_load_limits.""" diff --git a/examples/two_step_routing/two_step_routing.py b/examples/two_step_routing/two_step_routing.py index 85541b5b..e5a11bc5 100644 --- a/examples/two_step_routing/two_step_routing.py +++ b/examples/two_step_routing/two_step_routing.py @@ -198,6 +198,11 @@ class Options: min_average_shipments_per_round: The minimal (average) number of shipments that is delivered from a parking location without returning to the parking location. This is used to estimate the number of vehicles in the plan. + travel_mode_in_merged_transitions: When True, transition objects in the + merged response contain also the travel mode and travel duration multiple + used while computing route for the transition. These fields are extensions + to the CFR API, and converting a JSON response with these fields to the + CFR proto may fail. """ local_model_grouping: LocalModelGrouping = LocalModelGrouping.PARKING_AND_TIME @@ -210,6 +215,8 @@ class Options: min_average_shipments_per_round: int = 1 + travel_mode_in_merged_transitions: bool = False + # Defines a mapping from shipments to the parking locations from which they are # delivered. The key of the map is the index of a shipment in the request, and @@ -338,7 +345,6 @@ def make_local_request(self) -> cfr_json.OptimizeToursRequest: for parking_key, group_shipment_indices in self._parking_groups.items(): assert parking_key.parking_tag is not None - parking = self._parking_locations[parking_key.parking_tag] num_shipments = len(group_shipment_indices) assert num_shipments > 0 @@ -350,38 +356,16 @@ def make_local_request(self) -> cfr_json.OptimizeToursRequest: ) assert max_num_rounds > 0 vehicle_label = _make_local_model_vehicle_label(parking_key) - parking_waypoint: cfr_json.Waypoint = { - "location": {"latLng": parking.coordinates} - } group_vehicle_indices = [] for round_index in range(max_num_rounds): group_vehicle_indices.append(len(local_vehicles)) - vehicle: cfr_json.Vehicle = { - "label": f"{vehicle_label}/{round_index}", - # Start and end waypoints. - "endWaypoint": parking_waypoint, - "startWaypoint": parking_waypoint, - # Limits and travel speed. - "travelDurationMultiple": parking.travel_duration_multiple, - "travelMode": parking.travel_mode, - # Costs. - "fixedCost": self._options.local_model_vehicle_fixed_cost, - "costPerHour": self._options.local_model_vehicle_per_hour_cost, - "costPerKilometer": self._options.local_model_vehicle_per_km_cost, - # Transition attribute tags. - "startTags": [parking_key.parking_tag], - "endTags": [parking_key.parking_tag], - } - if parking.max_round_duration is not None: - vehicle["routeDurationLimit"] = { - "maxDuration": parking.max_round_duration, - } - if parking.delivery_load_limits is not None: - vehicle["loadLimits"] = { - unit: {"maxLoad": str(max_load)} - for unit, max_load in parking.delivery_load_limits.items() - } - local_vehicles.append(vehicle) + local_vehicles.append( + _make_local_model_vehicle( + self._options, + self._parking_locations[parking_key.parking_tag], + f"{vehicle_label}/{round_index}", + ) + ) # Add shipments from the group. From each shipment, we preserve only the # necessary properties for the local plan. @@ -451,7 +435,6 @@ def make_global_request( "vehicles": self._model["vehicles"], } - # TODO(ondrasej): Honor transition attributes from the input request. transition_attributes = _ParkingTransitionAttributeManager(self._model) # Take all shipments that are delivered directly, and copy them to the @@ -471,78 +454,23 @@ def make_global_request( # may be served by different vehicles, but if possible, it is likely that # they will be served by the same vehicle and the rounds will be next to # each other. - for route_index, route in enumerate(local_response["routes"]): - visits = route.get("visits") - if visits is None or not visits: + for route_index, route in enumerate(cfr_json.get_routes(local_response)): + visits = cfr_json.get_visits(route) + if not visits: # Skip unused vehicles. The local plan uses a simple estimate of the # number of required vehicles, and is very likely to oversupply. continue parking_tag = _get_parking_tag_from_local_route(route) - parking = self._parking_locations[parking_tag] - - # Get all shipments from the original model that are delivered in this - # parking location route. - shipment_indices = _get_shipment_indices_from_local_route_visits(visits) - shipments = tuple( - self._shipments[shipment_index] for shipment_index in shipment_indices - ) - assert shipments - - global_delivery: cfr_json.VisitRequest = { - # We use the coordinates of the parking location for the waypoint. - "arrivalWaypoint": {"location": {"latLng": parking.coordinates}}, - # The duration of the delivery at the parking location is the total - # duration of the local route for this round. - "duration": route["metrics"]["totalDuration"], - "tags": [parking_tag], - } - global_time_windows = _get_local_model_route_start_time_windows( - self._model, route, shipments - ) - if global_time_windows is not None: - global_delivery["timeWindows"] = global_time_windows - - # Add arrival/departure/reload costs and delays if needed. - parking_transition_tag = transition_attributes.get_or_create_if_needed( - parking - ) - if parking_transition_tag is not None: - global_delivery["tags"].append(parking_transition_tag) - - shipment_labels = ",".join(shipment["label"] for shipment in shipments) - global_shipment: cfr_json.Shipment = { - "label": f"p:{route_index} {shipment_labels}", - # We use the total duration of the parking location route as the - # duration of this virtual shipment. - "deliveries": [global_delivery], - } - # The load demands of the virtual shipment is the sum of the demands of - # all individual shipments delivered on the local route. - load_demands = cfr_json.combined_load_demands(shipments) - if load_demands: - global_shipment["loadDemands"] = load_demands - - # Add the penalty cost of the virtual shipment if needed. - penalty_cost = cfr_json.combined_penalty_cost(shipments) - if penalty_cost is not None: - global_shipment["penaltyCost"] = penalty_cost - - allowed_vehicle_indices = cfr_json.combined_allowed_vehicle_indices( - shipments - ) - if allowed_vehicle_indices: - global_shipment["allowedVehicleIndices"] = allowed_vehicle_indices - - costs_per_vehicle_and_indices = cfr_json.combined_costs_per_vehicle( - shipments + global_shipments.append( + _make_global_model_shipment_for_local_route( + model=self._model, + local_route_index=route_index, + local_route=route, + parking=self._parking_locations[parking_tag], + transition_attributes=transition_attributes, + ) ) - if costs_per_vehicle_and_indices is not None: - vehicle_indices, costs = costs_per_vehicle_and_indices - global_shipment["costsPerVehicle"] = costs - global_shipment["costsPerVehicleIndices"] = vehicle_indices - - global_shipments.append(global_shipment) # TODO(ondrasej): Restrict the preserved transition attributes only to tags # that are actually used in the model, to make the model smaller. @@ -572,10 +500,210 @@ def make_global_request( request["internalParameters"] = internal_parameters return request + def make_local_refinement_request( + self, + local_response: cfr_json.OptimizeToursResponse, + global_response: cfr_json.OptimizeToursResponse, + ) -> cfr_json.OptimizeToursRequest: + """Creates a refinement request for local routes for a complete solution. + + The refinement is done by re-optimizing the local (walking/biking) routes + whenever there is a sequence of consecutive visits to the same parking + location. This re-optimization is done on visits from all visits to the + parking location in the sequence, so that the solver can reorganize how the + local delivery rounds are done. + + This phase is different from the original local model in the sense that it: + - always uses the original visit time windows for the visits in the plan, + - uses a pickup & delivery model with a single vehicle and capacities to + make sure that the result is a sequence of delivery rounds. + - uses the original delivery rounds as an injected initial solution, so + the refined solution should always have the same or better cost. + + Outline of the design of the model: + - Each shipment is represented as a pickup (at the parking location) and a + delivery (at the delivery address). If the original shipment had a time + window constraint, the new shipment will have the same time window on + the delivery request. All shipments are mandatory. + - There is a single vehicle with capacity constraints corresponding to the + capacity constraints for delivery from this parking location. The + vehicle start and end time are flexible, but they are restricted to the + start/end time of the sequence of visits to the parking location in the + original solution. + - The capacity constraint makes the "vehicle" return to the parking + location when there are more shipments than can be delivered in one + round. At the same time, by using a single vehicle, we make sure that + the delivery rounds that we get out of the optimization can be executed + by one driver in a sequence. + - The model minimizes the total time and the total distance. Compared to + the base local model, the cost per km and cost per hour are the same, + but there is an additional steep cost for using more time than the + solution of the base local model. + + Args: + local_response: The original solution of the "local" part of the model. + global_response: The original solution of the "global" part of the model. + + Returns: + A new CFR request that models the refinement of local routes based on the + solution of the global model. + """ + global_routes = cfr_json.get_routes(global_response) + + existing_tags = cfr_json.get_all_visit_tags(self._model) + + def get_non_existent_tag(base: str) -> str: + tag = base + index = 0 + while tag in existing_tags: + index += 1 + tag = f"{base}#{index}" + return tag + + refinement_vehicles: list[cfr_json.Vehicle] = [] + refinement_shipments: list[cfr_json.Shipment] = [] + refinement_transition_attributes: list[cfr_json.TransitionAttributes] = ( + list(self._model.get("transitionAttributes", ())) + ) + + refinement_model: cfr_json.ShipmentModel = { + "globalEndTime": self._model["globalEndTime"], + "globalStartTime": self._model["globalStartTime"], + "shipments": refinement_shipments, + "vehicles": refinement_vehicles, + "transitionAttributes": refinement_transition_attributes, + } + refinement_injected_routes: list[cfr_json.ShipmentRoute] = [] + + consecutive_visit_sequences: list[_ConsecutiveParkingLocationVisits] = [] + for route in global_routes: + consecutive_visit_sequences.extend( + _get_consecutive_parking_location_visits(local_response, route) + ) + + for consecutive_visit_sequence in consecutive_visit_sequences: + parking = self._parking_locations[consecutive_visit_sequence.parking_tag] + + parking_waypoint: cfr_json.Waypoint = { + "location": {"latLng": parking.coordinates} + } + + refinement_vehicle_index = len(refinement_vehicles) + refinement_vehicle_label = ( + f"global_route:{consecutive_visit_sequence.vehicle_index}" + f" start:{consecutive_visit_sequence.first_global_visit_index}" + f" size:{consecutive_visit_sequence.num_global_visits}" + ) + refinement_vehicle = _make_local_model_vehicle( + self._options, parking, refinement_vehicle_label + ) + # Set up delays for the parking location reload delays. We model the delay + # using transition attributes, by adding a delay whenever the vehicle + # does a pickup (from the parking location) after a delivery. + # These pickup tags need to be different from any ohter tag used in the + # model. Otherwise, other transition attributes or existing tags might + # interfere with this modeling. + parking_pickup_tag = get_non_existent_tag(f"{parking.tag} pickup") + parking_delivery_tag = get_non_existent_tag(f"{parking.tag} delivery") + if parking.reload_duration: + refinement_transition_attributes.append({ + "srcTag": parking_delivery_tag, + "dstTag": parking_pickup_tag, + "delay": parking.reload_duration, + }) + + # NOTE(ondrasej): We use soft start time windows with steep costs instead + # of a hard time window here. The time window is exactly the total time of + # the previous solution, and as such it may be very tight and even slight + # changes in travel times can make it infeasible. + # By using soft start/end times, we allow the solver proceed even if this + # happens; if, in the end, the refined solution is worse than the original + # we preserve the original solution. + refinement_vehicle["startTimeWindows"] = [{ + "softStartTime": consecutive_visit_sequence.start_time, + "costPerHourBeforeSoftStartTime": 10000, + }] + refinement_vehicle["endTimeWindows"] = [{ + "softEndTime": consecutive_visit_sequence.end_time, + "costPerHourAfterSoftEndTime": 10000, + }] + refinement_vehicles.append(refinement_vehicle) + + injected_visits: list[cfr_json.Visit] = [] + refinement_injected_route: cfr_json.ShipmentRoute = { + "vehicleIndex": refinement_vehicle_index, + "visits": injected_visits, + } + refinement_injected_routes.append(refinement_injected_route) + + # The delivery rounds are added in the order in which they appear on the + # base global route. As a consequence, the injected solution that we build + # based on the shipments in these rounds is feasible by construction, + # because both the local and global routes were feasible. The only case + # where the injected solution may become infeasible is when there is a + # significant change in travel duration between the base local model and + # the refinement model; however, given that we do not use live traffic in + # the local models, this is very unlikely. + for delivery_round in consecutive_visit_sequence.shipment_indices: + for offset in range(len(delivery_round)): + refinement_shipment_index = len(refinement_shipments) + offset + injected_visits.append({ + "shipmentIndex": refinement_shipment_index, + "isPickup": True, + }) + + for shipment_index in delivery_round: + original_shipment = self._shipments[shipment_index] + # NOTE(ondrasej): This assumes that original_shipment has a single + # delivery and no pickups. + refinement_shipment_index = len(refinement_shipments) + injected_visits.append({ + "shipmentIndex": refinement_shipment_index, + "isPickup": False, + }) + + refinement_delivery = copy.deepcopy( + original_shipment["deliveries"][0] + ) + if "tags" in refinement_delivery: + refinement_delivery["tags"].append(parking_delivery_tag) + else: + refinement_delivery["tags"] = [parking_delivery_tag] + refinement_shipment: cfr_json.Shipment = { + "pickups": [ + { + "arrivalWaypoint": parking_waypoint, + "tags": [parking.tag, parking_pickup_tag], + }, + ], + "deliveries": [refinement_delivery], + "allowedVehicleIndices": [refinement_vehicle_index], + "label": f"{shipment_index}: {original_shipment.get('label')}", + } + # Preserve load demands. + load_demands = original_shipment.get("loadDemands") + if load_demands is not None: + refinement_shipment["loadDemands"] = load_demands + refinement_shipments.append(refinement_shipment) + + # TODO(ondrasej): Also add skipped any shipments delivered from this + # parking location that were skipped in the original plan. When adding + # more shipments, we need to make sure that the solution does include the + # skipped shipments at the expense of exceeding the available time. + + request = { + "label": self._request.get("label", "") + "/local_refinement", + "model": refinement_model, + "injectedFirstSolutionRoutes": refinement_injected_routes, + } + self._add_options_from_original_request(request) + return request + def merge_local_and_global_result( self, local_response: cfr_json.OptimizeToursResponse, global_response: cfr_json.OptimizeToursResponse, + check_consistency: bool = True, ) -> tuple[cfr_json.OptimizeToursRequest, cfr_json.OptimizeToursResponse]: """Creates a merged request and a response from the local and global models. @@ -605,6 +733,8 @@ def merge_local_and_global_result( global_response: A solution of the global model created by self.make_global_request(local_response). The global request itself is not needed. + check_consistency: Set to False to avoid consistency checks in the merged + response. Returns: A tuple (merged_request, merged_response) that contains the merged data @@ -644,15 +774,38 @@ def merge_local_and_global_result( if transition_attributes is not None: merged_model["transitionAttributes"] = transition_attributes - local_routes = local_response["routes"] + local_routes = cfr_json.get_routes(local_response) + global_routes = cfr_json.get_routes(global_response) populate_polylines = self._request.get("populatePolylines", False) - # We need to define these two outside of the loop to avoid a useless warning - # about capturing a variable defined in a loop. + # We defined merged_transitions and add_merged_transition outside of the + # loop to avoid a lint warning (and to avoid redefining the function for + # each iteration of the loop). merged_transitions = None - route_points = None - for global_route in global_response["routes"]: - global_visits = global_route.get("visits", ()) + + def add_merged_transition( + transition: cfr_json.Transition, + at_parking: ParkingLocation | None = None, + vehicle: cfr_json.Vehicle | None = None, + ): + assert (at_parking is None) != (vehicle is None) + assert merged_transitions is not None + if self._options.travel_mode_in_merged_transitions: + if at_parking is not None: + transition["travelMode"] = at_parking.travel_mode + transition["travelDurationMultiple"] = ( + at_parking.travel_duration_multiple + ) + if vehicle is not None: + transition["travelMode"] = vehicle.get("travelMode", 0) + transition["travelDurationMultiple"] = vehicle.get( + "travelDurationMultiple", 1 + ) + merged_transitions.append(transition) + + for global_route_index, global_route in enumerate(global_routes): + global_visits = cfr_json.get_visits(global_route) + global_vehicle = self._vehicles[global_route_index] if not global_visits: # This is an unused vehicle in the global model. We can just copy the # route as is. @@ -662,7 +815,6 @@ def merge_local_and_global_result( global_transitions = global_route["transitions"] merged_visits: list[cfr_json.Visit] = [] merged_transitions: list[cfr_json.Transition] = [] - route_points: list[cfr_json.LatLng] = [] merged_routes.append( { "vehicleIndex": global_route.get("vehicleIndex", 0), @@ -681,18 +833,11 @@ def merge_local_and_global_result( if global_breaks is not None: merged_routes[-1]["breaks"] = global_breaks - if not global_visits: - # We add empty routes, but there is no additional work to do on them. - continue - def add_parking_location_shipment( - local_route: cfr_json.ShipmentRoute, arrival: bool + parking: ParkingLocation, arrival: bool ): arrival_or_departure = "arrival" if arrival else "departure" shipment_index = len(merged_shipments) - parking_tag = _get_parking_tag_from_local_route(local_route) - parking = self._parking_locations[parking_tag] - shipment: cfr_json.Shipment = { "label": f"{parking.tag} {arrival_or_departure}", "deliveries": [{ @@ -706,22 +851,13 @@ def add_parking_location_shipment( merged_shipments.append(shipment) return shipment_index, shipment - def add_merged_transition(transition: cfr_json.Transition): - merged_transitions.append(transition) - if populate_polylines: - decoded_polyline = cfr_json.decode_polyline( - transition["routePolyline"].get("points", "") - ) - for latlng in decoded_polyline: - # Drop repeated points from the route polyline. - if not route_points or route_points[-1] != latlng: - route_points.append(latlng) - for global_visit_index, global_visit in enumerate(global_visits): - # The transition from the previous global visit to the current one can - # be copied without any modifications, and it is the same regardless of - # whether the next stop is a direct delivery or a parking location. - add_merged_transition(global_transitions[global_visit_index]) + # The transition from the previous global visit to the current one is + # always by vehicle. + add_merged_transition( + copy.deepcopy(global_transitions[global_visit_index]), + vehicle=global_vehicle, + ) global_visit_label = global_visit["shipmentLabel"] visit_type, index = _parse_global_shipment_label(global_visit_label) match visit_type: @@ -738,8 +874,10 @@ def add_merged_transition(transition: cfr_json.Transition): # of the route from the local model solution, and add virtual # shipments for entering and leaving the parking location. local_route = local_routes[index] + parking_tag = _get_parking_tag_from_local_route(local_route) + parking = self._parking_locations[parking_tag] arrival_shipment_index, arrival_shipment = ( - add_parking_location_shipment(local_route, arrival=True) + add_parking_location_shipment(parking, arrival=True) ) global_start_time = cfr_json.parse_time_string( global_visit["startTime"] @@ -756,7 +894,7 @@ def add_merged_transition(transition: cfr_json.Transition): # Transfer all visits and transitions from the local route. Update # the timestamps as needed. - local_visits = local_route["visits"] + local_visits = cfr_json.get_visits(local_route) local_transitions = local_route["transitions"] for local_visit_index, local_visit in enumerate(local_visits): local_transition_in = local_transitions[local_visit_index] @@ -764,7 +902,7 @@ def add_merged_transition(transition: cfr_json.Transition): merged_transition["startTime"] = cfr_json.update_time_string( merged_transition["startTime"], local_to_global_delta ) - add_merged_transition(merged_transition) + add_merged_transition(merged_transition, at_parking=parking) shipment_index = _get_shipment_index_from_local_route_visit( local_visit @@ -783,12 +921,12 @@ def add_merged_transition(transition: cfr_json.Transition): transition_to_parking["startTime"] = cfr_json.update_time_string( transition_to_parking["startTime"], local_to_global_delta ) - add_merged_transition(transition_to_parking) + add_merged_transition(transition_to_parking, at_parking=parking) # Add a virtual shipment and a visit for the departure from the # parking location. departure_shipment_index, departure_shipment = ( - add_parking_location_shipment(local_route, arrival=False) + add_parking_location_shipment(parking, arrival=False) ) merged_visits.append({ "shipmentIndex": departure_shipment_index, @@ -801,11 +939,20 @@ def add_merged_transition(transition: cfr_json.Transition): raise ValueError(f"Unexpected visit type: '{visit_type}'") # Add the transition back to the depot. - add_merged_transition(global_transitions[-1]) + add_merged_transition( + copy.deepcopy(global_transitions[-1]), vehicle=global_vehicle + ) if populate_polylines: - merged_routes[-1]["routePolyline"] = { - "points": cfr_json.encode_polyline(route_points) - } + route_polyline = cfr_json.merge_polylines_from_transitions( + merged_transitions + ) + if route_polyline is not None: + merged_routes[-1]["routePolyline"] = route_polyline + + # Update route metrics to include data from both local and global travel. + cfr_json.recompute_route_metrics( + merged_model, merged_routes[-1], check_consistency=check_consistency + ) merged_skipped_shipments = [] for local_skipped_shipment in local_response.get("skippedShipments", ()): @@ -825,13 +972,13 @@ def add_merged_transition(transition: cfr_json.Transition): # Shipments delivered directly can be added directly to the list. merged_skipped_shipments.append({ "index": int(index), - "label": self._model["shipments"][index].get("label", ""), + "label": self._shipments[index].get("label", ""), }) case "p": # For parking locations, we need to add all shipments delivered from # that parking location. local_route = local_routes[index] - for visit in local_route["visits"]: + for visit in cfr_json.get_visits(local_route): shipment_index, label = visit["shipmentLabel"].split( ": ", maxsplit=1 ) @@ -877,6 +1024,50 @@ def _add_options_from_original_request( request["populateTransitionPolylines"] = populate_transition_polylines +def _make_local_model_vehicle( + options: Options, parking: ParkingLocation, label: str +) -> cfr_json.Vehicle: + """Creates a vehicle for the local model from a given parking location. + + Args: + options: The options of the two-step planner. + parking: The parking location for which the vehicle is created. + label: The label of the new vehicle. + + Returns: + The newly created vehicle. + """ + parking_waypoint: cfr_json.Waypoint = { + "location": {"latLng": parking.coordinates} + } + vehicle: cfr_json.Vehicle = { + "label": label, + # Start and end waypoints. + "endWaypoint": parking_waypoint, + "startWaypoint": parking_waypoint, + # Limits and travel speed. + "travelDurationMultiple": parking.travel_duration_multiple, + "travelMode": parking.travel_mode, + # Costs. + "fixedCost": options.local_model_vehicle_fixed_cost, + "costPerHour": options.local_model_vehicle_per_hour_cost, + "costPerKilometer": options.local_model_vehicle_per_km_cost, + # Transition attribute tags. + "startTags": [parking.tag], + "endTags": [parking.tag], + } + if parking.max_round_duration is not None: + vehicle["routeDurationLimit"] = { + "maxDuration": parking.max_round_duration, + } + if parking.delivery_load_limits is not None: + vehicle["loadLimits"] = { + unit: {"maxLoad": str(max_load)} + for unit, max_load in parking.delivery_load_limits.items() + } + return vehicle + + def validate_request( request: cfr_json.OptimizeToursRequest, parking_for_shipment: ShipmentParkingMap, @@ -1056,7 +1247,95 @@ def _get_non_existent_tag(self, base: str) -> str: index += 1 +def _make_global_model_shipment_for_local_route( + model: cfr_json.ShipmentModel, + local_route_index: int, + local_route: cfr_json.ShipmentRoute, + parking: ParkingLocation, + transition_attributes: _ParkingTransitionAttributeManager, +) -> cfr_json.Shipment: + """Creates a virtual shipment in the global model for a local delivery route. + + Args: + model: The original model. + local_route_index: The index of the local delivery route in the local + response. + local_route: The local delivery route. + parking: The parking location for the local delivery route. + transition_attributes: The parking transition attribute manager used for the + construction of the global model. + + Returns: + The newly created global shipment. + """ + visits = cfr_json.get_visits(local_route) + + parking_tag = _get_parking_tag_from_local_route(local_route) + + # Get all shipments from the original model that are delivered in this + # parking location route. + shipment_indices = _get_shipment_indices_from_local_route_visits(visits) + shipments = tuple( + model["shipments"][shipment_index] for shipment_index in shipment_indices + ) + assert shipments + + global_delivery: cfr_json.VisitRequest = { + # We use the coordinates of the parking location for the waypoint. + "arrivalWaypoint": {"location": {"latLng": parking.coordinates}}, + # The duration of the delivery at the parking location is the total + # duration of the local route for this round. + "duration": local_route["metrics"]["totalDuration"], + "tags": [parking_tag], + } + global_time_windows = _get_local_model_route_start_time_windows( + model, local_route + ) + if global_time_windows is not None: + global_delivery["timeWindows"] = global_time_windows + + # Add arrival/departure/reload costs and delays if needed. + parking_transition_tag = transition_attributes.get_or_create_if_needed( + parking + ) + if parking_transition_tag is not None: + global_delivery["tags"].append(parking_transition_tag) + + shipment_labels = ",".join(shipment["label"] for shipment in shipments) + global_shipment: cfr_json.Shipment = { + "label": f"p:{local_route_index} {shipment_labels}", + # We use the total duration of the parking location route as the + # duration of this virtual shipment. + "deliveries": [global_delivery], + } + # The load demands of the virtual shipment is the sum of the demands of + # all individual shipments delivered on the local route. + load_demands = cfr_json.combined_load_demands(shipments) + if load_demands: + global_shipment["loadDemands"] = load_demands + + # Add the penalty cost of the virtual shipment if needed. + penalty_cost = cfr_json.combined_penalty_cost(shipments) + if penalty_cost is not None: + global_shipment["penaltyCost"] = penalty_cost + + allowed_vehicle_indices = cfr_json.combined_allowed_vehicle_indices(shipments) + if allowed_vehicle_indices: + global_shipment["allowedVehicleIndices"] = allowed_vehicle_indices + + costs_per_vehicle_and_indices = cfr_json.combined_costs_per_vehicle(shipments) + if costs_per_vehicle_and_indices is not None: + vehicle_indices, costs = costs_per_vehicle_and_indices + global_shipment["costsPerVehicle"] = costs + global_shipment["costsPerVehicleIndices"] = vehicle_indices + + return global_shipment + + _GLOBAL_SHIPEMNT_LABEL = re.compile(r"^([ps]):(\d+) .*") +_REFINEMENT_VEHICLE_LABEL = re.compile( + r"^global_route:(\d+) start:(\d+) size:(\d+)$" +) T = TypeVar("T") @@ -1117,7 +1396,6 @@ def _interval_intersection( def _get_local_model_route_start_time_windows( model: cfr_json.ShipmentModel, route: cfr_json.ShipmentRoute, - shipments: Sequence[cfr_json.Shipment], ) -> list[cfr_json.TimeWindow] | None: """Computes global time windows for starting a local route. @@ -1134,8 +1412,6 @@ def _get_local_model_route_start_time_windows( Args: model: The model in which the route is computed. route: The local route for which the time window is computed. - shipments: The list of shipments from the model that appear on the route, in - the order in which they appear. Returns: A list of time windows for the start of the route. The time windows account @@ -1144,15 +1420,10 @@ def _get_local_model_route_start_time_windows( start at any time within the global start/end interval of the model, returns None. """ - if not shipments: + visits = cfr_json.get_visits(route) + if not visits: return None - visits = route["visits"] - if len(shipments) != len(visits): - raise ValueError( - "The number of shipments does not match the number of visits." - ) - global_start_time = cfr_json.get_global_start_time(model) global_end_time = cfr_json.get_global_end_time(model) @@ -1169,10 +1440,9 @@ def _get_local_model_route_start_time_windows( # Start by allowing any start time for the local route. overall_route_start_time_intervals = ((global_start_time, global_end_time),) - for shipment, visit in zip(shipments, visits): - visit_type = "pickups" if visit.get("isPickup", False) else "deliveries" - visit_request_index = visit.get("visitRequestIndex", 0) - time_windows = shipment[visit_type][visit_request_index].get("timeWindows") + for visit in visits: + visit_request = cfr_json.get_visit_request(model, visit) + time_windows = visit_request.get("timeWindows") if not time_windows: # This shipment can be delivered at any time. No refinement of the route # delivery time interval is needed. @@ -1236,10 +1506,226 @@ def _get_local_model_route_start_time_windows( return global_time_windows +@dataclasses.dataclass(frozen=True) +class _ConsecutiveParkingLocationVisits: + """Contains info about a sequence of consecutive visits to a parking location. + + Attributes: + parking_tag: The parking tag of the parking location being visited. + global_route: The global route in which the consecutive visit sequences + appear. + first_global_visit_index: The index of the first visit to the parking + location in the global route. + num_global_visits: The number of visits to the parking location in the + sequence in the global route. + local_route_indices: The list of routes in the local model solution that + represent the sequence of consecutive visits to the parking location. The + length of `local_route_indices` and `shipment_indices` must be the same + and the local route index at a give index corresponds to the group of + shipments at the same index. + shipment_indices: The shipments delivered in this sequence of consecutive + visits to the parking location. This is a list of lists of shipment + indices; each inner list corresponds to one delivery round from the + parking location. + """ + + parking_tag: str + global_route: cfr_json.ShipmentRoute + first_global_visit_index: int + num_global_visits: int + local_route_indices: Sequence[int] + shipment_indices: Sequence[Sequence[int]] + + @property + def vehicle_index(self) -> int: + """The index of the vehicle in the global plan that did the delivery.""" + return self.global_route.get("vehicleIndex", 0) + + @property + def start_time(self) -> cfr_json.TimeString: + """Returns the start time of the first visit in the sequence.""" + visits = cfr_json.get_visits(self.global_route) + first_visit = visits[self.first_global_visit_index] + return first_visit["startTime"] + + @property + def end_time(self) -> cfr_json.TimeString: + """Returns the end time of the last visit in the sequence.""" + # The end time of a visit is not stored directly in the response, so instead + # we take the start time of the following transition. + transition_index = self.first_global_visit_index + self.num_global_visits + transition = self.global_route["transitions"][transition_index] + return transition["startTime"] + + +def _get_consecutive_parking_location_visits( + local_response: cfr_json.OptimizeToursResponse, + global_route: cfr_json.ShipmentRoute, +) -> Sequence[_ConsecutiveParkingLocationVisits]: + """Extracts the list of consecutive visits to the same parking location. + + Takes a route in the global model and returns the list of sequences of + consecutive visits to the same parking location. Only sequences with two or + more visits are counted. Shipments delivered directly in the global model + break sequences, but they never form a sequence. + + Args: + local_response: A solution of the local model. + global_route: A route in the global model. + + Returns: + The list of sequences of consecutive visits to the same parking location. + Each sequence is represented as a tuple (parking_tag, shipment_indices) + where `parking_tag` is the tag of the parking location to which this + applies and `shipment_indices` are indices of shipments from the original + request that are delivered during the visits to the parking location. + """ + local_routes = cfr_json.get_routes(local_response) + global_visits = cfr_json.get_visits(global_route) + consecutive_visits = [] + local_route_indices = [] + sequence_start = None + previous_parking_tag = None + + def add_sequence_if_needed(sequence_end: int): + if sequence_start is None: + return + assert previous_parking_tag is not None + if len(local_route_indices) <= 1: + return + + # Collect the indices of shipments from the original request that are + # handled during these visits. + shipment_indices = [] + for local_route_index in local_route_indices: + local_route = local_routes[local_route_index] + local_visits = cfr_json.get_visits(local_route) + shipment_indices.append([]) + for local_visit in local_visits: + local_shipment_label = local_visit.get("shipmentLabel", "") + shipment_indices[-1].append( + _get_shipment_index_from_local_label(local_shipment_label) + ) + + consecutive_visits.append( + _ConsecutiveParkingLocationVisits( + parking_tag=previous_parking_tag, + local_route_indices=local_route_indices, + global_route=global_route, + first_global_visit_index=sequence_start, + num_global_visits=sequence_end - sequence_start, + shipment_indices=shipment_indices, + ) + ) + + for global_visit_index, global_visit in enumerate(global_visits): + global_visit_label = global_visit["shipmentLabel"] + visit_type, index = _parse_global_shipment_label(global_visit_label) + if visit_type == "s": + add_sequence_if_needed(global_visit_index) + previous_parking_tag = None + sequence_start = None + local_route_indices = [] + continue + assert visit_type == "p" + local_route = local_routes[index] + parking_tag = _get_parking_tag_from_local_route(local_route) + if parking_tag != previous_parking_tag: + add_sequence_if_needed(global_visit_index) + previous_parking_tag = parking_tag + sequence_start = global_visit_index + local_route_indices = [] + local_route_indices.append(index) + add_sequence_if_needed(len(global_visits)) + return consecutive_visits + + +def _split_refined_local_route( + route: cfr_json.ShipmentRoute, +) -> Sequence[tuple[Sequence[cfr_json.Visit], Sequence[cfr_json.Transition]]]: + """Extracts delivery rounds from a local refinement model route. + + In the local refinement model, a route may contain more than one delivery + round. Each delivery round consists of a sequence of delivery visits, and the + rounds in a route are separated by sequences of pickup visits (at the address + of the parking location). This function returns the visits and transitions + corresponding to each of the delivery rounds. + + Args: + route: A route from the local delivery model to be split. + + Returns: + A sequence of splits of the current route. Each split is returned as a list + of visits and transitions that belong to the segment. Only delivery visits + are returned, and the first (resp. last) transition in each group is from + (resp. to) the parking location. + """ + if route.get("breaks", ()): + raise ValueError("Breaks in the local routes are not supported.") + # NOTE(ondrasej): This code assumes that all shipments are delivery-only and + # that all pickup visits are at the parking location address and they were + # added by the local refinement model to make the driver return to the parking + # at the end of each delivery round. + splits = [] + + visits = iter(enumerate(cfr_json.get_visits(route))) + transitions = route.get("transitions", ()) + indexed_visit = next(visits, None) + visit_index = None + while True: + # Drop pickup visits at the beginning of the sequence. + if indexed_visit is None: + # We already processed all visits on the route. Returns the results. + return splits + while indexed_visit is not None and indexed_visit[1].get("isPickup", False): + indexed_visit = next(visits, None) + if indexed_visit is None: + # Since all our shipments are delivery only, the last visit on any valid + # route must be a delivery. + raise ValueError("The route should not end with a pickup") + + split_visits = [] + split_transitions = [] + + # Extract visits and transitions from the current split. + while indexed_visit is not None: + visit_index, visit = indexed_visit + if visit.get("isPickup", False): + # The current round has ended. Add a new transition and move to the next + # one. + break + split_visits.append(visit) + split_transitions.append(transitions[visit_index]) + indexed_visit = next(visits, None) + + # Add the transition back to the parking location. We can just add the next + # transition - it will be either a return to the vehicle end location or a + # transition to a shipment pickup, but in both cases it will be transition + # to the parking location. + if indexed_visit is None: + visit_index += 1 + split_transitions.append(transitions[visit_index]) + + # If the algorithm is correct, there must be at least one split. Otherwise, + # we'd exit the parent while loop right at the beginning. + assert split_visits, "Unexpected empty visit list" + assert split_transitions, "Unexpected empty transition list" + + splits.append((split_visits, split_transitions)) + + +def _parse_refinement_vehicle_label(label: str) -> tuple[int, int, int]: + """Parses the label of a vehicle in the local refinement model.""" + match = _REFINEMENT_VEHICLE_LABEL.match(label) + if not match: + raise ValueError("Invalid vehicle label in refinement model: {label!r}") + return int(match[1]), int(match[2]), int(match[3]) + + def _parse_global_shipment_label(label: str) -> tuple[str, int]: match = _GLOBAL_SHIPEMNT_LABEL.match(label) if not match: - raise ValueError(f'Invalid shipment label: "{label}"') + raise ValueError(f"Invalid shipment label: {label!r}") return match[1], int(match[2]) diff --git a/examples/two_step_routing/two_step_routing_main.py b/examples/two_step_routing/two_step_routing_main.py index a87efdaa..177452df 100644 --- a/examples/two_step_routing/two_step_routing_main.py +++ b/examples/two_step_routing/two_step_routing_main.py @@ -62,6 +62,8 @@ class Flags: reuse_existing: When a file with a response exists, load it instead of resolving the request. local_grouping: The value of the --local_grouping flag or the default value. + travel_mode_in_merged_transitions: The value of the + --travel_mode_in_merged_transitions flag. local_timeout: The value of the --local_timeout flag or the default value. global_timeout: The value of the --global_timeout flag or the default value. """ @@ -72,6 +74,7 @@ class Flags: google_cloud_token: str reuse_existing: bool local_grouping: two_step_routing.LocalModelGrouping + travel_mode_in_merged_transitions: bool local_timeout: cfr_json.DurationString global_timeout: cfr_json.DurationString @@ -109,6 +112,12 @@ def _parse_flags() -> Flags: choices=tuple(two_step_routing.LocalModelGrouping.__members__), default="PARKING_AND_TIME", ) + parser.add_argument( + "--travel_mode_in_merged_transitions", + help="Add travel mode information to transitions in the merged solution.", + default=False, + action="store_true", + ) parser.add_argument( "--local_timeout", help=( @@ -137,6 +146,7 @@ def _parse_flags() -> Flags: google_cloud_token=flags.token, local_timeout=flags.local_timeout, local_grouping=two_step_routing.LocalModelGrouping[flags.local_grouping], + travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions, global_timeout=flags.global_timeout, reuse_existing=flags.reuse_existing, ) @@ -270,9 +280,12 @@ def _run_two_step_planner() -> None: options = two_step_routing.Options( local_model_grouping=two_step_routing.LocalModelGrouping.PARKING, local_model_vehicle_fixed_cost=0, + travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions, ) case two_step_routing.LocalModelGrouping.PARKING_AND_TIME: - options = two_step_routing.Options() + options = two_step_routing.Options( + travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions + ) case _: raise ValueError( "Unexpected value of --local_grouping: {flags.local_grouping!r}" diff --git a/examples/two_step_routing/two_step_routing_test.py b/examples/two_step_routing/two_step_routing_test.py index d9cae0fa..176bddea 100644 --- a/examples/two_step_routing/two_step_routing_test.py +++ b/examples/two_step_routing/two_step_routing_test.py @@ -1847,6 +1847,17 @@ class PlannerTest(unittest.TestCase): "startTime": "2023-08-11T15:57:18Z", }, ], + "metrics": { + "breakDuration": "0s", + "delayDuration": "0s", + "performedMandatoryShipmentCount": 14, + "performedShipmentCount": 14, + "totalDuration": "28800s", + "travelDistanceMeters": 0, + "travelDuration": "0s", + "visitDuration": "690s", + "waitDuration": "0s", + }, }, { "routeTotalCost": 721.965, @@ -1919,6 +1930,17 @@ class PlannerTest(unittest.TestCase): "startTime": "2023-08-11T19:57:18Z", }, ], + "metrics": { + "breakDuration": "0s", + "delayDuration": "0s", + "performedMandatoryShipmentCount": 7, + "performedShipmentCount": 7, + "totalDuration": "43200s", + "travelDistanceMeters": 0, + "travelDuration": "0s", + "visitDuration": "450s", + "waitDuration": "0s", + }, }, ] } @@ -2874,6 +2896,17 @@ class PlannerTestMergedModel(PlannerTest): "startTime": "2023-08-11T15:57:21Z", }, ], + "metrics": { + "breakDuration": "0s", + "delayDuration": "0s", + "performedMandatoryShipmentCount": 15, + "performedShipmentCount": 15, + "totalDuration": "28800s", + "travelDistanceMeters": 0, + "travelDuration": "0s", + "visitDuration": "840s", + "waitDuration": "0s", + }, }, {"vehicleIndex": 1, "vehicleLabel": "V002"}, ], @@ -2893,6 +2926,12 @@ def test_make_merged_request_and_response(self): merged_request, merged_response = planner.merge_local_and_global_result( self._LOCAL_RESPONSE_JSON, self._GLOBAL_RESPONSE_JSON, + # TODO(ondrasej): Earlier during development, we removed some of the + # durations from the local and global responses for brevity, and now the + # timing in the responses is not self-consistent. Regenerate the test + # data with all timing information and enable the consistency checks in + # the tests. + check_consistency=False, ) self.assertEqual(merged_request, self._EXPECTED_MERGED_REQUEST_JSON) self.assertEqual(merged_response, self._EXPECTED_MERGED_RESPONSE_JSON) @@ -2907,6 +2946,12 @@ def test_make_merged_request_and_response_with_skipped_shipments(self): merged_request, merged_response = planner.merge_local_and_global_result( self._LOCAL_RESPONSE_WITH_SKIPPED_SHIPMENTS_JSON, self._GLOBAL_RESPONSE_WITH_SKIPPED_SHIPMENTS_JSON, + # TODO(ondrasej): Earlier during development, we removed some of the + # durations from the local and global responses for brevity, and now the + # timing in the responses is not self-consistent. Regenerate the test + # data with all timing information and enable the consistency checks in + # the tests. + check_consistency=False, ) self.assertEqual( merged_request, @@ -2918,6 +2963,663 @@ def test_make_merged_request_and_response_with_skipped_shipments(self): ) +class PlannerTestRefinedLocalModel(PlannerTest): + _EXPECTED_LOCAL_REFINEMENT_REQUEST: cfr_json.OptimizeToursRequest = { + "allowLargeDeadlineDespiteInterruptionRisk": True, + "label": "my_little_model/local_refinement", + "injectedFirstSolutionRoutes": [ + { + "vehicleIndex": 0, + "visits": [ + {"isPickup": True, "shipmentIndex": 0}, + {"isPickup": False, "shipmentIndex": 0}, + {"isPickup": True, "shipmentIndex": 1}, + {"isPickup": True, "shipmentIndex": 2}, + {"isPickup": False, "shipmentIndex": 1}, + {"isPickup": False, "shipmentIndex": 2}, + {"isPickup": True, "shipmentIndex": 3}, + {"isPickup": False, "shipmentIndex": 3}, + ], + }, + { + "vehicleIndex": 1, + "visits": [ + {"isPickup": True, "shipmentIndex": 4}, + {"isPickup": False, "shipmentIndex": 4}, + {"isPickup": True, "shipmentIndex": 5}, + {"isPickup": True, "shipmentIndex": 6}, + {"isPickup": False, "shipmentIndex": 5}, + {"isPickup": False, "shipmentIndex": 6}, + ], + }, + ], + "model": { + "globalEndTime": "2023-08-12T00:00:00.000Z", + "globalStartTime": "2023-08-11T00:00:00.000Z", + "shipments": [ + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86595, + "longitude": 2.34888, + } + } + }, + "duration": "60s", + "tags": ["P001 delivery"], + "timeWindows": [{ + "endTime": "2023-08-11T16:00:00.000Z", + "startTime": "2023-08-11T14:00:00.000Z", + }], + }], + "label": "3: S004", + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P001", "P001 pickup"], + }], + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86471, + "longitude": 2.34901, + } + } + }, + "duration": "120s", + "tags": ["S001", "P001 delivery"], + }], + "label": "0: S001", + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P001", "P001 pickup"], + }], + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86593, + "longitude": 2.34886, + } + } + }, + "duration": "150s", + "tags": ["S002", "P001 delivery"], + }], + "label": "1: S002", + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P001", "P001 pickup"], + }], + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86594, + "longitude": 2.34887, + } + } + }, + "duration": "60s", + "tags": ["P001 delivery"], + "timeWindows": [ + {"startTime": "2023-08-11T12:00:00.000Z"} + ], + }], + "label": "2: S003", + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P001", "P001 pickup"], + }], + }, + { + "allowedVehicleIndices": [1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + "tags": ["P002 delivery"], + }], + "label": "7: S008", + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P002", "P002 pickup"], + }], + }, + { + "allowedVehicleIndices": [1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + "tags": ["P002 delivery"], + }], + "label": "5: S006", + "loadDemands": { + "ore": {"amount": "2"}, + "wheat": {"amount": "3"}, + }, + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P002", "P002 pickup"], + }], + }, + { + "allowedVehicleIndices": [1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86596, + "longitude": 2.34889, + } + } + }, + "duration": "150s", + "tags": ["P002 delivery"], + }], + "label": "4: S005", + "pickups": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "tags": ["P002", "P002 pickup"], + }], + }, + ], + "transitionAttributes": [ + {"cost": 1, "dstTag": "S002", "srcTag": "S001"}, + { + "delay": "60s", + "dstTag": "P002 pickup", + "srcTag": "P002 delivery", + }, + ], + "vehicles": [ + { + "costPerHour": 300, + "costPerKilometer": 60, + "endTags": ["P001"], + "endTimeWindows": [{ + "costPerHourAfterSoftEndTime": 10000, + "softEndTime": "2023-08-11T15:50:19Z", + }], + "endWaypoint": { + "location": { + "latLng": {"latitude": 48.86482, "longitude": 2.34932} + } + }, + "fixedCost": 10000, + "label": "global_route:0 start:1 size:3", + "loadLimits": {"ore": {"maxLoad": "2"}}, + "routeDurationLimit": {"maxDuration": "1800s"}, + "startTags": ["P001"], + "startTimeWindows": [{ + "costPerHourBeforeSoftStartTime": 10000, + "softStartTime": "2023-08-11T15:10:39Z", + }], + "startWaypoint": { + "location": { + "latLng": {"latitude": 48.86482, "longitude": 2.34932} + } + }, + "travelDurationMultiple": 1.1, + "travelMode": 1, + }, + { + "costPerHour": 300, + "costPerKilometer": 60, + "endTags": ["P002"], + "endTimeWindows": [{ + "costPerHourAfterSoftEndTime": 10000, + "softEndTime": "2023-08-11T19:57:18Z", + }], + "endWaypoint": { + "location": { + "latLng": {"latitude": 48.86482, "longitude": 2.34932} + } + }, + "fixedCost": 10000, + "label": "global_route:1 start:0 size:2", + "loadLimits": {"ore": {"maxLoad": "2"}}, + "startTags": ["P002"], + "startTimeWindows": [{ + "costPerHourBeforeSoftStartTime": 10000, + "softStartTime": "2023-08-11T19:40:49Z", + }], + "startWaypoint": { + "location": { + "latLng": {"latitude": 48.86482, "longitude": 2.34932} + } + }, + "travelDurationMultiple": 1.0, + "travelMode": 2, + }, + ], + }, + "searchMode": 1, + } + + def test_local_refinement_model(self): + planner = two_step_routing.Planner( + request_json=self._REQUEST_JSON, + parking_locations=self._PARKING_LOCATIONS, + parking_for_shipment=self._PARKING_FOR_SHIPMENT, + options=self._OPTIONS, + ) + local_refinement_request = planner.make_local_refinement_request( + self._LOCAL_RESPONSE_JSON, self._GLOBAL_RESPONSE_JSON + ) + self.assertEqual( + local_refinement_request, self._EXPECTED_LOCAL_REFINEMENT_REQUEST + ) + + +class GetConsecutiveParkingLocationVisits(unittest.TestCase): + """Tests for _get_consecutive_parking_location_visits.""" + + maxDiff = None + + def test_empty_route(self): + local_response: cfr_json.OptimizeToursResponse = {} + global_route: cfr_json.ShipmentRoute = {} + self.assertSequenceEqual( + two_step_routing._get_consecutive_parking_location_visits( + local_response, global_route + ), + (), + ) + + def test_only_shipments(self): + local_response: cfr_json.OptimizeToursResponse = {} + global_route: cfr_json.ShipmentRoute = { + "visits": [ + {"shipmentLabel": "s:1 S002"}, + {"shipmentLabel": "s:5 SOO6"}, + {"shipmentLabel": "s:6 S007"}, + ] + } + self.assertSequenceEqual( + two_step_routing._get_consecutive_parking_location_visits( + local_response, global_route + ), + (), + ) + + def test_different_parkings_and_shipments(self): + local_response: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "vehicleLabel": "P001 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "3: S003"}, + {"shipmentLabel": "5: S005"}, + ], + }, + { + "vehicleLabel": "P001 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "1: S001"}, + {"shipmentLabel": "12: S012"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "2: S002"}, + {"shipmentLabel": "8: S008"}, + ], + }, + ], + } + global_route: cfr_json.ShipmentRoute = { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:0 S001"}, + {"shipmentLabel": "p:1 P001"}, + {"shipmentLabel": "p:2 P002"}, + {"shipmentLabel": "p:0 P001"}, + ], + } + self.assertSequenceEqual( + two_step_routing._get_consecutive_parking_location_visits( + local_response, global_route + ), + (), + ) + + def test_consecutive_visits(self): + local_response: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "vehicleLabel": "P001 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "3: S003"}, + {"shipmentLabel": "5: S005"}, + ], + }, + { + "vehicleLabel": "P001 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "1: S001"}, + {"shipmentLabel": "12: S012"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "2: S002"}, + {"shipmentLabel": "8: S008"}, + {"shipmentLabel": "0: S000"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "4: S004"}, + {"shipmentLabel": "6: S006"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/2", + "visits": [ + {"shipmentLabel": "9: S009"}, + {"shipmentLabel": "10: S010"}, + ], + }, + ], + } + global_route: cfr_json.ShipmentRoute = { + "vehicleIndex": 2, + "visits": [ + {"shipmentLabel": "s:0 S001"}, + {"shipmentLabel": "p:0 P001"}, + {"shipmentLabel": "p:1 P001"}, + {"shipmentLabel": "s:10 S011"}, + {"shipmentLabel": "p:3 P002"}, + {"shipmentLabel": "p:2 P002"}, + {"shipmentLabel": "p:4 P002"}, + ], + } + self.assertSequenceEqual( + two_step_routing._get_consecutive_parking_location_visits( + local_response, global_route + ), + ( + two_step_routing._ConsecutiveParkingLocationVisits( + parking_tag="P001", + global_route=global_route, + first_global_visit_index=1, + num_global_visits=2, + local_route_indices=[0, 1], + shipment_indices=[[3, 5], [1, 12]], + ), + two_step_routing._ConsecutiveParkingLocationVisits( + parking_tag="P002", + global_route=global_route, + first_global_visit_index=4, + num_global_visits=3, + local_route_indices=[3, 2, 4], + shipment_indices=[[4, 6], [2, 8, 0], [9, 10]], + ), + ), + ) + + def test_only_parking(self): + local_response: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "vehicleLabel": "P001 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "3: S003"}, + {"shipmentLabel": "5: S005"}, + ], + }, + { + "vehicleLabel": "P001 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "1: S001"}, + {"shipmentLabel": "12: S012"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "2: S002"}, + {"shipmentLabel": "8: S008"}, + {"shipmentLabel": "0: S000"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "4: S004"}, + {"shipmentLabel": "6: S006"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/2", + "visits": [ + {"shipmentLabel": "9: S009"}, + {"shipmentLabel": "10: S010"}, + ], + }, + ], + } + global_route: cfr_json.ShipmentRoute = { + "visits": [ + {"shipmentLabel": "p:4 P002"}, + {"shipmentLabel": "p:0 P001"}, + {"shipmentLabel": "p:1 P001"}, + {"shipmentLabel": "p:3 P002"}, + {"shipmentLabel": "p:2 P002"}, + ] + } + self.assertSequenceEqual( + two_step_routing._get_consecutive_parking_location_visits( + local_response, global_route + ), + ( + two_step_routing._ConsecutiveParkingLocationVisits( + parking_tag="P001", + global_route=global_route, + first_global_visit_index=1, + num_global_visits=2, + local_route_indices=[0, 1], + shipment_indices=[[3, 5], [1, 12]], + ), + two_step_routing._ConsecutiveParkingLocationVisits( + parking_tag="P002", + global_route=global_route, + first_global_visit_index=3, + num_global_visits=2, + local_route_indices=[3, 2], + shipment_indices=[[4, 6], [2, 8, 0]], + ), + ), + ) + + +class SplitRefinedLocalRouteTest(unittest.TestCase): + """Tests for _split_refined_local_route.""" + + maxDiff = None + + def test_empty_route(self): + self.assertSequenceEqual( + two_step_routing._split_refined_local_route({}), () + ) + self.assertSequenceEqual( + two_step_routing._split_refined_local_route( + {"visits": [], "transitions": []} + ), + (), + ) + + def test_single_round(self): + visits: list[cfr_json.Visit] = [ + {"shipmentIndex": 0, "isPickup": True}, + {"shipmentIndex": 2, "isPickup": True}, + {"shipmentIndex": 8, "isPickup": True}, + {"shipmentIndex": 5, "isPickup": True}, + {"shipmentIndex": 2, "isPickup": False}, + {"shipmentIndex": 5, "isPickup": False}, + {"shipmentIndex": 0, "isPickup": False}, + {"shipmentIndex": 8, "isPickup": False}, + ] + transitions = [ + {"totalDuration": "0s"}, + {"totalDuration": "0s"}, + {"totalDuration": "0s"}, + {"totalDuration": "0s"}, + {"totalDuration": "30s"}, + {"totalDuration": "45s"}, + {"totalDuration": "60s"}, + {"totalDuration": "72s"}, + {"totalDuration": "18s"}, + ] + route: cfr_json.ShipmentRoute = { + "visits": visits, + "transitions": transitions, + } + self.assertSequenceEqual( + two_step_routing._split_refined_local_route(route), + ((visits[4:], transitions[4:]),), + ) + + def test_multiple_rounds(self): + visits: list[cfr_json.Visit] = [ + {"shipmentIndex": 0, "isPickup": True}, + {"shipmentIndex": 2, "isPickup": False}, + {"shipmentIndex": 2, "isPickup": True}, + {"shipmentIndex": 8, "isPickup": True}, + {"shipmentIndex": 5, "isPickup": False}, + {"shipmentIndex": 0, "isPickup": False}, + {"shipmentIndex": 5, "isPickup": True}, + {"shipmentIndex": 8, "isPickup": False}, + ] + transitions = [ + {"totalDuration": "0s"}, + {"totalDuration": "14s"}, + {"totalDuration": "16s"}, + {"totalDuration": "0s"}, + {"totalDuration": "32s"}, + {"totalDuration": "45s"}, + {"totalDuration": "27s"}, + {"totalDuration": "72s"}, + {"totalDuration": "18s"}, + ] + route: cfr_json.ShipmentRoute = { + "visits": visits, + "transitions": transitions, + } + self.assertSequenceEqual( + two_step_routing._split_refined_local_route(route), + ( + (visits[1:2], transitions[1:3]), + (visits[4:6], transitions[4:7]), + (visits[7:], transitions[7:]), + ), + ) + + +class ParseRefinementVehicleLabelTest(unittest.TestCase): + """Tests for _parse_refinement_vehicle_label.""" + + def test_empty_label(self): + with self.assertRaisesRegex( + ValueError, "Invalid vehicle label in refinement model" + ): + two_step_routing._parse_refinement_vehicle_label("") + + def test_invalid_label(self): + with self.assertRaisesRegex( + ValueError, "Invalid vehicle label in refinement model" + ): + two_step_routing._parse_refinement_vehicle_label( + "global_route:foo start:1 size:2" + ) + + def test_valid_label(self): + self.assertEqual( + two_step_routing._parse_refinement_vehicle_label( + "global_route:32 start:1 size:2" + ), + (32, 1, 2), + ) + + class ParseGlobalShipmentLabelTest(unittest.TestCase): """Tests for _parse_global_shipment_label."""