Rolling_Window_Peak_Table

Removing excess warnings from plots

options(warn = -1)

Loading Libraries

library(dplyr)

Attaching package: 'dplyr'
The following objects are masked from 'package:stats':

    filter, lag
The following objects are masked from 'package:base':

    intersect, setdiff, setequal, union
library(lmerTest)
Loading required package: lme4
Loading required package: Matrix

Attaching package: 'lmerTest'
The following object is masked from 'package:lme4':

    lmer
The following object is masked from 'package:stats':

    step
library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ forcats   1.0.0     ✔ readr     2.1.5
✔ ggplot2   3.5.1     ✔ stringr   1.5.1
✔ lubridate 1.9.3     ✔ tibble    3.2.1
✔ purrr     1.0.2     ✔ tidyr     1.3.1
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ tidyr::expand() masks Matrix::expand()
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag()    masks stats::lag()
✖ tidyr::pack()   masks Matrix::pack()
✖ tidyr::unpack() masks Matrix::unpack()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
library(modelr)
library(purrr)
library(emmeans)
Welcome to emmeans.
Caution: You lose important information if you filter this package's results.
See '? untidy'
library(gridExtra)

Attaching package: 'gridExtra'

The following object is masked from 'package:dplyr':

    combine
library(writexl)
library(gt)
library(webshot2)
library(broom.mixed)
library(ggplot2)
library(isotree)
library(tidyr)
library(ggforce)
library(plotrix)
library(pracma)

Attaching package: 'pracma'

The following object is masked from 'package:purrr':

    cross

The following object is masked from 'package:lmerTest':

    rand

The following objects are masked from 'package:Matrix':

    expm, lu, tril, triu

Loading the Rdata

load(file="Z:/Isaac/Visual Features/1-5/peak table/nested_df_of_selected.RData")

This df is a nested df where it has a full data set per sow, a baseline data set that was used to train a model from -50 ttf to 0 ttf, It has a nest of dfs for each hour after training like from -120 to -49 and -120 to -48 and so on until -120 to 120 for the whole data set. This was done this way to look at in “real time” with the data we have as we are looking to determine the onset of farrowing and that cannot be done with all of the data as we will not have all of the data or know when farrowing actually occurs in real time.

Directory of tables

peaktable_span_75_2_consec table with a span of .75 with a peak detected after a decline of two hours peaktable_span_5_2_consec table with a span of .5 with a peak detected after a decline of two hours peaktable_span_75_1_consec table with a span of .75 with a peak detected after a decline of one hour peaktable_span_5_1_consec table with a span of .5 with a peak detected after a decline of one hour peaktable_span_5_2_bef_2_aft table with a span of .75 with a peak detected after a decline of one hour with a slope ratio with 2 hours before and after the detected peak peaktable_span_75_2_bef_2_aft table with a span of .5 with a peak detected after a decline of one hour with a slope ratio with 2 hours before and after the detected peak peaktable_span_5_2_bef_2_aft_slope_thresh peaktable_span_5_2_bef_2_aft_slope_thresh_1 peaktable_span_35_2_bef_2_aft_slope_thresh_1

Defining Function for peak detection

find_realtime_peak <- function(win, span = 0.75, consistent_hours = 2) {
  
  win <- win %>%
    filter(!is.na(anomaly_score)) %>%
    arrange(ttf)
  
  if (nrow(win) < 10) return(tibble(
    status     = "Insufficient data",
    peak_ttf   = NA_real_,
    peak_score = NA_real_
  ))
  
  # Fit loess on the window's own data
  fit         <- loess(anomaly_score ~ ttf, data = win, span = span)
  ttf_grid    <- seq(min(win$ttf), max(win$ttf), length.out = 500)
  fitted_vals <- predict(fit, newdata = data.frame(ttf = ttf_grid))
  
  # First derivative
  deriv     <- diff(fitted_vals) / diff(ttf_grid)
  deriv_ttf <- ttf_grid[-1]
  
  # Step size between grid points
  step_size    <- diff(ttf_grid)[1]
  steps_needed <- ceiling(consistent_hours / step_size)
  
  # Zero crossings where slope goes + to -
  sign_changes <- which(diff(sign(deriv)) == -2)
  
  if (length(sign_changes) == 0) return(tibble(
    status     = "No peak yet",
    peak_ttf   = NA_real_,
    peak_score = NA_real_
  ))
  
  # For each candidate crossing, check that the slope stays
  # negative for the next `consistent_hours` hours
  confirmed_crossings <- sign_changes[sapply(sign_changes, function(idx) {
    end_idx <- min(idx + steps_needed, length(deriv))
    all(deriv[(idx + 1):end_idx] < 0)
  })]
  
  if (length(confirmed_crossings) == 0) return(tibble(
    status     = "No peak yet",
    peak_ttf   = NA_real_,
    peak_score = NA_real_
  ))
  
  candidate_ttfs   <- deriv_ttf[confirmed_crossings]
  candidate_scores <- fitted_vals[confirmed_crossings + 1]
  
  # Only consider peaks between -50 and 0
  valid <- which(candidate_ttfs >= -50 & candidate_ttfs <= 0)
  
  if (length(valid) == 0) return(tibble(
    status     = "No peak yet",
    peak_ttf   = NA_real_,
    peak_score = NA_real_
  ))
  
  best <- valid[which.max(candidate_scores[valid])]
  best_crossing <- confirmed_crossings[best]
  
  peak_time <- deriv_ttf[best_crossing]
  
  
  pre_window  <- deriv_ttf >= (peak_time - 2) & deriv_ttf < peak_time
  post_window <- deriv_ttf > peak_time & deriv_ttf <= (peak_time + 2)
  
  pos_slope <- mean(deriv[pre_window], na.rm = TRUE)
  neg_slope <- mean(deriv[post_window], na.rm = TRUE)

  slope_ratio <- abs(neg_slope)/pos_slope
  
  tibble(
    status     = "Peak detected",
    peak_ttf   = candidate_ttfs[best],
    peak_score = candidate_scores[best],
    pos_slope = pos_slope,
    neg_slope = neg_slope,
    slope_ratio = slope_ratio
  )
}

A table with the definition of what constitutes a peak.

In the table there will be sow number, ttf where peak was detected, ttf peak occurance, fitted value of the peak. To help clean up the table more, this is constrained to the times between end of training data and first instance of farrowing. If a peak was not detected before farrowing, it will be stated. The peak is found after 2 hours with a negative slope.

cutoffs <- -49:120

# Apply across all rolling windows for all sows
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.75, consistent_hours = 2)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_75_2_consec

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_75_2_consec <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_75_2_consec %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.75"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.75
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score
2 -20 Peak detected −20.48 0.41
4 -20 Peak detected −24.87 0.47
6 -46 Peak detected −49.69 0.43
8 -17 Peak detected −22.00 0.48
10 -41 Peak detected −41.88 0.43
12 -24 Peak detected −27.86 0.47
14 -25 Peak detected −28.22 0.55
16 -17 Peak detected −20.01 0.54
18 NA No peak detected before farrowing NA NA
22 0 Peak detected −6.42 0.52
24 -11 Peak detected −15.87 0.46
26 -33 Peak detected −39.24 0.41
30 NA No peak detected before farrowing NA NA
32 -19 Peak detected −24.80 0.51

With this table we see that there were two sows that a peak was not detected

The average time that a peak was detected for the sows where a peak was detected before farrowing is as follows

mean1 <- mean(peaktable_span_75_2_consec$window_cutoff, na.rm=TRUE)
mean1
[1] -22.75

With a standard error of:

se1 <- std.error(peaktable_span_75_2_consec$window_cutoff, na.rm=TRUE)
se1
[1] 3.626763

After viewing this, I would like to try a span of 0.5

# Apply across all rolling windows for all sows
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.5, consistent_hours = 2)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_5_2_consec

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_5_2_consec <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_5_2_consec %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score
2 -34 Peak detected −35.66 0.39
4 -22 Peak detected −27.85 0.50
6 -15 Peak detected −19.83 0.52
8 -22 Peak detected −25.65 0.53
10 -42 Peak detected −49.35 0.44
12 -26 Peak detected −32.85 0.50
14 -27 Peak detected −31.52 0.57
16 -38 Peak detected −41.52 0.47
18 -31 Peak detected −32.99 0.43
22 -22 Peak detected −27.01 0.49
24 -15 Peak detected −20.77 0.49
26 -39 Peak detected −41.57 0.44
30 -30 Peak detected −33.62 0.44
32 -28 Peak detected −29.60 0.53

With this table we see that there were no sows that a peak was not detected

The average time that a peak was detected for the sows where a peak was detected before farrowing is as follows

mean2 <- mean(peaktable_span_5_2_consec$window_cutoff, na.rm=TRUE)
mean2
[1] -27.92857

With a standard error of:

se2 <- std.error(peaktable_span_5_2_consec$window_cutoff, na.rm=TRUE)
se2
[1] 2.244564

After comparing these results, on average the first peak was detected closer to the instance of farrowing with a larger span but with a larger standard error. But with a smaller span, you are able to detect a peak for all of these sows.

I will now look at what changes when I have just the first change in slope after training.

This will be with a change in slope for just a single hour

# Apply across all rolling windows for all sows
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.75, consistent_hours = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_75_1_consec

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_75_1_consec <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_75_1_consec %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.75"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.75
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score
2 -20 Peak detected −20.48 0.41
4 -20 Peak detected −24.87 0.47
6 -46 Peak detected −49.69 0.43
8 -17 Peak detected −22.00 0.48
10 -41 Peak detected −41.88 0.43
12 -24 Peak detected −27.86 0.47
14 -25 Peak detected −28.22 0.55
16 -17 Peak detected −20.01 0.54
18 NA No peak detected before farrowing NA NA
22 0 Peak detected −6.42 0.52
24 -11 Peak detected −15.87 0.46
26 -33 Peak detected −39.24 0.41
30 NA No peak detected before farrowing NA NA
32 -19 Peak detected −24.80 0.51

With this table we see that there were two sows that a peak was not detected

The average time that a peak was detected for the sows where a peak was detected before farrowing is as follows

mean3 <- mean(peaktable_span_75_1_consec$window_cutoff, na.rm=TRUE)
mean3
[1] -22.75

With a standard error of:

se3 <- std.error(peaktable_span_75_1_consec$window_cutoff, na.rm=TRUE)
se3
[1] 3.626763

After viewing this, I would like to try a span of 0.5

# Apply across all rolling windows for all sows
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.5, consistent_hours = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_5_1_consec

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_5_1_consec <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_5_1_consec %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score
2 -34 Peak detected −35.66 0.39
4 -22 Peak detected −27.85 0.50
6 -15 Peak detected −19.83 0.52
8 -22 Peak detected −25.65 0.53
10 -42 Peak detected −49.35 0.44
12 -26 Peak detected −32.85 0.50
14 -27 Peak detected −31.52 0.57
16 -38 Peak detected −41.52 0.47
18 -31 Peak detected −32.99 0.43
22 -22 Peak detected −27.01 0.49
24 -15 Peak detected −20.77 0.49
26 -39 Peak detected −41.57 0.44
30 -30 Peak detected −33.62 0.44
32 -28 Peak detected −29.60 0.53

With this table we see that there were no sows that a peak was not detected

The average time that a peak was detected for the sows where a peak was detected before farrowing is as follows

mean4 <- mean(peaktable_span_5_1_consec$window_cutoff, na.rm=TRUE)
mean4
[1] -27.92857

With a standard error of:

se4 <-std.error(peaktable_span_5_1_consec$window_cutoff, na.rm=TRUE)
se4
[1] 2.244564

After comparing the results of 2 hours of consecutive change in slope versus a single change, there seems to be little to no difference in results when loooking at both a span of 0.75 and 0.5.

I will now look at a percentage of change.

For this I will look at the current values of change of what percentage of change it is from the 2 hours leading up to the detected peak with the 2 hours after the peak and see what the ratio of the slopes are to determine if there should be a percentage of change in slope to help predict farrowing.

# Apply across all rolling windows for all sows
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.5, consistent_hours = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_5_2_bef_2_aft

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_5_2_bef_2_aft <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_5_2_bef_2_aft %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 -34 Peak detected −35.66 0.39 0.0006772258 -2.139994e-04 0.3159942
4 -22 Peak detected −27.85 0.50 0.0006189332 -8.022101e-04 1.2961173
6 -15 Peak detected −19.83 0.52 0.0012612155 -9.230702e-04 0.7318894
8 -22 Peak detected −25.65 0.53 0.0012546587 -7.459551e-04 0.5945483
10 -42 Peak detected −49.35 0.44 0.0001861532 -3.562436e-04 1.9137114
12 -26 Peak detected −32.85 0.50 0.0006345351 -7.624947e-04 1.2016588
14 -27 Peak detected −31.52 0.57 0.0008730724 -1.112438e-03 1.2741644
16 -38 Peak detected −41.52 0.47 0.0008520580 -9.995764e-04 1.1731318
18 -31 Peak detected −32.99 0.43 0.0003022456 -7.681721e-05 0.2541549
22 -22 Peak detected −27.01 0.49 0.0010512904 -8.719002e-04 0.8293619
24 -15 Peak detected −20.77 0.49 0.0008971641 -1.114635e-03 1.2423985
26 -39 Peak detected −41.57 0.44 0.0018694529 -1.342590e-03 0.7181726
30 -30 Peak detected −33.62 0.44 0.0007514508 -4.402784e-04 0.5859044
32 -28 Peak detected −29.60 0.53 0.0002673115 -1.070911e-04 0.4006230

The average slope ratio is:

mean5 <- mean(peaktable_span_5_2_bef_2_aft$slope_ratio,na.rm=TRUE)
mean5
[1] 0.8951308

With a standard error of:

se5 <- std.error(peaktable_span_5_2_bef_2_aft$slope_ratio,na.rm=TRUE)
se5
[1] 0.1256379

The summary stats for this is:

summary(peaktable_span_5_2_bef_2_aft$slope_ratio)
   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
 0.2542  0.5881  0.7806  0.8951  1.2322  1.9137 

Now for the span of 0.75

# Apply across all rolling windows for all sows
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.75, consistent_hours = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_75_2_bef_2_aft

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_75_2_bef_2_aft <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_75_2_bef_2_aft %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.75"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.75
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 -20 Peak detected −20.48 0.41 1.104475e-04 -1.144903e-05 0.1036605
4 -20 Peak detected −24.87 0.47 1.050936e-04 -7.662491e-05 0.7291110
6 -46 Peak detected −49.69 0.43 2.146657e-04 -1.567483e-04 0.7301970
8 -17 Peak detected −22.00 0.48 1.729462e-04 -1.141740e-04 0.6601705
10 -41 Peak detected −41.88 0.43 8.095945e-05 -2.229411e-05 0.2753738
12 -24 Peak detected −27.86 0.47 1.203650e-04 -7.824407e-05 0.6500566
14 -25 Peak detected −28.22 0.55 3.240836e-04 -2.342701e-04 0.7228695
16 -17 Peak detected −20.01 0.54 2.579134e-04 -2.262678e-04 0.8773016
18 NA No peak detected before farrowing NA NA NA NA NA
22 0 Peak detected −6.42 0.52 1.400409e-04 -1.278893e-04 0.9132282
24 -11 Peak detected −15.87 0.46 1.351770e-04 -1.223318e-04 0.9049753
26 -33 Peak detected −39.24 0.41 4.567115e-04 -3.779709e-04 0.8275921
30 NA No peak detected before farrowing NA NA NA NA NA
32 -19 Peak detected −24.80 0.51 1.002595e-04 -6.853695e-05 0.6835958

The average slope ratio is:

mean6 <- mean(peaktable_span_75_2_bef_2_aft$slope_ratio,na.rm=TRUE)
mean6
[1] 0.6731777

With a standard error of:

se6 <- std.error(peaktable_span_75_2_bef_2_aft$slope_ratio,na.rm=TRUE)
se6
[1] 0.07119776

Now I will looking at using a threshold from the average slope ratio with the 0.5 span (0.8951308) as a threshold for a peak for the span of 0.5 to see if that helps

redefining function for adding a slope threshold

find_realtime_peak <- function(win, span = 0.75, consistent_hours = 2, min_slope_ratio = 0.8951308) {
  
  win <- win %>%
    filter(!is.na(anomaly_score)) %>%
    arrange(ttf)
  
  if (nrow(win) < 10) return(tibble(
    status      = "Insufficient data",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Fit loess on the window's own data
  fit         <- loess(anomaly_score ~ ttf, data = win, span = span)
  ttf_grid    <- seq(min(win$ttf), max(win$ttf), length.out = 500)
  fitted_vals <- predict(fit, newdata = data.frame(ttf = ttf_grid))
  
  # First derivative
  deriv     <- diff(fitted_vals) / diff(ttf_grid)
  deriv_ttf <- ttf_grid[-1]
  
  # Step size between grid points
  step_size    <- diff(ttf_grid)[1]
  steps_needed <- ceiling(consistent_hours / step_size)
  
  # Zero crossings where slope goes + to -
  sign_changes <- which(diff(sign(deriv)) == -2)
  
  if (length(sign_changes) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # For each candidate crossing, check that the slope stays
  # negative for the next `consistent_hours` hours
  confirmed_crossings <- sign_changes[sapply(sign_changes, function(idx) {
    end_idx <- min(idx + steps_needed, length(deriv))
    all(deriv[(idx + 1):end_idx] < 0)
  })]
  
  if (length(confirmed_crossings) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  candidate_ttfs   <- deriv_ttf[confirmed_crossings]
  candidate_scores <- fitted_vals[confirmed_crossings + 1]
  
  # Only consider peaks between -50 and 0
  valid <- which(candidate_ttfs >= -50 & candidate_ttfs <= 0)
  
  if (length(valid) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # For each valid peak, compute slope ratio and filter by threshold
  valid_with_ratio <- sapply(valid, function(v) {
    best_crossing <- confirmed_crossings[v]
    peak_time <- deriv_ttf[best_crossing]
    
    pre_window  <- deriv_ttf >= (peak_time - 2) & deriv_ttf < peak_time
    post_window <- deriv_ttf > peak_time & deriv_ttf <= (peak_time + 2)
    
    pos_slope <- mean(deriv[pre_window], na.rm = TRUE)
    neg_slope <- mean(deriv[post_window], na.rm = TRUE)
    slope_ratio <- abs(neg_slope) / pos_slope
    
    # Return TRUE if slope ratio meets threshold
    slope_ratio >= min_slope_ratio
  })
  
  valid_peaks <- valid[valid_with_ratio]
  
  if (length(valid_peaks) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Take the highest scoring peak that passes the threshold
  best <- valid_peaks[which.max(candidate_scores[valid_peaks])]
  best_crossing <- confirmed_crossings[best]
  
  peak_time <- deriv_ttf[best_crossing]
  
  pre_window  <- deriv_ttf >= (peak_time - 2) & deriv_ttf < peak_time
  post_window <- deriv_ttf > peak_time & deriv_ttf <= (peak_time + 2)
  
  pos_slope   <- mean(deriv[pre_window], na.rm = TRUE)
  neg_slope   <- mean(deriv[post_window], na.rm = TRUE)
  slope_ratio <- abs(neg_slope) / pos_slope
  
  tibble(
    status      = "Peak detected",
    peak_ttf    = candidate_ttfs[best],
    peak_score  = candidate_scores[best],
    pos_slope   = pos_slope,
    neg_slope   = neg_slope,
    slope_ratio = slope_ratio
  )
}
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.5, consistent_hours = 1, min_slope_ratio = 0.8951308)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_5_2_bef_2_aft_slope_thresh

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_5_2_bef_2_aft_slope_thresh <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_5_2_bef_2_aft_slope_thresh %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5 with a slope ratio threshold of 0.8951308"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5 with a slope ratio threshold of 0.8951308
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 -33 Peak detected −37.57 0.39 0.0005189581 -0.0006835839 1.3172238
4 -22 Peak detected −27.85 0.50 0.0006189332 -0.0008022101 1.2961173
6 -14 Peak detected −19.43 0.52 0.0010945440 -0.0010699308 0.9775128
8 -21 Peak detected −27.80 0.52 0.0009078704 -0.0010553770 1.1624754
10 -42 Peak detected −49.35 0.44 0.0001861532 -0.0003562436 1.9137114
12 -26 Peak detected −32.85 0.50 0.0006345351 -0.0007624947 1.2016588
14 -27 Peak detected −31.52 0.57 0.0008730724 -0.0011124378 1.2741644
16 -38 Peak detected −41.52 0.47 0.0008520580 -0.0009995764 1.1731318
18 -30 Peak detected −37.26 0.42 0.0003411042 -0.0004475044 1.3119287
22 -21 Peak detected −27.55 0.49 0.0008127364 -0.0008853570 1.0893531
24 -15 Peak detected −20.77 0.49 0.0008971641 -0.0011146353 1.2423985
26 -37 Peak detected −42.49 0.44 0.0011826055 -0.0012592069 1.0647734
30 -4 Peak detected −14.78 0.64 0.0007314642 -0.0007306034 0.9988232
32 -27 Peak detected −33.44 0.53 0.0004293402 -0.0005167597 1.2036136

The average slope ratio is:

mean7 <- mean(peaktable_span_5_2_bef_2_aft_slope_thresh$window_cutoff,na.rm=TRUE)
mean7
[1] -25.5

With a standard error of:

se7 <- std.error(peaktable_span_5_2_bef_2_aft_slope_thresh$window_cutoff,na.rm=TRUE)
se7
[1] 2.773006

Looking at this, we can see that with adding a threshold for the slope ratio that it brings the peak detection closer to farrowing.

I will try using a slope ratio threshold of 1 to see what will come of it

realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.5, consistent_hours = 1, min_slope_ratio = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_5_2_bef_2_aft_slope_thresh_1

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_5_2_bef_2_aft_slope_thresh_1 <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_5_2_bef_2_aft_slope_thresh_1 %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5 with a slope ratio threshold of 1"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.5 with a slope ratio threshold of 1
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 -33 Peak detected −37.57 0.39 0.0005189581 -0.0006835839 1.317224
4 -22 Peak detected −27.85 0.50 0.0006189332 -0.0008022101 1.296117
6 -13 Peak detected −22.16 0.51 0.0005374293 -0.0007438780 1.384141
8 -21 Peak detected −27.80 0.52 0.0009078704 -0.0010553770 1.162475
10 -42 Peak detected −49.35 0.44 0.0001861532 -0.0003562436 1.913711
12 -26 Peak detected −32.85 0.50 0.0006345351 -0.0007624947 1.201659
14 -27 Peak detected −31.52 0.57 0.0008730724 -0.0011124378 1.274164
16 -38 Peak detected −41.52 0.47 0.0008520580 -0.0009995764 1.173132
18 -30 Peak detected −37.26 0.42 0.0003411042 -0.0004475044 1.311929
22 -21 Peak detected −27.55 0.49 0.0008127364 -0.0008853570 1.089353
24 -15 Peak detected −20.77 0.49 0.0008971641 -0.0011146353 1.242398
26 -37 Peak detected −42.49 0.44 0.0011826055 -0.0012592069 1.064773
30 NA No peak detected before farrowing NA NA NA NA NA
32 -27 Peak detected −33.44 0.53 0.0004293402 -0.0005167597 1.203614

The average slope ratio is:

mean8 <- mean(peaktable_span_5_2_bef_2_aft_slope_thresh_1$window_cutoff,na.rm=TRUE)
mean8
[1] -27.07692

With a standard error of:

se8 <- std.error(peaktable_span_5_2_bef_2_aft_slope_thresh_1$window_cutoff,na.rm=TRUE)
se8
[1] 2.440212

In changing the slope ratio threshold, there was one sow that was not able detect a peak before farrowing

I will now look at what happens if I leave it the same but decrease the span to 0.35

realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.35, consistent_hours = 2, min_slope_ratio = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_35_2_bef_2_aft_slope_thresh_1

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_35_2_bef_2_aft_slope_thresh_1 <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_35_2_bef_2_aft_slope_thresh_1 %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 -44 Peak detected −47.90 0.35 0.0001992083 -0.0008900545 4.467959
4 -18 Peak detected −31.07 0.53 0.0027603620 -0.0034919395 1.265030
6 -17 Peak detected −24.36 0.55 0.0027341115 -0.0029047010 1.062393
8 -23 Peak detected −30.91 0.56 0.0023902401 -0.0029820883 1.247610
10 -1 Peak detected −20.36 0.56 0.0028221869 -0.0031482878 1.115549
12 -27 Peak detected −34.31 0.53 0.0020321423 -0.0024432422 1.202299
14 -24 Peak detected −33.36 0.58 0.0037125600 -0.0044258562 1.192131
16 -33 Peak detected −43.10 0.47 0.0011093979 -0.0023863348 2.151018
18 -34 Peak detected −40.28 0.43 0.0017909084 -0.0018587694 1.037892
22 -12 Peak detected −30.99 0.52 0.0016482444 -0.0037292226 2.262542
24 -16 Peak detected −23.52 0.53 0.0022347084 -0.0026046539 1.165545
26 -41 Peak detected −45.26 0.45 0.0042152765 -0.0047297776 1.122056
30 -31 Peak detected −37.79 0.45 0.0017834321 -0.0020164562 1.130660
32 -16 Peak detected −35.69 0.55 0.0017286020 -0.0021647178 1.252294

The average slope ratio is:

mean9 <- mean(peaktable_span_35_2_bef_2_aft_slope_thresh_1$window_cutoff,na.rm=TRUE)
mean9
[1] -24.07143

With a standard error of:

se9 <- std.error(peaktable_span_35_2_bef_2_aft_slope_thresh_1$window_cutoff,na.rm=TRUE)
se9
[1] 3.157869

Table with all average peak detection window cutoff with standard error

peak_detection_table <- data.frame(
  table = c('peaktable_span_75_2_consec','peaktable_span_5_2_consec','peaktable_span_75_1_consec','peaktable_span_5_1_consec','peaktable_span_5_2_bef_2_aft_slope_thresh','peaktable_span_5_2_bef_2_aft_slope_thresh_1','peaktable_span_35_2_bef_2_aft_slope_thresh_1'),
  mean = c(mean1,mean2,mean3,mean4,mean7,mean8,mean9),
  se = c(se1,se2,se3,se4,se7,se8,se9)
)
peak_detection_table
                                         table      mean       se
1                   peaktable_span_75_2_consec -22.75000 3.626763
2                    peaktable_span_5_2_consec -27.92857 2.244564
3                   peaktable_span_75_1_consec -22.75000 3.626763
4                    peaktable_span_5_1_consec -27.92857 2.244564
5    peaktable_span_5_2_bef_2_aft_slope_thresh -25.50000 2.773006
6  peaktable_span_5_2_bef_2_aft_slope_thresh_1 -27.07692 2.440212
7 peaktable_span_35_2_bef_2_aft_slope_thresh_1 -24.07143 3.157869

Results

Looking at the results of this table it is noted that two of the tables are not represented as they would be a duplicate of two, but used to find the slope ratio for future use. Looking at the results, we see that the first and third table are the closest to the first birth but are also not detecting peaks for 2 of the sows before farrowing. After that the method in which to detect peaks that is closest to farrowing and a peak detected in all current sows before farrowing is the last method in which a peak was detected after a slope change from positive to negative and was negative for at least 2 hours and the slope change ratio was at least 1 using fitted values from a loess function with a span of 0.35.

Now we will make sure that there are no early detections of peaks by adding a rule where it cannot be detected until 10 hours after farrowing

redefining function for a peak not to be detected until 10 hours after training was completed.

find_realtime_peak <- function(win, span = 0.75, consistent_hours = 2, min_slope_ratio = 0.8951308) {
  
  win <- win %>%
    filter(!is.na(anomaly_score)) %>%
    arrange(ttf)
  
  if (nrow(win) < 10) return(tibble(
    status      = "Insufficient data",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Fit loess on the window's own data
  fit         <- loess(anomaly_score ~ ttf, data = win, span = span)
  ttf_grid    <- seq(min(win$ttf), max(win$ttf), length.out = 500)
  fitted_vals <- predict(fit, newdata = data.frame(ttf = ttf_grid))
  
  # First derivative
  deriv     <- diff(fitted_vals) / diff(ttf_grid)
  deriv_ttf <- ttf_grid[-1]
  
  # Step size between grid points
  step_size    <- diff(ttf_grid)[1]
  steps_needed <- ceiling(consistent_hours / step_size)
  
  # Zero crossings where slope goes + to -
  sign_changes <- which(diff(sign(deriv)) == -2)
  
  if (length(sign_changes) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # For each candidate crossing, check that the slope stays
  # negative for the next `consistent_hours` hours
  confirmed_crossings <- sign_changes[sapply(sign_changes, function(idx) {
    end_idx <- min(idx + steps_needed, length(deriv))
    all(deriv[(idx + 1):end_idx] < 0)
  })]
  
  if (length(confirmed_crossings) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  candidate_ttfs   <- deriv_ttf[confirmed_crossings]
  candidate_scores <- fitted_vals[confirmed_crossings + 1]
  
  # Only consider peaks between -50 and 0
  valid <- which(candidate_ttfs >= -40 & candidate_ttfs <= 0)
  
  if (length(valid) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # For each valid peak, compute slope ratio and filter by threshold
  valid_with_ratio <- sapply(valid, function(v) {
    best_crossing <- confirmed_crossings[v]
    peak_time <- deriv_ttf[best_crossing]
    
    pre_window  <- deriv_ttf >= (peak_time - 2) & deriv_ttf < peak_time
    post_window <- deriv_ttf > peak_time & deriv_ttf <= (peak_time + 2)
    
    pos_slope <- mean(deriv[pre_window], na.rm = TRUE)
    neg_slope <- mean(deriv[post_window], na.rm = TRUE)
    slope_ratio <- abs(neg_slope) / pos_slope
    
    # Return TRUE if slope ratio meets threshold
    slope_ratio >= min_slope_ratio
  })
  
  valid_peaks <- valid[valid_with_ratio]
  
  if (length(valid_peaks) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Take the highest scoring peak that passes the threshold
  best <- valid_peaks[which.max(candidate_scores[valid_peaks])]
  best_crossing <- confirmed_crossings[best]
  
  peak_time <- deriv_ttf[best_crossing]
  
  pre_window  <- deriv_ttf >= (peak_time - 2) & deriv_ttf < peak_time
  post_window <- deriv_ttf > peak_time & deriv_ttf <= (peak_time + 2)
  
  pos_slope   <- mean(deriv[pre_window], na.rm = TRUE)
  neg_slope   <- mean(deriv[post_window], na.rm = TRUE)
  slope_ratio <- abs(neg_slope) / pos_slope
  
  tibble(
    status      = "Peak detected",
    peak_ttf    = candidate_ttfs[best],
    peak_score  = candidate_scores[best],
    pos_slope   = pos_slope,
    neg_slope   = neg_slope,
    slope_ratio = slope_ratio
  )
}
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.35, consistent_hours = 2, min_slope_ratio = 1)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1 and cannot be detected until 10 hours after training"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1 and cannot be detected until 10 hours after training
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 -36 Peak detected −39.51 0.40 0.001883733 -0.002142726 1.137490
4 -18 Peak detected −31.07 0.53 0.002760362 -0.003491940 1.265030
6 -17 Peak detected −24.36 0.55 0.002734111 -0.002904701 1.062393
8 -23 Peak detected −30.91 0.56 0.002390240 -0.002982088 1.247610
10 -1 Peak detected −20.36 0.56 0.002822187 -0.003148288 1.115549
12 -27 Peak detected −34.31 0.53 0.002032142 -0.002443242 1.202299
14 -24 Peak detected −33.36 0.58 0.003712560 -0.004425856 1.192131
16 -23 Peak detected −28.35 0.56 0.002507646 -0.003067844 1.223396
18 -32 Peak detected −38.43 0.44 0.001349397 -0.001358858 1.007011
22 -12 Peak detected −30.99 0.52 0.001648244 -0.003729223 2.262542
24 -16 Peak detected −23.52 0.53 0.002234708 -0.002604654 1.165545
26 -6 Peak detected −12.89 0.61 0.003758893 -0.003892039 1.035422
30 -31 Peak detected −37.79 0.45 0.001783432 -0.002016456 1.130660
32 -16 Peak detected −35.69 0.55 0.001728602 -0.002164718 1.252294

The average slope ratio is:

mean10 <- mean(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training$window_cutoff,na.rm=TRUE)
mean10
[1] -20.14286

With a standard error of:

se10 <- std.error(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training$window_cutoff,na.rm=TRUE)
se10
[1] 2.641297

Average anomaly score:

mean_peak_10 <- mean(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training$peak_score,na.rm=TRUE)
mean_peak_10
[1] 0.5256725

Table with all average peak detection window cutoff with standard error

peak_detection_table <- data.frame(
  method = c('peaktable_span_75_2_consec','peaktable_span_5_2_consec','peaktable_span_75_1_consec','peaktable_span_5_1_consec','peaktable_span_5_2_bef_2_aft_slope_thresh','peaktable_span_5_2_bef_2_aft_slope_thresh_1','peaktable_span_35_2_bef_2_aft_slope_thresh_1','peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training'),
  mean = c(mean1,mean2,mean3,mean4,mean7,mean8,mean9,mean10),
  se = c(se1,se2,se3,se4,se7,se8,se9,se10)
)
peak_detection_table
                                                          method      mean
1                                     peaktable_span_75_2_consec -22.75000
2                                      peaktable_span_5_2_consec -27.92857
3                                     peaktable_span_75_1_consec -22.75000
4                                      peaktable_span_5_1_consec -27.92857
5                      peaktable_span_5_2_bef_2_aft_slope_thresh -25.50000
6                    peaktable_span_5_2_bef_2_aft_slope_thresh_1 -27.07692
7                   peaktable_span_35_2_bef_2_aft_slope_thresh_1 -24.07143
8 peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training -20.14286
        se
1 3.626763
2 2.244564
3 3.626763
4 2.244564
5 2.773006
6 2.440212
7 3.157869
8 2.641297

Results

Now looking at the results after adding a rule of detection 10 hours after training was complete, the average time of where a peak was detected is now 20 hours before farrowing.

Now i want to look at what happens if we add a threshold for the anomaly score

I will use the most recent average anomaly score as the threshold to see how that works

Redefining the function to add a threshold of anomaly score

find_realtime_peak <- function(
  win,
  span = 0.75,
  consistent_hours = 2,
  min_slope_ratio = 0.8951308,
  min_peak_score = 1.5   # NEW anomaly score threshold
) {
  
  win <- win %>%
    filter(!is.na(anomaly_score)) %>%
    arrange(ttf)
  
  if (nrow(win) < 10) return(tibble(
    status      = "Insufficient data",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Fit loess
  fit         <- loess(anomaly_score ~ ttf, data = win, span = span)
  ttf_grid    <- seq(min(win$ttf), max(win$ttf), length.out = 500)
  fitted_vals <- predict(fit, newdata = data.frame(ttf = ttf_grid))
  
  # First derivative
  deriv     <- diff(fitted_vals) / diff(ttf_grid)
  deriv_ttf <- ttf_grid[-1]
  
  step_size    <- diff(ttf_grid)[1]
  steps_needed <- ceiling(consistent_hours / step_size)
  
  # Detect + to - zero crossings
  sign_changes <- which(diff(sign(deriv)) == -2)
  
  if (length(sign_changes) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Confirm sustained negative slope
  confirmed_crossings <- sign_changes[sapply(sign_changes, function(idx) {
    end_idx <- min(idx + steps_needed, length(deriv))
    all(deriv[(idx + 1):end_idx] < 0)
  })]
  
  if (length(confirmed_crossings) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  candidate_ttfs   <- deriv_ttf[confirmed_crossings]
  candidate_scores <- fitted_vals[confirmed_crossings + 1]
  
  # Filter by time window AND anomaly score threshold
  valid <- which(
    candidate_ttfs >= -40 &
    candidate_ttfs <= 0 &
    candidate_scores >= min_peak_score
  )
  
  if (length(valid) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Check slope ratio for each valid peak
  valid_with_ratio <- sapply(valid, function(v) {
    
    crossing_idx <- confirmed_crossings[v]
    peak_time    <- deriv_ttf[crossing_idx]
    
    pre_window  <- deriv_ttf >= (peak_time - consistent_hours) &
                   deriv_ttf <  peak_time
    post_window <- deriv_ttf >  peak_time &
                   deriv_ttf <= (peak_time + consistent_hours)
    
    pos_slope <- mean(deriv[pre_window], na.rm = TRUE)
    neg_slope <- mean(deriv[post_window], na.rm = TRUE)
    
    if (is.na(pos_slope) || pos_slope <= 0) return(FALSE)
    
    slope_ratio <- abs(neg_slope) / pos_slope
    
    slope_ratio >= min_slope_ratio
  })
  
  valid_peaks <- valid[valid_with_ratio]
  
  if (length(valid_peaks) == 0) return(tibble(
    status      = "No peak yet",
    peak_ttf    = NA_real_,
    peak_score  = NA_real_,
    pos_slope   = NA_real_,
    neg_slope   = NA_real_,
    slope_ratio = NA_real_
  ))
  
  # Select highest anomaly score peak
  best_idx <- valid_peaks[which.max(candidate_scores[valid_peaks])]
  crossing_idx <- confirmed_crossings[best_idx]
  
  peak_time <- deriv_ttf[crossing_idx]
  
  pre_window  <- deriv_ttf >= (peak_time - consistent_hours) &
                 deriv_ttf <  peak_time
  post_window <- deriv_ttf >  peak_time &
                 deriv_ttf <= (peak_time + consistent_hours)
  
  pos_slope <- mean(deriv[pre_window], na.rm = TRUE)
  neg_slope <- mean(deriv[post_window], na.rm = TRUE)
  
  slope_ratio <- ifelse(
    is.na(pos_slope) || pos_slope <= 0,
    NA_real_,
    abs(neg_slope) / pos_slope
  )
  
  tibble(
    status      = "Peak detected",
    peak_ttf    = candidate_ttfs[best_idx],
    peak_score  = candidate_scores[best_idx],
    pos_slope   = pos_slope,
    neg_slope   = neg_slope,
    slope_ratio = slope_ratio
  )
}
realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.35, consistent_hours = 2, min_slope_ratio = 1, min_peak_score=mean_peak_10)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1 and cannot be detected until 10 hours after training with an anomaly threshold of the average anomaly score of 0.5256725"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1 and cannot be detected until 10 hours after training with an anomaly threshold of the average anomaly score of 0.5256725
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 0 Peak detected −8.21 0.61 0.0029615118 -0.0029969923 1.011981
4 -18 Peak detected −31.07 0.53 0.0027603620 -0.0034919395 1.265030
6 -17 Peak detected −24.36 0.55 0.0027341115 -0.0029047010 1.062393
8 -23 Peak detected −30.91 0.56 0.0023902401 -0.0029820883 1.247610
10 -1 Peak detected −20.36 0.56 0.0028221869 -0.0031482878 1.115549
12 -27 Peak detected −34.31 0.53 0.0020321423 -0.0024432422 1.202299
14 -24 Peak detected −33.36 0.58 0.0037125600 -0.0044258562 1.192131
16 -23 Peak detected −28.35 0.56 0.0025076462 -0.0030678437 1.223396
18 NA No peak detected before farrowing NA NA NA NA NA
22 -1 Peak detected −10.98 0.53 0.0007870169 -0.0009210024 1.170245
24 -16 Peak detected −23.52 0.53 0.0022347084 -0.0026046539 1.165545
26 -6 Peak detected −12.89 0.61 0.0037588930 -0.0038920393 1.035422
30 -6 Peak detected −15.73 0.67 0.0015890592 -0.0016001284 1.006966
32 -16 Peak detected −35.69 0.55 0.0017286020 -0.0021647178 1.252294

The average slope ratio is:

mean11 <- mean(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh$window_cutoff,na.rm=TRUE)
mean11
[1] -13.69231

With a standard error of:

se11 <- std.error(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh$window_cutoff,na.rm=TRUE)
se11
[1] 2.685155

Using this method of a threshold for anomaly score works very well except that there was a single sow that a peak was not detected using the set of rules.

I will now look at a threshold lower at 0.5

realtime_table <- nested_df_of_selected %>%
  select(sow, rolling_scored) %>%
  mutate(
    window_results = map(
      rolling_scored,
      ~ map2_dfr(
          .x,
          cutoffs,
          function(win, cut) {
            if (is.null(win) || nrow(win) < 10) {
              return(tibble(
                window_cutoff = cut,
                status        = "Insufficient data",
                peak_ttf      = NA_real_,
                peak_score    = NA_real_,
                pos_slope     = NA_real_,
                neg_slope     = NA_real_,
                slope_ratio   = NA_real_
              ))
            }
            
            result <- find_realtime_peak(win, span = 0.35, consistent_hours = 2, min_slope_ratio = 1, min_peak_score= 0.5)
            result %>% mutate(window_cutoff = cut, .before = 1)
          }
        )
    )
  ) %>%
  select(sow, window_results) %>%
  unnest(window_results)

peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5

# Get first detected peak per sow (first window where peak is confirmed)
peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5 <- realtime_table %>%
  group_by(sow) %>%
  arrange(window_cutoff, .by_group = TRUE) %>%
  # Find first window where a peak was detected before farrowing
  filter(window_cutoff <= 0) %>%
  summarise(
    first_peak_row = {
      detected <- which(status == "Peak detected")
      if (length(detected) == 0) {
        tibble(
          window_cutoff = NA_integer_,
          status        = "No peak detected before farrowing",
          peak_ttf      = NA_real_,
          peak_score    = NA_real_,
          pos_slope     = NA_real_,
          neg_slope     = NA_real_,
          slope_ratio   = NA_real_
        )
      } else {
        slice(cur_data()[detected[1], ], 1) %>%
          select(window_cutoff, status, peak_ttf, peak_score, pos_slope, neg_slope, slope_ratio)
      }
    },
    .groups = "drop"
  ) %>%
  unnest(first_peak_row)

# Display table
peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5 %>%
  gt() %>%
  tab_header(
    title    = "First Pre-Farrowing Peak Detected per Sow",
    subtitle = "First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1 and cannot be detected until 10 hours after training with an anomaly score threshold of 0.5"
  ) %>%
  fmt_number(
    columns  = c(peak_ttf, peak_score),
    decimals = 2,
    rows     = !is.na(peak_ttf)
  ) %>%
  cols_label(
    sow           = "Sow",
    window_cutoff = "Window Cutoff (TTF)",
    status        = "Status",
    peak_ttf      = "Peak TTF (hrs)",
    peak_score    = "Peak Anomaly Score",
    pos_slope     = "Slope before peak",
    neg_slope     = "Slope after peak",
    slope_ratio   = "Slope ratio ((|Slope after|)/(Slope before))"
  ) %>%
  tab_style(
    style     = cell_fill(color = "lightgreen"),
    locations = cells_body(rows = status == "Peak detected")
  ) %>%
  tab_style(
    style     = cell_fill(color = "salmon"),
    locations = cells_body(rows = status == "No peak detected before farrowing")
  ) %>%
  tab_style(
    style     = cell_text(weight = "bold"),
    locations = cells_column_labels()
  )
First Pre-Farrowing Peak Detected per Sow
First rolling window (TTF -50 to 0) where loess slope consistently changes + to - for 2 hours with a span of 0.35 with a slope ratio threshold of 1 and cannot be detected until 10 hours after training with an anomaly score threshold of 0.5
Sow Window Cutoff (TTF) Status Peak TTF (hrs) Peak Anomaly Score Slope before peak Slope after peak Slope ratio ((|Slope after|)/(Slope before))
2 0 Peak detected −8.21 0.61 0.002961512 -0.002996992 1.011981
4 -18 Peak detected −31.07 0.53 0.002760362 -0.003491940 1.265030
6 -17 Peak detected −24.36 0.55 0.002734111 -0.002904701 1.062393
8 -23 Peak detected −30.91 0.56 0.002390240 -0.002982088 1.247610
10 -1 Peak detected −20.36 0.56 0.002822187 -0.003148288 1.115549
12 -27 Peak detected −34.31 0.53 0.002032142 -0.002443242 1.202299
14 -24 Peak detected −33.36 0.58 0.003712560 -0.004425856 1.192131
16 -23 Peak detected −28.35 0.56 0.002507646 -0.003067844 1.223396
18 -3 Peak detected −11.19 0.51 0.001098335 -0.001248475 1.136698
22 -12 Peak detected −30.99 0.52 0.001648244 -0.003729223 2.262542
24 -16 Peak detected −23.52 0.53 0.002234708 -0.002604654 1.165545
26 -6 Peak detected −12.89 0.61 0.003758893 -0.003892039 1.035422
30 -6 Peak detected −15.73 0.67 0.001589059 -0.001600128 1.006966
32 -16 Peak detected −35.69 0.55 0.001728602 -0.002164718 1.252294

The average slope ratio is:

mean12 <- mean(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5$window_cutoff,na.rm=TRUE)
mean12
[1] -13.71429

With a standard error of:

se12 <- std.error(peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5$window_cutoff,na.rm=TRUE)
se12
[1] 2.43696

In changing that slope threshold slightly lower, we are able to get a peak detected on all sows with an average of ~14 hours before farrowing

peak_detection_table <- data.frame(
  table = c('peaktable_span_75_2_consec','peaktable_span_5_2_consec','peaktable_span_75_1_consec','peaktable_span_5_1_consec','peaktable_span_5_2_bef_2_aft_slope_thresh','peaktable_span_5_2_bef_2_aft_slope_thresh_1','peaktable_span_35_2_bef_2_aft_slope_thresh_1','peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training','peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh','peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5'),
  mean = c(mean1,mean2,mean3,mean4,mean7,mean8,mean9,mean10,mean11,mean12),
  se = c(se1,se2,se3,se4,se7,se8,se9,se10,se11,se12)
)
peak_detection_table
                                                                          table
1                                                    peaktable_span_75_2_consec
2                                                     peaktable_span_5_2_consec
3                                                    peaktable_span_75_1_consec
4                                                     peaktable_span_5_1_consec
5                                     peaktable_span_5_2_bef_2_aft_slope_thresh
6                                   peaktable_span_5_2_bef_2_aft_slope_thresh_1
7                                  peaktable_span_35_2_bef_2_aft_slope_thresh_1
8                peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training
9    peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh
10 peaktable_span_35_2_bef_2_aft_slope_thresh_1_10_after_training_anom_thresh_5
        mean       se
1  -22.75000 3.626763
2  -27.92857 2.244564
3  -22.75000 3.626763
4  -27.92857 2.244564
5  -25.50000 2.773006
6  -27.07692 2.440212
7  -24.07143 3.157869
8  -20.14286 2.641297
9  -13.69231 2.685155
10 -13.71429 2.436960

Now with adding the threshold of anomaly score we are able to detect a peak with all sows on average ~14 (+-2.436) hours before farrowing

Parameters used for a peak to be detected: