-
-
Save rubin110/d5dd8e89457cb631f0a610ac539a2b12 to your computer and use it in GitHub Desktop.
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()); |
@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 }}
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
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 }}
Sorry just coming back to this. @maia @IkerGarcia so what's some good ESPHome code now? :)
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.
@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.
@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.
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.
@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.
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.
I hear you. Let me muck around a little bit with this. I'm no ESPHome expert but I like to play.
@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:
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...
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.