From f24ec4019810f2ef86c7d0d0092e506676c189cb Mon Sep 17 00:00:00 2001 From: Ondrej Sykora Date: Mon, 13 Nov 2023 14:17:45 +0000 Subject: [PATCH] Added a refinement phase to the two-step routing library. In this refinement phase, the solver collects all sequences of visits to a parking location, and resolves them using a modified local model to reoptimize for exactly the shipments delivered in these visits. It replaces the base local routes with the refined ones and then re-solves the global model by using the original solution as a warm-start. This refinement has the following effects: - it can merge visits from multiple delivery rounds in the original (unrefined) solution, thus removing unnecessary returns to the parking location to pick up more shipments. - by removing these returns to parking, it can save some time and open up opportunities to deliver even more shipments in the saved time, or better organize shipments now that the delivery rounds are known. We observed that it is very effective at reducing the inefficient returns, but it can also help with reducing other types of parking issues and even reduce skipped shipments. After the two refinement phases, there might still be unnecessary visits to the parking location - typically they would be created by the refined global phase that can now perform some shipments that were skipped in the original model. --- python/cfr/two_step_routing/README.md | 2 +- .../expected_integrated_global_request.json | 181 +++++ .../expected_integrated_local_request.json | 202 +++++ .../expected_integrated_local_response.json | 255 +++++++ .../expected_local_refinement_request.json | 4 +- .../small/local_refinement_response.json | 529 +++++++++++++ .../cfr/two_step_routing/two_step_routing.py | 711 +++++++++++++++++- .../two_step_routing/two_step_routing_main.py | 133 +++- .../two_step_routing/two_step_routing_test.py | 330 +++++++- 9 files changed, 2307 insertions(+), 40 deletions(-) create mode 100644 python/cfr/two_step_routing/testdata/small/expected_integrated_global_request.json create mode 100644 python/cfr/two_step_routing/testdata/small/expected_integrated_local_request.json create mode 100644 python/cfr/two_step_routing/testdata/small/expected_integrated_local_response.json create mode 100644 python/cfr/two_step_routing/testdata/small/local_refinement_response.json diff --git a/python/cfr/two_step_routing/README.md b/python/cfr/two_step_routing/README.md index 6a9d11ed..41d99459 100644 --- a/python/cfr/two_step_routing/README.md +++ b/python/cfr/two_step_routing/README.md @@ -4,7 +4,7 @@ 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. + final delivery address. - 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. diff --git a/python/cfr/two_step_routing/testdata/small/expected_integrated_global_request.json b/python/cfr/two_step_routing/testdata/small/expected_integrated_global_request.json new file mode 100644 index 00000000..d5edd51e --- /dev/null +++ b/python/cfr/two_step_routing/testdata/small/expected_integrated_global_request.json @@ -0,0 +1,181 @@ +{ + "allowLargeDeadlineDespiteInterruptionRisk": true, + "considerRoadTraffic": true, + "injectedFirstSolutionRoutes": [ + { + "vehicleIndex": 0, + "visits": [ + { "isPickup": false, "shipmentIndex": 0, "shipmentLabel": "s:8 S009" }, + { + "isPickup": false, + "shipmentIndex": 1, + "shipmentLabel": "p:0 S001,S004,S003,S002" + }, + { "isPickup": false, "shipmentIndex": 2, "shipmentLabel": "p:1 S007" } + ] + }, + { + "vehicleIndex": 1, + "visits": [ + { + "isPickup": false, + "shipmentIndex": 3, + "shipmentLabel": "p:2 S006,S008,S005" + } + ] + } + ], + "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], + "costsPerVehicle": [100, 200], + "costsPerVehicleIndices": [0, 1], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "duration": "1176s", + "tags": ["P001"], + "timeWindows": [ + { + "endTime": "2023-08-11T15:49:26Z", + "startTime": "2023-08-11T13:49:26Z" + } + ] + } + ], + "label": "p:0 S001,S004,S003,S002" + }, + { + "allowedVehicleIndices": [0], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "duration": "449s", + "tags": ["P002", "parking: P002"] + } + ], + "label": "p:1 S007", + "loadDemands": { "ore": { "amount": "1" }, "wood": { "amount": "5" } } + }, + { + "allowedVehicleIndices": [1], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "duration": "750s", + "tags": ["P002", "parking: P002"] + } + ], + "label": "p:2 S006,S008,S005", + "loadDemands": { "ore": { "amount": "2" }, "wheat": { "amount": "3" } } + } + ], + "transitionAttributes": [ + { "cost": 1, "dstTag": "S002", "srcTag": "S001" }, + { + "cost": 1000, + "delay": "180s", + "dstTag": "parking: P002", + "excludedSrcTag": "parking: P002" + }, + { + "delay": "180s", + "excludedDstTag": "parking: P002", + "srcTag": "parking: P002" + }, + { "delay": "60s", "dstTag": "parking: P002", "srcTag": "parking: P002" } + ], + "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", + "searchMode": 1 +} diff --git a/python/cfr/two_step_routing/testdata/small/expected_integrated_local_request.json b/python/cfr/two_step_routing/testdata/small/expected_integrated_local_request.json new file mode 100644 index 00000000..986c2a61 --- /dev/null +++ b/python/cfr/two_step_routing/testdata/small/expected_integrated_local_request.json @@ -0,0 +1,202 @@ +{ + "allowLargeDeadlineDespiteInterruptionRisk": true, + "label": "my_little_model/refined_local", + "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", + "tags": ["S001"] + } + ], + "label": "0: S001" + }, + { + "allowedVehicleIndices": [0], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86593, "longitude": 2.34886 } + } + }, + "duration": "150s", + "tags": ["S002"] + } + ], + "label": "1: S002" + }, + { + "allowedVehicleIndices": [1], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86594, "longitude": 2.34887 } + } + }, + "duration": "60s", + "timeWindows": [{ "startTime": "2023-08-11T12:00:00.000Z" }] + } + ], + "label": "2: S003" + }, + { + "allowedVehicleIndices": [2], + "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": "3: S004" + }, + { + "allowedVehicleIndices": [3], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86596, "longitude": 2.34889 } + } + }, + "duration": "150s" + } + ], + "label": "4: S005" + }, + { + "allowedVehicleIndices": [3], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86597, "longitude": 2.3489 } + } + }, + "duration": "150s" + } + ], + "label": "5: S006", + "loadDemands": { "ore": { "amount": "2" }, "wheat": { "amount": "3" } } + }, + { + "allowedVehicleIndices": [4], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86597, "longitude": 2.3489 } + } + }, + "duration": "150s" + } + ], + "label": "6: S007", + "loadDemands": { "ore": { "amount": "1" }, "wood": { "amount": "5" } } + }, + { + "allowedVehicleIndices": [5], + "deliveries": [ + { + "arrivalWaypoint": { + "location": { + "latLng": { "latitude": 48.86597, "longitude": 2.3489 } + } + }, + "duration": "150s" + } + ], + "label": "7: S008" + } + ], + "transitionAttributes": [{ "cost": 1, "dstTag": "S002", "srcTag": "S001" }], + "vehicles": [ + { + "costPerHour": 300, + "costPerKilometer": 60, + "endTags": ["P001"], + "endWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "fixedCost": 10000, + "label": "P001 [refinement]/0", + "loadLimits": { "ore": { "maxLoad": "2" } }, + "routeDurationLimit": { "maxDuration": "1800s" }, + "startTags": ["P001"], + "startWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "travelDurationMultiple": 1.1, + "travelMode": 1 + }, + { + "costPerHour": 300, + "costPerKilometer": 60, + "endTags": ["P002"], + "endWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "fixedCost": 10000, + "label": "P002 [vehicles=(0,)]/0", + "loadLimits": { "ore": { "maxLoad": "2" } }, + "startTags": ["P002"], + "startWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "travelDurationMultiple": 1.0, + "travelMode": 2 + }, + { + "costPerHour": 300, + "costPerKilometer": 60, + "endTags": ["P002"], + "endWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "fixedCost": 10000, + "label": "P002 [refinement]/0", + "loadLimits": { "ore": { "maxLoad": "2" } }, + "startTags": ["P002"], + "startWaypoint": { + "location": { + "latLng": { "latitude": 48.86482, "longitude": 2.34932 } + } + }, + "travelDurationMultiple": 1.0, + "travelMode": 2 + } + ] + }, + "parent": "my_awesome_project", + "searchMode": 1 +} diff --git a/python/cfr/two_step_routing/testdata/small/expected_integrated_local_response.json b/python/cfr/two_step_routing/testdata/small/expected_integrated_local_response.json new file mode 100644 index 00000000..43577a6f --- /dev/null +++ b/python/cfr/two_step_routing/testdata/small/expected_integrated_local_response.json @@ -0,0 +1,255 @@ +{ + "routes": [ + { + "metrics": { + "breakDuration": "0s", + "delayDuration": "0s", + "performedMandatoryShipmentCount": 4, + "performedShipmentCount": 4, + "totalDuration": "1176s", + "travelDistanceMeters": 2312, + "travelDuration": "786s", + "visitDuration": "390s", + "waitDuration": "0s" + }, + "transitions": [ + { + "startTime": "2023-08-11T13:51:14Z", + "totalDuration": "147s", + "travelDistanceMeters": 360, + "travelDuration": "147s", + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T13:55:41Z", + "totalDuration": "367s", + "travelDistanceMeters": 1231, + "travelDuration": "367s", + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T14:02:48Z", + "totalDuration": "0s", + "travelDuration": "0s", + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T14:03:48Z", + "totalDuration": "0s", + "travelDuration": "0s", + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T14:06:18Z", + "totalDuration": "272s", + "travelDistanceMeters": 721, + "travelDuration": "272s", + "waitDuration": "0s" + } + ], + "travelSteps": [ + { "distanceMeters": 360, "duration": "147s" }, + { "distanceMeters": 1233, "duration": "367s" }, + { "duration": "0s" }, + { "duration": "0s" }, + { "distanceMeters": 719, "duration": "272s" } + ], + "vehicleEndTime": "2023-08-11T14:10:50Z", + "vehicleIndex": 0, + "vehicleLabel": "P001 [refinement]/0", + "vehicleStartTime": "2023-08-11T13:51:14Z", + "visits": [ + { + "detour": "0s", + "shipmentIndex": 0, + "shipmentLabel": "0: S001", + "startTime": "2023-08-11T13:53:41Z" + }, + { + "detour": "118s", + "shipmentIndex": 3, + "shipmentLabel": "3: S004", + "startTime": "2023-08-11T14:01:48Z" + }, + { + "detour": "178s", + "shipmentIndex": 2, + "shipmentLabel": "2: S003", + "startTime": "2023-08-11T14:02:48Z" + }, + { + "detour": "238s", + "shipmentIndex": 1, + "shipmentLabel": "1: S002", + "startTime": "2023-08-11T14:03:48Z" + } + ] + }, + { + "endLoads": [{ "type": "ore" }, { "type": "wood" }], + "metrics": { + "breakDuration": "0s", + "delayDuration": "0s", + "maxLoads": { "ore": { "amount": "1" }, "wood": { "amount": "5" } }, + "performedShipmentCount": 1, + "totalDuration": "449s", + "travelDistanceMeters": 374, + "travelDuration": "299s", + "visitDuration": "150s", + "waitDuration": "0s" + }, + "routeCosts": { + "model.vehicles.cost_per_hour": 37.416666666666664, + "model.vehicles.cost_per_kilometer": 22.44, + "model.vehicles.fixed_cost": 10000 + }, + "routeTotalCost": 10059.856666666667, + "transitions": [ + { + "startTime": "2023-08-11T00:00:00Z", + "totalDuration": "150s", + "travelDistanceMeters": 187, + "travelDuration": "150s", + "vehicleLoads": { + "ore": { "amount": "1" }, + "wood": { "amount": "5" } + }, + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T00:05:00Z", + "totalDuration": "149s", + "travelDistanceMeters": 187, + "travelDuration": "149s", + "vehicleLoads": { "ore": {}, "wood": {} }, + "waitDuration": "0s" + } + ], + "travelSteps": [ + { "distanceMeters": 186, "duration": "149s" }, + { "distanceMeters": 186, "duration": "149s" } + ], + "vehicleDetour": "449s", + "vehicleEndTime": "2023-08-11T00:07:29Z", + "vehicleIndex": 1, + "vehicleLabel": "P002 [vehicles=(0,)]/0", + "vehicleStartTime": "2023-08-11T00:00:00Z", + "visits": [ + { + "arrivalLoads": [ + { "type": "ore", "value": "1" }, + { "type": "wood", "value": "5" } + ], + "demands": [ + { "type": "ore", "value": "-1" }, + { "type": "wood", "value": "-5" } + ], + "detour": "0s", + "loadDemands": { + "ore": { "amount": "-1" }, + "wood": { "amount": "-5" } + }, + "shipmentIndex": 6, + "shipmentLabel": "6: S007", + "startTime": "2023-08-11T00:02:30Z" + } + ] + }, + { + "metrics": { + "breakDuration": "0s", + "delayDuration": "0s", + "performedMandatoryShipmentCount": 3, + "performedShipmentCount": 3, + "totalDuration": "750s", + "travelDistanceMeters": 374, + "travelDuration": "300s", + "visitDuration": "450s", + "waitDuration": "0s" + }, + "transitions": [ + { + "startTime": "2023-08-11T08:10:53Z", + "totalDuration": "150s", + "travelDistanceMeters": 187, + "travelDuration": "150s", + "vehicleLoads": { + "ore": { "amount": "2" }, + "wheat": { "amount": "3" } + }, + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T08:15:53Z", + "totalDuration": "0s", + "travelDuration": "0s", + "vehicleLoads": { "ore": {}, "wheat": {} }, + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T08:18:23Z", + "totalDuration": "1s", + "travelDuration": "1s", + "vehicleLoads": { "ore": {}, "wheat": {} }, + "waitDuration": "0s" + }, + { + "startTime": "2023-08-11T08:20:54Z", + "totalDuration": "149s", + "travelDistanceMeters": 187, + "travelDuration": "149s", + "vehicleLoads": { "ore": {}, "wheat": {} }, + "waitDuration": "0s" + } + ], + "travelSteps": [ + { "distanceMeters": 186, "duration": "149s" }, + { "duration": "0s" }, + { "duration": "0s" }, + { "distanceMeters": 187, "duration": "149s" } + ], + "vehicleEndTime": "2023-08-11T08:23:23Z", + "vehicleIndex": 2, + "vehicleLabel": "P002 [refinement]/0", + "vehicleStartTime": "2023-08-11T08:10:53Z", + "visits": [ + { + "arrivalLoads": [ + { "type": "ore", "value": "2" }, + { "type": "wheat", "value": "3" } + ], + "demands": [ + { "type": "ore", "value": "-2" }, + { "type": "wheat", "value": "-3" } + ], + "detour": "0s", + "loadDemands": { + "ore": { "amount": "-2" }, + "wheat": { "amount": "-3" } + }, + "shipmentIndex": 5, + "shipmentLabel": "5: S006", + "startTime": "2023-08-11T08:13:23Z" + }, + { + "arrivalLoads": [{ "type": "ore" }, { "type": "wheat" }], + "demands": [{ "type": "ore" }, { "type": "wheat" }], + "detour": "150s", + "loadDemands": { "ore": {}, "wheat": {} }, + "shipmentIndex": 7, + "shipmentLabel": "7: S008", + "startTime": "2023-08-11T08:15:53Z" + }, + { + "arrivalLoads": [{ "type": "ore" }, { "type": "wheat" }], + "demands": [{ "type": "ore" }, { "type": "wheat" }], + "detour": "301s", + "loadDemands": { "ore": {}, "wheat": {} }, + "shipmentIndex": 4, + "shipmentLabel": "4: S005", + "startTime": "2023-08-11T08:18:24Z" + } + ] + } + ] +} diff --git a/python/cfr/two_step_routing/testdata/small/expected_local_refinement_request.json b/python/cfr/two_step_routing/testdata/small/expected_local_refinement_request.json index a96a88ee..a28d70c1 100644 --- a/python/cfr/two_step_routing/testdata/small/expected_local_refinement_request.json +++ b/python/cfr/two_step_routing/testdata/small/expected_local_refinement_request.json @@ -285,7 +285,7 @@ } }, "fixedCost": 10000, - "label": "global_route:0 start:1 size:3", + "label": "global_route:0 start:1 size:3 parking:P001", "loadLimits": { "ore": { "maxLoad": "2" } }, "routeDurationLimit": { "maxDuration": "1800s" }, "startTags": ["P001"], @@ -319,7 +319,7 @@ } }, "fixedCost": 10000, - "label": "global_route:1 start:0 size:2", + "label": "global_route:1 start:0 size:2 parking:P002", "loadLimits": { "ore": { "maxLoad": "2" } }, "startTags": ["P002"], "startTimeWindows": [ diff --git a/python/cfr/two_step_routing/testdata/small/local_refinement_response.json b/python/cfr/two_step_routing/testdata/small/local_refinement_response.json new file mode 100644 index 00000000..c8e6e3af --- /dev/null +++ b/python/cfr/two_step_routing/testdata/small/local_refinement_response.json @@ -0,0 +1,529 @@ +{ + "routes": [ + { + "vehicleLabel": "global_route:0 start:1 size:3 parking:P001", + "vehicleStartTime": "2023-08-11T13:51:14Z", + "vehicleEndTime": "2023-08-11T14:10:50Z", + "visits": [ + { + "isPickup": true, + "startTime": "2023-08-11T13:51:14Z", + "detour": "0s", + "shipmentLabel": "3: S004" + }, + { + "shipmentIndex": 3, + "isPickup": true, + "startTime": "2023-08-11T13:51:14Z", + "detour": "0s", + "shipmentLabel": "2: S003" + }, + { + "shipmentIndex": 2, + "isPickup": true, + "startTime": "2023-08-11T13:51:14Z", + "detour": "0s", + "shipmentLabel": "1: S002" + }, + { + "shipmentIndex": 1, + "isPickup": true, + "startTime": "2023-08-11T13:51:14Z", + "detour": "0s", + "shipmentLabel": "0: S001" + }, + { + "shipmentIndex": 1, + "startTime": "2023-08-11T13:53:41Z", + "detour": "0s", + "shipmentLabel": "0: S001" + }, + { + "startTime": "2023-08-11T14:01:48Z", + "detour": "118s", + "shipmentLabel": "3: S004" + }, + { + "shipmentIndex": 3, + "startTime": "2023-08-11T14:02:48Z", + "detour": "178s", + "shipmentLabel": "2: S003" + }, + { + "shipmentIndex": 2, + "startTime": "2023-08-11T14:03:48Z", + "detour": "238s", + "shipmentLabel": "1: S002" + } + ], + "transitions": [ + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T13:51:14Z" + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T13:51:14Z" + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T13:51:14Z" + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T13:51:14Z" + }, + { + "travelDuration": "147s", + "travelDistanceMeters": 360, + "waitDuration": "0s", + "totalDuration": "147s", + "startTime": "2023-08-11T13:51:14Z" + }, + { + "travelDuration": "367s", + "travelDistanceMeters": 1231, + "waitDuration": "0s", + "totalDuration": "367s", + "startTime": "2023-08-11T13:55:41Z" + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T14:02:48Z" + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T14:03:48Z" + }, + { + "travelDuration": "272s", + "travelDistanceMeters": 721, + "waitDuration": "0s", + "totalDuration": "272s", + "startTime": "2023-08-11T14:06:18Z" + } + ], + "metrics": { + "performedShipmentCount": 4, + "travelDuration": "786s", + "waitDuration": "0s", + "delayDuration": "0s", + "breakDuration": "0s", + "visitDuration": "390s", + "totalDuration": "1176s", + "travelDistanceMeters": 2312 + }, + "travelSteps": [ + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "147s", + "distanceMeters": 360 + }, + { + "duration": "367s", + "distanceMeters": 1233 + }, + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "272s", + "distanceMeters": 719 + } + ], + "vehicleDetour": "1176s", + "routeCosts": { + "model.vehicles.cost_per_hour": 98, + "model.vehicles.cost_per_kilometer": 138.72, + "model.vehicles.fixed_cost": 10000 + }, + "routeTotalCost": 10236.72 + }, + { + "vehicleIndex": 1, + "vehicleLabel": "global_route:1 start:0 size:2 parking:P002", + "vehicleStartTime": "2023-08-11T08:10:53Z", + "vehicleEndTime": "2023-08-11T08:23:23Z", + "visits": [ + { + "shipmentIndex": 5, + "isPickup": true, + "startTime": "2023-08-11T08:10:53Z", + "demands": [ + { + "type": "ore", + "value": "2" + }, + { + "type": "wheat", + "value": "3" + } + ], + "detour": "0s", + "shipmentLabel": "5: S006", + "arrivalLoads": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "loadDemands": { + "ore": { + "amount": "2" + }, + "wheat": { + "amount": "3" + } + } + }, + { + "shipmentIndex": 4, + "isPickup": true, + "startTime": "2023-08-11T08:10:53Z", + "demands": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "detour": "0s", + "shipmentLabel": "7: S008", + "arrivalLoads": [ + { + "type": "ore", + "value": "2" + }, + { + "type": "wheat", + "value": "3" + } + ], + "loadDemands": { + "wheat": {}, + "ore": {} + } + }, + { + "shipmentIndex": 6, + "isPickup": true, + "startTime": "2023-08-11T08:10:53Z", + "demands": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "detour": "0s", + "shipmentLabel": "4: S005", + "arrivalLoads": [ + { + "type": "ore", + "value": "2" + }, + { + "type": "wheat", + "value": "3" + } + ], + "loadDemands": { + "wheat": {}, + "ore": {} + } + }, + { + "shipmentIndex": 5, + "startTime": "2023-08-11T08:13:23Z", + "demands": [ + { + "type": "ore", + "value": "-2" + }, + { + "type": "wheat", + "value": "-3" + } + ], + "detour": "0s", + "shipmentLabel": "5: S006", + "arrivalLoads": [ + { + "type": "ore", + "value": "2" + }, + { + "type": "wheat", + "value": "3" + } + ], + "loadDemands": { + "ore": { + "amount": "-2" + }, + "wheat": { + "amount": "-3" + } + } + }, + { + "shipmentIndex": 4, + "startTime": "2023-08-11T08:15:53Z", + "demands": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "detour": "150s", + "shipmentLabel": "7: S008", + "arrivalLoads": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "loadDemands": { + "ore": {}, + "wheat": {} + } + }, + { + "shipmentIndex": 6, + "startTime": "2023-08-11T08:18:24Z", + "demands": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "detour": "301s", + "shipmentLabel": "4: S005", + "arrivalLoads": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "loadDemands": { + "ore": {}, + "wheat": {} + } + } + ], + "transitions": [ + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T08:10:53Z", + "vehicleLoads": { + "ore": {}, + "wheat": {} + } + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T08:10:53Z", + "vehicleLoads": { + "wheat": { + "amount": "3" + }, + "ore": { + "amount": "2" + } + } + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T08:10:53Z", + "vehicleLoads": { + "ore": { + "amount": "2" + }, + "wheat": { + "amount": "3" + } + } + }, + { + "travelDuration": "150s", + "travelDistanceMeters": 187, + "waitDuration": "0s", + "totalDuration": "150s", + "startTime": "2023-08-11T08:10:53Z", + "vehicleLoads": { + "ore": { + "amount": "2" + }, + "wheat": { + "amount": "3" + } + } + }, + { + "travelDuration": "0s", + "waitDuration": "0s", + "totalDuration": "0s", + "startTime": "2023-08-11T08:15:53Z", + "vehicleLoads": { + "wheat": {}, + "ore": {} + } + }, + { + "travelDuration": "1s", + "waitDuration": "0s", + "totalDuration": "1s", + "startTime": "2023-08-11T08:18:23Z", + "vehicleLoads": { + "ore": {}, + "wheat": {} + } + }, + { + "travelDuration": "149s", + "travelDistanceMeters": 187, + "waitDuration": "0s", + "totalDuration": "149s", + "startTime": "2023-08-11T08:20:54Z", + "vehicleLoads": { + "ore": {}, + "wheat": {} + } + } + ], + "metrics": { + "performedShipmentCount": 3, + "travelDuration": "300s", + "waitDuration": "0s", + "delayDuration": "0s", + "breakDuration": "0s", + "visitDuration": "450s", + "totalDuration": "750s", + "travelDistanceMeters": 374, + "maxLoads": { + "wheat": { + "amount": "3" + }, + "ore": { + "amount": "2" + } + } + }, + "endLoads": [ + { + "type": "ore" + }, + { + "type": "wheat" + } + ], + "travelSteps": [ + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "149s", + "distanceMeters": 186 + }, + { + "duration": "0s" + }, + { + "duration": "0s" + }, + { + "duration": "149s", + "distanceMeters": 187 + } + ], + "vehicleDetour": "750s", + "routeCosts": { + "model.vehicles.fixed_cost": 10000, + "model.vehicles.cost_per_hour": 62.5, + "model.vehicles.cost_per_kilometer": 22.44 + }, + "routeTotalCost": 10084.94 + } + ], + "totalCost": 20321.66, + "requestLabel": "my_little_model/local_refinement", + "metrics": { + "aggregatedRouteMetrics": { + "performedShipmentCount": 7, + "travelDuration": "1086s", + "waitDuration": "0s", + "delayDuration": "0s", + "breakDuration": "0s", + "visitDuration": "840s", + "totalDuration": "1926s", + "travelDistanceMeters": 2686, + "maxLoads": { + "wheat": { + "amount": "3" + }, + "ore": { + "amount": "2" + } + } + }, + "usedVehicleCount": 2, + "earliestVehicleStartTime": "2023-08-11T08:10:53Z", + "latestVehicleEndTime": "2023-08-11T14:10:50Z", + "totalCost": 20321.66, + "costs": { + "model.vehicles.cost_per_hour": 160.5, + "model.vehicles.fixed_cost": 20000, + "model.vehicles.cost_per_kilometer": 161.16 + } + } +} diff --git a/python/cfr/two_step_routing/two_step_routing.py b/python/cfr/two_step_routing/two_step_routing.py index 5245c44f..bd814320 100644 --- a/python/cfr/two_step_routing/two_step_routing.py +++ b/python/cfr/two_step_routing/two_step_routing.py @@ -45,7 +45,7 @@ """ import collections -from collections.abc import Collection, Mapping, Sequence, Set +from collections.abc import Collection, Mapping, MutableMapping, Sequence, Set import copy import dataclasses import enum @@ -459,13 +459,18 @@ def make_local_request(self) -> cfr_json.OptimizeToursRequest: return request def make_global_request( - self, local_response: cfr_json.OptimizeToursResponse + self, + local_response: cfr_json.OptimizeToursResponse, + consider_road_traffic_override: bool | None = None, ) -> cfr_json.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. + consider_road_traffic_override: When True or False, the + `considerRoadTraffic` option of the global model is set to this value. + When None, the value is taken from the base request. Returns: A JSON CFR request for the global model based on a solution of the local @@ -544,9 +549,12 @@ def make_global_request( "parent": self._request.get("parent"), } self._add_options_from_original_request(request) - consider_road_traffic = self._request.get("considerRoadTraffic") - if consider_road_traffic is not None: - request["considerRoadTraffic"] = consider_road_traffic + if consider_road_traffic_override is not None: + request["considerRoadTraffic"] = consider_road_traffic_override + else: + consider_road_traffic = self._request.get("considerRoadTraffic") + if consider_road_traffic is not None: + request["considerRoadTraffic"] = consider_road_traffic # TODO(ondrasej): Consider applying internal parameters also to the local # request; potentially, add separate internal parameters for the local and # the global models to the configuration of the planner. @@ -648,6 +656,7 @@ def get_non_existent_tag(base: str) -> str: f"global_route:{consecutive_visit_sequence.vehicle_index}" f" start:{consecutive_visit_sequence.first_global_visit_index}" f" size:{consecutive_visit_sequence.num_global_visits}" + f" parking:{consecutive_visit_sequence.parking_tag}" ) refinement_vehicle = _make_local_model_vehicle( self._options, parking, refinement_vehicle_label @@ -754,6 +763,71 @@ def get_non_existent_tag(base: str) -> str: self._add_options_from_original_request(request) return request + def integrate_local_refinement( + self, + local_request: cfr_json.OptimizeToursRequest, + local_response: cfr_json.OptimizeToursResponse, + global_request: cfr_json.OptimizeToursRequest, + global_response: cfr_json.OptimizeToursResponse, + refinement_response: cfr_json.OptimizeToursResponse, + ) -> tuple[ + cfr_json.OptimizeToursRequest, + cfr_json.OptimizeToursResponse, + cfr_json.OptimizeToursRequest, + ]: + """Integrates a refined local plan into the base local and global models. + + Takes the base local and global requests and responses, and a response to a + local refinement model, and creates: + + 1. an integrated local request and response; the local model is created by + replacing the refined parts of the base local model with the refinement. + The integrated local model covers all shipments delivered from a parking + location, and can be used as a local model with + Planner.merge_local_and_global_result. + 2. an integrated global request; the global request is similar to the base + global request, but it is based on the integrated local model rather than + on the base local model. + 3. a first solution hint for the integrated global request; this first + solution is created from the solution of the base global model by + replacing the refined parts. + + The integrated global model needs to be re-solved via CFR to (1) update + arrival and departure times in the solution, (2) inject road traffic + information if requested, and (3) use any time saved by the refinement to + deliver more shipments. The solution of the integrated global model can be + used together with the solution of the integrated local model to obtain a + merged request+response via `Planner.merge_local_and_global_result()`. + + Args: + local_request: A local request created by `self.make_local_request()`. + local_response: A solution for `local_request`. + global_request: A global request created by + `self.make_global_request(local_response)`. + global_response: A solution for `global_request`. + refinement_response: A solution for a request created by + `self.make_local_refinement_request(local_response, global_response)`. + + Returns: + A triple `(integrated_local_request, integrated_local_response, + integrated_global_request)` that contains the results of the integration + as described above. + + Raises: + ValueError: If the inputs are invalid or inconsistent. + """ + integration = _RefinedRouteIntegration( + options=self._options, + parking_locations=self._parking_locations, + request=self._request, + local_request=local_request, + local_response=local_response, + global_request=global_request, + global_response=global_response, + refinement_response=refinement_response, + ) + return integration.integrate() + def merge_local_and_global_result( self, local_response: cfr_json.OptimizeToursResponse, @@ -1231,6 +1305,545 @@ def append_shipment_error(error: str, shipment_index: int, label: str): return None +def _shipment_label_counts_in_global_route( + route: cfr_json.ShipmentRoute, +) -> Mapping[str, int]: + r"""Counts base shipment labels in the visits on a global model route. + + Assumes that the labels of the shipments on the route follow the format of + shipment labels in the global model "[ds]:\d+ (?.*)" where the + group contains a comma-separated list of shipment labels from the + base model. + + Note that when shipment labels in the base model contain commas, the counts + might not match but the results will be comparable in terms of the comparisons + done in `_assert_global_model_routes_handle_same_shipments()`. + + Args: + route: A route from the global model. + + Returns: + A mapping from shipment labels in the base model to the count of their + appearances on the route. + """ + label_count = collections.defaultdict(int) + for visit in cfr_json.get_visits(route): + global_shipment_label = visit["shipmentLabel"] + _, base_shipment_labels = global_shipment_label.split(" ", maxsplit=1) + for label in base_shipment_labels.split(","): + label_count[label] += 1 + return label_count + + +def _routes_by_unique_vehicle_indices( + response: cfr_json.OptimizeToursResponse, +) -> Mapping[int, cfr_json.ShipmentRoute]: + routes = {} + for route in cfr_json.get_routes(response): + vehicle = route.get("vehicleIndex", 0) + assert vehicle not in routes, f"Duplicate vehicle index {vehicle}" + routes[vehicle] = route + return routes + + +def _assert_global_model_routes_handle_same_shipments( + response_a: cfr_json.OptimizeToursResponse, + response_b: cfr_json.OptimizeToursResponse, +) -> None: + """Checks that routes in `response_a` and `response_b` serve same shipments. + + Assumes that `response_a` and `response_b` are two different solutions of + equivalent global models in a two-step routing model. They may be either two + different solutions of the same global model, or the solution of a base global + model and an integrated global model. + + Checks that the routes in `response_a` and `response_b` are similar in the + sense that: + - both responses have the same number of routes, + - the shipment labels on the routes are the same. This is done by extracting + the "original shipment labels" part of the the shipment labels on both + routes and checking that their numbers are the same. + + Args: + response_a: The first response to compare. + response_b: The second response to compare. + + Raises: + AssertionError: When the routes are not equivalent. + """ + routes_a = _routes_by_unique_vehicle_indices(response_a) + routes_b = _routes_by_unique_vehicle_indices(response_b) + + assert len(routes_a) == len(routes_b), ( + f"The number of routes is different. Found {len(routes_a)} routes in" + f" response_a, {len(routes_b)} in response_b." + ) + vehicle_indices_a = set(routes_a) + vehicle_indices_b = set(routes_b) + assert vehicle_indices_a == vehicle_indices_b, ( + "The vehicle indices of the routes are different. Found" + f" {vehicle_indices_a} in response_a and {vehicle_indices_b} in" + " response_b." + ) + + for vehicle_index in vehicle_indices_a: + route_a = routes_a[vehicle_index] + route_b = routes_b[vehicle_index] + label_count_a = _shipment_label_counts_in_global_route(route_a) + label_count_b = _shipment_label_counts_in_global_route(route_b) + assert label_count_a == label_count_b, ( + f"Shipment label counts for vehicle {vehicle_index} are different:\n" + f"response_a: {label_count_a},\n" + f"response_b: {label_count_b}" + ) + + +class _RefinedRouteIntegration: + """The integration of a refined local solution into the base models. + + This is the implementation of `Planner.integrate_local_refinement()`. See the + docstring of the method for more details of the algorithm. + """ + + def __init__( + self, + options: Options, + parking_locations: Mapping[str, ParkingLocation], + request: cfr_json.OptimizeToursRequest, + local_request: cfr_json.OptimizeToursRequest, + local_response: cfr_json.OptimizeToursResponse, + global_request: cfr_json.OptimizeToursRequest, + global_response: cfr_json.OptimizeToursResponse, + refinement_response: cfr_json.OptimizeToursResponse, + ): + """Initializes the integration algorithm. + + Args: + options: The options of the planner that uses this class. + parking_locations: The list of parking locations used in the planner. + request: The base request passed to the planner by the user. + local_request: A local request created by `planner.make_local_request()`. + local_response: A solution for `local_request`. + global_request: A global request created by + `planner.make_global_request(local_response)`. + global_response: A solution for `global_request`. + refinement_response: A solution for a request created by + `self.make_local_refinement_request(local_response, global_response)`. + """ + self._options = options + self._parking_locations = parking_locations + self._request = request + self._model = request["model"] + + self._local_request = local_request + local_model = local_request["model"] + self._local_vehicles = cfr_json.get_vehicles(local_model) + + self._local_response = local_response + self._local_routes = cfr_json.get_routes(local_response) + + self._global_request = global_request + global_model = self._global_request["model"] + self._global_shipments = cfr_json.get_shipments(global_model) + + self._global_response = global_response + self._global_routes = cfr_json.get_routes(global_response) + + refinement_routes = cfr_json.get_routes(refinement_response) + + # Shipments in the integrated local model are the same as in the base local + # model, only the vehicles are redefined based on the new routes. This is + # not 100% correct, as any fields based on vehicle indices + # (allowedVehicleIndices, costsPerVehicle) will be invalid in the integrated + # local model, but it is OK for use with merge_local_and_global_result(). + # TODO(ondrasej): Update vehicle-index based fields in the integrated local + # model to make it consistent with the rest of the request and the response. + integrated_local_shipments = list(cfr_json.get_shipments(local_model)) + self._integrated_local_vehicles: list[cfr_json.Vehicle] = [] + self._integrated_local_model: cfr_json.ShipmentModel = copy.copy( + local_model + ) + self._integrated_local_model["shipments"] = integrated_local_shipments + self._integrated_local_model["vehicles"] = self._integrated_local_vehicles + + self._integrated_local_routes: list[cfr_json.ShipmentRoute] = [] + self._integrated_local_request: cfr_json.OptimizeToursRequest | None = None + self._integrated_local_response: cfr_json.OptimizeToursResponse | None = ( + None + ) + + self._integrated_global_shipments: list[cfr_json.Shipment] = [] + self._integrated_global_model = copy.copy(global_model) + self._integrated_global_model["shipments"] = ( + self._integrated_global_shipments + ) + self._integrated_global_routes: list[cfr_json.ShipmentRoute] = [] + self._integrated_global_request: cfr_json.OptimizeToursRequest | None = None + + self._transition_attributes = _ParkingTransitionAttributeManager( + self._model + ) + + # Map routes from `refinement_routes` to the original global routes. + self._refinements_for_global_route: MutableMapping[ + int, MutableMapping[int, tuple[int, cfr_json.ShipmentRoute]] + ] = collections.defaultdict(dict) + for refinement_route in refinement_routes: + global_route_index, start_visit, num_visits, _ = ( + _parse_refinement_vehicle_label( + refinement_route.get("vehicleLabel", "") + ) + ) + self._refinements_for_global_route[global_route_index][start_visit] = ( + num_visits, + refinement_route, + ) + + self._local_shipment_for_original_shipment = ( + _make_local_shipment_from_shipment_map( + cfr_json.get_shipments(local_model) + ) + ) + + def integrate( + self, + ) -> tuple[ + cfr_json.OptimizeToursRequest, + cfr_json.OptimizeToursResponse, + cfr_json.OptimizeToursRequest, + ]: + """Integrates the refinement result into the base local and global models. + + Replaces the vehicles and routes in the base local model with the refined + ones and creates a new global model that uses the data from the refined + local model instead. At the global level, only the refined request is + created; it contains the updated virtual shipments, and it comes with a + first solution hint corresponding to the solution of the base global model. + + This method can be called multiple times. Subsequent calls just return the + same values as the first call. + + Returns: + A triple (local_request, local_response, global_request) where + local_request and local_response contain the integrated local model and + its solution, and global_request contains a global model adapted to the + integrated local model and injected routes based on the solution of the + base global model. + """ + if self._integrated_local_request is None: + self._integrate_global_routes() + self._integrate_global_skipped_shipments() + + # Create the integrated local request. We only need the request-level + # attributes, no need to copy also details of the model. + self._integrated_local_request = copy.copy(self._local_request) + self._integrated_local_request["model"] = self._integrated_local_model + self._integrated_local_request["label"] = ( + f"{self._request.get('label', '')}/refined_local" + ) + self._integrated_local_request.pop("injectedFirstSolutionRoutes", None) + + # Create the integrated local response. + self._integrated_local_response: cfr_json.OptimizeToursResponse = { + "routes": self._integrated_local_routes, + } + local_skipped_shipments = self._local_response.get("skippedShipments") + if local_skipped_shipments is not None: + self._integrated_local_response["skippedShipments"] = ( + local_skipped_shipments + ) + + # Create the integrated global request. + self._integrated_global_request = copy.copy(self._global_request) + self._integrated_global_request["model"] = self._integrated_global_model + self._integrated_global_request["injectedFirstSolutionRoutes"] = ( + self._integrated_global_routes + ) + consider_road_traffic = self._request.get("considerRoadTraffic") + if consider_road_traffic is not None: + self._integrated_global_request["considerRoadTraffic"] = ( + consider_road_traffic + ) + + _assert_global_model_routes_handle_same_shipments( + self._global_response, {"routes": self._integrated_global_routes} + ) + + assert self._integrated_local_request is not None + assert self._integrated_local_response is not None + assert self._integrated_global_request is not None + return ( + self._integrated_local_request, + self._integrated_local_response, + self._integrated_global_request, + ) + + def _integrate_global_skipped_shipments(self) -> None: + """Integrates skipped shipments from the global plan.""" + # NOTE(ondrasej): We'll be re-solving the global model with a warm start. + # The global skipped shipments were not part of the base global routes, so + # we only need to add the shipment definitions to the integrated global + # model, but we do not need to register them as "skipped" anywhere. + global_skipped_shipments = self._global_response.get("skippedShipments", ()) + for global_skipped_shipment in global_skipped_shipments: + global_shipment_index = global_skipped_shipment.get("index", 0) + global_shipment = self._global_shipments[global_shipment_index] + visit_type, index = _parse_global_shipment_label( + global_skipped_shipment["label"] + ) + match visit_type: + case "s": + # The visit is for a shipment delivered directly. It refers only to + # the shipment in the base model which did not change with + # integration, so we can reuse it in the integrate model without any + # changes. + self._add_integrated_global_shipment( + global_shipment, add_to_visits=None + ) + case "p": + # The visit is for a round of deliveries from a parking location. It + # was skipped in the base global model, and so it could not have been + # part of a refinement. + self._integrate_unmodified_local_route( + global_shipment, local_route_index=index, add_to_visits=None + ) + case _: + raise ValueError("Unexpected global visit type: {visit_type!r}") + + def _integrate_global_routes(self) -> None: + """Integrates visits from the global routes to the refined models. + + Goes through the routes in the solution of the base global model, and moves + them to the integrated local and global models. Shipments delivered directly + and local routes that did not go through the refinement are carried over + unmodified; local routes that were subject to refinement are replaced with + their refined versions. + """ + no_refinements = {} + for global_route_index, global_route in enumerate(self._global_routes): + refinements: Mapping[int, tuple[int, cfr_json.ShipmentRoute]] = ( + self._refinements_for_global_route.get( + global_route_index, no_refinements + ) + ) + + integrated_global_visits: list[cfr_json.Visit] = [] + self._integrated_global_routes.append({ + "vehicleIndex": global_route_index, + "visits": integrated_global_visits, + }) + + global_visits = cfr_json.get_visits(global_route) + num_visits_to_skip = 0 + for global_visit_index, global_visit in enumerate(global_visits): + if num_visits_to_skip > 0: + num_visits_to_skip -= 1 + continue + global_shipment_label = global_visit.get("shipmentLabel", "") + global_shipment_index = global_visit.get("shipmentIndex", 0) + global_shipment = self._global_shipments[global_shipment_index] + visit_type, index = _parse_global_shipment_label(global_shipment_label) + visit_refinement = refinements.get(global_visit_index) + if visit_refinement is not None: + if visit_type != "p": + raise ValueError( + "Expected a parking location visit, found" + f" {global_shipment_label!r}" + ) + num_refined_visits, refined_local_route = visit_refinement + self._integrate_refined_local_route( + refined_local_route, add_to_visits=integrated_global_visits + ) + # Skip all following visits that were part of the refined route that + # we just integrated. + num_visits_to_skip = num_refined_visits - 1 + else: + # This global visit was not part of the refinement, we just need to + # carry over the shipment or local delivery round from the base model. + match visit_type: + case "s": + self._add_integrated_global_shipment( + global_shipment, add_to_visits=integrated_global_visits + ) + case "p": + self._integrate_unmodified_local_route( + global_shipment, + local_route_index=index, + add_to_visits=integrated_global_visits, + ) + case _: + raise ValueError(f"Unexpected global visit type: {visit_type!r}") + assert num_visits_to_skip == 0 + + def _integrate_refined_local_route( + self, + refined_local_route: cfr_json.ShipmentRoute, + add_to_visits: list[cfr_json.Visit] | None, + ) -> None: + """Integrates a refined local route to the refined models and solutions. + + Splits the refined local route into delivery rounds, and creates a new + vehicle and route in the integrated local model and solution. Adds virtual + shipments and visits to the integrated global model and initial solution. + + Args: + refined_local_route: The route from the refinement local model that + represents the new delivery rounds for the parking location. + add_to_visits: When not None, visits for the shipments that represent the + new delivery rounds in the integrated global model are added to this + list. + """ + refined_route_splits = _split_refined_local_route(refined_local_route) + num_refined_route_splits = len(refined_route_splits) + refined_route_label = refined_local_route.get("vehicleLabel", "") + assert refined_route_splits, "Refined local routes are never empty." + + _, _, _, parking_tag = _parse_refinement_vehicle_label(refined_route_label) + parking = self._parking_locations[parking_tag] + + for refined_split_index, refined_split in enumerate(refined_route_splits): + ( + integrated_route_visits, + integrated_route_transitions, + integrated_route_travel_steps, + ) = copy.deepcopy(refined_split) + assert integrated_route_visits + assert ( + len(integrated_route_transitions) == len(integrated_route_visits) + 1 + ) + + # Update shipment indices in the integrated local route. + for visit in integrated_route_visits: + # We first extract the index of the shipment in the original model and + # then map it to a shipment index in the base local model. Since the + # visits are already a local copy, we can safely update it in place. + shipment_index = _get_shipment_index_from_local_route_visit(visit) + visit["shipmentIndex"] = self._local_shipment_for_original_shipment[ + shipment_index + ] + + # Add a new vehicle for the delivery round to the integrated local model. + integrated_local_vehicle_index = len(self._integrated_local_vehicles) + integrated_local_vehicle_label = ( + f"{parking_tag} [refinement]/{refined_split_index}" + ) + self._integrated_local_vehicles.append( + _make_local_model_vehicle( + self._options, parking, integrated_local_vehicle_label + ) + ) + + # Create the integrated local route for the delivery round. + integrated_local_route: cfr_json.ShipmentRoute = { + "vehicleIndex": integrated_local_vehicle_index, + "vehicleLabel": integrated_local_vehicle_label, + # TODO(ondrasej): See if this list conversion is really needed. + "visits": list(integrated_route_visits), + "transitions": list(integrated_route_transitions), + "travelSteps": list(integrated_route_travel_steps), + } + is_last_split = refined_split_index == num_refined_route_splits - 1 + remove_delay = None if is_last_split else parking.reload_duration + cfr_json.update_route_start_end_time_from_transitions( + integrated_local_route, + remove_delay_at_end=remove_delay, + ) + cfr_json.recompute_route_metrics( + self._integrated_local_model, integrated_local_route + ) + integrated_local_route_index = len(self._integrated_local_routes) + self._integrated_local_routes.append(integrated_local_route) + + # Add a global shipment for the local delivery round. + integrated_global_shipment = _make_global_model_shipment_for_local_route( + self._model, + integrated_local_route_index, + integrated_local_route, + parking, + transition_attributes=self._transition_attributes, + ) + self._add_integrated_global_shipment( + integrated_global_shipment, add_to_visits + ) + + def _integrate_unmodified_local_route( + self, + global_shipment: cfr_json.Shipment, + local_route_index: int, + add_to_visits: list[cfr_json.Visit] | None, + ) -> None: + """Integrates a local route into the refined models and solutions. + + Takes one local route (delivery round from a parking location) that was not + touched by the refinement process and integrates it into the refined global + and local models and their solutions. + + Args: + global_shipment: The shipment in the global model that represents the + local route. + local_route_index: The index of the local route in the base local model. + add_to_visits: When not none, a visit for the shipment that represents the + local route in the refined global model is added to this list. + """ + # Copy the original local vehicle and route to the integrated local request + # and response. We preserve the indices of shipments in the local model, so + # we can copy this route as is, except for the vehicle index in the route + # object. + integrated_local_vehicle_index = len(self._integrated_local_vehicles) + self._integrated_local_vehicles.append( + self._local_vehicles[local_route_index] + ) + # NOTE(ondrasej): We're going to change only the vehicle index of the route, + # a deep copy is not needed and would be inefficient. + integrated_local_route = copy.copy(self._local_routes[local_route_index]) + integrated_local_route["vehicleIndex"] = integrated_local_vehicle_index + self._integrated_local_routes.append(integrated_local_route) + + # Copy the virtual shipment for the parking location visit to the integrated + # global model. Most of the information in the shipment holds, we just need + # to update the index of the local route in the label. + integrated_global_shipment = copy.deepcopy(global_shipment) + _, original_shipment_label = integrated_global_shipment["label"].split( + " ", maxsplit=1 + ) + integrated_global_shipment["label"] = ( + f"p:{integrated_local_vehicle_index} {original_shipment_label}" + ) + self._add_integrated_global_shipment( + integrated_global_shipment, add_to_visits + ) + + def _add_integrated_global_shipment( + self, + shipment: cfr_json.Shipment, + add_to_visits: list[cfr_json.Visit] | None, + ) -> int: + """Adds `shipment` to the integrated request and returns its index. + + Args: + shipment: The shipment to be added. + add_to_visits: When not None, the method adds a delivery visit to the + newly added shipment to this list. + + Returns: + The index of the newly added integrated shipment. + """ + # NOTE(ondrasej): This method works only for shipments with a single + # delivery option and no pickups. + assert len(shipment["deliveries"]) == 1 + assert not shipment.get("pickups") + + shipment_index = len(self._integrated_global_shipments) + self._integrated_global_shipments.append(shipment) + if add_to_visits is not None: + add_to_visits.append({ + "shipmentIndex": shipment_index, + "shipmentLabel": shipment.get("label", ""), + "isPickup": False, + }) + return shipment_index + + class _ParkingTransitionAttributeManager: """Manages transition attributes for parking locations in the global model.""" @@ -1371,8 +1984,6 @@ def _make_global_model_shipment_for_local_route( """ visits = cfr_json.get_visits(local_route) - parking_tag = _get_parking_tag_from_local_route(local_route) - # Get all shipments from the original model that are delivered in this # parking location route. shipment_indices = _get_shipment_indices_from_local_route_visits(visits) @@ -1381,13 +1992,14 @@ def _make_global_model_shipment_for_local_route( ) assert shipments + global_delivery_tags: list[str] = [parking.tag] global_delivery: cfr_json.VisitRequest = { # We use the coordinates of the parking location for the waypoint. "arrivalWaypoint": {"location": {"latLng": parking.coordinates}}, # The duration of the delivery at the parking location is the total # duration of the local route for this round. "duration": local_route["metrics"]["totalDuration"], - "tags": [parking_tag], + "tags": global_delivery_tags, } global_time_windows = _get_local_model_route_start_time_windows( model, local_route @@ -1400,7 +2012,7 @@ def _make_global_model_shipment_for_local_route( parking ) if parking_transition_tag is not None: - global_delivery["tags"].append(parking_transition_tag) + global_delivery_tags.append(parking_transition_tag) shipment_labels = ",".join(shipment["label"] for shipment in shipments) global_shipment: cfr_json.Shipment = { @@ -1435,7 +2047,7 @@ def _make_global_model_shipment_for_local_route( _GLOBAL_SHIPEMNT_LABEL = re.compile(r"^([ps]):(\d+) .*") _REFINEMENT_VEHICLE_LABEL = re.compile( - r"^global_route:(\d+) start:(\d+) size:(\d+)$" + r"^global_route:(\d+) start:(\d+) size:(\d+) parking:(.*)$" ) @@ -1598,8 +2210,9 @@ def _get_local_model_route_start_time_windows( if not overall_route_start_time_intervals: raise ValueError( - "The shipments have incompatible time windows. Arrived an an empty time" - " window intersection." + f"The shipments in local route {route['vehicleLabel']!r} have" + " incompatible time windows. Arrived at an empty time window" + " intersection." ) # Transform intervals into time window data structures. @@ -1696,6 +2309,7 @@ def _get_consecutive_parking_location_visits( """ local_routes = cfr_json.get_routes(local_response) global_visits = cfr_json.get_visits(global_route) + global_transitions = cfr_json.get_transitions(global_route) consecutive_visits = [] local_route_indices = [] sequence_start = None @@ -1716,9 +2330,8 @@ def add_sequence_if_needed(sequence_end: int): local_visits = cfr_json.get_visits(local_route) shipment_indices.append([]) for local_visit in local_visits: - local_shipment_label = local_visit.get("shipmentLabel", "") shipment_indices[-1].append( - _get_shipment_index_from_local_label(local_shipment_label) + _get_shipment_index_from_local_route_visit(local_visit) ) consecutive_visits.append( @@ -1742,9 +2355,15 @@ def add_sequence_if_needed(sequence_end: int): local_route_indices = [] continue assert visit_type == "p" + transition_in = global_transitions[global_visit_index] + break_duration = transition_in.get("breakDuration", "0s") local_route = local_routes[index] parking_tag = _get_parking_tag_from_local_route(local_route) - if parking_tag != previous_parking_tag: + if parking_tag != previous_parking_tag or break_duration != "0s": + # The sequence ends when the vehicle moves to another parking location or + # when there is a break scheduled between the two visits. As of 2023-11-06 + # we do not support breaks in local routes, and so we need to keep the + # part before the break and the part after the break separate. add_sequence_if_needed(global_visit_index) previous_parking_tag = parking_tag sequence_start = global_visit_index @@ -1756,23 +2375,29 @@ def add_sequence_if_needed(sequence_end: int): def _split_refined_local_route( route: cfr_json.ShipmentRoute, -) -> Sequence[tuple[Sequence[cfr_json.Visit], Sequence[cfr_json.Transition]]]: +) -> Sequence[ + tuple[ + Sequence[cfr_json.Visit], + Sequence[cfr_json.Transition], + Sequence[cfr_json.TravelStep], + ] +]: """Extracts delivery rounds from a local refinement model route. In the local refinement model, a route may contain more than one delivery round. Each delivery round consists of a sequence of delivery visits, and the rounds in a route are separated by sequences of pickup visits (at the address - of the parking location). This function returns the visits and transitions - corresponding to each of the delivery rounds. + of the parking location). This function returns the visits, transitions, and + travel steps corresponding to each of the delivery rounds. Args: route: A route from the local delivery model to be split. Returns: A sequence of splits of the current route. Each split is returned as a list - of visits and transitions that belong to the segment. Only delivery visits - are returned, and the first (resp. last) transition in each group is from - (resp. to) the parking location. + of visits, transitions, and travel steps that belong to the segment. Only + delivery visits are returned, and the first (resp. last) transition in each + group is from (resp. to) the parking location. """ if route.get("breaks", ()): raise ValueError("Breaks in the local routes are not supported.") @@ -1784,6 +2409,7 @@ def _split_refined_local_route( visits = iter(enumerate(cfr_json.get_visits(route))) transitions = route.get("transitions", ()) + travel_steps = route.get("travelSteps", ()) indexed_visit = next(visits, None) visit_index = None while True: @@ -1800,6 +2426,7 @@ def _split_refined_local_route( split_visits = [] split_transitions = [] + split_travel_steps = [] # Extract visits and transitions from the current split. while indexed_visit is not None: @@ -1810,6 +2437,7 @@ def _split_refined_local_route( break split_visits.append(visit) split_transitions.append(transitions[visit_index]) + split_travel_steps.append(travel_steps[visit_index]) indexed_visit = next(visits, None) # Add the transition back to the parking location. We can just add the next @@ -1819,21 +2447,23 @@ def _split_refined_local_route( if indexed_visit is None: visit_index += 1 split_transitions.append(transitions[visit_index]) + split_travel_steps.append(travel_steps[visit_index]) # If the algorithm is correct, there must be at least one split. Otherwise, # we'd exit the parent while loop right at the beginning. assert split_visits, "Unexpected empty visit list" assert split_transitions, "Unexpected empty transition list" + assert split_travel_steps, "Unexpected empty travel step list" - splits.append((split_visits, split_transitions)) + splits.append((split_visits, split_transitions, split_travel_steps)) -def _parse_refinement_vehicle_label(label: str) -> tuple[int, int, int]: +def _parse_refinement_vehicle_label(label: str) -> tuple[int, int, int, str]: """Parses the label of a vehicle in the local refinement model.""" match = _REFINEMENT_VEHICLE_LABEL.match(label) if not match: raise ValueError("Invalid vehicle label in refinement model: {label!r}") - return int(match[1]), int(match[2]), int(match[3]) + return int(match[1]), int(match[2]), int(match[3]), match[4] def _parse_global_shipment_label(label: str) -> tuple[str, int]: @@ -1843,6 +2473,32 @@ def _parse_global_shipment_label(label: str) -> tuple[str, int]: return match[1], int(match[2]) +def _make_local_shipment_from_shipment_map( + local_shipments: Sequence[cfr_json.Shipment], +) -> Mapping[int, int]: + """Returns a map from shipment indices in the base model to the local model. + + Args: + local_shipments: The list of shipments from a local model in the two-step + routing library. + + Returns: + A mapping where the keys are the indices of the shipment in the base model, + and the value for a key is the index of the corresponding shipment in the + local model. If a shipment is not delivered through a parking location, the + shipment index is not present in the returned mapping. + """ + local_shipment_for_base_shipment = {} + for local_index, local_shipment in enumerate(local_shipments): + shipment_index = _get_shipment_index_from_local_shipment(local_shipment) + if shipment_index in local_shipment_for_base_shipment: + raise ValueError( + "Duplicate shipment indices in the local shipment labels" + ) + local_shipment_for_base_shipment[shipment_index] = local_index + return local_shipment_for_base_shipment + + def _get_shipment_index_from_local_label(label: str) -> int: shipment_index, _ = label.split(":") return int(shipment_index) @@ -1852,6 +2508,13 @@ def _get_shipment_index_from_local_route_visit(visit: cfr_json.Visit) -> int: return _get_shipment_index_from_local_label(visit["shipmentLabel"]) +def _get_shipment_index_from_local_shipment( + local_shipment: cfr_json.Shipment, +) -> int: + local_shipment_label = local_shipment.get("label", "") + return _get_shipment_index_from_local_label(local_shipment_label) + + def _get_shipment_indices_from_local_route_visits( visits: Sequence[cfr_json.Visit], ) -> Sequence[int]: diff --git a/python/cfr/two_step_routing/two_step_routing_main.py b/python/cfr/two_step_routing/two_step_routing_main.py index 43bb2214..923fa12f 100644 --- a/python/cfr/two_step_routing/two_step_routing_main.py +++ b/python/cfr/two_step_routing/two_step_routing_main.py @@ -53,13 +53,22 @@ class Flags: 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. - reuse_existing: When a file with a response exists, load it instead of - resolving the request. + reuse_existing: The value of the --reuse_existing flag. When a file with a + response exists, load it instead of resolving the request. + use_refinement: The value of the --use_refinement flag. When True, the + planner uses a third solve to reoptimize local routes from the same + parking if they are performed in a sequence (allowing the planner to merge + and reorganize them) and a fourth phase to clean up the global routes with + the updated local routes. local_grouping: The value of the --local_grouping flag or the default value. travel_mode_in_merged_transitions: The value of the --travel_mode_in_merged_transitions flag. local_timeout: The value of the --local_timeout flag or the default value. global_timeout: The value of the --global_timeout flag or the default value. + local_refinement_timeout: The value of the --local_refinement_timeout flag + or the default value. + global_refinement_timeout: The value of the --global_refinement_timeout flag + or the default value. """ request_file: str @@ -67,10 +76,13 @@ class Flags: google_cloud_project: str google_cloud_token: str reuse_existing: bool + use_refinement: bool local_grouping: two_step_routing.LocalModelGrouping travel_mode_in_merged_transitions: bool local_timeout: cfr_json.DurationString global_timeout: cfr_json.DurationString + local_refinement_timeout: cfr_json.DurationString + global_refinement_timeout: cfr_json.DurationString def _parse_flags() -> Flags: @@ -125,6 +137,28 @@ def _parse_flags() -> Flags: help="The timeout for the global model. Uses the duration string format.", default="1800s", ) + parser.add_argument( + "--local_refinement_timeout", + help=( + "The timeout for the local refinement model. Uses the duration string" + " format." + ), + default="240s", + ) + parser.add_argument( + "--global_refinement_timeout", + help=( + "The timeout for the global refinement model. Uses the duration" + " string format." + ), + default="240s", + ) + parser.add_argument( + "--use_refinement", + help="Use the refinement models to clean up parking location visits.", + default=False, + action="store_true", + ) parser.add_argument( "--reuse_existing", help="Reuse existing solution files, if they exist.", @@ -142,6 +176,9 @@ def _parse_flags() -> Flags: local_grouping=two_step_routing.LocalModelGrouping[flags.local_grouping], travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions, global_timeout=flags.global_timeout, + local_refinement_timeout=flags.local_refinement_timeout, + global_refinement_timeout=flags.global_refinement_timeout, + use_refinement=flags.use_refinement, reuse_existing=flags.reuse_existing, ) @@ -294,7 +331,15 @@ def _run_two_step_planner() -> None: ) logging.info("Creating global model") - global_request = planner.make_global_request(local_response) + # When doing a refinement pass later, do not use live traffic in the base + # global model. We will be injecting the solution from the base global model + # into a refined global model, and for this to work correctly, we need to use + # the same duration/distance matrices in both solves. + global_request_traffic_override = False if flags.use_refinement else None + global_request = planner.make_global_request( + local_response, + consider_road_traffic_override=global_request_traffic_override, + ) global_request["searchMode"] = 2 io_utils.write_json_to_file( f"{base_filename}.global_request.{flags.local_timeout}.json", @@ -310,6 +355,12 @@ def _run_two_step_planner() -> None: global_request, flags, flags.global_timeout, global_response_filename ) + # NOTE(ondrasej): Create the merged request+response from the first two phases + # even when refinement is used. Having a merged response from the first two + # phases in addition to the responses from the refined plan is very useful + # when evaluating the effects of the refinement. + # Each version uses a different file name, so there is no risk of creating a + # naming conflict or overwriting one with the other. logging.info("Merging the results") merged_request, merged_response = planner.merge_local_and_global_result( local_response, global_response @@ -325,6 +376,82 @@ def _run_two_step_planner() -> None: f"{base_filename}.merged_response.{timeout_suffix}.json", merged_response, ) + if flags.use_refinement: + logging.info("Creating local refinement model") + local_refinement_request_filename = ( + f"{base_filename}.local_refinement_request.{timeout_suffix}.json" + ) + local_refinement_request = planner.make_local_refinement_request( + local_response, global_response + ) + io_utils.write_json_to_file( + local_refinement_request_filename, + local_refinement_request, + ) + + logging.info("Solving local refinement model") + local_refinement_response_filename = ( + f"{base_filename}.local_refinement_response.{timeout_suffix}.json" + ) + local_refinement_response = _optimize_tours_and_write_response( + local_refinement_request, + flags, + flags.local_refinement_timeout, + local_refinement_response_filename, + ) + + logging.info("Integrating the refinement") + ( + integrated_local_request, + integrated_local_response, + integrated_global_request, + ) = planner.integrate_local_refinement( + local_request, + local_response, + global_request, + global_response, + local_refinement_response, + ) + io_utils.write_json_to_file( + f"{base_filename}.integrated_local_request.{timeout_suffix}.json", + integrated_local_request, + ) + io_utils.write_json_to_file( + f"{base_filename}.integrated_local_response.{timeout_suffix}.json", + integrated_local_response, + ) + io_utils.write_json_to_file( + f"{base_filename}.integrated_global_request.{timeout_suffix}.json", + integrated_global_request, + ) + + logging.info("Solving the integrated global model") + integrated_global_response_filename = ( + f"{base_filename}.integrated_global_response.{timeout_suffix}.json" + ) + integrated_global_response = _optimize_tours_and_write_response( + integrated_global_request, + flags, + flags.global_refinement_timeout, + integrated_global_response_filename, + ) + + logging.info("Merging the results") + merged_request, merged_response = planner.merge_local_and_global_result( + integrated_local_response, + integrated_global_response, + ) + + logging.info("Writing merged integrated request") + io_utils.write_json_to_file( + f"{base_filename}.merged_integrated_request.{timeout_suffix}.json", + merged_request, + ) + logging.info("Writing merged integrated response") + io_utils.write_json_to_file( + f"{base_filename}.merged_integrated_response.{timeout_suffix}.json", + merged_response, + ) if __name__ == "__main__": diff --git a/python/cfr/two_step_routing/two_step_routing_test.py b/python/cfr/two_step_routing/two_step_routing_test.py index 6e518ca9..0154faf4 100644 --- a/python/cfr/two_step_routing/two_step_routing_test.py +++ b/python/cfr/two_step_routing/two_step_routing_test.py @@ -4,6 +4,7 @@ # in the LICENSE file or at https://opensource.org/licenses/MIT. from collections.abc import Sequence +import copy import datetime from importlib import resources import json @@ -15,7 +16,9 @@ # Provides easy access to files under `./testdata`. See `_json()` below for # example use. -_TESTDATA = resources.files(__package__).joinpath("testdata") +_TESTDATA = resources.files( + "google3.third_party.cfr.python.cfr.two_step_routing" +).joinpath("testdata") def _json(path: str): @@ -322,6 +325,20 @@ def test_make_global_request(self): global_request = planner.make_global_request(self._LOCAL_RESPONSE_JSON) self.assertEqual(global_request, self._EXPECTED_GLOBAL_REQUEST_JSON) + def test_make_global_request_with_traffic_override(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, consider_road_traffic_override=False + ) + expected_request = copy.deepcopy(self._EXPECTED_GLOBAL_REQUEST_JSON) + expected_request["considerRoadTraffic"] = False + self.assertEqual(global_request, expected_request) + class PlannerTestMergedModel(PlannerTest): """Tests for Planner.merge_local_and_global_result().""" @@ -406,6 +423,49 @@ def test_local_refinement_model(self): ) +class PlannerTestIntegratedModels(PlannerTest): + _LOCAL_REFINEMENT_RESPONSE: cfr_json.OptimizeToursResponse = _json( + "small/local_refinement_response.json" + ) + _EXPECTED_INTEGRATED_LOCAL_REQUEST: cfr_json.OptimizeToursRequest = _json( + "small/expected_integrated_local_request.json" + ) + _EXPECTED_INTEGRATED_LOCAL_RESPONSE: cfr_json.OptimizeToursResponse = _json( + "small/expected_integrated_local_response.json" + ) + _EXPECTED_INTEGRATED_GLOBAL_REQUEST: cfr_json.OptimizeToursRequest = _json( + "small/expected_integrated_global_request.json" + ) + + def test_integrated_models(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, + ) + ( + integrated_local_request, + integrated_local_response, + integrated_global_request, + ) = planner.integrate_local_refinement( + local_request=self._EXPECTED_LOCAL_REQUEST_JSON, + local_response=self._LOCAL_RESPONSE_JSON, + global_request=self._EXPECTED_GLOBAL_REQUEST_JSON, + global_response=self._GLOBAL_RESPONSE_JSON, + refinement_response=self._LOCAL_REFINEMENT_RESPONSE, + ) + self.assertEqual( + integrated_local_request, self._EXPECTED_INTEGRATED_LOCAL_REQUEST + ) + self.assertEqual( + integrated_local_response, self._EXPECTED_INTEGRATED_LOCAL_RESPONSE + ) + self.assertEqual( + integrated_global_request, self._EXPECTED_INTEGRATED_GLOBAL_REQUEST + ) + + class GetLocalModelRouteStartTimeWindowsTest(unittest.TestCase): """Tests for _get_local_model_route_start_time_windows.""" @@ -557,7 +617,8 @@ def test_only_shipments(self): {"shipmentLabel": "s:1 S002"}, {"shipmentLabel": "s:5 SOO6"}, {"shipmentLabel": "s:6 S007"}, - ] + ], + "transitions": [{}, {}, {}, {}], } self.assertSequenceEqual( two_step_routing._get_consecutive_parking_location_visits( @@ -600,6 +661,7 @@ def test_different_parkings_and_shipments(self): {"shipmentLabel": "p:2 P002"}, {"shipmentLabel": "p:0 P001"}, ], + "transitions": [{}, {}, {}, {}, {}], } self.assertSequenceEqual( two_step_routing._get_consecutive_parking_location_visits( @@ -660,6 +722,7 @@ def test_consecutive_visits(self): {"shipmentLabel": "p:2 P002"}, {"shipmentLabel": "p:4 P002"}, ], + "transitions": [{}, {}, {}, {}, {}, {}, {}, {}], } self.assertSequenceEqual( two_step_routing._get_consecutive_parking_location_visits( @@ -685,6 +748,88 @@ def test_consecutive_visits(self): ), ) + def test_consecutive_visits_with_breaks(self): + local_response: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "vehicleLabel": "P001 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "3: S003"}, + {"shipmentLabel": "5: S005"}, + ], + }, + { + "vehicleLabel": "P001 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "1: S001"}, + {"shipmentLabel": "12: S012"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/0", + "visits": [ + {"shipmentLabel": "2: S002"}, + {"shipmentLabel": "8: S008"}, + {"shipmentLabel": "0: S000"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/1", + "visits": [ + {"shipmentLabel": "4: S004"}, + {"shipmentLabel": "6: S006"}, + ], + }, + { + "vehicleLabel": "P002 [vehicles=(0,)]/2", + "visits": [ + {"shipmentLabel": "9: S009"}, + {"shipmentLabel": "10: S010"}, + ], + }, + ], + } + global_route: cfr_json.ShipmentRoute = { + "vehicleIndex": 2, + "visits": [ + {"shipmentLabel": "s:0 S001"}, + {"shipmentLabel": "p:0 P001"}, + {"shipmentLabel": "p:1 P001"}, + {"shipmentLabel": "s:10 S011"}, + {"shipmentLabel": "p:3 P002"}, + {"shipmentLabel": "p:2 P002"}, + # There is a break between the visit above and the one below. This + # breaks splits this sequence into two; the one above has + # consecutive visits, the one below does not. Only the part above is + # returned by _get_consecutive_parking_location_visits(). + {"shipmentLabel": "p:4 P002"}, + ], + "transitions": [{}, {}, {}, {}, {}, {}, {"breakDuration": "600s"}, {}], + } + self.assertSequenceEqual( + two_step_routing._get_consecutive_parking_location_visits( + local_response, global_route + ), + ( + two_step_routing._ConsecutiveParkingLocationVisits( + parking_tag="P001", + global_route=global_route, + first_global_visit_index=1, + num_global_visits=2, + local_route_indices=[0, 1], + shipment_indices=[[3, 5], [1, 12]], + ), + two_step_routing._ConsecutiveParkingLocationVisits( + parking_tag="P002", + global_route=global_route, + first_global_visit_index=4, + num_global_visits=2, + local_route_indices=[3, 2], + shipment_indices=[[4, 6], [2, 8, 0]], + ), + ), + ) + def test_only_parking(self): local_response: cfr_json.OptimizeToursResponse = { "routes": [ @@ -733,7 +878,8 @@ def test_only_parking(self): {"shipmentLabel": "p:1 P001"}, {"shipmentLabel": "p:3 P002"}, {"shipmentLabel": "p:2 P002"}, - ] + ], + "transitions": [{}, {}, {}, {}, {}, {}], } self.assertSequenceEqual( two_step_routing._get_consecutive_parking_location_visits( @@ -798,13 +944,15 @@ def test_single_round(self): {"totalDuration": "72s"}, {"totalDuration": "18s"}, ] + travel_steps = [{} for _ in transitions] route: cfr_json.ShipmentRoute = { "visits": visits, "transitions": transitions, + "travelSteps": travel_steps, } self.assertSequenceEqual( two_step_routing._split_refined_local_route(route), - ((visits[4:], transitions[4:]),), + ((visits[4:], transitions[4:], travel_steps[4:]),), ) def test_multiple_rounds(self): @@ -829,16 +977,18 @@ def test_multiple_rounds(self): {"totalDuration": "72s"}, {"totalDuration": "18s"}, ] + travel_steps = [{} for _ in transitions] route: cfr_json.ShipmentRoute = { "visits": visits, "transitions": transitions, + "travelSteps": travel_steps, } self.assertSequenceEqual( two_step_routing._split_refined_local_route(route), ( - (visits[1:2], transitions[1:3]), - (visits[4:6], transitions[4:7]), - (visits[7:], transitions[7:]), + (visits[1:2], transitions[1:3], travel_steps[1:3]), + (visits[4:6], transitions[4:7], travel_steps[4:7]), + (visits[7:], transitions[7:], travel_steps[7:]), ), ) @@ -857,15 +1007,15 @@ def test_invalid_label(self): ValueError, "Invalid vehicle label in refinement model" ): two_step_routing._parse_refinement_vehicle_label( - "global_route:foo start:1 size:2" + "global_route:foo start:1 size:2 PARKING:P001" ) def test_valid_label(self): self.assertEqual( two_step_routing._parse_refinement_vehicle_label( - "global_route:32 start:1 size:2" + "global_route:32 start:1 size:2 parking:P002" ), - (32, 1, 2), + (32, 1, 2, "P002"), ) @@ -1246,5 +1396,165 @@ def test_with_datetime(self): pass +class TestAssertGlobalModelRoutesHandleSameShipments(unittest.TestCase): + """Tests for _assert_global_model_routes_handle_same_shipments.""" + + def test_no_routes(self): + two_step_routing._assert_global_model_routes_handle_same_shipments({}, {}) + + def test_empty_routes(self): + response_a: cfr_json.OptimizeToursResponse = {"routes": []} + response_b: cfr_json.OptimizeToursResponse = {"routes": []} + two_step_routing._assert_global_model_routes_handle_same_shipments( + response_a, response_b + ) + + def test_different_number_of_routes(self): + response_a: cfr_json.OptimizeToursResponse = { + "routes": [{"visits": []}, {"vehicleIndex": 1, "visits": []}] + } + response_b: cfr_json.OptimizeToursResponse = {"routes": [{"visits": []}]} + with self.assertRaisesRegex( + AssertionError, "The number of routes is different" + ): + two_step_routing._assert_global_model_routes_handle_same_shipments( + response_a, response_b + ) + + def test_multiple_routes_same_shipments(self): + response_a: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "visits": [ + {"shipmentLabel": "s:32 S001"}, + {"shipmentLabel": "p:0 S002,S003,S007"}, + {"shipmentLabel": "p:3 S004,S117,S231"}, + {"shipmentLabel": "p:12 S032,S078"}, + ] + }, + { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:12 S005"}, + {"shipmentLabel": "p:11 S008"}, + {"shipmentLabel": "p:3 S006,S011"}, + ], + }, + ] + } + response_b: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:12 S005"}, + {"shipmentLabel": "p:1 S008,S006,S011"}, + ], + }, + { + "visits": [ + {"shipmentLabel": "s:32 S001"}, + {"shipmentLabel": "p:2 S002,S003,S007,S004,S117,S231"}, + {"shipmentLabel": "p:0 S032,S078"}, + ] + }, + ] + } + two_step_routing._assert_global_model_routes_handle_same_shipments( + response_a, response_b + ) + + def test_multiple_routes_same_shipments_different_vehicles(self): + response_a: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "visits": [ + {"shipmentLabel": "s:32 S001"}, + {"shipmentLabel": "p:0 S002,S003,S007"}, + {"shipmentLabel": "p:3 S004,S117,S231"}, + {"shipmentLabel": "p:12 S032,S078"}, + ] + }, + { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:12 S005"}, + {"shipmentLabel": "p:11 S008"}, + {"shipmentLabel": "p:3 S006,S011"}, + ], + }, + ] + } + response_b: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:32 S001"}, + {"shipmentLabel": "p:2 S002,S003,S007,S004,S117,S231"}, + {"shipmentLabel": "p:0 S032,S078"}, + ], + }, + { + "vehicleIndex": 0, + "visits": [ + {"shipmentLabel": "s:12 S005"}, + {"shipmentLabel": "p:1 S008,S006,S011"}, + ], + }, + ] + } + with self.assertRaisesRegex( + AssertionError, "Shipment label counts for vehicle 0 are different" + ): + two_step_routing._assert_global_model_routes_handle_same_shipments( + response_a, response_b + ) + + def test_multiple_routes_different_shipments(self): + response_a: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "visits": [ + {"shipmentLabel": "s:32 S001"}, + {"shipmentLabel": "p:0 S002,S003,S007"}, + {"shipmentLabel": "p:3 S004,S117,S231"}, + {"shipmentLabel": "p:12 S032,S078"}, + ] + }, + { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:12 S005"}, + {"shipmentLabel": "p:11 S008,S009"}, + {"shipmentLabel": "p:3 S006,S011"}, + ], + }, + ] + } + response_b: cfr_json.OptimizeToursResponse = { + "routes": [ + { + "visits": [ + {"shipmentLabel": "s:32 S001"}, + {"shipmentLabel": "p:2 S002,S003,S007,S004,S117,S231"}, + {"shipmentLabel": "p:0 S032,S078"}, + ] + }, + { + "vehicleIndex": 1, + "visits": [ + {"shipmentLabel": "s:12 S005"}, + {"shipmentLabel": "p:1 S008,S006,S011"}, + ], + }, + ] + } + with self.assertRaisesRegex(AssertionError, ""): + two_step_routing._assert_global_model_routes_handle_same_shipments( + response_a, response_b + ) + + if __name__ == "__main__": unittest.main()