File Coverage

lib/Weather/PurpleAir/API.pm
Criterion Covered Total %
statement 29 232 12.5
branch 0 98 0.0
condition 0 65 0.0
subroutine 9 27 33.3
pod 5 19 26.3
total 43 441 9.7


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   123513 use strict;
  1         11  
  1         29  
6 1     1   5 use warnings;
  1         2  
  1         23  
7 1     1   10 use v5.10.0; # provides "say" and "state"
  1         3  
8              
9 1     1   463 use JSON::MaybeXS;
  1         5733  
  1         55  
10 1     1   569 use File::Valet;
  1         16953  
  1         82  
11 1     1   702 use HTTP::Tiny;
  1         43280  
  1         37  
12 1     1   8 use Time::HiRes;
  1         2  
  1         11  
13 1     1   523 use String::Similarity qw(similarity);
  1         619  
  1         2742  
14              
15             our $VERSION = '0.07';
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 0 or 1
247              
248             "C" is for verbosity. When set, the reported-upon sensors (and gestalt guess, when C parameter is
249             set) will be printed to stdout. This is normally set by the C utility.
250              
251             Future releases might implement higher verbosity levels.
252              
253             The default is 0 (do not print to stdout).
254              
255             =back
256              
257             =cut
258              
259             sub new {
260 1     1 1 130 my ($class, %opt_hr) = @_;
261 1         12 my $self = {
262             opt_hr => \%opt_hr,
263             ok => 'OK',
264             ex => undef, # stash caught exceptions here
265             n_err => 0,
266             n_warn => 0,
267             err => '',
268             err_ar => [],
269             js_or => JSON::MaybeXS->new(ascii => 1, allow_nonref => 1, space_after => 1),
270             sensor_hr=> {},
271             name_hr => {},
272             };
273 1         44 bless ($self, $class);
274              
275             # convert keys of form "some-parameter" to "some_parameter":
276 1         2 foreach my $k0 (keys %{$self->{opt_hr}}) {
  1         6  
277 0         0 my $k1 = join('_', split(/-/, $k0));
278 0 0       0 next if ($k0 eq $k1);
279 0         0 $self->{opt_hr}->{$k1} = $self->{opt_hr}->{$k0};
280 0         0 delete $self->{opt_hr}->{$k0};
281             }
282              
283 1         4 return $self;
284             }
285              
286             sub safely_to_json {
287 0     0 0   my ($self, $r) = @_;
288 0           my $js = eval { $self->{js_or}->encode($r); };
  0            
289 0 0         $self->{ex} = $@ unless(defined($js));
290 0           return $js;
291             }
292              
293             sub safely_from_json {
294 0     0 0   my ($self, $js) = @_;
295 0           my $r = eval { $self->{js_or}->decode($js); };
  0            
296 0 0         return $r if (defined $r);
297 0           $self->{ex} = $@;
298 0           $self->err("JSON decode failed", $@);
299 0           return;
300             }
301              
302             sub most_similar {
303 0     0 0   my ($self, $thing, $ar, $opt_hr) = @_;
304 0           my $best_match = '';
305 0           my $best_score = $self->opt('min_score', 0, $opt_hr);
306 0           for my $x (@$ar) {
307 0           my $score = similarity($thing, $x);
308 0 0         next unless($score >= $best_score);
309 0           $best_score = $score;
310 0           $best_match = $x;
311             }
312 0 0         return ($best_match, $best_score) if ($self->opt('best_match_and_score', 0, $opt_hr)); # for testing, mostly
313 0           return $best_match;
314             }
315              
316             sub opt {
317 0     0 0   my ($self, $name, $default_value, $alt_hr) = @_;
318 0   0       $alt_hr //= {};
319 0   0       return $self->{opt_hr}->{$name} // $self->{conf_hr}->{$name} // $alt_hr->{$name} // $default_value;
      0        
      0        
320             }
321              
322             # approximates python's "in" operator, because ~~ is unsane:
323             sub in {
324 0     0 0   my $v = shift @_;
325 0 0 0       return 0 unless (@_ && defined($_[0]));
326 0 0         if (ref($_[0]) eq 'ARRAY') {
327 0 0 0       foreach my $x (@{$_[0]}) { return 1 if defined($x) && $x eq $v; }
  0            
  0            
328             } else {
329 0 0 0       foreach my $x (@_) { return 1 if defined($x) && $x eq $v; }
  0            
330             }
331 0           return 0;
332             }
333              
334             sub ok {
335 0     0 1   my $self = shift(@_);
336 0           $self->all_is_well();
337 0           return ('OK', @_);
338             }
339              
340             sub err {
341 0     0 1   my $self = shift(@_);
342 0           $self->{ok} = "ERROR";
343 0           $self->{err} = [@_];
344 0 0 0       $self->shout("ERROR", @_) unless ($self->opt('q') || $self->opt('no_errors'));
345 0           $self->{n_err}++;
346 0           return ('ERROR', @_);
347             }
348              
349             sub warn {
350 0     0 0   my $self = shift(@_);
351 0           $self->{ok} = "WARNING";
352 0           $self->{err} = [@_];
353 0 0 0       $self->shout("WARNING", @_) unless ($self->opt('q') || $self->opt('no_warnings'));
354 0           $self->{n_warn}++;
355 0           return ('WARNING', @_);
356             }
357              
358             sub shout {
359 0     0 0   my $self = shift(@_);
360 0           my $msg = join("\t", @_);
361 0           print STDERR $msg, "\n";
362 0           return;
363             }
364              
365             sub all_is_well {
366 0     0 0   my ($self) = @_;
367 0           $self->{ok} = 'OK';
368 0           $self->{ex} = undef;
369 0           $self->{err} = '';
370 0           $self->{err_ar} = [];
371 0           return;
372             }
373              
374             sub sensors {
375 0     0 0   my ($self, $sensor_string, $opt_hr) = @_;
376 0   0       $opt_hr //= {};
377 0   0       my $sensor_list = $sensor_string // $self->opt('s', $self->opt('sensor', 25407, $opt_hr), $opt_hr);
378 0 0         return $sensor_list if (ref $sensor_list eq 'ARRAY');
379 0           return [split(/[^\d]+/, $sensor_list)];
380             }
381              
382             sub sensor_url {
383 0     0 0   my ($self, $sensor_id, $opt_hr) = @_;
384 0           my $api_url = $self->opt('api_url', "https://www.purpleair.com/json?show=", $opt_hr);
385 0           my $url = "$api_url$sensor_id";
386 0           return $url
387             }
388              
389             =head2 C
390              
391             =head2 C
392              
393             =head2 C value, ...})>
394              
395             =over 4
396              
397             The C method retrieves current data from each specified sensor and returns a hashref with
398             sensor name keys and arrayref values. The values represent the air quality at the sensor's location
399             (where higher values = more gunk in the air). Higher than 50 is bad, lower than 20 is good.
400              
401             If no list of sensor IDs is used, and no list was provided to C ...)> a default of
402             25407 (Gravenstein School) will be used, which probably is not very useful to you.
403              
404             See the notes under C in the section for C regarding sensor IDs and how to find them.
405              
406             C optionally accepts a hashref options, which are the same as those documented for C.
407              
408             Returns C on error and sets the object's C and C attributes.
409              
410             Returns a hashref as described on success and sets the object's C and C attributes to "OK" and "" respectively.
411              
412             =back
413              
414             =cut
415              
416             sub report {
417 0     0 1   my ($self, $sensors, $opt_hr) = @_;
418 0           $self->ok();
419 0   0       $opt_hr //= $opt_hr;
420              
421 0           my $report_hr = $self->gather_sensors($sensors, $opt_hr);
422            
423 0           my ($best_guess_aqi, $guess_deviation) = (0, 0);
424 0 0         if ($self->opt('g', 0, $opt_hr)) {
425 0           ($best_guess_aqi, $guess_deviation) = $self->guess_best_aqi($report_hr, $opt_hr);
426              
427 0           my ($p_best, $p_dev) = ($best_guess_aqi, $guess_deviation);
428 0 0         ($p_best, $p_dev) = (int($p_best+0.5), int($p_dev+0.5)) if ($self->opt('i', 0, $opt_hr));
429            
430 0 0         print "GUESSING\t$p_best\t$p_dev\n" if($self->opt('v', 0, $opt_hr));
431 0           $report_hr->{GUESSING} = [$best_guess_aqi];
432             }
433              
434 0           return ($report_hr, $guess_deviation);
435             }
436              
437             =head2 C
438              
439             This method implements the official USA EPA guideline for converting PM2.5
440             concentration to AQI, per
441             L.
442              
443             The sensors do not report AQI metrics, only raw concentrations, so conversion
444             is necessary. The C method does this automatically (unless the C
445             option is set).
446              
447             The official equation is a dumb-ass piecewise function of linear interpolations,
448             but nobody ever said government was smart.
449              
450             Takes a PM2.5 concentration as input, returns an AQI metric as output. The EPA
451             guideline asserts that the AQI must be truncated to a whole integer. I leave
452             that as an exercise for the programmer.
453              
454             =cut
455              
456             sub concentration_to_epa {
457 0     0 1   my ($self, $conc, $opt_hr) = @_;
458             # ref: https://www3.epa.gov/airnow/aqi-technical-assistance-document-sept2018.pdf
459 0 0 0       return $conc if ($conc > 500.0 || $conc < 0.05);
460 0           my ($Ihi, $Ilo, $BPhi, $BPlo);
461 0 0         if ($conc <= 12.0) { ($Ihi, $Ilo, $BPhi, $BPlo) = ( 50, 0, 12.0, 0.0); }
  0 0          
    0          
    0          
    0          
    0          
462 0           elsif ($conc <= 35.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (100, 51, 35.4, 12.0); }
463 0           elsif ($conc <= 55.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (150, 101, 55.4, 35.5); }
464 0           elsif ($conc <= 150.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (200, 151, 150.4, 55.5); }
465 0           elsif ($conc <= 250.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (300, 201, 250.4, 150.5); }
466 0           elsif ($conc <= 350.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (400, 301, 350.4, 250.5); }
467 0           else { ($Ihi, $Ilo, $BPhi, $BPlo) = (500, 401, 500.4, 350.4); }
468 0           my $epa = ($Ihi - $Ilo) * ($conc - $BPlo) / ($BPhi - $BPlo) + $Ilo;
469 0           return $epa;
470             }
471              
472             sub gather_sensors {
473 0     0 0   my ($self, $sensors, $opt_hr) = @_;
474 0           my $report_hr = {};
475 0   0       my $http_or = $self->opt('http_or', undef, $opt_hr) // HTTP::Tiny->new();
476              
477 0           $self->ok();
478 0 0 0       $sensors = $self->sensors($sensors, $opt_hr) unless (defined $sensors && ref $sensors eq "ARRAY");
479              
480 0           for my $sensor (@$sensors) {
481 0           my $hr = $self->fetch_aqi($http_or, $sensor, $opt_hr);
482 0 0         next unless(defined $hr);
483              
484 0           my ($name, $aqi_ar) = (undef, []);
485 0   0       for my $results_hr (@{$hr->{results} // []}) {
  0            
486 0   0       $name //= $results_hr->{Label};
487 0           my $aqi = $results_hr->{pm2_5_atm};
488 0 0         unless ($self->opt('now', 0, $opt_hr)) {
489 0           my $stats_hr = $self->safely_from_json($results_hr->{Stats});
490 0 0         if (defined $stats_hr) {
491 0           $aqi = $stats_hr->{v1}; # ten-minute average
492             } else {
493 0           $self->warn("unable to parse ten-minute average, using current value");
494             }
495             }
496            
497 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
498 0 0         push @{$aqi_ar}, $aqi if (defined $aqi);
  0            
499             }
500              
501 0   0       $name //= $sensor;
502 0           $self->{sensor_hr}->{$sensor} = $name;
503 0           $self->{name_hr}->{$name} = $sensor;
504 0 0         $report_hr->{$name} = $aqi_ar if (@$aqi_ar > 0);
505              
506 0           my $aqi = $self->aqi_calculate($aqi_ar, $opt_hr);
507              
508 0 0 0       if ($self->opt('v', 0, $opt_hr) && @$aqi_ar) {
509 0           my @p_aqi = @$aqi;
510 0 0         @p_aqi = map { int($_+0.5) } @p_aqi if ($self->opt('i', 0, $opt_hr));
  0            
511 0           my $s_aqi = join("\t", @p_aqi);
512 0           print "$name\t$s_aqi\n"
513             }
514             }
515              
516 0           return $report_hr;
517             }
518              
519             sub fetch_aqi {
520 0     0 0   my ($self, $http_or, $sensor, $opt_hr) = @_;
521 0           my $stash_path = $self->opt('stash_path', undef, $opt_hr);
522 0           my $url = $self->sensor_url($sensor, $opt_hr);
523 0           my $resp = $http_or->get($url);
524 0           my $hr;
525 0 0         if ($resp->{success}) {
526 0           my $js = $resp->{content};
527 0 0         wr_f("$stash_path/aqi.$sensor.json", $js) if (defined $stash_path);
528 0           $hr = $self->safely_from_json($js);
529             } else {
530 0           $self->err($resp->{status}, $resp->{reason});
531             }
532 0           return $hr;
533             }
534              
535             sub aqi_calculate {
536 0     0 0   my ($self, $aqi_ar, $opt_hr) = @_;
537 0           my $aqi = 0;
538 0 0         if ($self->opt('average', 0, $opt_hr)) {
539 0           $aqi += $_ for(@$aqi_ar);
540 0           $aqi /= @$aqi_ar;
541 0           $aqi = [$aqi];
542             } else {
543 0           $aqi = $aqi_ar;
544             }
545 0           return $aqi;
546             }
547              
548             sub guess_best_aqi {
549 0     0 0   my ($self, $report_hr, $opt_hr) = @_;
550 0           my $average = 0;
551 0           my $deviation = 0;
552 0           my $n_values = 0;
553 0           my $n_sensors = keys %$report_hr;
554 0           my %hi = (sensor => undef, result => 0, ppm => 0);
555 0           my %lo = (sensor => undef, result => 0, ppm => 1000000);
556 0           my @v_list; # superfluous, but simplifies stddev logic
557              
558 0           for my $k (keys %$report_hr) {
559 0           my $ar = $report_hr->{$k};
560 0           $n_values += @$ar;
561 0           for my $ix (0..$#$ar) {
562 0           my $ppm = $ar->[$ix];
563 0           $average += $ppm;
564 0           push @v_list, $ppm;
565 0 0         %hi = (sensor => $k, result => $ix, ppm => $ppm) if ($ppm > $hi{ppm});
566 0 0         %lo = (sensor => $k, result => $ix, ppm => $ppm) if ($ppm < $lo{ppm});
567             }
568             }
569              
570             # prune outliers if it will leave us with sufficient data for meaningful results
571 0           my $have_pruned = 0;
572              
573             # first the high reading(s)
574 0 0 0       if ($hi{sensor} && ($n_sensors + $n_values) > 3) {
575 0 0         print "DEBUG\tpruning high outliers n_sensors=$n_sensors n_values=$n_values\n" if ($self->opt('d', 0, $opt_hr));
576 0           my $ar = $report_hr->{$hi{sensor}};
577 0           my $high_ppm = $hi{ppm};
578 0 0         if ($n_sensors > 2) {
579             # potentially prune out all results from this sensor
580 0           for my $ppm (@$ar) {
581 0 0         next unless(abs($ppm - $high_ppm) < $high_ppm * $self->opt('prune_threshold', 0.1, $opt_hr));
582 0 0         print "DEBUG\tpruning high value $ppm\n" if ($self->opt('d', 0, $opt_hr));
583 0           $average -= $ppm;
584 0           $n_values--;
585 0           $have_pruned++;
586             }
587             } else {
588             # just prune the sensor's highest ppm
589 0 0         print "DEBUG\tpruning highest value $high_ppm\n" if ($self->opt('d', 0, $opt_hr));
590 0           $average -= $high_ppm;
591 0           $n_values--;
592 0           $have_pruned++;
593             }
594             }
595              
596             # now the low reading(s)
597 0 0         my $prune_threshold = $have_pruned ? 2 : 3;
598 0 0 0       if ($lo{sensor} && ($n_sensors + $n_values) > $prune_threshold) {
599 0 0         print "DEBUG\tpruning low outliers n_sensors=$n_sensors n_values=$n_values\n" if ($self->opt('d', 0, $opt_hr));
600 0           my $ar = $report_hr->{$lo{sensor}};
601 0           my $low_ppm = $lo{ppm};
602 0 0         if ($n_sensors > 2) {
603             # potentially prune out all results from this sensor
604 0           for my $ppm (@$ar) {
605             # zzapp -- this might be unfair, since threshold for low ppm is intrinsically of smaller magnitude
606             # maybe use different prune_threshold parameters for high and low?
607 0 0 0       next unless(!$ppm || (abs($ppm - $low_ppm) < $low_ppm * $self->opt('prune_threshold', 0.1, $opt_hr)));
608 0 0         print "DEBUG\tpruning low value $ppm\n" if ($self->opt('d', 0, $opt_hr));
609 0           $average -= $ppm;
610 0           $n_values--;
611             }
612             } else {
613             # just prune the sensor's lowest ppm
614 0 0         print "DEBUG\tpruning lowest value $low_ppm\n" if ($self->opt('d', 0, $opt_hr));
615 0           $average -= $low_ppm;
616 0           $n_values--;
617             }
618             }
619              
620             # calculate the average
621 0   0       $n_values ||= 1;
622 0           $average /= $n_values;
623              
624             # and now the standard deviation
625             # zzapp -- this includes pruned values, and I'm not sure if that's a bug or a feature
626 0           for my $v (@v_list) {
627 0           my $square_diff = ($average - $v) ** 2;
628 0           $deviation += $square_diff;
629             }
630 0           $deviation = ($deviation / @v_list) ** 0.5;
631              
632 0           return ($average, $deviation);
633             }
634              
635             =head1 ABOUT RELIABILITY
636              
637             The sensor readings are not always reliable. A car starting or a person
638             smoking near the sensor can produce a false high reading. Right now the
639             workaround is to specify multiple sensors and use the -g option. Future
640             releases will provide alternative remedies.
641              
642             =head1 AUTHOR
643              
644             TTK Ciar,
645              
646             =head1 COPYRIGHT AND LICENSE
647              
648             Copyright 2020 by TTK Ciar
649              
650             This library is free software; you can redistribute it and/or modify it under
651             the same terms as Perl itself.
652              
653             =head1 TO DO
654              
655             Write more documentation. Some methods are undocumented.
656              
657             Write more unit tests.
658              
659             Save some state and perform time averaging, perhaps throw out dramatic changes and reuse last known value.
660              
661             Pull down and cache the all-sensors blob, implement relevant operations:
662              
663             =over 4
664              
665             * find sensors by name
666              
667             * find sensors nearest a latitude/longitude, or near another sensor, maybe do some light GIS-fu
668              
669             =back
670              
671             =head1 HISTORY
672              
673             This was originally a throw-away script that just yanked JSON from the API, parsed out the sensor name
674             and first reading's PM2.5 concentration, and printed to stdout.
675              
676             It was quickly apparent that this left something to be desired, so the script was refactored into this
677             module and associated wrapper utility.
678              
679             My friend Matt using the throw-away script made me feel embarrassed at its shortcomings, which provided
680             some of the motivation to do better.
681              
682             =head1 SEE ALSO
683              
684             L
685              
686             L (stored in more convenient form by purpleairpy project)
687              
688             L by ReagentX, providing a different approach to the interface.
689              
690             L an unfortunately crude tool corresponding PM2.5 concentration to the AQI metric.
691              
692             L
693              
694             =cut
695              
696             1;