<- 10 ref_conc
Processing tetanus ELISA data
Warning: Anti_toxin
was missing on the cell 68F
of the tab Plate_22_hcdc
.
1 Parameters
The concentration of the reference anti-toxin is 10 IU/mL:
The threshold for positivity (in IU/mL):
<- .1 positive_threshold
The path to the data:
<- paste0(Sys.getenv("HOME"), "/Library/CloudStorage/",
path2data "OneDrive-OxfordUniversityClinicalResearchUnit/",
"GitHub/choisy/tetanus/")
The name of the data file:
<- "Tetanus_Dr. Thinh_HCDC samples.xlsx" datafile
2 Packages
Required packages:
<- c("dplyr", "purrr", "stringr",
required_packages "tidyr", "readxl", "readr", "mvtnorm")
Installing those that are not installed:
<- required_packages[! required_packages %in% installed.packages()[, "Package"]]
to_in if (length(to_in)) install.packages(to_in)
Loading some for interactive use:
library(dplyr)
library(purrr)
Warning: package 'purrr' was built under R version 4.5.1
library(stringr)
3 General functions
Tuning some base functions:
<- 2
lwd_val <- 4
color_data <- 2
color_model
<- function(file) paste0(path2data, file)
make_path <- function(file, ...) readxl::read_excel(make_path(file), ...)
read_excel2 <- function(file) readxl::excel_sheets(make_path(file))
excel_sheets2 <- function(...) plot(..., col = color_data)
plot2 <- function(...) seq(..., le = 512)
seq2 <- function(...) plot(..., type = "l", col = color_model, lwd = lwd_val)
plotl <- function(...) points(..., col = color_data, pch = 3, lwd = lwd_val)
points2 <- function(...) lines(..., col = color_model, lwd = lwd_val)
lines2 <- function(x) print(x, n = nrow(x))
print_all <- function(...) approx(..., ties = "ordered")
approx2
<- function(x, y1, y2, ...) {
polygon2 polygon(c(x, rev(x)), c(y1, rev(y2)), border = NA, ...)
}
<- function(x, file, ...) {
write_delim2 ::write_delim(x, make_path(paste0("outputs/", file)), ...)
readr }
A function that draws the frame of a plot:
<- bquote(log[10](concentration))
log_concentration_lab
<- function(...) {
plot_frame plot(..., type = "n",
xlab = log_concentration_lab, ylab = "optical density")
}
A function that splits the rows of a dataframe into a list of dataframes:
<- function(df) split(df, 1:nrow(df)) rowsplit
A function that simulates data from an nls
object:
<- function(object, newdata, nb = 9999) {
simulate_nls |>
nb ::rmvnorm(coef(object), vcov(object)) |>
mvtnormas.data.frame() |>
rowsplit() |>
map(as.list) |>
map(~ c(.x, newdata)) |>
map_dfc(eval, expr = parse(text = as.character(formula(object))[3]))
}
Functions for multipanel plots:
<- 3 / 2
mcx
<- function(mfrow) {
par_mfrow par(mfrow = mfrow, mex = mcx, cex.axis = mcx, cex.lab = mcx, cex.main = mcx)
}
4 Preparing the data
A function that removes the plate slot(s) that do(es) not contain any data:
<- function(x) x[map_lgl(x, ~ ! all(is.na(.x$RESULT)))] remove_empty_plates
A function that adds the sample ID whenever missing:
<- function(x) {
add_sample_id <- x$HCDC_SAMPLE_ID
id
$HCDC_SAMPLE_ID <- grep("Anti", id, value = TRUE, invert = TRUE) |>
xna.exclude() |>
unique() |>
rep(each = 3) |>
c(grep("Anti", id, value = TRUE))
x }
Reading and arranging the data:
<- datafile |>
plates excel_sheets2() |>
grepl("Plate", .x)])() |>
(\(.x) .x[setNames(map(.x, read_excel2, file = datafile), .x))() |>
(\(.x) map(~ setNames(.x, toupper(names(.x)))) |>
remove_empty_plates() |>
map(add_sample_id)
5 Specific functions
A 4-parameter logistic model that relates optical density \(\mbox{OD}\) to the logarithm of the concentration \(\mbox{LC}\):
\[ \mbox{OD} = d + \frac{a - d}{1 + 10^{\left(\mbox{LC} - c\right)b}} \]
where:
- \(a\) is the minimum \(\mbox{OD}\), i.e. when the concentration is \(0\);
- \(d\) is the maximum \(\mbox{OD}\), i.e. when the concentration is \(+\infty\);
- \(c\) is the \(\mbox{LC}\) of the point of inflexion, i.e. where \(\mbox{OD} = (d - a) / 2\);
- \(b\) is the Hill’s slope of the curve, i.e. the slope of the curve at the inflexion point.
Two functions that implement the calibration of a 4PL model:
<- function(x, y, eps = .3) {
good_guess4PL <- unique(table(x))
nb_rep <- order(x)
the_order <- x[the_order]
x <- y[the_order]
y <- min(y)
a <- max(y)
d <- approx2(y, x, (d - a) / 2)$y
c list(a = a, c = c, d = d,
b = (approx2(x, y, c + eps)$y - approx2(x, y, c - eps)$y) / (2 * eps))
}
<- function(df) {
nls4PL nls(RESULT ~ d + (a - d) / (1 + 10^((log10(concentration) - c) * b)),
with(df, good_guess4PL(log10(concentration), RESULT)))
df, }
A function that generates the standard curve with confidence interval in the form of a dataframe:
<- function(df, model, le = 512, level = .95, nb = 9999) {
standard_curve_data <- log10(df$concentration)
log_concentration <- seq(min(log_concentration), max(log_concentration), le = le)
logc <- (1 - level) / 2
alpha |>
df model() |>
simulate_nls(list(concentration = 10^logc), nb) |>
apply(1, quantile, c(alpha, .5, 1 - alpha)) |>
t() |> as.data.frame() |>
setNames(c("lower", "median", "upper")) |>
bind_cols(logc = logc, .x))()
(\(.x) }
A function that plots the output of standard_curve_data()
with or without data:
<- function(scdf, data = NULL, ylim = NULL) {
plot_standard_curve with(scdf, {
if (is.null(ylim)) ylim <- c(0, max(upper, data$RESULT))
plot_frame(logc, scdf$lower, ylim = ylim)
polygon2(logc, lower, upper, col = adjustcolor(color_model, .2))
lines2(logc, median)
})if (! is.null(data)) with(data, points2(log10(concentration), RESULT))
}
A function that converts a dataframe into a function:
<- function(df) {
data2function with(df, {
<- function(...) approxfun(y = logc, ...)
approxfun2 <- approxfun2(upper)
pred_lwr <- approxfun2(median)
pred_mdi <- approxfun2(lower)
pred_upp function(x) c(lower = pred_lwr(x), median = pred_mdi(x), upper = pred_upp(x))
}) }
A function that retrieves the anti-toxins data from a plate:
<- function(plate) {
get_antitoxins |>
plate filter(HCDC_SAMPLE_ID == "Anti_toxin") |>
mutate(concentration = ref_conc / DILUTION_FACTORS)
}
A function that retrieves the samples data from a plate and computes the log-concentrations:
<- function(plate, std_crv) {
process_samples |>
plate filter(HCDC_SAMPLE_ID != "Anti_toxin") |>
rowwise() |>
mutate(logconcentration = list(std_crv(RESULT))) |>
::unnest_wider(logconcentration)
tidyr }
An example on the first plate:
<- plates$Plate_01_hcdc plate
The 4 steps:
# step 1: retrieve the anti-toxins data:
<- get_antitoxins(plate)
anti_toxins # step 2: generate the standard curve with CI in the form of a dataframe:
<- standard_curve_data(anti_toxins, nls4PL)
standard_curve_df # step 3: convert the standard curve dataframe into a standard curve function:
<- data2function(standard_curve_df)
standard_curve # step 4: convert the OD of the samples into log-concentrations:
<- process_samples(plate, standard_curve) samples
The plots of the standard curve, with and without data:
<- unique(samples$DILUTION_FACTORS)
dilutions_factors <- function() {
add_dilutions abline(v = log10(positive_threshold / c(1, dilutions_factors)),
lwd = lwd_val, col = 3)
}
plot_standard_curve(standard_curve_df); add_dilutions()
plot_standard_curve(standard_curve_df, anti_toxins); add_dilutions()
6 Processing all the plates
The four steps applied to all the plates (takes about 30”):
<- map(plates, get_antitoxins)
anti_toxins <- map(anti_toxins, standard_curve_data, nls4PL)
standard_curve_df <- map(standard_curve_df, data2function)
standard_curves <- map2(plates, standard_curves, process_samples) samples
6.1 The standard curves
Showing all the standard curves together:
<- map(standard_curve_df, ~ .x$logc)
xs <- map(standard_curve_df, ~ .x$median)
ys plot_frame(unlist(xs), unlist(ys))
walk2(xs, ys, lines2)
add_dilutions()
Showing the standard curves and data points, plate by plate:
<- anti_toxins |>
titles names() |>
str_remove("_hcdc") |>
str_replace("_", " ")
<- par_mfrow(c(8, 3))
opar walk(seq_along(anti_toxins), function(i) {
plot_standard_curve(standard_curve_df[[i]], anti_toxins[[i]], ylim = c(- .2, 4))
mtext(titles[i], line = -4, at = -1.5)
})par(opar)
Showing the distributions of the ODs for the 3 dilutions, across the plates, together with the standard curves:
<- map_dfc(dilutions_factors,
dilutions function(x) map(samples, ~ .x |>
filter(DILUTION_FACTORS == x) |>
pull(RESULT)) |> unlist())
<- c(-.3, 4.1)
xlim <- function(x, y, ...) {
hist2 plot(unlist(ys), unlist(xs), type = "n", xaxs = "i", xlim = xlim,
xlab = "optical density", ylab = bquote(log[10](concentration)))
walk2(ys, xs, lines2)
par(new = TRUE)
hist(x, breaks = seq(0, 4, .1), col = color_data, xaxs = "i",
axes = FALSE, ann = FALSE, xlim = xlim, ylim = c(0, 300), , ...)
axis(4); mtext("number of samples", 4, 1.5)
abline(v = c(.8, 3.8), lty = 2)
mtext(y, 3, -1, font = 2)
}
<- par_mfrow(c(3, 1))
opar walk2(dilutions, paste("Dilution", dilutions_factors), hist2)
par(opar)
6.2 Concentrations
Plotting the concentration estimates per plates and dilution for all the samples:
<- function(x) {
plot_concentrations <- unique(table(x$HCDC_SAMPLE_ID))
nb_replicates <- nrow(x)
nb <- rep(2:4, nb / nb_replicates)
colors <- (1:(nb + nb / nb_replicates - 1))
tmp <- seq(nb_replicates + 1,
vertical_lines tail(tmp, nb_replicates + 1)[1],
+ 1)
nb_replicates <- tmp[-vertical_lines]
xs |>
x mutate(across(c(lower, median, upper), ~ 10^.x * DILUTION_FACTORS)) |>
with({
plot(xs, median, col = colors, lwd = lwd_val,
ylim = c(0, max(median, upper, na.rm = TRUE)),
axes = FALSE, xlab = NA, ylab = "concentration (IU/mL)")
axis(2)
segments(xs, lower, xs, upper, col = colors, lwd = lwd_val)
})abline(v = vertical_lines, col = "grey")
abline(h = .1, col = "grey")
}
<- par_mfrow(c(11, 2))
opar walk2(samples, titles, ~ {plot_concentrations(.x); mtext(.y)})
par(opar)
6.3 Looking at the dilutions
Showing where estimates are possible (in green). When they are not possible, it’s either because the concentration is too high (in red), or because the concentration is too low (in orange). Each of the 22 columns represents a plate and each of the 22 rows with each column represents a sample. Within each sample, the 3 columns are for the point estimates of the concentrations (middle) with 95% confidence interval (lower and upper bounds in the left and right columns respectively) and the 3 rows are for the 3 dilutions values (50, 100 and 200 from top to bottom):
# function to add missing values if there are less than 22 samples in a plate:
<- 22 # number of samples per plate
nb_smpls <- 3 # number of dilutions per sample
nb_dltns <- 3 # number of estimates per dilution (point + confidence interval)
nb_estmts <- matrix(rep(NA, nb_smpls * nb_dltns * nb_estmts), nrow = nb_estmts)
template <- function(x) {
format_mat 1:ncol(x)] <- x
template[,
template
}
# the function that draws the heatmap for 1 plate:
<- function(...) abline(..., col = "white")
abline2 <- function(x) {
plot_heatmap |>
x mutate(across(c(lower, median, upper),
~ as.numeric(is.na(.x)) * ((RESULT > 2) + 1))) |>
select(lower, median, upper) |>
as.matrix() |>
t() |>
format_mat() |>
ncol(.x):1])() |>
(\(.x) .x[, image(1:nb_estmts, 1:(nb_smpls * nb_dltns), .x,
(\(.x) axes = FALSE, ann = FALSE, col = c(3, 7, 2)))()
abline2(v = c(1.5, 2.5))
abline2(h = seq(nb_dltns, nb_dltns * nb_smpls, nb_dltns) + .5)
}
<- par(mfrow = c(1, 22))
opar walk(samples, plot_heatmap)
par(opar)
This is how to interprete the above figure:
Number of samples for which a concentration can be estimated:
<- samples |>
nb_NA bind_rows() |>
mutate(total = is.na(lower) + is.na(median) + is.na(upper))
# with full confidence interval:
|>
nb_NA group_by(HCDC_SAMPLE_ID) |>
summarise(OK = any(total < 1)) |>
pull(OK) |>
sum()
[1] 339
# with partial confidence interval:
|>
nb_NA group_by(HCDC_SAMPLE_ID) |>
summarise(OK = any(total < 2)) |>
pull(OK) |>
sum()
[1] 388
Out of 481 samples,
- there are 2 sample (0.4 %) for which a dilution of 50 is already too much in order to estimate the lower bound of its 95% confidence interval;
- there are 340 samples (70.6 %) for which the concentration can be estimated with a 95% confidence interval.
- and an additional 47 samples (9.8 %) for which the concentration can be estimated with a partial 95% confidence interval.
Let’s look at the optimal dilution for each sample:
<- samples |>
concentrations_estimates bind_rows() |>
select(HCDC_SAMPLE_ID, DILUTION_FACTORS, RESULT, lower, median, upper) |>
mutate(across(c(lower, median, upper), ~ DILUTION_FACTORS * 10^.x),
ci_range = upper - lower)
<- concentrations_estimates |>
best_estimates group_by(HCDC_SAMPLE_ID) |>
filter(ci_range == min(ci_range, na.rm = TRUE)) |>
ungroup()
<- best_estimates |>
optimal_dilutions pull(DILUTION_FACTORS) |>
table()
optimal_dilutions
50 100 200
80 78 181
round(100 * optimal_dilutions / 340, 1)
50 100 200
23.5 22.9 53.2
Meaning that, out of the 340 samples for which an estimate with confidence interval can be generate, 80 (23.5%) have an optimal dilution of 50, 78 (22.9%) have an optimal dilution of 100 and 182 (53.5%) have an optimal dilution of 200.
The estimates with the full confidence interval:
<- 1:nrow(best_estimates)
ticks with(best_estimates,
plot(ticks, median, ylim = c(0, max(upper)), type = "n", axes = FALSE, xlab = NA,
ylab = "concentration (UI/mL)"))
axis(2)
abline(h = .1, col = "grey")
|>
best_estimates arrange(median, ci_range) |>
with({
segments(ticks, lower, ticks, upper)
})
The number of missing estimates (point or any of tbe bounds of the confidence interval) as a function of the dilution:
# A function that adds axes and grid:
<- function() {
add_grid axis(1); axis(2, 0:3)
abline(h = 0:3, col = "grey")
abline(v = c(50, 100, 200), col = "grey")
}
<- function(...) {
plot3 plot2(..., axes = FALSE, xlab = "dilution", ylab = "number of missing estimates")
}
# Preparing the data:
<- nb_NA |>
nb_missing group_by(DILUTION_FACTORS, total) |>
tally() |>
ungroup()
<- nb_missing |>
mean_nb_missing group_by(DILUTION_FACTORS) |>
mutate(mean = sum(total * n) / sum(n)) |>
ungroup() |>
select(- total, - n) |>
unique()
# The plot:
with(nb_NA, plot3(jitter(DILUTION_FACTORS), jitter(total)))
with(mean_nb_missing, lines(DILUTION_FACTORS, mean, lwd = 2, col = 2))
add_grid()
An alternative plot:
with(nb_missing, plot3(DILUTION_FACTORS, total, cex = sqrt(n) / sqrt(min(n)), pch = 19,
xlim = c(45, 205), ylim = c(-.2, 3.3)))
with(mean_nb_missing, lines(DILUTION_FACTORS, mean, lwd = 2, col = 2))
add_grid()
6.4 Exporting the estimates
|>
best_estimates select(- ci_range) |>
write_delim2("concentrations_estimates.csv")
7 Negative controls
A function that retrieves the negative controls from 1 plate:
<- function(x) {
get_negatives_controls |>
x select(starts_with("NEGATIVE")) |>
unique() |>
::pivot_longer(everything(), names_to = "dilution", values_to = "od") |>
tidyrmutate(across(dilution, ~ stringr::str_remove(.x, "NEGATIVE_") |> as.integer()))
}
Retrieving all the negative controls of all the plates and computing the concentrations:
<- plates |>
neg_contr map_dfr(get_negatives_controls, .id = "plate") |>
rowwise() |>
mutate(concentration = list(10^standard_curve(od))) |>
::unnest_wider(concentration) |>
tidyrmutate(across(c(lower, median, upper), ~ .x * dilution),
ci_range = upper - lower,
positive = ! upper < positive_threshold)
Selecting the best dilution for each sample and checking that the negative control are identified as negative:
|>
neg_contr group_by(plate) |>
filter(ci_range == min(ci_range, na.rm = TRUE)) |>
ungroup() |>
print_all()
# A tibble: 22 × 8
plate dilution od lower median upper ci_range positive
<chr> <int> <dbl> <dbl> <dbl> <dbl> <dbl> <lgl>
1 Plate_01_hcdc 50 1.74 0.0347 0.0372 0.0399 0.00526 FALSE
2 Plate_02_hcdc 50 1.45 0.0274 0.0298 0.0322 0.00485 FALSE
3 Plate_03_hcdc 50 2.39 0.0553 0.0588 0.0626 0.00734 FALSE
4 Plate_05_hcdc 50 1.84 0.0376 0.0402 0.0431 0.00541 FALSE
5 Plate_06_hcdc 50 1.72 0.0343 0.0369 0.0395 0.00524 FALSE
6 Plate_07_hcdc 100 1.24 0.0445 0.0490 0.0535 0.00900 FALSE
7 Plate_08_hcdc 50 1.27 0.0230 0.0252 0.0275 0.00453 FALSE
8 Plate_09_hcdc 50 1.69 0.0334 0.0359 0.0386 0.00519 FALSE
9 Plate_10_hcdc 50 1.58 0.0305 0.0329 0.0355 0.00501 FALSE
10 Plate_11_hcdc 50 1.26 0.0227 0.0249 0.0272 0.00452 FALSE
11 Plate_12_hcdc 50 0.837 0.0121 0.0142 0.0162 0.00413 FALSE
12 Plate_13_hcdc 50 0.866 0.0129 0.0150 0.0170 0.00410 FALSE
13 Plate_14_hcdc 50 1.33 0.0244 0.0268 0.0291 0.00466 FALSE
14 Plate_15_hcdc 50 0.826 0.0117 0.0139 0.0159 0.00416 FALSE
15 Plate_16_hcdc 50 0.961 0.0155 0.0175 0.0196 0.00410 FALSE
16 Plate_17_hcdc 50 1.04 0.0175 0.0195 0.0217 0.00419 FALSE
17 Plate_18_hcdc 50 1.62 0.0316 0.0341 0.0367 0.00507 FALSE
18 Plate_19_hcdc 50 1.83 0.0373 0.0398 0.0426 0.00538 FALSE
19 Plate_20_hcdc 50 1.36 0.0252 0.0275 0.0299 0.00472 FALSE
20 Plate_21_hcdc 50 1.46 0.0275 0.0299 0.0324 0.00486 FALSE
21 Plate_22_hcdc 50 0.847 0.0124 0.0145 0.0165 0.00411 FALSE
22 Plate_23_hcdc 50 1.03 0.0172 0.0193 0.0214 0.00417 FALSE
8 New dilutions
Loading the data:
<- readxl::read_excel(paste0(path2data,
test "Tetanus_Mr. Thanh FORMAT_TESTING STAFF.xlsx")) |>
mutate(od = RESULT)
Calibrating the standard curve:
<- get_antitoxins(test)
anti_toxins_test <- standard_curve_data(anti_toxins_test, nls4PL)
standard_curve_df_test <- data2function(standard_curve_df_test)
standard_curve_test <- process_samples(test, standard_curve_test)
samples_test plot_standard_curve(standard_curve_df_test, anti_toxins_test)
add_dilutions()
Estimating samples concentrations:
|>
samples_test select(HCDC_SAMPLE_ID, DILUTION_FACTORS, RESULT, lower, median, upper) |>
mutate(across(c(lower, median, upper), ~ DILUTION_FACTORS * 10^.x),
ci_range = upper - lower) |>
group_by(HCDC_SAMPLE_ID) |>
filter(ci_range == min(ci_range, na.rm = TRUE)) |>
ungroup() |>
select(- ci_range)
# A tibble: 4 × 6
HCDC_SAMPLE_ID DILUTION_FACTORS RESULT lower median upper
<chr> <dbl> <dbl> <dbl> <dbl> <dbl>
1 S1_A 400 3.16 0.811 0.856 0.907
2 S2_H 200 3.89 1.49 1.81 2.37
3 S5_TR 100 1.47 0.0588 0.0620 0.0651
4 S6_NC 50 3.68 0.194 0.212 0.235
9 Luminex data
9.1 First assay
Loading the data:
<- readxl::read_excel(paste0(path2data,
luminex "Tetanus Luminex raw data-test01042025.xlsx")) |>
rename(od = `Mean Fluorescence Intensity (MFI)`,
HCDC_SAMPLE_ID = Sample)
The dilutions are the following:
sort(unique(luminex$DILUTION_FACTORS))
[1] 25 50 100 200 400 800 1600 3200 6400 12800
[11] 25600 51200 102400
Plotting the anti-toxin data:
<- c(-4.4, -.75)
xlim <- 2
col_elisa <- 4
col_luminex
<- get_antitoxins(luminex)
anti_toxins_luminex plot(unlist(xs), unlist(ys), xlim = xlim, axes = FALSE, ylab = NA, type = "n",
xlab = bquote(log[10](concentration)))
axis(1)
axis(2, col = col_elisa, col.axis = col_elisa)
mtext("optical density", 2, line = 1.5, col = col_elisa)
walk2(xs, ys, lines2)
par(new = TRUE)
with(anti_toxins_luminex,
plot2(log10(concentration), od, ylab = "MFI", xlim = xlim, lwd = lwd_val,
axes = FALSE, ann = FALSE))
|>
anti_toxins_luminex group_by(concentration) |>
summarize(od = mean(od)) |>
with(lines(log10(concentration), od, lwd = lwd_val, col = color_data))
axis(2, line = -3, col = col_luminex, col.axis = col_luminex)
mtext("median fluorescence intensity", 2, line = -1.5, col = col_luminex)
add_dilutions();
abline(v = log10(positive_threshold / 25), col = "grey", lwd = lwd_val)
9.2 Second assay
Selection of samples:
<- samples |>
high_concentrations bind_rows() |>
arrange(desc(lower)) |>
pull(HCDC_SAMPLE_ID) |>
unique() |>
head(10)
<- samples |>
low_concentrations bind_rows() |>
arrange(upper) |>
pull(HCDC_SAMPLE_ID) |>
unique() |>
head(10)
A selection of high concentration samples:
high_concentrations
[1] "U07B532207549" "U07B532300113" "U07B532207215" "U07B522205091"
[5] "U07B532207216" "U07B532207218" "U07B532207220" "U07B532207222"
[9] "U07B532207223" "U07B532207226"
A selection of low concentration samples:
low_concentrations
[1] "U07B522205309" "U07B522205149" "U07B532300109" "U07B522205067"
[5] "U07B522205077" "U07B522205288" "U07B522205063" "U07B532207252"
[9] "U07B522205188" "U07B522205294"
Results:
<- readxl::read_excel(paste0(path2data,
luminex2 "Result for Marc_Tetanus Luminex raw data-& 20 samples july2025.xlsx"),
skip = 1, .name_repair = "unique_quiet") |>
rename(od = `Mean Fluorescence Intensity (MFI)`,
HCDC_SAMPLE_ID = Sample)
Comparison with first assay:
<- get_antitoxins(luminex2)
anti_toxins_luminex2
with(anti_toxins_luminex2,
plot2(log10(concentration), od, lwd = lwd_val,
xlim = c(-4, 1), ylim = c(0, 3550),
xlab = bquote(log[10](concentration)),
ylab = "median fluorescence intensity"))
with(anti_toxins_luminex,
points(log10(concentration), od, ylab = "MFI", lwd = lwd_val, col = 2))
legend("topleft", legend = c("first assay", "second assay"), pch = 1, lwd = lwd_val,
col = c(2, 4), bty = "n", lty = 0)