File Coverage

lib/Weather/PurpleAir/API.pm
Criterion Covered Total %
statement 29 241 12.0
branch 0 110 0.0
condition 0 65 0.0
subroutine 9 27 33.3
pod 5 19 26.3
total 43 462 9.3


line stmt bran cond sub pod time code
1             package Weather::PurpleAir::API;
2              
3             ## ABSTRACT: Client for using the purpleair.com air quality sensor API
4              
5 1     1   113132 use strict;
  1         9  
  1         22  
6 1     1   3 use warnings;
  1         1  
  1         17  
7 1     1   9 use v5.10.0; # provides "say" and "state"
  1         3  
8              
9 1     1   316 use JSON::MaybeXS;
  1         5642  
  1         50  
10 1     1   512 use File::Valet;
  1         20164  
  1         71  
11 1     1   566 use HTTP::Tiny;
  1         33782  
  1         31  
12 1     1   7 use Time::HiRes;
  1         1  
  1         8  
13 1     1   513 use String::Similarity qw(similarity);
  1         456  
  1         2079  
14              
15             our $VERSION = '0.09';
16              
17             # Litchfield 38887 38888 City of Santa Rosa Laguna Treatment Plant 22961 22962 Geek Orchard 26363 26364
18              
19             =head1 NAME
20              
21             Weather::PurpleAir::API -- Client interface to Purple Air air quality API [DEPRECATED]
22              
23             =head1 SYNOPSIS
24              
25             use Weather::PurpleAir::API;
26             use Data::Dumper;
27              
28             my $api = Weather::PurpleAir::API->new();
29             my @sensors = qw(38887 22961 26363);
30             my $report = $api->report(\@sensors);
31             for my $sensor_name (keys %$report) {
32             print join("\t", ($sensor_name, @{$report->{$sensor_name}})), "\n";
33             }
34              
35             =head1 DESCRIPTION
36              
37             B C is deprecated! The PurpleAir team turned off their old API, and have set up a totally new API. A re-implementation of C for the new API is in progress, and will replace this module (which is now useless).
38              
39             C provides a convenient interface to the Purple Air air quality API. It will
40             pull down data for specified sensors, transform them as desired (for instance, converting from raw
41             PM2.5 concentration to USA EPA AQI number, averaging results from dual-sensor nodes, etc) and provide
42             a concise report by sensor name.
43              
44             The Purple Air map of sensors is located at L.
45              
46             This module is very much a work in progress and 0.x releases might not be suitable for use.
47              
48             For a simple commandline script wrapping this module, see C in this package.
49              
50             Please do not poll sensors too frequently or the Purple Air server might block your IP.
51              
52             =head1 PACKAGE ATTRIBUTES
53              
54             =over 4
55              
56             =item C<$Weather::PurpleAir::API::VERSION> (string)
57              
58             The version number of this package (#.## format).
59              
60             =back
61              
62             =head1 OBJECT ATTRIBUTES
63              
64             =over 4
65              
66             =item C (hashref)
67              
68             Keys on sensor ID numbers, maps to sensor names.
69              
70             It is empty until the C method is called.
71              
72             =item C (hashref)
73              
74             Keys on sensor names, maps to sensor ID numbers.
75              
76             It is empty until the C method is called.
77              
78             =item C (string)
79              
80             Indicates status of most recent operation: "OK", "WARNING" or "ERROR".
81              
82             =over 4
83              
84             * "OK" means everything completed as expected.
85              
86             * "WARNING" means everything completed and possibly-useful data was returned, but something suspicious happened.
87              
88             * "ERROR" means something went horribly wrong and the operation was unable to complete.
89              
90             =back
91              
92             =item C (string or arrayref)
93              
94             Set by WARNING and ERROR conditions, describes details of what went wrong.
95              
96             =item C (integer)
97              
98             Incremented every time an error occurs.
99              
100             =item C (integer)
101              
102             Incremented every time a warning occurs.
103              
104             =item C (exception or exception string)
105              
106             Set to $@ when an exception is caught (for instance, when a JSON string does not decode).
107              
108             =item C (C object reference)
109              
110             Contains a reference to the JSON decoder object used internally. May be overridden by the user when different behavior is desired. The default instance has parameters C 1, allow_nonref =E 1, space_after =E 1>.
111              
112             =back
113              
114             =head1 METHODS
115              
116             All functionality is available via a C object. Start with C and
117             use the resulting object to generate reports. Additional functionality (like finding sensors
118             by name, or finding sensors near locations) will come in future releases.
119              
120             =head2 C
121              
122             Instantiates and returns a C object. Options passed here may be overridden
123             by passing options to methods of the object.
124              
125             All options have hopefully-sane defaults:
126              
127             =over 4
128              
129             =item C =E string
130              
131             Override the URL at which the API is queried. Useful for testing, or if you have your own server.
132              
133             Default is "https://www.purpleair.com/json?show=", to which the sensor ID is appended by the C method.
134              
135             =item C =E 0 or 1
136              
137             Averages AQI metrics from a node's sensors instead of returning the AQI metrics from each sensor in a node.
138              
139             Default is 0 (do not average).
140              
141             =item C =E 0 or 1
142              
143             Activate debugging logic, which will print horribly confusing things to STDOUT. Default is 0 (off).
144              
145             =item C =E 0 or 1
146              
147             Indicate that reports should include a "GUESSING" entry, providing a pruned average of all results from all sensors. In future releases it might incorporate other heuristics (like using older cached values when bad metrics are detected).
148              
149             The format of this entry is a little different from the sensor entries. Instead of $report->{GUESSING} referring
150             to an array of AQI metrics, it refers to an array containing the guessed gestalt AQI metric and its standard
151             deviation (a measure of statistical skew). The higher the standard deviation, the lower the confidence you
152             should have in the guessed metric.
153              
154             For example:
155              
156             { GUESSING => [123.45, 1.09] } # AQI is 123.45, with high confidence
157             { GUESSING => [123.45, 10.3] } # AQI is 123.45, with somewhat less confidence
158             { GUESSING => [123.45, 67.5] } # AQI is 123.45, with very low confidence!
159              
160             Default is 0 (do not guess).
161              
162             =item C =E HTTP::Tiny object
163              
164             Provide the C instance used to query the API. This is useful when you need to customize the query timeout, set a user agent string or specify an https proxy.
165              
166             Default is undef (an C object will be instantiated internally).
167              
168             =item C =E 0 or 1
169              
170             Set this to suppress writing error messages to stderr. Default is 0 (errors will be displayed).
171              
172             =item C =E 0 or 1
173              
174             Set this to suppress writing warning messages to stderr. Default is 0 (warnings will be displayed).
175              
176             =item C =E 0 or 1
177              
178             Normally reports will use the ten-minute average AQI from each sensor. Specify C to use the current AQI instead.
179              
180             Default is 0 (report will use ten-minute average AQI metrics).
181              
182             =item C =E fraction between 0 and 1
183              
184             When the C (GUESSING) option is set, the guessing heuristic may prune more then one high and one low outlier
185             from the sensor data, if doing so will leave sufficient data left over for meaningful averaging. The prune
186             threshold determines how close to an outlier other data points must be, as a fraction of the outlier value, for
187             them to be pruned as well.
188              
189             For instance, if C = 0.1 (10%) and the high outlier is 90, then 90 * 0.1 = 9, so data points
190             which are 81 or higher might also be pruned. If C = 0.05 (5%) and the high outlier is 90,
191             90 * 0.05 = 4.5, so data points which are 85.5 or higher might be pruned, etc.
192              
193             Default is 0.1 (10%).
194              
195             =item C =E 0 or 1
196              
197             "C" is for "quiet". Setting this is equivalent to setting C and C.
198              
199             Default is 0.
200              
201             =item C =E 0 or 1
202              
203             When set, C will not convert the API's raw concentration numbers to USA EPA PM2.5 AQI scores (the
204             metric displayed on the Purple Air website map). Part-per-million concentrations of 2.5 micrometer diameter
205             particles will be provided instead.
206              
207             Default is 0 (concentrations will be converted to USA EPA PM2.5 AQI scores).
208              
209             =item C =E ID-number
210              
211             =item C =E [ID-number, ID-number, ...]
212              
213             =item C =E "ID-number ID-number ..."
214              
215             Normally sensor IDs are passed to the C method, but a default sensor or sensors can also be given
216             to C at object instantiation time.
217              
218             Right now C can only work with numeric sensor IDs and there isn't a really good
219             way to find the IDs of sensors. If you point a browser at the Purple Air map, some browsers will expose
220             the IDs of specific sensors and others will not.
221              
222             If you download the all-sensors blob (either from the API at L or the
223             cached blob at L) you can pick through the list and find IDs of
224             sensors of interest, which is a pain in the ass.
225              
226             Future releases of C will support functions for finding sensors near a location,
227             but this one doesn't, which is one of the reasons it's a 0.x release.
228              
229             Some example sensors and their IDs:
230              
231             25407 Gravenstein School
232             38887 Litchfield
233             22961 City Of Santa Rosa Laguna Treatment Plant
234             26363 Geek Orchard
235              
236             The default is 25407 (Gravenstein School, in Sonoma County, California)
237              
238             =item C =E path string
239              
240             When a C is provided, C will store a copy of each sensor's JSON blob in the
241             specified directory, under the name C.
242              
243             For example, if C = "/tmp" and C = 25407, the JSON blob will be stored in file
244             "/tmp/aqi.25407.json".
245              
246             The default is undef (not set, will not stash).
247              
248             =item C =E "C" or "F"
249              
250             Also display the sensor's reported temperature, in either degrees Celsius or Fahrenheit.
251              
252             =item C =E 0 or 1
253              
254             "C" is for verbosity. When set, the reported-upon sensors (and gestalt guess, when C parameter is
255             set) will be printed to stdout. This is normally set by the C utility.
256              
257             Future releases might implement higher verbosity levels.
258              
259             The default is 0 (do not print to stdout).
260              
261             =back
262              
263             =cut
264              
265             sub new {
266 1     1 1 92 my ($class, %opt_hr) = @_;
267 1         10 my $self = {
268             opt_hr => \%opt_hr,
269             ok => 'OK',
270             ex => undef, # stash caught exceptions here
271             n_err => 0,
272             n_warn => 0,
273             err => '',
274             err_ar => [],
275             js_or => JSON::MaybeXS->new(ascii => 1, allow_nonref => 1, space_after => 1),
276             sensor_hr=> {},
277             name_hr => {},
278             };
279 1         25 bless ($self, $class);
280              
281             # convert keys of form "some-parameter" to "some_parameter":
282 1         2 foreach my $k0 (keys %{$self->{opt_hr}}) {
  1         4  
283 0         0 my $k1 = join('_', split(/-/, $k0));
284 0 0       0 next if ($k0 eq $k1);
285 0         0 $self->{opt_hr}->{$k1} = $self->{opt_hr}->{$k0};
286 0         0 delete $self->{opt_hr}->{$k0};
287             }
288              
289 1         3 return $self;
290             }
291              
292             sub safely_to_json {
293 0     0 0   my ($self, $r) = @_;
294 0           my $js = eval { $self->{js_or}->encode($r); };
  0            
295 0 0         $self->{ex} = $@ unless(defined($js));
296 0           return $js;
297             }
298              
299             sub safely_from_json {
300 0     0 0   my ($self, $js) = @_;
301 0           my $r = eval { $self->{js_or}->decode($js); };
  0            
302 0 0         return $r if (defined $r);
303 0           $self->{ex} = $@;
304 0           $self->err("JSON decode failed", $@);
305 0           return;
306             }
307              
308             sub most_similar {
309 0     0 0   my ($self, $thing, $ar, $opt_hr) = @_;
310 0           my $best_match = '';
311 0           my $best_score = $self->opt('min_score', 0, $opt_hr);
312 0           for my $x (@$ar) {
313 0           my $score = similarity($thing, $x);
314 0 0         next unless($score >= $best_score);
315 0           $best_score = $score;
316 0           $best_match = $x;
317             }
318 0 0         return ($best_match, $best_score) if ($self->opt('best_match_and_score', 0, $opt_hr)); # for testing, mostly
319 0           return $best_match;
320             }
321              
322             sub opt {
323 0     0 0   my ($self, $name, $default_value, $alt_hr) = @_;
324 0   0       $alt_hr //= {};
325 0   0       return $self->{opt_hr}->{$name} // $self->{conf_hr}->{$name} // $alt_hr->{$name} // $default_value;
      0        
      0        
326             }
327              
328             # approximates python's "in" operator, because ~~ is unsane:
329             sub in {
330 0     0 0   my $v = shift @_;
331 0 0 0       return 0 unless (@_ && defined($_[0]));
332 0 0         if (ref($_[0]) eq 'ARRAY') {
333 0 0 0       foreach my $x (@{$_[0]}) { return 1 if defined($x) && $x eq $v; }
  0            
  0            
334             } else {
335 0 0 0       foreach my $x (@_) { return 1 if defined($x) && $x eq $v; }
  0            
336             }
337 0           return 0;
338             }
339              
340             sub ok {
341 0     0 1   my $self = shift(@_);
342 0           $self->all_is_well();
343 0           return ('OK', @_);
344             }
345              
346             sub err {
347 0     0 1   my $self = shift(@_);
348 0           $self->{ok} = "ERROR";
349 0           $self->{err} = [@_];
350 0 0 0       $self->shout("ERROR", @_) unless ($self->opt('q') || $self->opt('no_errors'));
351 0           $self->{n_err}++;
352 0           return ('ERROR', @_);
353             }
354              
355             sub warn {
356 0     0 0   my $self = shift(@_);
357 0           $self->{ok} = "WARNING";
358 0           $self->{err} = [@_];
359 0 0 0       $self->shout("WARNING", @_) unless ($self->opt('q') || $self->opt('no_warnings'));
360 0           $self->{n_warn}++;
361 0           return ('WARNING', @_);
362             }
363              
364             sub shout {
365 0     0 0   my $self = shift(@_);
366 0           my $msg = join("\t", @_);
367 0           print STDERR $msg, "\n";
368 0           return;
369             }
370              
371             sub all_is_well {
372 0     0 0   my ($self) = @_;
373 0           $self->{ok} = 'OK';
374 0           $self->{ex} = undef;
375 0           $self->{err} = '';
376 0           $self->{err_ar} = [];
377 0           return;
378             }
379              
380             sub sensors {
381 0     0 0   my ($self, $sensor_string, $opt_hr) = @_;
382 0   0       $opt_hr //= {};
383 0   0       my $sensor_list = $sensor_string // $self->opt('s', $self->opt('sensor', 25407, $opt_hr), $opt_hr);
384 0 0         return $sensor_list if (ref $sensor_list eq 'ARRAY');
385 0           return [split(/[^\d]+/, $sensor_list)];
386             }
387              
388             sub sensor_url {
389 0     0 0   my ($self, $sensor_id, $opt_hr) = @_;
390 0           my $api_url = $self->opt('api_url', "https://www.purpleair.com/json?show=", $opt_hr);
391 0           my $url = "$api_url$sensor_id";
392 0           return $url
393             }
394              
395             =head2 C
396              
397             =head2 C
398              
399             =head2 C value, ...})>
400              
401             =over 4
402              
403             The C method retrieves current data from each specified sensor and returns a hashref with
404             sensor name keys and arrayref values. The values represent the air quality at the sensor's location
405             (where higher values = more gunk in the air). Higher than 50 is bad, lower than 20 is good.
406              
407             If no list of sensor IDs is used, and no list was provided to C ...)> a default of
408             25407 (Gravenstein School) will be used, which probably is not very useful to you.
409              
410             See the notes under C in the section for C regarding sensor IDs and how to find them.
411              
412             C optionally accepts a hashref options, which are the same as those documented for C.
413              
414             Returns C on error and sets the object's C and C attributes.
415              
416             Returns a hashref as described on success and sets the object's C and C attributes to "OK" and "" respectively.
417              
418             =back
419              
420             =cut
421              
422             sub report {
423 0     0 1   my ($self, $sensors, $opt_hr) = @_;
424 0           $self->ok();
425 0   0       $opt_hr //= $opt_hr;
426              
427 0           my $report_hr = $self->gather_sensors($sensors, $opt_hr);
428            
429 0           my ($best_guess_aqi, $guess_deviation) = (0, 0);
430 0 0         if ($self->opt('g', 0, $opt_hr)) {
431 0           ($best_guess_aqi, $guess_deviation) = $self->guess_best_aqi($report_hr, $opt_hr);
432              
433 0           my ($p_best, $p_dev) = ($best_guess_aqi, $guess_deviation);
434 0 0         ($p_best, $p_dev) = (int($p_best+0.5), int($p_dev+0.5)) if ($self->opt('i', 0, $opt_hr));
435            
436 0 0         print "GUESSING\t$p_best\t$p_dev\n" if($self->opt('v', 0, $opt_hr));
437 0           $report_hr->{GUESSING} = [$best_guess_aqi];
438             }
439              
440 0           return ($report_hr, $guess_deviation);
441             }
442              
443             =head2 C
444              
445             This method implements the official USA EPA guideline for converting PM2.5
446             concentration to AQI, per
447             L.
448              
449             The sensors do not report AQI metrics, only raw concentrations, so conversion
450             is necessary. The C method does this automatically (unless the C
451             option is set).
452              
453             The official equation is a dumb-ass piecewise function of linear interpolations,
454             but nobody ever said government was smart.
455              
456             Takes a PM2.5 concentration as input, returns an AQI metric as output. The EPA
457             guideline asserts that the AQI must be truncated to a whole integer. I leave
458             that as an exercise for the programmer.
459              
460             =cut
461              
462             sub concentration_to_epa {
463 0     0 1   my ($self, $conc, $opt_hr) = @_;
464             # ref: https://www3.epa.gov/airnow/aqi-technical-assistance-document-sept2018.pdf
465 0 0 0       return $conc if ($conc > 500.0 || $conc < 0.05);
466 0           my ($Ihi, $Ilo, $BPhi, $BPlo);
467 0 0         if ($conc <= 12.0) { ($Ihi, $Ilo, $BPhi, $BPlo) = ( 50, 0, 12.0, 0.0); }
  0 0          
    0          
    0          
    0          
    0          
468 0           elsif ($conc <= 35.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (100, 51, 35.4, 12.0); }
469 0           elsif ($conc <= 55.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (150, 101, 55.4, 35.5); }
470 0           elsif ($conc <= 150.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (200, 151, 150.4, 55.5); }
471 0           elsif ($conc <= 250.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (300, 201, 250.4, 150.5); }
472 0           elsif ($conc <= 350.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (400, 301, 350.4, 250.5); }
473 0           else { ($Ihi, $Ilo, $BPhi, $BPlo) = (500, 401, 500.4, 350.4); }
474 0           my $epa = ($Ihi - $Ilo) * ($conc - $BPlo) / ($BPhi - $BPlo) + $Ilo;
475 0           return $epa;
476             }
477              
478             sub gather_sensors {
479 0     0 0   my ($self, $sensors, $opt_hr) = @_;
480 0           my $report_hr = {};
481 0   0       my $http_or = $self->opt('http_or', undef, $opt_hr) // HTTP::Tiny->new();
482              
483 0           $self->ok();
484 0 0 0       $sensors = $self->sensors($sensors, $opt_hr) unless (defined $sensors && ref $sensors eq "ARRAY");
485 0           my @temps;
486              
487 0           for my $sensor (@$sensors) {
488 0           my $hr = $self->fetch_aqi($http_or, $sensor, $opt_hr);
489 0 0         next unless(defined $hr);
490              
491 0           my ($name, $aqi_ar) = (undef, []);
492 0   0       for my $results_hr (@{$hr->{results} // []}) {
  0            
493 0   0       $name //= $results_hr->{Label};
494 0           my $aqi = $results_hr->{pm2_5_atm};
495 0 0         unless ($self->opt('now', 0, $opt_hr)) {
496 0           my $stats_hr = $self->safely_from_json($results_hr->{Stats});
497 0 0         if (defined $stats_hr) {
498 0           $aqi = $stats_hr->{v1}; # ten-minute average
499             } else {
500 0           $self->warn("unable to parse ten-minute average, using current value");
501             }
502             }
503            
504 0 0         $aqi = $self->concentration_to_epa($aqi, $opt_hr) unless ($self->opt('raw', 0, $opt_hr)); # approx conversion from raw concentration to US EPA PM2.5
505 0 0         push @{$aqi_ar}, $aqi if (defined $aqi);
  0            
506 0 0         push @temps, $results_hr->{temp_f} if (defined $results_hr->{temp_f});
507             }
508              
509 0   0       $name //= $sensor;
510 0           $self->{sensor_hr}->{$sensor} = $name;
511 0           $self->{name_hr}->{$name} = $sensor;
512 0 0         $report_hr->{$name} = $aqi_ar if (@$aqi_ar > 0);
513              
514 0           my $aqi = $self->aqi_calculate($aqi_ar, $opt_hr);
515 0           my $temp_f = 0;
516 0           $temp_f += $_ for (@temps);
517 0 0         $temp_f /= @temps if (@temps);
518 0 0         $temp_f = ($temp_f - 32) * 5 / 9 if (uc($self->opt('temperature', 'F')) eq 'C');
519 0 0         $temp_f = int($temp_f + 0.5) if ($self->opt('i'));
520 0 0         $report_hr->{temp_f} = $temp_f if ($self->opt('temperature'));
521              
522 0 0 0       if ($self->opt('v', 0, $opt_hr) && @$aqi_ar) {
523 0           my @p_aqi = @$aqi;
524 0 0         @p_aqi = map { int($_+0.5) } @p_aqi if ($self->opt('i', 0, $opt_hr));
  0            
525 0 0         push @p_aqi, $temp_f if ($self->opt('temperature'));
526 0           my $s_aqi = join("\t", @p_aqi);
527 0           print "$name\t$s_aqi\n";
528             }
529             }
530              
531 0           return $report_hr;
532             }
533              
534             sub fetch_aqi {
535 0     0 0   my ($self, $http_or, $sensor, $opt_hr) = @_;
536 0           my $stash_path = $self->opt('stash_path', undef, $opt_hr);
537 0           my $url = $self->sensor_url($sensor, $opt_hr);
538 0           my $resp = $http_or->get($url);
539 0           my $hr;
540 0 0         if ($resp->{success}) {
541 0           my $js = $resp->{content};
542 0 0         wr_f("$stash_path/aqi.$sensor.json", $js) if (defined $stash_path);
543 0           $hr = $self->safely_from_json($js);
544             } else {
545 0           $self->err($resp->{status}, $resp->{reason});
546             }
547 0           return $hr;
548             }
549              
550             sub aqi_calculate {
551 0     0 0   my ($self, $aqi_ar, $opt_hr) = @_;
552 0           my $aqi = 0;
553 0 0         if ($self->opt('average', 0, $opt_hr)) {
554 0           $aqi += $_ for(@$aqi_ar);
555 0           $aqi /= @$aqi_ar;
556 0           $aqi = [$aqi];
557             } else {
558 0           $aqi = $aqi_ar;
559             }
560 0           return $aqi;
561             }
562              
563             sub guess_best_aqi {
564 0     0 0   my ($self, $report_hr, $opt_hr) = @_;
565 0           my $average = 0;
566 0           my $deviation = 0;
567 0           my $n_values = 0;
568 0           my $n_sensors = keys %$report_hr;
569 0           my %hi = (sensor => undef, result => 0, ppm => 0);
570 0           my %lo = (sensor => undef, result => 0, ppm => 1000000);
571 0           my @v_list; # superfluous, but simplifies stddev logic
572              
573 0           for my $k (keys %$report_hr) {
574 0           my $ar = $report_hr->{$k};
575 0           $n_values += @$ar;
576 0           for my $ix (0..$#$ar) {
577 0           my $ppm = $ar->[$ix];
578 0           $average += $ppm;
579 0           push @v_list, $ppm;
580 0 0         %hi = (sensor => $k, result => $ix, ppm => $ppm) if ($ppm > $hi{ppm});
581 0 0         %lo = (sensor => $k, result => $ix, ppm => $ppm) if ($ppm < $lo{ppm});
582             }
583             }
584              
585             # prune outliers if it will leave us with sufficient data for meaningful results
586 0           my $have_pruned = 0;
587              
588             # first the high reading(s)
589 0 0 0       if ($hi{sensor} && ($n_sensors + $n_values) > 3) {
590 0 0         print "DEBUG\tpruning high outliers n_sensors=$n_sensors n_values=$n_values\n" if ($self->opt('d', 0, $opt_hr));
591 0           my $ar = $report_hr->{$hi{sensor}};
592 0           my $high_ppm = $hi{ppm};
593 0 0         if ($n_sensors > 2) {
594             # potentially prune out all results from this sensor
595 0           for my $ppm (@$ar) {
596 0 0         next unless(abs($ppm - $high_ppm) < $high_ppm * $self->opt('prune_threshold', 0.1, $opt_hr));
597 0 0         print "DEBUG\tpruning high value $ppm\n" if ($self->opt('d', 0, $opt_hr));
598 0           $average -= $ppm;
599 0           $n_values--;
600 0           $have_pruned++;
601             }
602             } else {
603             # just prune the sensor's highest ppm
604 0 0         print "DEBUG\tpruning highest value $high_ppm\n" if ($self->opt('d', 0, $opt_hr));
605 0           $average -= $high_ppm;
606 0           $n_values--;
607 0           $have_pruned++;
608             }
609             }
610              
611             # now the low reading(s)
612 0 0         my $prune_threshold = $have_pruned ? 2 : 3;
613 0 0 0       if ($lo{sensor} && ($n_sensors + $n_values) > $prune_threshold) {
614 0 0         print "DEBUG\tpruning low outliers n_sensors=$n_sensors n_values=$n_values\n" if ($self->opt('d', 0, $opt_hr));
615 0           my $ar = $report_hr->{$lo{sensor}};
616 0           my $low_ppm = $lo{ppm};
617 0 0         if ($n_sensors > 2) {
618             # potentially prune out all results from this sensor
619 0           for my $ppm (@$ar) {
620             # zzapp -- this might be unfair, since threshold for low ppm is intrinsically of smaller magnitude
621             # maybe use different prune_threshold parameters for high and low?
622 0 0 0       next unless(!$ppm || (abs($ppm - $low_ppm) < $low_ppm * $self->opt('prune_threshold', 0.1, $opt_hr)));
623 0 0         print "DEBUG\tpruning low value $ppm\n" if ($self->opt('d', 0, $opt_hr));
624 0           $average -= $ppm;
625 0           $n_values--;
626             }
627             } else {
628             # just prune the sensor's lowest ppm
629 0 0         print "DEBUG\tpruning lowest value $low_ppm\n" if ($self->opt('d', 0, $opt_hr));
630 0           $average -= $low_ppm;
631 0           $n_values--;
632             }
633             }
634              
635             # calculate the average
636 0   0       $n_values ||= 1;
637 0           $average /= $n_values;
638              
639             # and now the standard deviation
640             # zzapp -- this includes pruned values, and I'm not sure if that's a bug or a feature
641 0           for my $v (@v_list) {
642 0           my $square_diff = ($average - $v) ** 2;
643 0           $deviation += $square_diff;
644             }
645 0           $deviation = ($deviation / @v_list) ** 0.5;
646              
647 0           return ($average, $deviation);
648             }
649              
650             =head1 ABOUT RELIABILITY
651              
652             The sensor readings are not always reliable. A car starting or a person
653             smoking near the sensor can produce a false high reading. Right now the
654             workaround is to specify multiple sensors and use the -g option. Future
655             releases will provide alternative remedies.
656              
657             =head1 AUTHOR
658              
659             TTK Ciar,
660              
661             =head1 COPYRIGHT AND LICENSE
662              
663             Copyright 2020 by TTK Ciar
664              
665             This library is free software; you can redistribute it and/or modify it under
666             the same terms as Perl itself.
667              
668             =head1 TO DO
669              
670             Write more documentation. Some methods are undocumented.
671              
672             Write more unit tests.
673              
674             Save some state and perform time averaging, perhaps throw out dramatic changes and reuse last known value.
675              
676             Pull down and cache the all-sensors blob, implement relevant operations:
677              
678             =over 4
679              
680             * find sensors by name
681              
682             * find sensors nearest a latitude/longitude, or near another sensor, maybe do some light GIS-fu
683              
684             =back
685              
686             =head1 HISTORY
687              
688             This was originally a throw-away script that just yanked JSON from the API, parsed out the sensor name
689             and first reading's PM2.5 concentration, and printed to stdout.
690              
691             It was quickly apparent that this left something to be desired, so the script was refactored into this
692             module and associated wrapper utility.
693              
694             My friend Matt using the throw-away script made me feel embarrassed at its shortcomings, which provided
695             some of the motivation to do better.
696              
697             =head1 SEE ALSO
698              
699             L
700              
701             L (stored in more convenient form by purpleairpy project)
702              
703             L by ReagentX, providing a different approach to the interface.
704              
705             L an unfortunately crude tool corresponding PM2.5 concentration to the AQI metric.
706              
707             L
708              
709             =cut
710              
711             1;