Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .Rbuildignore
Original file line number Diff line number Diff line change
Expand Up @@ -146,3 +146,5 @@
^lib$
^strava_data$

^data-raw$
^generate_plot_examples\.R$
3 changes: 1 addition & 2 deletions DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: Athlytics
Title: Academic R Package for Sports Physiology Analysis from Local 'Strava' Data
Version: 1.0.0
Version: 1.0.1
Author: Zhiang He [aut, cre]
Maintainer: Zhiang He <[email protected]>
Authors@R:
Expand All @@ -27,7 +27,6 @@ Imports:
rlang (>= 0.4.0),
scales,
tidyr,
viridis,
zoo,
R.utils,
tools,
Expand Down
1 change: 0 additions & 1 deletion NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,5 @@ importFrom(tidyr,pivot_wider)
importFrom(tidyr,unnest)
importFrom(tools,toTitleCase)
importFrom(utils,read.csv)
importFrom(viridis,scale_color_viridis)
importFrom(zoo,rollapply)
importFrom(zoo,rollmean)
16 changes: 15 additions & 1 deletion NEWS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
# Athlytics 1.0.0
# Athlytics 1.0.1

## Code Quality Improvements

* **Reduced Cyclomatic Complexity**: Refactored `calculate_acwr()` and `calculate_exposure()` by extracting shared load calculation logic into internal helper functions (`calculate_daily_load_internal()`, `compute_single_load()`, `validate_load_metric_params()`). This improves code maintainability and testability without changing the public API.

* **Dependency Cleanup**: Removed unused `viridis` package from Imports. The package was declared as a dependency but never actually called (ggplot2's built-in `scale_color_viridis_d()` was used instead).

* **Documentation Fixes**: Fixed Rd line width issues in `plot_with_reference()` examples.

* **Build Configuration**: Updated `.Rbuildignore` to properly exclude development files.

---

# Athlytics 1.0.0

This major release transitions from Strava API to **local data export processing**, prioritizing user privacy and data ownership while eliminating API rate limits and authentication requirements.

Expand Down
95 changes: 12 additions & 83 deletions R/calculate_acwr.R
Original file line number Diff line number Diff line change
Expand Up @@ -195,11 +195,9 @@ calculate_acwr <- function(activities_data,
if (!is.numeric(acute_period) || acute_period <= 0) stop("`acute_period` must be a positive integer.")
if (!is.numeric(chronic_period) || chronic_period <= 0) stop("`chronic_period` must be a positive integer.")
if (acute_period >= chronic_period) stop("`acute_period` must be less than `chronic_period`.")
if (load_metric == "tss" && is.null(user_ftp)) stop("`user_ftp` is required when `load_metric` is 'tss'.")

valid_load_metrics <- c("duration_mins", "distance_km", "elapsed_time_mins", "tss", "hrss", "elevation_gain_m")
if (!load_metric %in% valid_load_metrics) stop("Invalid `load_metric`. Choose from: ", paste(valid_load_metrics, collapse = ", "))
if (load_metric == "hrss" && (is.null(user_max_hr) || is.null(user_resting_hr))) stop("`user_max_hr` and `user_resting_hr` are required when `load_metric` is 'hrss'.")

# Validate load metric parameters using internal helper
validate_load_metric_params(load_metric, user_ftp, user_max_hr, user_resting_hr)

# Force explicit activity_type specification to prevent mixing incompatible sports
if (is.null(activity_type) || length(activity_type) == 0) {
Expand Down Expand Up @@ -238,84 +236,15 @@ calculate_acwr <- function(activities_data,
stop("No activities found in local data for the required date range (", fetch_start_date, " to ", analysis_end_date,").")
}

# --- Process Activities into Daily Load ---
`%||%` <- function(x, y) if (is.null(x) || length(x) == 0) y else x
safe_as_numeric <- function(x) { as.numeric(x %||% 0) }

# Convert data frame to list format for processing
daily_load_df <- purrr::map_dfr(1:nrow(activities_df_filtered), function(i) {
activity <- activities_df_filtered[i, ]
# Get activity date and type
activity_date <- activity$date
act_type <- activity$type %||% "Unknown"

# Extract metrics from data frame columns
duration_sec <- safe_as_numeric(activity$moving_time)
distance_m <- safe_as_numeric(activity$distance)
elapsed_sec <- safe_as_numeric(activity$elapsed_time)
avg_hr <- safe_as_numeric(activity$average_heartrate)
avg_power <- safe_as_numeric(activity$average_watts)
elevation_gain <- safe_as_numeric(activity$elevation_gain)
# Use weighted_average_watts if available, otherwise average_watts
np_proxy <- safe_as_numeric(activity$weighted_average_watts %||% activity$average_watts %||% 0)
# message(sprintf(" Duration: %.0f sec", duration_sec))

# --- Added Debugging and Refined Logic ---
# message(sprintf(" Inputs check: load_metric='%s', duration_sec=%.1f, distance_m=%.1f, avg_hr=%.1f, np_proxy=%.1f, user_ftp=%s, user_max_hr=%s, user_resting_hr=%s",
# load_metric, duration_sec, distance_m, avg_hr, np_proxy,
# deparse(user_ftp), deparse(user_max_hr), deparse(user_resting_hr)))

if (duration_sec > 0) {
# Initialize load_value outside case_when to handle default case cleanly
load_value <- 0

if (load_metric == "duration_mins") {
load_value <- duration_sec / 60
} else if (load_metric == "distance_km") {
load_value <- distance_m / 1000
} else if (load_metric == "elapsed_time_mins") {
load_value <- elapsed_sec / 60
} else if (load_metric == "elevation_gain_m") {
load_value <- elevation_gain
} else if (load_metric == "hrss") {
# Check required HR parameters before calculating
if (!is.null(user_max_hr) && !is.null(user_resting_hr) && is.numeric(user_max_hr) && is.numeric(user_resting_hr) &&
user_max_hr > user_resting_hr && avg_hr > user_resting_hr && avg_hr <= user_max_hr) {
hr_reserve <- user_max_hr - user_resting_hr
avg_hr_rel <- (avg_hr - user_resting_hr) / hr_reserve
load_value <- (duration_sec / 60) * avg_hr_rel # Simplified TRIMP
} else {
# message(" Skipping HRSS calculation: Missing/invalid HR parameters or avg_hr out of range.")
}
} else if (load_metric == "tss") {
# Check required FTP parameter before calculating
if (!is.null(user_ftp) && is.numeric(user_ftp) && user_ftp > 0 && np_proxy > 0) {
intensity_factor <- np_proxy / user_ftp
load_value <- (duration_sec * np_proxy * intensity_factor) / (user_ftp * 3600) * 100
} else {
# message(" Skipping TSS calculation: Missing/invalid FTP or power data (np_proxy).")
}
}

# message(sprintf(" Calculated load_value: %.2f", load_value))
} else {
# message(" Duration <= 0, load is 0.")
load_value <- 0 # Define load_value even if duration is 0
}

if (!is.na(load_value) && load_value > 0) {
# message(" -> Activity PASSED filters, returning load data.")
data.frame(
date = activity_date,
load = load_value,
stringsAsFactors = FALSE
)
} else {
# message(" -> Activity FAILED final check (load NA or <= 0).")
NULL
}
})

# --- Process Activities into Daily Load (using internal helper) ---
daily_load_df <- calculate_daily_load_internal(
activities_df = activities_df_filtered,
load_metric = load_metric,
user_ftp = user_ftp,
user_max_hr = user_max_hr,
user_resting_hr = user_resting_hr
)

message("Finished processing activity list.")

if (is.null(daily_load_df) || nrow(daily_load_df) == 0) {
Expand Down
73 changes: 12 additions & 61 deletions R/calculate_exposure.R
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,13 @@ calculate_exposure <- function(activities_data,
stop("`activities_data` must be a data frame (e.g., from load_local_activities()).")
}

load_metric_options <- c("duration_mins", "distance_km", "hrss", "tss", "elevation_gain_m")
if (!load_metric %in% load_metric_options) {
stop(paste("'load_metric' must be one of:", paste(load_metric_options, collapse=", ")))
}
if (load_metric == "tss" && is.null(user_ftp)) {
stop("user_ftp is required when load_metric = 'tss'.")
}
if (load_metric == "hrss" && (is.null(user_max_hr) || is.null(user_resting_hr))) {
stop("user_max_hr and user_resting_hr are required when load_metric = 'hrss'.")
}
if (acute_period >= chronic_period) {
stop("acute_period must be less than chronic_period.")
}

# Validate load metric parameters using internal helper
validate_load_metric_params(load_metric, user_ftp, user_max_hr, user_resting_hr)

`%||%` <- function(x, y) if (is.null(x) || length(x) == 0) y else x

analysis_end_date <- tryCatch(lubridate::as_date(end_date %||% Sys.Date()), error = function(e) Sys.Date())
Expand Down Expand Up @@ -124,58 +117,16 @@ calculate_exposure <- function(activities_data,
stop("No activities found in local data for the required date range (", fetch_start_date, " to ", analysis_end_date,").")
}

# --- Process Activities into Daily Load ---
safe_as_numeric <- function(x) { as.numeric(x %||% 0) }

daily_load_df <- purrr::map_dfr(1:nrow(activities_df_filtered), function(i) {
activity <- activities_df_filtered[i, ]
activity_date <- activity$date
act_type <- activity$type %||% "Unknown"

duration_sec <- safe_as_numeric(activity$moving_time)
distance_m <- safe_as_numeric(activity$distance)
elapsed_sec <- safe_as_numeric(activity$elapsed_time)
avg_hr <- safe_as_numeric(activity$average_heartrate)
avg_power <- safe_as_numeric(activity$average_watts)
elevation_gain <- safe_as_numeric(activity$elevation_gain)
np_proxy <- safe_as_numeric(activity$weighted_average_watts %||% activity$average_watts %||% 0)

current_load <- 0
if (load_metric == "duration_mins") {
current_load <- duration_sec / 60
} else if (load_metric == "distance_km") {
current_load <- distance_m / 1000
} else if (load_metric == "hrss") {
if (avg_hr > 0 && !is.null(user_max_hr) && !is.null(user_resting_hr)) {
hr_reserve <- user_max_hr - user_resting_hr
if (hr_reserve > 0) {
percent_hrr <- (avg_hr - user_resting_hr) / hr_reserve
if (percent_hrr > 0) {
current_load <- (duration_sec / 3600) * percent_hrr * 100
}
}
}
} else if (load_metric == "tss") {
if (np_proxy > 0 && !is.null(user_ftp) && user_ftp > 0) {
intensity_factor <- np_proxy / user_ftp
current_load <- (duration_sec / 3600) * (intensity_factor^2) * 100
}
} else if (load_metric == "elevation_gain_m") {
current_load <- elevation_gain
}

if (current_load > 0) {
data.frame(
date = activity_date,
load = current_load,
stringsAsFactors = FALSE
)
} else {
NULL
}
})
# --- Process Activities into Daily Load (using internal helper) ---
daily_load_df <- calculate_daily_load_internal(
activities_df = activities_df_filtered,
load_metric = load_metric,
user_ftp = user_ftp,
user_max_hr = user_max_hr,
user_resting_hr = user_resting_hr
)

if (nrow(daily_load_df) == 0) {
if (is.null(daily_load_df) || nrow(daily_load_df) == 0) {
stop("No valid load data could be calculated from activities. Check if activities have the required metrics (HR, power, duration, distance).")
}

Expand Down
5 changes: 4 additions & 1 deletion R/cohort_reference.R
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,10 @@ add_reference_bands <- function(p,
#' reference_data <- data.frame(
#' date = as.Date(c("2023-01-01", "2023-04-01", "2023-07-01", "2023-10-01")),
#' percentile = rep(c("p05", "p25", "p50", "p75", "p95"), 4),
#' value = c(0.7, 0.9, 1.1, 1.3, 1.5, 0.7, 0.9, 1.1, 1.3, 1.5, 0.7, 0.9, 1.1, 1.3, 1.5, 0.7, 0.9, 1.1, 1.3, 1.5)
#' value = c(0.7, 0.9, 1.1, 1.3, 1.5,
#' 0.7, 0.9, 1.1, 1.3, 1.5,
#' 0.7, 0.9, 1.1, 1.3, 1.5,
#' 0.7, 0.9, 1.1, 1.3, 1.5)
#' )
#'
#' p <- plot_with_reference(
Expand Down
Loading
Loading