CO2 Sensors: SCD30/SCD4x

Monitoring CO₂ levels is essential for environmental studies, building management, and indoor air quality assessment. The SCD30 and SCD40 are CO₂ sensors that measure gas concentration using non-dispersive infrared (NDIR) technology. The SCD40/41 photoacoustic measurement system has a smaller form factor by 5x over the SCD30.

These sensors measure CO₂ concentrations with SCD30 ranging 400-10,000 ppm, SCD40 ranging from 400-2,000 ppm, and SCD41 ranging from 400-5,000 ppm. They also have integrated temperature and humidity sensing with the SHT4x sensor for temperature compensation and for comprehensive environmental monitoring.

SCD30/4x CO₂ Measurement Module

The SCD30 (green), SCD40 (purple), SCD41 (blue) modules include an NDIR CO2 sensor, a temperature sensor, and a humidity sensor. These sensors are compact, consume low power, and are calibrated for long-term stability. The NDIR technology has high accuracy (SCD30 ±30 ppm and SCD40/41 ±50 ppm CO₂ accuracy) in CO₂ measurements.

These modules are interfaced with microcontrollers like the Arduino or ESP32 via an I2C connection. The SCD30/4x sensors are configured for different measurement intervals, and they support automatic baseline correction to adapt to changing environments.


SCD30 with MicroPython

from machine import SoftI2C, Pin
import scd30
import time

# I2C Connection
i2c = SoftI2C(scl=Pin(22), sda=Pin(21))

# SCD30 sensor
sensor = scd30.SCD30(i2c, addr=0x61)

sensor.start_continous_measurement()
while True:
    if sensor.get_status_ready():
        co2, temp, rh = sensor.read_measurement()
        print(f"CO2: {co2}ppm, Temp: {temp}C, Hum: {rh}%")
    else:
        print("Measurement not ready.")
    time.sleep(5)

The scd30.py library is required to interface with the CO₂ sensors and obtain temperature, humidity, and concentration readings.

from machine import I2C
import utime
import struct

class SCD30:
    class NotFoundException(Exception):
        pass

    class CRCException(Exception):
        pass

    START_CONT_MEASURE = 0x0010
    STOP_CONT_MEASURE = 0x0104
    SET_MEASURE_INTERVAL = 0x4600
    GET_STATUS_READY = 0x0202
    READ_MEASUREMENT = 0x0300
    SET_ASC = 0x5306
    SET_FRC = 0x5204
    SET_TEMP_OFFSET = 0x5403
    SET_ALT_COMP = 0x5102
    GET_FIRMWARE_VER = 0xd100
    SOFT_RESET = 0xd304

    CLOCK_TIME_US = 10

    # Generated using
    # crc_table = []
    # for crc in range(256):
    #     for crc_bit in range(8):
    #         if crc & 0x80:
    #             crc = (crc << 1) ^ CRC8_POLYNOMIAL;
    #         else:
    #             crc = (crc << 1);
    #         crc = crc%256
    #     crc_table.append(crc)

    CRC_TABLE = [
        0, 49, 98, 83, 196, 245, 166, 151, 185, 136, 219, 234, 125, 76, 31, 46,
        67, 114, 33, 16, 135, 182, 229, 212, 250, 203, 152, 169, 62, 15, 92, 109,
        134, 183, 228, 213, 66, 115, 32, 17, 63, 14, 93, 108, 251, 202, 153, 168,
        197, 244, 167, 150, 1, 48, 99, 82, 124, 77, 30, 47, 184, 137, 218, 235,
        61, 12, 95, 110, 249, 200, 155, 170, 132, 181, 230, 215, 64, 113, 34, 19,
        126, 79, 28, 45, 186, 139, 216, 233, 199, 246, 165, 148, 3, 50, 97, 80,
        187, 138, 217, 232, 127, 78, 29, 44, 2, 51, 96, 81, 198, 247, 164, 149,
        248, 201, 154, 171, 60, 13, 94, 111, 65, 112, 35, 18, 133, 180, 231, 214,
        122, 75, 24, 41, 190, 143, 220, 237, 195, 242, 161, 144, 7, 54, 101, 84,
        57, 8, 91, 106, 253, 204, 159, 174, 128, 177, 226, 211, 68, 117, 38, 23,
        252, 205, 158, 175, 56, 9, 90, 107, 69, 116, 39, 22, 129, 176, 227, 210,
        191, 142, 221, 236, 123, 74, 25, 40, 6, 55, 100, 85, 194, 243, 160, 145,
        71, 118, 37, 20, 131, 178, 225, 208, 254, 207, 156, 173, 58, 11, 88, 105,
        4, 53, 102, 87, 192, 241, 162, 147, 189, 140, 223, 238, 121, 72, 27, 42,
        193, 240, 163, 146, 5, 52, 103, 86, 120, 73, 26, 43, 188, 141, 222, 239,
        130, 179, 224, 209, 70, 119, 36, 21, 59, 10, 89, 104, 255, 206, 157, 172
        ]

    def __init__(self, i2c, addr, pause=1000):
        self.i2c = i2c
        self.pause = pause
        self.addr = addr
        if not addr in i2c.scan():
            raise self.NotFoundException

    def start_continous_measurement(self, ambient_pressure=0):
        bint = struct.pack('>H', ambient_pressure)
        crc = self.__crc(bint[0], bint[1])
        data = bint + bytes([crc])
        self.i2c.writeto_mem(self.addr, self.START_CONT_MEASURE, data, addrsize=16)

    def stop_continous_measurement(self):
        self.__write_command(self.STOP_CONT_MEASURE)

    def soft_reset(self):
        self.__write_command(self.SOFT_RESET)

    def get_firmware_version(self):
        ver = self.__read_bytes(self.GET_FIRMWARE_VER, 3)
        self.__check_crc(ver)
        return struct.unpack('BB', ver)

    def read_measurement(self):
        measurement = self.__read_bytes(self.READ_MEASUREMENT, 18)
        for i in range(0, len(measurement), 3):
            self.__check_crc(measurement[i:i+3])

        value = measurement[0:]
        co2 = struct.unpack('>f', value[0:2] + value[3:5])[0]
        value = measurement[6:]
        temperature = struct.unpack('>f', value[0:2] + value[3:5])[0]
        value = measurement[12:]
        relh = struct.unpack('>f', value[0:2] + value[3:5])[0]
        return (co2, temperature, relh)

    def get_status_ready(self):
        ready = self.__read_bytes(self.GET_STATUS_READY, 3)
        self.__check_crc(ready)
        return struct.unpack('>H', ready)[0]

    def get_measurement_interval(self):
        bint = self.__read_bytes(self.SET_MEASURE_INTERVAL, 3)
        self.__check_crc(bint)
        return struct.unpack('>H', bint)[0]

    def set_measurement_interval(self, interval):
        bint = struct.pack('>H', interval)
        crc = self.__crc(bint[0], bint[1])
        data = bint + bytes([crc])
        self.i2c.writeto_mem(self.addr, self.SET_MEASURE_INTERVAL, data, addrsize=16)

    def get_automatic_recalibration(self):
        bint = self.__read_bytes(self.SET_ASC, 3)
        self.__check_crc(bint)
        return struct.unpack('>H', bint)[0] == 1

    def set_automatic_recalibration(self, enable):
        bint = struct.pack('>H', 1 if enable else 0)
        crc = self.__crc(bint[0], bint[1])
        data = bint + bytes([crc])
        self.i2c.writeto_mem(self.addr, self.SET_FRC, data, addrsize=16)

    def get_forced_recalibration(self):
        bint = self.__read_bytes(self.SET_FRC, 3)
        self.__check_crc(bint)
        return struct.unpack('>H', bint)[0]

    def set_forced_recalibration(self, co2ppm):
        bint = struct.pack('>H', co2ppm)
        crc = self.__crc(bint[0], bint[1])
        data = bint + bytes([crc])
        self.i2c.writeto_mem(self.addr, self.SET_FRC, data, addrsize=16)

    def get_temperature_offset(self):
        bint = self.__read_bytes(self.SET_TEMP_OFFSET, 3)
        self.__check_crc(bint)
        return struct.unpack('>H', bint)[0] / 100.0

    def set_temperature_offset(self, offset):
        bint = struct.pack('>H', int(offset * 100))
        crc = self.__crc(bint[0], bint[1])
        data = bint + bytes([crc])
        self.i2c.writeto_mem(self.addr, self.SET_TEMP_OFFSET, data, addrsize=16)

    def get_altitude_comp(self):
        bint = self.__read_bytes(self.SET_ALT_COMP, 3)
        self.__check_crc(bint)
        return struct.unpack('>H', bint)[0]

    def set_altitude_comp(self, altitude):
        bint = struct.pack('>H', altitude)
        crc = self.__crc(bint[0], bint[1])
        data = bint + bytes([crc])
        self.i2c.writeto_mem(self.addr, self.SET_ALT_COMP, data, addrsize=16)

    def __write_command(self, cmd):
        bcmd = struct.pack('>H', cmd)
        self.i2c.writeto(self.addr, bcmd)

    def __read_bytes(self, cmd, count):
        self.__write_command(cmd)
        utime.sleep_us(self.pause)
        return self.i2c.readfrom(self.addr, count)

    def __check_crc(self, arr):
        assert (len(arr) == 3)
        if self.__crc(arr[0], arr[1]) != arr[2]:
            raise self.CRCException

    def __crc(self, msb, lsb):
        crc = 0xff
        crc ^= msb
        crc = self.CRC_TABLE[crc]
        if lsb is not None:
            crc ^= lsb
            crc = self.CRC_TABLE[crc]
        return crc

SCD4x with Micropython

import time
from machine import SoftI2C, Pin
from scd4x import SCD4X

# I2C Connection for SCD40
i2c = SoftI2C(scl=Pin(18),sda=Pin(19))

# SCD40 sensor
sensor = SCD4X(i2c)
sensor.start_periodic_measurement()

while True:
    time.sleep(5)
    co2, temp, rh = sensor.co2, sensor.temperature, sensor.relative_humidity
    if co2 is not None and temp is not None and rh is not None:
        print(f"CO2: {co2}ppm, Temp: {temp:.2f}C, Hum: {rh:.2f}%")
    else:
        print("Measurement not ready.")

The scd4x.py library is required to interface with the CO₂ sensors and obtain temperature, humidity, and concentration readings.

import time

class SCD4X:
    """
    Based on https://github.com/adafruit/Adafruit_CircuitPython_SCD4X
    Copyright (c) 2021 ladyada for Adafruit Industries
    MIT License
    """

    from micropython import const

    DEFAULT_ADDRESS = 0x62
    DATA_READY = const(0xE4B8)
    STOP_PERIODIC_MEASUREMENT = const(0x3F86)
    START_PERIODIC_MEASUREMENT = const(0x21B1)
    READ_MEASUREMENT = const(0xEC05)

    def __init__(self, i2c_bus, address=DEFAULT_ADDRESS):
        self.i2c = i2c_bus
        self.address = address
        self._buffer = bytearray(18)
        self._cmd = bytearray(2)
        self._crc_buffer = bytearray(2)

        # cached readings
        self._temperature = None
        self._relative_humidity = None
        self._co2 = None

        self.stop_periodic_measurement()

    @property
    def co2(self):
        """Returns the CO2 concentration in PPM (parts per million)
        .. note::
            Between measurements, the most recent reading will be cached and returned.
        """

        if self.data_ready:
            self._read_data()
        return self._co2

    @property
    def temperature(self):
        """Returns the current temperature in degrees Celsius
        .. note::
            Between measurements, the most recent reading will be cached and returned.
        """

        if self.data_ready:
            self._read_data()
        return self._temperature

    @property
    def relative_humidity(self):
        """Returns the current relative humidity in %rH.
        .. note::
            Between measurements, the most recent reading will be cached and returned.
        """

        if self.data_ready:
            self._read_data()
        return self._relative_humidity

    def _read_data(self):
        """Reads the temp/hum/co2 from the sensor and caches it"""
        self._send_command(self.READ_MEASUREMENT, cmd_delay=0.001)
        self._read_reply(self._buffer, 9)
        self._co2 = (self._buffer[0] << 8) | self._buffer[1]
        temp = (self._buffer[3] << 8) | self._buffer[4]
        self._temperature = -45 + 175 * (temp / 2 ** 16)
        humi = (self._buffer[6] << 8) | self._buffer[7]
        self._relative_humidity = 100 * (humi / 2 ** 16)

    @property
    def data_ready(self):
        """Check the sensor to see if new data is available"""
        self._send_command(self.DATA_READY, cmd_delay=0.001)
        self._read_reply(self._buffer, 3)
        return not ((self._buffer[0] & 0x03 == 0) and (self._buffer[1] == 0))

    def stop_periodic_measurement(self):
        """Stop measurement mode"""
        self._send_command(self.STOP_PERIODIC_MEASUREMENT, cmd_delay=0.5)

    def start_periodic_measurement(self):
        """Put sensor into working mode, about 5s per measurement"""
        self._send_command(self.START_PERIODIC_MEASUREMENT, cmd_delay=0.01)

    def _send_command(self, cmd, cmd_delay=0.0):
        self._cmd[0] = (cmd >> 8) & 0xFF
        self._cmd[1] = cmd & 0xFF
        self.i2c.writeto(self.address, self._cmd)
        time.sleep(cmd_delay)

    def _read_reply(self, buff, num):
        self.i2c.readfrom_into(self.address, buff, num)
        self._check_buffer_crc(self._buffer[0:num])

    def _check_buffer_crc(self, buf):
        for i in range(0, len(buf), 3):
            self._crc_buffer[0] = buf[i]
            self._crc_buffer[1] = buf[i + 1]
            if self._crc8(self._crc_buffer) != buf[i + 2]:
                raise RuntimeError("CRC check failed while reading data")
        return True

    @staticmethod
    def _crc8(buffer):
        crc = 0xFF
        for byte in buffer:
            crc ^= byte
            for _ in range(8):
                if crc & 0x80:
                    crc = (crc << 1) ^ 0x31
                else:
                    crc = crc << 1
        return crc & 0xFF

✅ Activity: Monitor CO₂ with SCD30/4x

Applications of these CO₂ sensors include:

  • Environmental Monitoring: Tracking CO₂ levels in outdoor environments to study air quality.
  • Indoor Air Quality: Monitoring CO₂ concentration in buildings, schools, and offices to ensure a healthy indoor environment. Recommended range is 700-1,000ppm with below 700ppm for maximum comfort.
  • Horticulture: Managing CO₂ levels in greenhouses to optimize plant growth.
  • Scientific Research: Utilizing precise CO₂ measurements in various research fields.

Case Study: Daily CO₂ Monitoring

In this case study, the CO₂ concentration in an office is measured throughout the day using the SCD30 sensor and an ESP32. The presence of plants in the room can cause the CO₂ concentration to drop below the base outside air value (~421 ppm in 2024).

from machine import Pin, SoftI2C
import scd30
import time

# Initialize I2C for SCD30
i2c = SoftI2C(scl=Pin(22), sda=Pin(21))
sensor = scd30.SCD30(i2c, addr=0x61)

# Start Continuous Measurement for SCD30
sensor.start_continous_measurement()

# Open file to record data
with open('co2_data.csv', 'w') as fid:
    fid.write('Time,Temp,Humidity,CO2\n')

st = time.time()

# Run for 16 hours
for i in range(1440):
    # Read from SCD30
    if sensor.get_status_ready():
        co2, temp, hum = sensor.read_measurement()
    else:
        time.sleep(0.1)
        continue

    # Write data to file
    with open('co2_data.csv', 'a') as fid:
        fid.write(f'{(time.time()-st)/60.0},{temp},{hum},{co2}\n')

    print(f'{i} T:{temp}C H:{hum}% CO2:{co2}ppm')

    # Wait 40 sec before reading again
    time.sleep(40)

Results and Discussion

import pandas as pd
import datetime
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

file = 'co2_data.csv'
url = 'http://apmonitor.com/dde/uploads/Main/'
data = pd.read_csv(url+file)

# Starting time is 11:00 AM on Feb 14, 2024
st = datetime.datetime(2024, 2, 14, 11, 0)
# Create a time range based on Time
tr = [st + datetime.timedelta(minutes=x) \
      for x in data['Time']]
data['Time'] = tr
# Create plot
data.set_index('Time',drop=True,inplace=True)
data.plot(subplots=True)
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
plt.tight_layout(); plt.savefig('co2_data.png',dpi=300)
plt.show()

The SCD30 sensor measured fluctuations in CO₂ levels throughout the day. Higher concentrations were noted during the daytime in the room due to human occupancy and lower levels at night. There were no plants in the room to reduce CO₂ levels during daylight hours. Plants can sometimes achieve even lower CO₂ levels than outdoor due to photosynthesis. This study highlights the importance of monitoring CO₂ for managing indoor air quality and understanding environmental dynamics.

Case Study: Outdoor CO₂ Monitoring

Outdoor CO₂ concentration is monitored with the SCD30 sensor over 12 hours in a residential community in Provo, UT on Feb 16-17, 2024. Note the periods of increase in CO₂ concentration as local concentration increases likely due to local natural gas furnace emissions and from microbial emissions from the soil. The CO₂ levels decrease during the day when plants photosynthesize in the presence of light. Concentrations above background 425 ppm are from local emissions. The local emissions trend downward throughout the night as there is less automotive activity, but the natural gas use in homes increases as the external temperature drops.

import pandas as pd
import datetime
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

file = 'co2_data2.csv'
url = 'http://apmonitor.com/dde/uploads/Main/'
data = pd.read_csv(url+file)

# Starting time is 7:00 PM on Feb 16, 2024
st = datetime.datetime(2024, 2, 16, 19, 0)
# Create a time range based on Time
tr = [st + datetime.timedelta(minutes=x) \
      for x in data['Time']]
data['Time'] = tr
data['Baseline'] = 423.05 # on Feb 16, 2024
data.set_index('Time',drop=True,inplace=True)

# Create plot
fig, axs = plt.subplots(3,1,figsize=(6,4))
data.plot(ax=axs[0],y=['CO2','Baseline'])
axs[0].set_ylabel('CO2 (ppm)')
data.plot(ax=axs[1],y=['Humidity'],color='red',linestyle=':')
axs[1].set_ylabel('Hum (%)')
data.plot(ax=axs[2],y=['Temp'],color='green', linestyle='--')
axs[2].set_ylabel('Temp (°C)')
for i in range(2):
    axs[i].set_xticklabels([])
    axs[i].set_xlabel('')
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter('%H:%M'))
plt.tight_layout(); plt.savefig('co2_data.png',dpi=300)
plt.show()

Case Study: Compare SCD30 & SCD40

This case study compares SCD30 and SCD40 CO₂ sensors for indoor office air measurement. Several factors are taken into consideration such as the accuracy and precision of these sensors. The SCD30 has an accuracy of ±30 ppm, whereas the SCD40 is slightly less accurate with ±50 ppm. There are similar trend patterns displayed by both sensors but with offset. Calibration differences are a primary consideration. Each sensor has default calibration that is set at manufacture, which varies and influences the responsiveness to identical CO₂ levels. Environmental influences such as temperature and humidity are also factors, as each sensor may have different sensitivity to these factors.

Collect Data from SCD30 & SCD40

from machine import Pin, SoftI2C
import scd30
from scd4x import SCD4X
import time

# SCD30 Sensor
i30 = SoftI2C(scl=Pin(22), sda=Pin(21))
s30 = scd30.SCD30(i30, addr=0x61)
s30.start_continous_measurement()

# SCD40 Sensor
i40 = SoftI2C(sda=Pin(19), scl=Pin(18))
s40 = SCD4X(i40)
s40.start_periodic_measurement()

# file header
with open('co2_data3.csv', 'w') as fid:
    fid.write('Time,T30,H30,CO2_30,T40,H40,CO2_40\n')
st = time.time()

while True:
    # Wait 20 sec before reading
    time.sleep(20)
    tm = time.time()
    # Read from SCD30
    if s30.get_status_ready():
        co2_1, t1, h1 = s30.read_measurement()
    # Read from SCD40
    co2_2, t2, h2 = s40.co2, s40.temperature, s40.relative_humidity
    # Write data to file
    with open('co2_data.csv', 'a') as fid:
        fid.write(f'{(tm-st)/60.0},{t1},{h1},{co2_1},{t2},{h2},{co2_2}\n')
    print(f'{(tm-st)/60.0} T1:{t1}C T2:{t2}C ' +
          f'H1:{h1}% H2:{h2}% CO2_1:{co2_1}ppm CO2_2:{co2_2}ppm')

Both sensors support Automatic Baseline Correction (ABC) that is important for long-term accuracy. This method adjusts the calibration based on assumed ambient CO₂ level when the space is unoccupied and well-ventilated. Forced calibration is another method where the sensor is manually set to a known CO₂ concentration, often the outdoor ambient level, for recalibration. Additionally, cross-comparison with a highly accurate reference CO₂ sensor can be used to align and adjust the readings from the SCD30 and SCD40. Various factors such as occupancy, ventilation, and time of day influence the CO₂ levels. Comparing the trends from both sensors with occupancy is data that can be used to estimate space utilization from gas concentrations.

Visualize Data from SCD30 & SCD40

import pandas as pd
import datetime
import matplotlib.pyplot as plt
import matplotlib.dates as mdates

file = 'co2_data3.csv'
url = 'http://apmonitor.com/dde/uploads/Main/'
data = pd.read_csv(url+file)

# Starting time is 12:28 PM on Feb 29, 2024
st = datetime.datetime(2024, 2, 29, 12, 28)
# Create a time range based on Time
tr = [st + datetime.timedelta(minutes=x) \
      for x in data['Time']]
data['Time'] = tr
data['Baseline'] = 425.40 # on Feb 29, 2024
data.set_index('Time',drop=True,inplace=True)

# Create plot
fig, axs = plt.subplots(3,1,figsize=(10,6))
data.plot(ax=axs[0],y=['CO2_30','CO2_40','Baseline'])
axs[0].set_ylabel('CO2 (ppm)')
data.plot(ax=axs[1],y=['H30','H40'],color=['red','blue'],linestyle=':')
axs[1].set_ylabel('Hum (%)')
data.plot(ax=axs[2],y=['T30','T40'],color=['green','black'], linestyle='--')
axs[2].set_ylabel('Temp (°C)')
locator = mdates.HourLocator(interval=4)
for ax in axs:
    ax.grid()
    ax.xaxis.set_major_locator(locator)
    ax.xaxis.set_major_formatter(mdates.DateFormatter('%a %H:%M'))
for i in range(2):
    axs[i].set_xticklabels([])
    axs[i].set_xlabel('')
plt.tight_layout(); plt.savefig('co2_data3.png',dpi=300)
plt.show()

Case Study: Terrarium CO₂ Monitoring

Monitor the CO₂ concentration in a terrarium using the SCD30 sensor and an ESP32 microcontroller. The terrarium, containing both plants and soil, provides an environment to study the interaction between biotic components and the impact on CO₂ levels. Observe the effects of light on CO₂ concentration with likely rising CO₂ levels due to microbial emissions from the soil and decreasing CO₂ levels when plants photosynthesize in the presence of light.

Perform a regression analysis to determine the relationship between light level and CO₂ concentration. Develop a time-series prediction model to forecast future CO₂ levels under varying light conditions. This analysis may utilize time-series forecasting such as ARX (Linear) or Transformers (Nonlinear).

This analysis reveals insights into the dynamic relationship between artificial lighting and CO₂ levels in a terrarium with ties to environmental science, horticulture, and indoor air quality management. The forecast model could further be utilized to optimize conditions in controlled environments like greenhouses, maximizing plant growth while maintaining optimal CO₂ levels.

Thanks to Clint Guymon for the terrarium case study.