Propagating Measurement Uncertainty Through Aggregation

An hourly mean of sixty readings is not eight times more accurate than one reading, and treating it as though it were is how a network’s published figures come to claim a precision its hardware never had. The correction is not complicated — it is the standard separation of random from systematic error — but it has to be applied at every aggregation step, including the spatial ones. This guide does that for the three aggregations an environmental pipeline actually performs: over time, over sensors, and over space. It continues uncertainty quantification and data quality scoring.

Random Averages Down, Systematic Does Not

Split every uncertainty budget into two parts before doing anything else.

Random (independent between readings): electronic noise, quantisation, short-term turbulence. Averaging n readings reduces this by a factor of sqrt(n), because the errors partially cancel.

Systematic (shared across readings): calibration offset, drift at the current age, an unmodelled humidity response. Every reading in the hour carries the same error, so the mean carries it too, unchanged.

The consequence is a floor. For a low-cost PM2.5 sensor with 1.8 µg/m³ of noise and 5.7 µg/m³ of systematic error, a single reading has a combined uncertainty near 6.0 µg/m³ and an hourly mean of sixty samples has 5.7 µg/m³ — a 4% improvement, not the 87% that sqrt(60) would suggest. Recognising that floor changes what a network is used for: more sampling does not fix it, but a co-location campaign that shrinks the calibration residual does.

Averaging drives the random term down to a systematic floor Line chart of the uncertainty of a mean against the number of readings averaged, showing the random component falling as one over the square root of n while the total flattens against the systematic floor. Averaging drives the random term down to a systematic floor 0 2 4 6 1 10 60 1 000 hourly mean — 4 % better uncertainty (µg/m³) readings averaged (log scale) Random term alone Combined uncertainty Systematic floor
The floor is the calibration bias every reading in the hour shares. No amount of sampling moves it, which is what makes the 4 % figure honest.

Production-Ready Implementation

# python 3.11 · numpy==1.26.4 · pandas==2.2.2
from __future__ import annotations

from dataclasses import dataclass

import numpy as np
import pandas as pd


@dataclass(frozen=True)
class Uncertainty:
    """A measurement uncertainty split into the parts that behave differently.

    random:     independent between readings; averages down as 1/sqrt(n)
    systematic: shared by every reading from this sensor; survives averaging
    """

    random: float
    systematic: float

    @property
    def combined(self) -> float:
        return (self.random ** 2 + self.systematic ** 2) ** 0.5

    def after_averaging(self, n: int) -> "Uncertainty":
        if n <= 0:
            raise ValueError("n must be positive")
        return Uncertainty(random=self.random / n ** 0.5, systematic=self.systematic)


def temporal_mean(values: np.ndarray, u: Uncertainty) -> tuple[float, Uncertainty]:
    """Mean of n readings from ONE sensor, with correctly propagated uncertainty."""
    n = int(np.count_nonzero(~np.isnan(values)))
    return float(np.nanmean(values)), u.after_averaging(n)

Combining different sensors is an inverse-variance weighted mean, which is where the quality score earns its keep — a reference instrument and a low-cost node should not contribute equally:

def inverse_variance_mean(
    values: np.ndarray, uncertainties: np.ndarray
) -> tuple[float, float]:
    """Minimum-variance combination of readings with differing uncertainties.

    Each sensor's weight is 1/u^2, so a reference instrument with a third the
    uncertainty of a low-cost node carries nine times its weight.
    Returns (weighted mean, uncertainty of that mean).
    """
    mask = np.isfinite(values) & np.isfinite(uncertainties) & (uncertainties > 0)
    v, u = values[mask], uncertainties[mask]
    if v.size == 0:
        return float("nan"), float("nan")
    w = 1.0 / u ** 2
    mean = float(np.sum(w * v) / np.sum(w))
    return mean, float(1.0 / np.sqrt(np.sum(w)))

And through IDW interpolation, where the weights are known so the propagation is exact:

def idw_with_uncertainty(
    target_xy: tuple[float, float],
    sample_xy: np.ndarray,
    values: np.ndarray,
    uncertainties: np.ndarray,
    power: float = 2.0,
) -> tuple[float, float]:
    """IDW estimate at one target point, with propagated uncertainty.

    The interpolated value is sum(w*v)/sum(w); because the weights are constants,
    its uncertainty is sqrt(sum((w*u)^2))/sum(w) — assuming sensor errors are
    independent, which is true for noise and NOT true for a shared calibration
    bias across identical hardware.
    """
    d = np.hypot(sample_xy[:, 0] - target_xy[0], sample_xy[:, 1] - target_xy[1])
    d = np.maximum(d, 1e-9)
    w = 1.0 / d ** power
    total = float(np.sum(w))
    value = float(np.sum(w * values) / total)
    u = float(np.sqrt(np.sum((w * uncertainties) ** 2)) / total)
    return value, u

The caveat in that docstring is the one that matters in practice: a fleet of identical low-cost sensors calibrated against the same reference shares a calibration bias, so their errors are not independent and the propagated figure is optimistic. Where that applies, add the shared bias back as a systematic term after the interpolation rather than pretending it averaged away.

Inverse-variance weighting beats a plain mean Grouped bar chart comparing the plain mean and the inverse-variance weighted mean of three sensors with differing uncertainties, against the reference value. Inverse-variance weighting beats a plain mean 0 10 20 30 21 Sensor A (u=1) 19 Sensor B (u=1) 26 Sensor C (u=8) 22 Plain mean 20.1 Weighted mean PM2.5 (µg/m³)
The reference reads 20.0. Weighting by one over uncertainty squared gives the poor sensor a sixty-fourth of the weight, and the combination lands almost exactly on the truth.

Parameter Tuning Guide

Aggregation Random term Systematic term Uncertainty of the result
Mean of n readings, one sensor u_r / sqrt(n) u_s unchanged sqrt((u_r/sqrt(n))^2 + u_s^2)
Mean of k sensors, independent errors u_r / sqrt(n*k) u_s / sqrt(k) both shrink
Mean of k sensors, shared calibration u_r / sqrt(n*k) u_s unchanged systematic floor remains
Inverse-variance combination 1/sqrt(sum(1/u_i^2))
IDW at a point sqrt(sum((w_i u_i)^2))/sum(w_i)
Ordinary kriging at a point kriging variance, computed natively
Sensor Random (1σ) Systematic (1σ) Single reading Hourly mean (n=60) Improvement
Low-cost PM2.5 1.8 µg/m³ 5.7 µg/m³ 6.0 5.75 4%
Reference PM2.5 0.7 µg/m³ 1.1 µg/m³ 1.3 1.10 15%
Temperature 0.08 °C 0.17 °C 0.19 0.17 10%
Electrochemical NO₂ 2.1 ppb 7.7 ppb 8.0 7.71 4%

Read the last column as a research priority. When averaging buys 4%, the only route to a materially better figure is reducing the systematic term — better calibration, humidity correction, more frequent service — and no amount of sampling will substitute.

How uncertainty propagates through each aggregation Matrix of six aggregation operations with the formula for the resulting uncertainty and the assumption each formula depends on. How uncertainty propagates through each aggregation Result uncertainty Assumes Mean of n, one sensor √((u_r/√n)² + u_s²) noise independent between readings Mean of k sensors, independent both terms ÷ √k no shared calibration bias Mean of k sensors, shared calibration systematic term unchanged identical hardware, one campaign Inverse-variance combination 1/√(Σ 1/uᵢ²) uncertainties are known IDW at a point √(Σ(wᵢuᵢ)²)/Σwᵢ sensor errors independent Ordinary kriging kriging variance the variogram is correct
Row three is the one that catches fleets: a hundred identical nodes calibrated in the same campaign share a bias that averaging cannot remove.

Verification and Testing

Simulation is the right test here, because the true value is known only in a simulation.

# python 3.11 · numpy==1.26.4 · pytest==8.2.0
import numpy as np


def test_random_error_averages_down_and_systematic_does_not():
    rng = np.random.default_rng(42)
    truth, n_trials, n_samples = 20.0, 4000, 60
    u = Uncertainty(random=1.8, systematic=5.7)

    means = []
    for _ in range(n_trials):
        bias = rng.normal(0, u.systematic)            # one bias per sensor-hour
        readings = truth + bias + rng.normal(0, u.random, n_samples)
        means.append(readings.mean())

    observed = float(np.std(means))
    predicted = u.after_averaging(n_samples).combined
    assert abs(observed - predicted) / predicted < 0.06     # within 6%


def test_inverse_variance_beats_the_plain_mean():
    values = np.array([21.0, 19.0, 26.0])
    uncertainties = np.array([1.0, 1.0, 8.0])              # the third sensor is poor
    weighted, u = inverse_variance_mean(values, uncertainties)

    assert abs(weighted - 20.0) < abs(values.mean() - 20.0)  # closer to the reference pair
    assert u < uncertainties.min()                            # combining beats any single input


def test_uncertainty_of_a_mean_never_falls_below_the_systematic_floor():
    u = Uncertainty(random=1.8, systematic=5.7)
    assert u.after_averaging(100_000).combined >= 5.7

The first test is worth keeping in CI permanently: it is the empirical demonstration that the model is right, and it fails loudly if someone “simplifies” the propagation into a single sqrt(n) division.

Gotchas

Dividing the whole uncertainty by sqrt(n). The single most common error in this area. It produces an hourly mean claiming 0.8 µg/m³ uncertainty from sensors that are 6 µg/m³ off, and because the number is small it is rarely questioned.

Assuming independence across identical hardware. A hundred nodes of the same model calibrated in the same campaign share a systematic error. Averaging them does not remove it, and a spatial mean across the network inherits it in full.

Propagating uncertainty through a non-linear transform linearly. If the reported value is a function of the raw measurement — a humidity correction, a log transform — the uncertainty must be scaled by the derivative at that point, not carried across unchanged.

Reporting a coverage factor without saying so. A “±12 µg/m³” figure means nothing unless the reader knows whether it is k=1 (68%) or k=2 (95%). State the coverage factor in the column description and keep the stored value at k=1.


FAQ

Why does averaging not reduce calibration error?

Because every reading in the average carries the same calibration offset. Averaging reduces the part of the error that differs between readings — random noise — and leaves anything shared by all of them untouched. A sensor reading 4 µg/m³ high all hour produces an hourly mean that is 4 µg/m³ high.

How do I combine readings from sensors with different uncertainties?

Weight by inverse variance: each sensor contributes in proportion to one over its uncertainty squared. That is the minimum-variance combination, and its result is a weighted mean whose uncertainty is smaller than any individual input — the correct way to mix reference-grade and low-cost instruments.

Does spatial interpolation preserve uncertainty?

Only kriging does it natively, through the kriging variance surface. IDW produces a value with no error estimate at all, so if you need one, propagate the input uncertainties through the IDW weights explicitly — the weights are known, so the arithmetic is straightforward.