Finally some results in the area of NO2 / O3, this was hard stuff to achieve.
Quick recap:
- our O3 sensor has 1:1 cross sensitivity to NO2 and 1:2 negative cross sensitivity to H2S
- NO2 and O3 are usually correlated, see quick explaination below
When the sun rise, and with maximum effect at noon, UV light hit the VOC and NOx molecules, this creates Ozone as a chemical reaction. I am not an expert on this topic, but there’s plenty of analisys you can google, basically (copy pasted with no shame):
It is well established that the inter-conversion of O3, NO
and NO2 under atmospheric conditions is generally
dominated by the following reactions:
NO2 + hv = NO + O (1)
O + O2 = O3 + M (2)
O3 + NO = NO2 + O2 (3)
M (usually N2 or O2) represents a molecule that absorbs
the excess vibrational energy and thereby stabilizes the O3
molecule formed. hv represents the energy of a photon
(with a wavelength of < 424 nm) and O is an active
monoatomic oxygen molecule. These equations form a
cycle with no net chemistry i.e. the overall effect of
reaction (2) is the reverse of reaction (1). These reactions
therefore represent a closed system in which the NOx (NO
and NO2) components and the OX (O3 and NO2)
components relate separately. During daytime hours, NO,
NO2 and O3 are typically equilibrated on a timescale of a
few minutes. This is known as a photostationary state.
We trust whatever is written above 
What I noticed is O3 + NO2 (what basically out O3 sensor gives us) is more or less constant, that makes sense, as for the explaination above.
Our NO2 sensors has a lot less cross sensitivities, what I noticed is it has a huge relation with temperature, to be more specific, the relation is not with the absolute value of T, instead it does reacts to how fast the temperature changes, graphically speaking to the slope, what in math is called a derivative.
Even a really small change in temperature does fuck up our data, see here an example:
See here another example
It is clear that that data is pure bullshit, but the issue is because we are reading really low values compared to what the sensor is meant to do (see the datasheet some post above), we can still try to get something useful with a compensation in temperature and with some sort of Exponential smoothing - Wikipedia and averaging.
See what happens when I put the device outside, with a drastic climate change:
Here is how NO2 corrections are handled:
And Ozone:
Here you have a comparison of the achieved result with an official station 10km away
And here the math:
sensor:
- platform: derivative
source: sensor.airq_temperature
name: "Derivata T AirQ"
time_window: "00:01:00"
unit_time: h
- platform: statistics
name: "Sum Derivative T AirQ 15"
entity_id: sensor.derivata_t_airq
state_characteristic: sum
max_age:
minutes: 15
sampling_size: 15
- platform: statistics
name: "Sum Derivative T AirQ 30"
entity_id: sensor.derivata_t_airq
state_characteristic: sum
max_age:
minutes: 30
sampling_size: 30
- platform: statistics
name: "Sum Derivative T AirQ 1h"
entity_id: sensor.derivata_t_airq
state_characteristic: sum
max_age:
minutes: 60
sampling_size: 60
- platform: statistics
name: "Sum Derivative T AirQ 2h"
entity_id: sensor.derivata_t_airq
state_characteristic: sum
max_age:
minutes: 120
sampling_size: 120
- platform: filter
name: "filtered airq no2 avg"
entity_id: sensor.airq_compensated_no2
filters:
- filter: lowpass
time_constant: 45
- filter: time_simple_moving_average
window_size: "00:10"
- platform: filter
name: "filtered airq o3 avg"
entity_id: sensor.airq_ozone
filters:
- filter: lowpass
time_constant: 30
- filter: time_simple_moving_average
window_size: "00:15"
- platform: statistics
name: "distance 95 no2"
entity_id: sensor.airq_no2
state_characteristic: distance_95_percent_of_values
max_age:
minutes: 10080
sampling_size: 10080
- platform: statistics
name: "min multiplied no2"
entity_id: sensor.airq_compensated_multiplied_no2
state_characteristic: value_min
max_age:
minutes: 10080
sampling_size: 10080
- platform: statistics
name: "min o3"
entity_id: sensor.filtered_airq_o3_avg
state_characteristic: value_min
max_age:
minutes: 10080
sampling_size: 10080
- platform: statistics
name: "max o3"
entity_id: sensor.filtered_airq_o3_avg
state_characteristic: value_max
max_age:
minutes: 10080
sampling_size: 10080
template:
- trigger:
- platform: time_pattern
minutes: "/1"
seconds: "1"
sensor:
- name: "AirQ Compensated NO2"
unit_of_measurement: "µg/m³"
state: "{{ states('sensor.airq_no2') | float(0) - 0.1 * states('sensor.filtered_airq_o3_avg') | float(0) + 0.06 * states('sensor.sum_derivative_t_airq_15') | float(0) + 0.12 * states('sensor.sum_derivative_t_airq_30') | float(0) + 0.03 * states('sensor.sum_derivative_t_airq_1h') | float(0) + 0.02 * states('sensor.sum_derivative_t_airq_2h') | float(0) }}"
- sensor:
- name: "AirQ NO2 multiplier"
state: "{{ ( states('sensor.max_o3') | float - states('sensor.min_o3') | float ) / states('sensor.distance_95_no2') | float }}"
- sensor:
- name: "AirQ Compensated multiplied NO2"
unit_of_measurement: "µg/m³"
state: "{{ states('sensor.airq_no2_multiplier') | float * states('sensor.filtered_airq_no2_avg') | float }}"
- sensor:
- name: "AirQ Compensated Calibrated NO2"
unit_of_measurement: "µg/m³"
state: "{{ states('sensor.airq_compensated_multiplied_no2') | float - states('sensor.min_multiplied_no2') | float }}"
- trigger:
- platform: time_pattern
minutes: "/1"
seconds: "5"
sensor:
- name: "AirQ Compensated Calibrated H2S"
unit_of_measurement: "µg/m³"
state: "{{ max( 0.6 * max( states('sensor.airq_h2s') | float(0), 0.1) - 0.04 * (20 - states('sensor.airq_temperature') | float) + 0.18 * 0.6 * states('sensor.average_1h_o3') | float(0) + 0.2 * 0.6 * states('sensor.average_1h_no2') | float(0) - 12, 0.1) }}"
- trigger:
- platform: time_pattern
minutes: "/1"
seconds: "10"
sensor:
- name: "AirQ Compensated Calibrated O3"
unit_of_measurement: "µg/m³"
state: "{{ max( max( states('sensor.filtered_airq_o3_avg') | float(0), 0.1) - max( states('sensor.airq_compensated_calibrated_no2') | float(0), 0.1 ) + 0.5 * max( states('sensor.airq_compensated_calibrated_h2s') | float(0), 0.1) - max( states('sensor.min_03') | float(0), 0.1 ), 0.1) }}"





