Wanderlust (Linux-Magazin, Januar 2022)

Die Gpx-Daten meiner mittels Geo-Trackern und Apps wie Komoot aufgezeichneten Wanderwege bergen einiges Potential an statistischen Auswertungen in sich. An welchen Kalendertagen war ich unterwegs, an welchen faul? Und in welchen Regionen war ich hauptsächlich unterwegs, in welchen Bundesstaaten wurden die meisten Kilometer abgeschritten?

Egal wo die .gpx-Dateien herkommen, ob von einem Garmin-Tracker aufgezeichnet, oder einer App wie Komoot, von der man die Daten nach Abschluss von der Webseite herunterladen kann ([2]), die aufgezeichneten Daten schreien gerade dazu danach, sie durch mehr oder weniger intelligente Analyseprogramme zu schicken. Pro Wanderung (oder Radeltour) hält das Verzeichis tours (Abbildung 1) jeweils eine Datei im XML-Format vor (Abbildung 2). Jede dieser .gpx-Dateien besteht aus einer Reihe von mit Zeitstempeln aufgezeichneten Geo-Punkten, die jeweils den per GPS ermittelten Längen- und Breitengrad angeben, aus dem sich umgekehrt wieder ein Punkt auf der Erdoberfläche ermitteln lässt.

Abbildung 1: Aus einer Kollektion aus .gpx-Dateien extrahieren Auswertungsprogramme Bewegungsdaten.

Abbildung 2: Die XML-Daten aus der .gpx-Datei beschreiben einen Wanderweg.

Geklaut von Github

Die XML-Daten manuell mit Go einzulesen wäre eine Heidenarbeit, da ihr interner Aufbau mit separaten Tracks, Segmenten und Points entsprechende Strukturen in Go erfordert. Zum Glück steht aber schon das Projekt gpxgo auf Github bereit, das Listing 1 in Zeile 4 hereinzieht und das den Job mit ParseFile() in Zeile 26 in einem Federstrich erledigt. Ein später geschriebenes Analyseprogramm muss also nur gpxPoints() aus Listing 1 mit dem Namen einer .gpx-Datei aufrufen und bekommt alle Geo-Punkte darin mitsamt ihren Zeitstempeln als Go-Strukturen zurück. Praktisch!

Um den Mittelwert aller Geo-Punkte einer Datendatei auszurechnen, zum Beispiel um festzustellen, wo der Wanderweg insgesamt liegt, ruft gpxAvg() ab Zeile 43 in Listing 1 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 Geo-Punkt erhöht sich auch noch der Zähler nofPoints um Eins, und die Funktion muss am Ende nur die Gesamtsumme durch die Anzahl der Punkte teilen, um deren Mittelwert zu bekommen.

Listing 1: gpxread.go

    01 package main
    02 
    03 import (
    04   "github.com/tkrajina/gpxgo/gpx"
    05   "os"
    06   "path/filepath"
    07 )
    08 
    09 func gpxFiles() []string {
    10   tourDir := "tours"
    11   files := []string{}
    12 
    13   entries, err := os.ReadDir(tourDir)
    14   if err != nil {
    15     panic(err)
    16   }
    17 
    18   for _, entry := range entries {
    19     gpxPath := filepath.Join(tourDir, entry.Name())
    20     files = append(files, gpxPath)
    21   }
    22   return files
    23 }
    24 
    25 func gpxPoints(path string) []gpx.GPXPoint {
    26   gpxData, err := gpx.ParseFile(path)
    27   points := []gpx.GPXPoint{}
    28 
    29   if err != nil {
    30     panic(err)
    31   }
    32 
    33   for _, trk := range gpxData.Tracks {
    34     for _, seg := range trk.Segments {
    35       for _, pt := range seg.Points {
    36         points = append(points, pt)
    37       }
    38     }
    39   }
    40   return points
    41 }
    42 
    43 func gpxAvg(path string)(float64, float64, int) {
    44     nofPoints := 0
    45     latSum,longSum := 0.0, 0.0
    46     for _, pt := range gpxPoints(path) {
    47       latSum += pt.Latitude
    48       longSum += pt.Longitude
    49       nofPoints++
    50     }
    51     return latSum/float64(nofPoints),
    52       longSum/float64(nofPoints), nofPoints
    53 }

Überblick verschaffen

Um nun einen kurzen Überblick über den Inhalt aller gesammelten .gpx-Dateien zu erhalten, marschiert Listing 2 mit Hilfe von gpxFiles() in Listing 1 durch alle Dateien im Verzeichnis tours, liest ihre XML-Daten und gibt mit gpxPoints() eine Liste aller enthaltenen Geopunkte mitsamt ihren Zeitstempeln zurück. Die Ausgabe in Abbildung 3 zeigt, dass die Wegstrecken an weit auseinanderliegenden Orten aufgezeichnet wurden. Am Schnittpunkt des 37sten nördlichen Breitengrads und dem mit -122 verzeichneten westlichen Längengrad ist meine Wahlheimat San Francisco, während auf dem 48. Grad nördlicher Breite und dem 10. Grad östlicher Länge meine alte Heimat Augsburg liegt, die ich als amerikanischer Tourist letzten Sommer im Urlaub besucht habe.

Listing 2: tourstats.go

    01 package main
    02 
    03 import ("fmt")
    04 
    05 func main() {
    06   for _, path := range gpxFiles() {
    07     lat, lon, pts := gpxAvg(path)
    08     fmt.Printf("%s %.2f,%.2f (%d points)\n",
    09       path, lat, lon, pts)
    10   }
    11 }

Abbildung 3: Eine Sammlung von .gpx-Dateien und ihre Auswertung durch tourstats.go

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 und wann nicht. Daraus generiert Listing 3 eine zeitbasierte Aktivitätskurve. Um die Anzahl aller während eines bestimmten Kalendertages erfassten Track-Points aufzukumulieren, setzt es die Stunden-, Minuten-, und Sekundenwerte aller gefundenen Zeitstempel auf Null und macht mit time.Date() jeweils ein neues Datum, das den ganzen Kalendertag über 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.

Einfaches kompliziert

Das ist keineswegs so einfach wie in Scriptsprachen wie Python, deren Hashes ebenfalls unsortiert sind, aber ein sort-Kommando beim Auslesen schafft dort schnell Abhilfe. In Go muss die For-Schleife ab Zeile 22 in Listing 3 zunächst alle Schlüssel (Keys) der Hash-Map einsammeln und in ein neu angelegtes Array-Slice bugsieren. Dieses sortiert die Funktion sort.Slice() dann ab Zeile 25 aufsteigend nach der Zeit. Mit einem Array-Slice von Strings wäre das Ganze mit 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 Arraywerten an den Indices 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 Plot-Software go-chart übergeben, die Zeile 5 in Listing 3 von Github hereinholt.

Sie malt optisch ansprechende Diagramme als Balken, Kuchen-, oder Funktionsgrafiken, und im vorliegenden 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 kompiliert sich mit go build activity.go gpxread.go, da es neben den Plot-Instruktionen auch noch die Funktionen gpxFiles() und gpxPoints() aus Listing 1 hereinzieht. Als Ausgabe erzeugt es die Bilddatei activity.png nach Abbildung 4.

Abbildung 4: Wanderaktivität, als Anzahl der GPS-Trackpunkte über die Zeit

Listing 3: activity.go

    01 package main
    02 
    03 import (
    04   "github.com/wcharczuk/go-chart/v2"
    05   "os"
    06   "sort"
    07   "time"
    08 )
    09 
    10 func main() {
    11   perday := map[time.Time]int{}
    12 
    13   for _, path := range gpxFiles() {
    14     for _, pt := range gpxPoints(path) {
    15       t := time.Date(pt.Timestamp.Year(), pt.Timestamp.Month(), pt.Timestamp.Day(), 0, 0, 0, 0, time.Local)
    16       perday[t]++
    17     }
    18   }
    19 
    20   keys := []time.Time{}
    21   for day, _ := range perday {
    22     keys = append(keys, day)
    23   }
    24   sort.Slice(keys, func(i, j int) bool {
    25     return keys[i].Before(keys[j])
    26   })
    27 
    28   xVals := []time.Time{}
    29   yVals := []float64{}
    30   for _, key := range keys {
    31     xVals = append(xVals, key)
    32     yVals = append(yVals, float64(perday[key]))
    33   }
    34 
    35   mainSeries := chart.TimeSeries{
    36     Name: "GPS Activity",
    37     Style: chart.Style{
    38       StrokeColor: chart.ColorBlue,
    39       FillColor: chart.ColorBlue.WithAlpha(100),
    40     },
    41     XValues: xVals,
    42     YValues: yVals,
    43   }
    44 
    45   graph := chart.Chart{
    46     Width:  1280,
    47     Height: 720,
    48     Series: []chart.Series{mainSeries},
    49   }
    50 
    51   f, _ := os.Create("activity.png")
    52   defer f.Close()
    53 
    54   graph.Render(chart.PNG, f)
    55 }

Reale Welt

Wie aber kommt man nun von den GPX-Koordinaten in geografischer Länge und Breite auf das Land, in dem diese liegen, oder sogar den Bundesstaat oder die Stadt oder die Straße? Diese Funktion ist eine Domäne des sogenannten Geo-Mappings. Im vorliegenden 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 noch gebührenfrei an, aber die Eierköpfe dort haben das Angebot vor einiger Zeit zurückgezogen und verlangen nun die Registrierung mit einer Kreditkarte, von der Geld abgebucht wird, 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 Email registriert und einen API-Token für eine beschränkte Anzahl von Requests zum Ausprobieren erhält.

Listing 4: georev.go

    01 package main
    02 
    03 import (
    04   "encoding/json"
    05   "fmt"
    06   "github.com/peterbourgon/diskv"
    07   "io/ioutil"
    08   "net/http"
    09   "net/url"
    10 )
    11 
    12 type GeoState struct {
    13   ApiKey string
    14   Cache  *diskv.Diskv
    15 }
    16 
    17 func NewGeoState() *GeoState {
    18   state := GeoState{
    19     ApiKey: "XXX",
    20     Cache:  diskv.New(diskv.Options{BasePath: "cache"}),
    21   }
    22   return &state
    23 }
    24 
    25 func (state *GeoState) GeoRev(lat, lng float64) string {
    26   key := roundedLatLng(lat, lng)
    27 
    28   res, err := state.Cache.Read(key)
    29   if err != nil {
    30     res = []byte(state.GeoLookup(lat, lng))
    31     state.Cache.Write(key, res)
    32   }
    33 
    34   return string(res)
    35 }
    36 
    37 func roundedLatLng(lat, lng float64) string {
    38   return fmt.Sprintf("%.1f,%.1f", lat, lng)
    39 }
    40 
    41 func (state *GeoState) GeoLookup(lat, lng float64) string {
    42   u := url.URL{
    43     Scheme: "https",
    44     Host:   "api.opencagedata.com",
    45     Path:   "geocode/v1/json",
    46   }
    47   q := u.Query()
    48   q.Set("key", state.ApiKey)
    49   q.Set("q", roundedLatLng(lat, lng))
    50   u.RawQuery = q.Encode()
    51 
    52   resp, err := http.Get(u.String())
    53   if err != nil {
    54     panic(err)
    55   }
    56 
    57   body, err := ioutil.ReadAll(resp.Body)
    58   if err != nil {
    59     panic(err)
    60   }
    61   return stateFromJson(body)
    62 }
    63 
    64 func stateFromJson(txt []byte) string {
    65   var data map[string]interface{}
    66   json.Unmarshal(txt, &data)
    67 
    68   results := data["results"].([]interface{})[0].(map[string]interface{})
    69   return results["components"].(map[string]interface{})["state"].(string)
    70 }

Als Anwendungsbeispiel wandelt die Funktion GeoRev() ab Zeile 25 in Listing 4 eine Kombination aus Längen- und Breitengrad als Float64-Werte in den an dieser Stelle eingetragenen Bundesstaat um. Aus "37.7, -122.4" wird so "California", aus "49.4, 8.7" "Bavaria". Abbildung 5 zeigt die detaillierte Json-Antwort, die der Opencage-Server zu einer Koordinate im Stadtbereich von San Francisco liefert. Von der Postleitzahl über den Straßennamen, Land, Bundesstaat, und so weiter, ist dort alles vertreten. Das funktioniert freilich nur, falls Listing 4 in Zeile 19 einen gültigen API-Key enthält, den es auf der Opencage-Seite gegen Registrierung mittels Email (ohne Kreditkarte) gibt.

Abbildung 5: Die Json-Antwort zum Geo-Mapping von opencage.com enthält detaillierte Ortsangaben.

Mit Go gesuchte Daten aus einer Server-Antwort mit Json-Daten herauszufieseln ist immer ein rechtes Gefrett, 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" durch zum gesuchten String im Json-Salat, 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, einmal weil dies jedesmal einen Rundtrip übers Internet erfordert, und zum anderen weil dann schnell das limitierte Kontingent des kostenlosen Probeaccounts aufgebraucht wäre.

Deshalb definiert Listing 4 ein Verzeichnis cache, in dem die simple Cache-Implementiertung discv von Github die eingeholten API-Ergebnisse permanent zwischenspeichert. Die Funktion roundedLatLng() ab Zeile 39 runded 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 die es mit einem Web-Request an die API des Servers ein und speichert es im Cache. Dieser füllt sich mit den eingehenden Anfragen zusehens, wie ein Blick auf das Cache-Verzeichnis in Abbildung 6 offenbart.

Abbildung 6: Im Cache-Verzeichnis liegen die Ergebnisse bereits eingeholter Reverse-Geo-Mappings.

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.

Listing 5: states.go

    01 package main
    02 
    03 import (
    04   "github.com/wcharczuk/go-chart/v2"
    05   "os"
    06 )
    07 
    08 func main() {
    09   geo := NewGeoState()
    10   perState := map[string]int{}
    11 
    12   for _, path := range gpxFiles() {
    13     for _, pt := range gpxPoints(path) {
    14       state := geo.GeoRev(pt.Latitude, pt.Longitude)
    15       perState[state]++
    16     }
    17   }
    18 
    19   vals := []chart.Value{}
    20   for state, count := range perState {
    21     vals = append(vals, chart.Value{Value: float64(count), Label: state})
    22   }
    23 
    24   pie := chart.PieChart{
    25     Width:  512, Height: 512,
    26     Values: vals,
    27   }
    28 
    29   f, _ := os.Create("states.png")
    30   defer f.Close()
    31   pie.Render(chart.PNG, f)
    32 }

In der Hashmap perState zählt sie hierzu den Eintrag unter dem Schlüssel des Bundesstaat-Strings um Eins hoch. Je mehr Trackpunkte in der jeweiligen Region liegen, desto höher wird der Zählwert, und das Kuchenstück in der Grafik entsprechend größer.

Abbildung 7: Die auf den Wanderwegen besuchten Bundesstaaten

Es erzeugt in states.png eine Bilddatei, und Abbidlung 7 zeigt das Ergebnis: Meine GPX-Dateien decken hauptsächlich Kalifornien ab, aber auch die deutschen Bundesländer Bayern, Baden-Würtemberg und Niedersachsen.

Hunger auf mehr

Dies 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 Regionen mit den beliebtesten Wegen identifizieren möchte, kann mit einer AI-Library Cluster nach der K-means-Methode aufspüren. Oder vielleicht könnte eine künstliche Intelligenz vormals beliebte Wanderrouten vorschlagen, deren Begehung schon etwas zurückliegt?

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/12/snapshot/

[2]

Michael Schilli, "Wandern nach Plan": Linux-Magazin 09/2021, S.84, https://www.linux-magazin.de/ausgaben/2021/09/snapshot/

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc