Weather-PurpleAir-API/lib/Weather/PurpleAir/API.pm
package Weather::PurpleAir::API;
## ABSTRACT: Client for using the purpleair.com air quality sensor API
use strict;
use warnings;
use v5.10.0; # provides "say" and "state"
use JSON::MaybeXS;
use File::Valet;
use HTTP::Tiny;
use Time::HiRes;
use String::Similarity qw(similarity);
our $VERSION = '0.09';
# Litchfield 38887 38888 City of Santa Rosa Laguna Treatment Plant 22961 22962 Geek Orchard 26363 26364
=head1 NAME
Weather::PurpleAir::API -- Client interface to Purple Air air quality API [DEPRECATED]
=head1 SYNOPSIS
use Weather::PurpleAir::API;
use Data::Dumper;
my $api = Weather::PurpleAir::API->new();
my @sensors = qw(38887 22961 26363);
my $report = $api->report(\@sensors);
for my $sensor_name (keys %$report) {
print join("\t", ($sensor_name, @{$report->{$sensor_name}})), "\n";
}
=head1 DESCRIPTION
B<IMPORTANT!!> C<Weather::PurpleAir::API> is deprecated! The PurpleAir team turned off their old API, and have set up a totally new API. A re-implementation of C<Weather::PurpleAir::API> for the new API is in progress, and will replace this module (which is now useless).
C<Weather::PurpleAir::API> provides a convenient interface to the Purple Air air quality API. It will
pull down data for specified sensors, transform them as desired (for instance, converting from raw
PM2.5 concentration to USA EPA AQI number, averaging results from dual-sensor nodes, etc) and provide
a concise report by sensor name.
The Purple Air map of sensors is located at L<https://www.purpleair.com/map>.
This module is very much a work in progress and 0.x releases might not be suitable for use.
For a simple commandline script wrapping this module, see C<bin/aqi> in this package.
Please do not poll sensors too frequently or the Purple Air server might block your IP.
=head1 PACKAGE ATTRIBUTES
=over 4
=item C<$Weather::PurpleAir::API::VERSION> (string)
The version number of this package (#.## format).
=back
=head1 OBJECT ATTRIBUTES
=over 4
=item C<sensor_hr> (hashref)
Keys on sensor ID numbers, maps to sensor names.
It is empty until the C<report()> method is called.
=item C<name_hr> (hashref)
Keys on sensor names, maps to sensor ID numbers.
It is empty until the C<report()> method is called.
=item C<ok> (string)
Indicates status of most recent operation: "OK", "WARNING" or "ERROR".
=over 4
* "OK" means everything completed as expected.
* "WARNING" means everything completed and possibly-useful data was returned, but something suspicious happened.
* "ERROR" means something went horribly wrong and the operation was unable to complete.
=back
=item C<err> (string or arrayref)
Set by WARNING and ERROR conditions, describes details of what went wrong.
=item C<n_err> (integer)
Incremented every time an error occurs.
=item C<n_warn> (integer)
Incremented every time a warning occurs.
=item C<ex> (exception or exception string)
Set to $@ when an exception is caught (for instance, when a JSON string does not decode).
=item C<js_or> (C<JSON::MaybeXS> object reference)
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<ascii =E<gt> 1, allow_nonref =E<gt> 1, space_after =E<gt> 1>.
=back
=head1 METHODS
All functionality is available via a C<Weather::PurpleAir::API> object. Start with C<new> and
use the resulting object to generate reports. Additional functionality (like finding sensors
by name, or finding sensors near locations) will come in future releases.
=head2 C<new(%options)>
Instantiates and returns a C<Weather::PurpleAir::API> object. Options passed here may be overridden
by passing options to methods of the object.
All options have hopefully-sane defaults:
=over 4
=item C<api_url> =E<gt> string
Override the URL at which the API is queried. Useful for testing, or if you have your own server.
Default is "https://www.purpleair.com/json?show=", to which the sensor ID is appended by the C<sensor_url> method.
=item C<average> =E<gt> 0 or 1
Averages AQI metrics from a node's sensors instead of returning the AQI metrics from each sensor in a node.
Default is 0 (do not average).
=item C<d> =E<gt> 0 or 1
Activate debugging logic, which will print horribly confusing things to STDOUT. Default is 0 (off).
=item C<g> =E<gt> 0 or 1
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).
The format of this entry is a little different from the sensor entries. Instead of $report->{GUESSING} referring
to an array of AQI metrics, it refers to an array containing the guessed gestalt AQI metric and its standard
deviation (a measure of statistical skew). The higher the standard deviation, the lower the confidence you
should have in the guessed metric.
For example:
{ GUESSING => [123.45, 1.09] } # AQI is 123.45, with high confidence
{ GUESSING => [123.45, 10.3] } # AQI is 123.45, with somewhat less confidence
{ GUESSING => [123.45, 67.5] } # AQI is 123.45, with very low confidence!
Default is 0 (do not guess).
=item C<http_or> =E<gt> HTTP::Tiny object
Provide the C<HTTP::Tiny> 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.
Default is undef (an C<HTTP::Tiny> object will be instantiated internally).
=item C<no_errors> =E<gt> 0 or 1
Set this to suppress writing error messages to stderr. Default is 0 (errors will be displayed).
=item C<no_warnings> =E<gt> 0 or 1
Set this to suppress writing warning messages to stderr. Default is 0 (warnings will be displayed).
=item C<now> =E<gt> 0 or 1
Normally reports will use the ten-minute average AQI from each sensor. Specify C<now> to use the current AQI instead.
Default is 0 (report will use ten-minute average AQI metrics).
=item C<prune_threshold> =E<gt> fraction between 0 and 1
When the C<g> (GUESSING) option is set, the guessing heuristic may prune more then one high and one low outlier
from the sensor data, if doing so will leave sufficient data left over for meaningful averaging. The prune
threshold determines how close to an outlier other data points must be, as a fraction of the outlier value, for
them to be pruned as well.
For instance, if C<prune_threshold> = 0.1 (10%) and the high outlier is 90, then 90 * 0.1 = 9, so data points
which are 81 or higher might also be pruned. If C<prune_threshold> = 0.05 (5%) and the high outlier is 90,
90 * 0.05 = 4.5, so data points which are 85.5 or higher might be pruned, etc.
Default is 0.1 (10%).
=item C<q> =E<gt> 0 or 1
"C<q>" is for "quiet". Setting this is equivalent to setting C<no_errors> and C<no_warnings>.
Default is 0.
=item C<raw> =E<gt> 0 or 1
When set, C<report> will not convert the API's raw concentration numbers to USA EPA PM2.5 AQI scores (the
metric displayed on the Purple Air website map). Part-per-million concentrations of 2.5 micrometer diameter
particles will be provided instead.
Default is 0 (concentrations will be converted to USA EPA PM2.5 AQI scores).
=item C<sensor> =E<gt> ID-number
=item C<sensor> =E<gt> [ID-number, ID-number, ...]
=item C<sensor> =E<gt> "ID-number ID-number ..."
Normally sensor IDs are passed to the C<report> method, but a default sensor or sensors can also be given
to C<new> at object instantiation time.
Right now C<Weather::PurpleAir::API> can only work with numeric sensor IDs and there isn't a really good
way to find the IDs of sensors. If you point a browser at the Purple Air map, some browsers will expose
the IDs of specific sensors and others will not.
If you download the all-sensors blob (either from the API at L<https://www.purpleair.com/json> or the
cached blob at L<http://ciar.org/h/sensors.all.json>) you can pick through the list and find IDs of
sensors of interest, which is a pain in the ass.
Future releases of C<Weather::PurpleAir::API> will support functions for finding sensors near a location,
but this one doesn't, which is one of the reasons it's a 0.x release.
Some example sensors and their IDs:
25407 Gravenstein School
38887 Litchfield
22961 City Of Santa Rosa Laguna Treatment Plant
26363 Geek Orchard
The default is 25407 (Gravenstein School, in Sonoma County, California)
=item C<stash_path> =E<gt> path string
When a C<stash_path> is provided, C<report> will store a copy of each sensor's JSON blob in the
specified directory, under the name C<aqi.$sensor_id.json>.
For example, if C<stash_path> = "/tmp" and C<sensor> = 25407, the JSON blob will be stored in file
"/tmp/aqi.25407.json".
The default is undef (not set, will not stash).
=item C<temperature> =E<gt> "C" or "F"
Also display the sensor's reported temperature, in either degrees Celsius or Fahrenheit.
=item C<v> =E<gt> 0 or 1
"C<v>" is for verbosity. When set, the reported-upon sensors (and gestalt guess, when C<g> parameter is
set) will be printed to stdout. This is normally set by the C<bin/aqi> utility.
Future releases might implement higher verbosity levels.
The default is 0 (do not print to stdout).
=back
=cut
sub new {
my ($class, %opt_hr) = @_;
my $self = {
opt_hr => \%opt_hr,
ok => 'OK',
ex => undef, # stash caught exceptions here
n_err => 0,
n_warn => 0,
err => '',
err_ar => [],
js_or => JSON::MaybeXS->new(ascii => 1, allow_nonref => 1, space_after => 1),
sensor_hr=> {},
name_hr => {},
};
bless ($self, $class);
# convert keys of form "some-parameter" to "some_parameter":
foreach my $k0 (keys %{$self->{opt_hr}}) {
my $k1 = join('_', split(/-/, $k0));
next if ($k0 eq $k1);
$self->{opt_hr}->{$k1} = $self->{opt_hr}->{$k0};
delete $self->{opt_hr}->{$k0};
}
return $self;
}
sub safely_to_json {
my ($self, $r) = @_;
my $js = eval { $self->{js_or}->encode($r); };
$self->{ex} = $@ unless(defined($js));
return $js;
}
sub safely_from_json {
my ($self, $js) = @_;
my $r = eval { $self->{js_or}->decode($js); };
return $r if (defined $r);
$self->{ex} = $@;
$self->err("JSON decode failed", $@);
return;
}
sub most_similar {
my ($self, $thing, $ar, $opt_hr) = @_;
my $best_match = '';
my $best_score = $self->opt('min_score', 0, $opt_hr);
for my $x (@$ar) {
my $score = similarity($thing, $x);
next unless($score >= $best_score);
$best_score = $score;
$best_match = $x;
}
return ($best_match, $best_score) if ($self->opt('best_match_and_score', 0, $opt_hr)); # for testing, mostly
return $best_match;
}
sub opt {
my ($self, $name, $default_value, $alt_hr) = @_;
$alt_hr //= {};
return $self->{opt_hr}->{$name} // $self->{conf_hr}->{$name} // $alt_hr->{$name} // $default_value;
}
# approximates python's "in" operator, because ~~ is unsane:
sub in {
my $v = shift @_;
return 0 unless (@_ && defined($_[0]));
if (ref($_[0]) eq 'ARRAY') {
foreach my $x (@{$_[0]}) { return 1 if defined($x) && $x eq $v; }
} else {
foreach my $x (@_) { return 1 if defined($x) && $x eq $v; }
}
return 0;
}
sub ok {
my $self = shift(@_);
$self->all_is_well();
return ('OK', @_);
}
sub err {
my $self = shift(@_);
$self->{ok} = "ERROR";
$self->{err} = [@_];
$self->shout("ERROR", @_) unless ($self->opt('q') || $self->opt('no_errors'));
$self->{n_err}++;
return ('ERROR', @_);
}
sub warn {
my $self = shift(@_);
$self->{ok} = "WARNING";
$self->{err} = [@_];
$self->shout("WARNING", @_) unless ($self->opt('q') || $self->opt('no_warnings'));
$self->{n_warn}++;
return ('WARNING', @_);
}
sub shout {
my $self = shift(@_);
my $msg = join("\t", @_);
print STDERR $msg, "\n";
return;
}
sub all_is_well {
my ($self) = @_;
$self->{ok} = 'OK';
$self->{ex} = undef;
$self->{err} = '';
$self->{err_ar} = [];
return;
}
sub sensors {
my ($self, $sensor_string, $opt_hr) = @_;
$opt_hr //= {};
my $sensor_list = $sensor_string // $self->opt('s', $self->opt('sensor', 25407, $opt_hr), $opt_hr);
return $sensor_list if (ref $sensor_list eq 'ARRAY');
return [split(/[^\d]+/, $sensor_list)];
}
sub sensor_url {
my ($self, $sensor_id, $opt_hr) = @_;
my $api_url = $self->opt('api_url', "https://www.purpleair.com/json?show=", $opt_hr);
my $url = "$api_url$sensor_id";
return $url
}
=head2 C<report()>
=head2 C<report([sensor-id, sensor-id, ...])>
=head2 C<report([sensor-id, sensor-id, ...], {option =E<gt> value, ...})>
=over 4
The C<report> method retrieves current data from each specified sensor and returns a hashref with
sensor name keys and arrayref values. The values represent the air quality at the sensor's location
(where higher values = more gunk in the air). Higher than 50 is bad, lower than 20 is good.
If no list of sensor IDs is used, and no list was provided to C<new(sensor =E<gt> ...)> a default of
25407 (Gravenstein School) will be used, which probably is not very useful to you.
See the notes under C<sensor> in the section for C<new> regarding sensor IDs and how to find them.
C<report> optionally accepts a hashref options, which are the same as those documented for C<new>.
Returns C<undef> on error and sets the object's C<ok> and C<err> attributes.
Returns a hashref as described on success and sets the object's C<ok> and C<err> attributes to "OK" and "" respectively.
=back
=cut
sub report {
my ($self, $sensors, $opt_hr) = @_;
$self->ok();
$opt_hr //= $opt_hr;
my $report_hr = $self->gather_sensors($sensors, $opt_hr);
my ($best_guess_aqi, $guess_deviation) = (0, 0);
if ($self->opt('g', 0, $opt_hr)) {
($best_guess_aqi, $guess_deviation) = $self->guess_best_aqi($report_hr, $opt_hr);
my ($p_best, $p_dev) = ($best_guess_aqi, $guess_deviation);
($p_best, $p_dev) = (int($p_best+0.5), int($p_dev+0.5)) if ($self->opt('i', 0, $opt_hr));
print "GUESSING\t$p_best\t$p_dev\n" if($self->opt('v', 0, $opt_hr));
$report_hr->{GUESSING} = [$best_guess_aqi];
}
return ($report_hr, $guess_deviation);
}
=head2 C<concentration_to_epa($concentration)>
This method implements the official USA EPA guideline for converting PM2.5
concentration to AQI, per
L<https://www.airnow.gov/sites/default/files/2020-05/aqi-technical-assistance-document-sept2018.pdf>.
The sensors do not report AQI metrics, only raw concentrations, so conversion
is necessary. The C<report> method does this automatically (unless the C<raw>
option is set).
The official equation is a dumb-ass piecewise function of linear interpolations,
but nobody ever said government was smart.
Takes a PM2.5 concentration as input, returns an AQI metric as output. The EPA
guideline asserts that the AQI must be truncated to a whole integer. I leave
that as an exercise for the programmer.
=cut
sub concentration_to_epa {
my ($self, $conc, $opt_hr) = @_;
# ref: https://www3.epa.gov/airnow/aqi-technical-assistance-document-sept2018.pdf
return $conc if ($conc > 500.0 || $conc < 0.05);
my ($Ihi, $Ilo, $BPhi, $BPlo);
if ($conc <= 12.0) { ($Ihi, $Ilo, $BPhi, $BPlo) = ( 50, 0, 12.0, 0.0); }
elsif ($conc <= 35.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (100, 51, 35.4, 12.0); }
elsif ($conc <= 55.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (150, 101, 55.4, 35.5); }
elsif ($conc <= 150.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (200, 151, 150.4, 55.5); }
elsif ($conc <= 250.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (300, 201, 250.4, 150.5); }
elsif ($conc <= 350.4) { ($Ihi, $Ilo, $BPhi, $BPlo) = (400, 301, 350.4, 250.5); }
else { ($Ihi, $Ilo, $BPhi, $BPlo) = (500, 401, 500.4, 350.4); }
my $epa = ($Ihi - $Ilo) * ($conc - $BPlo) / ($BPhi - $BPlo) + $Ilo;
return $epa;
}
sub gather_sensors {
my ($self, $sensors, $opt_hr) = @_;
my $report_hr = {};
my $http_or = $self->opt('http_or', undef, $opt_hr) // HTTP::Tiny->new();
$self->ok();
$sensors = $self->sensors($sensors, $opt_hr) unless (defined $sensors && ref $sensors eq "ARRAY");
my @temps;
for my $sensor (@$sensors) {
my $hr = $self->fetch_aqi($http_or, $sensor, $opt_hr);
next unless(defined $hr);
my ($name, $aqi_ar) = (undef, []);
for my $results_hr (@{$hr->{results} // []}) {
$name //= $results_hr->{Label};
my $aqi = $results_hr->{pm2_5_atm};
unless ($self->opt('now', 0, $opt_hr)) {
my $stats_hr = $self->safely_from_json($results_hr->{Stats});
if (defined $stats_hr) {
$aqi = $stats_hr->{v1}; # ten-minute average
} else {
$self->warn("unable to parse ten-minute average, using current value");
}
}
$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
push @{$aqi_ar}, $aqi if (defined $aqi);
push @temps, $results_hr->{temp_f} if (defined $results_hr->{temp_f});
}
$name //= $sensor;
$self->{sensor_hr}->{$sensor} = $name;
$self->{name_hr}->{$name} = $sensor;
$report_hr->{$name} = $aqi_ar if (@$aqi_ar > 0);
my $aqi = $self->aqi_calculate($aqi_ar, $opt_hr);
my $temp_f = 0;
$temp_f += $_ for (@temps);
$temp_f /= @temps if (@temps);
$temp_f = ($temp_f - 32) * 5 / 9 if (uc($self->opt('temperature', 'F')) eq 'C');
$temp_f = int($temp_f + 0.5) if ($self->opt('i'));
$report_hr->{temp_f} = $temp_f if ($self->opt('temperature'));
if ($self->opt('v', 0, $opt_hr) && @$aqi_ar) {
my @p_aqi = @$aqi;
@p_aqi = map { int($_+0.5) } @p_aqi if ($self->opt('i', 0, $opt_hr));
push @p_aqi, $temp_f if ($self->opt('temperature'));
my $s_aqi = join("\t", @p_aqi);
print "$name\t$s_aqi\n";
}
}
return $report_hr;
}
sub fetch_aqi {
my ($self, $http_or, $sensor, $opt_hr) = @_;
my $stash_path = $self->opt('stash_path', undef, $opt_hr);
my $url = $self->sensor_url($sensor, $opt_hr);
my $resp = $http_or->get($url);
my $hr;
if ($resp->{success}) {
my $js = $resp->{content};
wr_f("$stash_path/aqi.$sensor.json", $js) if (defined $stash_path);
$hr = $self->safely_from_json($js);
} else {
$self->err($resp->{status}, $resp->{reason});
}
return $hr;
}
sub aqi_calculate {
my ($self, $aqi_ar, $opt_hr) = @_;
my $aqi = 0;
if ($self->opt('average', 0, $opt_hr)) {
$aqi += $_ for(@$aqi_ar);
$aqi /= @$aqi_ar;
$aqi = [$aqi];
} else {
$aqi = $aqi_ar;
}
return $aqi;
}
sub guess_best_aqi {
my ($self, $report_hr, $opt_hr) = @_;
my $average = 0;
my $deviation = 0;
my $n_values = 0;
my $n_sensors = keys %$report_hr;
my %hi = (sensor => undef, result => 0, ppm => 0);
my %lo = (sensor => undef, result => 0, ppm => 1000000);
my @v_list; # superfluous, but simplifies stddev logic
for my $k (keys %$report_hr) {
my $ar = $report_hr->{$k};
$n_values += @$ar;
for my $ix (0..$#$ar) {
my $ppm = $ar->[$ix];
$average += $ppm;
push @v_list, $ppm;
%hi = (sensor => $k, result => $ix, ppm => $ppm) if ($ppm > $hi{ppm});
%lo = (sensor => $k, result => $ix, ppm => $ppm) if ($ppm < $lo{ppm});
}
}
# prune outliers if it will leave us with sufficient data for meaningful results
my $have_pruned = 0;
# first the high reading(s)
if ($hi{sensor} && ($n_sensors + $n_values) > 3) {
print "DEBUG\tpruning high outliers n_sensors=$n_sensors n_values=$n_values\n" if ($self->opt('d', 0, $opt_hr));
my $ar = $report_hr->{$hi{sensor}};
my $high_ppm = $hi{ppm};
if ($n_sensors > 2) {
# potentially prune out all results from this sensor
for my $ppm (@$ar) {
next unless(abs($ppm - $high_ppm) < $high_ppm * $self->opt('prune_threshold', 0.1, $opt_hr));
print "DEBUG\tpruning high value $ppm\n" if ($self->opt('d', 0, $opt_hr));
$average -= $ppm;
$n_values--;
$have_pruned++;
}
} else {
# just prune the sensor's highest ppm
print "DEBUG\tpruning highest value $high_ppm\n" if ($self->opt('d', 0, $opt_hr));
$average -= $high_ppm;
$n_values--;
$have_pruned++;
}
}
# now the low reading(s)
my $prune_threshold = $have_pruned ? 2 : 3;
if ($lo{sensor} && ($n_sensors + $n_values) > $prune_threshold) {
print "DEBUG\tpruning low outliers n_sensors=$n_sensors n_values=$n_values\n" if ($self->opt('d', 0, $opt_hr));
my $ar = $report_hr->{$lo{sensor}};
my $low_ppm = $lo{ppm};
if ($n_sensors > 2) {
# potentially prune out all results from this sensor
for my $ppm (@$ar) {
# zzapp -- this might be unfair, since threshold for low ppm is intrinsically of smaller magnitude
# maybe use different prune_threshold parameters for high and low?
next unless(!$ppm || (abs($ppm - $low_ppm) < $low_ppm * $self->opt('prune_threshold', 0.1, $opt_hr)));
print "DEBUG\tpruning low value $ppm\n" if ($self->opt('d', 0, $opt_hr));
$average -= $ppm;
$n_values--;
}
} else {
# just prune the sensor's lowest ppm
print "DEBUG\tpruning lowest value $low_ppm\n" if ($self->opt('d', 0, $opt_hr));
$average -= $low_ppm;
$n_values--;
}
}
# calculate the average
$n_values ||= 1;
$average /= $n_values;
# and now the standard deviation
# zzapp -- this includes pruned values, and I'm not sure if that's a bug or a feature
for my $v (@v_list) {
my $square_diff = ($average - $v) ** 2;
$deviation += $square_diff;
}
$deviation = ($deviation / @v_list) ** 0.5;
return ($average, $deviation);
}
=head1 ABOUT RELIABILITY
The sensor readings are not always reliable. A car starting or a person
smoking near the sensor can produce a false high reading. Right now the
workaround is to specify multiple sensors and use the -g option. Future
releases will provide alternative remedies.
=head1 AUTHOR
TTK Ciar, <ttk[at]ciar[dot]org>
=head1 COPYRIGHT AND LICENSE
Copyright 2020 by TTK Ciar
This library is free software; you can redistribute it and/or modify it under
the same terms as Perl itself.
=head1 TO DO
Write more documentation. Some methods are undocumented.
Write more unit tests.
Save some state and perform time averaging, perhaps throw out dramatic changes and reuse last known value.
Pull down and cache the all-sensors blob, implement relevant operations:
=over 4
* find sensors by name
* find sensors nearest a latitude/longitude, or near another sensor, maybe do some light GIS-fu
=back
=head1 HISTORY
This was originally a throw-away script that just yanked JSON from the API, parsed out the sensor name
and first reading's PM2.5 concentration, and printed to stdout.
It was quickly apparent that this left something to be desired, so the script was refactored into this
module and associated wrapper utility.
My friend Matt using the throw-away script made me feel embarrassed at its shortcomings, which provided
some of the motivation to do better.
=head1 SEE ALSO
L<The PurpleAir Website|http://purpleair.com>
L<The PurpleAir API FAQ|https://github.com/bomeara/purpleairpy/blob/master/api.md> (stored in more convenient form by purpleairpy project)
L<Python API Client|https://github.com/ReagentX/purple_air_api> by ReagentX, providing a different approach to the interface.
L<Air Concentration to USA EPA PM2.5 AQI Calculator|https://aqicn.org/calculator/> an unfortunately crude tool corresponding PM2.5 concentration to the AQI metric.
L<USA EPA PM2.5 AQI Calculation Guidelines|https://www3.epa.gov/airnow/aqi-technical-assistance-document-sept2018.pdf>
=cut
1;