Pfadfinder (Linux-Magazin, Februar 2023)

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

Lokal gesichert

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.

Listing 1: tour-names.csv

    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

Schwindelnde Höhen

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.

Listing 2: climb.r

    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.

Nur bergauf zählt

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.

Listing 3: preproc.r

    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)

Mehr Spalten

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.

Erste Zeile Tourstart

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.

Anfang und Ende

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.

Listing 4: csvread.go

    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.

Listing 5: hikefind.go

    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.

Das Ganze in Farbe

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.

Infos

[1]

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

[2]

Michael Schilli, "Wandern nach Plan": Linux-Magazin 09/21, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2021/09/snapshot/<U>

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