diff --git a/python/cfr/two_step_routing/two_step_routing.py b/python/cfr/two_step_routing/two_step_routing.py index 8bb07a3b..6e829f99 100644 --- a/python/cfr/two_step_routing/two_step_routing.py +++ b/python/cfr/two_step_routing/two_step_routing.py @@ -44,16 +44,16 @@ locations. """ +import argparse import collections -from collections.abc import Collection, Iterable, Mapping, MutableMapping, Sequence, Set +from collections.abc import Callable, Collection, Iterable, Mapping, MutableMapping, Sequence, Set import copy import dataclasses -import datetime import enum import functools import math import re -from typing import Any, TypeAlias, TypeVar, cast +from typing import Any, Self, TypeAlias, TypeVar, cast from .. import utils from ..json import cfr_json @@ -75,33 +75,100 @@ class _ParkingGroupKey: the shipment does not have a delivery time window. allowed_vehicle_indices: The list of vehicle indices that can deliver the shipment. + penalty_cost_group: The penalty cost/penalty cost group of the shipment. + Contains `None` when grouping by penalty cost is not used or the value + returned by `InitialLocalModelGrouping.get_penalty_cost_group` when it is + provided. """ parking_tag: str | None = None time_windows: tuple[tuple[str | None, str | None], ...] = () allowed_vehicle_indices: tuple[int, ...] | None = None + penalty_cost_group: str | None = None -@enum.unique -class LocalModelGrouping(utils.EnumForArgparse): - """Specifies how shipments are grouped in the local model. +def _no_penalty_cost_grouping(shipment: cfr_json.Shipment) -> str | None: + """Implements "no grouping by penalty cost".""" + del shipment # Unused. + return None - In the local model, the routes are computed for each group separately, i.e. - shipments that are in different groups according to the selected strategy can - never appear on the same route. - Values: - PARKING_AND_TIME: Shipments are grouped by the assigned parking location and - by their time windows. Only shipments delivered from the same parking - location in the same time window can be delivered together. - PARKING: Shipments are grouped by the assigned parking location. Shipments - that are delivered from the same parking location but with different time - windows can still be merged together, as long as the time windows overlap - or are not too far from each other. +def _penalty_cost_per_item(shipment: cfr_json.Shipment) -> str | None: + """Groups shipments by their penalty cost per item in the shipment. + + The number of items in a shipment is determined as the number of + comma-separated components in the label of the shipment. The group name is the + penalty cost per item in a string format, or None when the shipment is + mandatory. + + Args: + shipment: The shipment for which the penalty cost per item is computed. + + Returns: + None for mandatory shipments. Otherwise, returns a string that contains the + penaltyCost per item of the shipment in a string format. """ + penalty_cost = shipment.get("penaltyCost") + if penalty_cost is None: + return None + # TODO(ondrasej): Allow other ways to determine the number of items in the + # shipment. + num_items = shipment.get("label", "").count(",") + 1 + return str(penalty_cost / num_items) - PARKING_AND_TIME = 0 - PARKING = 1 + +@dataclasses.dataclass(frozen=True) +class InitialLocalModelGrouping: + """Specifies how shipments are grouped in the initial local model. + + Shipments are always grouped by parking location and allowed vehicle indices. + The fields of this class allow additional grouping. + + Attributes: + time_windows: The shipments are grouped by their delivery time windows. + get_penalty_cost_group: A function that returns the transformed penalty cost + of the shipment used for the initial grouping of the shipments in the + local model. + """ + + time_windows: bool = False + get_penalty_cost_group: Callable[[cfr_json.Shipment], str | None] = ( + _no_penalty_cost_grouping + ) + + @classmethod + def from_string(cls, options: str) -> Self: + """Creates the grouping specification from command-line flags. + + Args: + options: The grouping options in a string format. Expects a + comma-separated list of option names. + + Returns: + A new instance of this class. + + Raises: + ArgumentTypeError: When parsing of the options fails. + """ + time_windows = False + get_penalty_cost_group = _no_penalty_cost_grouping + for option in options.split(","): + match option: + case "": + break + case "time_windows": + time_windows = True + case "penalty_cost_per_item": + get_penalty_cost_group = _penalty_cost_per_item + case _: + raise argparse.ArgumentTypeError( + f"Unknown grouping option {option!r}, possible values are" + " 'time_windows' and 'penalty_cost_per_item'" + ) + return cls( + time_windows=time_windows, + get_penalty_cost_group=get_penalty_cost_group, + ) @enum.unique @@ -321,6 +388,8 @@ class Options: Attributes: local_model_grouping: The grouping strategy used in the local model. + initial_local_model_grouping: The grouping strategy used in the initial + local model. local_model_vehicle_fixed_cost: The fixed cost of the vehicles in the local model. This should be a high number to make the solver use as few vehicles as possible. @@ -340,7 +409,7 @@ class Options: CFR proto may fail. """ - local_model_grouping: LocalModelGrouping = LocalModelGrouping.PARKING_AND_TIME + initial_local_model_grouping: InitialLocalModelGrouping # TODO(ondrasej): Do we actually need these? Perhaps they can be filled in on # the user side. @@ -438,7 +507,7 @@ def __init__( shipment = self._shipments[shipment_index] parking = self._parking_locations[parking_tag] parking_group_key = _parking_delivery_group_key( - self._options, shipment, parking + self._options.initial_local_model_grouping, shipment, parking ) parking_groups[parking_group_key].append(shipment_index) self._parking_groups: Mapping[_ParkingGroupKey, Sequence[int]] = ( @@ -2967,35 +3036,37 @@ def add_part(keyword: str, value: Any): parts.extend(_format_time_window(time_window)) if group_key.allowed_vehicle_indices is not None: add_part("vehicles=", group_key.allowed_vehicle_indices) + if group_key.penalty_cost_group is not None: + add_part("penalty_cost=", group_key.penalty_cost_group) parts.append("]") return "".join(parts) def _parking_delivery_group_key( - options: Options, + grouping: InitialLocalModelGrouping, shipment: cfr_json.Shipment, parking: ParkingLocation | None, ) -> _ParkingGroupKey: """Creates a key that groups shipments with the same time window and parking.""" if parking is None: return _ParkingGroupKey() - group_by_time = ( - options.local_model_grouping == LocalModelGrouping.PARKING_AND_TIME - ) parking_tag = parking.tag - delivery = shipment["deliveries"][0] - time_windows = delivery.get("timeWindows", ()) - key_time_windows = [] - if group_by_time and time_windows: - for time_window in time_windows: - key_time_windows.append( - (time_window.get("startTime"), time_window.get("endTime")) - ) + allowed_vehicle_indices = shipment.get("allowedVehicleIndices") if allowed_vehicle_indices is not None: allowed_vehicle_indices = tuple(sorted(allowed_vehicle_indices)) + + time_windows = () + if grouping.time_windows: + delivery = shipment["deliveries"][0] + time_windows = tuple( + (time_window.get("startTime"), time_window.get("endTime")) + for time_window in delivery.get("timeWindows", ()) + ) + return _ParkingGroupKey( parking_tag=parking_tag, - time_windows=tuple(key_time_windows), + time_windows=time_windows, allowed_vehicle_indices=allowed_vehicle_indices, + penalty_cost_group=grouping.get_penalty_cost_group(shipment), ) 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 3ed65387..e253f7e1 100644 --- a/python/cfr/two_step_routing/two_step_routing_main.py +++ b/python/cfr/two_step_routing/two_step_routing_main.py @@ -79,7 +79,7 @@ class Flags: reuse_existing: bool num_refinements: int end_with_local_refinement: bool - local_grouping: two_step_routing.LocalModelGrouping + local_grouping: two_step_routing.InitialLocalModelGrouping local_model_vehicle_fixed_cost: float | None travel_mode_in_merged_transitions: bool inject_start_times_to_refinement_first_solution: bool @@ -116,15 +116,15 @@ def _parse_flags() -> Flags: parser.add_argument( "--token", required=True, help="The Google Cloud auth key." ) - two_step_routing.LocalModelGrouping.add_as_argument( - parser, + parser.add_argument( "--local_grouping", - help="Controls the grouping mode in the local model.", - default=two_step_routing.LocalModelGrouping.PARKING_AND_TIME, + help="Controls the initial grouping of shipments in the local model.", + type=two_step_routing.InitialLocalModelGrouping.from_string, + default=two_step_routing.InitialLocalModelGrouping(time_windows=True), ) parser.add_argument( "--local_model_vehicle_fixed_cost", - default=None, + default=0, type=float, help=( "The cost of a vehicle in the initial local model. When None, the" @@ -340,26 +340,11 @@ def _run_two_step_planner() -> None: ) logging.info("Creating local model") - match flags.local_grouping: - case two_step_routing.LocalModelGrouping.PARKING: - options = two_step_routing.Options( - local_model_grouping=two_step_routing.LocalModelGrouping.PARKING, - local_model_vehicle_fixed_cost=0, - travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions, - ) - case two_step_routing.LocalModelGrouping.PARKING_AND_TIME: - options = two_step_routing.Options( - travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions - ) - case _: - raise ValueError( - "Unexpected value of --local_grouping: {flags.local_grouping!r}" - ) - if flags.local_model_vehicle_fixed_cost is not None: - options.local_model_vehicle_fixed_cost = ( - flags.local_model_vehicle_fixed_cost - ) - + options = two_step_routing.Options( + initial_local_model_grouping=flags.local_grouping, + local_model_vehicle_fixed_cost=flags.local_model_vehicle_fixed_cost, + travel_mode_in_merged_transitions=flags.travel_mode_in_merged_transitions, + ) planner = two_step_routing.Planner( request_json, parking_locations, parking_for_shipment, options ) 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 62ea5154..c49d13bc 100644 --- a/python/cfr/two_step_routing/two_step_routing_test.py +++ b/python/cfr/two_step_routing/two_step_routing_test.py @@ -185,12 +185,17 @@ class PlannerTest(unittest.TestCase): maxDiff = None _OPTIONS = two_step_routing.Options( + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=True + ), local_model_vehicle_fixed_cost=10000, min_average_shipments_per_round=2, ) _OPTIONS_GROUP_BY_PARKING = two_step_routing.Options( + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=False + ), local_model_vehicle_fixed_cost=0, - local_model_grouping=two_step_routing.LocalModelGrouping.PARKING, ) _REQUEST_JSON: cfr_json.OptimizeToursRequest = testdata.json( @@ -677,6 +682,9 @@ class PlannerTestWithPlaceId(unittest.TestCase): _OPTIONS = two_step_routing.Options( local_model_vehicle_fixed_cost=10000, min_average_shipments_per_round=1, + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=True + ), ) _REQUEST_JSON: cfr_json.OptimizeToursRequest = testdata.json( @@ -749,6 +757,9 @@ class PlannerTestWithBreaks(unittest.TestCase): _OPTIONS = two_step_routing.Options( local_model_vehicle_fixed_cost=10000, min_average_shipments_per_round=1, + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=True + ), ) _REQUEST_JSON: cfr_json.OptimizeToursRequest = testdata.json( @@ -1617,6 +1628,66 @@ def test_parking_tag_multiple_time_windows(self): "(start=2024-09-25T14:00:00Z)]", ) + def test_parking_tag_time_windows_penalty_cost(self): + self.assertEqual( + two_step_routing._make_local_model_vehicle_label( + two_step_routing._ParkingGroupKey( + "P123", + (("2024-02-13T16:00:00Z", None),), + None, + "150", + ) + ), + "P123 [time_windows=(start=2024-02-13T16:00:00Z) penalty_cost=150]", + ) + + +class InitialLocalModelGroupingTest(unittest.TestCase): + """Tests for InitialLocalModelGrouping.""" + + def test_from_string_no_options(self): + local_grouping = two_step_routing.InitialLocalModelGrouping.from_string("") + self.assertFalse(local_grouping.time_windows) + self.assertIs( + local_grouping.get_penalty_cost_group, + two_step_routing._no_penalty_cost_grouping, + ) + + def test_from_string_time_windows(self): + local_grouping = two_step_routing.InitialLocalModelGrouping.from_string( + "time_windows" + ) + self.assertTrue(local_grouping.time_windows) + self.assertIs( + local_grouping.get_penalty_cost_group, + two_step_routing._no_penalty_cost_grouping, + ) + + def test_from_string_penalty_cost_per_item(self): + local_grouping = two_step_routing.InitialLocalModelGrouping.from_string( + "penalty_cost_per_item" + ) + self.assertFalse(local_grouping.time_windows) + self.assertIs( + local_grouping.get_penalty_cost_group, + two_step_routing._penalty_cost_per_item, + ) + + def test_from_string_time_windows_penalty_cost_per_itme(self): + for test_input in ( + "time_windows,penalty_cost_per_item", + "penalty_cost_per_item,time_windows", + ): + with self.subTest(test_input=test_input): + local_grouping = two_step_routing.InitialLocalModelGrouping.from_string( + test_input + ) + self.assertTrue(local_grouping.time_windows) + self.assertIs( + local_grouping.get_penalty_cost_group, + two_step_routing._penalty_cost_per_item, + ) + class ParkingDeliveryGroupTest(unittest.TestCase): """Tests for _parking_delivery_group_key.""" @@ -1624,10 +1695,20 @@ class ParkingDeliveryGroupTest(unittest.TestCase): maxDiff = None _OPTIONS_GROUP_BY_PARKING_AND_TIME = two_step_routing.Options( - local_model_grouping=two_step_routing.LocalModelGrouping.PARKING_AND_TIME + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=True + ), ) _OPTIONS_GROUP_BY_PARKING = two_step_routing.Options( - local_model_grouping=two_step_routing.LocalModelGrouping.PARKING + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=False + ), + ) + _OPTIONS_GROUP_BY_PARKING_AND_TIME_AND_PENALTY = two_step_routing.Options( + initial_local_model_grouping=two_step_routing.InitialLocalModelGrouping( + time_windows=True, + get_penalty_cost_group=two_step_routing._penalty_cost_per_item, + ) ) _START_TIME = "2023-08-09T12:12:00.000Z" @@ -1700,6 +1781,20 @@ class ParkingDeliveryGroupTest(unittest.TestCase): ], "label": "2023081000001", } + _SHIPMENT_TIME_WINDOW_AND_PENALTY: cfr_json.Shipment = { + "deliveries": [ + { + "timeWindows": [ + { + "startTime": "2024-09-25T18:00:00Z", + "endTime": "2024-09-25T20:00:00Z", + }, + ] + }, + ], + "label": "2023081000001", + "penaltyCost": 12345, + } _PARKING_LOCATION = two_step_routing.ParkingLocation( coordinates={"latitude": 35.7668, "longitude": 139.7285}, tag="P1234" @@ -1715,13 +1810,17 @@ def test_with_no_parking(self): ): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, shipment, None + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, + shipment, + None, ), two_step_routing._ParkingGroupKey(), ) self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING, shipment, None + self._OPTIONS_GROUP_BY_PARKING.initial_local_model_grouping, + shipment, + None, ), two_step_routing._ParkingGroupKey(), ) @@ -1729,7 +1828,7 @@ def test_with_no_parking(self): def test_with_parking_and_no_time_window(self): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, self._SHIPMENT_NO_TIME_WINDOW, self._PARKING_LOCATION, ), @@ -1737,7 +1836,7 @@ def test_with_parking_and_no_time_window(self): ) self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING, + self._OPTIONS_GROUP_BY_PARKING.initial_local_model_grouping, self._SHIPMENT_NO_TIME_WINDOW, self._PARKING_LOCATION, ), @@ -1747,7 +1846,7 @@ def test_with_parking_and_no_time_window(self): def test_with_parking_and_time_window_start(self): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, self._SHIPMENT_TIME_WINDOW_START, self._PARKING_LOCATION, ), @@ -1755,7 +1854,7 @@ def test_with_parking_and_time_window_start(self): ) self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING, + self._OPTIONS_GROUP_BY_PARKING.initial_local_model_grouping, self._SHIPMENT_TIME_WINDOW_START, self._PARKING_LOCATION, ), @@ -1765,7 +1864,7 @@ def test_with_parking_and_time_window_start(self): def test_with_parking_and_time_window_end(self): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, self._SHIPMENT_TIME_WINDOW_END, self._PARKING_LOCATION, ), @@ -1773,7 +1872,7 @@ def test_with_parking_and_time_window_end(self): ) self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING, + self._OPTIONS_GROUP_BY_PARKING.initial_local_model_grouping, self._SHIPMENT_TIME_WINDOW_END, self._PARKING_LOCATION, ), @@ -1783,7 +1882,7 @@ def test_with_parking_and_time_window_end(self): def test_with_parking_and_time_window_start_end(self): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, self._SHIPMENT_TIME_WINDOW_START_END, self._PARKING_LOCATION, ), @@ -1794,7 +1893,7 @@ def test_with_parking_and_time_window_start_end(self): ) self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING, + self._OPTIONS_GROUP_BY_PARKING.initial_local_model_grouping, self._SHIPMENT_TIME_WINDOW_START_END, self._PARKING_LOCATION, ), @@ -1804,7 +1903,7 @@ def test_with_parking_and_time_window_start_end(self): def test_with_allowed_vehicles(self): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, self._SHIPMENT_ALLOWED_VEHICLES, self._PARKING_LOCATION, ), @@ -1816,7 +1915,7 @@ def test_with_allowed_vehicles(self): ) self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING, + self._OPTIONS_GROUP_BY_PARKING.initial_local_model_grouping, self._SHIPMENT_ALLOWED_VEHICLES, self._PARKING_LOCATION, ), @@ -1830,7 +1929,7 @@ def test_with_allowed_vehicles(self): def test_with_multiple_time_windows(self): self.assertEqual( two_step_routing._parking_delivery_group_key( - self._OPTIONS_GROUP_BY_PARKING_AND_TIME, + self._OPTIONS_GROUP_BY_PARKING_AND_TIME.initial_local_model_grouping, self._SHIPMENT_MULTIPLE_TIME_WINDOWS, self._PARKING_LOCATION, ), @@ -1843,6 +1942,21 @@ def test_with_multiple_time_windows(self): ), ) + def test_with_time_window_and_penalty_cost(self): + self.assertEqual( + two_step_routing._parking_delivery_group_key( + self._OPTIONS_GROUP_BY_PARKING_AND_TIME_AND_PENALTY.initial_local_model_grouping, + self._SHIPMENT_TIME_WINDOW_AND_PENALTY, + self._PARKING_LOCATION, + ), + two_step_routing._ParkingGroupKey( + "P1234", + (("2024-09-25T18:00:00Z", "2024-09-25T20:00:00Z"),), + None, + "12345.0", + ), + ) + class TestIntervalIntersection(unittest.TestCase): maxDiff = None