GO IAQS Starter Score Technical Implementation Paper

Achim Haug
March 10, 2026

Abstract

This 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.

1. Purpose

The GO IAQS Starter score is intended as a public communication framework for indoor air quality based on:

  1. PM2.5 concentration
  2. CO2 concentration

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.

2. Design Principles

  1. Deterministic outputs: same inputs always produce the same outputs.
  2. Piecewise linear scoring anchored to white paper breakpoints.
  3. Integer scoring via round-half-up to improve readability.
  4. Explicit handling for non-finite inputs and out-of-range values.
  5. Clear separation between computation and UI representation.
  6. Compatibility with conformance testing and cross-platform parity checks.

3. Normative Behavior Summary

3.1 Input normalization

  1. PM2.5 defaults to "12" if non-finite, then clamps to "[0, 100]".
  2. CO2 defaults to "950" if non-finite, then clamps to "[400, 5000]".

3.2 Pollutant anchors

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)`

3.3 Total score

  1. If pollutant scores differ: total score is the minimum.
  2. If equal:
    1. If shared score "<= 7", apply synergetic deduction ("shared - 1", floor at "0").
    2. If shared score ">= 8", keep the shared score.

3.4 Category mapping

1. Good: `8..10` (`A`, `#648EFF`)
2. Moderate: `4..7` (`B`, `#FFB000`)
3. Unhealthy: `0..3` (`Z`, `#FF190C`)

3.5 Additional derived outputs

  1. Dominant pollutant (pm25, co2, or both)
  2. Dominant labels for compact and widget displays
  3. Category advice text
  4. Status marker placement for 11-tick score scale
  5. Gauge needle coordinate mapping

4. Integration Guidance

  1. Treat the attached TypeScript module as the canonical shared scorer.
  2. Import this module in all UI surfaces rather than duplicating formulas.
  3. Validate each platform using a fixed threshold-focused conformance set.
  4. Preserve exact constants and thresholds to avoid parity drift.

5. Governance And Change Control

Any change to anchors, scoring bands, synergetic policy, category thresholds, or advice should be handled as a versioned standards update:

  1. Change proposal and rationale
  2. White paper alignment review
  3. Reference module update
  4. Conformance run across test cases
  5. Release note documenting behavioral change

6. Attachment A: Complete Reference Implementation

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)
  };
}

7. License

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:

  1. Commercial use is allowed.
  2. Attribution is required.
  3. Adaptations, adjustments, and improvements that are redistributed must remain open under the same license terms (ShareAlike).

8. Attachment B: HTML Widgets Reference

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>

9. Attachment C: Whitepaper Parity Test Script

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;
}
Open and Accurate Air Quality Monitors
This is an Ad for our Own Product

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 More

Your are being redirected to AirGradient Dashboard...