Skip to content

Instantly share code, notes, and snippets.

@rubin110
Last active November 22, 2024 07:12
Show Gist options
  • Save rubin110/d5dd8e89457cb631f0a610ac539a2b12 to your computer and use it in GitHub Desktop.
Save rubin110/d5dd8e89457cb631f0a610ac539a2b12 to your computer and use it in GitHub Desktop.
Generate AQI from PM2.5 on the great Apollo Automation AIR-1!
Generate AQI from PM2.5 on the great Apollo Automation AIR-1!
# https://apolloautomation.com/products/air-1
# https://wiki.apolloautomation.com/books/air-1
# https://github.com/ApolloAutomation/AIR-1
# This can also be applied to basically any other PM2.5 sensor.
# ESPHome doesn't provide a simple means to generate an AQI value from a PM2.5 sensor.
# AQI is subjective based on where you live in the world, so this is something you need
# to put together by hand. This can be done within ESPHome (as shown here) or a template
# in HA.
# All this is provided as is. The values generated are for the US EPA standards. Other
# parts of the world generate AQI differently. Regardless of if you're in the US or not
# you should do the research yourself before using any of this. I am no expert at any of
# this. If you've got thoughts on how this could be done differently/better/more
# accurately, I'm all ears. You can find me on the net as either rubin110 or Rubin Abdi
# or Rubin Starset.
# What's provided here is:
# - PM <2.5µm Weight concentration - Already part of the AIR-1 default yaml.
# - PM2.5 average - Most air quality products I've used average out PM2.5 because drafts
# happen when you walk past sensors which can widely mess with the value.
# - AQI Value - A bunch of math is applied to take the PM2.5 average and generate a USA
# EPA AQI number, which can be compared to other weather websites that also offer
# their air quality data as AQI.
# - AQI Level - This is the US EPA human readable level which you can point to a random
# person on the street and say the street smog is "Unhealthy" air, or the hills are
# alive with "Good" air.
# - AQI - This is both the AQI Value and AQI Level turned into a single entity to be
# displayed on an HA dashboard.
# - Air Pollution to Smoked Cigarettes Per Day Equivalence - Bonus! Some great people
# at Cal Berkeley figured out how many cigarettes you've smoked when polution is high.
# This is the closest thing you'll find to a universal standard in generating a human
# readable value when it comes down to understaning air quality polution. Now you have
# "cigarettes" as a unit of measurement in HA.
# You will need to copy and paste parts of this code into your AIR-1 yaml, or whatever
# PM2.5 device you've got. This works fine with the highly inaccurate PM1006 that comes
# inside the hackable Ikea Vindriktning air quality monitoring device.
# If you want to read more about how to generate these values:
# https://www.epa.gov/system/files/documents/2024-02/pm-naaqs-air-quality-index-fact-sheet.pdf
# https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
# https://www.airnow.gov/aqi/aqi-basics/
# https://berkeleyearth.org/air-pollution-and-cigarette-equivalence/
# If you live in a seasonale wild fire part of the world, humidity makes a difference.
# You should check out this paper:
# https://cfpub.epa.gov/si/si_public_record_report.cfm?Lab=CEMM&dirEntryId=349513
# Thanks!
sensor:
# Grab PM2.5 from the SEN5X, this is already part of the AIR-1 defauly yaml.
- platform: sen5x
id: sen55
pm_2_5:
name: "PM <2.5µm Weight concentration"
id: pm_2_5
accuracy_decimals: 2
# Average out the PM2.5 over time. On update use that value to generate the AQI Value
# and AQI Level, then populate those sensors. Also figures out how many cigarettes
# you've smoked today.
- platform: copy
source_id: pm_2_5
id: pm_2_5_average
name: "PM2.5 average"
accuracy_decimals: 1
filters:
- sliding_window_moving_average:
window_size: 50
send_every: 10
# send_first_at: 20
on_value:
lambda: |-
static int i = 0;
i++;
if(i>=1){
// https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
// https://www.epa.gov/system/files/documents/2024-02/pm-naaqs-air-quality-index-fact-sheet.pdf
if (id(pm_2_5_average).state < 9.0) {
// good
id(aqi_level).publish_state("Good");
id(pm_2_5_average_aqi).publish_state((50.0 - 0.0) / (9.0 - 0.0) * (id(pm_2_5_average).state - 0.0) + 0.0);
} else if (id(pm_2_5_average).state < 35.4) {
// moderate
id(aqi_level).publish_state("Moderate");
id(pm_2_5_average_aqi).publish_state((100.0 - 51.0) / (35.4 - 9.1) * (id(pm_2_5_average).state - 9.0) + 51.0);
} else if (id(pm_2_5_average).state < 55.4) {
// Unhealthy for Sensitive Groups
id(aqi_level).publish_state("Unhealthy for Sensitive Groups");
id(pm_2_5_average_aqi).publish_state((150.0 - 101.0) / (55.4 - 35.5) * (id(pm_2_5_average).state - 35.5) + 101.0);
} else if (id(pm_2_5_average).state < 125.4) {
// unhealthy
id(aqi_level).publish_state("Unhealthy");
id(pm_2_5_average_aqi).publish_state((200.0 - 151.0) / (125.4 - 55.5) * (id(pm_2_5_average).state - 55.5) + 151.0);
} else if (id(pm_2_5_average).state < 225.4) {
// very unhealthy
id(aqi_level).publish_state("Very Unhealthy");
id(pm_2_5_average_aqi).publish_state((300.0 - 201.0) / (225.4 - 125.5) * (id(pm_2_5_average).state - 125.5) + 201.0);
} else if (id(pm_2_5_average).state > 225.5) {
// hazardous
id(aqi_level).publish_state("Hazardous");
}
// https://berkeleyearth.org/air-pollution-and-cigarette-equivalence/
id(pm_2_5_cigarettes).publish_state(id(pm_2_5_average).state / 22);
}
# AQI Value sensors waiting to be populdated.
- platform: template
name: "AQI Value"
device_class: aqi
unit_of_measurement: "AQI"
icon: "mdi:air-filter"
accuracy_decimals: 0
id: pm_2_5_average_aqi
# Air Pollution Cigarette Equivalence waiting to be populdated.
- platform: template
name: "Air Pollution to Smoked Cigarettes Per Day Equivalence"
unit_of_measurement: "cigarettes"
icon: "mdi:smoking"
accuracy_decimals: 1
id: pm_2_5_cigarettes
text_sensor:
# AQI Level, the human readable level, waiting to be populated.
- platform: template
name: "AQI Level"
icon: "mdi:air-filter"
id: aqi_level
# Combind both the Value and Level into a single entity to be displayed on an HA dashboard.
- platform: template
name: "AQI"
icon: "mdi:air-filter"
id: aqi
lambda: return str_sprintf("%.0f - %s", id(pm_2_5_average_aqi).state, id(aqi_level).state.c_str());
@IkerGarcia
Copy link

IkerGarcia commented Oct 21, 2024

Updated with the latest values from the EPA (https://www.epa.gov/system/files/documents/2024-02/pm-naaqs-air-quality-index-fact-sheet.pdf)

Generate AQI from PM2.5 on the great Apollo Automation AIR-1!
# https://apolloautomation.com/products/air-1
# https://wiki.apolloautomation.com/books/air-1
# https://github.com/ApolloAutomation/AIR-1

# This can also be applied to basically any other PM2.5 sensor.

# ESPHome doesn't provide a simple means to generate an AQI value from a PM2.5 sensor.
# AQI is subjective based on where you live in the world, so this is something you need
# to put together by hand. This can be done within ESPHome (as shown here) or a template
# in HA.

# All this is provided as is. The values generated are for the US EPA standards. Other
# parts of the world generate AQI differently. Regardless of if you're in the US or not
# you should do the research yourself before using any of this. I am no expert at any of
# this. If you've got thoughts on how this could be done differently/better/more
# accurately, I'm all ears. You can find me on the net as either rubin110 or Rubin Abdi
# or Rubin Starset.

# What's provided here is:

# - PM <2.5µm Weight concentration - Already part of the AIR-1 default yaml.

# - PM2.5 average - Most air quality products I've used average out PM2.5 because drafts
#   happen when you walk past sensors which can widely mess with the value.

# - AQI Value - A bunch of math is applied to take the PM2.5 average and generate a USA
#   EPA AQI number, which can be compared to other weather websites that also offer
#   their air quality data as AQI.

# - AQI Level - This is the US EPA human readable level which you can point to a random
#   person on the street and say the street smog is "Unhealthy" air, or the hills are
#   alive with "Good" air.

# - AQI - This is both the AQI Value and AQI Level turned into a single entity to be
#   displayed on an HA dashboard.

# - Air Pollution to Smoked Cigarettes Per Day Equivalence - Bonus! Some great people
#   at Cal Berkeley figured out how many cigarettes you've smoked when polution is high.
#   This is the closest thing you'll find to a universal standard in generating a human
#   readable value when it comes down to understaning air quality polution. Now you have
#   "cigarettes" as a unit of measurement in HA.

# You will need to copy and paste parts of this code into your AIR-1 yaml, or whatever 
# PM2.5 device you've got. This works fine with the highly inaccurate PM1006 that comes
# inside the hackable Ikea Vindriktning air quality monitoring device.

# If you want to read more about how to generate these values:
# https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
# https://www.airnow.gov/aqi/aqi-basics/
# https://berkeleyearth.org/air-pollution-and-cigarette-equivalence/

# If you live in a seasonale wild fire part of the world, humidity makes a difference.
# You should check out this paper:
# https://cfpub.epa.gov/si/si_public_record_report.cfm?Lab=CEMM&dirEntryId=349513

# Thanks!


sensor:

# Grab PM2.5 from the SEN5X, this is already part of the AIR-1 defauly yaml.

  - platform: sen5x
    id: sen55
    pm_2_5:
      name: "PM <2.5µm Weight concentration"
      id: pm_2_5
      accuracy_decimals: 2

# Average out the PM2.5 over time. On update use that value to generate the AQI Value
# and AQI Level, then populate those sensors. Also figures out how many cigarettes
# you've smoked today.

  - platform: copy
    source_id: pm_2_5
    id: pm_2_5_average
    name: "PM2.5 average"
    accuracy_decimals: 1
    filters:
    - sliding_window_moving_average:
        window_size: 50
        send_every: 10
    #     send_first_at: 20
    on_value:
      lambda: |-
        static int i = 0;
        i++;
        if(i>=1){
          // https://en.wikipedia.org/wiki/Air_quality_index#Computing_the_AQI
          if (id(pm_2_5_average).state < 9.0) {
            // good
            id(aqi_level).publish_state("Good");
            id(pm_2_5_average_aqi).publish_state((50.0 - 0.0) / (9.0 - 0.0) * (id(pm_2_5_average).state - 0.0) + 0.0);
          } else if (id(pm_2_5_average).state < 35.4) {
            // moderate
            id(aqi_level).publish_state("Moderate");
            id(pm_2_5_average_aqi).publish_state((100.0 - 51.0) / (35.4 - 9.1) * (id(pm_2_5_average).state - 9.0) + 51.0);
          } else if (id(pm_2_5_average).state < 55.4) {
            // Unhealthy for Sensitive Groups
            id(aqi_level).publish_state("Unhealthy for Sensitive Groups");
            id(pm_2_5_average_aqi).publish_state((150.0 - 101.0) / (55.4 - 35.5) * (id(pm_2_5_average).state - 35.5) + 101.0);
          } else if (id(pm_2_5_average).state < 125.4) {
            // unhealthy
            id(aqi_level).publish_state("Unhealthy");
            id(pm_2_5_average_aqi).publish_state((200.0 - 151.0) / (125.4 - 55.5) * (id(pm_2_5_average).state - 55.5) + 151.0);
          } else if (id(pm_2_5_average).state < 225.4) {
            // very unhealthy
            id(aqi_level).publish_state("Very Unhealthy");
            id(pm_2_5_average_aqi).publish_state((300.0 - 201.0) / (225.4 - 125.5) * (id(pm_2_5_average).state - 125.5) + 201.0);
          } else if (id(pm_2_5_average).state > 225.5) {
            // hazardous
            id(aqi_level).publish_state("Hazardous");
          } 
          // https://berkeleyearth.org/air-pollution-and-cigarette-equivalence/
          id(pm_2_5_cigarettes).publish_state(id(pm_2_5_average).state / 22);
        }

# AQI Value sensors waiting to be populdated.

  - platform: template
    name: "AQI Value"
    device_class: aqi
    unit_of_measurement: "AQI"
    icon: "mdi:air-filter"
    accuracy_decimals: 0
    id: pm_2_5_average_aqi

# Air Pollution Cigarette Equivalence waiting to be populdated.

  - platform: template
    name: "Air Pollution to Smoked Cigarettes Per Day Equivalence"
    unit_of_measurement: "cigarettes"
    icon: "mdi:smoking"
    accuracy_decimals: 1
    id: pm_2_5_cigarettes

text_sensor:

# AQI Level, the human readable level, waiting to be populated.

  - platform: template
    name: "AQI Level"
    icon: "mdi:air-filter"
    id: aqi_level

# Combind both the Value and Level into a single entity to be displayed on an HA dashboard.

  - platform: template
    name: "AQI"
    icon: "mdi:air-filter"
    id: aqi
    lambda: return str_sprintf("%.0f - %s", id(pm_2_5_average_aqi).state, id(aqi_level).state.c_str());

@rubin110
Copy link
Author

@IkerGarcia Thank you, I've updated the gist!

@maia
Copy link

maia commented Oct 21, 2024

Thanks. Have you considered creating an "indoor equivalent" to the PM2,5-based AQI? Personally I have a Home Assistant template sensor that converts CO2, VOC and humidity values to a index ranging from 0 to 3 and use this index value to light the AIR-1 LED yellow, red or purple with increasing values, so I know how important it is to open the window (plus, it'll switch to a green light for 2 minutes when the levels are back to normal). Ideally people purchasing a AIR-1 wouldn't need to know how to code a template sensor.

@rubin110
Copy link
Author

@maia For the AQI, the yaml above does include the EPA's "categories" (and maybe I should have named those sensors that). These do correspond to colors, which you can find in some of the links up there. Here's some yaml for an HA chip to display colors:

type: template
entity: sensor.living_sensors_aqi_value
icon_color: |2-
    {% set aqi = states(entity) | float(0) %}
    {% if aqi < 50 %} 
      green
    {% elif aqi < 100 %}
      yellow
    {% elif aqi < 150 %}
      orange
    {% elif aqi < 200 %}
      red
    {% elif aqi < 300 %}
      purple
    {% elif aqi > 300 %}
      maroon
    {% else %}
        
    {% endif %}
icon: mdi:air-filter
content: '{{ states(entity) }} - {{ states("sensor.living_sensors_aqi_level") }}'
tap_action:
  action: more-info

CO2's healthy "values" are also subjective, and at least for me I've had a hard time bringing the values down to acceptable levels in my home. I've got a fan going on behind me right now the sensor in this room is still hitting ~1000. There's another chip card for that, note that my values are a little more forgiving than the general googlable standards...

type: template
entity: sensor.kitchen_sensors_co2
icon: mdi:molecule-co2
icon_color: |-
  {% set co2 = states(entity) | float(0) %}
  {% if co2 < 400 %} 
    green
  {% elif co2 < 1000 %}
    yellow
  {% elif co2 < 1500 %}
    orange
  {% elif co2 > 1500 %}
    red
  {% else %}
      
  {% endif %}
content: '{{ states(entity) | float(0) | round(0) }}'
tap_action:
  action: more-info

VOC is a new one for me and I honestly haven't had a chance to explore it or any of the other fun sensors the AIR-1 has to provide.

@maia
Copy link

maia commented Oct 22, 2024

@rubin110 Sorry for the misunderstanding. I personally know how to create template sensors in Home Assistant. What I am suggesting is creating an indoor AQI sensor inside the ESPHome configuration of the device, so that anyone who purchases it will have a preconfigured indoor sensor available – ideally with user configurable values.

This is what I use as template sensor, combined with a simple automation that will color the LED based on the index value the template sensor returns:

- sensor:
  - name: "Indoor AQI"
    unique_id: indoor_aqi
    icon: mdi:cloud-alert-outline
    unit_of_measurement: ""
    availability: >-
      {{ [
        states('sensor.apollo_air_1_co2'),
        states('sensor.apollo_air_1_sen55_voc'),
        states('sensor.apollo_air_1_sen55_nox'),
        states('sensor.apollo_air_1_sen55_humidity'),
        states('sensor.apollo_air_1_bc4964_pm_10_m_weight_concentration')
      ] | reject('in', ['unavailable', 'unknown', 'none']) | list | count > 0 }}
    state: >-
      {# Define sensor values and their respective thresholds #}
      {% set sensor_data = [
          (states('sensor.apollo_air_1_co2') | int(-1), [1200, 1600, 2000]),
          (states('sensor.apollo_air_1_sen55_voc') | int(-1), [200, 350, 500]),
          (states('sensor.apollo_air_1_sen55_nox') | int(-1), [4, 7, 10]),
          (states('sensor.apollo_air_1_sen55_humidity') | int(-1), [64, 67, 70]),
          (states('sensor.apollo_air_1_bc4964_pm_10_m_weight_concentration') | int(-1), [8, 12, 16])
      ] %}

      {# Convert generator to a list to avoid TypeError #}
      {% set valid_sensor_data = sensor_data | selectattr(0, 'ne', -1) | list %}

      {# Initialize a namespace to store the calculated levels and level variable #}
      {% set ns = namespace(calculated_levels=[], level=0) %}

      {# Loop over the valid sensor data and calculate the level based on thresholds #}
      {% for value, thresholds in valid_sensor_data %}
        {# Reset the level to 0 for each sensor #}
        {% set ns.level = 0 %}

        {# Iterate over thresholds and increment the level if the value exceeds each threshold #}
        {% for threshold in thresholds %}
          {% if value >= threshold %}
            {% set ns.level = ns.level + 1 %}
          {% endif %}
        {% endfor %}

        {# Append the calculated level to the list #}
        {% set ns.calculated_levels = ns.calculated_levels + [ns.level] %}
      {% endfor %}

      {# Return the max value #}
      {{ ns.calculated_levels | max | int }}

@IkerGarcia
Copy link

I posted in Discord but I don't know if you @maia are active there.

This seems an interesting approach to indoor air quality from the combination of different pollutants: https://www.mdpi.com/1424-8220/23/8/3999

@maia
Copy link

maia commented Oct 22, 2024

So this is my attempt to optimise the calculation as described in discord. We can continue to discuss it on discord, I just want to keep the code here where it doesn't spam the channel:

- sensor:
  - name: "Indoor AQI"
    unique_id: indoor_aqi
    icon: mdi:cloud-alert-outline
    unit_of_measurement: ""
    availability: >-
      {{ [
        states('sensor.apollo_air_1_co2'),
        states('sensor.apollo_air_1_sen55_voc'),
        states('sensor.apollo_air_1_sen55_nox'),
        states('sensor.apollo_air_1_sen55_humidity'),
        states('sensor.apollo_air_1_bc4964_pm_2_5_m_weight_concentration')
      ] | reject('in', ['unavailable', 'unknown', 'none']) | list | count > 0 }}
    state: >-
      {# Define sensor readings and their normalization ranges #}
      {% set sensors = [
          (states('sensor.apollo_air_1_co2') | float(default=-1), 800, 2000),
          (states('sensor.apollo_air_1_sen55_voc') | float(default=-1), 50, 400),
          (states('sensor.apollo_air_1_sen55_nox') | float(default=-1), 0, 10),
          (states('sensor.apollo_air_1_sen55_humidity') | float(default=-1), 64, 70),
          (states('sensor.apollo_air_1_bc4964_pm_2_5_m_weight_concentration') | float(default=-1), 0, 16)
      ] %}

      {# Initialize variables #}
      {% set exponent = 2 %}
      {% set ns = namespace(power_sum=0.0) %}

      {# Process each sensor reading #}
      {% for value, min_val, max_val in sensors %}
          {% if value != -1 %}
              {# Normalize the sensor value to [0, 1] #}
              {% set normalized = (value - min_val) / (max_val - min_val) %}
              {% set normalized = [normalized, 1.0] | min %}
              {% set normalized = [normalized, 0.0] | max %}
              {# Accumulate the powered normalized values #}
              {% set ns.power_sum = ns.power_sum + (normalized ** exponent) %}
          {% endif %}
      {% endfor %}

      {# Compute the aggregate value and AQI index #}
      {% set aggregate = ns.power_sum ** (1 / exponent) %}
      {% set index = (aggregate * 5) | round(0) | int %}
      {{ index }}

@rubin110
Copy link
Author

Sorry just coming back to this. @maia @IkerGarcia so what's some good ESPHome code now? :)

@maia
Copy link

maia commented Nov 17, 2024

I'm unfortunately not familiar with ESPHome code. As for the code listed above, I still use it but removed the PM2.5 value from the indoor AQI as in my case I use this indoor AQI as indicator to open the window – and as I'm seeing more PM2.5 particles outdoor than indoor (at least in winter during thermal inversion periods) it's counter-productive to use these in the calculation.

@rubin110
Copy link
Author

@maia So if I understand it correctly your original ask was to expose from the ESPHome device sensors which provide the EPA AQI values generated from the PM2.5 sensor, correct? The code in this gist does that already. Let me know if I'm mistaken on what you're asking for.

@maia
Copy link

maia commented Nov 17, 2024

@rubin110 My suggestion still is an ESPHome implementation of something like the code I pasted above, an indoor AQI sensor that aggregates CO2, VOC, NOX (and not PM2.5/PM10) as part of the AIR-1 firmware. Because no HA user should need to learn how to create a template sensor when purchasing an AIR-1, they should connect it to HA and have an indoor AQI value available.

@IkerGarcia
Copy link

@rubin110 @maia

I cannot help with ESPHome.

Regarding to the code, the last one posted here is ok. We could also share the approach I shared in Discord, the RESET Air Index, which seems to be a standard.

In my case I've seen that normal day activities can cause PM2.5 values rise, I hope I can probably share some info about this. On the other hand, NOX is not important for me. This is why I would use and standard.

Anyway, the best way to compare indoor/outdoor is two monitors.

@rubin110
Copy link
Author

@maia I know that the EPA standard pulls from other sensor readings too. Generally with most consumer off the shelf air monitors AQI is only calculated with PM2.5 (I'm assuming due to reduce BOM costs), which unfortunately has turned into the standard. Event PurpleAir only does PM2.5 readings.

I am interested in playing with your code and seeing how much reading all the other sensors will waver the AQI value away from just using PM2.5.

@maia
Copy link

maia commented Nov 22, 2024

This is a subjective opinion, likely influenced by the city I live in, which has a high standard of living and minimal pollution: When using the AIR-1 in my bedroom, I want it to alert me when it’s time to open the window, even if it’s freezing outside. Over the past few months, I’ve found three key values to be most relevant: CO2, VOC, and humidity (e.g., after taking a long shower in the adjoining bathroom). Including PM levels in the indoor AQI calculation has been counterproductive, as outdoor PM levels are often higher than indoors.

That said, I understand why outdoor AQI prioritises PM levels. If I had to pick one outdoor air quality metric, it would be PM too. For indoor air quality, however, CO2 is my subjective top priority. Ideally, VOC would also play a role, but the current algorithm from Sensirion hasn’t been as practical as I initially hoped.

Introducing any form of indoor AQI calculation into the AIR-1 firmware would be a big step forward. While I’m comfortable with template sensors and Jinja coding, many people find even creating a single template sensor in the file system overwhelming. That’s why I’m hoping someone can translate our ideas into ESP code to make it more accessible.

@rubin110
Copy link
Author

I hear you. Let me muck around a little bit with this. I'm no ESPHome expert but I like to play.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment