diff --git a/.github/workflows/go-build-test.yml b/.github/workflows/go-build-test.yml index 93e8a05e..e902ab89 100644 --- a/.github/workflows/go-build-test.yml +++ b/.github/workflows/go-build-test.yml @@ -8,6 +8,12 @@ jobs: SSH_AUTH_SOCK: /tmp/ssh_agent.sock RESOURCES: resources NEXTMV_LIBRARY_PATH: ~/.nextmv/lib + # Use a matrix strategy to test all the modules simultaneously. + strategy: + fail-fast: false + matrix: + MOD_PATH: + [./, ./route/google, ./route/here, ./route/osrm, ./route/routingkit] steps: # Installs dependencies. - name: install dependencies linux @@ -79,6 +85,8 @@ jobs: - name: go build run: go build -v ./... + working-directory: ${{ matrix.MOD_PATH }} - name: go test run: NEXTMV_LIBRARY_PATH=${{ env.NEXTMV_LIBRARY_PATH }} NEXTMV_TOKEN=${{ secrets.NEXTMV_TOKEN }} go test ./... + working-directory: ${{ matrix.MOD_PATH }} diff --git a/.github/workflows/go-lint.yml b/.github/workflows/go-lint.yml index d157e407..b6f15a49 100644 --- a/.github/workflows/go-lint.yml +++ b/.github/workflows/go-lint.yml @@ -3,6 +3,12 @@ on: [push] jobs: sdk-go-lint: runs-on: ubuntu-latest + # Use a matrix strategy to test all the modules simultaneously. + strategy: + fail-fast: false + matrix: + MOD_PATH: + [./, ./route/google, ./route/here, ./route/osrm, ./route/routingkit] steps: - name: set up go uses: actions/setup-go@v3 @@ -17,3 +23,4 @@ jobs: uses: golangci/golangci-lint-action@v3 with: version: v1.49.0 + working-directory: ${{ matrix.MOD_PATH }} diff --git a/.golangci.yml b/.golangci.yml index db060fa1..5491a176 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -12,7 +12,14 @@ linters-settings: gomodguard: allowed: modules: - - + - github.com/nextmv-io/sdk + - github.com/dgraph-io/ristretto + - github.com/golang/mock + - github.com/google/go-cmp + - github.com/hashicorp/golang-lru + - github.com/nextmv-io/go-routingkit + - github.com/twpayne/go-polyline + - googlemaps.github.io/maps # Functions cannot exceed this cyclomatic complexity. gocyclo: min-complexity: 10 @@ -148,3 +155,24 @@ issues: - path: route/example_router_test\.go linters: - dupl + + # Complexity in UnmarshalJSON is high due to multiple cases, fine for now. + - path: route/load\.go + linters: + - gocyclo + text: ByIndexLoader + + # Tag should be 'measure' in both cases ByPoint and ByIndex + - path: route/load\.go + linters: + - tagliatelle + text: measure + + # Please note that other Go modules have issues that are ignored but are + # not listed here. The reason is that linting must be done standing on the + # Go module and excluding issues here uses relative paths. That means that + # the relative paths from this file start from the root of sdk but for + # other modules the relative path start from that module. For that reason, + # linting is ignored by using the following syntax on a line: //nolint. If + # you look for uses of //nolint you will find the other linting issues + # being excluided. diff --git a/VERSION b/VERSION index f78b7047..014ec619 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.20.1 \ No newline at end of file +v0.20.2 \ No newline at end of file diff --git a/route/google/client.go b/route/google/client.go new file mode 100644 index 00000000..f4cf0c3f --- /dev/null +++ b/route/google/client.go @@ -0,0 +1,410 @@ +package google + +import ( + "context" + "math" + "sort" + "time" + + "github.com/nextmv-io/sdk/route" + "googlemaps.github.io/maps" +) + +const ( + maxAddresses int = 25 + maxElements int = 100 + maxElementsPerSecond int = 1000 +) + +// reference references a slice of the complete origins or destinations. +type reference struct { + start int + num int +} + +// matrixRequest represents a reference to a Google Maps request with additional +// information for allocating resulting information in the correct matrix +// indices. +type matrixRequest struct { + r *maps.DistanceMatrixRequest + origins reference + destinations reference +} + +// matrixResponse represents a reference to a Google Maps response with +// additional information for allocating resulting information in the correct +// matrix indices. +type matrixResponse struct { + r *maps.DistanceMatrixResponse + origins reference + destinations reference +} + +// DistanceDurationMatrices makes requests to the Google Distance Matrix API +// and returns route.ByIndex types to estimate distances (in meters) and +// durations (in seconds). It receives a Google Maps Client and Request. The +// coordinates passed to the request must be in the form latitude, longitude. +// The resulting distance and duration matrices are saved in memory. To find +// out how to create a client and request, please refer to the go package docs. +// This function takes into consideration the usage limits of the Distance +// Matrix API and thus may transform the request into multiple ones and handle +// them accordingly. You can find more about usage limits here in the official +// google maps documentation for the distance matrix, usage and billing. +func DistanceDurationMatrices(c *maps.Client, r *maps.DistanceMatrixRequest) ( + route.ByIndex, + route.ByIndex, + error, +) { + // Split request into multiple ones respecting the maxAddresses and + // maxElements usage limits. + requests := split(r) + + // Group requests that can go together respecting the maxElementsPerSecond + // limit. Sequential calls are performed, where each call is composed of + // concurrent requests to the API. Sleep between concurrent calls to avoid + // exceeding the usage limit. + groups := group(requests) + var responses []matrixResponse + for _, group := range groups { + resp, err := makeMatrixRequest(c, group) + if err != nil { + return nil, nil, err + } + responses = append(responses, resp...) + time.Sleep(1 * time.Second) + } + + // Instantiate an empty numOrigins x numDestinations matrix. + numOrigins, numDestinations := len(r.Origins), len(r.Destinations) + distances := make([][]float64, numOrigins) + durations := make([][]float64, numOrigins) + for i := 0; i < numOrigins; i++ { + distances[i] = make([]float64, numDestinations) + durations[i] = make([]float64, numDestinations) + } + + // The responses are processed to fill the distance and duration matrices. + for _, response := range responses { + for i, row := range response.r.Rows { + from := i + response.origins.start + for j, element := range row.Elements { + to := j + response.destinations.start + distances[from][to] = float64(element.Distance.Meters) + durations[from][to] = element.Duration.Seconds() + } + } + } + + return route.Matrix(distances), route.Matrix(durations), nil +} + +/* +split splits a single request into multiple ones following these rules: + + - there can only be a maximum of maxAddresses origins or destinations, + - there can only be a maximum of maxElements elements. + +For each new request, a reference of the indices of the original origins and +destinations is saved to allocate the correct positions of the matrix. +*/ +func split(r *maps.DistanceMatrixRequest) []matrixRequest { + var requests []matrixRequest + numOrigins, numDestinations := len(r.Origins), len(r.Destinations) + + // Origins are looped over first. + originsStart := 0 + originsRemaining := numOrigins + for originsRemaining > 0 { + // At a maximum we take maxAddresses origins at a time and create a + // temporary slice. + originsIncrease := int(math.Min( + float64(maxAddresses), + float64(originsRemaining)), + ) + originsEnd := originsStart + originsIncrease + originsSliced := r.Origins[originsStart:originsEnd] + + // For each batch of origins, we adjust the number of destinations that + // we will be slicing. + destinationsStart := 0 + destinationsRemaining := numDestinations + for destinationsRemaining > 0 { + // At a maximum we take the number of destinations that result in + // maxElements elements and create a temporary slice. + destinationsIncrease := int(math.Min( + float64(maxElements)/float64(originsIncrease), + float64(destinationsRemaining)), + ) + destinationsEnd := destinationsStart + destinationsIncrease + destinationsSliced := r.Destinations[destinationsStart:destinationsEnd] + + // The sliced origins and destinations are used to create a new + // request that has all the other attributes of the original one. + req := *r + req.Origins = originsSliced + req.Destinations = destinationsSliced + request := matrixRequest{ + r: &req, + origins: reference{ + start: originsStart, + num: originsIncrease, + }, + destinations: reference{ + start: destinationsStart, + num: destinationsIncrease, + }, + } + requests = append(requests, request) + + destinationsStart = destinationsEnd + destinationsRemaining -= destinationsIncrease + } + + originsStart = originsEnd + originsRemaining -= originsIncrease + } + + return requests +} + +// group groups requests that can be called concurrently respecting the +// maxElementsPerSecond limit. +func group(requests []matrixRequest) [][]matrixRequest { + // Create groups as long as there are requests left to process. + var groups [][]matrixRequest + remaining := len(requests) + for remaining > 0 { + // Create a group as long as the total elements do not exceed the limit. + var group []matrixRequest + remainingElements := maxElementsPerSecond + for i := len(requests) - remaining; i < len(requests); i++ { + elements := requests[i].origins.num * requests[i].destinations.num + if remainingElements-elements >= 0 { + // The element count is verified to check if the request can be + // added to the current group. If the request is added, the + // overall element and request count is updated. + group = append(group, requests[i]) + remainingElements -= elements + remaining-- + } else { + // If the request cannot be added, the current group is complete + // and a new group is created all over again. + break + } + } + + groups = append(groups, group) + } + + return groups +} + +// matrixResult gathers a response and possible error from concurrent requests. +type matrixResult struct { + res *matrixResponse + err error +} + +// makeMatrixRequest performs concurrent requests to the provided client. +func makeMatrixRequest( + c *maps.Client, + group []matrixRequest, +) ([]matrixResponse, error) { + // Define channels to gather results and quit other goroutines in case of an + // error. + out := make(chan matrixResult, len(group)) + // Perform concurrent requests. + for _, req := range group { + go func(req matrixRequest) { + // Actually make the request. + r, err := c.DistanceMatrix(context.Background(), req.r) + if err != nil { + // If the request errors, push the error to the chan and signal + // closure. + out <- matrixResult{res: nil, err: err} + } else { + // If the request does not error, push the request to the chan. + response := matrixResponse{ + r: r, + origins: req.origins, + destinations: req.destinations, + } + out <- matrixResult{res: &response, err: nil} + } + }(req) + } + + // Empty out the chan to gather responses. If there is an error found, + // immediately return it. Loop over the requests in the group to have + // control over the number of times we are getting an element from the chan + // but the resulting responses in the out chan are not expected to have the + // same order as the requests in group. + var responses []matrixResponse + for i := 0; i < len(group); i++ { + result := <-out + if result.err != nil { + return nil, result.err + } + responses = append(responses, *result.res) + } + + return responses, nil +} + +type directionRequest struct { + r *maps.DirectionsRequest + index int +} + +type directionResponse struct { + r []maps.Route + index int +} + +// directionsResult gathers a response and possible error from concurrent +// requests. +type directionsResult struct { + res *directionResponse + err error +} + +// makeDistanceRequest performs concurrent requests to the provided client. +func makeDirectionsRequest( + c *maps.Client, + points []string, + orgRequest *maps.DirectionsRequest, +) ([]directionResponse, error) { + // Loop over points to make directions requests taking into account Google's + // waypoint limitation per request. + remaining := len(points) - 1 + blockSize := 25 + start := 0 + // Determine the number of requests to be made. + numberRequests := int(math.Ceil(float64(remaining) / float64(blockSize))) + directionRequests := make([]directionRequest, numberRequests) + for i := range directionRequests { + count := int(math.Min(float64(blockSize), float64(remaining))) + end := start + count + + if count <= 0 { + break + } + + newReq := *orgRequest + newReq.Origin = points[start] + newReq.Destination = points[end] + // To make the waypoints we need the points to end-1, because the end + // point is the destination for the request. + waypoints := make([]string, end-1-start) + for j := start; j < end-1; j++ { + waypoints[j-start] = points[j+1] + } + newReq.Waypoints = waypoints + + start = end + remaining -= count + directionRequests[i] = directionRequest{ + r: &newReq, + index: i, + } + } + + // Define channels to gather results and quit other goroutines in case of an + // error. + out := make(chan directionsResult, len(directionRequests)) + // Perform concurrent requests. + for _, req := range directionRequests { + go func(req directionRequest) { + // Actually make the request. + r, _, err := c.Directions(context.Background(), req.r) + if err != nil { + // If the request errors, push the error to the chan and signal + // closure. + out <- directionsResult{res: nil, err: err} + } else { + response := directionResponse{ + r: r, + index: req.index, + } + // If the request does not error, push the request to the chan. + out <- directionsResult{res: &response, err: nil} + } + }(req) + } + + // Empty out the chan to gather responses. If there is an error found, + // immediately return it. + var responses []directionResponse + for i := 0; i < len(directionRequests); i++ { + result := <-out + if result.err != nil { + return nil, result.err + } + responses = append(responses, *result.res) + } + + // Sort the responses by index, that orders the row packs correctly. + sort.Slice(responses, func(i, j int) bool { + return responses[i].index < responses[j].index + }) + + return responses, nil +} + +// Polylines requests polylines for the given points. The first parameter +// returns a polyline from start to end and the second parameter returns a list +// of polylines, one per leg. +func Polylines( + c *maps.Client, + orgRequest *maps.DirectionsRequest, +) (string, []string, error) { + // Extract all points from the given request + points := make([]string, len(orgRequest.Waypoints)+2) + points[0] = orgRequest.Origin + points[len(points)-1] = orgRequest.Destination + for i, w := range orgRequest.Waypoints { + points[i+1] = w + } + + // Make requests to Google and retrieve results + responses, err := makeDirectionsRequest(c, points, orgRequest) + if err != nil { + return "", []string{}, err + } + + // the number of total legs for the original request. + decodedLegs := make([][]maps.LatLng, len(points)-1) + + // Stich results together. + index := -1 + for _, resp := range responses { + for _, route := range resp.r { + for i, leg := range route.Legs { + index++ + for _, steps := range leg.Steps { + dec, err := steps.Polyline.Decode() + if err != nil { + return "", []string{}, err + } + decodedLegs[index] = append(decodedLegs[i], dec...) //nolint:gocritic + } + } + } + } + + // Make a list of encoded legs to return + legLines := make([]string, len(points)-1) + for i, leg := range decodedLegs { + legLines[i] = maps.Encode(leg) + } + + // Finally, make a single request to get the polyline from start to end. + completeReq := *orgRequest + completeReq.Waypoints = nil + completeResp, _, err := c.Directions(context.Background(), &completeReq) + if err != nil { + return "", []string{}, err + } + + return completeResp[0].OverviewPolyline.Points, legLines, nil +} diff --git a/route/google/client_test.go b/route/google/client_test.go new file mode 100644 index 00000000..1c2c1c17 --- /dev/null +++ b/route/google/client_test.go @@ -0,0 +1,388 @@ +package google + +import ( + "encoding/base64" + "fmt" + "os" + "reflect" + "testing" + + "googlemaps.github.io/maps" +) + +// TestSplit tests the split function to assert that the complete list of +// origins and destinations is broken into proper requests that fulfill the +// usage limits. +func TestSplit(t *testing.T) { + type expected struct { + originsStart int + numOrigins int + destinationsStart int + numDestinations int + } + type testCase struct { + r *maps.DistanceMatrixRequest + expected []expected + } + tests := []testCase{ + // Case: 30 origins and 8 destinations produce 3 requests to accommodate + // 240 elements: 100, 100 and 40. + { + r: &maps.DistanceMatrixRequest{ + Origins: []string{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", + }, + Destinations: []string{ + "1", "2", "3", "4", "5", "6", "7", "8", + }, + }, + expected: []expected{ + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 0, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 4, + numDestinations: 4, + }, + { + originsStart: 25, + numOrigins: 5, + destinationsStart: 0, + numDestinations: 8, + }, + }, + }, + // Case: 8 origins and 30 destinations produce 3 requests to accommodate + // 240 elements: 96, 96 and 48. + { + r: &maps.DistanceMatrixRequest{ + Origins: []string{ + "1", "2", "3", "4", "5", "6", "7", "8", + }, + Destinations: []string{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", + }, + }, + expected: []expected{ + { + originsStart: 0, + numOrigins: 8, + destinationsStart: 0, + numDestinations: 12, + }, + { + originsStart: 0, + numOrigins: 8, + destinationsStart: 12, + numDestinations: 12, + }, + { + originsStart: 0, + numOrigins: 8, + destinationsStart: 24, + numDestinations: 6, + }, + }, + }, + // Case: 30 origins and 30 destinations produce 10 requests to + // accommodate 900 elements: 100, 100, 100, 100, 100, 100, 100, 50, 100 + // and 50. + { + r: &maps.DistanceMatrixRequest{ + Origins: []string{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", + }, + Destinations: []string{ + "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", + "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", + "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", + }, + }, + expected: []expected{ + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 0, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 4, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 8, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 12, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 16, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 20, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 24, + numDestinations: 4, + }, + { + originsStart: 0, + numOrigins: 25, + destinationsStart: 28, + numDestinations: 2, + }, + { + originsStart: 25, + numOrigins: 5, + destinationsStart: 0, + numDestinations: 20, + }, + { + originsStart: 25, + numOrigins: 5, + destinationsStart: 20, + numDestinations: 10, + }, + }, + }, + } + + // Convenient function for comparing fields in the expected struct. + compare := func(i, j, k, got, want int) { + if got != want { + t.Errorf( + "test %d, request %d, element %d: got %d, want %d", + i, j, k, got, want, + ) + } + } + + // Compare all the expected fields by test. + for i, test := range tests { + requests := split(test.r) + for j, request := range requests { + comparisons := []struct { + got int + want int + }{ + // Origins start index. + { + got: request.origins.start, + want: test.expected[j].originsStart, + }, + // Origins length. + { + got: request.origins.num, + want: test.expected[j].numOrigins, + }, + // Destinations start index. + { + got: request.destinations.start, + want: test.expected[j].destinationsStart, + }, + // Destinations length. + { + got: request.destinations.num, + want: test.expected[j].numDestinations, + }, + } + // Execute comparisons of the fields in the expected struct. + for k, comparison := range comparisons { + compare(i, j, k, comparison.got, comparison.want) + } + } + } +} + +// TestGroup tests the group function to assert that several requests are +// grouped together respecting the maxElementsPerSecond limit. +func TestGroup(t *testing.T) { + type testCase struct { + request matrixRequest + num int + expected int + } + + tests := []testCase{ + // Case: 22 100-element-long requests result in 3 groups of requests. + { + request: matrixRequest{ + origins: reference{num: 25}, + destinations: reference{num: 4}, + }, + num: 22, + expected: 3, + }, + // Case: 20 50-element-long requests result in 1 group of requests. + { + request: matrixRequest{ + origins: reference{num: 25}, + destinations: reference{num: 2}, + }, + num: 20, + expected: 1, + }, + } + + for i, test := range tests { + // Create large number of requests. + requests := make([]matrixRequest, test.num) + for r := range requests { + requests[r] = test.request + } + + // Assert number of groups is correct. + got := len(group(requests)) + want := test.expected + if got != want { + t.Errorf("test %d: got %d, want %d", i, got, want) + } + } +} + +func TestPolylines(t *testing.T) { + type testCase struct { + points [][2]float64 + expectedPolyFull string + expectedPolyLegs []string + } + + tests := []testCase{ + { + points: [][2]float64{ + {-74.028297, 4.875835}, + {-74.046965, 4.872842}, + {-74.041763, 4.885648}, + }, + // these lines are base64 encoded + expectedPolyFull: "c2h3XHxzeWJNQFJIRlxQSExCVkdYU1RJRl1KYUBEV0pbVE9U" + + "S0dxQFVRWnNAcEFHRE1SfUBiQkdSQ1RhQHxAYUFsQlNaXWpAaUBuQV9BdkJjQWhCS" + + "0R5QFNnQEdBP2VGaUJjRXVBY0BNaUFmRGlBcERjQHZBQ0JBQkFGRExMRG5DakJWVFJ" + + "gQGZAZkFMYEBAVEFgQFVsQFlaa0BWeUBSU0JbQ1VHcUBbbUFvQG9BbUBZRW1AP2F" + + "EYUB1Q11xQUdhR1l5Q1V5Q1lvQllxQFFpRXNBfUd3QmVCd0BPP2FAS3NAWVNASUpC" + + "TEhKTEZiQmBASkpASmJBWnRDdkBwR3BCfkJwQGZFZEBqRFp2Rlp+QkZUQGpCVG5B" + + "XHBCcEB4RHJBbEBUaUBiQlFeXWJBWX5ASUpHQnNASUlGSVJ9QGpDU1xTUlFWV3BA" + + "cUJsR3FAekI=", + expectedPolyLegs: []string{ + "c2h3XHxzeWJNP0hARD9CQD9GRkxITkZEREJGQko/SkFKRUxHSktIRURDQEtG" + + "UUJRQk9ATURJRE9IS0pPVD8/S0dxQFU/P1Fac0BwQT8/R0RFREdMW2pAYUB2" + + "QEVKQUY/TENGQUBDRkVGVWpAY0B6QF1wQEdMS0xNUE9YV2pAUWJAcUB8QU1Y" + + "fUB+QUVIQ0BBQEFAQz9BP0U/R0FpQFFbR0NBQT9FQEE/Pz9jRmlCPz9BP0M/" + + "QUFNRW9EbUE/P2NATWlBZkRzQGBDVW5AX0B0QT8/Q0BDQj9AQT8/QD9AP0B" + + "BPz9AP0A/QEA/P0A/QD9AQEA/QEA/P0BAPz9AQD8/QEA/QD9AQEA/QD9sQGJ" + + "AYEBYXFRgQFZOSkZIUF5AQFRmQFBeTGBAQFRBSD9WR1JNWENEVVRTSldKW0" + + "hNQkVASUJFP01CQT9ZQ1VHcUBbRUNnQWtAb0FtQFlFUUFbQD8/UUVZQ2tAS" + + "T8/aUFNbUJXZ0BFY0BFbUBBa0JJdUNPdUBHY0JNcUFLZ0FNc0BJe0BPW0dVS" + + "XtEbUFNRUlDZ0VxQXNAV1dJTUdZTWtAV1FJQ0E/P0VAQT9DP0k/V0ttQFdF" + + "QUNBQz9FQEVARUJBQj9AQUA/QD9EQEJAQEJGREJEQkZCUkZ8QFJMQkJAQkJ" + + "AP0JCQEJARD9EPz9SRm5AUl5MbkBMVkZsQFJuQ3hAYEN2QHpAWGJBVlxGXk" + + "RiQUpkQUpqRFp2RlpmQUR2QEBIQEo/cEBGeEBMZEBKaEBQUkZ8QWhAbkFiQ" + + "GZBYEBMRFJGXk5MRFhIbEBSXExmQFJIQHBCZEBoQEhgQEhiQEZIQmBARFBC" + + "TkBeRExAaEVeXEJ6REg/P0tgQEFAR1RBSEdaWXZAT2RAQUJRZEBDSkNGQ0Z" + + "FSkFARUhJTEVMQUBLUmNAaEFHUlNqQElWVWpAY0BiQVtwQHtAYkJjQGRBa0" + + "BuQU1USU5FSElOT2ZAQURXdEBFREVCQ0JFQlNGX0BKRUBFP0E/QT9DP0E/Q" + + "T9BP3FBa0BVSWFBWUlBQ0FBP0E/Qz9DQENAQUBBQEFAQUBnQHpBSVZZZkFx" + + "QHRCZUFyRD8/QEBAP0BAQD9AP0BAQD9AQEA/QEBAP0A/QEBAQEA/QEBAP0A" + + "/QEBAP0BAQD9AQEA/QD9AQEA/P0BAP0BAQD9AQEA/QD9AQEA/QEBAP0BAQD" + + "9APz9AQD9AQEA/QEBAP0A/QEBAP0BAQD9AQEA/QD8/QEA/QEBAP0BAQD9AP" + + "0BAQD9AQEA/QEBAP0A/P0BAP0BAQD9AQEA/QD9AQEA/QD9AQEA/QEBAP0A/" + + "P0BAP0A/QEBAP0A/QEBAP0BAQD9wQFZIQnZBbkBkQWZAWk5kQWZAekBgQG" + + "ZAWHhAZkBGRGBBcEBOSlxWfkBwQERCRkROSk5KeEFiQXBBfkBMSGJCekFU" + + "VGRAXGZAWlBKVk5kQFZKRnpAXkhCXk5CQHpAXEA/QD8/QEA/QEBAP0A/P0" + + "BAP0A/P0BAP0A/P0BAP0A/P0BAP0A/QEBAP0BAQD9AQEA/QD8/QEA/QD8/" + + "QEA/QD8/QEA/QD8/QEA/QD9AQEA/QEBAP0BAQD9APz9AQD9oQE5SSERAWk" + + "pMRD8/XmlBXmlBWHVAVnVAZEBnQVJnQFBfQGRAaUFMW25Ad0FQZUBOZUBCR" + + "T9DdkB3Qj8/SFk=", + "dXd2XHxnfWJNSVg/P3dAdkI/QkNET2RAUWRAb0B2QU1aZUBoQVFeU2ZAZU" + + "BmQVd0QFl0QF9AaEFfQGhBPz9NRVtLRUFTSWlAT0E/P0FBP0E/QUFBP0FB" + + "QT9BQUE/QT8/QUE/QT8/QUE/QT8/QUE/QT8/QUE/QT9BQUE/QUFBP0FBQT" + + "9BPz9BQT9BPz9BQT9BPz9BQT9BPz9BQT9BP0FBQT8/QUE/QT97QF1DQV9" + + "AT0lDe0BfQEtHZUBXV09RS2dAW2VAXVVVY0J7QU1JcUFfQXlBY0FPS09L" + + "R0VFQ19BcUBdV09LYUFxQEdFeUBnQGdAWXtAYUBlQWdAW09lQWdAd0FvQ" + + "ElDcUBXQT9BQUE/QUFBP0E/QUFBP0E/P0FBP0E/QUFBP0FBQT9BP0FBQT" + + "9BP0FBQT9BQUE/P0FBP0E/QUFBP0FBQT9BQUE/QT9BQUE/QUFBPz9BQT9" + + "BP0FBQT9BQUE/QUFBP0E/QUFBP0FBQT8/QUE/QT9BQUE/QUFBP0FBQT9B" + + "P0FBQT9BQUE/P0FBP0FBQT9BP0FBQT9BQUE/QUFBP0E/QUFBP0FBQUFBP" + + "0E/QUFBP0FBQT9BQUE/QT9BQUE/QUFBP0FBQT9BQUE/QUFBP0E/QUFBP0" + + "FBQUFBP0E/QUFBP0FBQT9BQUE/QT9BQUE/QUFBP0FBQT9BQUE/QUFDP0F" + + "BQT9BQUE/QUFBP0FBQz9BQUFBQT9BQUE/QUFDP0FBQT9BQUE/QUFBP0FB" + + "Qz9BQUE/QUFBP0FBQT9BQUM/QUFBP0FBQT9BQUM/QUFBP0FBQT9BQUE/Q" + + "UFDP0FBQT9BQUFBQT9BQUE/QUFBP0FBQT9BQUE/QUFBQUE/QUFBP0FBQT" + + "9BQUE/QUFjQWdAb0BfQFdPa0NhQn1AZ0BPSz8/Vn1AVGFBckBlQ05lQFh" + + "fQVRxQEZT", + }, + }, + } + for i, test := range tests { + coords := make([]string, len(test.points)) + for p, point := range test.points { + coords[p] = fmt.Sprintf("%f,%f", point[1], point[0]) + } + + apiKey := os.Getenv("GOOGLE_API_KEY") + + if apiKey == "" { + t.Skip() + } + c, err := maps.NewClient(maps.WithAPIKey(apiKey)) + if err != nil { + panic(err) + } + + rPoly := &maps.DirectionsRequest{ + Origin: coords[0], + Destination: coords[len(coords)-1], + Waypoints: coords[1 : len(coords)-1], + } + + fullPoly, polyLegs, err := Polylines(c, rPoly) + if err != nil { + panic(err) + } + + // Assert polylines are correct. + gotPolyFull := base64.StdEncoding.EncodeToString([]byte(fullPoly)) + wantPolyFull := test.expectedPolyFull + if gotPolyFull != wantPolyFull { + t.Errorf("test %d: got %s, want %s", i, gotPolyFull, wantPolyFull) + } + + encodedPolyLegs := make([]string, len(polyLegs)) + for i, p := range polyLegs { + encodedPolyLegs[i] = base64.StdEncoding.EncodeToString([]byte(p)) + } + + gotPolyLegs := encodedPolyLegs + wantPolyLegs := test.expectedPolyLegs + if !reflect.DeepEqual(gotPolyLegs, wantPolyLegs) { + t.Errorf("test %d: got %s, want %s", i, gotPolyLegs, wantPolyLegs) + } + } +} diff --git a/route/google/doc.go b/route/google/doc.go new file mode 100644 index 00000000..d61f3777 --- /dev/null +++ b/route/google/doc.go @@ -0,0 +1,83 @@ +/* +Package google provides functions for measuring distances and durations using +the Google Distance Matrix API and polylines from Google Maps Distance API. A +Google Maps client and request are required. The client uses an API key for +authentication. +Matrix API: At a minimum, the request requires the origins and destinations +to estimate. +Distance API: At minimum, the request requires the origin and destination. But +it is recommended to pass in waypoints encoded as a polyline with "enc:" +as a prefix to get a more precise polyline for each leg of the route. + +Here is a minimal example of how to create a client and matrix request, +assuming the points are in the form longitude, latitude: + + points := [][2]float64{ + {-74.028297, 4.875835}, + {-74.046965, 4.872842}, + {-74.041763, 4.885648}, + } + coords := make([]string, len(points)) + for p, point := range points { + coords[p] = fmt.Sprintf("%f,%f", point[1], point[0]) + } + r := &maps.DistanceMatrixRequest{ + Origins: coords, + Destinations: coords, + } + c, err := maps.NewClient(maps.WithAPIKey("")) + if err != nil { + panic(err) + } + +Distance and duration matrices can be constructed with the functions provided in +the package. + + dist, dur, err := google.DistanceDurationMatrices(c, r) + if err != nil { + panic(err) + } + +Once the measures have been created, you may estimate the distances and +durations by calling the Cost function: + + for p1 := range points { + for p2 := range points { + fmt.Printf( + "(%d, %d) = [%f, %f]\n", + p1, p2, dist.Cost(p1, p2), dur.Cost(p1, p2), + ) + } + } + +This should print the following result, which is in the format (from, to) = +[distance, duration]: + + (0, 0) = [0.000000, 0.000000] + (0, 1) = [6526.000000, 899.000000] + (0, 2) = [4889.000000, 669.000000] + (1, 0) = [5211.000000, 861.000000] + (1, 1) = [0.000000, 0.000000] + (1, 2) = [2260.000000, 302.000000] + (2, 0) = [3799.000000, 638.000000] + (2, 1) = [2260.000000, 311.000000] + (2, 2) = [0.000000, 0.000000] + +Making a request to retrieve polylines works similar. In this example we reuse +the same points and client from above and create a DirectionsRequest. The +polylines function returns a polyline from start to end and a slice of polylines +for each leg, given through the waypoints. All polylines are encoded in Google's +polyline format. + + rPoly := &maps.DirectionsRequest{ + Origin: coords[0], + Destination: coords[len(coords)-1], + Waypoints: coords[1 : len(coords)-1], + } + + fullPoly, polyLegs, err := google.Polylines(c, rPoly) + if err != nil { + panic(err) + } +*/ +package google diff --git a/route/google/go.mod b/route/google/go.mod new file mode 100644 index 00000000..f502024d --- /dev/null +++ b/route/google/go.mod @@ -0,0 +1,20 @@ +module github.com/nextmv-io/sdk/route/google + +go 1.19 + +replace github.com/nextmv-io/sdk => ../../. + +require ( + github.com/nextmv-io/sdk v0.0.0 + googlemaps.github.io/maps v1.3.2 +) + +require ( + github.com/google/go-cmp v0.5.6 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/stretchr/testify v1.7.0 // indirect + go.opencensus.io v0.23.0 // indirect + golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect + golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/route/google/go.sum b/route/google/go.sum new file mode 100644 index 00000000..3a9daf9c --- /dev/null +++ b/route/google/go.sum @@ -0,0 +1,125 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.23.0 h1:gqCw0LfLxScz8irSi8exQc7fyQ0fKQU/qnC/X8+V/1M= +go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w= +golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +googlemaps.github.io/maps v1.3.2 h1:3YfYdVWFTFi7lVdCdrDYW3dqHvfCSUdC7/x8pbMOuKQ= +googlemaps.github.io/maps v1.3.2/go.mod h1:cCq0JKYAnnCRSdiaBi7Ex9CW15uxIAk7oPi8V/xEh6s= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/route/here/doc.go b/route/here/doc.go new file mode 100644 index 00000000..41a939c4 --- /dev/null +++ b/route/here/doc.go @@ -0,0 +1,44 @@ +// Package here provides a client for measuring distances and durations. +// +// A HERE client requests distance and duration data using HERE Maps API. It +// makes requests to construct a matrix measure. +// +// client := here.NewClient("") +// +// The client can construct a distance matrix, a duration matrix, or both. +// +// distances, err := client.DistanceMatrix(ctx, points) +// durations, err := client.DurationMatrix(ctx, points) +// distances, durations, err := client.DistanceDurationMatrices(ctx, points) +// +// Each of these functions will use a synchronous request flow if the number +// of points requested is below HERE's size limit for synchronous API calls - +// otherwise, an asynchronous flow will be used. The functions all take a +// context which can be used to cancel the request flow while it is in progress. +// +// These measures implement route.ByIndex. +// +// These matrix-generating functions can also take one or more options +// that allow you to configure the routes that will be included in the matrices. +// For example, you can set a specific departure time to use when factoring +// in traffic time to the route durations: +// +// durations, err := client.DurationMatrix( +// ctx, +// points, +// here.WithDepartureTime(time.Date(2021, 12, 10, 8, 30, 0, 0, loc)), +// ) +// +// Or, you can configure a truck profile: +// +// distances, err := client.DistanceMatrix( +// ctx, +// points, +// here.WithTransportMode(here.Truck), +// here.WithTruck( +// Type: here.TruckTypeTractor, +// TrailerCount: 2, +// ShippedHazardousGoods: []here.HazardousGood{here.Poison}, +// ), +// ) +package here diff --git a/route/here/go.mod b/route/here/go.mod new file mode 100644 index 00000000..c7a20204 --- /dev/null +++ b/route/here/go.mod @@ -0,0 +1,12 @@ +module github.com/nextmv-io/sdk/route/here + +go 1.19 + +replace github.com/nextmv-io/sdk => ../../. + +require ( + github.com/google/go-cmp v0.5.6 + github.com/nextmv-io/sdk v0.0.0 +) + +require golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect diff --git a/route/here/go.sum b/route/here/go.sum new file mode 100644 index 00000000..58b75a45 --- /dev/null +++ b/route/here/go.sum @@ -0,0 +1,5 @@ +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/route/here/measure.go b/route/here/measure.go new file mode 100644 index 00000000..a9dbd198 --- /dev/null +++ b/route/here/measure.go @@ -0,0 +1,487 @@ +package here + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/nextmv-io/sdk/route" +) + +// Client represents a HERE maps client. See official documentation for HERE +// topics, getting started. +type Client interface { + // DistanceMatrix retrieves a HERE distance matrix. It uses the async HERE API + // if there are more than 500 points given. + DistanceMatrix( + ctx context.Context, + points []route.Point, + opts ...MatrixOption, + ) (route.ByIndex, error) + // DurationMatrix retrieves a HERE duration matrix. It uses the async HERE API + // if there are more than 500 points given. + DurationMatrix( + ctx context.Context, + points []route.Point, + opts ...MatrixOption, + ) (route.ByIndex, error) + + // DistanceDurationMatrices retrieves a HERE distance and duration matrix. It + // uses the async HERE API if there are more than 500 points given. + DistanceDurationMatrices( + ctx context.Context, + points []route.Point, + opts ...MatrixOption, + ) (distances, durations route.ByIndex, err error) +} + +type client struct { + // scheme and host of the HERE API - currently configurable for testing + // although this may eventually be useful for failover to alternative + // regions + schemeHost string + maxAsyncPollingInterval time.Duration + minAsyncPollingInterval time.Duration + retries int + denyRedirectedRequests []string + APIKey string + httpClient *http.Client + // maxSyncPoints controls the maximum number of points that should + // be requested from the sync endpoint - there is a maximum set by + // HERE (500 points) and is configurable below that number for testing + maxSyncPoints int +} + +const ( + defaultHereAPIHost = "matrix.router.hereapi.com" +) + +// NewClient returns a new OSRM Client. +func NewClient(apiKey string, opts ...ClientOption) Client { + c := &client{ + schemeHost: fmt.Sprintf("https://%s", defaultHereAPIHost), + maxAsyncPollingInterval: time.Second * 5, + minAsyncPollingInterval: time.Millisecond * 200, + retries: 10, + APIKey: apiKey, + httpClient: http.DefaultClient, + maxSyncPoints: 500, + denyRedirectedRequests: []string{}, + } + + for _, opt := range opts { + opt(c) + } + + c.denyRedirectedRequests = append(c.denyRedirectedRequests, defaultHereAPIHost) + c.httpClient.CheckRedirect = func(r *http.Request, via []*http.Request) error { + for _, host := range c.denyRedirectedRequests { + if strings.HasSuffix(r.URL.Hostname(), host) { + return http.ErrUseLastResponse + } + } + + return nil + } + + return c +} + +// DistanceMatrix retrieves a HERE distance matrix. It uses the async HERE API +// if there are more than 500 points given. +func (c *client) DistanceMatrix( + ctx context.Context, + points []route.Point, + opts ...MatrixOption, +) (route.ByIndex, error) { + if len(points) > c.maxSyncPoints { + distances, _, err := c.fetchMatricesAsync(ctx, points, true, false, opts) + return distances, err + } + distances, _, err := c.fetchMatricesSync(ctx, points, true, false, opts) + return distances, err +} + +// DurationMatrix retrieves a HERE duration matrix. It uses the async HERE API +// if there are more than 500 points given. +func (c *client) DurationMatrix( + ctx context.Context, + points []route.Point, + opts ...MatrixOption, +) (route.ByIndex, error) { + if len(points) > c.maxSyncPoints { + _, durations, err := c.fetchMatricesAsync( + ctx, points, false, true, opts) + return durations, err + } + _, durations, err := c.fetchMatricesSync(ctx, points, false, true, opts) + return durations, err +} + +// DistanceDurationMatrices retrieves a HERE distance and duration matrix. It +// uses the async HERE API if there are more than 500 points given. +func (c *client) DistanceDurationMatrices( + ctx context.Context, + points []route.Point, + opts ...MatrixOption, +) (distances, durations route.ByIndex, err error) { + if len(points) > c.maxSyncPoints { + return c.fetchMatricesAsync(ctx, points, true, true, opts) + } + return c.fetchMatricesSync(ctx, points, true, true, opts) +} + +// fetchMatricesSync makes a call to the sync HERE API endpoint. +func (c *client) fetchMatricesSync( + ctx context.Context, + points []route.Point, + includeDistance, + includeDuration bool, + opts []MatrixOption, +) (distances, durations route.ByIndex, err error) { + resp, err := c.calculate( + ctx, points, false, includeDistance, includeDuration, opts...) + if err != nil { + return nil, nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, nil, badStatusError(resp) + } + + var hereResponse matrixResponse + if err := json.NewDecoder(resp.Body).Decode(&hereResponse); err != nil { + return nil, nil, fmt.Errorf("decoding response: %v", err) + } + + if includeDistance { + distances = route.Matrix(reshape( + hereResponse.Matrix.Distances, + points, + )) + } + if includeDuration { + durations = route.Matrix(reshape( + hereResponse.Matrix.TravelTimes, + points, + )) + } + + return distances, durations, nil +} + +// fetchMatricesAsync makes a call to the async HERE API endpoint. +func (c *client) fetchMatricesAsync( //nolint:gocyclo + ctx context.Context, + points []route.Point, + includeDistance, + includeDuration bool, + opts []MatrixOption, +) (distances, durations route.ByIndex, err error) { + statusURL, err := c.startAsyncCalculation( + ctx, points, includeDistance, includeDuration, opts...) + if err != nil { + return nil, nil, fmt.Errorf("starting async calculation: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, statusURL, nil) + if err != nil { + return nil, nil, err + } + + pollInterval := c.minAsyncPollingInterval + retries := 0 + // returns true if there are more retries allowed, and increments retry + // counters + shouldRetry := func() bool { + if retries >= c.retries { + return false + } + retries++ + pollInterval *= 2 + if pollInterval > c.maxAsyncPollingInterval { + pollInterval = c.maxAsyncPollingInterval + } + return true + } + + var resultURL string + for { + var ready, retry bool + resultURL, ready, retry, err = c.poll(req) + if err != nil { + if !retry { + return nil, nil, fmt.Errorf( + "an error occurred while polling status: %v", err) + } + if !shouldRetry() { + return nil, nil, fmt.Errorf( + "maximum number of retries (%d) exceeded", c.retries) + } + } + if ready { + break + } + + select { + case <-ctx.Done(): + return nil, nil, context.Canceled + case <-time.After(pollInterval): + } + } + + retries = 0 + pollInterval = c.minAsyncPollingInterval + var resp *http.Response + req, err = http.NewRequestWithContext(ctx, http.MethodGet, resultURL, nil) + if err != nil { + return nil, nil, err + } + for { + resp, err = c.httpClient.Do(req) + if err != nil && !shouldRetry() || errors.Is(err, context.Canceled) { + return nil, nil, fmt.Errorf("getting result: %v", err) + } + defer func() { + if tempErr := resp.Body.Close(); tempErr != nil { + err = tempErr + } + }() + if err != nil { + return nil, nil, fmt.Errorf("closing response body: %w", err) + } + if resp.StatusCode == http.StatusOK { + break + } + if resp.StatusCode > http.StatusBadRequest && + resp.StatusCode < http.StatusInternalServerError { + return nil, nil, validationError{ + message: fmt.Sprintf("aborting request due to received status code: %d", + resp.StatusCode), + } + } + if resp.StatusCode >= http.StatusInternalServerError && !shouldRetry() { + return nil, nil, fmt.Errorf("received status code: %d", + resp.StatusCode) + } + + select { + case <-ctx.Done(): + return nil, nil, context.Canceled + case <-time.After(pollInterval): + } + } + + var hereResponse matrixResponse + if err := json.NewDecoder(resp.Body).Decode(&hereResponse); err != nil { + return nil, nil, fmt.Errorf("decoding result: %v", err) + } + + if includeDistance { + distances = route.Matrix(reshape( + hereResponse.Matrix.Distances, + points, + )) + } + if includeDuration { + durations = route.Matrix(reshape( + hereResponse.Matrix.TravelTimes, + points, + )) + } + + return distances, durations, nil +} + +func badStatusError(resp *http.Response) error { + b, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf( + "bad status code: %d, error reading response body: %v", + resp.StatusCode, + err, + ) + } + return fmt.Errorf( + "bad status code: %d, response body:%s", resp.StatusCode, string(b)) +} + +func urlWithAPIKey(u string, apiKey string) (string, error) { + parsed, err := url.Parse(u) + if err != nil { + return "", err + } + query := parsed.Query() + query.Set("apiKey", apiKey) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func (c *client) startAsyncCalculation( + ctx context.Context, + points []route.Point, + includeDistance, includeDuration bool, + opts ...MatrixOption, +) (string, error) { + resp, err := c.calculate( + ctx, points, true, includeDistance, includeDuration, opts...) + if err != nil { + return "", err + } + if resp.StatusCode != http.StatusAccepted { + return "", badStatusError(resp) + } + + var statusResponse statusResponse + if err := json.NewDecoder(resp.Body).Decode(&statusResponse); err != nil { + return "", fmt.Errorf( + "decoding status response from starting calculation: %v", err) + } + + statusURL, err := urlWithAPIKey(statusResponse.StatusURL, c.APIKey) + if err != nil { + return "", fmt.Errorf("parsing status URL: %v", err) + } + + return statusURL, nil +} + +func (c *client) poll( + req *http.Request, +) (resultURL string, ready bool, retry bool, err error) { + resp, err := c.httpClient.Do(req) + if err != nil { + return "", false, true, fmt.Errorf("getting status: %w", err) + } + defer func() { + if tempErr := resp.Body.Close(); tempErr != nil { + err = tempErr + } + }() + if err != nil { + return "", false, true, fmt.Errorf("closing response body: %w", err) + } + + if resp.StatusCode > http.StatusBadRequest && + resp.StatusCode < http.StatusInternalServerError { + return "", false, false, validationError{ + message: fmt.Sprintf("aborting request due to received status code: %d", + resp.StatusCode), + } + } + if resp.StatusCode >= http.StatusInternalServerError { + return "", false, true, fmt.Errorf( + "retry request due to received status code: %d", + resp.StatusCode) + } + var statusResponse statusResponse + if err := json.NewDecoder(resp.Body).Decode(&statusResponse); err != nil { + return "", false, true, fmt.Errorf("decoding status response: %v", err) + } + + resultURL, err = urlWithAPIKey(statusResponse.ResultURL, c.APIKey) + if err != nil { + return "", false, false, fmt.Errorf("parsing result URL: %v", err) + } + + if !isKnownStatusResponse(statusResponse.Status) { + return "", false, false, fmt.Errorf( + "unknown status: %s", statusResponse.Status) + } + + return resultURL, statusResponse.Status == responseStatusComplete, true, nil +} + +func (c *client) calculate( + ctx context.Context, + points []route.Point, + async, includeDistance, includeDuration bool, + opts ...MatrixOption, +) (*http.Response, error) { + url := fmt.Sprintf( + "%s/v8/matrix?apiKey=%s&async=%t", c.schemeHost, c.APIKey, async) + + var herePoints []point + for _, p := range points { + if len(p) == 2 { + herePoints = append(herePoints, point{ + Lon: p[0], + Lat: p[1], + }) + } + } + + hereReq := &matrixRequest{ + Origins: herePoints, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + } + + if includeDistance { + hereReq.MatrixAttributes = append(hereReq.MatrixAttributes, "distances") + } + if includeDuration { + hereReq.MatrixAttributes = append( + hereReq.MatrixAttributes, "travelTimes") + } + for _, opt := range opts { + opt(hereReq) + } + + body := new(bytes.Buffer) + if err := json.NewEncoder(body).Encode(hereReq); err != nil { + return nil, fmt.Errorf("encoding request: %v", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return nil, fmt.Errorf("constructing request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + return resp, nil +} + +func reshape(m []int, points []route.Point) [][]float64 { + // TODO: this can happen when we construct the here points + // so we don't need to iterate over the matrix again + widthWithoutZeroes := 0 + for _, p := range points { + if len(p) == 2 { + widthWithoutZeroes++ + } + } + + width := len(points) + reshaped := make([][]float64, width) + iZeroes := 0 + for i := 0; i < width; i++ { + reshaped[i] = make([]float64, width) + if len(points[i]) == 0 { + iZeroes++ + continue + } + jZeroes := 0 + for j := 0; j < width; j++ { + if len(points[j]) == 0 { + jZeroes++ + reshaped[i][j] = 0 + } else { + reshaped[i][j] = float64( + m[(i-iZeroes)*widthWithoutZeroes+j-jZeroes]) + } + } + } + + return reshaped +} diff --git a/route/here/measure_integration_test.go b/route/here/measure_integration_test.go new file mode 100644 index 00000000..f05a52c0 --- /dev/null +++ b/route/here/measure_integration_test.go @@ -0,0 +1,143 @@ +//go:build integration +// +build integration + +package here + +import ( + "context" + "fmt" + "log" + "os" + "testing" + "time" +) + +var apiKey = os.Getenv("HERE_API_KEY") + +// This is not a classical "test" so much as a script to get a gut-check that we're passing +// props on to the HERE measure correctly +func TestHereMeasuresIntegration(t *testing.T) { + points := []route.Point{ + // Boulder + {-105.276995, 40.023353}, + // Boulder + {-105.272575, 40.004848}, + // Longmont + {-105.099540, 40.178720}, + // Denver + {-104.989334, 39.734760}, + } + loc, err := time.LoadLocation("MST") + if err != nil { + t.Fatalf("loading location: %v", err) + } + tests := []struct { + description string + points []route.Point + opts []MatrixOption + }{ + { + description: "normal", + points: points, + opts: []MatrixOption{ + WithDepartureTime(time.Time{}), + }, + }, + { + description: "departure time", + points: points, + opts: []MatrixOption{ + WithDepartureTime(time.Date(2021, 12, 23, 5, 30, 0, 0, loc)), + }, + }, + { + description: "bike", + points: points, + opts: []MatrixOption{ + WithTransportMode(TransportModeBicycle), + }, + }, + { + description: "avoid features and areas", + points: points, + opts: []MatrixOption{ + WithAvoidFeatures([]Feature{ + TollRoad, + ControlledAccessHighway, + }), + WithAvoidAreas([]BoundingBox{ + { + North: 39.902022, + South: 39.857233, + East: -104.971970, + West: -105.122689, + }, + }), + }, + }, + { + description: "truck profile with axle groups", + points: points, + opts: []MatrixOption{ + WithTransportMode(TransportModeTruck), + WithTruckProfile(Truck{ + ShippedHazardousGoods: []HazardousGood{Poison, Explosive}, + GrossWeight: 36287, + TunnelCategory: TunnelCategoryNone, + Type: TruckTypeTractor, + AxleCount: 5, + Height: 411, + Width: 259, + Length: 2194, + TrailerCount: 2, + WeightPerAxleGroup: &WeightPerAxleGroup{ + Tandem: 14514, + }, + }), + }, + }, + { + description: "truck profile with weight per axle", + points: points, + opts: []MatrixOption{ + WithTransportMode(TransportModeTruck), + WithTruckProfile(Truck{ + ShippedHazardousGoods: []HazardousGood{Poison, Explosive}, + GrossWeight: 36287, + TunnelCategory: TunnelCategoryNone, + Type: TruckTypeTractor, + AxleCount: 5, + WeightPerAxle: 7257, + Height: 411, + Width: 259, + Length: 2194, + TrailerCount: 2, + }), + }, + }, + } + for i, test := range tests { + cli := NewClient(apiKey) + ctx := context.Background() + distances, durations, err := cli.DistanceDurationMatrices(ctx, test.points, test.opts...) + if err != nil { + t.Errorf("[%d] %s: getting matrices: %v", i, test.description, err) + } + log.Println(test.description) + log.Println("Distances") + for i := 0; i < len(test.points); i++ { + for j := 0; j < len(test.points); j++ { + fmt.Printf("%.0f ", distances.Cost(i, j)) + } + fmt.Println("") + } + log.Println("Durations") + for i := 0; i < len(test.points); i++ { + for j := 0; j < len(test.points); j++ { + fmt.Printf("%.0f ", durations.Cost(i, j)) + } + fmt.Println("") + } + fmt.Println("") + } +} diff --git a/route/here/measure_test.go b/route/here/measure_test.go new file mode 100644 index 00000000..3286f65d --- /dev/null +++ b/route/here/measure_test.go @@ -0,0 +1,1345 @@ +package here + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/nextmv-io/sdk/route" +) + +const apiKey string = "foo" + +// This isn't the best test in the world because it relies on timing, so it +// doesn't test that every individual cancellation point handles cancellations +// properly - but that is very hard to do so this is good enough for now. +func TestCancellation(t *testing.T) { + inPoints := []point{ + {Lon: 1.0, Lat: 0.1}, + {Lon: 2.0, Lat: 0.2}, + {Lon: 3.0, Lat: 0.3}, + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*1) + defer cancel() + s := newMockServer(ctx, apiKey, []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: internalServerError(), + latency: 60, + }, + { + Endpoint: "status", + BuildResponse: internalServerError(), + latency: 60, + }, + { + Endpoint: "status", + BuildResponse: internalServerError(), + latency: 60, + }, + }) + cli := NewClient(apiKey) + cli.(*client).retries = 100 + cli.(*client).schemeHost = s.URL + cli.(*client).maxSyncPoints = 1 + + points := []route.Point{{1.0, 0.1}, {2.0, 0.2}, {3.0, 0.3}} + _, err := cli.DistanceMatrix(ctx, points) + if err == nil { + t.Errorf("expected context cancellation error: %v", err) + } +} + +func TestClientRedirect(t *testing.T) { + apiKey := "foo" + inPoints := []point{ + {Lon: 1.0, Lat: 0.1}, + {Lon: 2.0, Lat: 0.2}, + {Lon: 3.0, Lat: 0.3}, + } + responseMatrix := []int{ + 0, 1, 2, + 3, 0, 4, + 5, 6, 0, + } + + serverHostName := "127.0.0.1" + testHostName := "hereapi.com" + + tests := []struct { + description string + requests []requestSpec + }{ + { + description: "ignores redirect for relative URL", + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusRedirect(responseStatusComplete), + redirectTo: "/some_path_that_wont_be_followed", + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + }, + { + description: "ignores redirect for full URL", + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusRedirect(responseStatusComplete), + redirectTo: fmt.Sprintf("https://%s/some_path", serverHostName), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + }, + { + description: "ignores redirects for hostnames greedily", + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusRedirect(responseStatusComplete), + redirectTo: fmt.Sprintf( + "https://foo.bar.baz.%s/some_path", testHostName), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + }, + } + + for _, test := range tests { + s := newMockServer(context.Background(), apiKey, test.requests) + u, _ := url.Parse(s.URL) + serverHostName = u.Hostname() + + cli := NewClient(apiKey, WithDenyRedirectPolicy( + serverHostName, testHostName)) + cli.(*client).schemeHost = s.URL + cli.(*client).maxSyncPoints = 1 + + points := []route.Point{{1.0, 0.1}, {2.0, 0.2}, {3.0, 0.3}} + _, err := cli.DistanceMatrix(context.Background(), points) + if err != nil { + t.Errorf( + "redirect test %s failed: unexpected error: %v", + test.description, + err, + ) + } + } +} + +func TestHereMeasures(t *testing.T) { + distances := func( + cli Client, + ps []route.Point, + opts ...MatrixOption, + ) (route.ByIndex, error) { + return cli.DistanceMatrix(context.Background(), ps, opts...) + } + durations := func( + cli Client, + ps []route.Point, + opts ...MatrixOption, + ) (route.ByIndex, error) { + return cli.DurationMatrix(context.Background(), ps, opts...) + } + selectDistances := func( + cli Client, + ps []route.Point, + opts ...MatrixOption, + ) (route.ByIndex, error) { + distances, _, err := cli.DistanceDurationMatrices( + context.Background(), ps, opts...) + return distances, err + } + selectDurations := func( + cli Client, + ps []route.Point, + opts ...MatrixOption, + ) (route.ByIndex, error) { + _, durations, err := cli.DistanceDurationMatrices( + context.Background(), ps, opts...) + return durations, err + } + type test struct { + description string + points []route.Point + maxSyncPoints int + retries int + expectErr bool + getMatrix func( + Client, + []route.Point, + ...MatrixOption, + ) (route.ByIndex, error) + expectedMatrix [][]float64 + requests []requestSpec + opts []MatrixOption + } + + apiKey := `foo` + responseMatrix := []int{ + 0, 1, 2, + 3, 0, 4, + 5, 6, 0, + } + points := []route.Point{{1.0, 0.1}, {2.0, 0.2}, {3.0, 0.3}} + pointsWithEmptyVals := []route.Point{ + {1.0, 0.1}, {2.0, 0.2}, {}, {3.0, 0.3}, {}, + } + inPoints := []point{ + {Lon: 1.0, Lat: 0.1}, + {Lon: 2.0, Lat: 0.2}, + {Lon: 3.0, Lat: 0.3}, + } + expectedMatrix := [][]float64{{0, 1.0, 2.0}, {3.0, 0, 4.0}, {5.0, 6.0, 0.0}} + expectedMatrixWithEmptyVals := [][]float64{ + {0, 1, 0, 2, 0}, + {3, 0, 0, 4, 0}, + {0, 0, 0, 0, 0}, + {5, 6, 0, 0, 0}, + {0, 0, 0, 0, 0}, + } + + truckProfile := Truck{ + ShippedHazardousGoods: []HazardousGood{ + Explosive, + Poison, + }, + GrossWeight: 100, + WeightPerAxle: 25, + Height: 1000, + Width: 20, + Length: 10, + TunnelCategory: TunnelCategoryC, + AxleCount: 4, + Type: TruckTypeTractor, + WeightPerAxleGroup: &WeightPerAxleGroup{ + Tandem: 6, + Triple: 7, + }, + } + + tests := []test{ + { + description: "sync calculation with empty locations (distances)", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "sync calculation (distances)", + points: points, + getMatrix: distances, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "sync calculation (durations)", + points: points, + getMatrix: durations, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + TravelTimes: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "sync calculation (distances from distances + durations)", + points: points, + getMatrix: selectDistances, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances", "travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + TravelTimes: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "sync calculation (durations from distances + durations)", + points: points, + getMatrix: selectDurations, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances", "travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + TravelTimes: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "sync with error", + points: points, + getMatrix: selectDurations, + expectedMatrix: expectedMatrix, + expectErr: true, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances", "travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: internalServerError(), + }, + }, + maxSyncPoints: 100, + }, + { + description: "async calculation with empty locations (durations)", + points: pointsWithEmptyVals, + getMatrix: durations, + expectedMatrix: expectedMatrixWithEmptyVals, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + TravelTimes: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation (distances)", + points: points, + getMatrix: distances, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation (durations)", + points: points, + getMatrix: durations, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + TravelTimes: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation (distances from distances " + + "+ durations)", + points: points, + getMatrix: selectDistances, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances", "travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + TravelTimes: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation (durations from distances " + + "+ durations)", + points: points, + getMatrix: selectDurations, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances", "travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + TravelTimes: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation with status retries", + points: points, + getMatrix: distances, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: internalServerError(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation with failure from status", + points: points, + getMatrix: distances, + expectErr: true, + expectedMatrix: expectedMatrix, + retries: 1, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: internalServerError(), + }, + { + Endpoint: "status", + BuildResponse: internalServerError(), + }, + }, + }, + { + description: "async calculation with 404 from status", + points: points, + getMatrix: distances, + expectErr: true, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: notFound(), + }, + }, + }, + { + description: "async calculation with result retries", + points: points, + getMatrix: distances, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: internalServerError(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + }, + { + description: "async calculation with failure from result", + points: points, + getMatrix: distances, + expectErr: true, + expectedMatrix: expectedMatrix, + retries: 1, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: internalServerError(), + }, + { + Endpoint: "result", + BuildResponse: internalServerError(), + }, + }, + }, + { + description: "async calculation with failure from result", + points: points, + getMatrix: distances, + expectErr: true, + expectedMatrix: expectedMatrix, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: notFound(), + }, + }, + }, + // The following tests test various options that HERE supports - I just + // randomly alternated between sync and async patterns and different + // matrix types between the tests to keep things interesting + // (I think testing all modes for all options would be overkill) + { + description: "departure time (any)", + points: points, + getMatrix: selectDurations, + expectedMatrix: expectedMatrix, + opts: []MatrixOption{ + WithDepartureTime(time.Time{}), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances", "travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + DepartureTime: "any", + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + TravelTimes: responseMatrix, + }, + }), + }, + }, + }, + { + description: "departure time (specific time)", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + opts: []MatrixOption{ + WithDepartureTime( + time.Date(2021, 12, 10, 9, 30, 0, 0, time.UTC)), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + DepartureTime: "2021-12-10T09:30:00Z", + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "transport mode", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + opts: []MatrixOption{ + WithTransportMode(TransportModeBicycle), + WithDepartureTime( + time.Date(2021, 12, 10, 9, 30, 0, 0, time.UTC)), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + DepartureTime: "2021-12-10T09:30:00Z", + TransportMode: TransportMode("bicycle"), + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "avoid features", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + opts: []MatrixOption{ + WithTransportMode(TransportModeBicycle), + WithAvoidFeatures([]Feature{Ferry, DirtRoad}), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + TransportMode: "bicycle", + Avoid: &avoid{ + Features: []Feature{Ferry, DirtRoad}, + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "avoid areas", + points: points, + getMatrix: durations, + expectedMatrix: expectedMatrix, + opts: []MatrixOption{ + WithDepartureTime(time.Time{}), + WithAvoidAreas([]BoundingBox{ + { + North: 0.8, + South: 0.6, + East: 0.7, + West: 0.5, + }, + }), + WithAvoidAreas([]BoundingBox{ + { + North: 4.8, + South: 2.6, + East: 3.7, + West: 1.5, + }, + }), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedAsync: true, + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"travelTimes"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + DepartureTime: "any", + Avoid: &avoid{ + Areas: []area{ + { + Type: "boundingBox", + North: 0.8, + South: 0.6, + East: 0.7, + West: 0.5, + }, + { + Type: "boundingBox", + North: 4.8, + South: 2.6, + East: 3.7, + West: 1.5, + }, + }, + }, + }, + BuildResponse: asyncMatrixSuccess(), + }, + { + Endpoint: "status", + BuildResponse: statusSuccess(), + }, + { + Endpoint: "result", + BuildResponse: resultSuccess(matrixResponse{ + Matrix: matrix{ + TravelTimes: responseMatrix, + }, + }), + }, + }, + }, + { + description: "truck profile", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + opts: []MatrixOption{ + WithTransportMode(TransportModeTruck), + WithTruckProfile(truckProfile), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + TransportMode: TransportModeTruck, + Truck: &truckProfile, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "scooter profile", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + opts: []MatrixOption{ + WithTransportMode(TransportModeScooter), + WithScooterProfile(Scooter{AllowHighway: true}), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + TransportMode: TransportModeScooter, + Scooter: &Scooter{ + AllowHighway: true, + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + { + description: "taxi profile", + points: pointsWithEmptyVals, + getMatrix: distances, + expectedMatrix: expectedMatrixWithEmptyVals, + opts: []MatrixOption{ + WithTransportMode(TransportModeTaxi), + WithTaxiProfile(Taxi{ + AllowDriveThroughTaxiRoads: true, + }), + }, + requests: []requestSpec{ + { + Endpoint: "matrix", + ExpectedRequest: matrixRequest{ + Origins: inPoints, + MatrixAttributes: []string{"distances"}, + RegionDefinition: regionDefinition{ + Type: "autoCircle", + }, + TransportMode: TransportModeTaxi, + Taxi: &Taxi{ + AllowDriveThroughTaxiRoads: true, + }, + }, + BuildResponse: syncMatrixSuccess(matrixResponse{ + Matrix: matrix{ + Distances: responseMatrix, + }, + }), + }, + }, + maxSyncPoints: 100, + }, + } + for i, test := range tests { + s := newMockServer(context.Background(), apiKey, test.requests) + cli := NewClient(apiKey) + cli.(*client).maxSyncPoints = 1 + if test.maxSyncPoints > 0 { + cli.(*client).maxSyncPoints = test.maxSyncPoints + } + if test.retries != 0 { + cli.(*client).retries = test.retries + } + cli.(*client).schemeHost = s.URL + + m, err := test.getMatrix(cli, test.points, test.opts...) + // Error from server is higher priority than error on the client-side + if s.Error() != nil { + t.Errorf( + "[%d] %s: error from mock server: %v", + i, + test.description, s.Error(), + ) + continue + } + if err != nil { + if !test.expectErr { + t.Errorf("[%d] %s: unexpected error: %v", i, test.description, err) + } + continue + } + + if err == nil && test.expectErr { + t.Errorf("[%d] %s: expected error but got none", i, test.description) + continue + } + + got := unpackMatrix(m, len(test.points)) + if diff := cmp.Diff(got, test.expectedMatrix); diff != "" { + t.Errorf("[%d] %s: (-want, +got)\n%s", i, test.description, diff) + } + } +} + +func unpackMatrix(m route.ByIndex, width int) [][]float64 { + matrix := make([][]float64, width) + for i := 0; i < width; i++ { + matrix[i] = make([]float64, width) + for j := 0; j < width; j++ { + matrix[i][j] = m.Cost(i, j) + } + } + return matrix +} + +type mockServer struct { + *httptest.Server + err error + expectedRequests int + requests []requestSpec +} + +func (m mockServer) Error() error { + if m.err != nil { + return m.err + } + if len(m.requests) != 0 { + return fmt.Errorf( + "received fewer requests than expected (%d)", m.expectedRequests) + } + return nil +} + +func (m *mockServer) PopRequest() *requestSpec { + if len(m.requests) == 0 { + m.err = fmt.Errorf( + "got more requests than expected (%d)", m.expectedRequests) + return nil + } + nextRequest := m.requests[0] + m.requests = m.requests[1:] + return &nextRequest +} + +type requestSpec struct { + // This is a partial URL path that uniquely identifies the endpoint + // Possible values are: matrix, status, and result + Endpoint string + ExpectedAsync bool + ExpectedRequest any + BuildResponse buildResponse + // latency will cause this request to wait some number of seconds + latency int64 + // If set and using a redirect header, will set the `location` header + // on the response + redirectTo string +} + +type buildResponse func(url string) (int, any) + +func notFound() buildResponse { + return func(url string) (int, any) { + return http.StatusNotFound, nil + } +} + +func internalServerError() buildResponse { + return func(url string) (int, any) { + return http.StatusInternalServerError, nil + } +} + +func statusRedirect(status responseStatus) buildResponse { + return func(url string) (int, any) { + resp := statusResponse{ + StatusURL: url + "/status", + Status: status, + } + if status == responseStatusComplete { + resp.ResultURL = url + "/result" + } + return http.StatusMovedPermanently, resp + } +} + +func syncMatrixSuccess(resp matrixResponse) buildResponse { + return func(url string) (int, any) { + return http.StatusOK, resp + } +} + +func asyncMatrixSuccess() buildResponse { + return func(url string) (int, any) { + return http.StatusAccepted, statusResponse{ + StatusURL: url + "/status", + } + } +} + +func statusSuccess() buildResponse { + return func(url string) (int, any) { + resp := statusResponse{ + StatusURL: url + "/status", + Status: responseStatusComplete, + ResultURL: url + "/result", + } + + return http.StatusOK, resp + } +} + +func resultSuccess(response matrixResponse) buildResponse { + return func(url string) (int, any) { + return http.StatusOK, response + } +} + +func newMockServer( //nolint:gocyclo + ctx context.Context, + apiKey string, + requests []requestSpec, +) *mockServer { + m := &mockServer{ + requests: requests, + expectedRequests: len(requests), + } + + s := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + nextRequest := m.PopRequest() + if nextRequest == nil { + return + } + + // Check if the api key is correct. + if r.URL.Query().Get("apiKey") != apiKey { + w.WriteHeader(http.StatusUnauthorized) + return + } + + async := r.URL.Query().Get("async") == "true" + if async && !nextRequest.ExpectedAsync { + m.err = fmt.Errorf("got unexpected async request") + return + } + if !async && nextRequest.ExpectedAsync { + m.err = fmt.Errorf("expected async request") + return + } + + if !strings.Contains(r.URL.Path, nextRequest.Endpoint) { + m.err = fmt.Errorf( + "expected request type %s, got request to %s", + nextRequest.Endpoint, + r.URL.Path, + ) + return + } + + if nextRequest.ExpectedRequest != nil { + request := make(map[string]any) + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + m.err = fmt.Errorf("decoding request: %v", err) + return + } + if diff := cmp.Diff( + toMap(nextRequest.ExpectedRequest), request); diff != "" { + m.err = fmt.Errorf("(-want, +got)\n%s", diff) + return + } + } + + if nextRequest.latency != 0 { + select { + case <-ctx.Done(): + case <-time.After(time.Second * time.Duration(nextRequest.latency)): + } + } + + statusCode, response := nextRequest.BuildResponse(m.URL) + + if statusCode == http.StatusMovedPermanently { + var redirect string + if strings.HasPrefix(nextRequest.redirectTo, "/") { + redirect = fmt.Sprintf("%s%s", m.URL, nextRequest.redirectTo) + } else { + redirect = nextRequest.redirectTo + } + w.Header().Set("Location", redirect) + } + w.WriteHeader(statusCode) + err := json.NewEncoder(w).Encode(response) + m.err = err + })) + + m.Server = s + + return m +} + +func toMap(x any) map[string]any { + b, _ := json.Marshal(x) + var m map[string]any + _ = json.Unmarshal(b, &m) + return m +} diff --git a/route/here/options.go b/route/here/options.go new file mode 100644 index 00000000..c55f3f50 --- /dev/null +++ b/route/here/options.go @@ -0,0 +1,127 @@ +package here + +import ( + "net/http" + "time" +) + +// ClientOption can pass options to be used with a HERE client. +type ClientOption func(*client) + +// MatrixOption is passed to functions on the Client that create matrices, +// configuring the HERE request the client will make. +type MatrixOption func(req *matrixRequest) + +// WithDepartureTime sets departure time to be used in the request. This will +// take traffic data into account for the given time. If no departure time is +// given, "any" will be used in the request and no traffic data is included, +// see official documentation for HERE matrix routing, concepts traffic. +func WithDepartureTime(t time.Time) MatrixOption { + return func(req *matrixRequest) { + depTime := "any" + if !t.IsZero() { + depTime = t.Format(time.RFC3339) + } + req.DepartureTime = depTime + } +} + +// WithTransportMode sets the transport mode for the request. +func WithTransportMode(mode TransportMode) MatrixOption { + return func(req *matrixRequest) { + req.TransportMode = mode + } +} + +// WithAvoidFeatures sets features that will be avoided in the calculated +// routes. +func WithAvoidFeatures(features []Feature) MatrixOption { + return func(req *matrixRequest) { + featureStrs := make([]string, len(features)) + for i, f := range features { + featureStrs[i] = string(f) + } + + if req.Avoid == nil { + req.Avoid = &avoid{ + Features: features, + } + } else { + req.Avoid.Features = features + } + } +} + +// WithAvoidAreas sets bounding boxes that will be avoided in the calculated +// routes. +func WithAvoidAreas(areas []BoundingBox) MatrixOption { + return func(req *matrixRequest) { + as := make([]area, len(areas)) + for i, a := range areas { + as[i] = area{ + Type: "boundingBox", + West: a.West, + South: a.South, + East: a.East, + North: a.North, + } + } + if req.Avoid == nil { + req.Avoid = &avoid{ + Areas: as, + } + } else { + req.Avoid.Areas = append(req.Avoid.Areas, as...) + } + } +} + +// WithTruckProfile sets a Truck profile on the matrix request. The following +// attributes are required by HERE: +// * TunnelCategory: if this is an empty string, the Client will automatically +// set it to TunnelCategoryNone +// * Type +// * AxleCount. +func WithTruckProfile(t Truck) MatrixOption { + return func(req *matrixRequest) { + if t.TunnelCategory == "" { + t.TunnelCategory = TunnelCategoryNone + } + req.Truck = &t + } +} + +// WithScooterProfile sets a Scooter profile on the request. +func WithScooterProfile(scooter Scooter) MatrixOption { + return func(req *matrixRequest) { + req.Scooter = &scooter + } +} + +// WithTaxiProfile sets a Taxi profile on the request. +func WithTaxiProfile(taxi Taxi) MatrixOption { + return func(req *matrixRequest) { + req.Taxi = &taxi + } +} + +// WithClientTransport overwrites the RoundTripper used by the internal +// http.Client. +func WithClientTransport(rt http.RoundTripper) ClientOption { + if rt == nil { + rt = http.DefaultTransport + } + + return func(c *client) { + c.httpClient.Transport = rt + } +} + +// WithDenyRedirectPolicy block redirected requests to specified hostnames. +// Matches hostname greedily e.g. google.com will match api.google.com, +// file.api.google.com, ... +func WithDenyRedirectPolicy(hostnames ...string) ClientOption { + return func(c *client) { + c.denyRedirectedRequests = hostnames + } +} diff --git a/route/here/schema.go b/route/here/schema.go new file mode 100644 index 00000000..98caff77 --- /dev/null +++ b/route/here/schema.go @@ -0,0 +1,245 @@ +package here + +import ( + "encoding/json" +) + +type validationError struct { + message string +} + +func (e validationError) Error() string { + return e.message +} + +type responseStatus string + +const ( + responseStatusComplete responseStatus = "completed" + responseStatusAccepted responseStatus = "accepted" + responseStatusInProgress responseStatus = "inProgress" +) + +func isKnownStatusResponse(status responseStatus) bool { + return status == responseStatusComplete || + status == responseStatusAccepted || + status == responseStatusInProgress +} + +type statusResponse struct { + MatrixID string `json:"matrixId"` //nolint:tagliatelle + Status responseStatus `json:"status"` + StatusURL string `json:"statusUrl"` //nolint:tagliatelle + ResultURL string `json:"resultUrl"` //nolint:tagliatelle + Error json.RawMessage `json:"error"` +} + +type matrixResponse struct { + Matrix matrix `json:"matrix"` + RegionDefinition regionDefinition `json:"regionDefinition"` //nolint:tagliatelle,lll +} + +type matrix struct { + NumOrigins int `json:"numOrigins"` //nolint:tagliatelle + NumDestinations int `json:"numDestinations"` //nolint:tagliatelle + TravelTimes []int `json:"travelTimes"` //nolint:tagliatelle + Distances []int `json:"distances"` +} + +type regionDefinition struct { + Type string `json:"type"` +} + +type point struct { + Lat float64 `json:"lat"` + Lon float64 `json:"lng"` //nolint:tagliatelle +} + +type matrixRequest struct { + Origins []point `json:"origins"` + RegionDefinition regionDefinition `json:"regionDefinition,omitempty"` //nolint:tagliatelle,lll + // This is either an RFC 3339 timestamp or the string "any" + DepartureTime string `json:"departureTime,omitempty"` //nolint:tagliatelle,lll + MatrixAttributes []string `json:"matrixAttributes"` //nolint:tagliatelle,lll + TransportMode TransportMode `json:"transportMode,omitempty"` //nolint:tagliatelle,lll + Avoid *avoid `json:"avoid,omitempty"` + Truck *Truck `json:"truck,omitempty"` + Scooter *Scooter `json:"scooter,omitempty"` + Taxi *Taxi `json:"taxi,omitempty"` +} + +type avoid struct { + // Features string `json:"features,omitempty"` + Features []Feature `json:"features,omitempty"` + Areas []area `json:"areas,omitempty"` +} + +type area struct { + Type string `json:"type"` + North float64 `json:"north"` + South float64 `json:"south"` + East float64 `json:"east"` + West float64 `json:"west"` +} + +// BoundingBox represents a region using four cooordinates corresponding +// to the furthest points in each of the cardinal directions within that region. +type BoundingBox struct { + North float64 + South float64 + East float64 + West float64 +} + +// TransportMode represents the type of vehicle that will be used for +// the calculated routes. +type TransportMode string + +// TransportModeCar causes routes to be calculated for car travel. +const TransportModeCar TransportMode = "car" + +// TransportModeTruck causes routes to be calculated for truck travel. +const TransportModeTruck TransportMode = "truck" + +// TransportModePedestrian causes routes to be calculated for pedestrian travel. +const TransportModePedestrian TransportMode = "pedestrian" + +// TransportModeBicycle causes routes to be calculated for bicycle travel. +const TransportModeBicycle TransportMode = "bicycle" + +// TransportModeTaxi causes routes to be calculated for taxi travel. +const TransportModeTaxi TransportMode = "taxi" + +// TransportModeScooter causes routes to be calculated for scooter travel. +const TransportModeScooter TransportMode = "scooter" + +// Feature represents a geographical feature. +type Feature string + +// TollRoad designates a toll road feature. +const TollRoad Feature = "tollRoad" + +// ControlledAccessHighway designates a controlled access highway. +const ControlledAccessHighway Feature = "controlledAccessHighway" + +// Ferry designates a ferry route. +const Ferry Feature = "ferry" + +// Tunnel designates a tunnel. +const Tunnel Feature = "tunnel" + +// DirtRoad designates a dirt road. +const DirtRoad Feature = "dirtRoad" + +// SeasonalClosure designates a route that is closed for the season. +const SeasonalClosure Feature = "seasonalClosure" + +// CarShuttleTrain designates a train that can transport cars. +const CarShuttleTrain Feature = "carShuttleTrain" + +// DifficultTurns represents u-turns, difficult turns, and sharp turns. +const DifficultTurns Feature = "difficultTurns" + +// UTurns designates u-turns. +const UTurns Feature = "uTurns" + +// Truck captures truck-specific routing parameters. +type Truck struct { + ShippedHazardousGoods []HazardousGood `json:"shippedHazardousGoods,omitempty"` //nolint:tagliatelle,lll + // in kilograms + GrossWeight int32 `json:"grossWeight,omitempty"` //nolint:tagliatelle + // in kilograms + WeightPerAxle int32 `json:"weightPerAxle,omitempty"` //nolint:tagliatelle + // in centimeters + Height int32 `json:"height,omitempty"` + // in centimeters + Width int32 `json:"width,omitempty"` + // in centimeters + Length int32 `json:"length,omitempty"` + TunnelCategory TunnelCategory `json:"tunnelCategory,omitempty"` //nolint:tagliatelle,lll + AxleCount int32 `json:"axleCount,omitempty"` //nolint:tagliatelle,lll + Type TruckType `json:"type,omitempty"` + TrailerCount int32 `json:"trailerCount,omitempty"` //nolint:tagliatelle,lll + WeightPerAxleGroup *WeightPerAxleGroup `json:"weightPerAxleGroup,omitempty"` //nolint:tagliatelle,lll +} + +// WeightPerAxleGroup captures the weights of different axle groups. +type WeightPerAxleGroup struct { + Single int32 `json:"single"` + Tandem int32 `json:"tandem"` + Triple int32 `json:"triple"` +} + +// TunnelCategory is a tunnel category used to restrict the transport of +// certain goods. +type TunnelCategory string + +// TunnelCategoryB represents tunnels with B category restrictions. +const TunnelCategoryB TunnelCategory = "B" + +// TunnelCategoryC represents tunnels with C category restrictions. +const TunnelCategoryC TunnelCategory = "C" + +// TunnelCategoryD represents a tunnel with D category restrictions. +const TunnelCategoryD TunnelCategory = "D" + +// TunnelCategoryE represents a tunnel with E category restrictions. +const TunnelCategoryE TunnelCategory = "E" + +// TunnelCategoryNone represents a tunnel with no category restrictions. +const TunnelCategoryNone TunnelCategory = "None" + +// TruckType specifies the type of truck. +type TruckType string + +// TruckTypeStraight refers to trucks with a permanently attached cargo area. +const TruckTypeStraight TruckType = "straight" + +// TruckTypeTractor refers to vehicles that can tow one or more semi-trailers. +const TruckTypeTractor TruckType = "tractor" + +// HazardousGood indicates a hazardous good that trucks can transport. +type HazardousGood string + +// Explosive represents explosive materials. +const Explosive HazardousGood = "explosive" + +// Gas designates gas. +const Gas HazardousGood = "gas" + +// Flammable designates flammable materials. +const Flammable HazardousGood = "flammable" + +// Combustible designates combustible materials. +const Combustible HazardousGood = "combustible" + +// Organic designates organical materials. +const Organic HazardousGood = "organic" + +// Poison designates poison. +const Poison HazardousGood = "poison" + +// Radioactive indicates radioactive materials. +const Radioactive HazardousGood = "radioactive" + +// Corrosive indicates corrosive materials. +const Corrosive HazardousGood = "corrosive" + +// PoisonousInhalation refers to materials that are poisonous to inhale. +const PoisonousInhalation HazardousGood = "poisonousInhalation" + +// HarmfulToWater indicates materials that are harmful to water. +const HarmfulToWater HazardousGood = "harmfulToWater" + +// OtherHazardousGood refers to other types of hazardous materials. +const OtherHazardousGood HazardousGood = "other" + +// Scooter captures routing parameters that can be set on scooters. +type Scooter struct { + AllowHighway bool `json:"allowHighway"` //nolint:tagliatelle +} + +// Taxi captures routing parameters that can be set on taxis. +type Taxi struct { + AllowDriveThroughTaxiRoads bool `json:"allowDriveThroughTaxiRoads"` //nolint:tagliatelle,lll +} diff --git a/route/load.go b/route/load.go new file mode 100644 index 00000000..5be802f2 --- /dev/null +++ b/route/load.go @@ -0,0 +1,148 @@ +package route + +import ( + "encoding/json" + "errors" + "fmt" +) + +// ByPointLoader can be embedded in schema structs and unmarshals a ByPoint JSON +// object into the appropriate implementation. +type ByPointLoader struct { + byPoint ByPoint +} + +type pointType string + +const ( + typeScale pointType = "scale" + typeEuclidean pointType = "euclidean" + typeHaversine pointType = "haversine" + typeTaxicab pointType = "taxicab" + typeConstant pointType = "constant" +) + +type byPointJSON struct { + ByPoint *ByPointLoader `json:"measure"` + Type pointType `json:"type"` + Scale float64 `json:"scale"` + Constant float64 `json:"constant"` +} + +// MarshalJSON returns the JSON representation for the underlying ByPoint. +func (l ByPointLoader) MarshalJSON() ([]byte, error) { + return json.Marshal(l.byPoint) +} + +// UnmarshalJSON converts the bytes into the appropriate implementation of +// ByPoint. +func (l *ByPointLoader) UnmarshalJSON(b []byte) error { + var j byPointJSON + if err := json.Unmarshal(b, &j); err != nil { + return err + } + + switch j.Type { + case "": + return errors.New(`no "type" field in json input`) + case typeEuclidean: + l.byPoint = EuclideanByPoint() + case typeHaversine: + l.byPoint = HaversineByPoint() + case typeTaxicab: + l.byPoint = TaxicabByPoint() + case typeScale: + l.byPoint = ScaleByPoint(j.ByPoint.To(), j.Scale) + case typeConstant: + l.byPoint = ConstantByPoint(j.Constant) + default: + return fmt.Errorf(`invalid type "%s"`, j.Type) + } + return nil +} + +// To returns the underlying ByPoint. +func (l *ByPointLoader) To() ByPoint { + return l.byPoint +} + +// ByIndexLoader can be embedded in schema structs and unmarshals a ByIndex JSON +// object into the appropriate implementation. +type ByIndexLoader struct { + byIndex ByIndex +} + +// byIndexJSON includes the union of all fields that may appear on a ByIndex +// JSON object (like a C oneof). We unmarshal onto this data structure instead +// of onto a map[string]any for type safety and because this will allow +// recursive measures to be automatically unmarshalled. +type byIndexJSON struct { + ByIndex *ByIndexLoader `json:"measure"` + Arcs map[int]map[int]float64 `json:"arcs"` + Type string `json:"type"` + Measures []ByIndexLoader `json:"measures"` + Costs []float64 `json:"costs"` + Matrix [][]float64 `json:"matrix"` + Constant float64 `json:"constant"` + Scale float64 `json:"scale"` + Exponent float64 `json:"exponent"` + Lower float64 `json:"lower"` + Upper float64 `json:"upper"` +} + +// MarshalJSON returns the JSON representation for the underlying Byindex. +func (l ByIndexLoader) MarshalJSON() ([]byte, error) { + return json.Marshal(l.byIndex) +} + +// UnmarshalJSON converts the bytes into the appropriate implementation of +// ByIndex. +func (l *ByIndexLoader) UnmarshalJSON(b []byte) error { + var j byIndexJSON + if err := json.Unmarshal(b, &j); err != nil { + return err + } + + requiresByIndex := j.Type == "location" || + j.Type == "power" || + j.Type == "scale" || + j.Type == "sparse" || + j.Type == "truncate" + if requiresByIndex && j.ByIndex == nil { + return errors.New(`location measure must include a "by_index" field`) + } + + switch j.Type { + case "": + return errors.New(`no "type" field in json input`) + case "constant": + l.byIndex = Constant(j.Constant) + case "sum": + measures := make([]ByIndex, len(j.Measures)) + for i, l := range j.Measures { + measures[i] = l.To() + } + l.byIndex = Sum(measures...) + case "location": + l.byIndex, _ = Location(j.ByIndex.To(), j.Costs, nil) + case "matrix": + l.byIndex = Matrix(j.Matrix) + case "power": + l.byIndex = Power(j.ByIndex.To(), j.Exponent) + case "scale": + l.byIndex = Scale(j.ByIndex.To(), j.Scale) + case "sparse": + l.byIndex = Sparse(j.ByIndex.To(), j.Arcs) + case "truncate": + l.byIndex = Truncate(j.ByIndex.To(), j.Lower, j.Upper) + default: + return fmt.Errorf(`invalid type "%s"`, j.Type) + } + + return nil +} + +// To returns the underlying ByIndex. +func (l *ByIndexLoader) To() ByIndex { + return l.byIndex +} diff --git a/route/measure.go b/route/measure.go index fc721df8..322bfb34 100644 --- a/route/measure.go +++ b/route/measure.go @@ -119,6 +119,12 @@ func Scale(m ByIndex, constant float64) ByIndex { return scaleFunc(m, constant) } +// ScaleByPoint scales the cost of some other measure by a constant. +func ScaleByPoint(m ByPoint, constant float64) ByPoint { + connect.Connect(con, &scaleFunc) + return scaleByPointFunc(m, constant) +} + // ByClockwise implements sort.Interface for sorting points clockwise around a // central point. func ByClockwise(center Point, points []Point) sort.Interface { @@ -192,6 +198,7 @@ var ( debugOverrideFunc func(ByIndex, ByIndex, func(int, int) bool) ByIndex powerFunc func(ByIndex, float64) ByIndex scaleFunc func(ByIndex, float64) ByIndex + scaleByPointFunc func(ByPoint, float64) ByPoint byClockwiseFunc func(Point, []Point) sort.Interface lessClockwiseFunc func(Point, Point, Point) bool sparseFunc func(ByIndex, map[int]map[int]float64) ByIndex diff --git a/route/osrm/client.go b/route/osrm/client.go new file mode 100644 index 00000000..1dde077a --- /dev/null +++ b/route/osrm/client.go @@ -0,0 +1,611 @@ +package osrm + +import ( + "context" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + + lru "github.com/hashicorp/golang-lru" + "github.com/nextmv-io/sdk/route" + polyline "github.com/twpayne/go-polyline" +) + +// Endpoint defines the OSRM endpoint to be used. +type Endpoint string + +const ( + // TableEndpoint is used to retrieve distance and duration matrices. + TableEndpoint Endpoint = "table" + // RouteEndpoint is used to retrieve polylines for a set of points. + RouteEndpoint Endpoint = "route" +) + +// Client represents an OSRM client. +type Client interface { + // Table requests a distance and/or duration table from an OSRM server. + Table( + points []route.Point, + opts ...TableOptions, + ) ( + distance, duration [][]float64, + err error, + ) + // Get performs a GET against the OSRM server returning the response + // body and an error. + Get(uri string) ([]byte, error) + // SnapRadius limits snapping a point to the street network to given radius + // in meters. + // Setting the snap radius to a value = 0 results in an unlimited snapping + // radius. + SnapRadius(radius int) error + // ScaleFactor is used in conjunction with duration calculations. Scales the + // table duration values by this number. This does not affect distances. + ScaleFactor(factor float64) error + + // MaxTableSize should be configured with the same value as the OSRM + // server's max-table-size setting, default is 100 + MaxTableSize(size int) error + + // Polyline requests polylines for the given points. The first parameter + // returns a polyline from start to end and the second parameter returns a + // list of polylines, one per leg. + Polyline(points []route.Point) (string, []string, error) +} + +// NewClient returns a new OSRM Client. +func NewClient(host string, opts ...ClientOption) Client { + c := &client{ + host: host, + httpClient: http.DefaultClient, + snapRadius: 1000, + maxTableSize: 100, + scaleFactor: 1.0, + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +// DefaultClient creates a new OSRM Client. +func DefaultClient(host string, useCache bool) Client { + opts := []ClientOption{} + if useCache { + opts = append(opts, WithCache(100)) + } + c := NewClient(host, opts...) + + return c +} + +// A client makes requests to an OSRM server. +type client struct { + httpClient *http.Client + cache *lru.Cache + host string + snapRadius int + scaleFactor float64 + maxTableSize int + useCache bool +} + +func (c *client) SnapRadius(radius int) error { + if radius < 0 { + return errors.New("radius must be >= 0") + } + c.snapRadius = radius + return nil +} + +func (c *client) MaxTableSize(size int) error { + if size < 1 { + return errors.New("max table size must be > 0") + } + c.maxTableSize = size + return nil +} + +func (c *client) ScaleFactor(factor float64) error { + if factor <= 0 { + return errors.New("scale factor must be > 0") + } + c.scaleFactor = factor + return nil +} + +// get performs a GET. +func (c *client) get(uri string) (data []byte, err error) { + var key string + + if c.useCache { + key = fmt.Sprintf("%x", sha1.Sum([]byte(uri))) + if result, ok := c.cache.Get(key); ok { + if b, ok := result.([]byte); ok { + return b, err + } + } + } + + // convert host to URL + h, err := url.Parse(c.host) + if err != nil { + return data, err + } + + // convert uri to URL + u, err := url.Parse(uri) + if err != nil { + return data, err + } + + // safely join host and uri + // http://example.com/foo + u = h.ResolveReference(u) + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodGet, u.String(), nil, + ) + if err != nil { + return data, err + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return data, err + } + + data, err = io.ReadAll(resp.Body) + if err != nil { + _ = resp.Body.Close() + return data, err + } + + if c.useCache { + c.cache.Add(key, data) + } + + err = resp.Body.Close() + return data, err +} + +func (c *client) Get(uri string) ([]byte, error) { + return c.get(uri) +} + +func (c *client) Table(points []route.Point, opts ...TableOptions) ( + distances, durations [][]float64, + err error, +) { + cfg := &tableConfig{ + parallelRuns: 16, + } + + for _, opt := range opts { + opt(cfg) + } + + // Creates paths with sources to make requests "by row". + requests, err := c.tableRequests(cfg, points) + if err != nil { + return nil, nil, err + } + // Run parallel requests. + out := make(chan result, len(requests)) + defer close(out) + guard := make(chan struct{}, cfg.parallelRuns) + defer close(guard) + + for _, req := range requests { + go func(req request) { + defer func() { <-guard }() + guard <- struct{}{} // would block if guard channel is already filled + body, err := c.get(req.path) + if err != nil { + out <- result{res: nil, err: err} + } + + var tableResp tableResponse + if err := json.Unmarshal(body, &tableResp); err != nil { + out <- result{res: nil, err: err} + } + + if c := tableResp.Code; c != "Ok" { + fmtString := `expected "Ok" response code; got %q (%q)` + out <- result{ + res: nil, + err: fmt.Errorf(fmtString, c, tableResp.Message), + } + } + tableResp.row = req.row + tableResp.column = req.column + out <- result{res: &tableResp, err: nil} + }(req) + } + + // Empty chan to a list of responses. + var responses []tableResponse + for i := 0; i < len(requests); i++ { + r := <-out + if r.err != nil { + return nil, nil, r.err + } + responses = append(responses, *r.res) + } + + // Stitch responses together. + routeResp := mergeRequests(responses) + + return routeResp.Distances, routeResp.Durations, nil +} + +var unroutablePoint = route.Point{-143.292892, 37.683603} + +func (c *client) tableRequests( //nolint:gocyclo + config *tableConfig, + points []route.Point, +) ([]request, error) { + // Turn points slice into OSRM-friendly semicolon-delimited point pairs + // []{{1,2}, {3,4}} => "1,2;3,4" + convertedPoints := make([][]float64, len(points)) + for i, point := range handleUnroutablePoints(points) { + convertedPoints[i] = []float64{ + point[1], point[0], + } + } + pointChunks := chunkBy(convertedPoints, c.maxTableSize) + requests := make([]request, 0) + for p1, pointChunk1 := range pointChunks { + for p2, pointChunk2 := range pointChunks { + resultingChunk := make([][]float64, len(pointChunk1)+len(pointChunk2)) + copy(resultingChunk, pointChunk1) + copy(resultingChunk[len(pointChunk1):], pointChunk2) + pointsParameter := polyline.EncodeCoords(resultingChunk) + path, err := getPath(TableEndpoint, "polyline("+string(pointsParameter)+")") + if err != nil { + return nil, err + } + + annotations := []string{} + if config.withDuration { + annotations = append(annotations, "duration") + } + + if config.withDistance { + annotations = append(annotations, "distance") + } + + // The OSRM server will error when annotations are properly escaped, making + // url.Values{} nonviable + if len(annotations) >= 1 { + path += "?annotations=" + path += strings.Join(annotations, ",") + } + + // Set scale factor. This only has an effect on durations. + if c.scaleFactor != 1.0 { + path += fmt.Sprintf("&scale_factor=%f", c.scaleFactor) + } + + if c.snapRadius > 0 { + // Set snap radius for points + path += "&radiuses=" + radiuses := make([]string, len(resultingChunk)) + for i := 0; i < len(radiuses); i++ { + radiuses[i] = strconv.Itoa(c.snapRadius) + } + path += strings.Join(radiuses, ";") + } + + indices := make([]string, len(resultingChunk)) + for i := 0; i < len(indices); i++ { + indices[i] = strconv.Itoa(i) + } + + requests = append(requests, + request{ + row: p1, + column: p2, + path: path + + "&sources=" + strings.Join(indices[:len(pointChunk1)], ";") + + "&destinations=" + strings.Join(indices[len(pointChunk1):], ";"), + }, + ) + } + } + return requests, nil +} + +// result gathers a response and possible error from concurrent requests. +type result struct { + res *tableResponse + err error +} + +// request holds a request and the request index for later stitching. +type request struct { + path string + row int + column int +} + +// tableResponse holds the tableResponse from the OSRM server. +type tableResponse struct { + Code string `json:"code"` + Message string `json:"message"` + Distances [][]float64 `json:"distances"` + Durations [][]float64 `json:"durations"` + row int + column int +} + +// TableOptions is a function that configures a tableConfig. +type TableOptions func(*tableConfig) + +// tableConfig defines options for the table configuration. +type tableConfig struct { + withDistance bool + withDuration bool + parallelRuns int +} + +// WithDuration returns a TableOptions function for composing a tableConfig with +// duration data enabled, telling the OSRM server to include duration data in +// the response table data. +func WithDuration() TableOptions { + return func(c *tableConfig) { + c.withDuration = true + } +} + +// WithDistance returns a TableOptions function for composing a tableConfig with +// distance data enabled, telling the OSRM server to include distance data in +// the response table data. +func WithDistance() TableOptions { + return func(c *tableConfig) { + c.withDistance = true + } +} + +// ClientOption can pass options to be used with an OSRM client. +type ClientOption func(*client) + +// WithClientTransport overwrites the RoundTripper used by the internal +// http.Client. +func WithClientTransport(rt http.RoundTripper) ClientOption { + if rt == nil { + rt = http.DefaultTransport + } + + return func(c *client) { + c.httpClient.Transport = rt + } +} + +// WithCache configures the maximum number of results cached. +func WithCache(maxItems int) ClientOption { + return func(c *client) { + c.useCache = true + + cache, _ := lru.New(maxItems) + c.cache = cache + } +} + +// ParallelRuns set the number of parallel calls to the OSRM server. If 0 is +// passed, the default value of 16 will be used. +func ParallelRuns(runs int) TableOptions { + return func(c *tableConfig) { + if runs > 0 { + c.parallelRuns = runs + } + } +} + +// Creates the points parameters for an OSRM request. +func pointsParameters(points []route.Point) []string { + // Turn points slice into OSRM-friendly semicolon-delimited point pairs + // []{{1,2}, {3,4}} => "1,2;3,4" + pointStrings := []string{} + points = handleUnroutablePoints(points) + for _, point := range points { + pointStrings = append(pointStrings, fmt.Sprintf("%f,%f", point[0], point[1])) + } + return pointStrings +} + +// sets nil values to an unroutable point. +func handleUnroutablePoints(in []route.Point) (out []route.Point) { + out = make([]route.Point, len(in)) + for i, point := range in { + if len(point) == 2 { + out[i] = in[i] + } else { + out[i] = unroutablePoint + } + } + return out +} + +// Creates the points parameter for an OSRM request. +func pointsParameter(points []route.Point) string { + return strings.Join(pointsParameters(points), ";") +} + +// RouteResponse holds the route response from the OSRM server. +type RouteResponse struct { + Code string `json:"code"` + Routes []Route `json:"routes"` + Message string `json:"message"` +} + +// Route partially represents the OSRM Route object. +type Route struct { + Geometry string `json:"geometry"` + Legs []Leg `json:"legs"` +} + +// Leg partially represents the OSRM Leg object. +type Leg struct { + Steps []Step `json:"steps"` +} + +// Step partially represents the OSRM Step object. +type Step struct { + Geometry string `json:"geometry"` +} + +// Creates polylines for the given points. First return parameter is a polyline +// from start to end, second parameter is a list of polylines per leg in the +// route. +func (c *client) Polyline(points []route.Point) (string, []string, error) { + // Turn points slice into OSRM-friendly semicolon-delimited point pairs + // []{{1,2}, {3,4}} => "1,2;3,4" + pointsParameter := pointsParameter(points) + + path, err := getPath(RouteEndpoint, pointsParameter) + if err != nil { + return "", []string{}, err + } + + // Get the simplified overview and single steps but no verbose annotations. + path += "?overview=simplified&steps=true&annotations=false" + + "&continue_straight=false" + + body, err := c.get(path) + if err != nil { + return "", []string{}, err + } + + var routeResp RouteResponse + if err := json.Unmarshal(body, &routeResp); err != nil { + return "", []string{}, err + } + + if routeResp.Code != "Ok" { + return "", []string{}, fmt.Errorf( + `expected "Ok" response code; got %q (%q)`, + routeResp.Code, + routeResp.Message, + ) + } + + // The fist route is the calculated route. Other routes are alternative + // routes that can be calculated but are not calculated in our case. + route := routeResp.Routes[0] + + decodedLegs := make([][][]float64, len(points)-1) + + // Loop over every step in every leg and stich the decoded steps together. + for i, leg := range route.Legs { + for _, steps := range leg.Steps { + buf := []byte(steps.Geometry) + coords, _, err := polyline.DecodeCoords(buf) + if err != nil { + return "", []string{}, err + } + decodedLegs[i] = append(decodedLegs[i], coords...) + } + } + + legs := make([]string, len(points)-1) + for i, leg := range decodedLegs { + legs[i] = string(polyline.EncodeCoords(leg)) + } + + return route.Geometry, legs, nil +} + +// Creates the path to the given endpoint including the given points. +func getPath(endpoint Endpoint, pointsParameter string) (string, error) { + u, err := url.Parse(fmt.Sprintf("/%s/v1/driving/", string(endpoint))) + if err != nil { + return "", err + } + + pointsURL, err := url.Parse(pointsParameter) + if err != nil { + return "", err + } + + u = u.ResolveReference(pointsURL) + return u.String(), nil +} + +// chunkBy converts a slice of things into smaller slices of a given max size. +func chunkBy[T any](items []T, chunkSize int) (chunks [][]T) { + chunks = make([][]T, 0, (len(items)/chunkSize)+1) + for chunkSize < len(items) { + items, chunks = items[chunkSize:], + append(chunks, items[0:chunkSize:chunkSize]) + } + return append(chunks, items) +} + +// mergeRequests stitches the given responses (and their matrices) together. The +// input responses can be in arbitrary order, but will be overwritten in the +// process. +func mergeRequests(responses []tableResponse) tableResponse { + // Sort the responses by index, that orders the row packs correctly. + sort.Slice(responses, func(i, j int) bool { + if responses[i].row == responses[j].row { + return responses[i].column < responses[j].column + } + return responses[i].row < responses[j].row + }) + + // --> Stitch distance matrices together + // Expects submatrices of the following structure: + // a a b b c + // a a b b c + // d d e e f + // d d e e f + // g g h h i + // The submatrices A, B, C, D, E, F of size 2x2 or less (at the edges) are + // merged into a single matrix of size 5x5. + // Furthermore, distance and duration matrices will be handled separately, + // since one of them may be empty. + + // Start with the first submatrix. + merged := responses[0] + subRow := 0 + disRows, disIndex := 0, 0 + durRows, durIndex := 0, 0 + for _, res := range responses[1:] { + if res.row != subRow { + // On row changes, we simply append the rows of the current leftmost + // submatrix to the merged matrix. + subRow++ + disIndex += disRows + durIndex += durRows + merged.Distances = append(merged.Distances, res.Distances...) + merged.Durations = append(merged.Durations, res.Durations...) + } else { + // On row stays, we append the columns of all rows individually. + for i := 0; i < len(res.Distances); i++ { + merged.Distances[disIndex+i] = append( + merged.Distances[disIndex+i], res.Distances[i]..., + ) + } + for i := 0; i < len(res.Durations); i++ { + merged.Durations[durIndex+i] = append( + merged.Durations[durIndex+i], res.Durations[i]..., + ) + } + disRows = len(res.Distances) + durRows = len(res.Durations) + } + } + + return merged +} diff --git a/route/osrm/client_test.go b/route/osrm/client_test.go new file mode 100644 index 00000000..c1216f9d --- /dev/null +++ b/route/osrm/client_test.go @@ -0,0 +1,182 @@ +package osrm_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/nextmv-io/sdk/route" + "github.com/nextmv-io/sdk/route/osrm" +) + +type testServer struct { + s *httptest.Server + reqCount int +} + +func newTestServer(t *testing.T, endpoint osrm.Endpoint) *testServer { + ts := &testServer{} + responseOk := "" + if endpoint == osrm.TableEndpoint { + responseOk = tableResponseOK + } else { + responseObject := osrm.RouteResponse{ + Code: "Ok", + Routes: []osrm.Route{ + { + Geometry: "mfp_I__vpAqJ`@wUrCa\\dCgGig@{DwW", + Legs: []osrm.Leg{ + { + Steps: []osrm.Step{ + {Geometry: "mfp_I__vpAWBQ@K@[BuBRgBLK@UBMMC?AA" + + "KAe@FyBTC@E?IDKDA@K@]BUBSBA?E@E@A@KFUBK@mA" + + "L{CZQ@qBRUBmAFc@@}@Fu@DG?a@B[@qAF}@JA?[D_" + + "E`@SBO@ODA@UDA?]JC?uBNE?OAKA"}, + {Geometry: "yer_IcuupACa@AI]mCCUE[AK[iCWqB[{Bk" + + "@sE_@_DAICSAOIm@AIQuACOQyAG[Gc@]wBw@aFKu@" + + "y@oFCMAOIm@?K"}, + {Geometry: "}sr_IevwpA"}, + }, + }, + }, + }, + }, + } + resp, err := json.Marshal(responseObject) + if err != nil { + t.Errorf("could not marshal response object, %v", err) + } + responseOk = string(resp) + } + ts.s = httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + ts.reqCount++ + _, err := io.WriteString(w, responseOk) + if err != nil { + t.Errorf("could not write resp: %v", err) + } + }), + ) + return ts +} + +func TestCacheHit(t *testing.T) { + ts := newTestServer(t, osrm.TableEndpoint) + defer ts.s.Close() + + c := osrm.DefaultClient(ts.s.URL, true) + + _, err := c.Get(ts.s.URL) + if err != nil { + t.Errorf("get failed: %v", err) + } + + _, err = c.Get(ts.s.URL) + if err != nil { + t.Errorf("get failed: %v", err) + } + + _, err = c.Get(ts.s.URL) + if err != nil { + t.Errorf("get failed: %v", err) + } + + if ts.reqCount != 1 { + t.Errorf("want: 1; got: %v", ts.reqCount) + } +} + +func TestCacheMiss(t *testing.T) { + ts := newTestServer(t, osrm.TableEndpoint) + defer ts.s.Close() + + c := osrm.DefaultClient(ts.s.URL, false) + _, err := c.Get(ts.s.URL) + if err != nil { + t.Errorf("get failed: %v", err) + } + + _, err = c.Get(ts.s.URL) + if err != nil { + t.Errorf("get failed: %v", err) + } + + _, err = c.Get(ts.s.URL) + if err != nil { + t.Errorf("get failed: %v", err) + } + + if ts.reqCount != 3 { + t.Errorf("want: 3; got: %v", ts.reqCount) + } +} + +func TestMatrixCall(t *testing.T) { + ts := newTestServer(t, osrm.TableEndpoint) + defer ts.s.Close() + + c := osrm.DefaultClient(ts.s.URL, true) + m, err := osrm.DurationMatrix(c, []route.Point{{0, 0}, {1, 1}}, 0) + if err != nil { + t.Fatalf("request error: %v", err) + } + if v := m.Cost(0, 1); v != 17699.1 { + t.Errorf("want: 0; got: %v", v) + } +} + +func TestPolylineCall(t *testing.T) { + ts := newTestServer(t, osrm.RouteEndpoint) + defer ts.s.Close() + + c := osrm.DefaultClient(ts.s.URL, true) + + polyline, polyLegs, err := osrm.Polyline( + c, + []route.Point{ + {13.388860, 52.517037}, + {13.397634, 52.529407}, + }, + ) + if err != nil { + t.Fatalf("request error: %v", err) + } + println(polyline) + println(polyLegs) +} + +const tableResponseOK = `{ + "code": "Ok", + "sources": [{ + "hint": "", + "distance": 9.215349, + "name": "", + "location": [-105.050583, 39.762548] + }, { + "hint": "", + "distance": 11740767.450958, + "name": "Prairie Hill Road", + "location": [-104.095128, 38.21453] + }], + "destinations": [{ + "hint": "", + "distance": 9.215349, + "name": "", + "location": [-105.050583, 39.762548] + }, { + "hint": "", + "distance": 11740767.450958, + "name": "Prairie Hill Road", + "location": [-104.095128, 38.21453] + }], + "durations": [ + [0, 17699.1], + [17732.3, 0] + ], + "distances": [ + [0, 245976.4], + [245938.6, 0] + ] +}` diff --git a/route/osrm/doc.go b/route/osrm/doc.go new file mode 100644 index 00000000..9bbcb9f1 --- /dev/null +++ b/route/osrm/doc.go @@ -0,0 +1,15 @@ +// Package osrm provides a client for measuring distances and durations. +// +// An OSRM client requests distance and duration data from an OSRM server. It +// makes requests to construct a matrix measurer. +// +// client := osrm.DefaultClient("http://localhost:5000", true) +// +// The client can construct a distance matrix, a duration matrix, or both. +// +// dist := osrm.DistanceMatrix(client, points, 0) +// dur := osrm.DurationMatrix(client, points, 0) +// dist, dur, err := osrm.DistanceDurationMatrices(client, points, 0) +// +// These measures implement route.ByIndex. +package osrm diff --git a/route/osrm/go.mod b/route/osrm/go.mod new file mode 100644 index 00000000..c6ca81b2 --- /dev/null +++ b/route/osrm/go.mod @@ -0,0 +1,13 @@ +module github.com/nextmv-io/sdk/route/osrm + +go 1.19 + +replace github.com/nextmv-io/sdk => ../../. + +require ( + github.com/golang/mock v1.6.0 + github.com/hashicorp/golang-lru v0.5.4 + github.com/nextmv-io/sdk v0.0.0 +) + +require github.com/twpayne/go-polyline v1.1.1 diff --git a/route/osrm/go.sum b/route/osrm/go.sum new file mode 100644 index 00000000..5858395d --- /dev/null +++ b/route/osrm/go.sum @@ -0,0 +1,44 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dvyukov/go-fuzz v0.0.0-20200318091601-be3528f3a813/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/twpayne/go-polyline v1.1.1 h1:/tSF1BR7rN4HWj4XKqvRUNrCiYVMCvywxTFVofvDV0w= +github.com/twpayne/go-polyline v1.1.1/go.mod h1:ybd9IWWivW/rlXPXuuckeKUyF3yrIim+iqA7kSl4NFY= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/route/osrm/matrix.go b/route/osrm/matrix.go new file mode 100644 index 00000000..60daf83a --- /dev/null +++ b/route/osrm/matrix.go @@ -0,0 +1,75 @@ +package osrm + +import ( + "fmt" + + "github.com/nextmv-io/sdk/route" +) + +func overrideZeroes(m route.ByIndex, points []route.Point) route.ByIndex { + return route.Override(m, route.Constant(0.0), func(i, j int) bool { + return len(points[i]) == 0 || len(points[j]) == 0 + }) +} + +// DistanceMatrix makes a request for a distance table from an OSRM server and +// returns a Matrix. ParallelQueries specifies the number of +// parallel queries to be made, pass 0 to calculate a default, based on the +// number of points given. +func DistanceMatrix( + c Client, points []route.Point, + parallelQueries int, +) (route.ByIndex, error) { + p1, _, err := c.Table(points, WithDistance(), ParallelRuns(parallelQueries)) + if err != nil { + return nil, fmt.Errorf("fetching matrix: %v", err) + } + + return overrideZeroes(route.Matrix(p1), points), nil +} + +// DurationMatrix makes a request for a duration table from an OSRM server and +// returns a Matrix. ParallelQueries specifies the number of +// parallel queries to be made, pass 0 to calculate a default, based on the +// number of points given. +func DurationMatrix( + c Client, points []route.Point, + parallelQueries int, +) (route.ByIndex, error) { + _, p2, err := c.Table(points, WithDuration(), ParallelRuns(parallelQueries)) + if err != nil { + return nil, fmt.Errorf("fetching matrix: %v", err) + } + + return overrideZeroes(route.Matrix(p2), points), nil +} + +// DistanceDurationMatrices fetches a distance and duration table from an OSRM +// server and returns a Matrix of each. ParallelQueries specifies the number of +// parallel queries to be made, pass 0 to calculate a default, based on the +// number of points given. +func DistanceDurationMatrices( + c Client, + points []route.Point, + parallelQueries int, +) ( + distance, duration route.ByIndex, + err error, +) { + p1, p2, err := c.Table( + points, + WithDistance(), + WithDuration(), + ParallelRuns(parallelQueries), + ) + if err != nil { + return nil, nil, fmt.Errorf("fetching matrices: %v", err) + } + + return overrideZeroes( + route.Matrix(p1), + points), + overrideZeroes(route.Matrix(p2), + points, + ), nil +} diff --git a/route/osrm/matrix_test.go b/route/osrm/matrix_test.go new file mode 100644 index 00000000..16752b8b --- /dev/null +++ b/route/osrm/matrix_test.go @@ -0,0 +1,453 @@ +package osrm_test + +import ( + "encoding/json" + "math/rand" + "net/http" + "net/http/httptest" + "os" + "reflect" + "testing" + + "github.com/nextmv-io/sdk/route" + "github.com/nextmv-io/sdk/route/osrm" +) + +var expectedDistances = [3][3]float64{ + {0, 10283.9, 8160.5}, + {10323.8, 0, 1931.5}, + {8441.5, 1378, 0}, +} + +var expectedDurations = [3][3]float64{ + {0, 781.1, 767.1}, + {804.9, 0, 187.2}, + {793.5, 119.5, 0}, +} + +var expectedDurationsUae = [3][3]float64{ + {0, 1111.7, 1154.6}, + {1295.4, 0, 761.1}, + {1167.7, 505.1, 0}, +} + +var expectedDurationsScaledBy2 = [3][3]float64{ + {0, 1562.2, 1534.2}, + {1609.8, 0, 374.4}, + {1587.0, 239, 0}, +} + +var p = []route.Point{ + {-105.050583, 39.762631}, + {-104.983978, 39.711413}, + {-104.983978, 39.721413}, +} + +var oceanPoints = []route.Point{ + {-42.79808862899699, 28.670649170472345}, + {-41.847832598419565, 17.13854940278748}, +} + +const ( + hostEnv = "ENGINES_TEST_OSRM_HOST" + hostEnvNotSetMsg = "skipping because " + hostEnv + " is not set" +) + +func newMockOSRM( + distances [][]float64, + durations [][]float64, +) *httptest.Server { + return httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "code": "Ok", + "distances": distances, + "durations": durations, + "message": "Everything worked", + }) + }), + ) +} + +func TestDistanceMatrixWithZeroPoints(t *testing.T) { + type matrixFunc func(c osrm.Client, + points []route.Point, + parallelQueries int, + ) (route.ByIndex, error) + + selectDistance := func(c osrm.Client, + points []route.Point, + parallelQueries int, + ) (route.ByIndex, error) { + distance, _, err := osrm.DistanceDurationMatrices(c, points, parallelQueries) + return distance, err + } + selectDuration := func(c osrm.Client, + points []route.Point, + parallelQueries int, + ) (route.ByIndex, error) { + _, duration, err := osrm.DistanceDurationMatrices(c, points, parallelQueries) + return duration, err + } + type testCase struct { + matrixFunc matrixFunc + distancesResponse [][]float64 + durationsResponse [][]float64 + points []route.Point + expected [][]float64 + } + matrixWithInfs := [][]float64{ + {1.0, 2.0, 999.0, 3.0, 999.0}, + {4.0, 5.0, 999.0, 6.0, 999.0}, + {999.0, 999.0, 999.0, 999.0, 999.0}, + {7.0, 8.0, 999.0, 9.0, 999.0}, + } + matrixWithZeroes := [][]float64{ + {1.0, 2.0, 0.0, 3.0, 0.0}, + {4.0, 5.0, 0.0, 6.0, 0.0}, + {0.0, 0.0, 0.0, 0.0, 0.0}, + {7.0, 8.0, 0.0, 9.0, 0.0}, + {0.0, 0.0, 0.0, 0.0, 0.0}, + } + matrixWithNoInfs := [][]float64{ + {1.0, 2.0, 3.0}, + {4.0, 5.0, 6.0}, + {7.0, 8.0, 9.0}, + } + cases := []testCase{ + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {}, + {3.0, 3.0}, + {}, + }, + distancesResponse: matrixWithInfs, + expected: matrixWithZeroes, + matrixFunc: osrm.DistanceMatrix, + }, + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {}, + {3.0, 3.0}, + {}, + }, + distancesResponse: matrixWithInfs, + expected: matrixWithZeroes, + matrixFunc: osrm.DistanceMatrix, + }, + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {}, + {3.0, 3.0}, + {}, + }, + durationsResponse: matrixWithInfs, + expected: matrixWithZeroes, + matrixFunc: osrm.DurationMatrix, + }, + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {}, + {3.0, 3.0}, + {}, + }, + distancesResponse: matrixWithInfs, + expected: matrixWithZeroes, + matrixFunc: selectDistance, + }, + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {3.0, 3.0}, + }, + distancesResponse: matrixWithNoInfs, + expected: matrixWithNoInfs, + matrixFunc: osrm.DistanceMatrix, + }, + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {3.0, 3.0}, + }, + durationsResponse: matrixWithNoInfs, + expected: matrixWithNoInfs, + matrixFunc: osrm.DurationMatrix, + }, + { + points: []route.Point{ + {1.0, 1.0}, + {2.0, 2.0}, + {3.0, 3.0}, + }, + durationsResponse: matrixWithNoInfs, + expected: matrixWithNoInfs, + matrixFunc: selectDuration, + }, + } + for i, test := range cases { + s := newMockOSRM(test.distancesResponse, test.durationsResponse) + c := osrm.DefaultClient(s.URL, false) + m, err := test.matrixFunc(c, test.points, 0) + if err != nil { + t.Errorf("[%d] unexpected error: %v", i, err) + } + got := unpackMeasure(m, test.points) + if !reflect.DeepEqual(test.expected, got) { + t.Errorf("[%d] expected %v, got %v", i, test.expected, got) + } + s.Close() + } +} + +func unpackMeasure(m route.ByIndex, points []route.Point) [][]float64 { + matrix := make([][]float64, len(points)) + for i := range matrix { + matrix[i] = make([]float64, len(points)) + for j := range matrix[i] { + matrix[i][j] = m.Cost(i, j) + } + } + return matrix +} + +func TestDistanceMatrix(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + disMeasurer, err := osrm.DistanceMatrix(c, p, 0) + if err != nil { + t.Fatalf("error requesting matrix: %v", err) + } + + for i, row := range expectedDistances { + for j, col := range row { + if c := disMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} + +func TestSnappingFailed(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + _, err := osrm.DistanceMatrix(c, oceanPoints, 0) + if err == nil { + t.Fatalf("snapping should have failed") + } + expectedErrorMsg := "fetching matrix: expected \"Ok\" response code; got" + + " \"NoSegment\" (\"Could not find a matching segment for coordinate 0\")" + actualErrorMsg := err.Error() + if actualErrorMsg != expectedErrorMsg { + t.Errorf("want: %v; got: %v\n", expectedErrorMsg, actualErrorMsg) + } +} + +func TestDistanceMatrixWithParallelQueries(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + disMeasurer, err := osrm.DistanceMatrix(c, p, 2) + if err != nil { + t.Fatalf("error requesting matrix: %v", err) + } + + for i, row := range expectedDistances { + for j, col := range row { + if c := disMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} + +func TestDurationMatrix(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + durMeasurer, err := osrm.DurationMatrix(c, p, 0) + if err != nil { + t.Fatalf("error requesting matrix: %v", err) + } + + for i, row := range expectedDurations { + for j, col := range row { + if c := durMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} + +// You will need http://download.geofabrik.de/asia/gcc-states.html for this +// test. +func TestDurationMatrixLarge(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + testpoints := make([]route.Point, 1000) + for i := 0; i < len(testpoints); i++ { + latMin := 24.17041401832874 + latMax := 24.253760 + lat := latMin + rand.Float64()*(latMax-latMin) + + lonMin := 55.656787 + lonMax := 55.81164689921658 + lon := lonMin + rand.Float64()*(lonMax-lonMin) + testpoints[i] = route.Point{lon, lat} + } + + c := osrm.DefaultClient(osrmHost, true) + + durMeasurer, err := osrm.DurationMatrix(c, testpoints, 0) + if err != nil { + t.Fatalf("error requesting matrix: %v", err) + } + + // Check some expected durations + for i, row := range expectedDurationsUae { + for j, col := range row { + if c := durMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } + + // Check that all durations are present and non-negative + for i := range testpoints { + for j := range testpoints { + if c := durMeasurer.Cost(i, j); c < 0 { + t.Errorf("received negative duration: %f\n", c) + } + } + } +} + +func TestDurationMatrixWithScaleFactor(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + err := c.ScaleFactor(2.0) + if err != nil { + t.Fatalf("error requesting matrix with scale factor: %v", err) + } + durMeasurer, err := osrm.DurationMatrix(c, p, 0) + if err != nil { + t.Fatalf("error requesting matrix: %v", err) + } + + for i, row := range expectedDurationsScaledBy2 { + for j, col := range row { + if c := durMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} + +func TestDurationMatrixWithParallelQueries(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + durMeasurer, err := osrm.DurationMatrix(c, p, 2) + if err != nil { + t.Fatalf("error requesting matrix: %v", err) + } + + for i, row := range expectedDurations { + for j, col := range row { + if c := durMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} + +func TestDistanceDurationMatrices(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + disMeasurer, durMeasurer, err := osrm.DistanceDurationMatrices(c, p, 0) + if err != nil { + t.Fatalf("error requesting matrices: %v", err) + } + + for i, row := range expectedDistances { + for j, col := range row { + if c := disMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } + + for i, row := range expectedDurations { + for j, col := range row { + if c := durMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} + +func TestDistanceDurationMatricesWithParallelQueries(t *testing.T) { + osrmHost := os.Getenv(hostEnv) + if osrmHost == "" { + t.Skip(hostEnvNotSetMsg) + } + + c := osrm.DefaultClient(osrmHost, true) + disMeasurer, durMeasurer, err := osrm.DistanceDurationMatrices(c, p, 2) + if err != nil { + t.Fatalf("error requesting matrices: %v", err) + } + + for i, row := range expectedDistances { + for j, col := range row { + if c := disMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } + + for i, row := range expectedDurations { + for j, col := range row { + if c := durMeasurer.Cost(i, j); c != col { + t.Errorf("want: %f; got: %f\n", col, c) + } + } + } +} diff --git a/route/osrm/osrmtest/mock_client.go b/route/osrm/osrmtest/mock_client.go new file mode 100644 index 00000000..e2bf9935 --- /dev/null +++ b/route/osrm/osrmtest/mock_client.go @@ -0,0 +1,116 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: client.go + +// Package osrmtest is a generated GoMock package. +package osrmtest + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + osrm "github.com/nextmv-io/sdk/route/osrm" + "github.com/nextmv-io/sdk/route" +) + +// MockClient is a mock of Client interface. +type MockClient struct { + ctrl *gomock.Controller + recorder *MockClientMockRecorder +} + +// MockClientMockRecorder is the mock recorder for MockClient. +type MockClientMockRecorder struct { + mock *MockClient +} + +// NewMockClient creates a new mock instance. +func NewMockClient(ctrl *gomock.Controller) *MockClient { + mock := &MockClient{ctrl: ctrl} + mock.recorder = &MockClientMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockClient) EXPECT() *MockClientMockRecorder { + return m.recorder +} + +// Get mocks base method. +func (m *MockClient) Get(uri string) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", uri) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Get indicates an expected call of Get. +func (mr *MockClientMockRecorder) Get(uri interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockClient)(nil).Get), uri) +} + +// Polyline mocks base method. +func (m *MockClient) Polyline(points []route.Point) (string, []string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Polyline", points) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].([]string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Polyline indicates an expected call of Polyline. +func (mr *MockClientMockRecorder) Polyline(points interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Polyline", reflect.TypeOf((*MockClient)(nil).Polyline), points) +} + +// ScaleFactor mocks base method. +func (m *MockClient) ScaleFactor(factor float64) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ScaleFactor", factor) + ret0, _ := ret[0].(error) + return ret0 +} + +// ScaleFactor indicates an expected call of ScaleFactor. +func (mr *MockClientMockRecorder) ScaleFactor(factor interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ScaleFactor", reflect.TypeOf((*MockClient)(nil).ScaleFactor), factor) +} + +// SnapRadius mocks base method. +func (m *MockClient) SnapRadius(radius int) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SnapRadius", radius) + ret0, _ := ret[0].(error) + return ret0 +} + +// SnapRadius indicates an expected call of SnapRadius. +func (mr *MockClientMockRecorder) SnapRadius(radius interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SnapRadius", reflect.TypeOf((*MockClient)(nil).SnapRadius), radius) +} + +// Table mocks base method. +func (m *MockClient) Table(points []route.Point, opts ...osrm.TableOptions) ([][]float64, [][]float64, error) { + m.ctrl.T.Helper() + varargs := []interface{}{points} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Table", varargs...) + ret0, _ := ret[0].([][]float64) + ret1, _ := ret[1].([][]float64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// Table indicates an expected call of Table. +func (mr *MockClientMockRecorder) Table(points interface{}, opts ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{points}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Table", reflect.TypeOf((*MockClient)(nil).Table), varargs...) +} diff --git a/route/osrm/route.go b/route/osrm/route.go new file mode 100644 index 00000000..df072e4c --- /dev/null +++ b/route/osrm/route.go @@ -0,0 +1,17 @@ +package osrm + +import "github.com/nextmv-io/sdk/route" + +// Polyline requests polylines for the given points. The first parameter returns +// a polyline from start to end and the second parameter returns a list of +// polylines, one per leg. +func Polyline( + c Client, points []route.Point, +) (string, []string, error) { + polyline, legLines, err := c.Polyline(points) + if err != nil { + return "", []string{}, err + } + + return polyline, legLines, nil +} diff --git a/route/routingkit/.gitignore b/route/routingkit/.gitignore new file mode 100644 index 00000000..5c7c4736 --- /dev/null +++ b/route/routingkit/.gitignore @@ -0,0 +1 @@ +testdata/*.ch diff --git a/route/routingkit/doc.go b/route/routingkit/doc.go new file mode 100644 index 00000000..ac1c4d03 --- /dev/null +++ b/route/routingkit/doc.go @@ -0,0 +1,45 @@ +// Package routingkit provides measures that calculate the cost of travelling +// between points on road networks. +// +// The `routingkit.ByPoint` constructor allow you to create a route.ByPoint +// that finds the shortest path between two points in terms of distance +// travelled: +// +// byPoint, err := routingkit.ByPoint( +// osmFile, // path to an .osm.pbf file +// 1000, // maximum distance to snap points to +// 1<<30, // use max 1GB to cache point distances +// routingkit.Car, // limit to roads accessible by car +// fallbackMeasure, // used when no route is found between points +// ) +// +// `routingkit.DurationByPoint` constructs a `route.ByPoint` that finds the +// shortest path in terms of travel time. (Only car travel times are supported) +// +// byPoint, err := routingkit.DurationByPoint( +// osmFile, +// 1000, +// 1<<30, +// fallbackMeasure +// ) +// +// Finally, `routingkit.Matrix` and `routingkit.DurationMatrix` construct a +// `route.ByIndex` containing pre-built matrices of all point-to-point costs, +// using the relevant units. +// +// distanceByIndex, err := routingkit.Matrix( +// osmFile, +// 1000, +// srcs, +// dests, +// routingkit.Car, +// fallbackMeasure +// ) +// durationByIndex, err := routingkit.DurationMatrix( +// osmFile, +// 1000, +// srcs, +// dests, +// fallbackMeasure +// ) +package routingkit diff --git a/route/routingkit/go.mod b/route/routingkit/go.mod new file mode 100644 index 00000000..1b059856 --- /dev/null +++ b/route/routingkit/go.mod @@ -0,0 +1,26 @@ +module github.com/nextmv-io/sdk/route/routingkit + +go 1.19 + +replace github.com/nextmv-io/sdk => ../../. + +require ( + github.com/dgraph-io/ristretto v0.1.0 + github.com/nextmv-io/go-routingkit v0.1.9 + github.com/nextmv-io/sdk v0.0.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.1.2 // indirect + github.com/datadog/czlib v0.0.0-20160811164712-4bc9a24e37f2 // indirect + github.com/dustin/go-humanize v1.0.0 // indirect + github.com/golang/glog v1.0.0 // indirect + github.com/paulmach/orb v0.7.1 // indirect + github.com/paulmach/osm v0.5.0 // indirect + github.com/paulmach/protoscan v0.2.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/stretchr/testify v1.7.0 // indirect + golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a // indirect + google.golang.org/protobuf v1.28.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect +) diff --git a/route/routingkit/go.sum b/route/routingkit/go.sum new file mode 100644 index 00000000..5029a208 --- /dev/null +++ b/route/routingkit/go.sum @@ -0,0 +1,144 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/datadog/czlib v0.0.0-20160811164712-4bc9a24e37f2 h1:ISaMhBq2dagaoptFGUyywT5SzpysCbHofX3sCNw1djo= +github.com/datadog/czlib v0.0.0-20160811164712-4bc9a24e37f2/go.mod h1:2yDaWzisHKoQoxm+EU4YgKBaD7g1M0pxy7THWG44Lro= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/ristretto v0.1.0 h1:Jv3CGQHp9OjuMBSne1485aDpUkTKEcUqF+jm/LuerPI= +github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/gogo/protobuf v1.3.0/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/nextmv-io/go-routingkit v0.1.9 h1:7iM27C9TtjgLsfoo3ziHyZ6yw7OVqZDe0LsEKebjUd0= +github.com/nextmv-io/go-routingkit v0.1.9/go.mod h1:ZoQfy6fSQdhxBOxVSZBf2pLGFKpq3lxtVNeCKmBZsRk= +github.com/paulmach/orb v0.1.3/go.mod h1:VFlX/8C+IQ1p6FTRRKzKoOPJnvEtA5G0Veuqwbu//Vk= +github.com/paulmach/orb v0.1.6/go.mod h1:pPwxxs3zoAyosNSbNKn1jiXV2+oovRDObDKfTvRegDI= +github.com/paulmach/orb v0.7.1 h1:Zha++Z5OX/l168sqHK3k4z18LDvr+YAO/VjK0ReQ9rU= +github.com/paulmach/orb v0.7.1/go.mod h1:FWRlTgl88VI1RBx/MkrwWDRhQ96ctqMCh8boXhmqB/A= +github.com/paulmach/osm v0.2.2/go.mod h1:bHtjwVUgLRe/C6Uy5+wcvuD4TqrBHBvLP67F+GquY4I= +github.com/paulmach/osm v0.5.0 h1:M3JKsvnakPf5f4qpS09NA1D8gOYJ7G8oUpjIPJIZ66g= +github.com/paulmach/osm v0.5.0/go.mod h1:v0vZa0rKnCsO8ovx0Z+hR9BWVD+vO4ogLOXcV18/0yk= +github.com/paulmach/protoscan v0.1.0/go.mod h1:2c55sl1Hu6/tgRfc8Y8zADsxuSCYC2IrPh0JCqP/yrw= +github.com/paulmach/protoscan v0.2.1 h1:rM0FpcTjUMvPUNk2BhPJrreDKetq43ChnL+x1sRg8O8= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a h1:dGzPydgVsqGcTRVwiLJ1jVbufYwmzD3LfVPLKsKg+0k= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20190921001708-c4c64cad1fd0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/route/routingkit/load.go b/route/routingkit/load.go new file mode 100644 index 00000000..aa028a9c --- /dev/null +++ b/route/routingkit/load.go @@ -0,0 +1,211 @@ +package routingkit + +import ( + "encoding/json" + "fmt" + + "github.com/nextmv-io/go-routingkit/routingkit" + "github.com/nextmv-io/sdk/route" +) + +// ByPointLoader can be embedded in schema structs and unmarshals a ByPoint JSON +// object into the appropriate implementation, including a routingkit.ByPoint. +type ByPointLoader struct { + byPoint route.ByPoint +} + +type byPointJSON struct { + ByPoint *ByPointLoader `json:"measure"` //nolint:tagliatelle + Type string `json:"type"` + OSMFile string `json:"osm"` //nolint:tagliatelle + Radius float64 `json:"radius"` + CacheSize int64 `json:"cache_size"` + ProfileLoader *ProfileLoader `json:"profile"` //nolint:tagliatelle +} + +// MarshalJSON returns the JSON representation for the underlying ByPoint. +func (l ByPointLoader) MarshalJSON() ([]byte, error) { + return json.Marshal(l.byPoint) +} + +// UnmarshalJSON converts the bytes into the appropriate implementation of +// ByPoint. +func (l *ByPointLoader) UnmarshalJSON(b []byte) error { + var j byPointJSON + if err := json.Unmarshal(b, &j); err != nil { + return err + } + + switch j.Type { + case "routingkit": + byPoint, err := ByPoint( + j.OSMFile, + j.Radius, + j.CacheSize, + j.ProfileLoader.To(), + j.ByPoint.To(), + ) + if err != nil { + return fmt.Errorf(`constructing measure: %v`, err) + } + l.byPoint = byPoint + case "routingkitDuration": + byPoint, err := DurationByPoint( + j.OSMFile, + j.Radius, + j.CacheSize, + j.ProfileLoader.To(), + j.ByPoint.To(), + ) + if err != nil { + return fmt.Errorf(`constructing measure: %v`, err) + } + l.byPoint = byPoint + default: + var byPointLoader route.ByPointLoader + if err := byPointLoader.UnmarshalJSON(b); err != nil { + return err + } + l.byPoint = byPointLoader.To() + } + return nil +} + +// To returns the underlying ByPoint. +func (l *ByPointLoader) To() route.ByPoint { + if l == nil { + return nil + } + return l.byPoint +} + +// ByIndexLoader can be embedded in schema structs and unmarshals a ByIndex JSON +// object into the appropriate implementation, including a routingkit.ByIndex. +type ByIndexLoader struct { + byIndex route.ByIndex +} + +type byIndexJSON struct { + Measure *ByPointLoader `json:"measure"` + OSMFile string `json:"osm"` //nolint:tagliatelle + Type string `json:"type"` + Sources []route.Point `json:"sources"` + Destinations []route.Point `json:"destinations"` + Radius float64 `json:"radius"` + ProfileLoader *ProfileLoader `json:"profile,omitempty"` //nolint:tagliatelle +} + +// MarshalJSON returns the JSON representation for the underlying ByIndex. +func (l ByIndexLoader) MarshalJSON() ([]byte, error) { + return json.Marshal(l.byIndex) +} + +// UnmarshalJSON converts the bytes into the appropriate implementation of +// ByIndex. +func (l *ByIndexLoader) UnmarshalJSON(b []byte) error { + var j byIndexJSON + if err := json.Unmarshal(b, &j); err != nil { + return err + } + + var m route.ByPoint + if j.Measure != nil { + m = j.Measure.To() + } + + switch j.Type { + case "routingkitMatrix": + byIndex, err := Matrix( + j.OSMFile, + j.Radius, + j.Sources, + j.Destinations, + j.ProfileLoader.To(), + m, + ) + if err != nil { + return fmt.Errorf(`constructing measure: %v`, err) + } + l.byIndex = byIndex + case "routingkitDurationMatrix": + byIndex, err := DurationMatrix( + j.OSMFile, + j.Radius, + j.Sources, + j.Destinations, + j.ProfileLoader.To(), + m, + ) + if err != nil { + return fmt.Errorf(`constructing measure: %v`, err) + } + l.byIndex = byIndex + default: + var byIndexLoader route.ByIndexLoader + if err := byIndexLoader.UnmarshalJSON(b); err != nil { + return err + } + l.byIndex = byIndexLoader.To() + } + return nil +} + +// To returns the underlying ByIndex. +func (l *ByIndexLoader) To() route.ByIndex { + return l.byIndex +} + +// ProfileLoader can be embedded in schema structs and unmarshals a +// routingkit.Profile JSON object into the appropriate implementation. +type ProfileLoader struct { + profile *routingkit.Profile +} + +type profileJSON struct { + Name string `json:"name"` +} + +// MarshalJSON returns the JSON representation for the underlying Profile. +func (l ProfileLoader) MarshalJSON() ([]byte, error) { + if l.profile == nil { + return json.Marshal(nil) + } + return json.Marshal(map[string]any{ + "name": l.profile.Name, + }) +} + +// UnmarshalJSON converts the bytes into the appropriate implementation of +// Profile. +func (l *ProfileLoader) UnmarshalJSON(b []byte) error { + var p profileJSON + if err := json.Unmarshal(b, &p); err != nil { + return err + } + var profile routingkit.Profile + switch p.Name { + case "car": + profile = routingkit.Car() + case "bike": + profile = routingkit.Bike() + case "pedestrian": + profile = routingkit.Pedestrian() + default: + return fmt.Errorf( + "%s is not an unmarshallable profile type: only car, bike, "+ + "and pedestrian can be unmarshalled", + p.Name, + ) + } + l.profile = &profile + + return nil +} + +// To returns the underlying Profile. +func (l *ProfileLoader) To() routingkit.Profile { + if l == nil || l.profile == nil { + return routingkit.Car() + } + return *l.profile +} diff --git a/route/routingkit/measure.go b/route/routingkit/measure.go new file mode 100644 index 00000000..f83e37ce --- /dev/null +++ b/route/routingkit/measure.go @@ -0,0 +1,342 @@ +package routingkit + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "math" + + "github.com/dgraph-io/ristretto" + "github.com/nextmv-io/go-routingkit/routingkit" + "github.com/nextmv-io/sdk/route" +) + +const cacheItemCost int64 = 80 + +// These profile constructors are exported for convenience - +// to avoid having to import two packages both called routingkit + +// Car constructs a car routingkit profile. +var Car = routingkit.Car + +// Bike constructs a bike routingkit profile. +var Bike = routingkit.Bike + +// Truck constructs a truck routingkit profile. +var Truck = routingkit.Truck + +// Pedestrian constructs a pedestrian routingkit profile. +var Pedestrian = routingkit.Pedestrian + +// DurationByPoint is a measure that uses routingkit to calculate car travel +// times between given points. It needs a .osm.pbf map file, a radius in which +// points can be snapped to the road network, a cache size in bytes (1 << 30 = 1 +// GB), a profile and a measure that is used in case no travel time can be +// computed. +func DurationByPoint( + mapFile string, + radius float64, + cacheSize int64, + profile routingkit.Profile, + m route.ByPoint, +) (route.ByPoint, error) { + client, err := routingkit.NewTravelTimeClient(mapFile, profile) + if err != nil { + return nil, err + } + cache, err := ristretto.NewCache(&ristretto.Config{ + // NumCounters should be 10 times the max number of cached items. Since + // the cost of each item is cacheItemCost , 10 * cacheSize / + // cacheItemCost gives the correct value + NumCounters: 10 * cacheSize / cacheItemCost, + MaxCost: cacheSize, + BufferItems: 64, + }) + if err != nil { + return nil, fmt.Errorf("creating cache: %v", err) + } + return durationByPoint{ + client: client, + mapFile: mapFile, + radius: radius, + m: m, + cache: cache, + cacheSize: cacheSize, + profile: profile, + }, nil +} + +type durationByPoint struct { + client routingkit.TravelTimeClient + m route.ByPoint + cache *ristretto.Cache + mapFile string + radius float64 + cacheSize int64 + profile routingkit.Profile +} + +// Cost calculates the road network travel time between the points. +func (b durationByPoint) Cost(p1, p2 route.Point) float64 { + key := make([]byte, 32) + binary.LittleEndian.PutUint64(key[0:], math.Float64bits(p1[0])) + binary.LittleEndian.PutUint64(key[8:], math.Float64bits(p1[1])) + binary.LittleEndian.PutUint64(key[16:], math.Float64bits(p2[0])) + binary.LittleEndian.PutUint64(key[24:], math.Float64bits(p2[1])) + val, found := b.cache.Get(key) + if found { + return val.(float64) + } + + d := b.client.TravelTime(coords(p1), coords(p2)) + if b.m != nil && d == routingkit.MaxDistance { + c := b.m.Cost(p1, p2) + b.cache.Set(key, c, cacheItemCost) + return c + } + dInSeconds := float64(d) / 1000.0 + // the cost of an entry is cacheItemCost + b.cache.Set(key, dInSeconds, cacheItemCost) + return dInSeconds +} + +// Triangular indicates that the measure does have the triangularity property. +func (b durationByPoint) Triangular() bool { + return true +} + +// MarshalJSON serializes the route. +func (b durationByPoint) MarshalJSON() ([]byte, error) { + m := make(map[string]any) + m["type"] = "routingkitDuration" + m["osm"] = b.mapFile + m["radius"] = b.radius + m["cache_size"] = b.cacheSize + m["profile"] = ProfileLoader{&b.profile} + if b.m != nil { + m["measure"] = b.m + } + return json.Marshal(m) +} + +// ByPoint constructs a route.ByPoint that computes the road network distance +// connecting any two points found within the provided mapFile. It needs a +// radius in which points can be snapped to the road network, a cache size in +// bytes (1 << 30 = 1 GB), a profile and a measure that is used in case no +// travel time can be computed. +func ByPoint( + mapFile string, + radius float64, + cacheSize int64, + profile routingkit.Profile, + m route.ByPoint, +) (route.ByPoint, error) { + client, err := routingkit.NewDistanceClient(mapFile, profile) + if err != nil { + return nil, fmt.Errorf("%v", err) + } + cache, err := ristretto.NewCache(&ristretto.Config{ + // NumCounters should be 10 times the max number of cached items. Since + // the cost of each item is cacheItemCost , 10 * cacheSize / + // cacheItemCost gives the correct value + NumCounters: 10 * cacheSize / cacheItemCost, + MaxCost: cacheSize, + BufferItems: 64, + }) + if err != nil { + return nil, fmt.Errorf("creating cache: %v", err) + } + return byPoint{ + client: client, + mapFile: mapFile, + radius: radius, + m: m, + cache: cache, + cacheSize: cacheSize, + profile: profile, + }, nil +} + +type byPoint struct { + client routingkit.DistanceClient + m route.ByPoint + cache *ristretto.Cache + mapFile string + radius float64 + cacheSize int64 + profile routingkit.Profile +} + +// Cost calculates the road network distance between the points. +func (b byPoint) Cost(p1, p2 route.Point) float64 { + key := make([]byte, 32) + binary.LittleEndian.PutUint64(key[0:], math.Float64bits(p1[0])) + binary.LittleEndian.PutUint64(key[8:], math.Float64bits(p1[1])) + binary.LittleEndian.PutUint64(key[16:], math.Float64bits(p2[0])) + binary.LittleEndian.PutUint64(key[24:], math.Float64bits(p2[1])) + val, found := b.cache.Get(key) + if found { + return val.(float64) + } + + d := b.client.Distance(coords(p1), coords(p2)) + if b.m != nil && d == routingkit.MaxDistance { + c := b.m.Cost(p1, p2) + b.cache.Set(key, c, cacheItemCost) + return c + } + // the cost of an entry is cacheItemCost + b.cache.Set(key, float64(d), cacheItemCost) + return float64(d) +} + +// Triangular indicates that the measure does have the triangularity property. +func (b byPoint) Triangular() bool { + return true +} + +// MarshalJSON serializes the route. +func (b byPoint) MarshalJSON() ([]byte, error) { + m := make(map[string]any) + m["type"] = "routingkit" + m["osm"] = b.mapFile + m["radius"] = b.radius + m["profile"] = ProfileLoader{&b.profile} + m["cache_size"] = b.cacheSize + if b.m != nil { + m["measure"] = b.m + } + return json.Marshal(m) +} + +// Matrix uses the provided mapFile to construct a route.ByIndex that can find +// the road network distance between any point in srcs and any point in dests. +// In addition to the mapFile, srcs and dests it needs a radius in which points +// can be snapped to the road network, a profile and a measure that is used in +// case no distances can be computed. +func Matrix( + mapFile string, + radius float64, + srcs []route.Point, + dests []route.Point, + profile routingkit.Profile, + m route.ByPoint, +) (route.ByIndex, error) { + client, err := routingkit.NewDistanceClient(mapFile, profile) + if err != nil { + return nil, fmt.Errorf("%v", err) + } + mx := client.Matrix(coordsSlice(srcs), coordsSlice(dests)) + + return matrix{ + ByIndex: route.Matrix(float64Matrix(mx, srcs, dests, m, false)), + mapFile: mapFile, + radius: radius, + srcs: srcs, + dests: dests, + profile: &ProfileLoader{&profile}, + ByPoint: m, + clientType: "routingkitMatrix", + }, nil +} + +type matrix struct { + route.ByPoint + route.ByIndex + mapFile string + clientType string + srcs []route.Point + dests []route.Point + radius float64 + profile *ProfileLoader +} + +// Cost returns the road network distance between the points. +func (m matrix) Cost(i, j int) float64 { + return m.ByIndex.Cost(i, j) +} + +// MarshalJSON serializes the route. +func (m matrix) MarshalJSON() ([]byte, error) { + data := map[string]any{ + "type": m.clientType, + "osm": m.mapFile, + "radius": m.radius, + "sources": m.srcs, + "destinations": m.dests, + "profile": m.profile, + } + if m.ByPoint != nil { + data["measure"] = m.ByPoint + } + + return json.Marshal(data) +} + +// DurationMatrix uses the provided mapFile to construct a route.ByIndex that +// can find the road network durations between any point in srcs and any point +// in dests. +// In addition to the mapFile, srcs and dests it needs a radius in which points +// can be snapped to the road network, a profile and a measure that is used in +// case no travel durations can be computed. +func DurationMatrix( + mapFile string, + radius float64, + srcs []route.Point, + dests []route.Point, + profile routingkit.Profile, + m route.ByPoint, +) (route.ByIndex, error) { + client, err := routingkit.NewTravelTimeClient(mapFile, profile) + if err != nil { + return nil, fmt.Errorf("%v", err) + } + mx := client.Matrix(coordsSlice(srcs), coordsSlice(dests)) + + return matrix{ + ByIndex: route.Matrix(float64Matrix(mx, srcs, dests, m, true)), + mapFile: mapFile, + radius: radius, + srcs: srcs, + dests: dests, + ByPoint: m, + profile: &ProfileLoader{&profile}, + clientType: "routingkitDurationMatrix", + }, nil +} + +func coords(p route.Point) []float32 { + return []float32{float32(p[0]), float32(p[1])} +} + +func coordsSlice(ps []route.Point) [][]float32 { + cs := make([][]float32, len(ps)) + for i, p := range ps { + cs[i] = coords(p) + } + return cs +} + +func float64Matrix(m [][]uint32, + srcs []route.Point, + dests []route.Point, + fallback route.ByPoint, + duration bool, +) [][]float64 { + fM := make([][]float64, len(m)) + for i, r := range m { + fM[i] = make([]float64, len(r)) + for j, c := range r { + if fallback != nil && c == routingkit.MaxDistance { + fM[i][j] = fallback.Cost(srcs[i], dests[j]) + } else { + if duration { + fM[i][j] = float64(c) / 1000.0 // convert to seconds + } else { + fM[i][j] = float64(c) + } + } + } + } + return fM +} diff --git a/route/routingkit/measure_test.go b/route/routingkit/measure_test.go new file mode 100644 index 00000000..f2f1630b --- /dev/null +++ b/route/routingkit/measure_test.go @@ -0,0 +1,358 @@ +package routingkit_test + +import ( + "encoding/json" + "testing" + "unicode" + + "github.com/nextmv-io/sdk/route" + "github.com/nextmv-io/sdk/route/routingkit" +) + +type byPointConstantMeasure float64 + +func (m byPointConstantMeasure) Cost(a route.Point, b route.Point) float64 { + return float64(m) +} + +func TestFallback(t *testing.T) { + sources := []route.Point{ + {-76.587490, 39.299710}, + } + dests := []route.Point{ + {-76.60548, 39.30772}, + {-76.582855, 39.309095}, + } + expected := [][]float64{ + {666, 1496}, + } + + m, err := routingkit.Matrix( + "testdata/maryland.osm.pbf", + 1000, + sources, + dests, + routingkit.Car(), + byPointConstantMeasure(666), + ) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + for i := range sources { + for j := range dests { + v := m.Cost(i, j) + if v != expected[i][j] { + t.Errorf("[%d,%d] expected %f, got %f", i, j, expected[i][j], v) + } + } + } +} + +func TestMatrix(t *testing.T) { + sources := []route.Point{ + {-76.587490, 39.299710}, + {-76.594045, 39.300524}, + {-76.586664, 39.290938}, + {-76.598423, 39.289484}, + } + dests := []route.Point{ + {-76.582855, 39.309095}, + {-76.599388, 39.302014}, + } + expected := [][]float64{ + {1496, 1259}, + {1831, 575}, + {2372, 2224}, + {3399, 1548}, + } + + m, err := routingkit.Matrix( + "testdata/maryland.osm.pbf", + 1000, + sources, + dests, + routingkit.Car(), + nil, + ) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + for i := range expected { + for j, expectedV := range expected[i] { + v := m.Cost(i, j) + if v != expectedV { + t.Errorf("[%d,%d] expected %f, got %f", i, j, expectedV, v) + } + } + } +} + +func TestMatrixMarshal(t *testing.T) { + m, err := routingkit.Matrix( + "testdata/maryland.osm.pbf", + 1000, + []route.Point{{1.0, 2.0}}, + []route.Point{{3.0, 4.0}}, + routingkit.Car(), + nil, + ) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + b, err := json.Marshal(m) + if err != nil { + t.Errorf("got %+v; want nil", err) + } + w := `{"destinations":[[3,4]],` + + `"osm":"testdata/maryland.osm.pbf","profile":{"name":"car"},"radius":1000,` + + `"sources":[[1,2]],"type":"routingkitMatrix"}` + if v := string(b); v != w { + t.Errorf("got %q; want %q", v, w) + } +} + +func TestByPoint(t *testing.T) { + p1 := route.Point{-76.58749, 39.29971} + p2 := route.Point{-76.59735, 39.30587} + + m, err := routingkit.ByPoint( + "testdata/maryland.osm.pbf", + 1000, + 1<<30, + routingkit.Car(), + nil, + ) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + + if v := int(m.Cost(p1, p2)); v != 1567 { + t.Errorf("got %v; want 1567", v) + } + if v := int(m.Cost(p2, p1)); v != 1706 { + t.Errorf("got %v; want 1706", v) + } + + // get the same values from the cache + if v := int(m.Cost(p1, p2)); v != 1567 { + t.Errorf("got %v; want 1567", v) + } + if v := int(m.Cost(p2, p1)); v != 1706 { + t.Errorf("got %v; want 1706", v) + } +} + +func TestByPointMarshal(t *testing.T) { + m, err := routingkit.ByPoint( + "testdata/maryland.osm.pbf", + 1000, + 1<<30, + routingkit.Pedestrian(), + nil) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + b, err := json.Marshal(m) + if err != nil { + t.Errorf("got %+v; want nil", err) + } + w := `{"cache_size":1073741824,"osm":"testdata/maryland.osm.pbf",` + + `"profile":{"name":"pedestrian"},` + + `"radius":1000,"type":"routingkit"}` + if v := string(b); v != w { + t.Errorf("got %q; want %q", v, w) + } +} + +func TestByPointLoader(t *testing.T) { + tests := []struct { + input string + from route.Point + to route.Point + expectedErr bool + expected int + }{ + { + input: `{"cache_size":1073741824,"osm":"testdata/maryland.osm.pbf",` + + `"profile":{"name":"car"},"radius":1000,"type":"routingkit"}`, + expectedErr: false, + from: route.Point{-76.58749, 39.29971}, + to: route.Point{-76.59735, 39.30587}, + expected: 1567, + }, + { + input: `{"cache_size":1073741824,"osm":"testdata/maryland.osm.pbf",` + + `"profile":{"name":"pedestrian"},"radius":1000,"type":"routingkit"}`, + expectedErr: false, + from: route.Point{-76.58749, 39.29971}, + to: route.Point{-76.59735, 39.30587}, + expected: 1555, + }, + } + for i, test := range tests { + var loader routingkit.ByPointLoader + if err := json.Unmarshal([]byte(test.input), &loader); err != nil { + if !test.expectedErr { + t.Errorf("[%d] unexpected error: %v", i, err) + } + continue + } + if test.expectedErr { + t.Errorf("[%d] expected error but got none", i) + continue + } + res := loader.To().Cost(test.from, test.to) + if int(res) != test.expected { + t.Errorf("[%d] expected %d, got %d", i, test.expected, int(res)) + } + marshalled, err := json.Marshal(loader) + if err != nil { + t.Errorf("error marshalling loader: %v", err) + } + got := string(marshalled) + want := removeSpace(test.input) + if got != want { + t.Errorf("[%d] got %s, want %s", i, got, want) + } + } +} + +func TestByIndexLoader(t *testing.T) { + tests := []struct { + input string + from int + to int + expectedErr bool + expected int + }{ + { + input: `{"destinations":[[-76.582855,39.309095],[-76.599388,39.302014]],` + + `"osm":"testdata/maryland.osm.pbf","profile":{"name":"car"},` + + `"radius":1000,` + + `"sources":[[-76.58749,39.29971],[-76.594045,39.300524],` + + `[-76.586664,39.290938],[-76.598423,39.289484]],` + + `"type":"routingkitMatrix"}`, + expectedErr: false, + from: 2, + to: 0, + expected: 2372, + }, + // with fallback measure + { + input: `{"destinations":[[-76.60548,39.30772],[-76.582855,39.309095]],` + + `"measure":{"type":"haversine"},` + + `"osm":"testdata/maryland.osm.pbf","profile":{"name":"car"},` + + `"radius":1000,"sources":[[-76.58749,39.29971]],` + + `"type":"routingkitMatrix"}`, + expectedErr: false, + from: 0, + to: 0, + expected: 1785, + }, + // routingkitDurationMatrix + { + input: `{"destinations":[[-76.582855,39.309095],[-76.599388,39.302014]],` + + `"osm":"testdata/maryland.osm.pbf",` + + `"profile":{"name":"car"},"radius":1000,` + + `"sources":[[-76.58749,39.29971],[-76.594045,39.300524],` + + `[-76.586664,39.290938],[-76.598423,39.289484]],` + + `"type":"routingkitDurationMatrix"}`, + expectedErr: false, + from: 2, + to: 0, + expected: 205, + }, + // routingkitDurationMatrix with fallback measure + { + input: `{"destinations":[[-76.60548,39.30772],[-76.582855,39.309095]],` + + `"measure":{"type":"haversine"},` + + `"osm":"testdata/maryland.osm.pbf","profile":{"name":"car"},` + + `"radius":1000,"sources":[[-76.58749,39.29971]],` + + `"type":"routingkitDurationMatrix"}`, + expectedErr: false, + from: 0, + to: 0, + expected: 1785, + }, + } + for i, test := range tests { + var loader routingkit.ByIndexLoader + if err := json.Unmarshal([]byte(test.input), &loader); err != nil { + if !test.expectedErr { + t.Errorf("[%d] unexpected error: %v", i, err) + } + continue + } + if test.expectedErr { + t.Errorf("[%d] expected error but got none", i) + continue + } + res := loader.To().Cost(test.from, test.to) + if int(res) != test.expected { + t.Errorf("[%d] expected %d, got %d", i, test.expected, int(res)) + } + marshalled, err := json.Marshal(loader) + if err != nil { + t.Errorf("error marshalling loader: %v", err) + } + got := string(marshalled) + want := removeSpace(test.input) + if got != want { + t.Errorf("[%d] got %s, want %s", i, got, want) + } + } +} + +func TestDurationByPoint(t *testing.T) { + p1 := route.Point{-76.58749, 39.29971} + p2 := route.Point{-76.59735, 39.30587} + + m, err := routingkit.DurationByPoint( + "testdata/maryland.osm.pbf", 1000, 1<<30, routingkit.Car(), nil) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + + if v := int(m.Cost(p1, p2)); v != 140 { + t.Errorf("got %v; want 140", v) + } + if v := int(m.Cost(p2, p1)); v != 156 { + t.Errorf("got %v; want 156", v) + } + + // get the same values from the cache + if v := int(m.Cost(p1, p2)); v != 140 { + t.Errorf("got %v; want 140", v) + } + if v := int(m.Cost(p2, p1)); v != 156 { + t.Errorf("got %v; want 156", v) + } +} + +func TestDurationByPointMarshal(t *testing.T) { + m, err := routingkit.DurationByPoint( + "testdata/maryland.osm.pbf", 1000, 1<<30, routingkit.Car(), nil) + if err != nil { + t.Fatalf("constructing measure: %v", err) + } + b, err := json.Marshal(m) + if err != nil { + t.Errorf("got %+v; want nil", err) + } + w := `{"cache_size":1073741824,"osm":"testdata/maryland.osm.pbf",` + + `"profile":{"name":"car"},` + + `"radius":1000,"type":"routingkitDuration"}` + if v := string(b); v != w { + t.Errorf("got %q; want %q", v, w) + } +} + +func removeSpace(s string) string { + rr := make([]rune, 0, len(s)) + for _, r := range s { + if !unicode.IsSpace(r) { + rr = append(rr, r) + } + } + return string(rr) +} diff --git a/route/routingkit/testdata/maryland.osm.pbf b/route/routingkit/testdata/maryland.osm.pbf new file mode 100644 index 00000000..d0f0911a Binary files /dev/null and b/route/routingkit/testdata/maryland.osm.pbf differ