## ----setup, output=FALSE, warning=FALSE, message=FALSE------------------------ library(serosv) library(dplyr) library(tidyr) library(magrittr) ## ----`generate mock data`, message=FALSE, warning=FALSE, output=FALSE--------- set.seed(1) # Config n_samples <- 50 n_plates <- 5 dilutions <- c(50, 100, 200) ref_conc_antitoxin <- 10 # 4PL function: OD = D + (A - D) / (1 + 10^((log10(conc) - c) * b)) mock_4pl <- function(conc, A = 0, B = 1.8, C = -2.5, D = 4) { D + (A - D) / (1 + 10^(( log10(conc) - C)*B)) } # Assign each sample a "true" concentration (UI/ml) # ~60% positive (conc > 0.1), ~40% negative sample_meta <- tibble( SAMPLE_ID = sprintf("S%03d", 1:n_samples), PLATE_ID = rep(1:n_plates, length.out = n_samples), true_conc = c( runif(round(n_samples * 0.8), 0.15, 100), # positives runif(round(n_samples * 0.2), 0.001, 0.09) # negatives ) %>% sample() # shuffle ) # Negative control concentrations per plate (one fixed OD per plate per dilution) neg_ctrl_conc <- tibble( PLATE_ID = 1:n_plates, true_neg_conc = runif(n_plates, 0.001, 0.04), neg_conc_50 = true_neg_conc/50, neg_conc_100 = true_neg_conc/100, neg_conc_200 = true_neg_conc/200 ) neg_ctrl <- neg_ctrl_conc %>% mutate( NEGATIVE_50 = round(mock_4pl(neg_conc_50) + rnorm(n_plates, 0, 0.003), 4), NEGATIVE_100 = round(mock_4pl(neg_conc_100) + rnorm(n_plates, 0, 0.003), 4), NEGATIVE_200 = round(mock_4pl(neg_conc_200) + rnorm(n_plates, 0, 0.003), 4) ) %>% select(PLATE_ID, NEGATIVE_50, NEGATIVE_100, NEGATIVE_200) # Antitoxin antitoxin_conc <- c(50, 100, 200, 400, 800, 1600, 3200, 6400, 12800, 25600, 51200, 102400) antitoxin <- tibble( SAMPLE_ID = rep("Antitoxin", n_plates), true_conc = rep(ref_conc_antitoxin, n_plates), PLATE_ID = 1:n_plates ) %>% crossing( DILUTION = antitoxin_conc ) %>% mutate( eff_conc = true_conc/DILUTION ) # Generate one row per sample × dilution, compute OD via 4PL + noise simulated_data <- sample_meta %>% crossing(DILUTION = dilutions) %>% mutate( eff_conc = true_conc / DILUTION ) %>% bind_rows( antitoxin ) %>% mutate( # Effective concentration decreases with dilution od_mean = mock_4pl(eff_conc, B = runif(n(), 1.7, 1.9), C = runif(n(), -2.7, -2.4)), noise = rnorm(n(), 0, 0.008), RESULT = round(pmax(od_mean + noise, 0.02), 4), # Blanks: clearly below result (reagent background only) BLANK_1 = round(runif(n(), 0.010, pmin(RESULT - 0.01, 0.045)), 4), BLANK_2 = round(runif(n(), 0.010, pmin(RESULT - 0.01, 0.045)), 4), BLANK_3 = round(runif(n(), 0.010, pmin(RESULT - 0.01, 0.045)), 4) ) %>% left_join(neg_ctrl, by = "PLATE_ID") %>% select( SAMPLE_ID, PLATE_ID, DILUTION, RESULT, # BLANK_1, BLANK_2, BLANK_3, NEGATIVE_50, NEGATIVE_100, NEGATIVE_200 ) %>% arrange(PLATE_ID, SAMPLE_ID, DILUTION) ## ----------------------------------------------------------------------------- simulated_data ## ----------------------------------------------------------------------------- standardized_dat <- standardize_data( simulated_data, plate_id_col = "PLATE_ID", # specify the column for plate id id_col = "SAMPLE_ID", # specify the column for sample id result_col = "RESULT", # specify the column for raw assay readings dilution_fct_col= "DILUTION", # specify the column for dilution factor antitoxin_label = "Antitoxin", # specify the label for antitoxin (in the sample id column) negative_col = "^NEGATIVE_*" # (optionally) specify the regex (i.e., pattern) for columns for negative controls ) standardized_dat ## ----------------------------------------------------------------------------- out <- to_titer( standardized_dat, model = "4PL", ci = 0.95, positive_threshold = 0.1, negative_control = TRUE ) ## ----------------------------------------------------------------------------- out ## ----------------------------------------------------------------------------- out %>% select(plate_id, processed_data) %>% unnest(processed_data) %>% select(plate_id, sample_id, result, lower, median, upper, positive) ## ----------------------------------------------------------------------------- # visualize standard curves with datapoints for antitoxin plot_standard_curve(out, facet=TRUE) # set facet to FALSE to view all the curves together plot_standard_curve(out, facet=FALSE) ## ----------------------------------------------------------------------------- # set facet to FALSE to view all the curves together plot_standard_curve(out, facet=FALSE) + add_thresholds( dilution_factors = c(50, 100, 200), positive_threshold = 0.1 ) ## ----------------------------------------------------------------------------- plot_titer_qc( out, n_plates = NULL, # maximum number of plates to visualize, if NULL then plot all n_samples = 10, # maximum number of samples per plate to visualize n_dilutions = 3 # number of dilutions used for testing ) ## ----message=FALSE, warning=FALSE--------------------------------------------- library(mvtnorm) library(purrr) # custom model function custom_4PL <- function (df){ good_guess4PL <- function(x, y, eps = 0.3) { nb_rep <- unique(table(x)) the_order <- order(x) x <- x[the_order] y <- y[the_order] a <- min(y) d <- max(y) c <- approx(y, x, (d - a)/2, ties = "ordered")$y list(a = a, c = c, d = d, b = (approx(x, y, c + eps, ties = "ordered")$y - approx(x, y, c - eps, ties = "ordered")$y)/(2 * eps)) } nls( result ~ d + (a - d) / (1 + 10 ^ ((log10(concentration) - c) * b)), data = df, start = with(df, good_guess4PL(log10(concentration), result)) ) } # custom quantify CI function for the model custom_quantify_ci <- function(object, newdata, nb = 9999, alpha = 0.025){ rowsplit <- function(df) split(df, 1:nrow(df)) nb |> rmvnorm(mean = coef(object), sigma = vcov(object)) |> as.data.frame() |> rowsplit() |> map(as.list) |> map(~ c(.x, newdata)) |> map_dfc(eval, expr = parse(text = as.character(formula(object))[3])) %>% apply(1, quantile, c(alpha, .5, 1 - alpha)) %>% t() %>% as.data.frame() %>% setNames(c("lower", "median", "upper")) } ## ----------------------------------------------------------------------------- # use the custom model and quantify ci function custom_mod <- list( "mod" = custom_4PL, "quantify_ci" = custom_quantify_ci ) custom_mod_out <- to_titer( standardized_dat, model = custom_mod, positive_threshold = 0.1, ci = 0.95, negative_control = TRUE) ## ----------------------------------------------------------------------------- plot_standard_curve(custom_mod_out, facet=TRUE)