Create GPS Test Data In Go

Doug Bridgens - Apr 24 - - Dev Community

Many of the functions I'm working on consume arrays of points (GPS tracks) []float64{lng,lat} on which to run statistical analysis. Individual tracks can have 50,000+ points, describing a real journey from A to B.

Testing functions that process GPS tracks has been unexpectedly difficult. Test data of the form [1.0,2.0], to test logically is fine. But beyond that, I want to be able to test consistency in things like finding clusters, or coefficient breakpoints.

For testing I'm not concerned with where the locations are on earth, but it's handy to be able to view tracks on a map for visual confirmation. So the coordinates need to, sort of, coordinate.

I've created a function that generates semi-coherent GPS location data tracks. It's trivial to create a 5,000 data input track, designed to test something in particular. Being able to export it as geoJSON and view the shape on map is good for speedy intuition checks.

I can setup the data to be skewed one way or another, or insert a cluster of weirdness. Particularly useful at larger data sizes, where one analysis method may be better than another.

func CreateGPSData() {

        line := orb.LineString{orb.Point{-3.188267, 55.953251}}

        // general direction and distance per point
        bearingRange := [2]int{Direction_SSE, Direction_SSW}
        distanceRange := [2]int{10 * 1000, 15 * 1000}

        // generate a test GPS track
        for range 5000 {
                newPoint := generateNewLocation(line[len(line)-1],
                        bearingRange,
                        distanceRange)
                line = append(line, newPoint)
        }

        // add skewness in the data
        bearingRange = [2]int{Direction_W, Direction_WNW}
        distanceRange = [2]int{1000, 1500}
        for range 100 {
                newPoint := generateNewLocation(line[len(line)-1],
                        bearingRange,
                        distanceRange)
                line = append(line, newPoint)
        }

        // do testing
}
Enter fullscreen mode Exit fullscreen mode

What doesn't seem to be too common is generating a new point, some distance away in a particular direction. The opposite of point.DistanceTo(point2). That's really all that the following function does.

The breakdown starts with some readability. Using compass rose names really helps with reading the calling function.

// the compass rose, naming format for readability
const (
        Direction_N = iota
        Direction_NNE
        Direction_NE
        Direction_ENE
        Direction_E
        Direction_ESE
        Direction_SE
        Direction_SSE
        Direction_S
        Direction_SSW
        Direction_SW
        Direction_WSW
        Direction_W
        Direction_WNW
        Direction_NW
        Direction_NNW
)

const (
        compassRoseDegrees = 22.5
)
Enter fullscreen mode Exit fullscreen mode

Rather than simply a series of linear points, I want a GPS track that wiggles a little. So the parameters are a range of direction and distance, giving a small amount of randomness when calculating the next point.

// generateNewLocation returns a new point in the range of direction and
// distance. It is meant to build non-repetitive but predictable GPS tracks, to
// help generate test input cases.
//
// It's also meant to be readable code.
func generateNewLocation(start orb.Point, direction [2]int, distance [2]int) orb.Point {
Enter fullscreen mode Exit fullscreen mode

Possibly the most common stupid mistakes I make are mixing up longitude and latitude indexes through typos. Named index values solve this.

// Mistakes with lon/lat indexing area easy to make, explicit index names
// helps
const (
    Longitude = 0
    Latitude  = 1
)
Enter fullscreen mode Exit fullscreen mode

I use the excellent orb package to hide much of the GPS specific calculations.

A point to note is that the Go maths package works in radians, so here's a helper function.

var (
    latitudeOneDegreeOfDistance = 111000  // metres
    newPoint                    orb.Point // []float64{Long, Lat}

    // convert from degrees to radians
    deg2rad = func(d float64) float64 { return d * math.Pi / 180 }
)
Enter fullscreen mode Exit fullscreen mode

A while ago I went back to study maths, and have found lots of use for it. Here using trig to workout the distances on the ground.

 // Use trigonometry of a right angled triangle to solve the distances on the ground.
// The hypotenuse is our desired distance to travel,  and one angle
// is our desired bearing.
//
// now work out the vertical (longitude) and horizontal (latitude) sides in
// distance units.
hyp := (rand.Float64() * float64(distance[1]-distance[0])) + float64(distance[0])

// Get the compass bearing in degrees, with a little randomness between the
// general direction. Non-linear tracks are easier to troubleshoot visually.
bearingMin := float64(direction[0]) * 22.5
bearingMax := float64(direction[1]) * 22.5
angle := (rand.Float64() * (bearingMax - bearingMin)) + bearingMin

// Calulate the other side lengths using SOH CAH TOA. The Go math package
// works in radians
adj := math.Cos(deg2rad(angle)) * hyp // adjacent side of angle
opp := math.Sin(deg2rad(angle)) * hyp // opposite side of angle
Enter fullscreen mode Exit fullscreen mode

Each degree change in latitude moves a fixed distance on the earth surface. So it is fairly simple to find the degree change we need to move our required distance on the ground.

// Each degree change in every latitude equates to ~111 km on the ground. So
// now find the degree change required for the length of adj
latitudeDelta := (1.0 / float64(latitudeOneDegreeOfDistance)) * adj
newPoint[Latitude] = start[Latitude] + latitudeDelta
Enter fullscreen mode Exit fullscreen mode

Longitude 'distance moved on the earth surface' on the other hand changes depending on the corresponding latitude.

First need to find what one longitude degree of travel is, at the current latitude. This is the longitude equivalent to latitudeOneDegreeOfDistance = 111000.

// Distance on the ground for each degree of longitude changes depending on
// latitude because the earth is not perfectly spherical. So we need to
// calculate the distance of one degree longitude for our current latitude.
p1 := orb.Point{1.0, start[Latitude]}
p2 := orb.Point{2.0, start[Latitude]}
longitudeOneDegreeOfDistance := geo.Distance(p1, p2) // returns metres
Enter fullscreen mode Exit fullscreen mode

After which it's the same calculation to find the longitude degree required to move our required distance on the ground.

// Now we can use this value to calculate the longitude degree change
// required to move opp distance (in a horizontal straight line) at this
// latitude.
longitudeDelta := (1.0 / longitudeOneDegreeOfDistance) * opp
Enter fullscreen mode Exit fullscreen mode

Now we have the new point, located roughly at the direction and distance required.

// The new point is a vertical and horizontal shift to arrive at hyp
// distance from the start point on the required bearing.
newPoint[Longitude] = start[Longitude] + longitudeDelta

return newPoint
Enter fullscreen mode Exit fullscreen mode

To print the geoJSON object, which can be viewed using the Mapbox viewer. The geojson package is part of orb.

fc := geojson.NewFeatureCollection()
f := geojson.NewFeature(line)
fc.Append(f)

rawJSON, _ := fc.MarshalJSON()
fmt.Println(string(rawJSON))
Enter fullscreen mode Exit fullscreen mode

The geoJSON output, which can be dropped right into the Mapbox viewer.

{"features":[{"type":"Feature","geometry":{"type":"LineString","coordinates":[[-3.188267,55.953251],[-3.135746915373942,55.86790144687853],[-3.1203515713600174,55.77480489552086],[-3.1047549460145367,55.67255911652837],[-3.157869557083529,55.55060126862168],[-3.228498091310353,55.43475670091365],[-3.2496225715462885,55.436812333443044],[-3.2661004391340707,55.439707415244094],[-3.2813563020992547,55.44313063594729],[-3.3021255075874594,55.44562577470332],[-3.319279338837285,55.446998922512265]]},"properties":{"stroke":"#b464d6","stroke-width":3}}],"type":"FeatureCollection"}
Enter fullscreen mode Exit fullscreen mode

The full code is in a Gist here.

. . . . . .