File Coverage

blib/lib/GPS/Tracer.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             #-----------------------------------------------------------------
2             # GPS::Tracer
3             # Authors: Martin Senger
4             # Kim Senger
5             # For copyright and disclaimer see below.
6             #
7             # $Id: Tracer.pm,v 1.2 2007/04/30 21:52:10 senger Exp $
8             #-----------------------------------------------------------------
9              
10             package GPS::Tracer;
11              
12 1     1   45213 use strict;
  1         3  
  1         112  
13 1     1   5 use warnings;
  1         2  
  1         36  
14 1     1   5 use vars qw( $VERSION $Revision $AUTOLOAD );
  1         7  
  1         82  
15              
16 1     1   6 use constant PI => 3.14159;
  1         2  
  1         91  
17 1     1   13 use constant R => 6378700;
  1         2  
  1         40  
18              
19 1     1   1135 use Text::CSV::Simple;
  1         35289  
  1         35  
20 1     1   562 use XML::Simple;
  0            
  0            
21             use LWP::UserAgent;
22             use File::Temp qw/ :POSIX /;
23             use File::Spec;
24             use Date::Calc qw( Add_Delta_Days );
25             use GD::Graph::hbars;
26              
27             $VERSION = '1.2';
28             $Revision = '$Id: Tracer.pm,v 1.2 2007/04/30 21:52:10 senger Exp $';
29              
30             #-----------------------------------------------------------------
31             # A list of allowed attribute names.
32             #-----------------------------------------------------------------
33             {
34             my %_allowed =
35             (
36             user => 1,
37             passwd => 1,
38             from_date => 1,
39             to_date => 1,
40             login_url => 1,
41             data_url => 1,
42             default_id => 1,
43             min_distance => 1,
44             result_dir => 1,
45             result_basename => 1,
46             input_data => 1,
47             input_format => 1,
48             );
49              
50             sub _accessible {
51             my ($self, $attr) = @_;
52             exists $_allowed{$attr};
53             }
54             }
55              
56             #-----------------------------------------------------------------
57             # Deal with 'set' and 'get' methods.
58             #-----------------------------------------------------------------
59             sub AUTOLOAD {
60             my ($self, $value) = @_;
61             my $ref_sub;
62             if ($AUTOLOAD =~ /.*::(\w+)/ && $self->_accessible ("$1")) {
63              
64             # get/set method
65             my $attr_name = "$1";
66             $ref_sub =
67             sub {
68             # get method
69             local *__ANON__ = "__ANON__$attr_name" . "_" . ref ($self);
70             my ($this, $value) = @_;
71             return $this->{$attr_name} unless defined $value;
72              
73             # set method
74             $this->{$attr_name} = $value;
75             return $this->{$attr_name};
76             };
77              
78             } else {
79             die ("No such method: $AUTOLOAD");
80             }
81              
82             no strict 'refs';
83             *{$AUTOLOAD} = $ref_sub;
84             use strict 'refs';
85             return $ref_sub->($self, $value);
86             }
87              
88             #-----------------------------------------------------------------
89             # Keep it here! The reason is the existence of AUTOLOAD...
90             #-----------------------------------------------------------------
91             sub DESTROY {
92             }
93              
94             #-----------------------------------------------------------------
95             # new
96             #-----------------------------------------------------------------
97             sub new {
98             my ($class, @args) = @_;
99              
100             # create an object
101             my $self = bless {}, ref ($class) || $class;
102              
103             # initialize the object
104             $self->init();
105              
106             # set all @args into this object with 'set' values
107             my (%args) = (@args == 1 ? (value => $args[0]) : @args);
108             foreach my $key (keys %args) {
109             no strict 'refs';
110             $self->$key ($args {$key});
111             }
112              
113             # done
114             return $self;
115             }
116              
117             #-----------------------------------------------------------------
118             # init
119             #-----------------------------------------------------------------
120             sub init {
121             my ($self) = shift;
122              
123             # some default values
124             $self->from_date ('0000-00-00 00:00:00'); # format: 2006-10-28 18:02:20
125             $self->to_date ('9999-99-99 23:59:59'); # format: 2006-10-28 18:02:20
126             $self->result_basename ('trout'); # as TRacer OUTput
127             $self->min_distance (500); # in metres
128             $self->input_format ('6,7,8,9'); # column indeces for time, lat, lng, alt
129              
130             }
131              
132             #-----------------------------------------------------------------
133             # toString
134             #-----------------------------------------------------------------
135             sub toString {
136             my $self = shift;
137             require Data::Dumper;
138             return Data::Dumper->Dump ( [$self], ['Tracer']);
139             }
140              
141              
142             # ----------------------------------------
143             # Subroutines
144             # ----------------------------------------
145              
146             my @MONTHS = qw(dummy Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec);
147              
148             sub create_all {
149             my ($self) = @_;
150             my @files = ();
151              
152             my $ra_data = $self->get_data;
153             push (@files, $self->convert2xml ($ra_data));
154             push (@files, $self->oneperday2xml ($ra_data));
155              
156             my $ra_sum = $self->get_summary ($ra_data);
157             push (@files, $self->_summary2csv ($ra_sum));
158             push (@files, $self->_summary2xml ($ra_sum));
159             push (@files, $self->_summary2graph ($ra_sum));
160              
161             my $ra_mindist = $self->_min_distance ($ra_data);
162             push (@files, $self->_min_distance2xml ($ra_mindist));
163             push (@files, $self->_convert2oziwpt ($ra_mindist));
164              
165             return @files;
166              
167             }
168              
169             #
170             # save daily distances to a CSV file
171             #
172             sub summary2csv {
173             my ($self, $ra_data) = @_;
174             return $self->_summary2csv ($self->get_summary ($ra_data));
175             }
176              
177             sub _summary2csv {
178             my ($self, $ra_sum) = @_;
179              
180             my @day_names = @{ $$ra_sum{day_names} };
181             my @day_dists = @{ $$ra_sum{day_dists} };
182              
183             my $filename = $self->_get_filename ('.csv');
184             if (open (CSV, ">$filename")) {
185             print CSV "Date,Metres\n";
186             foreach my $day (0..$#day_names) {
187             print CSV $day_names[$day], ',', $day_dists[$day], "\n";
188             }
189             close CSV;
190             } else {
191             warn "Cannot open file '$filename' for writing: $!\n";
192             }
193             return $filename;
194             }
195              
196             #
197             # save summaries to a short XML file:
198             #
199             #
200             #
201             #
202             sub summary2xml {
203             my ($self, $ra_data) = @_;
204             return $self->_summary2xml ($self->get_summary ($ra_data));
205             }
206              
207             sub _summary2xml {
208             my ($self, $ra_sum) = @_;
209              
210             my @day_names = @{ $$ra_sum{day_names} };
211             my $total_distance = $$ra_sum{total_dist};
212              
213             my $filename = $self->_get_filename ('-summary.xml');
214             my $xs = XML::Simple->new();
215             $xs->XMLout ({ 'total' => { days => (@day_names+0), kms => $total_distance / 1000 } },
216             RootName => 'summary',
217             OutputFile => $filename);
218             return $filename;
219             }
220              
221             #
222             # let's make a chart
223             #
224             sub summary2graph {
225             my ($self, $ra_data) = @_;
226             return $self->_summary2graph ($self->get_summary ($ra_data));
227             }
228              
229             sub _summary2graph {
230             my ($self, $ra_sum) = @_;
231              
232             my @day_names = @{ $$ra_sum{day_names} };
233             my @day_dists = @{ $$ra_sum{day_dists} };
234              
235             # change metres to kilometres (rounded to 100 metres)
236             map { $_ = int (($_ / 1000 + .05) * 10) / 10 } @day_dists;
237              
238             # make sure that missing dates are also listed (with 0 value)
239             if (@day_names > 0) {
240             my $expected_date = $day_names[0];
241             my $i = 0;
242             while ($i < @day_names) {
243             my $current_date = $day_names[$i];
244             while ($current_date gt $expected_date) {
245             $expected_date = $self->increase_date ($expected_date);
246             splice (@day_names, $i, 0, 'no data');
247             splice (@day_dists, $i, 0, 0);
248             $i++;
249             }
250             $i++;
251             $expected_date = $self->increase_date ($expected_date);
252             }
253             }
254              
255             # make dates more human-readable
256             map {
257             if ($_ ne 'no data') {
258             $_ = $MONTHS [substr ($_, 5, 2)] . ' ' . substr ($_, 8, 2);
259             } } @day_names;
260              
261             # if there are no data yet
262             if (@day_dists == 0) {
263             push (@day_dists, 0.001);
264             push (@day_names, 'not yet started');
265             }
266              
267             # create statistical (charts) files
268             my $filename = $self->_get_filename ('-chart.png');
269            
270             my $width = 400;
271             my $height= 100 + 15 * @day_names;
272             my $my_graph = GD::Graph::hbars->new ($width, $height);
273              
274             my (@g_args) = (); # for collecting the graph properties
275             push (@g_args, y_label => "Travelled km");
276             push (@g_args, y_number_format => "%.1f");
277             push (@g_args,
278             title => ' ',
279             x_label => '',
280             bar_spacing => 1,
281             shadow_depth => 0,
282             transparent => 0,
283             show_values => 1,
284             box_axis => 0,
285             r_margin => 5,
286             l_margin => 5,
287             fgclr => 'black',
288             labelclr => 'black',
289             axislabelclr => 'black',
290             dclrs => [ map 'lblue', (0..$#day_names) ],
291             );
292              
293             eval {
294             # plot the chart...
295             $my_graph->set (@g_args);
296             my $my_plot = $my_graph->plot ([ \@day_names, \@day_dists ]);
297              
298             # ...and save it to a file
299             open (IMG, ">$filename")
300             or die "Cannot create file '$filename': $!\n";
301             binmode IMG;
302             print IMG $my_plot->png;
303             close IMG;
304              
305             };
306             warn "Creating a chart failed: " . ($my_graph->error or $@) . "\n" if $@;
307             return $filename;
308             }
309              
310             #
311             sub increase_date {
312             my ($self, $date) = @_;
313             my ($y, $m, $d) = Add_Delta_Days (substr ($date, 0, 4),
314             substr ($date, 5, 2),
315             substr ($date, 8, 2),
316             1);
317             return sprintf "%04u-%02u-%02u", $y, $m, $d;
318             }
319              
320             #
321             # convert degress to radians
322             #
323             sub deg2rad {
324             my ($deg) = @_;
325             return $deg / (180 / PI);
326             }
327              
328             sub acos { atan2 ( sqrt (1 - $_[0] * $_[0]), $_[0]) }
329              
330             #
331             # calculate distance between two points in meters
332             #
333             sub distance {
334             my ($prev_lat, $prev_lng, $curr_lat, $curr_lng) = @_;
335             my $prev_lat_rad = deg2rad ($prev_lat);
336             my $curr_lat_rad = deg2rad ($curr_lat);
337              
338             return R * acos (sin ($prev_lat_rad) * sin ($curr_lat_rad) +
339             cos ($prev_lat_rad) * cos ($curr_lat_rad) *
340             cos (deg2rad ($prev_lng - $curr_lng)));
341             }
342              
343             #
344             # return a hashref with two keys ('day_names' and 'day_dists') where
345             # in both cases the values are arrayrefs of the same size, one with
346             # dates (YYYY-MM-DD), one with day distances (in metres)
347             #
348             sub get_summary {
349             my ($self, $ra_data) = @_;
350              
351             # here we collect returned values
352             my @day_names = ();
353             my @day_dists = ();
354             my $total_distance = 0;
355              
356             my $prev_rec;
357             foreach (@$ra_data) {
358             $day_dists[$#day_dists] += distance ($$prev_rec{'lat'}, $$prev_rec{'lng'},
359             $$_{'lat'}, $$_{'lng'})
360             if defined $prev_rec;
361             $prev_rec = $_;
362             if ($$_{'type'} == 1) {
363             push (@day_names, substr ($$_{'time'}, 0, 10));
364             push (@day_dists, 0);
365             }
366             }
367              
368             map { $total_distance += $_ } @day_dists;
369              
370             return { day_names => \@day_names,
371             day_dists => \@day_dists,
372             total_dist => $total_distance,
373             };
374             }
375              
376             #
377             # convert $ra_data to XML and save the result into
378             # file $filename - using some precaution
379             #
380             sub save2xml {
381             my ($self, $ra_data, $filename) = @_;
382              
383             # backup the old XML file
384             my $backup_file = "$filename.$$";
385             -e $filename and rename $filename, $backup_file;
386              
387             # convert to XML
388             my $xs = XML::Simple->new();
389             $xs->XMLout ({ 'marker' => $ra_data },
390             RootName => 'markers',
391             OutputFile => $filename);
392              
393             # back to the backup file on failure
394             -e $filename or rename $backup_file, $filename;
395             unlink $backup_file;
396             }
397              
398             #
399             # take only the first record of each day
400             #
401             sub oneperday2xml {
402             my ($self, $ra_data) = @_;
403             my @unique = grep { $$_{'type'} == 1 } @{$ra_data};
404              
405             # ...and the quite last record (if not already taken)
406             $self->maybe_add_last_record ($ra_data, \@unique);
407              
408             my $outfile = $self->_get_filename ('-oneperday.xml');
409             $self->save2xml (\@unique, $outfile);
410             return $outfile;
411             }
412              
413             #
414             # return only records with points not too close together
415             # (but kept there the one-per-day points);
416             #
417             sub _min_distance {
418             my ($self, $ra_data) = @_;
419              
420             my $prev_lat = 1000;
421             my $prev_lng = 1000;
422             my @unique =
423             grep { my $curr_lat = $$_{'lat'};
424             my $curr_lng = $$_{'lng'};
425             if ($prev_lat == 1000 or $$_{'type'} == 1) {
426             $prev_lat = $curr_lat; $prev_lng = $curr_lng;
427             1;
428             } else {
429             my $dist = distance ($prev_lat, $prev_lng, $curr_lat, $curr_lng);
430             $prev_lat = $curr_lat; $prev_lng = $curr_lng;
431             $dist > $self->min_distance;
432             }
433             } @{$ra_data};
434              
435             # ...and the quite last record (if not already taken)
436             $self->maybe_add_last_record ($ra_data, \@unique);
437              
438             return \@unique;
439             }
440              
441             #
442             # convert $ra_data to OziExplorer's waypoints and save the result into
443             # file $filename - using some precaution
444             #
445             sub save2oziwpt {
446             my ($self, $ra_data, $filename) = @_;
447              
448             # backup the old WPT file
449             my $backup_file = "$filename.$$";
450             -e $filename and rename $filename, $backup_file;
451              
452             # convert to WPT
453             if (open (WPT, ">$filename")) {
454             local ($\) = "\r\n"; # make newlines as in Windows
455             print WPT 'OziExplorer Waypoint File Version 1.1';
456             print WPT 'WGS 84';
457             print WPT 'Reserved 2';
458             print WPT 'magellan';
459             foreach (@$ra_data) {
460             my @record = ();
461             push (@record, -1); # 1: wpt number
462             push (@record, $self->wpt_name ($$_{'type'}, $$_{'time'})); # 2: wpt name
463             push (@record, $$_{'lat'}); # 3: latitude
464             push (@record, $$_{'lng'}); # 4: longitude
465             push (@record, ''); # 5: date
466             push (@record, $$_{'type'} == 1 ? 10 : 2); # 6: symbol in GPS
467             push (@record, 1); # 7: status
468             push (@record, 4); # 8: map display format
469             push (@record, 0); # 9: foreground color
470             push (@record, $$_{'type'} == 1 ? 4227327 : 5450740); # 10: background color
471             push (@record, $$_{'time'}); # 11: description
472             push (@record, 0); # 12: pointer direction
473             push (@record, 0); # 13: garmin display format
474             push (@record, 0); # 14: proximity distance
475             push (@record, ($$_{'elevation'} or -777)); # 15: altitude
476             push (@record, $$_{'type'} == 1 ? 8 : 6); # 16: font size
477             push (@record, 0); # 17: font style
478             push (@record, 17); # 18: symbol size
479             print WPT join (', ', @record);
480             }
481             close WPT;
482             }
483              
484             # back to the backup file on failure
485             -e $filename or rename $backup_file, $filename;
486             unlink $backup_file;
487             }
488              
489             #
490             # format waypoint name from the given timestamp $date_time
491             # 'wpt_type' is 1 for the first waypoint of the day
492             #
493             sub wpt_name {
494             my ($self, $wpt_type, $date_time) = @_;
495             if ($wpt_type == 1) {
496             return
497             $MONTHS [substr ($date_time, 5, 2)] . '-' . substr ($date_time, 8, 2)
498             . '/'
499             . substr ($date_time, 11, 5);
500             } else {
501             # return unchanged
502             return substr ($date_time, 11, 5);
503             }
504             }
505              
506             #
507             # create a file with OziExplorer waypoints
508             #
509             sub convert2oziwpt {
510             my ($self, $ra_data) = @_;
511             my $ra_mindist = $self->_min_distance ($ra_data);
512             return $self->_convert2oziwpt ($ra_mindist);
513             }
514             sub _convert2oziwpt {
515             my ($self, $ra_data) = @_;
516             my $outfile = $self->_get_filename ('-ozi.wpt');
517             $self->save2oziwpt ($ra_data, $outfile);
518             return $outfile;
519             }
520              
521             #
522             # create a file with more DISTANT points
523             #
524             sub min_distance2xml {
525             my ($self, $ra_data) = @_;
526             my $ra_mindist = $self->_min_distance ($ra_data);
527             return $self->_min_distance2xml ($ra_mindist);
528             }
529             sub _min_distance2xml {
530             my ($self, $ra_data) = @_;
531             my $outfile = $self->_get_filename ('-distance.xml');
532             $self->save2xml ($ra_data, $outfile);
533             return $outfile;
534             }
535              
536             #
537             # add the last record from $ra_from_data to $ra_to_data only if:
538             # - there is any record in $ra_from_data, and
539             # - the same record is not already in $ra_to_data, and
540             # - the new record is "far enough" from the last one in $ra_to_data
541             #
542             sub maybe_add_last_record {
543             my ($self, $ra_from_data, $ra_to_data) = @_;
544             # 'from' array must be non-empty, otherwise there is nothing to copy from
545             if (@$ra_from_data > 0) {
546             my $last_rec = $$ra_from_data[$#$ra_from_data];
547             # if 'to' array is still empty, there is nothing to test
548             if (@$ra_to_data == 0) {
549             push (@$ra_to_data, $last_rec);
550             return;
551             }
552             my $prev_rec = $$ra_to_data[$#$ra_to_data];
553             # the last record is already there, nothing to do
554             return if $last_rec eq $prev_rec;
555              
556             # finally: is the last record far enough from the previous one?
557             if (distance ($$prev_rec{'lat'}, $$prev_rec{'lng'},
558             $$last_rec{'lat'}, $$last_rec{'lng'}) > $self->min_distance) {
559             push (@$ra_to_data, $last_rec);
560             }
561             }
562             }
563              
564             #
565             # convert given $ra_data into XML and save it in a file;
566             # return the filename;
567             # do not change existing files if data are empty
568             #
569             sub convert2xml {
570             my ($self, $ra_data) = @_;
571             my $outfile = $self->_get_filename ('-all.xml');
572             $self->save2xml ($ra_data, $outfile);
573             return $outfile;
574             }
575              
576             #
577             # clean given data $ra_data: remove CSV header, remove records without
578             # any position, sort by time, remove records that are not in the
579             # wnated time range, add key 'type' to each record; return cleaned data
580             #
581             # $ra_data is a reference to an array of hashes with the following keys
582             # (the values are just examples):
583             # {
584             # 'elevation' => '',
585             # 'lat' => '78.22259',
586             # 'time' => '2006-10-29 16:02:01',
587             # 'lng' => '15.65249'
588             # },
589             #
590             # (the first element in $ra_data contains only headers)
591             #
592             sub clean_data {
593             my ($self, $ra_data) = @_;
594              
595             return unless $ra_data;
596             return $ra_data unless (@$ra_data > 1);
597             shift @$ra_data; # skip CSV headers
598              
599             # ignore records that do not have position
600             # (i.e. where lat="-90.00000" lng="-180.00000")
601             my @records =
602             grep { $$_{'lat'} !~ /^-90\./ and $$_{'lng'} !~ /^-180\./ }
603             @$ra_data;
604              
605             # sort by time
606             my @sorted =
607             grep { $$_{'time'} ge $self->from_date and $$_{'time'} le $self->to_date }
608             sort { $$a{'time'} cmp $$b{'time'} } @records;
609              
610             # label type of the marker...
611             # type 1 ... the first-in-a-day-points
612             # type 0 ... others
613             my $last_time = '0000-00-00';
614             foreach (@sorted) {
615             my $curr_time = substr ($$_{'time'}, 0, 10);
616             if ($curr_time ne $last_time) {
617             $last_time = $curr_time;
618             $$_{'type'} = 1;
619             } else {
620             $$_{'type'} = 0;
621             }
622             }
623             return \@sorted;
624             }
625              
626             #
627             # parse data from $filename and extract only wanted fields (columns)
628             #
629             sub parse_data {
630             my ($self, $filename) = @_;
631              
632             my @indeces = split (/\s*,\s*/, $self->input_format);
633             my $parser = Text::CSV::Simple->new;
634              
635             # field #: 5 6 7 8 9
636             # CSV header: Satellite_time Guardian_time Longitude Latitude Altitude
637             # XML attr: time lng lat elevation
638             $parser->want_fields (@indeces);
639             $parser->field_map (qw/time lng lat elevation/);
640             my @data = $parser->read_file ($filename);
641             return \@data;
642             }
643              
644             #
645             # create a file name from existing parameters and from the given
646             # $suffix; if there is a parameter indicateing result directory but
647             # this directory does not exist it is created (no error messages if it
648             # fails, however)
649             #
650             sub _get_filename {
651             my ($self, $suffix) = @_;
652              
653             # make the result directory unless it exists already
654             if ($self->result_dir) {
655             mkdir $self->result_dir
656             unless -d $self->result_dir;
657             return File::Spec->catfile ($self->result_dir,
658             $self->result_basename . $suffix);
659             }
660             return File::Spec->catfile ($self->result_basename . $suffix);
661             }
662              
663             #
664             # if the input file is defined and it exists, do nothing, just return
665             # its full name; otherwise use other fields to get data from Guardian,
666             # put them into a local file and return its full name
667             #
668             # die if an error occurs
669             #
670             sub fetch_data {
671             my $self = shift;
672              
673             # input file may be defined
674             if ($self->input_data) {
675             die "File with input data " . $self->input_data . " does not seem to exists.\n"
676             unless -e $self->input_data;
677             return $self->input_data;
678             }
679              
680             # no input give, let's go to Guardian
681              
682             my $ua = LWP::UserAgent->new (agent => 'Mozilla/5.0');
683              
684             # --- get login ID (from a user name and password)
685             my $response = $ua->post ($self->login_url,
686             { name => $self->user,
687             pw => $self->passwd,
688             });
689             $response->is_success or
690             die "$self->login_url: ", $response->status_line;
691             my $content = $response->content;
692              
693             my ($id) = $content =~ /name="id"\s+value="([^"]+)"/; #"
694             $id = $self->default_id unless $id;
695              
696             # --- get data (using the just received ID)
697             my $outfile = $self->_get_filename ('-guardian-raw.csv');
698             $response = $ua->post ($self->data_url, {
699             id => $id,
700             period => 'year',
701             }, ':content_file' => $outfile);
702             $response->is_success or
703             die "$self->data_url: ", $response->status_line;
704              
705             return $outfile;
706             }
707              
708             #
709             # a convenient method combining fetch_data(), parse_data() and
710             # clean_data()
711             #
712             sub get_data {
713             my $self = shift;
714             my $datafile = $self->fetch_data;
715             return $self->clean_data ($self->parse_data ($datafile));
716             }
717              
718             1;
719             __END__