Skip to content

Commit

Permalink
Allow ending by local refinement together with live traffic.
Browse files Browse the repository at this point in the history
During the local refinement phase, two consecutive visits to a bus stop
are not merged if there is negative wait time between them. This avoids
potential infeasibility of the local plan in case the negative wait time
removes too much of available time for deliveries.
  • Loading branch information
ondrasej committed Feb 11, 2024
1 parent 61a3088 commit f9461ad
Show file tree
Hide file tree
Showing 7 changed files with 846 additions and 20 deletions.
34 changes: 30 additions & 4 deletions python/cfr/json/cfr_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -1017,6 +1017,7 @@ def _recompute_one_transition_start_and_durations(
transition: Transition,
start_time: datetime.datetime,
end_time: datetime.datetime,
allow_negative_wait_duration: bool,
) -> None:
"""Updates `startTime` and `totalDuration` of one transition.
Expand All @@ -1029,18 +1030,26 @@ def _recompute_one_transition_start_and_durations(
transition: The transition to be updated.
start_time: The requested start time of the transition.
end_time: The requested end time of the transition.
allow_negative_wait_duration: Allow that the time slot betwen start_time and
end_time is smaller than the required minimal duration of the transition.
When this happens, the function adds a negative wait time so that the
total duration of the transition matches the time between the two visits.
Raises:
ValueError: When sum of the other durations of the transition is greater
than the duration between `start_time` and `end_time`.
than the duration between `start_time` and `end_time`, and
allow_negative_wait_duration is False.
"""
non_wait_duration = (
parse_duration_string(transition.get("travelDuration", "0s"))
+ parse_duration_string(transition.get("delayDuration", "0s"))
+ parse_duration_string(transition.get("breakDuration", "0s"))
)
required_total_duration = end_time - start_time
if non_wait_duration > required_total_duration:
if (
non_wait_duration > required_total_duration
and not allow_negative_wait_duration
):
raise ValueError(
f"The minimal duration of the transition ({non_wait_duration}) is"
f" greater than the available time slot ({required_total_duration})."
Expand All @@ -1052,7 +1061,9 @@ def _recompute_one_transition_start_and_durations(


def recompute_transition_starts_and_durations(
model: ShipmentModel, route: ShipmentRoute
model: ShipmentModel,
route: ShipmentRoute,
allow_negative_wait_duration: bool,
) -> None:
"""Recomputes transition start times and wait durations based on visit times.
Expand All @@ -1064,6 +1075,17 @@ def recompute_transition_starts_and_durations(
model: The model in which the transition times are recomputed.
route: The route, for which the transition times are recomputed. Modified in
place.
allow_negative_wait_duration: Allow that the time slot between two visits is
shorter than the minimal duration of a transition. The missing time is
"fixed" by using a negative wait time. This is the approach taken by the
CFR solver when there is a traffic infeasibility; setting this to true
allows the computation to proceed also on results computed with live
traffic that may have traffic infeasibilities.
Raises:
ValueError: When the time between two visits is shorter than then time
required by the transition between them, and allow_negative_wait_duration
is False.
"""
visits = get_visits(route)
if not visits:
Expand All @@ -1079,7 +1101,10 @@ def recompute_transition_starts_and_durations(
current_visit_start_time = parse_time_string(visit["startTime"])
transition_in = transitions[transition_index]
_recompute_one_transition_start_and_durations(
transition_in, previous_visit_end_time, current_visit_start_time
transition_in,
previous_visit_end_time,
current_visit_start_time,
allow_negative_wait_duration,
)

visit_duration = get_visit_request_duration(
Expand All @@ -1092,6 +1117,7 @@ def recompute_transition_starts_and_durations(
transitions[-1],
previous_visit_end_time,
parse_time_string(route["vehicleEndTime"]),
allow_negative_wait_duration,
)
except ValueError as err:
raise ValueError(
Expand Down
62 changes: 60 additions & 2 deletions python/cfr/json/cfr_json_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -1894,7 +1894,9 @@ def recompute_existing_solution(

if transitions:
self.assertNotEqual(route, expected_route)
cfr_json.recompute_transition_starts_and_durations(model, route)
cfr_json.recompute_transition_starts_and_durations(
model, route, allow_negative_wait_duration=False
)
self.assertEqual(route, expected_route)

def test_moderate_local(self):
Expand Down Expand Up @@ -1929,7 +1931,58 @@ def test_insufficient_time(self):
],
}
with self.assertRaisesRegex(ValueError, "minimal duration"):
cfr_json.recompute_transition_starts_and_durations(model, route)
cfr_json.recompute_transition_starts_and_durations(
model, route, allow_negative_wait_duration=False
)

def test_insufficient_time_allow_negative_duration(self):
model: cfr_json.ShipmentModel = {
"shipments": [{"deliveries": [{"duration": "120s"}]}],
}
route: cfr_json.ShipmentRoute = {
"vehicleIndex": 0,
"vehicleStartTime": "2024-01-15T10:00:00Z",
"vehicleEndTime": "2024-01-15T11:00:00z",
"visits": [{
"shipmentIndex": 0,
"visitRequestIndex": 0,
"isPickup": False,
"startTime": "2024-01-15T10:10:00Z",
}],
"transitions": [
{},
{"breakDuration": "1800s", "travelDuration": "1200s"},
],
}
expected_route: cfr_json.ShipmentRoute = {
"transitions": [
{
"startTime": "2024-01-15T10:00:00Z",
"totalDuration": "600s",
"waitDuration": "600s",
},
{
"breakDuration": "1800s",
"startTime": "2024-01-15T10:12:00Z",
"totalDuration": "2880s",
"travelDuration": "1200s",
"waitDuration": "-120s",
},
],
"vehicleEndTime": "2024-01-15T11:00:00z",
"vehicleIndex": 0,
"vehicleStartTime": "2024-01-15T10:00:00Z",
"visits": [{
"isPickup": False,
"shipmentIndex": 0,
"startTime": "2024-01-15T10:10:00Z",
"visitRequestIndex": 0,
}],
}
cfr_json.recompute_transition_starts_and_durations(
model, route, allow_negative_wait_duration=True
)
self.assertEqual(route, expected_route)


class UpdateTimeStringTest(unittest.TestCase):
Expand Down Expand Up @@ -2006,6 +2059,11 @@ def test_fractions(self):
"0.5s",
)

def test_negative_time(self):
self.assertEqual(
cfr_json.as_duration_string(datetime.timedelta(seconds=-100)), "-100s"
)


class EncodePolylineTest(unittest.TestCase):
"""Tests for encode_polyline."""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
{
"requestLabel": "my_little_model/refined_global",
"routes": [
{
"hasTrafficInfeasibilities": false,
"metrics": {
"breakDuration": "0s",
"delayDuration": "360s",
"performedMandatoryShipmentCount": 3,
"performedShipmentCount": 3,
"totalDuration": "28800s",
"travelDistanceMeters": 2404,
"travelDuration": "815s",
"visitDuration": "1775s",
"waitDuration": "25850s"
},
"routeTotalCost": 1582.404,
"transitions": [
{
"startTime": "2023-08-11T08:00:00Z",
"totalDuration": "421s",
"travelDistanceMeters": 1249,
"travelDuration": "421s",
"vehicleLoads": {
"ore": { "amount": "1" },
"wood": { "amount": "5" }
},
"waitDuration": "0s"
},
{
"startTime": "2023-08-11T08:09:31Z",
"totalDuration": "20503s",
"travelDistanceMeters": 719,
"travelDuration": "238s",
"vehicleLoads": {
"ore": { "amount": "1" },
"wood": { "amount": "5" }
},
"waitDuration": "20265s"
},
{
"delayDuration": "180s",
"startTime": "2023-08-11T14:10:50Z",
"totalDuration": "1802s",
"travelDuration": "0s",
"vehicleLoads": {
"ore": { "amount": "1" },
"wood": { "amount": "5" }
},
"waitDuration": "1622s"
},
{
"delayDuration": "180s",
"startTime": "2023-08-11T14:48:21Z",
"totalDuration": "4299s",
"travelDistanceMeters": 436,
"travelDuration": "156s",
"vehicleLoads": { "ore": {}, "wood": {} },
"waitDuration": "3963s"
}
],
"travelSteps": [
{ "distanceMeters": 1249, "duration": "421s" },
{ "distanceMeters": 719, "duration": "238s" },
{ "distanceMeters": 0, "duration": "0s" },
{ "distanceMeters": 436, "duration": "156s" }
],
"vehicleEndTime": "2023-08-11T16:00:00Z",
"vehicleIndex": 0,
"vehicleLabel": "V001",
"vehicleStartTime": "2023-08-11T08:00:00Z",
"visits": [
{
"detour": "56s",
"isPickup": false,
"shipmentIndex": 0,
"shipmentLabel": "s:8 S009",
"startTime": "2023-08-11T08:07:01Z"
},
{
"detour": "20574s",
"isPickup": false,
"shipmentIndex": 1,
"shipmentLabel": "p:0 S001,S004,S003,S002",
"startTime": "2023-08-11T13:51:14Z"
},
{
"detour": "23372s",
"isPickup": false,
"shipmentIndex": 2,
"shipmentLabel": "p:1 S007",
"startTime": "2023-08-11T14:40:52Z"
}
]
},
{
"hasTrafficInfeasibilities": true,
"metrics": {
"breakDuration": "0s",
"delayDuration": "360s",
"performedMandatoryShipmentCount": 1,
"performedShipmentCount": 1,
"totalDuration": "43200s",
"travelDistanceMeters": 1988,
"travelDuration": "722s",
"visitDuration": "750s",
"waitDuration": "41368s"
},
"routeTotalCost": 1721.988,
"transitions": [
{
"delayDuration": "180s",
"startTime": "2023-08-11T08:00:00Z",
"totalDuration": "653s",
"travelDistanceMeters": 1552,
"travelDuration": "573s",
"waitDuration": "-100s",
"vehicleLoads": {
"ore": { "amount": "2" },
"wheat": { "amount": "3" }
}
},
{
"delayDuration": "180s",
"startTime": "2023-08-11T08:23:23Z",
"totalDuration": "41797s",
"travelDistanceMeters": 436,
"travelDuration": "149s",
"vehicleLoads": { "ore": {}, "wheat": {} },
"waitDuration": "41468s"
}
],
"travelSteps": [
{ "distanceMeters": 1552, "duration": "573s" },
{ "distanceMeters": 436, "duration": "149s" }
],
"vehicleEndTime": "2023-08-11T20:00:00Z",
"vehicleIndex": 1,
"vehicleLabel": "V002",
"vehicleStartTime": "2023-08-11T08:00:00Z",
"visits": [
{
"detour": "0s",
"isPickup": false,
"shipmentIndex": 3,
"shipmentLabel": "p:2 S006,S008,S005",
"startTime": "2023-08-11T08:10:53Z"
}
]
}
]
}
Loading

0 comments on commit f9461ad

Please sign in to comment.