Open and Accurate Air Quality Monitors
We design professional, accurate and long-lasting air quality monitors that are open-source and open-hardware so that you have full control on how you want to use the monitor.
Learn MoreThis technical implementation paper defines a production-ready reference implementation of the GO IAQS Starter score for PM2.5 and CO2. The goal is to provide a transparent, reproducible, and auditable algorithm that software teams can adopt across web, mobile, embedded, and dashboard systems while preserving consistent score outcomes, color coding, dominant pollutant behavior, and advisory messaging.
This implementation reflects the GO AQS white paper thresholds currently used in the AirGradient website simulator and is designed to be portable as a standalone scoring library.
The GO IAQS Starter score is intended as a public communication framework for indoor air quality based on:
The algorithm converts pollutant concentrations into integer per-pollutant scores ("0..10"), combines them into a single total score with a synergetic rule, and derives category, grade letter, color, advice, and display helpers.
PM2.5 anchors (`ug/m^3`, score):
1. `(0,10)`
2. `(10,8)`
3. `(11,7)`
4. `(25,4)`
5. `(26,3)`
6. `(100,0)`
CO2 anchors (`ppm`, score):
1. `(400,10)`
2. `(800,8)`
3. `(801,7)`
4. `(1400,4)`
5. `(1401,3)`
6. `(5000,0)`
1. Good: `8..10` (`A`, `#648EFF`)
2. Moderate: `4..7` (`B`, `#FFB000`)
3. Unhealthy: `0..3` (`Z`, `#FF190C`)
Any change to anchors, scoring bands, synergetic policy, category thresholds, or advice should be handled as a versioned standards update:
File: apps/website/utils/go-iaqs-score.ts
/**
* Attribution: Achim Haug (AirGradient) for GO AQS.
* License: CC BY-SA 4.0.
*/
export type Category = 'Good' | 'Moderate' | 'Unhealthy';
export type Letter = 'A' | 'B' | 'Z';
export type Dominant = 'pm25' | 'co2' | 'both';
export type Anchor = {
x: number;
y: number;
};
export type CategoryInfo = {
category: Category;
letter: Letter;
colorHex: '#648EFF' | '#FFB000' | '#FF190C';
};
export type GaugePoint = {
x: number;
y: number;
};
export type GoIaqsResult = {
pm25Value: number;
co2Value: number;
pm25Score: number;
co2Score: number;
totalScore: number;
category: Category;
letter: Letter;
colorHex: '#648EFF' | '#FFB000' | '#FF190C';
dominant: Dominant;
dominantLabel: 'Both' | 'PM2.5' | 'CO2';
};
export const PM25_MIN = 0;
export const PM25_MAX = 100;
export const CO2_MIN = 400;
export const CO2_MAX = 5000;
export const PM25_DEFAULT = 12;
export const CO2_DEFAULT = 950;
export const SCORE_SCALE_VALUES = [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0];
const SCORE_MAX = SCORE_SCALE_VALUES[0];
const SCORE_MIN = SCORE_SCALE_VALUES[SCORE_SCALE_VALUES.length - 1];
export const PM25_ANCHORS: Anchor[] = [
{ x: 0, y: 10 },
{ x: 10, y: 8 },
{ x: 11, y: 7 },
{ x: 25, y: 4 },
{ x: 26, y: 3 },
{ x: 100, y: 0 }
];
export const CO2_ANCHORS: Anchor[] = [
{ x: 400, y: 10 },
{ x: 800, y: 8 },
{ x: 801, y: 7 },
{ x: 1400, y: 4 },
{ x: 1401, y: 3 },
{ x: 5000, y: 0 }
];
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function roundHalfUp(value: number): number {
return Math.floor(value + 0.5);
}
export function interpolateAnchors(value: number, anchors: Anchor[]): number {
if (value <= anchors[0].x) {
return anchors[0].y;
}
const lastAnchor = anchors[anchors.length - 1];
if (value >= lastAnchor.x) {
return lastAnchor.y;
}
for (let i = 0; i < anchors.length - 1; i += 1) {
const left = anchors[i];
const right = anchors[i + 1];
if (value <= right.x) {
const interpolated = left.y + ((value - left.x) * (right.y - left.y)) / (right.x - left.x);
return clamp(roundHalfUp(interpolated), SCORE_MIN, SCORE_MAX);
}
}
return SCORE_MIN;
}
export function computePollutantScore(
value: number,
min: number,
max: number,
anchors: Anchor[]
): number {
if (!Number.isFinite(value)) {
return SCORE_MIN;
}
if (value > max) {
return SCORE_MIN;
}
return interpolateAnchors(clamp(value, min, max), anchors);
}
export function getDominant(pm25Score: number, co2Score: number): Dominant {
if (pm25Score === co2Score) {
return 'both';
}
return pm25Score < co2Score ? 'pm25' : 'co2';
}
export function getTotalScore(pm25Score: number, co2Score: number): number {
if (pm25Score === co2Score) {
if (pm25Score <= 7) {
return Math.max(pm25Score - 1, SCORE_MIN);
}
return pm25Score;
}
return Math.min(pm25Score, co2Score);
}
export function getCategoryInfo(totalScore: number): CategoryInfo {
if (totalScore >= 8) {
return {
category: 'Good',
letter: 'A',
colorHex: '#648EFF'
};
}
if (totalScore >= 4) {
return {
category: 'Moderate',
letter: 'B',
colorHex: '#FFB000'
};
}
return {
category: 'Unhealthy',
letter: 'Z',
colorHex: '#FF190C'
};
}
export function getCategoryAdvice(category: Category): string {
if (category === 'Good') {
return 'Air quality is favorable for normal daily activity.';
}
if (category === 'Moderate') {
return 'Sensitive people should limit prolonged heavy exertion.';
}
return 'Avoid strenuous physical activities and improve ventilation.';
}
export function getDominantLabel(dominant: Dominant): 'Both' | 'PM2.5' | 'CO2' {
if (dominant === 'both') {
return 'Both';
}
return dominant === 'pm25' ? 'PM2.5' : 'CO2';
}
export function getDominantWidgetLabel(dominant: Dominant): 'PM2.5 and CO2' | 'PM2.5' | 'CO2' {
if (dominant === 'both') {
return 'PM2.5 and CO2';
}
return dominant === 'pm25' ? 'PM2.5' : 'CO2';
}
export function getStatusMarkerPercent(totalScore: number, tickCount = SCORE_SCALE_VALUES.length): number {
return ((SCORE_MAX - totalScore + 0.5) / tickCount) * 100;
}
export function getGaugeNeedlePoint(
totalScore: number,
radius = 100,
centerX = 180,
centerY = 180
): GaugePoint {
const goodStart = 7.5;
const moderateStart = 3.5;
let theta: number;
if (totalScore >= goodStart) {
theta = (2 * Math.PI) / 3 + ((totalScore - goodStart) * (Math.PI / 3)) / (SCORE_MAX - goodStart);
} else if (totalScore >= moderateStart) {
theta = Math.PI / 3 + ((totalScore - moderateStart) * (Math.PI / 3)) / (goodStart - moderateStart);
} else {
theta = (totalScore * (Math.PI / 3)) / moderateStart;
}
return {
x: centerX + radius * Math.cos(theta),
y: centerY - radius * Math.sin(theta)
};
}
export function calculateGoIaqsResult(pm25Input: number, co2Input: number): GoIaqsResult {
const normalizedPm25Input = Number.isFinite(pm25Input) ? pm25Input : PM25_DEFAULT;
const normalizedCo2Input = Number.isFinite(co2Input) ? co2Input : CO2_DEFAULT;
const pm25Value = clamp(normalizedPm25Input, PM25_MIN, PM25_MAX);
const co2Value = clamp(normalizedCo2Input, CO2_MIN, CO2_MAX);
const pm25Score = computePollutantScore(normalizedPm25Input, PM25_MIN, PM25_MAX, PM25_ANCHORS);
const co2Score = computePollutantScore(normalizedCo2Input, CO2_MIN, CO2_MAX, CO2_ANCHORS);
const dominant = getDominant(pm25Score, co2Score);
const totalScore = getTotalScore(pm25Score, co2Score);
const categoryInfo = getCategoryInfo(totalScore);
return {
pm25Value,
co2Value,
pm25Score,
co2Score,
totalScore,
category: categoryInfo.category,
letter: categoryInfo.letter,
colorHex: categoryInfo.colorHex,
dominant,
dominantLabel: getDominantLabel(dominant)
};
}
This technical paper and its attached reference implementations are licensed under Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0).
Licensing intent:
File: apps/shared/feature-documentation/go-aqs-simulator/go-iaqs-widgets.reference.html
<!--
Attribution: Achim Haug (AirGradient) for GO AQS.
License: CC BY-SA 4.0.
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GO IAQS Widgets Reference</title>
<style>
:root {
--aqs-color: #648eff;
}
body {
margin: 0;
padding: 24px;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
"Apple Color Emoji", "Segoe UI Emoji";
background: #fafafa;
color: #111;
}
.embed-widgets {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.embed-widget {
background: #f8f8f8;
border: 2px solid rgb(0 0 0 / 0.18);
border-radius: 22px;
padding: 1.15rem;
}
.status-widget {
background: #fff;
display: flex;
flex-direction: column;
justify-content: center;
}
.status-scale-wrap {
display: flex;
justify-content: center;
margin-bottom: 1rem;
}
.status-scale-track {
--good-end: 27.2727%;
--moderate-end: 63.6364%;
position: relative;
width: min(420px, 100%);
height: 46px;
border-radius: 999px;
background: linear-gradient(
90deg,
#648eff 0 var(--good-end),
#ffb000 var(--good-end) var(--moderate-end),
#ff190c var(--moderate-end) 100%
);
display: grid;
grid-template-columns: repeat(11, minmax(0, 1fr));
align-items: center;
padding: 0;
}
.status-scale-number {
text-align: center;
font-weight: 700;
color: #212121;
font-size: 1.55rem;
line-height: 1;
}
.status-scale-marker {
position: absolute;
top: -20px;
transform: translateX(-50%);
display: inline-flex;
flex-direction: column;
align-items: center;
}
.status-scale-score {
width: 44px;
height: 66px;
border-radius: 24px;
background: var(--aqs-color, #648eff);
color: #111;
display: grid;
place-items: center;
font-size: 1.9rem;
font-weight: 800;
border: 2px solid rgb(0 0 0 / 0.35);
}
.status-scale-letter {
width: 38px;
height: 28px;
margin-top: -5px;
border-radius: 0 0 16px 16px;
background: #141414;
color: #fff;
display: grid;
place-items: center;
font-weight: 800;
border: 2px solid rgb(0 0 0 / 0.35);
border-top: 0;
}
.status-content {
display: grid;
grid-template-columns: 1fr 1.65fr;
gap: 1rem;
align-items: center;
margin-top: 0.75rem;
}
.status-current {
padding-top: 0;
}
.status-current-title {
font-size: 2rem;
font-weight: 700;
line-height: 1.05;
margin: 0;
}
.status-time {
font-size: 1.2rem;
margin: 0;
color: #5d5d5d;
line-height: 1.25;
}
.status-headline {
margin: 0;
display: flex;
align-items: baseline;
gap: 0.85rem;
}
.status-big-score {
font-size: 4.2rem;
font-weight: 800;
line-height: 1;
color: #111;
}
.status-big-label {
font-size: 3rem;
font-weight: 700;
line-height: 1;
color: #111;
}
.status-subhead {
margin: 0.6rem 0 0;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.15;
color: #222;
}
.status-subhead-pollutant {
margin-top: 1rem;
}
.status-copy {
margin: 0.05rem 0 0;
font-size: 1.1rem;
line-height: 1.25;
color: #3b3b3b;
}
.gauge-widget {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fff;
}
.iaqs-gauge {
width: min(460px, 100%);
height: auto;
margin-top: 0.25rem;
}
.gauge-outer-good {
fill: none;
stroke: #d8e2ff;
stroke-width: 34;
stroke-linecap: butt;
}
.gauge-outer-moderate {
fill: none;
stroke: #ffecbd;
stroke-width: 34;
stroke-linecap: butt;
}
.gauge-outer-unhealthy {
fill: none;
stroke: #ffd3d0;
stroke-width: 34;
stroke-linecap: butt;
}
.gauge-inner-good {
fill: #648eff;
}
.gauge-inner-moderate {
fill: #ffb000;
}
.gauge-inner-unhealthy {
fill: #ff190c;
}
.gauge-needle {
stroke: #194480;
stroke-width: 8;
stroke-linecap: round;
}
.gauge-center-hole {
fill: #fff;
stroke: #234c84;
stroke-width: 7;
}
.gauge-center-dot {
fill: #234c84;
}
.gauge-label {
font-size: 9px;
font-weight: 800;
letter-spacing: 0.04em;
fill: #244c80;
text-anchor: middle;
dominant-baseline: middle;
}
.gauge-title {
margin: 0.35rem 0 0;
color: #234c84;
font-size: 2.15rem;
line-height: 1;
letter-spacing: 0.01em;
}
.gauge-title span {
font-weight: 300;
margin-right: 0.4rem;
}
.gauge-title strong {
font-weight: 800;
}
.gauge-current {
margin: 0.5rem 0 0;
color: #3f4d66;
font-size: 1.1rem;
font-weight: 600;
}
@media (width <= 991px) {
.embed-widgets {
grid-template-columns: 1fr;
}
.status-scale-track {
width: min(520px, 100%);
}
}
@media (width <= 767px) {
.status-content {
grid-template-columns: 1fr;
}
.status-big-label {
font-size: 2rem;
}
}
</style>
</head>
<body>
<!--
Inputs (no UI controls in this reference):
- Use URL query params: ?pm25=12&co2=950
- Or call window.setGoIaqsInputs(pm25UgM3, co2Ppm) from the console.
-->
<section id="widgets-root" class="embed-widgets">
<article class="embed-widget status-widget">
<div class="status-scale-wrap">
<div class="status-scale-track">
<span class="status-scale-number">10</span>
<span class="status-scale-number">9</span>
<span class="status-scale-number">8</span>
<span class="status-scale-number">7</span>
<span class="status-scale-number">6</span>
<span class="status-scale-number">5</span>
<span class="status-scale-number">4</span>
<span class="status-scale-number">3</span>
<span class="status-scale-number">2</span>
<span class="status-scale-number">1</span>
<span class="status-scale-number">0</span>
<div id="status-marker" class="status-scale-marker">
<span id="status-scale-score" class="status-scale-score">-</span>
<span id="status-scale-letter" class="status-scale-letter">-</span>
</div>
</div>
</div>
<div class="status-content">
<div class="status-current">
<p class="status-current-title">Currently</p>
<p id="widget-date" class="status-time"></p>
<p id="widget-time" class="status-time"></p>
</div>
<div class="status-main">
<p class="status-headline">
<span id="status-big-score" class="status-big-score">-</span>
<span id="status-big-label" class="status-big-label">-</span>
</p>
<p class="status-subhead">Advice</p>
<p id="advice" class="status-copy"></p>
<p class="status-subhead status-subhead-pollutant">Dominant Pollutant(s)</p>
<p id="dominant" class="status-copy"></p>
</div>
</div>
</article>
<article class="embed-widget gauge-widget">
<svg class="iaqs-gauge" viewBox="0 0 360 212" role="img" aria-label="GO IAQS gauge widget">
<defs>
<path id="gauge-label-path-good" d="M 40 180 A 140 140 0 0 1 110 58.8" />
<path id="gauge-label-path-moderate" d="M 110 58.8 A 140 140 0 0 1 250 58.8" />
<path id="gauge-label-path-unhealthy" d="M 250 58.8 A 140 140 0 0 1 320 180" />
</defs>
<path class="gauge-outer-good" d="M 40 180 A 140 140 0 0 1 110 58.8" />
<path class="gauge-outer-moderate" d="M 110 58.8 A 140 140 0 0 1 250 58.8" />
<path class="gauge-outer-unhealthy" d="M 250 58.8 A 140 140 0 0 1 320 180" />
<path class="gauge-inner-good" d="M 180 180 L 62 180 A 118 118 0 0 1 121 77.8 Z" />
<path
class="gauge-inner-moderate"
d="M 180 180 L 121 77.8 A 118 118 0 0 1 239 77.8 Z"
/>
<path
class="gauge-inner-unhealthy"
d="M 180 180 L 239 77.8 A 118 118 0 0 1 298 180 Z"
/>
<line id="gauge-needle" class="gauge-needle" x1="180" y1="180" x2="180" y2="80" />
<circle class="gauge-center-hole" cx="180" cy="180" r="24" />
<circle class="gauge-center-dot" cx="180" cy="180" r="11" />
<text class="gauge-label">
<textPath href="#gauge-label-path-good" startOffset="50%">GOOD</textPath>
</text>
<text class="gauge-label">
<textPath href="#gauge-label-path-moderate" startOffset="50%">MODERATE</textPath>
</text>
<text class="gauge-label">
<textPath href="#gauge-label-path-unhealthy" startOffset="50%">UNHEALTHY</textPath>
</text>
</svg>
<p class="gauge-title"><span>GO IAQS</span><strong>SCORE</strong></p>
<p id="gauge-current" class="gauge-current"></p>
</article>
</section>
<script>
const PM25_MIN = 0;
const PM25_MAX = 100;
const CO2_MIN = 400;
const CO2_MAX = 5000;
const PM25_ANCHORS = [
{ x: 0, y: 10 },
{ x: 10, y: 8 },
{ x: 11, y: 7 },
{ x: 25, y: 4 },
{ x: 26, y: 3 },
{ x: 100, y: 0 }
];
const CO2_ANCHORS = [
{ x: 400, y: 10 },
{ x: 800, y: 8 },
{ x: 801, y: 7 },
{ x: 1400, y: 4 },
{ x: 1401, y: 3 },
{ x: 5000, y: 0 }
];
const clamp = (x, lo, hi) => Math.min(Math.max(x, lo), hi);
const roundHalfUp = x => Math.floor(x + 0.5);
const interpolateAnchors = (value, anchors) => {
if (value <= anchors[0].x) return anchors[0].y;
const last = anchors[anchors.length - 1];
if (value >= last.x) return last.y;
for (let i = 0; i < anchors.length - 1; i += 1) {
const left = anchors[i];
const right = anchors[i + 1];
if (value <= right.x) {
const interpolated = left.y + ((value - left.x) * (right.y - left.y)) / (right.x - left.x);
return clamp(roundHalfUp(interpolated), 0, 10);
}
}
return 0;
};
const compute = (pm25UgM3Raw, co2PpmRaw) => {
const pm25UgM3 = clamp(Number.isFinite(pm25UgM3Raw) ? pm25UgM3Raw : 12, PM25_MIN, PM25_MAX);
const co2Ppm = clamp(Number.isFinite(co2PpmRaw) ? co2PpmRaw : 950, CO2_MIN, CO2_MAX);
const pm25Score = interpolateAnchors(pm25UgM3, PM25_ANCHORS);
const co2Score = interpolateAnchors(co2Ppm, CO2_ANCHORS);
const dominant = pm25Score === co2Score ? "both" : pm25Score < co2Score ? "pm25" : "co2";
let totalScore;
if (pm25Score === co2Score) {
const shared = pm25Score;
// Apply the synergetic deduction only for moderate/unhealthy levels.
totalScore = shared <= 7 ? Math.max(shared - 1, 0) : shared;
} else {
totalScore = Math.min(pm25Score, co2Score);
}
let categoryInfo;
if (totalScore >= 8) categoryInfo = { category: "Good", letter: "A", colorHex: "#648EFF" };
else if (totalScore >= 4)
categoryInfo = { category: "Moderate", letter: "B", colorHex: "#FFB000" };
else categoryInfo = { category: "Unhealthy", letter: "Z", colorHex: "#FF190C" };
const advice =
categoryInfo.category === "Good"
? "Air quality is favorable for normal daily activity."
: categoryInfo.category === "Moderate"
? "Sensitive people should limit prolonged heavy exertion."
: "Avoid strenuous physical activities and improve ventilation.";
const dominantWidgetLabel =
dominant === "both" ? "PM2.5 and CO2" : dominant === "pm25" ? "PM2.5" : "CO2";
const statusMarkerPercent = ((10 - totalScore + 0.5) / 11) * 100;
// Gauge needle mapping.
const goodStart = 7.5;
const moderateStart = 3.5;
let theta;
if (totalScore >= goodStart) {
theta = (2 * Math.PI) / 3 + ((totalScore - goodStart) * (Math.PI / 3)) / (10 - goodStart);
} else if (totalScore >= moderateStart) {
theta =
Math.PI / 3 + ((totalScore - moderateStart) * (Math.PI / 3)) / (goodStart - moderateStart);
} else {
theta = (totalScore * (Math.PI / 3)) / moderateStart;
}
const radius = 100;
const gaugeNeedlePoint = {
x: 180 + radius * Math.cos(theta),
y: 180 - radius * Math.sin(theta)
};
return {
pm25UgM3,
co2Ppm,
pm25Score,
co2Score,
totalScore,
...categoryInfo,
dominant,
dominantWidgetLabel,
advice,
statusMarkerPercent,
gaugeNeedlePoint
};
};
const render = state => {
const root = document.getElementById("widgets-root");
root.style.setProperty("--aqs-color", state.colorHex);
document.getElementById("status-marker").style.left = `${state.statusMarkerPercent}%`;
document.getElementById("status-scale-score").textContent = String(state.totalScore);
document.getElementById("status-scale-letter").textContent = state.letter;
document.getElementById("status-big-score").textContent = String(state.totalScore);
document.getElementById("status-big-label").textContent = state.category;
document.getElementById("advice").textContent = state.advice;
document.getElementById("dominant").textContent = state.dominantWidgetLabel;
const needle = document.getElementById("gauge-needle");
needle.setAttribute("x2", String(state.gaugeNeedlePoint.x));
needle.setAttribute("y2", String(state.gaugeNeedlePoint.y));
document.getElementById("gauge-current").textContent = `${state.totalScore} - ${state.category}`;
};
const now = new Date();
document.getElementById("widget-date").textContent = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric"
}).format(now);
document.getElementById("widget-time").textContent = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
timeZoneName: "short"
}).format(now);
const params = new URLSearchParams(window.location.search);
const initialPm25 = Number(params.get("pm25"));
const initialCo2 = Number(params.get("co2"));
render(compute(initialPm25, initialCo2));
window.setGoIaqsInputs = (pm25UgM3, co2Ppm) => {
render(compute(Number(pm25UgM3), Number(co2Ppm)));
};
</script>
</body>
</html>
File: apps/website/scripts/compare-go-iaqs-whitepaper-cases.ts
/**
* Attribution: Achim Haug (AirGradient) for GO AQS.
* License: CC BY-SA 4.0.
*/
import { calculateGoIaqsResult, getCategoryAdvice, type Anchor, type Category } from '../utils/go-iaqs-score.ts';
type Dominant = 'pm25' | 'co2' | 'both';
type ComparisonInput = {
pm25: number;
co2: number;
};
type ExpectedResult = {
totalScore: number;
dominant: Dominant;
colorHex: '#648EFF' | '#FFB000' | '#FF190C';
category: Category;
advice: string;
};
const WHITEPAPER_PM25_ANCHORS: Anchor[] = [
{ x: 0, y: 10 },
{ x: 10, y: 8 },
{ x: 11, y: 7 },
{ x: 25, y: 4 },
{ x: 26, y: 3 },
{ x: 100, y: 0 }
];
const WHITEPAPER_CO2_ANCHORS: Anchor[] = [
{ x: 400, y: 10 },
{ x: 800, y: 8 },
{ x: 801, y: 7 },
{ x: 1400, y: 4 },
{ x: 1401, y: 3 },
{ x: 5000, y: 0 }
];
const WHITEPAPER_CASES: ComparisonInput[] = [
{ pm25: 10, co2: 800 },
{ pm25: 11, co2: 800 },
{ pm25: 10, co2: 801 },
{ pm25: 11, co2: 801 },
{ pm25: 25, co2: 1400 },
{ pm25: 26, co2: 1400 },
{ pm25: 25, co2: 1401 },
{ pm25: 26, co2: 1401 },
{ pm25: 0, co2: 400 },
{ pm25: 100, co2: 5000 },
{ pm25: 9, co2: 799 },
{ pm25: 12, co2: 799 },
{ pm25: 9, co2: 802 },
{ pm25: 12, co2: 802 },
{ pm25: 24, co2: 1399 },
{ pm25: 27, co2: 1399 },
{ pm25: 24, co2: 1402 },
{ pm25: 27, co2: 1402 },
{ pm25: 18, co2: 1100 },
{ pm25: 63, co2: 3200 }
];
const SCORE_MIN = 0;
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function roundHalfUp(value: number): number {
return Math.floor(value + 0.5);
}
function interpolateAnchors(value: number, anchors: Anchor[]): number {
if (value <= anchors[0].x) {
return anchors[0].y;
}
const lastAnchor = anchors[anchors.length - 1];
if (value >= lastAnchor.x) {
return lastAnchor.y;
}
for (let i = 0; i < anchors.length - 1; i += 1) {
const left = anchors[i];
const right = anchors[i + 1];
if (value <= right.x) {
const interpolated = left.y + ((value - left.x) * (right.y - left.y)) / (right.x - left.x);
return clamp(roundHalfUp(interpolated), SCORE_MIN, 10);
}
}
return SCORE_MIN;
}
function computePollutantScore(value: number, min: number, max: number, anchors: Anchor[]): number {
if (!Number.isFinite(value) || value > max) {
return SCORE_MIN;
}
return interpolateAnchors(clamp(value, min, max), anchors);
}
function getDominant(pm25Score: number, co2Score: number): Dominant {
if (pm25Score === co2Score) {
return 'both';
}
return pm25Score < co2Score ? 'pm25' : 'co2';
}
function getTotalScore(pm25Score: number, co2Score: number): number {
if (pm25Score === co2Score) {
if (pm25Score <= 7) {
return Math.max(pm25Score - 1, SCORE_MIN);
}
return pm25Score;
}
return Math.min(pm25Score, co2Score);
}
function getCategory(totalScore: number): { category: Category; colorHex: '#648EFF' | '#FFB000' | '#FF190C' } {
if (totalScore >= 8) {
return { category: 'Good', colorHex: '#648EFF' };
}
if (totalScore >= 4) {
return { category: 'Moderate', colorHex: '#FFB000' };
}
return { category: 'Unhealthy', colorHex: '#FF190C' };
}
function calculateWhitepaperExpected(pm25: number, co2: number): ExpectedResult {
const pm25Score = computePollutantScore(pm25, 0, 100, WHITEPAPER_PM25_ANCHORS);
const co2Score = computePollutantScore(co2, 400, 5000, WHITEPAPER_CO2_ANCHORS);
const dominant = getDominant(pm25Score, co2Score);
const totalScore = getTotalScore(pm25Score, co2Score);
const categoryInfo = getCategory(totalScore);
return {
totalScore,
dominant,
colorHex: categoryInfo.colorHex,
category: categoryInfo.category,
advice: getCategoryAdvice(categoryInfo.category)
};
}
function dominantLabel(dominant: Dominant): string {
if (dominant === 'both') {
return 'both';
}
return dominant === 'pm25' ? 'PM2.5' : 'CO2';
}
const rows = WHITEPAPER_CASES.map((input, idx) => {
const expected = calculateWhitepaperExpected(input.pm25, input.co2);
const actual = calculateGoIaqsResult(input.pm25, input.co2);
const actualAdvice = getCategoryAdvice(actual.category);
const scoreMatch = expected.totalScore === actual.totalScore;
const dominantMatch = expected.dominant === actual.dominant;
const colorMatch = expected.colorHex === actual.colorHex;
const adviceMatch = expected.advice === actualAdvice;
const allMatch = scoreMatch && dominantMatch && colorMatch && adviceMatch;
return {
caseId: idx + 1,
...input,
expected,
actual,
actualAdvice,
scoreMatch,
dominantMatch,
colorMatch,
adviceMatch,
allMatch
};
});
const mismatchSummary = rows.reduce(
(acc, row) => {
if (!row.scoreMatch) acc.score += 1;
if (!row.dominantMatch) acc.dominant += 1;
if (!row.colorMatch) acc.color += 1;
if (!row.adviceMatch) acc.advice += 1;
if (!row.allMatch) acc.cases += 1;
return acc;
},
{ score: 0, dominant: 0, color: 0, advice: 0, cases: 0 }
);
console.log('# GO IAQS Whitepaper vs Website Comparison (20 Cases)\n');
console.log(
'| # | PM2.5 | CO2 | Score E/A | Dominant E/A | Color E/A | Advice E/A | Match |\n' +
'|---:|---:|---:|---|---|---|---|---|'
);
for (const row of rows) {
const scoreText = `${row.expected.totalScore}/${row.actual.totalScore}`;
const dominantText = `${dominantLabel(row.expected.dominant)}/${dominantLabel(row.actual.dominant)}`;
const colorText = `${row.expected.colorHex}/${row.actual.colorHex}`;
const adviceText = row.adviceMatch ? 'same' : 'different';
console.log(
`| ${row.caseId} | ${row.pm25} | ${row.co2} | ${scoreText} | ${dominantText} | ${colorText} | ${adviceText} | ${row.allMatch ? 'OK' : 'DIFF'} |`
);
}
console.log('\n## Summary');
console.log(`- Case mismatches: ${mismatchSummary.cases}`);
console.log(`- Score mismatches: ${mismatchSummary.score}`);
console.log(`- Dominant mismatches: ${mismatchSummary.dominant}`);
console.log(`- Color mismatches: ${mismatchSummary.color}`);
console.log(`- Advice mismatches: ${mismatchSummary.advice}`);
if (mismatchSummary.cases > 0) {
process.exitCode = 1;
}

We design professional, accurate and long-lasting air quality monitors that are open-source and open-hardware so that you have full control on how you want to use the monitor.
Learn MoreCurious about upcoming webinars, company updates, and the latest air quality trends? Sign up for our weekly newsletter and get the inside scoop delivered straight to your inbox.
Join our Newsletter