Fit wie ein Turnschuh (Linux-Magazin, Juli 2013)

Mit einem kleinen GPS-Empfänger am Arm joggt der rasende Perl-Reporter diesen Monat durch das Umland von San Francisco und bereitet die gesammelten Laufdaten mit Perl-Skripts grafisch auf.

Ein tragbares GPS-Gerät von der Größe einer Armbanduhr wie der Garmin Forerunner 10 eignet sich dazu, während eines Jogginglaufs laufend Ortskoordinaten aufzuzeichnen. So weiß der Läufer jederzeit, wie schnell er gerade unterwegs ist, und ob er zulegen muss oder nachlassen darf, um ein gestecktes Zeitziel zu erreichen. Nach abgeschlossener Sportaktivität kann er dann eventuell genußvoll neue Geschwindigkeitsrekorde verbuchen, auf einer Landkarte die abgespulten Kilometer Revue passieren lassen oder ein Höhenprofil der Strecke bestaunen.

Dicke Armbanduhr

Bis vor wenigen Jahren sahen tragbare GPS-Geräte noch aus wie Mobiltelefone aus den frühen Neunzigern, der kleine Garmin ist aber bereits auf die Größe einer LED-Digitaluhr aus den Siebzigern geschrumpft (Abbildung 1). Ein unter Ubuntu in den USB-Eingang eingestöpseltes Gerät erkennt der Linux-Kernel sofort als Speichereinheit und mounted die auf dem Gerät gespeicherten Dateien unter /media/GARMIN. Dazu ist allerdings ein spezielles Adapterkabel notwendig, das sich wie die Kreatur aus dem Film Alien an die GPS-Uhr anschmiegt und dem Gerät beim Kauf beliegt. Im Verzeichnis GARMIN/ACTIVITY stehen dann proprietäre .FIT-Dateien, in denen die Firma Garmin gespeicherte Bewegungsaktivitäten in einem Binärformat ablegt.

Diese Dateien kann der Sportler nun anschließend unter einem neuangelegten Account auf die Garmin-Website http://connect.garmin.com hochladen und bekommt dort die Laufdaten schön angezeigt (Abbildung 2). Das Beispiel zeigt eine etwa 7 Kilometer lange Route rund um den Lake Merced, einem Binnensee nahe des pazifischen Ozeans etwas südlich von San Francisco, die ich für den Perl-Snapshot in etwa 40 Minuten gerannt bin. Zu bestaunen ist an der Grafik nicht nur die Gesamtzeit, sondern auch die durchschnittliche Zeitspanne pro Meile (8:45 Minuten) und die überwundenen Höhenmeter (300 Fuß). Wer keine amerikanischen Einheiten mag, kann auf der Website natürlich auch Kilometer und Höhenmeter einstellen.

Abbildung 1: Der armbanduhrgroße GPS-Empfänger zeichnet beim Joggen die Geo-Koordinaten der zurückgelegten Strecke mit Zeitstempeln auf.

Abbildung 2: Auf der Webseite http://connect.garmin.com kann der Sportler seine .FIT-Daten hochladen und grafisch anzeigen.

Sechs-Sekunden-Takt

Das kleine GPS-Gerät ermittelt etwa alle sechs Sekunden die Position des Läufers mit Hilfe von erdumkreisenden Navigationssatelliten und speichert die Datenpunkte in geografischer Breite und Länge ab. Außerdem misst es die aktuelle Geschwindigkeit und die zurückgelegte Strecke. Das unter [3] erhältliche Perl-Modul Garmin::FIT kann die .FIT-Datei lesen und Abbildung 3 zeigt die von der dem Modul beiliegenden Utiliy fitdump ausgespuckten Daten.

Der Garmin 10 zeichnet übrigens (anders als andere GPS-Geräte) keine Schwankungen der Meereshöhe während des Laufs auf. Anscheinend ist das satellitentechnisch aufwändiger, aber die Garmin-Webseite kann die Lücken im Bewegungsprofil basierend auf den Geo-Koordinaten mühelos füllen, denn sie nutzt ein serverseitig statisch vorliegenden Höhenprofil, das wohl für alle bewohnten Punkte der Erde deren Höhe über dem Meeresspiegel weiß. Wer nicht gerade in einem Hochhaus die Treppen hochläuft, sollte also auch präzise Höheninformationen zur Laufstrecke erhalten.

Abbildung 3: Die aus dem Fit-Format ausgelesenen Rohdaten zeigen, dass das Gerät etwa alle sechs Sekunden geografische Breite, Länge, die aktuelle Geschwindigkeit, und die zurückgelegte Strecke misst.

Selbst ist der Mann

Aber selbst die schönste Webseite lässt sich noch verbessern und manch einer möchte die Daten gar für esoterisch anmutende Zwecke ummodeln. Damit Open-Source-Freunde das Format nicht mühselig entschlüsseln müssen, liegt unter [2] das Garmin-SDK vor, das zwar keine Perl-API definiert, aber doch alle verwendeten Felder dokumentiert. Der Entwickler Kiyokazu Suto hat daraus ein Perl-Modul gebaut, es allerdings (noch) nicht aufs CPAN hochgeladen. Er stellt es statt dessen auf seiner Webseite ([3]) unter einer Public-Domain-artigen Lizenz zur Verfügung.

Listing 1: fittest

    01 #!/usr/bin/perl
    02 use warnings;
    03 use strict;
    04 use Garmin::FIT;
    05 
    06 my $fit = Garmin::FIT->new();
    07 $fit->file( "354I2029.FIT" );
    08 $fit->data_message_callback_by_name('', 
    09     \&dump_it);
    10 
    11 $fit->open();
    12 $fit->fetch_header();
    13 1 while $fit->fetch();
    14 
    15 ###########################################
    16 sub dump_it {
    17 ###########################################
    18   my ($self, $desc, $v) = @_;
    19 
    20   if( $desc->{message_name} ) {
    21     print "$desc->{message_name} ";
    22   } else {
    23     print "Unknown ";
    24   }
    25 
    26   print "($desc->{message_number}):\n";
    27 
    28   $self->print_all_fields($desc, $v, 
    29       indent => '  ');
    30 }

Trotz offengelegtem Format ist das Herausfieseln der Daten aus dem Binärwust allerdings eine Sisyphus-Arbeit. Das Modul Garmin::FIT bietet eine Methode print_all_fields() an, mit der man schnell einen Dumper wie Listing 1 zusammenklopfen kann, der die Ausgabe in Abbildung 3 erzeugt. Zur Weiterverarbeitung der Daten müssen Entwickler allerdings tiefer bohren und eigene Funktionen schreiben. Listing 2 schickt sich deshalb an, die .FIT-Datei in eine leichter lesbare YAML-Datei umzuformen.

Fieseln aus FIT

Dazu lädt Zeile 12 eine auf der Kommandozeile übergebene .FIT-Datei ins Modul Garmin::FIT. Ziel des dann folgenden Verfahrens ist es, einen Perl-Array mit den Daten der "record"-Einträge der .FIT-Datei anzulegen und ihn dann mit der Funktion DumpFile() aus dem YAML-Modul als YAML-Daten in eine gleichnamige Datei mit der Endung .yaml hineinzupusten. Zum Durchforsten der FIT-Einträge definiert Zeile 16 mit der Methode data_message_callback_by_name() einen Callback, den der Parser bei jedem gefundenen Eintrag anspringt.

Die ab Zeile 30 definierte Funktion message erledigt die Fieselarbeit und setzt die zahlreichen Einzelfelder eines Eintrags zu einem sinnvollen Ganzen zusammen. Wie in Abbildung 4 zu sehen ist, liegt der Wert für die zurückgelegte Strecke nicht etwa im Eintrag distance der dem Callback überreichten Variable $v, sondern es existieren eine ganze Reihe von Werten in einer zweiten Variablen, $desc, die der Parser umständlich kombinieren muss, um zum gesuchten Wert zu gelangen. So steht in $desc unter dem Schlüssel i_distance ein Indexwert (4), unter dem der numerische Wert für die gesuchte Entfernung in $v steht (also $v[4]). Dieser wird dann mit einer Einheit ("m" für Meter) kombiniert und das Resultat mittels der Methode value_cooked() des Moduls Garmin::FIT erzeugt. Die Methode benötigt allerdings noch die Werte für a_distance (Skalierung/Einheit) und I_distance (Gültigkeitsbereich), damit sie endlich den gesuchten Wert zusammenbauen kann. Da das Format alle möglichen Daten auf kleinstem Raum speichern kann, ist dieses an Buchbinder Wanninger erinnernde Verfahren wohl notwendig.

Listing 2: fit2yaml

    01 #!/usr/bin/perl
    02 use warnings;
    03 use strict;
    04 use Garmin::FIT;
    05 use YAML qw( DumpFile );
    06 
    07 my( $file ) = @ARGV;
    08 die "usage: $0 fit-file" if !defined $file;
    09 ( my $yaml_file = $file ) =~ s/.fit$/.yaml/i;
    10 
    11 my $fit = Garmin::FIT->new();
    12 $fit->file( $file );
    13 
    14 my $messages = [];
    15 
    16 $fit->data_message_callback_by_name( '', 
    17   sub { 
    18       my $msg = message( @_ );
    19       push @$messages, $msg if $msg;
    20       return 1;
    21   } );
    22 
    23 $fit->open();
    24 $fit->fetch_header();
    25 1 while $fit->fetch();
    26 
    27 print DumpFile( $yaml_file, $messages );
    28 
    29 ###########################################
    30 sub message {
    31 ###########################################
    32   my ( $fit, $desc, $v ) = @_;
    33 
    34   if( !$desc->{ message_name } or
    35       $desc->{ message_name } ne
    36       "record" ) {
    37       return undef;
    38   }
    39 
    40   my $m = {};
    41 
    42   foreach my $i_name ( keys %$desc ) {
    43 
    44     next if $i_name !~ /^i_/;
    45     (my $name = $i_name ) =~ s/^i_//g;
    46 
    47     my $pname   = $name;
    48     my $attr    = $desc->{'a_' . $name};
    49     my $i       = $desc->{$i_name};
    50     my $invalid = $desc->{'I_' . $name};
    51 
    52     $m->{ $pname } = 
    53         $fit->value_cooked(
    54             "", $attr, "", $v->[$i] );
    55   }
    56 
    57   return $m;
    58 }

Abbildung 4: Das eigenwillige Garmin-Format.

Keep it Simple

Der Einfachheit halber müht sich Listing 2 erst gar nicht mit allen erlaubten Datenformaten ab, sondern konzentriert sich auf die record-Einträge, die die alle sechs Sekunden gemessenen Laufdaten enthalten. Das Gerät speichert auch weitere Events, wie zum Beispiel wann und wo der "Start"-Knopf gedrückt wurde, aber das soll hier nicht weiter interessieren. Abbildung 5 zeigt die fertigen YAML-Daten, eine Liste von Records, die jeweils die Felder distance (seit Start zurųckgelegte Strecke in km), position_lat (geografische Breite), position_long (geografische Länge), speed (Geschwindigkeit in m/s) und timestamp (aktuelle Uhrzeit) führen.

Abbildung 5: Die GPS-Daten des Garmin im YAML-Format.

Virtueller Konkurrent

Mit den YAML-Daten lassen sich nun ganz einfach Applikationen in Perl und anderen Sprachen schreiben, die die gespeicherten GPS-Daten interpretieren. Auf der Garmin-Webseite vermisste ich zum Beispiel eine Funktion eines älteren GPS-Geräts. Meine bislang verwendete GPS-Joghilfe (ein Garmin Forerunner 101) verfügte über eine Funktion, die ich beim neuen und leichteren Garmin 10 schmerzlich vermisste: den "Virtual Runner". Vor dem Lauf programmiert man dazu die Geschwindigkeit eines virtuellen Laufkumpans ein, der mit konstanter Geschwindigkeit läuft und per Definition exakt zur gewünschten Zeit die Ziellinie überschreitet. Während des Laufs zeigt dann das GPS-Gerät an, wo der virtuelle Läufer sich gerade befindet. Ist er 100m voraus, gilt es, Gas zu geben, denn nur wer auf gleicher Höhe mit dem virtuellen Kumpan trabt, wird zur gewünschen Zeit durch die Ziellinie spurten. Ist der Virtual Runner hingegen zurückgefallen, ist der Läufer so schnell unterwegs, dass er getrost die Laufgeschwindigkeit reduzieren und das hart erarbeitete Zeitpolster abschmelzen kann.

Listing 3: vrunner

    01 #!/usr/bin/perl
    02 use strict;
    03 use warnings;
    04 use Imager;
    05 use Imager::Plot;
    06 use YAML qw( LoadFile );
    07 
    08 my $plot = Imager::Plot->new(
    09   Width  => 600,
    10   Height => 400,
    11   GlobalFont => '/usr/share/fonts/' .
    12    'truetype/freefont/FreeSans.ttf');
    13 
    14 my( $first, $second ) = @ARGV;
    15 die "usage: $0 1.yaml 2.yaml" if
    16   !defined $second;
    17 
    18 my @first_pairs = data_extract( $first );
    19 my @second_pairs = data_extract( $second );
    20 
    21 data_set_add( 
    22     $plot, \@first_pairs, "green" );
    23 data_set_add( 
    24     $plot, \@second_pairs, "red" );
    25 
    26 my $img = Imager->new(xsize => 650,
    27                       ysize => 450);
    28 
    29 $img->box(filled => 1, color => 'white');
    30 
    31     # Add text
    32 $plot->{'Ylabel'} = 'Distance';
    33 $plot->{'Xlabel'} = 'Time';
    34 $plot->{'Title'}  = 'Fast Pace vs. Slow';
    35 
    36 $plot->Render(Image => $img,
    37     Xoff => 40, Yoff => 420);
    38 
    39 $img->write(file => "vrunner.png");
    40 
    41 ###########################################
    42 sub data_extract {
    43 ###########################################
    44   my( $file ) = @_;
    45 
    46   my $base;
    47   my @pairs = ();
    48 
    49   my $data = LoadFile( $file );
    50   for my $record ( @$data ) {
    51     my $time = $record->{ timestamp };
    52     my $dist = $record->{ distance };
    53     $base = $time if !defined $base;
    54     $dist =~ s/\D+$//;
    55     push @pairs, [ $time - $base, $dist ];
    56   }
    57 
    58   return @pairs;
    59 }
    60 
    61 ###########################################
    62 sub data_set_add {
    63 ###########################################
    64   my( $plot, $data, $color ) = @_;
    65 
    66   $plot->AddDataSet( 
    67     X => [ map { $_->[ 0 ] } @$data ],
    68     Y => [ map { $_->[ 1 ] } @$data ],
    69     style => {
    70       marker => {
    71         size => 2,
    72         symbol => "circle",
    73         color  => 
    74           Imager::Color->new($color),
    75       }
    76     }
    77   );
    78 }

Abbildung 6: Der gemütliche Jogger (rot) verliert stetig gegen den Läufer, der alles gibt.

Listing 3 implementiert eine daran angelehnte Funktion mittels zweier ins YAML-Format umgewandelter Fit-Dateien von zwei verschiedenen Läufen. Sie liest die distance und timestamp-Einträge aus und zeichnet ein Diagramm mit den von beiden Läufern zu bestimmten Zeitpunkten zurückgelegten Wegstrecken. Die X-Achse zeigt die vergangene Laufzeit in Sekunden, während die Y-Achse die von den Läufern zurückgelegte Wegstrecke in Metern anzeigt. Beide im Beispiel verwendeten Fit-Dateien beruhen auf wahren Daten, nach dem Lauf in Abbildung 2 absolvierte der Perlmeister einen weiteren Trainingslauf. Er wollte herausfinden, ob ein etwas langsameres Tempo auf den ersten paar Kilometern mehr Kraft für den Endspurt und einige kleinere Hügel (Höhenunterschied etwa 30m) lassen würde.

Uhrwerk oder Gaul?

Das CPAN-Modul Imager::Plot zeichnet die als Arrays übergebenen Daten ins Koordinatensystem und beschriftet auch schön die Achsen. Die ab Zeile 42 definierte Funktion data_extract() nimmt eine .yaml-Datei mit den .FIT-Daten entgegen und liefert einen Array zurück, der X-Y-Wertepaare mit gespeicherten Kombinationen aus Zeitstempel und zurückgelegter Wegstrecke enthält. Zeile 54 entfernt die Einheit "m" für "Meter" aus dem Wert für distance, sodass nur der Zahlenwert übrig bleibt. Die Variable $base wird zu Anfang auf den Zeitstempel des ersten Eintrags gesetzt, so dass folgende Zeitstempel nur noch relativ zu diesem Nullpunkt in den Ergebnisarray eingespeist werden können.

Der grüne Graph in Abbildung 6 zeigt den ersten, schnellen Lauf, der rote den zweiten, langsameren. Es stellte sich heraus, dass die sich durch das langsamere Starttempo gesammelten Kraftreserven nicht in eine schnellere zweite Hälfte umsetzen ließen. Einmal in Gang gesetzt, scheine ich zu laufen wie ein Uhrwerk, ohne vorgesetzte Karotte allerdings niemals schneller als notwendig, wie ein gleichgültiger Gaul.

Infos

[1]

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

[2]

FIT SDK: http://www.thisisant.com/resources/fit/

[3]

Das Perlmodul Garmin::FIT auf der Webseite des Autors Kiyokazu Suto: http://pub.ks-and-ks.ne.jp/cycling/GarminFIT.shtml

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, 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.