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,
lower_cyclength_bound = 21,
upper_cyclength_bound = 35,
impute_next_menses = FALSE,
next_menses_luteal_days = 14,
next_menses_max_window = 20,
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 (
0/1) indicating the first day of menses onset, where1marks onset. All subsequent bleeding days should be set to0. Periovulatory spotting should also be excluded.- ovtoday
A binary column (
0/1) indicating the day of estimated ovulation. Required even if biomarkers were not collected—use a column of all0s orNAs in that case. Accepted ovulation determination methods include:Urinary LH surge tests: Code
ovtoday == 1on the day after the first positive test (LH +1). Specify the brand and threshold (e.g., 40 mIU/ml).Basal body temperature (BBT): Code
ovtoday == 1on the day after the BBT nadir (BBT +1). Specify measurement method.Daily hormone assays: See Nagpal et al. (2025) for guidance.
- lower_cyclength_bound
Numeric lower bound of cycle lengths to include in scaling. Default is 21.
- upper_cyclength_bound
Numeric upper bound of cycle lengths to include in scaling. Default is 35.
- impute_next_menses
Logical; default
FALSE. WhenTRUE, opt in to imputing a next-menses onset from a biomarker-confirmed ovulation that has no recorded closing menses, so that cycle becomes scalable instead of being dropped for a missing anchor. This is the mirror image of the built-in ovulation imputation (which imputes ovulation backward from an observed menses at menses minus 15): here a menses onset is imputed forward from a confirmed ovulation, atovulation + next_menses_luteal_days. LeavingFALSEkeeps all previous behavior byte-for-byte identical. Only the general rule is applied; any study-specific gating (for example, blocking imputation across treatment phases or documented off-study breaks, or trust-ordered de-duplication of anchors) is the caller's responsibility to apply on top.- next_menses_luteal_days
Numeric; days after a confirmed ovulation at which to place the imputed next-menses onset when
impute_next_menses = TRUE. Default14(the population-average luteal length; ovulation + 14 is the last follicular day, i.e. the "LH+15" convention).- next_menses_max_window
Not currently used – kept for argument-signature stability, has no effect on the result. See
?impute_next_menses_onsets, themax_windowentry, for why: through 0.1.6 this bounded the search for a closing menses, which was a real bug (a genuine luteal phase just past the window got a fabricated onset that overwrote actually-observed data). As of 0.1.7 a real closing menses at any distance always prevents imputation. Passing this argument explicitly (any value) triggers a warning, since it would otherwise be a silent no-op for a caller relying on the old behavior.- 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
cyclic_lut/cyclic_time/luteal_lengthto scale it. Defaults7and18, from Bull et al. (2019) norms in 21-35 day cycles. Independent oflower_cyclength_bound/upper_cyclength_bound, which bound the whole cycle, not this one phase – widen these directly if your study population has longer or shorter luteal phases than the default norms assume. See the "Internal phase-length caps" section below.- 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
cyclic_fol/cyclic_timeto scale it. Defaults8and25, from Bull et al. (2019) norms in 21-35 day cycles. Same independence fromlower_cyclength_bound/upper_cyclength_boundas the luteal pair above. See the "Internal phase-length caps" section below.
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.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).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.
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, improving sensitivity and interpretability. The function requires identification of menses onset (menses) and the estimated day of ovulation (ovtoday), which may be determined via 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 biomarkers versus imputed is important for transparency and scientific rigor. Whenever possible, researchers should use objective biomarkers such as LH tests or basal body temperature (BBT) to identify ovulation, as these methods provide greater precision. 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. https://doi.org/10.31219/osf.io/hd5xw_v1Schmalenberger 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
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 discloses the luteal cap only. Both caps, and the fixed-vs-adjustable distinction (as it stood before this version), were stated at https://eisenlohrmoullab.github.io/menstrualcycleR/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)
#> # A tibble: 744 × 22
#> id date menses ovtoday symptom daterated m2mcount mcyclength
#> <int> <date> <dbl> <dbl> <dbl> <date> <dbl> <dbl>
#> 1 1 2024-01-20 1 0 5 2024-01-20 1 24
#> 2 1 2024-01-21 0 0 5 2024-01-21 2 24
#> 3 1 2024-01-22 0 0 3 2024-01-22 3 24
#> 4 1 2024-01-23 0 0 NA 2024-01-23 4 24
#> 5 1 2024-01-24 0 0 2 2024-01-24 5 24
#> 6 1 2024-01-25 0 0 1 2024-01-25 6 24
#> 7 1 2024-01-26 0 0 1 2024-01-26 7 24
#> 8 1 2024-01-27 0 0 1 2024-01-27 8 24
#> 9 1 2024-01-28 0 0 3 2024-01-28 9 24
#> 10 1 2024-01-29 0 0 4 2024-01-29 10 24
#> # ℹ 734 more rows
#> # ℹ 14 more variables: cycle_incomplete <dbl>, cyclenum <int>,
#> # ovtoday_impute <int>, scaled_cycleday <dbl>, scaled_cycleday_ov <dbl>,
#> # scaled_cycleday_impute <dbl>, scaled_cycleday_imp_ov <dbl>,
#> # cyclic_time <dbl>, cyclic_time_impute <dbl>,
#> # cyclic_time_impute_extended_phase <int>, cyclic_time_ov <dbl>,
#> # cyclic_time_imp_ov <dbl>, cyclic_time_imp_ov_extended_phase <int>, …