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()