aqi/
lib.rs

1// Copyright 2020 Brian J. Tarricone <brian@tarricone.org>
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15#![doc = include_str!("../README.md")]
16#![cfg_attr(not(feature = "std"), no_std)]
17
18use core::{convert::TryFrom, fmt};
19
20/// Error type for air quality calculations
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub enum AirQualityError {
23    /// The value provided was not in the range covered by the selected AQI calculation
24    OutOfRange,
25}
26
27impl fmt::Display for AirQualityError {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        match self {
30            AirQualityError::OutOfRange => f.write_str("Value is out of range for AQI"),
31        }
32    }
33}
34
35#[cfg(feature = "std")]
36impl std::error::Error for AirQualityError {}
37
38/// Result type for air quality calculations
39pub type Result<R> = core::result::Result<R, AirQualityError>;
40
41/// Represents the human-friendly interpretation of the AQI
42#[derive(Copy, Clone, Debug, Eq, PartialEq)]
43pub enum AirQualityLevel {
44    /// The air quality is good and safe for everyone
45    Good,
46    /// The air quality is moderate, but unusually sensitive people should avoid heavy outdoor
47    /// exertion
48    Moderate,
49    /// The air quality is unhealthy for those with respiratory issues or other health problems
50    UnhealthySensitive,
51    /// The air quality is unhealthy for everyone
52    Unhealthy,
53    /// The air quality is very unhealthy for everyone
54    VeryUnhealthy,
55    /// The air quality is hazardous and everyone should avoid outdoor exertion
56    Hazardous,
57}
58
59impl fmt::Display for AirQualityLevel {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        use AirQualityLevel::*;
62        match self {
63            Good => f.write_str("Good"),
64            Moderate => f.write_str("Moderate"),
65            UnhealthySensitive => f.write_str("Unhealthy for sensitive groups"),
66            Unhealthy => f.write_str("Unhealthy"),
67            VeryUnhealthy => f.write_str("Very unhealthy"),
68            Hazardous => f.write_str("Hazardous"),
69        }
70    }
71}
72
73macro_rules! def_try_from_aq {
74    ($tpe:ty) => {
75        impl TryFrom<$tpe> for AirQualityLevel {
76            type Error = AirQualityError;
77            fn try_from(v: $tpe) -> Result<Self> {
78                use AirQualityLevel::*;
79                match v {
80                    0..=50 => Ok(Good),
81                    51..=100 => Ok(Moderate),
82                    101..=150 => Ok(UnhealthySensitive),
83                    151..=200 => Ok(Unhealthy),
84                    201..=300 => Ok(VeryUnhealthy),
85                    301..=500 => Ok(Hazardous),
86                    _ => Err(AirQualityError::OutOfRange),
87                }
88            }
89        }
90    };
91}
92
93def_try_from_aq!(u16);
94def_try_from_aq!(i16);
95def_try_from_aq!(u32);
96def_try_from_aq!(i32);
97def_try_from_aq!(u64);
98def_try_from_aq!(i64);
99
100/// Calucated AQI and human-readable level
101#[derive(Copy, Clone, Debug, Eq, PartialEq)]
102pub struct AirQuality {
103    /// The numerical AQI value, in a range between 0 and 500
104    aqi: u32,
105    /// The human-friendly interpretation of the numeric AQI value
106    level: AirQualityLevel,
107}
108
109impl AirQuality {
110    pub fn new(aqi: u32, level: AirQualityLevel) -> Self {
111        Self { aqi, level }
112    }
113
114    pub fn aqi(&self) -> u32 {
115        self.aqi
116    }
117
118    pub fn level(&self) -> AirQualityLevel {
119        self.level
120    }
121}
122
123impl fmt::Display for AirQuality {
124    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
125        write!(f, "AQI: {}, {}", self.aqi, self.level)
126    }
127}
128
129struct Breakpoint {
130    conc_low: f64,
131    conc_high: f64,
132    aqi_low: u32,
133    aqi_high: u32,
134    level: AirQualityLevel,
135}
136
137const OZONE8_BREAKPOINTS: [Breakpoint; 5] = [
138    Breakpoint {
139        conc_low: 0.000,
140        conc_high: 0.054,
141        aqi_low: 0,
142        aqi_high: 50,
143        level: AirQualityLevel::Good,
144    },
145    Breakpoint {
146        conc_low: 0.055,
147        conc_high: 0.070,
148        aqi_low: 51,
149        aqi_high: 100,
150        level: AirQualityLevel::Moderate,
151    },
152    Breakpoint {
153        conc_low: 0.071,
154        conc_high: 0.085,
155        aqi_low: 101,
156        aqi_high: 150,
157        level: AirQualityLevel::UnhealthySensitive,
158    },
159    Breakpoint {
160        conc_low: 0.086,
161        conc_high: 0.105,
162        aqi_low: 151,
163        aqi_high: 200,
164        level: AirQualityLevel::Unhealthy,
165    },
166    Breakpoint {
167        conc_low: 0.106,
168        conc_high: 0.200,
169        aqi_low: 201,
170        aqi_high: 300,
171        level: AirQualityLevel::VeryUnhealthy,
172    },
173];
174const OZONE1_BREAKPOINTS: [Breakpoint; 5] = [
175    Breakpoint {
176        conc_low: 0.125,
177        conc_high: 0.164,
178        aqi_low: 101,
179        aqi_high: 150,
180        level: AirQualityLevel::UnhealthySensitive,
181    },
182    Breakpoint {
183        conc_low: 0.165,
184        conc_high: 0.204,
185        aqi_low: 151,
186        aqi_high: 200,
187        level: AirQualityLevel::Unhealthy,
188    },
189    Breakpoint {
190        conc_low: 0.205,
191        conc_high: 0.404,
192        aqi_low: 201,
193        aqi_high: 300,
194        level: AirQualityLevel::VeryUnhealthy,
195    },
196    Breakpoint {
197        conc_low: 0.405,
198        conc_high: 0.504,
199        aqi_low: 301,
200        aqi_high: 400,
201        level: AirQualityLevel::Hazardous,
202    },
203    Breakpoint {
204        conc_low: 0.505,
205        conc_high: 0.604,
206        aqi_low: 401,
207        aqi_high: 500,
208        level: AirQualityLevel::Hazardous,
209    },
210];
211const PM25_BREAKPOINTS: [Breakpoint; 7] = [
212    Breakpoint {
213        conc_low: 0.0,
214        conc_high: 12.0,
215        aqi_low: 0,
216        aqi_high: 50,
217        level: AirQualityLevel::Good,
218    },
219    Breakpoint {
220        conc_low: 12.1,
221        conc_high: 35.4,
222        aqi_low: 51,
223        aqi_high: 100,
224        level: AirQualityLevel::Moderate,
225    },
226    Breakpoint {
227        conc_low: 35.5,
228        conc_high: 55.4,
229        aqi_low: 101,
230        aqi_high: 150,
231        level: AirQualityLevel::UnhealthySensitive,
232    },
233    Breakpoint {
234        conc_low: 55.5,
235        conc_high: 150.4,
236        aqi_low: 151,
237        aqi_high: 200,
238        level: AirQualityLevel::Unhealthy,
239    },
240    Breakpoint {
241        conc_low: 150.5,
242        conc_high: 250.4,
243        aqi_low: 201,
244        aqi_high: 300,
245        level: AirQualityLevel::VeryUnhealthy,
246    },
247    Breakpoint {
248        conc_low: 250.5,
249        conc_high: 350.4,
250        aqi_low: 301,
251        aqi_high: 400,
252        level: AirQualityLevel::Hazardous,
253    },
254    Breakpoint {
255        conc_low: 350.5,
256        conc_high: 500.4,
257        aqi_low: 401,
258        aqi_high: 500,
259        level: AirQualityLevel::Hazardous,
260    },
261];
262const PM10_BREAKPOINTS: [Breakpoint; 7] = [
263    Breakpoint {
264        conc_low: 0.0,
265        conc_high: 54.0,
266        aqi_low: 0,
267        aqi_high: 50,
268        level: AirQualityLevel::Good,
269    },
270    Breakpoint {
271        conc_low: 55.0,
272        conc_high: 154.0,
273        aqi_low: 51,
274        aqi_high: 100,
275        level: AirQualityLevel::Moderate,
276    },
277    Breakpoint {
278        conc_low: 155.0,
279        conc_high: 254.0,
280        aqi_low: 101,
281        aqi_high: 150,
282        level: AirQualityLevel::UnhealthySensitive,
283    },
284    Breakpoint {
285        conc_low: 255.0,
286        conc_high: 354.0,
287        aqi_low: 151,
288        aqi_high: 200,
289        level: AirQualityLevel::Unhealthy,
290    },
291    Breakpoint {
292        conc_low: 355.0,
293        conc_high: 424.0,
294        aqi_low: 201,
295        aqi_high: 300,
296        level: AirQualityLevel::VeryUnhealthy,
297    },
298    Breakpoint {
299        conc_low: 425.0,
300        conc_high: 504.0,
301        aqi_low: 301,
302        aqi_high: 400,
303        level: AirQualityLevel::Hazardous,
304    },
305    Breakpoint {
306        conc_low: 505.0,
307        conc_high: 604.0,
308        aqi_low: 401,
309        aqi_high: 500,
310        level: AirQualityLevel::Hazardous,
311    },
312];
313const CO_BREAKPOINTS: [Breakpoint; 7] = [
314    Breakpoint {
315        conc_low: 0.0,
316        conc_high: 4.4,
317        aqi_low: 0,
318        aqi_high: 50,
319        level: AirQualityLevel::Good,
320    },
321    Breakpoint {
322        conc_low: 4.5,
323        conc_high: 9.4,
324        aqi_low: 51,
325        aqi_high: 100,
326        level: AirQualityLevel::Moderate,
327    },
328    Breakpoint {
329        conc_low: 9.5,
330        conc_high: 12.4,
331        aqi_low: 101,
332        aqi_high: 150,
333        level: AirQualityLevel::UnhealthySensitive,
334    },
335    Breakpoint {
336        conc_low: 12.5,
337        conc_high: 15.4,
338        aqi_low: 151,
339        aqi_high: 200,
340        level: AirQualityLevel::Unhealthy,
341    },
342    Breakpoint {
343        conc_low: 15.5,
344        conc_high: 30.4,
345        aqi_low: 201,
346        aqi_high: 300,
347        level: AirQualityLevel::VeryUnhealthy,
348    },
349    Breakpoint {
350        conc_low: 30.5,
351        conc_high: 40.4,
352        aqi_low: 301,
353        aqi_high: 400,
354        level: AirQualityLevel::Hazardous,
355    },
356    Breakpoint {
357        conc_low: 40.5,
358        conc_high: 50.4,
359        aqi_low: 401,
360        aqi_high: 500,
361        level: AirQualityLevel::Hazardous,
362    },
363];
364const SO2_1_BREAKPOINTS: [Breakpoint; 3] = [
365    Breakpoint {
366        conc_low: 0.0,
367        conc_high: 35.0,
368        aqi_low: 0,
369        aqi_high: 50,
370        level: AirQualityLevel::Good,
371    },
372    Breakpoint {
373        conc_low: 36.0,
374        conc_high: 75.0,
375        aqi_low: 51,
376        aqi_high: 100,
377        level: AirQualityLevel::Moderate,
378    },
379    Breakpoint {
380        conc_low: 76.0,
381        conc_high: 185.0,
382        aqi_low: 101,
383        aqi_high: 150,
384        level: AirQualityLevel::UnhealthySensitive,
385    },
386];
387const SO2_24_BREAKPOINTS: [Breakpoint; 7] = [
388    Breakpoint {
389        conc_low: 0.0,
390        conc_high: 35.0,
391        aqi_low: 0,
392        aqi_high: 50,
393        level: AirQualityLevel::Good,
394    },
395    Breakpoint {
396        conc_low: 36.0,
397        conc_high: 75.0,
398        aqi_low: 51,
399        aqi_high: 100,
400        level: AirQualityLevel::Moderate,
401    },
402    Breakpoint {
403        conc_low: 76.0,
404        conc_high: 185.0,
405        aqi_low: 101,
406        aqi_high: 150,
407        level: AirQualityLevel::UnhealthySensitive,
408    },
409    Breakpoint {
410        conc_low: 186.0,
411        conc_high: 304.0,
412        aqi_low: 151,
413        aqi_high: 200,
414        level: AirQualityLevel::Unhealthy,
415    },
416    Breakpoint {
417        conc_low: 305.0,
418        conc_high: 604.0,
419        aqi_low: 201,
420        aqi_high: 300,
421        level: AirQualityLevel::VeryUnhealthy,
422    },
423    Breakpoint {
424        conc_low: 605.0,
425        conc_high: 804.0,
426        aqi_low: 301,
427        aqi_high: 400,
428        level: AirQualityLevel::Hazardous,
429    },
430    Breakpoint {
431        conc_low: 805.0,
432        conc_high: 1004.0,
433        aqi_low: 401,
434        aqi_high: 500,
435        level: AirQualityLevel::Hazardous,
436    },
437];
438const NO2_BREAKPOINTS: [Breakpoint; 7] = [
439    Breakpoint {
440        conc_low: 0.0,
441        conc_high: 53.0,
442        aqi_low: 0,
443        aqi_high: 50,
444        level: AirQualityLevel::Good,
445    },
446    Breakpoint {
447        conc_low: 54.0,
448        conc_high: 100.0,
449        aqi_low: 51,
450        aqi_high: 100,
451        level: AirQualityLevel::Moderate,
452    },
453    Breakpoint {
454        conc_low: 101.0,
455        conc_high: 360.0,
456        aqi_low: 101,
457        aqi_high: 150,
458        level: AirQualityLevel::UnhealthySensitive,
459    },
460    Breakpoint {
461        conc_low: 361.0,
462        conc_high: 649.0,
463        aqi_low: 151,
464        aqi_high: 200,
465        level: AirQualityLevel::Unhealthy,
466    },
467    Breakpoint {
468        conc_low: 650.0,
469        conc_high: 1249.0,
470        aqi_low: 201,
471        aqi_high: 300,
472        level: AirQualityLevel::VeryUnhealthy,
473    },
474    Breakpoint {
475        conc_low: 1250.0,
476        conc_high: 1649.0,
477        aqi_low: 301,
478        aqi_high: 400,
479        level: AirQualityLevel::Hazardous,
480    },
481    Breakpoint {
482        conc_low: 1650.0,
483        conc_high: 2049.0,
484        aqi_low: 401,
485        aqi_high: 500,
486        level: AirQualityLevel::Hazardous,
487    },
488];
489
490fn find_breakpoint(breakpoints: &[Breakpoint], concentration: f64) -> Option<&Breakpoint> {
491    breakpoints.iter().find(|breakpoint| {
492        breakpoint.conc_low <= concentration && concentration <= breakpoint.conc_high
493    })
494}
495
496fn calc_aqi(breakpoints: &[Breakpoint], concentration: f64) -> Result<AirQuality> {
497    if let Some(breakpoint) = find_breakpoint(breakpoints, concentration) {
498        let aqi = ((breakpoint.aqi_high as f64 - breakpoint.aqi_low as f64)
499            / (breakpoint.conc_high - breakpoint.conc_low))
500            * (concentration - breakpoint.conc_low)
501            + (breakpoint.aqi_low as f64);
502        Ok(AirQuality {
503            aqi: round(aqi),
504            level: breakpoint.level,
505        })
506    } else {
507        Err(AirQualityError::OutOfRange)
508    }
509}
510
511fn trunc(value: f64, nplaces: u32) -> f64 {
512    let truncator = 10_u32.pow(nplaces) as f64;
513    ((value * truncator) as u64) as f64 / truncator
514}
515
516/// Calculates the Ozone Air Quality Index from the provided 8-hour concentration
517///
518/// The AQI is defined for concentrations between 0.000 and 0.200 ppm.  For
519/// values between 0.201 and 0.604 ppm, a 1-hour concentration should be used if
520/// available.
521///
522/// # Arguments
523///
524/// * `concentration` - The 8-hour ozone concentration in ppm
525pub fn ozone8(concentration: f64) -> Result<AirQuality> {
526    calc_aqi(&OZONE8_BREAKPOINTS, trunc(concentration, 3))
527}
528
529/// Calculates the ozone Air Quality Index from the provided 1-hour concentration
530///
531/// The AQI is defined for concentrations between 0.125 and 0.604 ppm.  For
532/// values between 0.000 and 0.124 ppm, an 8-hour concentration should be used if
533/// available.
534///
535/// # Arguments
536///
537/// * `concentration` - The 1-hour ozone concentration in ppm
538pub fn ozone1(concentration: f64) -> Result<AirQuality> {
539    calc_aqi(&OZONE1_BREAKPOINTS, trunc(concentration, 3))
540}
541
542/// Calculates the PM2.5 Air Quality Index from the provided 24-hour concentration
543///
544/// The AQI is defined for concentrations between 0.0 and 500.4 µg/m³.
545///
546/// # Arguments
547///
548/// * `concentration` - The 24-hour PM2.5 concentration in µg/m³
549pub fn pm2_5(concentration: f64) -> Result<AirQuality> {
550    calc_aqi(&PM25_BREAKPOINTS, trunc(concentration, 1))
551}
552
553/// Calcuates the EPA-adjusted PM2.5 Air Quality Index for the provided 24-hour concentration
554///
555/// See
556/// [https://cfpub.epa.gov/si/si_public_record_Report.cfm?dirEntryId=350075&Lab=CEMM](https://cfpub.epa.gov/si/si_public_record_Report.cfm?dirEntryId=350075&Lab=CEMM)
557/// for more information.
558///
559/// The EPA-adjusted AQI is defined for concentrations between 0.0 and
560/// 250.0 µg/m³.
561///
562///
563/// # Arguments
564///
565/// * `concentration` - The 24-hour PM2.5 concentration in µg/m³
566/// * `humidity` - Relative humidity % (between 0.0 - 1.0)
567pub fn pm2_5_epa(concentration: f64, humidity: f64) -> Result<AirQuality> {
568    if (0.0..=1.0).contains(&humidity) {
569        calc_aqi(
570            &PM25_BREAKPOINTS,
571            trunc(0.52 * concentration - 0.085 * humidity + 5.71, 1),
572        )
573    } else {
574        Err(AirQualityError::OutOfRange)
575    }
576}
577
578/// Calcuates the LRAPA-adjusted PM2.5 Air Quality Index for the provided 24-hour concentration
579///
580/// See
581/// [https://www.lrapa.org/DocumentCenter/View/4147/PurpleAir-Correction-Summary](https://www.lrapa.org/DocumentCenter/View/4147/PurpleAir-Correction-Summary)
582/// for more information.
583///
584/// The LRAPA-adjusted AQI is defined for concentrations between 0.66 and
585/// 1002.12 µg/m³.
586///
587///
588/// # Arguments
589///
590/// * `concentration` - The 24-hour PM2.5 concentration in µg/m³
591pub fn pm2_5_lrapa(concentration: f64) -> Result<AirQuality> {
592    if concentration <= 65.0 {
593        calc_aqi(&PM25_BREAKPOINTS, trunc(0.5 * concentration - 0.66, 1))
594    } else {
595        Err(AirQualityError::OutOfRange)
596    }
597}
598
599/// Calcuates the AQandU-adjusted PM2.5 Air Quality Index for the provided 24-hour concentration
600///
601/// See
602/// [https://www.aqandu.org/airu_sensor#calibrationSection](https://www.aqandu.org/airu_sensor#calibrationSection)
603/// for more information.
604///
605/// The AQandU-adjusted AQI is defined for concentrations between 0.0 and
606/// 639.78 µg/m³.
607///
608/// # Arguments
609///
610/// * `concentration` - The 24-hour PM2.5 concentration in µg/m³
611pub fn pm2_5_aqandu(concentration: f64) -> Result<AirQuality> {
612    calc_aqi(&PM25_BREAKPOINTS, trunc(0.778 * concentration + 2.65, 1))
613}
614
615/// Calculates the PM10 Air Quality Index from the provided 24-hour concentration
616///
617/// The AQI is defined for concentrations between 0.0 and 604.0 µg/m³.
618///
619/// # Arguments
620///
621/// * `concentration` - The 24-hour PM10 concentration in µg/m³
622pub fn pm10(concentration: f64) -> Result<AirQuality> {
623    calc_aqi(&PM10_BREAKPOINTS, concentration as u32 as f64)
624}
625
626/// Calculates the carbon monoxide Air Quality Index from the provided 8-hour concentration
627///
628/// The AQI is defined for concentrations between 0.0 and 50.4 ppm.
629///
630/// # Arguments
631///
632/// * `concentration` - The 8-hour CO concentration in ppm
633pub fn co(concentration: f64) -> Result<AirQuality> {
634    calc_aqi(&CO_BREAKPOINTS, trunc(concentration, 1))
635}
636
637/// Calculates the sulfur dioxide Air Quality Index from the provided 1-hour concentration
638///
639/// The AQI is  defined for concentrations between 0 and 185 ppb.  For
640/// values between 186 and 1004 ppb, a 24-hour concentration should be used if
641/// available.
642///
643/// # Arguments
644///
645/// * `concentration` - The 1-hour SO₂ concentration in ppb
646pub fn so2_1(concentration: f64) -> Result<AirQuality> {
647    calc_aqi(&SO2_1_BREAKPOINTS, trunc(concentration, 0))
648}
649
650/// Calculates the sulfur dioxide Air Quality Index from the provided 24-hour concentration
651///
652/// The AQI is defined for concentrations between 0 and 1004 ppb.
653///
654/// # Arguments
655///
656/// * `concentration` - The 24-hour SO₂ concentration in ppb
657pub fn so2_24(concentration: f64) -> Result<AirQuality> {
658    calc_aqi(&SO2_24_BREAKPOINTS, trunc(concentration, 0))
659}
660
661/// Calculates the nitrogen dioxide Air Quality Index from the provided 1-hour concentration
662///
663/// The AQI is defined for concentrations between 0 and 2049 ppb.
664///
665/// # Arguments
666///
667/// * `concentration` - The 1-hour NO₂ concentration in ppb
668pub fn no2(concentration: f64) -> Result<AirQuality> {
669    calc_aqi(&NO2_BREAKPOINTS, trunc(concentration, 0))
670}
671
672fn round(val: f64) -> u32 {
673    #[cfg(feature = "std")]
674    let res = val.round() as u32;
675
676    #[cfg(not(feature = "std"))]
677    let res = {
678        let whole = val as u32;
679        let frac = val - (whole as f64);
680        if frac >= 0.5 {
681            whole + 1
682        } else {
683            whole
684        }
685    };
686
687    res
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    #[test]
695    fn test_pm2_5() {
696        let test_data: [(f64, u32); 22] = [
697            (0.0, 0),
698            (12.0, 50),
699            (12.1, 51),
700            (16.0, 59),
701            (35.4, 100),
702            (35.5, 101),
703            (55.4, 150),
704            (55.5, 151),
705            (85.0, 166),
706            (94.0, 171),
707            (138.0, 194),
708            (150.4, 200),
709            (150.5, 201),
710            (158.0, 208),
711            (175.0, 225),
712            (192.0, 242),
713            (200.0, 250),
714            (250.4, 300),
715            (250.5, 301),
716            (350.4, 400),
717            (350.5, 401),
718            (500.4, 500),
719        ];
720
721        for (conc, aqi) in test_data.iter() {
722            assert_eq!(Ok(*aqi), pm2_5(*conc).map(|aq| aq.aqi));
723        }
724    }
725
726    #[test]
727    fn test_round() {
728        assert_eq!(round(4.5), 5);
729        assert_eq!(round(123.3), 123);
730        assert_eq!(round(84.9), 85);
731    }
732}