Um aufgezeichnete Fußwanderungen statistisch aufzubereiten, extrahiert Mike Schilli die entsprechenden GPS-Daten mit Go und greift zu Plottern und APIs für die Geoanalyse.
Die GPX-Daten meiner mittels Geo-Trackern und Apps wie Komoot aufgezeichneten Wanderwege bergen einiges Potenzial an statistischen Auswertungen. An welchen Kalendertagen war ich unterwegs, an welchen faul? In welchen Regionen war ich hauptsächlich unterwegs, in welchen Bundesstaaten habe ich die meisten Kilometer zurückgelegt?
Egal, wo die GPX-Dateien herkommen – ob von einem Garmin-Tracker aufgezeichnet oder von einer App wie Komoot, bei der man die Daten von der Webseite herunterladen kann [2] – die aufgezeichneten Daten schreien geradezu danach, sie durch mehr oder weniger intelligente Analyseprogramme zu schicken. Pro Wanderung oder Radtour hält das Verzeichnis »tours/« (Abbildung 1) jeweils eine Datei im XML-Format vor (Abbildung 2). Jede dieser GPX-Dateien besteht aus einer Reihe von mit Zeitstempeln aufgezeichneten Geodaten. Sie geben jeweils den per GPS ermittelten Längen- und Breitengrad an, aus dem sich umgekehrt wieder ein Punkt auf der Erdoberfläche ermitteln lässt.
Bei Github geklaut
Es wäre eine Heidenarbeit, die XML-Daten manuell mit Go einzulesen, da ihr interner Aufbau mit separaten Tracks, Segmenten und Points entsprechende Strukturen in Go erfordert. Zum Glück steht aber auf Github das Projekt Gpxgo bereit. Das Programm aus Listing 1 zieht es in Zeile 4 herein und erledigt den Job mit »ParseFile()« in Zeile 26 in einem Aufwasch. Ein später geschriebenes Analyseprogramm muss also nur »gpxPoints()« aus Listing 1 mit dem Namen einer GPX-Datei aufrufen und bekommt alle Geopunkte darin mitsamt ihrem Zeitstempel als Go-Strukturen zurück.
Um den Mittelwert aller Geopunkte einer GPX-Datei auszurechnen, zum Beispiel um festzustellen, wo der Wanderweg insgesamt liegt, ruft »gpxAvg()« ab Zeile 43 erst »gpxPoints()« auf, holt die Werte für Längen- und Breitengrade mit »pt.Longitude« und »pt.Latitude« aus der Point-Struktur und summiert sie in zwei Float64-Variablen auf. Mit jedem verarbeiteten Geopunkt erhöht sich der Zähler »nofPoints« um eins, und die Funktion muss am Ende nur die Gesamtsumme durch die Anzahl der Punkte teilen, um den Mittelwert zu bekommen.
Listing 1
gpxread.go
package main
import (
"github.com/tkrajina/gpxgo/gpx"
"os"
"path/filepath"
)
func gpxFiles() []string {
tourDir := "tours"
files := []string{}
entries, err := os.ReadDir(tourDir)
if err != nil {
panic(err)
}
for _, entry := range entries {
gpxPath := filepath.Join(tourDir, entry.Name())
files = append(files, gpxPath)
}
return files
}
func gpxPoints(path string) []gpx.GPXPoint {
gpxData, err := gpx.ParseFile(path)
points := []gpx.GPXPoint{}
if err != nil {
panic(err)
}
for _, trk := range gpxData.Tracks {
for _, seg := range trk.Segments {
for _, pt := range seg.Points {
points = append(points, pt)
}
}
}
return points
}
func gpxAvg(path string)(float64, float64, int) {
nofPoints := 0
latSum,longSum := 0.0, 0.0
for _, pt := range gpxPoints(path) {
latSum += pt.Latitude
longSum += pt.Longitude
nofPoints++
}
return latSum/float64(nofPoints),
longSum/float64(nofPoints), nofPoints
}
Überblick verschaffen
Um einen kurzen Überblick über den Inhalt aller gesammelten GPX-Dateien zu erhalten, marschiert Listing 2 mithilfe von »gpxFiles()« in Listing 1 durch alle Dateien im Verzeichnis »tours/«, liest deren XML-Daten aus und gibt mit »gpxPoints()« eine Liste aller enthaltenen Geopunkte mitsamt der Zeitstempel zurück.
Listing 2
tourstats.go
package main
import ("fmt")
func main() {
for _, path := range gpxFiles() {
lat, lon, pts := gpxAvg(path)
fmt.Printf("%s %.2f,%.2f (%d points)\n",
path, lat, lon, pts)
}
}
Die Ausgabe in Abbildung 3 zeigt, dass die Wegstrecken an weit auseinanderliegenden Orten aufgezeichnet wurden. Am Schnittpunkt des 37. nördlichen Breitengrads mit dem als -122 verzeichneten westlichen Längengrad liegt meine Wahlheimat San Francisco. Auf dem 48. Grad nördlicher Breite und dem 10. Grad östlicher Länge befindet sich meine alte Heimat Augsburg, die ich als amerikanischer Tourist letzten Sommer im Urlaub besucht habe.
Aktiv oder faul?
Die GPX-Punkte einer Aufzeichnung bringen auch Zeitstempel mit, also verrät eine Sammlung von GPX-Dateien, an welchen Kalendertagen Wanderungen aufgezeichnet wurden. Daraus generiert Listing 3 eine zeitbasierte Aktivitätskurve. Um die Anzahl aller während eines bestimmten Kalendertags erfassten Trackpoints zu kumulieren, setzt es die Stunden-, Minuten-, und Sekundenwerte aller gefundenen Zeitstempel auf null und erzeugt mit »time.Date()« jeweils ein neues Datum, das während des ganzen Kalendertags gleich bleibt. In der Hash-Map »perday« zählt Zeile 17 dann den jeweiligen Tageseintrag mit jedem gefundenen Zeitstempel um eins hoch. Bleibt also nur noch, die Schlüssel der Hash-Map, also die Datumswerte, zeitlich zu sortieren und mit den zugeordneten Zählern in ein Diagramm zu malen.
Listing 3
activity.go
package main
import (
"fmt"
"github.com/wcharczuk/go-chart/v2"
"os"
"sort"
"time"
)
func main() {
perday := map[time.Time]int{}
for _, path := range gpxFiles() {
for _, pt := range gpxPoints(path) {
t := time.Date(pt.Timestamp.Year(), pt.Timestamp.Month(), pt.Timestamp.Day(), 0, 0, 0, 0, time.Local)
perday[t]++
}
}
keys := []time.Time{}
for day, _ := range perday {
keys = append(keys, day)
}
sort.Slice(keys, func(i, j int) bool {
return keys[i].Before(keys[j])
})
xVals := []time.Time{}
yVals := []float64{}
for _, key := range keys {
xVals = append(xVals, key)
yVals = append(yVals, float64(perday[key]))
}
mainSeries := chart.TimeSeries{
Name: "GPS Activity",
Style: chart.Style{
StrokeColor: chart.ColorBlue,
FillColor: chart.ColorBlue.WithAlpha(100),
},
XValues: xVals,
YValues: yVals,
}
graph := chart.Chart{
Width: 1280,
Height: 720,
Series: []chart.Series{mainSeries},
}
f, _ := os.Create("activity.png")
defer f.Close()
graph.Render(chart.PNG, f)
}
Einfaches kompliziert
Das gestaltet sich zwar keineswegs so einfach wie in Skriptsprachen wie Python, deren Hashes ebenfalls unsortiert sind, aber ein Sort-Kommando beim Auslesen schafft schnell Abhilfe. In Go muss die For-Schleife ab Zeile 22 von Listing 3 zunächst alle Schlüssel (Keys) der Hash-Map einsammeln und in ein neu angelegtes Array-Slice bugsieren. Das sortiert die Funktion »sort.Slice()« dann ab Zeile 25 aufsteigend nach der Zeit.
Mit einem Array-Slice von Strings wäre das Ganze via »sort.Strings()« schnell erledigt, doch da es sich bei den Hash-Schlüsseln um Daten vom Typ »time.Time« handelt, muss Zeile 26 noch eine Callback-Funktion definieren, die dem Sort-Algorithmus mitteilt, welcher von zwei Array-Werten an den Indizes »i« und »j« denn nun größer ist. Zum Glück wartet der Typ »time.Time« mit einer Funktion »Before()« auf, der genau dieses Ergebnis liefert, also gibt die Callback-Funktion exakt dies zurück.
Mit den zeitlich aufsteigend sortierten Schlüsseln kann nun die For-Schleife ab Zeile 31 die Hash-Einträge ebenfalls sortiert an die Plotsoftware »go-chart« übergeben, die Zeile 5 in Listing 3 von Github hereinholt. Sie malt optisch ansprechende Diagramme als Balken, Kuchen-, oder Funktionsgrafiken. Im behandelten Fall liegen die Daten als Zeitwerte vor, also definiert Zeile 36 sie als »chart.TimeSeries« und setzt die Füll- und Strichfarbe auf Blau. Das Programm kompilieren Sie mit »go build activity.go gpxread.go«, da es neben den Plotinstruktionen auch noch die Funktionen »gpxFiles()« und »gpxPoints()« aus Listing 1 hereinzieht. Als Ausgabe erzeugt es die Bilddatei »activity.png« (Abbildung 4).
Reale Welt
Wie aber kommt man nun von den GPX-Koordinaten in geografischer Länge und Breite auf das Land, in dem sie liegen, oder sogar auf den Bundesstaat, die Stadt oder die Straße? Diese Funktion ist eine Domäne des sogenannten Geo-Mappings. Hier handelt es sich sogar um den “Reverse”-Fall – es geht also nicht darum, aus einer vorgegebenen Adresse auf die Stadt zu schließen, sondern umgekehrt von den Koordinaten auf die Stadt. Dafür braucht man letztendlich eine riesige Datenbank, die möglichst detailliert Orte auf der Landkarte ihren GPS-Koordinaten zuweist.
Online buhlen dafür verschiedene Dienste um die Gunst ihrer Kunden, die dann per API auf den Datenschatz zugreifen dürfen. Ursprünglich bot Google Maps ein solches Mapping gebührenfrei an, aber die Eierköpfe dort zogen das Angebot vor einiger Zeit zurück und verlangen nun die Registrierung mit einer Kreditkarte, von der der Konzern Geld abbucht, falls der Client eine bestimmte Anzahl von Requests überschreitet.
Sucht man online aber etwas herum, finden sich eine Reihe von Freemium-Angeboten einiger Anbieter wie Opencagedata.com, wo man sich per E-Mail registriert und ein API-Token für eine beschränkte Anzahl von Requests zum Ausprobieren erhält. Als Anwendungsbeispiel wandelt die Funktion »GeoRev()« ab Zeile 25 in Listing 4 eine Kombination aus Längen- und Breitengrad im Float64-Format in den an dieser Stelle eingetragenen Bundesstaat um. Aus »37.7, -122.4« wird so »California«, aus »49.4, 8.7« »Bavaria«.
Listing 4
georev.go
package main
import (
"encoding/json"
"fmt"
"github.com/peterbourgon/diskv"
"io/ioutil"
"net/http"
"net/url"
)
type GeoState struct {
ApiKey string
Cache *diskv.Diskv
}
func NewGeoState() *GeoState {
state := GeoState{
ApiKey: "API-Key",
Cache: diskv.New(diskv.Options{BasePath: "cache"}),
}
return &state
}
func (state *GeoState) GeoRev(lat, lng float64) string {
key := roundedLatLng(lat, lng)
res, err := state.Cache.Read(key)
if err != nil {
res = []byte(state.GeoLookup(lat, lng))
state.Cache.Write(key, res)
}
return string(res)
}
func roundedLatLng(lat, lng float64) string {
return fmt.Sprintf("%.1f,%.1f", lat, lng)
}
func (state *GeoState) GeoLookup(lat, lng float64) string {
u := url.URL{
Scheme: "https",
Host: "api.opencagedata.com",
Path: "geocode/v1/json",
}
q := u.Query()
q.Set("key", state.ApiKey)
q.Set("q", roundedLatLng(lat, lng))
u.RawQuery = q.Encode()
resp, err := http.Get(u.String())
if err != nil {
panic(err)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
panic(err)
}
return stateFromJson(body)
}
func stateFromJson(txt []byte) string {
var data map[string]interface{}
json.Unmarshal(txt, &data)
results := data["results"].([]interface{})[0].(map[string]interface{})
return results["components"].(map[string]interface{})["state"].(string)
}
Abbildung 5 zeigt die detaillierte JSON-Antwort, die der Opencage-Server zu einer Koordinate im Stadtbereich von San Francisco liefert. Von der Postleitzahl über Straßennamen, Land, Bundesstaat und so weiter ist dort alles vertreten. Das funktioniert freilich nur, wenn Listing 4 in Zeile 19 einen gültigen API-Key enthält, den es auf der Opencage-Seite gegen Registrierung mittels E-Mail (ohne Kreditkarte) gibt.

Abbildung 5: Die JSON-Antwort zum Geo-Mapping von Opencagedata.com enthält detaillierte Ortsangaben.
Mit Go gesuchte Daten aus einer Server-Antwort mit JSON-Daten herauszufieseln, ist immer schwierig, falls Client-seitig kein entsprechender Go-Typ vorliegt, der auch das letzte Fitzelchen der Datenstruktur nachbildet. Als Alternative bleibt Cowboy-Coding, das mittels Type Assertion die Daten auf Hashmaps mit »interface{}«-Werten zurechtbiegt. Die Funktion »stateFromJson()« hangelt sich so über die Einträge »results«, »components« und »state« zum gesuchten String im JSON-Salat durch, während sie Go ständig durch Type Assertions versichert, dass der nächste Teil auch wirklich vom erwarteten Typ ist.
Schlauer Cache
Allerdings wäre es extrem dumm, den Server mit Tausenden Requests aus fast identischen Trackpoints zu bombardieren – zum einen, weil das jedes Mal einen Rundtrip übers Internet erfordert, und zum anderen, weil dann schnell das limitierte Kontingent des kostenlosen Probe-Accounts aufgebraucht wäre.
Deshalb definiert Listing 4 ein Verzeichnis »cache/«, in dem die simple Cache-Implementierung »discv« von Github die eingeholten API-Ergebnisse permanent zwischenspeichert. Die Funktion »roundedLatLng()« ab Zeile 39 rundet bei eingehenden Anfragen den Längen- und Breitengrad auf eine Nachkommastelle ab und sieht erst einmal nach, ob dafür schon ein Ergebnis im Cache vorliegt. Falls nicht, holt es ein solches mit einem Web-Request an die API des Servers ein und speichert es im Cache. Der füllt sich zusehends mit den eingehenden Anfragen, wie ein Blick auf das Cache-Verzeichnis in Abbildung 6 offenbart.
Mit diesem Hilfspaket kann Listing 5 nun eine Kuchengrafik malen, die angibt, welche Bundesstaaten die GPX-Dateien insgesamt abdecken und wo die meisten Schritte absolviert wurden. In der Hashmap »perState« zählt sie dazu den Eintrag unter dem Schlüssel des Bundesstaat-Strings um eins hoch. Je mehr Track-Punkte in der jeweiligen Region liegen, desto höher der Zählwert und umso größer das Kuchenstück in der Grafik.
Listing 5
states.go
package main
import (
"github.com/wcharczuk/go-chart/v2"
"os"
)
func main() {
geo := NewGeoState()
perState := map[string]int{}
for _, path := range gpxFiles() {
for _, pt := range gpxPoints(path) {
state := geo.GeoRev(pt.Latitude, pt.Longitude)
perState[state]++
}
}
vals := []chart.Value{}
for state, count := range perState {
vals = append(vals, chart.Value{Value: float64(count), Label: state})
}
pie := chart.PieChart{
Width: 512, Height: 512,
Values: vals,
}
f, _ := os.Create("states.png")
defer f.Close()
pie.Render(chart.PNG, f)
}
Das Listing erzeugt die Bilddatei »states.png«, und Abbildung 7 zeigt das Ergebnis: Meine GPX-Dateien decken hauptsächlich Kalifornien ab, aber auch die deutschen Bundesländer Bayern, Baden-Württemberg und Niedersachsen.
Hunger auf mehr
Das sind freilich nur krude Beispiele, die zeigen, was möglich ist. Mit etwas mehr Aufwand lassen sich auch verstecktere Nuggets aus den Daten hervorholen. Wer zum Beispiel die Regionen mit den beliebtesten Wegen identifizieren möchte, kann mit einer AI-Library Cluster nach der k-Means-Methode aufspüren. Zudem könnte eine künstliche Intelligenz vormals beliebte Wanderrouten vorschlagen, deren Begehung mittlerweile schon etwas zurückliegt.
Infos
- Snapshot: Mike Schilli, “Wandern nach Plan”, LM 09/2021, S. 84, https://www.lm-online.de/44765
- Komoot: https://www.komoot.de/
- Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2022/01/snapshot/












