Skip to content

Commit 277d723

Browse files
committed
Add MQ135 gas sensor
1 parent ec138a6 commit 277d723

File tree

2 files changed

+200
-0
lines changed

2 files changed

+200
-0
lines changed

pslab/external/gas_sensor.py

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
"""Gas sensors can be used to measure the concentration of certain gases."""
2+
3+
from typing import Callable, Union
4+
5+
from pslab import Multimeter
6+
from pslab.serial_handler import SerialHandler
7+
8+
9+
class MQ135:
10+
"""MQ135 is a cheap gas sensor that can detect several harmful gases.
11+
12+
The MQ135 is most suitable for detecting flammable gases, but can also be
13+
used to measure CO2.
14+
15+
Parameters
16+
----------
17+
gas : {CO2, CO, EtOH, NH3, Tol, Ace}
18+
The gas to be detected:
19+
CO2: Carbon dioxide
20+
CO: Carbon monoxide
21+
EtOH: Ethanol
22+
NH3: Ammonia
23+
Tol: Toluene
24+
Ace: Acetone
25+
r_load : float
26+
Load resistance in ohm.
27+
device : :class:`SerialHandler`, optional
28+
Serial connection to PSLab device. If not provided, a new one will be
29+
created.
30+
channel : str, optional
31+
Analog input on which to monitor the sensor output voltage. The default
32+
value is CH1. Be aware that the sensor output voltage can be as high
33+
as 5 V, depending on load resistance and gas concentration.
34+
r0 : float, optional
35+
The sensor resistance when exposed to 100 ppm NH3 at 20 degC and 65%
36+
RH. Varies between individual sensors. Optional, but gas concentration
37+
cannot be measured unless R0 is known. If R0 is not known,
38+
:meth:`measure_r0` to find it.
39+
temperature : float or Callable, optional
40+
Ambient temperature in degC. The default value is 20. A callback can
41+
be provided in place of a fixed value.
42+
humidity : float or Callable, optional
43+
Relative humidity between 0 and 1. The default value is 0.65. A
44+
callback can be provided in place of a fixed value.
45+
"""
46+
47+
# Parameters manually extracted from data sheet.
48+
# ppm = A * (Rs/R0) ^ B
49+
_PARAMS = {
50+
"CO2": [109, -2.88],
51+
"CO": [583, -3.93],
52+
"EtOH": [76.4, -3.18],
53+
"NH3": [102, -2.49],
54+
"Tol": [44.6, -3.45],
55+
"Ace": [33.9, -3.42],
56+
}
57+
58+
# Assuming second degree temperature dependence and linear humidity dependence.
59+
_TEMPERATURE_CORRECTION = [3.28e-4, -2.55e-2, 1.38]
60+
_HUMIDITY_CORRECTION = -2.24e-1
61+
62+
def __init__(
63+
self,
64+
gas: str,
65+
r_load: float,
66+
device: SerialHandler = None,
67+
channel: str = "CH1",
68+
r0: float = None,
69+
temperature: Union[float, Callable] = 20,
70+
humidity: Union[float, Callable] = 0.65,
71+
):
72+
self._multimeter = Multimeter(device)
73+
self._params = self._PARAMS[gas]
74+
self.channel = channel
75+
self.r_load = r_load
76+
self.r0 = r0
77+
self.vcc = 5
78+
79+
if isinstance(temperature, Callable):
80+
self._temperature = temperature
81+
else:
82+
83+
def _temperature():
84+
return temperature
85+
86+
self._temperature = _temperature
87+
88+
if isinstance(humidity, Callable):
89+
self._humidity = humidity
90+
else:
91+
92+
def _humidity():
93+
return humidity
94+
95+
self._humidity = _humidity
96+
97+
@property
98+
def _voltage(self):
99+
return self._multimeter.measure_voltage(self.channel)
100+
101+
@property
102+
def _correction(self):
103+
"""Correct sensor resistance for temperature and humidity.
104+
105+
Coefficients are averages of curves fitted to temperature data for 33%
106+
and 85% relative humidity extracted manually from the data sheet.
107+
Humidity dependence is assumed to be linear, and is centered on 65% RH.
108+
"""
109+
t = self._temperature()
110+
h = self._humidity()
111+
a, b, c, d = *self._TEMPERATURE_CORRECTION, self._HUMIDITY_CORRECTION
112+
return a * t ** 2 + b * t + c + d * (h - 0.65)
113+
114+
@property
115+
def _sensor_resistance(self):
116+
return (
117+
(self.vcc / self._voltage - 1) * self.r_load / self._correction
118+
)
119+
120+
def measure_concentration(self):
121+
"""Measure the concentration of the configured gas.
122+
123+
Returns
124+
-------
125+
concentration : float
126+
Gas concentration in ppm.
127+
"""
128+
try:
129+
return self._params[0] * (self._sensor_resistance / self.r0) ** self._params[1]
130+
except TypeError:
131+
raise TypeError("r0 is not set.")
132+
133+
def measure_r0(self, gas_concentration: float):
134+
"""Determine sensor resistance at 100 ppm NH3 in otherwise clean air.
135+
136+
For best results, monitor R0 over several hours and use the average
137+
value.
138+
139+
The sensor resistance at 100 ppm NH3 (R0) is used as a reference
140+
against which the present sensor resistance must be compared in order
141+
to calculate gas concentration.
142+
143+
R0 can be determined by calibrating the sensor at any known gas
144+
concentration.
145+
146+
Parameters
147+
----------
148+
gas_concentration : float
149+
A known concentration of the configured gas in ppm.
150+
151+
Returns
152+
-------
153+
r0 : float
154+
The sensor resistance at 100 ppm NH3 in ohm.
155+
"""
156+
return self._sensor_resistance * (gas_concentration / self._params[0]) ** (
157+
1 / -self._params[1]
158+
)

tests/test_mq135.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import pytest
2+
3+
from pslab.external.gas_sensor import MQ135
4+
5+
R_LOAD = 22e3
6+
R0 = 50e3
7+
VCC = 5
8+
VOUT = 2
9+
A, B, C, D = *MQ135._TEMPERATURE_CORRECTION, MQ135._HUMIDITY_CORRECTION
10+
E, F = MQ135._PARAMS["CO2"]
11+
STANDARD_CORRECTION = A * 20 ** 2 + B * 20 + C + D * (0.65 - 0.65)
12+
EXPECTED_SENSOR_RESISTANCE = (VCC / VOUT - 1) * R_LOAD / STANDARD_CORRECTION
13+
CALIBRATION_CONCENTRATION = E * (EXPECTED_SENSOR_RESISTANCE / R0) ** F
14+
15+
16+
@pytest.fixture
17+
def mq135(mocker):
18+
mock = mocker.patch("pslab.external.gas_sensor.Multimeter")
19+
mock().measure_voltage.return_value = VOUT
20+
return MQ135("CO2", R_LOAD)
21+
22+
23+
def test_correction(mq135):
24+
assert mq135._correction == STANDARD_CORRECTION
25+
26+
27+
def test_sensor_resistance(mq135):
28+
assert mq135._sensor_resistance == EXPECTED_SENSOR_RESISTANCE
29+
30+
31+
def test_measure_concentration(mq135):
32+
mq135.r0 = R0
33+
assert mq135.measure_concentration() == E * (EXPECTED_SENSOR_RESISTANCE / R0) ** F
34+
35+
36+
def test_measure_concentration_r0_unset(mq135):
37+
with pytest.raises(TypeError):
38+
mq135.measure_concentration()
39+
40+
41+
def test_measure_r0(mq135):
42+
assert mq135.measure_r0(CALIBRATION_CONCENTRATION) == pytest.approx(R0)

0 commit comments

Comments
 (0)