Merge pull request #259 from airgradienthq/feat/pm-correction

Low Readings correction for Plantower PMS5003
This commit is contained in:
Samuel Siburian
2024-11-03 14:08:51 +07:00
committed by GitHub
12 changed files with 350 additions and 29 deletions

View File

@ -73,16 +73,17 @@ You get the following response:
| `noxIndex` | Number | Senisirion NOx Index |
| `noxRaw` | Number | NOx raw value |
| `boot` | Number | Counts every measurement cycle. Low boot counts indicate restarts. |
| `bootCount` | Number | Same as boot property. Required for Home Assistant compatability. Will be depreciated. |
| `bootCount` | Number | Same as boot property. Required for Home Assistant compatability. (deprecated soon!) |
| `ledMode` | String | Current configuration of the LED mode |
| `firmware` | String | Current firmware version |
| `model` | String | Current model name |
| `monitorDisplayCompensatedValues` | Boolean | Switching Display of AirGradient ONE to Compensated / Non Compensated Values |
Compensated values apply correction algorithms to make the sensor values more accurate. Temperature and relative humidity correction is only applied on the outdoor monitor Open Air but the properties _compensated will still be send also for the indoor monitor AirGradient ONE.
#### Get Configuration Parameters (GET)
With the path "/config" you can get the current configuration.
"/config" path returns the current configuration of the monitor.
```json
{
"country": "TH",
@ -99,28 +100,40 @@ With the path "/config" you can get the current configuration.
"displayBrightness": 100,
"offlineMode": false,
"model": "I-9PSL",
"monitorDisplayCompensatedValues": true
"monitorDisplayCompensatedValues": true,
"corrections": {
"pm02": {
"correctionAlgorithm": "epa_2021",
"slr": {}
}
}
}
}
```
#### Set Configuration Parameters (PUT)
Configuration parameters can be changed with a put request to the monitor, e.g.
Configuration parameters can be changed with a PUT request to the monitor, e.g.
Example to force CO2 calibration
```curl -X PUT -H "Content-Type: application/json" -d '{"co2CalibrationRequested":true}' http://airgradient_84fce612eff4.local/config ```
```bash
curl -X PUT -H "Content-Type: application/json" -d '{"co2CalibrationRequested":true}' http://airgradient_84fce612eff4.local/config
```
Example to set monitor to Celsius
```curl -X PUT -H "Content-Type: application/json" -d '{"temperatureUnit":"c"}' http://airgradient_84fce612eff4.local/config ```
```bash
curl -X PUT -H "Content-Type: application/json" -d '{"temperatureUnit":"c"}' http://airgradient_84fce612eff4.local/config
```
If you use command prompt on Windows, you need to escape the quotes:
``` -d "{\"param\":\"value\"}" ```
#### Avoiding Conflicts with Configuration on AirGradient Server
If the monitor is set up on the AirGradient dashboard, it will also receive configurations from there. In case you do not want this, please set `configurationControl` to `local`. In case you set it to `cloud` and want to change it to `local`, you need to make a factory reset.
If the monitor is set up on the AirGradient dashboard, it will also receive the configuration parameters from there. In case you do not want this, please set `configurationControl` to `local`. In case you set it to `cloud` and want to change it to `local`, you need to make a factory reset.
#### Configuration Parameters (GET/PUT)
@ -142,4 +155,47 @@ If the monitor is set up on the AirGradient dashboard, it will also receive conf
| `noxLearningOffset` | Set NOx learning gain offset. | Number | 0-720 (default 12) | `{"noxLearningOffset": 12}` |
| `tvocLearningOffset` | Set VOC learning gain offset. | Number | 0-720 (default 12) | `{"tvocLearningOffset": 12}` |
| `offlineMode` | Set monitor to run without WiFi. | Boolean | `false`: Disabled (default) <br> `true`: Enabled | `{"offlineMode": true}` |
| `monitorDisplayCompensatedValues` | Set the display show the PM value with/without compensate value (From [3.1.9]()) | Boolean | `false`: Without compensate (default) <br> `true`: with compensate | `{"monitorDisplayCompensatedValues": false }` |
| `monitorDisplayCompensatedValues` | Set the display show the PM value with/without compensate value (only on [3.1.9]()) | Boolean | `false`: Without compensate (default) <br> `true`: with compensate | `{"monitorDisplayCompensatedValues": false }` |
| `corrections` | Sets correction options to display and measurement values on local server response. | Object | _see corretions section_ | _see corretions section_ |
#### Corrections
The `corrections` object allows configuring PM2.5 correction algorithms and parameters. This affects both the display and local server response values.
Example correction configuration:
```json
{
"corrections": {
"pm02": {
"correctionAlgorithm": "<Option In String>",
"slr": {
"intercept": 0,
"scalingFactor": 0,
"useEpa2021": false
}
}
}
}
```
| Algorithm | Value | Description | SLR required |
|------------|-------------|------|---------|
| Raw | `"none"` | No correction (default) | No |
| EPA 2021 | `"epa_2021"` | Use EPA 2021 correction factors on top of raw value | No |
| PMS5003_20240104 | `"slr_PMS5003_20240104"` | Correction for PMS5003 sensor batch 20240104| Yes |
| PMS5003_20231218 | `"slr_PMS5003_20231218"` | Correction for PMS5003 sensor batch 20231218| Yes |
| PMS5003_20231030 | `"slr_PMS5003_20231030"` | Correction for PMS5003 sensor batch 20231030| Yes |
**Notes**:
- Set `useEpa2021` to true if want to apply EPA 2021 correction factors on top of SLR correction value.
- `intercept` and `scalingFactor` values can be obtained from [this article](https://www.airgradient.com/blog/low-readings-from-pms5003/)
**Example**:
```bash
curl --location -X PUT 'http://airgradient_84fce612eff4.local/config' --header 'Content-Type: application/json' --data '{"corrections":{"pm02":{"correctionAlgorithm":"slr_PMS5003_20231030","slr":{"intercept":0,"scalingFactor":0.02838,"useEpa2021":false}}}}'
```

View File

@ -1,5 +1,4 @@
#include "AgConfigure.h"
#include "Libraries/Arduino_JSON/src/Arduino_JSON.h"
#if ESP32
#include "FS.h"
#include "SPIFFS.h"
@ -22,6 +21,18 @@ const char *LED_BAR_MODE_NAMES[] = {
[LedBarModeCO2] = "co2",
};
const char *PM_CORRECTION_ALGORITHM_NAMES[] = {
[Unknown] = "-", // This is only to pass "non-trivial designated initializers" error
[None] = "none",
[EPA_2021] = "epa_2021",
[SLR_PMS5003_20220802] = "slr_PMS5003_20220802",
[SLR_PMS5003_20220803] = "slr_PMS5003_20220803",
[SLR_PMS5003_20220824] = "slr_PMS5003_20220824",
[SLR_PMS5003_20231030] = "slr_PMS5003_20231030",
[SLR_PMS5003_20231218] = "slr_PMS5003_20231218",
[SLR_PMS5003_20240104] = "slr_PMS5003_20240104",
};
#define JSON_PROP_NAME(name) jprop_##name
#define JSON_PROP_DEF(name) const char *JSON_PROP_NAME(name) = #name
@ -42,6 +53,7 @@ JSON_PROP_DEF(co2CalibrationRequested);
JSON_PROP_DEF(ledBarTestRequested);
JSON_PROP_DEF(offlineMode);
JSON_PROP_DEF(monitorDisplayCompensatedValues);
JSON_PROP_DEF(corrections);
#define jprop_model_default ""
#define jprop_country_default "TH"
@ -87,6 +99,112 @@ String Configuration::getLedBarModeName(LedBarMode mode) {
return String("unknown");
}
PMCorrectionAlgorithm Configuration::matchPmAlgorithm(String algorithm) {
// Loop through all algorithm names in the PM_CORRECTION_ALGORITHM_NAMES array
// If the input string matches an algorithm name, return the corresponding enum value
// Else return Unknown
const size_t enumSize = SLR_PMS5003_20240104 + 1; // Get the actual size of the enum
PMCorrectionAlgorithm result = PMCorrectionAlgorithm::Unknown;
// Loop through enum values
for (size_t enumVal = 0; enumVal < enumSize; enumVal++) {
if (algorithm == PM_CORRECTION_ALGORITHM_NAMES[enumVal]) {
result = static_cast<PMCorrectionAlgorithm>(enumVal);
}
}
return result;
}
bool Configuration::updatePmCorrection(JSONVar &json) {
if (!json.hasOwnProperty("corrections")) {
// TODO: need to response message?
Serial.println("corrections not found");
return false;
}
JSONVar corrections = json["corrections"];
if (!corrections.hasOwnProperty("pm02")) {
Serial.println("pm02 not found");
return false;
}
JSONVar pm02 = corrections["pm02"];
if (!pm02.hasOwnProperty("correctionAlgorithm")) {
Serial.println("correctionAlgorithm not found");
return false;
}
// TODO: Need to have data type check, with error message response if invalid
// Check algorithm
String algorithm = pm02["correctionAlgorithm"];
PMCorrectionAlgorithm algo = matchPmAlgorithm(algorithm);
if (algo == Unknown) {
logInfo("Unknown algorithm");
return false;
}
logInfo("Correction algorithm: " + algorithm);
// If algo is None or EPA_2021, no need to check slr
// But first check if pmCorrection different from algo
if (algo == None || algo == EPA_2021) {
if (pmCorrection.algorithm != algo) {
// Deep copy corrections from root to jconfig, so it will be saved later
jconfig[jprop_corrections]["pm02"]["correctionAlgorithm"] = algorithm;
jconfig[jprop_corrections]["pm02"]["slr"] = JSON.parse("{}"); // Clear slr
// Update pmCorrection with new values
pmCorrection.algorithm = algo;
pmCorrection.changed = true;
logInfo("PM2.5 correction updated");
return true;
}
return false;
}
// Check if pm02 has slr object
if (!pm02.hasOwnProperty("slr")) {
Serial.println("slr not found");
return false;
}
JSONVar slr = pm02["slr"];
// Validate required slr properties exist
if (!slr.hasOwnProperty("intercept") || !slr.hasOwnProperty("scalingFactor") ||
!slr.hasOwnProperty("useEpa2021")) {
Serial.println("Missing required slr properties");
return false;
}
// arduino_json doesn't support float type, need to cast to double first
float intercept = (float)((double)slr["intercept"]);
float scalingFactor = (float)((double)slr["scalingFactor"]);
// Compare with current pmCorrection
if (pmCorrection.algorithm == algo && pmCorrection.intercept == intercept &&
pmCorrection.scalingFactor == scalingFactor &&
pmCorrection.useEPA == (bool)slr["useEpa2021"]) {
return false; // No changes needed
}
// Deep copy corrections from root to jconfig, so it will be saved later
jconfig[jprop_corrections] = corrections;
// Update pmCorrection with new values
pmCorrection.algorithm = algo;
pmCorrection.intercept = intercept;
pmCorrection.scalingFactor = scalingFactor;
pmCorrection.useEPA = (bool)slr["useEpa2021"];
pmCorrection.changed = true;
// Correction values were updated
logInfo("PM2.5 correction updated");
return true;
}
/**
* @brief Save configure to device storage (EEPROM)
*
@ -171,6 +289,13 @@ void Configuration::defaultConfig(void) {
jconfig[jprop_offlineMode] = jprop_offlineMode_default;
jconfig[jprop_monitorDisplayCompensatedValues] = jprop_monitorDisplayCompensatedValues_default;
// PM2.5 correction
pmCorrection.algorithm = None;
pmCorrection.changed = false;
pmCorrection.intercept = -1;
pmCorrection.scalingFactor = -1;
pmCorrection.useEPA = false;
saveConfig();
}
@ -660,20 +785,25 @@ bool Configuration::parse(String data, bool isLocal) {
if (curVer != newVer) {
logInfo("Detected new firmware version: " + newVer);
otaNewFirmwareVersion = newVer;
udpated = true;
updated = true;
} else {
otaNewFirmwareVersion = String("");
}
}
}
// Corrections
if (updatePmCorrection(root)) {
changed = true;
}
if (changed) {
udpated = true;
updated = true;
saveConfig();
printConfig();
} else {
if (ledBarTestRequested || co2CalibrationRequested) {
udpated = true;
updated = true;
}
}
return true;
@ -860,8 +990,8 @@ String Configuration::getModel(void) {
}
bool Configuration::isUpdated(void) {
bool updated = this->udpated;
this->udpated = false;
bool updated = this->updated;
this->updated = false;
return updated;
}
@ -1118,6 +1248,15 @@ void Configuration::toConfig(const char *buf) {
jprop_monitorDisplayCompensatedValues_default;
}
// Set default first before parsing local config
pmCorrection.algorithm = PMCorrectionAlgorithm::None;
pmCorrection.intercept = 0;
pmCorrection.scalingFactor = 0;
pmCorrection.useEPA = false;
// Load correction from saved config
updatePmCorrection(jconfig);
if (changed) {
saveConfig();
}
@ -1216,3 +1355,23 @@ String Configuration::newFirmwareVersion(void) {
otaNewFirmwareVersion = String("");
return newFw;
}
bool Configuration::isPMCorrectionChanged(void) {
bool changed = pmCorrection.changed;
pmCorrection.changed = false;
return changed;
}
/**
* @brief Check if PM correction is enabled
*
* @return true if PM correction algorithm is not None, otherwise false
*/
bool Configuration::isPMCorrectionEnabled(void) {
PMCorrection pmCorrection = getPMCorrection();
return pmCorrection.algorithm != PMCorrectionAlgorithm::None;
}
Configuration::PMCorrection Configuration::getPMCorrection(void) {
return pmCorrection;
}

View File

@ -5,12 +5,22 @@
#include "Main/PrintLog.h"
#include "AirGradient.h"
#include <Arduino.h>
#include "Libraries/Arduino_JSON/src/Arduino_JSON.h"
class Configuration : public PrintLog {
public:
struct PMCorrection {
PMCorrectionAlgorithm algorithm;
float intercept;
float scalingFactor;
bool useEPA; // EPA 2021
bool changed;
};
private:
bool co2CalibrationRequested;
bool ledBarTestRequested;
bool udpated;
bool updated;
String failedMessage;
bool _noxLearnOffsetChanged;
bool _tvocLearningOffsetChanged;
@ -19,10 +29,13 @@ private:
String otaNewFirmwareVersion;
bool _offlineMode = false;
bool _ledBarModeChanged = false;
PMCorrection pmCorrection;
AirGradient* ag;
String getLedBarModeName(LedBarMode mode);
PMCorrectionAlgorithm matchPmAlgorithm(String algorithm);
bool updatePmCorrection(JSONVar &json);
void saveConfig(void);
void loadConfig(void);
void defaultConfig(void);
@ -83,6 +96,9 @@ public:
void setOfflineModeWithoutSave(bool offline);
bool isLedBarModeChanged(void);
bool isMonitorDisplayCompensatedValues(void);
bool isPMCorrectionChanged(void);
bool isPMCorrectionEnabled(void);
PMCorrection getPMCorrection(void);
};
#endif /** _AG_CONFIG_H_ */

View File

@ -313,14 +313,12 @@ void OledDisplay::showDashboard(const char *status) {
DISP()->drawStr(55, 27, "PM2.5");
/** Draw PM2.5 value */
int pm25 = value.get(Measurements::PM25);
if (utils::isValidPm(pm25)) {
/** Compensate PM2.5 value. */
if (config.hasSensorSHT && config.isMonitorDisplayCompensatedValues()) {
pm25 = ag->pms5003.compensate(pm25, value.getFloat(Measurements::Humidity));
logInfo("PM2.5 compensate: " + String(pm25));
if (config.hasSensorSHT && config.isPMCorrectionEnabled()) {
pm25 = (int)value.getCorrectedPM25(*ag, config);
}
if (config.isPmStandardInUSAQI()) {
sprintf(strBuf, "%d", ag->pms5003.convertPm25ToUsAqi(pm25));
} else {
@ -379,8 +377,8 @@ void OledDisplay::showDashboard(const char *status) {
/** Set PM */
int pm25 = value.get(Measurements::PM25);
if (config.hasSensorSHT && config.isMonitorDisplayCompensatedValues()) {
pm25 = (int)ag->pms5003.compensate(pm25, value.getFloat(Measurements::Humidity));
if (config.hasSensorSHT && config.isPMCorrectionEnabled()) {
pm25 = (int)value.getCorrectedPM25(*ag, config);
}
ag->display.setCursor(0, 12);

View File

@ -142,8 +142,8 @@ void StateMachine::co2handleLeds(void) {
*/
void StateMachine::pm25handleLeds(void) {
int pm25Value = value.get(Measurements::PM25);
if (config.isMonitorDisplayCompensatedValues() && config.hasSensorSHT) {
pm25Value = ag->pms5003.compensate(pm25Value, value.getFloat(Measurements::Humidity));
if (config.hasSensorSHT && config.isPMCorrectionEnabled()) {
pm25Value = (int)value.getCorrectedPM25(*ag, config);
}
if (pm25Value < 5) {

View File

@ -1,6 +1,7 @@
#include "AgValue.h"
#include "AgConfigure.h"
#include "AirGradient.h"
#include "App/AppDef.h"
#define json_prop_pmFirmware "firmware"
#define json_prop_pm01Ae "pm01"
@ -482,6 +483,38 @@ void Measurements::validateChannel(int ch) {
}
}
float Measurements::getCorrectedPM25(AirGradient &ag, Configuration &config, bool useAvg, int ch) {
float pm25;
float humidity;
float pm003Count;
int channel = ch - 1; // Array index
if (useAvg) {
// Directly call from the index
pm25 = _pm_25[channel].update.avg;
humidity = _humidity[channel].update.avg;
pm003Count = _pm_03_pc[channel].update.avg;
} else {
pm25 = get(PM25, ch);
humidity = getFloat(Humidity, ch);
pm003Count = get(PM03_PC, ch);
}
Configuration::PMCorrection pmCorrection = config.getPMCorrection();
if (pmCorrection.algorithm == PMCorrectionAlgorithm::EPA_2021) {
// EPA correction directly applied
pm25 = ag.pms5003.compensate(pm25, humidity);
} else {
// SLR correction, this is assumes before calling this function, correction algorithm is not None
pm25 = ag.pms5003.slrCorrection(pm25, pm003Count, pmCorrection.scalingFactor, pmCorrection.intercept);
if (pmCorrection.useEPA) {
// Add EPA compensation on top of SLR
pm25 = ag.pms5003.compensate(pm25, humidity);
}
}
return pm25;
}
String Measurements::toString(bool localServer, AgFirmwareMode fwMode, int rssi, AirGradient &ag,
Configuration &config) {
JSONVar root;
@ -605,10 +638,9 @@ JSONVar Measurements::buildIndoor(bool localServer, AirGradient &ag, Configurati
// Add pm25 compensated value only if PM2.5 and humidity value is valid
if (config.hasSensorPMS1 && utils::isValidPm(_pm_25[0].update.avg)) {
if (config.hasSensorSHT && utils::isValidHumidity(_humidity[0].update.avg)) {
float pm25 = ag.pms5003.compensate(_pm_25[0].update.avg, _humidity[0].update.avg);
if (utils::isValidPm(pm25)) {
indoor[json_prop_pm25Compensated] = ag.round2(pm25);
}
// Correction using moving average value
float tmp = getCorrectedPM25(ag, config, true);
indoor[json_prop_pm25Compensated] = ag.round2(tmp);
}
}

View File

@ -114,6 +114,18 @@ public:
*/
float getFloat(MeasurementType type, int ch = 1);
/**
* @brief Get the Corrected PM25 object based on the correction algorithm from configuration
*
* @param ag AirGradient instance
* @param config Configuration instance
* @param useAvg Use moving average value if true, otherwise use latest value
* @param ch MeasurementType channel
* @return float Corrected PM2.5 value
*/
float getCorrectedPM25(AirGradient &ag, Configuration &config, bool useAvg = false, int ch = 1);
/**
* build json payload for every measurements
*/

View File

@ -94,6 +94,18 @@ enum ConfigurationControl {
ConfigurationControlBoth
};
enum PMCorrectionAlgorithm {
Unknown, // Unknown algorithm
None, // No PM correction
EPA_2021,
SLR_PMS5003_20220802,
SLR_PMS5003_20220803,
SLR_PMS5003_20220824,
SLR_PMS5003_20231030,
SLR_PMS5003_20231218,
SLR_PMS5003_20240104,
};
enum AgFirmwareMode {
FW_MODE_I_9PSL, /** ONE_INDOOR */
FW_MODE_O_1PST, /** PMS5003T, S8 and SGP41 */

View File

@ -314,6 +314,36 @@ int PMSBase::pm25ToAQI(int pm02) {
return 500;
}
/**
* @brief SLR correction for PM2.5
*
* Reference: https://www.airgradient.com/blog/low-readings-from-pms5003/
*
* @param pm25 PM2.5 raw value
* @param pm003Count PM0.3 count
* @param scalingFactor Scaling factor
* @param intercept Intercept
* @return float Calibrated PM2.5 value
*/
float PMSBase::slrCorrection(float pm25, float pm003Count, float scalingFactor, float intercept) {
float calibrated;
float lowCalibrated = (scalingFactor * pm003Count) + intercept;
if (lowCalibrated < 31) {
calibrated = lowCalibrated;
} else {
calibrated = pm25;
}
// No negative value for pm2.5
if (calibrated < 0) {
return 0.0;
}
return calibrated;
}
/**
* @brief Correction PM2.5
*

View File

@ -39,6 +39,7 @@ public:
uint8_t getErrorCode(void);
int pm25ToAQI(int pm02);
float slrCorrection(float pm25, float pm003Count, float scalingFactor, float intercept);
float compensate(float pm25, float humidity);
private:

View File

@ -172,6 +172,10 @@ int PMS5003::getPm10ParticleCount(void) { return pms.getCount10(); }
*/
int PMS5003::convertPm25ToUsAqi(int pm25) { return pms.pm25ToAQI(pm25); }
float PMS5003::slrCorrection(float pm25, float pm003Count, float scalingFactor, float intercept) {
return pms.slrCorrection(pm25, pm003Count, scalingFactor, intercept);
}
/**
* @brief Correct PM2.5
*

View File

@ -42,6 +42,7 @@ public:
int getPm10ParticleCount(void);
int convertPm25ToUsAqi(int pm25);
float slrCorrection(float pm25, float pm003Count, float scalingFactor, float intercept);
float compensate(float pm25, float humidity);
int getFirmwareVersion(void);
uint8_t getErrorCode(void);