Skip to contents

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, where 1 marks onset. All subsequent bleeding days should be set to 0. 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 all 0s or NAs in that case. Accepted ovulation determination methods include:

  • Urinary LH surge tests: Code ovtoday == 1 on 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 == 1 on 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. When TRUE, 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, at ovulation + next_menses_luteal_days. Leaving FALSE keeps 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. Default 14 (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, the max_window entry, 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_length to scale it. Defaults 7 and 18, from Bull et al. (2019) norms in 21-35 day cycles. Independent of lower_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_time to scale it. Defaults 8 and 25, from Bull et al. (2019) norms in 21-35 day cycles. Same independence from lower_cyclength_bound/upper_cyclength_bound as 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 as cyclic_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 by cyclic_time_impute_extended_phase (below) wherever that last fallback tier supplied the value.

  • cyclic_time_ov: The ovulation-anchored counterpart of cyclic_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 as cyclic_time.

  • cyclic_time_imp_ov: The ovulation-anchored counterpart of cyclic_time_impute – same imputed-ovulation and phase-cap fallbacks, flagged by cyclic_time_imp_ov_extended_phase.

  • ovtoday_impute: A binary column indicating imputed ovulation days (value 1) 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 where cyclic_time_impute was filled by the phase-cap fallback described below (value 1), 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 as cyclic_time_impute_extended_phase, for the ovulation-centered cyclic_time_imp_ov column.

  • 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, per id.

  • cycle_incomplete: Binary (0/1); 1 marks a still-open trailing cycle with no closing menses yet observed (unless impute_next_menses = TRUE closes 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] (NA otherwise, including for imputed-ovulation or incomplete cycles).

  • menses_impute: Present only when impute_next_menses = TRUE. A binary column marking menses onsets that were imputed forward from a confirmed ovulation (value 1) 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 their cyclic_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 menstrualcycleR package. https://doi.org/10.31219/osf.io/hd5xw_v1

  • Schmalenberger 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>, …