Skip to content

Commit

Permalink
Show soft working hours in the vehicle list.
Browse files Browse the repository at this point in the history
Bonus change:
Lift the limitation on break times in the computation of max working hours.
  • Loading branch information
ondrasej committed Oct 31, 2023
1 parent c128ea4 commit 0989a8f
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 19 deletions.
8 changes: 8 additions & 0 deletions python/cfr/analysis/cfr-json-analysis.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
144 changes: 128 additions & 16 deletions python/cfr/json/cfr_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand All @@ -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


Expand Down
169 changes: 166 additions & 3 deletions python/cfr/json/cfr_json_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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",
Expand All @@ -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 = {
Expand Down

0 comments on commit 0989a8f

Please sign in to comment.