Unterwegs mit dem Ziegel-Navi (Linux-Magazin, März 2016)

Navigationshandgeräte wie das Garmin 64s schreiben bei Wanderungen laufend die aktuelle Position mit. Diese Daten lesen einige Skripts unter Linux aus und bereiten sie optisch auf.

Jedes Smartphone bietet heute einen GPS-Empfänger und Apps zuhauf, die den Wanderer auf eingeblendeten Landkarten durch die Wildnis leiten. Allerdings geht es in der freien Natur oft etwas rustikal zu, und da kommen robustere und spritzwasserfeste Geräte mit dicken Batterien zum Einsatz. Vor einiger Zeit hatte ich einmal im Online-Sonderangebot ein Garmin 64s gekauft, das mittlerweile etwas in die Jahre gekommen ist, aber aussieht, könnte es durchaus einem darüberfahrenden Fuchs-Panzer standhalten. Wer allerdings von seinem Smartphone intuitive On-Screen-Bedienung gewöhnt ist, reibt sich verwundert darüber die Augen, dass es auch heute tatsächlich noch LCD-Displays mit bizarr gestalteten Menüs gibt, durch die der User einen Cursor mittels einem Dutzend Plastikknöpfe auf der Vorderseite des Gerätes bugsieren muss.

UI für Steam-Punks

Ich wurde schon beim Eingeben eines einzigen Wegpunktes zur Markierung des Trail-Eingangs komplett wahnsinnig. So hätte wohl die Zukunft des Mobiltelefonierens ausgesehen, wenn Bill Gates mit seiner aggressive Monopolpolitik gewonnen hätte, gar nicht auszudenken! Wäre ich Produktdesigner bei Garmin, brächte ich sofort eine an den neuen Mad Max-Film "Fury Road" angelehnte Produktlinie im Steam-Punk-Look heraus.

Abbildung 1: Der GPS-Tracker Garmin-62s

Auch die Software zum Auslesen und Beschreiben des Gerätes läuft nur auf Windows, die Version für den Mac erkannte das eingestöpselte Gerät gar nicht. Aber da mein Ubuntu-System sofort darauf ansprang, als ich den USB-Stecker einschob (Syslog in Abbildung 2), und die Datenbereiche des Geräts auf zwei gemounteten Platten offenlegte, beruhigte ich mich sofort wieder, und beschloss, das Gerät auf Wanderungen einfach eingeschaltet mitzuführen. Denn auch ohne Zutun des Users schreibt es alle paar Sekunden einen Eintrag in eine sogenannte Tracks-Datei, die festhält, zu welchem Zeitpunkt sich das Gerät an welcher Position befindet. Es schreibt neben dem Zeitstempel die geografischen Längen- und Breitengrade mit, sowie die aktuelle Höhe über dem Meeresspiegel.

Abbildung 2: Nach dem Einstöpseln des USB-Steckers erkennt Ubuntu das Navigationsgerät sofort und mountet dessen internen Speicher als Festplatte.

Open Source statt Windows

Diese Werte legen Garmin-Geräte in einem XML-Dialekt namens GPX ab, der sich einfach mit Open-Source-Tools erforschen lässt. Abbildung 3 zeigt die auf dem Gerät liegenden Dateien. Die Tracks-Datei Track_2015-12-31 130304.gpx enthält die gesuchten über die Zeit aufgetragenen Aufenthaltsorte. Das XML zu Parsen ist nicht weiter schwierig, und ein CPAN-Autor hat sich schon die Mühe gemacht, alles sauber in das Modul Geo::Gpx zu verschnüren, sodass Listing 1 nach dem Herunterladen des Moduls nur noch wenige Zeilen benötigt, um das Ganze ins Yaml-Format zu überführen, das sich sowohl einfach visuell inspizieren als auch maschinell weiterverarbeiten lässt.

Abbildung 3: GPX-Dateien auf dem Garmin 62s

Die Ausgabe in Abbildung 4 zeigt zu jedem Zeitpunkt (time) in Unix-Sekunden die geografische Länge (lon für Longitude), Breite (lat für Latitude) und die Höhe über dem Meeresspiegel in Fuß (ele für Elevation) an. Falls gewünscht lässt sich das Gerät in den Settings auch auf metrische Maßeinheiten umstellen.

Abbildung 4: Listing 1 wandelt die GPX-Daten der Tracks-Datei in das augenfreundlichere Yaml-Format um.

Listing 1: gpx2yaml

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Geo::Gpx;
    04 use YAML qw( Dump );
    05 
    06 my( $file ) = @ARGV;
    07 die "usage: $0 file" if !defined $file;
    08 
    09 open my $fh, "<$file" or die $!;
    10 my $gpx = Geo::Gpx->new( input => $fh );
    11 
    12 print Dump $gpx->tracks->[ 0 ]->
    13    { segments }->[ 0 ]->{ points };

Listing 1 nutzt hierzu die Module Geo::Gpx und YAML vom CPAN, letzteres exportiert auf Anfrage die Methode Dump, die eine verschachtelte Datenstruktur als Yaml ausgibt, in diesem Fall der von Geo::Gpx gelieferte Array von Hashes unter tracks, segments und schließlich points im GPX-Salat. Die nachfolgend vorgestellten Skripts in der Verarbeitungskette lesen die Yaml-Ausgabe per Unix-Pipe und der Funktion Load() des YAML-Moduls ein und arbeiten mit der vereinfachten Datenstruktur weiter. Ihre Ausgabe verpacken sie ebenfalls wieder ins Yaml-Format, sodass sich beliebig viele solcher Skripts in guter alter Unix-Manier hintereinander hängen lassen, und jedes sich auf eine spezielle Aufgabe konzentriert.

Fauler Operator

Die nächste Verarbeitungsstufe kümmert sich darum, relevante Teile aus der Tracks-Datei herauszuschneiden. Wegen einer Mischung aus Operator-Trägheit und Vergesslichkeit ging ich kein einziges Mal in das Tracks-Menü des Geräts, um die Tracks eines Tages entweder zu löschen oder auf den Namen einer Tour (z.B. "Grand Canyon") umzuspeichern, sondern ließ es einfach während des gesamten Urlaubs munter in die gleiche Tracks-Datei weiterschreiben. Das Skript tours in Listing 2 schnappt sich deswegen die in Yaml umgemodelten GPX-Daten mittels gpx2yaml *.GPX | tours aus der Unix-Pipe und teilt sie in verschiedene Tagestouren auf, die es jeweils daran erkennt, dass zwischen zwei Einträgen fünf Stunden ohne jede Aktivität verstrichen sind, während denen das Gerät höchstwahrscheinlich ausgeschaltet war.

Abbildung 5: Das Skript tours extrahiert einzelne Touren aus den Tracking-Daten.

Abbildung 5 zeigt die von 01 bis 19 durchnumerierten Touren jeweils mit Start- und Endzeit, die das Skript deshalb anzeigt, weil es ohne Parameter aufgerufen wurde. Um eine bestimmte Tagestour herauszupicken, wählt der User diese per angezeigter Tournummer aus. Mit

    ... | tour --tour=18

kommen hinten (natürlich wieder als Yaml) die Trackdaten der Tour vom 30. Dezember 2015, an dem der Autor samt Frau einige Meilen des vereisten "Bright Angel Trails" im Grand Canyon hinunter- und wieder heraufstiefelte (Abbildung 6). Die Ausgabe von Listing 2 erscheint wie die von Abbildung 4, nur eben mit weniger Daten, da alles außer dem 30. Dezember im Tourenfilter festsitzt.

Abbildung 6: Mit dem Ziegel-Navi auf dem Bright Angels Trail im Grand Canyon.

Listing 2: tours

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Getopt::Long;
    04 GetOptions( \my %opts, "tour=s" );
    05 use YAML qw( Load Dump );
    06 
    07 my @tours           = ( );
    08 my $tour;
    09 my $tracks          = Load join "", <>;
    10 my $tour_split_secs = 60 * 60 * 5;
    11 
    12 for my $point ( @$tracks ) {
    13 
    14     # next tour?
    15   if( defined $tour and
    16     $point->{ "time" } > 
    17     $tour->{ end } + $tour_split_secs ) {
    18     undef $tour;
    19   }
    20 
    21     # start new tour?
    22   if( !defined $tour ) {
    23     $tour = {
    24       start  => $point->{ "time" }, 
    25       points => [] };
    26     push @tours, $tour;
    27   }
    28 
    29     # next point in current tour
    30   $tour->{ end } = $point->{ "time" };
    31   push @{ $tour->{ points } }, $point;
    32 }
    33 
    34 if( $opts{ tour } ) {
    35     print Dump( $tours[ 
    36         $opts{ tour } - 1 ]->{ points } );
    37 
    38 } else {
    39   my $idx = 1;
    40   for my $tour ( @tours ) {
    41     printf "tour %02d: %s - %s (%d)\n", 
    42       $idx, map( { scalar localtime $_ }
    43       ( $tour->{ start }, $tour->{ end } )
    44       ), scalar @{ $tour->{ points } };
    45     $idx++;
    46   }
    47 }

Touren extrahieren

Eine "Tour" definiert Listing 2 mittels des ersten Eintrags ("start"), des letzten Eintrags ("end"), sowie einer Reihe von zugehörigen Track-Points ("points") mit Geodaten und Zeitstempel. Es wandert durch Einträge der auf der Standard-Eingabe hereinpurzelnden Yaml-Daten, weist die Daten der aktuellen Tour zu und fängt eine neue Tour an, falls vor einem Eintrag eine Pause von länger als 60 * 60 * 5 Sekunden, also 5 Stunden (aus Zeile 10) lag. Das if-Konstrukt in Zeile 34 prüft, ob das Skript mit der Option --tour samt Tournummer aufgerufen wurde und druckt in diesem Fall die Yaml-Ausgabe dieser Tour. Falls keine Kommandozeilenoptionen vorliegen, springt das Skript in den else-Zweig ab Zeile 38 und gibt die Metadaten aller Touren wie in Abbildung 5 gezeigt aus.

Nun ist allerdings eine "Tour" keineswegs auf Aktivitäten während einer Bergwanderung beschränkt. Falls der User vergisst, das Navigationsgerät nach Abschluss des Gipfelsturms wieder auszuschalten, registriert es auch noch die anschließenden Bewegungen auf der Autofahrt nach Hause. Diese als körperliche Aktivität auszugeben wäre natürlich grob unsportlich, und außerdem stören die Daten beim späteren Eintragen in eine Landkarte, da das Auto bekanntlich weit längere Strecken in kürzerer Zeit zurücklegt als ein Wanderer zu Fuß.

Listing 3: hike-find

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use YAML qw( Load Dump );
    04 use Geo::Distance;
    05 
    06 my $tour    = Load( join "", <> );
    07 my @markers = ();
    08 
    09 my $geo = Geo::Distance->new();
    10 my $last_pt;
    11 
    12 for my $point ( @{ $tour } ) {
    13 
    14   if( $last_pt ) {
    15 
    16     my $k = $geo->distance("meter",
    17       $last_pt->{lon}, $last_pt->{lat},
    18       $point->{lon}, $point->{lat} );
    19 
    20     push @markers, $point;
    21 
    22     my $time_diff = 
    23      $point->{ time } - $last_pt->{ time };
    24 
    25     if( $k > 1_000 ) {
    26       @markers = ();
    27       $last_pt = $point;
    28       next;
    29     }
    30 
    31     my $speed = 1.0 * $k / $time_diff;
    32     if( $speed > 5 ) {
    33       last;
    34     }
    35   }
    36 
    37   $last_pt = $point;
    38 }
    39 
    40 print Dump( \@markers );

Autofahrt ausblenden

Ein weiterer Filter in Listing 3 sucht deshalb aus den Track-Daten einer Tour die erste Wanderung ("Hike") heraus. Mittels des CPAN-Moduls Geo::Distance ermittelt es die Distanz zwischen zwei aufeinanderfolgenden Messpunkten in den Track-Daten. Es nutzt ein wenig Geometrie, um den Abstand zweier per geografischer Länge und Breite angegebener Punkte auf der Erdoberfläche zu ermitteln und gibt das Ergebnis wegen des Parameters "meters" in Zeile 16 in Metern aus. Falls diese Distanz größer als ein Kilometer ist, verwirft Zeile 26 alle bisher aufgezeichneten Werte, um sich auf die folgenden Einträge zu konzentrieren. Zeile 31 berechnet die aktuelle Geschwindigkeit des Wanderers, indem es die ermittelte Distanz zwischen zwei aufeinander folgenden Track-Punkten durch die zwischen beiden Messpunkten verstrichene Zeit in Sekunden teilt. Ist dieser Werte größer als 5 Meter pro Sekunde, hat der Wanderer die Strecke wohl kaum zu Fuß zurückgelegt und sitzt bereits im Auto auf dem Weg nach Hause.

In diesem Fall bricht das last-Kommando in Zeile 33 ab, und am Skriptende druckt die Dump-Funktion in Zeile 40 alle bislang im Array @markers gesammelten Track-Daten im Yaml-Format für die nächste Verarbeitungsstufe aus.

Karten zeichnen

Um die Track-Daten zu visualisieren, erzeugen zwei weitere Skripts zwei verschiedene Darstellungen: Listing 4 nutzt die Google-Maps-API, um eine HTML-Darstellung eines Satellitenfotos farblich mit den Track-Punkten anzureichern, und Listing 5 zeichnet mit Hilfe der Google-Charts-API ein Höhenprofil der Wanderung.

Abbildung 7: Track-Spuren auf dem Bright Angel Trail im Grand Canyon.

Abbildung 7 zeigt die mit Hilfe der Google-Maps-API in das Satellitenfoto eingezeichneten Track-Daten des Navigationsgeräts. Insgesamt haben die Daten hierzu vier Filterskripts durchlaufen, bis das letzte schließlich die HTML-Daten erzeugte, die ein auf die erstellte Datei map.html gerichteter Browser ins rechte Licht rückte:

     gpx2yaml *gpx | tours --tour=18 | hike-find | map-draw >map.html

Im unteren DATA-Bereich des Skripts map-draw steht HTML-Text mit JavaScript-Code, der mit dem Google-Server kommuniziert, das Satellitenbild auf den Anfangspunkt des Trips zentriert und die Track-Koordinaten einträgt. Das sogenannte Overlay google.maps.Polyline verbindet alle Koordinaten mit roten Linien der in Zeile 59 festgelegten Farbe #f9290c, einem leuchtenden Rot. Dies alles läuft in der Javascript-Funktion initialize() ab Zeile 44 ab, und der Browser stößt letztere asynchron mit addDomListener an, sobald die Seite geladen wurde.

Das Perlskript map-draw flickt mittels des CPAN-Moduls Template::Toolkit dynamisch hereingereichte Koordinaten in den HTML/Javascript-Block, die Platzhalter [% center %] bzw. [% mappoints %] ersetzt das Script durch den ersten bzw. den Array aller mittels google.maps.LatLng aufgemotzter Trackpunkt-Objekte.

Wer mit Googles Map-API nur spielen möchte und weniger als 1000 Abfragen am Tag abfeuert, braucht sich nicht zu registrieren, für mehr ist ein Account mit API-Key erforderlich. Wer Abbildung 7 studiert, sieht die kleinen Geradenstücke, die die diskreten Punkte des Trackers miteinander verbinden, sodass ein zittriger Pfad ensteht. Bei ganz genauem Hinsehen zeigt sich, dass Hin- und Rückweg auf demselben Pfad verlaufen, und beide streckenweise leicht voneinander abweichen.

Listing 4: map-draw

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Template;
    04 use YAML qw( Load );
    05 
    06 my $trip = Load( join "", <> );
    07 my $data = join "", <DATA>;
    08 
    09 my $mappoints = join ", ", 
    10   map {
    11     mappoint( $_->{ lat }, $_->{ lon } )
    12   } @$trip;
    13 
    14 if( scalar @$trip <= 1 ) {
    15     die "Need at least one track point";
    16 }
    17 
    18 my $first = $trip->[0];
    19 my $center = 
    20   mappoint( 
    21     $first->{ lat }, $first->{ lon } );
    22 
    23 my $tmpl = Template->new;
    24 $tmpl->process( \$data, 
    25   { mappoints => $mappoints, 
    26     center => $center } ) or 
    27   die $tmpl->error();
    28 
    29 sub mappoint {
    30   my( $lat, $lon ) = @_;
    31 
    32   return 
    33     "new google.maps.LatLng( $lat, $lon )";
    34 }
    35 
    36 __DATA__
    37 <!DOCTYPE html>
    38 <html>
    39 <head>
    40 <script src="http://maps.googleapis.com/maps/api/js">
    41 </script>
    42 
    43 <script>
    44 function initialize() {
    45   var mapProp = {
    46     center:[% center %],
    47     zoom:16,
    48     mapTypeId:google.maps.MapTypeId.HYBRID
    49   };
    50   
    51   var map=new google.maps.Map(
    52     document.getElementById("googleMap"),
    53     mapProp);
    54 
    55   var tracks=[ [% mappoints %] ];
    56 
    57   var path=new google.maps.Polyline({
    58     path:tracks,
    59     strokeColor:"#f9290c",
    60     strokeOpacity:0.7,
    61     strokeWeight:4
    62   });
    63 
    64   path.setMap(map);
    65 }
    66 
    67 google.maps.event.addDomListener(window, 
    68   'load', initialize);
    69 </script>
    70 </head>
    71 
    72 <body>
    73 <div id="googleMap" 
    74   style="width:1000px;height:600px;"></div>
    75 </body>
    76 </html>

Auf und Nieder

Wieviele Höhenmeter legten die Wanderer zurück? Der Graph in Abbildung 8 offenbart, dass es während der ersten Hälfte der Wanderung steil bergab ging und im zweiten Teil wieder hoch. Der Höhenunterschied belief sich auf etwa 400 Fuß, also 120m. Die Wanderung ging um 9:30 morgens los, die tiefste Stelle wurde zur Halbzeit um 11:30 erreicht, und um 13:00 langten die Wanderer wieder am Einstieg an. Die Tatsache, dass der Weg runterwärts langsamer bewältigt wurde als bergauf, deutet darauf hin, dass es selbst mit Treckingstöcken nicht ganz einfach ist, vereiste Serpentinen bergab zu gehen!

Abbildung 8: Zurückgelegte Höhenmeter während der Wanderung.

Listing 5: elevation-chart

    01 #!/usr/local/bin/perl -w
    02 use strict;
    03 use Template;
    04 use DateTime;
    05 use YAML qw( Load );
    06 
    07 my $tmpl   = Template->new;
    08 my $data   = join "", <DATA>;
    09 my $points = Load( join "", <> );
    10 
    11 my $tracks = "";
    12 
    13 for my $point ( @$points ) {
    14   my $dt = DateTime->from_epoch(
    15     epoch => $point->{ time } );
    16   $dt->set_time_zone( "local" );
    17 
    18   $tracks .= ", " if length $tracks;
    19 
    20   $tracks .= sprintf "[[%d, %d, %d], %d]",
    21     $dt->hour, $dt->minute, $dt->second,
    22     int( $point->{ ele } );
    23 }
    24 
    25 $tmpl->process( \$data, 
    26   { tracks => $tracks } ) or 
    27   die $tmpl->error();
    28 
    29 __DATA__
    30 <!DOCTYPE html>
    31 <html>
    32 <head>
    33 <script type="text/javascript" src="https://www.gstatic.com/charts/loader.js"></script>
    34 <script>
    35 google.charts.load('current', {packages: ['corechart', 'line']});
    36 google.charts.setOnLoadCallback(drawBasic);
    37 
    38 function drawBasic() {
    39 
    40 var data = new google.visualization.DataTable();
    41   data.addColumn('timeofday', 'Time of Day');
    42   data.addColumn('number', 'Elevation');
    43 
    44   data.addRows([ [% tracks %] ] );
    45 
    46   var options = {
    47     hAxis: {
    48       title: 'Time',
    49         format: 'HH:mm',
    50         gridlines: {count: 10}
    51       },
    52       vAxis: {
    53         title: 'Elevation (feet)'
    54       }
    55     };
    56 
    57     var chart = new google.visualization.LineChart(document.getElementById('chart_div'));
    58 
    59     chart.draw(data, options);
    60 }
    61 </script>
    62 
    63 </head>
    64 <body>
    65 <div id="chart_div"></div>
    66 </body>
    67 </html>

Listing 5 nutzt wie Listing 4 das Template-Verfahren, um einen statischen HTML-Block im DATA-Bereich mit Javascript und dynamisch eingeflickten Trackdaten zu beleben. Die Ausgabe des Skripts leitet der User einfach in eine HTML-Datei auf der Festplatte um und lädt diese dann im Browser. Dieser wiederum schickt die Daten an Google, und der Server dort generiert den notwendigen SVG-Firlefanz, um den Graphen zu zeichnen.

Die Typendefinition für eingespeiste Werte und die Beschriftung der zwei Achsen in der farbigen graphischen Darstellung legen die Zeilen 41 und 42 mit addColumn mit "Time of Day" und "Elevation" fest. Erstere ist vom Typ timeofday, einem Array mit Elementen für Stunden, Minuten und Sekunden. Zweitere ist vom Typ number, also ein einfacher Integerwert. Um also den Datenpunkt um 09:04:33 Uhr mit einem Wert von 1993 Fuß über dem Meeresspiegel einzutüten, ruft das Skript folgenden Javascript-Code auf:

    data.addRows([ [[9, 4, 33], 1993], ...

Damit die Beschriftung der X-Achse die vollen Stundenwerte schön formatiert zeigt, setzt Zeile 49 die Format-Option auf format: 'HH:mm'. Zeile 33 lädt nicht mehr als die benötigten Javascript-Dateiein für Line-Charts vom Google-Server. Balken- und Kuchenformen bleiben erstmal außen vor, könnten aber bei Bedarf schnell nachgeladen werden. Wer das Skript elevation-chart in Listing 5 ans Ende der Prozesskette hängt und die Ausgabe in eine Datei umleitet, auf die er dann den Browser richtet, bekommt mit relativ wenig Aufwand eine optisch ansprechende grafische Darstellung, die sich vor allem für Webseiten eignet:

    ... hike-find | ./elevation-chart >ele.html

Da Googles Javascript-API für Charts keine Unix-Sekunden mag, wandelt Listing 5 die Datumsangaben des GPS-Emfpängers mittels des CPAN-Moduls DateTime in richtige Tagesdaten in den Einheiten Stunden, Minuten und Sekunden um. Mit dem CPAN-Werkzeugkasten am Gürtel gehen solche Anpassungen geschwind und zuverlässig von der Hand. Und mit technischer Unterstützung macht das Wandern im Gebirge gleich doppelt Spaß!

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2016/03/Perl

[2]

Google Chart API, https://developers.google.com/chart/interactive/docs/quick_start?hl=en

[3]

Google Maps API, https://developers.google.com/maps/?hl=en

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 der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.