python-aqi 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aqi/__init__.py +248 -0
- aqi/algos/__init__.py +62 -0
- aqi/algos/base.py +242 -0
- aqi/algos/epa.py +207 -0
- aqi/algos/mep.py +128 -0
- aqi/constants.py +23 -0
- aqi/exceptions.py +32 -0
- python_aqi-1.0.0.dist-info/METADATA +291 -0
- python_aqi-1.0.0.dist-info/RECORD +12 -0
- python_aqi-1.0.0.dist-info/WHEEL +4 -0
- python_aqi-1.0.0.dist-info/entry_points.txt +2 -0
- python_aqi-1.0.0.dist-info/licenses/LICENSE +12 -0
aqi/__init__.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from aqi.algos import available_algos, get_algo, list_algos
|
|
2
|
+
from aqi.constants import (
|
|
3
|
+
ALGO_EPA,
|
|
4
|
+
ALGO_EPA_2012,
|
|
5
|
+
ALGO_EPA_2015,
|
|
6
|
+
ALGO_EPA_2024,
|
|
7
|
+
ALGO_MEP,
|
|
8
|
+
ALGO_MEP_2012,
|
|
9
|
+
ALGO_MEP_2026,
|
|
10
|
+
POLLUTANT_CO_1H,
|
|
11
|
+
POLLUTANT_CO_8H,
|
|
12
|
+
POLLUTANT_CO_24H,
|
|
13
|
+
POLLUTANT_NO2_1H,
|
|
14
|
+
POLLUTANT_NO2_24H,
|
|
15
|
+
POLLUTANT_O3_1H,
|
|
16
|
+
POLLUTANT_O3_8H,
|
|
17
|
+
POLLUTANT_PM10,
|
|
18
|
+
POLLUTANT_PM25,
|
|
19
|
+
POLLUTANT_SO2_1H,
|
|
20
|
+
POLLUTANT_SO2_24H,
|
|
21
|
+
)
|
|
22
|
+
from aqi.exceptions import NotApplicableError, OutOfRangeError
|
|
23
|
+
|
|
24
|
+
__author__ = 'Stefan "hr" Berder'
|
|
25
|
+
__contact__ = "hr@bonz.org"
|
|
26
|
+
__license__ = "BSD 3-Clause"
|
|
27
|
+
__version__ = "1.0.0"
|
|
28
|
+
|
|
29
|
+
# the pollutant and algorithm constants are part of the public API
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ALGO_EPA",
|
|
32
|
+
"ALGO_EPA_2012",
|
|
33
|
+
"ALGO_EPA_2015",
|
|
34
|
+
"ALGO_EPA_2024",
|
|
35
|
+
"ALGO_MEP",
|
|
36
|
+
"ALGO_MEP_2012",
|
|
37
|
+
"ALGO_MEP_2026",
|
|
38
|
+
"NotApplicableError",
|
|
39
|
+
"OutOfRangeError",
|
|
40
|
+
"POLLUTANT_CO_1H",
|
|
41
|
+
"POLLUTANT_CO_8H",
|
|
42
|
+
"POLLUTANT_CO_24H",
|
|
43
|
+
"POLLUTANT_NO2_1H",
|
|
44
|
+
"POLLUTANT_NO2_24H",
|
|
45
|
+
"POLLUTANT_O3_1H",
|
|
46
|
+
"POLLUTANT_O3_8H",
|
|
47
|
+
"POLLUTANT_PM10",
|
|
48
|
+
"POLLUTANT_PM25",
|
|
49
|
+
"POLLUTANT_SO2_1H",
|
|
50
|
+
"POLLUTANT_SO2_24H",
|
|
51
|
+
"available_algos",
|
|
52
|
+
"console_aqi",
|
|
53
|
+
"get_algo",
|
|
54
|
+
"list_algos",
|
|
55
|
+
"to_aqi",
|
|
56
|
+
"to_cc",
|
|
57
|
+
"to_iaqi",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_algo(algo):
|
|
62
|
+
_aqi = get_algo(algo)
|
|
63
|
+
if _aqi is None:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Unknown algorithm {algo!r}, available: {', '.join(available_algos())}"
|
|
66
|
+
)
|
|
67
|
+
return _aqi
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def to_iaqi(elem, cc, algo=ALGO_EPA, extrapolate=False):
|
|
71
|
+
"""Calculate an intermediate AQI for a given pollutant. This is the
|
|
72
|
+
heart of the algo.
|
|
73
|
+
|
|
74
|
+
.. warning:: the concentration is passed as a string so
|
|
75
|
+
:class:`decimal.Decimal` doesn't act up with binary floats.
|
|
76
|
+
|
|
77
|
+
:param elem: pollutant constant
|
|
78
|
+
:type elem: int
|
|
79
|
+
:param cc: pollutant concentration (µg/m³ or ppm)
|
|
80
|
+
:type cc: str
|
|
81
|
+
:param algo: algorithm, ``'epa'`` for the latest version of a family,
|
|
82
|
+
``'epa:2012'`` for a given version, or a module name like
|
|
83
|
+
``'aqi.algos.epa'`` (latest version)
|
|
84
|
+
:type algo: str
|
|
85
|
+
:param extrapolate: compute values beyond the scale when the standard
|
|
86
|
+
describes how (EPA "Beyond the AQI")
|
|
87
|
+
:type extrapolate: bool
|
|
88
|
+
:raises OutOfRangeError: the concentration is not covered by the algorithm
|
|
89
|
+
"""
|
|
90
|
+
_aqi = _load_algo(algo)
|
|
91
|
+
return _aqi.iaqi(elem, cc, extrapolate=extrapolate)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def to_aqi(ccs, algo=ALGO_EPA, extrapolate=False):
|
|
95
|
+
"""Calculate the AQI based on a list of pollutants
|
|
96
|
+
|
|
97
|
+
:param ccs: a list of tuples of pollutants concentrations with
|
|
98
|
+
pollutant constant and concentration as values
|
|
99
|
+
:type ccs: list
|
|
100
|
+
:param algo: algorithm, see :func:`to_iaqi`
|
|
101
|
+
:type algo: str
|
|
102
|
+
:param extrapolate: see :func:`to_iaqi`
|
|
103
|
+
:type extrapolate: bool
|
|
104
|
+
:raises OutOfRangeError: a concentration is not covered by the algorithm
|
|
105
|
+
"""
|
|
106
|
+
_aqi = _load_algo(algo)
|
|
107
|
+
return _aqi.aqi(ccs, extrapolate=extrapolate)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_cc(elem, iaqi, algo=ALGO_EPA, extrapolate=False):
|
|
111
|
+
"""Calculate a concentration for a given pollutant.
|
|
112
|
+
|
|
113
|
+
.. warning:: the intermediate AQI is passed as a string
|
|
114
|
+
|
|
115
|
+
:param elem: pollutant constant
|
|
116
|
+
:type elem: int
|
|
117
|
+
:param iaqi: intermediate AQI
|
|
118
|
+
:type iaqi: str
|
|
119
|
+
:param algo: algorithm, ``'epa'`` for the latest version of a family,
|
|
120
|
+
``'epa:2012'`` for a given version, or a module name like
|
|
121
|
+
``'aqi.algos.epa'`` (latest version)
|
|
122
|
+
:type algo: str
|
|
123
|
+
:param extrapolate: see :func:`to_iaqi`
|
|
124
|
+
:type extrapolate: bool
|
|
125
|
+
:raises OutOfRangeError: the AQI value is not covered by the algorithm
|
|
126
|
+
"""
|
|
127
|
+
_aqi = _load_algo(algo)
|
|
128
|
+
return _aqi.cc(elem, iaqi, extrapolate=extrapolate)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def console_aqi():
|
|
132
|
+
"""Console entry point, this function is used as an entry point to
|
|
133
|
+
the 'aqi' command.
|
|
134
|
+
"""
|
|
135
|
+
import argparse
|
|
136
|
+
import sys
|
|
137
|
+
|
|
138
|
+
parser = argparse.ArgumentParser(
|
|
139
|
+
description="""Convert between AQI value and pollutant concentration (µg/m³ or
|
|
140
|
+
ppm)."""
|
|
141
|
+
)
|
|
142
|
+
parser.add_argument(
|
|
143
|
+
"-c",
|
|
144
|
+
dest="conv",
|
|
145
|
+
choices=["aqi", "cc"],
|
|
146
|
+
default="aqi",
|
|
147
|
+
help="Conversion to perform, defaults to 'aqi'",
|
|
148
|
+
)
|
|
149
|
+
parser.add_argument(
|
|
150
|
+
"-l",
|
|
151
|
+
action="store_true",
|
|
152
|
+
dest="list",
|
|
153
|
+
help="list the available algorithm versions and corresponding pollutants",
|
|
154
|
+
)
|
|
155
|
+
parser.add_argument(
|
|
156
|
+
"-v", action="store_true", dest="verbose", help="add IAQIs to the result"
|
|
157
|
+
)
|
|
158
|
+
parser.add_argument(
|
|
159
|
+
"-e",
|
|
160
|
+
action="store_true",
|
|
161
|
+
dest="extrapolate",
|
|
162
|
+
help="compute values beyond the AQI scale when the standard allows it "
|
|
163
|
+
"(EPA 'Beyond the AQI'), otherwise out of range values are an error",
|
|
164
|
+
)
|
|
165
|
+
parser.add_argument(
|
|
166
|
+
"algo",
|
|
167
|
+
nargs="?",
|
|
168
|
+
help="the formula to use for the AQI calculation: a family and version "
|
|
169
|
+
"like 'epa:2012', a family like 'epa' for its latest version, or the "
|
|
170
|
+
"python module path",
|
|
171
|
+
)
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"measures",
|
|
174
|
+
nargs="*",
|
|
175
|
+
metavar="measure",
|
|
176
|
+
help="pollutant measure, format is "
|
|
177
|
+
"element_name:concentration. Unknown "
|
|
178
|
+
"pollutants are silently ignored.",
|
|
179
|
+
)
|
|
180
|
+
args = parser.parse_args()
|
|
181
|
+
|
|
182
|
+
# list available algorithms
|
|
183
|
+
if args.list is True:
|
|
184
|
+
latest = {}
|
|
185
|
+
for name in available_algos():
|
|
186
|
+
latest[name.split(":")[0]] = name
|
|
187
|
+
for name, pollutants in list_algos():
|
|
188
|
+
elems = ", ".join(f"{elem} ({unit})" for elem, unit in pollutants)
|
|
189
|
+
note = " (latest)" if name in latest.values() else ""
|
|
190
|
+
print(f"{name}{note}: {elems}")
|
|
191
|
+
else:
|
|
192
|
+
# if not listing but missing other positional argument
|
|
193
|
+
if args.algo is None or args.measures is None:
|
|
194
|
+
sys.stderr.write("Missing algorithm or measure.\n")
|
|
195
|
+
parser.print_help()
|
|
196
|
+
sys.exit(1)
|
|
197
|
+
_aqi = get_algo(args.algo)
|
|
198
|
+
# couln't load the algo module or instanciate AQI class
|
|
199
|
+
if _aqi is None:
|
|
200
|
+
sys.stderr.write("Unknown algorithm or module is missing an AQI class\n")
|
|
201
|
+
parser.print_help()
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
try:
|
|
204
|
+
if args.conv == "aqi":
|
|
205
|
+
ccs = []
|
|
206
|
+
for measure in args.measures:
|
|
207
|
+
(elem, cc) = measure.split(":")
|
|
208
|
+
ccs.append((elem, cc))
|
|
209
|
+
|
|
210
|
+
ret = _aqi.aqi(ccs, iaqis=args.verbose, extrapolate=args.extrapolate)
|
|
211
|
+
if args.verbose is True:
|
|
212
|
+
iaqis = []
|
|
213
|
+
for constant, iaqi in ret[1].items():
|
|
214
|
+
iaqis.append(constant + ":" + str(iaqi))
|
|
215
|
+
sys.stdout.write(" ".join(iaqis) + "\n")
|
|
216
|
+
sys.stdout.write(str(ret[0]) + "\n")
|
|
217
|
+
else:
|
|
218
|
+
sys.stdout.write(str(ret) + "\n")
|
|
219
|
+
else:
|
|
220
|
+
iaqis = []
|
|
221
|
+
for measure in args.measures:
|
|
222
|
+
(elem, iaqi) = measure.split(":")
|
|
223
|
+
iaqis.append((elem, iaqi))
|
|
224
|
+
|
|
225
|
+
ret = []
|
|
226
|
+
for iaqi in iaqis:
|
|
227
|
+
ret.append(
|
|
228
|
+
(
|
|
229
|
+
iaqi[0],
|
|
230
|
+
_aqi.cc(iaqi[0], iaqi[1], extrapolate=args.extrapolate),
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
if len(ret) == 1:
|
|
234
|
+
sys.stdout.write(str(ret[0][1]) + "\n")
|
|
235
|
+
elif len(ret) > 1:
|
|
236
|
+
ccs = []
|
|
237
|
+
for elem, cc in ret:
|
|
238
|
+
if cc is None:
|
|
239
|
+
ccs.append(elem + ":na")
|
|
240
|
+
else:
|
|
241
|
+
ccs.append(elem + ":" + str(cc))
|
|
242
|
+
sys.stdout.write("\n".join(ccs) + "\n")
|
|
243
|
+
except OutOfRangeError as err:
|
|
244
|
+
sys.stderr.write(f"Out of range: {err}\n")
|
|
245
|
+
sys.exit(3)
|
|
246
|
+
|
|
247
|
+
# end the script without a problem
|
|
248
|
+
sys.exit(0)
|
aqi/algos/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
# algorithm families and the module holding their versions, each module has
|
|
4
|
+
# a ``VERSIONS`` dict (oldest first) and an ``AQI`` class, the latest version
|
|
5
|
+
FAMILIES = {
|
|
6
|
+
"epa": "aqi.algos.epa",
|
|
7
|
+
"mep": "aqi.algos.mep",
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _versions(family):
|
|
12
|
+
return importlib.import_module(FAMILIES[family]).VERSIONS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def available_algos():
|
|
16
|
+
"""Return the names of all the algorithm versions, as ``family:version``
|
|
17
|
+
strings, ordered by family and then from the oldest to the latest version.
|
|
18
|
+
"""
|
|
19
|
+
return [
|
|
20
|
+
f"{family}:{version}" for family in FAMILIES for version in _versions(family)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_algo(algo):
|
|
25
|
+
"""Instanciate an AQI algorithm class. If the algorithm is unknown or
|
|
26
|
+
there is a problem during the import or instanciation, return None.
|
|
27
|
+
|
|
28
|
+
:param algo: one of
|
|
29
|
+
|
|
30
|
+
* a ``family:version`` string like ``'epa:2012'``
|
|
31
|
+
* a family name like ``'epa'``, the latest version
|
|
32
|
+
* a module canonical name like ``'aqi.algos.epa'``, the latest
|
|
33
|
+
version of the module (this is how algorithms were always named)
|
|
34
|
+
:type algo: str
|
|
35
|
+
"""
|
|
36
|
+
family, sep, version = algo.partition(":")
|
|
37
|
+
if family in FAMILIES:
|
|
38
|
+
versions = _versions(family)
|
|
39
|
+
if not sep:
|
|
40
|
+
return versions[list(versions)[-1]]()
|
|
41
|
+
if version in versions:
|
|
42
|
+
return versions[version]()
|
|
43
|
+
return None
|
|
44
|
+
if sep:
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
# a module path
|
|
48
|
+
try:
|
|
49
|
+
mod = importlib.import_module(algo)
|
|
50
|
+
except (ImportError, ValueError):
|
|
51
|
+
return None
|
|
52
|
+
try:
|
|
53
|
+
return mod.AQI()
|
|
54
|
+
except AttributeError:
|
|
55
|
+
return None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def list_algos():
|
|
59
|
+
"""Return a list of tuples with the name of each available algorithm
|
|
60
|
+
version and its corresponding pollutants
|
|
61
|
+
"""
|
|
62
|
+
return [(name, get_algo(name).list_pollutants()) for name in available_algos()]
|
aqi/algos/base.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from decimal import (
|
|
3
|
+
ROUND_CEILING,
|
|
4
|
+
ROUND_DOWN,
|
|
5
|
+
ROUND_HALF_EVEN,
|
|
6
|
+
Decimal,
|
|
7
|
+
localcontext,
|
|
8
|
+
)
|
|
9
|
+
from fractions import Fraction
|
|
10
|
+
|
|
11
|
+
from aqi.exceptions import NotApplicableError, OutOfRangeError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def derive_piecewise(parent, bp=None, prec=None, units=None, aqi=None):
|
|
15
|
+
"""Build the ``piecewise`` table of a new version of a standard from the
|
|
16
|
+
previous one, only the given parts are replaced.
|
|
17
|
+
|
|
18
|
+
:param parent: the ``piecewise`` dict of the previous version
|
|
19
|
+
:param bp: breakpoints by pollutant to replace or add
|
|
20
|
+
:param prec: precisions by pollutant to replace or add
|
|
21
|
+
:param units: units by pollutant to replace or add
|
|
22
|
+
:param aqi: new list of AQI bands, replaces the whole list
|
|
23
|
+
:return: a new dict, ``parent`` is left untouched
|
|
24
|
+
"""
|
|
25
|
+
return {
|
|
26
|
+
"aqi": list(parent["aqi"] if aqi is None else aqi),
|
|
27
|
+
"bp": {**parent["bp"], **(bp or {})},
|
|
28
|
+
"prec": {**parent["prec"], **(prec or {})},
|
|
29
|
+
"units": {**parent["units"], **(units or {})},
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BaseAQI:
|
|
34
|
+
"""A generic AQI class"""
|
|
35
|
+
|
|
36
|
+
def iaqi(self, elem, cc, extrapolate=False):
|
|
37
|
+
"""Calculate an intermediate AQI for a given pollutant. This is
|
|
38
|
+
the heart of the algo. Return the IAQI for the given pollutant.
|
|
39
|
+
|
|
40
|
+
.. warning:: the concentration is passed as a string so
|
|
41
|
+
:class:`decimal.Decimal` doesn't act up with binary floats.
|
|
42
|
+
|
|
43
|
+
:param elem: pollutant constant
|
|
44
|
+
:type elem: int
|
|
45
|
+
:param cc: pollutant concentration (µg/m³ or ppm)
|
|
46
|
+
:type cc: str
|
|
47
|
+
:param extrapolate: compute values beyond the scale if the standard
|
|
48
|
+
allows it, otherwise raise
|
|
49
|
+
:type extrapolate: bool
|
|
50
|
+
:raises OutOfRangeError: the concentration is not covered
|
|
51
|
+
"""
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
def aqi(self, ccs, iaqis=False, extrapolate=False):
|
|
55
|
+
"""Calculate the AQI based on a list of pollutants. Return an
|
|
56
|
+
AQI value, if `iaqis` is set to True, send back a tuple
|
|
57
|
+
containing the AQI and a dict of IAQIs.
|
|
58
|
+
|
|
59
|
+
:param ccs: a list of tuples of pollutants concentrations with
|
|
60
|
+
pollutant constant and concentration as values
|
|
61
|
+
:type ccs: list
|
|
62
|
+
:param iaqis: return IAQIs with result
|
|
63
|
+
:type iaqis: bool
|
|
64
|
+
:param extrapolate: see :meth:`iaqi`
|
|
65
|
+
:type extrapolate: bool
|
|
66
|
+
"""
|
|
67
|
+
_iaqis = {}
|
|
68
|
+
skipped = None
|
|
69
|
+
for elem, cc in ccs:
|
|
70
|
+
try:
|
|
71
|
+
_iaqi = self.iaqi(elem, cc, extrapolate=extrapolate)
|
|
72
|
+
except NotApplicableError as err:
|
|
73
|
+
# blank in the table: the standard says to disregard the value
|
|
74
|
+
skipped = err
|
|
75
|
+
continue
|
|
76
|
+
if _iaqi is not None:
|
|
77
|
+
_iaqis[elem] = _iaqi
|
|
78
|
+
if not _iaqis and skipped is not None:
|
|
79
|
+
raise skipped
|
|
80
|
+
_aqi = max(_iaqis.values())
|
|
81
|
+
if iaqis:
|
|
82
|
+
return (_aqi, _iaqis)
|
|
83
|
+
else:
|
|
84
|
+
return _aqi
|
|
85
|
+
|
|
86
|
+
def cc(self, elem, iaqi, extrapolate=False):
|
|
87
|
+
"""Calculate a concentration for a given pollutant. Return the
|
|
88
|
+
concentration for the given pollutant based on the intermediate AQI.
|
|
89
|
+
|
|
90
|
+
.. warning:: the intermediate AQI is passed as a string
|
|
91
|
+
|
|
92
|
+
:param elem: pollutant constant
|
|
93
|
+
:type elem: int
|
|
94
|
+
:param cc: intermediate AQI
|
|
95
|
+
:type cc: str
|
|
96
|
+
:param extrapolate: see :meth:`iaqi`
|
|
97
|
+
:type extrapolate: bool
|
|
98
|
+
:raises OutOfRangeError: the AQI value is not covered
|
|
99
|
+
"""
|
|
100
|
+
raise NotImplementedError
|
|
101
|
+
|
|
102
|
+
def list_pollutants(self):
|
|
103
|
+
"""List pollutants covered by an algorithm, return a list of
|
|
104
|
+
pollutant names.
|
|
105
|
+
"""
|
|
106
|
+
raise NotImplementedError
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class PiecewiseAQI(BaseAQI):
|
|
110
|
+
"""A piecewise function AQI class (like EPA or MEP)
|
|
111
|
+
|
|
112
|
+
A pollutant's breakpoints are a list with one ``(low, high)`` tuple per AQI
|
|
113
|
+
band, ``None`` for a band the standard leaves blank. Values outside of the
|
|
114
|
+
breakpoints raise :class:`OutOfRangeError` (:class:`NotApplicableError`
|
|
115
|
+
for a blank band). An
|
|
116
|
+
algorithm whose standard describes how to go beyond the top of the scale
|
|
117
|
+
sets ``extrapolation`` to True: a pollutant whose table covers the whole
|
|
118
|
+
scale then extends its last band when ``extrapolate`` is requested.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
piecewise = None
|
|
122
|
+
extrapolation = False
|
|
123
|
+
|
|
124
|
+
#: how an IAQI is rounded to an integer, ``ROUND_HALF_EVEN`` or
|
|
125
|
+
#: ``ROUND_CEILING`` (HJ 633 rounds up)
|
|
126
|
+
rounding = ROUND_HALF_EVEN
|
|
127
|
+
#: how a concentration is brought to the precision of its pollutant,
|
|
128
|
+
#: ``ROUND_DOWN`` truncates (EPA), ``ROUND_HALF_EVEN`` is GB/T 8170 (HJ 633)
|
|
129
|
+
cc_rounding = ROUND_DOWN
|
|
130
|
+
#: pollutants whose IAQI is fixed above the top of the table, by pollutant,
|
|
131
|
+
#: instead of being out of range (HJ 633-2026 table 3 notes b and c)
|
|
132
|
+
saturation = {}
|
|
133
|
+
#: pollutants that the standard does not compute above the top of the
|
|
134
|
+
#: table (HJ 633-2012 notes), :class:`NotApplicableError` is raised
|
|
135
|
+
undefined_above = ()
|
|
136
|
+
|
|
137
|
+
def _check(self, elem):
|
|
138
|
+
if self.piecewise is None:
|
|
139
|
+
raise NameError("piecewise struct is not defined")
|
|
140
|
+
return elem in self.piecewise["bp"]
|
|
141
|
+
|
|
142
|
+
def _can_extrapolate(self, elem):
|
|
143
|
+
"""Only pollutants with a band for every AQI band can extend the top"""
|
|
144
|
+
return self.extrapolation and len(self.piecewise["bp"][elem]) == len(
|
|
145
|
+
self.piecewise["aqi"]
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def iaqi(self, elem, cc, extrapolate=False):
|
|
149
|
+
if not self._check(elem):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
raw = Decimal(cc)
|
|
153
|
+
prec = self.piecewise["prec"][elem]
|
|
154
|
+
# a pollutant without precision is used as given
|
|
155
|
+
_cc = raw if prec is None else raw.quantize(prec, rounding=self.cc_rounding)
|
|
156
|
+
|
|
157
|
+
# define breakpoints for this pollutant at this concentration
|
|
158
|
+
bps = self.piecewise["bp"][elem]
|
|
159
|
+
filled = [bp for bp in bps if bp is not None]
|
|
160
|
+
low, high = filled[0][0], filled[-1][1]
|
|
161
|
+
# a negative value must not be truncated to zero
|
|
162
|
+
if raw < 0:
|
|
163
|
+
raise OutOfRangeError(elem, raw, low, high)
|
|
164
|
+
|
|
165
|
+
idx = None
|
|
166
|
+
for i, bp in enumerate(bps):
|
|
167
|
+
if bp is not None and bp[0] <= _cc <= bp[1]:
|
|
168
|
+
idx = i
|
|
169
|
+
break
|
|
170
|
+
if idx is None:
|
|
171
|
+
if _cc > high and elem in self.saturation:
|
|
172
|
+
return Decimal(self.saturation[elem])
|
|
173
|
+
elif _cc > high and elem in self.undefined_above:
|
|
174
|
+
raise NotApplicableError(elem, _cc, low, high)
|
|
175
|
+
elif _cc > high and extrapolate and self._can_extrapolate(elem):
|
|
176
|
+
idx = len(bps) - 1
|
|
177
|
+
elif _cc < low and bps[0] is None:
|
|
178
|
+
raise NotApplicableError(elem, _cc, low, high)
|
|
179
|
+
else:
|
|
180
|
+
raise OutOfRangeError(elem, _cc, low, high)
|
|
181
|
+
bplo, bphi = bps[idx]
|
|
182
|
+
# get corresponding AQI boundaries
|
|
183
|
+
(aqilo, aqihi) = self.piecewise["aqi"][idx]
|
|
184
|
+
|
|
185
|
+
if self.rounding == ROUND_CEILING:
|
|
186
|
+
# computed exactly so that rounding up an exact value does not
|
|
187
|
+
# push it to the next integer
|
|
188
|
+
value = (Fraction(aqihi) - Fraction(aqilo)) / (
|
|
189
|
+
Fraction(bphi) - Fraction(bplo)
|
|
190
|
+
) * (Fraction(_cc) - Fraction(bplo)) + Fraction(aqilo)
|
|
191
|
+
return Decimal(math.ceil(value))
|
|
192
|
+
|
|
193
|
+
# equation
|
|
194
|
+
value = (aqihi - aqilo) / (bphi - bplo) * (_cc - bplo) + aqilo
|
|
195
|
+
return value.quantize(Decimal("1."), rounding=self.rounding)
|
|
196
|
+
|
|
197
|
+
def cc(self, elem, iaqi, extrapolate=False):
|
|
198
|
+
if not self._check(elem):
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
_iaqi = int(iaqi)
|
|
202
|
+
|
|
203
|
+
# define aqi breakpoints for this pollutant at this IAQI
|
|
204
|
+
bps = self.piecewise["aqi"]
|
|
205
|
+
ccs = self.piecewise["bp"][elem]
|
|
206
|
+
low, high = bps[0][0], bps[-1][1]
|
|
207
|
+
if _iaqi < low:
|
|
208
|
+
raise OutOfRangeError(elem, _iaqi, low, high)
|
|
209
|
+
|
|
210
|
+
idx = None
|
|
211
|
+
for i, bp in enumerate(bps):
|
|
212
|
+
if bp[0] <= _iaqi <= bp[1]:
|
|
213
|
+
idx = i
|
|
214
|
+
break
|
|
215
|
+
if idx is None:
|
|
216
|
+
if extrapolate and self._can_extrapolate(elem):
|
|
217
|
+
idx = len(bps) - 1
|
|
218
|
+
else:
|
|
219
|
+
raise OutOfRangeError(elem, _iaqi, low, high)
|
|
220
|
+
elif idx >= len(ccs):
|
|
221
|
+
# this pollutant has no band for this part of the scale
|
|
222
|
+
raise OutOfRangeError(elem, _iaqi, low, bps[len(ccs) - 1][1])
|
|
223
|
+
elif ccs[idx] is None:
|
|
224
|
+
first = next(i for i, bp in enumerate(ccs) if bp is not None)
|
|
225
|
+
raise NotApplicableError(elem, _iaqi, bps[first][0], bps[-1][1])
|
|
226
|
+
bplo, bphi = bps[idx]
|
|
227
|
+
# get corresponding concentration boundaries
|
|
228
|
+
(cclo, cchi) = ccs[idx]
|
|
229
|
+
|
|
230
|
+
# equation
|
|
231
|
+
prec = self.piecewise["prec"][elem]
|
|
232
|
+
if prec is None:
|
|
233
|
+
# no rounding: truncated so that rounding the IAQI of the result
|
|
234
|
+
# up does not reach the next integer
|
|
235
|
+
with localcontext() as ctx:
|
|
236
|
+
ctx.rounding = ROUND_DOWN
|
|
237
|
+
return (cchi - cclo) / (bphi - bplo) * (_iaqi - bplo) + cclo
|
|
238
|
+
value = (cchi - cclo) / (bphi - bplo) * (_iaqi - bplo) + cclo
|
|
239
|
+
return Decimal(value).quantize(prec, rounding=self.cc_rounding)
|
|
240
|
+
|
|
241
|
+
def list_pollutants(self):
|
|
242
|
+
return self.piecewise["units"].items()
|
aqi/algos/epa.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from decimal import Decimal
|
|
2
|
+
|
|
3
|
+
from aqi.algos.base import PiecewiseAQI, derive_piecewise
|
|
4
|
+
from aqi.constants import (
|
|
5
|
+
POLLUTANT_CO_8H,
|
|
6
|
+
POLLUTANT_NO2_1H,
|
|
7
|
+
POLLUTANT_O3_1H,
|
|
8
|
+
POLLUTANT_O3_8H,
|
|
9
|
+
POLLUTANT_PM10,
|
|
10
|
+
POLLUTANT_PM25,
|
|
11
|
+
POLLUTANT_SO2_1H,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class EPA_2012(PiecewiseAQI):
|
|
16
|
+
"""Implementation of the EPA AQI algorithm, as python-aqi has always
|
|
17
|
+
computed it: the PM2.5 breakpoints of the 2012 revision (annual standard
|
|
18
|
+
lowered to 12 µg/m³) and the 1997 ozone 8 hour breakpoints.
|
|
19
|
+
|
|
20
|
+
Source: Technical Assistance Document EPA-454/B-13-001, December 2013, and
|
|
21
|
+
the Federal Register of 15 January 2013 (78 FR 3286), both in
|
|
22
|
+
``docs/standards``. The ``(0, 0)`` first bands of 1 hour ozone are a known
|
|
23
|
+
flaw (issue 15) that is kept here, later versions leave them blank.
|
|
24
|
+
|
|
25
|
+
Above 500 the EPA calls values "Beyond the AQI" and computes them with
|
|
26
|
+
the Hazardous breakpoints (Technical Assistance Document, Q&A), this is
|
|
27
|
+
available with ``extrapolate=True``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
extrapolation = True
|
|
31
|
+
|
|
32
|
+
piecewise = {
|
|
33
|
+
"aqi": [
|
|
34
|
+
(0, 50),
|
|
35
|
+
(51, 100),
|
|
36
|
+
(101, 150),
|
|
37
|
+
(151, 200),
|
|
38
|
+
(201, 300),
|
|
39
|
+
(301, 400),
|
|
40
|
+
(401, 500),
|
|
41
|
+
],
|
|
42
|
+
"bp": {
|
|
43
|
+
POLLUTANT_O3_8H: [
|
|
44
|
+
(Decimal("0.000"), Decimal("0.059")),
|
|
45
|
+
(Decimal("0.060"), Decimal("0.075")),
|
|
46
|
+
(Decimal("0.076"), Decimal("0.095")),
|
|
47
|
+
(Decimal("0.096"), Decimal("0.115")),
|
|
48
|
+
(Decimal("0.116"), Decimal("0.374")),
|
|
49
|
+
],
|
|
50
|
+
POLLUTANT_O3_1H: [
|
|
51
|
+
(0, 0),
|
|
52
|
+
(0, 0),
|
|
53
|
+
(Decimal("0.125"), Decimal("0.164")),
|
|
54
|
+
(Decimal("0.165"), Decimal("0.204")),
|
|
55
|
+
(Decimal("0.205"), Decimal("0.404")),
|
|
56
|
+
(Decimal("0.405"), Decimal("0.504")),
|
|
57
|
+
(Decimal("0.505"), Decimal("0.604")),
|
|
58
|
+
],
|
|
59
|
+
POLLUTANT_PM10: [
|
|
60
|
+
(Decimal("0"), Decimal("54")),
|
|
61
|
+
(Decimal("55"), Decimal("154")),
|
|
62
|
+
(Decimal("155"), Decimal("254")),
|
|
63
|
+
(Decimal("255"), Decimal("354")),
|
|
64
|
+
(Decimal("355"), Decimal("424")),
|
|
65
|
+
(Decimal("425"), Decimal("504")),
|
|
66
|
+
(Decimal("505"), Decimal("604")),
|
|
67
|
+
],
|
|
68
|
+
POLLUTANT_PM25: [
|
|
69
|
+
(Decimal("0.0"), Decimal("12.0")),
|
|
70
|
+
(Decimal("12.1"), Decimal("35.4")),
|
|
71
|
+
(Decimal("35.5"), Decimal("55.4")),
|
|
72
|
+
(Decimal("55.5"), Decimal("150.4")),
|
|
73
|
+
(Decimal("150.5"), Decimal("250.4")),
|
|
74
|
+
(Decimal("250.5"), Decimal("350.4")),
|
|
75
|
+
(Decimal("350.5"), Decimal("500.4")),
|
|
76
|
+
],
|
|
77
|
+
POLLUTANT_CO_8H: [
|
|
78
|
+
(Decimal("0.0"), Decimal("4.4")),
|
|
79
|
+
(Decimal("4.5"), Decimal("9.4")),
|
|
80
|
+
(Decimal("9.5"), Decimal("12.4")),
|
|
81
|
+
(Decimal("12.5"), Decimal("15.4")),
|
|
82
|
+
(Decimal("15.5"), Decimal("30.4")),
|
|
83
|
+
(Decimal("30.5"), Decimal("40.4")),
|
|
84
|
+
(Decimal("40.5"), Decimal("50.4")),
|
|
85
|
+
],
|
|
86
|
+
POLLUTANT_SO2_1H: [
|
|
87
|
+
(Decimal("0"), Decimal("35")),
|
|
88
|
+
(Decimal("36"), Decimal("75")),
|
|
89
|
+
(Decimal("76"), Decimal("185")),
|
|
90
|
+
(Decimal("186"), Decimal("304")),
|
|
91
|
+
(Decimal("305"), Decimal("604")),
|
|
92
|
+
(Decimal("605"), Decimal("804")),
|
|
93
|
+
(Decimal("805"), Decimal("1004")),
|
|
94
|
+
],
|
|
95
|
+
POLLUTANT_NO2_1H: [
|
|
96
|
+
(Decimal("0"), Decimal("53")),
|
|
97
|
+
(Decimal("54"), Decimal("100")),
|
|
98
|
+
(Decimal("101"), Decimal("360")),
|
|
99
|
+
(Decimal("361"), Decimal("649")),
|
|
100
|
+
(Decimal("650"), Decimal("1249")),
|
|
101
|
+
(Decimal("1250"), Decimal("1649")),
|
|
102
|
+
(Decimal("1650"), Decimal("2049")),
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
"prec": {
|
|
106
|
+
POLLUTANT_O3_8H: Decimal(".001"),
|
|
107
|
+
POLLUTANT_O3_1H: Decimal(".001"),
|
|
108
|
+
POLLUTANT_PM10: Decimal("1."),
|
|
109
|
+
POLLUTANT_PM25: Decimal(".1"),
|
|
110
|
+
POLLUTANT_CO_8H: Decimal(".1"),
|
|
111
|
+
POLLUTANT_SO2_1H: Decimal("1."),
|
|
112
|
+
POLLUTANT_NO2_1H: Decimal("1."),
|
|
113
|
+
},
|
|
114
|
+
"units": {
|
|
115
|
+
POLLUTANT_O3_8H: "ppm",
|
|
116
|
+
POLLUTANT_O3_1H: "ppm",
|
|
117
|
+
POLLUTANT_PM10: "µg/m³",
|
|
118
|
+
POLLUTANT_PM25: "µg/m³",
|
|
119
|
+
POLLUTANT_CO_8H: "ppm",
|
|
120
|
+
POLLUTANT_SO2_1H: "ppb",
|
|
121
|
+
POLLUTANT_NO2_1H: "ppb",
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class EPA_2015(EPA_2012):
|
|
127
|
+
"""EPA AQI after the 2015 ozone standard (0.070 ppm): new 8 hour ozone
|
|
128
|
+
breakpoints, 1 hour ozone starts at 0.125 ppm (AQI 101) and the lower
|
|
129
|
+
bands are blank, as in the table of the standard.
|
|
130
|
+
|
|
131
|
+
Source: Technical Assistance Document EPA-454/B-18-007, September 2018,
|
|
132
|
+
Table 5 (``docs/standards``).
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
piecewise = derive_piecewise(
|
|
136
|
+
EPA_2012.piecewise,
|
|
137
|
+
bp={
|
|
138
|
+
POLLUTANT_O3_8H: [
|
|
139
|
+
(Decimal("0.000"), Decimal("0.054")),
|
|
140
|
+
(Decimal("0.055"), Decimal("0.070")),
|
|
141
|
+
(Decimal("0.071"), Decimal("0.085")),
|
|
142
|
+
(Decimal("0.086"), Decimal("0.105")),
|
|
143
|
+
(Decimal("0.106"), Decimal("0.200")),
|
|
144
|
+
],
|
|
145
|
+
POLLUTANT_O3_1H: [
|
|
146
|
+
None,
|
|
147
|
+
None,
|
|
148
|
+
(Decimal("0.125"), Decimal("0.164")),
|
|
149
|
+
(Decimal("0.165"), Decimal("0.204")),
|
|
150
|
+
(Decimal("0.205"), Decimal("0.404")),
|
|
151
|
+
(Decimal("0.405"), Decimal("0.504")),
|
|
152
|
+
(Decimal("0.505"), Decimal("0.604")),
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class EPA_2024(EPA_2015):
|
|
159
|
+
"""EPA AQI after the 2024 PM2.5 standard (annual 9 µg/m³), effective
|
|
160
|
+
6 May 2024: new PM2.5 breakpoints, and a single Hazardous category,
|
|
161
|
+
AQI 301 to 500, for every pollutant. Its top is the concentration of
|
|
162
|
+
AQI 500, so PM10, CO, SO2, NO2 and 1 hour ozone above their old 400 are
|
|
163
|
+
now on one linear band instead of two (PM10 504 µg/m³ is 389, not 400).
|
|
164
|
+
|
|
165
|
+
Source: Technical Assistance Document EPA-403/B-26-003, May 2026, Table 6
|
|
166
|
+
(same table as EPA-454/B-24-002, May 2024), in ``docs/standards``.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
_hazardous = {
|
|
170
|
+
POLLUTANT_O3_1H: (Decimal("0.405"), Decimal("0.604")),
|
|
171
|
+
POLLUTANT_PM10: (Decimal("425"), Decimal("604")),
|
|
172
|
+
POLLUTANT_CO_8H: (Decimal("30.5"), Decimal("50.4")),
|
|
173
|
+
POLLUTANT_SO2_1H: (Decimal("605"), Decimal("1004")),
|
|
174
|
+
POLLUTANT_NO2_1H: (Decimal("1250"), Decimal("2049")),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
piecewise = derive_piecewise(
|
|
178
|
+
EPA_2015.piecewise,
|
|
179
|
+
aqi=[(0, 50), (51, 100), (101, 150), (151, 200), (201, 300), (301, 500)],
|
|
180
|
+
bp={
|
|
181
|
+
POLLUTANT_PM25: [
|
|
182
|
+
(Decimal("0.0"), Decimal("9.0")),
|
|
183
|
+
(Decimal("9.1"), Decimal("35.4")),
|
|
184
|
+
(Decimal("35.5"), Decimal("55.4")),
|
|
185
|
+
(Decimal("55.5"), Decimal("125.4")),
|
|
186
|
+
(Decimal("125.5"), Decimal("225.4")),
|
|
187
|
+
(Decimal("225.5"), Decimal("325.4")),
|
|
188
|
+
],
|
|
189
|
+
**{
|
|
190
|
+
elem: [*EPA_2015.piecewise["bp"][elem][:5], hazardous]
|
|
191
|
+
for elem, hazardous in _hazardous.items()
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# oldest first, the last one is the latest and the default
|
|
198
|
+
VERSIONS = {
|
|
199
|
+
"2012": EPA_2012,
|
|
200
|
+
"2015": EPA_2015,
|
|
201
|
+
"2024": EPA_2024,
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
#: the latest version of the EPA algorithm
|
|
205
|
+
EPA = list(VERSIONS.values())[-1]
|
|
206
|
+
#: historical name, the module is loaded by ``aqi.algos.get_algo``
|
|
207
|
+
AQI = EPA
|
aqi/algos/mep.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from decimal import ROUND_CEILING, ROUND_HALF_EVEN, Decimal
|
|
2
|
+
|
|
3
|
+
from aqi.algos.base import PiecewiseAQI, derive_piecewise
|
|
4
|
+
from aqi.constants import (
|
|
5
|
+
POLLUTANT_CO_1H,
|
|
6
|
+
POLLUTANT_CO_24H,
|
|
7
|
+
POLLUTANT_NO2_1H,
|
|
8
|
+
POLLUTANT_NO2_24H,
|
|
9
|
+
POLLUTANT_O3_1H,
|
|
10
|
+
POLLUTANT_O3_8H,
|
|
11
|
+
POLLUTANT_PM10,
|
|
12
|
+
POLLUTANT_PM25,
|
|
13
|
+
POLLUTANT_SO2_1H,
|
|
14
|
+
POLLUTANT_SO2_24H,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# HJ 633-2012 table 1: the concentration limit of each IAQI, 0 to 500 (the IAQI
|
|
18
|
+
# are 0, 50, 100, 150, 200, 300, 400 and 500), blank cells are left out
|
|
19
|
+
_LIMITS_2012 = {
|
|
20
|
+
POLLUTANT_SO2_24H: "0 50 150 475 800 1600 2100 2620",
|
|
21
|
+
POLLUTANT_SO2_1H: "0 150 500 650 800",
|
|
22
|
+
POLLUTANT_NO2_24H: "0 40 80 180 280 565 750 940",
|
|
23
|
+
POLLUTANT_NO2_1H: "0 100 200 700 1200 2340 3090 3840",
|
|
24
|
+
POLLUTANT_CO_24H: "0 2 4 14 24 36 48 60",
|
|
25
|
+
POLLUTANT_CO_1H: "0 5 10 35 60 90 120 150",
|
|
26
|
+
POLLUTANT_O3_8H: "0 100 160 215 265 800",
|
|
27
|
+
POLLUTANT_O3_1H: "0 160 200 300 400 800 1000 1200",
|
|
28
|
+
POLLUTANT_PM10: "0 50 150 250 350 420 500 600",
|
|
29
|
+
POLLUTANT_PM25: "0 35 75 115 150 250 350 500",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
_UNITS = {
|
|
33
|
+
POLLUTANT_SO2_24H: "µg/m³",
|
|
34
|
+
POLLUTANT_SO2_1H: "µg/m³",
|
|
35
|
+
POLLUTANT_NO2_24H: "µg/m³",
|
|
36
|
+
POLLUTANT_NO2_1H: "µg/m³",
|
|
37
|
+
POLLUTANT_PM10: "µg/m³",
|
|
38
|
+
POLLUTANT_CO_24H: "mg/m³",
|
|
39
|
+
POLLUTANT_CO_1H: "mg/m³",
|
|
40
|
+
POLLUTANT_O3_1H: "µg/m³",
|
|
41
|
+
POLLUTANT_O3_8H: "µg/m³",
|
|
42
|
+
POLLUTANT_PM25: "µg/m³",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _bands(limits):
|
|
47
|
+
"""Each band goes from a limit to the next one: the formula (1) of the
|
|
48
|
+
standards interpolates between neighbouring limits of the table"""
|
|
49
|
+
points = [Decimal(limit) for limit in limits.split()]
|
|
50
|
+
return list(zip(points, points[1:]))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MEP_2012(PiecewiseAQI):
|
|
54
|
+
"""MEP AQI algorithm, HJ 633-2012 (in force from 1 January 2016, replaced
|
|
55
|
+
by HJ 633-2026 on 1 March 2026).
|
|
56
|
+
|
|
57
|
+
The calculation follows the standard: the formula (1) interpolates between
|
|
58
|
+
neighbouring limits of table 1, concentrations are used as they are given
|
|
59
|
+
(the standard does not round them) and IAQI and AQI are rounded up
|
|
60
|
+
(clause 6.2). SO2 1 hour and O3 8 hour above 800 µg/m³ are not computed
|
|
61
|
+
(notes 2 and 3 of table 1): :class:`NotApplicableError`, and :meth:`aqi`
|
|
62
|
+
leaves them out, the 24 hour SO2 and the 1 hour O3 are used instead.
|
|
63
|
+
|
|
64
|
+
Before 1.0 this class was laid out like the EPA tables (gaps between bands,
|
|
65
|
+
concentrations truncated to integers, IAQI rounded half to even), so some
|
|
66
|
+
results differ from python-aqi 0.6, see the changelog.
|
|
67
|
+
|
|
68
|
+
Source: HJ 633-2012, table 1 and clauses 4.2 and 6.2 (``docs/standards``).
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
rounding = ROUND_CEILING
|
|
72
|
+
undefined_above = (POLLUTANT_SO2_1H, POLLUTANT_O3_8H)
|
|
73
|
+
|
|
74
|
+
piecewise = {
|
|
75
|
+
"aqi": [(0, 50), (50, 100), (100, 150), (150, 200), (200, 300), (300, 400)]
|
|
76
|
+
+ [(400, 500)],
|
|
77
|
+
"bp": {elem: _bands(limits) for elem, limits in _LIMITS_2012.items()},
|
|
78
|
+
"prec": dict.fromkeys(_LIMITS_2012),
|
|
79
|
+
"units": _UNITS,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class MEP_2026(MEP_2012):
|
|
84
|
+
"""MEP AQI algorithm, HJ 633-2026 (replaces HJ 633-2012, effective
|
|
85
|
+
1 March 2026).
|
|
86
|
+
|
|
87
|
+
Compared with HJ 633-2012 the limits of IAQI 100 change for PM10 (150 to
|
|
88
|
+
120 µg/m³) and PM2.5 (75 to 60 µg/m³). The calculation is the same except
|
|
89
|
+
that concentrations are rounded to integers and CO to one decimal
|
|
90
|
+
following GB/T 8170, half to even (clause 4.2.2), and that above the table
|
|
91
|
+
SO2 1 hour is IAQI 200 and O3 8 hour is IAQI 300 (notes b and c), any other
|
|
92
|
+
value is out of range.
|
|
93
|
+
|
|
94
|
+
Source: HJ 633-2026, table 3 and clauses 4.2.2 to 4.2.6
|
|
95
|
+
(``docs/standards``, transcription in ``hj633-2026-table.rst``).
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
cc_rounding = ROUND_HALF_EVEN
|
|
99
|
+
saturation = {POLLUTANT_SO2_1H: 200, POLLUTANT_O3_8H: 300}
|
|
100
|
+
undefined_above = ()
|
|
101
|
+
|
|
102
|
+
piecewise = derive_piecewise(
|
|
103
|
+
MEP_2012.piecewise,
|
|
104
|
+
bp={
|
|
105
|
+
elem: _bands(limits)
|
|
106
|
+
for elem, limits in {
|
|
107
|
+
POLLUTANT_PM10: "0 50 120 250 350 420 500 600",
|
|
108
|
+
POLLUTANT_PM25: "0 35 60 115 150 250 350 500",
|
|
109
|
+
}.items()
|
|
110
|
+
},
|
|
111
|
+
prec={
|
|
112
|
+
**dict.fromkeys(_LIMITS_2012, Decimal("1.")),
|
|
113
|
+
POLLUTANT_CO_24H: Decimal("0.1"),
|
|
114
|
+
POLLUTANT_CO_1H: Decimal("0.1"),
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# oldest first, the last one is the latest and the default
|
|
120
|
+
VERSIONS = {
|
|
121
|
+
"2012": MEP_2012,
|
|
122
|
+
"2026": MEP_2026,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#: the latest version of the MEP algorithm
|
|
126
|
+
MEP = list(VERSIONS.values())[-1]
|
|
127
|
+
#: historical name, the module is loaded by ``aqi.algos.get_algo``
|
|
128
|
+
AQI = MEP
|
aqi/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# constants for pollutants names
|
|
2
|
+
POLLUTANT_O3_8H = "o3_8h"
|
|
3
|
+
POLLUTANT_O3_1H = "o3_1h"
|
|
4
|
+
POLLUTANT_PM10 = "pm10"
|
|
5
|
+
POLLUTANT_PM25 = "pm25"
|
|
6
|
+
POLLUTANT_CO_1H = "co_1h"
|
|
7
|
+
POLLUTANT_CO_8H = "co_8h"
|
|
8
|
+
POLLUTANT_CO_24H = "co_24h"
|
|
9
|
+
POLLUTANT_SO2_1H = "so2_1h"
|
|
10
|
+
POLLUTANT_SO2_24H = "so2_24h"
|
|
11
|
+
POLLUTANT_NO2_1H = "no2_1h"
|
|
12
|
+
POLLUTANT_NO2_24H = "no2_24h"
|
|
13
|
+
|
|
14
|
+
# constants for algorithms, canonical module name, always the latest version
|
|
15
|
+
ALGO_EPA = "aqi.algos.epa"
|
|
16
|
+
ALGO_MEP = "aqi.algos.mep"
|
|
17
|
+
|
|
18
|
+
# constants for a given version of an algorithm, "family:version"
|
|
19
|
+
ALGO_EPA_2012 = "epa:2012"
|
|
20
|
+
ALGO_EPA_2015 = "epa:2015"
|
|
21
|
+
ALGO_EPA_2024 = "epa:2024"
|
|
22
|
+
ALGO_MEP_2012 = "mep:2012"
|
|
23
|
+
ALGO_MEP_2026 = "mep:2026"
|
aqi/exceptions.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
class OutOfRangeError(ValueError):
|
|
2
|
+
"""A concentration or an AQI value is outside of what an algorithm covers.
|
|
3
|
+
|
|
4
|
+
Standards define a scale (0 to 500 for the EPA AQI), values outside of it
|
|
5
|
+
are not capped because that would hide how far off the scale they are.
|
|
6
|
+
Code using the library decides what to do with them.
|
|
7
|
+
|
|
8
|
+
:param elem: pollutant constant
|
|
9
|
+
:param value: the value that was refused
|
|
10
|
+
:param low: lowest value covered by the algorithm for this pollutant
|
|
11
|
+
:param high: highest value covered by the algorithm for this pollutant
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self, elem, value, low, high):
|
|
15
|
+
self.elem = elem
|
|
16
|
+
self.value = value
|
|
17
|
+
self.low = low
|
|
18
|
+
self.high = high
|
|
19
|
+
super().__init__(
|
|
20
|
+
f"{elem}: {value} is not covered by the algorithm "
|
|
21
|
+
f"(valid range is {low} to {high})"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotApplicableError(OutOfRangeError):
|
|
26
|
+
"""The standard leaves this part of the scale blank for the pollutant.
|
|
27
|
+
|
|
28
|
+
EPA 1 hour ozone has no value below 0.125 ppm (AQI 101): the standard says
|
|
29
|
+
to disregard it and to use 8 hour ozone. :meth:`BaseAQI.aqi` does so, a
|
|
30
|
+
direct call to ``iaqi`` or ``cc`` raises this error. HJ 633-2012 does the
|
|
31
|
+
same above the table for SO2 1 hour and O3 8 hour.
|
|
32
|
+
"""
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: python-aqi
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A library to convert between AQI value and pollutant concentration (µg/m³ or ppm)
|
|
5
|
+
Project-URL: Homepage, https://github.com/hrbonz/python-aqi
|
|
6
|
+
Project-URL: Documentation, https://python-aqi.readthedocs.io/
|
|
7
|
+
Author-email: "Stefan \"hr\" Berder" <hr@bonz.org>
|
|
8
|
+
License-Expression: BSD-3-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: EPA,MEP,air quality,pm2.5
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/x-rst
|
|
24
|
+
|
|
25
|
+
###########################
|
|
26
|
+
python-aqi - AQI conversion
|
|
27
|
+
###########################
|
|
28
|
+
|
|
29
|
+
A library to convert between AQI value and pollutant concentration
|
|
30
|
+
(µg/m³ or ppm) using the following algorithms:
|
|
31
|
+
|
|
32
|
+
* United States Environmental Protection Agency (EPA)
|
|
33
|
+
* China Ministry of Environmental Protection (MEP)
|
|
34
|
+
|
|
35
|
+
The versions of each standard are kept, see `the documentation
|
|
36
|
+
<https://python-aqi.readthedocs.io/>`_ for how an AQI is computed, the
|
|
37
|
+
standards and versions, and the API reference.
|
|
38
|
+
|
|
39
|
+
.. image:: https://github.com/hrbonz/python-aqi/actions/workflows/ci.yml/badge.svg?branch=master
|
|
40
|
+
:target: https://github.com/hrbonz/python-aqi/actions/workflows/ci.yml
|
|
41
|
+
:alt: Testing Status
|
|
42
|
+
|
|
43
|
+
.. image:: https://readthedocs.org/projects/python-aqi/badge/?version=latest
|
|
44
|
+
:target: https://readthedocs.org/projects/python-aqi/?badge=latest
|
|
45
|
+
:alt: Documentation Status
|
|
46
|
+
|
|
47
|
+
.. image:: http://img.shields.io/badge/license-BSD%203--Clause-blue.svg
|
|
48
|
+
:target: http://opensource.org/licenses/BSD-3-Clause
|
|
49
|
+
:alt: license BSD 3-Clause
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
Install
|
|
53
|
+
=======
|
|
54
|
+
|
|
55
|
+
With pip::
|
|
56
|
+
|
|
57
|
+
$ pip install python-aqi
|
|
58
|
+
|
|
59
|
+
With `uv <https://docs.astral.sh/uv/>`_, to add it to a project::
|
|
60
|
+
|
|
61
|
+
$ uv add python-aqi
|
|
62
|
+
|
|
63
|
+
or to install the ``aqi`` command line tool::
|
|
64
|
+
|
|
65
|
+
$ uv tool install python-aqi
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
Usage
|
|
69
|
+
=====
|
|
70
|
+
|
|
71
|
+
Library
|
|
72
|
+
-------
|
|
73
|
+
|
|
74
|
+
Convert a pollutant to its IAQI (Intermediate Air Quality Index)::
|
|
75
|
+
|
|
76
|
+
import aqi
|
|
77
|
+
myaqi = aqi.to_iaqi(aqi.POLLUTANT_PM25, '12', algo=aqi.ALGO_EPA)
|
|
78
|
+
|
|
79
|
+
Get an AQI out of several pollutant concentrations, default algorithm is EPA::
|
|
80
|
+
|
|
81
|
+
import aqi
|
|
82
|
+
myaqi = aqi.to_aqi([
|
|
83
|
+
(aqi.POLLUTANT_PM25, '12'),
|
|
84
|
+
(aqi.POLLUTANT_PM10, '24'),
|
|
85
|
+
(aqi.POLLUTANT_O3_8H, '0.087')
|
|
86
|
+
])
|
|
87
|
+
|
|
88
|
+
The algorithm is a standard and its version: ``'epa:2012'``, or ``'epa'`` for
|
|
89
|
+
the latest version of the family (``aqi.ALGO_EPA``, ``aqi.ALGO_EPA_2012`` and
|
|
90
|
+
the matching ``MEP`` constants are available). Old versions stay selectable, the
|
|
91
|
+
default is always the latest. ``aqi.available_algos()`` lists them all::
|
|
92
|
+
|
|
93
|
+
aqi.available_algos()
|
|
94
|
+
# ['epa:2012', 'epa:2015', 'epa:2024', 'mep:2012', 'mep:2026']
|
|
95
|
+
|
|
96
|
+
The EPA versions are named after the revision that changed the table:
|
|
97
|
+
|
|
98
|
+
* ``epa:2012``: what python-aqi computed until 0.6, PM2.5 Good up to 12.0
|
|
99
|
+
µg/m³ and the 1997 ozone 8 hour breakpoints
|
|
100
|
+
* ``epa:2015``: new ozone 8 hour breakpoints (0.054, 0.070, 0.085, 0.105, 0.200
|
|
101
|
+
ppm), 1 hour ozone only above 0.125 ppm
|
|
102
|
+
* ``epa:2024``: new PM2.5 breakpoints (Good up to 9.0 µg/m³) and a single
|
|
103
|
+
Hazardous category, 301 to 500, for every pollutant. This is the default.
|
|
104
|
+
|
|
105
|
+
The MEP (China) versions follow the standards. The name ``MEP`` stands for the
|
|
106
|
+
Ministry of Environmental Protection, which published HJ 633-2012. The ministry
|
|
107
|
+
was renamed Ministry of Ecology and Environment (MEE) in 2018 and publishes
|
|
108
|
+
HJ 633-2026 under that name, the library keeps ``MEP`` for all versions so that
|
|
109
|
+
the name used by existing code does not change:
|
|
110
|
+
|
|
111
|
+
* ``mep:2012``: HJ 633-2012, IAQI interpolated between the limits of the
|
|
112
|
+
table and IAQI and AQI rounded up, concentrations used as given
|
|
113
|
+
* ``mep:2026``: HJ 633-2026, effective 1 March 2026. The limits of IAQI 100
|
|
114
|
+
change for PM10 (150 to 120 µg/m³) and PM2.5 (75 to 60 µg/m³), and the
|
|
115
|
+
calculation follows the standard: IAQI interpolated between the limits of
|
|
116
|
+
the table, IAQI and AQI rounded up, concentrations rounded to integers (CO to
|
|
117
|
+
one decimal). This is the default for MEP.
|
|
118
|
+
|
|
119
|
+
``mep:2012`` used to be laid out like the EPA tables and now follows
|
|
120
|
+
HJ 633-2012 (``aqi.to_iaqi(aqi.POLLUTANT_PM25, '60', algo=aqi.ALGO_MEP_2012)``
|
|
121
|
+
is 82, it was 81). The default changed with the 2024 and 2026 versions: the same PM2.5 concentration can
|
|
122
|
+
now give a different AQI, ``aqi.to_iaqi(aqi.POLLUTANT_PM25, '12.1')`` is 57,
|
|
123
|
+
it was 51, and ``aqi.to_iaqi(aqi.POLLUTANT_PM25, '60', algo=aqi.ALGO_MEP)`` is
|
|
124
|
+
100, it was 81. Use ``algo=aqi.ALGO_EPA_2012`` to keep the old EPA results.
|
|
125
|
+
|
|
126
|
+
1 hour ozone has no value under 0.125 ppm in the EPA table. ``to_aqi`` ignores
|
|
127
|
+
it when other pollutants are given (the standard says to disregard it), for
|
|
128
|
+
``to_iaqi`` and ``to_cc`` it raises ``aqi.NotApplicableError``, an
|
|
129
|
+
``OutOfRangeError``.
|
|
130
|
+
|
|
131
|
+
Convert an IAQI to its pollutant concentration::
|
|
132
|
+
|
|
133
|
+
import aqi
|
|
134
|
+
mycc = aqi.to_cc(aqi.POLLUTANT_PM25, '22', algo=aqi.ALGO_EPA)
|
|
135
|
+
|
|
136
|
+
Out of range values
|
|
137
|
+
~~~~~~~~~~~~~~~~~~~
|
|
138
|
+
|
|
139
|
+
A concentration (or an AQI) that is not covered by the algorithm raises
|
|
140
|
+
``aqi.OutOfRangeError``, a subclass of ``ValueError`` that carries ``elem``,
|
|
141
|
+
``value``, ``low`` and ``high``. Values are never capped: a capped value would
|
|
142
|
+
hide how far above the scale a measure is, so what to do with it is up to the
|
|
143
|
+
calling code::
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
myaqi = aqi.to_aqi([(aqi.POLLUTANT_PM25, '800')])
|
|
147
|
+
except aqi.OutOfRangeError as err:
|
|
148
|
+
print(err.elem, err.value, err.high)
|
|
149
|
+
|
|
150
|
+
The EPA calls AQI values above 500 "Beyond the AQI" and explains how to
|
|
151
|
+
compute them: use the regular formula with the Hazardous breakpoints. This is
|
|
152
|
+
available on request with ``extrapolate=True``::
|
|
153
|
+
|
|
154
|
+
aqi.to_aqi([(aqi.POLLUTANT_PM25, '800')], extrapolate=True) # 1445
|
|
155
|
+
|
|
156
|
+
This only applies to the EPA algorithm and to pollutants whose table covers
|
|
157
|
+
the whole scale. 8 hour ozone, for example, stops at 300 because the EPA uses
|
|
158
|
+
1 hour ozone above that. The MEP standard has no such method, so ``extrapolate=True``
|
|
159
|
+
still raises with it. HJ 633-2026 does fix the IAQI of two pollutants above
|
|
160
|
+
their table: SO2 1 hour above 800 µg/m³ is 200 and O3 8 hour above 800 µg/m³
|
|
161
|
+
is 300 (HJ 633-2012 said they are not computed: ``mep:2012`` raises
|
|
162
|
+
``aqi.NotApplicableError`` and ``to_aqi`` leaves them out).
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
Command line
|
|
166
|
+
------------
|
|
167
|
+
|
|
168
|
+
List supported algorithms and pollutants::
|
|
169
|
+
|
|
170
|
+
$ aqi -l
|
|
171
|
+
epa:2012: o3_8h (ppm), o3_1h (ppm), pm10 (µg/m³), pm25 (µg/m³), co_8h (ppm), so2_1h (ppb), no2_1h (ppb)
|
|
172
|
+
epa:2015: o3_8h (ppm), o3_1h (ppm), pm10 (µg/m³), pm25 (µg/m³), co_8h (ppm), so2_1h (ppb), no2_1h (ppb)
|
|
173
|
+
epa:2024 (latest): o3_8h (ppm), o3_1h (ppm), pm10 (µg/m³), pm25 (µg/m³), co_8h (ppm), so2_1h (ppb), no2_1h (ppb)
|
|
174
|
+
mep:2012: so2_24h (µg/m³), so2_1h (µg/m³), no2_24h (µg/m³), no2_1h (µg/m³), pm10 (µg/m³), co_24h (mg/m³), co_1h (mg/m³), o3_1h (µg/m³), o3_8h (µg/m³), pm25 (µg/m³)
|
|
175
|
+
mep:2026 (latest): so2_24h (µg/m³), so2_1h (µg/m³), no2_24h (µg/m³), no2_1h (µg/m³), pm10 (µg/m³), co_24h (mg/m³), co_1h (mg/m³), o3_1h (µg/m³), o3_8h (µg/m³), pm25 (µg/m³)
|
|
176
|
+
|
|
177
|
+
Pick the version of a standard with ``family:version``, ``epa`` alone means the
|
|
178
|
+
latest version, the module names (``aqi.algos.epa``) still work and also mean
|
|
179
|
+
the latest::
|
|
180
|
+
|
|
181
|
+
$ aqi epa:2012 pm25:12
|
|
182
|
+
50
|
|
183
|
+
|
|
184
|
+
Convert PM2.5 to IAQI using EPA algorithm::
|
|
185
|
+
|
|
186
|
+
$ aqi aqi.algos.epa pm25:12
|
|
187
|
+
56
|
|
188
|
+
|
|
189
|
+
Convert PM2.5 to IAQI using EPA algorithm (full length)::
|
|
190
|
+
|
|
191
|
+
$ aqi -c aqi aqi.algos.epa pm25:12
|
|
192
|
+
56
|
|
193
|
+
|
|
194
|
+
Convert pollutants concentrations to AQI using EPA algorithm::
|
|
195
|
+
|
|
196
|
+
$ aqi aqi.algos.epa pm25:40.9 o3_8h:0.077 co_8h:8.4
|
|
197
|
+
122
|
|
198
|
+
|
|
199
|
+
Convert pollutants concentrations to AQI using EPA algorithm, display IAQIs::
|
|
200
|
+
|
|
201
|
+
$ aqi -v aqi.algos.epa pm25:40.9 o3_8h:0.077 co_8h:8.4
|
|
202
|
+
pm25:114 o3_8h:122 co_8h:90
|
|
203
|
+
122
|
|
204
|
+
|
|
205
|
+
Convert PM2.5 IAQI to concentration using EPA algorithm::
|
|
206
|
+
|
|
207
|
+
$ aqi -c cc aqi.algos.epa pm25:39
|
|
208
|
+
7.0
|
|
209
|
+
|
|
210
|
+
Out of range values are an error, the message goes to stderr and the exit
|
|
211
|
+
code is 3::
|
|
212
|
+
|
|
213
|
+
$ aqi aqi.algos.epa pm25:800
|
|
214
|
+
Out of range: pm25: 800.0 is not covered by the algorithm (valid range is 0.0 to 325.4)
|
|
215
|
+
|
|
216
|
+
``-e`` computes EPA values beyond the scale ("Beyond the AQI")::
|
|
217
|
+
|
|
218
|
+
$ aqi -e aqi.algos.epa pm25:800
|
|
219
|
+
1445
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
Development
|
|
223
|
+
===========
|
|
224
|
+
|
|
225
|
+
Set up the development environment (ruff, pre-commit, hatch, coverage)::
|
|
226
|
+
|
|
227
|
+
$ uv sync
|
|
228
|
+
$ uv run pre-commit install
|
|
229
|
+
|
|
230
|
+
Code is linted and formatted with `ruff <https://docs.astral.sh/ruff/>`_
|
|
231
|
+
through `pre-commit <https://pre-commit.com/>`_, the checks run on each
|
|
232
|
+
commit. To run them on all files::
|
|
233
|
+
|
|
234
|
+
$ uv run pre-commit run --all-files
|
|
235
|
+
|
|
236
|
+
Test the package::
|
|
237
|
+
|
|
238
|
+
$ uv run python -m unittest discover
|
|
239
|
+
|
|
240
|
+
The same checks and the tests on Python 3.9 to 3.14 run on GitHub Actions
|
|
241
|
+
(``.github/workflows/ci.yml``) for each push and pull request.
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
Release
|
|
245
|
+
=======
|
|
246
|
+
|
|
247
|
+
A release is made from ``master`` with a clean tree, after adding a
|
|
248
|
+
``X.Y.Z (unreleased)`` section to ``docs/changelog.rst``::
|
|
249
|
+
|
|
250
|
+
$ make release SEGMENT=major # or minor, patch (the default)
|
|
251
|
+
|
|
252
|
+
It runs, in this order:
|
|
253
|
+
|
|
254
|
+
``make bump``
|
|
255
|
+
runs the tests, bumps the version with ``hatch version``, refreshes
|
|
256
|
+
``uv.lock``, dates the changelog section, commits ``release: bump to
|
|
257
|
+
X.Y.Z`` and tags ``X.Y.Z`` (no ``v``).
|
|
258
|
+
``make publish``
|
|
259
|
+
builds the tagged commit and uploads it to PyPI with ``uv publish``. The
|
|
260
|
+
token is a PyPI API token in the ``UV_PUBLISH_TOKEN`` environment
|
|
261
|
+
variable, it is not stored in the repository.
|
|
262
|
+
``make push``
|
|
263
|
+
pushes the commit and the tag to ``origin`` (``REMOTE=...`` to change it)
|
|
264
|
+
once the upload worked.
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
Project
|
|
268
|
+
=======
|
|
269
|
+
|
|
270
|
+
* `Source code on github <https://github.com/hrbonz/python-aqi>`_
|
|
271
|
+
* `Documentation on readthedocs <https://python-aqi.readthedocs.io/>`_
|
|
272
|
+
* `Package on pypi <https://pypi.python.org/pypi/python-aqi>`_
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
Resources
|
|
276
|
+
=========
|
|
277
|
+
|
|
278
|
+
* EPA AQI: Technical Assistance Document for the Reporting of Daily Air
|
|
279
|
+
Quality – the Air Quality Index (AQI) December 2013) found at http://www.epa.gov/airnow/aqi-technical-assistance-document-dec2013.pdf
|
|
280
|
+
* National Ambient Air Quality Standards for Particulate Matter found at http://www.gpo.gov/fdsys/pkg/FR-2013-01-15/pdf/2012-30946.pdf
|
|
281
|
+
* MEP AQI:
|
|
282
|
+
|
|
283
|
+
* GB3095—2012 (2012/02/29) found at http://www.mep.gov.cn/gkml/hbb/bwj/201203/t20120302_224147.htm
|
|
284
|
+
* HJ633-2012 (2012/02/29) found at https://www.mee.gov.cn/ywgz/fgbz/bz/bzwb/jcffbz/201203/W020120410332725219541.pdf
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
License
|
|
288
|
+
=======
|
|
289
|
+
|
|
290
|
+
python-aqi is published under a BSD 3-clause license, see the LICENSE file
|
|
291
|
+
distributed with the project.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
aqi/__init__.py,sha256=5HrsQQKmyYjjkjK5Nhwildlsmb9scapww3G2rsehBKQ,8013
|
|
2
|
+
aqi/constants.py,sha256=oMDTcc2cGfDpr_-ks9Bmqgbq8wbykqyAzKXhH-3O24k,663
|
|
3
|
+
aqi/exceptions.py,sha256=-XPw7iFnxVV3AUt9dzleSAM8-hdV7SjObM-EO2Mfu4M,1277
|
|
4
|
+
aqi/algos/__init__.py,sha256=UDD6UDQkif428L_siDxt3JJwcp9JrzFWm1Q9R90KdtA,1856
|
|
5
|
+
aqi/algos/base.py,sha256=v7E9nZLGjO5V0-K-gMTcA80fgSm1PYjBiWQ_v5trGJs,9307
|
|
6
|
+
aqi/algos/epa.py,sha256=LrDC6QD_SSSIMXXtnTWjALjGCl8YeneyZ3jrWcIuBAw,7593
|
|
7
|
+
aqi/algos/mep.py,sha256=6JTQdFaDSg0UUK_K06EfG0_LRxC5iOHDFkWaO5yzpB8,4487
|
|
8
|
+
python_aqi-1.0.0.dist-info/METADATA,sha256=nIr2pPpEqRx7ihFMhpaWqFL3Xrcsj0EM8PDMP3YuAgo,10848
|
|
9
|
+
python_aqi-1.0.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
10
|
+
python_aqi-1.0.0.dist-info/entry_points.txt,sha256=8hD9G44U89jRxY04bcdbtdlR74_B85P3zZ_rz6RasPg,40
|
|
11
|
+
python_aqi-1.0.0.dist-info/licenses/LICENSE,sha256=0sCaGMpUPnZH6XEvjxudkJMOAJiNU_x-fQF8Awoj9cU,1489
|
|
12
|
+
python_aqi-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Copyright (c) 2014, Stefan "hr" Berder
|
|
2
|
+
All rights reserved.
|
|
3
|
+
|
|
4
|
+
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
|
5
|
+
|
|
6
|
+
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
|
|
7
|
+
|
|
8
|
+
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
|
9
|
+
|
|
10
|
+
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
|
11
|
+
|
|
12
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|