Gap filling a flux time series

R
missing data
time series
carbon flux
ecology tutorial
Eddy covariance gap filling in R: three missing-data mechanisms in a year of half-hourly net ecosystem exchange, five fills scored against a known budget.
Author

Tidy Ecology

Published

2026-08-02

A flux tower stands over a mixed forest, logging a three-dimensional sonic anemometer and an infrared gas analyser at ten hertz and writing out one covariance every thirty minutes. A calendar year of that is seventeen and a half thousand numbers, each of them a net ecosystem exchange of carbon dioxide in micromoles per square metre per second, negative when the canopy takes carbon up and positive when it gives carbon back. The site is asked for one figure at the end of the year: how much carbon the forest gained or lost, in gC/m2/yr.

That figure is a sum over every half hour of the year, and the tower did not deliver every half hour. Some are gone because the mains failed and the analyser was cold for a day and a half. Some are gone because it rained on the sonic and the diagnostic flag went up. Some are gone because the analyst deleted them on purpose: on calm nights the eddy covariance method undercounts, so half hours with a friction velocity below a site-specific threshold are thrown out as a matter of protocol. Published towers report thirty to forty-five per cent of half hours missing in a typical year, and the annual sum has to be produced anyway.

The gap-filling literature starts from that arithmetic. Falge et al (2001) put the problem in its modern form, comparing fills across a network of sites and showing that the choice of method moves the published annual sum; Reichstein et al (2005) supplied the look-alike matching algorithm most towers now run; Moffat et al (2007) benchmarked fifteen techniques against artificial gaps. What all three keep saying, and what is easy to lose in the software, is that the damage is not proportional to how much is missing. This post measures that on a simulated year where the true flux is known at every half hour, three mechanisms remove half hours in three patterns, and five filling methods compete on two scores at once: the point-level root mean squared error at the gaps, and the annual sum against the truth.

It sits next to the missing-data cluster and does not repeat it. Single imputation: bias and variance works on a rectangular dataset with a missing response, and the quantity at stake is a regression slope; multiple imputation by chained equations fixes the uncertainty single imputation hides, again for a slope. Two things change here. The quantity is a total over the same rows being filled, so a bias of a fraction of a micromole in a typical half hour arrives in the answer multiplied by the length of the year. And the missingness is manufactured by the measurement system rather than by nature: one mechanism per cause, each with its own relationship to the flux, sorted by the taxonomy of Rubin (1976) that missing data: MCAR, MAR and MNAR covers from the general side.

Three sibling posts carry the parts this one leaves alone. Night-time flux and the u-star threshold is where the friction velocity threshold comes from; here it is simply assumed to exist. Partitioning net flux into GPP and respiration takes the filled series apart afterwards, and checking an annual flux budget asks whether the finished number is defensible.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"),
          axis.text = element_text(colour = "#2c3a31"))
}

A year the simulation knows exactly

The drivers come first, because every fill below leans on them. Global radiation follows clear-sky solar geometry at forty-seven degrees north, dimmed by a cloudiness series with a synoptic persistence of a day or two. Air temperature carries a seasonal cycle, a diurnal cycle whose amplitude grows in summer, and a slow weather departure. Vapour pressure deficit is computed from air temperature and a relative humidity that falls when the sun is out and the air is warm.

n_day <- 365
n_hh  <- 48
n_tot <- n_day * n_hh
doy   <- rep(seq_len(n_day), each = n_hh)
hod   <- rep(seq(0, 23.5, by = 0.5), times = n_day)
conv_gc <- 12.011e-6 * 1800        # umol/m2/s for half an hour -> gC/m2

lat_rad  <- 47 * pi / 180
decl     <- 23.45 * pi / 180 * sin(2 * pi * (284 + doy) / 365)
hour_ang <- (hod - 12) * 15 * pi / 180
cos_zen  <- sin(lat_rad) * sin(decl) + cos(lat_rad) * cos(decl) * cos(hour_ang)
rg_pot   <- 1000 * pmax(cos_zen, 0)
is_night <- rg_pot < 20

ar1 <- function(nn, phi, sdv) {
  as.numeric(stats::filter(rnorm(nn, 0, sdv), phi, method = "recursive"))
}

set.seed(20260802)
cloud_z   <- ar1(n_tot, 0.988, 1)
cloud_z   <- cloud_z / sd(cloud_z)
clearness <- 0.32 + 0.62 * plogis(1.1 * cloud_z)
rg        <- rg_pot * clearness

ta_seas <- 9.5 + 10.5 * sin(2 * pi * (doy - 111) / 365)
ta_diur <- (4 + 2.2 * sin(2 * pi * (doy - 111) / 365)) * sin(2 * pi * (hod - 9) / 24)
ta      <- ta_seas + ta_diur + ar1(n_tot, 0.985, 0.42)

es_kpa <- 0.6108 * exp(17.27 * ta / (ta + 237.3))
rh     <- 0.93 - 0.30 * (rg / 900) - 0.011 * (ta - 9.5) + ar1(n_tot, 0.97, 0.012)
rh     <- pmin(pmax(rh, 0.22), 0.99)
vpd    <- 10 * es_kpa * (1 - rh)

print(round(c(half_hours = n_tot, night_share = mean(is_night),
              max_rg = max(rg), mean_daytime_rg = mean(rg[!is_night]),
              min_ta = min(ta), max_ta = max(ta), mean_ta = mean(ta),
              max_vpd = max(vpd), mean_vpd = mean(vpd)), 3))
     half_hours     night_share          max_rg mean_daytime_rg          min_ta 
      17520.000           0.508         815.903         280.584         -10.465 
         max_ta         mean_ta         max_vpd        mean_vpd 
         32.306           9.409          22.906           2.590 

The year holds 17520 half hours, 50.82 per cent of them dark, with radiation peaking at 816 W/m2, air temperature running from -10.46 to 32.31 degrees C, and vapour pressure deficit reaching 22.91 hPa on the driest summer afternoons.

The flux itself is built from two processes that every partitioning scheme assumes. Gross primary production follows a saturating light response scaled by a phenology curve and throttled at high vapour pressure deficit. Ecosystem respiration follows the Lloyd and Taylor temperature function with a small phenological term on top. Net ecosystem exchange is respiration minus production, so it is negative when the canopy is winning.

On top of that goes measurement noise. Richardson et al (2006) measured the random error of half-hourly tower fluxes at eight sites and found two things that a simulation has to carry: the error scales with the size of the flux, and its distribution is double exponential rather than normal, with heavier tails than a Gaussian of the same width.

lai_f   <- plogis((doy - 118) / 9) * plogis((298 - doy) / 11)
alpha_q <- 0.055
gpp_max <- 40
f_vpd   <- exp(-0.035 * pmax(vpd - 8, 0))
gpp     <- lai_f * f_vpd * (alpha_q * rg * gpp_max) / (alpha_q * rg + gpp_max)

r_ref <- 2.0
e0    <- 230
reco  <- r_ref * exp(e0 * (1 / (283.15 - 227.13) - 1 / (ta + 273.15 - 227.13))) *
  (0.7 + 0.5 * lai_f)
nee_true <- reco - gpp

set.seed(4102026)
sig_v <- 0.50 + 0.16 * abs(nee_true)
nee_m <- nee_true + sig_v * (rexp(n_tot) - rexp(n_tot)) / sqrt(2)
eps   <- nee_m - nee_true

truth_annual <- sum(nee_m) * conv_gc
noiseless_annual <- sum(nee_true) * conv_gc
print(round(c(max_gpp = max(gpp), max_reco = max(reco),
              mean_nee = mean(nee_true), mean_night_nee = mean(nee_true[is_night]),
              gpp_annual = sum(gpp) * conv_gc, reco_annual = sum(reco) * conv_gc,
              nee_annual_noiseless = noiseless_annual,
              nee_annual = truth_annual), 3))
             max_gpp             max_reco             mean_nee 
              20.461                7.726               -0.888 
      mean_night_nee           gpp_annual          reco_annual 
               1.680             1180.451              844.027 
nee_annual_noiseless           nee_annual 
            -336.424             -338.902 
print(round(c(mean_sigma = mean(sig_v), sigma_night = mean(sig_v[is_night]),
              sigma_day = mean(sig_v[!is_night]), sd_eps = sd(eps)), 3))
 mean_sigma sigma_night   sigma_day      sd_eps 
      0.967       0.769       1.171       1.082 

Gross primary production peaks at 20.46 umol/m2/s and totals 1180 gC/m2/yr, respiration totals 844 gC/m2/yr, and the difference is an annual net exchange of -338.9 gC/m2/yr. That is the number every method below is trying to recover, and it is a sink of the size a mid-latitude forest reports. Nights average 1.68 umol/m2/s, a steady positive release; the year as a whole averages -0.888.

The measurement noise has a standard deviation of 0.769 umol/m2/s at night and 1.171 in daylight. It is symmetric, so over a whole year most of it cancels: the noiseless annual sum is -336.42 gC/m2/yr and the noisy one -338.9, a difference of 2.48 grams. That is the reason the annual sum is worth computing at all, and it is also why a bias, which does not cancel, is the thing to watch.

Three ways to lose a half hour

The three mechanisms are written separately because they behave separately, and the point of the post is that they are not interchangeable.

Instrument downtime is a power cut, a full disc, a laptop that rebooted, an analyser sent away for repair. It knows nothing about the flux or the weather, and it removes runs rather than isolated half hours. Twenty-six short outages are scattered uniformly through the year, plus one nineteen-day block in early September when the analyser goes off site.

Rain and diagnostics removes half hours through the weather. Rain on the sonic transducer makes the wind measurement unusable, and the analyser’s own flags fire when the optical path is dirty or the sample cell ices up. The probability of loss depends on cloudiness and on air temperature, both of which also drive the flux, so this loss is missing at random given the drivers.

Friction velocity filtering is different in kind, because the analyst does it on purpose. When the air above the canopy is calm, the eddies that carry the flux past the sensor are too weak to be measured, so half hours below a threshold friction velocity are deleted. That threshold is estimated per site and per season; where it comes from is the subject of the u-star threshold post, and here it is fixed at a plausible value. Calm air happens at night, and it happens most on warm, still summer nights, which are exactly the nights when the soil is warm and respiration is at its strongest. The filter therefore removes half hours whose flux value is systematically higher than that of the half hours it leaves behind: missing not at random with respect to the quantity being measured.

set.seed(7112026)
wind_z <- ar1(n_tot, 0.985, 1); wind_z <- wind_z / sd(wind_z)
ustar  <- exp(-1.30 + 0.45 * wind_z + 0.55 * (rg / 900) -
                0.030 * pmax(ta, 0) * is_night + rnorm(n_tot, 0, 0.28))
ustar_thr <- 0.2

## 1: instrument downtime, blocky and indifferent to everything
set.seed(8112026)
n_out  <- 26
out_st <- sample.int(n_tot, n_out)
out_ln <- 10 + rpois(n_out, 46)
m_down <- rep(FALSE, n_tot)
for (k in seq_len(n_out)) {
  m_down[out_st[k]:min(n_tot, out_st[k] + out_ln[k] - 1)] <- TRUE
}
long_start <- (250 - 1) * 48 + 1
long_len   <- 19 * 48
long_idx   <- long_start:(long_start + long_len - 1)
m_down[long_idx] <- TRUE

## 2: rain and diagnostic flags, driven by cloudiness and temperature
set.seed(9112026)
p_rain <- plogis(-1.75 - 5.5 * (clearness - 0.5))
p_diag <- plogis(-4.3 + 0.16 * pmax(-ta, 0))
m_wx   <- (runif(n_tot) < p_rain) | (runif(n_tot) < p_diag)

## 3: the friction velocity filter, applied to night half hours only
m_us <- is_night & ustar < ustar_thr

miss  <- m_down | m_wx | m_us
obs   <- !miss
gaps  <- which(miss)
n_gap <- length(gaps)

print(round(c(downtime = mean(m_down), weather = mean(m_wx),
              ustar_filter = mean(m_us), any_gap = mean(miss),
              downtime_only = mean(m_down & !m_wx & !m_us),
              weather_only = mean(m_wx & !m_down & !m_us),
              ustar_only = mean(m_us & !m_down & !m_wx)), 4))
     downtime       weather  ustar_filter       any_gap downtime_only 
       0.1307        0.1160        0.2167        0.3930        0.0842 
 weather_only    ustar_only 
       0.0817        0.1604 

Coverage lands where a real tower’s does. Downtime touches 13.07 per cent of half hours, weather and diagnostics 11.6 per cent, the friction velocity filter 21.67 per cent, and the union is 39.3 per cent, or 6886 half hours out of 17520. The mechanisms overlap, so the shares do not add: 8.42 per cent is downtime alone, 8.17 per cent weather alone and 16.04 per cent the filter alone.

night_kept <- is_night & obs
print(round(c(night_removed_by_filter = mean(m_us[is_night]),
              mean_flux_kept = mean(nee_true[obs]),
              mean_flux_lost = mean(nee_true[miss]),
              mean_flux_all = mean(nee_true)), 4))
night_removed_by_filter          mean_flux_kept          mean_flux_lost 
                 0.4264                 -1.7133                  0.3861 
          mean_flux_all 
                -0.8882 
print(round(c(night_kept_flux = mean(nee_true[night_kept]),
              night_filtered_flux = mean(nee_true[m_us]),
              night_all_flux = mean(nee_true[is_night]),
              night_kept_temp = mean(ta[night_kept]),
              night_filtered_temp = mean(ta[m_us])), 4))
    night_kept_flux night_filtered_flux      night_all_flux     night_kept_temp 
             1.3467              2.0565              1.6796              4.6151 
night_filtered_temp 
             8.4518 
print(round(c(downtime_flux = mean(nee_true[m_down]),
              weather_flux = mean(nee_true[m_wx])), 4))
downtime_flux  weather_flux 
      -0.9726       -0.4670 
runs <- rle(miss)
run_len <- runs$lengths[runs$values]
print(round(c(n_runs = length(run_len), median_run = median(run_len),
              mean_run = mean(run_len), longest_run_days = max(run_len) / 48,
              share_in_runs_over_a_day = sum(run_len[run_len > 48]) / sum(run_len)), 3))
                  n_runs               median_run                 mean_run 
                1952.000                    1.000                    3.528 
        longest_run_days share_in_runs_over_a_day 
                  19.125                    0.329 

The filter removes 42.64 per cent of all night half hours, and the half hours it removes are not a random sample of the night. Their mean true flux is 2.057 umol/m2/s against 1.347 for the night half hours that survive, because the calm nights are the warm ones: mean air temperature 8.45 degrees C in the deleted half hours against 4.62 in the kept ones. That gap of 0.71 umol/m2/s inside the night is the whole story of the post, and it is invisible in any summary of the data that survives.

The other two mechanisms are much better behaved. Half hours lost to downtime average -0.973 umol/m2/s and half hours lost to weather -0.467, both within a micromole of the year’s mean of -0.888. Put the three together and what survives averages -1.713, nearly twice the true mean.

The gaps are also structured in length. There are 1952 separate runs with a median of 1 half hour, but the longest is 19.12 days and 32.88 per cent of all missing half hours sit inside runs longer than a day. Isolated half hours and multi-day blocks are different problems, and Richardson and Hollinger (2007) showed that long gaps carry an uncertainty of their own that short ones do not.

A dense tile map with day of year from 1 to 365 across the bottom and hour of day from 0 to 24 up the side. The middle band of the panel, the daylight hours, is mostly pale sage green. A red band runs along the top and bottom edges where the night hours are: wide at the left and right edges of the panel but broken up by patches of green, and narrower through the middle of the panel where it is almost solid red. Small gold flecks are scattered across the whole panel. Thin dark vertical lines appear throughout, and one solid dark stripe about nineteen days wide crosses the full height of the panel a little past day 250.
Figure 1: Every half hour of the simulated year, coloured by why it is present or absent. Day of year runs across, hour of day up. Where mechanisms overlap the earlier one in the legend wins. The friction velocity filter takes night hours, so its band is widest at the two ends of the panel where nights are long, and densest through the middle where the calm warm nights are. Weather losses fleck the whole panel and the analyser outage is the solid stripe just past day 250.
Three curves on a panel with hour of day from 0 to 24 across and mean flux from about minus five to plus three micromoles per square metre per second up the side. A pale horizontal line marks zero. A dark curve for all half hours and a green curve for the surviving half hours both run at about plus one and a half through the night, dive to a minimum near minus five in the middle of the day, and climb back after dark. A red curve labelled deleted by the filter appears only at the left and right edges of the panel, in two separate pieces covering the night hours, and in both pieces it lies clearly above the other two, at about plus two and a quarter. Through the night hours the green curve is the lowest of the three.
Figure 2: Mean true net ecosystem exchange by half hour of day, over all half hours, over the half hours that survive the three mechanisms, and over the half hours the friction velocity filter deletes. Negative is uptake. The filter’s curve is drawn only for clock times it deletes at least a hundred times, so it breaks across the middle of the day. The three curves coincide in daylight and separate at night, where the deleted half hours carry the highest respiration.

Five fills

The five candidates run on the same masked series. The first is not a filling method at all: take the mean of the half hours that were measured and multiply by the number of half hours in the year, which is what every field campaign reporting a seasonal total from partial coverage has done, usually without saying so. The second is overall mean imputation, the method single imputation: bias and variance opens with, and it puts the same number in every gap. The third is a mean diurnal course computed in a moving window: for a gap in the small hours of a July night, average all the measured values from the same clock time within a week either side, widening the window if none survive. That is the look-up table approach, and it is what most towers ran before the current algorithm arrived. The fourth is linear interpolation between the last measured value before the gap and the first after it.

The fifth is marginal distribution sampling in the sense of Reichstein et al (2005): look for half hours in a moving window whose radiation, temperature and vapour pressure deficit all match the gap’s within tolerances, and average their fluxes. When no such half hour exists, the algorithm falls back through a fixed ladder of weaker conditions, and reporting how often each rung is used is part of using it honestly.

## tolerances and window lengths shared by the last two methods
tol_rg_day   <- 50      # W/m2 on global radiation
tol_rg_night <- 20      # W/m2 when the gap itself is dark
tol_ta       <- 2.5     # degrees C on air temperature
tol_vpd      <- 5       # hPa on vapour pressure deficit
win_short    <- 7L      # days
win_long     <- 14L
win_max      <- 140L

mdc_fill <- function(y_in, ok_v, gaps_v,
                     wins = c(win_short, win_long, 28L, 56L, win_max)) {
  nn <- length(y_in)
  out <- numeric(length(gaps_v))
  for (k in seq_along(gaps_v)) {
    i <- gaps_v[k]
    got <- FALSE
    for (w in wins) {
      jj <- i + 48L * (-w:w)
      jj <- jj[jj >= 1L & jj <= nn]
      jj <- jj[ok_v[jj]]
      if (length(jj)) { out[k] <- mean(y_in[jj]); got <- TRUE; break }
    }
    if (!got) out[k] <- mean(y_in[ok_v])
  }
  out
}

The look-alike matcher is longer because the fallback ladder is the method. Rung A asks for all three drivers within tolerance inside a window of 7 days either side, then the same inside 14 days. Rung B drops temperature and vapour pressure deficit and matches on radiation alone. Rung C abandons the drivers and takes the mean diurnal course within one day, plus or minus an hour. Rungs D and E widen the first two out to 140 days. The tolerances are those of the published algorithm: 50 W/m2 on radiation, 20 W/m2 when the gap itself is dark, 2.5 degrees C on air temperature and 5 hPa on vapour pressure deficit.

mds_fill <- function(y_in, ok_v, rg_v, ta_v, vpd_v, hod_v, gaps_v) {
  nn <- length(y_in)
  out <- numeric(length(gaps_v))
  tier <- integer(length(gaps_v))
  gmean <- mean(y_in[ok_v])
  win_a <- c(win_short, win_long)
  win_wide <- c(21L, 28L, 42L, 56L, 84L, win_max)
  win_mdc <- c(2L, 4L, win_short, win_long, 28L, 56L, win_max)
  for (k in seq_along(gaps_v)) {
    i <- gaps_v[k]
    tol_rg <- if (rg_v[i] < tol_rg_day) tol_rg_night else tol_rg_day
    val <- NA_real_
    tt <- 0L
    ## A: all three drivers, the short then the long window
    for (jj in seq_along(win_a)) {
      w <- win_a[jj] * 48L
      cand <- max(1L, i - w):min(nn, i + w)
      okc <- ok_v[cand] & abs(rg_v[cand] - rg_v[i]) <= tol_rg &
        abs(ta_v[cand] - ta_v[i]) <= tol_ta & abs(vpd_v[cand] - vpd_v[i]) <= tol_vpd
      if (any(okc)) { val <- mean(y_in[cand[okc]]); tt <- jj; break }
    }
    ## B: radiation alone, short window
    if (is.na(val)) {
      w <- win_short * 48L
      cand <- max(1L, i - w):min(nn, i + w)
      okc <- ok_v[cand] & abs(rg_v[cand] - rg_v[i]) <= tol_rg
      if (any(okc)) { val <- mean(y_in[cand[okc]]); tt <- 3L }
    }
    ## C: mean diurnal course within one day, plus or minus an hour
    if (is.na(val)) {
      cand <- max(1L, i - 50L):min(nn, i + 50L)
      dh <- abs(hod_v[cand] - hod_v[i])
      okc <- ok_v[cand] & (dh <= 1 | dh >= 23)
      if (any(okc)) { val <- mean(y_in[cand[okc]]); tt <- 4L }
    }
    ## D: all three drivers, windows widening to the maximum
    if (is.na(val)) for (w0 in win_wide) {
      w <- w0 * 48L
      cand <- max(1L, i - w):min(nn, i + w)
      okc <- ok_v[cand] & abs(rg_v[cand] - rg_v[i]) <= tol_rg &
        abs(ta_v[cand] - ta_v[i]) <= tol_ta & abs(vpd_v[cand] - vpd_v[i]) <= tol_vpd
      if (any(okc)) { val <- mean(y_in[cand[okc]]); tt <- 5L; break }
    }
    ## E: mean diurnal course, windows widening to the maximum
    if (is.na(val)) for (w0 in win_mdc) {
      w <- w0 * 48L
      cand <- max(1L, i - w):min(nn, i + w)
      dh <- abs(hod_v[cand] - hod_v[i])
      okc <- ok_v[cand] & (dh <= 1 | dh >= 23)
      if (any(okc)) { val <- mean(y_in[cand[okc]]); tt <- 6L; break }
    }
    if (is.na(val)) { val <- gmean; tt <- 7L }
    out[k] <- val
    tier[k] <- tt
  }
  list(value = out, tier = tier)
}

mds <- mds_fill(nee_m, obs, rg, ta, vpd, hod, gaps)
tier_pct <- 100 * tabulate(mds$tier, 7) / n_gap
names(tier_pct) <- c("A_7d", "A_14d", "Rg_only", "diurnal_1d",
                     "A_widened", "diurnal_widened", "series_mean")
print(round(tier_pct, 3))
           A_7d           A_14d         Rg_only      diurnal_1d       A_widened 
         94.148           5.199           0.363           0.029           0.261 
diurnal_widened     series_mean 
          0.000           0.000 

The ladder is barely used. Rung A at 7 days settles 94.15 per cent of the gaps, the 14 -day version another 5.2 per cent, and everything below that accounts for 0.65 per cent. The last two rungs never fire at all. That is a consequence of an assumption made here for clarity and violated by every real dataset: the drivers are complete. At a real tower the radiation sensor and the thermometer have their own gaps, often at the same moment as the flux, and the deeper rungs exist for exactly that. The 0.26 per cent that does reach rung D is the middle of the nineteen-day outage, where no measured half hour exists inside two weeks in either direction.

fill_mean <- nee_m; fill_mean[miss] <- mean(nee_m[obs])
fill_mdc  <- nee_m; fill_mdc[miss]  <- mdc_fill(nee_m, obs, gaps)
fill_lin  <- approx(which(obs), nee_m[obs], xout = seq_len(n_tot), rule = 2)$y
fill_mds  <- nee_m; fill_mds[miss]  <- mds$value
ann_drop  <- mean(nee_m[obs]) * n_tot * conv_gc
print(round(c(scaled_up_mean = ann_drop,
              overall_mean_fill = sum(fill_mean) * conv_gc), 4))
   scaled_up_mean overall_mean_fill 
        -652.6644         -652.6644 
Three stacked panels sharing a horizontal axis of day of year from 216 to 223 and a vertical axis of flux from about minus twenty to plus five micromoles per square metre per second. In every panel a thin grey line shows a repeating daily cycle with troughs near minus fifteen each midday and small positive humps each night. The top panel, labelled diurnal-seasonal mean, has green dots that fill the missing stretches with a smooth repeating daily shape close to the grey line. The middle panel, linear interpolation, has gold dots forming long straight segments, including one that runs level across the two-day gap between days 218 and 220 while the grey line behind it goes on cycling. The bottom panel, look-alike matching, has red dots that follow the grey line closely everywhere, including inside the two-day gap.
Figure 3: One week in August, day 216 to day 222. The grey line is the series the instrument would have recorded had nothing been discarded, drawn for reference; the coloured dots are the values each method puts into the half hours that were in fact lost. The week spans a two-day outage as well as the nightly friction velocity gaps. Linear interpolation draws one long chord across the outage; the diurnal-seasonal mean and the look-alike matcher both rebuild a daily cycle inside it.

The noise floor, and what beating it would mean

Two scores, computed on the same objects. The point-level score is the root mean squared error between the filled value and the value the instrument actually recorded at that half hour, over the 6886 gap half hours. The annual score is the filled series summed and converted, against the truth.

Before reading either, the floor has to be set. No filling method can predict the measurement noise, because the noise is not a function of anything: the best conceivable predictor of a missing half hour is its own noise-free true flux, and even that leaves the noise behind. The root mean squared error of that ideal predictor is the smallest number any method could report.

floor_rmse <- sqrt(mean(eps[miss]^2))

score_of <- function(v, nm) {
  rm <- sqrt(mean((v[miss] - nee_m[miss])^2))
  data.frame(method = nm, rmse = rm, floor_ratio = rm / floor_rmse,
             annual = sum(v) * conv_gc, error = sum(v) * conv_gc - truth_annual)
}
sc <- rbind(
  data.frame(method = "measured mean scaled up", rmse = NA_real_,
             floor_ratio = NA_real_, annual = ann_drop,
             error = ann_drop - truth_annual),
  score_of(fill_mean, "overall mean fill"),
  score_of(fill_mdc, "diurnal-seasonal mean"),
  score_of(fill_lin, "linear interpolation"),
  score_of(fill_mds, "look-alike matching"))
print(sc$method)
[1] "measured mean scaled up" "overall mean fill"      
[3] "diurnal-seasonal mean"   "linear interpolation"   
[5] "look-alike matching"    
print(round(sc[, -1], 4))
    rmse floor_ratio    annual     error
1     NA          NA -652.6644 -313.7623
2 4.3095      4.3560 -652.6644 -313.7623
3 1.4593      1.4750 -355.1445  -16.2424
4 3.4097      3.4465 -281.4295   57.4725
5 1.0677      1.0792 -341.5161   -2.6140
print(round(c(truth = truth_annual, noise_floor = floor_rmse,
              lookalike_own_error =
                sqrt(sc$rmse[5]^2 - floor_rmse^2)), 4))
              truth         noise_floor lookalike_own_error 
          -338.9021              0.9893              0.4016 

The floor is 0.9893 umol/m2/s. Look-alike matching reaches 1.0677, which is 1.0792 times the floor: subtract the noise in quadrature and the method’s own error is 0.4016 umol/m2/s, about two fifths of the noise it is swimming in. That is what a method at the floor looks like, and it means the drivers have been used up. Nothing further can be squeezed out of radiation, temperature and vapour pressure deficit at this site; the remaining error is the instrument’s.

The diurnal-seasonal mean sits at 1.4593, or 1.475 times the floor. Linear interpolation reaches 3.4097 and overall mean imputation 4.3095, 4.356 times the floor, which is the number you get for using no information at all.

The first two rows of the annual column are worth staring at. Scaling up the measured mean gives -652.66 gC/m2/yr and filling every gap with the overall mean gives -652.66, and those are not similar numbers, they are the same number to every digit. Filling a gap with the mean of what you measured and then summing is algebraically identical to summing what you measured and multiplying by the ratio of lengths. The field campaign that scales up its measured mean has performed mean imputation without knowing it, and it inherits every property mean imputation has.

Against a truth of -338.9 gC/m2/yr, that number is -313.76 gC/m2/yr out. The forest is reported as taking up nearly twice as much carbon as it did.

A scatter of four labelled points on a panel whose horizontal axis is root mean squared error from one to about four and a half micromoles per square metre per second on a logarithmic scale, and whose vertical axis is annual error from minus 345 to plus 100 grams of carbon per square metre per year. A red dashed vertical line just left of one is labelled noise floor, and a dashed horizontal line marks zero annual error. Look-alike matching sits just right of the vertical line and almost on the horizontal one. The diurnal-seasonal mean is a little further right and slightly below it. Linear interpolation is far to the right and well above the horizontal line. Overall mean fill is furthest right and near the bottom of the panel.
Figure 4: Point-level root mean squared error at the gaps against the error in the annual sum, for the four methods that produce a value per half hour. The vertical dashed line is the measurement noise floor, which no method can pass. The horizontal dashed line is a correct annual sum. Scaling up the measured mean is not plotted because it makes no half-hourly prediction, but its annual error is identical to that of the overall mean fill.

Root mean squared error and the annual sum are different questions

The scatter above is not a diagonal line, and the departures from one are instructive.

in_long <- rep(FALSE, n_tot)
in_long[long_idx] <- TRUE
short_gap <- miss & !in_long

part_of <- function(v, nm) data.frame(
  method = nm,
  rmse_short = sqrt(mean((v[short_gap] - nee_m[short_gap])^2)),
  gC_short = sum(v[short_gap] - nee_m[short_gap]) * conv_gc,
  gC_outage = sum(v[in_long] - nee_m[in_long]) * conv_gc)
parts <- rbind(part_of(fill_mdc, "diurnal-seasonal mean"),
               part_of(fill_lin, "linear interpolation"),
               part_of(fill_mds, "look-alike matching"))
print(parts$method)
[1] "diurnal-seasonal mean" "linear interpolation"  "look-alike matching"  
print(round(parts[, -1], 4))
  rmse_short gC_short gC_outage
1     1.2926 -20.2274    3.9850
2     2.3955 -21.6199   79.0924
3     0.9507  -5.2055    2.5915
print(round(c(mean_fill_rmse_ratio = sc$floor_ratio[2],
              linear_rmse_ratio = sc$floor_ratio[4],
              mean_fill_annual_error = sc$error[2],
              linear_annual_error = sc$error[4]), 3))
  mean_fill_rmse_ratio      linear_rmse_ratio mean_fill_annual_error 
                 4.356                  3.446               -313.762 
   linear_annual_error 
                57.473 

Take the two worst point-level performers first. Overall mean imputation has a root mean squared error of 4.31 and linear interpolation 3.41, a difference of only 26.39 per cent. Their annual errors are -313.76 and 57.47 gC/m2/yr: opposite in sign and different by a factor of 5.46. A ranking by root mean squared error would call these two methods roughly equivalent. On the question the tower is actually asked, one is wrong by half a year’s uptake and the other by a sixth of it.

Now the reverse case, which is the one that decides what to run. Split linear interpolation’s error between the nineteen-day outage and everything else. Across the short gaps its root mean squared error is 2.396, 1.85 times that of the diurnal-seasonal mean, so on the point-level score it is clearly the worse method. Its contribution to the annual sum over those same half hours is -21.62 gC/m2/yr against the diurnal-seasonal mean’s -20.23. Two methods a factor of 1.85 apart in point accuracy land within 1.39 gC/m2/yr of each other on the budget, because interpolation’s errors over short gaps are symmetric and cancel in the sum.

The whole of linear interpolation’s annual error, 57.47 gC/m2/yr, and more than the whole of it, comes from a single stretch: the outage contributes 79.09 gC/m2/yr on its own. Drawing a straight line across nineteen days of early September ignores the fact that the canopy was shutting down while the soil was still warm, and no amount of averaging over the rest of the year repairs it. The look-alike matcher gets 2.59 gC/m2/yr from the same stretch and the diurnal-seasonal mean 3.98, because both of them reach for other days with similar weather instead of for the two endpoints.

The general form: root mean squared error rewards getting each half hour right, and a sum only cares whether the errors cancel. A method with a large symmetric error can be nearly unbiased on the annual total, and a method with a small but one-sided error will not be. Moffat et al (2007) reached the same conclusion from their comparison, which is why they scored techniques on bias and on error separately rather than picking a winner.

The mechanism, not the amount

Everything so far has treated the 39.3 per cent missing as one quantity. It is not. The test is to keep the same number of missing half hours and change only where they fall: take the 3796 half hours removed by the friction velocity filter, put them back, and delete the same number of half hours chosen uniformly at random from the whole year instead. Downtime and weather stay exactly as they were.

set.seed(1212026)
n_us   <- sum(m_us)
m_rand <- rep(FALSE, n_tot)
m_rand[sample.int(n_tot, n_us)] <- TRUE
miss_b <- m_down | m_wx | m_rand
obs_b  <- !miss_b
gaps_b <- which(miss_b)

fb_mean <- nee_m; fb_mean[miss_b] <- mean(nee_m[obs_b])
fb_mdc  <- nee_m; fb_mdc[miss_b]  <- mdc_fill(nee_m, obs_b, gaps_b)
fb_lin  <- approx(which(obs_b), nee_m[obs_b], xout = seq_len(n_tot), rule = 2)$y
fb_mds  <- nee_m
fb_mds[miss_b] <- mds_fill(nee_m, obs_b, rg, ta, vpd, hod, gaps_b)$value

swap <- data.frame(
  method = sc$method,
  as_measured = sc$error,
  reshuffled = c(mean(nee_m[obs_b]) * n_tot * conv_gc - truth_annual,
                 sum(fb_mean) * conv_gc - truth_annual,
                 sum(fb_mdc) * conv_gc - truth_annual,
                 sum(fb_lin) * conv_gc - truth_annual,
                 sum(fb_mds) * conv_gc - truth_annual))
print(swap$method)
[1] "measured mean scaled up" "overall mean fill"      
[3] "diurnal-seasonal mean"   "linear interpolation"   
[5] "look-alike matching"    
print(round(swap[, -1], 3))
  as_measured reshuffled
1    -313.762    -22.895
2    -313.762    -22.895
3     -16.242     -7.036
4      57.473     86.677
5      -2.614      0.696
print(round(c(gaps_original = mean(miss), gaps_reshuffled = mean(miss_b),
              kept_mean_original = mean(nee_true[obs]),
              kept_mean_reshuffled = mean(nee_true[obs_b]),
              true_mean = mean(nee_true)), 4))
       gaps_original      gaps_reshuffled   kept_mean_original 
              0.3930               0.3984              -1.7133 
kept_mean_reshuffled            true_mean 
             -0.9421              -0.8882 

The reshuffled year is missing 39.84 per cent of its half hours against 39.3 per cent before, so if anything it has slightly less data. Mean imputation’s annual error goes from -313.76 gC/m2/yr to -22.89, a collapse of 92.7 per cent for no change in coverage whatever. The mean of the surviving half hours moves from -1.7133 umol/m2/s to -0.9421, against a true mean of -0.8882.

That is the finding the whole post exists for. Mean imputation of a year of flux is not biased because 39.3 per cent of the year is missing. It is biased because the missing half hours are the high-respiration ones. Delete just as many at random and the same crude method comes within 22.9 gC/m2/yr, and that residual is the downtime and weather mechanisms, which are mildly non-random themselves and are still worth 22.9 grams between them.

The better methods barely notice the swap. Look-alike matching goes from -2.61 to 0.7 gC/m2/yr and the diurnal-seasonal mean from -16.24 to -7.04 , because both of them condition on time of day and on the drivers, which is precisely the information that makes the friction velocity filter ignorable. Linear interpolation gets worse, 57.47 to 86.68, because scattering isolated gaps through the daytime asks it to draw chords across the steep parts of the diurnal cycle.

A dumbbell chart with four method labels down the left side and annual error from about minus 330 to plus 100 grams of carbon per square metre per year across the bottom. A dashed vertical line marks zero. For overall mean fill a long grey bar runs from a red dot far to the left at about minus 314 to a green dot close to zero at about minus 23. For the diurnal-seasonal mean the two dots sit close together just left of the line, and for look-alike matching they almost overlap on it. For linear interpolation the green dot lies to the right of the red one, both on the positive side.
Figure 5: Error in the annual sum for each method, with the friction velocity gaps in place and with the same number of gaps moved to random positions in the year. Instrument downtime and weather losses are identical in both. The methods that condition on time of day and on the drivers hardly move; the methods that do not have almost all of their error removed by the reshuffle.

An honest error bar for the annual sum

The tower has to publish an uncertainty as well as a number. The usual first attempt propagates the random measurement error: each measured half hour carries a known error standard deviation, the errors are close to independent, so the standard error of their sum is the square root of the sum of their variances.

The honest version has to include the fact that a third of the year was invented. Repeating the whole exercise gives that: draw a fresh noise realisation, draw a fresh set of gaps from the same three mechanisms, fill with the look-alike matcher, and record the annual sum. The loop below runs two hundred replicates, which is modest but enough to pin a standard deviation to within about five per cent. It also runs a second version in which the noise is redrawn but the gap positions are held at the ones the tower actually suffered, so that the two sources can be told apart.

n_rep <- 200
ann_fixed <- numeric(n_rep)
ann_full  <- numeric(n_rep)
for (b in seq_len(n_rep)) {
  set.seed(500000 + b)
  yb <- nee_true + sig_v * (rexp(n_tot) - rexp(n_tot)) / sqrt(2)
  fa <- yb
  fa[miss] <- mds_fill(yb, obs, rg, ta, vpd, hod, gaps)$value
  ann_fixed[b] <- sum(fa) * conv_gc
  ub <- exp(-1.30 + 0.45 * wind_z + 0.55 * (rg / 900) -
              0.030 * pmax(ta, 0) * is_night + rnorm(n_tot, 0, 0.28))
  md_b <- rep(FALSE, n_tot)
  st_b <- sample.int(n_tot, n_out)
  ln_b <- 10 + rpois(n_out, 46)
  for (k in seq_len(n_out)) {
    md_b[st_b[k]:min(n_tot, st_b[k] + ln_b[k] - 1)] <- TRUE
  }
  md_b[long_idx] <- TRUE
  mm <- md_b | (runif(n_tot) < p_rain) | (runif(n_tot) < p_diag) |
    (is_night & ub < ustar_thr)
  fb <- yb
  fb[mm] <- mds_fill(yb, !mm, rg, ta, vpd, hod, which(mm))$value
  ann_full[b] <- sum(fb) * conv_gc
}

se_naive <- sqrt(sum(sig_v[obs]^2)) * conv_gc
qq <- quantile(ann_full, c(0.025, 0.975))
print(round(c(replicates = n_rep, truth = truth_annual,
              mean_annual = mean(ann_full), sd_annual = sd(ann_full),
              bias = mean(ann_full) - truth_annual,
              sd_fixed_gaps = sd(ann_fixed),
              naive_se = se_naive,
              ratio = sd(ann_full) / se_naive), 4))
   replicates         truth   mean_annual     sd_annual          bias 
     200.0000     -338.9021     -339.6641        4.4053       -0.7620 
sd_fixed_gaps      naive_se         ratio 
       4.5101        2.5893        1.7014 
print(round(c(lower = unname(qq[1]), upper = unname(qq[2]),
              width = unname(qq[2] - qq[1]),
              diurnal_estimate = sc$annual[3],
              diurnal_in_sd_units = (sc$annual[3] - mean(ann_full)) / sd(ann_full),
              mean_fill_in_sd_units =
                (sc$annual[2] - mean(ann_full)) / sd(ann_full)), 3))
                lower                 upper                 width 
             -348.162              -331.135                17.026 
     diurnal_estimate   diurnal_in_sd_units mean_fill_in_sd_units 
             -355.145                -3.514               -71.051 

The naive standard error is 2.589 gC/m2/yr. The spread of the annual sum over 200 replicates of the whole process is 4.405, larger by a factor of 1.701. A tower reporting the naive figure would publish an interval 41.2 per cent too narrow.

Where the extra comes from is not where I expected. Holding the gap positions fixed and redrawing only the noise gives 4.51 gC/m2/yr, which is essentially the full figure: which half hours happen to be lost contributes almost nothing once the mechanism is held fixed. The gap between 2.589 and 4.405 is therefore not about gap placement at all. It is that a filled half hour inherits the noise of the handful of look-alikes it was averaged from, so the filled third of the year does not average its error down the way an independent measurement would, and the propagated measurement error leaves that out. In quadrature the filled half hours add 3.564 gC/m2/yr of their own.

The mean over replicates is -339.66 gC/m2/yr against a truth of -338.9, a bias of -0.762, so the look-alike matcher is close to unbiased here and the interval is a fair one. The central 95 per cent of the replicates runs from -348.16 to -331.14 gC/m2/yr, a width of 17.03.

Then the number that puts the interval in its place. The diurnal-seasonal mean, a method nobody would call unreasonable, returns -355.14 gC/m2/yr for this same year, which is 3.51 standard deviations from the centre of that interval and well outside it. Mean imputation is 71.1 standard deviations away. The uncertainty a gap-filling replicate study produces is conditional on the filling method, and the spread between methods is a term the interval does not contain and cannot. Hui et al (2004) made the same point from the other direction by treating gap filling as a multiple imputation problem, where the between-imputation variance is at least reported; Papale et al (2006), building the standard European processing chain, ended up quoting uncertainty from a set of processing choices rather than from one.

A histogram of two hundred values on a horizontal axis of annual net exchange running from about minus 357 to minus 327 grams of carbon per square metre per year. The bars form a single mound centred near minus 340, with one isolated bar out at the right-hand end. A dark vertical line labelled truth stands near the centre of the mound. Two horizontal bars with end caps are drawn below the histogram, both centred on the mound: a gold one spanning roughly minus 345 to minus 335 and, beneath it, a dark green one noticeably wider, spanning roughly minus 348 to minus 331. A red dashed vertical line labelled diurnal-seasonal mean stands near minus 355, well to the left of both bars and clear of every histogram bar.
Figure 6: Distribution of the annual sum over two hundred replicates of the whole noise-and-gap process, filled by look-alike matching. The solid vertical line is the truth. The pale band is the naive interval from propagating the measurement error of the measured half hours only; the darker band is the interval the replicates give. The diurnal-seasonal mean’s estimate for the same year is marked at the left, outside both.

The honest limit

There is no truth column at a real tower, and that is not a detail. Every number above is a distance from a value the simulation set; in the field the only way to score a filling method is to hide half hours that were measured and put them back, which tests the method on the missingness it can already handle and says nothing about the half hours the friction velocity filter took. Moffat et al (2007) benchmarked on artificial gaps for exactly this reason, and were explicit that artificial gaps are a lower bound on the difficulty.

The simulation carries only random error. A real half-hourly flux has been through a coordinate rotation, a spectral correction for the frequency response of the instruments and a density correction for the effect of heat and water vapour transport on the measured mixing ratio, and each of those is a model with its own systematic error that does not cancel over a year. Energy balance closure at most sites falls short by ten to thirty per cent, a standing hint that something is being missed at the half-hourly level. Those errors sit on top of everything measured here and, unlike the noise, they do not average away.

The friction velocity filter is treated here as pure data loss, with a perfectly good value sitting behind the mask, and that is generous to it. The filter exists because the measured value on a calm night is itself wrong rather than merely unusual: carbon accumulates in the canopy air space instead of passing the sensor. The real situation is therefore worse than the simulated one, since the half hours removed are both non-randomly selected and unusable, and the fill has to reconstruct a quantity that was never measured well anywhere in that part of the driver space. Papale et al (2006) put the uncertainty from the threshold choice alone at a size comparable with everything else in the processing chain.

The drivers are complete here and they are not complete anywhere. When the mains fails it takes the radiation sensor with the analyser, so the look-alike matcher loses its matching variables precisely when it needs them, which is what the lower rungs of the ladder are for and why they fired 0.65 per cent of the time here instead of a realistic several per cent. That understates the difficulty of the long outage in particular. And the size of the bias reported above belongs to this site and this filter: a threshold of 0.2 m/s removing 42.6 per cent of night half hours is severe but not unusual, a windier site loses fewer and a sheltered valley more, and the annual error from mean imputation scales with that loss. What transfers is not the number -314 but the mechanism behind it.

Where to go next

The practical summary is short. Do not scale up a measured mean, because it is mean imputation and it inherits the full weight of whatever made the data missing. Condition the fill on time of day at minimum and on the drivers if they exist, since that is what converts a missing-not-at-random loss into one the fill can handle. Report the point-level error against the noise floor rather than on its own, because a root mean squared error only means something relative to what was achievable. And treat any single uncertainty figure as conditional: rerun the whole chain with a second filling method and quote the gap between them, since that gap is usually larger than the interval either method reports.

The threshold this post assumed is estimated in night-time flux and the u-star threshold, worth reading first if the filter’s severity is what you are unsure about. Once a filled series exists, splitting it into gross uptake and respiration is partitioning net flux into GPP and respiration, and the checks that decide whether the resulting budget can be published are in checking an annual flux budget. For the statistical side, multiple imputation by chained equations is the general machinery for turning a fill into an honest interval, and the flux literature’s version of it is Hui et al (2004); the reason it has not displaced the deterministic fill is partly inertia and partly that the between-imputation variance answers only one of the two questions asked here.

References

Falge E, Baldocchi D, Olson R, Anthoni P, Aubinet M, et al 2001 Agricultural and Forest Meteorology 107(1):43-69 (10.1016/S0168-1923(00)00225-2)

Reichstein M, Falge E, Baldocchi D, Papale D, Aubinet M, et al 2005 Global Change Biology 11(9):1424-1439 (10.1111/j.1365-2486.2005.001002.x)

Moffat AM, Papale D, Reichstein M, Hollinger DY, Richardson AD, et al 2007 Agricultural and Forest Meteorology 147(3-4):209-232 (10.1016/j.agrformet.2007.08.011)

Papale D, Reichstein M, Aubinet M, Canfora E, Bernhofer C, et al 2006 Biogeosciences 3(4):571-583 (10.5194/bg-3-571-2006)

Richardson AD, Hollinger DY, Burba G, Davis KJ, Flanagan LB, et al 2006 Agricultural and Forest Meteorology 136(1-2):1-18 (10.1016/j.agrformet.2006.01.007)

Richardson AD, Hollinger DY 2007 Agricultural and Forest Meteorology 147(3-4):199-208 (10.1016/j.agrformet.2007.06.004)

Hui D, Wan S, Su B, Katul G, Monson R, Luo Y 2004 Agricultural and Forest Meteorology 121(1-2):93-111 (10.1016/S0168-1923(03)00158-8)

Rubin DB 1976 Biometrika 63(3):581-592 (10.1093/biomet/63.3.581)

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.