Background

Portfolio piece — a synthetic recreation of a non-destructive vibration-based firmness measurement workflow I built during a fruit-quality project. The original recordings are not shareable, so this repo simulates qualitatively similar two-channel waveforms (stem end and calyx end), recovers the characteristic minimum from each trace, extracts its time t_min, and looks at the joint (CH1 t_min, CH2 t_min) distribution.

The pipeline reproduces the four steps I used in practice:

  1. acquire two-channel time-series per fruit;
  2. detect the diagnostic minimum on each channel;
  3. plot every trace with a coloured marker at the detected minimum for visual QA;
  4. summarise the dataset as a CH1 Ɨ CH2 scatter coloured by trial.

Simulated data

source("R/simulate.R")
source("R/process.R")
traces <- readRDS("data/traces.rds")
n_fruit <- length(unique(traces$fruit))
n_trial <- length(unique(traces$trial))
sprintf("%d fruits across %d trials, %d samples per trace.",
        n_fruit, n_trial, sum(traces$fruit == 1))
## [1] "104 fruits across 13 trials, 101 samples per trace."

Example traces with detected minima

Vertical bars mark the detected minimum on each channel (orange = CH1, green = CH2).

plot_trace(traces, fruit_id = 4)

plot_trace(traces, fruit_id = 60)

plot_trace(traces, fruit_id = 95)

A larger PNG gallery is rendered by R/batch_plots.R into figures/traces/.

Per-fruit summary

summary_df <- summarise_traces(traces)
head(summary_df)
## # A tibble: 6 Ɨ 6
##   trial  trial_no fruit channel t_min_ms  y_min
##   <fct>     <int> <int> <chr>      <dbl>  <dbl>
## 1 Sep-24        1     1 CH1         0.54 -0.916
## 2 Sep-24        1     1 CH2         0.56 -0.978
## 3 Sep-24        1     2 CH1         0.56 -0.985
## 4 Sep-24        1     2 CH2         0.62 -0.825
## 5 Sep-24        1     3 CH1         0.54 -1.00 
## 6 Sep-24        1     3 CH2         0.56 -0.924
summary_wide <- tidyr::pivot_wider(summary_df,
                                   id_cols = c(trial, trial_no, fruit),
                                   names_from = channel,
                                   values_from = t_min_ms)

CH1 vs CH2 scatter (pattern detection)

Each point is one fruit. Most points sit near the CH1 = CH2 diagonal; vertical excursions are CH2-only outliers (skin defect on the calyx end of the fruit, in the original interpretation).

library(ggplot2)
ggplot(summary_wide, aes(CH1, CH2, colour = trial)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed",
              colour = "grey60") +
  geom_point(size = 2.2, alpha = 0.85) +
  scale_x_continuous(limits = c(0.4, 1.05)) +
  scale_y_continuous(limits = c(0.4, 1.20)) +
  labs(title = "CH2 vs CH1 (synthetic, 2024-2025)",
       x = "CH1 t_min (ms)",
       y = "CH2 t_min (ms)",
       colour = "Trial") +
  theme_minimal(base_size = 12) +
  theme(legend.key.height = unit(0.8, "lines"))

ggsave("figures/scatter_ch1_ch2.png", width = 7, height = 5.2, dpi = 150)

SS1 — CH2 vs CH1 with continuous t_min gradient

Same scatter as above but coloured by the mean t_min across the two channels — a purely geometric ranking that highlights the diagonal trend without claiming any biophysical firmness model. Earlier minima cluster in the lower-left (purple/blue); later minima in the upper-right (orange/red).

summary_wide$t_min_mean <- (summary_wide$CH1 + summary_wide$CH2) / 2

ss1_palette <- c("blueviolet", "deepskyblue", "forestgreen",
                 "yellowgreen", "gold", "orange", "red")

ggplot(summary_wide, aes(CH1, CH2, colour = t_min_mean)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed",
              colour = "grey60") +
  geom_point(size = 2.4, alpha = 0.9) +
  scale_x_continuous(limits = c(0.4, 1.05)) +
  scale_y_continuous(limits = c(0.4, 1.20)) +
  scale_colour_gradientn(colours = ss1_palette) +
  labs(title = "SS1 — CH2 vs CH1, coloured by mean t_min",
       x = "CH1 t_min (ms)",
       y = "CH2 t_min (ms)",
       colour = "mean t_min (ms)") +
  theme_minimal(base_size = 12) +
  theme(legend.key.height = unit(1.2, "lines"))

ggsave("figures/ss1_firmness.png", width = 7.2, height = 5.4, dpi = 150)

Off-diagonal points indicate asymmetry between stem-end and calyx-end response on the same fruit.

Disclaimer

Traces are simulated with a damped Gabor wavelet plus a small initial transient and Gaussian noise. The repo demonstrates the processing pipeline, not a biological finding.

sessionInfo()
## R version 4.2.3 (2023-03-15 ucrt)
## Platform: x86_64-w64-mingw32/x64 (64-bit)
## Running under: Windows 10 x64 (build 26200)
## 
## Matrix products: default
## 
## locale:
## [1] LC_COLLATE=English_United Kingdom.utf8 
## [2] LC_CTYPE=English_United Kingdom.utf8   
## [3] LC_MONETARY=English_United Kingdom.utf8
## [4] LC_NUMERIC=C                           
## [5] LC_TIME=English_United Kingdom.utf8    
## 
## attached base packages:
## [1] stats     graphics  grDevices utils     datasets  methods   base     
## 
## other attached packages:
## [1] tidyr_1.3.1   dplyr_1.1.2   ggplot2_3.5.1
## 
## loaded via a namespace (and not attached):
##  [1] pillar_1.9.0      bslib_0.6.2       compiler_4.2.3    jquerylib_0.1.4  
##  [5] highr_0.11        tools_4.2.3       digest_0.6.34     jsonlite_1.8.9   
##  [9] evaluate_1.0.1    lifecycle_1.0.4   tibble_3.2.1      gtable_0.3.6     
## [13] pkgconfig_2.0.3   rlang_1.1.3       cli_3.6.1         yaml_2.3.10      
## [17] xfun_0.43         fastmap_1.1.1     withr_3.0.2       knitr_1.45       
## [21] systemfonts_1.1.0 generics_0.1.3    vctrs_0.6.5       sass_0.4.9       
## [25] grid_4.2.3        tidyselect_1.2.1  glue_1.8.0        R6_2.5.1         
## [29] textshaping_0.4.0 fansi_1.0.4       rmarkdown_2.29    purrr_1.0.2      
## [33] farver_2.1.2      magrittr_2.0.3    scales_1.3.0      htmltools_0.5.7  
## [37] colorspace_2.1-1  ragg_1.3.3        labeling_0.4.3    utf8_1.2.3       
## [41] munsell_0.5.1     cachem_1.0.8