How the Confidence Score Works
PurpleAir sensors report PM2.5 data through two channels, called Channel A and Channel B. The confidence score reflects how closely the two channels agree and whether either channel shows signs of abnormal behavior.
At a high level, the confidence calculation works as follows:
- A channel sum is calculated separately for Channel A and Channel B.
- An initial confidence score is calculated based on how similar those two sums are.
- The confidence score is then adjusted using automatic rules and any manual downgrades applied by PurpleAir staff.
The complete process is described below:
-
Calculate the channel sums
A channel sum is calculated independently for Channel A and Channel B.
Each sum is the total of all PM2.5 pseudo-averaged values for that channel (real-time, 10-minute, 30-minute, 60-minute, 6-hour, 24-hour, and 1-week pseudo-averages). -
Apply automatic downgrades
Each channel is checked to determine whether it should be automatically downgraded.
A channel is downgraded if either of the following conditions is true:- Its latest reported PM2.5 value (real-time reading) is greater than 2000.
- Its channel sum is more than 10× the other channel’s sum.
(Only the higher-reading channel is downgraded under this rule.)
-
Calculate the initial confidence score
The initial confidence score is calculated from the two channel sums using a modified form of Relative Percentage Difference, which determines how closely the channels match.The initial confidence score is then reduced when either channel’s latest real-time PM2.5 value exceeds 2000, with a larger reduction applied if both channels are above this threshold. This adjustment is not related to the automatic downgrade logic in the previous step.
-
Calculate downgraded confidence values
Two adjusted confidence values are created from the initial confidence—confidence auto and confidence manual—one for automatic downgrades and one for manual downgrades.- confidence_auto uses only automatic downgrades. The channels involved are indicated by channel_flags_auto, but this flag set does not represent the number of downgraded channels and cannot be used directly in the formula.
- confidence_manual uses only manual downgrades. The channels involved are indicated by channel_flags_manual, but this flag set also does not represent the number of downgraded channels and cannot be used directly in the formula.
Each downgraded channel reduces the corresponding score by 40%.
The calculations are:
Automatic:
confidence_auto = initial_confidence * (100 - (40 * number_of_automatic_downgrades)) / 100Manual:
confidence_manual = initial_confidence * (100 - (40 * number_of_manual_downgrades)) / 100 -
Set downgrade flags
channel_flags are updated to record which channels (A, B, or both) were downgraded automatically or manually. -
Calculate the final confidence score
The final confidence score is calculated as the average of the two downgraded values:
confidence = (confidence_auto + confidence_manual) / 2
Implementation
The following code calculates the confidence score, with some minor adjustments for readability.
public class ConfidenceCalculator {
public static final int MAX_PM = 2000;
public static double getZeroIfNodeIsNull(final JsonNode node) {
if (node == null) {
return 0;
} else {
return (double)node.asDouble();
}
}
/*
* This is where we start the confidence calculation
*
* objA -- stats_a -- real-time pseudo averaged data for channel A
* objB -- stats_b -- real-time pseudo averaged data for channel B
*/
public static void calculateFlags(final Object e, int channelState, int channelFlagsManual, ObjectNode objA, ObjectNode objB){
ObjectNode performance = calculatePerformance(objA, objB, channelState, channelFlagsManual);
Integer confidenceAuto = null;
Integer confidenceManual = null;
int channelFlagsAuto = 3;
// It starts as flags = 3, all bad.
if (performance.get("channel_flags_auto") != null) {
channelFlagsAuto = performance.get("channel_flags_auto").asInt();
}
if (performance.get("confidence_manual") != null) {
confidenceManual = performance.get("confidence_manual").asInt();
}
if (performance.get("confidence_auto") != null) {
confidenceAuto = performance.get("confidence_auto").asInt();
}
int channelFlags = channelFlagsManual;
if (channelFlagsManual == 0) {
channelFlags = channelFlagsAuto;
}
Integer confidence;
if (confidenceManual == null && confidenceAuto == null) {
confidence = 30;
} else if (confidenceManual == null) {
confidence = confidenceAuto;
} else if (confidenceAuto == null) {
confidence = confidenceManual;
} else {
confidence = (confidenceManual + confidenceAuto) / 2;
}
}
// Calculates confidence_manual and confidence_auto
public static ObjectNode calculatePerformance(final ObjectNode objA,
final ObjectNode objB, final int channelState, final int channelFlagsManual) {
// Initialize our perf object holding confidence values
ObjectMapper objectMapper = new ObjectMapper();
ObjectNode perf = (ObjectNode) objectMapper.readValue("{}", JsonNode.class);
// Return an object with no confidence values if there's no PM data
if (objA == null && objB == null) {
return perf;
}
float pmA = 0;
if (objA != null) {
JsonNode jsA = objA.get("pm2.5_atm"); // pm2.5_atm_a
if (jsA != null) {
pmA = jsA.floatValue();
}
}
float pmB = 0;
if (objB != null) {
JsonNode jsB = objB.get("pm2.5_atm"); // pm2.5_atm_b
if (jsB != null) {
pmB = jsB.floatValue();
}
}
double totala = pmA;
double vA = 0;
if (objA != null) {
vA = getZeroIfNodeIsNull(objA.get("pm2.5")); // pm2.5_a
totala += vA;
totala += getZeroIfNodeIsNull(objA.get("pm2.5_10minute")); // pm2.5_10minute_a
totala += getZeroIfNodeIsNull(objA.get("pm2.5_30minute")); // pm2.5_30minute_a
totala += getZeroIfNodeIsNull(objA.get("pm2.5_60minute")); // pm2.5_60minute_a
totala += getZeroIfNodeIsNull(objA.get("pm2.5_6hour")); // pm2.5_6hour_a
totala += getZeroIfNodeIsNull(objA.get("pm2.5_24hour")); // pm2.5_24hour_a
totala += getZeroIfNodeIsNull(objA.get("pm2.5_1week")); // pm2.5_1week_a
}
double totalb = pmB;
double vB = 0;
if (objB != null) {
vB = getZeroIfNodeIsNull(objB.get("pm2.5")); // pm2.5_b
totalb += vB;
totalb += getZeroIfNodeIsNull(objB.get("pm2.5_10minute")); // pm2.5_10minute_b
totalb += getZeroIfNodeIsNull(objB.get("pm2.5_30minute")); // pm2.5_30minute_b
totalb += getZeroIfNodeIsNull(objB.get("pm2.5_60minute")); // pm2.5_60minute_b
totalb += getZeroIfNodeIsNull(objB.get("pm2.5_6hour")); // pm2.5_6hour_b
totalb += getZeroIfNodeIsNull(objB.get("pm2.5_24hour")); // pm2.5_24hour_b
totalb += getZeroIfNodeIsNull(objB.get("pm2.5_1week")); // pm2.5_1week_b
}
// Calculate automatic downgrades
int channelFlagsAuto = 0;
if (vA > MAX_PM || objB != null && totala > (totalb * 10)) {
channelFlagsAuto = channelFlagsAuto + 1;
}
if (vB > MAX_PM || objA != null && totalb > (totala * 10)) {
channelFlagsAuto = channelFlagsAuto + 2;
}
perf.put("channel_flags_auto", channelFlagsAuto);
if (objA != null && objB != null) {
// calculate initial confidence
int confidence;
confidence = (int) getConfidence(totala, totalb);
// Lower confidence for abnormally high readings
if (pmA > MAX_PM && pmB > MAX_PM) {
confidence = confidence - 40;
} else if (pmA > MAX_PM) {
confidence = confidence - 60;
} else if (pmB > MAX_PM) {
confidence = confidence - 60;
}
// Calculate confidence_auto by adjusting for automatic downgrades
perf.put("confidence_auto", getConfidenceAdjusted(confidence, channelState, channelFlagsAuto));
// Calculate confidence_manual by adjusting for manual downgrades
perf.put("confidence_manual", getConfidenceAdjusted(confidence, channelState, channelFlagsManual));
}
return perf;
}
// Gets the initial confidence between two channel sums
private static double getConfidence(final double a, final double b) {
double diff = Math.abs(a - b);
double avg = (a + b) / 2;
double pc = Math.round(diff / avg * 100 / 1.6);
pc = pc - 25;
if (pc < 0) {
pc = 0;
}
double npcx;
npcx = 100 - pc;
if (npcx < 0) {
npcx = 0;
}
return npcx;
}
// Adjusts confidence based on downgrades (channelFlags)
private static int getConfidenceAdjusted(final int conf, final int state, final int channelFlags) {
int c = conf;
if (c < 0) {
c = 0;
}
if (state == 3 && channelFlags > 0) {
int correction = 1;
if (channelFlags > 2) {
correction = 2;
}
return c * (100 - (40 * correction)) / 100;
}
return c;
}
}