diff --git a/python/cfr/analysis/cfr-json-analysis.ipynb b/python/cfr/analysis/cfr-json-analysis.ipynb index 372c5234..419029ed 100644 --- a/python/cfr/analysis/cfr-json-analysis.ipynb +++ b/python/cfr/analysis/cfr-json-analysis.ipynb @@ -948,6 +948,14 @@ " row[\"first visit\"] = str(first_visit_start)\n", " row[\"last visit\"] = str(last_visit_start)\n", " row[\"end time\"] = str(end_time)\n", + " row[\"max working time\"] = str(\n", + " cfr_json.get_vehicle_max_working_hours(scenario.model, vehicle)\n", + " )\n", + " row[\"soft working time\"] = str(\n", + " cfr_json.get_vehicle_max_working_hours(\n", + " scenario.model, vehicle, soft_limit=True\n", + " )\n", + " )\n", " row[\"actual working time\"] = str(\n", " cfr_json.get_vehicle_actual_working_hours(route)\n", " )\n", diff --git a/python/cfr/json/cfr_json.py b/python/cfr/json/cfr_json.py index fa716336..6e093c8e 100644 --- a/python/cfr/json/cfr_json.py +++ b/python/cfr/json/cfr_json.py @@ -420,6 +420,25 @@ def get_transitions(route: ShipmentRoute) -> Sequence[Transition]: return route.get("transitions", ()) +def get_break_earliest_start_time( + break_request: BreakRequest, +) -> datetime.datetime: + """Returns the earliest start time of a break request.""" + return parse_time_string(break_request["earliestStartTime"]) + + +def get_break_latest_start_time( + break_request: BreakRequest, +) -> datetime.datetime: + """Returns the latest start time of a break request.""" + return parse_time_string(break_request["latestStartTime"]) + + +def get_break_min_duration(break_request: BreakRequest) -> datetime.timedelta: + """Returns the minimal duration of a break request.""" + return parse_duration_string(break_request["minDuration"]) + + def get_visit_request(model: ShipmentModel, visit: Visit) -> VisitRequest: """Returns the visit request used in `visit`.""" shipment_index = visit.get("shipmentIndex", 0) @@ -620,13 +639,101 @@ def get_vehicle_latest_end( ) +def get_unavoidable_breaks( + break_requests: Sequence[BreakRequest], + start_time: datetime.datetime, + end_time: datetime.datetime, +) -> tuple[int, int] | None: + """Finds the smallest set of breaks that fall into (start_time, end_time). + + Computes which breaks can be pushed outside of working hours, either by having + them start and end before `start_time`, or by having them start after + `end_time`. Respects the CFR invariant that breaks must be scheduled in the + order in which they appear in `break_requests` and that they can't overlap. + Uses the shortest possible duration for all breaks. + + Args: + break_requests: The sequence of break requests. + start_time: The start time of the interval. + end_time: The end time of the interval. Must be greater or equal to + start_time. + + Returns: + None when all breaks can be avoided. Otherwise, returns a tuple + `(first_break, last_break)` where `first_break`, resp. `last_break`, are the + indices of the first, resp. last, break that intersect the interval. + """ + if not break_requests: + return None + num_break_requests = len(break_requests) + + first_break_index = 0 + earliest_break_start = get_break_earliest_start_time(break_requests[0]) + while first_break_index < num_break_requests: + break_request = break_requests[first_break_index] + earliest_break_start = max( + get_break_earliest_start_time(break_request), + earliest_break_start, + ) + min_duration = get_break_min_duration(break_request) + break_end_time = earliest_break_start + min_duration + + if break_end_time > start_time: + break + first_break_index += 1 + earliest_break_start = break_end_time + + if first_break_index == num_break_requests: + # All breaks can end before `start_time`. + return None + + last_break_index = num_break_requests - 1 + latest_break_start = get_break_latest_start_time( + break_requests[last_break_index] + ) + latest_break_end = latest_break_start + get_break_min_duration( + break_requests[last_break_index] + ) + while last_break_index >= first_break_index: + break_request = break_requests[last_break_index] + min_duration = get_break_min_duration(break_request) + latest_break_start = min( + get_break_latest_start_time(break_request), + latest_break_end - min_duration, + ) + if latest_break_start < end_time: + break + last_break_index -= 1 + latest_break_end = latest_break_start + + if last_break_index < first_break_index: + # All breaks can end before `start_time` or start after `end_time`. + return None + + return first_break_index, last_break_index + + def get_vehicle_max_working_hours( model: ShipmentModel, vehicle: Vehicle, *, soft_limit: bool = False ) -> datetime.timedelta: - """Computes the total working hours of `vehicle` in `model`. - - As of 2023-10-04, only breaks that happen completely between the earliest - start and the latest end of the vehicle working hours are supported. + """Computes the maximal total working hours of `vehicle` in `model`. + + First determines the earliest start time and the latest end time of the + vehicle; then considers all breaks that overlap with this time interval, and + subtracts their full min duration from the length of the interval. + + Limitations of this algorithm (as of 2023-10-30): + - it doesn't take into account the max route length of the vehicle, the + computation is based only on the start/end time. When the route has a + flexible start but a fixed maximal duration, this function will overestimate + the maximal working time. + - when a break request may overlap with the start time or the end time, it + assumes that the break is taken in full within the working hours of the + vehicle. This may underestimate the maximal working hours in case where the + start time or end time are flexible and some breaks may be avoided by moving + them. + Both limitations would require solving an optimization problem to determine + the max working hours correctly. Args: model: The model in which the working hours are computed. @@ -646,24 +753,29 @@ def get_vehicle_max_working_hours( start or end after the latest vehicle end time. """ # TODO(ondrasej): Also take into account Vehicle.routeDurationLimit. + # TODO(ondrasej): It is hard to make this function really correct and precise. + # Perhaps we should side-step the issue by counting considering breaks to be + # part of the work and avoid getting into all of this complexity. start_time = get_vehicle_earliest_start(model, vehicle, soft_limit=soft_limit) end_time = get_vehicle_latest_end(model, vehicle, soft_limit=soft_limit) working_hours = end_time - start_time break_rule = vehicle.get("breakRule") if break_rule is None: return working_hours - break_requests = break_rule.get("breakRequests", ()) - for break_request in break_requests: - # TODO(ondrasej): Relax the requirements that the breaks happen entirely - # within the working hours of the vehicle. - earliest_break_start = parse_time_string(break_request["earliestStartTime"]) - latest_break_start = parse_time_string(break_request["latestStartTime"]) - min_duration = parse_duration_string(break_request["minDuration"]) - if earliest_break_start < start_time: - raise ValueError("Unsupported case: break may start before vehicle start") - if latest_break_start + min_duration > end_time: - raise ValueError("Unsupported case: break ends after vehicle end") - working_hours -= min_duration + break_requests = break_rule.get("breakRequests") + if break_requests is None: + return working_hours + unavoidable_breaks = get_unavoidable_breaks( + break_requests, start_time, end_time + ) + if unavoidable_breaks is None: + return working_hours + + first_break_index, last_break_index = unavoidable_breaks + + for break_index in range(first_break_index, last_break_index + 1): + break_request = break_requests[break_index] + working_hours -= get_break_min_duration(break_request) return working_hours diff --git a/python/cfr/json/cfr_json_test.py b/python/cfr/json/cfr_json_test.py index 534a424c..cda726e6 100644 --- a/python/cfr/json/cfr_json_test.py +++ b/python/cfr/json/cfr_json_test.py @@ -486,6 +486,149 @@ def test_some_transitions(self): ) +class GetBreakPropertiesTest(unittest.TestCase): + """Tests for accessor functions for BreakRequest.""" + + _BREAK_REQUEST: cfr_json.BreakRequest = { + "earliestStartTime": "2023-10-31T11:23:45Z", + "latestStartTime": "2023-10-31T15:10:00Z", + "minDuration": "180s", + } + + def test_get_earliest_start(self): + self.assertEqual( + cfr_json.get_break_earliest_start_time(self._BREAK_REQUEST), + _datetime_utc(2023, 10, 31, 11, 23, 45), + ) + + def test_get_latest_start(self): + self.assertEqual( + cfr_json.get_break_latest_start_time(self._BREAK_REQUEST), + _datetime_utc(2023, 10, 31, 15, 10, 00), + ) + + def test_get_min_duration(self): + self.assertEqual( + cfr_json.get_break_min_duration(self._BREAK_REQUEST), + datetime.timedelta(minutes=3), + ) + + +class GetUnavoidableBreaksTest(unittest.TestCase): + """Tests for get_unavoidable_breaks.""" + + _BREAKS: Sequence[cfr_json.BreakRequest] = ( + { + "earliestStartTime": "2023-10-31T08:00:00Z", + "latestStartTime": "2023-10-31T21:00:00Z", + "minDuration": "3600s", + }, + { + "earliestStartTime": "2023-10-31T08:00:00Z", + "latestStartTime": "2023-10-31T21:00:00Z", + "minDuration": "3600s", + }, + { + "earliestStartTime": "2023-10-31T08:00:00Z", + "latestStartTime": "2023-10-31T21:00:00Z", + "minDuration": "1800s", + }, + { + "earliestStartTime": "2023-10-31T08:00:00Z", + "latestStartTime": "2023-10-31T21:00:00Z", + "minDuration": "1800s", + }, + ) + + def test_empty_breaks(self): + self.assertIsNone( + cfr_json.get_unavoidable_breaks( + (), + _datetime_utc(2023, 10, 31, 12, 0, 0), + _datetime_utc(2023, 10, 31, 16, 0, 0), + ) + ) + + def test_all_pushed_before(self): + # Case 1: there is a safety buffer between the end of the last break and the + # start time. + self.assertIsNone( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 14, 0, 0), + _datetime_utc(2023, 10, 31, 23, 0, 0), + ) + ) + # Case 2: the start time is right at the end of the second break. + self.assertIsNone( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 11, 0, 0), + _datetime_utc(2023, 10, 31, 22, 0, 0), + ) + ) + + def test_some_pushed_before_some_pushed_after(self): + # Case 1: there is a safety buffer between the end of the last break and the + # start time. + self.assertIsNone( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 12, 0, 0), + _datetime_utc(2023, 10, 31, 20, 0, 0), + ) + ) + # Case 2: the start time is right at the end of the second break. + self.assertIsNone( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 10, 0, 0), + _datetime_utc(2023, 10, 31, 20, 0, 0), + ) + ) + + def test_all_are_unavoidable(self): + self.assertEqual( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 5, 0, 0), + _datetime_utc(2023, 10, 31, 23, 0, 0), + ), + (0, 3), + ) + self.assertEqual( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 8, 0, 0), + _datetime_utc(2023, 10, 31, 22, 0, 0), + ), + (0, 3), + ) + + def test_some_avoidable_some_not(self): + # No overlap: _BREAKS[0] ends right before the start time, _BREAKS[3] starts + # right after the end time. + self.assertEqual( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 9, 0, 0), + _datetime_utc(2023, 10, 31, 21, 0, 0), + ), + (1, 2), + ) + # Small overlap. _BREAKS[1] can't end entirely before the start time, and + # _BREAKS[2] can't end entirely after the end time, even though there is + # some slack. + self.assertEqual( + cfr_json.get_unavoidable_breaks( + self._BREAKS, + _datetime_utc(2023, 10, 31, 9, 30, 0), + _datetime_utc(2023, 10, 31, 20, 45, 0), + ), + (1, 2), + ) + + class GetVisitRequestTest(unittest.TestCase): """Tests for get_visit_request.""" @@ -1049,7 +1192,7 @@ def test_with_breaks(self): datetime.timedelta(hours=9, minutes=20), ) - def test_with_unsupported_breaks(self): + def test_with_avoidable_breaks(self): vehicle: cfr_json.Vehicle = { "startTimeWindows": [{ "startTime": "2023-10-04T12:00:00Z", @@ -1062,8 +1205,28 @@ def test_with_unsupported_breaks(self): }] }, } - with self.assertRaisesRegex(ValueError, "Unsupported case"): - cfr_json.get_vehicle_max_working_hours(self._SHIPMENT_MODEL, vehicle) + self.assertEqual( + cfr_json.get_vehicle_max_working_hours(self._SHIPMENT_MODEL, vehicle), + datetime.timedelta(hours=6), + ) + + def test_with_breaks_overlaping_start(self): + vehicle: cfr_json.Vehicle = { + "startTimeWindows": [{ + "startTime": "2023-10-04T11:00:00Z", + }], + "breakRule": { + "breakRequests": [{ + "earliestStartTime": "2023-10-04T10:00:00Z", + "latestStartTime": "2023-10-04T12:00:00Z", + "minDuration": "7200s", + }] + }, + } + self.assertEqual( + cfr_json.get_vehicle_max_working_hours(self._SHIPMENT_MODEL, vehicle), + datetime.timedelta(hours=5), + ) def test_with_soft_time_limit(self): vehicle: cfr_json.Vehicle = {