What this report does

This Level 1 CMJ Readiness Report is designed to answer one question:

Is the athlete neuromuscularly ready today?

It uses Typical Error (TE) and Smallest Worthwhile Change (SWC) rather than population SD to flag meaningful changes.

Metrics (Level 1)

  • Outcome: Jump Height (Impulse–Momentum)
  • Capacity: Concentric Impulse
  • Quality: RSI-mod
  • Context: Days since last jump, tests in baseline window

1) Load packages

library(tidyverse)
library(lubridate)
library(formattable)

2) Parameters

Edit these in the YAML header under params: (recommended), or override when knitting.

file_path     <- params$file_path
lookback_days <- params$lookback_days
min_tests     <- params$min_tests

monitor_te <- params$monitor_te
flag_te    <- params$flag_te

good_col <- "#11cc47"
warn_col <- "#e6a500"
bad_col  <- "#aa1818"

3) Load + standardize data

Required columns (exact names): - Name - Date (MM/DD/YYYY) - Jump Height (Imp-Mom) [cm] - RSI-modified [m/s] - Concentric Impulse [N*s]

cmj <- read_csv(file_path, show_col_types = FALSE) %>%
  mutate(Date = mdy(Date)) %>%     # ForceDecks export typically MM/DD/YYYY
  arrange(Name, Date) %>%
  rename(
    Jump_Height = `Jump Height (Imp-Mom) [cm]`,
    RSI_Mod     = `RSI-modified [m/s]`,
    Con_Impulse = `Concentric Impulse [N*s]`
  )

metrics <- c("Jump_Height", "Con_Impulse", "RSI_Mod")

missing_metrics <- setdiff(metrics, names(cmj))
if (length(missing_metrics) > 0) {
  stop(paste("Missing required metric columns:", paste(missing_metrics, collapse = ", ")))
}

4) Latest test per athlete + context

latest <- cmj %>%
  group_by(Name) %>%
  slice_max(Date, n = 1, with_ties = FALSE) %>%
  ungroup() %>%
  mutate(Days_Since_Last_Jump = as.integer(Sys.Date() - Date))

5) Athlete-specific lookback window (excludes latest)

window <- cmj %>%
  group_by(Name) %>%
  mutate(Latest_Date = max(Date, na.rm = TRUE)) %>%
  filter(
    Date > Latest_Date - days(lookback_days),
    Date < Latest_Date
  ) %>%
  ungroup()

6) Typical Error (TE) and baselines

Typical Error (TE) is computed from successive test-to-test differences:

\[ TE = \frac{SD(\Delta)}{\sqrt{2}} \]

calc_te <- function(x) {
  x <- x[!is.na(x)]
  if (length(x) < 3) return(NA_real_)   # need at least 3 tests for diff-based TE
  sd(diff(x)) / sqrt(2)
}
# Baselines in WIDE form (mean/sd/te per metric)
baseline_wide <- window %>%
  group_by(Name) %>%
  summarise(
    Tests_in_Window = n(),
    across(
      all_of(metrics),
      list(
        mean = ~mean(.x, na.rm = TRUE),
        sd   = ~sd(.x, na.rm = TRUE),
        te   = ~calc_te(.x)
      ),
      .names = "{.col}_{.fn}"
    ),
    .groups = "drop"
  )

# Convert to LONG form for safe joins (one row per athlete x metric)
baseline_long <- baseline_wide %>%
  pivot_longer(
    cols = -c(Name, Tests_in_Window),
    names_to = c("Metric", ".value"),
    names_pattern = "^(.*)_(mean|sd|te)$"
  )

7) Compute SWC, TE-score, and severity tiers

SWC is set to:

\[ SWC = 0.2 \times SD \]

Severity tiers: - Stable: within SWC / within noise - Monitor: ≥ monitor_te TE - Flag: ≥ flag_te TE - Insufficient: not enough baseline data

# Latest values in long form
latest_long <- latest %>%
  select(Name, Date, Days_Since_Last_Jump, all_of(metrics)) %>%
  pivot_longer(
    cols = all_of(metrics),
    names_to = "Metric",
    values_to = "Latest"
  )

# Join baselines and compute change + severity
report_long <- latest_long %>%
  left_join(baseline_long, by = c("Name", "Metric")) %>%
  mutate(
    Change = Latest - mean,
    SWC    = 0.2 * sd,
    TE_Z   = Change / te,
    TE_Abs = abs(TE_Z),

    Severity = case_when(
      is.na(Tests_in_Window) | Tests_in_Window < min_tests ~ "Insufficient",
      is.na(te) | te <= 0 ~ "Insufficient",
      is.na(SWC) | SWC <= 0 ~ "Insufficient",
      abs(Change) < SWC ~ "Stable",
      TE_Abs < monitor_te ~ "Stable",
      TE_Abs < flag_te ~ "Monitor",
      TRUE ~ "Flag"
    )
  )

8) Final Level 1 table

final <- report_long %>%
  mutate(
    Metric = recode(
      Metric,
      Jump_Height = "Outcome — Jump Height (cm)",
      Con_Impulse = "Capacity — Concentric Impulse (N·s)",
      RSI_Mod     = "Quality — RSI-mod"
    )
  ) %>%
  select(
    Name, Date, Days_Since_Last_Jump, Tests_in_Window,
    Metric, Latest, TE_Z, Severity
  ) %>%
  pivot_wider(
    names_from = Metric,
    values_from = c(Latest, TE_Z, Severity),
    names_glue = "{Metric} — { .value }"
  ) %>%
  arrange(Days_Since_Last_Jump, Name)

# Round numeric columns for readability
num_cols <- names(final)[map_lgl(final, is.numeric)]
final[num_cols] <- lapply(final[num_cols], function(x) round(x, 2))

final
## # A tibble: 8 × 13
##   Name    Date       Days_Since_Last_Jump Tests_in_Window Outcome — Jump Heigh…¹
##   <chr>   <date>                    <dbl>           <dbl>                  <dbl>
## 1 Athlet… 2025-11-30                   22               9                   39.1
## 2 Athlet… 2025-11-30                   22               9                   46.9
## 3 Athlet… 2025-11-30                   22               9                   43.9
## 4 Athlet… 2025-11-30                   22               9                   58.0
## 5 Athlet… 2025-11-30                   22               9                   49.1
## 6 Athlet… 2025-11-30                   22               9                   42.9
## 7 Athlet… 2025-11-30                   22               9                   54.2
## 8 Athlet… 2025-11-30                   22               9                   55.0
## # ℹ abbreviated name: ¹​`Outcome — Jump Height (cm) — Latest`
## # ℹ 8 more variables: `Capacity — Concentric Impulse (N·s) — Latest` <dbl>,
## #   `Quality — RSI-mod — Latest` <dbl>,
## #   `Outcome — Jump Height (cm) — TE_Z` <dbl>,
## #   `Capacity — Concentric Impulse (N·s) — TE_Z` <dbl>,
## #   `Quality — RSI-mod — TE_Z` <dbl>,
## #   `Outcome — Jump Height (cm) — Severity` <chr>, …

9) Render formatted output

severity_cols <- names(final)[stringr::str_detect(names(final), "Severity$")]

fmt <- lapply(severity_cols, function(col) {
  formatter(
    "span",
    style = x ~ ifelse(
      x == "Flag", style(color = bad_col, font.weight = "bold"),
      ifelse(x == "Monitor", style(color = warn_col, font.weight = "bold"),
             ifelse(x == "Stable", style(color = good_col), style(color = "gray50")))
    )
  )
})
names(fmt) <- severity_cols

formattable(
  final,
  align = "c",
  row.names = FALSE,
  fmt
)
Name Date Days_Since_Last_Jump Tests_in_Window Outcome — Jump Height (cm) — Latest Capacity — Concentric Impulse (N·s) — Latest Quality — RSI-mod — Latest Outcome — Jump Height (cm) — TE_Z Capacity — Concentric Impulse (N·s) — TE_Z Quality — RSI-mod — TE_Z Outcome — Jump Height (cm) — Severity Capacity — Concentric Impulse (N·s) — Severity Quality — RSI-mod — Severity
Athlete A 2025-11-30 22 9 39.11 240.7 0.67 -2.50 -1.35 -0.30 Flag Monitor Stable
Athlete B 2025-11-30 22 9 46.89 257.0 0.65 -0.23 1.76 0.25 Stable Monitor Stable
Athlete C 2025-11-30 22 9 43.87 275.0 0.59 -4.43 -3.84 -1.51 Flag Flag Monitor
Athlete D 2025-11-30 22 9 57.96 220.4 0.71 2.11 -1.63 -0.81 Flag Monitor Stable
Athlete E 2025-11-30 22 9 49.12 297.0 0.52 1.49 -1.16 0.66 Monitor Monitor Stable
Athlete F 2025-11-30 22 9 42.86 272.5 0.66 0.32 0.23 -0.84 Stable Stable Stable
Athlete G 2025-11-30 22 9 54.21 220.6 0.59 -0.64 0.58 -0.57 Stable Stable Stable
Athlete H 2025-11-30 22 9 55.02 297.1 0.43 -0.88 1.25 -1.28 Stable Monitor Monitor

Troubleshooting

  • If you see Insufficient for many athletes, expand lookback_days or ensure each athlete has at least min_tests tests prior to the latest test.
  • If your ForceDecks export uses different column names, adjust the rename() step in section 3.