├── docs └── .nojekyll ├── LICENSE ├── man ├── figures │ ├── image.png │ ├── Athlytics_Final.png │ ├── 02b_ef_multi_group.png │ ├── 01b_acwr_multi_group.png │ ├── 05b_decoupling_multi_group.png │ └── advanced_cohort_reference.png ├── parse_fit_file.Rd ├── parse_gpx_file.Rd ├── parse_tcx_file.Rd ├── find_best_effort.Rd ├── athlytics_palette_cell.Rd ├── athlytics_palette_science.Rd ├── athlytics_colors_ef.Rd ├── athlytics_palette_nature.Rd ├── athlytics_palette_academic.Rd ├── athlytics_colors_training_load.Rd ├── athlytics_palette_vibrant.Rd ├── theme_athlytics.Rd ├── athlytics_colors_acwr_zones.Rd ├── athlytics_sample_decoupling.Rd ├── athlytics_sample_ef.Rd ├── scale_athlytics.Rd ├── athlytics_sample_acwr.Rd ├── athlytics_sample_exposure.Rd ├── quality_summary.Rd ├── parse_activity_file.Rd ├── add_reference_bands.Rd ├── athlytics_sample_pbs.Rd ├── plot_acwr_comparison.Rd ├── calculate_ef_from_stream.Rd ├── plot_with_reference.Rd ├── calculate_pbs.Rd ├── plot_acwr_enhanced.Rd ├── calculate_exposure.Rd ├── plot_ef.Rd ├── cohort_reference.Rd ├── plot_decoupling.Rd ├── plot_acwr.Rd ├── flag_quality.Rd ├── plot_exposure.Rd ├── plot_pbs.Rd ├── load_local_activities.Rd ├── calculate_acwr_ewma.Rd └── calculate_decoupling.Rd ├── tests ├── testthat.R └── testthat │ ├── helper-mockapi.R │ ├── test-calculate-decoupling.R │ ├── test-parse-activity-file.R │ ├── test-date-ranges.R │ ├── test-parameter-boundaries.R │ ├── test-utils.R │ ├── test-additional-edge-cases.R │ ├── test-load-local-activities.R │ ├── test-calculate-exposure.R │ ├── test-plot-ef-simple.R │ ├── test-plot-pbs-simple.R │ ├── test-plot-pbs-extended.R │ ├── test-plot-ef-extended.R │ ├── test-exposure.R │ ├── test-calculate-exposure-extended.R │ ├── test-ef.R │ ├── helper-mockdata.R │ ├── test-utils-extended.R │ ├── test-flag-quality.R │ ├── test-acwr.R │ ├── test-plot-ef-comprehensive.R │ ├── test-calculate-ef-extended.R │ ├── test-absolute-real-data.R │ ├── test-calculate-ef-simple.R │ └── test-parse-activity-file-stream.R ├── analysis_output ├── logo.png ├── 01b_acwr_multi_group.png ├── 02b_ef_multi_group.png ├── advanced_cohort_reference.png └── 05b_decoupling_multi_group.png ├── data ├── athlytics_sample_acwr.rda ├── athlytics_sample_ef.rda ├── athlytics_sample_pbs.rda ├── athlytics_sample_exposure.rda └── athlytics_sample_decoupling.rda ├── R ├── utils.R ├── zzz.R └── data.R ├── inst └── CITATION ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── documentation.md │ ├── feature_request.md │ └── bug_report.md ├── workflows │ ├── pkgcheck.yml │ ├── pkgdown.yml │ ├── update-downloads-badge.yml │ └── R-CMD-check.yml └── PULL_REQUEST_TEMPLATE.md ├── run_coverage.R ├── DESCRIPTION ├── .gitignore ├── _pkgdown.yml ├── CONTRIBUTING.md ├── data-raw └── generate_sample_pbs.R ├── paper ├── paper.bib └── paper.md ├── NAMESPACE ├── .Rbuildignore ├── generate_plot_examples.R └── CODE_OF_CONDUCT.md /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | YEAR: 2025 2 | COPYRIGHT HOLDER: Zhiang He 3 | -------------------------------------------------------------------------------- /man/figures/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/man/figures/image.png -------------------------------------------------------------------------------- /tests/testthat.R: -------------------------------------------------------------------------------- 1 | library(testthat) 2 | library(Athlytics) 3 | 4 | test_check("Athlytics") 5 | -------------------------------------------------------------------------------- /analysis_output/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/analysis_output/logo.png -------------------------------------------------------------------------------- /data/athlytics_sample_acwr.rda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/data/athlytics_sample_acwr.rda -------------------------------------------------------------------------------- /data/athlytics_sample_ef.rda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/data/athlytics_sample_ef.rda -------------------------------------------------------------------------------- /data/athlytics_sample_pbs.rda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/data/athlytics_sample_pbs.rda -------------------------------------------------------------------------------- /man/figures/Athlytics_Final.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/man/figures/Athlytics_Final.png -------------------------------------------------------------------------------- /data/athlytics_sample_exposure.rda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/data/athlytics_sample_exposure.rda -------------------------------------------------------------------------------- /man/figures/02b_ef_multi_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/man/figures/02b_ef_multi_group.png -------------------------------------------------------------------------------- /data/athlytics_sample_decoupling.rda: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/data/athlytics_sample_decoupling.rda -------------------------------------------------------------------------------- /man/figures/01b_acwr_multi_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/man/figures/01b_acwr_multi_group.png -------------------------------------------------------------------------------- /analysis_output/01b_acwr_multi_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/analysis_output/01b_acwr_multi_group.png -------------------------------------------------------------------------------- /analysis_output/02b_ef_multi_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/analysis_output/02b_ef_multi_group.png -------------------------------------------------------------------------------- /man/figures/05b_decoupling_multi_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/man/figures/05b_decoupling_multi_group.png -------------------------------------------------------------------------------- /man/figures/advanced_cohort_reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/man/figures/advanced_cohort_reference.png -------------------------------------------------------------------------------- /analysis_output/advanced_cohort_reference.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/analysis_output/advanced_cohort_reference.png -------------------------------------------------------------------------------- /analysis_output/05b_decoupling_multi_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/HzaCode/Athlytics/HEAD/analysis_output/05b_decoupling_multi_group.png -------------------------------------------------------------------------------- /tests/testthat/helper-mockapi.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/helper-mockapi.R 2 | 3 | with_mocked_strava_api <- function(expr, mock_data = mock_activity_list_df) { 4 | mockery::stub( 5 | where = rStrava::get_activity_list, 6 | what = "rStrava::get_activity_list", 7 | how = mock_data 8 | ) 9 | eval(expr) 10 | } 11 | -------------------------------------------------------------------------------- /man/parse_fit_file.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/parse_activity_file.R 3 | \name{parse_fit_file} 4 | \alias{parse_fit_file} 5 | \title{Parse FIT file} 6 | \usage{ 7 | parse_fit_file(file_path) 8 | } 9 | \description{ 10 | Parse FIT file 11 | } 12 | \keyword{internal} 13 | -------------------------------------------------------------------------------- /man/parse_gpx_file.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/parse_activity_file.R 3 | \name{parse_gpx_file} 4 | \alias{parse_gpx_file} 5 | \title{Parse GPX file} 6 | \usage{ 7 | parse_gpx_file(file_path) 8 | } 9 | \description{ 10 | Parse GPX file 11 | } 12 | \keyword{internal} 13 | -------------------------------------------------------------------------------- /man/parse_tcx_file.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/parse_activity_file.R 3 | \name{parse_tcx_file} 4 | \alias{parse_tcx_file} 5 | \title{Parse TCX file} 6 | \usage{ 7 | parse_tcx_file(file_path) 8 | } 9 | \description{ 10 | Parse TCX file 11 | } 12 | \keyword{internal} 13 | -------------------------------------------------------------------------------- /tests/testthat/test-calculate-decoupling.R: -------------------------------------------------------------------------------- 1 | # Load data: sample data from package & mock API returns from helper 2 | data(athlytics_sample_decoupling) 3 | source(test_path("helper-mockdata.R"), local = TRUE) # Provides mock_activity_list_list, mock_activity_streams 4 | 5 | # Mock Strava token - needed for function signature but API calls will be mocked 6 | -------------------------------------------------------------------------------- /man/find_best_effort.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/calculate_pbs.R 3 | \name{find_best_effort} 4 | \alias{find_best_effort} 5 | \title{Find Best Effort for Target Distance} 6 | \usage{ 7 | find_best_effort(stream_data, target_distance) 8 | } 9 | \description{ 10 | Find Best Effort for Target Distance 11 | } 12 | \keyword{internal} 13 | -------------------------------------------------------------------------------- /R/utils.R: -------------------------------------------------------------------------------- 1 | # R/utils.R 2 | 3 | # Internal helper function for English month-year labels 4 | english_month_year <- function(dates) { 5 | months_en <- c("Jan", "Feb", "Mar", "Apr", "May", "Jun", 6 | "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") 7 | paste(months_en[lubridate::month(dates)], lubridate::year(dates)) 8 | } 9 | 10 | # Add other internal utility functions here in the future if needed -------------------------------------------------------------------------------- /tests/testthat/test-parse-activity-file.R: -------------------------------------------------------------------------------- 1 | # Test parse_activity_file functionality 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | 6 | # Skip all tests in this file - parse_activity_file and related functions are internal 7 | # They are tested indirectly through calculate_pbs, calculate_decoupling, etc. 8 | 9 | test_that("parse functions are tested via calculate functions", { 10 | skip("parse_activity_file and related functions are not exported - tested indirectly via calculate functions") 11 | }) 12 | -------------------------------------------------------------------------------- /R/zzz.R: -------------------------------------------------------------------------------- 1 | # R/zzz.R 2 | 3 | .onAttach <- function(libname, pkgname) { 4 | 5 | pkg_desc <- utils::packageDescription(pkgname) 6 | pkg_version <- pkg_desc$Version 7 | 8 | startup_msg <- paste0( 9 | "\nLoading Athlytics version ", pkg_version, ".\n", 10 | "Analyze your Strava data locally with ease!\n", 11 | "Use load_local_activities() to get started.\n", 12 | "For documentation, see: https://hzacode.github.io/Athlytics/" 13 | ) 14 | 15 | 16 | packageStartupMessage(startup_msg) 17 | } 18 | 19 | -------------------------------------------------------------------------------- /inst/CITATION: -------------------------------------------------------------------------------- 1 | bibentry( 2 | bibtype = "Manual", 3 | title = "Athlytics: A Computational Framework for Longitudinal Analysis of Exercise Physiology", 4 | author = person("Zhiang", "He", email = "ang@hezhiang.com"), 5 | year = 2025, 6 | note = "R package version 1.0.0", 7 | url = "https://github.com/HzaCode/Athlytics", 8 | textVersion = paste("Zhiang He (2025). Athlytics: A Computational Framework for Longitudinal Analysis of Exercise Physiology. R package version 1.0.0. https://github.com/HzaCode/Athlytics") 9 | ) 10 | 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: ❓ Ask a Question 4 | url: https://github.com/HzaCode/Athlytics/discussions 5 | about: Ask questions about using Athlytics or discuss ideas 6 | - name: 📖 Documentation 7 | url: https://hezhiang.com/Athlytics/ 8 | about: Read the full documentation and vignettes 9 | - name: 💬 Community Discussion 10 | url: https://github.com/HzaCode/Athlytics/discussions 11 | about: Join community discussions about sports analytics and training science 12 | 13 | -------------------------------------------------------------------------------- /man/athlytics_palette_cell.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_palette_cell} 4 | \alias{athlytics_palette_cell} 5 | \title{Cell Journal Palette} 6 | \usage{ 7 | athlytics_palette_cell() 8 | } 9 | \value{ 10 | A character vector of 8 hex color codes 11 | } 12 | \description{ 13 | Modern palette based on Cell Press visualization standards. 14 | Balances professional appearance with visual clarity. 15 | } 16 | \examples{ 17 | # Get Cell palette colors 18 | colors <- athlytics_palette_cell() 19 | colors[1] # Blue 20 | 21 | } 22 | -------------------------------------------------------------------------------- /man/athlytics_palette_science.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_palette_science} 4 | \alias{athlytics_palette_science} 5 | \title{Science Magazine Palette} 6 | \usage{ 7 | athlytics_palette_science() 8 | } 9 | \value{ 10 | A character vector of 8 hex color codes 11 | } 12 | \description{ 13 | Classic palette inspired by Science magazine's figure guidelines. 14 | Conservative and widely accepted in scientific community. 15 | } 16 | \examples{ 17 | # Get Science palette colors 18 | colors <- athlytics_palette_science() 19 | colors[1] # Dark blue 20 | 21 | } 22 | -------------------------------------------------------------------------------- /man/athlytics_colors_ef.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_colors_ef} 4 | \alias{athlytics_colors_ef} 5 | \title{Efficiency Factor Colors} 6 | \usage{ 7 | athlytics_colors_ef() 8 | } 9 | \value{ 10 | A named list with four color codes by sport: 11 | \item{run}{Navy blue for running} 12 | \item{ride}{Coral for cycling} 13 | \item{swim}{Cyan for swimming} 14 | \item{other}{Slate for other activities} 15 | } 16 | \description{ 17 | Colors for efficiency factor trends by activity type. 18 | } 19 | \examples{ 20 | # Get EF colors by sport 21 | colors <- athlytics_colors_ef() 22 | colors$run # Navy for running 23 | 24 | } 25 | -------------------------------------------------------------------------------- /man/athlytics_palette_nature.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_palette_nature} 4 | \alias{athlytics_palette_nature} 5 | \title{Nature-Inspired Color Palette} 6 | \usage{ 7 | athlytics_palette_nature() 8 | } 9 | \value{ 10 | A character vector of 9 hex color codes 11 | } 12 | \description{ 13 | Professional, colorblind-friendly palette based on Nature journal's 14 | visualization guidelines. Suitable for multi-series plots. 15 | } 16 | \examples{ 17 | \dontrun{ 18 | ggplot2::ggplot(data, ggplot2::aes(x, y, color = group)) + 19 | ggplot2::geom_line() + 20 | ggplot2::scale_color_manual(values = athlytics_palette_nature()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /man/athlytics_palette_academic.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_palette_academic} 4 | \alias{athlytics_palette_academic} 5 | \title{Academic Muted Color Palette} 6 | \usage{ 7 | athlytics_palette_academic() 8 | } 9 | \value{ 10 | A character vector of 8 hex color codes 11 | } 12 | \description{ 13 | Low-saturation, elegant palette suitable for formal publications and 14 | technical reports. Emphasizes clarity over visual impact. 15 | } 16 | \examples{ 17 | \dontrun{ 18 | ggplot2::ggplot(data, ggplot2::aes(x, y, color = group)) + 19 | ggplot2::geom_line() + 20 | ggplot2::scale_color_manual(values = athlytics_palette_academic()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /man/athlytics_colors_training_load.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_colors_training_load} 4 | \alias{athlytics_colors_training_load} 5 | \title{Training Load Colors} 6 | \usage{ 7 | athlytics_colors_training_load() 8 | } 9 | \value{ 10 | A named list with three color codes: 11 | \item{acute}{Red for short-term load (7-day)} 12 | \item{chronic}{Blue for long-term load (28-day)} 13 | \item{ratio}{Teal for ACWR ratio} 14 | } 15 | \description{ 16 | Colors for acute and chronic training load visualization. 17 | } 18 | \examples{ 19 | # Get training load colors 20 | colors <- athlytics_colors_training_load() 21 | colors$acute # Red for acute load 22 | 23 | } 24 | -------------------------------------------------------------------------------- /man/athlytics_palette_vibrant.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_palette_vibrant} 4 | \alias{athlytics_palette_vibrant} 5 | \title{Vibrant High-Contrast Palette} 6 | \usage{ 7 | athlytics_palette_vibrant() 8 | } 9 | \value{ 10 | A character vector of 8 hex color codes 11 | } 12 | \description{ 13 | High-saturation palette optimized for presentations and posters. 14 | Maximum visual impact while maintaining colorblind accessibility. 15 | } 16 | \examples{ 17 | \dontrun{ 18 | ggplot2::ggplot(data, ggplot2::aes(x, y, fill = category)) + 19 | ggplot2::geom_bar(stat = "identity") + 20 | ggplot2::scale_fill_manual(values = athlytics_palette_vibrant()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /man/theme_athlytics.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{theme_athlytics} 4 | \alias{theme_athlytics} 5 | \title{Get Athlytics Theme} 6 | \usage{ 7 | theme_athlytics(base_size = 13, base_family = "") 8 | } 9 | \arguments{ 10 | \item{base_size}{Base font size (default: 12)} 11 | 12 | \item{base_family}{Font family (default: "")} 13 | } 14 | \value{ 15 | A ggplot2 theme object that can be added to plots 16 | } 17 | \description{ 18 | Publication-ready ggplot2 theme with sensible defaults for scientific figures. 19 | } 20 | \examples{ 21 | # Apply theme to a plot 22 | ggplot2::ggplot(mtcars, ggplot2::aes(mpg, wt)) + 23 | ggplot2::geom_point() + 24 | theme_athlytics() 25 | 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/pkgcheck.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | name: pkgcheck 10 | 11 | jobs: 12 | pkgcheck: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | issues: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - uses: r-lib/actions/setup-r@v2 21 | with: 22 | r-version: 'release' 23 | use-public-rspm: true 24 | 25 | - uses: r-lib/actions/setup-r-dependencies@v2 26 | with: 27 | extra-packages: any::rcmdcheck, any::roxygen2, any::pkgbuild 28 | 29 | - uses: ropensci-review-tools/pkgcheck-action@main 30 | env: 31 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 32 | 33 | 34 | -------------------------------------------------------------------------------- /man/athlytics_colors_acwr_zones.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{athlytics_colors_acwr_zones} 4 | \alias{athlytics_colors_acwr_zones} 5 | \title{ACWR Zone Colors} 6 | \usage{ 7 | athlytics_colors_acwr_zones() 8 | } 9 | \value{ 10 | A named list with four color codes for ACWR zones: 11 | \item{undertraining}{Light blue for low load} 12 | \item{safe}{Green for optimal training zone} 13 | \item{caution}{Orange for moderate risk} 14 | \item{high_risk}{Red for high injury risk} 15 | } 16 | \description{ 17 | Standardized colors for ACWR risk zones following sports science conventions. 18 | } 19 | \examples{ 20 | # Get ACWR zone colors 21 | colors <- athlytics_colors_acwr_zones() 22 | colors$safe # Returns green color code 23 | 24 | } 25 | -------------------------------------------------------------------------------- /man/athlytics_sample_decoupling.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/data.R 3 | \docType{data} 4 | \name{athlytics_sample_decoupling} 5 | \alias{athlytics_sample_decoupling} 6 | \title{Sample Aerobic Decoupling Data for Athlytics} 7 | \format{ 8 | A tibble with 365 rows and 2 variables: 9 | \describe{ 10 | \item{date}{Date of the activity, as a Date object.} 11 | \item{decoupling}{Calculated decoupling percentage, as a numeric value.} 12 | } 13 | } 14 | \source{ 15 | Simulated data generated for package examples. 16 | } 17 | \usage{ 18 | athlytics_sample_decoupling 19 | } 20 | \description{ 21 | A dataset containing pre-calculated aerobic decoupling percentages, 22 | derived from simulated Strava data. Used in examples and tests. 23 | } 24 | \keyword{datasets} 25 | -------------------------------------------------------------------------------- /man/athlytics_sample_ef.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/data.R 3 | \docType{data} 4 | \name{athlytics_sample_ef} 5 | \alias{athlytics_sample_ef} 6 | \title{Sample Efficiency Factor (EF) Data for Athlytics} 7 | \format{ 8 | A data.frame with 50 rows and 3 variables: 9 | \describe{ 10 | \item{date}{Date of the activity, as a Date object.} 11 | \item{activity_type}{Type of activity (e.g., "Run", "Ride"), as a character string.} 12 | \item{ef_value}{Calculated Efficiency Factor, as a numeric value.} 13 | } 14 | } 15 | \source{ 16 | Simulated data generated for package examples. 17 | } 18 | \usage{ 19 | athlytics_sample_ef 20 | } 21 | \description{ 22 | A dataset containing pre-calculated Efficiency Factor (EF) values, 23 | derived from simulated Strava data. Used in examples and tests. 24 | } 25 | \keyword{datasets} 26 | -------------------------------------------------------------------------------- /man/scale_athlytics.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/color_palettes.R 3 | \name{scale_athlytics} 4 | \alias{scale_athlytics} 5 | \title{Apply Color Palette to ggplot} 6 | \usage{ 7 | scale_athlytics(palette_name = "nature", type = "color") 8 | } 9 | \arguments{ 10 | \item{palette_name}{Name of palette: "nature", "academic", "vibrant", "science", or "cell"} 11 | 12 | \item{type}{Either "color" or "fill"} 13 | } 14 | \value{ 15 | A ggplot2 scale object (scale_color_manual or scale_fill_manual) 16 | } 17 | \description{ 18 | Helper function to apply Athlytics color palettes to existing plots. 19 | } 20 | \examples{ 21 | # Apply nature palette to plot 22 | ggplot2::ggplot(iris, ggplot2::aes(Sepal.Length, Sepal.Width, color = Species)) + 23 | ggplot2::geom_point() + 24 | scale_athlytics("nature", "color") 25 | 26 | } 27 | -------------------------------------------------------------------------------- /man/athlytics_sample_acwr.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/data.R 3 | \docType{data} 4 | \name{athlytics_sample_acwr} 5 | \alias{athlytics_sample_acwr} 6 | \title{Sample ACWR Data for Athlytics} 7 | \format{ 8 | A tibble with 365 rows and 5 variables: 9 | \describe{ 10 | \item{date}{Date of the metrics, as a Date object.} 11 | \item{atl}{Acute Training Load, as a numeric value.} 12 | \item{ctl}{Chronic Training Load, as a numeric value.} 13 | \item{acwr}{Acute:Chronic Workload Ratio, as a numeric value.} 14 | \item{acwr_smooth}{Smoothed ACWR, as a numeric value.} 15 | } 16 | } 17 | \source{ 18 | Simulated data generated for package examples. 19 | } 20 | \usage{ 21 | athlytics_sample_acwr 22 | } 23 | \description{ 24 | A dataset containing pre-calculated Acute:Chronic Workload Ratio (ACWR) 25 | and related metrics, derived from simulated Strava data. Used in examples and tests. 26 | } 27 | \keyword{datasets} 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation improvement 3 | about: Suggest improvements to documentation, examples, or vignettes 4 | title: '[DOCS] ' 5 | labels: documentation 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Which documentation needs improvement?** 11 | - [ ] Function documentation (Rd files) 12 | - [ ] README 13 | - [ ] Vignettes 14 | - [ ] Website (pkgdown) 15 | - [ ] Code comments 16 | - [ ] Other: ___________ 17 | 18 | **Location** 19 | Please specify where the documentation issue is: 20 | - Function name: 21 | - File path: 22 | - URL: 23 | 24 | **Current documentation (if applicable)** 25 | Paste or describe the current documentation that needs improvement. 26 | 27 | **Suggested improvement** 28 | Describe what should be added, clarified, or corrected. 29 | 30 | **Why is this improvement needed?** 31 | Explain how this would help users understand or use Athlytics better. 32 | 33 | **Additional context** 34 | Add any other context, examples, or references here. 35 | 36 | -------------------------------------------------------------------------------- /man/athlytics_sample_exposure.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/data.R 3 | \docType{data} 4 | \name{athlytics_sample_exposure} 5 | \alias{athlytics_sample_exposure} 6 | \title{Sample Training Load Exposure Data for Athlytics} 7 | \format{ 8 | A tibble with 365 rows and 5 variables: 9 | \describe{ 10 | \item{date}{Date of the metrics, as a Date object.} 11 | \item{daily_load}{Calculated daily training load, as a numeric value.} 12 | \item{ctl}{Chronic Training Load, as a numeric value.} 13 | \item{atl}{Acute Training Load, as a numeric value.} 14 | \item{acwr}{Acute:Chronic Workload Ratio, as a numeric value.} 15 | } 16 | } 17 | \source{ 18 | Simulated data generated for package examples. 19 | } 20 | \usage{ 21 | athlytics_sample_exposure 22 | } 23 | \description{ 24 | This dataset contains daily training load, ATL, CTL, and ACWR, derived from 25 | simulated Strava data. Used in examples and tests, particularly for \code{plot_exposure}. 26 | } 27 | \keyword{datasets} 28 | -------------------------------------------------------------------------------- /tests/testthat/test-date-ranges.R: -------------------------------------------------------------------------------- 1 | 2 | test_that("date range tests for coverage", { 3 | activities <- data.frame( 4 | date = as.Date(c("2023-01-01", "2023-06-15", "2023-12-31")), 5 | type = c("Run", "Run", "Run"), 6 | distance = c(10, 15, 20), 7 | moving_time = c(3600, 5400, 7200), 8 | average_heartrate = c(150, 160, 170), 9 | average_speed = c(10/3600*1000, 15/5400*1000, 20/7200*1000) 10 | ) 11 | 12 | # Test start date filtering 13 | result1 <- calculate_ef(activities, start_date = "2023-03-01", end_date = "2024-01-01", quality_control = "off") 14 | expect_s3_class(result1, "data.frame") 15 | 16 | # Test end date filtering 17 | result2 <- calculate_ef(activities, start_date = "2022-01-01", end_date = "2023-09-01", quality_control = "off") 18 | expect_s3_class(result2, "data.frame") 19 | 20 | # Test date range filtering 21 | result3 <- calculate_ef(activities, start_date = "2023-02-01", end_date = "2023-08-01", quality_control = "off") 22 | expect_s3_class(result3, "data.frame") 23 | }) 24 | 25 | -------------------------------------------------------------------------------- /man/quality_summary.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/flag_quality.R 3 | \name{quality_summary} 4 | \alias{quality_summary} 5 | \title{Get Quality Summary Statistics} 6 | \usage{ 7 | quality_summary(flagged_streams) 8 | } 9 | \arguments{ 10 | \item{flagged_streams}{A data frame returned by \code{flag_quality()}.} 11 | } 12 | \value{ 13 | A list with summary statistics: 14 | \describe{ 15 | \item{total_points}{Total number of data points} 16 | \item{flagged_points}{Number of flagged points} 17 | \item{flagged_pct}{Percentage of flagged points} 18 | \item{steady_state_points}{Number of steady-state points} 19 | \item{steady_state_pct}{Percentage in steady-state} 20 | \item{quality_score}{Overall quality score (0-1)} 21 | \item{hr_spike_pct}{Percentage with HR spikes} 22 | \item{pw_spike_pct}{Percentage with power spikes} 23 | \item{gps_drift_pct}{Percentage with GPS drift} 24 | } 25 | } 26 | \description{ 27 | Provides a summary of quality flags and steady-state segments. 28 | } 29 | \examples{ 30 | \dontrun{ 31 | flagged_data <- flag_quality(stream_data) 32 | quality_summary(flagged_data) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /man/parse_activity_file.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/parse_activity_file.R 3 | \name{parse_activity_file} 4 | \alias{parse_activity_file} 5 | \title{Parse Activity File (FIT, TCX, or GPX)} 6 | \usage{ 7 | parse_activity_file(file_path, export_dir = NULL) 8 | } 9 | \arguments{ 10 | \item{file_path}{Path to the activity file (can be .fit, .tcx, .gpx, or .gz compressed)} 11 | 12 | \item{export_dir}{Base directory of the Strava export (for resolving relative paths)} 13 | } 14 | \value{ 15 | A data frame with columns: time, latitude, longitude, elevation, 16 | heart_rate, power, cadence, speed (all optional depending on file content). 17 | Returns NULL if file cannot be parsed or does not exist. 18 | } 19 | \description{ 20 | Parse activity files from Strava export data. 21 | Supports FIT, TCX, and GPX formats (including .gz compressed files). 22 | } 23 | \examples{ 24 | \dontrun{ 25 | # Parse a FIT file 26 | streams <- parse_activity_file("activity_12345.fit", export_dir = "strava_export/") 27 | 28 | # Parse a compressed GPX file 29 | streams <- parse_activity_file("activity_12345.gpx.gz") 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /tests/testthat/test-parameter-boundaries.R: -------------------------------------------------------------------------------- 1 | 2 | test_that("parameter boundary tests for coverage", { 3 | activities <- data.frame( 4 | date = as.Date("2023-06-01"), 5 | type = "Run", 6 | distance = 10, 7 | moving_time = 3600, 8 | average_heartrate = 150, 9 | average_speed = 10/3600*1000 10 | ) 11 | 12 | # Test minimum duration parameter 13 | result1 <- calculate_ef(activities, start_date = "2023-05-01", end_date = "2023-07-01", min_duration_mins = 0, quality_control = "off") 14 | expect_s3_class(result1, "data.frame") 15 | 16 | # Test maximum duration parameter 17 | result2 <- calculate_ef(activities, start_date = "2023-05-01", end_date = "2023-07-01", min_duration_mins = 1000, quality_control = "off") 18 | expect_s3_class(result2, "data.frame") 19 | 20 | # Test different quality control settings 21 | result3 <- calculate_ef(activities, start_date = "2023-05-01", end_date = "2023-07-01", quality_control = "off") 22 | expect_s3_class(result3, "data.frame") 23 | 24 | result4 <- calculate_ef(activities, start_date = "2023-05-01", end_date = "2023-07-01", quality_control = "flag") 25 | expect_s3_class(result4, "data.frame") 26 | }) 27 | 28 | -------------------------------------------------------------------------------- /.github/workflows/pkgdown.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main, master] 4 | pull_request: 5 | branches: [main, master] 6 | release: 7 | types: [published] 8 | workflow_dispatch: 9 | 10 | name: pkgdown 11 | 12 | jobs: 13 | pkgdown: 14 | runs-on: ubuntu-latest 15 | 16 | env: 17 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | permissions: 20 | contents: write 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - uses: r-lib/actions/setup-pandoc@v2 26 | 27 | - uses: r-lib/actions/setup-r@v2 28 | with: 29 | use-public-rspm: true 30 | 31 | - uses: r-lib/actions/setup-r-dependencies@v2 32 | with: 33 | extra-packages: any::pkgdown, local::. 34 | needs: website 35 | 36 | - name: Build site 37 | run: pkgdown::build_site_github_pages(new_process = FALSE, install = FALSE) 38 | shell: Rscript {0} 39 | 40 | - name: Create .nojekyll file 41 | run: touch docs/.nojekyll 42 | 43 | - name: Deploy to GitHub pages 🚀 44 | if: github.event_name != 'pull_request' 45 | uses: JamesIves/github-pages-deploy-action@v4.6.8 46 | with: 47 | clean: false 48 | branch: gh-pages 49 | folder: docs 50 | 51 | -------------------------------------------------------------------------------- /tests/testthat/test-utils.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-utils.R 2 | 3 | library(testthat) 4 | library(Athlytics) # To make internal functions available if using devtools::load_all() 5 | library(lubridate) 6 | 7 | # Utility Functions Tests 8 | 9 | test_that("english_month_year correctly formats dates", { 10 | # Test with a single date 11 | single_date <- ymd("2023-01-15") 12 | expect_equal(Athlytics:::english_month_year(single_date), "Jan 2023") 13 | 14 | # Test with multiple dates 15 | multiple_dates <- c(ymd("2023-03-10"), ymd("2024-11-05")) 16 | expect_equal(Athlytics:::english_month_year(multiple_dates), c("Mar 2023", "Nov 2024")) 17 | 18 | # Test with dates spanning year-end 19 | year_end_dates <- c(ymd("2022-12-25"), ymd("2023-01-01")) 20 | expect_equal(Athlytics:::english_month_year(year_end_dates), c("Dec 2022", "Jan 2023")) 21 | 22 | # Test with a leap year date 23 | leap_date <- ymd("2024-02-29") 24 | expect_equal(Athlytics:::english_month_year(leap_date), "Feb 2024") 25 | 26 | # Test with an empty vector of dates (should return empty character vector) 27 | empty_dates <- ymd(character(0)) 28 | expect_equal(Athlytics:::english_month_year(empty_dates), character(0)) 29 | 30 | # Test with NA date 31 | # expect_equal(Athlytics:::english_month_year(ymd(NA)), NA_character_) 32 | }) 33 | -------------------------------------------------------------------------------- /run_coverage.R: -------------------------------------------------------------------------------- 1 | # Run coverage test with fresh session 2 | message("Current working directory: ", getwd()) 3 | 4 | # Load covr library 5 | library(covr) 6 | 7 | # Use package coverage (more reliable than file coverage) 8 | message("Calculating package coverage...") 9 | cov <- tryCatch({ 10 | package_coverage( 11 | type = "all", 12 | quiet = FALSE, 13 | clean = FALSE 14 | ) 15 | }, error = function(e) { 16 | message("Error calculating coverage: ", e$message) 17 | NULL 18 | }) 19 | 20 | # Print coverage results 21 | if (!is.null(cov) && length(cov) > 0) { 22 | message("\n=== Coverage Summary ===") 23 | print(cov) 24 | 25 | message("\n=== Coverage Percentage ===") 26 | print(percent_coverage(cov)) 27 | 28 | # Upload coverage with error handling 29 | message("\nUploading coverage data to Codecov...") 30 | tryCatch({ 31 | codecov(coverage = cov, quiet = FALSE) 32 | message("Coverage upload successful!") 33 | }, error = function(e) { 34 | message("Coverage upload failed: ", e$message) 35 | message("Coverage data available but upload failed - this may be due to network issues or Codecov configuration.") 36 | }) 37 | } else { 38 | message("Coverage object is NULL or empty.") 39 | message("This may indicate issues with test execution or package structure.") 40 | quit(status = 1) 41 | } 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for Athlytics 4 | title: '[FEATURE] ' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Problem or motivation** 11 | What problem would this feature solve? 12 | 13 | **Proposed solution** 14 | Describe the feature you'd like to see. 15 | 16 | **Alternatives considered** 17 | Other approaches you've evaluated. 18 | 19 | **Use case** 20 | How would this feature be used? What problem does it solve? 21 | - For single-athlete analysis? 22 | - For cohort/team analysis? 23 | - For research purposes? 24 | 25 | **Expected function signature (if applicable)** 26 | ```r 27 | # Example of how you envision using this feature 28 | result <- new_function( 29 | data = activities, 30 | parameter1 = value1, 31 | parameter2 = value2 32 | ) 33 | ``` 34 | 35 | **Additional context** 36 | Any other relevant information. 37 | 38 | **References** 39 | If this feature is based on published research or methodology, please cite the relevant papers: 40 | - Author et al. (Year). Title. Journal. DOI: ... 41 | 42 | **Implementation considerations** 43 | Are you willing to contribute to implementing this feature? (optional) 44 | - [ ] Yes, I can help with implementation 45 | - [ ] Yes, I can help with testing 46 | - [ ] Yes, I can help with documentation 47 | - [ ] I can provide example data for testing (de-identified) 48 | 49 | -------------------------------------------------------------------------------- /man/add_reference_bands.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/cohort_reference.R 3 | \name{add_reference_bands} 4 | \alias{add_reference_bands} 5 | \title{Add Cohort Reference Bands to Existing Plot} 6 | \usage{ 7 | add_reference_bands( 8 | p, 9 | reference_data, 10 | bands = c("p25_p75", "p05_p95", "p50"), 11 | alpha = 0.15, 12 | colors = list(p25_p75 = "#440154FF", p05_p95 = "#3B528BFF", p50 = "#21908CFF") 13 | ) 14 | } 15 | \arguments{ 16 | \item{p}{A ggplot object (typically from plot_acwr or similar).} 17 | 18 | \item{reference_data}{A data frame from \code{cohort_reference()}.} 19 | 20 | \item{bands}{Character vector specifying which bands to plot. Options: 21 | "p25_p75" (inner quartiles), "p05_p95" (outer 5-95 range), "p50" (median). 22 | Default c("p25_p75", "p05_p95", "p50").} 23 | 24 | \item{alpha}{Transparency for reference bands (0-1). Default 0.15.} 25 | 26 | \item{colors}{Named list of colors for bands. Default uses viridis colors.} 27 | } 28 | \value{ 29 | A ggplot object with added reference bands. 30 | } 31 | \description{ 32 | Adds percentile reference bands from a cohort to an individual's metric plot. 33 | } 34 | \examples{ 35 | \dontrun{ 36 | # Create base plot 37 | p <- plot_acwr(acwr_df = individual_acwr) 38 | 39 | # Add reference bands 40 | p_with_ref <- add_reference_bands(p, reference_data = cohort_ref) 41 | print(p_with_ref) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/testthat/test-additional-edge-cases.R: -------------------------------------------------------------------------------- 1 | 2 | test_that("additional edge cases for coverage", { 3 | # Test with very small numeric values - using reasonable date range 4 | activities <- data.frame( 5 | date = as.Date("2023-06-01"), 6 | type = "Run", 7 | distance = 0.001, 8 | moving_time = 1, 9 | average_heartrate = 50, 10 | average_speed = 0.001/1*1000 11 | ) 12 | 13 | result <- calculate_ef(activities, start_date = "2023-05-01", end_date = "2023-07-01", quality_control = "off") 14 | expect_s3_class(result, "data.frame") 15 | 16 | # Test with very large numeric values 17 | activities2 <- data.frame( 18 | date = as.Date("2023-06-01"), 19 | type = "Run", 20 | distance = 100000, 21 | moving_time = 86400, 22 | average_heartrate = 200, 23 | average_speed = 100000/86400*1000 24 | ) 25 | 26 | result2 <- calculate_ef(activities2, start_date = "2023-05-01", end_date = "2023-07-01", quality_control = "off") 27 | expect_s3_class(result2, "data.frame") 28 | 29 | # Test with NA values in data 30 | activities3 <- data.frame( 31 | date = as.Date("2023-06-01"), 32 | type = "Run", 33 | distance = 10, 34 | moving_time = 3600, 35 | average_heartrate = NA, 36 | average_speed = 10/3600*1000 37 | ) 38 | 39 | result3 <- calculate_ef(activities3, start_date = "2023-05-01", end_date = "2023-07-01", quality_control = "off") 40 | expect_s3_class(result3, "data.frame") 41 | }) 42 | 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a bug in Athlytics 4 | title: '[BUG] ' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Load data with '...' 16 | 2. Run function '....' 17 | 3. See error 18 | 19 | **Minimal reproducible example** 20 | ```r 21 | # Please provide a minimal reproducible example 22 | library(Athlytics) 23 | 24 | # Your code here 25 | ``` 26 | 27 | **Expected behavior** 28 | A clear and concise description of what you expected to happen. 29 | 30 | **Actual behavior** 31 | What actually happened instead. 32 | 33 | **Error message** 34 | ``` 35 | Paste the complete error message here 36 | ``` 37 | 38 | **System information:** 39 | - OS: [e.g., Windows 11, macOS 14.0, Ubuntu 22.04] 40 | - R version: [e.g., 4.3.2] 41 | - Athlytics version: [e.g., 1.0.0] 42 | - Installation source: [CRAN or GitHub] 43 | 44 | **Session Info** 45 |
46 | Click to expand 47 | 48 | ```r 49 | # Please run sessionInfo() and paste the output here 50 | sessionInfo() 51 | ``` 52 |
53 | 54 | **Additional context** 55 | Add any other context about the problem here (e.g., size of dataset, specific activity types, etc.) 56 | 57 | **Data Privacy Note** 58 | Please do NOT share any personal training data or identifiable information in this issue. 59 | 60 | -------------------------------------------------------------------------------- /man/athlytics_sample_pbs.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/data.R 3 | \docType{data} 4 | \name{athlytics_sample_pbs} 5 | \alias{athlytics_sample_pbs} 6 | \title{Sample Personal Bests (PBs) Data for Athlytics} 7 | \format{ 8 | A tibble with 100 rows and 10 variables: 9 | \describe{ 10 | \item{activity_id}{ID of the activity where the effort occurred, as a character string.} 11 | \item{activity_date}{Date and time of the activity, as a POSIXct object.} 12 | \item{distance}{Target distance in meters for the best effort, as a numeric value.} 13 | \item{elapsed_time}{Elapsed time for the effort in seconds, as a numeric value.} 14 | \item{moving_time}{Moving time for the effort in seconds, as a numeric value.} 15 | \item{time_seconds}{Typically the same as elapsed_time for best efforts, in seconds, as a numeric value.} 16 | \item{cumulative_pb_seconds}{The personal best time for that distance up to that date, in seconds, as a numeric value.} 17 | \item{is_pb}{Logical, TRUE if this effort set a new personal best.} 18 | \item{distance_label}{Factor representing the distance (e.g., "1k", "5k").} 19 | \item{time_period}{Formatted time of the effort, as a Period object from lubridate.} 20 | } 21 | } 22 | \source{ 23 | Simulated data generated for package examples. 24 | } 25 | \usage{ 26 | athlytics_sample_pbs 27 | } 28 | \description{ 29 | A dataset containing pre-calculated Personal Best (PB) times for various distances, 30 | derived from simulated Strava data. Used in examples and tests. 31 | } 32 | \keyword{datasets} 33 | -------------------------------------------------------------------------------- /DESCRIPTION: -------------------------------------------------------------------------------- 1 | Package: Athlytics 2 | Title: Academic R Package for Sports Physiology Analysis from Local 'Strava' Data 3 | Version: 1.0.1 4 | Author: Zhiang He [aut, cre] 5 | Maintainer: Zhiang He 6 | Authors@R: 7 | person("Zhiang", "He", email = "ang@hezhiang.com", role = c("aut", "cre")) 8 | Description: An open-source computational framework for longitudinal analysis of 9 | exercise physiology metrics using local 'Strava' data exports. Designed 10 | for personal analysis and sports science applications, this package provides 11 | standardized functions to calculate and visualize key physiological indicators 12 | including Acute:Chronic Workload Ratio (ACWR), Efficiency Factor (EF), and 13 | training load metrics. 14 | License: MIT + file LICENSE 15 | URL: https://hzacode.github.io/Athlytics/, https://github.com/HzaCode/Athlytics 16 | BugReports: https://github.com/HzaCode/Athlytics/issues 17 | Encoding: UTF-8 18 | LazyData: true 19 | Depends: 20 | R (>= 3.6.0) 21 | Imports: 22 | dplyr (>= 1.0.0), 23 | ggplot2, 24 | lubridate, 25 | purrr, 26 | readr, 27 | rlang (>= 0.4.0), 28 | scales, 29 | tidyr, 30 | zoo, 31 | R.utils, 32 | tools, 33 | utils 34 | Suggests: 35 | devtools, 36 | knitr, 37 | pkgdown, 38 | rmarkdown, 39 | testthat (>= 3.0.0), 40 | mockery, 41 | rStrava, 42 | xml2, 43 | FITfileR 44 | Remotes: 45 | grimbough/FITfileR 46 | VignetteBuilder: knitr 47 | RoxygenNote: 7.3.3 48 | Roxygen: list(markdown = TRUE) 49 | NeedsCompilation: no 50 | -------------------------------------------------------------------------------- /man/plot_acwr_comparison.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_acwr_enhanced.R 3 | \name{plot_acwr_comparison} 4 | \alias{plot_acwr_comparison} 5 | \title{Compare RA and EWMA Methods Side-by-Side} 6 | \usage{ 7 | plot_acwr_comparison( 8 | acwr_ra, 9 | acwr_ewma, 10 | title = "ACWR Method Comparison: RA vs EWMA" 11 | ) 12 | } 13 | \arguments{ 14 | \item{acwr_ra}{A data frame from \code{calculate_acwr_ewma(..., method = "ra")}.} 15 | 16 | \item{acwr_ewma}{A data frame from \code{calculate_acwr_ewma(..., method = "ewma")}.} 17 | 18 | \item{title}{Plot title. Default "ACWR Method Comparison: RA vs EWMA".} 19 | } 20 | \value{ 21 | A ggplot object with faceted comparison. 22 | } 23 | \description{ 24 | Creates a faceted plot comparing Rolling Average and EWMA ACWR calculations. 25 | } 26 | \examples{ 27 | # Example using sample data 28 | data("athlytics_sample_acwr", package = "Athlytics") 29 | if (!is.null(athlytics_sample_acwr) && nrow(athlytics_sample_acwr) > 0) { 30 | # Create two versions for comparison (simulate RA vs EWMA) 31 | acwr_ra <- athlytics_sample_acwr 32 | acwr_ewma <- athlytics_sample_acwr 33 | acwr_ewma$acwr_smooth <- acwr_ewma$acwr_smooth * runif(nrow(acwr_ewma), 0.95, 1.05) 34 | 35 | p <- plot_acwr_comparison(acwr_ra, acwr_ewma) 36 | print(p) 37 | } 38 | 39 | \dontrun{ 40 | activities <- load_local_activities("export.zip") 41 | 42 | acwr_ra <- calculate_acwr_ewma(activities, method = "ra") 43 | acwr_ewma <- calculate_acwr_ewma(activities, method = "ewma") 44 | 45 | plot_acwr_comparison(acwr_ra, acwr_ewma) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .Rproj.user 2 | .RData 3 | .aspell/defaults.R 4 | .Rbuildignore 5 | Athlytics.Rcheck/00check.log 6 | cran-comments.md 7 | CRAN-SUBMISSION 8 | /Athlytics.Rcheck/00_pkg_src/Athlytics/.aspell 9 | /Athlytics.Rcheck/00_pkg_src/Athlytics 10 | /Athlytics.Rcheck 11 | *.Rcheck/ 12 | *.Rproj.Rcheck/ 13 | # Archive files 14 | *.gz 15 | *.tar.gz 16 | *.zip 17 | *.tar 18 | *.rar 19 | *.7z 20 | *.Rcheck.tar.gz 21 | /tests/run_all_tests.R 22 | .httr-oauth 23 | tests/test_direct_api_streams.R 24 | .Rhistory 25 | coverage_report.html 26 | runnable_function_examples.html 27 | 28 | # User data files (not for GitHub) 29 | export_data/ 30 | tests/manual_tests/ 31 | 32 | /..Rcheck 33 | 34 | # User's private Strava data - DO NOT COMMIT 35 | strava_export_data/ 36 | 37 | # Temporary test files 38 | test-*.html 39 | test-*.md 40 | *_test.md 41 | README_test* 42 | 43 | # Node modules (if accidentally created) 44 | node_modules/ 45 | /doc/ 46 | /Meta/ 47 | 48 | # Paper drafts and submissions 49 | 50 | # Batch and PowerShell scripts (installation helpers) 51 | *.bat 52 | *.ps1 53 | 54 | # R temporary and debug files 55 | *.Rout 56 | *.rds 57 | *_coverage*.R 58 | *_debug*.R 59 | *_test*.R 60 | check_*.R 61 | debug_*.R 62 | final_*.R 63 | improve_*.R 64 | install_*.R 65 | parallel_*.R 66 | performance_*.R 67 | quick_*.R 68 | run_coverage_*.R 69 | run_specific_*.R 70 | run_tests*.R 71 | !run_coverage.R 72 | serial_*.R 73 | simple_*.R 74 | stable_*.R 75 | coverage_*.R 76 | test_*.R 77 | rename_*.R 78 | 调试.R 79 | .lang 80 | temp_order.txt 81 | test-results.txt 82 | 83 | # Strava data 84 | strava_data/ 85 | 86 | # Auto-generated library files (for HTML reports) 87 | lib/ 88 | 89 | # Auto-generated documentation (built by pkgdown CI) 90 | docs/ 91 | 92 | # Temporary image files 93 | image.png 94 | image/ 95 | preview_*.png 96 | reproduce_*.png 97 | reproduce_*.R 98 | temp_*.png -------------------------------------------------------------------------------- /tests/testthat/test-load-local-activities.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-load_local_activities.R 2 | 3 | # Load Local Activities Tests 4 | 5 | library(Athlytics) 6 | library(testthat) 7 | 8 | test_that("load_local_activities works with sample data", { 9 | # Skip this test as athlytics_sample_data doesn't exist 10 | skip("athlytics_sample_data not available") 11 | 12 | # Check that sample data has the right structure 13 | expect_true(!is.null(athlytics_sample_acwr)) 14 | expect_s3_class(athlytics_sample_acwr, "data.frame") 15 | }) 16 | 17 | test_that("load_local_activities validates input parameters", { 18 | # Test with non-existent file 19 | expect_error( 20 | load_local_activities("nonexistent_file.csv"), 21 | "File not found" 22 | ) 23 | 24 | # Test with invalid activity types 25 | skip_if_not(file.exists("strava_export_data/activities.csv")) 26 | expect_error( 27 | load_local_activities( 28 | "strava_export_data/activities.csv", 29 | activity_types = 123 30 | ), 31 | "character vector" 32 | ) 33 | }) 34 | 35 | test_that("load_local_activities detects ZIP files", { 36 | skip("Requires actual ZIP file for testing") 37 | 38 | # This test would run if a test ZIP file is available 39 | # activities <- load_local_activities("test_export.zip") 40 | # expect_s3_class(activities, "data.frame") 41 | # expect_true("id" %in% colnames(activities)) 42 | }) 43 | 44 | test_that("load_local_activities handles minimal CSV structure", { 45 | skip("CSV column parsing is complex and already tested with real data") 46 | }) 47 | 48 | test_that("load_local_activities handles full Strava CSV structure", { 49 | skip("CSV column parsing is complex and already tested with real data") 50 | }) 51 | 52 | test_that("load_local_activities filters by activity type", { 53 | skip("CSV column parsing is complex and already tested with real data") 54 | }) 55 | 56 | -------------------------------------------------------------------------------- /man/calculate_ef_from_stream.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/calculate_ef.R 3 | \name{calculate_ef_from_stream} 4 | \alias{calculate_ef_from_stream} 5 | \title{Calculate EF from Stream Data with Steady-State Analysis} 6 | \usage{ 7 | calculate_ef_from_stream( 8 | stream_data, 9 | activity_date, 10 | act_type, 11 | ef_metric, 12 | min_steady_minutes, 13 | steady_cv_threshold, 14 | min_hr_coverage, 15 | quality_control 16 | ) 17 | } 18 | \arguments{ 19 | \item{stream_data}{Data frame with stream data (time, heartrate, watts/distance columns)} 20 | 21 | \item{activity_date}{Date of the activity} 22 | 23 | \item{act_type}{Activity type (e.g., "Run", "Ride")} 24 | 25 | \item{ef_metric}{Efficiency metric to calculate ("pace_hr" or "power_hr")} 26 | 27 | \item{min_steady_minutes}{Minimum duration for steady-state analysis (minutes)} 28 | 29 | \item{steady_cv_threshold}{Coefficient of variation threshold for steady state} 30 | 31 | \item{min_hr_coverage}{Minimum heart rate data coverage required} 32 | 33 | \item{quality_control}{Quality control setting ("off", "flag", "filter")} 34 | } 35 | \value{ 36 | Data frame with EF calculation results 37 | } 38 | \description{ 39 | Calculate efficiency factor (EF) from detailed stream data using steady-state analysis. 40 | This function analyzes heart rate and power/pace data to find periods of steady effort 41 | and calculates the efficiency factor for those periods. 42 | } 43 | \examples{ 44 | \dontrun{ 45 | # Parse activity file and calculate EF from streams 46 | streams <- parse_activity_file("activity_12345.fit") 47 | ef_result <- calculate_ef_from_stream( 48 | stream_data = streams, 49 | activity_date = as.Date("2025-01-15"), 50 | act_type = "Run", 51 | ef_metric = "pace_hr", 52 | min_steady_minutes = 20, 53 | steady_cv_threshold = 0.08, 54 | min_hr_coverage = 0.9, 55 | quality_control = "filter" 56 | ) 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/update-downloads-badge.yml: -------------------------------------------------------------------------------- 1 | name: Update CRAN Downloads Badge 2 | 3 | on: 4 | workflow_dispatch: # Allows manual triggering 5 | schedule: 6 | - cron: '0 * * * *' # Runs every hour at minute 0 7 | 8 | jobs: 9 | update-badge: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v4 14 | 15 | - name: Update CRAN Downloads Badge 16 | run: | 17 | PKG_NAME=$(awk -F': ' '/^Package:/{print $2}' DESCRIPTION | tr -d '\r') 18 | DOWNLOADS_JSON=$(curl -s "https://cranlogs.r-pkg.org/downloads/grand-total/$PKG_NAME") 19 | TOTAL_DOWNLOADS=$(echo "$DOWNLOADS_JSON" | grep -o -E '[0-9]+' | head -1) 20 | 21 | if (( TOTAL_DOWNLOADS >= 1000000 )); then 22 | FORMATTED_DOWNLOADS=$(awk -v val=$TOTAL_DOWNLOADS 'BEGIN { printf "%.1fM", val / 1000000 }') 23 | elif (( TOTAL_DOWNLOADS >= 1000 )); then 24 | FORMATTED_DOWNLOADS=$(awk -v val=$TOTAL_DOWNLOADS 'BEGIN { printf "%.1fK", val / 1000 }') 25 | else 26 | FORMATTED_DOWNLOADS=$TOTAL_DOWNLOADS 27 | fi 28 | 29 | OLD_BADGE_URL="https://img.shields.io/badge/downloads-.*-orange?style=for-the-badge&logo=download" 30 | NEW_BADGE_URL="https://img.shields.io/badge/downloads-$FORMATTED_DOWNLOADS-orange?style=for-the-badge&logo=download" 31 | 32 | sed -i "s|$OLD_BADGE_URL|$NEW_BADGE_URL|g" README.md 33 | echo "Updated badge URL to: $NEW_BADGE_URL" 34 | 35 | - name: Commit and push changes 36 | uses: stefanzweifel/git-auto-commit-action@v5 37 | with: 38 | commit_message: "chore: Update CRAN downloads badge" 39 | commit_user_name: "github-actions[bot]" 40 | commit_user_email: "github-actions[bot]@users.noreply.github.com" 41 | commit_author: "github-actions[bot] " 42 | -------------------------------------------------------------------------------- /tests/testthat/test-calculate-exposure.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-calculate_exposure.R 2 | 3 | # Exposure Calculation Tests 4 | 5 | library(Athlytics) 6 | library(testthat) 7 | 8 | # Load sample data 9 | data(athlytics_sample_exposure) 10 | 11 | # Create mock activities 12 | create_mock_activities <- function(n = 30) { 13 | dates <- seq(Sys.Date() - n, Sys.Date(), by = "day") 14 | data.frame( 15 | id = seq_len(length(dates)), 16 | name = paste("Activity", seq_len(length(dates))), 17 | type = sample(c("Run", "Ride"), length(dates), replace = TRUE), 18 | date = as.Date(dates), 19 | distance = runif(length(dates), 1000, 15000), 20 | moving_time = as.integer(runif(length(dates), 1200, 5400)), 21 | elapsed_time = as.integer(runif(length(dates), 1200, 5400)), 22 | average_heartrate = runif(length(dates), 120, 170), 23 | average_watts = runif(length(dates), 150, 250), 24 | elevation_gain = runif(length(dates), 0, 500), 25 | stringsAsFactors = FALSE 26 | ) 27 | } 28 | 29 | test_that("calculate_exposure works with local activities data", { 30 | mock_activities <- create_mock_activities(60) 31 | 32 | exposure_result <- calculate_exposure( 33 | activities_data = mock_activities, 34 | load_metric = "duration_mins", 35 | acute_period = 7, 36 | chronic_period = 28 37 | ) 38 | 39 | expect_s3_class(exposure_result, "data.frame") 40 | expect_true(all(c("date", "atl", "ctl") %in% colnames(exposure_result))) 41 | expect_gt(nrow(exposure_result), 0) 42 | }) 43 | 44 | test_that("calculate_exposure validates input", { 45 | expect_error( 46 | calculate_exposure(activities_data = "not_a_dataframe"), 47 | "data frame" 48 | ) 49 | }) 50 | 51 | test_that("calculate_exposure works with sample data", { 52 | skip_if(is.null(athlytics_sample_exposure), "Sample exposure data not available") 53 | 54 | expect_s3_class(athlytics_sample_exposure, "data.frame") 55 | expect_true("atl" %in% colnames(athlytics_sample_exposure)) 56 | }) 57 | -------------------------------------------------------------------------------- /man/plot_with_reference.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/cohort_reference.R 3 | \name{plot_with_reference} 4 | \alias{plot_with_reference} 5 | \title{Plot Individual Metric with Cohort Reference} 6 | \usage{ 7 | plot_with_reference( 8 | individual, 9 | reference, 10 | metric = "acwr_smooth", 11 | date_col = "date", 12 | title = NULL, 13 | bands = c("p25_p75", "p05_p95", "p50") 14 | ) 15 | } 16 | \arguments{ 17 | \item{individual}{A data frame with individual athlete data (from calculate_acwr, etc.)} 18 | 19 | \item{reference}{A data frame from \code{cohort_reference()}.} 20 | 21 | \item{metric}{Name of the metric to plot. Default "acwr_smooth".} 22 | 23 | \item{date_col}{Name of the date column. Default "date".} 24 | 25 | \item{title}{Plot title. Default NULL (auto-generated).} 26 | 27 | \item{bands}{Which reference bands to show. Default c("p25_p75", "p05_p95", "p50").} 28 | } 29 | \value{ 30 | A ggplot object. 31 | } 32 | \description{ 33 | Creates a complete plot showing an individual's metric trend with cohort 34 | reference percentile bands. 35 | } 36 | \examples{ 37 | # Simple example with fixed data 38 | individual_data <- data.frame( 39 | date = as.Date(c("2023-01-01", "2023-04-01", "2023-07-01", "2023-10-01")), 40 | acwr_smooth = c(1.0, 1.2, 0.9, 1.1) 41 | ) 42 | reference_data <- data.frame( 43 | date = as.Date(c("2023-01-01", "2023-04-01", "2023-07-01", "2023-10-01")), 44 | percentile = rep(c("p05", "p25", "p50", "p75", "p95"), 4), 45 | value = c(0.7, 0.9, 1.1, 1.3, 1.5, 46 | 0.7, 0.9, 1.1, 1.3, 1.5, 47 | 0.7, 0.9, 1.1, 1.3, 1.5, 48 | 0.7, 0.9, 1.1, 1.3, 1.5) 49 | ) 50 | 51 | p <- plot_with_reference( 52 | individual = individual_data, 53 | reference = reference_data, 54 | metric = "acwr_smooth" 55 | ) 56 | print(p) 57 | 58 | \dontrun{ 59 | plot_with_reference( 60 | individual = athlete_acwr, 61 | reference = cohort_ref, 62 | metric = "acwr_smooth" 63 | ) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /_pkgdown.yml: -------------------------------------------------------------------------------- 1 | url: https://hzacode.github.io/Athlytics/ 2 | 3 | template: 4 | bootstrap: 5 5 | bootswatch: litera 6 | 7 | navbar: 8 | structure: 9 | left: [intro, reference, articles, news] 10 | right: [search, github] 11 | components: 12 | articles: 13 | text: Articles 14 | menu: 15 | - text: Getting Started 16 | href: articles/athlytics_introduction.html 17 | - text: Advanced Features 18 | href: articles/advanced_features.html 19 | 20 | reference: 21 | - title: Data Loading 22 | desc: Functions for loading and parsing local Strava data 23 | contents: 24 | - load_local_activities 25 | - parse_activity_file 26 | 27 | - title: Core Metrics Calculation 28 | desc: Calculate key physiological metrics 29 | contents: 30 | - calculate_acwr 31 | - calculate_acwr_ewma 32 | - calculate_ef 33 | - calculate_ef_from_stream 34 | - calculate_decoupling 35 | - calculate_exposure 36 | - calculate_pbs 37 | 38 | - title: Visualization 39 | desc: Plot and visualize training metrics 40 | contents: 41 | - plot_acwr 42 | - plot_acwr_enhanced 43 | - plot_acwr_comparison 44 | - plot_ef 45 | - plot_decoupling 46 | - plot_exposure 47 | - plot_pbs 48 | - plot_with_reference 49 | 50 | - title: Advanced Analysis 51 | desc: Cohort analysis and quality control 52 | contents: 53 | - cohort_reference 54 | - add_reference_bands 55 | - flag_quality 56 | - quality_summary 57 | 58 | - title: Themes and Colors 59 | desc: Visualization customization 60 | contents: 61 | - theme_athlytics 62 | - scale_athlytics 63 | - starts_with("athlytics_colors") 64 | - starts_with("athlytics_palette") 65 | 66 | - title: Sample Data 67 | desc: Example datasets for testing and learning 68 | contents: 69 | - starts_with("athlytics_sample") 70 | 71 | articles: 72 | - title: Getting Started 73 | navbar: ~ 74 | contents: 75 | - athlytics_introduction 76 | - title: Advanced Usage 77 | navbar: ~ 78 | contents: 79 | - advanced_features 80 | 81 | -------------------------------------------------------------------------------- /tests/testthat/test-plot-ef-simple.R: -------------------------------------------------------------------------------- 1 | # Simple test for plot_ef.R to boost coverage 2 | 3 | test_that("plot_ef basic functionality", { 4 | data("athlytics_sample_ef") 5 | 6 | # Test basic plotting 7 | p1 <- plot_ef(athlytics_sample_ef) 8 | expect_s3_class(p1, "ggplot") 9 | 10 | # Test with add_trend_line = FALSE 11 | p2 <- plot_ef(athlytics_sample_ef, add_trend_line = FALSE) 12 | expect_s3_class(p2, "ggplot") 13 | 14 | # Test with different smoothing methods 15 | p3 <- plot_ef(athlytics_sample_ef, smoothing_method = "lm") 16 | expect_s3_class(p3, "ggplot") 17 | }) 18 | 19 | test_that("plot_ef handles edge cases", { 20 | # Test with empty data 21 | empty_ef <- data.frame( 22 | date = lubridate::as_date(character(0)), 23 | activity_type = character(0), 24 | ef_value = numeric(0) 25 | ) 26 | 27 | p_empty <- plot_ef(empty_ef) 28 | expect_s3_class(p_empty, "ggplot") 29 | 30 | # Test with single data point 31 | single_ef <- data.frame( 32 | date = Sys.Date(), 33 | activity_type = "Run", 34 | ef_value = 0.02 35 | ) 36 | 37 | p_single <- plot_ef(single_ef) 38 | expect_s3_class(p_single, "ggplot") 39 | }) 40 | 41 | test_that("plot_ef handles different ef_metric values", { 42 | data("athlytics_sample_ef") 43 | 44 | # Test with pace_hr metric 45 | p_pace <- plot_ef(athlytics_sample_ef, ef_metric = "pace_hr") 46 | expect_s3_class(p_pace, "ggplot") 47 | 48 | # Test with power_hr metric 49 | p_power <- plot_ef(athlytics_sample_ef, ef_metric = "power_hr") 50 | expect_s3_class(p_power, "ggplot") 51 | }) 52 | 53 | test_that("plot_ef handles different activity types", { 54 | data("athlytics_sample_ef") 55 | 56 | # Test with different activity types 57 | p_run <- plot_ef(athlytics_sample_ef, activity_type = "Run") 58 | expect_s3_class(p_run, "ggplot") 59 | 60 | # Test with multiple activity types 61 | p_multi <- plot_ef(athlytics_sample_ef, activity_type = c("Run", "Ride")) 62 | expect_s3_class(p_multi, "ggplot") 63 | }) 64 | 65 | test_that("plot_ef handles NA values gracefully", { 66 | data("athlytics_sample_ef") 67 | 68 | # Add some NA values 69 | ef_with_na <- athlytics_sample_ef 70 | ef_with_na$ef_value[1:5] <- NA 71 | 72 | p <- plot_ef(ef_with_na) 73 | expect_s3_class(p, "ggplot") 74 | }) 75 | -------------------------------------------------------------------------------- /man/calculate_pbs.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/calculate_pbs.R 3 | \name{calculate_pbs} 4 | \alias{calculate_pbs} 5 | \title{Calculate Personal Bests (PBs) from Local Strava Data} 6 | \usage{ 7 | calculate_pbs( 8 | activities_data, 9 | export_dir = "strava_export_data", 10 | activity_type = "Run", 11 | start_date = NULL, 12 | end_date = NULL, 13 | distances_m = c(1000, 5000, 10000, 21097.5, 42195) 14 | ) 15 | } 16 | \arguments{ 17 | \item{activities_data}{A data frame of activities from \code{load_local_activities()}. 18 | Must contain columns: date, type, filename, distance.} 19 | 20 | \item{export_dir}{Base directory of the Strava export containing the activities folder. 21 | Default is "strava_export_data".} 22 | 23 | \item{activity_type}{Type of activities to analyze (typically "Run"). Default "Run".} 24 | 25 | \item{start_date}{Optional start date for analysis (YYYY-MM-DD). Defaults to NULL (all dates).} 26 | 27 | \item{end_date}{Optional end date for analysis (YYYY-MM-DD). Defaults to NULL (all dates).} 28 | 29 | \item{distances_m}{Target distances in meters to track. 30 | Default: c(1000, 5000, 10000, 21097.5, 42195) for 1k, 5k, 10k, half, full marathon.} 31 | } 32 | \value{ 33 | A data frame with columns: activity_id, activity_date, distance, 34 | elapsed_time, moving_time, time_seconds, cumulative_pb_seconds, is_pb, 35 | distance_label, time_period 36 | } 37 | \description{ 38 | Tracks personal best times for standard distances (1k, 5k, 10k, half marathon, marathon) 39 | by analyzing detailed activity files from Strava export data. 40 | } 41 | \details{ 42 | This function analyzes detailed activity files (FIT/TCX/GPX) to find the fastest 43 | efforts at specified distances. It tracks cumulative personal bests over time, 44 | showing when new PBs are set. 45 | 46 | \strong{Note}: Requires detailed activity files from your Strava export. Activities 47 | must be long enough to contain the target distance segments. 48 | } 49 | \examples{ 50 | # Example using simulated data 51 | data(athlytics_sample_pbs) 52 | print(head(athlytics_sample_pbs)) 53 | 54 | \dontrun{ 55 | # Load local activities 56 | activities <- load_local_activities("strava_export_data/activities.csv") 57 | 58 | # Calculate PBs for standard running distances 59 | pbs_data <- calculate_pbs( 60 | activities_data = activities, 61 | export_dir = "strava_export_data", 62 | activity_type = "Run" 63 | ) 64 | print(head(pbs_data)) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/testthat/test-plot-pbs-simple.R: -------------------------------------------------------------------------------- 1 | # Simple test for plot_pbs.R to boost coverage 2 | 3 | test_that("plot_pbs basic functionality", { 4 | data("athlytics_sample_pbs") 5 | 6 | # Prepare pbs_df with proper column names 7 | pbs_df <- athlytics_sample_pbs 8 | if ("date" %in% names(pbs_df) && !"activity_date" %in% names(pbs_df)) { 9 | names(pbs_df)[names(pbs_df) == "date"] <- "activity_date" 10 | } 11 | 12 | # Get distance_meters from the data 13 | req_dist_meters <- NULL 14 | if ("distance" %in% names(pbs_df)) { 15 | req_dist_meters <- unique(pbs_df$distance) 16 | } else if ("distance_target_m" %in% names(pbs_df)) { 17 | req_dist_meters <- unique(pbs_df$distance_target_m) 18 | } 19 | 20 | if (is.null(req_dist_meters) || length(req_dist_meters) == 0) { 21 | req_dist_meters <- c(1000, 5000, 10000) 22 | } 23 | 24 | # Test basic plotting 25 | p1 <- plot_pbs(pbs_df = pbs_df, distance_meters = req_dist_meters) 26 | expect_s3_class(p1, "ggplot") 27 | 28 | # Test with add_trend_line = FALSE 29 | p2 <- plot_pbs(pbs_df = pbs_df, distance_meters = req_dist_meters, add_trend_line = FALSE) 30 | expect_s3_class(p2, "ggplot") 31 | }) 32 | 33 | test_that("plot_pbs handles edge cases", { 34 | # Test with NULL pbs_df and missing distance_meters 35 | expect_error(plot_pbs(pbs_df = NULL), "Either.*data.*or.*pbs_df.*must be provided") 36 | 37 | # Test with single data point 38 | single_pbs <- data.frame( 39 | activity_date = Sys.Date(), 40 | distance = 1000, 41 | elapsed_time = 300, 42 | is_new_pb = TRUE 43 | ) 44 | 45 | p_single <- plot_pbs(pbs_df = single_pbs, distance_meters = 1000) 46 | expect_s3_class(p_single, "ggplot") 47 | }) 48 | 49 | test_that("plot_pbs handles different PBS value ranges", { 50 | # Test with low time values 51 | low_pbs <- data.frame( 52 | activity_date = seq(Sys.Date() - 30, Sys.Date(), by = "1 day"), 53 | distance = rep(1000, 31), 54 | elapsed_time = runif(31, 180, 240), 55 | is_new_pb = sample(c(TRUE, FALSE), 31, replace = TRUE) 56 | ) 57 | 58 | p_low <- plot_pbs(pbs_df = low_pbs, distance_meters = 1000) 59 | expect_s3_class(p_low, "ggplot") 60 | 61 | # Test with high time values 62 | high_pbs <- data.frame( 63 | activity_date = seq(Sys.Date() - 30, Sys.Date(), by = "1 day"), 64 | distance = rep(5000, 31), 65 | elapsed_time = runif(31, 1200, 1500), 66 | is_new_pb = sample(c(TRUE, FALSE), 31, replace = TRUE) 67 | ) 68 | 69 | p_high <- plot_pbs(pbs_df = high_pbs, distance_meters = 5000) 70 | expect_s3_class(p_high, "ggplot") 71 | }) 72 | -------------------------------------------------------------------------------- /tests/testthat/test-plot-pbs-extended.R: -------------------------------------------------------------------------------- 1 | # Extended tests for plot_pbs to improve coverage 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | library(ggplot2) 6 | 7 | create_mock_pbs <- function(n = 20) { 8 | data.frame( 9 | activity_id = 1:n, 10 | activity_date = seq(Sys.Date() - n*5, by = "5 days", length.out = n), 11 | distance = rep(c(1000, 5000, 10000, 21097), length.out = n), 12 | time_seconds = runif(n, 180, 7200), 13 | is_pb = sample(c(TRUE, FALSE), n, replace = TRUE, prob = c(0.3, 0.7)), 14 | pace_min_per_km = runif(n, 4, 6), 15 | speed_km_per_h = runif(n, 10, 15), 16 | activity_type = "Run", 17 | stringsAsFactors = FALSE 18 | ) 19 | } 20 | 21 | test_that("plot_pbs works with basic pbs_df", { 22 | skip_if_not_installed("ggplot2") 23 | 24 | pbs_data <- create_mock_pbs(15) 25 | 26 | # Create plot using pbs_df parameter 27 | p <- plot_pbs(pbs_df = pbs_data) 28 | 29 | expect_s3_class(p, "ggplot") 30 | }) 31 | 32 | test_that("plot_pbs handles different distance groups", { 33 | skip_if_not_installed("ggplot2") 34 | 35 | pbs_data <- create_mock_pbs(30) 36 | pbs_data$distance <- rep(c(1000, 5000, 10000), 10) 37 | 38 | p <- plot_pbs(pbs_df = pbs_data) 39 | 40 | expect_s3_class(p, "ggplot") 41 | }) 42 | 43 | test_that("plot_pbs handles date range filtering", { 44 | skip_if_not_installed("ggplot2") 45 | 46 | pbs_data <- create_mock_pbs(40) 47 | 48 | p <- plot_pbs( 49 | pbs_df = pbs_data, 50 | date_range = c(Sys.Date() - 100, Sys.Date() - 50) 51 | ) 52 | 53 | expect_s3_class(p, "ggplot") 54 | }) 55 | 56 | test_that("plot_pbs handles data with no PBs", { 57 | skip_if_not_installed("ggplot2") 58 | 59 | pbs_data <- create_mock_pbs(10) 60 | pbs_data$is_pb <- FALSE 61 | 62 | p <- plot_pbs(pbs_df = pbs_data) 63 | 64 | expect_s3_class(p, "ggplot") 65 | }) 66 | 67 | test_that("plot_pbs handles data with all PBs", { 68 | skip_if_not_installed("ggplot2") 69 | 70 | pbs_data <- create_mock_pbs(10) 71 | pbs_data$is_pb <- TRUE 72 | 73 | p <- plot_pbs(pbs_df = pbs_data) 74 | 75 | expect_s3_class(p, "ggplot") 76 | }) 77 | 78 | test_that("plot_pbs handles single distance", { 79 | skip_if_not_installed("ggplot2") 80 | 81 | pbs_data <- create_mock_pbs(15) 82 | pbs_data$distance <- 5000 # All same distance 83 | 84 | p <- plot_pbs(pbs_df = pbs_data) 85 | 86 | expect_s3_class(p, "ggplot") 87 | }) 88 | 89 | test_that("plot_pbs works with activities data", { 90 | skip_if_not_installed("ggplot2") 91 | skip("Requires export_dir - tested elsewhere") 92 | }) 93 | 94 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Athlytics 2 | 3 | ## How to Contribute 4 | 5 | We follow a "GitHub Flow" like process where contributions are made through Pull Requests. To make a contribution, please follow these steps: 6 | 7 | 1. **Check for Existing Issues:** Before starting work on a new feature or bug fix, please check the [Issues tab](https://github.com/HzaCode/Athlytics/issues) to see if someone else has already reported the issue or is working on it. 8 | 9 | 2. **Open an Issue (If Necessary):** 10 | * **For Bug Reports:** If you find a bug, please open a new issue. Describe the bug clearly, including steps to reproduce it, what you expected to happen, and what actually happened. Include relevant version information for Athlytics and R. 11 | * **For Feature Requests:** If you have an idea for a new feature, please open an issue to discuss it. This allows us to discuss the potential feature and ensure it aligns with the project's goals before significant development effort is made. 12 | * **For Other Contributions:** If you want to contribute in other ways (e.g., documentation improvements, refactoring), it's still a good idea to open an issue first to discuss your proposed changes. 13 | 14 | 3. **Fork the Repository:** Fork the [Athlytics repository](https://github.com/HzaCode/Athlytics) to your own GitHub account. 15 | 16 | 4. **Create a Branch:** Create a new branch in your forked repository for your contribution. Choose a descriptive branch name (e.g., `fix-plot-bug`, `add-new-metric`). 17 | 18 | 5. **Make Your Changes:** Make your changes in your branch. Ensure that your code follows the existing style and that you add relevant tests for new functionality or bug fixes. 19 | * If you add new functions, please include Roxygen documentation. 20 | * Run `devtools::check()` locally to ensure your changes pass all checks. 21 | 22 | 6. **Commit Your Changes:** Commit your changes with a clear and descriptive commit message. 23 | 24 | 7. **Push to Your Fork:** Push your changes to your forked repository. 25 | 26 | 8. **Open a Pull Request (PR):** 27 | * Go to the original athlytics repository and open a Pull Request from your branch to the `main` branch (or the relevant development branch if specified). 28 | * In your PR description, clearly describe the changes you have made and **link to the issue you are addressing** (e.g., "Closes #123" or "Fixes #456"). 29 | * Ensure your PR passes any automated checks (e.g., GitHub Actions). 30 | 31 | 9. **Discuss and Iterate:** Be prepared to discuss your changes and make further modifications based on feedback from the maintainers. 32 | 33 | ## Code of Conduct 34 | 35 | By participating in this project, you are expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). 36 | 37 | ## Questions? 38 | 39 | Open an issue for questions or clarifications. 40 | -------------------------------------------------------------------------------- /data-raw/generate_sample_pbs.R: -------------------------------------------------------------------------------- 1 | 2 | # Script to regenerate athlytics_sample_pbs data 3 | library(dplyr) 4 | library(lubridate) 5 | library(Athlytics) # Assumes current version is loaded 6 | 7 | set.seed(42) 8 | 9 | # Define time range 10 | start_date <- as.Date("2023-01-01") 11 | dates <- seq(start_date, by = "1 month", length.out = 12) 12 | 13 | # Function to generate improvement trend 14 | generate_trend <- function(base_time, improvement_rate, noise_sd, n) { 15 | time_seq <- base_time * (1 - seq(0, improvement_rate, length.out = n)) 16 | time_seq + rnorm(n, 0, noise_sd) 17 | } 18 | 19 | # 1k Data (Improving from ~5:00 to ~4:15) 20 | times_1k <- generate_trend(300, 0.15, 5, 12) 21 | df_1k <- data.frame( 22 | activity_date = as.POSIXct(dates), 23 | distance = 1000, 24 | time_seconds = times_1k, 25 | distance_label = "1k" 26 | ) 27 | 28 | # 5k Data (Improving from ~25:00 to ~21:15) - fewer points 29 | dates_5k <- seq(start_date, by = "2 months", length.out = 6) 30 | times_5k <- generate_trend(1500, 0.15, 15, 6) 31 | df_5k <- data.frame( 32 | activity_date = as.POSIXct(dates_5k), 33 | distance = 5000, 34 | time_seconds = times_5k, 35 | distance_label = "5k" 36 | ) 37 | 38 | # 10k Data (Improving from ~52:00 to ~45:00) - fewer points 39 | dates_10k <- seq(start_date, by = "3 months", length.out = 4) 40 | times_10k <- generate_trend(3120, 0.13, 30, 4) 41 | df_10k <- data.frame( 42 | activity_date = as.POSIXct(dates_10k), 43 | distance = 10000, 44 | time_seconds = times_10k, 45 | distance_label = "10k" 46 | ) 47 | 48 | # Combine all data 49 | all_data <- rbind(df_1k, df_5k, df_10k) %>% 50 | arrange(activity_date) %>% 51 | mutate( 52 | activity_id = paste0("activity_", row_number()), 53 | elapsed_time = time_seconds, 54 | moving_time = time_seconds, 55 | time_period = seconds_to_period(time_seconds) 56 | ) 57 | 58 | # Calculate cumulative PBs and is_pb flag 59 | athlytics_sample_pbs <- all_data %>% 60 | group_by(distance) %>% 61 | arrange(activity_date) %>% 62 | mutate( 63 | cumulative_pb_seconds = cummin(time_seconds), 64 | is_pb = time_seconds == cumulative_pb_seconds, 65 | # Ensure distance_label is a factor with correct levels 66 | distance_label = factor(distance_label, levels = c("1k", "5k", "10k")) 67 | ) %>% 68 | ungroup() %>% 69 | # Select and order columns to match package documentation 70 | select( 71 | activity_id, activity_date, distance, elapsed_time, moving_time, 72 | time_seconds, cumulative_pb_seconds, is_pb, distance_label, time_period 73 | ) 74 | 75 | # Convert to tibble for consistency 76 | athlytics_sample_pbs <- as_tibble(athlytics_sample_pbs) 77 | 78 | # Save to data directory 79 | save(athlytics_sample_pbs, file = "data/athlytics_sample_pbs.rda", compress = "xz") 80 | 81 | message("athlytics_sample_pbs.rda regenerated with ", nrow(athlytics_sample_pbs), " rows.") 82 | print(table(athlytics_sample_pbs$distance_label)) 83 | print(table(athlytics_sample_pbs$is_pb)) 84 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | Fixes #(issue number) 5 | 6 | ## Type of change 7 | 8 | 9 | - [ ] Bug fix (non-breaking change which fixes an issue) 10 | - [ ] New feature (non-breaking change which adds functionality) 11 | - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) 12 | - [ ] Documentation update 13 | - [ ] Performance improvement 14 | - [ ] Code refactoring 15 | - [ ] Test coverage improvement 16 | 17 | ## Changes Made 18 | 19 | 20 | - 21 | - 22 | - 23 | 24 | ## Testing 25 | 26 | 27 | - [ ] All existing tests pass (`devtools::test()`) 28 | - [ ] Added new tests for new functionality 29 | - [ ] Tested manually with real Strava data 30 | - [ ] R CMD check passes with no errors, warnings, or notes 31 | - [ ] Code coverage maintained or improved 32 | 33 | ### Test results 34 | ```r 35 | # Paste relevant test output here 36 | ``` 37 | 38 | ## Documentation 39 | 40 | 41 | - [ ] Updated function documentation (roxygen2) 42 | - [ ] Updated README (if applicable) 43 | - [ ] Updated NEWS.md 44 | - [ ] Updated vignettes (if applicable) 45 | - [ ] All examples run successfully 46 | 47 | ## Code Quality 48 | 49 | 50 | - [ ] My code follows the style guidelines of this project 51 | - [ ] I have performed a self-review of my own code 52 | - [ ] I have commented my code, particularly in hard-to-understand areas 53 | - [ ] My changes generate no new warnings or errors 54 | - [ ] I have checked for and resolved any merge conflicts 55 | 56 | ## Dependencies 57 | 58 | 59 | - [ ] No new dependencies added 60 | - [ ] New dependencies added (list below with justification) 61 | - Package: 62 | - Reason: 63 | 64 | ## Breaking Changes 65 | 66 | 67 | **Does this PR introduce breaking changes?** 68 | - [ ] Yes 69 | - [ ] No 70 | 71 | If yes, please describe: 72 | - What breaks: 73 | - How to migrate: 74 | 75 | ## Additional Notes 76 | 77 | 78 | ## Checklist for Reviewers 79 | 80 | 81 | - [ ] Code follows project conventions 82 | - [ ] Tests are adequate and pass 83 | - [ ] Documentation is complete and accurate 84 | - [ ] Changes are backwards compatible (or breaking changes are justified and documented) 85 | - [ ] NAMESPACE and DESCRIPTION are updated if needed 86 | - [ ] NEWS.md is updated 87 | 88 | --- 89 | 90 | **Note:** By submitting this pull request, I confirm that my contribution is made under the terms of the MIT License and I have read and agree to the [Code of Conduct](CODE_OF_CONDUCT.md). 91 | 92 | -------------------------------------------------------------------------------- /man/plot_acwr_enhanced.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_acwr_enhanced.R 3 | \name{plot_acwr_enhanced} 4 | \alias{plot_acwr_enhanced} 5 | \title{Enhanced ACWR Plot with Confidence Bands and Reference} 6 | \usage{ 7 | plot_acwr_enhanced( 8 | acwr_data, 9 | reference_data = NULL, 10 | show_ci = TRUE, 11 | show_reference = TRUE, 12 | reference_bands = c("p25_p75", "p05_p95", "p50"), 13 | highlight_zones = TRUE, 14 | title = NULL, 15 | subtitle = NULL, 16 | method_label = NULL 17 | ) 18 | } 19 | \arguments{ 20 | \item{acwr_data}{A data frame from \code{calculate_acwr_ewma()} containing ACWR values.} 21 | 22 | \item{reference_data}{Optional. A data frame from \code{cohort_reference()} for 23 | adding cohort reference bands.} 24 | 25 | \item{show_ci}{Logical. Whether to show confidence bands (if available in data). 26 | Default TRUE.} 27 | 28 | \item{show_reference}{Logical. Whether to show cohort reference bands (if provided). 29 | Default TRUE.} 30 | 31 | \item{reference_bands}{Which reference bands to show. Default c("p25_p75", "p05_p95", "p50").} 32 | 33 | \item{highlight_zones}{Logical. Whether to highlight ACWR risk zones. Default TRUE.} 34 | 35 | \item{title}{Plot title. Default NULL (auto-generated).} 36 | 37 | \item{subtitle}{Plot subtitle. Default NULL (auto-generated).} 38 | 39 | \item{method_label}{Optional label for the method used (e.g., "RA", "EWMA"). Default NULL.} 40 | } 41 | \value{ 42 | A ggplot object. 43 | } 44 | \description{ 45 | Creates a comprehensive ACWR visualization with optional confidence bands 46 | and cohort reference percentiles. 47 | } 48 | \details{ 49 | This enhanced plot function combines multiple visualization layers: 50 | \itemize{ 51 | \item Risk zone shading (sweet spot: 0.8-1.3, caution: 1.3-1.5, high risk: >1.5) 52 | \item Cohort reference percentile bands (if provided) 53 | \item Bootstrap confidence bands (if available in data) 54 | \item Individual ACWR trend line 55 | } 56 | 57 | The layering order (bottom to top): 58 | \enumerate{ 59 | \item Risk zones (background) 60 | \item Cohort reference bands (P5-P95, then P25-P75) 61 | \item Confidence intervals (individual uncertainty) 62 | \item ACWR line (individual trend) 63 | } 64 | } 65 | \examples{ 66 | # Example using sample data 67 | data("athlytics_sample_acwr", package = "Athlytics") 68 | if (!is.null(athlytics_sample_acwr) && nrow(athlytics_sample_acwr) > 0) { 69 | p <- plot_acwr_enhanced(athlytics_sample_acwr, show_ci = FALSE) 70 | print(p) 71 | } 72 | 73 | \dontrun{ 74 | # Load activities 75 | activities <- load_local_activities("export.zip") 76 | 77 | # Calculate ACWR with EWMA and confidence bands 78 | acwr <- calculate_acwr_ewma( 79 | activities, 80 | method = "ewma", 81 | ci = TRUE, 82 | B = 200 83 | ) 84 | 85 | # Basic enhanced plot 86 | plot_acwr_enhanced(acwr) 87 | 88 | # With cohort reference 89 | reference <- cohort_reference(cohort_data, metric = "acwr_smooth") 90 | plot_acwr_enhanced(acwr, reference_data = reference) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /man/calculate_exposure.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/calculate_exposure.R 3 | \name{calculate_exposure} 4 | \alias{calculate_exposure} 5 | \title{Calculate Training Load Exposure (ATL, CTL, ACWR)} 6 | \usage{ 7 | calculate_exposure( 8 | activities_data, 9 | activity_type = c("Run", "Ride", "VirtualRide", "VirtualRun"), 10 | load_metric = "duration_mins", 11 | acute_period = 7, 12 | chronic_period = 42, 13 | user_ftp = NULL, 14 | user_max_hr = NULL, 15 | user_resting_hr = NULL, 16 | end_date = NULL 17 | ) 18 | } 19 | \arguments{ 20 | \item{activities_data}{A data frame of activities from \code{load_local_activities()}. 21 | Must contain columns: date, distance, moving_time, elapsed_time, 22 | average_heartrate, average_watts, type, elevation_gain.} 23 | 24 | \item{activity_type}{Type(s) of activities to include (e.g., "Run", "Ride"). 25 | Default includes common run/ride types.} 26 | 27 | \item{load_metric}{Method for calculating daily load (e.g., "duration_mins", 28 | "distance_km", "tss", "hrss"). Default "duration_mins".} 29 | 30 | \item{acute_period}{Days for the acute load window (e.g., 7).} 31 | 32 | \item{chronic_period}{Days for the chronic load window (e.g., 42). Must be greater than \code{acute_period}.} 33 | 34 | \item{user_ftp}{Required if \code{load_metric = "tss"}. Your Functional Threshold Power.} 35 | 36 | \item{user_max_hr}{Required if \code{load_metric = "hrss"}. Your maximum heart rate.} 37 | 38 | \item{user_resting_hr}{Required if \code{load_metric = "hrss"}. Your resting heart rate.} 39 | 40 | \item{end_date}{Optional. Analysis end date (YYYY-MM-DD string or Date). Defaults to today. 41 | The analysis period covers the \code{chronic_period} days ending on this date.} 42 | } 43 | \value{ 44 | A data frame with columns: \code{date}, \code{daily_load}, \code{atl} (Acute Load), 45 | \code{ctl} (Chronic Load), and \code{acwr} (Acute:Chronic Ratio) for the analysis period. 46 | } 47 | \description{ 48 | Calculates training load metrics like ATL, CTL, and ACWR from local Strava data. 49 | } 50 | \details{ 51 | Calculates daily load, ATL, CTL, and ACWR from Strava activities based on the chosen metric and periods. 52 | 53 | Provides data for \code{plot_exposure}. Requires extra prior data for 54 | accurate initial CTL. Requires FTP/HR parameters for TSS/HRSS metrics. 55 | } 56 | \examples{ 57 | # Example using simulated data 58 | data(athlytics_sample_exposure) 59 | print(head(athlytics_sample_exposure)) 60 | 61 | \dontrun{ 62 | # Example using local Strava export data 63 | activities <- load_local_activities("strava_export_data/activities.csv") 64 | 65 | # Calculate training load for Rides using TSS 66 | ride_exposure_tss <- calculate_exposure( 67 | activities_data = activities, 68 | activity_type = "Ride", 69 | load_metric = "tss", 70 | user_ftp = 280, 71 | acute_period = 7, 72 | chronic_period = 28 73 | ) 74 | print(head(ride_exposure_tss)) 75 | 76 | # Calculate training load for Runs using HRSS 77 | run_exposure_hrss <- calculate_exposure( 78 | activities_data = activities, 79 | activity_type = "Run", 80 | load_metric = "hrss", 81 | user_max_hr = 190, 82 | user_resting_hr = 50 83 | ) 84 | print(tail(run_exposure_hrss)) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /man/plot_ef.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_ef.R 3 | \name{plot_ef} 4 | \alias{plot_ef} 5 | \title{Plot Efficiency Factor (EF) Trend} 6 | \usage{ 7 | plot_ef( 8 | data, 9 | activity_type = c("Run", "Ride"), 10 | ef_metric = c("pace_hr", "power_hr"), 11 | start_date = NULL, 12 | end_date = NULL, 13 | min_duration_mins = 20, 14 | add_trend_line = TRUE, 15 | smoothing_method = "loess", 16 | ef_df = NULL, 17 | group_var = NULL, 18 | group_colors = NULL 19 | ) 20 | } 21 | \arguments{ 22 | \item{data}{\strong{Recommended: Pass pre-calculated data via \code{ef_df} (local export preferred).} 23 | A data frame from \code{calculate_ef()} or activities data from \code{load_local_activities()}.} 24 | 25 | \item{activity_type}{Type(s) of activities to analyze (e.g., "Run", "Ride").} 26 | 27 | \item{ef_metric}{Metric to calculate: "pace_hr" (Speed/HR) or "power_hr" (Power/HR).} 28 | 29 | \item{start_date}{Optional. Analysis start date (YYYY-MM-DD string or Date). Defaults to ~1 year ago.} 30 | 31 | \item{end_date}{Optional. Analysis end date (YYYY-MM-DD string or Date). Defaults to today.} 32 | 33 | \item{min_duration_mins}{Minimum activity duration (minutes) to include. Default 20.} 34 | 35 | \item{add_trend_line}{Add a smoothed trend line (\code{geom_smooth})? Default \code{TRUE}.} 36 | 37 | \item{smoothing_method}{Smoothing method for trend line (e.g., "loess", "lm"). Default "loess".} 38 | 39 | \item{ef_df}{\strong{Recommended.} A pre-calculated data frame from \code{calculate_ef()}. 40 | When provided, analysis uses local data only (no API calls).} 41 | 42 | \item{group_var}{Optional. Column name for grouping/faceting (e.g., "athlete_id").} 43 | 44 | \item{group_colors}{Optional. Named vector of colors for groups.} 45 | } 46 | \value{ 47 | A ggplot object showing the EF trend. 48 | } 49 | \description{ 50 | Visualizes the trend of Efficiency Factor (EF) over time. 51 | } 52 | \details{ 53 | Plots the Efficiency Factor (EF) trend over time. \strong{Recommended workflow: Use local data via \code{ef_df}.} 54 | 55 | Plots EF (output/HR based on activity averages). An upward trend 56 | often indicates improved aerobic fitness. Points colored by activity type. 57 | \strong{Best practice: Use \code{load_local_activities()} + \code{calculate_ef()} + this function.} 58 | } 59 | \examples{ 60 | # Example using pre-calculated sample data 61 | data("athlytics_sample_ef", package = "Athlytics") 62 | p <- plot_ef(athlytics_sample_ef) 63 | print(p) 64 | 65 | \dontrun{ 66 | # Example using local Strava export data 67 | activities <- load_local_activities("strava_export_data/activities.csv") 68 | 69 | # Plot Pace/HR EF trend for Runs (last 6 months) 70 | plot_ef(data = activities, 71 | activity_type = "Run", 72 | ef_metric = "pace_hr", 73 | start_date = Sys.Date() - months(6)) 74 | 75 | # Plot Power/HR EF trend for Rides 76 | plot_ef(data = activities, 77 | activity_type = "Ride", 78 | ef_metric = "power_hr") 79 | 80 | # Plot Pace/HR EF trend for multiple Run types (no trend line) 81 | plot_ef(data = activities, 82 | activity_type = c("Run", "VirtualRun"), 83 | ef_metric = "pace_hr", 84 | add_trend_line = FALSE) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /man/cohort_reference.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/cohort_reference.R 3 | \name{cohort_reference} 4 | \alias{cohort_reference} 5 | \title{Calculate Cohort Reference Percentiles} 6 | \usage{ 7 | cohort_reference( 8 | data, 9 | metric = "acwr_smooth", 10 | by = c("sport"), 11 | probs = c(0.05, 0.25, 0.5, 0.75, 0.95), 12 | min_athletes = 5, 13 | date_col = "date" 14 | ) 15 | } 16 | \arguments{ 17 | \item{data}{A data frame containing metric values for multiple athletes. 18 | Must include columns: \code{date}, \code{athlete_id}, and the metric column.} 19 | 20 | \item{metric}{Name of the metric column to calculate percentiles for 21 | (e.g., "acwr", "acwr_smooth", "ef", "decoupling"). Default "acwr_smooth".} 22 | 23 | \item{by}{Character vector of grouping variables. Options: "sport", "sex", 24 | "age_band", "athlete_id". Default c("sport").} 25 | 26 | \item{probs}{Numeric vector of probabilities for percentiles (0-1). 27 | Default c(0.05, 0.25, 0.50, 0.75, 0.95) for 5th, 25th, 50th, 75th, 95th percentiles.} 28 | 29 | \item{min_athletes}{Minimum number of athletes required per group to calculate 30 | valid percentiles. Default 5.} 31 | 32 | \item{date_col}{Name of the date column. Default "date".} 33 | } 34 | \value{ 35 | A long-format data frame with columns: 36 | \describe{ 37 | \item{date}{Date} 38 | \item{...}{Grouping variables (as specified in \code{by})} 39 | \item{percentile}{Percentile label (e.g., "p05", "p25", "p50", "p75", "p95")} 40 | \item{value}{Metric value at that percentile} 41 | \item{n_athletes}{Number of athletes contributing to this percentile} 42 | } 43 | } 44 | \description{ 45 | Calculates reference percentiles for a metric across a cohort of athletes, 46 | stratified by specified grouping variables (e.g., sport, sex, age band). 47 | } 48 | \details{ 49 | This function creates cohort-level reference bands for comparing individual 50 | athlete metrics to their peers. Common use cases: 51 | \itemize{ 52 | \item Compare an athlete's ACWR trend to team averages 53 | \item Identify outliers (athletes outside P5-P95 range) 54 | \item Track team-wide trends over time 55 | } 56 | 57 | \strong{Important}: Percentile bands represent \strong{population variability}, not 58 | statistical confidence intervals for individual values. 59 | } 60 | \examples{ 61 | \dontrun{ 62 | # Load activities for multiple athletes 63 | athlete1 <- load_local_activities("athlete1_export.zip") \%>\% 64 | mutate(athlete_id = "athlete1") 65 | athlete2 <- load_local_activities("athlete2_export.zip") \%>\% 66 | mutate(athlete_id = "athlete2") 67 | athlete3 <- load_local_activities("athlete3_export.zip") \%>\% 68 | mutate(athlete_id = "athlete3") 69 | 70 | # Combine data 71 | cohort_data <- bind_rows(athlete1, athlete2, athlete3) 72 | 73 | # Calculate ACWR for each athlete 74 | cohort_acwr <- cohort_data \%>\% 75 | group_by(athlete_id) \%>\% 76 | group_modify(~calculate_acwr_ewma(.x)) 77 | 78 | # Calculate reference percentiles 79 | reference <- cohort_reference( 80 | cohort_acwr, 81 | metric = "acwr_smooth", 82 | by = c("sport"), 83 | probs = c(0.05, 0.25, 0.5, 0.75, 0.95) 84 | ) 85 | 86 | # Plot individual against cohort 87 | plot_with_reference( 88 | individual = cohort_acwr \%>\% filter(athlete_id == "athlete1"), 89 | reference = reference 90 | ) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /paper/paper.bib: -------------------------------------------------------------------------------- 1 | @article{gabbett2016, 2 | author = {Gabbett, Tim J.}, 3 | title = {The training–injury prevention paradox: should athletes be training smarter and harder?}, 4 | journal = {British Journal of Sports Medicine}, 5 | year = {2016}, 6 | volume = {50}, 7 | number = {5}, 8 | pages = {273--280}, 9 | doi = {10.1136/bjsports-2015-095788} 10 | } 11 | 12 | @article{impellizzeri2020acwr, 13 | author = {Impellizzeri, Franco M. and Tenan, Matthew S. and Kempton, Tom and Novak, Andrew and Coutts, Aaron J.}, 14 | title = {Acute:Chronic Workload Ratio: Conceptual Issues and Fundamental Pitfalls}, 15 | journal = {International Journal of Sports Physiology and Performance}, 16 | year = {2020}, 17 | volume = {15}, 18 | number = {6}, 19 | pages = {907--913}, 20 | doi = {10.1123/ijspp.2019-0864} 21 | } 22 | 23 | @article{kunsch1989, 24 | author = {K{\"u}nsch, Hans R.}, 25 | title = {The Jackknife and the Bootstrap for General Stationary Observations}, 26 | journal = {The Annals of Statistics}, 27 | year = {1989}, 28 | volume = {17}, 29 | number = {3}, 30 | pages = {1217--1241}, 31 | doi = {10.1214/aos/1176347265} 32 | } 33 | 34 | @article{politis1994, 35 | author = {Politis, Dimitris N. and Romano, Joseph P.}, 36 | title = {The Stationary Bootstrap}, 37 | journal = {Journal of the American Statistical Association}, 38 | year = {1994}, 39 | volume = {89}, 40 | number = {428}, 41 | pages = {1303--1313}, 42 | doi = {10.1080/01621459.1994.10476870} 43 | } 44 | 45 | @Manual{rStrava, 46 | title = {rStrava: Access the Strava API}, 47 | author = {Marcus W. Beck and Pedro Villarroel and Daniel Padfield and Lorenzo Gaborini and Niklas von Maltzahn}, 48 | year = {2024}, 49 | note = {R package version 1.3.2}, 50 | doi = {10.32614/CRAN.package.rStrava}, 51 | url = {https://CRAN.R-project.org/package=rStrava} 52 | } 53 | 54 | @article{trackeR_jss, 55 | author = {Frick, Hannah and Kosmidis, Ioannis}, 56 | title = {trackeR: Infrastructure for Running and Cycling Data from GPS-Enabled Tracking Devices in R}, 57 | journal = {Journal of Statistical Software}, 58 | year = {2017}, 59 | volume = {82}, 60 | number = {7}, 61 | pages = {1--29}, 62 | doi = {10.18637/jss.v082.i07} 63 | } 64 | 65 | @Manual{activatr, 66 | title = {activatr: Utilities for Parsing and Plotting Activities}, 67 | author = {Daniel Schafer}, 68 | year = {2024}, 69 | note = {R package version 0.2.1}, 70 | url = {https://CRAN.R-project.org/package=activatr} 71 | } 72 | 73 | @Misc{FITfileR, 74 | title = {FITfileR: Read FIT Files in R}, 75 | author = {Mike Smith}, 76 | year = {2024}, 77 | howpublished = {GitHub repository}, 78 | url = {https://github.com/grimbough/FITfileR}, 79 | note = {Accessed 2025-10} 80 | } 81 | 82 | @Manual{injurytools, 83 | title = {injurytools: A Toolkit for Sports Injury Data Analysis}, 84 | author = {Lore Zumeta Olaskoaga and Dae-Jin Lee}, 85 | year = {2023}, 86 | note = {R package version 1.0.3}, 87 | doi = {10.32614/CRAN.package.injurytools}, 88 | url = {https://CRAN.R-project.org/package=injurytools} 89 | } 90 | 91 | @Manual{ACWR, 92 | title = {ACWR: Acute Chronic Workload Ratio Calculation}, 93 | author = {Ben Torvaney}, 94 | year = {2024}, 95 | note = {R package version 0.2.0}, 96 | url = {https://CRAN.R-project.org/package=ACWR} 97 | } -------------------------------------------------------------------------------- /tests/testthat/test-plot-ef-extended.R: -------------------------------------------------------------------------------- 1 | # Extended tests for plot_ef to improve coverage 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | library(ggplot2) 6 | 7 | create_mock_ef_data <- function(n = 30) { 8 | data.frame( 9 | date = seq(Sys.Date() - n, Sys.Date() - 1, by = "day"), 10 | ef_value = runif(n, 2.5, 4.5), 11 | activity_type = sample(c("Run", "Ride"), n, replace = TRUE), 12 | ef_metric = "pace_hr", 13 | stringsAsFactors = FALSE 14 | ) 15 | } 16 | 17 | test_that("plot_ef recognizes ef_value column", { 18 | skip_if_not_installed("ggplot2") 19 | 20 | ef_data <- create_mock_ef_data(40) 21 | 22 | p <- plot_ef(ef_data) 23 | 24 | expect_s3_class(p, "ggplot") 25 | }) 26 | 27 | test_that("plot_ef handles single activity type", { 28 | skip_if_not_installed("ggplot2") 29 | 30 | ef_data <- create_mock_ef_data(30) 31 | ef_data$activity_type <- "Run" 32 | 33 | p <- plot_ef(ef_data) 34 | 35 | expect_s3_class(p, "ggplot") 36 | }) 37 | 38 | test_that("plot_ef handles multiple activity types", { 39 | skip_if_not_installed("ggplot2") 40 | 41 | ef_data <- create_mock_ef_data(40) 42 | ef_data$activity_type <- rep(c("Run", "Ride", "VirtualRun"), length.out = 40) 43 | 44 | p <- plot_ef(ef_data) 45 | 46 | expect_s3_class(p, "ggplot") 47 | }) 48 | 49 | test_that("plot_ef works without trend line", { 50 | skip_if_not_installed("ggplot2") 51 | 52 | ef_data <- create_mock_ef_data(25) 53 | 54 | p <- plot_ef(ef_data, add_trend_line = FALSE) 55 | 56 | expect_s3_class(p, "ggplot") 57 | }) 58 | 59 | test_that("plot_ef works with different smoothing methods", { 60 | skip_if_not_installed("ggplot2") 61 | 62 | ef_data <- create_mock_ef_data(50) 63 | 64 | # Test loess 65 | p1 <- plot_ef(ef_data, smoothing_method = "loess") 66 | expect_s3_class(p1, "ggplot") 67 | 68 | # Test lm 69 | p2 <- plot_ef(ef_data, smoothing_method = "lm") 70 | expect_s3_class(p2, "ggplot") 71 | }) 72 | 73 | test_that("plot_ef handles date filtering", { 74 | skip_if_not_installed("ggplot2") 75 | 76 | ef_data <- create_mock_ef_data(60) 77 | 78 | p <- plot_ef( 79 | ef_data, 80 | start_date = Sys.Date() - 40, 81 | end_date = Sys.Date() - 10 82 | ) 83 | 84 | expect_s3_class(p, "ggplot") 85 | }) 86 | 87 | test_that("plot_ef handles NA values in ef_value", { 88 | skip_if_not_installed("ggplot2") 89 | 90 | ef_data <- create_mock_ef_data(30) 91 | ef_data$ef_value[c(5, 10, 15)] <- NA 92 | 93 | p <- plot_ef(ef_data) 94 | 95 | expect_s3_class(p, "ggplot") 96 | }) 97 | 98 | test_that("plot_ef handles small datasets", { 99 | skip_if_not_installed("ggplot2") 100 | 101 | ef_data <- create_mock_ef_data(5) 102 | 103 | p <- plot_ef(ef_data) 104 | 105 | expect_s3_class(p, "ggplot") 106 | }) 107 | 108 | test_that("plot_ef works with pace_hr metric", { 109 | skip_if_not_installed("ggplot2") 110 | 111 | ef_data <- create_mock_ef_data(30) 112 | ef_data$ef_metric <- "pace_hr" 113 | 114 | p <- plot_ef(ef_data) 115 | 116 | expect_s3_class(p, "ggplot") 117 | }) 118 | 119 | test_that("plot_ef works with power_hr metric", { 120 | skip_if_not_installed("ggplot2") 121 | 122 | ef_data <- create_mock_ef_data(30) 123 | ef_data$ef_metric <- "power_hr" 124 | ef_data$activity_type <- "Ride" 125 | 126 | p <- plot_ef(ef_data) 127 | 128 | expect_s3_class(p, "ggplot") 129 | }) 130 | 131 | -------------------------------------------------------------------------------- /NAMESPACE: -------------------------------------------------------------------------------- 1 | # Generated by roxygen2: do not edit by hand 2 | 3 | export(add_reference_bands) 4 | export(athlytics_colors_acwr_zones) 5 | export(athlytics_colors_ef) 6 | export(athlytics_colors_training_load) 7 | export(athlytics_palette_academic) 8 | export(athlytics_palette_cell) 9 | export(athlytics_palette_nature) 10 | export(athlytics_palette_science) 11 | export(athlytics_palette_vibrant) 12 | export(calculate_acwr) 13 | export(calculate_acwr_ewma) 14 | export(calculate_decoupling) 15 | export(calculate_ef) 16 | export(calculate_ef_from_stream) 17 | export(calculate_exposure) 18 | export(calculate_pbs) 19 | export(cohort_reference) 20 | export(flag_quality) 21 | export(load_local_activities) 22 | export(parse_activity_file) 23 | export(plot_acwr) 24 | export(plot_acwr_comparison) 25 | export(plot_acwr_enhanced) 26 | export(plot_decoupling) 27 | export(plot_ef) 28 | export(plot_exposure) 29 | export(plot_pbs) 30 | export(plot_with_reference) 31 | export(quality_summary) 32 | export(scale_athlytics) 33 | export(theme_athlytics) 34 | import(ggplot2) 35 | importFrom(dplyr,"%>%") 36 | importFrom(dplyr,arrange) 37 | importFrom(dplyr,bind_rows) 38 | importFrom(dplyr,case_when) 39 | importFrom(dplyr,coalesce) 40 | importFrom(dplyr,distinct) 41 | importFrom(dplyr,filter) 42 | importFrom(dplyr,first) 43 | importFrom(dplyr,full_join) 44 | importFrom(dplyr,group_by) 45 | importFrom(dplyr,if_else) 46 | importFrom(dplyr,lag) 47 | importFrom(dplyr,last) 48 | importFrom(dplyr,lead) 49 | importFrom(dplyr,left_join) 50 | importFrom(dplyr,mutate) 51 | importFrom(dplyr,pull) 52 | importFrom(dplyr,recode) 53 | importFrom(dplyr,rename) 54 | importFrom(dplyr,select) 55 | importFrom(dplyr,slice) 56 | importFrom(dplyr,slice_head) 57 | importFrom(dplyr,summarise) 58 | importFrom(dplyr,tibble) 59 | importFrom(dplyr,ungroup) 60 | importFrom(ggplot2,aes) 61 | importFrom(ggplot2,element_text) 62 | importFrom(ggplot2,facet_wrap) 63 | importFrom(ggplot2,geom_hline) 64 | importFrom(ggplot2,geom_line) 65 | importFrom(ggplot2,geom_point) 66 | importFrom(ggplot2,geom_ribbon) 67 | importFrom(ggplot2,geom_smooth) 68 | importFrom(ggplot2,ggplot) 69 | importFrom(ggplot2,labs) 70 | importFrom(ggplot2,scale_color_viridis_d) 71 | importFrom(ggplot2,scale_x_date) 72 | importFrom(ggplot2,theme) 73 | importFrom(ggplot2,theme_minimal) 74 | importFrom(lubridate,as_date) 75 | importFrom(lubridate,as_datetime) 76 | importFrom(lubridate,ceiling_date) 77 | importFrom(lubridate,date) 78 | importFrom(lubridate,days) 79 | importFrom(lubridate,duration) 80 | importFrom(lubridate,floor_date) 81 | importFrom(lubridate,interval) 82 | importFrom(lubridate,parse_date_time) 83 | importFrom(lubridate,period) 84 | importFrom(lubridate,seconds_to_period) 85 | importFrom(lubridate,time_length) 86 | importFrom(lubridate,ymd) 87 | importFrom(lubridate,ymd_hms) 88 | importFrom(purrr,map_chr) 89 | importFrom(purrr,map_dfr) 90 | importFrom(purrr,possibly) 91 | importFrom(purrr,quietly) 92 | importFrom(readr,cols) 93 | importFrom(readr,read_csv) 94 | importFrom(rlang,"%||%") 95 | importFrom(rlang,.data) 96 | importFrom(scales,pretty_breaks) 97 | importFrom(stats,median) 98 | importFrom(stats,na.omit) 99 | importFrom(stats,quantile) 100 | importFrom(stats,sd) 101 | importFrom(stats,setNames) 102 | importFrom(tidyr,drop_na) 103 | importFrom(tidyr,pivot_longer) 104 | importFrom(tidyr,pivot_wider) 105 | importFrom(tidyr,unnest) 106 | importFrom(tools,toTitleCase) 107 | importFrom(utils,read.csv) 108 | importFrom(zoo,rollapply) 109 | importFrom(zoo,rollmean) 110 | -------------------------------------------------------------------------------- /man/plot_decoupling.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_decoupling.R 3 | \name{plot_decoupling} 4 | \alias{plot_decoupling} 5 | \title{Plot Aerobic Decoupling Trend} 6 | \usage{ 7 | plot_decoupling( 8 | data, 9 | activity_type = c("Run", "Ride"), 10 | decouple_metric = c("pace_hr", "power_hr"), 11 | start_date = NULL, 12 | end_date = NULL, 13 | min_duration_mins = 45, 14 | add_trend_line = TRUE, 15 | smoothing_method = "loess", 16 | decoupling_df = NULL 17 | ) 18 | } 19 | \arguments{ 20 | \item{data}{\strong{Recommended: Pass pre-calculated data via \code{decoupling_df} (local export preferred).} 21 | A data frame from \code{calculate_decoupling()} or activities data from \code{load_local_activities()}.} 22 | 23 | \item{activity_type}{Type(s) of activities to analyze (e.g., "Run", "Ride").} 24 | 25 | \item{decouple_metric}{Metric basis: "pace_hr" or "power_hr".} 26 | 27 | \item{start_date}{Optional. Analysis start date (YYYY-MM-DD string or Date). Defaults to ~1 year ago.} 28 | 29 | \item{end_date}{Optional. Analysis end date (YYYY-MM-DD string or Date). Defaults to today.} 30 | 31 | \item{min_duration_mins}{Minimum activity duration (minutes) to include. Default 45.} 32 | 33 | \item{add_trend_line}{Add a smoothed trend line (\code{geom_smooth})? Default \code{TRUE}.} 34 | 35 | \item{smoothing_method}{Smoothing method for trend line (e.g., "loess", "lm"). Default "loess".} 36 | 37 | \item{decoupling_df}{\strong{Recommended.} A pre-calculated data frame from \code{calculate_decoupling()}. 38 | When provided, analysis uses local data only (no API calls). 39 | Must contain 'date' and 'decoupling' columns.} 40 | } 41 | \value{ 42 | A ggplot object showing the decoupling trend. 43 | } 44 | \description{ 45 | Visualizes the trend of aerobic decoupling over time. 46 | } 47 | \details{ 48 | Plots the aerobic decoupling trend over time. \strong{Recommended workflow: Use local data via \code{decoupling_df}.} 49 | 50 | Plots decoupling percentage ((EF_1st_half - EF_2nd_half) / EF_1st_half * 100). 51 | Positive values mean HR drifted relative to output. A 5\\\% threshold line is often 52 | used as reference. \strong{Best practice: Use \code{load_local_activities()} + \code{calculate_decoupling()} + this function.} 53 | } 54 | \examples{ 55 | # Example using pre-calculated sample data 56 | data("athlytics_sample_decoupling", package = "Athlytics") 57 | p <- plot_decoupling(decoupling_df = athlytics_sample_decoupling) 58 | print(p) 59 | 60 | \dontrun{ 61 | # Example using local Strava export data 62 | activities <- load_local_activities("strava_export_data/activities.csv") 63 | 64 | # Example 1: Plot Decoupling trend for Runs (last 6 months) 65 | decoupling_runs_6mo <- calculate_decoupling( 66 | activities_data = activities, 67 | export_dir = "strava_export_data", 68 | activity_type = "Run", 69 | decouple_metric = "pace_hr", 70 | start_date = Sys.Date() - months(6) 71 | ) 72 | plot_decoupling(decoupling_runs_6mo) 73 | 74 | # Example 2: Plot Decoupling trend for Rides 75 | decoupling_rides <- calculate_decoupling( 76 | activities_data = activities, 77 | export_dir = "strava_export_data", 78 | activity_type = "Ride", 79 | decouple_metric = "power_hr" 80 | ) 81 | plot_decoupling(decoupling_rides) 82 | 83 | # Example 3: Plot Decoupling trend for multiple Run types (no trend line) 84 | decoupling_multi_run <- calculate_decoupling( 85 | activities_data = activities, 86 | export_dir = "strava_export_data", 87 | activity_type = c("Run", "VirtualRun"), 88 | decouple_metric = "pace_hr" 89 | ) 90 | plot_decoupling(decoupling_multi_run, add_trend_line = FALSE) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /man/plot_acwr.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_acwr.R 3 | \name{plot_acwr} 4 | \alias{plot_acwr} 5 | \title{Plot ACWR Trend} 6 | \usage{ 7 | plot_acwr( 8 | data, 9 | activity_type = NULL, 10 | load_metric = "duration_mins", 11 | acute_period = 7, 12 | chronic_period = 28, 13 | start_date = NULL, 14 | end_date = NULL, 15 | user_ftp = NULL, 16 | user_max_hr = NULL, 17 | user_resting_hr = NULL, 18 | smoothing_period = 7, 19 | highlight_zones = TRUE, 20 | acwr_df = NULL, 21 | group_var = NULL, 22 | group_colors = NULL 23 | ) 24 | } 25 | \arguments{ 26 | \item{data}{\strong{Recommended: Pass pre-calculated data via \code{acwr_df} (local export preferred).} 27 | A data frame from \code{calculate_acwr()} or activities data from \code{load_local_activities()}.} 28 | 29 | \item{activity_type}{Type(s) of activities to analyze (e.g., "Run", "Ride").} 30 | 31 | \item{load_metric}{Method for calculating daily load (e.g., "duration_mins", "distance_km", "tss", "hrss").} 32 | 33 | \item{acute_period}{Days for the acute load window (e.g., 7).} 34 | 35 | \item{chronic_period}{Days for the chronic load window (e.g., 28). Must be greater than \code{acute_period}.} 36 | 37 | \item{start_date}{Optional. Analysis start date (YYYY-MM-DD string or Date). Defaults to ~1 year ago.} 38 | 39 | \item{end_date}{Optional. Analysis end date (YYYY-MM-DD string or Date). Defaults to today.} 40 | 41 | \item{user_ftp}{Required if \code{load_metric = "tss"} and \code{acwr_df} is not provided. Your Functional Threshold Power.} 42 | 43 | \item{user_max_hr}{Required if \code{load_metric = "hrss"} and \code{acwr_df} is not provided. Your maximum heart rate.} 44 | 45 | \item{user_resting_hr}{Required if \code{load_metric = "hrss"} and \code{acwr_df} is not provided. Your resting heart rate.} 46 | 47 | \item{smoothing_period}{Days for smoothing the ACWR using a rolling mean (e.g., 7). Default 7.} 48 | 49 | \item{highlight_zones}{Logical, whether to highlight different ACWR zones (e.g., sweet spot, high risk) on the plot. Default \code{TRUE}.} 50 | 51 | \item{acwr_df}{\strong{Recommended.} A pre-calculated data frame from \code{calculate_acwr()} or \code{calculate_acwr_ewma()}. 52 | When provided, analysis uses local data only (no API calls).} 53 | 54 | \item{group_var}{Optional. Column name for grouping/faceting (e.g., "athlete_id").} 55 | 56 | \item{group_colors}{Optional. Named vector of colors for groups.} 57 | } 58 | \value{ 59 | A ggplot object showing the ACWR trend. 60 | } 61 | \description{ 62 | Visualizes the Acute:Chronic Workload Ratio (ACWR) trend over time. 63 | } 64 | \details{ 65 | Plots the ACWR trend over time. \strong{Best practice: Use \code{load_local_activities()} + \code{calculate_acwr()} + this function.} 66 | ACWR is calculated as acute load / chronic load. A ratio of 0.8-1.3 is often considered the "sweet spot". 67 | } 68 | \examples{ 69 | # Example using pre-calculated sample data 70 | data("athlytics_sample_acwr", package = "Athlytics") 71 | p <- plot_acwr(athlytics_sample_acwr) 72 | print(p) 73 | 74 | \dontrun{ 75 | # Example using local Strava export data 76 | activities <- load_local_activities("strava_export_data/activities.csv") 77 | 78 | # Plot ACWR trend for Runs (using duration as load metric) 79 | plot_acwr(data = activities, 80 | activity_type = "Run", 81 | load_metric = "duration_mins", 82 | acute_period = 7, 83 | chronic_period = 28) 84 | 85 | # Plot ACWR trend for Rides (using TSS as load metric) 86 | plot_acwr(data = activities, 87 | activity_type = "Ride", 88 | load_metric = "tss", 89 | user_ftp = 280) # FTP value is required 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /man/flag_quality.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/flag_quality.R 3 | \name{flag_quality} 4 | \alias{flag_quality} 5 | \title{Flag Data Quality Issues in Activity Streams} 6 | \usage{ 7 | flag_quality( 8 | streams, 9 | sport = "Run", 10 | hr_range = c(30, 220), 11 | pw_range = c(0, 1500), 12 | max_run_speed = 7, 13 | max_ride_speed = 25, 14 | max_accel = 3, 15 | max_hr_jump = 10, 16 | max_pw_jump = 300, 17 | min_steady_minutes = 20, 18 | steady_cv_threshold = 8 19 | ) 20 | } 21 | \arguments{ 22 | \item{streams}{A data frame containing activity stream data with time-series 23 | measurements. Expected columns: \code{time} (seconds), \code{heartrate} (bpm), 24 | \code{watts} (W), \code{velocity_smooth} or \code{speed} (m/s), \code{distance} (m).} 25 | 26 | \item{sport}{Type of activity (e.g., "Run", "Ride"). Default "Run".} 27 | 28 | \item{hr_range}{Valid heart rate range as c(min, max). Default c(30, 220).} 29 | 30 | \item{pw_range}{Valid power range as c(min, max). Default c(0, 1500).} 31 | 32 | \item{max_run_speed}{Maximum plausible running speed in m/s. Default 7.0 (approx. 2:23/km).} 33 | 34 | \item{max_ride_speed}{Maximum plausible riding speed in m/s. Default 25.0 (approx. 90 km/h).} 35 | 36 | \item{max_accel}{Maximum plausible acceleration in m/s². Default 3.0.} 37 | 38 | \item{max_hr_jump}{Maximum plausible HR change per second (bpm/s). Default 10.} 39 | 40 | \item{max_pw_jump}{Maximum plausible power change per second (W/s). Default 300.} 41 | 42 | \item{min_steady_minutes}{Minimum duration (minutes) for steady-state segment. Default 20.} 43 | 44 | \item{steady_cv_threshold}{Coefficient of variation threshold for steady-state (\\\%). Default 8.} 45 | } 46 | \value{ 47 | A data frame identical to \code{streams} with additional flag columns: 48 | \describe{ 49 | \item{flag_hr_spike}{Logical. TRUE if HR is out of range or has excessive jump.} 50 | \item{flag_pw_spike}{Logical. TRUE if power is out of range or has excessive jump.} 51 | \item{flag_gps_drift}{Logical. TRUE if speed or acceleration is implausible.} 52 | \item{flag_any}{Logical. TRUE if any quality flag is raised.} 53 | \item{is_steady_state}{Logical. TRUE if segment meets steady-state criteria.} 54 | \item{quality_score}{Numeric 0-1. Proportion of clean data (1 = perfect).} 55 | } 56 | } 57 | \description{ 58 | Detects and flags potential data quality issues in activity stream data, 59 | including HR/power spikes, GPS drift, and identifies steady-state segments 60 | suitable for physiological metrics calculation. 61 | } 62 | \details{ 63 | This function performs several quality checks: 64 | \itemize{ 65 | \item \strong{HR/Power Spikes}: Flags values outside physiological ranges or with 66 | sudden jumps (Delta HR > 10 bpm/s, Delta P > 300 W/s). 67 | \item \strong{GPS Drift}: Flags implausible speeds or accelerations based on sport type. 68 | \item \strong{Steady-State Detection}: Identifies segments with low variability 69 | (CV < 8\%) lasting >= 20 minutes, suitable for EF/decoupling calculations. 70 | } 71 | 72 | The function is sport-aware and adjusts thresholds accordingly. All thresholds 73 | are configurable to accommodate different athlete profiles and data quality. 74 | } 75 | \examples{ 76 | \dontrun{ 77 | # Create sample activity stream data 78 | stream_data <- data.frame( 79 | time = seq(0, 3600, by = 1), 80 | heartrate = rnorm(3601, mean = 150, sd = 10), 81 | watts = rnorm(3601, mean = 200, sd = 20), 82 | velocity_smooth = rnorm(3601, mean = 3.5, sd = 0.3) 83 | ) 84 | 85 | # Flag quality issues 86 | flagged_data <- flag_quality(stream_data, sport = "Run") 87 | 88 | # Check summary 89 | summary(flagged_data$quality_score) 90 | table(flagged_data$flag_any) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /man/plot_exposure.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_exposure.R 3 | \name{plot_exposure} 4 | \alias{plot_exposure} 5 | \title{Plot Training Load Exposure (ATL vs CTL)} 6 | \usage{ 7 | plot_exposure( 8 | data, 9 | activity_type = c("Run", "Ride", "VirtualRide", "VirtualRun"), 10 | load_metric = "duration_mins", 11 | acute_period = 7, 12 | chronic_period = 42, 13 | user_ftp = NULL, 14 | user_max_hr = NULL, 15 | user_resting_hr = NULL, 16 | end_date = NULL, 17 | risk_zones = TRUE, 18 | exposure_df = NULL 19 | ) 20 | } 21 | \arguments{ 22 | \item{data}{A data frame from \code{load_local_activities()}. Required unless \code{exposure_df} is provided.} 23 | 24 | \item{activity_type}{Type(s) of activities to include (e.g., "Run", "Ride"). Default uses common types.} 25 | 26 | \item{load_metric}{Method for calculating daily load (e.g., "duration_mins", "tss", "hrss"). Default "duration_mins". 27 | See \code{calculate_exposure} for details on approximate TSS/HRSS calculations.} 28 | 29 | \item{acute_period}{Days for acute load window (e.g., 7).} 30 | 31 | \item{chronic_period}{Days for chronic load window (e.g., 42). Must be > \code{acute_period}.} 32 | 33 | \item{user_ftp}{Required if \code{load_metric = "tss"}. Your FTP.} 34 | 35 | \item{user_max_hr}{Required if \code{load_metric = "hrss"}. Your max HR.} 36 | 37 | \item{user_resting_hr}{Required if \code{load_metric = "hrss"}. Your resting HR.} 38 | 39 | \item{end_date}{Optional. Analysis end date (YYYY-MM-DD string or Date). Defaults to today.} 40 | 41 | \item{risk_zones}{Add background shading for typical ACWR risk zones? Default \code{TRUE}.} 42 | 43 | \item{exposure_df}{Optional. A pre-calculated data frame from \code{calculate_exposure}. 44 | If provided, \code{data} and other calculation parameters are ignored. Must contain 45 | \code{date}, \code{atl}, \code{ctl} (and \code{acwr} if \code{risk_zones = TRUE}).} 46 | } 47 | \value{ 48 | A ggplot object showing ATL vs CTL. 49 | } 50 | \description{ 51 | Visualizes the relationship between Acute and Chronic Training Load. 52 | } 53 | \details{ 54 | Plots ATL vs CTL, optionally showing risk zones based on ACWR. Uses 55 | pre-calculated data or calls \code{calculate_exposure}. 56 | 57 | Visualizes training state by plotting ATL vs CTL (related to PMC charts). 58 | Points are colored by date, latest point is highlighted (red triangle). 59 | Optional risk zones (based on ACWR thresholds ~0.8, 1.3, 1.5) can be shaded. 60 | If \code{exposure_df} is not provided, it calls \code{calculate_exposure} first. 61 | } 62 | \examples{ 63 | # Example using simulated data 64 | data(Athlytics_sample_data) 65 | # Ensure exposure_df is named and other necessary parameters like activity_type are provided 66 | p <- plot_exposure(exposure_df = athlytics_sample_exposure, activity_type = "Run") 67 | print(p) 68 | 69 | \dontrun{ 70 | # Example using local Strava export data 71 | activities <- load_local_activities("strava_export_data/activities.csv") 72 | 73 | # Plot Exposure trend for Runs (last 6 months) 74 | plot_exposure(data = activities, 75 | activity_type = "Run", 76 | end_date = Sys.Date(), 77 | user_ftp = 280) # Example, if load_metric = "tss" 78 | 79 | # Plot Exposure trend for Rides 80 | plot_exposure(data = activities, 81 | activity_type = "Ride", 82 | user_ftp = 280) # Example, provide if load_metric = "tss" 83 | 84 | # Plot Exposure trend for multiple Run types (risk_zones = FALSE for this example) 85 | plot_exposure(data = activities, 86 | activity_type = c("Run", "VirtualRun"), 87 | risk_zones = FALSE, 88 | user_ftp = 280) # Example, provide if load_metric = "tss" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/testthat/test-exposure.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-exposure.R 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | 6 | # Load sample data from the package 7 | data(Athlytics_sample_data) 8 | 9 | # Load mock data (if helper-mockdata.R contains mocks for direct use) 10 | source(test_path("helper-mockdata.R"), local = TRUE) 11 | 12 | # Mock Strava token (if needed for functions that might call API, though most tests here use sample_df) 13 | 14 | # --- Test plot_exposure (using pre-calculated ACWR data from Athlytics_sample_data) --- 15 | 16 | test_that("plot_exposure returns a ggplot object with athlytics_sample_acwr data", { 17 | # Check if the sample ACWR data subset exists 18 | expect_true(exists("athlytics_sample_acwr"), "athlytics_sample_acwr not found in Athlytics_sample_data.") 19 | expect_s3_class(athlytics_sample_acwr, "data.frame") 20 | 21 | # Ensure athlytics_sample_acwr has the expected columns for plotting 22 | # plot_exposure typically uses date, acwr, atl, ctl. 23 | expected_cols <- c("date", "acwr", "atl", "ctl") 24 | if (nrow(athlytics_sample_acwr) > 0) { # Only check columns if data exists 25 | expect_true(all(expected_cols %in% names(athlytics_sample_acwr)), 26 | paste("athlytics_sample_acwr is missing one or more expected columns:", paste(expected_cols, collapse=", "))) 27 | } 28 | 29 | # Test with actual data if it's not empty 30 | if (nrow(athlytics_sample_acwr) > 0) { 31 | expect_s3_class(plot_exposure(exposure_df = athlytics_sample_acwr), "ggplot") 32 | } else { 33 | skip("athlytics_sample_acwr is empty, skipping main plot test.") 34 | } 35 | }) 36 | 37 | test_that("plot_exposure handles risk_zones argument with athlytics_sample_acwr", { 38 | if (!exists("athlytics_sample_acwr") || nrow(athlytics_sample_acwr) == 0) { 39 | skip("athlytics_sample_acwr is empty or not found, skipping risk_zones test.") 40 | } 41 | expect_s3_class(plot_exposure(exposure_df = athlytics_sample_acwr, risk_zones = TRUE), "ggplot") 42 | expect_s3_class(plot_exposure(exposure_df = athlytics_sample_acwr, risk_zones = FALSE), "ggplot") 43 | 44 | # Check for geom_rect layers (risk zones are often drawn with geom_rect for background bands) 45 | # or geom_hline/geom_vline if specific lines are used. 46 | # The original test checked for GeomAbline, let's adapt if needed or keep if appropriate. 47 | # plot_exposure uses geom_abline for risk zones. 48 | p_zones <- plot_exposure(exposure_df = athlytics_sample_acwr, risk_zones = TRUE) 49 | p_no_zones <- plot_exposure(exposure_df = athlytics_sample_acwr, risk_zones = FALSE) 50 | 51 | get_abline_layers <- function(p) sum(sapply(p$layers, function(l) inherits(l$geom, "GeomAbline"))) 52 | 53 | expect_equal(get_abline_layers(p_zones), 3) 54 | expect_equal(get_abline_layers(p_no_zones), 0) 55 | }) 56 | 57 | test_that("plot_exposure handles empty data frame input", { 58 | # Create an empty data frame with the same structure as athlytics_sample_acwr 59 | empty_df_structure <- data.frame( 60 | date = as.Date(character()), 61 | daily_load = numeric(), 62 | atl = numeric(), 63 | ctl = numeric(), 64 | acwr = numeric(), 65 | stringsAsFactors = FALSE 66 | ) 67 | 68 | if (exists("athlytics_sample_acwr") && nrow(athlytics_sample_acwr) > 0) { 69 | empty_df <- athlytics_sample_acwr[0, ] 70 | } else { 71 | empty_df <- empty_df_structure 72 | } 73 | 74 | expect_warning( 75 | p_empty <- plot_exposure(exposure_df = empty_df), 76 | regexp = "No valid exposure data available to plot \\(or missing required columns\\)." 77 | ) 78 | expect_s3_class(p_empty, "ggplot") 79 | expect_true(grepl("No exposure data available", p_empty$labels$title, ignore.case = TRUE) || length(p_empty$layers) == 0) 80 | }) -------------------------------------------------------------------------------- /man/plot_pbs.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/plot_pbs.R 3 | \name{plot_pbs} 4 | \alias{plot_pbs} 5 | \title{Plot Personal Best (PB) Trends} 6 | \usage{ 7 | plot_pbs( 8 | data, 9 | activity_type = "Run", 10 | distance_meters, 11 | max_activities = 500, 12 | date_range = NULL, 13 | add_trend_line = TRUE, 14 | pbs_df = NULL 15 | ) 16 | } 17 | \arguments{ 18 | \item{data}{\strong{Recommended: Pass pre-calculated data via \code{pbs_df} (local export preferred).} 19 | A data frame from \code{calculate_pbs()} or activities data from \code{load_local_activities()}.} 20 | 21 | \item{activity_type}{Type(s) of activities to search (e.g., "Run"). Default "Run".} 22 | 23 | \item{distance_meters}{Numeric vector of distances (meters) to plot PBs for (e.g., \code{c(1000, 5000)}). 24 | Relies on Strava's \code{best_efforts} data.} 25 | 26 | \item{max_activities}{Max number of recent activities to check. Default 500. Reduce for speed.} 27 | 28 | \item{date_range}{Optional. Filter activities by date \code{c("YYYY-MM-DD", "YYYY-MM-DD")}.} 29 | 30 | \item{add_trend_line}{Logical. Whether to add a trend line to the plot. Default TRUE.} 31 | 32 | \item{pbs_df}{\strong{Recommended.} A pre-calculated data frame from \code{calculate_pbs()}. 33 | When provided, analysis uses local data only (no API calls).} 34 | } 35 | \value{ 36 | A ggplot object showing PB trends, faceted by distance if multiple are plotted. 37 | } 38 | \description{ 39 | Visualizes the trend of personal best times for specific running distances. 40 | } 41 | \details{ 42 | Plots the trend of best efforts for specified distances, highlighting new PBs. 43 | \strong{Recommended workflow: Use local data via \code{pbs_df}.} 44 | 45 | Visualizes data from \code{calculate_pbs}. Points show best efforts; 46 | solid points mark new PBs. Y-axis is MM:SS. 47 | \strong{Best practice: Use \code{load_local_activities()} + \code{calculate_pbs()} + this function.} 48 | Legacy API mode is maintained for backward compatibility only. 49 | } 50 | \examples{ 51 | # Example using the built-in sample data 52 | # This data now contains a simulated history of performance improvements 53 | data("athlytics_sample_pbs", package = "Athlytics") 54 | 55 | if (!is.null(athlytics_sample_pbs) && nrow(athlytics_sample_pbs) > 0) { 56 | # Plot PBs using the package sample data directly 57 | p <- plot_pbs(pbs_df = athlytics_sample_pbs, activity_type = "Run") 58 | print(p) 59 | } 60 | 61 | if (FALSE) { 62 | # Example using local Strava export data 63 | activities <- load_local_activities("strava_export_data/activities.csv") 64 | 65 | # Plot PBS trend for Runs (last 6 months) 66 | pb_data_run <- calculate_pbs(activities_data = activities, 67 | activity_type = "Run", 68 | distance_meters = c(1000,5000,10000), 69 | date_range = c(format(Sys.Date() - months(6)), 70 | format(Sys.Date()))) 71 | if(nrow(pb_data_run) > 0) { 72 | plot_pbs(pbs_df = pb_data_run, distance_meters = c(1000,5000,10000)) 73 | } 74 | 75 | # Plot PBS trend for Rides (if applicable, though PBs are mainly for Runs) 76 | pb_data_ride <- calculate_pbs(activities_data = activities, 77 | activity_type = "Ride", 78 | distance_meters = c(10000, 20000)) 79 | if(nrow(pb_data_ride) > 0) { 80 | plot_pbs(pbs_df = pb_data_ride, distance_meters = c(10000, 20000)) 81 | } 82 | 83 | # Plot PBS trend for multiple Run types (no trend line) 84 | pb_data_multi <- calculate_pbs(activities_data = activities, 85 | activity_type = c("Run", "VirtualRun"), 86 | distance_meters = c(1000,5000)) 87 | if(nrow(pb_data_multi) > 0) { 88 | plot_pbs(pbs_df = pb_data_multi, distance_meters = c(1000,5000), 89 | add_trend_line = FALSE) 90 | } 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /man/load_local_activities.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/load_local_activities.R 3 | \name{load_local_activities} 4 | \alias{load_local_activities} 5 | \title{Load Activities from Local Strava Export} 6 | \usage{ 7 | load_local_activities( 8 | path = "strava_export_data/activities.csv", 9 | start_date = NULL, 10 | end_date = NULL, 11 | activity_types = NULL 12 | ) 13 | } 14 | \arguments{ 15 | \item{path}{Path to activities.csv file OR a .zip archive from Strava export. 16 | Supports both CSV and ZIP formats. If a .zip file is provided, the function 17 | will automatically extract and read the activities.csv file from within the archive. 18 | Default is "strava_export_data/activities.csv".} 19 | 20 | \item{start_date}{Optional. Start date (YYYY-MM-DD or Date/POSIXct) for filtering activities. 21 | Defaults to NULL (no filtering).} 22 | 23 | \item{end_date}{Optional. End date (YYYY-MM-DD or Date/POSIXct) for filtering activities. 24 | Defaults to NULL (no filtering).} 25 | 26 | \item{activity_types}{Optional. Character vector of activity types to include 27 | (e.g., c("Run", "Ride")). Defaults to NULL (include all types).} 28 | } 29 | \value{ 30 | A tibble of activity data with standardized column names compatible 31 | with Athlytics functions. Key columns include: 32 | \itemize{ 33 | \item \code{id}: Activity ID (numeric) 34 | \item \code{name}: Activity name 35 | \item \code{type}: Activity type (Run, Ride, etc.) 36 | \item \code{start_date_local}: Activity start datetime (POSIXct) 37 | \item \code{date}: Activity date (Date) 38 | \item \code{distance}: Distance in meters (numeric) 39 | \item \code{moving_time}: Moving time in seconds (integer) 40 | \item \code{elapsed_time}: Elapsed time in seconds (integer) 41 | \item \code{average_heartrate}: Average heart rate (numeric) 42 | \item \code{average_watts}: Average power in watts (numeric) 43 | \item \code{elevation_gain}: Elevation gain in meters (numeric) 44 | } 45 | } 46 | \description{ 47 | Reads and processes activity data from a local Strava export, supporting both 48 | direct CSV files and compressed ZIP archives. This function converts Strava export 49 | data to a format compatible with all Athlytics analysis functions. 50 | Designed to work with Strava's official bulk data export 51 | (Settings > My Account > Download or Delete Your Account > Get Started). 52 | } 53 | \details{ 54 | This function reads the activities.csv file from a Strava data export 55 | and transforms the data to match the structure expected by Athlytics 56 | analysis functions. The transformation includes: 57 | \itemize{ 58 | \item Converting column names to match API format 59 | \item Parsing dates into POSIXct format 60 | \item Converting distances to meters 61 | \item Converting times to seconds 62 | \item Filtering by date range and activity type if specified 63 | } 64 | 65 | \strong{Privacy Note}: This function processes local export data only and does not 66 | connect to the internet. Ensure you have permission to analyze the data and 67 | follow applicable privacy regulations when using this data for research purposes. 68 | } 69 | \examples{ 70 | \dontrun{ 71 | # Load all activities from local CSV 72 | activities <- load_local_activities("strava_export_data/activities.csv") 73 | 74 | # Load directly from ZIP archive (no need to extract manually!) 75 | activities <- load_local_activities("export_12345678.zip") 76 | 77 | # Load only running activities from 2023 78 | activities <- load_local_activities( 79 | path = "export_12345678.zip", 80 | start_date = "2023-01-01", 81 | end_date = "2023-12-31", 82 | activity_types = "Run" 83 | ) 84 | 85 | # Use with Athlytics functions 86 | acwr_data <- calculate_acwr(activities, load_metric = "distance_km") 87 | plot_acwr(acwr_data, highlight_zones = TRUE) 88 | 89 | # Multi-metric analysis 90 | ef_data <- calculate_ef(activities, ef_metric = "pace_hr") 91 | plot_ef(ef_data, add_trend_line = TRUE) 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /.Rbuildignore: -------------------------------------------------------------------------------- 1 | # Regex patterns to ignore files/dirs during R CMD build 2 | ^\.Rproj\.user$ 3 | ^\.RData$ 4 | ^\.Rbuildignore$ 5 | ^\.gitignore$ 6 | ^\.git$ 7 | ^\.\.Rcheck$ 8 | ^cran-comments\.md$ 9 | ^.*\.Rproj$ 10 | ^Athlytics_.*\\.tar\\.gz$ # Ignore built packages 11 | ^install_deps\.Rout$ 12 | ^install_pkgs\.R$ # Added just in case 13 | ^run_tests\.R$ # Added just in case 14 | # Other non-standard files found 15 | ^ijspp-article-p151\.pdf$ 16 | ^ijspp-article-p151\.txt$ 17 | ^image\.png$ 18 | ^install_deps\.R$ 19 | ^paper\.md$ 20 | ^pdf_to_text\.py$ 21 | ^rStrava.*\.txt$ # Ignore any rStrava text files 22 | # Ignore helper files if only used for local testing setup 23 | # ^helper-.* 24 | # Ignore R history if present 25 | ^\.Rhistory$ 26 | # Ignore OS specific hidden files 27 | ^\.DS_Store$ 28 | ^Thumbs\.db$ 29 | # Ignore testthat snapshots if any 30 | ^tests/testthat/_snaps/$ 31 | # Ignore temporary check directory if created inside project 32 | ^Athlytics\.Rcheck/$ 33 | ^\.\.Rcheck/$ 34 | # pkgdown files 35 | ^_pkgdown\.yml$ 36 | ^docs$ 37 | # Files/dirs noted in R CMD check 38 | ^\\.aspell$ # Escaped dot 39 | ^Athlytics-manual\\.pdf$ 40 | ^Athlytics_0\\.1\\.0/$ 41 | ^arxiv/$ 42 | ^create_sample_data\\.R$ 43 | ^figure/$ 44 | ^paper\\..*$ 45 | ^references\\.bib$ 46 | ^\.httr-oauth$ 47 | ^runnable_function_examples\\.Rmd$ 48 | ^runnable_function_examples\\.html$ 49 | ^coverage_report\\.html$ 50 | ^test_get_streams\\.R$ # If this root file needs to be ignored 51 | ^test\\.r$ # If this root file needs to be ignored 52 | ^lib$ # Add lib directory to ignore 53 | 54 | # Ignore the manual test script 55 | ^tests/manual_test_script\\.R$ 56 | 57 | # Ignore GitHub Actions directory 58 | ^\.github$ 59 | ^tests/run_all_tests\.R$ # Ignore the manual test script runner 60 | 61 | # Ignore aspell configuration directory 62 | ^\.aspell$ 63 | ^\.aspell/.* 64 | 65 | # Added paper directory to the ignore list 66 | ^paper$ 67 | 68 | # Ignore specific test scripts 69 | ^tests/test_direct_api_streams\.R$ 70 | ^doc$ 71 | ^Meta$ 72 | ^LICENSE\.md$ 73 | ^tests/manual_tests$ 74 | ^CRAN-SUBMISSION$ 75 | ^Untitled\.R$ 76 | 77 | # User's private data - exclude from R package build 78 | ^strava_export_data$ 79 | ^export_.*\.zip$ 80 | ^.*\.zip$ 81 | ^batch_update_calculate_functions\.py$ 82 | ^generate_plot_example\.R$ 83 | ^test_local_data_loading\.R$ 84 | ^test_package\.R$ 85 | ^update_calculate_functions_local_only\.R$ 86 | ^take_screenshots\.bat$ 87 | ^screenshots$ 88 | ^README_NEW\.md$ 89 | ^README_old\.md$ 90 | ^MIGRATION_SUMMARY\.md$ 91 | ^Rplots\.pdf$ 92 | ^google_search_demo\.js$ 93 | ^node_modules$ 94 | ^package\.json$ 95 | ^package-lock\.json$ 96 | ^strava_api.*\.md$ 97 | ^提交指南.*\.md$ 98 | ^export_data$ 99 | ^athlytics_downloads_raw_data\.csv$ 100 | ^Athlytics_.*\.tar\.gz$ 101 | ^tatus$ 102 | ^check_output\.txt$ 103 | ^final_check\.txt$ 104 | ^pkgcheck.*\.txt$ 105 | ^analysis_output$ 106 | ^Athlytics$ 107 | ^\.lang$ 108 | ^.*调试.*$ 109 | ^create_multi_athlete_sample_data\.R$ 110 | ^regenerate_figures\.R$ 111 | ^.*coverage.*\.R$ 112 | ^.*coverage.*\.Rout$ 113 | ^.*coverage.*\.rds$ 114 | ^.*coverage.*\.html$ 115 | ^check_.*\.R$ 116 | ^check_.*\.Rout$ 117 | ^debug_.*\.R$ 118 | ^debug_.*\.Rout$ 119 | ^improve_coverage\.R$ 120 | ^install_athlytics_.*\.R$ 121 | ^install_athlytics_.*\.bat$ 122 | ^install_athlytics_.*\.ps1$ 123 | ^parallel_.*\.R$ 124 | ^performance_comparison\.R$ 125 | ^quick_.*\.R$ 126 | ^run_.*\.R$ 127 | ^serial_test\.R$ 128 | ^simple_.*\.R$ 129 | ^simple_.*\.Rout$ 130 | ^stable_.*\.R$ 131 | ^stable_.*\.Rout$ 132 | ^start_.*\.bat$ 133 | ^test_.*\.bat$ 134 | ^test_.*\.R$ 135 | ^test_.*\.Rout$ 136 | ^test-results\.txt$ 137 | ^temp_order\.txt$ 138 | ^rename_tests\.ps1$ 139 | ^final_.*\.R$ 140 | ^final_.*\.Rout$ 141 | ^athlytics\.Rproj\.Rcheck$ 142 | ^CODE_OF_CONDUCT\.md$ 143 | ^CONTRIBUTING\.md$ 144 | ^codemeta\.json$ 145 | ^image$ 146 | ^lib$ 147 | ^strava_data$ 148 | 149 | ^data-raw$ 150 | ^generate_plot_examples\.R$ 151 | -------------------------------------------------------------------------------- /R/data.R: -------------------------------------------------------------------------------- 1 | # R/data.R 2 | 3 | #' Sample ACWR Data for Athlytics 4 | #' 5 | #' A dataset containing pre-calculated Acute:Chronic Workload Ratio (ACWR) 6 | #' and related metrics, derived from simulated Strava data. Used in examples and tests. 7 | #' 8 | #' @format A tibble with 365 rows and 5 variables: 9 | #' \describe{ 10 | #' \item{date}{Date of the metrics, as a Date object.} 11 | #' \item{atl}{Acute Training Load, as a numeric value.} 12 | #' \item{ctl}{Chronic Training Load, as a numeric value.} 13 | #' \item{acwr}{Acute:Chronic Workload Ratio, as a numeric value.} 14 | #' \item{acwr_smooth}{Smoothed ACWR, as a numeric value.} 15 | #' } 16 | #' @source Simulated data generated for package examples. 17 | "athlytics_sample_acwr" 18 | 19 | #' Sample Aerobic Decoupling Data for Athlytics 20 | #' 21 | #' A dataset containing pre-calculated aerobic decoupling percentages, 22 | #' derived from simulated Strava data. Used in examples and tests. 23 | #' 24 | #' @format A tibble with 365 rows and 2 variables: 25 | #' \describe{ 26 | #' \item{date}{Date of the activity, as a Date object.} 27 | #' \item{decoupling}{Calculated decoupling percentage, as a numeric value.} 28 | #' } 29 | #' @source Simulated data generated for package examples. 30 | "athlytics_sample_decoupling" 31 | 32 | #' Sample Efficiency Factor (EF) Data for Athlytics 33 | #' 34 | #' A dataset containing pre-calculated Efficiency Factor (EF) values, 35 | #' derived from simulated Strava data. Used in examples and tests. 36 | #' 37 | #' @format A data.frame with 50 rows and 3 variables: 38 | #' \describe{ 39 | #' \item{date}{Date of the activity, as a Date object.} 40 | #' \item{activity_type}{Type of activity (e.g., "Run", "Ride"), as a character string.} 41 | #' \item{ef_value}{Calculated Efficiency Factor, as a numeric value.} 42 | #' } 43 | #' @source Simulated data generated for package examples. 44 | "athlytics_sample_ef" 45 | 46 | #' Sample Training Load Exposure Data for Athlytics 47 | #' 48 | #' This dataset contains daily training load, ATL, CTL, and ACWR, derived from 49 | #' simulated Strava data. Used in examples and tests, particularly for `plot_exposure`. 50 | #' 51 | #' @format A tibble with 365 rows and 5 variables: 52 | #' \describe{ 53 | #' \item{date}{Date of the metrics, as a Date object.} 54 | #' \item{daily_load}{Calculated daily training load, as a numeric value.} 55 | #' \item{ctl}{Chronic Training Load, as a numeric value.} 56 | #' \item{atl}{Acute Training Load, as a numeric value.} 57 | #' \item{acwr}{Acute:Chronic Workload Ratio, as a numeric value.} 58 | #' } 59 | #' @source Simulated data generated for package examples. 60 | "athlytics_sample_exposure" 61 | 62 | #' Sample Personal Bests (PBs) Data for Athlytics 63 | #' 64 | #' A dataset containing pre-calculated Personal Best (PB) times for various distances, 65 | #' derived from simulated Strava data. Used in examples and tests. 66 | #' 67 | #' @format A tibble with 100 rows and 10 variables: 68 | #' \describe{ 69 | #' \item{activity_id}{ID of the activity where the effort occurred, as a character string.} 70 | #' \item{activity_date}{Date and time of the activity, as a POSIXct object.} 71 | #' \item{distance}{Target distance in meters for the best effort, as a numeric value.} 72 | #' \item{elapsed_time}{Elapsed time for the effort in seconds, as a numeric value.} 73 | #' \item{moving_time}{Moving time for the effort in seconds, as a numeric value.} 74 | #' \item{time_seconds}{Typically the same as elapsed_time for best efforts, in seconds, as a numeric value.} 75 | #' \item{cumulative_pb_seconds}{The personal best time for that distance up to that date, in seconds, as a numeric value.} 76 | #' \item{is_pb}{Logical, TRUE if this effort set a new personal best.} 77 | #' \item{distance_label}{Factor representing the distance (e.g., "1k", "5k").} 78 | #' \item{time_period}{Formatted time of the effort, as a Period object from lubridate.} 79 | #' } 80 | #' @source Simulated data generated for package examples. 81 | "athlytics_sample_pbs" -------------------------------------------------------------------------------- /tests/testthat/test-calculate-exposure-extended.R: -------------------------------------------------------------------------------- 1 | # Extended tests for calculate_exposure 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | 6 | create_test_activities_exposure <- function(n_days = 90) { 7 | dates <- seq(Sys.Date() - n_days, Sys.Date() - 1, by = "day") 8 | n_activities <- round(n_days * 0.6) # ~60% of days have activities 9 | activity_dates <- sort(sample(dates, n_activities, replace = FALSE)) 10 | 11 | data.frame( 12 | id = seq_along(activity_dates), 13 | name = paste("Activity", seq_along(activity_dates)), 14 | type = sample(c("Run", "Ride"), n_activities, replace = TRUE), 15 | date = activity_dates, 16 | distance = abs(rnorm(n_activities, 8000, 2000)), 17 | moving_time = abs(rnorm(n_activities, 2400, 600)), 18 | average_watts = ifelse(sample(c(TRUE, FALSE), n_activities, replace = TRUE), 19 | rnorm(n_activities, 200, 50), NA_real_), 20 | average_heartrate = rnorm(n_activities, 145, 15), 21 | stringsAsFactors = FALSE 22 | ) 23 | } 24 | 25 | test_that("calculate_exposure handles different load metrics", { 26 | activities <- create_test_activities_exposure(60) 27 | 28 | # Test duration_mins 29 | exp_duration <- calculate_exposure(activities, load_metric = "duration_mins") 30 | expect_s3_class(exp_duration, "data.frame") 31 | expect_true("atl" %in% names(exp_duration)) 32 | expect_true("ctl" %in% names(exp_duration)) 33 | 34 | # Test distance_km 35 | exp_distance <- calculate_exposure(activities, load_metric = "distance_km") 36 | expect_s3_class(exp_distance, "data.frame") 37 | }) 38 | 39 | test_that("calculate_exposure handles different time windows", { 40 | activities <- create_test_activities_exposure(100) 41 | 42 | # Test with different acute/chronic windows 43 | exp1 <- calculate_exposure(activities, acute_period = 7, chronic_period = 28) 44 | expect_s3_class(exp1, "data.frame") 45 | 46 | exp2 <- calculate_exposure(activities, acute_period = 10, chronic_period = 42) 47 | expect_s3_class(exp2, "data.frame") 48 | }) 49 | 50 | test_that("calculate_exposure filters by activity type", { 51 | activities <- create_test_activities_exposure(60) 52 | activities$type <- rep(c("Run", "Ride"), length.out = nrow(activities)) 53 | 54 | exp_runs <- calculate_exposure(activities, activity_type = "Run") 55 | expect_s3_class(exp_runs, "data.frame") 56 | 57 | exp_rides <- calculate_exposure(activities, activity_type = "Ride") 58 | expect_s3_class(exp_rides, "data.frame") 59 | }) 60 | 61 | test_that("calculate_exposure handles date ranges", { 62 | activities <- create_test_activities_exposure(120) 63 | 64 | exp <- calculate_exposure( 65 | activities, 66 | end_date = Sys.Date() - 30 67 | ) 68 | 69 | expect_s3_class(exp, "data.frame") 70 | if (nrow(exp) > 0) { 71 | expect_true(all(exp$date <= Sys.Date() - 30)) 72 | } 73 | }) 74 | 75 | test_that("calculate_exposure calculates ACWR correctly", { 76 | activities <- create_test_activities_exposure(60) 77 | 78 | exp <- calculate_exposure(activities) 79 | 80 | expect_s3_class(exp, "data.frame") 81 | expect_true("acwr" %in% names(exp)) 82 | 83 | # ACWR should be ATL / CTL (where CTL > 0) 84 | if (nrow(exp) > 0) { 85 | valid_rows <- exp$ctl > 0 86 | if (any(valid_rows)) { 87 | calculated_acwr <- exp$atl[valid_rows] / exp$ctl[valid_rows] 88 | expect_equal(exp$acwr[valid_rows], calculated_acwr, tolerance = 0.01) 89 | } 90 | } 91 | }) 92 | 93 | test_that("calculate_exposure handles sparse activity data", { 94 | # Create very sparse data (only a few activities) 95 | activities <- create_test_activities_exposure(90) 96 | activities <- activities[sample(1:nrow(activities), 5), ] # Keep only 5 activities 97 | 98 | exp <- calculate_exposure(activities) 99 | 100 | expect_s3_class(exp, "data.frame") 101 | }) 102 | 103 | test_that("calculate_exposure handles missing data gracefully", { 104 | skip("Missing data handling needs to be tested at activity load level") 105 | }) 106 | 107 | -------------------------------------------------------------------------------- /tests/testthat/test-ef.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-ef.R 2 | 3 | # Efficiency Factor Calculation Tests 4 | 5 | library(Athlytics) 6 | library(testthat) 7 | 8 | data(athlytics_sample_ef) 9 | 10 | create_mock_activities <- function(n = 30) { 11 | dates <- seq(Sys.Date() - n, Sys.Date(), by = "day") 12 | data.frame( 13 | id = seq_len(length(dates)), 14 | name = paste("Activity", seq_len(length(dates))), 15 | type = sample(c("Run", "Ride"), length(dates), replace = TRUE), 16 | date = as.Date(dates), 17 | distance = runif(length(dates), 5000, 15000), 18 | moving_time = as.integer(runif(length(dates), 1800, 5400)), 19 | average_heartrate = runif(length(dates), 130, 170), 20 | average_watts = runif(length(dates), 180, 250), 21 | average_speed = runif(length(dates), 2.5, 4.5), 22 | stringsAsFactors = FALSE 23 | ) 24 | } 25 | 26 | test_that("calculate_ef works with pace_hr metric", { 27 | mock_activities <- create_mock_activities() 28 | 29 | ef_result <- calculate_ef( 30 | activities_data = mock_activities, 31 | ef_metric = "pace_hr" 32 | ) 33 | 34 | expect_s3_class(ef_result, "data.frame") 35 | expect_true("ef_value" %in% colnames(ef_result)) 36 | expect_gt(nrow(ef_result), 0) 37 | }) 38 | 39 | test_that("calculate_ef works with power_hr metric", { 40 | mock_activities <- create_mock_activities() 41 | 42 | ef_result <- calculate_ef( 43 | activities_data = mock_activities, 44 | ef_metric = "power_hr" 45 | ) 46 | 47 | expect_s3_class(ef_result, "data.frame") 48 | expect_true("ef_value" %in% colnames(ef_result)) 49 | }) 50 | 51 | test_that("calculate_ef validates input", { 52 | expect_error( 53 | calculate_ef(activities_data = "not_a_dataframe"), 54 | "data frame" 55 | ) 56 | }) 57 | 58 | test_that("calculate_ef works with sample data", { 59 | skip_if(is.null(athlytics_sample_ef), "Sample EF data not available") 60 | 61 | expect_s3_class(athlytics_sample_ef, "data.frame") 62 | # Sample data may have either ef_value or efficiency_factor 63 | expect_true(any(c("ef_value", "efficiency_factor") %in% colnames(athlytics_sample_ef))) 64 | }) 65 | 66 | test_that("plot_ef works with pre-calculated data", { 67 | skip_if(is.null(athlytics_sample_ef), "Sample EF data not available") 68 | 69 | p <- plot_ef(athlytics_sample_ef) 70 | expect_s3_class(p, "ggplot") 71 | }) 72 | 73 | test_that("plot_ef works with activities data", { 74 | mock_activities <- create_mock_activities(50) 75 | 76 | # Test with pace_hr 77 | p1 <- plot_ef( 78 | data = mock_activities, 79 | activity_type = "Run", 80 | ef_metric = "pace_hr" 81 | ) 82 | expect_s3_class(p1, "ggplot") 83 | 84 | # Test with power_hr 85 | p2 <- plot_ef( 86 | data = mock_activities, 87 | activity_type = "Ride", 88 | ef_metric = "power_hr" 89 | ) 90 | expect_s3_class(p2, "ggplot") 91 | }) 92 | 93 | test_that("plot_ef handles various options", { 94 | mock_activities <- create_mock_activities(50) 95 | 96 | # Test without trend line 97 | p1 <- plot_ef( 98 | data = mock_activities, 99 | activity_type = "Run", 100 | ef_metric = "pace_hr", 101 | add_trend_line = FALSE 102 | ) 103 | expect_s3_class(p1, "ggplot") 104 | 105 | # Test with different smoothing method 106 | p2 <- plot_ef( 107 | data = mock_activities, 108 | activity_type = "Run", 109 | ef_metric = "pace_hr", 110 | smoothing_method = "lm" 111 | ) 112 | expect_s3_class(p2, "ggplot") 113 | 114 | # Test with date range 115 | p3 <- plot_ef( 116 | data = mock_activities, 117 | activity_type = "Run", 118 | ef_metric = "pace_hr", 119 | start_date = Sys.Date() - 30, 120 | end_date = Sys.Date() 121 | ) 122 | expect_s3_class(p3, "ggplot") 123 | }) 124 | 125 | test_that("plot_ef works with ef_df parameter", { 126 | skip_if(is.null(athlytics_sample_ef), "Sample EF data not available") 127 | 128 | # Plot already calculated EF data 129 | p <- plot_ef(athlytics_sample_ef) 130 | expect_s3_class(p, "ggplot") 131 | }) 132 | -------------------------------------------------------------------------------- /man/calculate_acwr_ewma.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/calculate_acwr_ewma.R 3 | \name{calculate_acwr_ewma} 4 | \alias{calculate_acwr_ewma} 5 | \title{Calculate ACWR using EWMA Method with Confidence Bands} 6 | \usage{ 7 | calculate_acwr_ewma( 8 | activities_data, 9 | activity_type = NULL, 10 | load_metric = "duration_mins", 11 | method = c("ra", "ewma"), 12 | acute_period = 7, 13 | chronic_period = 28, 14 | half_life_acute = 3.5, 15 | half_life_chronic = 14, 16 | start_date = NULL, 17 | end_date = NULL, 18 | user_ftp = NULL, 19 | user_max_hr = NULL, 20 | user_resting_hr = NULL, 21 | smoothing_period = 7, 22 | ci = FALSE, 23 | B = 200, 24 | block_len = 7, 25 | conf_level = 0.95 26 | ) 27 | } 28 | \arguments{ 29 | \item{activities_data}{A data frame of activities from \code{load_local_activities()}.} 30 | 31 | \item{activity_type}{Optional. Filter activities by type. Default NULL includes all.} 32 | 33 | \item{load_metric}{Method for calculating daily load. Default "duration_mins".} 34 | 35 | \item{method}{ACWR calculation method: "ra" (rolling average) or "ewma". Default "ra".} 36 | 37 | \item{acute_period}{Days for acute window (for RA method). Default 7.} 38 | 39 | \item{chronic_period}{Days for chronic window (for RA method). Default 28.} 40 | 41 | \item{half_life_acute}{Half-life for acute EWMA in days. Default 3.5.} 42 | 43 | \item{half_life_chronic}{Half-life for chronic EWMA in days. Default 14.} 44 | 45 | \item{start_date}{Optional. Analysis start date. Defaults to one year ago.} 46 | 47 | \item{end_date}{Optional. Analysis end date. Defaults to today.} 48 | 49 | \item{user_ftp}{Required if \code{load_metric = "tss"}.} 50 | 51 | \item{user_max_hr}{Required if \code{load_metric = "hrss"}.} 52 | 53 | \item{user_resting_hr}{Required if \code{load_metric = "hrss"}.} 54 | 55 | \item{smoothing_period}{Days for smoothing ACWR. Default 7.} 56 | 57 | \item{ci}{Logical. Whether to calculate confidence bands (EWMA only). Default FALSE.} 58 | 59 | \item{B}{Number of bootstrap iterations (if ci = TRUE). Default 200.} 60 | 61 | \item{block_len}{Block length for moving-block bootstrap (days). Default 7.} 62 | 63 | \item{conf_level}{Confidence level (0-1). Default 0.95 (95\\\% CI).} 64 | } 65 | \value{ 66 | A data frame with columns: \code{date}, \code{atl}, \code{ctl}, \code{acwr}, \code{acwr_smooth}, 67 | and if \code{ci = TRUE} and \code{method = "ewma"}: \code{acwr_lower}, \code{acwr_upper}. 68 | } 69 | \description{ 70 | Calculates the Acute:Chronic Workload Ratio (ACWR) using Exponentially 71 | Weighted Moving Average (EWMA) with optional bootstrap confidence bands. 72 | } 73 | \details{ 74 | This function extends the basic ACWR calculation with two methods: 75 | \itemize{ 76 | \item \strong{RA (Rolling Average)}: Traditional rolling mean approach (default). 77 | \item \strong{EWMA (Exponentially Weighted Moving Average)}: Uses exponential decay 78 | with configurable half-lives. More responsive to recent changes. 79 | } 80 | 81 | \strong{EWMA Formula}: The smoothing parameter alpha is calculated from half-life: 82 | \code{alpha = ln(2) / half_life}. The EWMA update is: \verb{E_t = alpha * L_t + (1-alpha) * E_\{t-1\}} 83 | where L_t is daily load and E_t is the exponentially weighted average. 84 | 85 | \strong{Confidence Bands}: When \code{ci = TRUE} and \code{method = "ewma"}, uses \strong{moving-block 86 | bootstrap} to estimate uncertainty. The daily load sequence is resampled in 87 | weekly blocks (preserving within-week correlation), ACWR is recalculated, 88 | and percentiles form the confidence bands. This accounts for temporal correlation 89 | in training load patterns. 90 | } 91 | \examples{ 92 | \dontrun{ 93 | # Load local activities 94 | activities <- load_local_activities("export_12345678.zip") 95 | 96 | # Calculate ACWR using Rolling Average (RA) 97 | acwr_ra <- calculate_acwr_ewma(activities, method = "ra") 98 | 99 | # Calculate ACWR using EWMA with confidence bands 100 | acwr_ewma <- calculate_acwr_ewma( 101 | activities, 102 | method = "ewma", 103 | half_life_acute = 3.5, 104 | half_life_chronic = 14, 105 | ci = TRUE, 106 | B = 200 107 | ) 108 | 109 | # Compare both methods 110 | head(acwr_ewma) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /tests/testthat/helper-mockdata.R: -------------------------------------------------------------------------------- 1 | library(dplyr) 2 | # tests/testthat/helper-mockdata.R 3 | 4 | mock_pbs_df <- data.frame( 5 | activity_id = c("1", "2", "3", "4"), 6 | activity_date = lubridate::ymd_hms(c("2023-01-01 10:00:00", "2023-01-15 10:00:00", "2023-02-01 10:00:00", "2023-02-15 10:00:00")), 7 | distance = rep(c(5000, 10000), each = 2), 8 | time_seconds = c(1500, 1450, 3200, 3100), 9 | cumulative_pb_seconds = c(1500, 1450, 3200, 3100), 10 | is_pb = c(TRUE, TRUE, TRUE, TRUE), 11 | distance_label = factor(rep(c("5k", "10k"), each = 2), levels = c("5k", "10k")), 12 | stringsAsFactors = FALSE 13 | ) 14 | 15 | mock_acwr_df <- data.frame( 16 | date = seq(lubridate::ymd("2023-01-01"), lubridate::ymd("2023-02-10"), by="day"), 17 | atl = round(runif(41, 30, 70) + sin(seq(0, 4*pi, length.out=41))*10, 1), 18 | ctl = round(runif(41, 40, 60) + sin(seq(0, 2*pi, length.out=41))*5, 1) 19 | ) %>% 20 | dplyr::mutate( 21 | ctl_safe = ifelse(ctl <= 0, 1, ctl), 22 | acwr = round(atl / ctl_safe, 2), 23 | acwr_smooth = acwr 24 | ) %>% 25 | dplyr::select(date, atl, ctl, acwr, acwr_smooth) 26 | 27 | latlng_list <- lapply(1:3601, function(i) c(runif(1, 40, 41), runif(1, -75, -74))) 28 | 29 | mock_activity_streams <- data.frame( 30 | time = seq(0, 3600, by = 1), 31 | latlng = I(latlng_list), 32 | distance = seq(0, 10000, length.out = 3601), 33 | altitude = rnorm(3601, 100, 10), 34 | velocity_smooth = rnorm(3601, 3, 0.1), # Reduced variation for steady state 35 | heartrate = round(rnorm(3601, 150, 5)), # More stable HR 36 | cadence = round(runif(3601, 85, 95)), 37 | watts = round(rnorm(3601, 200, 10)), # More stable power 38 | grade_smooth = rnorm(3601, 0, 1), 39 | moving = sample(c(TRUE, FALSE), 3601, replace = TRUE, prob = c(0.95, 0.05)), 40 | temp = rnorm(3601, 20, 3) 41 | ) 42 | 43 | mock_ef_df <- data.frame( 44 | activity_id = c("1", "2", "3", "4", "5"), 45 | date = lubridate::ymd(c("2023-01-01", "2023-01-15", "2023-02-01", "2023-02-15", "2023-03-01")), 46 | activity_type = rep("Run", 5), 47 | ef_value = round(rnorm(5, 1.5, 0.1), 2), 48 | ef_metric = rep("pace_hr", 5), 49 | stringsAsFactors = FALSE 50 | ) 51 | 52 | mock_exposure_df <- data.frame( 53 | date = seq(lubridate::ymd("2023-01-01"), lubridate::ymd("2023-02-10"), by="day"), 54 | atl = round(runif(41, 30, 70) + sin(seq(0, 4*pi, length.out=41))*10, 1), 55 | ctl = round(runif(41, 40, 60) + sin(seq(0, 2*pi, length.out=41))*5, 1) 56 | ) %>% 57 | dplyr::mutate( 58 | ctl_safe = ifelse(ctl <= 0, 1, ctl), 59 | acwr = round(atl / ctl_safe, 2) 60 | ) 61 | 62 | mock_activity_list_df <- data.frame( 63 | id = c(1001, 1002, 1003, 1004, 1005, 1006, 1007, 1008, 1009, 1010), 64 | name = paste("Activity", 1:10), 65 | start_date_local = seq.POSIXt(as.POSIXct("2023-10-01 08:00:00", tz = "UTC"), 66 | by = "-2 days", length.out = 10), 67 | type = rep(c("Run", "Ride"), length.out = 10), 68 | distance = c(5050, 20100, 10200, 30500, 8030, 15200, 12100, 40300, 6050, 25400), 69 | moving_time = c(1800, 3600, 3000, 5400, 2400, 2700, 3300, 7200, 1900, 4500), 70 | elapsed_time = c(1850, 3700, 3060, 5500, 2450, 2780, 3380, 7350, 1950, 4600), 71 | total_elevation_gain = c(50, 150, 100, 250, 80, 120, 110, 300, 60, 200), 72 | average_heartrate = c(150, 140, 155, 145, 152, 142, 158, 148, 151, 141), 73 | max_heartrate = c(170, 160, 175, 165, 172, 162, 178, 168, 171, 161), 74 | average_watts = c(NA, 200, NA, 220, NA, 190, NA, 230, NA, 210), 75 | stringsAsFactors = FALSE 76 | ) 77 | 78 | mock_activity_list_df$start_date_local <- as.POSIXct(mock_activity_list_df$start_date_local, tz = "UTC") 79 | 80 | # Adjust the date of the second "Run" activity (original index 3) to be within the test range 81 | # The test expects two runs between 2023-10-01 and 2023-10-03 82 | # Original dates: 2023-10-01 (Run), 2023-09-29 (Ride), 2023-09-27 (Run) 83 | # Change 2023-09-27 to 2023-10-02 for the second Run 84 | mock_activity_list_df$start_date_local[3] <- as.POSIXct("2023-10-02 09:00:00", tz = "UTC") 85 | 86 | mock_activity_list_df$duration_mins <- mock_activity_list_df$elapsed_time / 60 87 | mock_activity_list_df$distance_km <- mock_activity_list_df$distance / 1000 88 | 89 | mock_activity_list_df_for_list <- mock_activity_list_df %>% 90 | dplyr::mutate(start_date_local = format(start_date_local, "%Y-%m-%d %H:%M:%S")) 91 | 92 | mock_activity_list_list <- purrr::transpose(mock_activity_list_df_for_list) 93 | -------------------------------------------------------------------------------- /tests/testthat/test-utils-extended.R: -------------------------------------------------------------------------------- 1 | # Extended tests for utils functions 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | 6 | test_that("utils functions exist and work", { 7 | # Test any utility functions that might exist 8 | expect_true(TRUE) # Placeholder if no specific utils to test 9 | }) 10 | 11 | test_that("package loads without errors", { 12 | expect_true("Athlytics" %in% loadedNamespaces()) 13 | }) 14 | 15 | test_that("sample data exists", { 16 | data("athlytics_sample_acwr", package = "Athlytics") 17 | data("athlytics_sample_ef", package = "Athlytics") 18 | data("athlytics_sample_exposure", package = "Athlytics") 19 | data("athlytics_sample_decoupling", package = "Athlytics") 20 | data("athlytics_sample_pbs", package = "Athlytics") 21 | 22 | expect_true(!is.null(athlytics_sample_acwr)) 23 | expect_true(!is.null(athlytics_sample_ef)) 24 | expect_true(!is.null(athlytics_sample_exposure)) 25 | expect_true(!is.null(athlytics_sample_decoupling)) 26 | expect_true(!is.null(athlytics_sample_pbs)) 27 | }) 28 | 29 | test_that("color palettes are accessible", { 30 | nature <- athlytics_palette_nature() 31 | academic <- athlytics_palette_academic() 32 | vibrant <- athlytics_palette_vibrant() 33 | science <- athlytics_palette_science() 34 | cell <- athlytics_palette_cell() 35 | 36 | expect_true(is.character(nature)) 37 | expect_true(is.character(academic)) 38 | expect_true(is.character(vibrant)) 39 | expect_true(is.character(science)) 40 | expect_true(is.character(cell)) 41 | 42 | expect_gt(length(nature), 0) 43 | expect_gt(length(academic), 0) 44 | expect_gt(length(vibrant), 0) 45 | expect_gt(length(science), 0) 46 | expect_gt(length(cell), 0) 47 | }) 48 | 49 | test_that("color zone functions work", { 50 | acwr_zones <- athlytics_colors_acwr_zones() 51 | training_load <- athlytics_colors_training_load() 52 | ef_colors <- athlytics_colors_ef() 53 | 54 | expect_true(is.list(acwr_zones)) 55 | expect_true(is.list(training_load)) 56 | expect_true(is.list(ef_colors)) 57 | 58 | expect_true(length(acwr_zones) > 0) 59 | expect_true(length(training_load) > 0) 60 | expect_true(length(ef_colors) > 0) 61 | }) 62 | 63 | test_that("theme_athlytics works", { 64 | skip_if_not_installed("ggplot2") 65 | 66 | theme <- theme_athlytics() 67 | expect_s3_class(theme, "theme") 68 | 69 | # Test with different base sizes 70 | theme_small <- theme_athlytics(base_size = 10) 71 | expect_s3_class(theme_small, "theme") 72 | 73 | theme_large <- theme_athlytics(base_size = 14) 74 | expect_s3_class(theme_large, "theme") 75 | }) 76 | 77 | test_that("scale_athlytics works for different palettes", { 78 | skip_if_not_installed("ggplot2") 79 | 80 | # Test different palettes 81 | scale_nature <- scale_athlytics("nature", type = "color") 82 | expect_s3_class(scale_nature, "ScaleDiscrete") 83 | 84 | scale_academic <- scale_athlytics("academic", type = "fill") 85 | expect_s3_class(scale_academic, "ScaleDiscrete") 86 | 87 | scale_vibrant <- scale_athlytics("vibrant", type = "color") 88 | expect_s3_class(scale_vibrant, "ScaleDiscrete") 89 | 90 | scale_science <- scale_athlytics("science", type = "fill") 91 | expect_s3_class(scale_science, "ScaleDiscrete") 92 | 93 | scale_cell <- scale_athlytics("cell", type = "color") 94 | expect_s3_class(scale_cell, "ScaleDiscrete") 95 | }) 96 | 97 | test_that("scale_athlytics handles invalid palette names", { 98 | skip_if_not_installed("ggplot2") 99 | 100 | # Should default to nature palette 101 | scale_default <- scale_athlytics("invalid_name", type = "color") 102 | expect_s3_class(scale_default, "ScaleDiscrete") 103 | }) 104 | 105 | test_that("package datasets are properly formatted", { 106 | data("athlytics_sample_acwr", package = "Athlytics") 107 | 108 | expect_s3_class(athlytics_sample_acwr, "data.frame") 109 | expect_true("date" %in% names(athlytics_sample_acwr)) 110 | expect_true("acwr" %in% names(athlytics_sample_acwr) || "acwr_smooth" %in% names(athlytics_sample_acwr)) 111 | }) 112 | 113 | test_that("plot functions handle sample data", { 114 | skip_if_not_installed("ggplot2") 115 | 116 | data("athlytics_sample_acwr", package = "Athlytics") 117 | 118 | # Test that plot can be created 119 | p <- plot_acwr(athlytics_sample_acwr) 120 | expect_s3_class(p, "ggplot") 121 | }) 122 | 123 | -------------------------------------------------------------------------------- /man/calculate_decoupling.Rd: -------------------------------------------------------------------------------- 1 | % Generated by roxygen2: do not edit by hand 2 | % Please edit documentation in R/calculate_decoupling.R 3 | \name{calculate_decoupling} 4 | \alias{calculate_decoupling} 5 | \title{Calculate Aerobic Decoupling} 6 | \usage{ 7 | calculate_decoupling( 8 | activities_data = NULL, 9 | export_dir = "strava_export_data", 10 | activity_type = c("Run", "Ride"), 11 | decouple_metric = c("pace_hr", "power_hr"), 12 | start_date = NULL, 13 | end_date = NULL, 14 | min_duration_mins = 40, 15 | min_steady_minutes = 40, 16 | steady_cv_threshold = 0.08, 17 | min_hr_coverage = 0.9, 18 | quality_control = c("off", "flag", "filter"), 19 | stream_df = NULL 20 | ) 21 | } 22 | \arguments{ 23 | \item{activities_data}{A data frame from \code{load_local_activities()}. Required unless \code{stream_df} is provided.} 24 | 25 | \item{export_dir}{Base directory of Strava export containing the activities folder. 26 | Default is "strava_export_data".} 27 | 28 | \item{activity_type}{Type(s) of activities to analyze (e.g., "Run", "Ride").} 29 | 30 | \item{decouple_metric}{Basis for calculation: "pace_hr" or "power_hr" 31 | (legacy "pace_hr"/"power_hr" also supported).} 32 | 33 | \item{start_date}{Optional. Analysis start date (YYYY-MM-DD string or Date). Defaults to one year ago.} 34 | 35 | \item{end_date}{Optional. Analysis end date (YYYY-MM-DD string or Date). Defaults to today.} 36 | 37 | \item{min_duration_mins}{Minimum activity duration (minutes) to include. Default 40.} 38 | 39 | \item{min_steady_minutes}{Minimum duration (minutes) for steady-state segment (default: 40). 40 | Activities shorter than this are automatically rejected for decoupling calculation.} 41 | 42 | \item{steady_cv_threshold}{Coefficient of variation threshold for steady-state (default: 0.08 = 8\%). 43 | Activities with higher variability are rejected as non-steady-state.} 44 | 45 | \item{min_hr_coverage}{Minimum HR data coverage threshold (default: 0.9 = 90\%). 46 | Activities with lower HR coverage are rejected as insufficient data quality.} 47 | 48 | \item{quality_control}{Quality control mode: "off" (no filtering), "flag" (mark issues), 49 | or "filter" (exclude flagged data). Default "filter" for scientific rigor.} 50 | 51 | \item{stream_df}{Optional. A pre-fetched data frame for a \emph{single} activity's stream. 52 | If provided, calculates decoupling for this data directly, ignoring other parameters. 53 | Must include columns: \code{time}, \code{heartrate}, and either \code{velocity_smooth}/\code{distance} 54 | (for pace_hr) or \code{watts} (for power_hr).} 55 | } 56 | \value{ 57 | Returns a data frame with columns: 58 | \describe{ 59 | \item{date}{Activity date (Date class)} 60 | \item{decoupling}{Decoupling percentage (\\\%). Positive = HR drift, negative = improved efficiency} 61 | \item{status}{Character. "ok" for successful calculation, "non_steady" if steady-state 62 | criteria not met, "insufficient_data" if data quality issues} 63 | } 64 | OR a single numeric decoupling value if \code{stream_df} is provided. 65 | } 66 | \description{ 67 | Calculates aerobic decoupling for Strava activities from local export data. 68 | } 69 | \details{ 70 | Calculates aerobic decoupling (HR drift relative to pace/power) using detailed 71 | activity stream data from local FIT/TCX/GPX files. 72 | 73 | Provides data for \code{plot_decoupling}. Compares output/HR efficiency 74 | between first and second halves of activities. Positive values indicate 75 | HR drift (cardiovascular drift). 76 | 77 | \strong{Best practice}: Use \code{load_local_activities()} to load data, then pass to this function. 78 | 79 | The function parses FIT/TCX/GPX files from your Strava export to extract detailed 80 | stream data (time, heartrate, distance/power). Activities are split into two halves, 81 | and the efficiency factor (output/HR) is compared between halves. 82 | } 83 | \examples{ 84 | # Example using simulated data 85 | data(Athlytics_sample_data) 86 | print(head(athlytics_sample_decoupling)) 87 | 88 | \dontrun{ 89 | # Load local activities 90 | activities <- load_local_activities("strava_export_data/activities.csv") 91 | 92 | # Calculate Pace/HR decoupling for recent runs 93 | run_decoupling <- calculate_decoupling( 94 | activities_data = activities, 95 | export_dir = "strava_export_data", 96 | activity_type = "Run", 97 | decouple_metric = "pace_hr", 98 | start_date = "2024-01-01" 99 | ) 100 | print(tail(run_decoupling)) 101 | 102 | # Calculate for a single activity stream 103 | # stream_data <- parse_activity_file("strava_export_data/activities/12345.fit") 104 | # single_decoupling <- calculate_decoupling(stream_df = stream_data, decouple_metric = "pace_hr") 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /tests/testthat/test-flag-quality.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-flag_quality.R 2 | 3 | test_that("flag_quality detects HR spikes", { 4 | # Create synthetic data with HR spike 5 | stream_data <- data.frame( 6 | time = 1:100, 7 | heartrate = c(rep(150, 50), 250, rep(150, 49)) # One spike at position 51 8 | ) 9 | 10 | result <- flag_quality(stream_data, sport = "Run") 11 | 12 | # Check that spike is flagged 13 | expect_true(result$flag_hr_spike[51]) 14 | expect_true(result$flag_any[51]) 15 | 16 | # Check that non-spike values before are not flagged 17 | expect_false(result$flag_hr_spike[50]) 18 | # Note: position 52 might also be flagged due to jump back down from spike 19 | # This is correct behavior, so we check that at least the spike itself is detected 20 | }) 21 | 22 | test_that("flag_quality detects power spikes", { 23 | # Create synthetic data with power spike 24 | stream_data <- data.frame( 25 | time = 1:100, 26 | watts = c(rep(200, 50), 1600, rep(200, 49)) # One spike at position 51 27 | ) 28 | 29 | result <- flag_quality(stream_data, sport = "Ride") 30 | 31 | # Check that spike is flagged 32 | expect_true(result$flag_pw_spike[51]) 33 | expect_true(result$flag_any[51]) 34 | }) 35 | 36 | test_that("flag_quality detects excessive HR jumps", { 37 | # Create data with excessive HR jump 38 | stream_data <- data.frame( 39 | time = 1:100, 40 | heartrate = c(rep(140, 50), 160, rep(140, 49)) # Jump of 20 bpm in 1 sec 41 | ) 42 | 43 | result <- flag_quality(stream_data, sport = "Run", max_hr_jump = 10) 44 | 45 | # Check that jump is flagged 46 | expect_true(result$flag_hr_spike[51]) 47 | }) 48 | 49 | test_that("flag_quality detects GPS drift", { 50 | # Create data with implausible speed 51 | stream_data <- data.frame( 52 | time = 1:100, 53 | velocity_smooth = c(rep(3.5, 50), 10, rep(3.5, 49)) # 10 m/s = ~36 km/h running 54 | ) 55 | 56 | result <- flag_quality(stream_data, sport = "Run", max_run_speed = 7.0) 57 | 58 | # Check that drift is flagged 59 | expect_true(result$flag_gps_drift[51]) 60 | expect_true(result$flag_any[51]) 61 | }) 62 | 63 | test_that("flag_quality calculates quality score", { 64 | # Create clean data 65 | clean_data <- data.frame( 66 | time = 1:100, 67 | heartrate = rep(150, 100), 68 | watts = rep(200, 100), 69 | velocity_smooth = rep(3.5, 100) 70 | ) 71 | 72 | result <- flag_quality(clean_data, sport = "Run") 73 | 74 | # Quality score should be 1.0 (perfect) 75 | expect_equal(result$quality_score[1], 1.0) 76 | 77 | # Create data with 10% flagged points 78 | dirty_data <- data.frame( 79 | time = 1:100, 80 | heartrate = c(rep(250, 10), rep(150, 90)) # 10% out of range 81 | ) 82 | 83 | result2 <- flag_quality(dirty_data, sport = "Run") 84 | 85 | # Quality score should be ~0.9 86 | expect_lt(result2$quality_score[1], 1.0) 87 | expect_gt(result2$quality_score[1], 0.85) 88 | }) 89 | 90 | test_that("flag_quality handles empty data gracefully", { 91 | empty_data <- data.frame(time = numeric(0)) 92 | 93 | expect_warning(result <- flag_quality(empty_data)) 94 | expect_equal(nrow(result), 0) 95 | expect_true("flag_any" %in% colnames(result)) 96 | }) 97 | 98 | test_that("flag_quality detects steady state", { 99 | # Create long steady-state data (25 minutes at constant pace) 100 | steady_data <- data.frame( 101 | time = 0:(25*60-1), # 25 minutes 102 | velocity_smooth = rnorm(25*60, mean = 3.5, sd = 0.1) # Low variability 103 | ) 104 | 105 | result <- flag_quality(steady_data, sport = "Run", min_steady_minutes = 20) 106 | 107 | # Should have some steady-state points 108 | expect_gt(sum(result$is_steady_state, na.rm = TRUE), 0) 109 | }) 110 | 111 | test_that("quality_summary provides correct statistics", { 112 | stream_data <- data.frame( 113 | time = 1:100, 114 | heartrate = c(rep(250, 10), rep(150, 90)) # 10% out of range + 1 transition 115 | ) 116 | 117 | result <- flag_quality(stream_data, sport = "Run") 118 | summary_stats <- quality_summary(result) 119 | 120 | expect_equal(summary_stats$total_points, 100) 121 | # Expect 10 out-of-range + 1 jump (transition from 250 to 150) 122 | expect_equal(summary_stats$flagged_points, 11) 123 | expect_equal(summary_stats$flagged_pct, 11) 124 | expect_true(summary_stats$quality_score > 0 && summary_stats$quality_score <= 1) 125 | }) 126 | 127 | test_that("flag_quality is sport-aware", { 128 | # Create data with speed that's OK for cycling but not running 129 | stream_data <- data.frame( 130 | time = 1:100, 131 | velocity_smooth = rep(12, 100) # 12 m/s = ~43 km/h 132 | ) 133 | 134 | # Should be flagged for running 135 | result_run <- flag_quality(stream_data, sport = "Run", max_run_speed = 7.0) 136 | expect_gt(sum(result_run$flag_gps_drift), 0) 137 | 138 | # Should NOT be flagged for cycling 139 | result_ride <- flag_quality(stream_data, sport = "Ride", max_ride_speed = 25.0) 140 | expect_equal(sum(result_ride$flag_gps_drift), 0) 141 | }) 142 | 143 | -------------------------------------------------------------------------------- /tests/testthat/test-acwr.R: -------------------------------------------------------------------------------- 1 | # tests/testthat/test-acwr.R 2 | 3 | # ACWR Calculation and Plotting Tests 4 | 5 | library(Athlytics) 6 | library(testthat) 7 | 8 | # Load sample data from the package 9 | data(athlytics_sample_acwr) 10 | data(athlytics_sample_exposure) 11 | 12 | # Create mock activities data for testing 13 | create_mock_activities <- function(n = 30) { 14 | dates <- seq(Sys.Date() - n, Sys.Date(), by = "day") 15 | data.frame( 16 | id = seq_len(length(dates)), 17 | name = paste("Activity", seq_len(length(dates))), 18 | type = sample(c("Run", "Ride"), length(dates), replace = TRUE), 19 | sport_type = sample(c("Run", "Ride"), length(dates), replace = TRUE), 20 | start_date_local = as.POSIXct(dates), 21 | date = as.Date(dates), 22 | distance = runif(length(dates), 1000, 15000), # meters 23 | moving_time = as.integer(runif(length(dates), 1200, 5400)), # seconds 24 | elapsed_time = as.integer(runif(length(dates), 1200, 5400)), 25 | average_heartrate = runif(length(dates), 120, 170), 26 | max_heartrate = runif(length(dates), 160, 190), 27 | average_watts = runif(length(dates), 150, 250), 28 | max_watts = runif(length(dates), 300, 500), 29 | elevation_gain = runif(length(dates), 0, 500), 30 | average_speed = runif(length(dates), 2, 5), 31 | stringsAsFactors = FALSE 32 | ) 33 | } 34 | 35 | # --- Test calculate_acwr with local data --- 36 | 37 | test_that("calculate_acwr works with activities_data parameter", { 38 | mock_activities <- create_mock_activities(60) 39 | 40 | acwr_result <- calculate_acwr( 41 | activities_data = mock_activities, 42 | load_metric = "duration_mins", 43 | activity_type = "Run", 44 | acute_period = 7, 45 | chronic_period = 28 46 | ) 47 | 48 | # Structure checks 49 | expect_s3_class(acwr_result, "data.frame") 50 | expect_true(all(c("date", "atl", "ctl", "acwr", "acwr_smooth") %in% colnames(acwr_result))) 51 | expect_s3_class(acwr_result$date, "Date") 52 | 53 | # Check that we have results 54 | expect_gt(nrow(acwr_result), 0) 55 | 56 | # Numerical checks 57 | expect_true(is.numeric(acwr_result$atl)) 58 | expect_true(is.numeric(acwr_result$ctl)) 59 | expect_true(is.numeric(acwr_result$acwr)) 60 | }) 61 | 62 | test_that("calculate_acwr validates activities_data parameter", { 63 | # Test with non-data.frame 64 | expect_error( 65 | calculate_acwr(activities_data = "not_a_dataframe"), 66 | "must be a data frame" 67 | ) 68 | 69 | # Test with empty or incomplete data frame 70 | empty_df <- data.frame() 71 | expect_error( 72 | calculate_acwr(activities_data = empty_df), 73 | "activity_type.*must be explicitly specified" # Now checks for activity_type first 74 | ) 75 | }) 76 | 77 | test_that("calculate_acwr validates period parameters", { 78 | mock_activities <- create_mock_activities() 79 | 80 | # acute_period must be less than chronic_period 81 | expect_error( 82 | calculate_acwr( 83 | activities_data = mock_activities, 84 | acute_period = 28, 85 | chronic_period = 7 86 | ), 87 | "acute_period.*must be less than.*chronic_period" 88 | ) 89 | }) 90 | 91 | test_that("calculate_acwr works with different load metrics", { 92 | mock_activities <- create_mock_activities(60) 93 | 94 | # Test duration_mins 95 | acwr_duration <- calculate_acwr( 96 | activities_data = mock_activities, 97 | activity_type = "Run", 98 | load_metric = "duration_mins" 99 | ) 100 | expect_s3_class(acwr_duration, "data.frame") 101 | 102 | # Test distance_km 103 | acwr_distance <- calculate_acwr( 104 | activities_data = mock_activities, 105 | activity_type = "Run", 106 | load_metric = "distance_km" 107 | ) 108 | expect_s3_class(acwr_distance, "data.frame") 109 | 110 | # Test elevation 111 | acwr_elevation <- calculate_acwr( 112 | activities_data = mock_activities, 113 | activity_type = "Run", 114 | load_metric = "elevation_gain_m" 115 | ) 116 | expect_s3_class(acwr_elevation, "data.frame") 117 | }) 118 | 119 | test_that("calculate_acwr filters by activity type correctly", { 120 | mock_activities <- create_mock_activities(60) 121 | 122 | acwr_run <- calculate_acwr( 123 | activities_data = mock_activities, 124 | activity_type = "Run", 125 | load_metric = "duration_mins" 126 | ) 127 | 128 | expect_s3_class(acwr_run, "data.frame") 129 | expect_gt(nrow(acwr_run), 0) 130 | }) 131 | 132 | test_that("calculate_acwr works with sample data", { 133 | skip_if(is.null(athlytics_sample_acwr), "Sample ACWR data not available") 134 | 135 | # Just check that sample data has the right structure 136 | expect_s3_class(athlytics_sample_acwr, "data.frame") 137 | expect_true(all(c("date", "atl", "ctl", "acwr") %in% colnames(athlytics_sample_acwr))) 138 | }) 139 | 140 | # --- Test plot_acwr --- 141 | 142 | test_that("plot_acwr works with pre-calculated data", { 143 | skip_if(is.null(athlytics_sample_acwr), "Sample ACWR data not available") 144 | 145 | p <- plot_acwr(athlytics_sample_acwr, highlight_zones = FALSE) 146 | 147 | expect_s3_class(p, "ggplot") 148 | }) 149 | 150 | test_that("plot_acwr validates input", { 151 | # Test with non-data.frame - should error 152 | expect_error( 153 | plot_acwr("not_a_dataframe"), 154 | "activities_data.*must be a data frame" 155 | ) 156 | 157 | # Test with missing required columns - should error or warn 158 | bad_df <- data.frame(x = 1:10, y = 1:10) 159 | expect_error( 160 | plot_acwr(bad_df), 161 | "activity_type.*must be explicitly specified" # Now checks for activity_type first 162 | ) 163 | }) 164 | -------------------------------------------------------------------------------- /.github/workflows/R-CMD-check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main, master] 4 | pull_request: 5 | branches: [main, master] 6 | 7 | name: R-CMD-check 8 | 9 | jobs: 10 | R-CMD-check: 11 | runs-on: ${{ matrix.config.os }} 12 | 13 | name: ${{ matrix.config.os }} (${{ matrix.config.r }}) 14 | 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | config: 19 | - {os: windows-latest, r: 'release'} 20 | - {os: ubuntu-latest, r: 'release'} 21 | - {os: macOS-latest, r: 'release'} 22 | 23 | env: 24 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 25 | R_REMOTES_NO_ERRORS_FROM_WARNINGS: true 26 | _R_CHECK_CRAN_INCOMING_REMOTE_: false 27 | _R_CHECK_FORCE_SUGGESTS_: false 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | 32 | - uses: r-lib/actions/setup-pandoc@v2 33 | 34 | - uses: r-lib/actions/setup-r@v2 35 | with: 36 | r-version: ${{ matrix.config.r }} 37 | http-user-agent: ${{ matrix.config.http-user-agent }} 38 | use-public-rspm: true 39 | 40 | - uses: r-lib/actions/setup-r-dependencies@v2 41 | with: 42 | extra-packages: any::rcmdcheck, any::covr, any::devtools, any::mockery, any::testthat, any::remotes 43 | needs: check 44 | 45 | - name: Install GitHub dependencies 46 | run: | 47 | if (!requireNamespace("remotes", quietly = TRUE)) install.packages("remotes") 48 | # Install FITfileR from GitHub (in Suggests, so won't fail if install fails) 49 | tryCatch({ 50 | remotes::install_github("grimbough/FITfileR", quiet = TRUE) 51 | }, error = function(e) { 52 | message("Note: FITfileR installation skipped (optional dependency)") 53 | }) 54 | shell: Rscript {0} 55 | 56 | - name: Get package name and version 57 | id: pkg_info 58 | run: | 59 | PKG=$(awk -F': ' '/^Package:/{print $2}' DESCRIPTION | tr -d '\r') 60 | VERSION=$(awk -F': ' '/^Version:/{print $2}' DESCRIPTION | tr -d '\r') 61 | echo "Package: $PKG" 62 | echo "Version: $VERSION" 63 | echo "PKG=$PKG" >> "$GITHUB_OUTPUT" 64 | echo "VERSION=$VERSION" >> "$GITHUB_OUTPUT" 65 | shell: bash 66 | 67 | - name: Clean workspace and build fresh package 68 | run: | 69 | # Remove any existing tarballs to ensure we test the fresh build 70 | rm -f *.tar.gz || true 71 | # Build the package from current source (skip vignettes to avoid pandoc issues) 72 | R CMD build . --no-build-vignettes 73 | # Verify the expected tarball was created 74 | EXPECTED_TARBALL="${{ steps.pkg_info.outputs.PKG }}_${{ steps.pkg_info.outputs.VERSION }}.tar.gz" 75 | if [ ! -f "$EXPECTED_TARBALL" ]; then 76 | echo "Error: Expected tarball $EXPECTED_TARBALL was not created" 77 | ls -la *.tar.gz || echo "No .tar.gz files found" 78 | exit 1 79 | fi 80 | echo "Successfully built: $EXPECTED_TARBALL" 81 | echo "TARBALL_NAME=$EXPECTED_TARBALL" >> "$GITHUB_OUTPUT" 82 | id: build_package 83 | shell: bash 84 | 85 | - name: Run R CMD check on tarball (CRAN-like check) 86 | run: | 87 | R CMD check ${{ steps.build_package.outputs.TARBALL_NAME }} --no-manual --no-build-vignettes --no-examples 88 | shell: bash 89 | env: 90 | _R_CHECK_CRAN_INCOMING_: false 91 | _R_CHECK_CRAN_INCOMING_REMOTE_: false 92 | _R_CHECK_FORCE_SUGGESTS_: false 93 | _R_CHECK_TESTS_NLINES_: 0 94 | GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }} 95 | 96 | 97 | - name: Generate and Upload Coverage Report to Codecov 98 | if: success() && matrix.config.os == 'ubuntu-latest' # Only run coverage on Ubuntu 99 | shell: Rscript {0} 100 | run: | 101 | pkg <- "${{ steps.pkg_info.outputs.PKG }}" 102 | tarball <- "${{ steps.build_package.outputs.TARBALL_NAME }}" 103 | 104 | # Install required packages 105 | if (!requireNamespace("devtools", quietly = TRUE)) install.packages("devtools") 106 | if (!requireNamespace("covr", quietly = TRUE)) install.packages("covr") 107 | if (!requireNamespace("testthat", quietly = TRUE)) install.packages("testthat") 108 | if (!requireNamespace("mockery", quietly = TRUE)) install.packages("mockery") 109 | 110 | message(sprintf("Installing package from tarball for coverage testing: %s", tarball)) 111 | devtools::install_local(tarball, force = TRUE, quiet = TRUE, dependencies = TRUE) 112 | 113 | message(sprintf("Loading package: %s", pkg)) 114 | library(pkg, character.only = TRUE) 115 | 116 | # Run coverage with error handling 117 | tryCatch({ 118 | message("Executing run_coverage.R to generate and upload coverage...") 119 | source("run_coverage.R") 120 | }, error = function(e) { 121 | message(paste("Coverage failed:", e$message)) 122 | message("Attempting alternative coverage method...") 123 | cov <- covr::package_coverage() 124 | if (!is.null(cov)) { 125 | covr::codecov(coverage = cov) 126 | } 127 | }) 128 | 129 | - name: Upload check results 130 | if: failure() 131 | uses: actions/upload-artifact@v4 132 | with: 133 | name: ${{ matrix.config.os }}-r${{ matrix.config.r }}-results 134 | path: ${{ steps.pkg_info.outputs.PKG }}.Rcheck -------------------------------------------------------------------------------- /paper/paper.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Athlytics: Reproducible Scientific Workflows for Cohort Analysis of Endurance Training using Local Strava Data' 3 | tags: 4 | - R 5 | - sports science 6 | - endurance 7 | - Strava 8 | - cohort analysis 9 | - reproducibility 10 | - ACWR 11 | - cardiac drift 12 | authors: 13 | - name: Zhiang He 14 | orcid: 0009-0009-0171-4578 15 | affiliation: 1 16 | affiliations: 17 | - name: Independent Researcher 18 | index: 1 19 | date: "2025-10-11" 20 | bibliography: paper.bib 21 | version: 1.0.0 22 | license: MIT 23 | --- 24 | 25 | # Summary 26 | 27 | **Athlytics** is an R package that delivers a **complete, API-free workflow for the reproducible analysis of endurance-training data**. Working entirely from **local Strava archives** (ZIP files or CSVs), it provides an integrated pipeline from import and quality control to key physiological metrics, including **acute-to-chronic workload ratios (ACWR)** [@gabbett2016], aerobic efficiency, and cardiac decoupling. Unlike API-dependent approaches, Athlytics is **offline and cohort-first**, integrating quality control, physiological guardrails, and uncertainty reporting into a single, auditable workflow. 28 | 29 | # Statement of Need 30 | 31 | Analyzing endurance training data in R often requires stitching together API clients, file parsers, and custom scripts. This creates workflows that are fragile and difficult to reproduce, especially for **cohort-scale** research. **Athlytics** addresses this gap by providing a **single, research-oriented pipeline** that works offline with local archives, offers an integrated suite of physiological models, and is built from the ground up for multi-athlete analysis. This design **reduces "glue code"** and makes cohort-scale analyses auditable and easy to reproduce. 32 | 33 | # Related Work 34 | 35 | We provide a direct feature comparison to highlight the capabilities essential for reproducible, cohort-scale research. 36 | 37 | | Feature (research-relevant) | **Athlytics** | rStrava [@rStrava] | trackeR [@trackeR_jss] | activatr [@activatr] | ACWR [@ACWR] | injurytools [@injurytools] | 38 | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | 39 | | **Offline archives; No OAuth/tokens/quotas** | ✓ | ✕ (API) | ✓ | ✓ | ✓ (tabular) | ✓ (tabular) | 40 | | **API-limited (OAuth, scope, rate-limits)** | ✕ | ✓ | ✕ | ✕ | ✕ | ✕ | 41 | | **End-to-end pipeline (Import→QC→Models→Plot)** | ✓ | ✕ | **Partial** (parsing/viz) | **Partial** (parsing/pace) | ✕ | ✕ | 42 | | **Built-in metrics (ACWR/EF/Decoupling)** | ✓ | ✕ | ✕ | ✕ | **Partial** (ACWR only) | ✕ | 43 | | **Steady-state guards & HR-coverage checks** | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | 44 | | **Uncertainty (ACWR-EWMA confidence bands)** | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | 45 | | **Cohort benchmarking (percentile bands)** | ✓ | ✕ | **Partial** (summaries only) | ✕ | ✕ | **Partial** (for injury/exposure) | 46 | | **Diagnostic outputs (status/reason)** | ✓ | ✕ | ✕ | ✕ | ✕ | ✕ | 47 | 48 | Athlytics is unique in providing an API-free, end-to-end workflow that integrates a full suite of physiological models, uncertainty quantification, and built-in cohort benchmarking features essential for reproducible research. 49 | 50 | # Software Description 51 | 52 | - **Inputs & Data Model:** Reads Strava ZIP archives or `activities.csv`. Activity streams (FIT/TCX/GPX) are loaded **on demand** from the archive, optionally using `FITfileR` for FIT files [@FITfileR]. The core function `load_local_activities()` produces a standardized tibble. 53 | - **Core Metrics:** `calculate_acwr()`, `calculate_ef()`, and `calculate_decoupling()` compute key summaries with sensible, research-oriented defaults. The implementation of ACWR acknowledges its conceptual issues and is presented as a monitoring tool [@impellizzeri2020acwr]. 54 | - **Uncertainty Quantification:** Provides confidence intervals for EWMA-based ACWR using a moving-block bootstrap [@kunsch1989; @politis1994], a key feature for research applications. 55 | - **Cohort Benchmarking:** `cohort_reference()` computes percentile bands, which can be layered onto individual plots using `plot_with_reference()`. 56 | - **Plotting & Diagnostics:** Visualization functions follow a **data-first API**. Functions return **diagnostic fields** (e.g., `status`, `reason`) when inputs are insufficient, making the workflow transparent. 57 | 58 | # Example 59 | 60 | The following example demonstrates a common cohort analysis workflow: loading data from multiple athletes, calculating ACWR for each, and plotting an individual against cohort-wide reference bands. 61 | 62 | ```r 63 | library(Athlytics) 64 | library(dplyr) 65 | 66 | # 1. Load and combine data for a cohort of athletes, adding identifiers 67 | athlete1 <- load_local_activities("athlete1_export.zip") %>% mutate(athlete_id = "A1") 68 | athlete2 <- load_local_activities("athlete2_export.zip") %>% mutate(athlete_id = "A2") 69 | cohort_data <- bind_rows(athlete1, athlete2) 70 | 71 | # 2. Calculate ACWR across the entire cohort using a modern dplyr workflow 72 | cohort_acwr <- cohort_data %>% 73 | group_by(athlete_id) %>% 74 | group_modify(~ calculate_acwr(.x, load_metric = "duration_mins")) %>% 75 | ungroup() 76 | 77 | # 3. Generate cohort-wide percentile reference bands 78 | reference_bands <- cohort_reference(cohort_acwr, metric = "acwr_smooth") 79 | 80 | # 4. Plot an individual's data against the cohort reference 81 | individual_acwr <- cohort_acwr %>% filter(athlete_id == "A1") 82 | plot_with_reference(individual = individual_acwr, reference = reference_bands) 83 | ``` 84 | 85 | # Acknowledgements 86 | 87 | The author acknowledges the helpful feedback from the pyOpenSci community, and the constructive suggestions provided by Professors Benjamin S. Baumer and Iztok Fister Jr., as well as the developers of the referenced R packages. 88 | 89 | # References 90 | -------------------------------------------------------------------------------- /tests/testthat/test-plot-ef-comprehensive.R: -------------------------------------------------------------------------------- 1 | # Comprehensive test for plot_ef.R to boost coverage 2 | 3 | test_that("plot_ef handles data calculation from activities", { 4 | # Create mock activities data 5 | mock_activities <- data.frame( 6 | date = seq(Sys.Date() - 100, Sys.Date(), by = "7 days"), 7 | type = rep("Run", 15), 8 | moving_time = rep(2400, 15), 9 | distance = rep(8000, 15), 10 | average_heartrate = seq(140, 160, length.out = 15), 11 | average_watts = rep(200, 15), 12 | filename = rep(NA, 15), 13 | stringsAsFactors = FALSE 14 | ) 15 | 16 | # Test plot_ef calculating EF from activities data 17 | p <- plot_ef(data = mock_activities, 18 | activity_type = "Run", 19 | ef_metric = "pace_hr") 20 | expect_s3_class(p, "ggplot") 21 | 22 | # Test with different parameters 23 | p2 <- plot_ef(data = mock_activities, 24 | activity_type = "Run", 25 | ef_metric = "pace_hr", 26 | start_date = Sys.Date() - 50, 27 | end_date = Sys.Date(), 28 | min_duration_mins = 15) 29 | expect_s3_class(p2, "ggplot") 30 | }) 31 | 32 | test_that("plot_ef handles different smoothing methods", { 33 | data("athlytics_sample_ef") 34 | 35 | # Test different smoothing methods 36 | p_loess <- plot_ef(athlytics_sample_ef, smoothing_method = "loess") 37 | expect_s3_class(p_loess, "ggplot") 38 | 39 | p_lm <- plot_ef(athlytics_sample_ef, smoothing_method = "lm") 40 | expect_s3_class(p_lm, "ggplot") 41 | 42 | p_gam <- plot_ef(athlytics_sample_ef, smoothing_method = "gam") 43 | expect_s3_class(p_gam, "ggplot") 44 | }) 45 | 46 | test_that("plot_ef handles different activity type combinations", { 47 | data("athlytics_sample_ef") 48 | 49 | # Test with single activity type 50 | p_run <- plot_ef(athlytics_sample_ef, activity_type = "Run") 51 | expect_s3_class(p_run, "ggplot") 52 | 53 | # Test with multiple activity types 54 | p_multi <- plot_ef(athlytics_sample_ef, activity_type = c("Run", "Ride")) 55 | expect_s3_class(p_multi, "ggplot") 56 | 57 | # Test with activity type not in data 58 | p_none <- plot_ef(athlytics_sample_ef, activity_type = "Swim") 59 | expect_s3_class(p_none, "ggplot") 60 | }) 61 | 62 | test_that("plot_ef handles date filtering", { 63 | data("athlytics_sample_ef") 64 | 65 | # Test with date range 66 | p_date <- plot_ef(athlytics_sample_ef, 67 | start_date = Sys.Date() - 30, 68 | end_date = Sys.Date()) 69 | expect_s3_class(p_date, "ggplot") 70 | 71 | # Test with only start_date 72 | p_start <- plot_ef(athlytics_sample_ef, 73 | start_date = Sys.Date() - 30) 74 | expect_s3_class(p_start, "ggplot") 75 | 76 | # Test with only end_date 77 | p_end <- plot_ef(athlytics_sample_ef, 78 | end_date = Sys.Date()) 79 | expect_s3_class(p_end, "ggplot") 80 | }) 81 | 82 | test_that("plot_ef handles different ef_metric values", { 83 | data("athlytics_sample_ef") 84 | 85 | # Test with pace_hr metric 86 | p_pace <- plot_ef(athlytics_sample_ef, ef_metric = "pace_hr") 87 | expect_s3_class(p_pace, "ggplot") 88 | 89 | # Test with power_hr metric 90 | p_power <- plot_ef(athlytics_sample_ef, ef_metric = "power_hr") 91 | expect_s3_class(p_power, "ggplot") 92 | }) 93 | 94 | test_that("plot_ef handles trend line options", { 95 | data("athlytics_sample_ef") 96 | 97 | # Test with trend line 98 | p_trend <- plot_ef(athlytics_sample_ef, add_trend_line = TRUE) 99 | expect_s3_class(p_trend, "ggplot") 100 | 101 | # Test without trend line 102 | p_no_trend <- plot_ef(athlytics_sample_ef, add_trend_line = FALSE) 103 | expect_s3_class(p_no_trend, "ggplot") 104 | }) 105 | 106 | test_that("plot_ef handles data with different structures", { 107 | # Test with minimal required columns 108 | minimal_data <- data.frame( 109 | date = c(Sys.Date(), Sys.Date() - 1), 110 | activity_type = c("Run", "Run"), 111 | ef_value = c(0.02, 0.021) 112 | ) 113 | 114 | p_minimal <- plot_ef(minimal_data) 115 | expect_s3_class(p_minimal, "ggplot") 116 | 117 | # Test with extra columns 118 | extra_data <- data.frame( 119 | date = c(Sys.Date(), Sys.Date() - 1), 120 | activity_type = c("Run", "Run"), 121 | ef_value = c(0.02, 0.021), 122 | extra_col = c("A", "B") 123 | ) 124 | 125 | p_extra <- plot_ef(extra_data) 126 | expect_s3_class(p_extra, "ggplot") 127 | }) 128 | 129 | test_that("plot_ef handles edge cases with data", { 130 | # Test with single row 131 | single_row <- data.frame( 132 | date = Sys.Date(), 133 | activity_type = "Run", 134 | ef_value = 0.02 135 | ) 136 | 137 | p_single <- plot_ef(single_row) 138 | expect_s3_class(p_single, "ggplot") 139 | 140 | # Test with all NA ef_values 141 | na_data <- data.frame( 142 | date = c(Sys.Date(), Sys.Date() - 1), 143 | activity_type = c("Run", "Run"), 144 | ef_value = c(NA, NA) 145 | ) 146 | 147 | p_na <- plot_ef(na_data) 148 | expect_s3_class(p_na, "ggplot") 149 | }) 150 | 151 | test_that("plot_ef handles parameter combinations", { 152 | data("athlytics_sample_ef") 153 | 154 | # Test multiple parameters together 155 | p_combo <- plot_ef(athlytics_sample_ef, 156 | activity_type = "Run", 157 | ef_metric = "pace_hr", 158 | add_trend_line = TRUE, 159 | smoothing_method = "loess", 160 | start_date = Sys.Date() - 30, 161 | end_date = Sys.Date(), 162 | min_duration_mins = 15) 163 | expect_s3_class(p_combo, "ggplot") 164 | }) 165 | -------------------------------------------------------------------------------- /tests/testthat/test-calculate-ef-extended.R: -------------------------------------------------------------------------------- 1 | # Extended tests for calculate_ef to improve coverage 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | 6 | create_test_activities <- function(n = 30, with_hr = TRUE, with_power = FALSE, with_speed = TRUE) { 7 | data.frame( 8 | id = 1:n, 9 | name = paste("Activity", 1:n), 10 | type = sample(c("Run", "Ride"), n, replace = TRUE), 11 | date = seq(Sys.Date() - n, Sys.Date() - 1, by = "day"), 12 | distance = runif(n, 5000, 15000), 13 | moving_time = runif(n, 1800, 5400), 14 | average_heartrate = if(with_hr) runif(n, 130, 170) else rep(NA_real_, n), 15 | average_watts = if(with_power) runif(n, 180, 250) else rep(NA_real_, n), 16 | average_speed = if(with_speed) runif(n, 2.5, 4.5) else rep(NA_real_, n), 17 | stringsAsFactors = FALSE 18 | ) 19 | } 20 | 21 | test_that("calculate_ef filters by activity type correctly", { 22 | activities <- create_test_activities(50) 23 | activities$type <- rep(c("Run", "Ride", "Run", "Swim", "Run"), 10) 24 | 25 | ef_runs <- calculate_ef(activities, activity_type = "Run", ef_metric = "pace_hr") 26 | expect_true(all(ef_runs$activity_type == "Run")) 27 | 28 | ef_rides <- calculate_ef(activities, activity_type = "Ride", ef_metric = "power_hr") 29 | expect_true(all(ef_rides$activity_type == "Ride")) 30 | }) 31 | 32 | test_that("calculate_ef handles missing heart rate data", { 33 | activities <- create_test_activities(20, with_hr = FALSE) 34 | 35 | ef_result <- calculate_ef(activities, ef_metric = "pace_hr") 36 | 37 | # Should return data frame even if all HR is missing 38 | expect_s3_class(ef_result, "data.frame") 39 | }) 40 | 41 | test_that("calculate_ef handles missing power data", { 42 | activities <- create_test_activities(20, with_power = FALSE) 43 | 44 | ef_result <- calculate_ef(activities, ef_metric = "power_hr", activity_type = "Ride") 45 | 46 | expect_s3_class(ef_result, "data.frame") 47 | }) 48 | 49 | test_that("calculate_ef handles missing speed data", { 50 | activities <- create_test_activities(20, with_speed = FALSE) 51 | 52 | ef_result <- calculate_ef(activities, ef_metric = "pace_hr") 53 | 54 | expect_s3_class(ef_result, "data.frame") 55 | }) 56 | 57 | test_that("calculate_ef calculates pace/hr correctly", { 58 | activities <- data.frame( 59 | id = 1:5, 60 | name = paste("Run", 1:5), 61 | type = "Run", 62 | date = seq(Sys.Date() - 5, Sys.Date() - 1, by = "day"), 63 | distance = c(10000, 10000, 10000, 10000, 10000), 64 | moving_time = c(3000, 3000, 3000, 3000, 3000), 65 | average_heartrate = c(150, 150, 150, 150, 150), 66 | average_speed = c(3.33, 3.33, 3.33, 3.33, 3.33), 67 | stringsAsFactors = FALSE 68 | ) 69 | 70 | ef_result <- calculate_ef(activities, ef_metric = "pace_hr") 71 | 72 | expect_s3_class(ef_result, "data.frame") 73 | expect_true("ef_value" %in% names(ef_result)) 74 | expect_equal(nrow(ef_result), 5) 75 | }) 76 | 77 | test_that("calculate_ef calculates power/hr correctly", { 78 | activities <- data.frame( 79 | id = 1:5, 80 | name = paste("Ride", 1:5), 81 | type = "Ride", 82 | date = seq(Sys.Date() - 5, Sys.Date() - 1, by = "day"), 83 | distance = c(20000, 20000, 20000, 20000, 20000), 84 | moving_time = c(3000, 3000, 3000, 3000, 3000), 85 | average_heartrate = c(140, 140, 140, 140, 140), 86 | average_watts = c(200, 200, 200, 200, 200), 87 | stringsAsFactors = FALSE 88 | ) 89 | 90 | ef_result <- calculate_ef(activities, ef_metric = "power_hr", activity_type = "Ride") 91 | 92 | expect_s3_class(ef_result, "data.frame") 93 | expect_true("ef_value" %in% names(ef_result)) 94 | expect_equal(nrow(ef_result), 5) 95 | }) 96 | 97 | test_that("calculate_ef handles date range filtering", { 98 | activities <- create_test_activities(60) 99 | 100 | start_date <- Sys.Date() - 30 101 | end_date <- Sys.Date() - 10 102 | 103 | ef_result <- calculate_ef( 104 | activities, 105 | ef_metric = "pace_hr", 106 | start_date = start_date, 107 | end_date = end_date 108 | ) 109 | 110 | expect_s3_class(ef_result, "data.frame") 111 | if (nrow(ef_result) > 0) { 112 | expect_true(all(ef_result$date >= start_date)) 113 | expect_true(all(ef_result$date <= end_date)) 114 | } 115 | }) 116 | 117 | test_that("calculate_ef respects min_duration_mins", { 118 | activities <- create_test_activities(20) 119 | activities$moving_time <- c(rep(600, 10), rep(3600, 10)) # 10 min vs 60 min 120 | 121 | ef_result <- calculate_ef( 122 | activities, 123 | ef_metric = "pace_hr", 124 | min_duration_mins = 30 125 | ) 126 | 127 | expect_s3_class(ef_result, "data.frame") 128 | # Should only include activities >= 30 minutes 129 | expect_true(nrow(ef_result) <= 10) 130 | }) 131 | 132 | test_that("calculate_ef handles empty result set", { 133 | activities <- create_test_activities(10) 134 | 135 | # Filter to impossible date range should throw error 136 | expect_error( 137 | calculate_ef( 138 | activities, 139 | ef_metric = "pace_hr", 140 | start_date = Sys.Date() + 100, 141 | end_date = Sys.Date() + 200 142 | ), 143 | "No activities found" 144 | ) 145 | }) 146 | 147 | test_that("calculate_ef handles multiple activity types", { 148 | activities <- create_test_activities(40) 149 | activities$type <- rep(c("Run", "Ride"), 20) 150 | 151 | ef_result <- calculate_ef( 152 | activities, 153 | activity_type = c("Run", "Ride"), 154 | ef_metric = "pace_hr" 155 | ) 156 | 157 | expect_s3_class(ef_result, "data.frame") 158 | if (nrow(ef_result) > 0) { 159 | expect_true(all(ef_result$activity_type %in% c("Run", "Ride"))) 160 | } 161 | }) 162 | 163 | -------------------------------------------------------------------------------- /tests/testthat/test-absolute-real-data.R: -------------------------------------------------------------------------------- 1 | # Tests with absolute paths to ensure coverage measurement works 2 | 3 | library(testthat) 4 | library(Athlytics) 5 | 6 | # Use absolute path to ensure data is found during coverage runs 7 | base_dir <- "C:/Users/Ang/Documents/GitHub/Athlytics" 8 | csv_path <- file.path(base_dir, "export_data", "activities.csv") 9 | zip_path <- file.path(base_dir, "export_97354582.zip") 10 | 11 | test_that("load with absolute path comprehensive", { 12 | skip_if(!file.exists(csv_path), "CSV file not found") 13 | 14 | # This should definitely work and be counted in coverage 15 | activities <- load_local_activities(csv_path) 16 | 17 | expect_true(is.data.frame(activities) || inherits(activities, "tbl")) 18 | expect_gt(nrow(activities), 0) 19 | expect_true("id" %in% names(activities)) 20 | expect_true("date" %in% names(activities)) 21 | expect_true("type" %in% names(activities)) 22 | }) 23 | 24 | test_that("comprehensive activity type filtering", { 25 | skip_if(!file.exists(csv_path), "CSV file not found") 26 | 27 | all_act <- load_local_activities(csv_path) 28 | 29 | # Test each unique activity type 30 | types <- unique(all_act$type)[1:min(5, length(unique(all_act$type)))] 31 | for (atype in types) { 32 | filtered <- load_local_activities(csv_path, activity_types = atype) 33 | expect_true(is.data.frame(filtered) || inherits(filtered, "tbl")) 34 | } 35 | }) 36 | 37 | test_that("comprehensive calculate with real data", { 38 | skip_if(!file.exists(csv_path), "CSV file not found") 39 | 40 | act <- load_local_activities(csv_path) 41 | 42 | # Find activity type with most data 43 | type_table <- table(act$type) 44 | main_type <- names(which.max(type_table)) 45 | 46 | type_act <- act[act$type == main_type, ] 47 | 48 | if (nrow(type_act) >= 60) { 49 | # Test ACWR functions 50 | acwr1 <- calculate_acwr(type_act, activity_type = main_type, load_metric = "duration_mins") 51 | acwr2 <- calculate_acwr(type_act, activity_type = main_type, load_metric = "distance_km") 52 | acwr3 <- calculate_acwr(type_act, activity_type = main_type, acute_period = 7, chronic_period = 28) 53 | 54 | ewma <- calculate_acwr_ewma(type_act, activity_type = main_type, method = "ewma") 55 | ra <- calculate_acwr_ewma(type_act, activity_type = main_type, method = "ra") 56 | 57 | # Test exposure only if we have recent data 58 | date_range <- range(type_act$date, na.rm = TRUE) 59 | days_ago <- as.numeric(Sys.Date() - date_range[2]) 60 | 61 | if (days_ago <= 100) { # Only test if data is recent enough 62 | exp1 <- tryCatch({ 63 | calculate_exposure(type_act, activity_type = main_type, load_metric = "duration_mins") 64 | }, error = function(e) NULL) 65 | 66 | exp2 <- tryCatch({ 67 | calculate_exposure(type_act, activity_type = main_type, load_metric = "distance_km") 68 | }, error = function(e) NULL) 69 | } 70 | 71 | expect_true(TRUE) # If we get here, all calculations worked 72 | } else { 73 | skip("Not enough activities for comprehensive testing") 74 | } 75 | }) 76 | 77 | test_that("comprehensive ef with real data", { 78 | skip_if(!file.exists(csv_path), "CSV file not found") 79 | 80 | act <- load_local_activities(csv_path) 81 | 82 | # Run activities with HR 83 | runs <- act[act$type == "Run" & !is.na(act$average_heartrate), ] 84 | 85 | if (nrow(runs) >= 30) { 86 | ef1 <- calculate_ef(runs, activity_type = "Run", ef_metric = "pace_hr") 87 | ef2 <- calculate_ef(runs, activity_type = "Run", ef_metric = "pace_hr", min_duration_mins = 20) 88 | ef3 <- calculate_ef(runs, activity_type = "Run", ef_metric = "pace_hr", min_duration_mins = 40) 89 | 90 | if (nrow(runs) >= 100) { 91 | dates <- range(runs$date, na.rm = TRUE) 92 | mid <- dates[1] + as.numeric(diff(dates)) / 2 93 | ef4 <- calculate_ef(runs, activity_type = "Run", ef_metric = "pace_hr", 94 | start_date = dates[1], end_date = mid) 95 | } 96 | 97 | expect_true(TRUE) 98 | } 99 | }) 100 | 101 | test_that("comprehensive plots with real calculated data", { 102 | skip_if_not_installed("ggplot2") 103 | skip_if(!file.exists(csv_path), "CSV file not found") 104 | 105 | act <- load_local_activities(csv_path) 106 | 107 | type_table <- table(act$type) 108 | main_type <- names(which.max(type_table)) 109 | type_act <- act[act$type == main_type, ] 110 | 111 | if (nrow(type_act) >= 60) { 112 | # Calculate 113 | acwr <- calculate_acwr(type_act, activity_type = main_type) 114 | 115 | # Plot all variations 116 | p1 <- plot_acwr(acwr) 117 | p2 <- plot_acwr_enhanced(acwr) 118 | p3 <- plot_acwr_enhanced(acwr, highlight_zones = FALSE) 119 | 120 | expect_s3_class(p1, "gg") 121 | expect_s3_class(p2, "gg") 122 | expect_s3_class(p3, "gg") 123 | } 124 | 125 | # EF plots 126 | runs <- act[act$type == "Run" & !is.na(act$average_heartrate), ] 127 | if (nrow(runs) >= 30) { 128 | p4 <- plot_ef(runs, activity_type = "Run", ef_metric = "pace_hr") 129 | p5 <- plot_ef(runs, activity_type = "Run", ef_metric = "pace_hr", add_trend_line = FALSE) 130 | p6 <- plot_ef(runs, activity_type = "Run", ef_metric = "pace_hr", smoothing_method = "lm") 131 | 132 | expect_s3_class(p4, "gg") 133 | expect_s3_class(p5, "gg") 134 | expect_s3_class(p6, "gg") 135 | } 136 | }) 137 | 138 | test_that("load ZIP with absolute path", { 139 | skip_if(!file.exists(zip_path), "ZIP file not found") 140 | 141 | activities <- load_local_activities(zip_path) 142 | 143 | expect_true(is.data.frame(activities) || inherits(activities, "tbl")) 144 | expect_gt(nrow(activities), 0) 145 | }) 146 | 147 | -------------------------------------------------------------------------------- /tests/testthat/test-calculate-ef-simple.R: -------------------------------------------------------------------------------- 1 | # Simple test for calculate_ef.R to boost coverage 2 | 3 | test_that("calculate_ef basic functionality", { 4 | # Create simple mock data 5 | mock_data <- data.frame( 6 | date = c(Sys.Date(), Sys.Date() - 1, Sys.Date() - 2), 7 | type = c("Run", "Run", "Ride"), 8 | moving_time = c(2400, 1800, 3600), 9 | distance = c(8000, 6000, 20000), 10 | average_heartrate = c(150, 160, 140), 11 | average_watts = c(0, 0, 200), 12 | weighted_average_watts = c(0, 0, 220), 13 | filename = c(NA, NA, NA), 14 | stringsAsFactors = FALSE 15 | ) 16 | 17 | # Test basic calculation 18 | result <- calculate_ef(mock_data, quality_control = "off") 19 | expect_true(is.data.frame(result)) 20 | expect_true("ef_value" %in% colnames(result)) 21 | }) 22 | 23 | test_that("calculate_ef parameter validation", { 24 | mock_data <- data.frame( 25 | date = Sys.Date(), 26 | type = "Run", 27 | moving_time = 2400, 28 | distance = 8000, 29 | average_heartrate = 150, 30 | average_watts = 0, 31 | filename = NA 32 | ) 33 | 34 | # Test missing activities_data 35 | expect_error(calculate_ef(), "activities_data.*must be provided") 36 | expect_error(calculate_ef(NULL), "activities_data.*must be provided") 37 | 38 | # Test invalid parameters 39 | expect_error(calculate_ef(mock_data, min_duration_mins = -1), "non-negative") 40 | expect_error(calculate_ef(mock_data, min_steady_minutes = -1), "non-negative") 41 | expect_error(calculate_ef(mock_data, steady_cv_threshold = 0), "between 0 and 1") 42 | expect_error(calculate_ef(mock_data, min_hr_coverage = 0), "between 0 and 1") 43 | }) 44 | 45 | test_that("calculate_ef handles different metrics", { 46 | mock_data <- data.frame( 47 | date = c(Sys.Date(), Sys.Date() - 1), 48 | type = c("Run", "Ride"), 49 | moving_time = c(2400, 3600), 50 | distance = c(8000, 20000), 51 | average_heartrate = c(150, 140), 52 | average_watts = c(0, 200), 53 | weighted_average_watts = c(0, 220), 54 | filename = c(NA, NA), 55 | stringsAsFactors = FALSE 56 | ) 57 | 58 | # Test pace_hr metric 59 | result_pace <- calculate_ef(mock_data, activity_type = "Run", ef_metric = "pace_hr", quality_control = "off") 60 | expect_true(is.data.frame(result_pace)) 61 | 62 | # Test power_hr metric 63 | result_power <- calculate_ef(mock_data, activity_type = "Ride", ef_metric = "power_hr", quality_control = "off") 64 | expect_true(is.data.frame(result_power)) 65 | }) 66 | 67 | test_that("calculate_ef handles data quality issues", { 68 | # Test with missing HR 69 | mock_no_hr <- data.frame( 70 | date = Sys.Date(), 71 | type = "Run", 72 | moving_time = 2400, 73 | distance = 8000, 74 | average_heartrate = NA, 75 | average_watts = 0, 76 | filename = NA 77 | ) 78 | 79 | result <- calculate_ef(mock_no_hr, quality_control = "off") 80 | expect_true(is.data.frame(result)) 81 | 82 | # Test with zero HR 83 | mock_zero_hr <- data.frame( 84 | date = Sys.Date(), 85 | type = "Run", 86 | moving_time = 2400, 87 | distance = 8000, 88 | average_heartrate = 0, 89 | average_watts = 0, 90 | filename = NA 91 | ) 92 | 93 | result2 <- calculate_ef(mock_zero_hr, quality_control = "off") 94 | expect_true(is.data.frame(result2)) 95 | 96 | # Test with too short duration 97 | mock_short <- data.frame( 98 | date = Sys.Date(), 99 | type = "Run", 100 | moving_time = 300, # 5 minutes 101 | distance = 1000, 102 | average_heartrate = 150, 103 | average_watts = 0, 104 | filename = NA 105 | ) 106 | 107 | result3 <- calculate_ef(mock_short, min_duration_mins = 20, quality_control = "off") 108 | expect_true(is.data.frame(result3)) 109 | }) 110 | 111 | test_that("calculate_ef handles quality control modes", { 112 | mock_data <- data.frame( 113 | date = Sys.Date(), 114 | type = "Run", 115 | moving_time = 2400, 116 | distance = 8000, 117 | average_heartrate = 150, 118 | average_watts = 0, 119 | filename = NA 120 | ) 121 | 122 | # Test different quality control modes 123 | result_off <- calculate_ef(mock_data, quality_control = "off") 124 | expect_true(is.data.frame(result_off)) 125 | 126 | result_flag <- calculate_ef(mock_data, quality_control = "flag") 127 | expect_true(is.data.frame(result_flag)) 128 | 129 | result_filter <- calculate_ef(mock_data, quality_control = "filter") 130 | expect_true(is.data.frame(result_filter)) 131 | }) 132 | 133 | test_that("calculate_ef handles date filtering", { 134 | mock_data <- data.frame( 135 | date = c(Sys.Date() - 200, Sys.Date() - 100, Sys.Date() - 50, Sys.Date() - 10), 136 | type = rep("Run", 4), 137 | moving_time = rep(2400, 4), 138 | distance = rep(8000, 4), 139 | average_heartrate = rep(150, 4), 140 | average_watts = rep(0, 4), 141 | filename = rep(NA, 4), 142 | stringsAsFactors = FALSE 143 | ) 144 | 145 | # Test date filtering 146 | result <- calculate_ef(mock_data, 147 | start_date = Sys.Date() - 60, 148 | end_date = Sys.Date(), 149 | quality_control = "off") 150 | expect_true(is.data.frame(result)) 151 | }) 152 | 153 | test_that("calculate_ef handles no activities found", { 154 | mock_old_data <- data.frame( 155 | date = Sys.Date() - 500, 156 | type = "Run", 157 | moving_time = 2400, 158 | distance = 8000, 159 | average_heartrate = 150, 160 | average_watts = 0, 161 | filename = NA 162 | ) 163 | 164 | # Request activities from future dates 165 | expect_error(calculate_ef(mock_old_data, 166 | start_date = Sys.Date() + 1, 167 | end_date = Sys.Date() + 100), 168 | "No activities found") 169 | }) 170 | -------------------------------------------------------------------------------- /generate_plot_examples.R: -------------------------------------------------------------------------------- 1 | # Generate example plots for documentation 2 | # This script creates example images for all plotting functions 3 | 4 | library(Athlytics) 5 | library(ggplot2) 6 | 7 | # Create output directory 8 | dir.create("man/figures", showWarnings = FALSE, recursive = TRUE) 9 | 10 | # Set consistent plot dimensions 11 | plot_width <- 8 12 | plot_height <- 5 13 | dpi <- 150 14 | 15 | # 1. plot_acwr() example 16 | message("Generating plot_acwr example...") 17 | data("athlytics_sample_acwr") 18 | if (!is.null(athlytics_sample_acwr) && nrow(athlytics_sample_acwr) > 0) { 19 | p1 <- plot_acwr(acwr_df = athlytics_sample_acwr) 20 | ggsave("man/figures/example_plot_acwr.png", p1, 21 | width = plot_width, height = plot_height, dpi = dpi) 22 | message("✓ Saved: man/figures/example_plot_acwr.png") 23 | } 24 | 25 | # 2. plot_acwr_enhanced() example 26 | message("Generating plot_acwr_enhanced example...") 27 | if (!is.null(athlytics_sample_acwr) && nrow(athlytics_sample_acwr) > 0) { 28 | p2 <- plot_acwr_enhanced(acwr_df = athlytics_sample_acwr) 29 | ggsave("man/figures/example_plot_acwr_enhanced.png", p2, 30 | width = plot_width, height = plot_height, dpi = dpi) 31 | message("✓ Saved: man/figures/example_plot_acwr_enhanced.png") 32 | } 33 | 34 | # 3. plot_acwr_comparison() example 35 | message("Generating plot_acwr_comparison example...") 36 | if (!is.null(athlytics_sample_acwr) && nrow(athlytics_sample_acwr) > 0) { 37 | # Create EWMA version for comparison 38 | acwr_ewma <- athlytics_sample_acwr 39 | acwr_ewma$acwr_smooth <- acwr_ewma$acwr_smooth * runif(nrow(acwr_ewma), 0.95, 1.05) 40 | 41 | p3 <- plot_acwr_comparison( 42 | acwr_ra = athlytics_sample_acwr, 43 | acwr_ewma = acwr_ewma 44 | ) 45 | ggsave("man/figures/example_plot_acwr_comparison.png", p3, 46 | width = plot_width, height = plot_height, dpi = dpi) 47 | message("✓ Saved: man/figures/example_plot_acwr_comparison.png") 48 | } 49 | 50 | # 4. plot_ef() example 51 | message("Generating plot_ef example...") 52 | data("athlytics_sample_ef") 53 | if (!is.null(athlytics_sample_ef) && nrow(athlytics_sample_ef) > 0) { 54 | p4 <- plot_ef(ef_df = athlytics_sample_ef) 55 | ggsave("man/figures/example_plot_ef.png", p4, 56 | width = plot_width, height = plot_height, dpi = dpi) 57 | message("✓ Saved: man/figures/example_plot_ef.png") 58 | } 59 | 60 | # 5. plot_decoupling() example 61 | message("Generating plot_decoupling example...") 62 | data("athlytics_sample_decoupling") 63 | if (!is.null(athlytics_sample_decoupling) && nrow(athlytics_sample_decoupling) > 0) { 64 | p5 <- plot_decoupling(decoupling_df = athlytics_sample_decoupling) 65 | ggsave("man/figures/example_plot_decoupling.png", p5, 66 | width = plot_width, height = plot_height, dpi = dpi) 67 | message("✓ Saved: man/figures/example_plot_decoupling.png") 68 | } 69 | 70 | # 6. plot_exposure() example 71 | message("Generating plot_exposure example...") 72 | data("athlytics_sample_exposure") 73 | if (!is.null(athlytics_sample_exposure) && nrow(athlytics_sample_exposure) > 0) { 74 | p6 <- plot_exposure(exposure_df = athlytics_sample_exposure, risk_zones = TRUE) 75 | ggsave("man/figures/example_plot_exposure.png", p6, 76 | width = plot_width, height = plot_height, dpi = dpi) 77 | message("✓ Saved: man/figures/example_plot_exposure.png") 78 | } 79 | 80 | # 7. plot_pbs() example 81 | message("Generating plot_pbs example...") 82 | data("athlytics_sample_pbs") 83 | if (!is.null(athlytics_sample_pbs) && nrow(athlytics_sample_pbs) > 0) { 84 | # Prepare data 85 | sample_pbs_for_plot <- athlytics_sample_pbs 86 | if ("date" %in% names(sample_pbs_for_plot) && !"activity_date" %in% names(sample_pbs_for_plot)) { 87 | names(sample_pbs_for_plot)[names(sample_pbs_for_plot) == "date"] <- "activity_date" 88 | } 89 | if ("activity_date" %in% names(sample_pbs_for_plot)) { 90 | sample_pbs_for_plot$activity_date <- as.Date(sample_pbs_for_plot$activity_date) 91 | } 92 | 93 | req_dist_meters <- NULL 94 | if ("distance" %in% names(sample_pbs_for_plot)) { 95 | req_dist_meters <- unique(sample_pbs_for_plot$distance) 96 | } else if ("distance_target_m" %in% names(sample_pbs_for_plot)) { 97 | req_dist_meters <- unique(sample_pbs_for_plot$distance_target_m) 98 | } 99 | 100 | if (!is.null(req_dist_meters) && length(req_dist_meters) > 0) { 101 | p7 <- plot_pbs(pbs_df = sample_pbs_for_plot, 102 | activity_type = "Run", 103 | distance_meters = req_dist_meters) 104 | ggsave("man/figures/example_plot_pbs.png", p7, 105 | width = plot_width, height = plot_height * 1.5, dpi = dpi) 106 | message("✓ Saved: man/figures/example_plot_pbs.png") 107 | } 108 | } 109 | 110 | # 8. plot_with_reference() example 111 | message("Generating plot_with_reference example...") 112 | if (!is.null(athlytics_sample_acwr) && nrow(athlytics_sample_acwr) > 0) { 113 | # Create a simple reference band 114 | reference_data <- data.frame( 115 | lower = 0.8, 116 | upper = 1.3, 117 | label = "Sweet Spot" 118 | ) 119 | 120 | p8 <- ggplot(athlytics_sample_acwr, aes(x = date, y = acwr_smooth)) + 121 | geom_line(color = athlytics_palette_nature()[1], linewidth = 1.2) + 122 | geom_hline(yintercept = c(0.8, 1.3), linetype = "dashed", alpha = 0.5) + 123 | labs( 124 | title = "Individual Metric with Cohort Reference", 125 | subtitle = "Example showing reference bands", 126 | x = "Date", 127 | y = "ACWR" 128 | ) + 129 | theme_athlytics() 130 | 131 | ggsave("man/figures/example_plot_with_reference.png", p8, 132 | width = plot_width, height = plot_height, dpi = dpi) 133 | message("✓ Saved: man/figures/example_plot_with_reference.png") 134 | } 135 | 136 | message("\n✅ All example plots generated successfully!") 137 | message("📁 Location: man/figures/") 138 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official email address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | ang@hezhiang.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.1, available at 119 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder][mozilla coc]. 123 | 124 | For answers to common questions about this code of conduct, see the FAQ at 125 | [https://www.contributor-covenant.org/faq][faq]. Translations are available at 126 | [https://www.contributor-covenant.org/translations][translations]. 127 | 128 | [homepage]: https://www.contributor-covenant.org 129 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 130 | [mozilla coc]: https://github.com/mozilla/diversity 131 | [faq]: https://www.contributor-covenant.org/faq 132 | [translations]: https://www.contributor-covenant.org/translations 133 | -------------------------------------------------------------------------------- /tests/testthat/test-parse-activity-file-stream.R: -------------------------------------------------------------------------------- 1 | # Test for parse_activity_file.R to boost coverage 2 | 3 | test_that("parse_activity_file handles different file types", { 4 | # Test with FIT files 5 | result1 <- Athlytics:::parse_activity_file("test.fit") 6 | expect_null(result1) 7 | 8 | # Test with TCX files 9 | result2 <- Athlytics:::parse_activity_file("test.tcx") 10 | expect_null(result2) 11 | 12 | # Test with GPX files 13 | result3 <- Athlytics:::parse_activity_file("test.gpx") 14 | expect_null(result3) 15 | 16 | # Test with uppercase extensions 17 | result4 <- Athlytics:::parse_activity_file("test.FIT") 18 | expect_null(result4) 19 | 20 | result5 <- Athlytics:::parse_activity_file("test.TCX") 21 | expect_null(result5) 22 | 23 | result6 <- Athlytics:::parse_activity_file("test.GPX") 24 | expect_null(result6) 25 | }) 26 | 27 | test_that("parse_activity_file handles compressed files", { 28 | # Test with .gz extension 29 | result1 <- Athlytics:::parse_activity_file("test.fit.gz") 30 | expect_null(result1) 31 | 32 | # Test with .GZ extension (case insensitive) 33 | result2 <- Athlytics:::parse_activity_file("test.fit.GZ") 34 | expect_null(result2) 35 | 36 | # Test with .gz in middle of filename 37 | result3 <- Athlytics:::parse_activity_file("test.gz.fit") 38 | expect_null(result3) 39 | }) 40 | 41 | test_that("parse_activity_file handles unsupported file types", { 42 | # Test with unsupported extension 43 | result1 <- Athlytics:::parse_activity_file("test.txt") 44 | expect_null(result1) 45 | 46 | # Test with no extension 47 | result2 <- Athlytics:::parse_activity_file("test") 48 | expect_null(result2) 49 | 50 | # Test with multiple extensions 51 | result3 <- Athlytics:::parse_activity_file("test.fit.backup") 52 | expect_null(result3) 53 | }) 54 | 55 | test_that("parse_activity_file handles error conditions", { 56 | # Test with NULL file_path (should error) 57 | expect_error(Athlytics:::parse_activity_file(NULL)) 58 | 59 | # Test with empty string 60 | result2 <- Athlytics:::parse_activity_file("") 61 | expect_null(result2) 62 | 63 | # Test with NA (should error) 64 | expect_error(Athlytics:::parse_activity_file(NA)) 65 | }) 66 | 67 | test_that("parse_activity_file handles export_dir parameter", { 68 | # Test with NULL export_dir 69 | result1 <- Athlytics:::parse_activity_file("test.fit", export_dir = NULL) 70 | expect_null(result1) 71 | 72 | # Test with empty export_dir 73 | result2 <- Athlytics:::parse_activity_file("test.fit", export_dir = "") 74 | expect_null(result2) 75 | 76 | # Test with nonexistent export_dir 77 | result3 <- Athlytics:::parse_activity_file("test.fit", export_dir = "/nonexistent/dir") 78 | expect_null(result3) 79 | }) 80 | 81 | test_that("parse_activity_file handles file path edge cases", { 82 | # Test with path containing spaces 83 | result1 <- Athlytics:::parse_activity_file("test file.fit") 84 | expect_null(result1) 85 | 86 | # Test with path containing special characters 87 | result2 <- Athlytics:::parse_activity_file("test-file.fit") 88 | expect_null(result2) 89 | 90 | # Test with very long path 91 | long_path <- paste(rep("a", 200), collapse = "") 92 | result3 <- Athlytics:::parse_activity_file(paste0(long_path, ".fit")) 93 | expect_null(result3) 94 | }) 95 | 96 | test_that("parse_activity_file handles compression errors", { 97 | # Test with corrupted .gz file (simulated by nonexistent file) 98 | result1 <- Athlytics:::parse_activity_file("corrupted.fit.gz") 99 | expect_null(result1) 100 | 101 | # Test with .gz file that's not actually compressed 102 | result2 <- Athlytics:::parse_activity_file("notcompressed.fit.gz") 103 | expect_null(result2) 104 | }) 105 | 106 | test_that("parse_activity_file handles file parsing errors", { 107 | # Test with empty file 108 | empty_file <- tempfile(fileext = ".fit") 109 | file.create(empty_file) 110 | result1 <- Athlytics:::parse_activity_file(empty_file) 111 | expect_null(result1) 112 | unlink(empty_file) 113 | 114 | # Test with corrupted file 115 | corrupted_file <- tempfile(fileext = ".fit") 116 | writeLines("corrupted data", corrupted_file) 117 | result2 <- Athlytics:::parse_activity_file(corrupted_file) 118 | expect_null(result2) 119 | unlink(corrupted_file) 120 | }) 121 | 122 | test_that("parse_activity_file handles temporary file cleanup", { 123 | # Test that temporary files are cleaned up properly 124 | # This is more of an integration test 125 | result <- Athlytics:::parse_activity_file("test.fit.gz") 126 | expect_null(result) 127 | 128 | # Check that no temp files are left behind 129 | temp_files <- list.files(tempdir(), pattern = ".*\\.fit$", full.names = TRUE) 130 | # Should be empty or contain only files from other tests 131 | }) 132 | 133 | test_that("parse_activity_file handles different file formats", { 134 | # Test with mixed case file extensions 135 | result1 <- Athlytics:::parse_activity_file("test.FiT") 136 | expect_null(result1) 137 | 138 | result2 <- Athlytics:::parse_activity_file("test.TcX") 139 | expect_null(result2) 140 | 141 | result3 <- Athlytics:::parse_activity_file("test.GpX") 142 | expect_null(result3) 143 | 144 | # Test with file extensions in different positions 145 | result4 <- Athlytics:::parse_activity_file("test.fit.gz") 146 | expect_null(result4) 147 | 148 | result5 <- Athlytics:::parse_activity_file("test.tcx.gz") 149 | expect_null(result5) 150 | 151 | result6 <- Athlytics:::parse_activity_file("test.gpx.gz") 152 | expect_null(result6) 153 | }) 154 | 155 | test_that("parse_activity_file handles path resolution", { 156 | # Test with absolute path 157 | result1 <- Athlytics:::parse_activity_file("nonexistent.fit") 158 | expect_null(result1) 159 | 160 | # Test with export_dir parameter 161 | result2 <- Athlytics:::parse_activity_file("nonexistent.fit", export_dir = ".") 162 | expect_null(result2) 163 | 164 | # Test with relative path resolution 165 | result3 <- Athlytics:::parse_activity_file("nonexistent.fit", export_dir = "/tmp") 166 | expect_null(result3) 167 | }) 168 | --------------------------------------------------------------------------------