From dca66dbb11c8f549ca4a4a3a65b2b44ec59abf0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20S=C3=BDkora?= Date: Thu, 17 Aug 2023 01:12:46 +0200 Subject: [PATCH] Added example code that solves two-step routing problems with CFR. (#89) --- examples/two_step_routing/README.md | 31 + examples/two_step_routing/__init__.py | 0 .../two_step_routing/example_parking.json | 26 + .../two_step_routing/example_request.json | 286 +++ examples/two_step_routing/two_step_routing.py | 1207 ++++++++++ .../two_step_routing/two_step_routing_main.py | 228 ++ .../two_step_routing/two_step_routing_test.py | 2029 +++++++++++++++++ 7 files changed, 3807 insertions(+) create mode 100644 examples/two_step_routing/README.md create mode 100644 examples/two_step_routing/__init__.py create mode 100644 examples/two_step_routing/example_parking.json create mode 100644 examples/two_step_routing/example_request.json create mode 100644 examples/two_step_routing/two_step_routing.py create mode 100644 examples/two_step_routing/two_step_routing_main.py create mode 100644 examples/two_step_routing/two_step_routing_test.py diff --git a/examples/two_step_routing/README.md b/examples/two_step_routing/README.md new file mode 100644 index 00000000..6a8b315b --- /dev/null +++ b/examples/two_step_routing/README.md @@ -0,0 +1,31 @@ +# Two-step routing example + +This directory contains a Python library that uses the Cloud Fleet Routing (CFR) +API to optimize routes with two-step deliveries: under this model, shipments can +be handled in two ways: +- delivered directly: the vehicle handling the shipment arrives directly to the + final delivery addres. +- delivered through a parking location: when handling the shipment, the vehicle + parks at a specified parking location, while the driver delivers the shipment + by foot. + +This library solves the problem by decomposing it into two CFR requests that are +solved using the CFR API, and then combining their solution to build the +combined driving/walking routes. + +## Example + +``` +CLOUD_ACCESS_TOKEN=$(gcloud auth print-access-token) +CLOUD_PROJECT_ID=... +python3 two_step_routing_main.py \ + --request=example_request.json \ + --parking=example_parking.json \ + --project="${CLOUD_PROJECT_ID}" \ + --token="${CLOUD_ACCESS_TOKEN}" +``` + +## License + +The example code is licensed under an MIT-style license, see +[LICENSE](../../LICENSE) for details. diff --git a/examples/two_step_routing/__init__.py b/examples/two_step_routing/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/two_step_routing/example_parking.json b/examples/two_step_routing/example_parking.json new file mode 100644 index 00000000..cdc710a3 --- /dev/null +++ b/examples/two_step_routing/example_parking.json @@ -0,0 +1,26 @@ +{ + "parking_locations": [ + { + "coordinates": {"latitude": 48.86482, "longitude": 2.34932}, + "tag": "P001", + "travel_duration_multiple": 1.1, + "delivery_load_limits": {"ore": 2} + }, + { + "coordinates": {"latitude": 48.86482, "longitude": 2.34932}, + "tag": "P002", + "travel_mode": 2, + "delivery_load_limits": {"ore": 2} + } + ], + "parking_for_shipment": { + "0": "P001", + "1": "P001", + "2": "P001", + "3": "P001", + "4": "P002", + "5": "P002", + "6": "P002", + "7": "P002" + } +} diff --git a/examples/two_step_routing/example_request.json b/examples/two_step_routing/example_request.json new file mode 100644 index 00000000..5f651742 --- /dev/null +++ b/examples/two_step_routing/example_request.json @@ -0,0 +1,286 @@ +{ + "model": { + "shipments": [ + { + "label": "S001", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86471, + "longitude": 2.34901 + } + } + }, + "duration": "120s" + } + ], + "allowedVehicleIndices": [ + 0 + ] + }, + { + "label": "S002", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86593, + "longitude": 2.34886 + } + } + }, + "duration": "150s" + } + ], + "allowedVehicleIndices": [ + 0 + ] + }, + { + "label": "S003", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86594, + "longitude": 2.34887 + } + } + }, + "duration": "60s", + "timeWindows": [ + { + "startTime": "2023-08-11T12:00:00.000Z" + } + ] + } + ], + "allowedVehicleIndices": [ + 0 + ] + }, + { + "label": "S004", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86595, + "longitude": 2.34888 + } + } + }, + "duration": "60s", + "timeWindows": [ + { + "startTime": "2023-08-11T14:00:00.000Z", + "endTime": "2023-08-11T16:00:00.000Z" + } + ] + } + ], + "allowedVehicleIndices": [ + 0 + ] + }, + { + "label": "S005", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86596, + "longitude": 2.34889 + } + } + }, + "duration": "150s" + } + ], + "allowedVehicleIndices": [ + 0, + 1 + ] + }, + { + "label": "S006", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489 + } + } + }, + "duration": "150s" + } + ], + "allowedVehicleIndices": [ + 0, + 1 + ], + "loadDemands": { + "wheat": { + "amount": "3" + }, + "ore": { + "amount": "2" + } + } + }, + { + "label": "S007", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489 + } + } + }, + "duration": "150s" + } + ], + "allowedVehicleIndices": [ + 0, + 1 + ], + "loadDemands": { + "ore": { + "amount": "1" + }, + "wood": { + "amount": "5" + } + } + }, + { + "label": "S008", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489 + } + } + }, + "duration": "150s" + } + ], + "allowedVehicleIndices": [ + 0, + 1 + ] + }, + { + "label": "S009", + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489 + } + } + }, + "duration": "150s" + } + ], + "allowedVehicleIndices": [ + 0, + 1 + ] + } + ], + "vehicles": [ + { + "label": "V001", + "travelMode": 1, + "travelDurationMultiple": 1, + "costPerHour": 60, + "costPerKilometer": 1, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86321, + "longitude": 2.34767 + } + } + }, + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86321, + "longitude": 2.34767 + } + } + }, + "startTimeWindows": [ + { + "startTime": "2023-08-11T08:00:00.000Z", + "endTime": "2023-08-11T08:00:00.000Z" + } + ], + "endTimeWindows": [ + { + "startTime": "2023-08-11T16:00:00.000Z", + "endTime": "2023-08-11T21:00:00.000Z" + } + ] + }, + { + "label": "V002", + "travelMode": 1, + "travelDurationMultiple": 1, + "costPerHour": 60, + "costPerKilometer": 1, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86321, + "longitude": 2.34767 + } + } + }, + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86321, + "longitude": 2.34767 + } + } + }, + "startTimeWindows": [ + { + "startTime": "2023-08-11T08:00:00.000Z", + "endTime": "2023-08-11T08:00:00.000Z" + } + ], + "endTimeWindows": [ + { + "startTime": "2023-08-11T20:00:00.000Z", + "endTime": "2023-08-11T21:00:00.000Z" + } + ] + } + ], + "globalStartTime": "2023-08-11T00:00:00.000Z", + "globalEndTime": "2023-08-12T00:00:00.000Z" + }, + "searchMode": 1, + "label": "my_little_model", + "parent": "my_awesome_project" +} \ No newline at end of file diff --git a/examples/two_step_routing/two_step_routing.py b/examples/two_step_routing/two_step_routing.py new file mode 100644 index 00000000..4504d4a8 --- /dev/null +++ b/examples/two_step_routing/two_step_routing.py @@ -0,0 +1,1207 @@ +# Copyright 2023 Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE file or at https://opensource.org/licenses/MIT. + +"""Implements a basic two-step route optimization algorithm on top of CFR. + +Takes a Cloud Fleet Routing (CFR) request augmented with parking location data, +and creates a response that uses the given parking locations for deliveries. + +Technically, the optimization is done by decomposing the original request into +a sequence of requests that solve parts of the optimization problem, and then +recombining them into full routes that include both driving and walking +directions. + +On a high level, the solver does the following: +1. For each parking location, compute optimized routes for shipments that are + delivered from this parking location. These routes start at the parking + location and visit one or more final delivery locations. Additional + constraints may be used to limit the length of these local routes. + + These optimized routes are used in two ways: + - they provide an estimation of the time necessary to serve each parking + location; this estimate is used in the global plan. + - they are included in the final routes. + +2. Based on the results from step 1, compute optimized routes that connect the + parking locations and shipments that are delivered directly to the customer + location (i.e. they are not delivered through a parking location). + + All shipments that are delivered through a parking location are represented + by one (or more) "virtual" shipments that represent the parking location and + its shipments. This shipment has has the coordinates of the parking location + and the visit duration is equivalent to the time needed to deliver the + shipments from the parking location. + +3. The results from both plans are merged into a final plan that includes both + directions for "driving" from the depots to the parking locations (and to + custommer sites that are not served from a parking locatio) and directions + for "walking" from the parking location to the final delivery locations. + + This plan contains all shipments from the original request, and pairs of + "virtual" shipments that represent arrivals to and departures from parking + locations. +""" + +import collections +from collections.abc import Collection, Mapping, Sequence, Set +import copy +import dataclasses +import datetime +import math +import re +from typing import TypeAlias, TypedDict + +# A duration in a string format following the protocol buffers specification in +# https://protobuf.dev/reference/protobuf/google.protobuf/#duration +DurationString: TypeAlias = str + +# A timestamp in a string format following the protocol buffers specification in +# https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp. +TimeString: TypeAlias = str + + +# The amount value represented as a string. This is effectively an int64 stored +# as a string, because JSON doesn't have 64-bit integers. See the reference in +# https://developers.google.com/discovery/v1/type-format +Int64String: TypeAlias = str + +# These TypedDicts are based on the JSON format for CFR requests that uses +# smallCamelCase for all names. Note that these are not full definitions, they +# have only attributes that are used in the code of the two-step planner. +# +# pylint: disable=invalid-name + + +class LatLng(TypedDict): + """Represents a latitude-longitude pair in the JSON CFR request.""" + + latitude: float + longitude: float + + +class DurationLimit(TypedDict, total=False): + """Represents a duration limit in the JSON CFR request.""" + + maxDuration: DurationString + + +class TimeWindow(TypedDict, total=False): + """Represents a time window in the JSON CFR request.""" + + startTime: TimeString + softEndTime: TimeString + endTime: TimeString + + costPerHourAfterSoftEndTime: float + + +class Load(TypedDict): + """Represents a load object in the JSON CFR request.""" + + amount: Int64String + + +class LoadLimit(TypedDict): + """Represents the vehicle load limit in the JSON CFR request.""" + + maxLoad: Int64String + + +class Location(TypedDict): + """Represents a location in the JSON CFR request.""" + + latLng: LatLng + + +class Waypoint(TypedDict): + """Represents a waypoint in the JSON CFR request.""" + + location: Location + + +class VisitRequest(TypedDict, total=False): + """Represents a delivery in the JSON CFR request.""" + + arrivalWaypoint: Waypoint + timeWindows: list[TimeWindow] + duration: DurationString + + +class Shipment(TypedDict, total=False): + """Represents a shipment in the JSON CFR request.""" + + pickups: list[VisitRequest] + deliveries: list[VisitRequest] + label: str + + allowedVehicleIndices: list[int] + + loadDemands: dict[str, Load] + + penaltyCost: float + costsPerVehicle: list[float] + costsPerVehicleIndices: list[int] + + +class Vehicle(TypedDict, total=False): + """Represents a vehicle in the JSON CFR request.""" + + label: str + + startWaypoint: Waypoint + endWaypoint: Waypoint + + startTimeWindows: list[TimeWindow] + endTimeWindows: list[TimeWindow] + + travelMode: int + travelDurationMultiple: float + + routeDurationLimit: DurationLimit + + fixedCost: float + costPerHour: float + costPerKilometer: float + + loadLimits: dict[str, LoadLimit] + + +class ShipmentModel(TypedDict, total=False): + """Represents a shipment model in the JSON CFR request.""" + + shipments: list[Shipment] + vehicles: list[Vehicle] + globalStartTime: TimeString + globalEndTime: TimeString + + +class OptimizeToursRequest(TypedDict, total=False): + """Represents the JSON CFR request.""" + + label: str + model: ShipmentModel + parent: str + timeout: DurationString + searchMode: int + + +class Visit(TypedDict, total=False): + """Represents a single visit on a route in the JSON CFR results.""" + + shipmentIndex: int + shipmentLabel: str + startTime: TimeString + detour: str + isPickup: bool + + +class Transition(TypedDict, total=False): + """Represents a single transition on a route in the JSON CFR results.""" + + travelDuration: str + travelDistanceMeters: int + waitDuration: str + totalDuration: str + startTime: str + + +class AggregatedMetrics(TypedDict, total=False): + """Represents aggregated route metrics in the JSON CFR results.""" + + performedShipmentCount: int + totalDuration: DurationString + + +class ShipmentRoute(TypedDict, total=False): + """Represents a single route in the JSON CFR result.""" + + vehicleIndex: int + vehicleLabel: str + + vehicleStartTime: str + vehicleEndTime: str + + visits: list[Visit] + transitions: list[Transition] + metrics: AggregatedMetrics + + routeTotalCost: float + + +class SkippedShipment(TypedDict, total=False): + """Represents a skipped shipment in the JSON CFR result.""" + + index: int + penaltyCost: float + label: str + + +class OptimizeToursResponse(TypedDict, total=False): + """Represents the JSON CFR result.""" + + routes: list[ShipmentRoute] + skippedShipments: list[SkippedShipment] + totalCost: float + + +# pylint: enable=invalid-name + +# A key used to group shipments into parking groups. The key contains the +# parking tag of the shipment (if used), and the delivery time window timestamps +# (if present). +_ParkingGroupKey = tuple[str | None, str | None, str | None] + +# The type of parking location tags. Technically, this is a string, but we use +# an alias with a different name to make this apparent from type annotations +# alone. +ParkingTag: TypeAlias = str + + +@dataclasses.dataclass(frozen=True) +class ParkingLocation: + """Defines one parking location for the planner. + + Attributes: + coordinates: The coordinates of the parking location. When delivering a + shipment using the two-step delivery, the driver first drives to these + coordinates and then uses a different mode of transport to the final + delivery location. + tag: A unique name used for the parking location. Used to match parking + locations in `ShipmentParkingMap`, and it is also used in the labels of + the virtual shipments generated for parking locations by the planner. + travel_mode: The travel mode used in the CFR requests when computing + optimized routes from the parking lot to the final delivery locations. + Overrides `Vehicle.travel_mode` for vehicles used in the local route + optimization plan. + travel_duration_multiple: The travel duration multiple used when computing + optimized routes from the parking lot to the final delivery locations. + Overrides `Vehicle.travel_duration_multiple` for vehicles used in the + local route optimization plan. + delivery_load_limits: The load limits applied when delivering shipments from + the parking location. This is equivalent to Vehicle.loadLimits, and it + restricts the number of shipments that can be delivered without returning + to the parking location. When the number of shipments delivered from the + parking location exceeds this limit, the model will create multiple routes + starting and ending at the parking location that will appear as multiple + visits to the parking location in the global model. Since the local model + allows only very limited cost tuning, we accept only one value per unit, + and this value is used as the hard limit. + """ + + coordinates: LatLng + tag: str + + travel_mode: int = 1 + travel_duration_multiple: float = 1.0 + + delivery_load_limits: Mapping[str, int] | None = None + + +@dataclasses.dataclass +class Options: + """Options for the two-step planner. + + Attributes: + local_model_vehicle_fixed_cost: The fixed cost of the vehicles in the local + model. This should be a high number to make the solver use as few vehicles + as possible. + local_model_vehicle_per_hour_cost: The per-hour cost of the vehicles in the + local model. This should be a small positive number so that the solver + prefers faster routes. + local_model_vehicle_per_km_cost: The per-kilometer cost of the vehicles in + the local model. This should be a small positive number so that the solver + prefers shorter routes. + 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. + max_round_duration: The maximal duration of a single delivery round from the + parking location to customer sites. + """ + + # TODO(ondrasej): Do we actually need these? Perhaps they can be filled in on + # the user side. + local_model_vehicle_fixed_cost: float = 10_000 + local_model_vehicle_per_hour_cost: float = 300 + local_model_vehicle_per_km_cost: float = 60 + + min_average_shipments_per_round: int = 4 + max_round_duration: str = "7200s" + + +# 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 +# the value is the label of the parking location through which it is delivered. +ShipmentParkingMap = Mapping[int, ParkingTag] + + +class Planner: + """The two-step routing planner.""" + + _request: OptimizeToursRequest + _model: ShipmentModel + _options: Options + _shipments: Sequence[Shipment] + _vehicles: Sequence[Vehicle] + + _parking_locations: Mapping[str, ParkingLocation] + _parking_for_shipment: ShipmentParkingMap + _parking_groups: Mapping[_ParkingGroupKey, Sequence[int]] + _direct_shipments: Set[int] + + def __init__( + self, + request_json: OptimizeToursRequest, + parking_locations: Collection[ParkingLocation], + parking_for_shipment: ShipmentParkingMap, + options: Options, + ): + """Initializes the two-step planner. + + Args: + request_json: The CFR JSON request, in the natural Python format. + parking_locations: The list of parking locations used in the plan. + parking_for_shipment: Mapping from shipment indices in `request_json` to + tags of parking locations in `parking_locations`. + options: Options of the two-step planner. + + Raises: + ValueError: When an inconsistency is found in the input data. For example + when a shipment index or parking location tag in `parking_for_shipment` + is invalid. + """ + self._options = options + self._request = request_json + + # TODO(ondrasej): Do more extensive validation of the model, in particular + # check that it does not use any unexpected features. + try: + self._model = self._request["model"] + self._shipments = self._model["shipments"] + self._vehicles = self._model["vehicles"] + except KeyError as e: + raise ValueError( + "The request does not have the expected structure" + ) from e + + self._num_shipments = len(self._shipments) + + # Index and validate the parking locations. + indexed_parking_locations = {} + for parking in parking_locations: + if parking.tag in indexed_parking_locations: + raise ValueError(f"Duplicate parking tag: {parking.tag}") + indexed_parking_locations[parking.tag] = parking + self._parking_locations: Mapping[str, ParkingLocation] = ( + indexed_parking_locations + ) + + # Index and validate the mapping between shipments and parking locations. + self._parking_for_shipment = parking_for_shipment + + parking_groups = collections.defaultdict(list) + for shipment_index, parking_tag in self._parking_for_shipment.items(): + if parking_tag not in self._parking_locations: + raise ValueError( + f"Parking tag '{parking_tag}' from parking_for_shipment was not" + " found in parking_locations." + ) + if shipment_index < 0 or shipment_index >= self._num_shipments: + raise ValueError( + f"Invalid shipment index: {shipment_index}. The shipment index must" + f" be between 0 and {self._num_shipments}" + ) + shipment = self._shipments[shipment_index] + parking = self._parking_locations[parking_tag] + parking_group_key = _parking_delivery_group_key(shipment, parking) + parking_groups[parking_group_key].append(shipment_index) + self._parking_groups: Mapping[_ParkingGroupKey, Sequence[int]] = ( + parking_groups + ) + + # Collect indices of shipments that are delivered directly. + self._direct_shipments = set(range(self._num_shipments)) + self._direct_shipments.difference_update(self._parking_for_shipment.keys()) + + def make_local_request(self) -> OptimizeToursRequest: + """Builds the local model request. + + Returns: + The JSON CFR request for the local model, in the natural Python format. + The request can be exported to a JSON string via `json.dumps()`. + + Note that, for efficiency reasons, the returned data structure may contain + parts of the input data strucutres, and it is thus not safe to mutate. If + mutating it is needed, first make a copy via copy.deepcopy(). + """ + + local_shipments: list[Shipment] = [] + local_vehicles: list[Vehicle] = [] + local_model = { + "globalEndTime": self._model["globalEndTime"], + "globalStartTime": self._model["globalStartTime"], + "shipments": local_shipments, + "vehicles": local_vehicles, + } + + round_duration_limit: DurationLimit = { + "maxDuration": self._options.max_round_duration + } + + for parking_key, group_shipment_indices in self._parking_groups.items(): + parking_tag, start_time, end_time = parking_key + assert parking_tag is not None + parking = self._parking_locations[parking_tag] + num_shipments = len(group_shipment_indices) + assert num_shipments > 0 + + # Add a virtual vehicle for each delivery round from the parking location + # to customer sites. We use the minimal average number of shipments per + # round to compute a bound on the required number of vehicles. + max_num_rounds = math.ceil( + num_shipments / self._options.min_average_shipments_per_round + ) + assert max_num_rounds > 0 + vehicle_label = _make_local_model_vehicle_label( + parking_tag, start_time, end_time + ) + parking_waypoint: Waypoint = {"location": {"latLng": parking.coordinates}} + group_vehicle_indices = [] + for round_index in range(max_num_rounds): + group_vehicle_indices.append(len(local_vehicles)) + vehicle: Vehicle = { + "label": f"{vehicle_label}/{round_index}", + # Start and end waypoints. + "endWaypoint": parking_waypoint, + "startWaypoint": parking_waypoint, + # Limits and travel speed. + "routeDurationLimit": round_duration_limit, + "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, + } + 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() + } + if start_time is not None: + vehicle["startTimeWindows"] = [{ + "startTime": start_time, + }] + if end_time is not None: + vehicle["endTimeWindows"] = [{ + "endTime": end_time, + }] + local_vehicles.append(vehicle) + + # Add shipments from the group. From each shipment, we preserve only the + # necessary properties for the local plan. + for shipment_index in group_shipment_indices: + shipment = self._shipments[shipment_index] + delivery = shipment["deliveries"][0] + local_shipment: Shipment = { + "deliveries": [{ + "arrivalWaypoint": delivery["arrivalWaypoint"], + "duration": delivery["duration"], + }], + "label": f"{shipment_index}: {shipment['label']}", + "allowedVehicleIndices": group_vehicle_indices, + } + # Copy load demands from the original shipment, if present. + load_demands = shipment.get("loadDemands") + if load_demands is not None: + local_shipment["loadDemands"] = load_demands + local_shipments.append(local_shipment) + + return { + "label": self._request.get("label", "") + "/local", + "model": local_model, + "parent": self._request.get("parent"), + } + + def make_global_request( + self, local_response: OptimizeToursResponse + ) -> OptimizeToursRequest: + """Creates a request for the global model. + + Args: + local_response: A solution to the local model created by + self.make_local_request() in the JSON format. + + Returns: + A JSON CFR request for the global model based on a solution of the local + model. + + Note that, for efficiency reasons, the returned data structure may contain + parts of the input data strucutres, and it is thus not safe to mutate. If + mutating it is needed, first make a copy via copy.deepcopy(). + + Raises: + ValueError: When `local_response` has an unexpected format. + """ + + # TODO(ondrasej): Validate that the local results corresponds to the + # original request. + global_shipments: list[Shipment] = [] + global_model: ShipmentModel = { + "globalStartTime": self._model["globalStartTime"], + "globalEndTime": self._model["globalEndTime"], + "shipments": global_shipments, + # Vehicles are the same as in the original request. + "vehicles": self._model["vehicles"], + } + + # Take all shipments that are delivered directly, and copy them to the + # global request. the only change we make is that we add the original + # shipment index to their label. + for shipment_index in self._direct_shipments: + # We"re changing only the label - no need to make a deep copy. + shipment = copy.copy(self._shipments[shipment_index]) + shipment["label"] = f"s:{shipment_index} {shipment.get('label')}" + global_shipments.append(shipment) + + # Create a single virtual shipment for each group of shipments that are + # delivered together through a parking location. Note that this way, we may + # get multiple virtual shipments for a single parking location, if the + # parking location has shipments with multiple time windows or if there are + # too many shipments to deliver in one round. In the optimized routes, they + # 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: + # 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 + # We need one shipment to determine the time window of the parking + # location visit (if there is one). + shipment = shipments[0] + + global_delivery: 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"], + } + # The delivery time windows of all the shipments on the local route are + # either the same, or none of them has a delivery time window. We just + # take the time windows definition of one of them and if present, we use + # it as the time window of the delivery in the global model. + time_windows = shipment["deliveries"][0].get("timeWindows") + if time_windows is not None: + global_time_windows = [] + local_route_duration = _parse_duration_string( + route["metrics"]["totalDuration"] + ) + duration_to_first_shipment = _parse_duration_string( + route["transitions"][0]["totalDuration"] + ) + duration_from_last_shipment = _parse_duration_string( + route["transitions"][-1]["totalDuration"] + ) + for time_window in time_windows: + global_time_window = {} + if "startTime" in time_window: + # Shift the beginning of the time window so that the walking time to + # the first delivery on the route from the parking location does not + # eat time from the delivery time window. + start_time = _parse_time_string(time_window["startTime"]) + global_time_window["startTime"] = _make_time_string( + start_time - duration_to_first_shipment + ) + if "endTime" in time_window: + # Shift the end of the time window so that (1) the driver has enough + # time to do all deliveries within the time window, and (2) the time + # to walk from the last shipment back to the parking location does + # not eat from the delivery time window. + end_time = _parse_time_string(time_window["endTime"]) + global_time_window["endTime"] = _make_time_string( + end_time - local_route_duration + duration_from_last_shipment + ) + global_time_windows.append(global_time_window) + + global_delivery["timeWindows"] = global_time_windows + + shipment_labels = ",".join(shipment["label"] for shipment in shipments) + global_shipment: 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 = _combined_load_demands(shipments) + if load_demands: + global_shipment["loadDemands"] = load_demands + + # Add the penalty cost of the virtual shipment if needed. + penalty_cost = _combined_penalty_cost(shipments) + if penalty_cost is not None: + global_shipment["penaltyCost"] = penalty_cost + + allowed_vehicle_indices = _combined_allowed_vehicle_indices(shipments) + if allowed_vehicle_indices: + global_shipment["allowedVehicleIndices"] = allowed_vehicle_indices + + costs_per_vehicle_and_indices = _combined_costs_per_vehicle(shipments) + if costs_per_vehicle_and_indices is not None: + costs, vehicle_indices = costs_per_vehicle_and_indices + global_shipment["costsPerVehicle"] = costs + global_shipment["costsPerVehicleIndices"] = vehicle_indices + + global_shipments.append(global_shipment) + + return { + "label": self._request.get("label", "") + "/global", + "model": global_model, + "parent": self._request.get("parent"), + } + + def merge_local_and_global_result( + self, + local_response: OptimizeToursResponse, + global_response: OptimizeToursResponse, + ) -> tuple[OptimizeToursRequest, OptimizeToursResponse]: + """Creates a merged request and a response from the local and global models. + + The merged request and response incorporate both the global "driving" routes + from the global model and the local "walking" routes from the local model. + The merged request uses the same shipments as the original request, extended + with "virtual" shipments used to represent parking location arrivals and + departures in the merged routes. + + Each parking location visit from the global plan is replaced by a visit to + a virtual shipment that represents the arrival to the parking location, then + all shipments delivered from the parking location, and then another virtual + shipment that represents the departure from the parking location. The + virtual shipments use the coordinates of the parking location as their + position; the actual shipments delivered through the parking location and + transitions between them are taken from the local plan. + + The request and the response follow the same structure as standard CFR JSON + requests, but they do not use all fields, and sending the merged request to + the CFR API endpoint would not produce the merged response. The pair can + however be used for example to inspect the solution in the fleet routing app + or be used by other applications that consume a CFR response. + + Args: + local_response: A solution of the local model created by + self.make_local_request(). The local request itself is not needed. + global_response: A solution of the global model created by + self.make_global_request(local_response). The global request itself is + not needed. + + Returns: + A tuple (merged_request, merged_response) that contains the merged data + from the original request and the local and global results. + + Note that, for efficiency reasons, the returned data structure may contain + parts of the input data strucutres, and it is thus not safe to mutate. If + mutating it is needed, first make a copy via copy.deepcopy(). + """ + + # The shipments in the merged request consist of all shipments in the + # original request + virtual shipments to handle parking location visits. We + # preserve the shipment indices from the original request, and add all the + # virtual shipments at the end. + merged_shipments: list[Shipment] = copy.copy(self._shipments) + merged_model: ShipmentModel = { + # The start and end time remain unchanged. + "globalStartTime": self._model["globalStartTime"], + "globalEndTime": self._model["globalEndTime"], + "shipments": merged_shipments, + # The vehicles in the merged model are the vehicles from the global + # model and from the local model. This preserves vehicle indices from + # the original request. + "vehicles": self._model["vehicles"], + } + merged_request: OptimizeToursRequest = { + "model": merged_model, + "label": self._request.get("label", "") + "/merged", + "parent": self._request.get("parent"), + } + merged_routes: list[ShipmentRoute] = [] + merged_result: OptimizeToursResponse = { + "routes": merged_routes, + } + + local_routes = local_response["routes"] + + for global_route in global_response["routes"]: + global_visits = global_route.get("visits", ()) + if not global_visits: + # This is an unused vehicle in the global model. We can just copy the + # route as is. + merged_routes.append(global_route) + continue + + global_transitions = global_route["transitions"] + merged_visits: list[Visit] = [] + merged_transitions: list[Transition] = [] + merged_routes.append( + { + "vehicleIndex": global_route.get("vehicleIndex", 0), + "vehicleLabel": global_route["vehicleLabel"], + "vehicleStartTime": global_route["vehicleStartTime"], + "vehicleEndTime": global_route["vehicleEndTime"], + "visits": merged_visits, + "transitions": merged_transitions, + "routeTotalCost": global_route["routeTotalCost"], + # TODO(ondrasej): metrics, detailed costs, ... + } + ) + + 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: ShipmentRoute, 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: Shipment = { + "label": f"{parking.tag} {arrival_or_departure}", + "deliveries": [{ + "arrivalWaypoint": { + "location": {"latLng": parking.coordinates} + }, + "duration": "0s", + }], + # TODO(ondrasej): Vehicle costs and allowed vehicle indices. + } + merged_shipments.append(shipment) + return shipment_index, shipment + + 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. + merged_transitions.append(global_transitions[global_visit_index]) + global_visit_label = global_visit["shipmentLabel"] + visit_type, index = _parse_global_shipment_label(global_visit_label) + match visit_type: + case "s": + # This is direct delivery of one of the shipments in the original + # request. We just copy it and update the shipment index and label + # accordingly. + merged_visit = copy.deepcopy(global_visit) + merged_visit["shipmentIndex"] = index + merged_visit["shipmentLabel"] = self._shipments[index]["label"] + merged_visits.append(merged_visit) + case "p": + # This is delivery through a parking location. We need to copy parts + # of the route from the local model solution, and add virtual + # shipments for entering and leaving the parking location. + local_route = local_routes[index] + arrival_shipment_index, arrival_shipment = ( + add_parking_location_shipment(local_route, arrival=True) + ) + global_start_time = _parse_time_string(global_visit["startTime"]) + local_start_time = _parse_time_string( + local_route["vehicleStartTime"] + ) + local_to_global_delta = global_start_time - local_start_time + merged_visits.append({ + "shipmentIndex": arrival_shipment_index, + "shipmentLabel": arrival_shipment["label"], + "startTime": global_visit["startTime"], + }) + + # Transfer all visits and transitions from the local route. Update + # the timestamps as needed. + local_visits = local_route["visits"] + local_transitions = local_route["transitions"] + for local_visit_index, local_visit in enumerate(local_visits): + local_transition_in = local_transitions[local_visit_index] + merged_transition = copy.deepcopy(local_transition_in) + merged_transition["startTime"] = _update_time_string( + merged_transition["startTime"], local_to_global_delta + ) + merged_transitions.append(merged_transition) + + shipment_index = _get_shipment_index_from_local_route_visit( + local_visit + ) + merged_visit: Visit = { + "shipmentIndex": shipment_index, + "shipmentLabel": self._shipments[shipment_index]["label"], + "startTime": _update_time_string( + local_visit["startTime"], local_to_global_delta + ), + } + merged_visits.append(merged_visit) + + # Add a transition back to the parking location. + transition_to_parking = copy.deepcopy(local_transitions[-1]) + transition_to_parking["startTime"] = _update_time_string( + transition_to_parking["startTime"], local_to_global_delta + ) + merged_transitions.append(transition_to_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) + ) + merged_visits.append({ + "shipmentIndex": departure_shipment_index, + "shipmentLabel": departure_shipment["label"], + "startTime": _update_time_string( + local_route["vehicleEndTime"], local_to_global_delta + ), + }) + case _: + raise ValueError(f"Unexpected visit type: '{visit_type}'") + + # Add the transition back to the depot. + merged_transitions.append(global_transitions[-1]) + + merged_skipped_shipments = [] + for local_skipped_shipment in local_response.get("skippedShipments", ()): + shipment_index, label = local_skipped_shipment["label"].split( + ": ", maxsplit=1 + ) + merged_skipped_shipments.append({ + "index": int(shipment_index), + "label": label, + }) + for global_skipped_shipment in global_response.get("skippedShipments", ()): + shipment_type, index = _parse_global_shipment_label( + global_skipped_shipment["label"] + ) + match shipment_type: + case "s": + # Shipments delivered directly can be added directly to the list. + merged_skipped_shipments.append(global_skipped_shipment) + 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"]: + shipment_index, label = visit["shipmentLabel"].split( + " ", maxsplit=1 + ) + merged_skipped_shipments.append({ + "index": int(shipment_index), + "label": label, + }) + + if merged_skipped_shipments: + merged_result["skippedShipments"] = merged_skipped_shipments + + return merged_request, merged_result + + +def validate_request( + request: OptimizeToursRequest, + parking_for_shipment: ShipmentParkingMap, +) -> Sequence[str] | None: + """Checks that request conforms to the requirements of the two-step planner. + + Args: + request: The validated request in the CFR JSON format. + parking_for_shipment: Mapping from shipment indices in the request to + parking location tags. + + Returns: + A list of errors found during the validation or None when no issues + are found. Note that this function might not be exhaustive, and even if it + does not return any errors, the two-step planner may still not support the + plan correctly. + """ + shipments = request["model"]["shipments"] + errors = [] + + def append_shipment_error(error: str, shipment_index: int, label: str): + errors.append(f"{error}. Invalid shipment {shipment_index} ({label!r})") + + for shipment_index, shipment in enumerate(shipments): + if shipment_index not in parking_for_shipment: + # Shipment is not delivered via a parking location. + continue + + label = shipment.get("label", "") + + if shipment.get("pickups"): + append_shipment_error( + "Shipments delivered via parking must not have any pickups", + shipment_index, + label, + ) + + deliveries = shipment.get("deliveries", ()) + if len(deliveries) != 1: + append_shipment_error( + "Shipments delivered via parking must have exactly one delivery" + " visit request", + shipment_index, + label + ) + continue + + delivery = deliveries[0] + time_windows = delivery.get("timeWindows", ()) + if len(time_windows) > 1: + append_shipment_error( + "Shipments delivered via parking must have at most one delivery time" + " window", + shipment_index, + label + ) + + if errors: + return errors + return None + + +_GLOBAL_SHIPEMNT_LABEL = re.compile(r"^([ps]):(\d+) .*") + + +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}"') + return match[1], int(match[2]) + + +def _combined_penalty_cost( + shipments: Collection[Shipment], +) -> float | None: + """Returns the combined skipped shipment penalty cost of a group of shipments. + + Args: + shipments: The list of shipments. + + Returns: + The sum of the penalty costs of the shipments or None if any of the + shipments is mandatory. + """ + cost_sum = 0 + for shipment in shipments: + shipment_cost = shipment.get("penaltyCost") + if shipment_cost is None: + return None + cost_sum += shipment_cost + return cost_sum + + +def _combined_costs_per_vehicle( + shipments: Collection[Shipment], +) -> tuple[list[int], list[float]] | None: + """Returns the combined shipment-vehicle costs for the shipments. + + The cost of the group for a vehicle is the maximum of the costs of the + individual shipments for that vehicle. + + Args: + shipments: The group of shipments for which the costs are computed. + + Returns: + A tuple (vehicle_indices, costs) that can be used in attributes + `costsPerVehicle` and `costsPerVehicleIndices` of a shipment. Returns None + when there are no vehicle-shipment costs. + """ + vehicle_costs = collections.defaultdict(float) + for shipment in shipments: + costs = shipment.get("costsPerVehicle") + if costs is None: + continue + vehicle_indices = shipment.get("costsPerVehicleIndices") + if vehicle_indices is None: + raise ValueError( + "Vehicle-shipment costs are supported only when using" + " costsPerVehicleIndices." + ) + for vehicle_index, cost in zip(vehicle_indices, costs, strict=True): + vehicle_costs[vehicle_index] = max(vehicle_costs[vehicle_index], cost) + + if not vehicle_costs: + # There were no vehicle-shipment costs. + return None + + # Convert the dict into a list of costs and a list of corresponding indices. + indices, costs = zip(*sorted(vehicle_costs.items())) + return list(indices), list(costs) + + +def _combined_allowed_vehicle_indices( + shipments: Collection[Shipment], +) -> list[int] | None: + """Returns the list of allowed vehicle indices that can serve all shipments.""" + allowed_vehicles = None + for shipment in shipments: + shipment_allowed_vehicles = shipment.get("allowedVehicleIndices") + if shipment_allowed_vehicles is None: + continue + if allowed_vehicles is None: + allowed_vehicles = set(shipment_allowed_vehicles) + else: + allowed_vehicles.intersection_update(shipment_allowed_vehicles) + if not allowed_vehicles: + raise ValueError("No allowed vehicles are left") + if allowed_vehicles is None: + return None + return sorted(allowed_vehicles) + + +def _combined_load_demands(shipments: Collection[Shipment]) -> dict[str, Load]: + """Computes the combined load demands of all shipments in `shipments`.""" + demands = collections.defaultdict(int) + for shipment in shipments: + shipment_demands = shipment.get("loadDemands") + if shipment_demands is None: + continue + for unit, amount in shipment_demands.items(): + demands[unit] += int(amount["amount"]) + return {unit: {"amount": str(amount)} for unit, amount in demands.items()} + + +def _get_shipment_index_from_local_label(label: str) -> int: + shipment_index, _ = label.split(":") + return int(shipment_index) + + +def _get_shipment_index_from_local_route_visit(visit: Visit) -> int: + return _get_shipment_index_from_local_label(visit["shipmentLabel"]) + + +def _get_shipment_indices_from_local_route_visits( + visits: Sequence[Visit], +) -> Sequence[int]: + """Returns the list of shipment indices from a route in the local model. + + Args: + visits: The list of visits from a route that is from a solution of the local + model. Shipment labels in the visit must follow the format used in the + local model. + + Raises: + ValueError: When some of the shipment labels do not follow the expected + format. + """ + return tuple( + _get_shipment_index_from_local_route_visit(visit) for visit in visits + ) + + +def _get_parking_tag_from_local_route(route: ShipmentRoute) -> str: + """Extracts the parking location tag from a route. + + Expects that the route is from a solution of the local model, and the vehicle + label in the route follows the format used for the vehicles. + + Args: + route: The route from which the parking tag is extracted. + + Returns: + The parking tag for the route. + + Raises: + ValueError: When the vehicle label of the route does not have the expected + format. + """ + parking_tag, _ = route["vehicleLabel"].rsplit(" [") + if not parking_tag: + raise ValueError( + "Invalid vehicle label in the local route: " + route["vehicleLabel"] + ) + return parking_tag + + +def _make_local_model_vehicle_label( + parking_tag: str, start_time: TimeString | None, end_time: TimeString | None +) -> str: + """Creates a label for a vehicle in the local model.""" + parts = [parking_tag, " ["] + if start_time is not None: + parts.append(start_time) + if start_time is not None or end_time is not None: + parts.append(",") + if end_time is not None: + parts.append(end_time) + parts.append("]") + return "".join(parts) + + +def _parking_delivery_group_key( + shipment: Shipment, parking: ParkingLocation | None +) -> _ParkingGroupKey: + """Creates a key that groups shipments with the same time window and parking.""" + if parking is None: + return (None, None, None) + parking_tag = parking.tag + start_time = None + end_time = None + delivery = shipment["deliveries"][0] + time_window = next(iter(delivery.get("timeWindows", ())), None) + if time_window is not None: + start_time = time_window.get("startTime") + end_time = time_window.get("endTime") + return (parking_tag, start_time, end_time) + + +def _update_time_string( + time_string: TimeString, delta: datetime.timedelta +) -> TimeString: + """Takes the time from `times_string` and adds `delta` to it.""" + timestamp = _parse_time_string(time_string) + updated_timestamp = timestamp + delta + return _make_time_string(updated_timestamp) + + +def _parse_time_string(time_string: TimeString) -> datetime.datetime: + """Parses the time string and converts it into a datetime.""" + if time_string.endswith("Z") or time_string.endswith("z"): + # Drop the 'Z', we do not need it for parsing. + time_string = time_string[:-1] + return datetime.datetime.fromisoformat(time_string) + + +def _make_time_string(timestamp: datetime.datetime) -> TimeString: + """Formats timestampt to a string format used in the CFR JSON API.""" + date_string = timestamp.isoformat() + if "+" not in date_string: + # There is no time zone offset. We need to add the "Z" terminator. + date_string += "Z" + return date_string + + +def _parse_duration_string(duration: DurationString) -> datetime.timedelta: + """Parses the duration string and converts it to a timedelta. + + Args: + duration: The duration in the string format "{number_of_seconds}s". + + Returns: + The duration as a timedelta object. + + Raises: + ValueError: When the duration string does not have the right format. + """ + if not duration.endswith("s"): + raise ValueError(f"Unexpected duration string format: '{duration}'") + seconds = float(duration[:-1]) + return datetime.timedelta(seconds=seconds) diff --git a/examples/two_step_routing/two_step_routing_main.py b/examples/two_step_routing/two_step_routing_main.py new file mode 100644 index 00000000..29085bfd --- /dev/null +++ b/examples/two_step_routing/two_step_routing_main.py @@ -0,0 +1,228 @@ +# Copyright 2023 Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE file or at https://opensource.org/licenses/MIT. + +r"""End-to-end example of running the two-step delivery planner. + +Reads a CFR request and parking location data from JSON file and runs the +two-step delivery planner on by making requests to the CFR service. The CFR +request is a CFR request in the JSON format; the parking data is stored in +a JSON file that contains an object with the following keys: +- "parking_locations": + Contains the list of parking location definitions. Each element of the list + is a dict that contains the attributes of the class + two_step_routing.ParkingLocation. +- "parking_for_shipment": + Contains the mapping from shipment indices to parking location tags for + shipments that are delivered through a parking location. + +To run this, you need to have a Google cloud project with the CFR API enabled, +and have an access token for using the HTTP API: + + PROJECT_ID=... + GCLOUD_TOKEN=$(gcloud auth print-access-token) + + CFR_REQUEST_JSON_FILE=... + PARKING_JSON_FILE=... + + python3 two_step_routing_main.py \ + --request "${CFR_REQUEST_JSON_FILE}" \ + --parking "${PARKING_JSON_FILE} \ + --project "${PROJECT_ID}" \ + --token "${GCLOUD_TOKEN}" +""" + +import argparse +from collections.abc import Mapping +import dataclasses +from http import client +import json +import logging +import os + +import two_step_routing + + +class PlannerError(Exception): + """Raised when there is an exception in the planner.""" + + +@dataclasses.dataclass(frozen=True) +class Flags: + """Holds the values of command-line flags of this script. + + Attributes: + request_file: The value of the --request flag. + parking_file: The value of the --parking flag. + google_cloud_project: The value of the --project flag. + google_cloud_token: The value of the --token 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. + """ + + request_file: str + parking_file: str + google_cloud_project: str + google_cloud_token: str + local_timeout: str + global_timeout: str + + +def _parse_flags() -> Flags: + """Parses the command-line flags from sys.argv.""" + parser = argparse.ArgumentParser(prog="two_step_routing_main") + parser.add_argument( + "--request", + required=True, + help=( + "The name of the file that contains the input CFR request in the JSON" + " format." + ), + ) + parser.add_argument( + "--parking", + required=True, + help=( + "The name of the file that contains the parking data in the JSON" + " format. " + ), + ) + parser.add_argument( + "--project", + required=True, + help="The Google Cloud project ID used for the CFR API requests.", + ) + parser.add_argument( + "--token", required=True, help="The Google Cloud auth key." + ) + parser.add_argument( + "--local_timeout", + help=( + "The timeout used for the local model. Uses the duration string" + " format." + ), + default="240s", + ) + parser.add_argument( + "--global_timeout", + help="The timeout for the global model. Uses the duration string format.", + default="1800s", + ) + flags = parser.parse_args() + + return Flags( + request_file=flags.request, + parking_file=flags.parking, + google_cloud_project=flags.project, + google_cloud_token=flags.token, + local_timeout=flags.local_timeout, + global_timeout=flags.global_timeout, + ) + + +def _run_optimize_tours( + request: two_step_routing.OptimizeToursRequest, flags: Flags +) -> two_step_routing.OptimizeToursResponse: + """Solves request using the Google CFR API. + + Args: + request: The request to be solved. + flags: The command-line flags. + + Returns: + Upon success, returns the response from the server. + + Raises: + PlannerError: When the CFR API invocation fails. The exception contains the + status, explanation, and the body of the response. + """ + host = "cloudoptimization.googleapis.com" + path = f"/v1/projects/{flags.google_cloud_project}:optimizeTours" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {flags.google_cloud_token}", + "x-goog-user-project": flags.google_cloud_project, + } + connection = client.HTTPSConnection(host) + # For longer running requests, it may be necessary to set an explicit deadline + # and set up keepalive pings so that the connection is not dropped before the + # server returns. + connection.request("POST", path, body=json.dumps(request), headers=headers) + response = connection.getresponse() + if response.status != 200: + body = response.read() + raise PlannerError( + f"Request failed: {response.status} {response.reason}\n{body}" + ) + return json.load(response) + + +def _write_json_to_file(filename: str, value) -> None: + """Writes JSON data to a utf-8 text file.""" + with open(filename, "wt", encoding="utf-8") as f: + json.dump(value, f, ensure_ascii=False, indent=2) + + +def _run_two_step_planner() -> None: + """Runs the two-step planner with parameters from command-line flags.""" + flags = _parse_flags() + + logging.info("Parsing %s", flags.request_file) + with open(flags.request_file, "rb") as f: + request_json: two_step_routing.OptimizeToursRequest = json.load(f) + logging.info("Parsing %s", flags.parking_file) + with open(flags.parking_file, "rb") as f: + parking_json = json.load(f) + + base_filename, _ = os.path.splitext(flags.request_file) + + logging.info("Extracting parking locations") + parking_for_shipment: Mapping[int, str] = { + int(shipment): parking + for shipment, parking in parking_json["parking_for_shipment"].items() + } + parking_locations: list[two_step_routing.ParkingLocation] = [] + for parking_location_json in parking_json["parking_locations"]: + parking_locations.append( + two_step_routing.ParkingLocation(**parking_location_json) + ) + + logging.info("Creating local model") + options = two_step_routing.Options() + planner = two_step_routing.Planner( + request_json, parking_locations, parking_for_shipment, options + ) + + local_request = planner.make_local_request() + local_request["searchMode"] = 2 + local_request["timeout"] = flags.local_timeout + _write_json_to_file(base_filename + ".local_request.json", local_request) + + logging.info("Solving local model") + local_response = _run_optimize_tours(local_request, flags) + _write_json_to_file(base_filename + ".local_response.json", local_response) + + logging.info("Creating global model") + global_request = planner.make_global_request(local_response) + global_request["timeout"] = flags.global_timeout + global_request["searchMode"] = 2 + _write_json_to_file(base_filename + ".global_request.json", global_request) + + logging.info("Solving global model") + global_response = _run_optimize_tours(global_request, flags) + _write_json_to_file(base_filename + ".global_response.json", global_response) + + logging.info("Merging the results") + merged_request, merged_response = planner.merge_local_and_global_result( + local_response, global_response + ) + + logging.info("Writing merged request") + _write_json_to_file(base_filename + ".merged_request.json", merged_request) + logging.info("Writing merged response") + _write_json_to_file(base_filename + ".merged_response.json", merged_response) + + +if __name__ == "__main__": + _run_two_step_planner() diff --git a/examples/two_step_routing/two_step_routing_test.py b/examples/two_step_routing/two_step_routing_test.py new file mode 100644 index 00000000..7f695ced --- /dev/null +++ b/examples/two_step_routing/two_step_routing_test.py @@ -0,0 +1,2029 @@ +# Copyright 2023 Google LLC. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be found +# in the LICENSE file or at https://opensource.org/licenses/MIT. + +from collections.abc import Mapping, Sequence +import datetime +import unittest + +import two_step_routing + + +def _make_shipment( + label: str, + latlng: tuple[float, float], + duration: str, + allowed_vehicle_indices: list[int] | None = None, + delivery_start: two_step_routing.TimeString | None = None, + delivery_end: two_step_routing.TimeString | None = None, + load_demands: Mapping[str, int] | None = None, +) -> two_step_routing.Shipment: + delivery = { + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": latlng[0], + "longitude": latlng[1], + } + } + }, + "duration": duration, + } + time_window = None + if delivery_start is not None: + time_window = {} + time_window["startTime"] = delivery_start + if delivery_end is not None: + time_window = time_window or {} + time_window["endTime"] = delivery_end + if time_window is not None: + delivery["timeWindows"] = [time_window] + shipment = { + "label": label, + "deliveries": [delivery], + } + if allowed_vehicle_indices is not None: + shipment["allowedVehicleIndices"] = allowed_vehicle_indices + if load_demands is not None: + shipment["loadDemands"] = { + unit: {"amount": str(amount)} for unit, amount in load_demands.items() + } + return shipment + + +def _make_vehicle( + label: str, + depot_latlng: tuple[float, float], + start_time: tuple[str, str], + end_time: tuple[str, str], +) -> two_step_routing.Vehicle: + return { + "label": label, + "travelMode": 1, + "travelDurationMultiple": 1, + "costPerHour": 60, + "costPerKilometer": 1, + "startWaypoint": { + "location": { + "latLng": { + "latitude": depot_latlng[0], + "longitude": depot_latlng[1], + } + } + }, + "endWaypoint": { + "location": { + "latLng": { + "latitude": depot_latlng[0], + "longitude": depot_latlng[1], + } + } + }, + "startTimeWindows": [{ + "startTime": start_time[0], + "endTime": start_time[1], + }], + "endTimeWindows": [{ + "startTime": end_time[0], + "endTime": end_time[1], + }], + } + + +class PlannerTest(unittest.TestCase): + """Tests for the Planner class.""" + + maxDiff = None + + _OPTIONS = two_step_routing.Options( + local_model_vehicle_fixed_cost=10000, + max_round_duration="1800s", + min_average_shipments_per_round=2, + ) + + _REQUEST_JSON: two_step_routing.OptimizeToursRequest = { + "model": { + "shipments": [ + _make_shipment( # 0 + "S001", + latlng=(48.86471, 2.34901), + duration="120s", + allowed_vehicle_indices=[0], + ), + _make_shipment( # 1 + "S002", + latlng=(48.86593, 2.34886), + duration="150s", + allowed_vehicle_indices=[0], + ), + _make_shipment( # 2 + "S003", + latlng=(48.86594, 2.34887), + duration="60s", + allowed_vehicle_indices=[0], + delivery_start="2023-08-11T12:00:00.000Z", + ), + _make_shipment( # 3 + "S004", + latlng=(48.86595, 2.34888), + duration="60s", + allowed_vehicle_indices=[0], + delivery_start="2023-08-11T14:00:00.000Z", + delivery_end="2023-08-11T16:00:00.000Z", + ), + _make_shipment( # 4 + "S005", + latlng=(48.86596, 2.34889), + duration="150s", + allowed_vehicle_indices=[0, 1], + ), + _make_shipment( # 5 + "S006", + latlng=(48.86597, 2.34890), + duration="150s", + allowed_vehicle_indices=[0, 1], + load_demands={"wheat": 3, "ore": 2}, + ), + _make_shipment( # 6 + "S007", + latlng=(48.86597, 2.34890), + duration="150s", + allowed_vehicle_indices=[0, 1], + load_demands={"ore": 1, "wood": 5}, + ), + _make_shipment( # 7 + "S008", + latlng=(48.86597, 2.34890), + duration="150s", + allowed_vehicle_indices=[0, 1], + ), + _make_shipment( # 8 + "S009", + latlng=(48.86597, 2.34890), + duration="150s", + allowed_vehicle_indices=[0, 1], + ), + ], + "vehicles": [ + _make_vehicle( + "V001", + depot_latlng=(48.86321, 2.34767), + start_time=( + "2023-08-11T08:00:00.000Z", + "2023-08-11T08:00:00.000Z", + ), + end_time=( + "2023-08-11T16:00:00.000Z", + "2023-08-11T21:00:00.000Z", + ), + ), + _make_vehicle( + "V002", + depot_latlng=(48.86321, 2.34767), + start_time=( + "2023-08-11T08:00:00.000Z", + "2023-08-11T08:00:00.000Z", + ), + end_time=( + "2023-08-11T20:00:00.000Z", + "2023-08-11T21:00:00.000Z", + ), + ), + ], + "globalStartTime": "2023-08-11T00:00:00.000Z", + "globalEndTime": "2023-08-12T00:00:00.000Z", + }, + "searchMode": 1, + "label": "my_little_model", + "parent": "my_awesome_project", + } + _PARKING_LOCATIONS: Sequence[two_step_routing.ParkingLocation] = ( + two_step_routing.ParkingLocation( + coordinates={"latitude": 48.86482, "longitude": 2.34932}, + tag="P001", + travel_duration_multiple=1.1, + delivery_load_limits={"ore": 2}, + ), + two_step_routing.ParkingLocation( + coordinates={"latitude": 48.86482, "longitude": 2.34932}, + tag="P002", + travel_mode=2, + delivery_load_limits={"ore": 2}, + ), + ) + _PARKING_FOR_SHIPMENT = { + 0: "P001", + 1: "P001", + 2: "P001", + 3: "P001", + 4: "P002", + 5: "P002", + 6: "P002", + 7: "P002", + } + + # The expected local model request created by the two-step planner for the + # base request defined above. + _EXPECTED_LOCAL_REQUEST_JSON: two_step_routing.OptimizeToursRequest = { + "label": "my_little_model/local", + "model": { + "globalEndTime": "2023-08-12T00:00:00.000Z", + "globalStartTime": "2023-08-11T00:00:00.000Z", + "shipments": [ + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86471, + "longitude": 2.34901, + } + } + }, + "duration": "120s", + }], + "label": "0: S001", + "allowedVehicleIndices": [0], + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86593, + "longitude": 2.34886, + } + } + }, + "duration": "150s", + }], + "label": "1: S002", + "allowedVehicleIndices": [0], + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86594, + "longitude": 2.34887, + } + } + }, + "duration": "60s", + }], + "label": "2: S003", + "allowedVehicleIndices": [1], + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86595, + "longitude": 2.34888, + } + } + }, + "duration": "60s", + }], + "label": "3: S004", + "allowedVehicleIndices": [2], + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86596, + "longitude": 2.34889, + } + } + }, + "duration": "150s", + }], + "label": "4: S005", + "allowedVehicleIndices": [3, 4], + }, + { + "allowedVehicleIndices": [3, 4], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "5: S006", + "loadDemands": { + "ore": {"amount": "2"}, + "wheat": {"amount": "3"}, + }, + }, + { + "allowedVehicleIndices": [3, 4], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "6: S007", + "loadDemands": { + "ore": {"amount": "1"}, + "wood": {"amount": "5"}, + }, + }, + { + "allowedVehicleIndices": [3, 4], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "7: S008", + }, + ], + "vehicles": [ + { + "label": "P001 []/0", + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "routeDurationLimit": {"maxDuration": "1800s"}, + "travelDurationMultiple": 1.1, + "travelMode": 1, + "fixedCost": 10000, + "costPerHour": 300, + "costPerKilometer": 60, + "loadLimits": {"ore": {"maxLoad": "2"}}, + }, + { + "label": "P001 [2023-08-11T12:00:00.000Z,]/0", + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "startTimeWindows": [{ + "startTime": "2023-08-11T12:00:00.000Z", + }], + "routeDurationLimit": {"maxDuration": "1800s"}, + "travelDurationMultiple": 1.1, + "travelMode": 1, + "fixedCost": 10000, + "costPerHour": 300, + "costPerKilometer": 60, + "loadLimits": {"ore": {"maxLoad": "2"}}, + }, + { + "label": ( + "P001 [2023-08-11T14:00:00.000Z," + "2023-08-11T16:00:00.000Z]/0" + ), + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "startTimeWindows": [{ + "startTime": "2023-08-11T14:00:00.000Z", + }], + "endTimeWindows": [{ + "endTime": "2023-08-11T16:00:00.000Z", + }], + "routeDurationLimit": {"maxDuration": "1800s"}, + "travelDurationMultiple": 1.1, + "travelMode": 1, + "fixedCost": 10000, + "costPerHour": 300, + "costPerKilometer": 60, + "loadLimits": {"ore": {"maxLoad": "2"}}, + }, + { + "label": "P002 []/0", + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "routeDurationLimit": {"maxDuration": "1800s"}, + "travelDurationMultiple": 1.0, + "travelMode": 2, + "fixedCost": 10000, + "costPerHour": 300, + "costPerKilometer": 60, + "loadLimits": {"ore": {"maxLoad": "2"}}, + }, + { + "costPerHour": 300, + "costPerKilometer": 60, + "endWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "fixedCost": 10000, + "label": "P002 []/1", + "routeDurationLimit": {"maxDuration": "1800s"}, + "startWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "travelDurationMultiple": 1.0, + "travelMode": 2, + "loadLimits": {"ore": {"maxLoad": "2"}}, + }, + ], + }, + "parent": "my_awesome_project", + } + # An example response from the CFR solver for _EXPECTED_LOCAL_REQUEST_JSON. + # Fields that are not needed by the two-step solver were removed from the + # response to make it shorter. + _LOCAL_RESPONSE_JSON: two_step_routing.OptimizeToursResponse = { + "routes": [ + { + "vehicleLabel": "P001 []/0", + "vehicleStartTime": "2023-08-11T00:00:00Z", + "vehicleEndTime": "2023-08-11T00:15:32Z", + "visits": [ + { + "startTime": "2023-08-11T00:02:37Z", + "shipmentLabel": "0: S001", + }, + { + "shipmentIndex": 1, + "startTime": "2023-08-11T00:10:54Z", + "shipmentLabel": "1: S002", + }, + ], + "transitions": [ + { + "totalDuration": "157s", + "startTime": "2023-08-11T00:00:00Z", + }, + { + "totalDuration": "377s", + "startTime": "2023-08-11T00:04:37Z", + }, + { + "totalDuration": "128s", + "startTime": "2023-08-11T00:13:24Z", + }, + ], + "metrics": { + "totalDuration": "932s", + }, + "routeTotalCost": 10191.306666666665, + }, + { + "vehicleIndex": 1, + "vehicleLabel": "P001 [2023-08-11T12:00:00.000Z,]/0", + "vehicleStartTime": "2023-08-11T12:00:00Z", + "vehicleEndTime": "2023-08-11T12:12:04Z", + "visits": [{ + "shipmentIndex": 2, + "startTime": "2023-08-11T12:08:56Z", + "shipmentLabel": "2: S003", + }], + "transitions": [ + { + "totalDuration": "536s", + "startTime": "2023-08-11T12:00:00Z", + }, + { + "totalDuration": "128s", + "startTime": "2023-08-11T12:09:56Z", + }, + ], + "metrics": { + "totalDuration": "724s", + }, + "routeTotalCost": 10174.273333333333, + }, + { + "vehicleIndex": 2, + "vehicleLabel": ( + "P001 [2023-08-11T14:00:00.000Z,2023-08-11T16:00:00.000Z]/0" + ), + "vehicleStartTime": "2023-08-11T14:00:00Z", + "vehicleEndTime": "2023-08-11T14:12:04Z", + "visits": [{ + "shipmentIndex": 3, + "startTime": "2023-08-11T14:08:56Z", + "shipmentLabel": "3: S004", + }], + "transitions": [ + { + "totalDuration": "536s", + "startTime": "2023-08-11T14:00:00Z", + }, + { + "totalDuration": "128s", + "startTime": "2023-08-11T14:09:56Z", + }, + ], + "metrics": { + "totalDuration": "724s", + }, + "routeTotalCost": 10174.273333333334, + }, + { + "vehicleIndex": 3, + "vehicleLabel": "P002 []/0", + "vehicleStartTime": "2023-08-11T00:00:00Z", + "vehicleEndTime": "2023-08-11T00:06:59Z", + "visits": [{ + "shipmentIndex": 5, + "startTime": "2023-08-11T00:02:15Z", + "shipmentLabel": "5: S006", + }], + "transitions": [ + { + "totalDuration": "135s", + "startTime": "2023-08-11T00:00:00Z", + }, + { + "totalDuration": "134s", + "startTime": "2023-08-11T00:04:45Z", + }, + ], + "metrics": { + "totalDuration": "419s", + }, + "routeTotalCost": 10057.356666666667, + }, + { + "vehicleIndex": 4, + "vehicleLabel": "P002 []/1", + "vehicleStartTime": "2023-08-11T00:00:00Z", + "vehicleEndTime": "2023-08-11T00:12:00Z", + "visits": [ + { + "shipmentIndex": 7, + "startTime": "2023-08-11T00:02:15Z", + "shipmentLabel": "7: S008", + }, + { + "shipmentIndex": 6, + "startTime": "2023-08-11T00:04:45Z", + "shipmentLabel": "6: S007", + }, + { + "shipmentIndex": 4, + "startTime": "2023-08-11T00:07:15Z", + "shipmentLabel": "4: S005", + }, + ], + "transitions": [ + { + "totalDuration": "135s", + "startTime": "2023-08-11T00:00:00Z", + }, + { + "totalDuration": "0s", + "startTime": "2023-08-11T00:04:45Z", + }, + { + "totalDuration": "0s", + "startTime": "2023-08-11T00:07:15Z", + }, + { + "totalDuration": "135s", + "startTime": "2023-08-11T00:09:45Z", + }, + ], + "metrics": { + "totalDuration": "720s", + }, + "routeTotalCost": 10082.44, + }, + ], + "totalCost": 50679.65, + } + + # The expected global model request created by the two-step planner for the + # base request defined above, using _EXPECTED_LOCAL_REQUEST_JSON as the + # solution of the local model. + _EXPECTED_GLOBAL_REQUEST_JSON: two_step_routing.OptimizeToursRequest = { + "label": "my_little_model/global", + "model": { + "globalEndTime": "2023-08-12T00:00:00.000Z", + "globalStartTime": "2023-08-11T00:00:00.000Z", + "shipments": [ + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "s:8 S009", + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "932s", + }], + "label": "p:0 S001,S002", + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "724s", + "timeWindows": [{"startTime": "2023-08-11T11:51:04Z"}], + }], + "label": "p:1 S003", + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "724s", + "timeWindows": [{ + "endTime": "2023-08-11T15:50:04Z", + "startTime": "2023-08-11T13:51:04Z", + }], + }], + "label": "p:2 S004", + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "419s", + }], + "label": "p:3 S006", + "loadDemands": { + "ore": {"amount": "2"}, + "wheat": {"amount": "3"}, + }, + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "720s", + }], + "label": "p:4 S008,S007,S005", + "loadDemands": { + "ore": {"amount": "1"}, + "wood": {"amount": "5"}, + }, + }, + ], + "vehicles": [ + { + "costPerHour": 60, + "costPerKilometer": 1, + "endTimeWindows": [{ + "endTime": "2023-08-11T21:00:00.000Z", + "startTime": "2023-08-11T16:00:00.000Z", + }], + "endWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "label": "V001", + "startTimeWindows": [{ + "endTime": "2023-08-11T08:00:00.000Z", + "startTime": "2023-08-11T08:00:00.000Z", + }], + "startWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "travelDurationMultiple": 1, + "travelMode": 1, + }, + { + "costPerHour": 60, + "costPerKilometer": 1, + "endTimeWindows": [{ + "endTime": "2023-08-11T21:00:00.000Z", + "startTime": "2023-08-11T20:00:00.000Z", + }], + "endWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "label": "V002", + "startTimeWindows": [{ + "endTime": "2023-08-11T08:00:00.000Z", + "startTime": "2023-08-11T08:00:00.000Z", + }], + "startWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "travelDurationMultiple": 1, + "travelMode": 1, + }, + ], + }, + "parent": "my_awesome_project", + } + + # An example response from the CFR solver for _EXPECTED_GLOBAL_REQUEST_JSON. + # Fields that are not needed by the two-step solver were removed from the + # response to make it shorter. + _GLOBAL_RESPONSE_JSON: two_step_routing.OptimizeToursResponse = { + "routes": [ + { + "vehicleLabel": "V001", + "vehicleStartTime": "2023-08-11T08:00:00Z", + "vehicleEndTime": "2023-08-11T16:00:00Z", + "visits": [ + { + "startTime": "2023-08-11T14:54:13Z", + "shipmentLabel": "s:8 S009", + }, + { + "shipmentIndex": 4, + "startTime": "2023-08-11T14:58:39Z", + "shipmentLabel": "p:3 S006", + }, + { + "shipmentIndex": 5, + "startTime": "2023-08-11T15:05:38Z", + "shipmentLabel": "p:4 S008,S007,S005", + }, + { + "shipmentIndex": 3, + "startTime": "2023-08-11T15:17:38Z", + "shipmentLabel": "p:2 S004", + }, + { + "shipmentIndex": 1, + "startTime": "2023-08-11T15:29:42Z", + "shipmentLabel": "p:0 S001,S002", + }, + { + "shipmentIndex": 2, + "startTime": "2023-08-11T15:45:14Z", + "shipmentLabel": "p:1 S003", + }, + ], + "transitions": [ + { + "totalDuration": "24853s", + "startTime": "2023-08-11T08:00:00Z", + }, + { + "totalDuration": "116s", + "startTime": "2023-08-11T14:56:43Z", + }, + { + "totalDuration": "0s", + "startTime": "2023-08-11T15:05:38Z", + }, + { + "totalDuration": "0s", + "startTime": "2023-08-11T15:17:38Z", + }, + { + "totalDuration": "0s", + "startTime": "2023-08-11T15:29:42Z", + }, + { + "totalDuration": "0s", + "startTime": "2023-08-11T15:45:14Z", + }, + { + "totalDuration": "162s", + "startTime": "2023-08-11T15:57:18Z", + }, + ], + "metrics": { + "totalDuration": "28800s", + }, + "routeTotalCost": 481.985, + }, + {"vehicleIndex": 1, "vehicleLabel": "V002"}, + ], + "totalCost": 481.985, + } + + # The expected merged model request created by the two-step planner for the + # base request defined above, using _EXPECTED_LOCAL_REQUEST and + # _EXPECTED_GLOBAL_REQUEST as the solutions of the local and global models. + _EXPECTED_MERGED_REQUEST_JSON = { + "label": "my_little_model/merged", + "model": { + "globalEndTime": "2023-08-12T00:00:00.000Z", + "globalStartTime": "2023-08-11T00:00:00.000Z", + "shipments": [ + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86471, + "longitude": 2.34901, + } + } + }, + "duration": "120s", + }], + "label": "S001", + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86593, + "longitude": 2.34886, + } + } + }, + "duration": "150s", + }], + "label": "S002", + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86594, + "longitude": 2.34887, + } + } + }, + "duration": "60s", + "timeWindows": [ + {"startTime": "2023-08-11T12:00:00.000Z"} + ], + }], + "label": "S003", + }, + { + "allowedVehicleIndices": [0], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86595, + "longitude": 2.34888, + } + } + }, + "duration": "60s", + "timeWindows": [{ + "endTime": "2023-08-11T16:00:00.000Z", + "startTime": "2023-08-11T14:00:00.000Z", + }], + }], + "label": "S004", + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86596, + "longitude": 2.34889, + } + } + }, + "duration": "150s", + }], + "label": "S005", + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "S006", + "loadDemands": { + "ore": {"amount": "2"}, + "wheat": {"amount": "3"}, + }, + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "S007", + "loadDemands": { + "ore": {"amount": "1"}, + "wood": {"amount": "5"}, + }, + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "S008", + }, + { + "allowedVehicleIndices": [0, 1], + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86597, + "longitude": 2.3489, + } + } + }, + "duration": "150s", + }], + "label": "S009", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P002 arrival", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P002 departure", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P002 arrival", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P002 departure", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P001 arrival", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P001 departure", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P001 arrival", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P001 departure", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P001 arrival", + }, + { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": { + "latitude": 48.86482, + "longitude": 2.34932, + } + } + }, + "duration": "0s", + }], + "label": "P001 departure", + }, + ], + "vehicles": [ + { + "costPerHour": 60, + "costPerKilometer": 1, + "endTimeWindows": [{ + "endTime": "2023-08-11T21:00:00.000Z", + "startTime": "2023-08-11T16:00:00.000Z", + }], + "endWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "label": "V001", + "startTimeWindows": [{ + "endTime": "2023-08-11T08:00:00.000Z", + "startTime": "2023-08-11T08:00:00.000Z", + }], + "startWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "travelDurationMultiple": 1, + "travelMode": 1, + }, + { + "costPerHour": 60, + "costPerKilometer": 1, + "endTimeWindows": [{ + "endTime": "2023-08-11T21:00:00.000Z", + "startTime": "2023-08-11T20:00:00.000Z", + }], + "endWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "label": "V002", + "startTimeWindows": [{ + "endTime": "2023-08-11T08:00:00.000Z", + "startTime": "2023-08-11T08:00:00.000Z", + }], + "startWaypoint": { + "location": { + "latLng": {"latitude": 48.86321, "longitude": 2.34767} + } + }, + "travelDurationMultiple": 1, + "travelMode": 1, + }, + ], + }, + "parent": "my_awesome_project", + } + + # The expected merged model response creatd by the two-step planner for the + # base request defined above, using _EXPECTED_LOCAL_REQUEST and + # _EXPECTED_GLOBAL_REQUEST as the solutions of the local and global models. + _EXPECTED_MERGED_RESPONSE_JSON = { + "routes": [ + { + "routeTotalCost": 481.985, + "transitions": [ + { + "startTime": "2023-08-11T08:00:00Z", + "totalDuration": "24853s", + }, + { + "startTime": "2023-08-11T14:56:43Z", + "totalDuration": "116s", + }, + { + "startTime": "2023-08-11T14:58:39Z", + "totalDuration": "135s", + }, + { + "startTime": "2023-08-11T15:03:24Z", + "totalDuration": "134s", + }, + {"startTime": "2023-08-11T15:05:38Z", "totalDuration": "0s"}, + { + "startTime": "2023-08-11T15:05:38Z", + "totalDuration": "135s", + }, + {"startTime": "2023-08-11T15:10:23Z", "totalDuration": "0s"}, + {"startTime": "2023-08-11T15:12:53Z", "totalDuration": "0s"}, + { + "startTime": "2023-08-11T15:15:23Z", + "totalDuration": "135s", + }, + {"startTime": "2023-08-11T15:17:38Z", "totalDuration": "0s"}, + { + "startTime": "2023-08-11T15:17:38Z", + "totalDuration": "536s", + }, + { + "startTime": "2023-08-11T15:27:34Z", + "totalDuration": "128s", + }, + {"startTime": "2023-08-11T15:29:42Z", "totalDuration": "0s"}, + { + "startTime": "2023-08-11T15:29:42Z", + "totalDuration": "157s", + }, + { + "startTime": "2023-08-11T15:34:19Z", + "totalDuration": "377s", + }, + { + "startTime": "2023-08-11T15:43:06Z", + "totalDuration": "128s", + }, + {"startTime": "2023-08-11T15:45:14Z", "totalDuration": "0s"}, + { + "startTime": "2023-08-11T15:45:14Z", + "totalDuration": "536s", + }, + { + "startTime": "2023-08-11T15:55:10Z", + "totalDuration": "128s", + }, + { + "startTime": "2023-08-11T15:57:18Z", + "totalDuration": "162s", + }, + ], + "vehicleEndTime": "2023-08-11T16:00:00Z", + "vehicleIndex": 0, + "vehicleLabel": "V001", + "vehicleStartTime": "2023-08-11T08:00:00Z", + "visits": [ + { + "shipmentIndex": 8, + "shipmentLabel": "S009", + "startTime": "2023-08-11T14:54:13Z", + }, + { + "shipmentIndex": 9, + "shipmentLabel": "P002 arrival", + "startTime": "2023-08-11T14:58:39Z", + }, + { + "shipmentIndex": 5, + "shipmentLabel": "S006", + "startTime": "2023-08-11T15:00:54Z", + }, + { + "shipmentIndex": 10, + "shipmentLabel": "P002 departure", + "startTime": "2023-08-11T15:05:38Z", + }, + { + "shipmentIndex": 11, + "shipmentLabel": "P002 arrival", + "startTime": "2023-08-11T15:05:38Z", + }, + { + "shipmentIndex": 7, + "shipmentLabel": "S008", + "startTime": "2023-08-11T15:07:53Z", + }, + { + "shipmentIndex": 6, + "shipmentLabel": "S007", + "startTime": "2023-08-11T15:10:23Z", + }, + { + "shipmentIndex": 4, + "shipmentLabel": "S005", + "startTime": "2023-08-11T15:12:53Z", + }, + { + "shipmentIndex": 12, + "shipmentLabel": "P002 departure", + "startTime": "2023-08-11T15:17:38Z", + }, + { + "shipmentIndex": 13, + "shipmentLabel": "P001 arrival", + "startTime": "2023-08-11T15:17:38Z", + }, + { + "shipmentIndex": 3, + "shipmentLabel": "S004", + "startTime": "2023-08-11T15:26:34Z", + }, + { + "shipmentIndex": 14, + "shipmentLabel": "P001 departure", + "startTime": "2023-08-11T15:29:42Z", + }, + { + "shipmentIndex": 15, + "shipmentLabel": "P001 arrival", + "startTime": "2023-08-11T15:29:42Z", + }, + { + "shipmentIndex": 0, + "shipmentLabel": "S001", + "startTime": "2023-08-11T15:32:19Z", + }, + { + "shipmentIndex": 1, + "shipmentLabel": "S002", + "startTime": "2023-08-11T15:40:36Z", + }, + { + "shipmentIndex": 16, + "shipmentLabel": "P001 departure", + "startTime": "2023-08-11T15:45:14Z", + }, + { + "shipmentIndex": 17, + "shipmentLabel": "P001 arrival", + "startTime": "2023-08-11T15:45:14Z", + }, + { + "shipmentIndex": 2, + "shipmentLabel": "S003", + "startTime": "2023-08-11T15:54:10Z", + }, + { + "shipmentIndex": 18, + "shipmentLabel": "P001 departure", + "startTime": "2023-08-11T15:57:18Z", + }, + ], + }, + {"vehicleIndex": 1, "vehicleLabel": "V002"}, + ] + } + + def validate_response( + self, + request: two_step_routing.OptimizeToursRequest, + response: two_step_routing.OptimizeToursResponse, + ): + """Validates basic properties of the merged response.""" + vehicles = request["model"]["vehicles"] + shipments = request["model"]["shipments"] + num_vehicles = len(vehicles) + num_shipments = len(shipments) + routes = response["routes"] + self.assertEqual(num_vehicles, len(routes)) + + picked_up_shipments = set() + delivered_shipments = set() + + for vehicle_index in range(num_vehicles): + with self.subTest(vehicle_index=vehicle_index): + route = routes[vehicle_index] + vehicle = vehicles[vehicle_index] + self.assertEqual(route["vehicleLabel"], vehicle["label"]) + self.assertEqual(route.get("vehicleIndex", 0), vehicle_index) + + if "visits" not in route: + self.assertNotIn("transitions", route) + continue + + visits = route["visits"] + transitions = route["transitions"] + self.assertEqual(len(visits) + 1, len(transitions)) + + total_duration = datetime.timedelta() + current_time = two_step_routing._parse_time_string( + route["vehicleStartTime"] + ) + for visit_index, visit in enumerate(visits): + with self.subTest(visit_index=visit_index): + shipment_index = visit.get("shipmentIndex", 0) + # Make sure that each shipment is delivered at most once, and that + # if a shipment has a pickup and a delivery, it is picked up before + # it is delivered. + self.assertNotIn(shipment_index, delivered_shipments) + + is_pickup = visit.get("isPickup", False) + if is_pickup: + self.assertNotIn( + shipment_index, + picked_up_shipments, + "Shipment was already picked up", + ) + picked_up_shipments.add(shipment_index) + else: + delivered_shipments.add(shipment_index) + + shipment = shipments[shipment_index] + transition = transitions[visit_index] + self.assertEqual( + current_time, + two_step_routing._parse_time_string(transition["startTime"]), + ) + transition_duration = two_step_routing._parse_duration_string( + transition["totalDuration"] + ) + visit_duration = two_step_routing._parse_duration_string( + shipment["deliveries"][0]["duration"] + ) + total_duration += transition_duration + current_time += transition_duration + self.assertEqual( + current_time, + two_step_routing._parse_time_string(visit["startTime"]), + ) + total_duration += visit_duration + current_time += visit_duration + total_duration += two_step_routing._parse_duration_string( + transitions[-1]["totalDuration"] + ) + self.assertEqual( + total_duration, + two_step_routing._parse_time_string(route["vehicleEndTime"]) + - two_step_routing._parse_time_string(route["vehicleStartTime"]), + ) + + # Collect skipped shipment indices. + skipped_shipments = set() + for skipped_shipment in response.get("skippedShipments", ()): + skipped_shipments.add(skipped_shipment["index"]) + + # A skipped shipment should not be picked up or delivered. + self.assertEqual(set(), skipped_shipments.intersection(picked_up_shipments)) + self.assertEqual(set(), skipped_shipments.intersection(delivered_shipments)) + + # Check that each shipment is picked up, delivered, or skipped. + picked_up_delivered_and_skipped_shipments = delivered_shipments.union( + picked_up_shipments, skipped_shipments + ) + self.assertCountEqual( + tuple(range(num_shipments)), picked_up_delivered_and_skipped_shipments + ) + + def test_validate_request(self): + self.assertIsNone( + two_step_routing.validate_request( + self._REQUEST_JSON, self._PARKING_FOR_SHIPMENT + ) + ) + + def test_local_request_and_response(self): + self.validate_response( + self._EXPECTED_LOCAL_REQUEST_JSON, self._LOCAL_RESPONSE_JSON + ) + + def test_global_request_and_response(self): + self.validate_response( + self._EXPECTED_GLOBAL_REQUEST_JSON, self._GLOBAL_RESPONSE_JSON + ) + + def test_merged_request_and_response(self): + self.validate_response( + self._EXPECTED_MERGED_REQUEST_JSON, self._EXPECTED_MERGED_RESPONSE_JSON + ) + + +class PlannerTestLocalModel(PlannerTest): + """Tests for Planner.make_local_request().""" + + def test_make_local_model_time_windows(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, + ) + self.assertCountEqual(planner._direct_shipments, [8]) + self.assertEqual( + planner.make_local_request(), + self._EXPECTED_LOCAL_REQUEST_JSON, + ) + + +class PlannerTestGlobalModel(PlannerTest): + """Tests for Planner.make_global_request().""" + + def test_make_global_request(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, + ) + global_request = planner.make_global_request(self._LOCAL_RESPONSE_JSON) + self.assertEqual(global_request, self._EXPECTED_GLOBAL_REQUEST_JSON) + + +class PlannerTestMergedModel(PlannerTest): + """Tests for Planner.merge_local_and_global_result().""" + + def test_make_merged_request_and_response(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, + ) + merged_request, merged_response = planner.merge_local_and_global_result( + self._LOCAL_RESPONSE_JSON, + self._GLOBAL_RESPONSE_JSON, + ) + self.assertEqual(merged_request, self._EXPECTED_MERGED_REQUEST_JSON) + self.assertEqual(merged_response, self._EXPECTED_MERGED_RESPONSE_JSON) + + +class ParseGlobalShipmentLabelTest(unittest.TestCase): + """Tests for _parse_global_shipment_label.""" + + def test_empty_label(self): + with self.assertRaises(ValueError): + two_step_routing._parse_global_shipment_label("") + + def test_invalid_label(self): + with self.assertRaises(ValueError): + two_step_routing._parse_global_shipment_label("foobar") + + def test_shipment_label(self): + visit_type, index = two_step_routing._parse_global_shipment_label( + "s:1 S003" + ) + self.assertEqual(visit_type, "s") + self.assertEqual(index, 1) + + def test_parking_label(self): + visit_type, index = two_step_routing._parse_global_shipment_label( + "p:3 S003,S004,S007" + ) + self.assertEqual(visit_type, "p") + self.assertEqual(index, 3) + + +class CombinedCostsPerVehicleTest(unittest.TestCase): + """Tests for _combined_costs_per_vehicle.""" + + def test_no_shipments(self): + self.assertIsNone(two_step_routing._combined_costs_per_vehicle([])) + + def test_no_costs_per_vehicle(self): + self.assertIsNone( + two_step_routing._combined_costs_per_vehicle([{}, {}, {}]) + ) + + def test_some_costs(self): + shipments = [ + { + "costsPerVehicle": [1000, 2000, 3000], + "costsPerVehicleIndices": [0, 2, 5], + }, + { + "costsPerVehicle": [10, 20, 30, 40], + "costsPerVehicleIndices": [1, 3, 5, 6], + }, + {}, + { + "costsPerVehicle": [2, 3], + "costsPerVehicleIndices": [5, 6], + }, + ] + expected_costs = [1000, 10, 2000, 20, 3000, 40] + expected_vehicle_indices = [0, 1, 2, 3, 5, 6] + self.assertEqual( + two_step_routing._combined_costs_per_vehicle(shipments), + (expected_vehicle_indices, expected_costs), + ) + + +class CombinedPenaltyCostTest(unittest.TestCase): + """Tests for _combined_penalty_cost.""" + + def test_no_shipments(self): + self.assertEqual(two_step_routing._combined_penalty_cost(()), 0) + + def test_no_mandatory_shipments(self): + shipments = [ + {"penaltyCost": 100}, + {"penaltyCost": 1_000}, + {"penaltyCost": 10_000}, + ] + self.assertEqual(two_step_routing._combined_penalty_cost(shipments), 11100) + + def test_some_mandatory_shipments(self): + shipments = [{"penaltyCost": 100}, {}, {"penaltyCost": 10000}] + self.assertIsNone(two_step_routing._combined_penalty_cost(shipments)) + + def test_all_mandatory_shipments(self): + shipments = [{}, {}, {}] + self.assertIsNone(two_step_routing._combined_penalty_cost(shipments)) + + +class CombinedLoadDemandsTest(unittest.TestCase): + """Tests for _combined_load_demands.""" + + def test_no_shipments(self): + self.assertEqual(two_step_routing._combined_load_demands(()), {}) + + def test_some_shipments(self): + shipments = [ + _make_shipment( + "S001", + latlng=(48.86471, 2.34901), + duration="120s", + load_demands={"wheat": 3, "wood": 1}, + ), + _make_shipment( + "S002", + latlng=(48.86471, 2.34901), + duration="120s", + load_demands={"wood": 5, "ore": 2}, + ), + _make_shipment( + "S002", + latlng=(48.86471, 2.34901), + duration="120s", + ), + ] + self.assertEqual( + two_step_routing._combined_load_demands(shipments), + { + "wheat": {"amount": "3"}, + "wood": {"amount": "6"}, + "ore": {"amount": "2"}, + }, + ) + + +class GetParkingTagFromLocalRouteTest(unittest.TestCase): + """Tests for _get_parking_tag_from_local_route.""" + + def test_empty_string(self): + with self.assertRaises(ValueError): + two_step_routing._get_parking_tag_from_local_route({"vehicleLabel": ""}) + + def test_no_timestamp(self): + self.assertEqual( + two_step_routing._get_parking_tag_from_local_route( + {"vehicleLabel": "P002 []/1"} + ), + "P002", + ) + + def test_with_timestamp(self): + self.assertEqual( + two_step_routing._get_parking_tag_from_local_route( + { + "vehicleLabel": ( + "P001 [2023-08-11T14:00:00.000Z,2023-08-11T16:00:00.000Z]/0" + ) + }, + ), + "P001", + ) + + +class MakeLocalModelVehicleLabelTest(unittest.TestCase): + """Tests for _make_local_delivery_model_vehicle_label.""" + + def test_parking_tag_only(self): + self.assertEqual( + two_step_routing._make_local_model_vehicle_label("P123", None, None), + "P123 []", + ) + + def test_parking_tag_and_start_time(self): + self.assertEqual( + two_step_routing._make_local_model_vehicle_label( + "P123", "2023-08-11T00:00:00.000Z", None + ), + "P123 [2023-08-11T00:00:00.000Z,]", + ) + + def test_parking_tag_and_end_time(self): + self.assertEqual( + two_step_routing._make_local_model_vehicle_label( + "P123", None, "2023-08-11T00:00:00.000Z" + ), + "P123 [,2023-08-11T00:00:00.000Z]", + ) + + def test_parking_tag_and_start_and_end_time(self): + self.assertEqual( + two_step_routing._make_local_model_vehicle_label( + "P123", "2023-08-11T00:00:00.000Z", "2023-08-11T08:00:00.000Z" + ), + "P123 [2023-08-11T00:00:00.000Z,2023-08-11T08:00:00.000Z]", + ) + + +class ParkingDeliveryGroupTest(unittest.TestCase): + """Tests for _parking_delivery_group_key.""" + + _START_TIME = "2023-08-09T12:12:00.000Z" + _END_TIME = "2023-08-09T12:45:32.000Z" + _SHIPMENT_NO_TIME_WINDOW: two_step_routing.Shipment = { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": {"latitude": 35.7669, "longitude": 139.7286} + } + }, + }], + "label": "2023081000001", + } + _SHIPMENT_TIME_WINDOW_START: two_step_routing.Shipment = { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": {"latitude": 35.7669, "longitude": 139.7286} + } + }, + "timeWindows": [{"startTime": _START_TIME}], + }], + } + _SHIPMENT_TIME_WINDOW_END: two_step_routing.Shipment = { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": {"latitude": 35.7669, "longitude": 139.7286} + } + }, + "timeWindows": [{"endTime": _END_TIME}], + }], + } + _SHIPMENT_TIME_WINDOW_START_END: two_step_routing.Shipment = { + "deliveries": [{ + "arrivalWaypoint": { + "location": { + "latLng": {"latitude": 35.7669, "longitude": 139.7286} + } + }, + "timeWindows": [{ + "startTime": _START_TIME, + "endTime": _END_TIME, + }], + }], + } + + _PARKING_LOCATION = two_step_routing.ParkingLocation( + coordinates={"latitude": 35.7668, "longitude": 139.7285}, tag="P1234" + ) + + def test_with_no_parking(self): + for shipment in ( + self._SHIPMENT_NO_TIME_WINDOW, + self._SHIPMENT_TIME_WINDOW_START, + self._SHIPMENT_TIME_WINDOW_END, + self._SHIPMENT_TIME_WINDOW_START_END, + ): + self.assertEqual( + two_step_routing._parking_delivery_group_key(shipment, None), + (None, None, None), + ) + + def test_with_parking_and_no_time_window(self): + self.assertEqual( + two_step_routing._parking_delivery_group_key( + self._SHIPMENT_NO_TIME_WINDOW, self._PARKING_LOCATION + ), + ("P1234", None, None), + ) + + def test_with_parking_and_time_window_start(self): + self.assertEqual( + two_step_routing._parking_delivery_group_key( + self._SHIPMENT_TIME_WINDOW_START, self._PARKING_LOCATION + ), + ("P1234", self._START_TIME, None), + ) + + def test_with_parking_and_time_window_end(self): + self.assertEqual( + two_step_routing._parking_delivery_group_key( + self._SHIPMENT_TIME_WINDOW_END, self._PARKING_LOCATION + ), + ("P1234", None, self._END_TIME), + ) + + def test_with_parking_and_time_window_start_end(self): + self.assertEqual( + two_step_routing._parking_delivery_group_key( + self._SHIPMENT_TIME_WINDOW_START_END, self._PARKING_LOCATION + ), + ("P1234", self._START_TIME, self._END_TIME), + ) + + +class ParseTimeStringTest(unittest.TestCase): + """Tests for _parse_time_string.""" + + maxDiff = None + + def test_empty_string(self): + with self.assertRaises(ValueError): + two_step_routing._parse_time_string("") + + def test_date_only(self): + self.assertEqual( + two_step_routing._parse_time_string("2023-08-11"), + datetime.datetime(year=2023, month=8, day=11), + ) + + def test_fractional_seconds(self): + self.assertEqual( + two_step_routing._parse_time_string("2023-08-15T12:32:44.567Z"), + datetime.datetime( + year=2023, + month=8, + day=15, + hour=12, + minute=32, + second=44, + microsecond=567000, + ), + ) + + +class MakeTimeStringTest(unittest.TestCase): + """Tests for _make_time_string.""" + + maxDiff = None + + def test_no_timezone(self): + self.assertEqual( + two_step_routing._make_time_string( + datetime.datetime( + year=2023, + month=8, + day=15, + hour=9, + minute=21, + second=32, + ) + ), + "2023-08-15T09:21:32Z", + ) + + def test_with_timezone(self): + self.assertEqual( + two_step_routing._make_time_string( + datetime.datetime( + year=2023, + month=8, + day=15, + hour=9, + minute=21, + second=32, + tzinfo=datetime.timezone(datetime.timedelta(hours=+2)), + ) + ), + "2023-08-15T09:21:32+02:00", + ) + + +class UpdateTimeStringTest(unittest.TestCase): + """Tests fo _update_time_string.""" + + def test_invalid_time(self): + with self.assertRaises(ValueError): + two_step_routing._update_time_string( + "foobar", datetime.timedelta(seconds=12) + ) + + def test_update_some_time(self): + self.assertEqual( + two_step_routing._update_time_string( + "2023-08-15T12:32:44Z", datetime.timedelta(hours=-3) + ), + "2023-08-15T09:32:44Z", + ) + + +class ParseDurationStringTest(unittest.TestCase): + """Tests for _parse_duration_string.""" + + def test_empty_string(self): + with self.assertRaises(ValueError): + two_step_routing._parse_duration_string("") + + def test_invalid_format(self): + with self.assertRaises(ValueError): + two_step_routing._parse_duration_string("foobar") + + def test_invalid_suffix(self): + with self.assertRaises(ValueError): + two_step_routing._parse_duration_string("2h") + + def test_invalid_amount(self): + with self.assertRaises(ValueError): + two_step_routing._parse_duration_string("ABCs") + + def test_valid_parse(self): + self.assertEqual( + two_step_routing._parse_duration_string("0s"), + datetime.timedelta(seconds=0), + ) + self.assertEqual( + two_step_routing._parse_duration_string("1800s"), + datetime.timedelta(minutes=30), + ) + self.assertEqual( + two_step_routing._parse_duration_string("0.5s"), + datetime.timedelta(seconds=0.5), + ) + + +if __name__ == "__main__": + unittest.main()