MallocArray / airgradient_esphome

ESPHome definition for an AirGradient DIY device to send data to HomeAssistant and AirGradient servers
GNU General Public License v3.0
224 stars 34 forks source link

On-device AQI calculations #46

Closed ex-nerd closed 3 months ago

ex-nerd commented 5 months ago

When I got my OpenAir, I added on-device AQI calculation to existing firmware I found. Since it resets if you power cycle, it's not as good as getting this inside of home assistant but so far I haven't found a plugin that actually does that (and I got side-tracked while working on the necessary database queries to do it myself).

The main difference with my calculations are that they are intended to more closely follow official AQI and NowCast calculations (specific differences are documented in this commit), which require 24 and 12 hours worth of data, respectively.

You can find the code here:

https://github.com/ex-nerd/ESPHome-AirGradient/blob/main/air-gradient-open-air.yaml

I haven't had time to poke through your code yet to figure out the best place to add it, but if someone else doesn't beat me to it, I will try to get a pull request put together to port this over. I also haven't done any work for a proper "indoor AQI" calculation, which I know includes a mix of info besides just particulate measurement.

MallocArray commented 5 months ago

I wonder if doing the AQI inside of HomeAssistant as a sensor template would be better, as it keeps the complex configuration out of ESPHome and lets the data reside in HA's database instead of in-memory of the ESP device, which is pretty limited, especially the ESP8266 based devices. https://www.home-assistant.io/integrations/template/#yaml-configuration

Looks like your code is very similar to https://github.com/ajfriesen/ESPHome-AirGradient/blob/main/air-gradient-open-air.yaml Did you contribute similar code there?

I haven't read all the way through your code, but I see you aren't restoring the values of some of the globals, so not sure how you are tracking across a reboot, but I am trying to be cautious about the memory usage, as I've bumped up against the limit on the D1 Mini several times just with a handful of fonts.

ex-nerd commented 5 months ago

Looks like your code is very similar to https://github.com/ajfriesen/ESPHome-AirGradient/blob/main/air-gradient-open-air.yaml Did you contribute similar code there?

Yes. The code in that repo is from my pull request and should be identical.

And no, it doesn't track across reboot. Based on my research at the time, the flash memory on most esp* devices can't handle very many write cycles and would only last a few months months if anything was being written at the rate that measurements are taken. I never got around to looking into longer write cycles because at "every 6 hours (or so)" you're just getting into the realm of time it takes for the NowCast to regenerate on its own.

I agree it's something best tracked within Home Assistant, though I still haven't had time to figure that out (it gets a bit messy calculating averages when that database only stores values when they change). I'll get to it eventually (if someone else doesn't do it first) but for now, these on-device calculations work well enough.

MallocArray commented 4 months ago

I just pushed a package with your local nowcast and aqi calculations. I'm still waiting for enough time for it to run the calculations, but the sensors all show up.

You could try it out by adding this under the packages section nowcast_aqi: github://MallocArray/airgradient_esphome/packages/sensor_nowcast_aqi.yaml@nowcast_aqi

ex-nerd commented 4 months ago

Thanks. I'll have to poke at this after work.

Over the weekend, I started looking into what it would take to get proper AQI/Nowcast calculations into home assistant, and there just doesn't seem to be a way to reliably do this with just the SQL queries or the statistics sensor (which can't look at discrete time ranges in the past, only "from now to past-time"). In the mean time, I created https://github.com/ex-nerd/home-assistant-aqi so I can eventually do the necessary expansion of "value hasn't changed" but there's a lot to learn for how to set up a plugin in general so it'll be awhile because that has anything useful.

ex-nerd commented 4 months ago

Oh, and just in case there is ever any doubt … the original project that contains my AQI calculation code has an MIT license, which should be compatible with this project's GPL. But I also grant separate GPL license for any of my own code that's used for this project.

ex-nerd commented 4 months ago

Looks good on my end, too … except where my device would seem to crash and/or reboot every 15±5 minutes. No errors in the logs, so it might be a problematic USB dongle (I'd occasionally see these on my old config, but only once every 1-2 weeks).

FWIW, here's the config I'm using:

# AirGradient Open Air Outdoor Monitor with dual PMS5003T sensors
# Model: O-1PPT
# https://www.airgradient.com/open-airgradient/instructions/overview/
# https://github.com/MallocArray/airgradient_esphome/

substitutions:
  id: "1"
  name: "airgradient-open-air"
  friendly_name: "AirGradient Open Air"
  config_version: 2.0.4
  name_add_mac_suffix: "false"  # Must have quotes around value

# Enable logging?
#logger:
  # baud_rate: 0

# Enable Home Assistant API (API password is deprecated in favor of encryption key)
# https://esphome.io/components/api.html
api:
  encryption:
    key: !secret home_assistant_encryption_key

ota:
  password: !secret ota_password

wifi:
  networks:
    - ssid: !secret wifi_ssid
      password: !secret wifi_password
  # reboot_timeout: 15min (default)

  # Enable fallback hotspot (captive portal) in case wifi connection fails
  ap:
    ssid: "${name} Hotspot"
    password: !secret fallback_ssid_password

dashboard_import:
  package_import_url: github://MallocArray/airgradient_esphome/airgradient-open-air-o-1ppt.yaml
  import_full_config: false

packages:
  board: github://MallocArray/airgradient_esphome/packages/airgradient_esp32-c3_board.yaml
  pm_2.5: github://MallocArray/airgradient_esphome/packages/sensor_pms5003t_extended_life.yaml
  pm_2.5_2: github://MallocArray/airgradient_esphome/packages/sensor_pms5003t_2_extended_life.yaml
  # There is now a VOC/NOx add-on board, but this one doesn't have it.
  # tvoc: github://MallocArray/airgradient_esphome/packages/sensor_sgp41.yaml
  airgradient_api: github://MallocArray/airgradient_esphome/packages/airgradient_api_esp32-c3_dual_pms5003t.yaml
  hardware_watchdog: github://MallocArray/airgradient_esphome/packages/watchdog.yaml
  wifi: github://MallocArray/airgradient_esphome/packages/sensor_wifi.yaml
  uptime: github://MallocArray/airgradient_esphome/packages/sensor_uptime.yaml
  safe_mode: github://MallocArray/airgradient_esphome/packages/switch_safe_mode.yaml
  nowcast_aqi: github://MallocArray/airgradient_esphome/packages/sensor_nowcast_aqi.yaml@nowcast_aqi
MallocArray commented 4 months ago

I have sporadic luck with uptime in general, but not sure if it is ESPHome overall, or a specific component. Mine has been up for 20 hours now without issue, but I have other devices that often reboot every 2 hours.

Sometime a full power cycle by removing the power cord can help. Other times if I turn off the AirGradient Upload, or comment out the entire airgradient_api line it can help stabilize, but not always.

image

ex-nerd commented 4 months ago

Well, yes, it would seem there's a problem with http upload. Looks like there's a workaround posted here: https://github.com/esphome/issues/issues/2853#issuecomment-1949349868

update: Workaround added, and seems to be more stable (no reboots in over an hour).

ex-nerd commented 4 months ago

Just for posterity, here is the display I use in Home Assistant. It needs card_mod, mini-graph-card, stack-in-card extras from HACS:

type: custom:stack-in-card
mode: vertical
keep:
  background: true
  border_radius: false
  margin: false
cards:
  - type: markdown
    content: >
      {%- set category =

      states('sensor.airgradient_open_air_nowcast_category')

      -%}

      {% if category == "unknown" %} # NowCast: Calculating

      {% set mins =
      states('sensor.airgradient_open_air_nowcast_minutes_remaining')

      | int %} Available in {% if mins > 60 %}{{ mins // 60 }} hours {% endif

      %}{{ mins%60 }} minutes

      {% else %}

      # NowCast: {{ category }}

      {% endif %}
    card_mod:
      style: |
        ha-card {
          {% set aqi = states('sensor.airgradient_open_air_nowcast') -%}
          {%- set aqi = aqi | float if is_number(aqi) else -1 -%}
          {%- if aqi < 0 -%}
            background-color: #a0a0a0; color: #393939;
          {%- elif aqi < 50 -%}
            background-color: #00e400; color: #393939;
          {%- elif aqi < 100 -%}
            background-color: #ffff00; color: #004000;
          {%- elif aqi < 150 -%}
            background-color: #ff7e00; color: #404000;
          {%- elif aqi < 200 -%}
            background-color: #ff0000; color: #400000;
          {%- elif aqi < 300 -%}
              background-color: #8f3f97; color: #300030;
          {%- else -%}
            background-color: #7e0023; color: #200000;
          {% endif %}
          text-align: center;
        }
        h1 {
          line-height: 1px;
          margin: 0 !important;
          padding: 0 !important;
          border: 1px solid red !impotant;
        }
  - type: conditional
    conditions:
      - entity: sensor.airgradient_open_air_nowcast
        state_not: unknown
    card:
      type: gauge
      entity: sensor.airgradient_open_air_nowcast
      needle: true
      min: 0
      max: 500
      segments:
        - from: 0
          color: '#00e400'
        - from: 50
          color: '#ffff00'
        - from: 100
          color: '#ff7e00'
        - from: 150
          color: '#ff0000'
        - from: 200
          color: '#8f3f97'
        - from: 300
          color: '#7e0023'
      name: ''
  - type: custom:mini-graph-card
    entities:
      - entity: sensor.airgradient_open_air_nowcast
    color_thresholds:
      - value: 0
        color: '#00e400'
      - value: 50
        color: '#ffff00'
      - value: 100
        color: '#ff0000'
      - value: 200
        color: '#8f3f97'
      - value: 300
        color: '#7e0023'
    show:
      labels: false
      fill: fade
      name: false
      state: true
      icon: false
    min_bound_range: 20
    line_width: 4
    hours_to_show: 24
    points_per_hour: 1
    card_mod:
      style: |
        div.states {
          display: none !important;
          position: absolute;
          bottom: 0;
          left: 0;
        }
        ha-card:hover div.states {
          display:block !important;
        }
  - type: markdown
    content: |
      {%- set category =
      states('sensor.airgradient_open_air_aqi_category')
      -%} {% if category == "unknown" %} # AQI: Calculating

      {% set mins = states('sensor.airgradient_open_air_aqi_minutes_remaining')
      | int %} Available in {% if mins > 60 %}{{ mins // 60 }} hours {% endif
      %}{{ mins%60 }} minutes
      {% else %} # AQI: {{ category }} {% endif %}
    card_mod:
      style: |
        ha-card {
          {% set aqi = states('sensor.airgradient_open_air_aqi') -%}
          {%- set aqi = aqi | float if is_number(aqi) else -1 -%}
          {%- if aqi < 0 -%}
            background-color: #a0a0a0; color: #393939;
          {%- elif aqi < 50 -%}
            background-color: #00e400; color: #393939;
          {%- elif aqi < 100 -%}
            background-color: #ffff00; color: #004000;
          {%- elif aqi < 150 -%}
            background-color: #ff7e00; color: #404000;
          {%- elif aqi < 200 -%}
            background-color: #ff0000; color: #400000;
          {%- elif aqi < 300 -%}
              background-color: #8f3f97; color: #300030;
          {%- else -%}
            background-color: #7e0023; color: #200000;
          {% endif %}
          text-align: center;
        }
        h1 {
          line-height: 1px;
          margin: 0 !important;
          padding: 0 !important;
          border: 1px solid red !impotant;
        }
  - type: conditional
    conditions:
      - entity: sensor.airgradient_open_air_aqi
        state_not: unknown
    card:
      type: gauge
      entity: sensor.airgradient_open_air_aqi
      needle: true
      min: 0
      max: 500
      segments:
        - from: 0
          color: '#00e400'
        - from: 50
          color: '#ffff00'
        - from: 100
          color: '#ff7e00'
        - from: 150
          color: '#ff0000'
        - from: 200
          color: '#8f3f97'
        - from: 300
          color: '#7e0023'
      name: ''
  - type: custom:mini-graph-card
    entities:
      - entity: sensor.airgradient_open_air_aqi
    color_thresholds:
      - value: 0
        color: '#00e400'
      - value: 50
        color: '#ffff00'
      - value: 100
        color: '#ff0000'
      - value: 200
        color: '#8f3f97'
      - value: 300
        color: '#7e0023'
    show:
      labels: false
      fill: fade
      name: false
      state: true
      icon: false
    min_bound_range: 20
    line_width: 4
    hours_to_show: 72
    points_per_hour: 0.125
    card_mod:
      style: |
        div.states {
          display: none !important;
          position: absolute;
          bottom: 0;
          left: 0;
        }
        ha-card:hover div.states {
          display:block !important;
        }
  - type: horizontal-stack
    cards:
      - type: custom:mini-graph-card
        entities:
          - entity: sensor.airgradient_open_air_pm_1_0_average
            name: PM1
            state_adaptive_color: true
            color: wheat
        line_width: 2
        hours_to_show: 24
        min_bound_range: 10
        show:
          labels: false
          fill: fade
          name: true
          icon: false
          name_adaptive_color: true
      - type: custom:mini-graph-card
        entities:
          - entity: sensor.airgradient_open_air_pm_2_5_average
            name: PM2.5
            state_adaptive_color: true
            color: tan
        line_width: 2
        hours_to_show: 24
        min_bound_range: 10
        show:
          labels: false
          fill: fade
          name: true
          icon: false
          name_adaptive_color: true
      - type: custom:mini-graph-card
        entities:
          - entity: >-
              sensor.airgradient_open_air_pm_10_0_average
            name: PM10
            state_adaptive_color: true
            color: burlywood
        line_width: 2
        hours_to_show: 24
        min_bound_range: 10
        show:
          labels: false
          fill: fade
          name: true
          icon: false
          name_adaptive_color: true
columns: 1
MallocArray commented 4 months ago

Looking again, I see the "PM 2.5 1h Average" has a siliding window moving average that assumes the update_interval of the PMS5003 sensor is 3 minutes as you had it. I'm currently using the ESPHome default which is whenever a value comes in, 1 second with this hardware. There is an alternative file that has a 2 min interval configured. There is some concern that allowing the fan to shut down outdoors could let insects get inside or that accuracy on the first reading after spinup may not be fully accurate, so I don't want to hard code the default to 3 min.

Not sure what is best to do with the 1h average. Leaving it as you had it wouldn't be a true 1 hr average if readings are coming in every second, but I don't think you can use a substitution for the window_size, or not with a calculation.

Thoughts?

ex-nerd commented 4 months ago

There is an alternative file that has a 2 min interval configured. There is some concern that allowing the fan to shut down outdoors could let insects get inside or that accuracy on the first reading after spinup may not be fully accurate, so I don't want to hard code the default to 3 min.

I wondered why the default was set to 1s when everything in the forums when I bought mine said to use the delayed values (especially with something like AQI that's intended to only change once/day, or nowCast once/hour).

I think I added the pm_update_interval substitution to my original updates for this to account for that. Would it work to just add something like that to each of the two PMS5003 files? If not, maybe a global/variable would work.

ex-nerd commented 4 months ago

Not sure what is best to do with the 1h average. Leaving it as you had it wouldn't be a true 1 hr average if readings are coming in every second, but I don't think you can use a substitution for the window_size, or not with a calculation.

Looking at the code again, I wonder if the better option would be to replace the "copy" sensor with a template that runs on a schedule and just reads the current value of the sensor? Something like (untested):

  - platform: template
    id: pm_2_5_1h_avg
    name: "PM 2.5 1h Average"
    disabled_by_default: true
    device_class: pm25
    accuracy_decimals: 1
    filters:
      - sliding_window_moving_average:
          window_size: 60
          send_every: 60
          send_first_at: 60
    update_interval: 60s
    lambda: |
      return id(pm_2_5).state;
    on_value:
      lambda: |
        // Insert the current value
        float current = id(pm_2_5_1h_avg).state;
        if (!isnan(current)) {
          id(pm_2_5_hourly_avg).insert(id(pm_2_5_hourly_avg).begin(), current);
          // Truncate anything past the first 24
          if (id(pm_2_5_hourly_avg).size() > 24) {
            id(pm_2_5_hourly_avg).resize(24);
          }
        }
ex-nerd commented 4 months ago

Not sure what happened, but my AQI counter hit zero and never switched over to "available" so there's definitely something not quite right about the current code.

MallocArray commented 4 months ago

My uptime is currently 5 hours but I have and AQI number and a status of Moderate. Did you check your uptime to see if it rebooted recently?

I see what you are doing with the template sensor and I think that would work.

MallocArray commented 4 months ago

Just pushed a new update with the modifications and setup a substitution for the update_interval on the extended_life configs.

ex-nerd commented 4 months ago

Did you check your uptime to see if it rebooted recently?

A reboot would/should reset the counter. Counter reached 0min right at 18h of uptime (total now of 27h) but the AQI values were never received. Looks more like something just didn't send the AQI number or category name. I'm running the "extended life" version of the PMS5003 code so I wonder if it's something in the math messing up the number of entries in the rolling window counter.

… though looking at the NowCast counter, it actually ended early (about 9h instead of 12h, probably due to the 2.5min vs 3min differences in your extended-life readings and the original ajfriesen ones). I'd expect the standard AQI calculation to also be faster. That also suggests there's something not quite right with the sliding window on the copy sensor config.

Either way, it looks like the once/hour calculation for those sensors may be too infrequent. I originally set those to avoid flooding Home Assistant with duplicate values, but I now know that HA basically ignores updates if the value doesn't change, so it's probably safe to increase the frequency here to something like 5min or 15min.

ex-nerd commented 4 months ago

Just pushed a new update with the modifications and setup a substitution for the update_interval on the extended_life configs.

Installed/running (even remembered to double check that the cache updated … apparently just wiping build files isn't actually enough by itself).

ncd7 commented 4 months ago

I 100% think it's best to do this with a template sensor rather than try to shove it on the device TBH.

To get around the (sadly horrible) issue with any kind of time derivative or time-based windows in HA getting wrong because all sensors only update when values change (makes sense as an optimization but not for anything that's a function of time), you can potentially use a second template sensor that adds a very tiny random value every X seconds and base your derivative or time window sensor based on that. Just be sure to add it as an exclusion to the recorder not to clog your HA DB unnecessarily.

You can use the -filter platform to do time window moving avgs based on that new 'slightly noisy' template sensor.

Or as you say another alternative is to make your AQI template sensor just get triggered on a time pattern, that's probably much simpler than the above.

I'm comparing the AirGradient ONE v9 to an IQAir AirVisual sensor and saw a few cases where AG shows a spike in PM0.3 but AQI stays at 0 whereas the AirVisual shows an AQI of 6. the AirVisual is quite old now (probably 5+ years) and these laser sensors do have a lifetime but the discrepancy was interesting. In most cases they do match perfectly.

BTW @MallocArray wanted to say thank you for creating such a cool 'integration' (or whatever it's called). I'd never ddone any ESPHome at all, installed it last night and flashed the device with your YAML no issues. Fantastic work!

ncd7 commented 4 months ago

Oh apologies, I missed all the stuff you've already done. It looks like you are way ahead of the game :)!

Tangentially related but @MallocArray , would you consider adding an PM10.0 AQI and then a final AQI sensor that displays the max of the two? This won't require any storage or complicated tracking so I think it's fine to do on-device. (I see that for PM 0.3 there really isn't enough research nor guidance so computing AQI based on that would not make any sense today).

ex-nerd commented 4 months ago

I 100% think it's best to do this with a template sensor rather than try to shove it on the device TBH.

I've finally gotten around to working on a plugin to do this, but I have to get through the initial learning curve of "how to write a plugin" first (and find time among a dozen other hobbies). Unfortunately, there just isn't a way to "expand" the HA readings using basic sensors and create accurate averages. Your "noisy" trick sounds interesting but does result in a huge number of readings that could eventually slow the whole database down and defeat the purpose of the original devs' "compressing" readings in the first place.

The on-device calculations already existed from my earlier work, and as of yesterday's update (esp. with the addition of the http-post crash workaround), everything seems stable and running how it did on the other firmware. It's annoying that there's a delay when the device power cycles, but also requires near zero setup from the end user to get the values into HA.

Makes for a nice dashboard, too (this is from the code I put into an earlier comment): Screenshot 2024-05-17 at 10 35 22 AM

ex-nerd commented 4 months ago

Just pushed a new update with the modifications and setup a substitution for the update_interval on the extended_life configs.

This also seems to have fixed (at least for this one boot cycle) the issue where the AQI counter went to zero but never started calculating data. This basically confirms my suspicion that it had something to do with the frequency messing up some of the math in the lambda that was rotating values out of the (for lack of a better word) ring buffer of recent hourly readings.

ncd7 commented 3 months ago

To your point about thrashing the DB -- you must add any such 'noisy' sensors to your configuration exclusions in the recorder as I stated above. This way you pay 0 DB cost, it's really performant as far as I can tell (the downside is you get no history but to me that's a synthetic sensor anyway, I even tend to hide them).

I agree that if you can get it to work on-device that's ideal from a usability perspective but I was worried about the whole device memory and wear-and-tear that I saw mentioned above.

As long as it's optional I think that's great!

I have something along the same vein but comparing AirNow (from official govt agency) and IQAir (the swiss company). You are inspiring me to add that extra data of PM values! Obviously, for the home I'll use the AGs.

image

@MallocArray in the meantime, would you be open to implementing the PM 10 AQI, or maybe I can just open a PR myself for you to review? I have no idea about ESPHome but it seems simple enough to copy the YAML and modify per wiki definition for PM10.

ex-nerd commented 3 months ago

To your point about thrashing the DB -- you must add any such 'noisy' sensors to your configuration exclusions in the recorder as I stated above.

How does that work with AQI, then? By definition, AQI is a rolling average of the last 24 hourly averages (picking the worst of pm2.5 or pm10 with a minimum of at 18 hourly readings). It needs that history. NowCast is similar, though over 12 hours and is weighted toward more recent readings so you need to keep track of the hours when readings were taken. The formulas are standard, though I remember when I was researching this stuff that the weights used for NowCast seem to be interpreted differently by different companies (PurpleAir tends to weight "more recent" stronger than others).

If you're not storing the "jitter" date and averages in the HA database then it's not really any different than doing the calculations on the device. The benefit of something in HA is that if you lose power for a couple seconds/hours, you don't lose data and have to wait 12-18h for readings to show up again.

Anyway I've been running the on-device calculations since I wrote them last year (git says june of 2023). Nothing is stored on the device so there isn't really any wear/tear. I actually looked into it when I originally wrote the calculations but sadly, the flash on the esp* processors can't handle the write cycles required to maintain state across reboots … even once/hour storage adds up and could degrade the memory after half a year or so (could do so much more if these devices had microSD cards). Even at the "extended life" 2.5min check cycle, I would expect the fans in the particle sensors to die long before anything else.

ex-nerd commented 3 months ago

On the other hand, storing hourly averages "with jitter" would probably be fine for HA and simplify some of the other calculations needed. Might even be able to get away with template sensors instead of a full plugin.

ncd7 commented 3 months ago

The graphs I showed above were just for the visuals, those are using official sources (via integrations) from the EPA and from IQAir. I'm going to take inspiration from you and add the PM values to those as well like you have them.

Cool, if nothing is stored on the device then it should be fine.

What the jitter sensors help with is to trigger the filter platform that computes the time-window average to avoid issues when the sensor returns the same PM value. The way the time-based algo works is it seems it doesn't require the jitter in the DB (the other type of window does). I've tested it and it seems to work really well though who knows.

I just feel template sensors (with some potential extra ones) should give you enough power to accomplish what you need without the need for a full-blown plugin, even if you use a different / better template mechanism from what I suggested.

MallocArray commented 3 months ago

So where are we at this point? Is the file in the current branch sufficient, particularly for @ex-nerd since he created the original method?

I personally don't even know what Nowcast means, and I can easily pull AQI from local sources, so I'm not sure that I'll be using this myself, but if it looks like what you are expecting, we can merge it in. Or we can keep tweaking

ex-nerd commented 3 months ago

It's been stable/accurate for me since I flashed your updated version yesterday, so … seems good to go for me. Can always file a bug report/PR if something doesn't work right.

Conversation about AQI calculations with Home Assistant template sensors is probably best handled elsewhere like the Home Assistant forum. I'll probably start a thread there for info once you've merged the AQI branch.


As for what the two readings are, my understanding is:

Official AQI is calculated once per day off of based on hourly averages of no fewer than 18 hours of the previous day. If you don't have at least 18 full hours of data, AQI for that day is supposed to be invalid. It was designed to measure air quality over long durations (weeks/months), e.g. "healthy forest air" vs "smoggy city air" … All of the airgradient calculations I've seen (including mine) avoid this once/day calculation and do it based on rolling averages. In the case of my algorithm, it starts counting after 18 hours worth of data but includes up to 24h (since the data is lost on reset, there's no need to check if any individual hour in the middle was missing data, and remove it from the calculation)

Because AQI is so (intentionally) slow to update, someone came up with NowCast as a way to represent "current conditions" … especially (specifically?) for wildfire smoke. NowCast looks at the previous 12 hours worth of hourly averages and uses a weighted scoring system (recent hours weigh more and taper off for each hour). This provides a sort of "hourly air quality" with a smoothing effect to make it somewhat resilient to plumes of smoke (or of clean air during a wildfire) passing over the exact spot of the sensor. It's more of a "what's the air quality like this afternoon" rather than "what's the air quality right this instant that you can get directly from the pm2.5 reading." I've heard that some providers use different weights for the hours, e.g. rumors are that PurpleAir weights the most recent hour significantly heavier than others to give it more of a "real time" feel.

It's a pretty safe bet to assume that almost everywhere you see "AQI" being used to talk about air quality, it's actually NowCast.

Both calculations look at pm2.5 and pm10 and take the worst value for any given hour. As far as I've seen there are no official formulas that include readings for smaller particulate, nor for VOCs. Wikipedia has a good breakdown of the formulas for bothAQI and NowCast.

There's also IAQI (Indoor Air Quality Index) … which is like NowCast but designed for indoor air. It's like your "pm2.5 AQI" calculation but also a bunch of other readings that don't as much sense for outdoor readings. Atmo has some info about the formula here. When I originally looked into this for my AirGradient DIY Pro, I think the issue was that the VOC and NOx readings weren't available in units compatible with the formula. However, it looks like Atmo has provided a table that could be used.

MallocArray commented 3 months ago

Merged