Как создать тестовые данные GPS в Go
Многие функции, над которыми я работаю, используют массивы точек (GPS-треков) []float64{lng,lat}
для статистического анализа. Отдельные треки могут содержать более 50 000 точек, описывающих реальное путешествие из пункта А в пункт Б.
Тестирование функций, которые обрабатывают GPS-треки, оказалось неожиданно сложным. Тестовые данные вида [1.0,2.0]
для логического тестирования подходят. Но помимо этого, я хочу иметь возможность проверять согласованность в таких вещах, как поиск кластеров или контрольных точек коэффициента.
Для тестирования меня не интересует, где находятся эти места на земле, но удобно иметь возможность просматривать треки на карте для визуального подтверждения. Так что координаты должны как бы согласовываться.
Я создал функцию, которая генерирует полусвязные треки с данными GPS о местоположении. Создать дорожку из 5000 входных данных, предназначенную для тестирования чего-то конкретного, несложно. Возможность экспортировать ее в виде GeoJSON и просматривать форму на карте полезна для быстрой проверки интуиции.
В тестовой функции я могу настроить так, чтобы данные были искажены в ту или иную сторону, или вставить кластер странностей. Особенно полезно при больших объемах данных, когда один метод анализа может быть лучше другого.
func TestAnomalyDetection(t *testing.T) {
line := orb.LineString{orb.Point{-3.188267, 55.953251}}
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
}
Я оставил следующий код как документированную единственную функцию, чтобы сделать его более читабельным. И я использовал пакет orb
, чтобы скрыть множество наиболее распространенных вычислений и типов (orb
).
Что, по-видимому, не является слишком распространенным явлением, так это создание новой точки на некотором расстоянии в определенном направлении. Противоположном point.DistanceTo(point2)
. На самом деле это все, что делает следующая функция.
// 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
)
// 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 {
// Mistakes with lon/lat indexing area easy to make, explicit index names
// helps
const (
Longitude = 0
Latitude = 1
)
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 }
)
// 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
// 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
// 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
// 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
// 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
}
Для вывода объекта geoJSON, который можно просмотреть с помощью средства просмотра Mapbox.
fc := geojson.NewFeatureCollection()
f := geojson.NewFeature(line)
fc.Append(f)
rawJSON, _ := fc.MarshalJSON()
fmt.Println(string(rawJSON))