Skip to content

Commit

Permalink
Make route polylines available in the merged solution.
Browse files Browse the repository at this point in the history
This includes two types of changes:
- respect (and preserve) 'populatePolylines' and 'populateTransitionPolylines'
  parametrs from the source request in the global and local requests.
- re-create route polylines in the merged solution by concatenating individual
  transition polylines from the local and global transitions while walking
  through the stops.
  • Loading branch information
ondrasej committed Aug 30, 2023
1 parent 1a3e5b3 commit ea56634
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 14 deletions.
4 changes: 3 additions & 1 deletion examples/two_step_routing/example_request.json
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@
"globalEndTime": "2023-08-12T00:00:00.000Z"
},
"searchMode": 1,
"populatePolylines": true,
"populateTransitionPolylines": true,
"label": "my_little_model",
"parent": "my_awesome_project"
}
}
171 changes: 160 additions & 11 deletions examples/two_step_routing/two_step_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"""

import collections
from collections.abc import Collection, Mapping, Sequence, Set
from collections.abc import Collection, Iterable, Mapping, Sequence, Set
import copy
import dataclasses
import datetime
Expand Down Expand Up @@ -135,6 +135,7 @@ class Shipment(TypedDict, total=False):
pickups: list[VisitRequest]
deliveries: list[VisitRequest]
label: str
shipmentType: str

allowedVehicleIndices: list[int]

Expand Down Expand Up @@ -202,6 +203,9 @@ class OptimizeToursRequest(TypedDict, total=False):
timeout: DurationString
searchMode: int

populatePolylines: bool
populateTransitionPolylines: bool


class Visit(TypedDict, total=False):
"""Represents a single visit on a route in the JSON CFR results."""
Expand All @@ -213,14 +217,21 @@ class Visit(TypedDict, total=False):
isPickup: bool


class EncodedPolyline(TypedDict, total=False):
"""Represents an encoded polyline in the JSON CFR results."""

points: str


class Transition(TypedDict, total=False):
"""Represents a single transition on a route in the JSON CFR results."""

travelDuration: str
travelDuration: DurationString
travelDistanceMeters: int
waitDuration: str
totalDuration: str
startTime: str
waitDuration: DurationString
totalDuration: DurationString
startTime: TimeString
routePolyline: EncodedPolyline


class AggregatedMetrics(TypedDict, total=False):
Expand All @@ -245,6 +256,8 @@ class ShipmentRoute(TypedDict, total=False):

routeTotalCost: float

routePolyline: EncodedPolyline


class SkippedShipment(TypedDict, total=False):
"""Represents a skipped shipment in the JSON CFR result."""
Expand Down Expand Up @@ -551,11 +564,13 @@ def make_local_request(self) -> OptimizeToursRequest:
local_shipment["loadDemands"] = load_demands
local_shipments.append(local_shipment)

return {
request = {
"label": self._request.get("label", "") + "/local",
"model": local_model,
"parent": self._request.get("parent"),
}
self._add_polyline_options_if_needed(request)
return request

def make_global_request(
self, local_response: OptimizeToursResponse
Expand Down Expand Up @@ -710,11 +725,13 @@ def make_global_request(

global_shipments.append(global_shipment)

return {
request = {
"label": self._request.get("label", "") + "/global",
"model": global_model,
"parent": self._request.get("parent"),
}
self._add_polyline_options_if_needed(request)
return request

def merge_local_and_global_result(
self,
Expand Down Expand Up @@ -785,7 +802,12 @@ def merge_local_and_global_result(
}

local_routes = local_response["routes"]
populate_polylines = self._request.get("populatePolylines", False)

# We need to define these two outside of the loop to avoid a useless warning
# about capturing a variable defined in a loop.
merged_transitions = None
route_points = None
for global_route in global_response["routes"]:
global_visits = global_route.get("visits", ())
if not global_visits:
Expand All @@ -797,6 +819,7 @@ def merge_local_and_global_result(
global_transitions = global_route["transitions"]
merged_visits: list[Visit] = []
merged_transitions: list[Transition] = []
route_points: list[LatLng] = []
merged_routes.append(
{
"vehicleIndex": global_route.get("vehicleIndex", 0),
Expand Down Expand Up @@ -835,11 +858,22 @@ def add_parking_location_shipment(
merged_shipments.append(shipment)
return shipment_index, shipment

def add_merged_transition(transition: Transition):
merged_transitions.append(transition)
if populate_polylines:
decoded_polyline = decode_polyline(
transition["routePolyline"].get("points", "")
)
for latlng in decoded_polyline:
# Drop repeated points from the route polyline.
if not route_points or route_points[-1] != latlng:
route_points.append(latlng)

for global_visit_index, global_visit in enumerate(global_visits):
# The transition from the previous global visit to the current one can
# be copied without any modifications, and it is the same regardless of
# whether the next stop is a direct delivery or a parking location.
merged_transitions.append(global_transitions[global_visit_index])
add_merged_transition(global_transitions[global_visit_index])
global_visit_label = global_visit["shipmentLabel"]
visit_type, index = _parse_global_shipment_label(global_visit_label)
match visit_type:
Expand Down Expand Up @@ -880,7 +914,7 @@ def add_parking_location_shipment(
merged_transition["startTime"] = _update_time_string(
merged_transition["startTime"], local_to_global_delta
)
merged_transitions.append(merged_transition)
add_merged_transition(merged_transition)

shipment_index = _get_shipment_index_from_local_route_visit(
local_visit
Expand All @@ -899,7 +933,7 @@ def add_parking_location_shipment(
transition_to_parking["startTime"] = _update_time_string(
transition_to_parking["startTime"], local_to_global_delta
)
merged_transitions.append(transition_to_parking)
add_merged_transition(transition_to_parking)

# Add a virtual shipment and a visit for the departure from the
# parking location.
Expand All @@ -917,7 +951,11 @@ def add_parking_location_shipment(
raise ValueError(f"Unexpected visit type: '{visit_type}'")

# Add the transition back to the depot.
merged_transitions.append(global_transitions[-1])
add_merged_transition(global_transitions[-1])
if populate_polylines:
merged_routes[-1]["routePolyline"] = {
"points": encode_polyline(route_points)
}

merged_skipped_shipments = []
for local_skipped_shipment in local_response.get("skippedShipments", ()):
Expand Down Expand Up @@ -954,6 +992,19 @@ def add_parking_location_shipment(

return merged_request, merged_result

def _add_polyline_options_if_needed(
self, request: OptimizeToursRequest
) -> None:
"""Copies "populatePolylines" options from `self._request` to `request`."""
populate_polylines = self._request.get("populatePolylines")
if populate_polylines is not None:
request["populatePolylines"] = populate_polylines
populate_transition_polylines = (
self._request.get("populateTransitionPolylines") or populate_polylines
)
if populate_transition_polylines is not None:
request["populateTransitionPolylines"] = populate_transition_polylines


def validate_request(
request: OptimizeToursRequest,
Expand Down Expand Up @@ -1257,3 +1308,101 @@ def parse_duration_string(duration: DurationString) -> datetime.timedelta:
raise ValueError(f"Unexpected duration string format: '{duration}'")
seconds = float(duration[:-1])
return datetime.timedelta(seconds=seconds)


def encode_polyline(polyline: Sequence[LatLng]) -> str:
"""Encodes a sequence of latlng pairs to a string.
Uses the encoding algorithm as described in the Google maps documentation at
https://developers.google.com/maps/documentation/utilities/polylinealgorithm.
Args:
polyline: A sequence of latlng pairs to be encoded.
Returns:
A string that contains the encoded polyline.
"""
chunks = []

def encode_varint(value: int):
value = value << 1
if value < 0:
value = ~value
if value == 0:
chunks.append(63)
else:
while value != 0:
chunk = value & 31
value = value >> 5
if value != 0:
chunk = chunk | 32
chunks.append(chunk + 63)

previous_lat = 0
previous_lng = 0
for latlng in polyline:
lat = round(latlng["latitude"] * 1e5)
lng = round(latlng["longitude"] * 1e5)
encode_varint(lat - previous_lat)
encode_varint(lng - previous_lng)
previous_lat = lat
previous_lng = lng

return bytes(chunks).decode("ascii")


def _decoded_varints(encoded_string: str) -> Iterable[int]:
"""Extracts int values from a varint-encoded string."""
decoded_int = 0
shift_bits = 0
for chunk in encoded_string.encode("ascii"):
chunk -= 63
if chunk < 0:
raise ValueError("Invalid varint encoding")
decoded_int += (chunk & 31) << shift_bits
is_last_chunk = chunk & 32 == 0
if is_last_chunk:
if decoded_int & 1 == 1:
decoded_int = ~decoded_int
yield decoded_int >> 1
decoded_int = 0
shift_bits = 0
else:
shift_bits += 5
if shift_bits != 0:
# The last chunk had the "another chunk follows" bit set.
raise ValueError("Invalid varint encoding")


def decode_polyline(encoded_polyline: str) -> Sequence[LatLng]:
"""Decodes a sequence of latlng pairs from a string.
Uses the encoding algorithm as described in the Google Maps documentation at
https://developers.google.com/maps/documentation/utilities/polylinealgorithm.
Args:
encoded_polyline: The encoded polyline in the string format.
Returns:
The polyline as a sequence of points.
Raises:
ValueError: When the string has incorrect format.
"""
lat_lngs = []
lat_e5 = 0
lng_e5 = 0
varint_iter = iter(_decoded_varints(encoded_polyline))
try:
for lat_e5_delta, lng_e5_delta in zip(
varint_iter, varint_iter, strict=True
):
lat_e5 += lat_e5_delta
lng_e5 += lng_e5_delta
lat_lngs.append({"latitude": lat_e5 / 1e5, "longitude": lng_e5 / 1e5})
except ValueError as err:
if "zip()" in str(err):
raise ValueError("Longitude is missing.") from None
raise

return lat_lngs
2 changes: 1 addition & 1 deletion examples/two_step_routing/two_step_routing_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ def _run_optimize_tours(
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 60)
sock.setsockopt(
socket.IPPROTO_TCP, socket.TCP_KEEPCNT, int(timeout_seconds) // 30
socket.IPPROTO_TCP, socket.TCP_KEEPCNT, max(int(timeout_seconds) // 30, 1)
)

# For longer running requests, it may be necessary to set an explicit deadline
Expand Down
60 changes: 59 additions & 1 deletion examples/two_step_routing/two_step_routing_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2191,7 +2191,7 @@ def test_with_timezone(self):


class UpdateTimeStringTest(unittest.TestCase):
"""Tests fo _update_time_string."""
"""Tests of _update_time_string."""

def test_invalid_time(self):
with self.assertRaises(ValueError):
Expand Down Expand Up @@ -2242,5 +2242,63 @@ def test_valid_parse(self):
)


class EncodePolylineTest(unittest.TestCase):
"""Tests for encode_polyline."""

def test_empty(self):
self.assertEqual(two_step_routing.encode_polyline(()), "")

def test_maps_doc_example(self):
self.assertSequenceEqual(
two_step_routing.encode_polyline((
{"latitude": 38.5, "longitude": -120.2},
{"latitude": 40.7, "longitude": -120.95},
{"latitude": 43.252, "longitude": -126.453},
)),
"_p~iF~ps|U_ulLnnqC_mqNvxq`@",
)


class DecodePolylineTest(unittest.TestCase):
"""Tests of decode_polyline."""

maxDiff = None

def test_empty(self):
self.assertSequenceEqual(two_step_routing.decode_polyline(""), ())

def test_maps_doc_example(self):
self.assertSequenceEqual(
two_step_routing.decode_polyline("_p~iF~ps|U_ulLnnqC_mqNvxq`@"),
(
{"latitude": 38.5, "longitude": -120.2},
{"latitude": 40.7, "longitude": -120.95},
{"latitude": 43.252, "longitude": -126.453},
),
)

def test_encode_and_decode(self):
polyline = (
{"latitude": 38.5, "longitude": -120.2},
{"latitude": 40.7, "longitude": -120.95},
{"latitude": 40.7, "longitude": -122.31},
{"latitude": 40.4, "longitude": -122.31},
{"latitude": 43.252, "longitude": -126.453},
)
encoded1 = two_step_routing.encode_polyline(polyline)
decoded1 = two_step_routing.decode_polyline(encoded1)
self.assertSequenceEqual(decoded1, polyline)
encoded2 = two_step_routing.encode_polyline(decoded1)
self.assertEqual(encoded1, encoded2)

def test_missing_lng(self):
with self.assertRaisesRegex(ValueError, "Longitude is missing"):
two_step_routing.decode_polyline("_p~iF")

def test_incomplete_varint(self):
with self.assertRaisesRegex(ValueError, "Invalid varint encoding"):
two_step_routing.decode_polyline("_p~iF~ps")


if __name__ == "__main__":
unittest.main()

0 comments on commit ea56634

Please sign in to comment.