This R Markdown workflow imports raw OD values from one or more Tecan Infinite 200Pro 96-well plate Excel files and a sample metadata CSV file, then converts raw absorbance values into final cortisol concentrations in ng/mL.
When more than one plate is analyzed, each plate is processed independently through blank subtraction, B0 calculation, %B/B0 calculation, 4-parameter standard curve fitting, and concentration back-calculation. Only the final corrected sample concentrations are combined across plates for downstream plots and statistical comparisons.
The workflow does the following:
SampleID == "BLK".%B/B0 values using the plate-specific
standard S0/B0 as the maximum binding reference.# Install these once if needed:
# install.packages(c("readxl", "readr", "dplyr", "tidyr", "stringr", "purrr", "ggplot2", "minpack.lm", "scales", "multcompView"))
library(readxl)
library(readr)
library(dplyr)
library(tidyr)
library(stringr)
library(purrr)
library(ggplot2)
library(minpack.lm)
library(scales)
library(multcompView)
Edit the file paths below for each new ELISA plate.
# Raw Tecan Excel export file(s) containing the 96-well plate OD values.
#
# For a single plate, use one named entry:
# plate_excel_files <- c("1" = "Plate1_Tecan_RawData.xlsx")
#
# For multiple plates, add one named entry per plate. The names must match the
# values in the metadata column titled `Plate Number`.
# Example:
# plate_excel_files <- c(
# "1" = "Plate1_Tecan_RawData.xlsx",
# "2" = "Plate2_Tecan_RawData.xlsx"
# )
plate_excel_files <- c("1" = "Plate1_Tecan_RawData.xlsx")
# Sample metadata CSV.
# Required columns:
# SampleID
# Cell Position 1
# Cell Position 2
# Plate Number
# Additional columns such as Treatment, SampleDay, Time, etc. are allowed.
#
# Important: Cell positions are interpreted within each plate. For example, A3
# on Plate 1 and A3 on Plate 2 are treated as different wells because they have
# different Plate Number values in the metadata.
metadata_csv_file <- "ThermalPriming_Cortisol_Metadata_9July2026-.csv"
# Output files.
processed_metadata_output <- "processed_cortisol_metadata.csv"
sample_processing_diagnostics_output <- "sample_processing_diagnostics.csv"
samples_excluded_from_statistics_output <- "samples_excluded_from_statistics_diagnostic.csv"
group_representation_diagnostics_output <- "group_representation_diagnostics.csv"
standard_curve_plot_output <- "cortisol_standard_curve.pdf"
standard_curve_with_samples_plot_output <- "cortisol_standard_curve_with_samples.pdf"
competitive_elisa_interpretation_plot_output <- "cortisol_competitive_elisa_interpretation_with_samples.pdf"
grouped_boxplots_output <- "cortisol_grouped_boxplots.pdf"
statistical_summary_output <- "cortisol_group_statistics.csv"
anova_kruskal_summary_output <- "cortisol_overall_group_tests.csv"
posthoc_tukey_output <- "cortisol_tukey_posthoc_tests.csv"
posthoc_wilcoxon_output <- "cortisol_wilcoxon_posthoc_tests.csv"
# Quick-reference files that include only statistically significant post-hoc comparisons.
significant_tukey_output <- "cortisol_significant_tukey_posthoc_tests.csv"
significant_wilcoxon_output <- "cortisol_significant_wilcoxon_posthoc_tests.csv"
excluded_well_notes_output <- "cortisol_excluded_well_notes.csv"
significance_letters_output <- "cortisol_significance_letters.csv"
plate_processing_summary_output <- "cortisol_plate_processing_summary.csv"
# Outlier-screening outputs. These do not alter the primary analysis.
outlier_screening_output <- "cortisol_extreme_outlier_screening.csv"
grouped_boxplots_extreme_outliers_removed_output <- "cortisol_grouped_boxplots_extreme_outliers_removed.pdf"
statistical_summary_extreme_outliers_removed_output <- "cortisol_group_statistics_extreme_outliers_removed.csv"
anova_kruskal_summary_extreme_outliers_removed_output <- "cortisol_overall_group_tests_extreme_outliers_removed.csv"
posthoc_tukey_extreme_outliers_removed_output <- "cortisol_tukey_posthoc_tests_extreme_outliers_removed.csv"
posthoc_wilcoxon_extreme_outliers_removed_output <- "cortisol_wilcoxon_posthoc_tests_extreme_outliers_removed.csv"
significant_tukey_extreme_outliers_removed_output <- "cortisol_significant_tukey_posthoc_tests_extreme_outliers_removed.csv"
significant_wilcoxon_extreme_outliers_removed_output <- "cortisol_significant_wilcoxon_posthoc_tests_extreme_outliers_removed.csv"
# Significance threshold used for the quick-reference post-hoc outputs and plot letters.
posthoc_significance_alpha <- 0.05
# Add compact letter displays to final jittered boxplots.
# Groups that share a letter are not significantly different.
# Groups with no letters in common are significantly different at the alpha level above.
show_significance_letters <- TRUE
# Choose which post-hoc method should be used for the plot letters.
# Options:
# "tukey" = ANOVA/Tukey-based letters; best when ANOVA assumptions are reasonable.
# "wilcoxon" = Pairwise Wilcoxon/BH-adjusted letters; useful when emphasizing non-parametric results.
significance_letter_method <- "wilcoxon"
# Concentration back-calculation/extrapolation options.
#
# ELISA concentrations are most reliable when sample %B/B0 values fall within the
# measured standard curve range, and especially within the common 20-80% B/B0
# working range. For preliminary work, you may still want to calculate values
# outside the standard range using the fitted 4PL equation.
#
# TRUE = keep finite 4PL back-calculated values even if they are outside the
# observed standard curve range, and flag them as extrapolated/cautious.
# FALSE = set values outside the observed standard curve range to NA.
allow_curve_extrapolation <- TRUE
# Include extrapolated values in downstream plots/statistics?
# TRUE is useful for exploratory/preliminary analyses, but final/publication
# analyses should usually inspect these flags carefully.
include_extrapolated_concentrations_in_statistics <- TRUE
# Quantification-confidence labels in the output table and standard-curve-with-samples plot.
# These labels do not alter the concentration calculation; they are interpretive flags.
# Preferred assay range is commonly 20-80% B/B0. Values within the measured
# standard range but outside 20-80% are still interpolation values, but are less ideal.
preferred_percent_B_B0_lower <- 20
preferred_percent_B_B0_upper <- 80
# When TRUE, the sample-on-standard-curve plot will show the measured standard
# concentration range as a lightly shaded vertical band. Points outside this band
# are extrapolated or tail-estimated and should be interpreted cautiously.
show_standard_range_shading <- TRUE
# Add a teaching/interpretation plot showing the inverse competitive ELISA
# relationship, overlaid with actual samples. This is separate from the formal
# standard-curve plot and is meant to help interpret dilution/rerun decisions.
make_competitive_elisa_interpretation_plot <- TRUE
# Label the most extreme actual sample points on the interpretation plot.
label_extreme_samples_on_interpretation_plot <- TRUE
number_of_extreme_samples_to_label <- 10
# If a sample %B/B0 value is outside the observed standard range but still inside
# the fitted 4PL curve/asymptotes, the script will use the 4PL equation to
# extrapolate a concentration. This preserves variation among samples instead of
# assigning all values to the nearest standard concentration.
#
# If a sample is outside the fitted 4PL asymptotes, the algebraic 4PL inverse has
# no exact real-valued solution. For exploratory/preliminary work, the script can
# optionally use a local log-linear tail extrapolation based on the two nearest
# standards at that end of the curve. This is less defensible than true 4PL
# interpolation/extrapolation, but it avoids artificial identical boundary values.
use_tail_extrapolation_outside_model_asymptotes <- TRUE
# Enforce the expected competitive ELISA direction during tail extrapolation.
# For cortisol competitive ELISA: higher %B/B0 must produce lower concentration,
# even when values are extrapolated beyond the fitted curve.
enforce_competitive_monotonic_tail_extrapolation <- TRUE
# Boundary estimates are now OFF by default because they collapse all out-of-range
# samples to the same minimum/maximum standard value and can visually erase real
# variation. Turn this on only if you specifically want censored values such as
# "at or below lowest standard" or "at or above highest standard."
use_boundary_estimates_outside_model_asymptotes <- FALSE
# Boundary values used only when use_boundary_estimates_outside_model_asymptotes = TRUE.
# These are retained as an optional fallback but are not used by default.
lower_boundary_concentration_ng_mL <- 0.005
upper_boundary_concentration_ng_mL <- 50
# Optional manually excluded wells.
# Add any wells that should be excluded because of a visible pipetting issue, bubble, plate reader artifact,
# or any other concern with the raw OD value.
#
# For one plate, you can list wells directly:
# excluded_wells <- c("C7", "D7")
#
# For multiple plates, use plate-specific labels written as "PlateNumber:Well":
# excluded_wells <- c("1:C7", "2:D7")
#
# Direct well labels without a plate number are applied to ALL plates, so plate-specific
# labels are safer when analyzing more than one plate.
# Leave empty if no wells should be excluded.
excluded_wells <- c()
# Plot options for the grouped concentration plots.
# Set use_log10_y_axis <- TRUE if cortisol concentrations span a wide range.
use_log10_y_axis <- FALSE
# Metadata-driven grouping options.
# The first three metadata columns are always treated as identifiers/well positions:
# SampleID, Cell Position 1, Cell Position 2
# Every additional metadata column is treated as a possible grouping variable unless excluded below.
#
# Leave grouping_variables_to_analyze <- NULL to analyze all eligible metadata grouping columns.
# Or provide a character vector to analyze only selected metadata columns, for example:
# grouping_variables_to_analyze <- c("Treatment", "SampleDay")
grouping_variables_to_analyze <- NULL
# Column used to count unique research samples in statistical summaries.
# Default = SampleID. If your metadata has a true biological individual ID
# column, such as OysterID, AnimalID, IndividualID, etc., change this to that
# column name so n reflects unique biological individuals rather than rows.
#
# Important: statistical tests still use one concentration value per metadata row.
# The unique-n column is mainly for accurate reporting and diagnostics.
statistics_unique_id_column <- "SampleID"
# Optional column identifying the biological individual/organism.
# For your oyster dataset this can be "Oyster". If the column is present,
# diagnostics will report both tissue/sample-level n and oyster-level n.
# If absent, the script will simply skip oyster-level diagnostic counts.
biological_individual_id_column <- "Oyster"
# Optional expected design checks. Set to NA to skip a check.
# These are only diagnostic warnings; they do not change the calculations.
expected_individuals_per_simple_group <- 8
expected_min_tissues_per_individual <- 4
expected_max_tissues_per_individual <- 5
# Exclude standards from plots/statistics. Standards are needed for the curve,
# but they are not research samples and should not contribute to group n values.
exclude_standards_from_plots_and_statistics <- TRUE
# Optional: manually exclude additional SampleID values from plots/statistics.
# This is useful for QC decisions after processing. Leave empty if not needed.
exclude_sample_ids_from_plots_and_statistics <- c()
# Optional extreme outlier screening.
# This does not change the primary figures/statistics. If enabled, the report will
# print and save a list of samples that appear to be extreme outliers, then create
# a second exploratory set of figures/statistics with those rows excluded.
#
# Method: within each grouping variable, extreme outliers are flagged using the
# conservative boxplot rule: values below Q1 - 3*IQR or above Q3 + 3*IQR.
# This is stricter than the usual 1.5*IQR rule and is intended only as a QC screen.
screen_extreme_outliers <- TRUE
extreme_outlier_iqr_multiplier <- 3
produce_secondary_outputs_without_extreme_outliers <- TRUE
# Minimum number of observations required within a group before outliers are screened.
# Groups smaller than this are skipped because quartile/IQR outlier calls are unstable.
minimum_group_n_for_outlier_screening <- 6
# If TRUE, a row flagged as an extreme outlier for ANY grouping variable will be
# excluded from the secondary exploratory outputs. The primary outputs still include it.
exclude_if_extreme_outlier_in_any_grouping <- TRUE
# Optional combined grouping variables.
# This allows comparisons such as TreatmentDay, DoseTime, SiteTreatment, etc., without hard-coding
# any specific metadata structure. Each named item becomes a new grouping variable by pasting together
# the listed metadata columns.
#
# Examples:
# combined_grouping_variables <- list(TreatmentDay = c("Treatment", "SampleDay"))
# combined_grouping_variables <- list(DoseTime = c("Dose", "Time"), SiteTreatment = c("Site", "Treatment"))
# Leave as list() if no combined grouping variable is needed.
combined_grouping_variables <- list(Ploidy = c("Ploidy", "Tissue"))
# Optional focused plotting layout.
# This creates an additional plot with one metadata variable on the x-axis and another shown by color.
# Leave either value as NULL to skip this extra plot.
# This is useful for designs like x = SampleDay and color = Treatment, but can be changed
# to match any metadata columns in a future CSV.
focused_plot_x_variable <- "Treatment"
focused_plot_color_variable <- "Tissue"
# Set run_group_statistics <- TRUE to output statistical comparisons for each grouping variable.
# For grouping variables with 2 groups, this uses a Welch two-sample t-test.
# For grouping variables with 3 or more groups, this uses a one-way ANOVA.
run_group_statistics <- TRUE
# Optional correction factors.
# If every sample has the same dilution factor, set it here.
# Example: if samples were diluted 1:200 before loading, use 200.
default_sample_dilution_factor <- 50
# If every sample has the same extraction dilution/concentration correction factor, set it here.
# Leave as 1 if no extraction correction is needed.
default_extraction_correction_factor <- 1
# If your metadata file contains columns named DilutionFactor and/or ExtractionCorrectionFactor,
# those sample-specific values will override the defaults above.
The script supports two separate correction factors so the output remains transparent:
DilutionFactor corrects for any dilution made
before loading the ELISA plate.ExtractionCorrectionFactor corrects for any dilution,
concentration, or scaling that occurred during sample
extraction/preparation before the ELISA dilution step.The final corrected concentration is calculated as:
FinalConcentration_ng_mL <- Concentration_ng_mL * DilutionFactor * ExtractionCorrectionFactor
Suppose a tissue extract is diluted 1:4 before being loaded onto the ELISA plate. The ELISA standard curve estimates the diluted sample concentration as 0.75 ng/mL.
If no additional extraction correction is needed:
measured_concentration <- 0.75
DilutionFactor <- 4
ExtractionCorrectionFactor <- 1
final_concentration <- measured_concentration * DilutionFactor * ExtractionCorrectionFactor
final_concentration
[1] 3
So the corrected concentration would be 3.0 ng/mL.
If the extraction process also requires a 2x correction, then:
measured_concentration <- 0.75
DilutionFactor <- 4
ExtractionCorrectionFactor <- 2
final_concentration <- measured_concentration * DilutionFactor * ExtractionCorrectionFactor
final_concentration
[1] 6
So the corrected concentration would be 6.0 ng/mL.
Yes: this R Markdown is designed to use per-sample correction factors when they are present in the metadata file.
If your metadata CSV contains a column named
DilutionFactor, those sample-specific values are used. If
that column is absent, the script uses
default_sample_dilution_factor for every sample.
If your metadata CSV contains a column named
ExtractionCorrectionFactor, those sample-specific values
are used. If that column is absent, the script uses
default_extraction_correction_factor for every sample.
For example, your metadata CSV can include:
| SampleID | Cell Position 1 | Cell Position 2 | Treatment | DilutionFactor | ExtractionCorrectionFactor |
|---|---|---|---|---|---|
| Sample_001 | A3 | A4 | Control | 4 | 1 |
| Sample_002 | B3 | B4 | HeatStress | 10 | 2 |
| Sample_003 | C3 | C4 | HeatStress | 1 | 1 |
This allows each sample to be corrected individually when needed.
# Convert a well location such as "A3" or "H12" into row and column components.
parse_well_position <- function(well_position) {
well_position <- str_trim(as.character(well_position))
well_position <- str_to_upper(well_position)
tibble(
Well = well_position,
PlateRow = str_extract(well_position, "^[A-H]"),
PlateCol = as.integer(str_extract(well_position, "[0-9]+$"))
)
}
# Normalize plate numbers so metadata values and plate_excel_files names match reliably.
normalize_plate_number <- function(plate_number) {
str_trim(as.character(plate_number))
}
# Read one Tecan Excel export and return the 96-well OD table in long format.
read_tecan_plate <- function(tecan_excel_file, plate_number) {
tecan_raw <- read_excel(tecan_excel_file, col_names = FALSE)
plate_header_row <- which(tecan_raw[[1]] == "<>")
if (length(plate_header_row) != 1) {
stop("Could not uniquely identify the plate header row marked with '<>' in file: ", tecan_excel_file)
}
plate_matrix <- read_excel(
tecan_excel_file,
skip = plate_header_row - 1,
n_max = 9,
col_names = TRUE
)
colnames(plate_matrix)[1] <- "PlateRow"
plate_matrix %>%
pivot_longer(
cols = -PlateRow,
names_to = "PlateCol",
values_to = "RawOD"
) %>%
mutate(
`Plate Number` = normalize_plate_number(plate_number),
PlateRow = str_to_upper(as.character(PlateRow)),
PlateCol = as.integer(PlateCol),
Well = paste0(PlateRow, PlateCol),
PlateWellID = paste(`Plate Number`, Well, sep = ":"),
RawOD = as.numeric(RawOD)
) %>%
select(`Plate Number`, PlateWellID, Well, PlateRow, PlateCol, RawOD)
}
# Extract an OD value from the long-format plate table using a plate number and well location like "A3".
get_od_from_well <- function(well_position, plate_number, plate_long, value_column = "RawOD") {
parsed <- parse_well_position(well_position)
plate_number <- normalize_plate_number(plate_number)
value <- plate_long %>%
filter(`Plate Number` == plate_number, PlateRow == parsed$PlateRow, PlateCol == parsed$PlateCol) %>%
pull({{ value_column }})
if (length(value) != 1) {
stop("Could not uniquely identify well position ", well_position, " on plate ", plate_number, ".")
}
value
}
# Convert manually entered excluded wells into a plate-aware table.
# Entries like "1:C7" exclude C7 only on Plate 1.
# Entries like "C7" exclude C7 on every plate.
make_excluded_well_table <- function(excluded_wells, plate_numbers) {
excluded_wells <- str_to_upper(str_trim(as.character(excluded_wells)))
excluded_wells <- excluded_wells[!is.na(excluded_wells) & excluded_wells != ""]
plate_numbers <- normalize_plate_number(plate_numbers)
if (length(excluded_wells) == 0) {
return(tibble(`Plate Number` = character(), Well = character(), PlateWellID = character()))
}
map_dfr(excluded_wells, function(entry) {
if (str_detect(entry, ":")) {
parts <- str_split_fixed(entry, ":", 2)
tibble(
`Plate Number` = normalize_plate_number(parts[, 1]),
Well = str_to_upper(str_trim(parts[, 2]))
)
} else {
tibble(
`Plate Number` = plate_numbers,
Well = entry
)
}
}) %>%
distinct() %>%
mutate(PlateWellID = paste(`Plate Number`, Well, sep = ":"))
}
# Summarize a duplicate pair while respecting manually excluded wells.
# If both wells are usable, the two values are averaged.
# If one well is excluded, the usable single value is carried forward and a note is created.
# If both wells are excluded, the result is set to NA and a warning note is created.
summarize_duplicate_wells <- function(well1, well2, plate_number, plate_long, excluded_well_table = tibble()) {
plate_number <- normalize_plate_number(plate_number)
wells <- str_to_upper(str_trim(c(as.character(well1), as.character(well2))))
plate_well_ids <- paste(plate_number, wells, sep = ":")
raw_values <- map_dbl(wells, get_od_from_well, plate_number = plate_number, plate_long = plate_long, value_column = "RawOD")
corrected_values <- map_dbl(wells, get_od_from_well, plate_number = plate_number, plate_long = plate_long, value_column = "BlankCorrectedOD")
excluded_flags <- plate_well_ids %in% excluded_well_table$PlateWellID
usable_flags <- !excluded_flags
n_excluded <- sum(excluded_flags)
n_used <- sum(usable_flags)
excluded_list <- paste(plate_well_ids[excluded_flags], collapse = ";")
used_list <- paste(plate_well_ids[usable_flags], collapse = ";")
note <- dplyr::case_when(
n_excluded == 0 ~ NA_character_,
n_excluded == 1 ~ paste0("One duplicate well excluded (", excluded_list, "); value calculated from single usable well: ", used_list, "."),
n_excluded == 2 ~ paste0("Both duplicate wells excluded (", excluded_list, "); average and concentration set to NA."),
TRUE ~ NA_character_
)
tibble(
RawOD_1 = raw_values[1],
RawOD_2 = raw_values[2],
BlankCorrectedOD_1 = corrected_values[1],
BlankCorrectedOD_2 = corrected_values[2],
Well1_Excluded = excluded_flags[1],
Well2_Excluded = excluded_flags[2],
ExcludedWellsForSample = if_else(excluded_list == "", NA_character_, excluded_list),
WellsUsedForAverage = if_else(used_list == "", NA_character_, used_list),
NumberOfWellsUsedForAverage = n_used,
AverageRawOD = if_else(n_used > 0, mean(raw_values[usable_flags], na.rm = TRUE), NA_real_),
AverageBlankCorrectedOD = if_else(n_used > 0, mean(corrected_values[usable_flags], na.rm = TRUE), NA_real_),
ExcludedWellNote = note
)
}
# Four-parameter logistic model.
# Bottom = lower asymptote
# Top = upper asymptote
# IC50 = concentration where response is halfway between Top and Bottom
# Hill = slope/steepness parameter
four_parameter_logistic <- function(x, Bottom, Top, IC50, Hill) {
Bottom + ((Top - Bottom) / (1 + (x / IC50)^Hill))
}
# Inverse of the 4-parameter logistic model.
# This converts %B/B0 values back into concentrations.
invert_four_parameter_logistic <- function(y, Bottom, Top, IC50, Hill) {
# Algebraic inverse of:
# y = Bottom + ((Top - Bottom) / (1 + (x / IC50)^Hill))
#
# Important: if y is outside the fitted model asymptotes, there is no exact
# real-valued solution. In that case this function returns NA rather than
# a misleading complex/NaN value.
ratio <- ((Top - Bottom) / (y - Bottom)) - 1
x <- IC50 * (ratio^(1 / Hill))
x[!is.finite(x) | !is.numeric(x)] <- NA_real_
as.numeric(x)
}
# Local tail extrapolation used only when a sample response is outside the fitted
# 4PL asymptotes and therefore cannot be inverted exactly. This extends the
# relationship between %B/B0 and log10(concentration) using the two nearest
# nonzero standards at the relevant end of the curve. Values are flagged clearly
# and should be considered exploratory only.
tail_extrapolate_log_linear <- function(y, slope, intercept) {
x <- 10^(intercept + slope * y)
x[!is.finite(x) | x <= 0] <- NA_real_
as.numeric(x)
}
For multi-plate analyses, the script uses Plate Number
as a fixed metadata column. This means the same well address can safely
appear on multiple plates. For example, A3 on Plate 1 and
A3 on Plate 2 are treated as different observations.
The plate-specific calculations are intentionally kept separate until each sample has a final concentration:
%B/B0 is calculated separately for each plate.The Tecan export contains instrument settings before the actual plate
matrix. This code automatically finds the plate data by locating the row
where the first cell is <>, which is followed by
columns 1-12 and rows A-H.
# Each Excel file is read separately and tagged with its Plate Number before being combined.
# This prevents identical well names on different plates, such as A3 on Plate 1 and A3 on Plate 2,
# from being confused.
if (is.null(names(plate_excel_files)) || any(names(plate_excel_files) == "")) {
stop("plate_excel_files must be a named vector, for example c('1' = 'Plate1.xlsx', '2' = 'Plate2.xlsx').")
}
plate_file_map <- tibble(
`Plate Number` = normalize_plate_number(names(plate_excel_files)),
TecanExcelFile = as.character(plate_excel_files)
)
plate_long <- pmap_dfr(
plate_file_map,
function(`Plate Number`, TecanExcelFile) {
read_tecan_plate(TecanExcelFile, plate_number = `Plate Number`)
}
)
plate_long
# A tibble: 96 × 6
`Plate Number` PlateWellID Well PlateRow PlateCol RawOD
<chr> <chr> <chr> <chr> <int> <dbl>
1 1 1:A1 A1 A 1 0.374
2 1 1:A2 A2 A 2 0.517
3 1 1:A3 A3 A 3 0.498
4 1 1:A4 A4 A 4 0.572
5 1 1:A5 A5 A 5 0.520
6 1 1:A6 A6 A 6 0.507
7 1 1:A7 A7 A 7 0.507
8 1 1:A8 A8 A 8 0.586
9 1 1:A9 A9 A 9 0.551
10 1 1:A10 A10 A 10 0.548
# ℹ 86 more rows
The metadata file must contain at least these columns:
SampleIDCell Position 1Cell Position 2The row with SampleID == "BLK" is used to identify the
two blank wells.
metadata <- read_csv(metadata_csv_file, show_col_types = FALSE)
required_columns <- c("SampleID", "Cell Position 1", "Cell Position 2", "Plate Number")
missing_columns <- setdiff(required_columns, colnames(metadata))
if (length(missing_columns) > 0) {
stop("The metadata file is missing required column(s): ", paste(missing_columns, collapse = ", "))
}
metadata <- metadata %>%
mutate(
SampleID = as.character(SampleID),
`Plate Number` = normalize_plate_number(`Plate Number`),
`Cell Position 1` = str_to_upper(str_trim(as.character(`Cell Position 1`))),
`Cell Position 2` = str_to_upper(str_trim(as.character(`Cell Position 2`)))
)
metadata_plates <- sort(unique(metadata$`Plate Number`))
plate_files_available <- sort(unique(plate_file_map$`Plate Number`))
missing_plate_files <- setdiff(metadata_plates, plate_files_available)
if (length(missing_plate_files) > 0) {
stop(
"The metadata contains Plate Number value(s) with no matching Excel file in plate_excel_files: ",
paste(missing_plate_files, collapse = ", ")
)
}
metadata
# A tibble: 40 × 11
SampleID `Plate Number` `Cell Position 1` `Cell Position 2` `Dilution Factor`
<chr> <chr> <chr> <chr> <dbl>
1 D-19_06… 1 A3 A4 0
2 D-19_06… 1 B3 B4 35
3 D-19_06… 1 C3 C4 30
4 D-48_06… 1 D3 D4 0
5 D-48_06… 1 E3 E4 26.5
6 D-48_06… 1 F3 F4 0
7 D-CTL-4… 1 G3 G4 50
8 D-43_06… 1 H3 H4 0
9 D-CTL-1… 1 A5 A6 50
10 D-CTL-1… 1 B5 B6 45.5
# ℹ 30 more rows
# ℹ 6 more variables:
# SampleTissue_MassorVolume_ForCortisolExtraction_mg_or_ul <dbl>,
# ExtractionCorrectionFactor <dbl>, Tissue <chr>, Ploidy <chr>,
# SampleTime <chr>, Treatment <chr>
Any wells listed in excluded_wells are removed from the
averaging step. If only one well from a duplicate pair is excluded, the
code uses the remaining single well and records a note in the
outputs.
excluded_well_table <- make_excluded_well_table(
excluded_wells = excluded_wells,
plate_numbers = plate_file_map$`Plate Number`
)
if (nrow(excluded_well_table) == 0) {
cat("No wells were manually excluded.\n")
} else {
cat("Manually excluded plate/well combinations:\n")
print(excluded_well_table)
}
No wells were manually excluded.
blank_rows <- metadata %>%
filter(str_to_upper(SampleID) == "BLK")
blank_counts <- blank_rows %>%
count(`Plate Number`, name = "NumberOfBlankRows")
plates_missing_blank <- setdiff(metadata_plates, blank_counts$`Plate Number`)
plates_with_multiple_blanks <- blank_counts %>%
filter(NumberOfBlankRows != 1) %>%
pull(`Plate Number`)
if (length(plates_missing_blank) > 0) {
stop("Each plate must have exactly one metadata row where SampleID is 'BLK'. Missing for plate(s): ", paste(plates_missing_blank, collapse = ", "))
}
if (length(plates_with_multiple_blanks) > 0) {
stop("Each plate must have exactly one metadata row where SampleID is 'BLK'. Multiple blank rows found for plate(s): ", paste(plates_with_multiple_blanks, collapse = ", "))
}
blank_summary <- blank_rows %>%
mutate(
BlankWells = map2(`Cell Position 1`, `Cell Position 2`, ~ c(.x, .y)),
BlankRawValues = map2(
BlankWells,
`Plate Number`,
~ map_dbl(.x, get_od_from_well, plate_number = .y, plate_long = plate_long, value_column = "RawOD")
),
BlankPlateWellIDs = map2(`Plate Number`, BlankWells, ~ paste(.x, .y, sep = ":")),
BlankExcludedFlags = map(BlankPlateWellIDs, ~ .x %in% excluded_well_table$PlateWellID)
) %>%
rowwise() %>%
mutate(
AverageBlankRawOD = {
flags <- unlist(BlankExcludedFlags)
vals <- unlist(BlankRawValues)
if (all(flags)) NA_real_ else mean(vals[!flags], na.rm = TRUE)
},
ExcludedBlankWells = paste(unlist(BlankPlateWellIDs)[unlist(BlankExcludedFlags)], collapse = ";"),
UsedBlankWells = paste(unlist(BlankPlateWellIDs)[!unlist(BlankExcludedFlags)], collapse = ";")
) %>%
ungroup() %>%
select(`Plate Number`, AverageBlankRawOD, ExcludedBlankWells, UsedBlankWells)
if (any(is.na(blank_summary$AverageBlankRawOD))) {
bad_plates <- blank_summary %>% filter(is.na(AverageBlankRawOD)) %>% pull(`Plate Number`)
stop("Both blank wells were manually excluded for plate(s): ", paste(bad_plates, collapse = ", "), ". At least one blank well per plate is required.")
}
cat("Average Raw OD of Blanks by plate:\n")
Average Raw OD of Blanks by plate:
print(blank_summary %>% select(`Plate Number`, AverageBlankRawOD, UsedBlankWells, ExcludedBlankWells))
# A tibble: 1 × 4
`Plate Number` AverageBlankRawOD UsedBlankWells ExcludedBlankWells
<chr> <dbl> <chr> <chr>
1 1 0.0541 1:H11;1:H12 ""
plate_long <- plate_long %>%
left_join(blank_summary %>% select(`Plate Number`, AverageBlankRawOD), by = "Plate Number") %>%
mutate(
BlankCorrectedOD = RawOD - AverageBlankRawOD,
ExcludedFromAnalysis = PlateWellID %in% excluded_well_table$PlateWellID
)
plate_long
# A tibble: 96 × 9
`Plate Number` PlateWellID Well PlateRow PlateCol RawOD AverageBlankRawOD
<chr> <chr> <chr> <chr> <int> <dbl> <dbl>
1 1 1:A1 A1 A 1 0.374 0.0541
2 1 1:A2 A2 A 2 0.517 0.0541
3 1 1:A3 A3 A 3 0.498 0.0541
4 1 1:A4 A4 A 4 0.572 0.0541
5 1 1:A5 A5 A 5 0.520 0.0541
6 1 1:A6 A6 A 6 0.507 0.0541
7 1 1:A7 A7 A 7 0.507 0.0541
8 1 1:A8 A8 A 8 0.586 0.0541
9 1 1:A9 A9 A 9 0.551 0.0541
10 1 1:A10 A10 A 10 0.548 0.0541
# ℹ 86 more rows
# ℹ 2 more variables: BlankCorrectedOD <dbl>, ExcludedFromAnalysis <lgl>
The kit standards are assumed to be loaded in duplicate in columns 1 and 2:
Known cortisol concentrations are:
| Standard | Concentration ng/mL |
|---|---|
| S0 | 0.000 |
| S1 | 0.005 |
| S2 | 0.020 |
| S3 | 0.100 |
| S4 | 0.500 |
| S5 | 2.000 |
| S6 | 10.000 |
| S7 | 50.000 |
standard_map <- expand_grid(
`Plate Number` = plate_file_map$`Plate Number`,
StandardIndex = 0:7
) %>%
mutate(
Standard = paste0("S", StandardIndex),
BindingName = paste0("B", StandardIndex),
PlateRow = LETTERS[StandardIndex + 1],
Well1 = paste0(PlateRow, "1"),
Well2 = paste0(PlateRow, "2"),
KnownConcentration_ng_mL = c(0.000, 0.005, 0.020, 0.100, 0.500, 2.000, 10.000, 50.000)[StandardIndex + 1]
) %>%
select(-StandardIndex)
standards_processed <- standard_map %>%
mutate(
duplicate_summary = pmap(
list(Well1, Well2, `Plate Number`),
summarize_duplicate_wells,
plate_long = plate_long,
excluded_well_table = excluded_well_table
)
) %>%
unnest(duplicate_summary)
# B0 is the maximum binding reference from S0. It is calculated separately for each plate.
B0_by_plate <- standards_processed %>%
filter(Standard == "S0") %>%
transmute(`Plate Number`, B0 = AverageBlankCorrectedOD)
if (any(is.na(B0_by_plate$B0) | B0_by_plate$B0 == 0)) {
bad_plates <- B0_by_plate %>% filter(is.na(B0) | B0 == 0) %>% pull(`Plate Number`)
stop("B0 could not be calculated or is zero for plate(s): ", paste(bad_plates, collapse = ", "), ". Check standards S0 wells A1 and A2, including any manually excluded wells.")
}
standards_processed <- standards_processed %>%
left_join(B0_by_plate, by = "Plate Number") %>%
mutate(Percent_B_B0 = (AverageBlankCorrectedOD / B0) * 100)
standards_processed
# A tibble: 8 × 21
`Plate Number` Standard BindingName PlateRow Well1 Well2
<chr> <chr> <chr> <chr> <chr> <chr>
1 1 S0 B0 A A1 A2
2 1 S1 B1 B B1 B2
3 1 S2 B2 C C1 C2
4 1 S3 B3 D D1 D2
5 1 S4 B4 E E1 E2
6 1 S5 B5 F F1 F2
7 1 S6 B6 G G1 G2
8 1 S7 B7 H H1 H2
# ℹ 15 more variables: KnownConcentration_ng_mL <dbl>, RawOD_1 <dbl>,
# RawOD_2 <dbl>, BlankCorrectedOD_1 <dbl>, BlankCorrectedOD_2 <dbl>,
# Well1_Excluded <lgl>, Well2_Excluded <lgl>, ExcludedWellsForSample <chr>,
# WellsUsedForAverage <chr>, NumberOfWellsUsedForAverage <int>,
# AverageRawOD <dbl>, AverageBlankCorrectedOD <dbl>, ExcludedWellNote <chr>,
# B0 <dbl>, Percent_B_B0 <dbl>
S0/B0 is used as the 100% maximum binding reference. The curve itself is fit with S1-S7 because S0 has a known concentration of zero and the model is concentration-based.
standards_for_curve <- standards_processed %>%
filter(Standard != "S0")
fit_one_plate_curve <- function(plate_standard_data) {
# Cortisol assays are competitive ELISAs, so the standard curve should be
# monotonic decreasing: higher %B/B0 = lower cortisol concentration.
#
# This re-parameterized 4PL enforces:
# Top > Bottom, IC50 > 0, and Hill > 0
# so the curve cannot accidentally flip direction during fitting.
response_span_start <- max(plate_standard_data$Percent_B_B0, na.rm = TRUE) -
min(plate_standard_data$Percent_B_B0, na.rm = TRUE)
if (!is.finite(response_span_start) || response_span_start <= 0) {
stop("The standard responses do not span a usable range for this plate. Check standards and blank correction.")
}
fit_4pl <- nlsLM(
Percent_B_B0 ~ Bottom +
(exp(LogSpan) / (1 + (KnownConcentration_ng_mL / exp(LogIC50))^exp(LogHill))),
data = plate_standard_data,
start = list(
Bottom = min(plate_standard_data$Percent_B_B0, na.rm = TRUE),
LogSpan = log(response_span_start),
LogIC50 = log(stats::median(plate_standard_data$KnownConcentration_ng_mL, na.rm = TRUE)),
LogHill = log(1)
),
control = nls.lm.control(maxiter = 1000)
)
curve_parameters <- coef(fit_4pl)
tibble(
Bottom = unname(curve_parameters["Bottom"]),
Top = unname(curve_parameters["Bottom"] + exp(curve_parameters["LogSpan"])),
IC50 = unname(exp(curve_parameters["LogIC50"])),
Hill = unname(exp(curve_parameters["LogHill"])),
CurveDirection = "Competitive/decreasing: higher %B/B0 corresponds to lower cortisol"
)
}
curve_parameters_by_plate <- standards_for_curve %>%
group_by(`Plate Number`) %>%
group_modify(~ fit_one_plate_curve(.x)) %>%
ungroup()
cat("4-parameter logistic equation used for each plate:\n")
4-parameter logistic equation used for each plate:
cat("%B/B0 = Bottom + ((Top - Bottom) / (1 + (Concentration / IC50)^Hill))\n\n")
%B/B0 = Bottom + ((Top - Bottom) / (1 + (Concentration / IC50)^Hill))
cat("Fitted parameters by plate:\n")
Fitted parameters by plate:
print(curve_parameters_by_plate)
# A tibble: 1 × 6
`Plate Number` Bottom Top IC50 Hill CurveDirection
<chr> <dbl> <dbl> <dbl> <dbl> <chr>
1 1 4.14 120. 0.0189 0.579 Competitive/decreasing: higher %B/B0…
# Observed standard curve range by plate.
# This is used to flag concentrations that are extrapolated beyond the measured
# standards but still calculable from the fitted 4PL equation.
tail_coefficients_by_plate <- standards_for_curve %>%
group_by(`Plate Number`) %>%
group_modify(~ {
d <- .x %>%
filter(KnownConcentration_ng_mL > 0, is.finite(Percent_B_B0)) %>%
arrange(KnownConcentration_ng_mL)
if (nrow(d) < 2) {
return(tibble(
LowResponseTailSlope = NA_real_,
LowResponseTailIntercept = NA_real_,
HighResponseTailSlope = NA_real_,
HighResponseTailIntercept = NA_real_,
TailExtrapolationDirectionRule = "Insufficient standards for monotonic tail extrapolation"
))
}
# Competitive cortisol ELISA direction:
# higher %B/B0 -> lower cortisol concentration
# lower %B/B0 -> higher cortisol concentration
#
# To avoid the extrapolated tail flipping direction because of noisy standards,
# each tail line is anchored at the nearest measured standard boundary and the
# slope is forced to be negative when enforce_competitive_monotonic_tail_extrapolation = TRUE.
# This ensures that samples above the high-binding end of the curve get lower,
# not higher, concentrations as %B/B0 increases.
high_response_boundary <- d %>% slice_max(order_by = Percent_B_B0, n = 1, with_ties = FALSE)
high_response_neighbor <- d %>%
filter(row_number() != match(high_response_boundary$KnownConcentration_ng_mL, KnownConcentration_ng_mL)) %>%
slice_max(order_by = Percent_B_B0, n = 1, with_ties = FALSE)
low_response_boundary <- d %>% slice_min(order_by = Percent_B_B0, n = 1, with_ties = FALSE)
low_response_neighbor <- d %>%
filter(row_number() != match(low_response_boundary$KnownConcentration_ng_mL, KnownConcentration_ng_mL)) %>%
slice_min(order_by = Percent_B_B0, n = 1, with_ties = FALSE)
calculate_tail_line <- function(boundary, neighbor) {
dy <- neighbor$Percent_B_B0 - boundary$Percent_B_B0
dx <- log10(neighbor$KnownConcentration_ng_mL) - log10(boundary$KnownConcentration_ng_mL)
slope <- dx / dy
if (!is.finite(slope) || dy == 0) {
slope <- NA_real_
}
if (enforce_competitive_monotonic_tail_extrapolation && is.finite(slope)) {
slope <- -abs(slope)
}
intercept <- log10(boundary$KnownConcentration_ng_mL) - slope * boundary$Percent_B_B0
tibble(Slope = slope, Intercept = intercept)
}
high_tail <- calculate_tail_line(high_response_boundary, high_response_neighbor)
low_tail <- calculate_tail_line(low_response_boundary, low_response_neighbor)
tibble(
LowResponseTailSlope = low_tail$Slope,
LowResponseTailIntercept = low_tail$Intercept,
HighResponseTailSlope = high_tail$Slope,
HighResponseTailIntercept = high_tail$Intercept,
TailExtrapolationDirectionRule = if_else(
enforce_competitive_monotonic_tail_extrapolation,
"Strict competitive monotonic tail enforced: higher %B/B0 gives lower concentration",
"Tail slopes based only on nearest standards; monotonic direction not forced"
)
)
}) %>%
ungroup()
standard_curve_range_by_plate <- standards_for_curve %>%
group_by(`Plate Number`) %>%
summarize(
MinStandardConcentration_ng_mL = min(KnownConcentration_ng_mL, na.rm = TRUE),
MaxStandardConcentration_ng_mL = max(KnownConcentration_ng_mL, na.rm = TRUE),
MinStandardPercent_B_B0 = min(Percent_B_B0, na.rm = TRUE),
MaxStandardPercent_B_B0 = max(Percent_B_B0, na.rm = TRUE),
# These two columns make boundary estimates direction-safe.
# For a competitive ELISA, the lowest %B/B0 usually corresponds to the highest
# concentration, but this is calculated from the standards rather than assumed.
BoundaryConcentrationAtMinPercent_ng_mL = KnownConcentration_ng_mL[which.min(Percent_B_B0)],
BoundaryConcentrationAtMaxPercent_ng_mL = KnownConcentration_ng_mL[which.max(Percent_B_B0)],
.groups = "drop"
) %>%
left_join(tail_coefficients_by_plate, by = "Plate Number")
cat("Observed standard curve range by plate:\n")
Observed standard curve range by plate:
print(standard_curve_range_by_plate)
# A tibble: 1 × 12
`Plate Number` MinStandardConcentration_ng_mL MaxStandardConcentration_ng_mL
<chr> <dbl> <dbl>
1 1 0.005 50
# ℹ 9 more variables: MinStandardPercent_B_B0 <dbl>,
# MaxStandardPercent_B_B0 <dbl>,
# BoundaryConcentrationAtMinPercent_ng_mL <dbl>,
# BoundaryConcentrationAtMaxPercent_ng_mL <dbl>, LowResponseTailSlope <dbl>,
# LowResponseTailIntercept <dbl>, HighResponseTailSlope <dbl>,
# HighResponseTailIntercept <dbl>, TailExtrapolationDirectionRule <chr>
This table is a quick check that each plate was processed independently. It prints and saves the blank average, B0, and 4-parameter curve coefficients for every plate.
plate_processing_summary <- blank_summary %>%
select(`Plate Number`, AverageBlankRawOD, UsedBlankWells, ExcludedBlankWells) %>%
left_join(B0_by_plate, by = "Plate Number") %>%
left_join(curve_parameters_by_plate, by = "Plate Number") %>%
left_join(standard_curve_range_by_plate, by = "Plate Number") %>%
arrange(`Plate Number`)
cat("Plate-specific processing summary. Each row represents an independently processed plate:\n")
Plate-specific processing summary. Each row represents an independently processed plate:
print(plate_processing_summary)
# A tibble: 1 × 21
`Plate Number` AverageBlankRawOD UsedBlankWells ExcludedBlankWells B0
<chr> <dbl> <chr> <chr> <dbl>
1 1 0.0541 1:H11;1:H12 "" 0.392
# ℹ 16 more variables: Bottom <dbl>, Top <dbl>, IC50 <dbl>, Hill <dbl>,
# CurveDirection <chr>, MinStandardConcentration_ng_mL <dbl>,
# MaxStandardConcentration_ng_mL <dbl>, MinStandardPercent_B_B0 <dbl>,
# MaxStandardPercent_B_B0 <dbl>,
# BoundaryConcentrationAtMinPercent_ng_mL <dbl>,
# BoundaryConcentrationAtMaxPercent_ng_mL <dbl>, LowResponseTailSlope <dbl>,
# LowResponseTailIntercept <dbl>, HighResponseTailSlope <dbl>, …
write_csv(plate_processing_summary, plate_processing_summary_output)
cat("Plate processing summary written to: ", plate_processing_summary_output, "\n", sep = "")
Plate processing summary written to: cortisol_plate_processing_summary.csv
make_standard_curve_plot <- function(plate_number) {
this_standards_all <- standards_processed %>%
filter(`Plate Number` == plate_number)
this_standards_curve <- standards_for_curve %>%
filter(`Plate Number` == plate_number)
this_params <- curve_parameters_by_plate %>%
filter(`Plate Number` == plate_number)
curve_data <- tibble(
KnownConcentration_ng_mL = exp(seq(
log(min(this_standards_curve$KnownConcentration_ng_mL)),
log(max(this_standards_curve$KnownConcentration_ng_mL)),
length.out = 300
))
) %>%
mutate(
Predicted_Percent_B_B0 = four_parameter_logistic(
KnownConcentration_ng_mL,
Bottom = this_params$Bottom,
Top = this_params$Top,
IC50 = this_params$IC50,
Hill = this_params$Hill
)
)
ggplot() +
geom_point(
data = this_standards_all,
aes(x = KnownConcentration_ng_mL, y = Percent_B_B0),
size = 3
) +
geom_line(
data = curve_data,
aes(x = KnownConcentration_ng_mL, y = Predicted_Percent_B_B0),
linewidth = 1
) +
scale_x_continuous(
trans = scales::pseudo_log_trans(sigma = 0.001),
breaks = c(0, 0.005, 0.02, 0.1, 0.5, 2, 10, 50)
) +
labs(
title = paste("Cortisol ELISA 4-Parameter Standard Curve - Plate", plate_number),
x = "Known cortisol concentration (ng/mL)",
y = "%B/B0"
) +
theme_bw()
}
standard_curve_plots <- map(plate_file_map$`Plate Number`, make_standard_curve_plot)
# Print plots in the knitted report.
walk(standard_curve_plots, print)
# Save all standard curves into one multi-page PDF.
pdf(standard_curve_plot_output, width = 7, height = 5)
walk(standard_curve_plots, print)
dev.off()
quartz_off_screen
2
cat("Standard curve plot(s) written to: ", standard_curve_plot_output, "\n", sep = "")
Standard curve plot(s) written to: cortisol_standard_curve.pdf
For each sample listed in the metadata file, the two duplicate wells
are extracted from the correct plate, blank-corrected with that plate’s
blank average, averaged, converted to %B/B0 using that
plate’s B0, and back-calculated to a cortisol concentration from that
plate’s own 4-parameter standard curve.
The blank row is retained in the full output, but concentration calculations are only meaningful for actual samples.
processed_metadata <- metadata %>%
mutate(
duplicate_summary = pmap(
list(`Cell Position 1`, `Cell Position 2`, `Plate Number`),
summarize_duplicate_wells,
plate_long = plate_long,
excluded_well_table = excluded_well_table
)
) %>%
unnest(duplicate_summary) %>%
left_join(B0_by_plate, by = "Plate Number") %>%
left_join(curve_parameters_by_plate, by = "Plate Number") %>%
left_join(standard_curve_range_by_plate, by = "Plate Number") %>%
mutate(
Percent_B_B0 = (AverageBlankCorrectedOD / B0) * 100
)
# Add sample-specific or default dilution factors.
# If these columns already exist in the metadata file, keep and use them.
# If not, create them using the default values specified above.
if (!("DilutionFactor" %in% colnames(processed_metadata))) {
processed_metadata <- processed_metadata %>%
mutate(DilutionFactor = default_sample_dilution_factor)
}
if (!("ExtractionCorrectionFactor" %in% colnames(processed_metadata))) {
processed_metadata <- processed_metadata %>%
mutate(ExtractionCorrectionFactor = default_extraction_correction_factor)
}
processed_metadata <- processed_metadata %>%
mutate(
IsBlank = str_to_upper(SampleID) == "BLK",
IsStandard = str_detect(str_to_upper(SampleID), "^S[0-7]$"),
Percent_B_B0_Warning = case_when(
IsBlank ~ NA_character_,
Percent_B_B0 < 20 ~ "Below recommended 20% B/B0 range; dilute/rerun or interpret cautiously.",
Percent_B_B0 > 80 ~ "Above recommended 80% B/B0 range; dilute/rerun or interpret cautiously.",
TRUE ~ NA_character_
),
# Direction-independent check for whether a sample falls within the observed
# standard %B/B0 range on its own plate. Competitive ELISAs often decrease
# with concentration, so use min/max rather than assuming a direction.
WithinObservedStandardPercentRange = !is.na(Percent_B_B0) &
Percent_B_B0 >= MinStandardPercent_B_B0 &
Percent_B_B0 <= MaxStandardPercent_B_B0,
RawCurveConcentration_ng_mL = if_else(
IsBlank,
NA_real_,
invert_four_parameter_logistic(
Percent_B_B0,
Bottom = Bottom,
Top = Top,
IC50 = IC50,
Hill = Hill
)
),
# The fitted 4PL equation has upper and lower asymptotes. If a sample %B/B0
# is beyond those fitted asymptotes, the exact 4PL inverse cannot return a
# real concentration even if the sample is only slightly above the preferred
# 20-80% range. In that case, optional log-linear tail extrapolation is used.
FittedMinAsymptotePercent_B_B0 = pmin(Bottom, Top),
FittedMaxAsymptotePercent_B_B0 = pmax(Bottom, Top),
NeedsHighResponseTailExtrapolation = is.na(RawCurveConcentration_ng_mL) &
!is.na(Percent_B_B0) &
Percent_B_B0 > FittedMaxAsymptotePercent_B_B0,
NeedsLowResponseTailExtrapolation = is.na(RawCurveConcentration_ng_mL) &
!is.na(Percent_B_B0) &
Percent_B_B0 < FittedMinAsymptotePercent_B_B0,
# Fallback: if the 4PL inverse returns NA for another numerical reason, use
# the nearest observed standard-response tail. This prevents samples such as
# high-%B/B0 values above the preferred 80% range from being left as NA when
# exploratory extrapolation is enabled.
NearestTailForUnsolvedSample = case_when(
is.na(RawCurveConcentration_ng_mL) & !is.na(Percent_B_B0) &
abs(Percent_B_B0 - MaxStandardPercent_B_B0) <= abs(Percent_B_B0 - MinStandardPercent_B_B0) ~ "high_response",
is.na(RawCurveConcentration_ng_mL) & !is.na(Percent_B_B0) ~ "low_response",
TRUE ~ NA_character_
),
CurveBackCalculationType = case_when(
IsBlank ~ "Blank - not calculated",
is.na(Percent_B_B0) ~ "No %B/B0 available",
!is.na(RawCurveConcentration_ng_mL) & WithinObservedStandardPercentRange ~ "Interpolated within observed standard range",
!is.na(RawCurveConcentration_ng_mL) & !WithinObservedStandardPercentRange ~ "4PL equation extrapolated outside observed standard range - interpret cautiously",
use_tail_extrapolation_outside_model_asymptotes & NeedsHighResponseTailExtrapolation ~ "Outside fitted 4PL upper asymptote; log-linear low-concentration/high-binding tail extrapolation - exploratory only",
use_tail_extrapolation_outside_model_asymptotes & NeedsLowResponseTailExtrapolation ~ "Outside fitted 4PL lower asymptote; log-linear high-concentration/low-binding tail extrapolation - exploratory only",
use_tail_extrapolation_outside_model_asymptotes & is.na(RawCurveConcentration_ng_mL) & NearestTailForUnsolvedSample == "high_response" ~ "4PL inverse failed numerically; log-linear low-concentration/high-binding tail extrapolation - exploratory only",
use_tail_extrapolation_outside_model_asymptotes & is.na(RawCurveConcentration_ng_mL) & NearestTailForUnsolvedSample == "low_response" ~ "4PL inverse failed numerically; log-linear high-concentration/low-binding tail extrapolation - exploratory only",
is.na(RawCurveConcentration_ng_mL) & use_boundary_estimates_outside_model_asymptotes & Percent_B_B0 > MaxStandardPercent_B_B0 ~ "Outside fitted curve/asymptote; boundary estimate at low concentration limit",
is.na(RawCurveConcentration_ng_mL) & use_boundary_estimates_outside_model_asymptotes & Percent_B_B0 < MinStandardPercent_B_B0 ~ "Outside fitted curve/asymptote; boundary estimate at high concentration limit",
is.na(RawCurveConcentration_ng_mL) ~ "Outside fitted curve/asymptote; no exact concentration solution",
TRUE ~ "Unclassified curve result"
),
TailExtrapolatedConcentration_ng_mL = case_when(
IsBlank ~ NA_real_,
!use_tail_extrapolation_outside_model_asymptotes ~ NA_real_,
is.na(Percent_B_B0) ~ NA_real_,
is.finite(RawCurveConcentration_ng_mL) ~ NA_real_,
NeedsHighResponseTailExtrapolation | NearestTailForUnsolvedSample == "high_response" ~ tail_extrapolate_log_linear(
Percent_B_B0,
slope = HighResponseTailSlope,
intercept = HighResponseTailIntercept
),
NeedsLowResponseTailExtrapolation | NearestTailForUnsolvedSample == "low_response" ~ tail_extrapolate_log_linear(
Percent_B_B0,
slope = LowResponseTailSlope,
intercept = LowResponseTailIntercept
),
TRUE ~ NA_real_
),
BoundaryEstimateConcentration_ng_mL = case_when(
IsBlank ~ NA_real_,
!use_boundary_estimates_outside_model_asymptotes ~ NA_real_,
is.na(Percent_B_B0) ~ NA_real_,
# If the sample response is above the observed standard-response range,
# assign the concentration of the standard at the high-response boundary.
Percent_B_B0 > MaxStandardPercent_B_B0 ~ BoundaryConcentrationAtMaxPercent_ng_mL,
# If the sample response is below the observed standard-response range,
# assign the concentration of the standard at the low-response boundary.
# In a typical competitive cortisol ELISA this is the highest standard, S7.
Percent_B_B0 < MinStandardPercent_B_B0 ~ BoundaryConcentrationAtMinPercent_ng_mL,
TRUE ~ NA_real_
),
Concentration_ng_mL = case_when(
IsBlank ~ NA_real_,
!allow_curve_extrapolation & !WithinObservedStandardPercentRange ~ NA_real_,
is.finite(RawCurveConcentration_ng_mL) ~ RawCurveConcentration_ng_mL,
use_tail_extrapolation_outside_model_asymptotes & is.finite(TailExtrapolatedConcentration_ng_mL) ~ TailExtrapolatedConcentration_ng_mL,
use_boundary_estimates_outside_model_asymptotes & is.finite(BoundaryEstimateConcentration_ng_mL) ~ BoundaryEstimateConcentration_ng_mL,
TRUE ~ NA_real_
),
UsesExtrapolatedConcentration = str_detect(CurveBackCalculationType, "extrapolat"),
UsesTailExtrapolation = str_detect(CurveBackCalculationType, "tail extrapolation"),
UsesBoundaryEstimate = str_detect(CurveBackCalculationType, "boundary estimate"),
QuantificationConfidence = case_when(
IsBlank ~ "Blank/control - not quantified",
IsStandard ~ "Standard/control - not a research sample",
is.na(Concentration_ng_mL) ~ "No reportable concentration under current settings",
UsesBoundaryEstimate ~ "Censored boundary estimate - interpret with extreme caution",
UsesTailExtrapolation ~ "Very low confidence - log-linear tail extrapolation outside fitted 4PL asymptote",
UsesExtrapolatedConcentration ~ "Low confidence - 4PL extrapolation outside measured standard range",
WithinObservedStandardPercentRange &
Percent_B_B0 >= preferred_percent_B_B0_lower &
Percent_B_B0 <= preferred_percent_B_B0_upper ~ "High confidence - within measured standard range and preferred 20-80% B/B0 range",
WithinObservedStandardPercentRange ~ "Moderate confidence - within measured standard range but outside preferred 20-80% B/B0 range",
TRUE ~ "Unclassified quantification confidence"
),
ConcentrationInterpretationNote = case_when(
IsBlank ~ NA_character_,
CurveBackCalculationType == "Interpolated within observed standard range" ~ "Best-supported value: sample %B/B0 is within the measured standard curve range.",
UsesTailExtrapolation ~ "Exploratory-only value: sample is outside the fitted 4PL asymptote, so the exact 4PL equation cannot be inverted. This value uses a local log-linear tail extrapolation from the nearest two standards; rerun at a better dilution for final interpretation.",
UsesExtrapolatedConcentration ~ "Cautious/preliminary value: sample is outside the measured standard range but still has a finite 4PL back-calculated concentration.",
UsesBoundaryEstimate ~ "Censored/boundary estimate: sample response is outside the fitted curve/asymptote, so this is not an exact concentration. Treat as approximate and rerun at a better dilution.",
is.na(Concentration_ng_mL) ~ "No concentration reported because the value cannot be reliably solved from the fitted curve under current settings.",
TRUE ~ NA_character_
),
AssayRerunRecommendation = case_when(
IsBlank ~ "Blank/control - no rerun recommendation",
IsStandard ~ "Standard/control - no rerun recommendation",
is.na(Percent_B_B0) ~ "Cannot evaluate - missing %B/B0",
Percent_B_B0 > preferred_percent_B_B0_upper ~ "Signal too high / cortisol likely low: rerun less diluted or concentrate sample if accurate quantification is needed",
Percent_B_B0 < preferred_percent_B_B0_lower ~ "Signal too low / cortisol likely high: dilute sample more and rerun if accurate quantification is needed",
TRUE ~ "Within preferred 20-80% B/B0 working range"
),
CompetitiveELISAInterpretation = case_when(
IsBlank ~ "Blank/control",
IsStandard ~ "Standard/control",
is.na(Percent_B_B0) ~ "Missing %B/B0",
Percent_B_B0 > preferred_percent_B_B0_upper ~ "High OD/%B/B0 = low cortisol; near top plateau",
Percent_B_B0 < preferred_percent_B_B0_lower ~ "Low OD/%B/B0 = high cortisol; near bottom plateau",
TRUE ~ "Preferred working range"
),
FinalConcentration_ng_mL = Concentration_ng_mL * DilutionFactor * ExtractionCorrectionFactor,
CompetitiveELISADirectionCheck = case_when(
IsBlank ~ "Blank - not evaluated",
IsStandard ~ "Standard/control - not evaluated as sample",
is.na(Percent_B_B0) | is.na(Concentration_ng_mL) ~ "Cannot evaluate direction - missing value",
Percent_B_B0 > preferred_percent_B_B0_upper & Concentration_ng_mL > IC50 ~
"CHECK: high %B/B0 should indicate low cortisol; concentration is above plate IC50",
Percent_B_B0 < preferred_percent_B_B0_lower & Concentration_ng_mL < IC50 ~
"CHECK: low %B/B0 should indicate high cortisol; concentration is below plate IC50",
TRUE ~ "Direction consistent with competitive ELISA"
)
) %>%
mutate(
ProcessingStatus = case_when(
IsBlank ~ "Blank control; excluded from concentration statistics.",
IsStandard ~ "Standard/control well; used for curve fitting but excluded from sample statistics.",
NumberOfWellsUsedForAverage == 0 ~ "No usable duplicate wells after exclusions or missing OD values.",
is.na(AverageBlankCorrectedOD) ~ "Missing average blank-corrected OD.",
is.na(B0) ~ "Missing B0 for this plate; check standards on this plate.",
is.na(Percent_B_B0) ~ "Could not calculate %B/B0.",
is.na(Concentration_ng_mL) ~ "Could not back-calculate concentration from the plate-specific 4PL curve under current settings.",
!is.finite(Concentration_ng_mL) ~ "Back-calculated concentration was not finite.",
is.na(DilutionFactor) | is.na(ExtractionCorrectionFactor) ~ "Missing dilution or extraction correction factor.",
is.na(FinalConcentration_ng_mL) ~ "Final concentration is missing after corrections.",
UsesBoundaryEstimate ~ "Usable only as a cautious boundary estimate; rerun at a better dilution for final interpretation.",
UsesExtrapolatedConcentration ~ "Usable as cautious extrapolated concentration; rerun if this will be used for final interpretation.",
TRUE ~ "Usable sample concentration."
),
IncludedInPlotsAndStatistics = !IsBlank &
(!exclude_standards_from_plots_and_statistics | !IsStandard) &
!(SampleID %in% exclude_sample_ids_from_plots_and_statistics) &
!is.na(FinalConcentration_ng_mL) &
(include_extrapolated_concentrations_in_statistics | (!UsesExtrapolatedConcentration & !UsesBoundaryEstimate))
)
processed_metadata
# A tibble: 40 × 67
SampleID `Plate Number` `Cell Position 1` `Cell Position 2` `Dilution Factor`
<chr> <chr> <chr> <chr> <dbl>
1 D-19_06… 1 A3 A4 0
2 D-19_06… 1 B3 B4 35
3 D-19_06… 1 C3 C4 30
4 D-48_06… 1 D3 D4 0
5 D-48_06… 1 E3 E4 26.5
6 D-48_06… 1 F3 F4 0
7 D-CTL-4… 1 G3 G4 50
8 D-43_06… 1 H3 H4 0
9 D-CTL-1… 1 A5 A6 50
10 D-CTL-1… 1 B5 B6 45.5
# ℹ 30 more rows
# ℹ 62 more variables:
# SampleTissue_MassorVolume_ForCortisolExtraction_mg_or_ul <dbl>,
# ExtractionCorrectionFactor <dbl>, Tissue <chr>, Ploidy <chr>,
# SampleTime <chr>, Treatment <chr>, RawOD_1 <dbl>, RawOD_2 <dbl>,
# BlankCorrectedOD_1 <dbl>, BlankCorrectedOD_2 <dbl>, Well1_Excluded <lgl>,
# Well2_Excluded <lgl>, ExcludedWellsForSample <chr>, …
The column CurveBackCalculationType separates
concentration values into practical calculation categories:
%B/B0 falls within the
measured standard curve range for that plate.%B/B0, so the script uses the nearest two
nonzero standards at that end of the curve to preserve variation for
exploratory visualization. This is not a true 4PL concentration and
should be rerun at a better dilution for final interpretation.use_boundary_estimates_outside_model_asymptotes <- TRUE.
These are censored/approximate values, not exact concentrations, and are
off by default because they can collapse many samples to identical
values.The column QuantificationConfidence gives a quick
user-facing interpretation of those categories:
curve_backcalculation_summary <- processed_metadata %>%
filter(!IsBlank, !IsStandard) %>%
count(`Plate Number`, CurveBackCalculationType, name = "n_samples")
curve_backcalculation_summary
# A tibble: 3 × 3
`Plate Number` CurveBackCalculationType n_samples
<chr> <chr> <int>
1 1 4PL equation extrapolated outside observed standard … 11
2 1 Interpolated within observed standard range 2
3 1 Outside fitted 4PL upper asymptote; log-linear low-c… 26
This plot overlays research samples onto the plate-specific standard curve. The shaded region marks the measured standard concentration range. Samples outside this shaded region were not interpolated between standards and should be interpreted cautiously.
make_standard_curve_with_samples_plot <- function(plate_number) {
this_standards_all <- standards_processed %>%
filter(`Plate Number` == plate_number)
this_standards_curve <- standards_for_curve %>%
filter(`Plate Number` == plate_number)
this_samples <- processed_metadata %>%
filter(
`Plate Number` == plate_number,
!IsBlank,
!IsStandard,
!is.na(Percent_B_B0),
!is.na(Concentration_ng_mL),
is.finite(Concentration_ng_mL),
Concentration_ng_mL > 0
)
this_params <- curve_parameters_by_plate %>%
filter(`Plate Number` == plate_number)
if (nrow(this_params) == 0 || nrow(this_standards_curve) == 0) {
return(
ggplot() +
annotate("text", x = 1, y = 1, label = paste("No standard curve available for Plate", plate_number)) +
theme_void()
)
}
min_standard_conc <- min(this_standards_curve$KnownConcentration_ng_mL, na.rm = TRUE)
max_standard_conc <- max(this_standards_curve$KnownConcentration_ng_mL, na.rm = TRUE)
# Extend the displayed x-axis to include samples when extrapolated values exist.
min_plot_conc <- min(c(min_standard_conc, this_samples$Concentration_ng_mL), na.rm = TRUE)
max_plot_conc <- max(c(max_standard_conc, this_samples$Concentration_ng_mL), na.rm = TRUE)
# Keep plotting limits finite and positive.
if (!is.finite(min_plot_conc) || min_plot_conc <= 0) min_plot_conc <- min_standard_conc
if (!is.finite(max_plot_conc) || max_plot_conc <= 0) max_plot_conc <- max_standard_conc
curve_data <- tibble(
KnownConcentration_ng_mL = exp(seq(
log(min_plot_conc),
log(max_plot_conc),
length.out = 500
))
) %>%
mutate(
Predicted_Percent_B_B0 = four_parameter_logistic(
KnownConcentration_ng_mL,
Bottom = this_params$Bottom,
Top = this_params$Top,
IC50 = this_params$IC50,
Hill = this_params$Hill
)
)
p <- ggplot()
if (isTRUE(show_standard_range_shading)) {
p <- p +
annotate(
"rect",
xmin = min_standard_conc, xmax = max_standard_conc,
ymin = -Inf, ymax = Inf,
alpha = 0.10
)
}
p +
geom_line(
data = curve_data,
aes(x = KnownConcentration_ng_mL, y = Predicted_Percent_B_B0),
linewidth = 1
) +
geom_point(
data = this_standards_all,
aes(x = KnownConcentration_ng_mL, y = Percent_B_B0),
size = 3,
shape = 17
) +
geom_point(
data = this_samples,
aes(x = Concentration_ng_mL, y = Percent_B_B0, color = QuantificationConfidence),
size = 2.3,
alpha = 0.85
) +
scale_x_continuous(
trans = scales::pseudo_log_trans(sigma = 0.001),
breaks = sort(unique(c(0.005, 0.02, 0.1, 0.5, 2, 10, 50, min_standard_conc, max_standard_conc)))
) +
labs(
title = paste("Cortisol ELISA Standard Curve with Samples - Plate", plate_number),
subtitle = "Shaded region = measured standard concentration range; sample points outside this region are extrapolated/cautious.",
x = "Cortisol concentration from curve (ng/mL before dilution/extraction correction)",
y = "%B/B0",
color = "Quantification confidence"
) +
theme_bw() +
guides(
color = guide_legend(
nrow = 4,
byrow = TRUE,
title.position = "top"
)
) +
theme(
legend.position = "bottom",
legend.text = element_text(size = 8),
legend.title = element_text(size = 9)
)
}
standard_curve_with_samples_plots <- map(plate_file_map$`Plate Number`, make_standard_curve_with_samples_plot)
walk(standard_curve_with_samples_plots, print)
pdf(standard_curve_with_samples_plot_output, width = 8, height = 5)
walk(standard_curve_with_samples_plots, print)
dev.off()
quartz_off_screen
2
cat("Standard curve with sample overlay plot(s) written to: ", standard_curve_with_samples_plot_output, "\n", sep = "")
Standard curve with sample overlay plot(s) written to: cortisol_standard_curve_with_samples.pdf
This plot is a teaching/QC aid for competitive ELISA interpretation.
Cortisol is measured by a competitive assay, so the
signal is inverse to analyte concentration: higher OD or
%B/B0 means lower cortisol, while lower OD or
%B/B0 means higher cortisol.
The plot overlays actual samples onto a conceptual inverse curve and labels the assay action that would usually improve quantification:
%B/B0 > 80%: signal is high and cortisol is likely
low. If accurate quantification is needed, rerun the sample less
diluted or concentrate the sample.%B/B0 between 20% and 80%: preferred working
range.%B/B0 < 20%: signal is low and cortisol is likely
high. If accurate quantification is needed, dilute more
and rerun.make_competitive_interpretation_plot_one_plate <- function(plate_number) {
this_samples <- processed_metadata %>%
filter(
`Plate Number` == plate_number,
!IsBlank,
!IsStandard,
!is.na(Percent_B_B0),
!is.na(Concentration_ng_mL),
is.finite(Concentration_ng_mL),
Concentration_ng_mL > 0
) %>%
mutate(
AssayActionShort = case_when(
Percent_B_B0 > preferred_percent_B_B0_upper ~ "Less diluted / concentrate",
Percent_B_B0 < preferred_percent_B_B0_lower ~ "Dilute more",
TRUE ~ "Preferred range"
)
)
if (nrow(this_samples) == 0) {
return(
ggplot() +
annotate("text", x = 1, y = 1, label = paste("No quantified samples available for Plate", plate_number)) +
theme_void()
)
}
min_sample_conc <- min(this_samples$Concentration_ng_mL, na.rm = TRUE)
max_sample_conc <- max(this_samples$Concentration_ng_mL, na.rm = TRUE)
min_x <- min(c(0.001, min_sample_conc), na.rm = TRUE)
max_x <- max(c(100, max_sample_conc), na.rm = TRUE)
if (!is.finite(min_x) || min_x <= 0) min_x <- 0.001
if (!is.finite(max_x) || max_x <= min_x) max_x <- 100
conceptual_curve <- tibble(
Concentration_ng_mL = exp(seq(log(min_x), log(max_x), length.out = 500))
) %>%
mutate(
Percent_B_B0 = 100 / (1 + (Concentration_ng_mL / 1)^1.5)
)
label_data <- this_samples %>%
mutate(
DistanceFromPreferredRange = case_when(
Percent_B_B0 > preferred_percent_B_B0_upper ~ Percent_B_B0 - preferred_percent_B_B0_upper,
Percent_B_B0 < preferred_percent_B_B0_lower ~ preferred_percent_B_B0_lower - Percent_B_B0,
TRUE ~ 0
)
) %>%
arrange(desc(DistanceFromPreferredRange)) %>%
slice_head(n = number_of_extreme_samples_to_label) %>%
filter(label_extreme_samples_on_interpretation_plot, DistanceFromPreferredRange > 0)
ggplot(conceptual_curve, aes(x = Concentration_ng_mL, y = Percent_B_B0)) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = preferred_percent_B_B0_upper, ymax = 100,
alpha = 0.08) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = preferred_percent_B_B0_lower, ymax = preferred_percent_B_B0_upper,
alpha = 0.08) +
annotate("rect", xmin = -Inf, xmax = Inf, ymin = 0, ymax = preferred_percent_B_B0_lower,
alpha = 0.08) +
geom_hline(yintercept = c(preferred_percent_B_B0_lower, preferred_percent_B_B0_upper), linetype = "dashed") +
geom_line(linewidth = 1) +
geom_point(
data = this_samples,
aes(x = Concentration_ng_mL, y = Percent_B_B0, color = AssayActionShort),
inherit.aes = FALSE,
size = 2.4,
alpha = 0.85
) +
geom_text(
data = label_data,
aes(x = Concentration_ng_mL, y = Percent_B_B0, label = SampleID),
inherit.aes = FALSE,
size = 2.8,
vjust = -0.7,
check_overlap = TRUE
) +
annotate("text", x = min_x, y = 92, hjust = 0,
label = "High OD / high %B/B0\nLow cortisol\nLess diluted or concentrate") +
annotate("text", x = sqrt(min_x * max_x), y = 50, hjust = 0.5,
label = "Preferred 20-80% B/B0\nBest quantification zone") +
annotate("text", x = max_x, y = 8, hjust = 1,
label = "Low OD / low %B/B0\nHigh cortisol\nDilute more") +
scale_x_continuous(trans = scales::pseudo_log_trans(sigma = 0.001)) +
labs(
title = paste("Competitive ELISA Interpretation with Actual Samples - Plate", plate_number),
subtitle = "Cortisol concentration is inversely related to OD/%B/B0; labels show rerun direction for out-of-range samples.",
x = "Back-calculated cortisol concentration (ng/mL before dilution/extraction correction)",
y = "%B/B0 (signal)",
color = "Assay action"
) +
theme_bw() +
theme(legend.position = "bottom")
}
if (isTRUE(make_competitive_elisa_interpretation_plot)) {
competitive_interpretation_plots <- map(plate_file_map$`Plate Number`, make_competitive_interpretation_plot_one_plate)
walk(competitive_interpretation_plots, print)
pdf(competitive_elisa_interpretation_plot_output, width = 8, height = 5)
walk(competitive_interpretation_plots, print)
dev.off()
cat("Competitive ELISA interpretation plot(s) written to: ", competitive_elisa_interpretation_plot_output, "\n", sep = "")
}
Competitive ELISA interpretation plot(s) written to: cortisol_competitive_elisa_interpretation_with_samples.pdf
Best results are expected for samples with %B/B0 values
between 20% and 80%. Samples outside this range should generally be
diluted and rerun, or interpreted cautiously.
warnings_table <- processed_metadata %>%
filter(!IsBlank, !is.na(Percent_B_B0_Warning)) %>%
select(`Plate Number`, SampleID, `Cell Position 1`, `Cell Position 2`, Percent_B_B0, Percent_B_B0_Warning)
if (nrow(warnings_table) == 0) {
cat("No samples fell outside the recommended 20-80% B/B0 range.\n")
} else {
cat("WARNING: The following samples fell outside the recommended 20-80% B/B0 range:\n")
print(warnings_table)
}
WARNING: The following samples fell outside the recommended 20-80% B/B0 range:
# A tibble: 39 × 6
`Plate Number` SampleID `Cell Position 1` `Cell Position 2` Percent_B_B0
<chr> <chr> <chr> <chr> <dbl>
1 1 D-19_0609_P2 A3 A4 123.
2 1 D-19_0609_P3 B3 B4 127.
3 1 D-19_0609_P4 C3 C4 117.
4 1 D-48_0610_P4 D3 D4 82.4
5 1 D-48_0610_P2 E3 E4 115.
6 1 D-48_0610_P1 F3 F4 130.
7 1 D-CTL-43_061… G3 G4 124.
8 1 D-43_0610_P3 H3 H4 108.
9 1 D-CTL-19_060… A5 A6 117.
10 1 D-CTL-19_060… B5 B6 122.
# ℹ 29 more rows
# ℹ 1 more variable: Percent_B_B0_Warning <chr>
The processed metadata output includes ExcludedWellNote,
WellsUsedForAverage, and
NumberOfWellsUsedForAverage. These columns document cases
where a duplicate average was calculated from only one usable well.
write_csv(processed_metadata, processed_metadata_output)
cat("Processed metadata written to: ", processed_metadata_output, "\n", sep = "")
Processed metadata written to: processed_cortisol_metadata.csv
excluded_well_notes <- processed_metadata %>%
filter(!is.na(ExcludedWellNote)) %>%
select(
`Plate Number`,
SampleID,
`Cell Position 1`,
`Cell Position 2`,
ExcludedWellsForSample,
WellsUsedForAverage,
NumberOfWellsUsedForAverage,
ExcludedWellNote
)
if (nrow(excluded_well_notes) == 0) {
cat("No sample duplicate pairs were affected by manually excluded wells.\n")
} else {
write_csv(excluded_well_notes, excluded_well_notes_output)
cat("Excluded-well notes written to: ", excluded_well_notes_output, "\n", sep = "")
print(excluded_well_notes)
}
No sample duplicate pairs were affected by manually excluded wells.
This section uses the metadata CSV as the guide for group names and
comparisons. Only these metadata columns are assumed to be fixed:
SampleID, Cell Position 1,
Cell Position 2, and Plate Number. Any
additional metadata columns are treated as possible grouping variables,
such as Treatment, SampleDay,
Timepoint, Site, Tank,
Exposure, etc.
You can also define optional combined grouping variables in the
user-input section, such as
TreatmentDay = Treatment + SampleDay. If those columns are
not present in a future metadata file, that combined grouping variable
is simply skipped.
Important plotting details:
SampleID == "BLK") is excluded
from all plots and statistics.plot_data <- processed_metadata %>%
# Do not include blanks in any downstream plots or grouping statistics.
filter(!IsBlank) %>%
# Standards are curve-calibration wells, not research samples.
filter(!exclude_standards_from_plots_and_statistics | !IsStandard) %>%
# Optional manual sample-level exclusions for plotting/statistics only.
filter(!(SampleID %in% exclude_sample_ids_from_plots_and_statistics)) %>%
# Statistical tests require a calculated concentration.
filter(!is.na(FinalConcentration_ng_mL))
# Identify the core metadata columns that are always present and should not be treated
# as experimental grouping variables.
required_metadata_columns <- c("SampleID", "Cell Position 1", "Cell Position 2", "Plate Number")
# Technical columns are generated by this workflow and should not be used as experimental groups.
technical_columns <- c(
required_metadata_columns,
"RawOD_1", "RawOD_2", "AverageRawOD",
"BlankCorrectedOD_1", "BlankCorrectedOD_2", "AverageBlankCorrectedOD",
"Well1_Excluded", "Well2_Excluded", "ExcludedWellsForSample", "WellsUsedForAverage",
"NumberOfWellsUsedForAverage", "ExcludedWellNote",
"Percent_B_B0", "Percent_B_B0_Warning",
"DilutionFactor", "ExtractionCorrectionFactor",
"Concentration_ng_mL", "FinalConcentration_ng_mL", "IsBlank", "IsStandard"
)
# Candidate grouping variables come from any metadata columns beyond the required first three columns.
# This keeps the workflow general: Treatment/SampleDay can be used when present, but future metadata
# files can instead use columns such as Site, Timepoint, Sex, Tank, Exposure, Batch, etc.
metadata_grouping_columns <- setdiff(colnames(metadata), required_metadata_columns)
metadata_grouping_columns <- setdiff(metadata_grouping_columns, c("DilutionFactor", "ExtractionCorrectionFactor"))
# If the researcher requested specific grouping variables, use only those columns if they exist.
if (!is.null(grouping_variables_to_analyze)) {
missing_requested_groups <- setdiff(grouping_variables_to_analyze, colnames(plot_data))
if (length(missing_requested_groups) > 0) {
warning(
"These requested grouping variable(s) were not found in the metadata and will be skipped: ",
paste(missing_requested_groups, collapse = ", ")
)
}
metadata_grouping_columns <- intersect(grouping_variables_to_analyze, colnames(plot_data))
}
# Create any requested combined grouping variables, but only when all component columns exist.
created_combined_grouping_columns <- character()
if (length(combined_grouping_variables) > 0) {
for (new_group_name in names(combined_grouping_variables)) {
component_columns <- combined_grouping_variables[[new_group_name]]
missing_components <- setdiff(component_columns, colnames(plot_data))
if (length(missing_components) == 0) {
plot_data <- plot_data %>%
mutate(
!!new_group_name := pmap_chr(
select(., all_of(component_columns)),
~ paste(c(...), collapse = "_")
)
)
created_combined_grouping_columns <- c(created_combined_grouping_columns, new_group_name)
} else {
message(
"Skipping combined grouping variable '", new_group_name,
"' because these metadata column(s) were not found: ",
paste(missing_components, collapse = ", ")
)
}
}
}
# Analyze combined variables first, followed by individual metadata variables.
grouping_columns <- c(created_combined_grouping_columns, metadata_grouping_columns)
grouping_columns <- unique(setdiff(grouping_columns, technical_columns))
# Keep grouping columns that have at least two unique non-missing values.
grouping_columns <- grouping_columns[map_int(plot_data[grouping_columns], ~ n_distinct(.x, na.rm = TRUE)) >= 2]
cat("Grouping variables that will be plotted/analyzed:\n")
Grouping variables that will be plotted/analyzed:
if (length(grouping_columns) == 0) {
cat("None found. Add metadata columns beyond SampleID, Cell Position 1, Cell Position 2, and Plate Number, or check grouping_variables_to_analyze.\n")
} else {
print(grouping_columns)
}
[1] "Ploidy"
[2] "Dilution Factor"
[3] "SampleTissue_MassorVolume_ForCortisolExtraction_mg_or_ul"
[4] "SampleTime"
[5] "Treatment"
# Create compact letter displays for a grouping variable.
# Interpretation: groups that share at least one letter are not significantly different.
# Groups with no letters in common are significantly different at the selected alpha level.
make_compact_letters <- function(data, group_col, method = "tukey", alpha = 0.05) {
# Build a clean data frame for the selected grouping variable.
# Important: group names can sometimes contain spaces, hyphens, slashes, or other
# characters that confuse multcompView::multcompLetters(), because that function
# expects comparison names formatted like "GroupA-GroupB". To avoid this, the
# statistical model uses safe temporary group names, then maps the letters back
# onto the original metadata group names for plotting/output.
stats_data <- data %>%
filter(!is.na(.data[[group_col]]), !is.na(FinalConcentration_ng_mL)) %>%
mutate(GroupOriginal = as.character(.data[[group_col]])) %>%
droplevels()
if (n_distinct(stats_data$GroupOriginal) < 2 || nrow(stats_data) < 3) {
return(tibble())
}
original_levels <- sort(unique(stats_data$GroupOriginal))
safe_levels <- make.names(original_levels, unique = TRUE)
original_to_safe <- setNames(safe_levels, original_levels)
safe_to_original <- setNames(original_levels, safe_levels)
stats_data <- stats_data %>%
mutate(GroupSafe = factor(original_to_safe[GroupOriginal], levels = safe_levels))
method <- str_to_lower(method)
p_values <- tryCatch({
if (method == "tukey") {
anova_model <- aov(FinalConcentration_ng_mL ~ GroupSafe, data = stats_data)
tukey_result <- TukeyHSD(anova_model)$GroupSafe
p <- tukey_result[, "p adj"]
names(p) <- rownames(tukey_result)
p
} else if (method == "wilcoxon") {
wilcox_result <- pairwise.wilcox.test(
x = stats_data$FinalConcentration_ng_mL,
g = stats_data$GroupSafe,
p.adjust.method = "BH",
exact = FALSE
)
p_table <- as.data.frame(as.table(wilcox_result$p.value), stringsAsFactors = FALSE) %>%
filter(!is.na(Freq))
p <- p_table$Freq
names(p) <- paste(p_table$Var1, p_table$Var2, sep = "-")
p
} else {
stop("significance_letter_method must be either 'tukey' or 'wilcoxon'.")
}
}, error = function(e) {
warning("Could not calculate post-hoc p-values for compact letters for ", group_col, ": ", e$message)
NULL
})
if (is.null(p_values) || length(p_values) == 0) {
return(tibble())
}
letters <- tryCatch({
multcompView::multcompLetters(p_values, threshold = alpha)$Letters
}, error = function(e) {
warning(
"Post-hoc tests were calculated for ", group_col,
", but compact letters could not be generated. This usually happens when group names ",
"or pairwise-comparison names are difficult to parse. The post-hoc CSV outputs are still valid. ",
"Original error: ", e$message
)
NULL
})
if (is.null(letters) || length(letters) == 0) {
return(tibble())
}
tibble(
GroupingVariable = group_col,
Group = unname(safe_to_original[names(letters)]),
SignificanceLetters = unname(letters),
LetterMethod = method,
Alpha = alpha
)
}
# Pre-calculate letters for every grouping variable so they can be printed, saved, and added to plots.
if (show_significance_letters && length(grouping_columns) > 0) {
significance_letters <- map_dfr(
grouping_columns,
~ make_compact_letters(
data = plot_data,
group_col = .x,
method = significance_letter_method,
alpha = posthoc_significance_alpha
)
)
if (nrow(significance_letters) > 0) {
write_csv(significance_letters, significance_letters_output)
cat("\nCompact letter displays for plots:\n")
print(significance_letters)
cat("Significance letters written to: ", significance_letters_output, "\n", sep = "")
} else {
cat("\nNo compact letter displays could be calculated for the available grouping variables.\n")
}
} else {
significance_letters <- tibble()
}
Compact letter displays for plots:
# A tibble: 38 × 5
GroupingVariable Group SignificanceLetters LetterMethod Alpha
<chr> <chr> <chr> <chr> <dbl>
1 Ploidy Triploid_Homogenate a wilcoxon 0.05
2 Ploidy Diploid_Homogenate a wilcoxon 0.05
3 Dilution Factor 25 a wilcoxon 0.05
4 Dilution Factor 26.5 a wilcoxon 0.05
5 Dilution Factor 28 a wilcoxon 0.05
6 Dilution Factor 29 a wilcoxon 0.05
7 Dilution Factor 30 a wilcoxon 0.05
8 Dilution Factor 34 a wilcoxon 0.05
9 Dilution Factor 35 a wilcoxon 0.05
10 Dilution Factor 45 a wilcoxon 0.05
# ℹ 28 more rows
Significance letters written to: cortisol_significance_letters.csv
make_grouped_concentration_plot <- function(data, group_col) {
# Build a plotting-specific data frame so the x-axis values and the letter-table
# values are guaranteed to use the same variable name and type. This avoids a
# common issue where significance letters are calculated correctly but fail to
# appear because the letter table does not join cleanly to the plotted factor.
plot_df <- data %>%
filter(!is.na(.data[[group_col]]), !is.na(FinalConcentration_ng_mL)) %>%
mutate(PlotGroup = as.factor(as.character(.data[[group_col]]))) %>%
droplevels()
p <- ggplot(
plot_df,
aes(
x = PlotGroup,
y = FinalConcentration_ng_mL,
color = PlotGroup
)
) +
geom_boxplot(outlier.shape = NA, alpha = 0.35) +
geom_point(
position = position_jitter(width = 0.15, height = 0),
alpha = 0.8,
size = 2
) +
labs(
title = paste("Final cortisol concentration by", group_col),
subtitle = if (show_significance_letters) {
paste0(
"Letters show ", significance_letter_method,
" post-hoc groupings. Groups sharing a letter are not significantly different."
)
} else {
NULL
},
x = group_col,
y = "Final cortisol concentration (ng/mL)",
color = group_col
) +
theme_bw() +
theme(
axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = margin(t = 10, r = 10, b = 10, l = 10)
)
if (show_significance_letters && exists("significance_letters") && nrow(significance_letters) > 0) {
letters_for_plot <- significance_letters %>%
filter(GroupingVariable == group_col) %>%
transmute(
PlotGroup = as.factor(as.character(Group)),
SignificanceLetters = SignificanceLetters
)
if (nrow(letters_for_plot) > 0) {
label_positions <- plot_df %>%
group_by(PlotGroup) %>%
summarize(group_max = max(FinalConcentration_ng_mL, na.rm = TRUE), .groups = "drop")
y_range <- range(plot_df$FinalConcentration_ng_mL, na.rm = TRUE)
y_padding <- ifelse(diff(y_range) == 0, abs(max(y_range, na.rm = TRUE)) * 0.1, diff(y_range) * 0.10)
if (is.na(y_padding) || y_padding == 0) y_padding <- 0.1
letters_for_plot <- letters_for_plot %>%
left_join(label_positions, by = "PlotGroup") %>%
filter(!is.na(group_max)) %>%
mutate(label_y = if (use_log10_y_axis) group_max * 1.25 else group_max + y_padding)
if (nrow(letters_for_plot) > 0) {
# Add the letters and explicitly expand the y-axis so labels are not clipped.
p <- p +
geom_text(
data = letters_for_plot,
aes(x = PlotGroup, y = label_y, label = SignificanceLetters),
inherit.aes = FALSE,
color = "black",
size = 5,
fontface = "bold",
vjust = 0
) +
coord_cartesian(clip = "off")
if (!use_log10_y_axis) {
p <- p + expand_limits(y = max(letters_for_plot$label_y, na.rm = TRUE) + y_padding)
}
} else {
message("Significance letters were calculated for ", group_col,
", but none matched the plotted groups. Check group names in the metadata.")
}
} else {
message("No significance letters available for plotting variable: ", group_col)
}
}
if (use_log10_y_axis) {
p <- p +
scale_y_log10(expand = expansion(mult = c(0.05, 0.25))) +
labs(y = "Final cortisol concentration (ng/mL), log10 scale")
} else {
p <- p + scale_y_continuous(expand = expansion(mult = c(0.05, 0.20)))
}
p
}
if (length(grouping_columns) == 0) {
cat("No grouping columns with at least two groups were found. No grouped boxplots were created.\n")
} else {
pdf(grouped_boxplots_output, width = 9, height = 6)
for (group_col in grouping_columns) {
print(make_grouped_concentration_plot(plot_data, group_col))
}
dev.off()
cat("Grouped boxplots written to: ", grouped_boxplots_output, "\n", sep = "")
}
Grouped boxplots written to: cortisol_grouped_boxplots.pdf
# Extreme outlier screening for QC review.
#
# This section is intentionally separate from the primary analysis. The primary
# figures/statistics above still include all processed samples unless you manually
# exclude SampleIDs in the user-input section. This chunk only identifies samples
# that look unusually high/low relative to their group and, if requested, creates
# a secondary exploratory data set with those rows removed.
make_outlier_key <- function(data) {
key_cols <- c("Plate Number", "SampleID", "Cell Position 1", "Cell Position 2")
key_cols <- key_cols[key_cols %in% names(data)]
if (length(key_cols) == 0) {
return(as.character(seq_len(nrow(data))))
}
do.call(paste, c(data[key_cols], sep = "||"))
}
screen_extreme_outliers_for_group <- function(data, group_col, iqr_multiplier = 3, min_group_n = 6) {
if (!(group_col %in% names(data))) return(tibble())
data %>%
mutate(
OutlierKey = make_outlier_key(.),
GroupingVariable = group_col,
Group = as.character(.data[[group_col]])
) %>%
filter(!is.na(Group), !is.na(FinalConcentration_ng_mL)) %>%
group_by(GroupingVariable, Group) %>%
mutate(
GroupN = n(),
Q1 = quantile(FinalConcentration_ng_mL, 0.25, na.rm = TRUE),
Q3 = quantile(FinalConcentration_ng_mL, 0.75, na.rm = TRUE),
IQR_Value = IQR(FinalConcentration_ng_mL, na.rm = TRUE),
LowerExtremeOutlierCutoff = Q1 - iqr_multiplier * IQR_Value,
UpperExtremeOutlierCutoff = Q3 + iqr_multiplier * IQR_Value,
IsExtremeOutlier = GroupN >= min_group_n & IQR_Value > 0 &
(FinalConcentration_ng_mL < LowerExtremeOutlierCutoff |
FinalConcentration_ng_mL > UpperExtremeOutlierCutoff),
OutlierDirection = case_when(
IsExtremeOutlier & FinalConcentration_ng_mL < LowerExtremeOutlierCutoff ~ "Low extreme outlier",
IsExtremeOutlier & FinalConcentration_ng_mL > UpperExtremeOutlierCutoff ~ "High extreme outlier",
TRUE ~ NA_character_
)
) %>%
ungroup() %>%
filter(IsExtremeOutlier) %>%
select(
OutlierKey,
GroupingVariable, Group, GroupN,
any_of(c("Plate Number", "SampleID", biological_individual_id_column, "Tissue", "HatcheryLocation")),
FinalConcentration_ng_mL,
Q1, Q3, IQR_Value, LowerExtremeOutlierCutoff, UpperExtremeOutlierCutoff,
OutlierDirection,
any_of(c("ConcentrationBackCalculationStatus", "ConcentrationCaution", "Percent_B_B0", "Percent_B_B0_Warning", "ExcludedWellNote"))
) %>%
distinct()
}
if (isTRUE(screen_extreme_outliers) && exists("plot_data") && nrow(plot_data) > 0 && length(grouping_columns) > 0) {
extreme_outlier_screening <- map_dfr(
grouping_columns,
~ screen_extreme_outliers_for_group(
data = plot_data,
group_col = .x,
iqr_multiplier = extreme_outlier_iqr_multiplier,
min_group_n = minimum_group_n_for_outlier_screening
)
)
write_csv(extreme_outlier_screening, outlier_screening_output)
cat("\nExtreme outlier screening used the within-group Q1 - ", extreme_outlier_iqr_multiplier,
"*IQR and Q3 + ", extreme_outlier_iqr_multiplier,
"*IQR rule. This is a QC screen, not an automatic reason to delete data.\n", sep = "")
if (nrow(extreme_outlier_screening) == 0) {
cat("No extreme outliers were flagged using the current settings.\n")
} else {
cat("\nPotential extreme outliers flagged for researcher review:\n")
print(extreme_outlier_screening)
cat("\nExtreme outlier screening table written to: ", outlier_screening_output, "\n", sep = "")
}
if (isTRUE(produce_secondary_outputs_without_extreme_outliers) && nrow(extreme_outlier_screening) > 0) {
outlier_keys_to_exclude <- if (isTRUE(exclude_if_extreme_outlier_in_any_grouping)) {
unique(extreme_outlier_screening$OutlierKey)
} else {
character(0)
}
plot_data_no_extreme_outliers <- plot_data %>%
mutate(OutlierKey = make_outlier_key(.)) %>%
filter(!(OutlierKey %in% outlier_keys_to_exclude)) %>%
select(-OutlierKey)
cat("\nSecondary exploratory data set created with ", length(outlier_keys_to_exclude),
" row(s) removed because they were flagged as extreme outliers.\n", sep = "")
} else {
plot_data_no_extreme_outliers <- plot_data
}
} else {
extreme_outlier_screening <- tibble()
write_csv(extreme_outlier_screening, outlier_screening_output)
plot_data_no_extreme_outliers <- plot_data
cat("Extreme outlier screening was skipped.\n")
}
Extreme outlier screening used the within-group Q1 - 3*IQR and Q3 + 3*IQR rule. This is a QC screen, not an automatic reason to delete data.
Potential extreme outliers flagged for researcher review:
# A tibble: 7 × 17
OutlierKey GroupingVariable Group GroupN `Plate Number` SampleID Tissue
<chr> <chr> <chr> <int> <chr> <chr> <chr>
1 1||T-48_0610_P2|… Ploidy Trip… 20 1 T-48_06… Homog…
2 1||T-48_0610_P4|… Ploidy Trip… 20 1 T-48_06… Homog…
3 1||T-48_0610_P2|… SampleTime 48 15 1 T-48_06… Homog…
4 1||T-48_0610_P4|… SampleTime 48 15 1 T-48_06… Homog…
5 1||D48-RECOVER24… SampleTime 24 7 1 D48-REC… Homog…
6 1||T-48_0610_P4|… Treatment EXP 15 1 T-48_06… Homog…
7 1||D48-RECOVER24… Treatment REC 7 1 D48-REC… Homog…
# ℹ 10 more variables: FinalConcentration_ng_mL <dbl>, Q1 <dbl>, Q3 <dbl>,
# IQR_Value <dbl>, LowerExtremeOutlierCutoff <dbl>,
# UpperExtremeOutlierCutoff <dbl>, OutlierDirection <chr>,
# Percent_B_B0 <dbl>, Percent_B_B0_Warning <chr>, ExcludedWellNote <chr>
Extreme outlier screening table written to: cortisol_extreme_outlier_screening.csv
Secondary exploratory data set created with 3 row(s) removed because they were flagged as extreme outliers.
If extreme outliers were flagged above, this section creates a second set of jittered boxplots with those rows removed. These are exploratory QC plots and should be interpreted alongside the primary plots, not as a replacement unless the researcher decides the flagged samples should be excluded.
if (isTRUE(produce_secondary_outputs_without_extreme_outliers) &&
exists("plot_data_no_extreme_outliers") &&
exists("extreme_outlier_screening") &&
nrow(extreme_outlier_screening) > 0 &&
length(grouping_columns) > 0) {
pdf(grouped_boxplots_extreme_outliers_removed_output, width = 9, height = 6)
for (group_col in grouping_columns) {
p_no_outliers <- make_grouped_concentration_plot(plot_data_no_extreme_outliers, group_col) +
labs(
title = paste("Final cortisol concentration by", group_col, "(extreme outliers excluded)"),
subtitle = "Exploratory QC version: rows flagged by the extreme outlier screen were removed."
)
print(p_no_outliers)
}
dev.off()
cat("Secondary grouped boxplots with extreme outliers excluded written to: ",
grouped_boxplots_extreme_outliers_removed_output, "\n", sep = "")
} else {
cat("No secondary outlier-excluded grouped boxplots were created because no extreme outliers were flagged or the option is turned off.\n")
}
Secondary grouped boxplots with extreme outliers excluded written to: cortisol_grouped_boxplots_extreme_outliers_removed.pdf
This table helps troubleshoot unexpected group sizes. It reports how many non-blank, non-standard research-sample rows are present in the metadata, how many have a calculated final concentration, and how many unique research/sample IDs are represented. If group sizes look wrong, check this table first.
The diagnostic files have been written to the output folder. Check
sample_processing_diagnostics.csv,
samples_excluded_from_statistics_diagnostic.csv, and
group_representation_diagnostics.csv if group sizes look
unexpected.
This section runs the statistical comparisons you described: an overall test comparing all groups against one another, followed by post-hoc pairwise tests to identify which groups differ.
For each eligible metadata grouping variable, including any combined
grouping variables requested in
combined_grouping_variables, the code outputs:
n, mean, SD, median, and
IQR.The code also prints a simple assumption check. If the ANOVA residuals look non-normal or the group variances look unequal, the Kruskal-Wallis/Wilcoxon results are usually safer to emphasize, especially with small sample sizes.
# This section is intentionally defensive because metadata-driven grouping variables
# can vary a lot from one run to the next. Some grouping variables may have too few
# samples, only one replicate per group, all-identical values, or otherwise fail one
# of the statistical tests. Those cases are now skipped with an explanatory note
# instead of stopping the whole R Markdown render.
# Helper: safely convert a value to TRUE/FALSE.
is_true <- function(x) {
is.logical(x) && length(x) == 1 && !is.na(x) && x
}
# Helper: format p-values for assumption notes without creating "NA" warnings.
format_p <- function(x) {
if (length(x) != 1 || is.na(x)) {
return("not available")
}
as.character(round(x, 4))
}
# Helper to format pairwise.wilcox.test output into a tidy table.
tidy_pairwise_wilcox <- function(pairwise_result, grouping_variable) {
if (is.null(pairwise_result) || is.null(pairwise_result$p.value)) {
return(tibble())
}
p_matrix <- pairwise_result$p.value
as.data.frame(as.table(p_matrix), stringsAsFactors = FALSE) %>%
as_tibble() %>%
filter(!is.na(Freq)) %>%
transmute(
GroupingVariable = grouping_variable,
group1 = as.character(Var1),
group2 = as.character(Var2),
p_adjusted = as.numeric(Freq),
p_adjust_method = pairwise_result$p.adjust.method
)
}
# Helper to format TukeyHSD output into a tidy table.
tidy_tukey <- function(tukey_result, grouping_variable) {
if (is.null(tukey_result) || is.null(tukey_result$Group)) {
return(tibble())
}
tukey_table <- as.data.frame(tukey_result$Group)
tukey_table$comparison <- rownames(tukey_table)
rownames(tukey_table) <- NULL
tukey_table %>%
as_tibble() %>%
separate(comparison, into = c("group1", "group2"), sep = "-", remove = FALSE, extra = "merge") %>%
transmute(
GroupingVariable = grouping_variable,
comparison,
group1,
group2,
mean_difference = diff,
conf_low = lwr,
conf_high = upr,
p_adjusted = `p adj`
)
}
# Helper: create a standardized "not run" row.
not_run_row <- function(group_col, test_name, note) {
tibble(
GroupingVariable = group_col,
Test = test_name,
Statistic = NA_real_,
df = NA_real_,
p_value = NA_real_,
AssumptionNotes = note,
RecommendedInterpretation = "Do not interpret this test for this grouping variable."
)
}
if (is_true(run_group_statistics) && length(grouping_columns) > 0) {
group_summary_statistics <- list()
overall_test_results <- list()
tukey_posthoc_results <- list()
wilcoxon_posthoc_results <- list()
for (group_col in grouping_columns) {
stats_data <- plot_data %>%
filter(!is.na(.data[[group_col]]), !is.na(FinalConcentration_ng_mL)) %>%
mutate(Group = as.factor(.data[[group_col]])) %>%
droplevels()
number_of_groups <- n_distinct(stats_data$Group)
total_n <- nrow(stats_data)
# Summary statistics for each group.
# n_rows_used_in_stats counts concentration rows used in the tests.
# n_unique_ids_used_in_stats counts unique SampleID values by default, or the
# column named in statistics_unique_id_column if you set one. This prevents
# confusing row counts with biological/sample counts.
this_group_summary <- stats_data %>%
group_by(Group) %>%
summarize(
n_rows_used_in_stats = n(),
n_unique_ids_used_in_stats = n_distinct(.data[[statistics_unique_id_column]], na.rm = TRUE),
mean_ng_mL = mean(FinalConcentration_ng_mL, na.rm = TRUE),
sd_ng_mL = sd(FinalConcentration_ng_mL, na.rm = TRUE),
median_ng_mL = median(FinalConcentration_ng_mL, na.rm = TRUE),
iqr_ng_mL = IQR(FinalConcentration_ng_mL, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(GroupingVariable = group_col) %>%
select(GroupingVariable, Group, n_rows_used_in_stats, n_unique_ids_used_in_stats, mean_ng_mL, sd_ng_mL, median_ng_mL, iqr_ng_mL)
group_summary_statistics[[group_col]] <- this_group_summary
# For ANOVA/Tukey/Kruskal/Wilcoxon, at least 2 groups and at least 3 total samples are needed.
# Tukey and ANOVA also require residual degrees of freedom, which fails when every group has n = 1.
group_counts <- stats_data %>% count(Group, name = "n")
residual_df <- total_n - number_of_groups
if (number_of_groups < 2 || total_n < 3) {
note <- "Fewer than 2 groups or too few total samples."
overall_test_results[[paste0(group_col, "_NotRun")]] <- not_run_row(group_col, "Not run", note)
next
}
if (residual_df < 1) {
note <- "No residual degrees of freedom because each group has too few observations, often n = 1 per group."
overall_test_results[[paste0(group_col, "_ANOVA_NotRun")]] <- not_run_row(group_col, "One-way ANOVA", note)
overall_test_results[[paste0(group_col, "_Kruskal_NotRun")]] <- not_run_row(group_col, "Kruskal-Wallis", note)
next
}
# One-way ANOVA across all groups.
anova_model <- tryCatch(
aov(FinalConcentration_ng_mL ~ Group, data = stats_data),
error = function(e) {
message("ANOVA skipped for ", group_col, ": ", e$message)
NULL
}
)
if (!is.null(anova_model)) {
anova_table <- summary(anova_model)[[1]]
anova_p <- anova_table[["Pr(>F)"]][1]
anova_f <- anova_table[["F value"]][1]
anova_df <- anova_table[["Df"]][1]
residuals_anova <- residuals(anova_model)
shapiro_p <- tryCatch(
if (length(residuals_anova) >= 3 && length(residuals_anova) <= 5000 && sd(residuals_anova, na.rm = TRUE) > 0) {
shapiro.test(residuals_anova)$p.value
} else {
NA_real_
},
error = function(e) NA_real_
)
bartlett_p <- tryCatch(
bartlett.test(FinalConcentration_ng_mL ~ Group, data = stats_data)$p.value,
error = function(e) NA_real_
)
assumption_notes <- paste0(
"ANOVA residual Shapiro-Wilk p = ", format_p(shapiro_p),
"; Bartlett equal-variance p = ", format_p(bartlett_p), "."
)
recommended_interpretation <- if (
(!is.na(shapiro_p) && shapiro_p < 0.05) ||
(!is.na(bartlett_p) && bartlett_p < 0.05)
) {
"Assumptions may be weak; emphasize Kruskal-Wallis and pairwise Wilcoxon results."
} else {
"ANOVA assumptions are not strongly contradicted; ANOVA/Tukey are reasonable, but still review sample size and plots."
}
overall_test_results[[paste0(group_col, "_ANOVA")]] <- tibble(
GroupingVariable = group_col,
Test = "One-way ANOVA",
Statistic = anova_f,
df = anova_df,
p_value = anova_p,
AssumptionNotes = assumption_notes,
RecommendedInterpretation = recommended_interpretation
)
# Tukey post-hoc pairwise comparisons for ANOVA.
# This can fail for some metadata groupings, so it is wrapped in tryCatch.
tukey_result <- tryCatch(
{
tr <- TukeyHSD(anova_model)
names(tr) <- "Group"
tr
},
error = function(e) {
message("Tukey post-hoc skipped for ", group_col, ": ", e$message)
NULL
}
)
tukey_posthoc_results[[group_col]] <- tidy_tukey(tukey_result, group_col)
} else {
overall_test_results[[paste0(group_col, "_ANOVA_NotRun")]] <- not_run_row(
group_col,
"One-way ANOVA",
"ANOVA model could not be fit for this grouping variable."
)
}
# Kruskal-Wallis non-parametric overall test across all groups.
kruskal_result <- tryCatch(
kruskal.test(FinalConcentration_ng_mL ~ Group, data = stats_data),
error = function(e) {
message("Kruskal-Wallis skipped for ", group_col, ": ", e$message)
NULL
}
)
if (!is.null(kruskal_result)) {
overall_test_results[[paste0(group_col, "_Kruskal")]] <- tibble(
GroupingVariable = group_col,
Test = "Kruskal-Wallis",
Statistic = unname(kruskal_result$statistic),
df = unname(kruskal_result$parameter),
p_value = kruskal_result$p.value,
AssumptionNotes = "Non-parametric comparison of group distributions; useful when ANOVA assumptions are questionable.",
RecommendedInterpretation = "Use with the pairwise Wilcoxon table to identify which groups differ."
)
} else {
overall_test_results[[paste0(group_col, "_Kruskal_NotRun")]] <- not_run_row(
group_col,
"Kruskal-Wallis",
"Kruskal-Wallis test could not be run for this grouping variable."
)
}
# Pairwise Wilcoxon post-hoc comparisons with multiple-test correction.
wilcox_result <- tryCatch(
pairwise.wilcox.test(
x = stats_data$FinalConcentration_ng_mL,
g = stats_data$Group,
p.adjust.method = "BH",
exact = FALSE
),
error = function(e) {
message("Pairwise Wilcoxon skipped for ", group_col, ": ", e$message)
NULL
}
)
wilcoxon_posthoc_results[[group_col]] <- tidy_pairwise_wilcox(wilcox_result, group_col)
}
group_statistics <- bind_rows(group_summary_statistics)
overall_statistics <- bind_rows(overall_test_results)
tukey_posthoc_statistics <- bind_rows(tukey_posthoc_results)
wilcoxon_posthoc_statistics <- bind_rows(wilcoxon_posthoc_results)
# Ensure empty post-hoc objects still have the expected columns so downstream filtering/writing does not fail.
if (nrow(tukey_posthoc_statistics) == 0) {
tukey_posthoc_statistics <- tibble(
GroupingVariable = character(), comparison = character(), group1 = character(), group2 = character(),
mean_difference = numeric(), conf_low = numeric(), conf_high = numeric(), p_adjusted = numeric()
)
}
if (nrow(wilcoxon_posthoc_statistics) == 0) {
wilcoxon_posthoc_statistics <- tibble(
GroupingVariable = character(), group1 = character(), group2 = character(),
p_adjusted = numeric(), p_adjust_method = character()
)
}
cat("\nGroup summary statistics:\n")
print(group_statistics)
cat("\nOverall group comparison tests. These p-values test whether there is evidence of any difference among groups:\n")
print(overall_statistics)
cat("\nTukey post-hoc pairwise comparisons. These adjusted p-values identify which specific groups differ after ANOVA:\n")
print(tukey_posthoc_statistics)
cat("\nPairwise Wilcoxon post-hoc comparisons. These adjusted p-values identify which specific groups differ using a non-parametric approach:\n")
print(wilcoxon_posthoc_statistics)
significant_tukey_posthoc <- tukey_posthoc_statistics %>%
filter(!is.na(p_adjusted), p_adjusted < posthoc_significance_alpha) %>%
arrange(GroupingVariable, p_adjusted)
significant_wilcoxon_posthoc <- wilcoxon_posthoc_statistics %>%
filter(!is.na(p_adjusted), p_adjusted < posthoc_significance_alpha) %>%
arrange(GroupingVariable, p_adjusted)
cat("\nQuick reference: significant Tukey post-hoc comparisons only ",
"(adjusted p < ", posthoc_significance_alpha, "):\n", sep = "")
if (nrow(significant_tukey_posthoc) == 0) {
cat("No significant Tukey post-hoc comparisons found at this threshold.\n")
} else {
print(significant_tukey_posthoc)
}
cat("\nQuick reference: significant pairwise Wilcoxon post-hoc comparisons only ",
"(BH-adjusted p < ", posthoc_significance_alpha, "):\n", sep = "")
if (nrow(significant_wilcoxon_posthoc) == 0) {
cat("No significant pairwise Wilcoxon post-hoc comparisons found at this threshold.\n")
} else {
print(significant_wilcoxon_posthoc)
}
write_csv(group_statistics, statistical_summary_output)
write_csv(overall_statistics, anova_kruskal_summary_output)
write_csv(tukey_posthoc_statistics, posthoc_tukey_output)
write_csv(wilcoxon_posthoc_statistics, posthoc_wilcoxon_output)
write_csv(significant_tukey_posthoc, significant_tukey_output)
write_csv(significant_wilcoxon_posthoc, significant_wilcoxon_output)
cat("\nGroup summary statistics written to: ", statistical_summary_output, "\n", sep = "")
cat("Overall ANOVA/Kruskal-Wallis results written to: ", anova_kruskal_summary_output, "\n", sep = "")
cat("Tukey post-hoc results written to: ", posthoc_tukey_output, "\n", sep = "")
cat("Pairwise Wilcoxon post-hoc results written to: ", posthoc_wilcoxon_output, "\n", sep = "")
cat("Significant Tukey post-hoc quick reference written to: ", significant_tukey_output, "\n", sep = "")
cat("Significant Wilcoxon post-hoc quick reference written to: ", significant_wilcoxon_output, "\n", sep = "")
} else {
cat("Optional group statistics were skipped.\n")
}
Group summary statistics:
# A tibble: 38 × 8
GroupingVariable Group n_rows_used_in_stats n_unique_ids_used_in…¹ mean_ng_mL
<chr> <fct> <int> <int> <dbl>
1 Ploidy Dipl… 19 19 0.0131
2 Ploidy Trip… 20 20 0.0163
3 Dilution Factor 0 9 9 0.0248
4 Dilution Factor 25 1 1 0.00504
5 Dilution Factor 26.5 1 1 0.00239
6 Dilution Factor 28 1 1 0.00633
7 Dilution Factor 29 1 1 0.0129
8 Dilution Factor 30 1 1 0.00143
9 Dilution Factor 34 1 1 0.0123
10 Dilution Factor 35 1 1 0.0118
# ℹ 28 more rows
# ℹ abbreviated name: ¹n_unique_ids_used_in_stats
# ℹ 3 more variables: sd_ng_mL <dbl>, median_ng_mL <dbl>, iqr_ng_mL <dbl>
Overall group comparison tests. These p-values test whether there is evidence of any difference among groups:
# A tibble: 10 × 7
GroupingVariable Test Statistic df p_value AssumptionNotes
<chr> <chr> <dbl> <dbl> <dbl> <chr>
1 Ploidy One-… 0.400 1 5.31e-1 ANOVA residual…
2 Ploidy Krus… 0.0639 1 8.00e-1 Non-parametric…
3 Dilution Factor One-… 0.616 10 7.87e-1 ANOVA residual…
4 Dilution Factor Krus… 7.23 10 7.04e-1 Non-parametric…
5 SampleTissue_MassorVolume_ForC… One-… 14.0 17 7.41e-8 ANOVA residual…
6 SampleTissue_MassorVolume_ForC… Krus… 25.7 17 7.92e-2 Non-parametric…
7 SampleTime One-… 0.881 3 4.60e-1 ANOVA residual…
8 SampleTime Krus… 0.816 3 8.46e-1 Non-parametric…
9 Treatment One-… 0.445 2 6.44e-1 ANOVA residual…
10 Treatment Krus… 0.876 2 6.45e-1 Non-parametric…
# ℹ 1 more variable: RecommendedInterpretation <chr>
Tukey post-hoc pairwise comparisons. These adjusted p-values identify which specific groups differ after ANOVA:
# A tibble: 218 × 8
GroupingVariable comparison group1 group2 mean_difference conf_low conf_high
<chr> <chr> <chr> <chr> <dbl> <dbl> <dbl>
1 Ploidy Triploid_H… Tripl… Diplo… 0.00310 -0.00684 0.0130
2 Dilution Factor 25-0 25 0 -0.0198 -0.0788 0.0393
3 Dilution Factor 26.5-0 26.5 0 -0.0224 -0.0815 0.0367
4 Dilution Factor 28-0 28 0 -0.0185 -0.0776 0.0406
5 Dilution Factor 29-0 29 0 -0.0119 -0.0710 0.0472
6 Dilution Factor 30-0 30 0 -0.0234 -0.0825 0.0357
7 Dilution Factor 34-0 34 0 -0.0125 -0.0716 0.0466
8 Dilution Factor 35-0 35 0 -0.0130 -0.0721 0.0460
9 Dilution Factor 45-0 45 0 -0.00850 -0.0523 0.0353
10 Dilution Factor 45.5-0 45.5 0 -0.00404 -0.0631 0.0550
# ℹ 208 more rows
# ℹ 1 more variable: p_adjusted <dbl>
Pairwise Wilcoxon post-hoc comparisons. These adjusted p-values identify which specific groups differ using a non-parametric approach:
# A tibble: 218 × 5
GroupingVariable group1 group2 p_adjusted p_adjust_method
<chr> <chr> <chr> <dbl> <chr>
1 Ploidy Triploid_Homogenate Diploid_Homo… 0.811 BH
2 Dilution Factor 25 0 1 BH
3 Dilution Factor 26.5 0 1 BH
4 Dilution Factor 28 0 1 BH
5 Dilution Factor 29 0 1 BH
6 Dilution Factor 30 0 1 BH
7 Dilution Factor 34 0 1 BH
8 Dilution Factor 35 0 1 BH
9 Dilution Factor 45 0 1 BH
10 Dilution Factor 45.5 0 1 BH
# ℹ 208 more rows
Quick reference: significant Tukey post-hoc comparisons only (adjusted p < 0.05):
# A tibble: 44 × 8
GroupingVariable comparison group1 group2 mean_difference conf_low conf_high
<chr> <chr> <chr> <chr> <dbl> <dbl> <dbl>
1 SampleTissue_Mas… 100-25 100 25 -0.0594 -0.0829 -0.0359
2 SampleTissue_Mas… 25-10 25 10 0.0705 0.0424 0.0985
3 SampleTissue_Mas… 40-25 40 25 -0.0717 -0.104 -0.0392
4 SampleTissue_Mas… 43-10 43 10 0.0612 0.0331 0.0893
5 SampleTissue_Mas… 60-25 60 25 -0.0702 -0.103 -0.0378
6 SampleTissue_Mas… 53-25 53 25 -0.0693 -0.102 -0.0368
7 SampleTissue_Mas… 25-11 25 11 0.0693 0.0368 0.102
8 SampleTissue_Mas… 100-43 100 43 -0.0501 -0.0736 -0.0266
9 SampleTissue_Mas… 50-25 50 25 -0.0666 -0.0991 -0.0342
10 SampleTissue_Mas… 56-25 56 25 -0.0653 -0.0978 -0.0329
# ℹ 34 more rows
# ℹ 1 more variable: p_adjusted <dbl>
Quick reference: significant pairwise Wilcoxon post-hoc comparisons only (BH-adjusted p < 0.05):
No significant pairwise Wilcoxon post-hoc comparisons found at this threshold.
Group summary statistics written to: cortisol_group_statistics.csv
Overall ANOVA/Kruskal-Wallis results written to: cortisol_overall_group_tests.csv
Tukey post-hoc results written to: cortisol_tukey_posthoc_tests.csv
Pairwise Wilcoxon post-hoc results written to: cortisol_wilcoxon_posthoc_tests.csv
Significant Tukey post-hoc quick reference written to: cortisol_significant_tukey_posthoc_tests.csv
Significant Wilcoxon post-hoc quick reference written to: cortisol_significant_wilcoxon_posthoc_tests.csv
# Secondary exploratory statistics with extreme outliers excluded.
# This reuses the same grouping variables as the primary statistics section, but
# uses plot_data_no_extreme_outliers. The primary statistics are not modified.
run_group_statistics_simple <- function(data, grouping_columns, label_suffix = "") {
group_summary_statistics <- list()
overall_test_results <- list()
tukey_posthoc_results <- list()
wilcoxon_posthoc_results <- list()
for (group_col in grouping_columns) {
if (!(group_col %in% names(data))) next
stats_data <- data %>%
filter(!is.na(.data[[group_col]]), !is.na(FinalConcentration_ng_mL)) %>%
mutate(Group = as.factor(as.character(.data[[group_col]]))) %>%
droplevels()
if (nrow(stats_data) == 0 || n_distinct(stats_data$Group) < 2) next
group_summary_statistics[[group_col]] <- stats_data %>%
group_by(Group) %>%
summarize(
GroupingVariable = group_col,
n_rows_used_in_stats = n(),
n_unique_ids_used_in_stats = if (statistics_unique_id_column %in% names(.)) {
n_distinct(.data[[statistics_unique_id_column]], na.rm = TRUE)
} else {
n_distinct(SampleID, na.rm = TRUE)
},
mean_FinalConcentration_ng_mL = mean(FinalConcentration_ng_mL, na.rm = TRUE),
sd_FinalConcentration_ng_mL = sd(FinalConcentration_ng_mL, na.rm = TRUE),
median_FinalConcentration_ng_mL = median(FinalConcentration_ng_mL, na.rm = TRUE),
IQR_FinalConcentration_ng_mL = IQR(FinalConcentration_ng_mL, na.rm = TRUE),
.groups = "drop"
) %>%
relocate(GroupingVariable, Group) %>%
mutate(AnalysisDataset = label_suffix)
if (nrow(stats_data) >= 3 && n_distinct(stats_data$Group) >= 2) {
anova_result <- tryCatch({
anova_model <- aov(FinalConcentration_ng_mL ~ Group, data = stats_data)
anova_summary <- summary(anova_model)[[1]]
tibble(
GroupingVariable = group_col,
Test = "ANOVA",
Statistic = as.numeric(anova_summary$`F value`[1]),
df = as.numeric(anova_summary$Df[1]),
p_value = as.numeric(anova_summary$`Pr(>F)`[1]),
AnalysisDataset = label_suffix
)
}, error = function(e) {
tibble(GroupingVariable = group_col, Test = "ANOVA", Statistic = NA_real_, df = NA_real_, p_value = NA_real_, AnalysisDataset = label_suffix)
})
kruskal_result <- tryCatch({
kt <- kruskal.test(FinalConcentration_ng_mL ~ Group, data = stats_data)
tibble(
GroupingVariable = group_col,
Test = "Kruskal-Wallis",
Statistic = as.numeric(kt$statistic),
df = as.numeric(kt$parameter),
p_value = as.numeric(kt$p.value),
AnalysisDataset = label_suffix
)
}, error = function(e) {
tibble(GroupingVariable = group_col, Test = "Kruskal-Wallis", Statistic = NA_real_, df = NA_real_, p_value = NA_real_, AnalysisDataset = label_suffix)
})
overall_test_results[[group_col]] <- bind_rows(anova_result, kruskal_result)
tukey_posthoc_results[[group_col]] <- tryCatch({
anova_model <- aov(FinalConcentration_ng_mL ~ Group, data = stats_data)
tidy_tukey(TukeyHSD(anova_model), group_col) %>% mutate(AnalysisDataset = label_suffix)
}, error = function(e) tibble())
wilcoxon_posthoc_results[[group_col]] <- tryCatch({
tidy_pairwise_wilcox(
pairwise.wilcox.test(stats_data$FinalConcentration_ng_mL, stats_data$Group, p.adjust.method = "BH", exact = FALSE),
group_col
) %>% mutate(AnalysisDataset = label_suffix)
}, error = function(e) tibble())
}
}
list(
summary = bind_rows(group_summary_statistics),
overall = bind_rows(overall_test_results),
tukey = bind_rows(tukey_posthoc_results),
wilcoxon = bind_rows(wilcoxon_posthoc_results)
)
}
if (isTRUE(produce_secondary_outputs_without_extreme_outliers) &&
exists("plot_data_no_extreme_outliers") &&
exists("extreme_outlier_screening") &&
nrow(extreme_outlier_screening) > 0 &&
isTRUE(run_group_statistics) &&
length(grouping_columns) > 0) {
secondary_stats <- run_group_statistics_simple(
data = plot_data_no_extreme_outliers,
grouping_columns = grouping_columns,
label_suffix = "Extreme outliers excluded"
)
secondary_significant_tukey <- secondary_stats$tukey %>%
filter(!is.na(p_adjusted), p_adjusted < posthoc_significance_alpha) %>%
arrange(GroupingVariable, p_adjusted)
secondary_significant_wilcoxon <- secondary_stats$wilcoxon %>%
filter(!is.na(p_adjusted), p_adjusted < posthoc_significance_alpha) %>%
arrange(GroupingVariable, p_adjusted)
cat("\nSecondary exploratory statistics with extreme outliers excluded:\n")
cat("\nGroup summary statistics:\n")
print(secondary_stats$summary)
cat("\nOverall tests:\n")
print(secondary_stats$overall)
cat("\nSignificant Tukey post-hoc comparisons only:\n")
if (nrow(secondary_significant_tukey) == 0) cat("No significant Tukey comparisons found.\n") else print(secondary_significant_tukey)
cat("\nSignificant Wilcoxon post-hoc comparisons only:\n")
if (nrow(secondary_significant_wilcoxon) == 0) cat("No significant Wilcoxon comparisons found.\n") else print(secondary_significant_wilcoxon)
write_csv(secondary_stats$summary, statistical_summary_extreme_outliers_removed_output)
write_csv(secondary_stats$overall, anova_kruskal_summary_extreme_outliers_removed_output)
write_csv(secondary_stats$tukey, posthoc_tukey_extreme_outliers_removed_output)
write_csv(secondary_stats$wilcoxon, posthoc_wilcoxon_extreme_outliers_removed_output)
write_csv(secondary_significant_tukey, significant_tukey_extreme_outliers_removed_output)
write_csv(secondary_significant_wilcoxon, significant_wilcoxon_extreme_outliers_removed_output)
} else {
cat("Secondary outlier-excluded statistics were skipped because no extreme outliers were flagged, statistics are off, or the option is turned off.\n")
}
Secondary exploratory statistics with extreme outliers excluded:
Group summary statistics:
# A tibble: 35 × 9
GroupingVariable Group n_rows_used_in_stats n_unique_ids_used_in…¹
<chr> <fct> <int> <int>
1 Ploidy Diploid_Homogen… 18 18
2 Ploidy Triploid_Homoge… 18 18
3 Dilution Factor 0 6 6
4 Dilution Factor 25 1 1
5 Dilution Factor 26.5 1 1
6 Dilution Factor 28 1 1
7 Dilution Factor 29 1 1
8 Dilution Factor 30 1 1
9 Dilution Factor 34 1 1
10 Dilution Factor 35 1 1
# ℹ 25 more rows
# ℹ abbreviated name: ¹n_unique_ids_used_in_stats
# ℹ 5 more variables: mean_FinalConcentration_ng_mL <dbl>,
# sd_FinalConcentration_ng_mL <dbl>, median_FinalConcentration_ng_mL <dbl>,
# IQR_FinalConcentration_ng_mL <dbl>, AnalysisDataset <chr>
Overall tests:
# A tibble: 10 × 6
GroupingVariable Test Statistic df p_value AnalysisDataset
<chr> <chr> <dbl> <dbl> <dbl> <chr>
1 Ploidy ANOVA 0.168 1 0.685 Extreme outlie…
2 Ploidy Krus… 0.00100 1 0.975 Extreme outlie…
3 Dilution Factor ANOVA 0.649 10 0.759 Extreme outlie…
4 Dilution Factor Krus… 12.6 10 0.247 Extreme outlie…
5 SampleTissue_MassorVolume_ForC… ANOVA 3.17 14 0.00839 Extreme outlie…
6 SampleTissue_MassorVolume_ForC… Krus… 20.7 14 0.111 Extreme outlie…
7 SampleTime ANOVA 0.0779 3 0.972 Extreme outlie…
8 SampleTime Krus… 0.146 3 0.986 Extreme outlie…
9 Treatment ANOVA 0.382 2 0.686 Extreme outlie…
10 Treatment Krus… 2.60 2 0.273 Extreme outlie…
Significant Tukey post-hoc comparisons only:
# A tibble: 7 × 9
GroupingVariable comparison group1 group2 mean_difference conf_low conf_high
<chr> <chr> <chr> <chr> <dbl> <dbl> <dbl>
1 SampleTissue_Mass… 14-10 14 10 0.0361 0.00893 0.0633
2 SampleTissue_Mass… 40-14 40 14 -0.0373 -0.0687 -0.00594
3 SampleTissue_Mass… 60-14 60 14 -0.0359 -0.0673 -0.00450
4 SampleTissue_Mass… 53-14 53 14 -0.0349 -0.0663 -0.00355
5 SampleTissue_Mass… 14-11 14 11 0.0349 0.00353 0.0663
6 SampleTissue_Mass… 14-100 14 100 0.0250 0.00228 0.0478
7 SampleTissue_Mass… 50-14 50 14 -0.0323 -0.0637 -0.000903
# ℹ 2 more variables: p_adjusted <dbl>, AnalysisDataset <chr>
Significant Wilcoxon post-hoc comparisons only:
No significant Wilcoxon comparisons found.
This section summarizes within-group variation in final cortisol concentration. It reports robust dispersion metrics (IQR and MAD), plus SD, variance, and coefficient of variation. It also runs a Fligner-Killeen test for each grouping variable to test whether dispersion differs among groups. Violin plots with boxplot overlays are generated to emphasize distribution shape and within-group spread; each group is labeled with its IQR. The same analysis is run for the full dataset and, when available, the exploratory dataset with extreme outliers removed.
# -----------------------------------------------------------------------------
# Within-group dispersion analysis
# -----------------------------------------------------------------------------
# This section is intended to complement the group-comparison statistics above.
# Kruskal-Wallis / ANOVA ask whether group central tendencies differ, whereas
# this section asks how variable the samples are within each group.
run_dispersion_analysis <- function(data, grouping_columns, dataset_label,
summary_output_file, fligner_output_file,
plot_output_file) {
if (!is.data.frame(data) || nrow(data) == 0) {
cat("Dispersion analysis skipped for ", dataset_label, ": no data rows available.\n", sep = "")
write_csv(tibble(), summary_output_file)
write_csv(tibble(), fligner_output_file)
return(invisible(NULL))
}
if (length(grouping_columns) == 0) {
cat("Dispersion analysis skipped for ", dataset_label, ": no grouping columns available.\n", sep = "")
write_csv(tibble(), summary_output_file)
write_csv(tibble(), fligner_output_file)
return(invisible(NULL))
}
dispersion_data <- data %>%
filter(!is.na(FinalConcentration_ng_mL))
dispersion_summary <- map_dfr(grouping_columns, function(group_col) {
if (!(group_col %in% names(dispersion_data))) return(tibble())
dispersion_data %>%
mutate(Group = as.character(.data[[group_col]])) %>%
filter(!is.na(Group), Group != "") %>%
group_by(Group) %>%
summarize(
AnalysisDataset = dataset_label,
GroupingVariable = group_col,
n = n(),
median_ng_mL = median(FinalConcentration_ng_mL, na.rm = TRUE),
mean_ng_mL = mean(FinalConcentration_ng_mL, na.rm = TRUE),
sd_ng_mL = sd(FinalConcentration_ng_mL, na.rm = TRUE),
variance_ng_mL = var(FinalConcentration_ng_mL, na.rm = TRUE),
IQR_ng_mL = IQR(FinalConcentration_ng_mL, na.rm = TRUE),
MAD_ng_mL = mad(FinalConcentration_ng_mL, na.rm = TRUE),
min_ng_mL = min(FinalConcentration_ng_mL, na.rm = TRUE),
max_ng_mL = max(FinalConcentration_ng_mL, na.rm = TRUE),
coefficient_of_variation_percent = ifelse(
is.na(mean_ng_mL) | mean_ng_mL == 0,
NA_real_,
100 * sd_ng_mL / mean_ng_mL
),
.groups = "drop"
) %>%
select(AnalysisDataset, GroupingVariable, Group, everything())
})
fligner_results <- map_dfr(grouping_columns, function(group_col) {
if (!(group_col %in% names(dispersion_data))) return(tibble())
test_data <- dispersion_data %>%
mutate(Group = as.factor(.data[[group_col]])) %>%
filter(!is.na(Group), !is.na(FinalConcentration_ng_mL)) %>%
group_by(Group) %>%
filter(n() >= 2) %>%
ungroup()
if (n_distinct(test_data$Group) < 2 || nrow(test_data) < 3) {
return(tibble(
AnalysisDataset = dataset_label,
GroupingVariable = group_col,
Test = "Fligner-Killeen test",
Statistic = NA_real_,
P_Value = NA_real_,
Note = "Skipped: fewer than two groups with at least two observations"
))
}
tryCatch({
fligner_result <- fligner.test(FinalConcentration_ng_mL ~ Group, data = test_data)
tibble(
AnalysisDataset = dataset_label,
GroupingVariable = group_col,
Test = "Fligner-Killeen test",
Statistic = unname(fligner_result$statistic),
P_Value = fligner_result$p.value,
Note = ifelse(fligner_result$p.value < 0.05,
"Significant evidence that dispersion differs among groups",
"No significant evidence that dispersion differs among groups")
)
}, error = function(e) {
tibble(
AnalysisDataset = dataset_label,
GroupingVariable = group_col,
Test = "Fligner-Killeen test",
Statistic = NA_real_,
P_Value = NA_real_,
Note = paste("Skipped/error:", e$message)
)
})
})
write_csv(dispersion_summary, summary_output_file)
write_csv(fligner_results, fligner_output_file)
cat("\nDispersion summary written to: ", summary_output_file, "\n", sep = "")
cat("Fligner-Killeen dispersion test results written to: ", fligner_output_file, "\n", sep = "")
cat("\nDispersion summary for ", dataset_label, ":\n", sep = "")
print(dispersion_summary)
cat("\nFligner-Killeen tests for ", dataset_label, ":\n", sep = "")
print(fligner_results)
# Visualize within-group dispersion for each grouping variable.
pdf(plot_output_file, width = 9, height = 6)
for (group_col in grouping_columns) {
if (!(group_col %in% names(dispersion_data))) next
plot_df <- dispersion_data %>%
mutate(Group = as.factor(.data[[group_col]])) %>%
filter(!is.na(Group), !is.na(FinalConcentration_ng_mL))
if (n_distinct(plot_df$Group) < 2) next
# Pull IQR values from the summary table for this grouping variable so they
# can be displayed directly on the dispersion plots.
label_df <- dispersion_summary %>%
filter(
AnalysisDataset == dataset_label,
GroupingVariable == group_col,
Group %in% as.character(plot_df$Group)
) %>%
mutate(
Group = factor(Group, levels = levels(plot_df$Group)),
IQR_Label = paste0("IQR = ", signif(IQR_ng_mL, 3))
)
# Put labels slightly above the observed data for each group. This is done
# group-by-group so labels remain readable even if some groups have much
# larger concentrations than others.
label_positions <- plot_df %>%
group_by(Group) %>%
summarize(
LabelY = max(FinalConcentration_ng_mL, na.rm = TRUE),
.groups = "drop"
) %>%
mutate(
LabelY = ifelse(is.finite(LabelY) & LabelY > 0, LabelY * 1.20, NA_real_)
)
label_df <- label_df %>%
left_join(label_positions, by = "Group") %>%
filter(!is.na(LabelY))
p <- ggplot(plot_df, aes(x = Group, y = FinalConcentration_ng_mL, fill = Group)) +
geom_violin(trim = FALSE, alpha = 0.35, color = NA) +
geom_boxplot(width = 0.18, outlier.shape = NA, alpha = 0.75, color = "black") +
geom_jitter(aes(color = Group), width = 0.12, alpha = 0.45, size = 1.6) +
geom_text(
data = label_df,
aes(x = Group, y = LabelY, label = IQR_Label),
inherit.aes = FALSE,
size = 3,
angle = 90,
hjust = 0,
vjust = 0.5
) +
labs(
title = paste("Within-group dispersion of cortisol concentrations:", group_col),
subtitle = paste(dataset_label, "— violin width shows distribution shape; labels show within-group IQR"),
x = group_col,
y = "Final cortisol concentration (ng/mL)"
) +
theme_bw() +
theme(
legend.position = "none",
axis.text.x = element_text(angle = 45, hjust = 1),
plot.margin = margin(t = 10, r = 35, b = 10, l = 10)
) +
coord_cartesian(clip = "off")
if (exists("use_log10_y_axis") && isTRUE(use_log10_y_axis)) {
p <- p + scale_y_log10() + labs(y = "Final cortisol concentration (ng/mL), log10 scale")
}
print(p)
}
dev.off()
cat("Dispersion plots written to: ", plot_output_file, "\n", sep = "")
invisible(list(summary = dispersion_summary, fligner = fligner_results))
}
# Full dataset dispersion analysis.
if (exists("plot_data")) {
dispersion_full_results <- run_dispersion_analysis(
data = plot_data,
grouping_columns = grouping_columns,
dataset_label = "Full dataset",
summary_output_file = "cortisol_dispersion_summary_full_dataset.csv",
fligner_output_file = "cortisol_dispersion_fligner_tests_full_dataset.csv",
plot_output_file = "cortisol_dispersion_plots_full_dataset.pdf"
)
} else {
cat("Full dataset dispersion analysis skipped because plot_data was not found.\n")
}
Dispersion summary written to: cortisol_dispersion_summary_full_dataset.csv
Fligner-Killeen dispersion test results written to: cortisol_dispersion_fligner_tests_full_dataset.csv
Dispersion summary for Full dataset:
# A tibble: 38 × 13
AnalysisDataset GroupingVariable Group n median_ng_mL mean_ng_mL sd_ng_mL
<chr> <chr> <chr> <int> <dbl> <dbl> <dbl>
1 Full dataset Ploidy Dipl… 19 0.0120 0.0131 0.0113
2 Full dataset Ploidy Trip… 20 0.0124 0.0163 0.0183
3 Full dataset Dilution Factor 0 9 0.00788 0.0248 0.0285
4 Full dataset Dilution Factor 25 1 0.00504 0.00504 NA
5 Full dataset Dilution Factor 26.5 1 0.00239 0.00239 NA
6 Full dataset Dilution Factor 28 1 0.00633 0.00633 NA
7 Full dataset Dilution Factor 29 1 0.0129 0.0129 NA
8 Full dataset Dilution Factor 30 1 0.00143 0.00143 NA
9 Full dataset Dilution Factor 34 1 0.0123 0.0123 NA
10 Full dataset Dilution Factor 35 1 0.0118 0.0118 NA
# ℹ 28 more rows
# ℹ 6 more variables: variance_ng_mL <dbl>, IQR_ng_mL <dbl>, MAD_ng_mL <dbl>,
# min_ng_mL <dbl>, max_ng_mL <dbl>, coefficient_of_variation_percent <dbl>
Fligner-Killeen tests for Full dataset:
# A tibble: 5 × 6
AnalysisDataset GroupingVariable Test Statistic P_Value Note
<chr> <chr> <chr> <dbl> <dbl> <chr>
1 Full dataset Ploidy Flig… 0.573 0.449 No s…
2 Full dataset Dilution Factor Flig… 7.63 0.0220 Sign…
3 Full dataset SampleTissue_MassorVolume_ForCo… Flig… 2.78 0.249 No s…
4 Full dataset SampleTime Flig… 3.70 0.295 No s…
5 Full dataset Treatment Flig… 2.63 0.269 No s…
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Full dataset — violin width shows distribution shape; labels show
within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning: Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Full dataset — violin width shows distribution shape; labels show
within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning: Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Full dataset — violin width shows distribution shape; labels show
within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Full dataset — violin width shows distribution shape; labels show
within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Full dataset — violin width shows distribution shape; labels show
within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Dispersion plots written to: cortisol_dispersion_plots_full_dataset.pdf
# Outlier-removed dispersion analysis.
if (exists("plot_data_no_extreme_outliers")) {
dispersion_no_outlier_results <- run_dispersion_analysis(
data = plot_data_no_extreme_outliers,
grouping_columns = grouping_columns,
dataset_label = "Extreme outliers excluded",
summary_output_file = "cortisol_dispersion_summary_extreme_outliers_removed.csv",
fligner_output_file = "cortisol_dispersion_fligner_tests_extreme_outliers_removed.csv",
plot_output_file = "cortisol_dispersion_plots_extreme_outliers_removed.pdf"
)
} else {
cat("Outlier-removed dispersion analysis skipped because plot_data_no_extreme_outliers was not found.\n")
}
Dispersion summary written to: cortisol_dispersion_summary_extreme_outliers_removed.csv
Fligner-Killeen dispersion test results written to: cortisol_dispersion_fligner_tests_extreme_outliers_removed.csv
Dispersion summary for Extreme outliers excluded:
# A tibble: 35 × 13
AnalysisDataset GroupingVariable Group n median_ng_mL mean_ng_mL sd_ng_mL
<chr> <chr> <chr> <int> <dbl> <dbl> <dbl>
1 Extreme outlie… Ploidy Dipl… 18 0.0119 0.0117 0.00970
2 Extreme outlie… Ploidy Trip… 18 0.0107 0.0106 0.00595
3 Extreme outlie… Dilution Factor 0 6 0.00191 0.00834 0.0145
4 Extreme outlie… Dilution Factor 25 1 0.00504 0.00504 NA
5 Extreme outlie… Dilution Factor 26.5 1 0.00239 0.00239 NA
6 Extreme outlie… Dilution Factor 28 1 0.00633 0.00633 NA
7 Extreme outlie… Dilution Factor 29 1 0.0129 0.0129 NA
8 Extreme outlie… Dilution Factor 30 1 0.00143 0.00143 NA
9 Extreme outlie… Dilution Factor 34 1 0.0123 0.0123 NA
10 Extreme outlie… Dilution Factor 35 1 0.0118 0.0118 NA
# ℹ 25 more rows
# ℹ 6 more variables: variance_ng_mL <dbl>, IQR_ng_mL <dbl>, MAD_ng_mL <dbl>,
# min_ng_mL <dbl>, max_ng_mL <dbl>, coefficient_of_variation_percent <dbl>
Fligner-Killeen tests for Extreme outliers excluded:
# A tibble: 5 × 6
AnalysisDataset GroupingVariable Test Statistic P_Value Note
<chr> <chr> <chr> <dbl> <dbl> <chr>
1 Extreme outliers excluded Ploidy Flig… 3.16 0.0754 No s…
2 Extreme outliers excluded Dilution Factor Flig… 0.148 0.929 No s…
3 Extreme outliers excluded SampleTissue_MassorVo… Flig… 2.78 0.249 No s…
4 Extreme outliers excluded SampleTime Flig… 6.75 0.0803 No s…
5 Extreme outliers excluded Treatment Flig… 1.17 0.558 No s…
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Extreme outliers excluded — violin width shows distribution shape; labels
show within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning: Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Extreme outliers excluded — violin width shows distribution shape; labels
show within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning: Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Groups with fewer than two datapoints have been dropped.
ℹ Set `drop = FALSE` to consider such groups for position adjustment purposes.
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Extreme outliers excluded — violin width shows distribution shape; labels
show within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Extreme outliers excluded — violin width shows distribution shape; labels
show within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Warning in grid.Call.graphics(C_text, as.graphicsAnnot(x$label), x$x, x$y, :
for 'Extreme outliers excluded — violin width shows distribution shape; labels
show within-group IQR' in 'mbcsToSbcs': - substituted for — (U+2014)
Dispersion plots written to: cortisol_dispersion_plots_extreme_outliers_removed.pdf
This optional plot is also metadata-driven. It uses
focused_plot_x_variable for the x-axis and
focused_plot_color_variable for color. For your example
metadata, this creates a SampleDay-by-Treatment plot. For a different
metadata file, change those two variables in the user-input section or
set either one to NULL to skip this plot.
if (!is.null(focused_plot_x_variable) && !is.null(focused_plot_color_variable)) {
if (all(c(focused_plot_x_variable, focused_plot_color_variable) %in% colnames(plot_data))) {
focused_two_variable_plot <- ggplot(
plot_data,
aes(
x = as.factor(.data[[focused_plot_x_variable]]),
y = FinalConcentration_ng_mL,
color = as.factor(.data[[focused_plot_color_variable]])
)
) +
geom_boxplot(
aes(group = interaction(.data[[focused_plot_x_variable]], .data[[focused_plot_color_variable]])),
outlier.shape = NA,
alpha = 0.35,
position = position_dodge(width = 0.8)
) +
geom_point(
position = position_jitterdodge(jitter.width = 0.15, jitter.height = 0, dodge.width = 0.8),
alpha = 0.8,
size = 2
) +
labs(
title = paste("Final cortisol concentration by", focused_plot_x_variable, "and", focused_plot_color_variable),
x = focused_plot_x_variable,
y = "Final cortisol concentration (ng/mL)",
color = focused_plot_color_variable
) +
theme_bw()
if (use_log10_y_axis) {
focused_two_variable_plot <- focused_two_variable_plot +
scale_y_log10() +
labs(y = "Final cortisol concentration (ng/mL), log10 scale")
}
focused_two_variable_plot
} else {
message(
"Focused two-variable plot skipped because one or both requested columns were not found: ",
focused_plot_x_variable, ", ", focused_plot_color_variable
)
}
}
This section creates a simplified CSV for quick review. It includes
only the sample ID, calculated %B/B0, and the final
dilution-corrected cortisol concentration. Rows are sorted from highest
to lowest final cortisol concentration.
# This output intentionally uses the same working/output location as the rest of
# the script. If a future version defines an output folder, this chunk will use it;
# otherwise it writes to the current working directory just like the other CSVs.
clean_ranked_cortisol_summary_output <- if (exists("output_directory")) {
file.path(output_directory, "clean_ranked_cortisol_summary.csv")
} else {
"clean_ranked_cortisol_summary.csv"
}
# Use the main processed metadata table created earlier in the workflow.
# This table contains the final concentration columns after blank correction,
# plate-specific standard curve back-calculation, dilution correction, and QC flags.
clean_summary_source <- processed_metadata
# If IsBlank / IsStandard columns are missing for any reason, create safe defaults.
if (!("IsBlank" %in% colnames(clean_summary_source))) {
clean_summary_source$IsBlank <- grepl("^BLK$|blank", clean_summary_source$SampleID, ignore.case = TRUE)
}
if (!("IsStandard" %in% colnames(clean_summary_source))) {
clean_summary_source$IsStandard <- grepl("^S[0-7]$", clean_summary_source$SampleID, ignore.case = TRUE)
}
# Choose the final concentration column used by this version of the script.
final_conc_col <- dplyr::case_when(
"FinalConcentration_ng_mL" %in% colnames(clean_summary_source) ~ "FinalConcentration_ng_mL",
"Final_Concentration_ng_mL" %in% colnames(clean_summary_source) ~ "Final_Concentration_ng_mL",
TRUE ~ NA_character_
)
if (is.na(final_conc_col)) {
stop("Could not find a final concentration column for the clean ranked cortisol summary.")
}
clean_ranked_cortisol_summary <- clean_summary_source %>%
dplyr::mutate(
IsBlank_clean = as.logical(IsBlank),
IsStandard_clean = as.logical(IsStandard)
) %>%
dplyr::filter(
!is.na(SampleID),
is.na(IsBlank_clean) | IsBlank_clean == FALSE,
is.na(IsStandard_clean) | IsStandard_clean == FALSE
) %>%
dplyr::transmute(
SampleID = as.character(SampleID),
Percent_B_B0 = Percent_B_B0,
FinalConcentration_ng_mL = .data[[final_conc_col]]
) %>%
dplyr::arrange(dplyr::desc(FinalConcentration_ng_mL), SampleID)
readr::write_csv(clean_ranked_cortisol_summary, clean_ranked_cortisol_summary_output)
cat("Clean ranked cortisol summary saved to: ", clean_ranked_cortisol_summary_output, "
", sep = "")
Clean ranked cortisol summary saved to: clean_ranked_cortisol_summary.csv
knitr::kable(
clean_ranked_cortisol_summary,
digits = 4,
caption = "Clean ranked cortisol summary: samples ordered from highest to lowest final cortisol concentration."
)
| SampleID | Percent_B_B0 | FinalConcentration_ng_mL |
|---|---|---|
| T-48_0610_P4 | 81.3737 | 0.0717 |
| T-48_0610_P2 | 90.7954 | 0.0624 |
| D48-RECOVER24_0611_P1 | 92.1486 | 0.0391 |
| D-48_0610_P4 | 82.4461 | 0.0373 |
| D-CTL-19_0609_P2 | 122.0478 | 0.0208 |
| T48-RECOVER24_0611_P1 | 122.0094 | 0.0206 |
| D-CTL-43_0610_P3 | 123.8095 | 0.0204 |
| D-CTL-19_0609_P4 | 124.8564 | 0.0192 |
| T-CTL-48_0610_P1 | 125.0606 | 0.0189 |
| D-CTL-19_0609_P3 | 125.7883 | 0.0181 |
| T-CTL-19_0609_P1 | 126.5033 | 0.0173 |
| D-CTL-48_0610_P1 | 126.5926 | 0.0172 |
| T-CTL-48_0610_P3 | 128.1374 | 0.0156 |
| T-19_0609_P4 | 128.3927 | 0.0154 |
| D-CTL-48_0610_P3 | 128.9417 | 0.0149 |
| T-CTL-19_0609_P2 | 129.0310 | 0.0148 |
| T-CTL-48_0610_P2 | 129.3757 | 0.0145 |
| D48-RECOVER24_0611_P4 | 122.4180 | 0.0129 |
| T48-RECOVER24_0611_P3 | 131.8141 | 0.0125 |
| T-19_0609_P1 | 125.7883 | 0.0123 |
| D48-RECOVER24_0611_P2 | 130.6779 | 0.0120 |
| D-19_0609_P3 | 126.9884 | 0.0118 |
| D-CTL-48_0610_P4 | 136.2441 | 0.0095 |
| T-19_0609_P2 | 136.7931 | 0.0091 |
| T-CTL-48_0610_P4 | 137.1633 | 0.0089 |
| T48-RECOVER24_0611_P4 | 103.3321 | 0.0079 |
| D-CTL-48_0610_P2 | 140.0102 | 0.0075 |
| T-19_0609_P3 | 133.3844 | 0.0063 |
| T-48_0610_P3 | 135.2355 | 0.0050 |
| T-CTL-19_0609_P3 | 115.4092 | 0.0040 |
| T48-RECOVER24_0611_P2 | 115.5113 | 0.0038 |
| T-CTL-19_0609_P4 | 116.0858 | 0.0030 |
| D-19_0609_P2 | 122.7244 | 0.0024 |
| D-48_0610_P2 | 115.0645 | 0.0024 |
| D-CTL-19_0609_P1 | 117.2986 | 0.0016 |
| D-19_0609_P4 | 116.5582 | 0.0014 |
| D-48_0610_P1 | 129.7332 | 0.0014 |
| T-48_0610_P1 | 112.2814 | 0.0010 |
| D-43_0610_P3 | 108.1323 | 0.0000 |
This section evaluates whether oyster shell size differs between hatchery locations and whether shell size is associated with cortisol concentration. Because each oyster can contribute multiple tissue samples, all size-based analyses are summarized at the oyster level to avoid treating multiple tissues from the same oyster as independent biological replicates.
# ============================================================
# Oyster size, size-cortisol correlations, and size-adjusted models
# ============================================================
# This section is designed to run on both:
# 1. the full processed dataset
# 2. the secondary dataset with extreme outliers removed, if available
#
# Key design choice:
# Size comparisons are oyster-level only.
# Size-cortisol correlations and size-adjusted models use one consistent tissue
# per oyster, selected as the most frequently represented tissue with usable data.
# ============================================================
library(dplyr)
library(ggplot2)
library(readr)
library(broom)
# ---------- Helper functions ----------
first_non_missing <- function(x) {
x <- x[!is.na(x) & x != ""]
if (length(x) == 0) return(NA)
x[1]
}
make_oyster_id <- function(data) {
if ("Oyster" %in% colnames(data)) {
as.character(data$Oyster)
} else {
# Fallback: use the portion of SampleID before the final underscore-delimited tissue token.
# If your SampleID format changes, it is better to include an explicit Oyster column.
gsub("(_[^_]+)$", "", as.character(data$SampleID))
}
}
clean_sample_rows <- function(data) {
data %>%
mutate(
SampleID_chr = as.character(SampleID),
IsBlank_safe = grepl("^BLK$|blank", SampleID_chr, ignore.case = TRUE),
IsStandard_safe = grepl("^S[0-7]$", SampleID_chr, ignore.case = TRUE)
) %>%
filter(!IsBlank_safe, !IsStandard_safe)
}
has_required_size_columns <- function(data) {
all(c("SampleID", "HatcheryLocation", "Shell_Length_(mm)", "Shell_Width_(mm)") %in% colnames(data))
}
prepare_oyster_size_data <- function(data) {
if (!has_required_size_columns(data)) {
return(tibble(
Note = "Size analysis skipped: required columns SampleID, HatcheryLocation, Shell_Length_(mm), Shell_Width_(mm) were not all present."
))
}
data %>%
clean_sample_rows() %>%
mutate(
OysterID = make_oyster_id(.),
Shell_Length_mm = suppressWarnings(as.numeric(.data[["Shell_Length_(mm)"]])),
Shell_Width_mm = suppressWarnings(as.numeric(.data[["Shell_Width_(mm)"]])),
HatcheryLocation = as.factor(HatcheryLocation)
) %>%
filter(!is.na(OysterID), OysterID != "", !is.na(HatcheryLocation)) %>%
group_by(OysterID, HatcheryLocation) %>%
summarize(
Shell_Length_mm = first_non_missing(Shell_Length_mm),
Shell_Width_mm = first_non_missing(Shell_Width_mm),
n_rows_for_oyster = n(),
.groups = "drop"
) %>%
filter(!is.na(Shell_Length_mm) | !is.na(Shell_Width_mm))
}
run_two_group_wilcox <- function(data, response_col) {
if (!response_col %in% colnames(data)) return(tibble())
test_data <- data %>% filter(!is.na(.data[[response_col]]), !is.na(HatcheryLocation))
if (n_distinct(test_data$HatcheryLocation) != 2 || nrow(test_data) < 3) {
return(tibble(
response = response_col,
method = "Wilcoxon rank-sum test",
note = "Test skipped: exactly two HatcheryLocation groups with sufficient data are required."
))
}
wt <- wilcox.test(as.formula(paste(response_col, "~ HatcheryLocation")), data = test_data, exact = FALSE)
broom::tidy(wt) %>% mutate(response = response_col, .before = 1)
}
plot_size_by_location <- function(oyster_size_data, response_col, y_label, plot_title) {
ggplot(oyster_size_data,
aes(x = HatcheryLocation, y = .data[[response_col]], color = HatcheryLocation, fill = HatcheryLocation)) +
geom_violin(trim = FALSE, alpha = 0.25, na.rm = TRUE) +
geom_boxplot(width = 0.20, outlier.shape = NA, alpha = 0.45, na.rm = TRUE) +
geom_jitter(width = 0.12, size = 2, alpha = 0.75, na.rm = TRUE) +
theme_minimal() +
theme(legend.position = "none") +
labs(title = plot_title, x = "Hatchery location", y = y_label)
}
choose_representative_tissue <- function(data) {
if (!"Tissue" %in% colnames(data)) return(NA_character_)
tissue_counts <- data %>%
clean_sample_rows() %>%
mutate(
OysterID = make_oyster_id(.),
FinalConcentration_ng_mL = suppressWarnings(as.numeric(FinalConcentration_ng_mL))
) %>%
filter(!is.na(Tissue), Tissue != "", !is.na(OysterID), !is.na(FinalConcentration_ng_mL)) %>%
distinct(OysterID, Tissue) %>%
count(Tissue, sort = TRUE)
if (nrow(tissue_counts) == 0) return(NA_character_)
as.character(tissue_counts$Tissue[1])
}
prepare_oyster_cortisol_size_data <- function(data, selected_tissue = NULL) {
if (!has_required_size_columns(data) || !"FinalConcentration_ng_mL" %in% colnames(data)) {
return(tibble(
Note = "Correlation/model analysis skipped: required size columns or FinalConcentration_ng_mL were not present."
))
}
if (is.null(selected_tissue) || is.na(selected_tissue)) {
selected_tissue <- choose_representative_tissue(data)
}
if (is.na(selected_tissue)) {
return(tibble(
Note = "Correlation/model analysis skipped: no usable Tissue column/data were available."
))
}
data %>%
clean_sample_rows() %>%
mutate(
OysterID = make_oyster_id(.),
Shell_Length_mm = suppressWarnings(as.numeric(.data[["Shell_Length_(mm)"]])),
Shell_Width_mm = suppressWarnings(as.numeric(.data[["Shell_Width_(mm)"]])),
Cortisol_ng_mL = suppressWarnings(as.numeric(FinalConcentration_ng_mL)),
HatcheryLocation = as.factor(HatcheryLocation),
SelectedTissue = selected_tissue
) %>%
filter(Tissue == selected_tissue,
!is.na(OysterID), OysterID != "",
!is.na(HatcheryLocation),
!is.na(Cortisol_ng_mL), Cortisol_ng_mL > 0) %>%
group_by(OysterID, HatcheryLocation, SelectedTissue) %>%
summarize(
Shell_Length_mm = first_non_missing(Shell_Length_mm),
Shell_Width_mm = first_non_missing(Shell_Width_mm),
# If more than one row exists for the selected tissue/dilution, use the median
# to avoid allowing one oyster to contribute multiple values.
Cortisol_ng_mL = median(Cortisol_ng_mL, na.rm = TRUE),
n_rows_for_oyster_selected_tissue = n(),
.groups = "drop"
) %>%
filter(!is.na(Cortisol_ng_mL))
}
run_spearman_tests <- function(data) {
out <- list()
if ("Shell_Length_mm" %in% colnames(data) && sum(!is.na(data$Shell_Length_mm) & !is.na(data$Cortisol_ng_mL)) >= 4) {
out[["Shell_Length_mm"]] <- broom::tidy(cor.test(data$Shell_Length_mm, data$Cortisol_ng_mL,
method = "spearman", exact = FALSE)) %>%
mutate(size_metric = "Shell_Length_mm", .before = 1)
}
if ("Shell_Width_mm" %in% colnames(data) && sum(!is.na(data$Shell_Width_mm) & !is.na(data$Cortisol_ng_mL)) >= 4) {
out[["Shell_Width_mm"]] <- broom::tidy(cor.test(data$Shell_Width_mm, data$Cortisol_ng_mL,
method = "spearman", exact = FALSE)) %>%
mutate(size_metric = "Shell_Width_mm", .before = 1)
}
if (length(out) == 0) {
return(tibble(note = "Spearman correlations skipped: insufficient complete observations."))
}
bind_rows(out)
}
plot_size_cortisol <- function(data, size_col, x_label, plot_title) {
ggplot(data, aes(x = .data[[size_col]], y = Cortisol_ng_mL, color = HatcheryLocation)) +
geom_point(size = 2.3, alpha = 0.85, na.rm = TRUE) +
geom_smooth(method = "lm", se = FALSE, na.rm = TRUE) +
scale_y_log10() +
theme_minimal() +
labs(title = plot_title, x = x_label, y = "Final cortisol concentration (ng/mL, log10 scale)", color = "Hatchery location")
}
run_size_adjusted_models <- function(data) {
if (!"Cortisol_ng_mL" %in% colnames(data)) return(tibble(note = "Model skipped: no cortisol column."))
model_data <- data %>%
mutate(log10_Cortisol = log10(Cortisol_ng_mL)) %>%
filter(is.finite(log10_Cortisol), !is.na(HatcheryLocation))
model_outputs <- list()
for (size_col in c("Shell_Length_mm", "Shell_Width_mm")) {
if (!size_col %in% colnames(model_data)) next
md <- model_data %>% filter(!is.na(.data[[size_col]]))
if (nrow(md) < 6 || n_distinct(md$HatcheryLocation) < 2) {
model_outputs[[size_col]] <- tibble(
model = paste("log10_Cortisol ~ HatcheryLocation +", size_col),
note = "Model skipped: insufficient complete observations or fewer than two hatchery locations."
)
next
}
# Main size-adjusted model: hatchery location + size covariate
main_formula <- as.formula(paste("log10_Cortisol ~ HatcheryLocation +", size_col))
main_model <- lm(main_formula, data = md)
main_terms <- broom::tidy(main_model) %>%
mutate(model = paste("log10_Cortisol ~ HatcheryLocation +", size_col), .before = 1)
main_glance <- broom::glance(main_model) %>%
mutate(model = paste("log10_Cortisol ~ HatcheryLocation +", size_col), .before = 1)
# Interaction model: tests whether the size-cortisol slope differs by hatchery location
interaction_formula <- as.formula(paste("log10_Cortisol ~ HatcheryLocation *", size_col))
interaction_terms <- tibble()
interaction_glance <- tibble()
interaction_anova <- tibble()
if (nrow(md) >= 8) {
interaction_model <- lm(interaction_formula, data = md)
interaction_terms <- broom::tidy(interaction_model) %>%
mutate(model = paste("log10_Cortisol ~ HatcheryLocation *", size_col), .before = 1)
interaction_glance <- broom::glance(interaction_model) %>%
mutate(model = paste("log10_Cortisol ~ HatcheryLocation *", size_col), .before = 1)
interaction_anova <- broom::tidy(anova(main_model, interaction_model)) %>%
mutate(model_comparison = paste("Interaction test for", size_col), .before = 1)
}
model_outputs[[size_col]] <- list(
terms = bind_rows(main_terms, interaction_terms),
fit = bind_rows(main_glance, interaction_glance),
interaction_test = interaction_anova
)
}
model_outputs
}
save_model_outputs <- function(model_outputs, prefix) {
if (is.data.frame(model_outputs)) {
write_csv(model_outputs, paste0(prefix, "_size_adjusted_model_notes.csv"))
return(invisible(NULL))
}
terms <- purrr::map_dfr(model_outputs, function(x) {
if (is.list(x) && "terms" %in% names(x)) {
return(x$terms)
} else {
return(tibble())
}
})
fit <- purrr::map_dfr(model_outputs, function(x) {
if (is.list(x) && "fit" %in% names(x)) {
return(x$fit)
} else {
return(tibble())
}
})
interaction <- purrr::map_dfr(model_outputs, function(x) {
if (is.list(x) && "interaction_test" %in% names(x)) {
return(x$interaction_test)
} else {
return(tibble())
}
})
readr::write_csv(terms, paste0(prefix, "_size_adjusted_model_terms.csv"))
readr::write_csv(fit, paste0(prefix, "_size_adjusted_model_fit.csv"))
readr::write_csv(interaction, paste0(prefix, "_size_adjusted_interaction_tests.csv"))
return(list(
terms = terms,
fit = fit,
interaction = interaction
))
}
run_oyster_size_workflow <- function(data, dataset_label, output_prefix) {
cat("\n==============================\n")
cat("Oyster size workflow:", dataset_label, "\n")
cat("==============================\n")
if (!has_required_size_columns(data)) {
cat("Skipping oyster size workflow because required shell size columns are missing.\n")
return(invisible(NULL))
}
# 1. Oyster-level size comparisons between hatchery locations
oyster_size_data <- prepare_oyster_size_data(data)
write_csv(oyster_size_data, paste0(output_prefix, "_oyster_size_by_individual.csv"))
size_summary <- oyster_size_data %>%
group_by(HatcheryLocation) %>%
summarize(
n_oysters = n(),
mean_length_mm = mean(Shell_Length_mm, na.rm = TRUE),
median_length_mm = median(Shell_Length_mm, na.rm = TRUE),
sd_length_mm = sd(Shell_Length_mm, na.rm = TRUE),
mean_width_mm = mean(Shell_Width_mm, na.rm = TRUE),
median_width_mm = median(Shell_Width_mm, na.rm = TRUE),
sd_width_mm = sd(Shell_Width_mm, na.rm = TRUE),
.groups = "drop"
)
write_csv(size_summary, paste0(output_prefix, "_oyster_size_summary_by_hatchery_location.csv"))
print(size_summary)
size_tests <- bind_rows(
run_two_group_wilcox(oyster_size_data, "Shell_Length_mm"),
run_two_group_wilcox(oyster_size_data, "Shell_Width_mm")
)
write_csv(size_tests, paste0(output_prefix, "_oyster_size_wilcoxon_tests.csv"))
print(size_tests)
p_size_length <- plot_size_by_location(
oyster_size_data, "Shell_Length_mm", "Shell length (mm)",
paste("Shell length by hatchery location -", dataset_label)
)
p_size_width <- plot_size_by_location(
oyster_size_data, "Shell_Width_mm", "Shell width (mm)",
paste("Shell width by hatchery location -", dataset_label)
)
pdf(paste0(output_prefix, "_oyster_size_by_hatchery_location.pdf"), width = 7, height = 5)
print(p_size_length)
print(p_size_width)
dev.off()
# 2. Choose the most consistently represented tissue for oyster-level cortisol-size correlations
selected_tissue <- choose_representative_tissue(data)
cat("Selected tissue for size-cortisol correlation/modeling:", selected_tissue, "\n")
write_csv(tibble(Dataset = dataset_label, SelectedTissue = selected_tissue),
paste0(output_prefix, "_selected_tissue_for_size_cortisol_analysis.csv"))
corr_data <- prepare_oyster_cortisol_size_data(data, selected_tissue)
write_csv(corr_data, paste0(output_prefix, "_oyster_size_cortisol_correlation_data.csv"))
if ("Cortisol_ng_mL" %in% colnames(corr_data) && nrow(corr_data) >= 4) {
corr_tests <- run_spearman_tests(corr_data)
write_csv(corr_tests, paste0(output_prefix, "_spearman_size_cortisol_correlations.csv"))
print(corr_tests)
p_corr_length <- plot_size_cortisol(
corr_data, "Shell_Length_mm", "Shell length (mm)",
paste("Cortisol vs shell length -", selected_tissue, "-", dataset_label)
)
p_corr_width <- plot_size_cortisol(
corr_data, "Shell_Width_mm", "Shell width (mm)",
paste("Cortisol vs shell width -", selected_tissue, "-", dataset_label)
)
pdf(paste0(output_prefix, "_size_cortisol_correlation_plots.pdf"), width = 7, height = 5)
print(p_corr_length)
print(p_corr_width)
dev.off()
# 3. Size-adjusted models
model_outputs <- run_size_adjusted_models(corr_data)
saved_models <- save_model_outputs(model_outputs, output_prefix)
if (!is.null(saved_models)) {
cat("\nSize-adjusted model terms:\n")
print(saved_models$terms)
cat("\nInteraction tests comparing additive vs interaction models:\n")
print(saved_models$interaction)
}
} else {
cat("Size-cortisol correlations and size-adjusted models skipped because fewer than 4 complete oyster-level observations were available.\n")
}
invisible(TRUE)
}
# ---------- Run workflow for full data ----------
if (exists("plot_data") && nrow(plot_data) > 0) {
run_oyster_size_workflow(
data = plot_data,
dataset_label = "Full dataset",
output_prefix = "full_dataset"
)
} else if (exists("processed_metadata") && nrow(processed_metadata) > 0) {
run_oyster_size_workflow(
data = processed_metadata,
dataset_label = "Full dataset",
output_prefix = "full_dataset"
)
}
==============================
Oyster size workflow: Full dataset
==============================
Skipping oyster size workflow because required shell size columns are missing.
# ---------- Run workflow for extreme-outlier-removed data ----------
if (exists("plot_data_no_extreme_outliers") && nrow(plot_data_no_extreme_outliers) > 0) {
run_oyster_size_workflow(
data = plot_data_no_extreme_outliers,
dataset_label = "Extreme outliers removed",
output_prefix = "outliers_removed_dataset"
)
} else {
cat("Extreme-outlier-removed oyster size workflow skipped because plot_data_no_extreme_outliers was not available.\n")
}
==============================
Oyster size workflow: Extreme outliers removed
==============================
Skipping oyster size workflow because required shell size columns are missing.
<> row.SampleID == "BLK".excluded_wells are removed from
duplicate averages. If one replicate is excluded, the calculation uses
the remaining well and records this in
ExcludedWellNote.SampleID,
Cell Position 1, Cell Position 2, and
Plate Number are automatically used as possible grouping
variables.combined_grouping_variables, such as
TreatmentDay = c("Treatment", "SampleDay").default_sample_dilution_factor and
default_extraction_correction_factor, or sample-by-sample
with metadata columns named DilutionFactor and
ExtractionCorrectionFactor.getwd()
getwd()
list.files()
plate_file_map
file.exists(“Plate1_Tecan_RawData.xlsx”) read_tecan_plate(“Plate1_Tecan_RawData.xlsx”, plate_number = 1)
plate_data <- plate_file_map %>% pmap(read_tecan_plate) str(plate_file_map)
args(read_tecan_plate) names(plate_file_map)
plate_file_map <- plate_file_map %>% rename( tecan_excel_file =
TecanExcelFile, plate_number = Plate Number )
names(plate_file_map)
plate_data <- plate_file_map %>% pmap(read_tecan_plate)
plate_data str(plate_data)
setwd(“/Users/sydneyirwin/Desktop/Cortisol Coding”)
getwd() list.files()
rstudioapi::getSourceEditorContext()$path
getwd()
metadata[41, ]
metadata <- read_csv(metadata_csv_file, show_col_types = FALSE)