SCD4x CO₂ sensors

About this Post

In this article, I share my experiences with the SCD40 and SCD41 sensor modules (SCD4x for short) for measuring the carbon dioxide content of the air. I use the arduino_i2c_scd4x library from Sensirion.

In an earlier post, I wrote about the MH-Z14 and MH-Z19 CO₂ sensors, which are based on a similar spectroscopic measuring principle. I was very satisfied with their performance. I was less enthusiastic about the CO₂ sensor modules CCS811 and SGP30, which I covered here. In this respect, I was very excited to see how the SCD4x modules would perform (spoiler: excellent!).

Here’s what you can expect in this article:

SCD4x versions

Various SCD4x modules
Various SCD4x modules

If you search for SCD4x modules, you will essentially find the designs shown above. They all have pull-up resistors for the I2C lines and capacitors for voltage stabilization.

The large, red modules also have Qwiic connectors, which you can use to connect the modules very conveniently to boards or other I2C modules, provided they also have this feature. These variants also have a power LED. The LED and the pull-ups can be connected or disconnected via solder pads on the back.

I have seen another variant (not shown here) which also has a 3.3 volt voltage regulator and a 3.3 volt output. This variant also offers the option of setting the I2C lines to 3.3 or 5 volts.  

The design does not determine whether an SCD40 or SCD41 sensor has been used for the module. So be careful what you order.  

The main differences between the SCD40 and the SCD41 are their (optimum) measuring ranges and their accuracy. In addition, only the SCD41 is capable of single shot mode.

Wide price range

I have seen prices for SCD41 modules ranging from around €14 to €100 (!). For the SCD40, the prices ranged between 11 and 70 €. Are branded modules worth it? In my view, not in this case. The no-name modules were no worse than the Sparkfun product. But of course, I can’t promise you that you won’t get cheap junk somewhere.  

The measuring principle of the SCD4x sensors

The SCD4x sensors are based on the photoacoustic measuring principle. Molecules such as CO₂ absorb light of certain wavelengths. If light is sent through a gas mixture and that light is only absorbed by one type of molecule, these molecules are selectively excited (heated). This causes the gas mixture to expand slightly. If the light is switched on and off in quick succession, the gas mixture will expand and contract periodically. This generates a pressure wave, i.e. sound, which is detected by an acoustic sensor (microphone). The intensity of the acoustic signal is proportional to the concentration of the absorbing gas in the gas mixture.  

The light used to measure CO₂ is infrared light, which is invisible to us. The ability of CO₂ to absorb infrared light is known from the greenhouse effect.

As the measured values are also influenced by relative humidity and temperature, the SCD4x modules have corresponding sensors. An integrated microcontroller corrects the raw data accordingly. How exactly this happens is a company secret of the manufacturer Sensirion. Air pressure also influences the measurement result and can be corrected. However, there is no integrated sensor for this.

I find it quite impressive that this measuring principle works so well in such small sensors. 

Since I was curious, I sacrificed one of my sensors and cut it open:

SCD41 Sensor, open

Unfortunately, I cannot say with certainty which part is doing what. Nevertheless, the insight is perhaps quite interesting for you.

Technical data

The SCD40 and SCD41 can output CO₂ concentrations from 0 to 40000 ppm. However, there is only a specified accuracy for measured values between 400 and 2000 ppm (SCD40) and between 400 and 5000 ppm (SCD41).

I have compiled some technical data here: 

Selected technical data of the SCD4x sensors

Other important features are

  • Automatic or forced calibration with preset values
  • Temperature, pressure/altitude and humidity compensation
  • Functional self-test
  • EEPROM for permanent storage of settings

A data sheet for the SCD40 and SCD41 can be found here.

Connecting the SCD4x modules to the microcontroller

The modules have two pins for the power supply and two for the I2C connection. And as the SCD4x modules – at least the ones I tested – have pull-up resistors, you only need four cables to get started.

It should be noted that the current peaks are relatively high and may exceed the capabilities of your microcontroller board. With many Arduino boards, the maximum current of the 3.3V output is 50 mA. If necessary, you should therefore consider a separate power supply.  

But even with external, potent power sources I have only had limited good experiences at 3.3 volts, even with additional capacitors. With 4-5 volts, the SCD4x modules ran more reliably for me. In any case, you should ensure a stable voltage.  

Here is an example circuit with a classic Arduino Nano:

SCD4x - Connected to a classic Arduino Nano
SCD4x module connected to an Arduino Nano

Libraries for the SCD4x modules

As already mentioned, I am using the arduino_i2c_scd4x library from Sensirion for the examples in this article, which you can install via the library manager of the Arduino IDE. The library is still quite young (as of today: release 1.0.0) and still has room for improvement in a few less relevant areas. But it is the most complete of the libraries I have looked at. It could also contain a few more example sketches – but that’s why you have this article.

A similarly good alternative is the SparkFun_SCD4x_Arduino_Library library. It has many examples, but lacks a few functions such as setting the target value for automatic self-calibration.

Then I took a look at the DFRobot_SCD4X library, which, however, lagged well behind the other two libraries in terms of its range of functions (please note: as of today).

If you are more into C, you may be happy with the embedded-i2c-scd4x from Sensirion. 

Getting started – simple measurements

Enough theory, now let’s get down to practice. And I would like to illustrate this to you primarily using practical examples. We will start with a simple sketch that provides you with measured values for the CO₂ concentration, temperature and relative humidity. The standard settings are used here.

#include <SensirionI2cScd4x.h>
#include <Wire.h>

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

void setup() {
    Serial.begin(115200);
    Wire.begin();
    sensor.begin(Wire, SCD41_I2C_ADDR_62); // alt.: SCD40_I2C_ADDR_62
    uint64_t serialNumber = 0;
    delay(30);

    error = sensor.wakeUp();
    if (error) {
        Serial.print("Error trying to execute wakeUp(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.stopPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute stopPeriodicMeasurement(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.reinit();
    if (error) {
        Serial.print("Error trying to execute reinit(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.getSerialNumber(serialNumber);
    if (error) {
        Serial.print("Error trying to execute getSerialNumber(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    Serial.print("SCD4x connected, serial number: ");
    PrintUint64(serialNumber);
    Serial.println();

    error = sensor.startPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute startPeriodicMeasurement(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
}

void loop() {
    bool dataReady = false;
    uint16_t co2Concentration = 0;
    float temperature = 0.0;
    float relativeHumidity = 0.0;

    error = sensor.getDataReadyStatus(dataReady);
    if (error) {
        Serial.print("Error trying to execute getDataReadyStatus(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }

    if (dataReady) {
        sensor.readMeasurement(co2Concentration, temperature, relativeHumidity);
  
        Serial.println();
        Serial.print("CO2[ppm]: ");
        Serial.print(co2Concentration);

        Serial.print("\tTemperature[°C]: ");
        Serial.print(temperature, 1);

        Serial.print("\tHumidity[%RH]: ");
        Serial.print(relativeHumidity, 1);

        Serial.println();
    }
    else
        Serial.print(".");

    delay(500);
}

void PrintUint64(uint64_t& value) {
    Serial.print("0x");
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

 

Here is the output:

SCD4x - Output of scd4x_basic_reading.ino
Output of scd4x_basic_reading.ino

Explanations of the sketch

Error codes

Almost all functions in the library return an error code. This is very helpful for debugging. As the get functions are supposed to return additional data, but functions can only return one value, you pass the associated variables as a reference.

The error messages contain many character strings. If this causes the RAM to run low, use the F macro.

Initialization sequence

To bring the SCD4x into a defined state, the sketch uses an initialization sequence that you will also find in most of the other examples:

  1. Inclusion of the libraries.
  2. Transfer the TwoWire object and the I2C address to begin().
  3. Wait 30 milliseconds for the SCD4x to start up.
  4. Wake up SCD4x with wakeUp().
  5. Switch off periodic measurements with stopPeriodicMeasurement(), as the SCD4x is “deaf” to most instructions in this mode.
  6. Read default settings from the EEPROM of the SCD4x using reinit()
  7. Read out the serial number with getSerialNumber(). This is the best way to check the availability of the SCD4x.
  8. Make settings at this point – although we are not yet doing this in this example.
  9. Starting measurements – here with startPeriodicMeasurement() for periodic mode.

Passing the I2C address as SCD4x_I2C_ADDR_62 with x = 0 or 1 gives the impression that the SCD4x variant is being transmitted. However, this is simply 0x62.

The serial number is so large that it requires a 64-bit integer variable. To be able to output this with Serial.print(), it must be split into two 32-bit integer values. This is done by PrintUint64().

Reading out the measured values

The measured values are always provided as a three-pack consisting of CO₂ concentration, temperature and humidity. You can check whether a new set of measured values is available with getDataReadyStatus(). If this is the case, query the data with readMeasurement(). A measurement takes approx. 5 seconds.

Measuring modes

Periodic measurements

You have just learned about periodic measurements. Just a few more comments.

Unless you are planning battery operation, I would choose this option. The average power consumption is 15 milliamps at 3.3 volts and 11 milliamps at 5 volts.  

During periodic measurements, the sensor only reacts to the following functions:

  • readMeasurement(): you already know.
  • getDataReadyStatus(): you already know.
  • stopPeriodicMeasurement(): You already know this function too. 
  • setAmbientPressure(): we’ll get to that.

Low power measurements

Using low-power measurements, you can reduce the average power consumption to 3.2 milliamps at 3.3 volts and 2.8 milliamps at 5 volts. In this mode, a measurement is taken every 30 seconds. Between measurements, the current consumption at 5 volts drops to approx. 1 milliampere when using the power LED and to approx. 0.6 milliampere without the power LED. The high current consumption during the measurement drives the average upwards. It would be nice if the SCD4x modules had interrupt pins that signal when new measurements are ready – but they do not.

The sketch differs from the previous one only in that the low-power measurements are started with startLowPowerPeriodicMeasurement() instead of the periodic measurements.

#include <SensirionI2cScd4x.h>
#include <Wire.h>

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

void setup() {
    Serial.begin(115200);
    Wire.begin();
    sensor.begin(Wire, SCD41_I2C_ADDR_62);
    uint64_t serialNumber = 0;
    delay(30);

    error = sensor.wakeUp();
    if (error) {
        Serial.print("Error trying to execute wakeUp(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.stopPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute stopPeriodicMeasurement(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.reinit();
    if (error) {
        Serial.print("Error trying to execute reinit(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

     error = sensor.getSerialNumber(serialNumber);
    if (error) {
        Serial.print("Error trying to execute getSerialNumber(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    Serial.print("SCD4x connected, serial number: ");
    PrintUint64(serialNumber);
    Serial.println();

    error = sensor.startLowPowerPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute startPeriodicMeasurement(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    else
        Serial.println("Low power mode enabled!");

}

void loop() {
    bool dataReady = false;
    uint16_t co2Concentration = 0;
    float temperature = 0.0;
    float relativeHumidity = 0.0;

    error = sensor.getDataReadyStatus(dataReady);
    if (error) {
        Serial.print("Error trying to execute getDataReadyStatus(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }

    if (dataReady) {
        sensor.readMeasurement(co2Concentration, temperature, relativeHumidity);
  
        Serial.println();
        Serial.print("CO2[ppm]: ");
        Serial.print(co2Concentration);

        Serial.print("\tTemperature[°C]: ");
        Serial.print(temperature, 1);

        Serial.print("\tHumidity[%RH]: ");
        Serial.print(relativeHumidity, 1);

        Serial.println();
    }
    else
        Serial.print(".");

    delay(500);
}

void PrintUint64(uint64_t& value) {
    Serial.print("0x");
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

 

Here is the output:

SCD4x - Output of scd4x_low_power_periodic.ino
Output of scd4x_low_power_periodic.ino

Single shot measurements (SCD41 only)

The third measurement mode “Single Shot” is only available for the SCD41. Here, you initiate each measurement explicitly.

#include <SensirionI2cScd4x.h>
#include <Wire.h>
#define SINGLE_SHOT_PAUSE 300000 // please adjust, but consider ASC settings
//#define USE_POWER_DOWN

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

void setup() {
    Serial.begin(115200);
    Wire.begin();
    uint64_t serialNumber = 0;
    sensor.begin(Wire, SCD41_I2C_ADDR_62);
    delay(30);

    error = sensor.wakeUp();
    if (error) {
        Serial.print("Error trying to execute wakeUp(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.stopPeriodicMeasurement();
    if (error) {
        Serial.print("Error trying to execute stopPeriodicMeasurement(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.reinit();
    if (error) {
        Serial.print("Error trying to execute reinit(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.getSerialNumber(serialNumber);
    if (error) {
        Serial.print("Error trying to execute getSerialNumber(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    Serial.print("SCD4x connected, serial number: ");
    PrintUint64(serialNumber);
    Serial.println();

    error = sensor.setTemperatureOffset(2.0); 
    if (error) {
        Serial.print(F("Error trying to execute setTemperatureOffset(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

#ifdef USE_POWER_DOWN 
    error = sensor.persistSettings(); 
    if (error) {
        Serial.print(F("Error trying to execute persistSettings(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif
}

void loop() {
    uint16_t co2Concentration = 0;
    float temperature = 0.0;
    float relativeHumidity = 0.0;

    Serial.println("Starting measurement");
    
#ifdef USE_POWER_DOWN 
    error = sensor.measureSingleShot();
    if (error) {
        Serial.print("Error trying to execute measureSingleShot(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif
    
    error = sensor.measureAndReadSingleShot(co2Concentration, temperature, relativeHumidity);
    if (error) {
        Serial.print("Error trying to execute measureAndReadSingleShot(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    Serial.print("CO2[ppm]: ");
    Serial.print(co2Concentration);

    Serial.print("\tTemperature[°C]: ");
    Serial.print(temperature, 1);

    Serial.print("\tHumidity[%RH]: ");
    Serial.println(relativeHumidity, 1);

    error = sensor.measureSingleShotRhtOnly();
    if (error) {
        Serial.print("Error trying to execute measureSingleShotRhtOnly(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
    delay(100);
    
    error = sensor.readMeasurement(co2Concentration, temperature, relativeHumidity);
    if (error) {
        Serial.print("Error trying to execute readMeasurement(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
    
    Serial.print("RH/T only:  ");

    Serial.print("\tTemperature[°C]: ");
    Serial.print(temperature, 1);

    Serial.print("\tHumidity[%RH]: ");
    Serial.println(relativeHumidity, 1);

    Serial.println("-----");

#ifdef USE_POWER_DOWN
    error = sensor.powerDown();
    if (error) {
        Serial.print("Error trying to execute powerDown(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif

    delay(SINGLE_SHOT_PAUSE);

#ifdef USE_POWER_DOWN
    error = sensor.wakeUp();
    if (error) {
        Serial.print("Error trying to execute wakeUp(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif }

void PrintUint64(uint64_t& value) {
    Serial.print("0x");
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

 

Here is the output:

Output of scd4x_single_shot.ino

Explanation of the code

Idle vs. power down

Between measurements, the SCD4x switches to an energy-saving idle mode. Alternatively, you can switch the SCD4x off completely with powerDown(). If you do this, you must switch it on again with wakeUp() before performing a new measurement. In the sketch above, you can switch between the options by commenting or uncommenting #define USE_POWER_DOWN.  

The setTemperatureOffset(2.0) function sets the temperature offset to 2 degrees. Settings are always saved to the RAM of the SCD4x and are lost if you switch the SCD4x off with powerDown(). Either make the settings again after the next wakeUp() or save the settings permanently in the SCD4x EEPROM with persistSettings(). In the example, I have opted for the latter option.

After a powerDown() the first measured value should be discarded. To do this, use the function measureSingleShot(). It initiates a measurement and blocks the program for 5 seconds until the measurement is completed.  

Important note: If you are working with the power-down state, automatic self-calibration (ASC) is not possible. If you use ASC and no power-down, and your single shot period deviates from 5 minutes, then you must change the periods for the initial and standard calibration. I will come back to this.  

Regular single shot measurement

Use measureAndReadSingleShot() to initiate a measurement and read out the results. The function blocks the program for (another) 5 seconds. The SCD4x then goes into idle mode or you can switch it off with powerDown().

RH / T measurements

In single shot mode, you only query the temperature and relative humidity with measureSingleShotRhtOnly(). This takes approx. 50 milliseconds. The query in the example, directly after the complete measurement, does not make much sense and is for illustrative purposes only.  

Special features of single shot measurements

During the pauses, the current consumption in the idle state drops to approx. 0.6 milliamperes (@ 5V / with LED) or 0.18 milliamperes (@ 5V / without LED). In the power-down state, I measured 2.5 microamperes (@ 5V / without LED).  

If you take a measurement every 5 minutes, you will achieve an average power consumption of 0.36 milliamperes (without LED) according to the data sheet. This means that battery operation with acceptable runtimes is also possible. In the appendix you will find two examples of how you can combine the single shot measurements with the deep sleep mode of AVR or ESP32 based boards.

The fluctuation of the measured values is higher in single shot mode than in low power or periodic mode. There can be outliers of +/- 30 or even 40 ppm.  

Calibration of the SCD4x modules

Automatic self-calibration (ASC)

The ASC has two phases, namely the initial calibration and the subsequent standard calibrations. The initial calibration is active in the first 44 hours of operation. After this, the standard calibration takes effect for a periods of 156 hours. In principle, the SCD4x takes the lowest measured value within the period and assumes that this represents a CO₂ concentration of 400 ppm. However, this means that you have to ventilate well in between.

Unfortunately, the average value of 400 ppm is already history. We are currently more likely to be between 420 and 430 ppm (see here). There are also seasonal and daily fluctuations. It also makes a difference whether you live in the middle of the forest or in a big city.

Customization of the ASC

You can change the ASC target value of 400 ppm with the function setAutomaticSelfCalibrationTarget(ascTarget). The value is queried with getAutomaticSelfCalibrationTarget().  

You can change the period for the initial and standard calibration with setAutomaticSelfCalibrationInitialPeriod(ascInitialPeriod) and setAutomaticSelfCalibrationStandardPeriod(ascStandardPeriod) respectively. The parameters to be passed are the hours. The values must be a whole multiple of 4. Corresponding get functions are available to query these settings.

Adjusting the ASC in single shot mode

The ASC periods for the initial and standard calibrations are set to 48 and 168 hours respectively in single shot mode. However, this only applies if the period for the single shot measurements is 5 minutes. If you change the single shot period and want to retain the ASC periods, you must scale the ASC parameters accordingly:  

    \[ \text{ascInitialParam}_{\text{mod}} = \frac{48\cdot5}{\text{SingleShotPeriodInMinutes}} \]

Or:

    \[ \text{ascStandardParam}_{\text{mod}} = \frac{168\cdot5}{\text{SingleShotPeriodInMinutes}} \]

For example, if you perform a single shot measurement every 3 minutes, the parameter for the standard calibration would be 168*5/3 = 280. You must round the value to a multiple of 4 if necessary. You would then set the value with setAutomaticSelfCalibrationStandardPeriod(280).  

More information on low power measurements can be found in the document SCD4x Low Power Operation from Sensirion.

And once again as a reminder: there is no ASC when using power down. 

Forced calibration (FRC – forced recalibration)

Alternatively, you can carry out a forced calibration (FRC) with performForcedRecalibration(CO2_CALIB_VALUE, frcCorr). The parameter CO2_CALIB_VALUE is the current CO₂ concentration in ppm to which you want to calibrate. The forced recalibration factor frcCorr is a conversion factor for the calibration. There is not much you can do with this value, except that a frcCorr of 0xFFFF indicates a failed calibration.  

Most users will not have the option of specifying a certain CO₂ concentration. To do this, you would either have to artificially create an appropriate atmosphere or you would need a device that measures the CO₂ concentration reliably and absolutely. What you could do, instead, is ventilate intensively and then assume a concentration of 400 ppm (or rather 430 ppm). In my experience, this is the best method for putting the sensors into operation.

For a good calibration, the SCD4x sensor must have been active in periodic measurement mode for at least three minutes. Here is a sketch as a suggestion:

#include <SensirionI2cScd4x.h>
#include <Wire.h>
#define CO2_CALIB_VALUE 400

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

void setup() {
    Serial.begin(115200);
    Wire.begin();
    sensor.begin(Wire, SCD41_I2C_ADDR_62);
    uint64_t serialNumber = 0;
    delay(30);

    sensor.wakeUp();
    sensor.stopPeriodicMeasurement();
    sensor.reinit();
    sensor.getSerialNumber(serialNumber);
    if (error) {
        Serial.print("Error trying to execute getSerialNumber(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    Serial.print("SCD4x connected, serial number: ");
    PrintUint64(serialNumber);
    Serial.println();

    sensor.startPeriodicMeasurement();
    Serial.println("Forced calibration - warm up phase");
    unsigned int i = 300; // warm up period in seconds
    
    while (i>0) {
        Serial.print(F("Remaining time [s]: "));
        Serial.println(i);
        i -= 5; 
        delay(5000);
    }

    sensor.stopPeriodicMeasurement();
    uint16_t frcCorr = 0;
    error = sensor.performForcedRecalibration(CO2_CALIB_VALUE, frcCorr);
    if (error) {
        Serial.print("Error trying to execute performForcedRecalibration(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    else {
        Serial.print("FRC Value: 0x");
        Serial.println(frcCorr, HEX);
    }

    sensor.startPeriodicMeasurement();
}

void loop() {
    bool dataReady = false;
    uint16_t co2Concentration = 0;
    float temperature = 0.0;
    float relativeHumidity = 0.0;

    error = sensor.getDataReadyStatus(dataReady);
    if (error) {
        Serial.print("Error trying to execute getDataReadyStatus(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }

    if (dataReady) {
        sensor.readMeasurement(co2Concentration, temperature, relativeHumidity);
  
        Serial.println();
        Serial.print("CO2[ppm]: ");
        Serial.print(co2Concentration);

        Serial.print("\tTemperature[°C]: ");
        Serial.print(temperature, 1);

        Serial.print("\tHumidity[%RH]: ");
        Serial.print(relativeHumidity, 1);

        Serial.println();
    }
    else
        Serial.print(".");

    delay(500);
}

void PrintUint64(uint64_t& value) {
    Serial.print("0x");
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

 

Here is an excerpt from the output:

SCD4x - Output of scd4x_forced_calibration.ino
Output of scd4x_forced_calibration.ino

Further corrections and settings

The following corrections are saved to the RAM of the SCD4x by default, i.e. settings are deleted after a restart. Use persistSettings() to copy the data from the RAM to the EEPROM of the SCD4x module.  

To make corrections, you must stop the periodic measurements. One exception is the ambient pressure setting.  

Below you will find a sketch that queries the settings. 

Correction of the altitude or pressure

Atmospheric pressure influences the measurement of the CO₂ concentration. The atmospheric pressure fluctuates around a mean value depending on the weather. This mean value in turn depends on the altitude. The default setting is 0 meters. You can change the setting using setSensorAltitude(sensorAltitude). Here sensorAltitude is the altitude in meters. Use getSensorAltitude() to query the setting.

Nevertheless, there is still the weather-related fluctuation. If you have an air pressure sensor, you can overwrite the average value with the actual pressure. The function setAmbientPressure(pressure) is used for this purpose. Where pressure is the pressure in Pascal (= mbar * 100). It makes sense that this function is one of the few that can also be executed during periodic measurements. The function getAmbientPressure() returns the current setting. However, the return value is 0 if you have not yet defined a pressure (or this is still a bug in the library?).  

Temperature offset

The SCD4x modules become warm during operation. The measured temperatures are therefore reduced by an offset so that they correspond to the ambient temperature. The standard offset is 4 °C. With this setting, the temperatures in my measurements in periodic mode were approx. 0 to 1 °C too low. In low-power mode and single-shot mode, the temperatures were approx. 2 – 3 °C too low when using the standard offset.

The way your module is installed also affects the offset. There is no one-fits-all recommendation. Just try it out and adjust the value.  

Use the function setTemperatureOffset(offset) to set the offset. offset is of data type float. Use getTemperatureOffset() to query the offset. To set the offset, the periodic measurements must be stopped.

The temperature offset does not affect the output values for the CO₂ concentration, but it does affect the output values for the relative humidity. This makes sense, as the warmer air in the measuring cell has a lower relative humidity. To calculate the CO₂ concentration, the SCD4x uses the raw temperature and humidity values, as these are the conditions in the measuring cell.

Factory reset

If you have tweaked the settings too much and want to restore everything to its original state, the performFactoryReset() function will do it for you. 

Query the settings

The following sketch queries the current settings and performs a self-test with performSelfTest()

#include <SensirionI2cScd4x.h>
#include <Wire.h>

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

uint16_t sensorVariant;
float tOffset = 3.0;
uint16_t tOffsetRaw = 9999;
uint32_t ambientPressure = 9999;
uint16_t sensorAltitude = 9999;
uint16_t ascEnabled = 9999; 
uint16_t ascTarget = 9999;
uint16_t ascInitialPeriod = 9999;
uint16_t ascStandardPeriod = 9999;
uint16_t sensorStatus = 9999;

void setup() {
    Serial.begin(115200);
    Wire.begin();
    sensor.begin(Wire, SCD41_I2C_ADDR_62);
    uint64_t serialNumber = 0;
    delay(30);

    sensor.wakeUp();
    sensor.stopPeriodicMeasurement();
    sensor.reinit();
    sensor.getSerialNumber(serialNumber);
    if (error) {
        Serial.print(F("Error trying to execute getSerialNumber(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    Serial.print(F("SCD4x connected, serial number: "));
    PrintUint64(serialNumber);
    Serial.println();

    sensor.getSensorVariantRaw(sensorVariant);
    Serial.print(F("Sensor Type: 0x"));
    Serial.println(sensorVariant, HEX);
    Serial.print(F("I am an SCD4"));
    Serial.println((sensorVariant & 0x1000) >> 12);
    Serial.println();

    sensor.getTemperatureOffsetRaw(tOffsetRaw);
    Serial.print(F("Temperature Offset [°C]: "));
    Serial.println(tOffsetRaw/65535.0 * 175.0, 1);

    // sensor.setAmbientPressure(101200); // uncomment to test
    error = sensor.getAmbientPressure(ambientPressure);
    Serial.print(F("Ambient pressure   [Pa]: "));
    Serial.println(ambientPressure);
    
    // sensor.setSensorAltitude(500); // uncomment to test
    sensor.getSensorAltitude(sensorAltitude);
    Serial.print(F("Sensor Altitude     [m]: "));
    Serial.println(sensorAltitude);
    Serial.println();

    Serial.println(F("Automatic self calibration (ASC):"));
    sensor.getAutomaticSelfCalibrationEnabled(ascEnabled);
    Serial.print(F(" - enabled?           : "));
    if(ascEnabled == 1) {
        Serial.println(F("yes"));
    }
    else Serial.println(F("no")); 

    sensor.getAutomaticSelfCalibrationTarget(ascTarget);
    Serial.print(F(" - target     [ppmCO2]: "));
    Serial.println(ascTarget);

    sensor.getAutomaticSelfCalibrationInitialPeriod(ascInitialPeriod);
    Serial.print(F(" - initial period  [h]: "));
    Serial.println(ascInitialPeriod);

    sensor.getAutomaticSelfCalibrationStandardPeriod(ascStandardPeriod);
    Serial.print(F(" - standard period [h]: "));
    Serial.println(ascStandardPeriod);
    Serial.println();

    Serial.print(F("Performing self test... "));
    sensor.performSelfTest(sensorStatus); 
    Serial.print(F("Result: "));
    if(sensorStatus == 0) {
        Serial.println(F("OK"));
    }
    else {
        Serial.print(F("not OK - status is "));
        Serial.println(sensorStatus);  
    }
}

void loop() {}

void PrintUint64(uint64_t& value) {
    Serial.print(F("0x"));
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

 

Output of scd4x_get_settings_and_self_test.ino
Output of scd4x_get_settings_and_self_test.ino

Tests and comparisons

SCD41 vs. Technoline WL 1030

I tested one of the SCD41 sensors against a commercially available CO₂ measuring device, the Technoline WL 1030. The Technoline WL 1030 was the test winner of “Stiftung Warentest” in 2021. The manufacturer claims an accuracy of +/- 5% +/- 50 ppm in the range 400 – 5000 ppm.

I carried out the test in a self-made box, which was also used in my article about the MH-Z14 and MH-Z19 sensors. I have laid the electrical connections through the wall. An electric motor with propeller was used for mixing gas inside. I took the CO₂ from my soda streamer and transferred it into the chamber through a small hole using a syringe. You can find more details in the article just mentioned.  

SCD4x test: Technoline WL 1030 vs. SCD41
Test: Technoline WL 1030 vs. SCD41

I freshly calibrated the Technoline device and the SCD41 module against 400 ppm CO₂ in order to have reasonably comparable initial values. I then added 1 milliliter of CO₂ to the chamber at a time, waited 3-5 minutes and noted the measured values. I stopped after 6 milliliters and initially left the chamber closed (until reading 8, see below), then later left it open for a longer period of time.  

Technoline WL 1030 vs. SCD41
Test results: Technoline WL 1030 vs. SCD41 vs. calculated.

Repeating the test with another SCD41 module produced a similar result.

Evaluation and interpretation

I had estimated that adding 1 milliliter of pure CO₂ would increase the CO₂ concentration in the chamber by approx. 308 ppm. This is the gray straight line (“Calculated”). The measured values initially correspond well, but then begin to deviate. However, this is not due to the measuring devices, but to my inability to get the chamber really airtight. I had already shown this in the article about the MH-Z14 and MH-Z19 modules by adding a larger amount of CO₂ in one step.  

From this I conclude:

  1. The measured values of the Technoline WL 1030 and the SCD41 make sense in terms of their absolute size. 
  2. The measuring devices show good agreement. The deviations in this test were <= 40 ppm.

Further tests confirmed that the measured values of the SCD41 tended to be slightly higher than those of the Technoline device. I tested two modules in parallel with the Technoline device for one or two weeks, so that automatic calibrations also took effect. The measured values always matched up well.  

SCD4x long-term test

I would therefore say that the measured values of the SCD41 modules can be trusted, and they should fully meet their specified accuracy, as far as I can judge with the means at my disposal.  

If you are carrying out comparative tests yourself, then I would advise calibrating the modules in parallel or letting them run for a few days until the ASC takes effect. Otherwise, the values can differ by 100 ppm or more.  

SCD41 vs. SCD40

To test the SCD40, I calibrated such a module in parallel with an SCD41 module to 400 ppm CO₂ and let it run in my study. The values developed as follows over the afternoon:

SCD4x practical test: SCD40 vs. SCD41
SCD4x practical test: SCD40 vs. SCD41

Here, the measured values of the SCD40 were once again higher than those of the SCD41 module. This trend was confirmed in measurements over several days. Sometimes the SCD40 value was almost 100 ppm higher, but after a few minutes the values returned to normal.

Without parallel calibration, the SCD40 and SCD41 sometimes differed by 150 ppm in the initial phase.  

However, my data set for the SCD40 is less solid overall, as I have only one module and have tested it less than the SCD41 modules. But the tendency seemed to confirm that the SCD40 module measures a little less accurately. As the price difference is not that great, I would advise going for the SCD41.  

MH-Z14/19 vs. SCD4x

So which is better, the MH-Z14/19 modules or the SCD4x modules? In terms of price, the modules are not much different if you buy from the Far East, for example from AliExpress. They also all deliver measured values that seem to be reliable. However, there are a few areas where the SCD4x modules come out on top:

  • More setting options
  • Smaller dimensions
  • Better documentation, less black box  
  • Lower power consumption in normal mode + low power modes

If you have an MH-Z14 or MH-Z19 and are happy with it, I wouldn’t say you absolutely have to get an SCD40 or SCD41. But if you are about to buy a new one, I would recommend an SCD4x, especially the SCD41.

Appendix – Single-shot measurements with deep sleep pauses

Here are two sketches to illustrate how you can use certain combinations of microcontrollers and the SCD41 module to save energy. To do this, you send the microcontrollers into deep sleep. Some microcontrollers, such as the good old AVR ones, continue where they left off after waking up. Others, such as the ESP32, start with a reset after waking up. There are also sleep modes after which an ESP32 does not perform a reset, but these are significantly less energy efficient.  

With the sketches, I only want to illustrate the principle. For a battery-operated project, you would certainly not output the data on the serial monitor, but on an energy-saving display.  

Deep sleep with the classic AVR-based Arduino boards

I have written the following sketch for ATmega328P based boards, such as the Arduino UNO R3, the classic Arduino Nano or the Arduino Pro Mini. Or you can use the “naked” ATmega328P.

I use the watchdog timer (WDT) to wake up from deep sleep. The other timers do not work for this purpose as they are switched off in deep sleep. The maximum period for the WDT interrupt is 8 seconds. A counter is incremented by 1 with each wake-up. If the counter is less than 37, the ATmega328P goes back to sleep. The short wake-up is hardly significant in terms of energy. The measurement is carried out at the 37th wake-up. 37 x 8 equals 296 seconds, i.e. approximately five minutes.  

In the power-down state, the SCD41 forgets its settings. If you use power down and make settings in the setup, you must set the settings again after waking up, or you can write them to the EEPROM of the SCD41.

#include <Wire.h>
#include <SensirionI2cScd4x.h>
#include <avr/wdt.h>
#include <avr/sleep.h>
#define WDT_WAKE_UPS 37 // = ~5min (WDT_WAKE_UPS * 8s = Single Shot Pause) 
//#define USE_POWER_DOWN

volatile unsigned int wdtCounter = 0;

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

void setup() {
    ADCSRA = 0; // ADC off (would consume current in deep sleep)
    Serial.begin(115200);
    Wire.begin();
    uint64_t serialNumber = 0;
    sensor.begin(Wire, SCD41_I2C_ADDR_62);
    delay(30);

    sensor.wakeUp();
    if (error) {
        Serial.print(F("Error trying to execute wakeUp(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.stopPeriodicMeasurement();
    if (error) {
        Serial.print(F("Error trying to execute stopPeriodicMeasurement(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.reinit();
    if (error) {
        Serial.print(F("Error trying to execute reinit(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    error = sensor.getSerialNumber(serialNumber);
    if (error) {
        Serial.print(F("Error trying to execute getSerialNumber(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
        return;
    }
    Serial.print(F("SCD4x connected, serial number: "));
    PrintUint64(serialNumber);
    Serial.println();

    error = sensor.setTemperatureOffset(2.0); 
    if (error) {
        Serial.print(F("Error trying to execute setTemperatureOffset(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
    Serial.println();

#ifdef USE_POWER_DOWN 
    error = sensor.persistSettings(); 
    if (error) {
        Serial.print(F("Error trying to execute persistSettings(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif

    watchdogSetup();
}

void loop() {
    uint16_t co2Concentration = 0;
    float temperature = 0.0;
    float relativeHumidity = 0.0;

#ifdef USE_POWER_DOWN 
    error = sensor.measureSingleShot();
    if (error) {
        Serial.print("Error trying to execute measureSingleShot(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif

    error = sensor.measureAndReadSingleShot(co2Concentration, temperature, relativeHumidity);
    if (error) {
        Serial.print(F("Error trying to execute measureAndReadSingleShot(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    Serial.print(F("CO2[ppm]: "));
    Serial.print(co2Concentration);

    Serial.print(F("\tTemperature[°C]: "));
    Serial.print(temperature, 1);

    Serial.print(F("\tHumidity[%RH]: "));
    Serial.println(relativeHumidity, 1);
    Serial.println(F("-----"));
    Serial.flush();

#ifdef USE_POWER_DOWN
    error = sensor.powerDown();
    if (error) {
        Serial.print("Error trying to execute powerDown(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif

    while(wdtCounter < WDT_WAKE_UPS){
        set_sleep_mode(SLEEP_MODE_PWR_DOWN); // chose power down modus
        sleep_mode(); // sleep now!
    }
    wdtCounter = 0;
    sleep_disable(); // disable sleep after wake up
    
#ifdef USE_POWER_DOWN
    error = sensor.wakeUp();
    if (error) {
        Serial.print("Error trying to execute wakeUp(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif
    
#ifdef USE_POWER_DOWN
    Serial.println(F("I woke up! Wait 10s for a result"));
#else
    Serial.println(F("I woke up! Wait 5s for a result"));
#endif
}

void watchdogSetup(void){
    cli();
    wdt_reset();
    WDTCSR |= (1<<WDCE) | (1<<WDE);
    WDTCSR = (1<<WDIE) | (0<<WDE) | (1<<WDP3) | (1<<WDP0);  // 8s / interrupt, no system reset
    sei();
}

void PrintUint64(uint64_t& value) {
    Serial.print(F("0x"));
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

ISR(WDT_vect){ 
  wdtCounter++;
}

 

And this is what the output looks like: 

Output of scd4x_single_shot_with_avr_wdt.ino
Output of scd4x_single_shot_with_avr_wdt.ino

It should also be noted that the first measured value should be discarded after the power down of the SCD41. This means that the measurement time is 10 seconds instead of 5, so you have to calculate whether the additional power saving during the power down phase is worthwhile at all.

Also, even if I repeat myself, the automatic self-calibration (ASC) does not work when using power down.

And one more repetition: If you use the ASC and the sleep phase deviates from 5 minutes, then you must adjust the periods for the initial and standard calibration.   

Deep sleep with the ESP32

We can wake up the ESP32 using the timer. We specify the sleep time in microseconds.  

When the ESP32 wakes up from deep sleep, it runs through setup() again. There are a few instructions there that only serve to create a clean, defined state for the first start. However, we do not need this after the subsequent resets. This is why the following sketch distinguishes between the first start and subsequent resets. And here, too, the settings are saved in the first run if power down is activated.  

#include <Wire.h>
#include <SensirionI2cScd4x.h>
#define SLEEP_TIME 300000000 // = 300s = 5min
#define USE_POWER_DOWN

SensirionI2cScd4x sensor;

static char errorMessage[64];
static int16_t error;

void setup() {
    uint16_t co2Concentration = 0;
    float temperature = 0.0;
    float relativeHumidity = 0.0;

    Serial.begin(115200);
    Wire.begin();
    delay(1000);
    esp_sleep_wakeup_cause_t wakeupReason;
    wakeupReason = esp_sleep_get_wakeup_cause();
   
    sensor.begin(Wire, SCD41_I2C_ADDR_62);
    delay(30);

    if(!(wakeupReason==ESP_SLEEP_WAKEUP_TIMER)) { // initial setup
        uint64_t serialNumber = 0;
        error = sensor.wakeUp();
        if (error) {
            Serial.print(F("Error trying to execute wakeUp(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
        }

        error = sensor.stopPeriodicMeasurement();
        if (error) {
            Serial.print(F("Error trying to execute stopPeriodicMeasurement(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
        }

        error = sensor.reinit();
        if (error) {
            Serial.print(F("Error trying to execute reinit(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
        }

        error = sensor.getSerialNumber(serialNumber);
        if (error) {
            Serial.print(F("Error trying to execute getSerialNumber(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
            return;
        }
        Serial.print(F("SCD4x connected, serial number: "));
        PrintUint64(serialNumber);
        Serial.println();

        error = sensor.setTemperatureOffset(1.0); 
        if (error) {
            Serial.print(F("Error trying to execute setTemperatureOffset(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
        }

#ifdef USE_POWER_DOWN 
        error = sensor.persistSettings(); 
        if (error) {
            Serial.print(F("Error trying to execute persistSettings(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
        }
#endif

    } // end of initial setup
    else 
#ifdef USE_POWER_DOWN 
        error = sensor.wakeUp();
        if (error) {
            Serial.print(F("Error trying to execute wakeUp(): "));
            errorToString(error, errorMessage, sizeof errorMessage);
            Serial.println(errorMessage);
        }
        Serial.println(F("I woke up! Wait 10s for a result"));
#else 
        Serial.println(F("I woke up! Wait 5s for a result"));
#endif
  
#ifdef USE_POWER_DOWN 
    error = sensor.measureSingleShot();
    if (error) {
        Serial.print(F("Error trying to execute measureSingleShot(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif
    
    error = sensor.measureAndReadSingleShot(co2Concentration, temperature, relativeHumidity);
    if (error) {
        Serial.print(F("Error trying to execute measureAndReadSingleShot(): "));
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }

    Serial.print(F("CO2[ppm]: "));
    Serial.print(co2Concentration);

    Serial.print(F("\tTemperature[°C]: "));
    Serial.print(temperature, 1);

    Serial.print(F("\tHumidity[%RH]: "));
    Serial.println(relativeHumidity, 1);
    Serial.println(F("-----"));
    Serial.flush();    

#ifdef USE_POWER_DOWN
    error = sensor.powerDown();
    if (error) {
        Serial.print("Error trying to execute powerDown(): ");
        errorToString(error, errorMessage, sizeof errorMessage);
        Serial.println(errorMessage);
    }
#endif

    esp_sleep_enable_timer_wakeup(SLEEP_TIME);
    esp_deep_sleep_start();
}

void loop(){}

void PrintUint64(uint64_t& value) {
    Serial.print(F("0x"));
    Serial.print((uint32_t)(value >> 32), HEX);
    Serial.print((uint32_t)(value & 0xFFFFFFFF), HEX);
}

Leave a Reply

Your email address will not be published. Required fields are marked *