This R Publication is intended to provide updates on current and ongoing data analysis to the researches involved in the deployment of CU Boulder’s PHBV sensors (gen2) by Vanderbilt researchers. Using this approach provides transparency regarding the data analysis methods used, and improves communication between researchers.
This update reruns the V2 analysis pipeline on the sensor download
collected in May 2026 (received July 2026). It is the
same physical deployment described previously — four loggers with 3
sensors each (parallelized PHBV on long stake PCB) — now with roughly
two full years of readings (June 2024 through late May 2026). Loggers
were installed at four sites, referred to here as
grass_burn, grass_no_burn,
juniper_burn and juniper_no_burn. Note
that the logger at the juniper_burn site initially had
an unseated SD card, so data does not start from that logger until later
in the experiment. That logger’s real-time clock also produced a large
number of corrupted timestamps in this download, so we use the
hand-corrected timestamp file (Dates_edited_...) provided
by the field team for juniper_burn.
# Load Libraries ----------------------------------------------------------
library(tidyverse); library(dplyr); library(lubridate); library(scales)
library(ggplot2); library(cowplot); library(RColorBrewer); library(ggpubr);
library(DescTools); library(stringr); library(zoo)
library(changepoint)
library(reshape)
library(readxl)
Site colors are defined once here and reused by every site-colored plot below — edit this block to change the coloring everywhere.
# Site color coding (edit these to change every plot's site colors):
sitePalette <- c(grass_burn = "#E41A1C", # red
grass_no_burn = "#377EB8", # blue
juniper_burn = "#4DAF4A", # green
juniper_no_burn = "#984EA3") # purple
scale_color_site <- function(...) ggplot2::scale_color_manual(values = sitePalette, ...)
scale_fill_site <- function(...) ggplot2::scale_fill_manual(values = sitePalette, ...)
# Import and Process Text Files -------------------------------------------
# When knitting/rendering, the working directory is already the location of
# this .Rmd, so relative paths resolve correctly. (In interactive RStudio the
# original analysis set this explicitly via rstudioapi.)
if (requireNamespace("rstudioapi", quietly = TRUE) && rstudioapi::isAvailable()) {
setwd(dirname(rstudioapi::getActiveDocumentContext()$path))
}
dataDir <- "Re_ Titan Cave sensors"
dev1 <- read.table(file.path(dataDir, 'Grass_burn_site1_May2026.TXT'),
sep=',', header=TRUE)
names(dev1) <- c('device','time','temp','ch1','ch2','ch3','ch4','ch5','ch6')
dev1$site <- 'grass_burn'
dev3 <- read.table(file.path(dataDir, 'Grass_control_site3_May2026.TXT'),
sep=',', header=TRUE)
names(dev3) <- c('device','time','temp','ch1','ch2','ch3','ch4','ch5','ch6')
dev3$site <- 'grass_no_burn'
dev4 <- read.table(file.path(dataDir, 'Juniper_control_site4_May2026.TXT'),
sep=',', header=TRUE)
names(dev4) <- c('device','time','temp','ch1','ch2','ch3','ch4','ch5','ch6')
dev4$site <- 'juniper_no_burn'
#Adding Device 2, which had a bad SD card installation initially.
#This logger's RTC also produced many corrupted timestamps in the May 2026
#download, so we use the hand-corrected timestamp file from the field team.
dev2 <- read.table(file.path(dataDir, 'Dates_edited_Juniper_Burn_Site2_May2026.txt'),
sep=',', header=TRUE)
names(dev2) <- c('device','time','temp','ch1','ch2','ch3','ch4','ch5','ch6')
dev2$site <- 'juniper_burn'
####Combine the data####
wideData <- rbind(dev1,dev3,dev4,dev2)
rm(dev3,dev4)
#Format dates
wideData$time <- mdy_hms(wideData$time)
#Because the wakeup schedule is set by the timer, not the RTC, we can interpolate
#timestamps, where row = time order:
#Interpolate corrupted timestamps (NA after parsing) within each device
wideData <- wideData %>%
group_by(device) %>%
mutate(time = as.POSIXct(na.approx(as.numeric(time), na.rm = FALSE),
origin = "1970-01-01", tz = "UTC")) %>%
ungroup() %>%
as.data.frame()
#Remove early dates to only include good sensor port data
wideData <- subset(wideData, time > as.POSIXct("2024-06-12 15:00:00"))
#Filter out pre-SD datapoint from Device 2
preData <- subset(wideData, device=="Device 2")
preData <- subset(preData, time < "2024-10-01")
wideData <- setdiff(wideData,preData)
####Convert to long format####
allData <- melt(wideData,id=c('site','device','time'))
#Remove logger temperature data
tempData <- subset(allData, variable == 'temp')
allData <- setdiff(allData,tempData)
####Clean up the data a bit####
names(allData) <- c('site','sensor','time','variable','reading')
allData$sensor <- paste(allData$sensor,allData$variable)
allData <- select(allData, -'variable')
####Normalize the data by channel####
#Get an averaged initial resistance
#r_0 will be the mean resistance value between these dateTimes:
rStart <- as.POSIXct("2024-06-13 00:00:00")
r_0 <- allData %>%
group_by(sensor) %>%
arrange(time) %>%
filter(time < pmax(min(time), rStart) + hours(12)) %>%
dplyr::summarise(r0 = mean(reading))
allData <- merge(allData, r_0, by="sensor")
allData$r_norm <- allData$reading / allData$r0
####Include only channels that were hooked up to sensors####
allData <- subset(allData, sensor %in%
c('Device 1 ch1', 'Device 1 ch2', 'Device 1 ch3',
'Device 2 ch1', 'Device 2 ch2', 'Device 2 ch3',
'Device 3 ch1', 'Device 3 ch2', 'Device 3 ch3',
'Device 4 ch1', 'Device 4 ch2', 'Device 4 ch3'))
####Plot raw sensor data in a facet grid####
rawplot <- ggplot(allData) + theme_bw() + theme(legend.position = "bottom") +
geom_point(aes(x=time,y=r_norm,col=site),shape=21,size=1.5) +
xlab("") + ylab(expression("Normalized Resistance (R/R"[o]*")")) +
scale_fill_site() + scale_color_site()
rawplot + facet_wrap(~sensor) + ylim(0.5,1.5) + ggtitle("Raw Data (set y axis)")
rawplot + facet_wrap(~sensor, scales='free_y') + ggtitle("Raw Data (free y axis)")
Based on the above plot, it looks like some channels were unused in this experiment. We’ll use brute force to only retain sensors that were on active channels.
Additionally, some sensors show sudden failures (very high normalized resistance). We remove any data past a failure for those sensor channels.
####Remove very high normalized resistances####
#This removes data from channels after a sensor has failed
#(failed sensors read as very large resistance values).
allData <- subset(allData, r_norm <= 50)
#Replot
ggplot(allData) + theme_bw() + theme(legend.position = "bottom") +
geom_point(aes(x=time,y=r_norm,col=site),shape=21,size=1.5) +
facet_wrap(~sensor, scale='free_y') + ylim(0.5,3) +
xlab("") + ylab(expression("Normalized Resistance (R/R"[o]*")")) +
scale_fill_site() + scale_color_site()
#Define data types
allData$time <- as.POSIXct(allData$time)
allData$site <- as.factor(allData$site); allData$sensor <- as.factor(allData$sensor)
A few sensors produce isolated single-point spikes — for example, Device 4 ch2 (which failed on 2024-08-09) has one lone post-failure reading near R/R₀ ≈ 44. A single point like this stretches the y-axis and masks the real signal in the overlaid and averaged plots below.
We apply a very simple despike: for each sensor, compare every reading to the median of a small centered window (7 points) and drop any point that jumps more than 1.0 (in R/R₀ units) away from that local median. This removes sudden single-point outliers while leaving the genuine, gradually-varying signal untouched.
allData <- allData %>%
group_by(sensor) %>%
arrange(time) %>%
mutate(localMed = rollapply(r_norm, width = 7, FUN = median,
align = "center", fill = median(r_norm))) %>%
filter(abs(r_norm - localMed) <= 1) %>%
select(-localMed) %>%
ungroup() %>%
as.data.frame()
Based on raw sensor signals, do we see significant differences in sensor response as a function of the field in which the sensor is deployed?
ggplot(allData) + theme_bw() + theme(legend.position = "bottom") +
geom_point(aes(x=time,y=r_norm,col=site)) +
scale_color_site() + scale_fill_site() +
xlab("") + ylab(expression("Normalized Resistance (R/R"[o]*")"))
We often see larger sensor signal variability in healthy soil. We also
see generally higher normalized resistance in the
juniper site, and across the two-year record we see a
positive slope dominating the response — R/R₀ rises
over time, strongly at the juniper sites and only weakly at the grass
sites. A rising R/R₀ is consistent with microbial colonization /
degradation of the biodegradable polymer, whereas a flat or negative
slope suggests little colonization. We also see some interesting moments
where the sensor set as a whole responds; these are worth examining —
for example, large rainfall events that impacted all sites (see the
environmental-correction section below). The long-term rise is modeled
explicitly in the final section.
We often average sensor signal by treatment, examining these data by looking at both the mean and standard deviation of the sensor signal within each treatment (or field site).
####Let's average sensor signals by site to look for differences####
aveData <- allData %>%
mutate(hourTime = cut(time, breaks='2 hours')) %>%
group_by(site,hourTime) %>%
dplyr::summarise(stdev = sd(r_norm), r_norm = mean(r_norm, na.rm=T))
aveData$hourTime <- as.POSIXct(aveData$hourTime)
aveData$site <- as.factor(aveData$site)
sitePlot <- ggplot(aveData) + theme_bw() + theme(legend.position = "bottom") +
geom_point(aes(x=hourTime,y=r_norm,col=site)) +
scale_color_site() + scale_fill_site() +
xlab("") + ylab(expression("Normalized Resistance (R/R"[o]*")"))
devPlot <- sitePlot +
geom_ribbon(aes(x=hourTime,ymax=r_norm+stdev,ymin=r_norm-stdev,
col=site,fill=site), alpha=0.3)
show(sitePlot); show(devPlot)
### Examine sensor response by soil type and treatment
#Embarrassingly brute force factor assignment
aveData <- data.frame(aveData)
aveData$soil <- "NA"; aveData$treatment <- "NA"
aveData[aveData$site=="grass_burn",]$soil <- "grass"
aveData[aveData$site=="grass_no_burn",]$soil <- "grass"
aveData[aveData$site=="grass_burn",]$treatment <- "burn"
aveData[aveData$site=="grass_no_burn",]$treatment <- "no_burn"
aveData[aveData$site=="juniper_burn",]$soil <- "juniper"
aveData[aveData$site=="juniper_no_burn",]$soil <- "juniper"
aveData[aveData$site=="juniper_burn",]$treatment <- "burn"
aveData[aveData$site=="juniper_no_burn",]$treatment <- "no_burn"
devPlot <- ggplot(aveData) + theme_bw() + theme(legend.position = "bottom") +
geom_point(aes(x=hourTime,y=r_norm,fill=site)) +
geom_ribbon(aes(x=hourTime,ymax=r_norm+stdev,ymin=r_norm-stdev,
col=site,fill=site), alpha=0.3) +
scale_color_site() + scale_fill_site() +
xlab("") + ylab(expression("Normalized Resistance (R/R"[o]*")"))
devPlot + facet_wrap(~site)
devPlot + facet_wrap(~soil)
These results may suggest higher sensor response variability in the
burn sites than in the non-burn sites, along
with a much more clear sensor response in the juniper plots
than in the grass plots.
The normalized resistance of a PHBV sensor is not driven by microbial degradation alone — it also responds directly to soil temperature and water content. Diurnal warming/cooling and wetting/drying events therefore add environmental “noise” on top of the slower biological signal we care about. Each logger site has a co-located HOBO logger recording soil temperature (°C) and volumetric water content (m³/m³) every 4 hours, which lets us estimate and remove that environmental component.
Our approach is a first-order empirical correction, applied independently to each sensor channel \(i\). The symbols used below are defined immediately after the equations.
Step 1 — interpolate the environment onto sensor timestamps. The HOBO logger samples \(T\) and \(W\) at 4-hour times \(\tau_k\). For a sensor timestamp \(t\) with \(\tau_k \le t < \tau_{k+1}\), linear interpolation gives
\[ T(t) = T(\tau_k) + \frac{t-\tau_k}{\tau_{k+1}-\tau_k}\,\bigl(T(\tau_{k+1})-T(\tau_k)\bigr), \qquad W(t)\ \text{analogously.} \]
Step 2 — fit a per-channel linear model. For each channel \(i\) we estimate, by ordinary least squares,
\[ x_i(t) = \beta_{0,i} + \beta_{T,i}\,T(t) + \beta_{W,i}\,W(t) + \varepsilon_i(t), \qquad \hat{\boldsymbol\beta}_i = \arg\min_{\boldsymbol\beta}\sum_{t}\bigl(x_i(t) - \beta_0 - \beta_T T(t) - \beta_W W(t)\bigr)^2 . \]
Step 3 — form the corrected signal. We preserve each channel’s mean level \(\bar x_i\) and add back the residual \(\hat\varepsilon_i(t) = x_i(t) - \hat x_i(t)\), where \(\hat x_i(t) = \hat\beta_{0,i} + \hat\beta_{T,i}\,T(t) + \hat\beta_{W,i}\,W(t)\) is the fitted value:
\[ \tilde x_i(t) \;=\; \bar x_i + \hat\varepsilon_i(t). \]
Because ordinary least squares satisfies \(\bar x_i = \hat\beta_{0,i} + \hat\beta_{T,i}\,\bar T_i + \hat\beta_{W,i}\,\bar W_i\), this is equivalent to
\[ \boxed{\;\tilde x_i(t) \;=\; x_i(t) \;-\; \hat\beta_{T,i}\bigl(T(t)-\bar T_i\bigr) \;-\; \hat\beta_{W,i}\bigl(W(t)-\bar W_i\bigr)\;} \]
— that is, the correction subtracts only the temperature- and moisture-driven deviations from their means, leaving the channel mean \(\bar x_i\) unchanged.
Where, for each sensor channel \(i\):
r_norm).temp_c) and volumetric water content (m³/m³,
water), interpolated onto sensor time \(t\).r_corr).Caveat: because slow seasonal trends in temperature and moisture can partly co-vary with the multi-month degradation trend, this linear correction can absorb some genuine low-frequency biological signal along with the environmental confound. It is intended primarily to suppress diurnal and event-driven (rainfall/wetting) artifacts, not to be treated as a perfect separation of biology from environment.
envFiles <- c(grass_burn = "Grass_site_1_burn_Temp_Moisture_UpdatedMay2026.xlsx",
grass_no_burn = "Grass_site3_noburn_Temp_Moisture_UpdatedMay2026.xlsx",
juniper_burn = "Juniper_site2_burn_Temp_Moisture_UpdatedMay2026.xlsx",
juniper_no_burn = "Juniper_site4_noburn_Temp_Moisture_UpdatedMay2026.xlsx")
env <- purrr::map_dfr(names(envFiles), function(s){
x <- readxl::read_excel(file.path(dataDir, envFiles[s]))
data.frame(site = s,
time = as.POSIXct(x$Date),
temp_c = as.numeric(x$Temp_C),
water = as.numeric(x$WaterContent))
})
env <- env[!is.na(env$time), ]
# Note: the two burn-site loggers record back to 2023, while the two control
# loggers begin 2024-10-30. Restrict the view to the sensor-deployment era.
envPlot <- env %>%
filter(time > as.POSIXct("2024-06-12")) %>%
pivot_longer(c(temp_c, water), names_to = "variable", values_to = "value") %>%
mutate(variable = recode(variable,
temp_c = "Soil temperature (deg C)",
water = "Water content (m3/m3)"))
ggplot(envPlot) + theme_bw() + theme(legend.position = "bottom") +
geom_line(aes(x = time, y = value, col = site)) +
facet_wrap(~variable, ncol = 1, scales = "free_y") +
scale_color_site() + xlab("") + ylab("") +
ggtitle("Environmental conditions by site")
# Interpolate the 4-hourly environmental series onto every sensor timestamp,
# separately per site (rule = 1 leaves readings outside the logger's coverage
# as NA so they are excluded rather than extrapolated).
sensorTimes <- allData %>% distinct(site, time) %>%
group_by(site) %>%
group_modify(function(.x, .y){
e <- env %>% filter(site == .y$site) %>% arrange(time)
et <- e %>% filter(!is.na(temp_c))
ew <- e %>% filter(!is.na(water))
tnum <- as.numeric(.x$time)
tibble(time = .x$time,
temp_c = approx(as.numeric(et$time), et$temp_c, xout = tnum, rule = 1)$y,
water = approx(as.numeric(ew$time), ew$water, xout = tnum, rule = 1)$y)
}) %>% ungroup()
allEnv <- merge(allData, sensorTimes, by = c("site", "time"))
# Keep only readings that fall within a site's environmental coverage.
corrData <- allEnv %>%
filter(!is.na(temp_c), !is.na(water)) %>%
group_by(sensor) %>%
filter(n() > 10) %>% # skip degenerate (failed) channels
mutate(r_corr = { m <- lm(r_norm ~ temp_c + water); mean(r_norm) + resid(m) }) %>%
ungroup()
# How much of each channel's variance is explained by temperature + moisture?
fitTable <- corrData %>%
group_by(site, sensor) %>%
group_modify(function(.x, .y){
m <- lm(r_norm ~ temp_c + water, data = .x)
tibble(n = nrow(.x),
R2 = summary(m)$r.squared,
beta_temp = coef(m)[["temp_c"]],
beta_water = coef(m)[["water"]])
}) %>% ungroup()
knitr::kable(fitTable, digits = c(0,0,0,3,4,3),
caption = "Variance in normalized resistance explained by temperature and moisture, per sensor channel")
| site | sensor | n | R2 | beta_temp | beta_water |
|---|---|---|---|---|---|
| grass_burn | Device 1 ch1 | 25335 | 0.307 | -0.0014 | 0.873 |
| grass_burn | Device 1 ch2 | 25335 | 0.301 | -0.0012 | 0.857 |
| grass_burn | Device 1 ch3 | 25335 | 0.275 | -0.0013 | 1.205 |
| grass_no_burn | Device 3 ch1 | 27261 | 0.505 | -0.0011 | 1.139 |
| grass_no_burn | Device 3 ch2 | 27261 | 0.418 | -0.0018 | 0.696 |
| grass_no_burn | Device 3 ch3 | 27261 | 0.422 | -0.0011 | 0.850 |
| juniper_burn | Device 2 ch1 | 26865 | 0.418 | -0.0187 | 10.512 |
| juniper_burn | Device 2 ch2 | 26862 | 0.485 | -0.0548 | 43.624 |
| juniper_burn | Device 2 ch3 | 26865 | 0.441 | -0.0155 | 8.986 |
| juniper_no_burn | Device 4 ch1 | 26923 | 0.045 | 0.0011 | -3.295 |
| juniper_no_burn | Device 4 ch3 | 26923 | 0.105 | 0.0380 | -6.826 |
The table above reports, per channel, the fitted slopes \(\hat\beta_{T,i}\) (beta_temp)
and \(\hat\beta_{W,i}\)
(beta_water) and the coefficient of determination \(R_i^2\) — the fraction of that channel’s
normalized-resistance variance explained by temperature and moisture
together. Temperature and moisture explain a substantial fraction of the
raw signal variance at most channels (moisture, \(\hat\beta_{W,i}\), is typically the
stronger driver), confirming that a meaningful part of the raw
fluctuation is environmental rather than biological. (But note the
companion methods study,
env_correction_methods_july2026.Rmd: much of this in-sample
\(R_i^2\) is shared seasonal trend
rather than removable noise, so this correction should be treated as
light-touch.)
compareLong <- corrData %>%
select(site, sensor, time, r_norm, r_corr) %>%
pivot_longer(c(r_norm, r_corr), names_to = "type", values_to = "value") %>%
mutate(type = recode(type, r_norm = "raw", r_corr = "corrected"))
ggplot(compareLong) + theme_bw() + theme(legend.position = "bottom") +
geom_point(aes(x = time, y = value, col = type), shape = 21, size = 0.6, alpha = 0.3) +
facet_wrap(~sensor, scales = "free_y") +
scale_color_brewer(palette = "Set1") +
xlab("") + ylab(expression("Normalized Resistance (R/R"[o]*")")) +
guides(colour = guide_legend(override.aes = list(alpha = 1, size = 2))) +
ggtitle("Raw vs. environmentally-corrected sensor signal")
aveCorr <- corrData %>%
mutate(hourTime = cut(time, breaks = "2 hours")) %>%
group_by(site, hourTime) %>%
dplyr::summarise(stdev = sd(r_corr), r_corr = mean(r_corr, na.rm = TRUE), .groups = "drop")
aveCorr$hourTime <- as.POSIXct(aveCorr$hourTime)
aveCorr$site <- as.factor(aveCorr$site)
ggplot(aveCorr) + theme_bw() + theme(legend.position = "bottom") +
geom_ribbon(aes(x = hourTime, ymax = r_corr + stdev, ymin = r_corr - stdev,
col = site, fill = site), alpha = 0.3) +
geom_point(aes(x = hourTime, y = r_corr, fill = site), shape = 21, col = "black") +
scale_color_site() + scale_fill_site() +
xlab("") + ylab(expression("Corrected Normalized Resistance (R/R"[o]*")")) +
facet_wrap(~site, scales = "free_y") +
ggtitle("Environmentally-corrected signal, averaged by site")
After correction, the residual signal reflects the portion of each
sensor’s response that is not explained by soil temperature or
moisture — a cleaner starting point for interpreting
microbial-degradation dynamics. Note that the corrected series for the
two control (no_burn) sites begin at 2024‑10‑30, when their
environmental loggers were installed; earlier sensor readings have no
matching environmental data and are excluded from this section only.
Everything from here on — the degradation-trend slopes, the
changepoint decomposition, and the per-window slopes — is run on
whichever signal you pick here. Change the single line
below to "RAW" (the raw normalized R/R₀) or
"CORRECTED" (the environmentally-corrected signal from the
previous section). The default is "RAW".
signalSource <- "RAW" # <-- change to "RAW" or "CORRECTED"
# When CORRECTED is selected, swap the environmentally-corrected values into
# r_norm so every analysis below uses them without any further changes. The
# corrected data covers fewer rows (the two control sites' environmental loggers
# begin 2024-10-30), so those series start later than in the raw signal.
if (identical(signalSource, "CORRECTED")) {
allData <- corrData %>%
mutate(r_norm = r_corr) %>%
dplyr::select(dplyr::any_of(names(allData)))
}
# Short label used in the titles of every plot below so each figure states
# which signal it was built from.
signalLabel <- if (identical(signalSource, "CORRECTED")) "environmentally-corrected" else "raw"
cat("Downstream analysis is using the", signalSource, "signal.\n")
## Downstream analysis is using the RAW signal.
Across the two-year record, normalized resistance rises over time rather than declining. Because a rising R/R₀ is the response we associate with microbial colonization and degradation of the PHBV polymer, the sensor slope — the slope of that rise — is a natural summary metric for each site. Here we estimate a linear sensor slope per channel and compare it across soil type and fire treatment.
Two deliberate choices keep this honest:
trendData <- allData %>%
mutate(days = as.numeric(difftime(time, min(time), units = "days")))
# Weekly mean per channel to reduce autocorrelation before fitting a slope.
weekly <- trendData %>%
mutate(week = floor(days / 7)) %>%
group_by(site, sensor, week) %>%
dplyr::summarise(days = mean(days), r_norm = mean(r_norm), .groups = "drop")
# One linear slope per channel; keep only channels with enough temporal
# coverage (>= 8 weekly points and >= 180 days span). This drops the failed
# Device 4 ch2, whose usable data spans only ~2 months.
slopes <- weekly %>%
group_by(site, sensor) %>%
filter(n() >= 8, (max(days) - min(days)) >= 180) %>%
# Fit each model once per channel and reuse it for both the slope and the R2
# (the previous version re-fit the same lm four times per group).
group_modify(function(.x, .y){
m1 <- lm(r_norm ~ days, data = .x)
m2 <- lm(r_norm ~ poly(days, 2, raw = TRUE), data = .x)
tibble(span_days = round(max(.x$days) - min(.x$days)),
slope_1st_per_day = coef(m1)[["days"]],
R2_1st = summary(m1)$r.squared,
slope_2nd_per_day = coef(m2)[[2]],
R2_2nd = summary(m2)$r.squared)
}) %>%
ungroup() %>%
mutate(soil = ifelse(grepl("grass", site), "grass", "juniper"),
treatment = ifelse(grepl("no_burn", site), "no_burn", "burn"))
slopesDisplay <- slopes %>% select(site, sensor, span_days, slope_1st_per_day, R2_1st, slope_2nd_per_day, R2_2nd, soil, treatment)
knitr::kable(slopesDisplay, digits = c(0,0,0,5,3,5,3,0,0),
caption = "Linear component of 1st and 2nd order polynomial fits, per channel (units: R/R0 per day)")
| site | sensor | span_days | slope_1st_per_day | R2_1st | slope_2nd_per_day | R2_2nd | soil | treatment |
|---|---|---|---|---|---|---|---|---|
| grass_burn | Device 1 ch1 | 538 | 0.00009 | 0.085 | -0.00002 | 0.093 | grass | burn |
| grass_burn | Device 1 ch2 | 538 | 0.00010 | 0.119 | -0.00023 | 0.205 | grass | burn |
| grass_burn | Device 1 ch3 | 538 | 0.00023 | 0.285 | -0.00026 | 0.371 | grass | burn |
| grass_no_burn | Device 3 ch1 | 706 | 0.00014 | 0.266 | 0.00002 | 0.279 | grass | no_burn |
| grass_no_burn | Device 3 ch2 | 706 | 0.00006 | 0.097 | -0.00009 | 0.140 | grass | no_burn |
| grass_no_burn | Device 3 ch3 | 706 | 0.00010 | 0.261 | -0.00006 | 0.302 | grass | no_burn |
| juniper_burn | Device 2 ch1 | 584 | 0.00172 | 0.733 | -0.00097 | 0.790 | juniper | burn |
| juniper_burn | Device 2 ch2 | 584 | 0.00660 | 0.750 | 0.00620 | 0.750 | juniper | burn |
| juniper_burn | Device 2 ch3 | 584 | 0.00138 | 0.731 | -0.00064 | 0.780 | juniper | burn |
| juniper_no_burn | Device 4 ch1 | 706 | 0.00158 | 0.551 | -0.00095 | 0.645 | juniper | no_burn |
| juniper_no_burn | Device 4 ch3 | 706 | 0.00347 | 0.717 | -0.00147 | 0.814 | juniper | no_burn |
The two panels below place the fits side by side. Left: the 1st-order (linear) fit drawn over the weekly-averaged signal (matching how the per-channel slopes above are estimated). Right: the 2nd-order (quadratic) fit drawn over the full raw signal, so any curvature is visible before we report the 2nd-order slope in the next section.
# Left: weekly-averaged signal with the 1st-order (linear) fit.
p_linear <- ggplot(weekly, aes(x = days, y = r_norm, color = site)) + theme_bw() + theme(legend.position = "bottom") +
geom_point(size = 0.7, alpha = 0.4) +
geom_smooth(method = "lm", se = FALSE, color = "black", linewidth = 0.6) +
facet_wrap(~site, scales = "free_y") +
scale_color_site() +
xlab("Days since deployment") +
ylab(expression("Normalized Resistance (R/R"[o]*")")) +
ggtitle(paste0("Weekly-averaged ", signalLabel, " signal, 1st-order (linear) fit"))
# Right: full raw signal with the 2nd-order (quadratic) fit, shown before the
# 2nd-order slope is reported in the next section.
p_quad <- ggplot(trendData, aes(x = days, y = r_norm, color = site)) + theme_bw() + theme(legend.position = "bottom") +
geom_point(size = 0.3, alpha = 0.15) +
geom_smooth(method = "lm", formula = y ~ poly(x, 2, raw = TRUE),
se = FALSE, color = "black", linewidth = 0.6) +
facet_wrap(~site, scales = "free_y") +
scale_color_site() +
xlab("Days since deployment") +
ylab(expression("Normalized Resistance (R/R"[o]*")")) +
ggtitle(paste0("Full ", signalLabel, " signal, 2nd-order (quadratic) fit"))
cowplot::plot_grid(p_linear, p_quad, nrow = 2, labels = c("A", "B"))
# Reshape slopes to long format for side-by-side comparison
slopesLong <- slopes %>%
pivot_longer(cols = starts_with("slope_"),
names_to = "model",
values_to = "slope_per_day") %>%
mutate(model = factor(ifelse(grepl("1st", model), "1st order", "2nd order linear"),
levels = c("1st order", "2nd order linear")))
ggplot(slopesLong, aes(x = treatment, y = slope_per_day, fill = site)) + theme_bw() + theme(legend.position = "bottom") +
geom_boxplot(outlier.shape = NA, alpha = 0.7) +
geom_jitter(width = 0.12, size = 2, alpha = 0.8, color = "grey20") +
facet_grid(soil ~ model, scales = "free_y") +
scale_fill_site() +
xlab("") + ylab("Sensor slope (R/R0 per day)") +
ggtitle(paste0("Sensor slope by soil type and treatment (", signalLabel,
" signal): 1st vs 2nd order (points = channels)"))
# Descriptive model on the 1st-order per-channel slopes (n is small — interpret with care).
trendModel1st <- lm(slope_1st_per_day ~ soil * treatment, data = slopes)
trendModel2nd <- lm(slope_2nd_per_day ~ soil * treatment, data = slopes)
knitr::kable(as.data.frame(summary(trendModel1st)$coefficients), digits = 3,
caption = "Linear model of 1st-order sensor slope vs. soil and treatment")
| Estimate | Std. Error | t value | Pr(>|t|) | |
|---|---|---|---|---|
| (Intercept) | 0.000 | 0.001 | 0.149 | 0.885 |
| soiljuniper | 0.003 | 0.001 | 2.308 | 0.054 |
| treatmentno_burn | 0.000 | 0.001 | -0.031 | 0.976 |
| soiljuniper:treatmentno_burn | -0.001 | 0.002 | -0.332 | 0.750 |
knitr::kable(as.data.frame(summary(trendModel2nd)$coefficients), digits = 3,
caption = "Linear model of 2nd-order linear component vs. soil and treatment")
| Estimate | Std. Error | t value | Pr(>|t|) | |
|---|---|---|---|---|
| (Intercept) | 0.000 | 0.001 | -0.138 | 0.894 |
| soiljuniper | 0.002 | 0.002 | 0.962 | 0.368 |
| treatmentno_burn | 0.000 | 0.002 | 0.073 | 0.944 |
| soiljuniper:treatmentno_burn | -0.003 | 0.003 | -1.080 | 0.316 |
What the trend shows. The sensor slope is dominated
by soil type. The juniper channels climb at roughly
0.0025–0.0032 R/R₀ per day and are well-described by a
straight line (R² ≈ 0.55–0.75), whereas the grass channels are nearly
flat (≈ 0.0001 per day, R² < 0.3) — an order-of-magnitude difference.
In the model, the soil = juniper term is large and
marginally significant even at this tiny sample size (p ≈ 0.05), while
the treatment = no_burn term is essentially zero (p >
0.7): burn vs. no-burn makes little difference to the long-term
rise, but juniper soils show a much stronger degradation signal than
grass.
Caveats. The grass slopes are so close to zero that their sign should not be over-interpreted. And while the juniper rise is reasonably approximated by a single line over this window (R² ≈ 0.55–0.75), it may in fact accelerate partway through — a single linear slope is only a first-order summary of the rate. The next section addresses this directly by splitting each soil type’s signal into two windows.
Visually, each soil type’s signal appears to have two regimes — an earlier window and a later window with a different rate of change. To make this objective, we use changepoint detection to find the single point in time where each soil type’s signal changes, splitting it into Decomposition Window 1 and Decomposition Window 2, and then fit a separate slope within each window.
To ensure every sensor trace of a given soil type shares the
same two windows, we detect the changepoint once, on
the daily average of all sensors of that soil type
(changepoint::cpt.mean, at-most-one-change). We force a
single changepoint so both soil types are split, even where the shift is
subtle. Because prior work shows between-sensor variability dwarfs any
logger-introduced variability, averaging across a soil type’s sensors is
a fair way to locate a shared regime boundary.
# Label each reading with soil type and treatment.
allData <- allData %>%
mutate(soil = ifelse(grepl("grass", site), "grass", "juniper"),
treatment = ifelse(grepl("no_burn", site), "no_burn", "burn"))
# Daily average signal per soil type (the series the changepoint is detected on).
soilDaily <- allData %>%
mutate(day = as.Date(time)) %>%
group_by(soil, day) %>%
dplyr::summarise(r_norm = mean(r_norm), .groups = "drop") %>%
arrange(soil, day)
# One changepoint per soil type (AMOC = at most one change; pen.value = 0 forces
# the single best split so every soil type is divided into two windows).
changepoints <- soilDaily %>%
group_by(soil) %>%
group_modify(function(.x, .y){
cp <- changepoint::cpt.mean(.x$r_norm, method = "AMOC",
penalty = "Manual", pen.value = 0)
idx <- changepoint::cpts(cp)
tibble(cp_day = .x$day[idx])
}) %>% ungroup()
knitr::kable(changepoints, caption = "Detected changepoint date separating Decomposition Window 1 from Window 2, per soil type")
| soil | cp_day |
|---|---|
| grass | 2025-09-23 |
| juniper | 2025-09-23 |
# Default changepoint for the figures below. The auto-detected dates are shown
# above for reference; the two-window helpers accept ANY changepoint, so change
# this to explore other splits. (Prior analysis applied a single manual date to
# both soil types.)
cpDefault <- as.Date("2025-04-01")
The whole two-window decomposition — the Soil × Treatment
time series plus both per-window slope box
plots — is wrapped in a single function of the changepoint,
two_window_figure(), so any split date can be
rendered on demand. It accepts either a single date (applied to
both soil types) or a data frame with per-soil cp_day, and
returns the assembled figure alone (time series on top, Window
1 lower-left, Window 2 lower-right) — which makes it easy to flip
through several date choices in quick succession. A companion
two_window_summary() returns the matching mean-slope
table.
# Normalize the changepoint argument: accept a single date (applied to both soil
# types) or a data frame with columns `soil` and `cp_day`.
as_changepoints <- function(cp){
if (is.data.frame(cp)) return(dplyr::mutate(cp, cp_day = as.Date(cp_day)))
tibble(soil = c("grass", "juniper"), cp_day = as.Date(cp))
}
# Per-channel within-window sensor slopes (weekly-averaged), in long form ready
# for the box plots. A pure function of the changepoint and the static, labelled
# `allData`. The 2nd-order fit is constrained flat at each window's left edge
# (r_norm = a + c*(days - t0)^2), so it is summarised by its slope at the
# window's RIGHT edge -- the end-of-window rate 2*c*(t_right - t0).
window_slopes_long <- function(cp){
cps <- as_changepoints(cp)
allData %>%
left_join(cps, by = "soil") %>%
mutate(window = ifelse(as.Date(time) <= cp_day,
"Decomposition Window 1", "Decomposition Window 2"),
days = as.numeric(difftime(time, min(time), units = "days")),
week = floor(days / 7)) %>%
group_by(soil, treatment, site, sensor, window, week) %>%
dplyr::summarise(days = mean(days), r_norm = mean(r_norm), .groups = "drop") %>%
group_by(soil, treatment, site, sensor, window) %>%
filter(n() >= 4) %>% # need a few weeks to fit a slope
dplyr::summarise(
slope_1st_per_day = coef(lm(r_norm ~ days))[["days"]],
slope_2nd_per_day = {
t0 <- min(days)
c2 <- coef(lm(r_norm ~ I((days - t0)^2)))[[2]]
2 * c2 * (max(days) - t0)
}, .groups = "drop") %>%
pivot_longer(starts_with("slope_"), names_to = "model", values_to = "slope_per_day") %>%
mutate(model = factor(ifelse(grepl("1st", model), "1st order", "2nd order (flat-start)"),
levels = c("1st order", "2nd order (flat-start)")))
}
# Mean sensor slope by soil x treatment x window x fit (the summary table).
two_window_summary <- function(cp){
window_slopes_long(cp) %>%
group_by(soil, treatment, window, model) %>%
dplyr::summarise(mean_slope_per_day = mean(slope_per_day),
n_channels = n_distinct(sensor), .groups = "drop")
}
# The combined figure: Soil x Treatment time series on top, both per-window slope
# box plots below. Returns the plot object only, so several changepoint choices
# can be rendered back to back.
two_window_figure <- function(cp){
cps <- as_changepoints(cp)
cpLabel <- paste(format(sort(unique(cps$cp_day)), "%Y-%m-%d"), collapse = " / ")
# Per-site (soil x treatment) daily average, tagged with each soil's window.
siteDaily <- allData %>%
mutate(day = as.Date(time)) %>%
group_by(site, soil, treatment, day) %>%
dplyr::summarise(r_norm = mean(r_norm), .groups = "drop") %>%
left_join(cps, by = "soil") %>%
mutate(window = ifelse(day <= cp_day, "Decomposition Window 1", "Decomposition Window 2"))
# Top panel: daily-average signal per site, with per-treatment/per-window fits.
# 1st order : y = b0 + b1*t (ordinary linear fit)
# 2nd order : y = a + c*(t - t0)^2 (flat at each window's left edge)
p_main <- ggplot(siteDaily, aes(x = day, y = r_norm, color = site)) +
theme_bw() + theme(legend.position = "bottom") +
geom_point(size = 0.8, alpha = 0.4) +
geom_smooth(aes(group = interaction(site, window), linetype = "1st order (linear)"),
method = "lm", formula = y ~ x, se = FALSE) +
geom_smooth(aes(group = interaction(site, window), linetype = "2nd order (flat at window start)"),
method = "lm", formula = y ~ I((as.numeric(x) - min(as.numeric(x)))^2), se = FALSE) +
geom_vline(data = cps, aes(xintercept = cp_day), linetype = "dashed") +
facet_wrap(~soil, ncol = 1, scales = "free_y") +
scale_color_site() +
scale_linetype_manual(name = "Fit",
values = c("1st order (linear)" = "solid",
"2nd order (flat at window start)" = "twodash")) +
xlab("") + ylab(expression("Daily mean R/R"[o])) +
ggtitle(paste0("Soil x Treatment average split at ", cpLabel,
" (", signalLabel, " signal)"))
# Lower panels: one slope box plot per decomposition window.
wl <- window_slopes_long(cp)
boxplot_window <- function(w){
ggplot(filter(wl, window == w),
aes(x = treatment, y = slope_per_day, fill = site)) +
theme_bw() + theme(legend.position = "bottom") +
geom_boxplot(outlier.shape = NA, alpha = 0.7) +
geom_jitter(width = 0.12, size = 2, alpha = 0.8, color = "grey20") +
facet_grid(soil ~ model, scales = "free_y") +
scale_fill_site() +
xlab("") + ylab("Sensor slope (R/R0 per day)") +
ggtitle(w)
}
# Time series full-width on top; the two window box plots side by side below.
cowplot::plot_grid(
p_main,
cowplot::plot_grid(boxplot_window("Decomposition Window 1"),
boxplot_window("Decomposition Window 2"),
ncol = 2, labels = c("B", "C")),
ncol = 1, rel_heights = c(1.2, 1), labels = c("A", ""))
}
At the default changepoint (2025-04-01, applied to both soil types) the mean sensor slope, broken down by soil type, treatment, window, and fit, and then the combined figure:
knitr::kable(two_window_summary(cpDefault), digits = c(0,0,0,0,5,0),
caption = "Mean R/R0 sensor slope per day by soil type, treatment, decomposition window, and fit (1st order = straight-line slope; 2nd order = end-of-window slope from a fit constrained flat at the window start)")
| soil | treatment | window | model | mean_slope_per_day | n_channels |
|---|---|---|---|---|---|
| grass | burn | Decomposition Window 1 | 1st order | -0.00001 | 3 |
| grass | burn | Decomposition Window 1 | 2nd order (flat-start) | 0.00007 | 3 |
| grass | burn | Decomposition Window 2 | 1st order | 0.00018 | 3 |
| grass | burn | Decomposition Window 2 | 2nd order (flat-start) | 0.00048 | 3 |
| grass | no_burn | Decomposition Window 1 | 1st order | -0.00006 | 3 |
| grass | no_burn | Decomposition Window 1 | 2nd order (flat-start) | 0.00000 | 3 |
| grass | no_burn | Decomposition Window 2 | 1st order | 0.00009 | 3 |
| grass | no_burn | Decomposition Window 2 | 2nd order (flat-start) | 0.00022 | 3 |
| juniper | burn | Decomposition Window 1 | 1st order | 0.00010 | 3 |
| juniper | burn | Decomposition Window 1 | 2nd order (flat-start) | 0.00037 | 3 |
| juniper | burn | Decomposition Window 2 | 1st order | 0.00386 | 3 |
| juniper | burn | Decomposition Window 2 | 2nd order (flat-start) | 0.00695 | 3 |
| juniper | no_burn | Decomposition Window 1 | 1st order | -0.00087 | 3 |
| juniper | no_burn | Decomposition Window 1 | 2nd order (flat-start) | -0.00144 | 3 |
| juniper | no_burn | Decomposition Window 2 | 1st order | 0.00391 | 2 |
| juniper | no_burn | Decomposition Window 2 | 2nd order (flat-start) | 0.00768 | 2 |
two_window_figure(cpDefault)
Because the figure is a plain function of the changepoint, several candidate split dates can be rendered back to back — just edit the vector below (here: the manual default vs. the auto-detected autumn shift):
for (cp in c("2025-04-01", "2025-09-23")) print(two_window_figure(cp))
What the decomposition shows. Both soil types are split at essentially the same date — late September 2025 — which is itself notable: it points to a shared seasonal driver rather than a soil-specific one. (The environmental records around that date are shown above, in the changepoint section.) Grouping the within-window sensor slope by both soil type and treatment reveals that the acceleration is not uniform:
So there is a soil × treatment × window interaction: the strong post-September acceleration is concentrated specifically in the juniper no-burn site, while the juniper burn site shows a steady rise and the grass sites stay flat. The dominant effect remains soil type (juniper ≫ grass), but within juniper the timing of the rise differs by treatment — burning appears to bring the signal on earlier and steadier, whereas the unburned control stays quiet until the fall inflection point.