diff --git a/python/gmpro/json/transforms_breaks.py b/python/gmpro/json/transforms_breaks.py index 7bd19703..88807974 100644 --- a/python/gmpro/json/transforms_breaks.py +++ b/python/gmpro/json/transforms_breaks.py @@ -163,6 +163,9 @@ class BreakTransformRule: "depot", the break is at the start waypoint of the vehicle; otherwise, the value must be a valid Waypoint JSON object, and it will be used as the location of the break. The break request itself is removed. + avoid_u_turns: When True, the visit request created for the break has + `avoidUTurns` set to True. Can be True only when `break_at_waypoint` is + not None. virtual_shipment_label: WHen the break request is transformed into a virtual shipment, this string is used as a base of the label of this shipment. """ @@ -172,6 +175,7 @@ class BreakTransformRule: actions: Sequence[BreakTransformAction] new_break_request: bool break_at_waypoint: cfr_json.Waypoint | str | None + avoid_u_turns: bool virtual_shipment_label: str def applies_to( @@ -581,6 +585,7 @@ def compile_rules(rules: str) -> Sequence[BreakTransformRule]: actions: list[BreakTransformAction] = [] new_break_request = False break_at_waypoint = None + avoid_u_turns = False virtual_shipment_label = "break" for component in _tokenize(rules): @@ -590,8 +595,13 @@ def compile_rules(rules: str) -> Sequence[BreakTransformRule]: or selectors or context_selectors or break_at_waypoint + or avoid_u_turns or new_break_request ): + if avoid_u_turns and break_at_waypoint is None: + raise ValueError( + "`avoidUTurns` can be used only together with `location`" + ) compiled_rules.append( BreakTransformRule( selectors=selectors, @@ -599,6 +609,7 @@ def compile_rules(rules: str) -> Sequence[BreakTransformRule]: actions=actions, new_break_request=new_break_request, break_at_waypoint=break_at_waypoint, + avoid_u_turns=avoid_u_turns, virtual_shipment_label=virtual_shipment_label, ) ) @@ -607,6 +618,7 @@ def compile_rules(rules: str) -> Sequence[BreakTransformRule]: actions = [] new_break_request = False break_at_waypoint = None + avoid_u_turns = False virtual_shipment_label = "break" continue @@ -663,6 +675,12 @@ def compile_rules(rules: str) -> Sequence[BreakTransformRule]: f"Only '=' is allowed for `location`, found {str(component)!r}" ) break_at_waypoint = component.value + case "avoidUTurns": + if component.operator is not None: + raise ValueError( + f"avoidUTurns does not accept operands, found {str(component)!r}" + ) + avoid_u_turns = True case "virtualShipmentLabel": if component.operator != "=": raise ValueError( @@ -707,6 +725,14 @@ def compile_rules(rules: str) -> Sequence[BreakTransformRule]: return compiled_rules +@dataclasses.dataclass(frozen=True) +class _BreakAtWaypoint: + waypoint: cfr_json.Waypoint | str + break_request: cfr_json.BreakRequest + label: str + avoid_u_turns: bool + + def transform_breaks_for_vehicle( compiled_rules: Sequence[BreakTransformRule], model: cfr_json.ShipmentModel, @@ -719,9 +745,7 @@ def transform_breaks_for_vehicle( if (break_rule := vehicle.get("breakRule")) is not None: if (old_break_requests := break_rule.get("breakRequests")) is not None: break_requests = old_break_requests - breaks_at_waypoint: list[ - tuple[cfr_json.Waypoint, cfr_json.BreakRequest, str] - ] = [] + breaks_at_waypoint: list[_BreakAtWaypoint] = [] logging.debug("Processing vehicle_index=%d", vehicle_index) for transform in compiled_rules: @@ -758,11 +782,14 @@ def transform_breaks_for_vehicle( rule_new_requests = transform.apply_to(model, vehicle, request) if transform.break_at_waypoint: for new_request in rule_new_requests: - breaks_at_waypoint.append(( - transform.break_at_waypoint, - new_request, - transform.virtual_shipment_label, - )) + breaks_at_waypoint.append( + _BreakAtWaypoint( + waypoint=transform.break_at_waypoint, + break_request=new_request, + label=transform.virtual_shipment_label, + avoid_u_turns=transform.avoid_u_turns, + ) + ) else: new_requests.extend(rule_new_requests) @@ -789,11 +816,14 @@ def transform_breaks_for_vehicle( ) if transform.break_at_waypoint: for new_request in rule_new_requests: - breaks_at_waypoint.append(( - transform.break_at_waypoint, - new_request, - transform.virtual_shipment_label, - )) + breaks_at_waypoint.append( + _BreakAtWaypoint( + waypoint=transform.break_at_waypoint, + break_request=new_request, + label=transform.virtual_shipment_label, + avoid_u_turns=transform.avoid_u_turns, + ) + ) else: new_requests.extend(rule_new_requests) @@ -811,27 +841,30 @@ def transform_breaks_for_vehicle( if shipments is None: shipments = [] model["shipments"] = shipments - for src_waypoint, break_request, shipment_label_base in breaks_at_waypoint: - match src_waypoint: + for break_at_waypoint in breaks_at_waypoint: + match break_at_waypoint.waypoint: case "depot": # TODO(ondrasej): Also support `startLocation`. waypoint = vehicle["startWaypoint"] case value if isinstance(value, dict): - waypoint = cast(cfr_json.Waypoint, src_waypoint) + waypoint = cast(cfr_json.Waypoint, break_at_waypoint.waypoint) case _: raise ValueError("Unexpected waypoint value {waypoint!r}") - shipment_label = f"{shipment_label_base}, {vehicle_index=}" + shipment_label = f"{break_at_waypoint.label}, {vehicle_index=}" if vehicle_label := vehicle.get("label"): shipment_label += f", {vehicle_label=}" - shipment: cfr_json.Shipment = { - "deliveries": [{ - "arrivalWaypoint": waypoint, - "duration": break_request["minDuration"], - "timeWindows": [{ - "startTime": break_request["earliestStartTime"], - "endTime": break_request["latestStartTime"], - }], + delivery: cfr_json.VisitRequest = { + "arrivalWaypoint": waypoint, + "duration": break_at_waypoint.break_request["minDuration"], + "timeWindows": [{ + "startTime": break_at_waypoint.break_request["earliestStartTime"], + "endTime": break_at_waypoint.break_request["latestStartTime"], }], + } + if break_at_waypoint.avoid_u_turns: + delivery["avoidUTurns"] = True + shipment: cfr_json.Shipment = { + "deliveries": [delivery], "label": shipment_label, "allowedVehicleIndices": [vehicle_index], } diff --git a/python/gmpro/json/transforms_breaks_test.py b/python/gmpro/json/transforms_breaks_test.py index 64ef080e..2cfd7d76 100644 --- a/python/gmpro/json/transforms_breaks_test.py +++ b/python/gmpro/json/transforms_breaks_test.py @@ -197,6 +197,70 @@ def test_break_at_location(self): expected_model, ) + def test_avoid_u_turns_at_location(self): + model: cfr_json.ShipmentModel = { + "globalStartTime": "2024-02-09T08:00:00Z", + "globalEndTime": "2024-02-09T18:00:00Z", + "vehicles": [{ + "startWaypoint": {"placeId": "foobar"}, + "breakRule": { + "breakRequests": [ + { + "earliestStartTime": "2024-02-09T11:30:00Z", + "latestStartTime": "2024-02-09T12:30:00Z", + "minDuration": "3600s", + }, + { + "earliestStartTime": "2024-02-09T14:00:00Z", + "latestStartTime": "2024-02-09T16:00:00Z", + "minDuration": "3600s", + }, + ] + }, + }], + } + expected_model: cfr_json.ShipmentModel = { + "globalStartTime": "2024-02-09T08:00:00Z", + "globalEndTime": "2024-02-09T18:00:00Z", + "vehicles": [{ + "startWaypoint": {"placeId": "foobar"}, + "breakRule": { + "breakRequests": [ + { + "earliestStartTime": "2024-02-09T11:30:00Z", + "latestStartTime": "2024-02-09T12:30:00Z", + "minDuration": "3600s", + }, + ] + }, + }], + "shipments": [{ + "allowedVehicleIndices": [0], + "label": "This is a break, vehicle_index=0", + "deliveries": [{ + "arrivalWaypoint": {"placeId": "barbaz", "sideOfRoad": True}, + "timeWindows": [{ + "startTime": "2024-02-09T14:00:00Z", + "endTime": "2024-02-09T16:00:00Z", + }], + "duration": "3600s", + "avoidUTurns": True, + }], + }], + } + self.assertEqual( + self.run_transform_breaks( + model, + """ + @time=14:00:00 + location={"placeId": "barbaz", "sideOfRoad": true} + virtualShipmentLabel="This is a break" + avoidUTurns + """, + ), + expected_model, + ) + def test_all_return_to_depot(self): model: cfr_json.ShipmentModel = { "globalStartTime": "2024-02-09T08:00:00Z", @@ -1093,6 +1157,12 @@ def test_invalid_name(self): with self.assertRaisesRegex(ValueError, "Unexpected name .foo."): transforms_breaks.compile_rules("""foo=bar""") + def test_avoid_u_turns_without_location(self): + with self.assertRaisesRegex( + ValueError, "`avoidUTurns` can be used only together with `location`" + ): + transforms_breaks.compile_rules("""avoidUTurns""") + class ParseTimeTest(unittest.TestCase): """Tests for _parse_time."""