Skip to main content

2. Precision and variance components (EP05, EP15)

Tags: [S] primary public source, [Y] my synthesis (standard theory, not quoted).

(a) Concept

  • Repeatability = within-run noise. Between-run, between-day add drift. Within-laboratory precision = all three together (one site, one instrument).
  • Variances add; SDs do not. SD_within-lab = sqrt(σ²_day + σ²_run + σ²_rep). [Y]
  • CV% = 100·SD/mean at a given level. Always report at medical decision levels.

(b) Regulatory anchor

Item Statement Tag
EP05 current edition Ed4, published 2025-12-11 (supersedes EP05-A3, 2014) [S] clsi.org
EP05-A3 single-site design 20 days × 2 runs × 2 replicates per sample [S] CLSI 2014 press release
EP05 Ed4 design not verified: paywalled –
EP15 EP15-A3 (2014, reaffirmed 2019), no newer edition listed; completed within 5 working days; bias: ≥25 measurements over ≥5 days [S] clsi.org
What is reported Repeatability and within-lab SD and CV% with 95% CI, per level [Y]

(c) R lab: simulate and analyse an EP05-style study

True values: day SD 1.2, run SD 1.5, repeatability SD 2.0 at a mean of 100.

set.seed(20)
D <- 20; R <- 2; K <- 2
d <- expand.grid(rep = 1:K, run = 1:R, day = 1:D)
d$y <- 100 + rnorm(D, 0, 1.2)[d$day] +
  rnorm(D * R, 0, 1.5)[(d$day - 1) * R + d$run] + rnorm(nrow(d), 0, 2)
d$day <- factor(d$day); d$run <- factor(d$run)

Three estimators, one answer (cross-check required by rule 4)

fit_vca <- anovaVCA(y ~ day/run, d)
vca <- fit_vca$aov.tab[c("day", "day:run", "error"), "VC"]
hand <- ep05_anova(d$y, d$day, d$run, K)[c("day", "run", "repeatability")]
m <- lmer(y ~ 1 + (1 | day) + (1 | day:run), d)
vc <- as.data.frame(VarCorr(m))
reml <- vc$vcov[match(c("day", "day:run", "Residual"), vc$grp)]
cmp <- data.frame(component = c("day", "run", "repeatability"),
                  hand_ANOVA = unname(hand), VCA_anovaVCA = vca, lme4_REML = reml)
kable(cmp, digits = 4, caption = "Variance components: three estimators")
Variance components: three estimators
component hand_ANOVA VCA_anovaVCA lme4_REML
day day 1.0697 1.0697 1.0697
day:run run 0.4096 0.4096 0.4096
error repeatability 4.8088 4.8088 4.8088
stopifnot(isTRUE(all.equal(cmp$hand_ANOVA, cmp$VCA_anovaVCA, tolerance = 1e-8)),
          isTRUE(all.equal(cmp$VCA_anovaVCA, cmp$lme4_REML, tolerance = 1e-4)))

Balanced data: ANOVA and REML coincide unless a component is truncated at 0.

The standard precision table

inf <- VCAinference(fit_vca, alpha = 0.05, VarVC = TRUE)
mu <- mean(d$y)
sdt <- inf$ConfInt$SD$TwoSided
cvt <- inf$ConfInt$CV$TwoSided
sdest <- fit_vca$aov.tab[, "SD"]; names(sdest) <- rownames(fit_vca$aov.tab)
tab <- data.frame(
  Component = c("Repeatability", "Between-run", "Between-day", "Within-laboratory"),
  SD  = sdest[c("error", "day:run", "day", "total")],
  `SD 95% CI` = sprintf("%.2f - %.2f", sdt[c("error", "day:run", "day", "total"), "LCL"],
                        sdt[c("error", "day:run", "day", "total"), "UCL"]),
  `CV%` = 100 * sdest[c("error", "day:run", "day", "total")] / mu,
  `CV% 95% CI` = sprintf("%.2f - %.2f", cvt[c("error", "day:run", "day", "total"), "LCL"],
                         cvt[c("error", "day:run", "day", "total"), "UCL"]),
  check.names = FALSE, row.names = NULL)
kable(tab, digits = 2, caption = sprintf("Precision at mean %.1f (n = %d, %d days)", mu, nrow(d), D))
Precision at mean 99.9 (n = 80, 20 days)
Component SD SD 95% CI CV% CV% 95% CI
Repeatability 2.19 1.80 - 2.81 2.20 1.80 - 2.81
Between-run 0.64 0.00 - 1.56 0.64 0.00 - 1.57
Between-day 1.03 0.00 - 1.69 1.04 0.00 - 1.70
Within-laboratory 2.51 2.15 - 3.01 2.51 2.15 - 3.01

Note the lower CI limit of 0 for day and run: with 19 and 20 df the data cannot exclude zero. The within-lab row has ~70 effective df (Satterthwaite), not 79. [Y]

Verification against a claim (EP15 logic, [Y])

# Pretend a manufacturer claims repeatability SD = 2.0; we ran 5 days x 5 replicates (df = 20)
s_obs <- 2.3; nu <- 20; claim <- 2.0
uvl <- claim * sqrt(qchisq(0.95, nu) / nu)
data.frame(claim, observed = s_obs, df = nu, UVL = round(uvl, 3),
           verified = s_obs <= uvl)
  claim observed df   UVL verified
1     2      2.3 20 2.506     TRUE

Bayesian version (brms)

Weakly informative half-normal priors on all SDs; fitted once and cached in cache/.

cache <- "../cache/ep05_brms_draws.rds"           # posterior draws only (a few KB); delete to refit (about 2 min)
if (file.exists(cache)) {
  dr <- readRDS(cache)
} else {
  b <- brm(y ~ 1 + (1 | day) + (1 | day:run), d, chains = 2, iter = 2000, cores = 2,
           prior = c(prior(normal(100, 20), class = Intercept),
                     prior(normal(0, 3), class = sd),
                     prior(normal(0, 3), class = sigma)),
           backend = "rstan", seed = 1, refresh = 0, silent = 2)
  dr <- as.data.frame(as_draws_df(b))[, c("b_Intercept", "sd_day__Intercept", "sd_day:run__Intercept", "sigma")]
  saveRDS(dr, cache)
}
sd_draws <- cbind(day = dr$sd_day__Intercept, run = dr$`sd_day:run__Intercept`,
                  rep = dr$sigma)
wl <- sqrt(rowSums(sd_draws^2))
q <- function(x) sprintf("%.2f (%.2f - %.2f)", median(x), quantile(x, .025), quantile(x, .975))
kable(data.frame(
  Component = c("Between-day", "Between-run", "Repeatability", "Within-lab"),
  `Bayes median SD (95% CrI)` = c(q(sd_draws[, "day"]), q(sd_draws[, "run"]),
                                  q(sd_draws[, "rep"]), q(wl)),
  `Frequentist SD` = round(c(sdest["day"], sdest["day:run"], sdest["error"], sdest["total"]), 2),
  check.names = FALSE), caption = "Bayes vs ANOVA")
Bayes vs ANOVA
Component Bayes median SD (95% CrI) Frequentist SD
day Between-day 0.95 (0.11 - 1.92) 1.03
day:run Between-run 0.75 (0.04 - 1.69) 0.64
error Repeatability 2.20 (1.82 - 2.71) 2.19
total Within-lab 2.60 (2.22 - 3.18) 2.51

Differences to discuss. The Bayesian SDs for day and run are never exactly 0 and are pulled by the prior when levels are few. Credible ≠ confidence interval. For a claim a regulator expects the CLSI-style estimate; use Bayes sparingly, as supportive or as a way to borrow strength across levels or sites. [Y]

(d) Claims

Appended to claims_ledger.csv (chapter = 2): edition of EP05, EP05-A3 design, EP15 public facts; everything marked [Y] above is synthesis and not in the ledger as a sourced claim.