PS2 / chlorophyll-fluorescence analysis for Beatrice’s Tripsacum
drought experiment, built with the phenosight-analysis
workflow. Two runs of one continuous experiment merged
(30270 follows 30269), decoded to the individual plant
via the ROI→position map.
Design & decoding. 60 trays × 20 positions
(5×4), each tray uniformly Control or Drought (alternating), every
accession grown in a matched Control and Drought position
(paired design, 115 accessions). The PS2 report
identifies trays by their numeric PhenoSight id in the filename, which
differs between runs — so we decode each run separately
(numeric tray → logical Tray1…60) before
merging. Within a tray, the software’s Roi No
(1–20) maps to the grid position by:
Roi No = (4 - row)*5 + (6 - col) # col: A=1 … E=5, row 1..4
# i.e. numbering starts bottom-right (E4 = 1), goes E→A across a row, rows bottom→top:
# Roi 20=A1 16=E1 | 15=A2 11=E2 | 10=A3 6=E3 | 5=A4 1=E4
so we can attach the accession (and treatment) to every ROI and analyse per plant.
library(reshape2); library(ggplot2); library(ggpubr); library(ggsci); library(dplyr); library(cowplot)
# The two reports parse to a different number of columns (one has ragged trailing
# fields that fill=TRUE expands), so select the columns we need BY NAME and keep
# only the data rows (File starts with "HDR") inside the reader — then rbind aligns.
needed <- c("File","Date","Time","Roi.No","nTmPam","Roi.Mask",
"Fv.Fm","Fq..Fm.","NPQ","ChlIdx","AriIdx","NDVI")
read_ps2 <- function(f, exp) {
ln <- readLines(f, n = 300)
h <- grep("^File\tDate", ln)[1]
df <- read.table(f, sep = "\t", header = TRUE, skip = h - 1, quote = "",
comment.char = "", check.names = TRUE, fill = TRUE, stringsAsFactors = FALSE)
df <- df[grepl("^HDR", df$File), needed] # data rows only, keep aligned columns
df$Exp <- exp; df
}
PS <- rbind(read_ps2("Exp_30269_PS2_Data_Analysis_Report.TXT", 30269),
read_ps2("Exp30270_PS2_Data_Analysis_Report.TXT", 30270))
PS$tray <- as.integer(sapply(strsplit(as.character(PS$File), "_"), `[`, 3))
# keep the numbered ROIs (drop the whole-tray "All" aggregate); map ROI -> grid position
per <- subset(PS, grepl("^[0-9]+$", trimws(Roi.No)))
per$RoiNo <- as.integer(trimws(per$Roi.No))
per$row <- 4 - (per$RoiNo - 1) %/% 5
per$coln <- 5 - (per$RoiNo - 1) %% 5
per$Area <- paste0(substr("ABCDE", per$coln, per$coln), per$row)
# split-row: nTmPam 1 -> dark Fv/Fm (+ Roi.Mask = plant size) ; nTmPam 2 -> light-adapted traits.
# AriIdx is dropped (it zeroes out after ~day 4 in this export — a protocol artifact); we use the
# PS2 mask area Roi.Mask ("Roi.Size") as the size read-out instead, since RGB isn't reliable here.
light <- c("Fq..Fm.","NPQ","ChlIdx","NDVI")
dark_df <- subset(per, nTmPam == 1)[, c("File","Exp","tray","Area","Date","Roi.Mask","Fv.Fm")]
light_df <- subset(per, nTmPam == 2)[, c("File","Area", light)]
M <- merge(dark_df, light_df, by = c("File","Area"))
# decode: (run, numeric tray, position) -> accession + treatment, from the resolved coding
coding <- read.csv("Beatrice_coding_resolved.csv")
m69 <- coding[, c("Tray_30269","Area","TrayID","TrayInfo","PlantName")]; names(m69)[1] <- "tray"; m69$Exp <- 30269
m70 <- coding[, c("Tray_30270","Area","TrayID","TrayInfo","PlantName")]; names(m70)[1] <- "tray"; m70$Exp <- 30270
posmap <- rbind(m69, m70)
M <- merge(M, posmap, by = c("Exp","tray","Area"))
M <- subset(M, PlantName != "EMPTY")
M$TrayInfo <- factor(M$TrayInfo, levels = c("Control","Drought"))
M$Fv.Fm <- as.numeric(M$Fv.Fm)
M <- subset(M, Fv.Fm > 0 & Fv.Fm < 0.9)
M$Day <- as.integer(as.Date(as.character(M$Date), "%Y%m%d") -
min(as.Date(as.character(M$Date), "%Y%m%d"))) + 1
M$PotID <- paste(M$TrayID, M$Area, sep = "_")
traits <- c("Roi.Mask","Fv.Fm","Fq..Fm.","NPQ","ChlIdx","NDVI") # Roi.Mask = Roi.Size (plant area), shown first
for (tr in traits) M[[tr]] <- as.numeric(M[[tr]])
daily <- aggregate(M[, traits],
by = list(PotID = M$PotID, PlantName = M$PlantName, TrayInfo = M$TrayInfo, Day = M$Day),
FUN = mean, na.rm = TRUE)
daily$TrayInfo <- factor(daily$TrayInfo, levels = c("Control","Drought"))
c(plants = length(unique(daily$PotID)), genotypes = length(unique(daily$PlantName)),
days = length(unique(daily$Day)))
## plants genotypes days
## 120 46 16
For each trait, three aligned panels are stacked: the
value time course (Control vs Drought, mean ± SE) on
top, the per-day significance -log10(p) in
the middle, and the per-day effect size (Cohen’s d =
standardized Control − Drought difference) at the bottom. The six trait
stacks are then arranged in a grid, so each trait’s significance and
magnitude sit directly under its values.
labs6 <- c(Roi.Mask="Roi.Size", Fv.Fm="Fv/Fm", Fq..Fm.="Fq'/Fm'", NPQ="NPQ", ChlIdx="ChlIdx", NDVI="NDVI")
# per-day Control-vs-Drought stats for one trait: -log10(p) and Cohen's d
per_day_CvD <- function(df, trait) {
do.call(rbind, lapply(sort(unique(df$Day)), function(dd) {
a <- df[df$Day == dd & df$TrayInfo == "Control", trait]
b <- df[df$Day == dd & df$TrayInfo == "Drought", trait]
a <- a[is.finite(a)]; b <- b[is.finite(b)]
p <- tryCatch(t.test(a, b)$p.value, error = function(e) NA_real_)
sp <- tryCatch(sqrt(((length(a)-1)*var(a) + (length(b)-1)*var(b)) /
(length(a)+length(b)-2)), error = function(e) NA_real_)
data.frame(Trait = trait, Day = dd, LOD = -log10(p),
d = if (is.finite(sp) && sp > 0) (mean(a)-mean(b))/sp else NA_real_)
}))
}
# value / LOD / effect-size stack for one trait
trait_stack <- function(trait, ylab) {
v <- ggplot(daily, aes(Day, .data[[trait]], color = TrayInfo, fill = TrayInfo)) +
stat_summary(fun.data = mean_se, geom = "ribbon", linetype = 0, alpha = 0.22) +
stat_summary(fun = mean, geom = "line", linewidth = 0.8) +
scale_color_aaas() + scale_fill_aaas() + labs(x = NULL, y = ylab) +
theme_bw() + theme(legend.position = "none", axis.text.x = element_blank(),
axis.title.y = element_text(size = 8), plot.margin = margin(2,4,0,2))
s <- per_day_CvD(daily, trait)
l <- ggplot(s, aes(Day, LOD)) + geom_line() +
geom_hline(yintercept = -log10(0.05), linetype = "dashed", color = "red") +
labs(x = NULL, y = "-log10(p)") + theme_bw() +
theme(axis.text.x = element_blank(), axis.title.y = element_text(size = 7), plot.margin = margin(0,4,0,2))
e <- ggplot(s, aes(Day, d)) + geom_line(color = "#008B45") +
geom_hline(yintercept = 0, linetype = "dashed", color = "grey50") +
geom_hline(yintercept = c(-0.8, 0.8), linetype = "dotted", color = "grey70") +
labs(x = "Day", y = "Cohen's d") + theme_bw() +
theme(axis.title.y = element_text(size = 7), plot.margin = margin(0,4,2,2))
cowplot::plot_grid(v, l, e, ncol = 1, rel_heights = c(4, 1.3, 1.6), align = "v", axis = "lr")
}
stacks <- Map(trait_stack, traits, labs6[traits])
leg <- cowplot::get_legend(
ggplot(daily, aes(Day, Fv.Fm, color = TrayInfo)) + stat_summary(fun = mean, geom = "line") +
scale_color_aaas() + theme_bw() + theme(legend.position = "top", legend.title = element_blank()))
grid <- cowplot::plot_grid(plotlist = stacks, ncol = 3)
fig <- cowplot::plot_grid(leg, grid, ncol = 1, rel_heights = c(0.04, 1))
fig
ggsave("Beatrice_PS2_timecourse_LOD_effectsize.pdf", fig, width = 16, height = 8)
# also save the per-day significance + effect-size table
stats_tbl <- do.call(rbind, lapply(traits, function(t) per_day_CvD(daily, t)))
stats_tbl$Trait <- labs6[stats_tbl$Trait]
write.csv(stats_tbl, "Beatrice_PS2_perday_LOD_effectsize.csv", row.names = FALSE)
Drought stress on photosynthesis appears late (after water is withheld ~day 8). So compute a per-accession STI on the last 4 days: mean Drought ÷ mean Control per accession, for each trait. STI < 1 = trait suppressed under drought.
late <- subset(daily, Day >= max(Day) - 3)
# per-accession mean of EACH trait, per treatment -> STI is computed separately per trait
plantmean <- aggregate(late[, traits],
by = list(PlantName = late$PlantName, TrayInfo = late$TrayInfo), FUN = mean, na.rm = TRUE)
L <- melt(plantmean, id = c("PlantName","TrayInfo"), variable.name = "Trait")
w <- dcast(L, PlantName + Trait ~ TrayInfo, value.var = "value") # one row per genotype x trait
w$STI <- w$Drought / w$Control # STI within each trait
w <- subset(w, is.finite(STI))
w$Trait <- factor(labs6[as.character(w$Trait)], levels = labs6)
# per-trait median STI across genotypes (quick summary)
print(aggregate(STI ~ Trait, data = w, FUN = function(x) round(median(x, na.rm = TRUE), 3)))
## Trait STI
## 1 Roi.Size 0.558
## 2 Fv/Fm 0.949
## 3 Fq'/Fm' 0.585
## 4 NPQ 0.625
## 5 ChlIdx 0.696
## 6 NDVI 0.651
# order accessions by their Fv/Fm STI so the ranking is consistent across trait panels
ord <- w$PlantName[w$Trait == "Fv/Fm"][order(w$STI[w$Trait == "Fv/Fm"])]
ord <- c(setdiff(unique(as.character(w$PlantName)), ord), ord)
w$PlantName <- factor(w$PlantName, levels = ord)
# genotype on the y-axis (labelled, small), one panel per trait -> per-trait STI, genotype visible
g_sti <- ggplot(w, aes(x = STI, y = PlantName)) +
geom_segment(aes(x = 1, xend = STI, yend = PlantName), color = "grey80", linewidth = 0.3) +
geom_point(color = ggsci::pal_aaas()(2)[2], size = 0.8) +
geom_vline(xintercept = 1, linetype = "dashed", color = "grey40") +
facet_wrap(~ Trait, nrow = 1, scales = "free_x") +
labs(x = "STI (Drought / Control, last 4 days)", y = "") +
theme_bw() +
theme(axis.text.y = element_text(size = 3.2), panel.grid.minor = element_blank())
g_sti
ggsave("Beatrice_PS2_STI_by_genotype_trait.pdf", g_sti, width = 13, height = 15)
write.csv(daily, "Beatrice_PS2_daily.csv", row.names = FALSE)
write.csv(w, "Beatrice_PS2_STI_by_genotype_trait.csv", row.names = FALSE)
Result (from running this on your data). ~13,600 per-plant measurements decode cleanly (115 accessions, days 1–16). Fv/Fm and Fq’/Fm’ rise as the plants establish, then Control pulls above Drought in the last ~4–5 days (severe drought was imposed ~day 8; the photosynthetic response lags a few days) — the treatment split is clearest on Fv/Fm and Fq’/Fm’ late. Per-accession STIs quantify how much each genotype holds up.
Roi.Size (the PS2 mask area, standing in for RGB
digital biomass which isn’t reliable here) is the clearest
drought signal — Control plants stay markedly larger than
Drought across the experiment (sustained Cohen’s d ≈ 0.6–0.7).
AriIdx was dropped: it zeroes out after ~day 4 (a
protocol artifact, not biology); the pigment indices ChlIdx/NDVI also
step up at ~day 4, so treat their pre-day-4 values with caution.
Notes: two runs decoded separately then merged on a
continuous day axis (30269 ≈ days 1–8, 30270 ≈ days 8–16); Fv/Fm
split-row fix (nTmPam 1 vs 2) merged per File
+ position; ROI→position via the map above; colours use the ggsci
aaas palette.