Author

Marco D’Agostino, Théo Degand, Lei Ding

INTRODUCTION & SET UP

This notebook presents simple codes to load, process and clean time-series data about leaf water potential ( \(LWP\) ) , soil water potential (\(SWP\)) and weight (\(W\)) for transpiration rate (\(T_r\)).

The repository contains the following folders :

  • data/ :

    • raw/ contains one folder per variable. Inside each subfolder are stored raw files directly collected from sensors. Typically, each file corresponds to one plant.

    • clean/ contains cleaned tables (in .csv format) for metadata and for each variable.

  • plots/ : contains plots and figures generated by this notebook.

  • src/ contains custom functions used to process, compile and clean the raw data:

    • Data_Preparation.R contains functions to clean weight and Leaf Water Potential :

      • load_LWP() : Load all CSV files from psychrometers in the input folder into a single dataframe. By default it loads “Date”, “Time”,“Chambertemp”, “dT”, “WetBulb”, “LWP”, “Intercept”, “Slope”, “Correction_dT”, “Correctionfactor”, “BatteryLevel” columns.

      • load_psy_file() : Simple function to load and format one CSV psychrometer file. This function is used in a loop in load_LWP()

      • load_SWP() : Loads all .data files present in the input folder into a single dataframe with “DateTime”, “Teros_ID” and “SWP” columns.

      • load_Weight() : Loads all .csv files from weight scales present in input folder into one dataframe with “DateTime”, “Scale” (weighting scale ID) and “Weight”.

      • compute_Tr() : computes transpiration rate from weight data, following : \(T_r(t) = \frac{W(t) - W(t-1)}{\Delta t}\)

      • process_hourly() : summarize a dataframe into Mean, SD, Median and Mad by grouping per givein variables.

      • load_csv_custom() : custom function to load pre-processed csv files to correctly format DateTime column in POSIXct format.

      • clean.weight() : takes pre-loaded weight data, detect refilling times, cut and paste data to have weight values only relative to evapo-tranpsiration.

      • clean.LWP() : applies the cleaning algorithm to pre-loaded LWP data.

    • LWP_algorithm.R contains codes for cleaning LWP data. The main function to use is clean.LWP().

    • Custom_Plots.R contains codes for custom plots :

      • plot_timeseries() is a custom plot function to show timeseries data with day and night patterns represented by grey and white rectangles in background.

      • plot_destructive() is a custom plot function to construct beautifull boxplots for many data.

NB : We included a custom-function to plot timeseries, and show day-night period with grey rectangles. Go in src/Plot_Timeseries.R for details.

Loading libraries and defining paths

Code
# rm(list=ls())                                               # clean workspace [comment when Rendering]
# setwd(dirname(rstudioapi::getActiveDocumentContext()$path)) # Set working directory to the current file [comment when Rendering]

# LOAD LIBRARIES
library(SciViews)
library(ggplot2)
library(pander)
library(dplyr)
library(tidyverse)
library(data.table)
library(anytime)
library(ggthemes)

# LOAD UTILITARY FUNCTIONS IN SRC FOLDER
function_path <- "./R/"
for(i in list.files(function_path, pattern = ".R")){
  print(i)
  source(paste0(function_path, i))
}

# SET DIRECTORIES
data_path_clean <- paste0("data/clean/") # Set path to Clean folder
data_path_raw   <- paste0("data/raw/")   # Set path to row folder
plot_path       <- paste0("plots/")      # Set path to plot folder

path_LWP        <- paste0(data_path_raw, "LWP/")
path_SWP        <- paste0(data_path_raw, "SWP/")
path_weight     <- paste0(data_path_raw, "weight/")
Code
# OPTIONAL : define a custom theme
my_theme <- theme_classic() +
  theme(
    text            = element_text(size = 11),
    axis.title      = element_text(size = 12),
    axis.text       = element_text(size = 11),
    axis.text.x     = element_text(size = 8, angle = 90, hjust = 1, vjust = 0.5),
    plot.title      = element_text(size = 12, hjust = 0.5),
    strip.background = element_rect(colour = "white", fill = "white"),
    strip.text = element_text(size = 12), # Increase size and make bold
  )

Load Metadata

Load metadata about the experiment.

Here we designed a simple experiment as example :

  • 4 plants (Plant_ID column)

  • 2 different treatments (Treatment column) :

    • well watered (WW)

    • water deficit (WD )

  • Leaf water potential (\(LWP\)) was recorded with psychrometers (PSY column). Raw files are stored in data/raw/LWP/.

  • Soil water potential (\(SWP\)) was recorded with TEROS 21 (Teros column). Raw files are stored in data/raw/SWP/.

  • Weight (\(W\)) was recorded with scales (Scale column). Raw files are stored in data/raw/weight/.

Code
Plants_ID <- read.csv(paste0(data_path_clean, "Plants_ID.csv"))
pander(Plants_ID)
Plant_ID PSY Teros Treatment Scale
1 PSY1N909 MP40 WD plant1
2 PSY1N905 MP01 WW plant2
3 PSY1N903 MP04 WD plant3
4 PSY1N906 MP68 WW plant4

1) LOAD LEAF WATER POTENTIAL DATA

Extract from raw files

\(LWP\) data from psychrometers are stored in separated .csv files.

We use the custom function load_LWP() to load and process each csv file in the provided path.

The result is a single table containing Date, Time, LWP value and the ID of plants.

By default, the function also load columns :

  • Chambertemp : Temperature inside the chamber [°C]

  • dT : the difference of temperature between the 2 thermocouples [°C]

  • WetBulb : the wet bulb (see PSY1-LEAF manual (ICT) for formal definition)

  • LWP : the Leaf Water Potential measure [MPa]

  • Intercept : Intercept of the calibration curve.

  • Slope : Slope of the calibration curve.

  • Correction_dT : Correction factor for dT

  • Correctionfactor : Correction factor

  • BatteryLevel : The level of battery

NB: By default, the psychrometers are calibrated with Intercept = 1.00 and Slope = -3.80.

The single file is written as LWP_raw.csv in the raw folder.

Code
# Compile all PSY files into one dataframe
LWP_raw <- load_LWP(path = path_LWP) %>% 
  na.omit()

# Change the PSY id by Plant_ID
LWP_raw <- left_join(LWP_raw, 
                     Plants_ID[c("Plant_ID", "PSY")], 
                     by = "PSY") 

pander(head(LWP_raw))

ggplot(LWP_raw) +
  geom_line(aes(x = DateTime, y = LWP)) +
  facet_wrap(~Plant_ID) +
  theme_few()

# Write to raw folder
write.csv(x = LWP_raw, file = paste0(data_path_raw, "LWP_raw.csv"), row.names = F)

Load compiled raw file and apply filtering

Once the raw files are compiled, we can simply load the processed csv file.

NB : each time we load the raw .csv files, we need to format correctly the DateTime column.

Data must be filtered by :

  • Realistic values of Leaf Water Potential ; here > -2MPa. This is set arbitrary by visualization, and need to be checked for the species and the conditions. In our experience, -2MPa works nice as a filter for outliers in maize and tomato.

  • WetBulb > 0 (PSY1-LEAF manual)

  • -1.5 < dT < 0.5 (Bourbia et al. 2021)

  • Battery level (in case there is issue when charging)

In addition, we developed a LWP cleaning algorithm to get rid of remaining wrong data. It consists of creating buffers per day, and for each buffer we run a bunch of tests to check if we want to keep the buffer of not.

  • check.na evaluates how many NAN are in the buffer. If there is more NAN than the nan.th proportion, the buffer is discarded.

  • check.zero evaluates how many zeros are in the buffer. If there is more zeros than the zero.th proportion, the buffer is discarded.

  • check.cycles evaluates if LWP at night is lesser than LWP at day. If the mean LWP at day is higher that the mean LWP at night multiplied by cycle.alpha, the buffer is discarded.

  • check.slope evaluates the mean slope of the buffer via a linear model. If the absolute value of the slope is higher than slope.th, the buffer is discarded.

  • check.level evaluates the statistical mean of the buffer compared to the mean of the other buffers. If \(\mu_{LWP, i} < \mu_{LWP} - \alpha \cdot \sigma_{LWP}\) or \(\mu_{LWP, i} > \mu_{LWP} + \alpha \cdot \sigma_{LWP}\) (with \(\mu_{LWP}\) is the mean LWP for all buffers, \(\mu_{LWP,i}\) is the mean LWP of the ith buffer, \(\alpha\) is the unput multiplicator and \(\sigma_{LWP}\) is the standard deviation of LWP), the buffer is discarded.

NB : for the example dataset, data was already good so the application of LWP_algo don’t improve the quality.

Finally, the clean dataframe is written to storage.

Code
# LOAD RAW LWP
LWP_raw <- load_csv_custom(file = paste0(data_path_raw, "LWP_raw.csv")) %>% 
  mutate(Plant_ID = as.factor(Plant_ID))
################################################################################

# CLEANING MANNUALLY
LWP_clean1 <- LWP_raw %>% 
  filter(LWP > -2) %>%                # Realistic values
  filter(dT >= -1.5 & dT <= 0.5) %>%  # Filter on dT (Bourbia 2021)
  filter(WetBulb > 0)                 # Filter on WetBulb (PSY1-LEAF manual)

################################################################################

LWP_clean2 <- clean.LWP(LWP_data = LWP_clean1,
                       check.na     = T,
                       check.zero   = T, 
                       check.cycles = T, 
                       check.slope  = T, 
                       check.level  = T, 
                       nan.th       = 0.5, 
                       zero.th      = 0.5, 
                       cycle.alpha  = 0.5, 
                       slope.th     = 0.00005, 
                       level.alpha  = 2)

################################################################################
p_comp <- plot_timeseries(data = LWP_clean2
                         ,x = "DateTime", y = "LWP"
                         ,color = "Plant_ID"
                         ,show.lines = F
                         ) +
  geom_line(data = LWP_raw, aes(x = DateTime, y = LWP), color = "red") +
  geom_line(data = LWP_clean1, aes(x = DateTime, y = LWP), color = "orange") +
  geom_line(data = LWP_clean2, aes(x = DateTime, y = LWP), color = "green") +
  facet_wrap(~Plant_ID) +
  ylab("Leaf Water Potantial [MPa]") 
p_comp

Code
ggsave(filename = paste0(plot_path, "LWP_comparison.png"), plot = p_comp
       ,width = 4, height = 3)
################################################################################

# WRITE TO CLEAN
write.csv(LWP_clean2 %>% 
            select(c("DateTime", "Plant_ID", "LWP"))
          ,file = paste0(data_path_clean, "LWP_clean.csv")
          ,row.names = F
          )

Load clean file

Code
LWP_clean <- read.csv(paste0(data_path_clean, "LWP_clean.csv")) %>% 
  mutate(DateTime = anytime(DateTime)) %>% 
  mutate(DateTime = as.POSIXct(DateTime, format = "%Y-%m-%d %H:%M:%S")) %>% 
  left_join(Plants_ID[c("Plant_ID", "Treatment")])

################################################################################
LWP_plot <- plot_timeseries(data = LWP_clean
                            ,x = "DateTime", y = "LWP"
                            ,color = "Treatment"
                            ) +
  geom_line(aes(x = DateTime, y = LWP, color = Treatment)) +
  geom_point(aes(x = DateTime, y = LWP, color = Treatment)) +
  facet_wrap(~Plant_ID, ncol = 2) +
  ylab("Leaf Water Potantial [MPa]") 

# Show plot and save to file
LWP_plot

Code
ggsave(filename = "LWP_clean.png", plot = LWP_plot, 
       device = "png", path = plot_path, width = 9, height = 4)

2) LOAD SOIL WATER POTENTIAL DATA

Extract from sensor files and compile into a single raw file

SWP data is stored as a unique raw .DAT file.
The function load_SWP loads the file and convert it to a tibble.
The pre-processed file can then be stored in data/raw/ folder.

Depending on the format of the .DAT file, you may need to change the inputs :
- path_SWP : Path to the folder containing the .DAT files.   - sep : Separator of column. By defaut is space, other used formats are ",", ";".
- skip : Number of lines to skip. By default is zero.
- filename : By default is null and the function scan every file of the folder. If filename is specified, the function only loads the corresponding file.

Code
# Extract from DAT files
SWP_raw <- load_SWP(path_SWP = path_SWP,
                    sep = "\t",
                    skip = 0
                    )

# Replace Teros_ID by Plant_ID and set DateTime as POSIXct
SWP_raw <- left_join(SWP_raw, Plants_ID[c("Plant_ID", "Teros")], by = c("Teros_ID" = "Teros")) %>% 
    select(c("DateTime", "SWP", "Plant_ID")) %>% 
    mutate(DateTime = as.POSIXct(DateTime, format = "%d/%m/%Y %H:%M"))

# Write to disk
write.csv(x = SWP_raw, file = paste0(data_path_raw, "SWP_raw.csv"), row.names = F)

Load raw file, clean it a bit and write to a clean file

Here we load the pre-processed SWP dataframe. Statistical or manual corrections can be done at will.
The final version of SWP data can then be written to data/clean/ folder.

Code
# Load SWP_raw
SWP_raw <- na.omit(read.csv(paste0(data_path_raw, "SWP_raw.csv"))) %>% 
  mutate(DateTime = anytime(DateTime)) %>% 
  mutate(DateTime = as.POSIXct(DateTime, format = "%Y-%m-%d %H:%M:%S"))

# Filter data so DateTime match with LWP
SWP_clean <- SWP_raw %>% 
  filter(DateTime >= min(LWP_clean$DateTime) & DateTime <= max(LWP_clean$DateTime)) %>% 
  filter(DateTime > as.POSIXct("2024-10-25"))

# Write to disk
write.csv(x = SWP_clean, file = paste0(data_path_clean, "SWP_clean.csv"), row.names = F)

Simple plot :

Code
SWP_clean <- read.csv(paste0(data_path_raw, "SWP_raw.csv")) %>% 
  mutate(DateTime = anytime(DateTime)) %>% 
  mutate(DateTime = as.POSIXct(DateTime, format = "%Y-%m-%d %H:%M:%S")) %>% 
  left_join(Plants_ID[c("Plant_ID", "Treatment")]) %>% 
  filter(DateTime > as.Date("2024-10-25"))
           

SWP_plot <- plot_timeseries(data = SWP_clean
                            ,x = "DateTime", y = "SWP"
                            ,color = "Treatment"
                            ,facet = "Plant_ID"
                            ,ncol = 2) +
  geom_line(aes(x = DateTime, y = SWP, color = Treatment)) +
  geom_point(aes(x = DateTime, y = SWP, color = Treatment))

# =========================================== #
SWP_plot

Code
ggsave(filename = "SWP_clean.png", plot = SWP_plot, device = "png", path = plot_path, width = 9, height = 4)

3) EXTRACT WEIGHT DATA

Extract from sensor output files and compile into one single raw file

Weight data is stored as individual .csv files in data/raw/weight/ folder.
Similarly to LWP and SWP, the function load_W function extracts individual files and creates a single table.
The table is store in data/raw/folder.

Code
# Load raw data
W_raw <- load_Weigth(path = path_weight, skip = 1) %>% 
  inner_join(Plants_ID[c("Plant_ID", "Scale")], by = "Scale") %>% 
  mutate(Date = as.Date(Date, format = "%d/%m/%Y")) %>%
  select(-c("Scale"))

# Write to disk
write.csv(x = W_raw, file = paste0(data_path_raw, "W_raw.csv"), row.names = F)

Load raw file, clean a bit and write clean file

Here we load the pre-processed Weight table, apply corrections and filtering at will, then write cleaned data to data/clean/ folder.

For correction, the function clean.weight automatically detect the refilling (that are defined by “jumps” of weight superior of input threshold), then reconnect the data to return only the weight corresponding to plant growth and transpiration.

Code
# Load raw file
W_raw <- unique(read.csv(paste0(data_path_raw, "W_raw.csv"))) %>% 
  mutate(DateTime = anytime(DateTime)) %>% 
  mutate(DateTime = as.POSIXct(DateTime, format = "%Y-%m-%d %H:%M:%S"))

# Cleaning
W_clean <- clean.weight(W_raw, refill_th = 10) %>% 
  filter(DateTime >= as.POSIXct("2024-10-24 11:00:00"))

Code
# Write to disk
write.csv(x = W_clean, file = paste0(data_path_clean, "W_clean.csv"), row.names = F)

4) Compute Transpiration rate

The function compute_Tr computes transpiration rate \(T_r\) from Weight time series using the formula

\[ T_{r, t} [g \cdot h^{-1}] = (W_{t-1} - W_{t}) / \Delta t \]

Pre-processed transpiration rate data is stored in data/raw/ folder.

Code
# Load clean Weight file
W_clean <- unique(read.csv(paste0(data_path_clean, "W_clean.csv"))) %>% 
  mutate(DateTime = anytime(DateTime)) %>% 
  mutate(DateTime = as.POSIXct(DateTime, format = "%Y-%m-%d %H:%M:%S")) %>% 
  mutate(Date = as.Date(DateTime),
         Hour = hour(DateTime)
         )

# Compute Tr using custom function
Tr_raw <- compute_Tr(W = W_clean, th = 20, DT.format = "%Y-%m-%d %H:%M:%S")

# Write to disk
write.csv(x = Tr_raw, file = paste0(data_path_raw, "Tr_raw.csv"), row.names = F)

Load Tr raw from file

Code
# Read Tr raw fil from folder
Tr_raw <- unique(read.csv(paste0(data_path_raw, "Tr_raw.csv"))) %>% 
  mutate(DateTime = anytime(DateTime)) %>% 
  mutate(DateTime = as.POSIXct(DateTime, format = "%Y-%m-%d %H:%M:%S")) %>% 
  left_join(Plants_ID[c("Plant_ID", "Treatment")]) %>% 
  filter(DateTime > "2024-10-25")

Simple plot

Code
Tr_plot <- plot_timeseries(data = Tr_raw
                           ,x = "DateTime", y = "Tr"
                           ,color = "Treatment"
                           ,show.lines = T
                           ) +
  facet_wrap(~Plant_ID) +
  ylab("Transpiration rate [g/h]")

Tr_plot

Code
ggsave(filename = "Tr_raw.png", 
       plot = Tr_plot, device = "png", path = plot_path, width = 9, height = 4)

Make one plot with the 3 variables

Create a gigantic file with every time series measurement.

For that, we create temporary table by gathering variables into one key column (Measure) and one value column (Value).

Then we bind them by row. For better visualization, we create a new attribute Plant_name that combine Plant_ID and Treatment.

Code
LWP_temp <- LWP_clean[c("Plant_ID", "DateTime", "LWP")] %>% 
  gather(LWP, key = "Measure", value = "Value") 

SWP_temp <- SWP_clean[c("Plant_ID", "DateTime", "SWP")] %>% 
  gather(SWP, key = "Measure", value = "Value") %>% 
  mutate(Value = Value/1000)

W_temp   <- W_clean[c("Plant_ID", "DateTime", "Weight")] %>% 
  gather(Weight, key = "Measure", value = "Value") %>% 
  mutate(Value = Value/1500)

T_temp   <- Tr_raw[c("Plant_ID", "DateTime", "Tr")] %>% 
  gather(Tr, key = "Measure", value = "Value") %>% 
  mutate(Value = Value/10)

TS_full <- rbind(LWP_temp, SWP_temp) %>% 
  rbind(T_temp) %>% 
  left_join(Plants_ID, by = "Plant_ID") %>% 
  mutate(Plant_name = paste0("Plant ", Plant_ID, " | ", Treatment))

Generate the plot

Code
measures_colors <- c("LWP" = "darkgreen", "SWP" = "orange", "Tr" = "blue")

################################################################################
full_plot <- plot_timeseries(data = TS_full
                             ,x = "DateTime", y = "Value"
                             ,color = "Measure"
                             ) +
  
  # Show data
  geom_line(aes(x = DateTime, y = Value, color = Measure),
            linewidth = 0.25
            ) +
  geom_point(aes(x = DateTime, y = Value, color = Measure),
             size = 0.5) +
  
  scale_color_manual(values = measures_colors) +
  facet_wrap(~Plant_name) +
  
  # Set x limits and define the format of breaks
  scale_x_datetime(# date_breaks = "1 day",
                   date_labels = "%d/%m",
                   name = "Date") + 
  
  # Set y limits and add a secondary axis
  scale_y_continuous(limits = c(-2, 1.5),
                     bquote(expr = psi~"[MPa]"), 
                     sec.axis = sec_axis(~.*10, name = bquote(T[act]~"["*gh^-1*"]"))
                     ) 
Scale for x is already present.
Adding another scale for x, which will replace the existing scale.
Code
################################################################################

full_plot

Code
ggsave(filename = "Full_plot.png", plot = full_plot, device = "png", path = plot_path, width = 7, height = 5)

svg(filename = paste0(plot_path, "Full_plot.svg"), width = 7, height = 5)
full_plot
dev.off()
png 
  2