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.
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. |
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. |
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.
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. |
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 }
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ß.
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 );
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.
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.
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>
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. |
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ß!
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2016/03/Perl
Google Chart API, https://developers.google.com/chart/interactive/docs/quick_start?hl=en
Google Maps API, https://developers.google.com/maps/?hl=en