-
-
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()); |
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.
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