Humidity Correction for Low-Cost PM2.5 Sensors

Humidity is the largest single error term in a low-cost PM2.5 network, larger than sensor noise and larger than the calibration residual — and unlike those, it is not random. It is a systematic over-reading that scales with how damp the air is, which means it correlates with weather, with time of day, and with exactly the conditions under which a network’s numbers get scrutinised. Correcting it is the highest-value normalization step available, and it must run before the linear cross-calibration described in cross-device sensor normalization techniques, because otherwise the regression absorbs an average humidity effect into a constant gain.

The Physics, Briefly

An optical particle counter infers mass from scattered light. Ambient aerosol contains hygroscopic material — sulphates, nitrates, sea salt — that takes up water as relative humidity rises. The particle grows, its scattering cross-section grows faster than its dry mass, and the instrument reports a concentration inflated by water that a gravimetric reference dries away before weighing.

The kappa-Köhler framework parameterises this with a single hygroscopicity parameter κ. The growth factor for the reported mass is approximately:

C_dry = C_wet / (1 + κ · a_w / (1 − a_w)) where a_w = RH/100

κ near 0.2 suits continental urban aerosol; 0.4 to 0.6 suits marine or heavily sulphate-influenced air. The term a_w/(1−a_w) is what makes the correction explosive near saturation: at 50% humidity it is 1.0, at 90% it is 9.0, and at 98% it is 49. That behaviour is real — and it is also why the correction stops being trustworthy at the top of the range.

Hygroscopic growth factor against relative humidity Line chart of the kappa-Koehler growth factor against relative humidity for three aerosol types, showing the curve rising slowly to 60 percent and steeply above 90, with the cut-off at 95 percent marked. Hygroscopic growth factor against relative humidity 2.5 5 7.5 30 50 75 90 95 discard above here reported ÷ dry mass relative humidity (%) Continental urban (κ=0.20) Coastal / marine (κ=0.45) Arid / dust (κ=0.05)
The term a/(1−a) is what makes the correction explosive near saturation: 1.0 at 50 % humidity, 9.0 at 90 %, 49 at 98 %.

Production-Ready Implementation

# python 3.11 · pandas==2.2.2 · numpy==1.26.4 · scikit-learn==1.4.2
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd

QC_GOOD, QC_SUSPECT, QC_HIGH_RH = 1, 2, 6


@dataclass(frozen=True)
class HumidityCorrection:
    """kappa-Koehler correction for hygroscopic growth in optical PM measurements.

    kappa: aerosol hygroscopicity. 0.2 continental urban, 0.4-0.6 marine.
    rh_max: above this the growth factor is too sensitive to trust — flag instead.
    """

    kappa: float = 0.22
    rh_max: float = 95.0
    rh_floor: float = 35.0        # below this, growth is negligible; leave the value alone

    def growth_factor(self, rh_pct: np.ndarray) -> np.ndarray:
        """Multiplicative inflation of reported mass at each humidity."""
        a_w = np.clip(rh_pct, 0.0, 99.5) / 100.0
        return 1.0 + self.kappa * a_w / (1.0 - a_w)

    def apply(self, df: pd.DataFrame, pm_col: str = "pm25",
              rh_col: str = "humidity_pct") -> pd.DataFrame:
        """Return the frame with a dry-mass estimate and a flag above rh_max."""
        out = df.copy()
        rh = out[rh_col].to_numpy(dtype=float)
        pm = out[pm_col].to_numpy(dtype=float)

        gf = self.growth_factor(rh)
        corrected = np.where(rh > self.rh_floor, pm / gf, pm)

        too_wet = rh > self.rh_max
        corrected = np.where(too_wet, np.nan, corrected)

        out[f"{pm_col}_dry"] = corrected
        out.loc[too_wet, "qc_flag"] = np.maximum(out.loc[too_wet, "qc_flag"], QC_HIGH_RH)
        out["growth_factor"] = gf
        return out

The rh_floor is not physics, it is engineering: below about 35% the correction is under 1.2% and applying it only adds the humidity sensor’s own error to a clean measurement.

Where co-location data exists, fit the relationship instead of assuming it:

from sklearn.linear_model import HuberRegressor


def fit_empirical_correction(
    df: pd.DataFrame, *, pm_col: str = "pm25", rh_col: str = "humidity_pct",
    ref_col: str = "reference_pm25",
) -> tuple[HuberRegressor, float]:
    """Regress the observed inflation ratio on the Koehler growth term.

    Fitting ratio = 1 + k * a_w/(1-a_w) recovers an effective kappa for THIS
    sensor and THIS aerosol, absorbing the optics and the local particle mix
    together. Returns the model and its residual standard deviation.
    """
    usable = (
        df[ref_col].notna() & (df[ref_col] > 2.0)      # ratios are unstable near zero
        & df[pm_col].notna() & df[rh_col].between(20, 95)
    )
    d = df.loc[usable]
    a_w = d[rh_col].to_numpy(dtype=float) / 100.0
    x = (a_w / (1.0 - a_w)).reshape(-1, 1)
    y = d[pm_col].to_numpy(dtype=float) / d[ref_col].to_numpy(dtype=float)

    model = HuberRegressor(fit_intercept=True).fit(x, y)
    residual_sd = float(np.std(y - model.predict(x)))
    return model, residual_sd

Excluding reference values below 2 µg/m³ matters more than it looks: at very low concentrations the ratio’s denominator approaches zero and a handful of points dominate the fit, producing an effective κ that is fitted to noise.

Residual against humidity, before and after correction Scatter plot of the difference between a low-cost sensor and its reference against relative humidity, showing a clear upward trend before correction and a flat cloud after it — the acceptance test for the whole method. Residual against humidity, before and after correction 0 10 20 40 60 80 zero bias sensor − reference (µg/m³) relative humidity (%) Before correction After correction
The goal is removing the dependence, not making one summary number smaller. A correction that lowers mean error while leaving this trend has moved the bias around.

Parameter Tuning Guide

Environment κ Typical over-read at 85% RH Notes
Continental urban 0.20 +48% traffic-dominated, moderate sulphate
Coastal / marine 0.45 +109% sea salt is strongly hygroscopic
Biomass-burning plume 0.10 +24% fresh smoke is less hygroscopic
Industrial sulphate 0.55 +133% the largest corrections you will see
Arid / dust 0.05 +12% mineral dust takes up little water
Relative humidity Growth factor (κ=0.22) Action
below 35% < 1.12 leave uncorrected
35–60% 1.12–1.33 correct
60–80% 1.33–1.88 correct
80–90% 1.88–2.98 correct, flag as suspect
90–95% 2.98–5.18 correct, flag as suspect
above 95% > 5.18 discard the value, flag QC_HIGH_RH

The bottom row loses data — typically 2 to 6% of readings in a temperate maritime climate, more in fog-prone valleys. That loss is honest; publishing a value divided by a growth factor of eight is not.

Readings discarded above the humidity cut-off, by climate Horizontal bar chart of the share of readings discarded at 95 percent relative humidity across five deployment climates, from arid at well under one percent to a fog-prone valley at nearly ten. Readings discarded above the humidity cut-off, by climate share of readings flagged rather than corrected Arid / continental interior 0.3 % Temperate inland 2.1 % Temperate maritime 5.8 % Coastal, winter 7.4 % Fog-prone valley 9.6 %
That loss is honest. Publishing a value divided by a growth factor of eight is not, and the flag keeps the coverage cost visible.

Verification and Testing

# python 3.11 · pandas==2.2.2 · numpy==1.26.4 · pytest==8.2.0
def test_correction_reduces_humidity_correlation_in_the_residuals(colocation):
    """The acceptance test: after correction, error must not depend on humidity."""
    corrected = HumidityCorrection(kappa=0.22).apply(colocation)
    ok = corrected["pm25_dry"].notna() & corrected["reference_pm25"].notna()

    before = np.corrcoef(colocation.loc[ok, "pm25"] - colocation.loc[ok, "reference_pm25"],
                         colocation.loc[ok, "humidity_pct"])[0, 1]
    after = np.corrcoef(corrected.loc[ok, "pm25_dry"] - corrected.loc[ok, "reference_pm25"],
                        corrected.loc[ok, "humidity_pct"])[0, 1]

    assert abs(after) < 0.5 * abs(before)


def test_growth_factor_matches_published_values():
    c = HumidityCorrection(kappa=0.22)
    assert abs(float(c.growth_factor(np.array([50.0]))[0]) - 1.22) < 0.01
    assert abs(float(c.growth_factor(np.array([90.0]))[0]) - 2.98) < 0.05


def test_very_humid_readings_are_flagged_not_corrected():
    df = pd.DataFrame({"pm25": [40.0], "humidity_pct": [97.0], "qc_flag": [QC_GOOD]})
    out = HumidityCorrection().apply(df)
    assert np.isnan(out.loc[0, "pm25_dry"])
    assert out.loc[0, "qc_flag"] == QC_HIGH_RH

The first test is the one that decides whether the correction is working at all. A correction that lowers the mean error while leaving the residuals correlated with humidity has simply moved the bias around; the goal is to remove the dependence, not to make one summary number smaller.

Gotchas

Correcting after the linear calibration. The gain and offset fitted on uncorrected data have already absorbed an average humidity effect. Correct for humidity first, then fit the transfer function on the dry-mass estimate, as cross-calibration assumes.

Using ambient humidity instead of the humidity at the sensor inlet. Some units heat their inlet or sit inside a warm enclosure, where the local humidity is materially lower than outside. Use the sensor’s own humidity channel where it has one.

A single κ across a whole region. Coastal and inland sites in the same network genuinely have different aerosol. If your sites span an environment boundary, fit κ per site.

Applying the correction to a reference-grade instrument. Gravimetric and beta-attenuation monitors condition the sample before measuring, so their output is already dry mass. Correcting them introduces exactly the bias you are trying to remove.


FAQ

Why do optical PM2.5 sensors over-read in humid air?

They size particles by how much light they scatter. Hygroscopic particles absorb water as humidity rises, growing physically larger and scattering far more light, so the sensor reports a mass concentration that includes water the reference gravimetric method dries off. Above about 75% relative humidity the effect grows steeply, reaching a factor of two or more near saturation.

Should I correct or discard readings at very high humidity?

Correct up to about 95% and discard above it. Beyond that the growth factor is extremely sensitive to small humidity errors and fog droplets are counted as particles, so the correction amplifies noise rather than removing bias. Flag rather than delete, so the coverage loss stays visible.

Kappa-Köhler or an empirical fit?

Empirical if you have co-location data covering a wide humidity range, because it absorbs the sensor’s own optics and your local aerosol together. Kappa-Köhler when you do not, because it is physically grounded and needs only one tunable parameter that published values constrain reasonably well.