| Title: | Phase-Aligned Cycle Time Scaling (PACTS) and Menstrual Cycle Analysis |
| Version: | 1.1.1 |
| Description: | Implements Phase-Aligned Cycle Time Scaling (PACTS) for menstrual cycle data, placing observations from cycles of differing length onto a common continuous timeline that accounts for variability in ovulation timing. Provides cycle time variables anchored on either menses onset or ovulation, summaries of how ovulation was determined in each cycle, and plotting functions for cycle-aligned outcomes. Methods are described in Nagpal et al. (2025) <doi:10.1016/j.psyneuen.2025.107584>. |
| URL: | https://menstrualcycler.clearlabresearch.com/, https://github.com/eisenlohrmoullab/menstrualcycleR |
| BugReports: | https://github.com/eisenlohrmoullab/menstrualcycleR/issues |
| License: | GPL (≥ 3) |
| Encoding: | UTF-8 |
| Imports: | dplyr, ggplot2 (≥ 3.4.0), magrittr, purrr, rlang, tidyr, zoo, viridis, glue, lubridate, shiny |
| NeedsCompilation: | no |
| Depends: | R (≥ 3.5.0) |
| Suggests: | knitr, rmarkdown, testthat (≥ 3.0.0), marginaleffects, mgcv, shinyjs, writexl |
| Config/testthat/edition: | 3 |
| VignetteBuilder: | knitr |
| LazyData: | true |
| Config/roxygen2/version: | 8.1.0 |
| Packaged: | 2026-10-24 22:50:31 UTC; toryeisenlohr-moul |
| Author: | Anisha Nagpal |
| Maintainer: | Tory Eisenlohr-Moul <temo@uchicago.edu> |
| Repository: | CRAN |
| Date/Publication: | 2026-11-08 17:30:02 UTC |
menstrualcycleR: Phase-Aligned Cycle Time Scaling (PACTS)
Description
Tools for standardizing menstrual cycle data onto a continuous, phase-aligned timeline (PACTS), so cycle-time predictors can be modeled the same way across participants and cycles despite variability in cycle length and ovulation timing.
Where to start
pacts_scaling is the main entry point – it takes a long-format
diary (one row per person-day) with menses and ovulation markers and returns
the same data with cyclic_time, cyclic_time_impute, cyclic_time_ov, and
cyclic_time_imp_ov added (see ?pacts_scaling's @return for what each
means and which to use). For a full worked example, including GAMM modeling
of the resulting cycle-time variables, see the "Getting Started" vignette:
vignette("menstrualcycleR-overview", package = "menstrualcycleR"), or the
hosted copy at
https://menstrualcycler.clearlabresearch.com/articles/menstrualcycleR-overview.html
– installing via remotes::install_github() does not build vignettes
locally unless you pass build_vignettes = TRUE.
For a visual explainer of why PACTS realigns cycles the way it does, see https://menstrualcycler.clearlabresearch.com/pacts-explainer.html.
Other exported functions
-
cycledata_check: summarize per-symptom, per-phase data availability before scaling. -
summary_ovulation: summarize confirmed-vs-imputed ovulation rates after scaling. -
cycle_plot/cycle_plot_individual: visualize a symptom across phase-aligned cycle time, across the whole sample or for one individual. -
launch_app: an interactive Shiny app covering the same workflow (requires the shinyjs and cpass packages, installed separately – see?launch_app).
Author(s)
Maintainer: Tory Eisenlohr-Moul temo@uchicago.edu (ORCID)
Authors:
Tory Eisenlohr-Moul temo@uchicago.edu (ORCID)
Anisha Nagpal anagpa7@uic.edu (ORCID)
References
Nagpal, A., Schmalenberger, K. M., Barone, J. C., Mulligan, E., Stumper, A.,
Knol, L., Failenschmid, J., Kiesner, J., Peters, J. R., & Eisenlohr-Moul, T. A.
(2025). Studying the Menstrual Cycle as a Continuous Variable: Implementing
Phase-Aligned Cycle Time Scaling (PACTS) with the menstrualcycleR package.
Psychoneuroendocrinology, 107584. doi:10.1016/j.psyneuen.2025.107584
See Also
Useful links:
Report bugs at https://github.com/eisenlohrmoullab/menstrualcycleR/issues
Plot Symptom Data Across the Menstrual Cycle
Description
This function creates a visual representation of symptom data across the menstrual cycle. It calculates mean and deviation values for the symptom by participant, allows centering the visualization on menses or ovulation, and provides flexibility in choosing the y-axis scale for the plot.
Usage
cycle_plot(
data,
symptom,
centering = "menses",
include_impute = TRUE,
y_scale = "person-centered_roll",
rollingavg = 5,
align_val = "center",
se = FALSE
)
Arguments
data |
A dataframe containing the input data. Must include columns for scaled cycle day variables and the symptom variable.
Scaled cycle day variables are added to your dataframe after applying the |
symptom |
A string specifying the symptom variable to analyze. |
centering |
A string indicating the centering phase of the cycle ("menses" or "ovulation"). Default is "menses". |
include_impute |
A boolean indicating whether to use imputed cycle time values based on NC cycle length norms. Default is TRUE. |
y_scale |
A string specifying the y-axis scale. Options are "person-centered", "person-centered_roll", or "means". Default is "person-centered_roll". |
rollingavg |
A numeric indicating how many days of a rolling average to use, the default is 5 |
align_val |
From the zoo package, using rollapply: alignment of rolling avg. Can be "center", "left", "right" |
se |
A boolean indicating whether to include a standard error ribbon. Default is False |
Value
A list containing the following elements: 1. A dataframe: The input dataframe augmented with person-centered mean (.m) and deviation (.d) values for the symptom variable. 2. A summary dataframe: Contains the summarized y-axis variable values for each 5% increment of the cycle percentage. 3. A ggplot object: A plot visualizing the summarized data across the menstrual cycle.
Examples
# Example usage:
# Load the example dataset
cycle_df = cycledata
data_with_scaling <- pacts_scaling(
cycle_df,
id = id,
date = daterated,
menses = menses,
ovtoday = ovtoday,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35
)
cycle_plot_data <- cycle_plot(
data_with_scaling,
"symptom",
centering = "menses",
include_impute = TRUE,
y_scale = "person-centered"
)
cycle_plot_data$plot
Generate Cycle-Specific Plots and Summary Data for a Given ID
Description
This function creates cycle-specific plots and summary statistics for a specified individual (id),
storing both in a named list for easy access.
Usage
cycle_plot_individual(
data,
id,
symptoms,
centering = "menses",
y_scale = "person-centered",
include_impute = TRUE,
rollingavg = 5
)
Arguments
data |
A dataframe containing menstrual cycle data, including |
id |
Numeric id number for the specific individual for whom cycle plots should be generated. |
symptoms |
A vector of strings specifying the symptom variable to analyze that exist in |
centering |
A string indicating the centering phase of the cycle ("menses" or "ovulation"). |
y_scale |
A string specifying the y-axis scale ("person-centered", "person-centered_roll", "raw", or "roll"). |
include_impute |
A boolean indicating whether to use imputed cycle time values. |
rollingavg |
A numeric indicating how many days of a rolling average to use, the default is 5 |
Value
A named list, one element per entry in symptoms. Each of those is itself a named
list, one element per cycle (named "Cycle_1", "Cycle_2", ...), each containing:
-
$plot: The cycle-specific ggplot object with the ID displayed -
$summary: The corresponding summary data
So for a single symptom "symptom" and its first cycle, access the plot as
result[["symptom"]][["Cycle_1"]]$plot.
Examples
cycle_df = cycledata
data_with_scaling <- pacts_scaling(
cycle_df,
id = id,
date = daterated,
menses = menses,
ovtoday = ovtoday,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35
)
result <- cycle_plot_individual(
data_with_scaling,
id = unique(data_with_scaling$id)[1],
symptoms = "symptom"
)
# the first cycle's plot and summary for "symptom":
result[["symptom"]][["Cycle_1"]]$plot
result[["symptom"]][["Cycle_1"]]$summary
Example longitudinal daily dataset with cycle measures
Description
This dataset contains daily symptom and cycle information used to test and demonstrate the package.
Usage
cycledata
Format
A data frame with 619 rows and 5 variables:
- id
Participant ID
- daterated
Date of symptom observation
- symptom
Daily rating of symptom outcome of interest
- menses
1indicating first day of menstruation, menses onset- ovtoday
1indicating estimated day of ovulation in study, based on biomarkers of ovulation.
Source
Simulated or anonymized data
Analyze Non-Missing Symptom Data by Cycle Phase
Description
This function provides detailed information about the availability of non-missing symptom data, stratified by cycle phases (luteal and follicular) and overall availability. It also generates visualizations to aid in data quality assessment.
Usage
cycledata_check(data, symptom_columns)
Arguments
data |
A data frame containing the input data.
|
symptom_columns |
A character vector of column names representing the dependent variables of interest.
For example, |
Value
A list containing three components:
-
by_id: A data frame summarizing non-missing values for each ID and symptom, including:Total number of non-missing values.
Number of non-missing values during the luteal phase.
Number of non-missing values during the follicular phase.
-
overall: A data frame summarizing non-missing values for each symptom, including:Total number of non-missing values.
Number of non-missing values during the luteal phase.
Number of non-missing values during the follicular phase.
-
data_symptom_plots: Visualizations showing the availability of non-missing data for each symptom, stratified by ID.
Examples
# Load example dataset
cycle_df = cycledata
data_with_scaling <- pacts_scaling(
cycle_df,
id = id,
date = daterated,
menses = menses,
ovtoday = ovtoday,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35
)
# Analyze symptom data availability
data_available_info <- cycledata_check(
data_with_scaling,
symptom_columns = c("symptom")
)
# View results
print(data_available_info$by_id)
print(data_available_info$overall)
print(data_available_info$data_symptom_plots$symptom)
Practice dataset of special cases: one person per setting
Description
A second, deliberately awkward practice dataset. Where cycledata is an
ordinary daily diary, every person here exists to exercise exactly one
situation that pacts_scaling() has a setting for, or one situation no
setting can rescue. The case column names which. Use it to see what a
setting actually changes before switching it on for your own data, and to
recognise a person whose columns come back empty for a reason.
Usage
cycledata_special
Format
A data frame with 998 rows and 6 variables:
- id
Participant ID, 1 to 15. One person per case.
- daterated
Date of observation
- symptom
Daily rating, 0 to 10, with a perimenstrual peak.
NAon roughly 8 percent of days, at random, so that coverage figures have to be counted over days that were actually rated.- menses
1on the first day of menses,0otherwise- ovtoday
1on a biomarker-confirmed day of ovulation,0otherwise- case
Which special case this person is, in words. Reads
NAon rows the package itself adds – calendar-filled gaps and imputed anchors – which is deliberate: it is the cheapest way to see which rows in your output nobody rated. Carry it across those rows withdplyr::group_by(id) |> tidyr::fill(case, .direction = "downup").
Details
cycledata cannot do this job: all 25 of its people have every cycle in
range, none was observed before their first recorded menses onset, none has
a right-censored luteal phase, and none has a calendar gap. A dataset in
which nothing unusual happens cannot show a setting working, and cannot
catch a claim about one that is wrong.
The fifteen cases
Figures are from a run at the default settings unless a setting is named. "Days covered" counts rows with a non-missing value, out of all rows for that person after the package fills the calendar.
- 1. Ordinary
Three complete cycles, ovulation confirmed, every phase in range. The baseline: without it you cannot tell a setting that recovered nothing from a dataset that is broken.
- 2. Eight rated days before the first recorded onset
With
impute_leading_ovulation = TRUE,cyclic_time_imputegains 15 days – but 7 of those rows are ones the package fabricated, because the imputed ovulation lands a week before this diary begins. Gains more rows than data, which is the caution that matters for this setting.- 3. Thirty rated days before the first onset
The same setting gains 15 of those 30 days and fabricates nothing. The 15 is a placement, not a cap, which this person is here to show:
leading_ovulation_luteal_days = 25scales 25 days,= 40scales 40 and fabricates 10 rows before the diary begins, and the phase-length caps never gate any of it (a 25-day imputed luteal phase scales withluteal_phase_max_daysleft at its default of 18). So this setting has no upper bound of any kind – not the cycle-length bounds, which need a cycle length there is none of, and not the phase caps. What limits it is the number you pass.- 4. A confirmed ovulation among the leading days
Nothing is imputed (the setting declines when a confirmed ovulation is already there), and those days scale anyway, in the confirmed-only columns, with no setting switched on. The six days before that ovulation have no onset on their left, so no follicular fraction exists for them and they stay empty.
- 5. A confirmed ovulation at the end, no closing onset
With
impute_next_menses = TRUE, the cycle closes 14 days after that ovulation: 14 days are gained incyclic_timeas well ascyclic_time_impute, and 5 rows are fabricated to reach the imputed onset.- 6. An 18-day cycle, ovulation not confirmed
Shorter than
lower_cyclength_bound, so no ovulation is imputed and the cycle has no cycle time at all – while the 28-day cycles either side of it do.- 7. A 42-day cycle, ovulation not confirmed
Longer than
upper_cyclength_bound, same result. Widening the two bounds toc(18, 43)imputes an ovulation for this cycle and person 6's, and recovers 58 days between them.- 8. Confirmed ovulation 22 days before the next onset
The luteal phase runs past
luteal_phase_max_days, inside a 35-day cycle that is still within the cycle-length bounds.cyclic_timecovers 14 days;cyclic_time_imputecovers 36, recovering the luteal phase through the phase-cap fallback and flagging 21 of those rows incyclic_time_impute_extended_phase.luteal_phase_max_days = 25returns all 22 days tocyclic_timeitself.- 9. Confirmed ovulation 26 days after the onset
The same, on the follicular side of
follicular_phase_max_days: 10 days incyclic_time, 36 incyclic_time_impute, 26 flagged.follicular_phase_max_days = 30returns all 26.- 10. Confirmed ovulation 5 days before the next onset
Under
luteal_phase_min_days, and this is the asymmetry: the fallback ignores the phase ceilings but still applies the floors, so unlike people 8 and 9 these days are empty in every column. Only loweringluteal_phase_min_daysrecovers them.- 11. An eleven-day hole in the diary
Eleven days with no rows at all, swallowing neither an onset nor an ovulation. They come back in the output with no rating on them, which is why a coverage figure counted over output rows overstates what you have.
- 12. Ovulation confirmed on no day
Every cycle in range, so every cycle takes the imputed path:
cyclic_timeis empty for this person andcyclic_time_imputecovers 86 days. The confirmed-versus-imputed contrast, in one person.- 13. No onset and no confirmed ovulation
No anchor of any kind. Nothing scales, under any setting. The case that reads as a bug.
- 14. A confirmed ovulation but no onset ever recorded
The menses-to-menses counter never starts, so there is no cycle length – and that one day still scales, pinned at
cyclic_time = 1, because the ovulation anchor is set independently of any cycle. Withimpute_next_menses = TRUEa closing onset is imputed and the whole luteal phase scales, 15 days, in the confirmed columns too.- 15. Both opt-in rules at once, at opposite ends
Ten rated days before the first onset and a confirmed ovulation at the end with no closing onset. The only person who exercises both rules together, and therefore the only one where the order they are applied in could matter. It does not: 15 days at the front, 14 at the back, 29 together, with rows fabricated at both ends.
Overlaps, and where the order of operations matters
pacts_scaling() applies impute_next_menses first and
impute_leading_ovulation second, so the leading rule can see a
table that already contains an imputed onset. Four things follow, all
measured on this dataset rather than reasoned from the code:
-
The two rules do not interact. They act on opposite ends of a diary, and on person 15, who has both, the days gained together are exactly the sum of the days each gains alone. The order is therefore not something a caller has to think about.
-
An imputed onset never becomes a leading-day anchor, and this holds structurally rather than by luck.
impute_next_mensesonly ever imputes an onset forward from a confirmed ovulation, so whenever that imputed onset is a person's first – person 14, who has no observed onset at all – there is by construction a confirmed ovulation before it, which is precisely the condition on whichimpute_leading_ovulationdeclines. No combination of the two settings imputes an ovulation before an imputed onset. -
leading_ovulation_luteal_daysis ungated in both directions, which person 3 demonstrates: nothing bounds how many leading days it will scale except the number you pass. Treat a value above the default as a methods decision to record, not a recovery setting to turn up. -
Persons 8 and 9 depend on a bound they do not demonstrate. The phase-cap fallback that recovers them is itself gated on the cycle falling inside
[lower_cyclength_bound, upper_cyclength_bound], and both their cycles are 35 days – the default upper bound exactly. Atupper_cyclength_bound = 34their recovered days drop from 72 to 24 and the extended-phase flag from 47 to 0, so they stop demonstrating the phase caps at all. The CLEAR lab's own[20, 43]window keeps them working; a narrower one silently changes what they show.
Three overlaps are deliberate and not conflicts. Persons 5 and 14 both
exercise impute_next_menses, but on 5 the imputed onset closes a cycle that
already had an opening onset, while on 14 it creates the only onset that
person has. Person 12 is what the whole dataset looks like under
ovtoday = NULL, which is worth having in one person as well as one call.
And person 10 exists to contradict persons 8 and 9: the phase-length floors
and ceilings are not symmetric, and only the ceilings have a fallback.
Note
This dataset, its documentation and its automatic checks were drafted with Claude Code and reviewed by the package authors, who are responsible for their content. See the README for the package's full statement on AI tool use.
Source
Generated, not real and not derived from any real record: the
anchors are written out by hand and the symptom values come from a fixed
random seed. The script that builds it, with the arithmetic for every case
asserted as it runs, is data-raw/cycledata_special.R in the package
sources; tests/testthat/test_cycledata_special.R checks that each case
still behaves as its label says, so a change to a default that stops a
person demonstrating their setting fails a test instead of passing
silently.
See Also
cycledata for the ordinary practice dataset, pacts_scaling()
for the settings each person exercises.
Examples
# What each person is, and how much of them scales at the defaults
scaled <- pacts_scaling(
cycledata_special,
id = id, date = daterated, menses = menses, ovtoday = ovtoday
)
# Two settings, and the days each one gains
leading <- pacts_scaling(
cycledata_special,
id = id, date = daterated, menses = menses, ovtoday = ovtoday,
impute_leading_ovulation = TRUE
)
# People 2, 3 and 15 gain 15 days each; nobody else changes
gained <- function(x) tapply(!is.na(x$cyclic_time_impute), x$id, sum)
gained(leading) - gained(scaled)
# And the rows the package added, which carry no rating
sum(is.na(leading$symptom)) - sum(is.na(scaled$symptom))
Impute a leading ovulation anchor before a participant's first recorded menses onset
Description
Days observed BEFORE a participant's first recorded menses onset (the left-censored
start of participation) have no cycle anchor on their left, so PACTS cannot place them:
the luteal phase they belong to has a closing menses (the first onset) but no ovulation.
This opt-in rule imputes that missing ovulation with the same population-average
backward count the package already uses inside observed cycles: ovulation is placed
luteal_days days (default 15) before the first menses onset, so the leading days scale
as the end of a luteal phase in cyclic_time_impute / cyclic_time_imp_ov (never in
the confirmed-only columns). The anchor is marked in a new column
ovtoday_leading_impute (1 on the imputed day, 0 elsewhere). If the imputed day lies
before the first observed row, a blank row is added on that date (other columns NA),
exactly as impute_next_menses_onsets() adds a blank row for an imputed onset.
Usage
impute_leading_ovulation_anchors(
data,
id,
date,
menses,
ovtoday,
luteal_days = 15
)
Arguments
data, id, date, menses, ovtoday |
As in |
luteal_days |
Integer; days before the first menses onset at which the ovulation is placed. Default 15 (the package's backward-count convention). |
Details
Nothing is imputed when the participant has no menses onset, when no observed row
precedes the first onset, or when a confirmed ovulation (ovtoday == 1) already lies
before the first onset. Only the general rule is applied; study-specific gating is the
caller's responsibility.
Value
data with an added integer column ovtoday_leading_impute, and possibly one
added row per participant on the imputed date.
Impute a next-menses onset from a confirmed ovulation (Internal)
Description
Implements the GENERAL next-menses imputation rule: for each
biomarker-confirmed ovulation (ovtoday == 1) that has NO observed menses
onset recorded anywhere after it for that person, synthesize a menses onset
at ovulation + luteal_days (the population-average luteal length, so
ovulation + 14 = the "LH+15"/last-follicular-day rule). This lets a confirmed
ovulation whose next menses was never recorded at all still contribute a
scalable cycle, rather than being dropped for lack of a closing anchor.
Usage
impute_next_menses_onsets(
data,
id,
date,
menses,
ovtoday,
luteal_days = 14,
max_window = 20
)
Arguments
data |
A data frame in long format (one row per id-date). |
id, date, menses, ovtoday |
Unquoted column names, as in |
luteal_days |
Days after ovulation to place the imputed onset. Default 14. |
max_window |
Not currently used to gate the impute decision – kept for
argument-signature stability; a non-default value has no effect on the
result. Through 0.1.6, imputation fired whenever no OBSERVED menses onset
fell within |
Details
A real, observed closing menses – at ANY distance from the ovulation, not
just a nearby one – always wins and is never overridden or duplicated by an
imputed onset (see the max_window entry below for why this search is
deliberately unbounded as of 0.1.7).
Study-specific gating (e.g. blocking imputation across treatment phases or documented off-study breaks, trust-ordered de-duplication of anchors) is deliberately NOT done here; it is the caller's responsibility to apply on top. This function does the general rule only.
Operates on the user's own id / date / menses / ovtoday columns and is
run before cycle-length calculation, so downstream scaling sees the augmented
anchors. Imputed onsets are marked in a new menses_impute column (1 =
imputed onset, 0 = everything else).
Value
data with imputed menses onsets added (existing rows updated, or new
rows appended for onset dates outside the observed range) and a
menses_impute indicator column.
Launch the Menstrual Cycle Shiny App
Description
This function launches an interactive Shiny application designed to help users upload and process their menstrual cycle data. The app provides tools to apply Phase-Aligned Cycle Time Scaling (PACTS), generate scaled cycleday variables, and visualize results in a browser interface.
Usage
launch_app()
Details
Users can upload a .csv file, process their data using built-in PACTS functionality, and explore cycle-aligned visualizations
to support analysis and interpretation.
Requires three packages that are not installed automatically with menstrualcycleR,
because they are needed only for this app and not for pacts_scaling() or any
other exported function. shinyjs and writexl are suggested dependencies and
install from CRAN; writexl backs the app's download buttons. cpass is not on CRAN and installs
from GitHub with remotes::install_github("lasy/cpass"); it powers the app's
optional CPASS tab only. launch_app() checks for both and reports which are
missing rather than failing part-way through.
Value
Called for its side effect of launching the Shiny application. Returns the
result of shiny::runApp() invisibly; the call blocks until the app is closed.
Throws an error, without launching, if any required package is missing or if the app
directory cannot be found.
Phase-Aligned Cycle Time Scaling (PACTS)
Description
This function uses the Phase-Aligned Cycle Time Scaling (PACTS) method, as described in Nagpal et al. (2025), to compute continuous, biologically meaningful cycle time variables in a long-format dataset, where each row represents a unique observation date for a specific naturally-cycling individual.
Usage
pacts_scaling(
data,
id,
date,
menses,
ovtoday = NULL,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35,
impute_next_menses = FALSE,
next_menses_luteal_days = 14,
next_menses_max_window = 20,
impute_leading_ovulation = FALSE,
leading_ovulation_luteal_days = 15,
luteal_phase_min_days = 7,
luteal_phase_max_days = 18,
follicular_phase_min_days = 8,
follicular_phase_max_days = 25
)
Arguments
data |
A data frame containing the required input variables. |
id |
A unique identifier for each naturally-cycling individual in the dataset. |
date |
A date column corresponding to the day of observation. Ensure all id-date combinations are unique. You may wish to reclassify post-midnight survey entries to the previous day to maintain alignment with sleep or daily tracking data. |
menses |
A binary column ( |
ovtoday |
A binary column (
|
lower_cyclength_bound |
Numeric lower bound of cycle lengths eligible for ovulation IMPUTATION. Default is 21. Despite the historical argument name, this does not bound which cycles are scaled: a cycle with a confirmed ovulation is scaled whatever its length, gated by its phase lengths instead (see "Internal phase-length caps" below). This differs from Nagpal et al. (2025) Section 2.1.1, which states that cycle time "is computed only for 21-35 day cycles"; that sentence describes the imputation gate, and the package applies the carve-out named in the section's next sentence ("If ovulation is confirmed, users may modify this range") automatically rather than on request. |
upper_cyclength_bound |
Numeric upper bound of cycle lengths eligible for ovulation imputation. Default is 35. See |
impute_next_menses |
Logical; default |
next_menses_luteal_days |
Numeric; days after a confirmed ovulation at which to place the imputed next-menses onset when |
next_menses_max_window |
Not currently used – kept for argument-signature stability, has no effect on the result. See |
impute_leading_ovulation |
Logical; default |
leading_ovulation_luteal_days |
Numeric; days before the first menses onset at which the leading ovulation is placed when |
luteal_phase_min_days, luteal_phase_max_days |
Numeric bounds (days) on how long a confirmed ovulation's luteal phase (ovulation to next menses) may be for |
follicular_phase_min_days, follicular_phase_max_days |
Numeric bounds (days) on how long a confirmed ovulation's follicular phase (menses to ovulation) may be for |
Details
The PACTS method aligns observations across cycles by centering time either on menses onset or ovulation. This allows researchers to model menstrual cycle dynamics as continuous functions of time. Continuous cycle measurement increases statistical precision and power relative to categorical phase coding (Nagpal et al., 2025, Section 1.1). The function requires identification of menses onset (menses) and the estimated day of ovulation (ovtoday), which may be determined via an ovulation biomarker (preferred) or imputed based on typical luteal phase length when unavailable.
When ovulation is not directly assessed, the function imputes ovulation as 15 days prior to the next menses onset (i.e., the last day of the follicular phase), based on the population-average luteal phase length. Imputed ovulation days are recorded in a new binary column, ovtoday_impute.
Reporting how often ovulation was confirmed using an ovulation biomarker versus imputed is important for transparency and scientific rigor. Whenever possible, researchers should use ovulation biomarkers such as LH tests or basal body temperature (BBT). Neither pinpoints the day of ovulation, which would require ultrasound; both indicate that ovulation occurred within 24 to 36 hours (Nagpal et al., 2025, Section 2.1.2). This function supports both confirmed and imputed ovulation, allowing analyses to flexibly account for variable data availability across participants and cycles.
For further guidance on ovulation identification and justification of the -15 day imputation approach, see:
Nagpal et al. (2025). Studying the Menstrual Cycle as a Continuous Variable: Implementing Phase-Aligned Cycle Time Scaling (PACTS) with the
menstrualcycleRpackage. Psychoneuroendocrinology, 107584. https://doi.org/10.1016/j.psyneuen.2025.107584Schmalenberger et al. (2021). How to study the menstrual cycle: Practical tools and recommendations. Psychoneuroendocrinology, 123, 104895. https://doi.org/10.1016/j.psyneuen.2020.104895
Value
The original data frame with the following additional columns. Use the four
cyclic_time* columns for analysis – they are the variables reported in Nagpal et al.
(2025) and the ones this package actively maintains. The four scaled_cycleday* columns
are an earlier, legacy implementation of the same idea, kept only for backward compatibility
with existing analysis code; they are numerically similar but not identical to their
cyclic_time* counterparts (different coverage, small value differences even on rows both
cover) and should not be treated as interchangeable with them.
-
cyclic_time: The primary, menses-anchored cycle time variable, centered on menses onset (menses == 1-> 0) and spanning -1 (start of luteal phase) to +1 (ovulation). Only covers cycles with biomarker-confirmed ovulation whose luteal and follicular phases both fall within[luteal_phase_min_days, luteal_phase_max_days]/[follicular_phase_min_days, follicular_phase_max_days]– see "Internal phase-length caps" below. -
cyclic_time_impute: Same ascyclic_time, but with broader coverage: falls back to imputed ovulation (ovtoday_impute) for cycles without confirmed ovulation, and then to the phase-cap fallback described below for phases that exceed the internal caps. Flagged bycyclic_time_impute_extended_phase(below) wherever that last fallback tier supplied the value. -
cyclic_time_ov: The ovulation-anchored counterpart ofcyclic_time– centered on confirmed ovulation (ovtoday == 1-> 0), spanning -1 (start of follicular phase) to +1 (end of luteal phase). Same confirmed-ovulation-only, phase-cap-gated coverage ascyclic_time. -
cyclic_time_imp_ov: The ovulation-anchored counterpart ofcyclic_time_impute– same imputed-ovulation and phase-cap fallbacks, flagged bycyclic_time_imp_ov_extended_phase. -
ovtoday_impute: A binary column indicating imputed ovulation days (value1) for cycles without confirmed ovulation, estimated as 15 days before menses onset. Report the confirmed-versus-imputed rate for transparency. -
cyclic_time_impute_extended_phase: Binary column (0/1) marking rows wherecyclic_time_imputewas filled by the phase-cap fallback described below (value1), versus the normal confirmed or imputed-ovulation paths (0). Report this rate alongside the imputed-ovulation rate for full transparency about coverage. -
cyclic_time_imp_ov_extended_phase: The same flag ascyclic_time_impute_extended_phase, for the ovulation-centeredcyclic_time_imp_ovcolumn. -
mcyclength: The length, in days, of the menses-to-menses cycle this row belongs to. Oncycle_incomplete == 1rows this is not a cycle length – it is the number of days observed so far in a still-open trailing cycle (seecycle_incompletebelow). Usemcyclength_completeinstead if you want to threshold cycle length directly. -
mcyclength_complete: Same asmcyclength, butNAwhenevercycle_incomplete == 1. Prefer this column for any length restriction – e.g.mcyclength_complete >= 21 \& mcyclength_complete <= 35– since an incomplete cycle'sNAhere can never satisfy a numeric comparison, so it is excluded automatically without a separatecycle_incomplete == 0clause. -
m2mcount: A forward day count starting from each menses onset (day of onset = 0). -
cyclenum: Which complete menses-to-menses cycle, in sequence, this row belongs to, perid. -
cycle_incomplete: Binary (0/1);1marks a still-open trailing cycle with no closing menses yet observed (unlessimpute_next_menses = TRUEcloses it – see below). On these rowsmcyclengthis days-observed-so-far, not a cycle length –mcyclength_completeisNAinstead. -
luteal_length: The confirmed-ovulation luteal phase length in days, only populated when it falls within[luteal_phase_min_days, luteal_phase_max_days](NAotherwise, including for imputed-ovulation or incomplete cycles). -
menses_impute: Present only whenimpute_next_menses = TRUE. A binary column marking menses onsets that were imputed forward from a confirmed ovulation (value1) versus observed onsets (0). Report the imputed-versus-observed onset rate for transparency, just as you would for imputed ovulation. -
scaled_cycleday,scaled_cycleday_impute,scaled_cycleday_ov,scaled_cycleday_imp_ov: Legacy variables – see note above. Same conceptual meaning as theircyclic_time*counterparts (confirmed-only / imputed-inclusive, x menses-anchored / ovulation-anchored) but computed independently; do not mix the two families within one analysis.
Internal phase-length caps, and how they differ from the cycle-length bounds
a confirmed ovulation's luteal phase (ovulation to next menses) is only scaled by cyclic_lut/cyclic_time when it falls within [luteal_phase_min_days, luteal_phase_max_days] (default 7-18 days), and its follicular phase (menses to ovulation) only within [follicular_phase_min_days, follicular_phase_max_days] (default 8-25 days) – both from Bull et al. (2019) norms in 21-35 day cycles. As of this version these four bounds are caller-adjustable, independently of lower_cyclength_bound/upper_cyclength_bound, which bound the whole cycle, not either phase individually. In every version through 0.1.6 they were hardcoded (not arguments at all), so a cycle could fall entirely inside the caller's accepted overall length (e.g. the whole [20,43]-day CLEAR lab standard) and still have one phase silently uncovered by cyclic_time, because that phase alone exceeded the fixed 18 or 25 day limit with no way to widen it. A caller whose study population has genuinely longer or shorter phases than the Bull et al. norms should now widen these four arguments directly, rather than relying on the fallback described next. cyclic_time_impute additionally falls back, for exactly the days a capped phase would otherwise leave with no coverage at all (a confirmed ovulation already suppresses the ordinary imputed-ovulation fallback – see ovtoday_impute; and a still-open trailing cycle, cycle_incomplete == 1, never gets this fallback either, matching every other imputed column), to the same phase fraction computed without the phase-length ceiling (the floor still applies), gated only on the overall cycle falling within the caller's own [lower_cyclength_bound, upper_cyclength_bound]. That fallback exists for whatever still runs past even a caller-widened phase bound, trading phase-length plausibility for coverage in the imputed column only – it never touches cyclic_time itself. Those filled rows (excluding the ovulation-anchor day itself, whose value is always pinned by a separate, pre-existing override) are flagged in cyclic_time_impute_extended_phase. cyclic_time_imp_ov, the ovulation-centered sibling of cyclic_time_impute, receives the identical fallback, flagged in cyclic_time_imp_ov_extended_phase. This fallback only ever widens coverage, in one direction: the four bound arguments narrow cyclic_time/cyclic_time_ov directly, but the fallback ignores luteal_phase_max_days/follicular_phase_max_days entirely (it checks only the floor and the overall cycle-length bound) – so narrowing either *_max_days argument below its default to exclude implausibly long phases from analysis does not exclude them from cyclic_time_impute/cyclic_time_imp_ov, which recover them anyway through this fallback. A caller who wants that stricter behavior in the imputed columns too should additionally filter out rows where cyclic_time_impute_extended_phase == 1 (or the _imp_ov equivalent) after calling this function; there is no argument that disables the fallback itself.
History. These caps existed as hardcoded literals in every released version of this package (v0.1.0 onward; git history traces the specific thresholds back further, to before cyclic_time existed at all) until they became adjustable arguments in this version. The published Nagpal et al. (2025) paper and its supplement do not mention either cap. The package's own "Getting Started" vignette now discloses both caps (Section 3, "Cycle Length Inclusion Criteria"). Both caps, and the fixed-vs-adjustable distinction (as it stood before this version), were stated at https://menstrualcycler.clearlabresearch.com/pacts-explainer.html ("Inclusion defaults") – an automated documentation pass describing the code's actual behavior at the time, not a deliberate methods decision recorded anywhere else. See NEWS.md for the full history.
Examples
# Load the example dataset
cycle_df = cycledata
data_with_scaling <- pacts_scaling(
cycle_df,
id = id,
date = daterated,
menses = menses,
ovtoday = ovtoday,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35
)
# View the result
print(data_with_scaling)
Process Data (Internal)
Description
Internal helper functions to process the data.
Usage
process_luteal_phase_base(
data,
id,
date,
menses,
luteal_phase_min_days = 7,
luteal_phase_max_days = 18
)
Arguments
data |
A data frame containing cycle data. |
id |
A column specifying individual ids. |
date |
A column specifying the dates. |
menses |
A column indicating menses (0/1). |
luteal_phase_min_days, luteal_phase_max_days |
Numeric bounds (days) on how long a
confirmed ovulation's luteal phase (ovulation to next menses) may be for |
Value
A data frame with processed data.
Summarize Ovulation Confirmation and Imputation
Description
This function provides a summary of how ovulation was identified across the dataset – either through direct confirmation using an ovulation biomarker or via imputation based on menstrual cycle timing.
Usage
summary_ovulation(data)
Arguments
data |
A dataframe containing the output of |
Details
Specifically, it counts the number of cycles in which ovulation was:
-
Confirmed using an ovulation biomarker (i.e., a
1in theovtodaycolumn), such as urinary LH surge tests or basal body temperature (BBT). -
Imputed using the backward-count method, which estimates ovulation as 15 days before the subsequent menses onset (
ovtoday_impute == 1), based on the typical length of the luteal phase.
The output includes both:
Overall summary counts across the entire dataset.
Per-individual summaries to support participant-level quality checks and reporting.
Report this summary in publications: it states how often ovulation timing was measured versus estimated. Ovulation-biomarker confirmation is preferred where available. Note that a positive LH-surge test or a basal body temperature (BBT) nadir does not pinpoint the day of ovulation, which would require ultrasound; it indicates that ovulation occurred within 24 to 36 hours (Nagpal et al., 2025, Section 2.1.2), and the width of that window depends on the detection method.
When ovulation biomarkers are unavailable, the -15 day backward count is recommended over a cycle-midpoint assumption, because the luteal phase varies less in length than the follicular phase. Against 33 hormone-confirmed cycles it was off by a mean absolute 0.97 days (SD 0.88; Nagpal et al., 2025, Section 3.2).
For further guidance on ovulation identification and the rationale for the -15 day imputation method, see:
Nagpal et al. (2025). Studying the Menstrual Cycle as a Continuous Variable: Implementing Phase-Aligned Cycle Time Scaling (PACTS) with the
menstrualcycleRpackage. Psychoneuroendocrinology, 107584. https://doi.org/10.1016/j.psyneuen.2025.107584Schmalenberger et al. (2021). How to study the menstrual cycle: Practical tools and recommendations. Psychoneuroendocrinology, 123, 104895. https://doi.org/10.1016/j.psyneuen.2020.104895
Value
A list with two data frames:
-
ovstatus_total: A dataset-level summary showing:The number of cycles with confirmed ovulation (
ovtoday == 1).The number of cycles with imputed ovulation (
ovtoday_impute == 1).
-
ovstatus_id: A participant-level summary showing, for each unique ID:The number of cycles whose length falls outside 21-35 days. Descriptive only: a confirmed-ovulation cycle outside that range is still scaled, gated by its phase lengths instead (see
lower_cyclength_boundin?pacts_scaling). Still-open trailing cycles, whose length is not yet knowable, are not counted.The number of cycles with confirmed ovulation.
The number of cycles with imputed ovulation.
Examples
cycle_df = cycledata
data_with_scaling <- pacts_scaling(
cycle_df,
id = id,
date = daterated,
menses = menses,
ovtoday = ovtoday,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35
)
ov_summary = summary_ovulation(data_with_scaling)
print(ov_summary)
ov_summary$ovstatus_total
ov_summary$ovstatus_id