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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aqi = aqi:console_aqi
@@ -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.