Welchen unserer auf Komoot aufgezeichneten Stadtwanderwege wollen wir denn heute erneut absolvieren? Diese Frage stellt sich überraschend oft, denn je nach physischer Verfassung sollte die Tagestour kurz oder lang, hügelig oder flach, also anstrengend oder erholsam, und je nach Zeitvorgabe vielleicht nicht zu weit von zuhause entfernt liegen.
Leider bietet Komoot nur sehr rudimentäre Filtermöglichkeiten (Abbildung 1) und kann mit dem kleinstmöglichen Suchradius von drei Meilen nur das gesamte Stadtgebiet von San Francisco nach gespeicherten Touren absuchen. Für feinere Suchvorgaben schwebt mir ein Kommandozeilen-Tool vor, das eine stark verkleinerte Auswahl anhand Höhenprofil, Tourlänge und Entfernung zum Einstieg bietet.
Abbildung 1: Komoot bietet nur grobe Wanderwegfilter. |
Die Tourdaten, anhand derer das neue Tool seine Auswahl treffen kann, liegen, dank einer zurückliegenden Snapshot-Ausgabe ([2]) bereits als .gpx
-Dateien auf der Festplatte vor. Hierbei handelt es sich um ein XML-Format, das die Wegpunkte der jeweiligen Tour als Geo-Koordinaten mit Höhe über dem Meeresspiegel samt Zeitstempeln festhält (Abbildung 2).
Abbildung 2: Beispiel einer Tourdatei im .gpx-Format |
Mangels öffentlich verfügbarer API auf Komoots Webseite hat sich in [2] ein Screen-Scraper auf Komoot eingeloggt, die .gpx
-Daten vom Netz geholt und sie lokal unter ihrer ID (zum Beispiel als 523799045.gpx
) auf der Festplatte ins Verzeichnis tours
kopiert. Zur heutigen Nachbearbeitung weist die .csv
-Datei in Listing 1 den IDs leicht erkennbare Tournamen zu. Es handelt sich um eine Auswahl meiner Touren, die teils in Deutschland, teils in den USA liegen. Der Rest geht nun automatisch. Ein Präprozessor wurstelt sich durch die Gpx-Daten aller Touren, bestimmt deren Gesamtdauer, die erklommenen Höhenmeter und die Entfernung des Tour-Einstiegs vom Wohnort. Anschließend filtert ein Kommandozeilenprogramm in Go die Touren aufgrund eingestellter Kriterien heraus.
01 id, name 02 401269499,Forest Hill Parkside Farmers Market 03 411337149,Presidio 04 394385030,Vulcan Stairs and around Buena Vista 05 526430358,Aggenstein 06 434310884,Heidelberg Schlierbach 07 418406673,Tank Hill Mt Olympus 08 514603221,Bernal around the Hill 09 434601991,Heidelberg Philo-Altstadt-Schloss 10 510083576,Forest Hill Stairs Mini Loop 11 405638419,Around Mt Davidson 12 393675355,Laidley Glen Park Loop 13 416081317,Sanchez-Mission
Wie anstrengend sich eine Tour gestaltet, liegt unter anderem daran, wieviele Höhenmeter der Wanderer dabei bergauf läuft. Jeder abgewanderte Geo-Punkt der Gpx-Datei (Abbildung 1) enthält nicht nur die geografische Länge und Breite (trkpt lat/lon
), sondern auch die aktuelle Höhe über dem Meeresspiegel in Metern (ele
). Führt der Weg bergauf, wächst der Höhenwert von Punkt zu Punkt an. Um also die auf der Tour zu absolvierenden Höhenmeter auszurechnen, muss der Algorithmus durch alle Trackpunkte wandern, jeweils die Differenz der Meereshöhe zum Folgepunkt durch Subtraktion ermitteln, und schließlich diese Differenzen insgesamt aufsummieren. Negative Werte filtert er vorher heraus, denn für die Schwere der Tour sollen nur Steigungen zählen, keine abfallenden Wegstücke.
01 #!/usr/bin/env Rscript 02 library("gpx") 03 04 hike <- read_gpx("tours/686129674.gpx") 05 track <- hike$tracks[[1]] 06 07 ele <- track$Elevation 08 steps <- diff(ele) 09 upsteps <- steps[steps > 0] 10 print(sum(upsteps))
Listing 2 löst diese Aufgabe elegant in nur wenigen Zeilen R-Code. Die Installation von R mittels sudo apt-get install r-base-core
bringt noch keine GPX-Library mit, aber im CRAN-Netzwerk steht eine bereit, die mit install.packages('gpx')
in einer interaktiven R-Session (einfach R
auf der Kommandozeile aufrufen) auf dem lokalen Rechner landet. Ab dann findet sich im Suchpfad das Programm Rscript
, das die nachfolgenden Listings aus ihren Shebang-Zeilen am Anfang aufrufen, und das den Code in den Listings durch den R-Interpreter schickt.
Zum Code: Die in Zeile 4 aufgerufene Funktion read_gpx()
stammt aus der vorher installierten Library gpx
und nimmt den Pfad zu einer .gpx
-Datei entgegen. Zurück kommt im Erfolgsfall ein Sammelsurium von benamten Datenbehältern, und unter dem Namen tracks
ein Array mit Tracks, von denen eine .gpx-Datei mehrere enthalten kann, aber hier nur der erste gebraucht wird. Zeile 5 holt den entsprechenden Dataframe mit dem Ausdruck hike$tracks[[1]]
hervor (Array-Elemente in R werden von 1 ab durchnumiert, nicht von 0) und weist ihn der Variablen track
zu. Abbildung 3 zeigt die Daten des in der Variablen track
liegenden Dataframes.
Abbildung 3: Dataframe aus den XML-Daten der .gpx-Datei |
Da die Höhenwerte im Dataframe track
in der Spalte "Elevation"
liegen, holt Zeile 7 sie mit dem Ausdruck track$Elevation
heraus. Diesen Vektor mit allen Höhenwerten der Trackpunkte in der Datei weist das Skript dann der Variablen ele
zu.
Nachdem die Höhenwerte der Messpunkte alle im Vektor ele
liegen, ermittelt die in R eingebaute Funktion diff()
die Einzeldifferenzen zwischen ihnen. Das Ergebnis ist wieder ein Vektor. Lägen also in ele
zum Beispiel die Werte (2,10,8,12)
, würde diff()
daraus (8,-2,4)
machen. Die Recode-Anweisung in Zeile 9 filtert die negativen Werte heraus, sodass im Beispiel nur noch (8,4)
übrig bleiben. Die Funktion sum()
, in Zeile 10, die ebenfalls aus dem R-Standardfundus stammt, schnappt sich diesen Vektor, und summiert dessen Einzelelemente auf. Im Beispiel wäre das Ergebnis 12. Das Skript in Listing 2 lässt sich, falls es als ausführbar markiert ist, von der Kommandozeile aus aufrufen und gibt die Summe der während der Tour erklommenen Höhenmeter als Integer auf der Standarausgabe aus.
Das später vorgestellte Filterprogramm braucht nun aber nicht nur die Höhenmeter einer Tour aus der Sammlung, sondern die Werte von allen Touren, und nicht nur die Höhenmeter, sondern auch noch geografische Breite und Länge des Startpunktes der Tour, sowie die Dauer der Tour in Minuten. Dies erledigt der Präprozessor in Listing 3, und herauskommt eine .csv-Datei nach Abbildung 4.
Abbildung 4: Von preproc.r erzeugte .csv-Datei mit den Metadaten aller Touren |
Wie funktioniert nun der Präprozessor? Als erstes liest Listing 3 die CSV-Daten aus tour-names.csv
(Listing 1) ein, und bekommt einen Dataframe mit den Spalten id
und name
zurück. Die For-Schleife ab Zeile 6 iteriert nun durch alle Zeilen dieses Dataframes, Zeile 7 extrahiert die numerische id
der Tour und Zeile 8 stöpselt daraus den Pfad zur .gpx
-Datei auf der Platte zusammen. Die Funktion read_gpx()
liest daraufhin die Tourdaten aus dem Gpx-Format, und der Rest der Höhenmeterrechnung ist analog zu Listing 2. Nun gilt es, den errechneten Zahlenwert in einer neuen Spalte "ele"
an den Dataframe anzuhängen.
01 #!/usr/bin/env Rscript 02 library("gpx") 03 04 idnames <- read.csv("tour-names.csv") 05 06 for (row in 1:nrow(idnames)) { 07 id <- idnames[row, "id"] 08 gpxf <- paste("tours/", id, ".gpx", sep="") 09 10 hike <- read_gpx(gpxf) 11 track <- hike$tracks[[1]] 12 13 # elevation 14 ele <- track$Elevation 15 steps <- diff(ele) 16 upsteps <- steps[steps > 0] 17 idnames[row,3] = sum(upsteps) 18 names(idnames)[3] = "ele" 19 20 # starting point 21 idnames[row,4] = track[1, "Latitude"] 22 idnames[row,5] = track[1, "Longitude"] 23 names(idnames)[4] = "lat" 24 names(idnames)[5] = "lon" 25 26 # duration 27 start <- track[1, "Time"] 28 stop <- tail(track, 1)[1, "Time"] 29 mins <- round(as.numeric(difftime(stop, start), units="mins"), 0) 30 idnames[row,6] = mins 31 names(idnames)[6] = "mins" 32 } 33 34 write.csv(idnames)
Um einem Dataframe eine neue Spalte hinzuzufügen, genügt es, dieser einen Wert zuzuweisen, entweder mit der Dollar-Notation als idnames$newcol
oder mit den Indexnummern für Zeile und Spalte wie in idnames[row,col]
, wobei col
die neue Spaltennummer ist. Im vorliegenden Fall ist row
die Indexnummer der aktuell von der for-Schleife bearbeiteten Datenreihe und col
gleich 3, da "ele"
als dritte Spalte im Dataframe erscheinen soll. Damit die neue Spalte auch einen Namen (und nicht nur neue Werte) erhält, ist anschließend in Zeiel 18 noch der Array names(idnames)
zu modifizieren, indem auch an ihn ein Element mit dem neuen Spaltennamen angehängt wird.
Wichtig ist es, erst einen neuen Spaltenwert einzufügen, damit R weiß, dass der Dataframe gewachsen ist. Erst dann kann auch der Name in names
hinein. Wer versucht, dies vorher auszuführen, erhält eine Fehlermeldung, da R denkt, der Dataframe wäre zu schmal.
Für den Inhalt der nächsten zwei Spalten, Nummer 4 und 5, sucht das R-Skript die geografische Breite und Länge des Startpunkts der Tour. Da die Gpx-Daten als Dataframe vorliegen, ist das ein Kinderspiel, denn die erste Zeile addressiert R einfach mit dem Index "1" und versteht den Namen der Spalte als Spaltenindex, also muss Zeile 21 nur nach dem Index [1, "Latitude"]
im Gpx-Dataframe fragen und bekommt die geografische Breite als Zahlenwert geliefert. Für die geografische Länge gilt Analoges, hier fügen die Zeilen 23 und 24 neue Spalten in den Ergebnis-Dataframe idnames
ein, als Nummer 4 und Nummer 5.
Fehlt noch die Dauer der Tour, die der Abschnitt ab Zeile 26 ermittelt und ins Ergebnis einfügt. Diese errechnet sich aus der Differenz zwischen dem letzten und dem ersten Zeitstempel in der Gpx-Datei. Zeile 27 holt unter der Indexnummer 1
die erste Zeile und mit "Time"
den Wert in der Spalte mit den Zeitstempeln. Den letzten Eintrag aus dem Gpx-Dataframe holt hingegen in Zeile 28 die R-Funktion tail()
mit dem Parameter 1
(nur das letzte Element), mit analoger Spaltenextraktion wie zur Ermittlung der Startzeit.
Die Differenz dieser beiden Zeitstempel errechnet die R-Funktion difftime()
. Um daraus Minuten zu machen, ruft Zeile 29 die R-Funktion as.numeric()
mit dem Parameter units="mins"
auf. Zurück kommt eine Fließkommazahl mit Minutenbruchteilen, die die R-Funktion round()
mit einem Präzisionswert von 0 (keine Nachkommastellen) auf die nächste ganze Zahl rundet. Fertig ist die Tourlänge, und die Zeilen 30 und 31 fügen den Wert in Spalte 6 unter dem Namen "mins" in den Ergebnis-Dataframe ein.
Abschließend schreibt write.csv()
die ganze Enchilada noch im CSV-Format in die Standardausgabe, und der User legt das Ergebnis zur späteren Wegfilterung in der Datei tour-data.csv
ab, von wo das nachfolgend erläuterte Go-Programm hikefind
sie aufschnappt und filtert. Hierzu übernimmt Listing 4 mit readCSV()
das Einlesen der Metadaten und legt die Einzeleinträge in einem Array-Slice mit Elementen vom Typ Tour
ab, der ab Zeile 13 definiert ist und alle wichtigen Metadaten wie Dauer, Höhenmeter oder Startpunkt führt.
01 package main 02 03 import ( 04 "encoding/csv" 05 "fmt" 06 "io" 07 "os" 08 "strconv" 09 ) 10 11 const csvFile = "tour-data.csv" 12 13 type Tour struct { 14 name string 15 file string 16 gain int 17 lat float64 18 lng float64 19 mins int 20 } 21 22 func readCSV() ([]Tour, error) { 23 _, err := os.Stat(csvFile) 24 f, err := os.Open(csvFile) 25 if err != nil { 26 panic(err) 27 } 28 29 tours := []Tour{} 30 31 r := csv.NewReader(f) 32 33 firstLine := true 34 for { 35 record, err := r.Read() 36 if err == io.EOF { 37 break 38 } 39 if err != nil { 40 fmt.Printf("Error\n") 41 return tours, err 42 } 43 if firstLine { 44 // skip header 45 firstLine = false 46 continue 47 } 48 49 gain, err := strconv.ParseFloat(record[3], 32) 50 panicOnErr(err) 51 lat, err := strconv.ParseFloat(record[4], 64) 52 panicOnErr(err) 53 lng, err := strconv.ParseFloat(record[5], 64) 54 panicOnErr(err) 55 mins, err := strconv.ParseInt(record[6], 10, 64) 56 panicOnErr(err) 57 58 tour := Tour{ 59 name: record[2], 60 gain: int(gain), 61 lat: lat, 62 lng: lng, 63 mins: int(mins)} 64 65 tours = append(tours, tour) 66 } 67 return tours, nil 68 } 69 70 func panicOnErr(err error) { 71 if err != nil { 72 panic(err) 73 } 74 }
Erwartungsgemäß geht die Datenverarbeitung in Go weniger elegant von der Hand, das Paket encoding/csv
versteht zwar das CSV-Format, aber Gos Reader-Typ muss sich mühsam durch die Zeilen der Datei arbeiten, auf das Dateiende prüfen (Zeile 36) oder etwaige Lesefehler behandeln. Da die erste Zeile im CSV-Format die Spaltennamen auflistet, steuert die Logik ab Zeile 43 mit der Bool-Variablen firstLine
daran vorbei. Mit ParseFloat()
und ParseInt()
und der jeweiligen Bitpräzision (32 bzw. 64 Bit) sowie der Basis 10 für den Integer fieseln die Zeilen 49 bis 56 dann die Spaltenwerte heraus und Zeile 58 füllt die Struktur vom Typ Tour
damit. Zeile 65 hängt den Einzeleintrag an das Array-Slice mit allen Zeilendaten aus der CSV-Datei an, und weiter geht's in die nächste Runde.
01 package main 02 03 import ( 04 "flag" 05 "fmt" 06 "github.com/fatih/color" 07 geo "github.com/kellydunn/golang-geo" 08 ) 09 10 func main() { 11 home := geo.NewPoint(37.751051, -122.427288) 12 13 gain := flag.Int("gain", 0, "elevation gain") 14 radius := flag.Float64("radius", 0, "radius from home") 15 mins := flag.Int("mins", 0, "hiking time in minutes") 16 17 flag.Parse() 18 flag.Usage = func() { 19 fmt.Print(`hikefind [--gain=max-gain] [--radius=max-dist] [--mins=max-mins]`) 20 } 21 22 tours, err := readCSV() 23 if err != nil { 24 panic(err) 25 } 26 27 for _, tour := range tours { 28 if *gain != 0 && tour.gain > *gain { 29 continue 30 } 31 32 start := geo.NewPoint(tour.lat, tour.lng) 33 dist := home.GreatCircleDistance(start) 34 if *radius != 0 && dist > *radius { 35 continue 36 } 37 if *mins != 0 && tour.mins > *mins { 38 continue 39 } 40 fmt.Printf("%s: [%s:%s:%s]\n", 41 tour.name, 42 color.RedString(fmt.Sprintf("%dm", tour.gain)), 43 color.GreenString(fmt.Sprintf("%.1fkm", dist)), 44 color.BlueString(fmt.Sprintf("%dmins", tour.mins))) 45 } 46 }
Das Hauptprogramm in Listing 5 versteht die Filter-Flags --gain
(maximaler Höhenanstieg in Metern), --radius
(maximale Entfernung vom Wohnort, dessen Koordinaten in home
in Zeile 11 definiert sind) und --mins
(maximale Tourdauer in Minuten), die entweder Fließkomma- oder Integerwerte vom User entgegennehmen und hikefind
dazu veranlassen, die Touren aus der CSV-Metadatei entsprechend zu filtern.
Die For-Schleife ab Zeile 27 iteriert über alle mittels readCSV()
in Zeile 22 eingelesenen Tourmetadaten und setzt die drei implementierten Filter gain
, radius
und mins
an. Die Entfernung vom Wohnort prüft der Radius-Filter mit Hilfe des Github-Pakets kellydunn/golang-geo
, das mit Hilfe der Funktion GreatCircleDistance()
die Entfernung der beiden Geopunkte in Kilometern ermittelt und anschließend das numerische Ergebnis mit dem eingestellten Filterwert vergleicht.
Springt einer der drei Filter an, geht die For-Schleife mittels continue
ohne Ausgabe in die nächste Runde, passiert ein Eintrag hingegen alle Filter unbeschadet, gibt die Print-Anweisung ab Zeile 40 die Tour aus.
Damit die Metawerte der ausgedruckten Touren später schön ins Auge springen, zieht Listing 5 anfangs das Paket fatih/color
von Github herein, das Funktionen zur Ausgabe der in Terminals üblichen Ansi-Farbcodes bereitstellt.
Abbildung 5: Ohne Optionen listet hikefind alle Trails auf |
Abbildung 6: Auswahl nach Gusto des Users |
Kompiliert werden die Listings 4 und 5 wie immer mit dem Dreisprung
$ go mod init hikefind
$ go mod tidy
$ go build hikefind.go csvread.go
und heraus kommt ein Binary hikefind
, das entweder alle Touren ausgibt (Abbildung 5 ohne Kommanozeilenoptionen) oder mit beliebigen Kombinationen aus den verschiedenen Filtertypen eine entsprechend schmälere Auswahl vorschlägt. Abbildung 6 zeigt alle Wanderwege im Umkreis von 10 Kilometern meiner Wahlheimat San Francisco, die weniger als 100 Höhenmeter hochgehen und höchstens 60 Minuten lang sind. Übrig bleiben ganze drei Routen, es ist halt doch eine recht hügelige Stadt.
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2023/01/snapshot/
Michael Schilli, "Wandern nach Plan": Linux-Magazin 09/21, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2021/09/snapshot/<U>
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc