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