CRAN resubmission. Three changes, all requested by the CRAN reviewer on 2026-10-10; no scaled cycle-time value changes and no exported function changes behaviour.
The title no longer opens with "Tools for". Title is now "Phase-Aligned
Cycle Time Scaling (PACTS) and Menstrual Cycle Analysis". CRAN treats a leading
"Tools for" as redundant, since every package is a set of tools.
T and F are written out as TRUE and FALSE. cycle_plot()'s se
argument defaulted to F, which is what the reviewer saw in the help page, and
cycle_plot_individual() passed partial = T to zoo::rollapply() in three
places. T and F are ordinary variable names in R, not reserved words, so
code that relies on them breaks for anyone who happens to have assigned them --
which is why CRAN asks for the spelled-out forms. The "Getting Started"
vignette's se = T calls and its chunk options are spelled out too, since they
are the code a reader copies.
No other change. The default of se is still FALSE and every result is
identical.
A second practice dataset ships alongside cycledata, built for one job: showing what
each of pacts_scaling()'s settings actually does. It holds 15 people and 998 rows, and
every person is one special case, named in words in a new case column.
The dataset exists because cycledata cannot do this. All 25 of its people have every
cycle inside 21-35 days, not one was observed before their first recorded menses onset,
none has a confirmed ovulation with no closing onset, and none has a calendar gap -- so
the settings added in this version and in 0.1.7 change nothing at all when run on it. A
dataset in which nothing unusual happens can neither demonstrate a setting working nor
catch a claim about one that is wrong. That blind spot is what let the ovulation-imputation
behaviour of the cycle-length bounds go undocumented for as long as it did.
The fifteen: an ordinary three-cycle record, to compare against; 8 and then 30 rated days
before a first recorded onset (the first fabricates 7 rows, the second shows how far the
setting reaches); a confirmed ovulation among the leading days, which scales with no setting
at all and makes impute_leading_ovulation correctly decline; a confirmed ovulation at the end
of the diary with no closing onset; an 18-day and a 42-day cycle with ovulation unconfirmed, on
the wrong side of lower_cyclength_bound and upper_cyclength_bound; a luteal phase past
luteal_phase_max_days and a follicular phase past follicular_phase_max_days, both
recovered by the phase-cap fallback and flagged; a luteal phase under
luteal_phase_min_days, recovered by nothing, which is the asymmetry between the phase
floors and the phase ceilings; an eleven-day hole in a diary, to show the filled rows coming
back with no rating on them; a person with ovulation confirmed on no day; a person with no
anchor of any kind, whose columns are empty for a reason; and a person with one confirmed
ovulation and no onset ever recorded, whose single day scales pinned at cyclic_time = 1
and whose whole luteal phase scales once impute_next_menses closes the cycle; and a person
with leading days AND an unclosed trailing ovulation, who is the only one both opt-in rules
act on at once.
Every one of those claims is checked by an automatic test rather than only written down
(tests/testthat/test_cycledata_special.R, 94 checks), so a change to a default that stops
a person demonstrating their setting fails a test instead of leaving a false sentence in the
help page. The dataset is generated, not real and not derived from any real record, and the
script that builds it -- asserting each case's arithmetic as it runs -- ships in the package
sources at data-raw/cycledata_special.R.
Both vignettes now point at it. "Preparing Your Data" gains a section, "When your data is
awkward", that works through four of the cases with live output: how far apart a setting's
recovered ROWS and recovered DATA are (45 days gained, 31 of them carrying a rating, 12 rows
fabricated), that leading_ovulation_luteal_days scales linearly with whatever it is passed,
which person no combination of settings can reach and why, and what the NAs in the case
column are for. The overview vignette introduces the dataset where it introduces cycledata.
Neither is a code change; both are corrections to what the documentation said.
leading_ovulation_luteal_days has no upper bound of any kind. The 15 is where the
ovulation is placed by default, not a cap on how far back the rule reaches, and the
documentation added with the setting in this version implied otherwise. Setting it to 25
scales 25 leading days; setting it to 40 scales 40 and fabricates rows before the diary
begins. The cycle-length bounds cannot gate it (a left-censored tail has no cycle length to
test, which is the reason the default declines to scale it at all) and the phase-length caps
do not: a 25-day imputed luteal phase scales with luteal_phase_max_days left at 18. A value
above the default is a methods decision to record, not a recovery dial. The help page for
the new dataset now says so, and person 3 demonstrates it.
The two opt-in rules are applied in a fixed order, and it does not matter.
impute_next_menses runs first and impute_leading_ovulation second, so the leading rule can
see an imputed onset. On a person who has both -- person 15 of the new dataset -- the days
gained together are exactly the sum of the days each gains alone. And an imputed onset can
never become a leading-day anchor, structurally: impute_next_menses only imputes an onset
forward from a confirmed ovulation, so whenever that onset is a person's first, a confirmed
ovulation necessarily precedes it, which is the condition on which the leading rule declines.
cycle_plot() and cycle_plot_individual() passed size to their line layer, which
ggplot2 deprecated in 3.4.0. Every call therefore printed a deprecation warning telling
the user to report an issue against this package. Both now pass linewidth, and
DESCRIPTION records the ggplot2 (>= 3.4.0) floor that linewidth needs. These were the
only two size arguments in the plotting code, so nothing that legitimately still takes
size (point and text sizes) is touched.
cycle_plot_individual() stopped with "No data found for ID x" in two quite different
situations: when no row had that id at all, and when the participant had plenty of rows
but none belonging to a numbered menses-to-menses cycle. The second is not missing data --
it is a participant with no recorded menses onset, or none that closes a cycle -- and the
old message sent a reader looking for a data problem that was not there. The two cases now
have separate messages, and the second names ?cycledata_special, which ships worked
examples of both.
The comment in .github/workflows/R-CMD-check.yaml claimed the vignettes' build-time
dependencies including tidyverse were declared in Suggests. tidyverse was dropped
from Suggests for CRAN and is not needed: the vignette loads it only inside an
eval = FALSE chunk that shows a reader what to install. Comment corrected; no
configuration change.
pacts_scaling()'s help page had not been regenerated after this version's new settings
were added, so the help page and the function disagreed about which settings exist.
R CMD check --as-cran reports that as a WARNING, and a warning is a rejection.
ovtoday_leading_impute was missing from the list of column names the package declares it
expects, which R flags when checking the code.
Two files the testing tool writes when a check fails
(tests/testthat/testthat-problems.rds and tests/testthat/_problems/) had been committed
and would have shipped inside the package. Removed, and excluded from future builds.
The follicular-phase pass closed a participant's run on their FIRST row whenever the row before it -- the previous participant's last row -- was an ovulation day; the check looked at row i-1 without confirming it belonged to the same participant. That participant's whole first follicular phase was then never scaled (in every column). Present in every prior release; on a 125-participant study dataset it affected 3 participants and 43 person-days. Fixed by requiring the previous row to belong to the same participant. Results for everyone else are unchanged (tested against the existing suite).
pacts_scaling() gains impute_leading_ovulation = FALSE (with leading_ovulation_luteal_days = 15).
When TRUE, days a participant was observed BEFORE their first recorded menses onset -- the
left-censored start of participation, which previously could never be scaled because the luteal
phase they belong to had a closing menses but no ovulation -- get an ovulation imputed at the
first onset minus 15 days, the same backward count the package uses inside observed cycles. The
leading days then scale as the end of a luteal phase in cyclic_time_impute / cyclic_time_imp_ov
only; the confirmed-only columns are untouched. The imputed anchor is marked in a new column
ovtoday_leading_impute, and a blank row is added when the imputed day precedes the first
observed row (as impute_next_menses does for an imputed onset). Nothing is imputed when a
confirmed ovulation already lies before the first onset. The default FALSE keeps every
existing result byte-for-byte identical (tested). Requested by the CLEAR Lab ADHD-Cycle
analysis (2026-09-27), where roughly a third of participants began the diary in a luteal phase.
Prepared for CRAN, but never submitted: the packaging work below cleared the blockers, and 1.1.0 superseded this version before a submission was made, so CRAN has never carried 1.0.0. (Recorded here because this entry read "First CRAN release" until 2026-09-29, which was never true.) No scaled cycle-time values change and no exported function changes behavior -- the major version marks readiness for CRAN, not a break with 0.1.9. Code written against 0.1.9 runs unchanged.
cpass is no longer listed in Suggests, and the Remotes: lasy/cpass field is gone.
CRAN does not accept a Remotes field, and does not accept a suggested package that is
not in a mainstream repository. launch_app() is unchanged: it still checks for both
shinyjs and cpass with requireNamespace() and reports whichever is missing, so the
app's CPASS tab behaves exactly as before for anyone who installed cpass from GitHub.
requireNamespace() does not require a package to be declared, so dropping the
declaration costs nothing at run time. Only the documentation wording changed, to say
that shinyjs comes from CRAN and cpass from GitHub.
The Description field is rewritten. It no longer opens with the package name, which
CRAN policy disallows, and it now cites the PACTS paper by DOI.
Three documentation links pointed at the retired eisenlohrmoullab.github.io domain and
now point at menstrualcycler.clearlabresearch.com directly rather than relying on the
redirect. Documentation is regenerated with roxygen2 8.1.0, which replaces the
RoxygenNote field with Config/roxygen2/version.
vignette("preparing-your-data") covers getting data into the shape pacts_scaling()
expects, and choosing among the four cycle-time variables it returns. It starts from the
case the overview vignette assumes away: a period-tracker export listing period start
dates, a separate table of daily measurements, and no ovulation biomarker anywhere. It
gives the join that turns the first into a daily menses flag, notes on reshaping Oura,
Apple Health and Fitbit exports, a pre-scaling check list, and the reporting that belongs
in a methods section.
Two points in it are not stated elsewhere in the documentation.
Switching anchor changes no row's inclusion. cyclic_time and cyclic_time_ov
scale the same rows as each other, and so do cyclic_time_impute and
cyclic_time_imp_ov. This held on cycledata and on a copy chopped to create a
left-censored opening tail and an open trailing cycle. Switching between confirmed-only
and imputation-inclusive does change coverage: 358 of 744 rows against 735 in
cycledata, and 0 against 744 in a copy with ovtoday zeroed throughout.
Each variable places one anchor at zero and the other at the wrap. On cyclic_time
menses onset is at zero and ovulation is at -1/+1; on cyclic_time_ov the reverse.
A feature at the wrap is split across the two ends of the plotted axis, so an arithmetic
mean of its position is meaningless and the summary has to be circular.
The vignette states what the published paper does not: no confirmation rate has been established below which ovulation-anchored questions become unanswerable. In its place it gives the sensitivity analysis Nagpal et al. (2025) ran on their own 44 cycles -- fit on confirmed cycles only and on the full set, and report both.
pacts_scaling() previously required an ovtoday column even when none had been measured,
and the documentation told such users to fabricate a column of zeros. Read as a hard
requirement, that turned the most common wearable-study situation into a wall.
ovtoday now defaults to NULL, meaning "no ovulation biomarker was collected". The column
is created as all-NA, every cycle takes the imputed-ovulation path, and a message says so
and points at summary_ovulation(). Results are identical to supplying the column by hand.
Three states are kept deliberately distinct, and the distinction is the point:
NULL takes the imputed path described above.data still errors and lists the available columns, so a
typo cannot be mistaken for "no biomarker".Passing NULL when an ovtoday column exists warns, and honors the NULL.
Previously, omitting the argument produced argument "x" is missing, with no default, which
named an internal helper's parameter rather than ovtoday.
Covered by tests/testthat/test_ovtoday_null_path.R, which exists mainly to hold the three
states apart. The first implementation collapsed omitted into NULL, because giving the
argument a default makes rlang::quo_is_missing() permanently FALSE; only base::missing(),
read before enquo() rebinds the name, separates them.
launch_app() did not check for writexl. The Shiny app calls
writexl::write_xlsx() for its download buttons and loads the package at startup, but
writexl was declared nowhere and the dependency gate checked only shinyjs and cpass.
A user holding both gated packages got a clean pass from launch_app() and then hit an
error before the app rendered. writexl is now a suggested dependency and is checked
alongside the other two.
launch_app() now documents its return value, as CRAN requires of exported functions.
The vignettes required R 4.1 while DESCRIPTION declared 3.5. Nine uses of the native
|> pipe have been replaced with %>%. The package's own code was already free of 4.1-only
syntax, so only vignette building was affected, and only on R 4.0 or older.
tidyverse is no longer a suggested dependency. The overview vignette loaded the whole
suite but used only dplyr and ggplot2, both already in Imports. It now loads those two,
and mentions tidyverse as an alternative for readers who have it.
cycle_plot() used partial = T rather than TRUE.
An audit of all package documentation against Nagpal et al. (2025) produced the following.
Ovulation-biomarker precision. ?summary_ovulation and ?pacts_scaling said biomarker
confirmation gives "more precise" or "greater precision" estimates. Section 2.1.2 of the paper
says a positive LH-surge test and a BBT nadir do not pinpoint the day of ovulation, which
would need ultrasound; they place it within 24 to 36 hours, and the window depends on the
method. Both files now say that instead.
Statistical significance is not effect size. The overview vignette read two significant random-effects p-values as indicating "meaningful" variation and heterogeneity. They indicate detectable variation at this sample size. Corrected in both places, since that section exists to teach GAMM output interpretation.
What a smoothing penalty does. The vignette said the wiggliness penalty ensures the model "captures important trends without overfitting noise." It now says the penalty trades bias for variance and can oversmooth a real feature at small n.
Quantified two vague claims using the paper's own figures: the day -15 backward count differs from hormone-confirmed ovulation by a mean absolute 0.97 days (SD 0.88) across 33 cycles, with error growing with cycle length (r = 0.395).
"biomarker" is now "ovulation biomarker" in the 18 places it stood alone as a noun, matching the paper's own usage in Sections 1.3 and 2.1.1. Compound forms such as "biomarker-confirmed ovulation" are unchanged, since the noun already names what is measured. One section heading changed with its cross-reference.
Verified unchanged: every pasted statistic in the overview vignette still matches a live knit (n = 611, R-sq.(adj) = 0.528, deviance explained 55.5%, and both p-values).
The overview vignette gains a section naming four unresolved areas, so that defaults are not
read as validated thresholds: statistical power for PACTS designs, cyclical clustering and
subgroup identification, the reliability of timing features read off per-person smooths, and how
much cycle coverage a person needs before their data support an estimate. cycledata_check()
reports coverage and deliberately sets no threshold.
ovstatus_id gains a column, Total cycles with cycle length < 21 or > 35. The per-cycle
flag behind it has existed since 0.1.9 but the roll-up line was commented out, so the value
was computed and discarded.
Enabling it needed one fix. The flag is NA on a still-open trailing cycle, where
mcyclength_complete is NA and the cycle's length is not yet knowable, and the roll-up used
a bare sum(). Every participant in cycledata has such a cycle, so the column would have
come back NA for all 25 of them. The roll-up now passes na.rm = TRUE.
The column is descriptive, not an inclusion criterion. A confirmed-ovulation cycle outside
21-35 days is still scaled, gated by its phase lengths. It is reported so that this divergence
from Nagpal et al. (2025) Section 2.1.1 is visible in output rather than only in prose --
see the lower_cyclength_bound documentation in ?pacts_scaling, which now states the
divergence explicitly, as do both vignettes and the README quick start at the point where
pacts_scaling() is called.
Claude Code was used for this release: the CRAN packaging changes above, the new vignette, and this entry. The decision to use it was made by Dr. Tory Eisenlohr-Moul, the package maintainer, who reviewed and approved every change.
No AI coding tool was used in the package's initial development, which began in January
2025. Anisha Nagpal's contributions predate all AI tool use: her final commit is dated
18 March 2026, and the first AI-assisted commit is dated 30 May 2026. Of 567 commits at
the time of this release, 49 carry a Claude Code
Co-Authored-By trailer (from June 2026) and 2 are from GitHub Copilot's coding agent
(May 2026, updating inst/CITATION and the startup citation in R/zzzz.R).
The README carries this statement permanently. No AI system is listed in Authors@R.
Adds mcyclength_complete, returned alongside mcyclength from pacts_scaling().
Situation: on cycle_incomplete == 1 rows, mcyclength is days-observed-so-far in a
still-open trailing cycle, not a cycle length. Filtering on mcyclength alone, without
a separate cycle_incomplete == 0 clause, silently admits those rows whenever the
observed-so-far count happens to land in range. Before: getting this right required
remembering that second clause every time (documented since 0.1.8, but easy to miss --
a downstream analysis filtered on mcyclength alone and admitted 263 person-days from
a still-open cycle before the omission was caught). Now: mcyclength_complete is NA
whenever cycle_incomplete == 1, so
filter(mcyclength_complete >= 21, mcyclength_complete <= 35) excludes those rows
automatically, with no second clause needed. mcyclength itself is unchanged.
Also fixes the same defect internally in summary_ovulation()'s cycles_outside_norm
calculation, which compared mcyclength (not mcyclength_complete) to its [21,35]
reference norm. This value is currently dropped before summary_ovulation() returns,
so no user-visible output changes -- the fix is so the computation is correct if this
column is ever exposed later, rather than a landmine waiting to be uncovered.
One clarification while documenting this, since it is easy to over-read the 0.1.8
correction as "the length bound never matters": lower_cyclength_bound/
upper_cyclength_bound fully gate cycle-time computation for a cycle with no
confirmed ovulation -- a closed cycle outside the bound gets zero cyclic_time_impute
coverage, not partial, until the bound is widened to include it. The bound does not
gate a confirmed-ovulation cycle, which scales on its phase lengths regardless of
mcyclength (see the vignette's "Cycle Length Inclusion Criteria" section). The
published PACTS paper's summary of this rule (Nagpal et al., 2025, Psychoneuroendocrinology
181:107584, Section 2.1.1) predates the 0.1.8 correction and does not distinguish the
two cases.
Also corrects several pre-existing documentation errors found while reviewing the above,
none related to mcyclength_complete: the vignette's "Interpreting the GAMM Summary
Output" section had a pasted model-fit snapshot (n=575, R-sq.(adj)=0.533) that had
drifted since the 0.1.7 phase-cap fallback and no longer matched a live run of the
vignette's own code (n=611, R-sq.(adj)=0.528) -- every number in that section is now
current. A "574" in the data-availability section contradicted "575" from the live chunk
directly above it. Three citations (?pacts_scaling, ?summary_ovulation, the vignette's
reference list) pointed at the retired OSF preprint instead of the published paper.
process_luteal_phase_base() was missing two @param entries. A couple of smaller
copy-paste/staleness fixes round it out -- see the git history for the full list.
Corrects some errors in the vignette and fixes a few small bugs. No scaled cycle-time values change for any existing user.
To clarify how scaling eligibility works: lower_cyclength_bound /
upper_cyclength_bound determine which cycles are eligible for an imputed
ovulation. A cycle with a confirmed ovulation is scaled based on its phase
lengths (luteal 7–18 days, follicular 8–25 days, each adjustable). The vignette
now describes this accurately, and shows how to filter by cycle length after
scaling for users who want that.
Also fixes a crash affecting complete cycles of 14 days or fewer when eligible for ovulation imputation, plus a few minor internal corrections.
Four bug fixes that change scaled cycle-time values in specific situations, four new arguments, input validation, and documentation updates. Each fix states the situation it applies to.
Confirmed ovulation with no later menses in the data. Situation: a participant's
last row is N days after a confirmed ovulation, and no menses onset is observed after
that ovulation. Before: cyclic_time, cyclic_time_ov, and luteal_length treated
day N as the last day of the luteal phase. Example: with N = 6, day 6 received the
value of the day before menses and day 3 received the value of mid-luteal. Now: those
N days are NA in all three columns. The same days can be scaled with
impute_next_menses = TRUE, which adds an onset at ovulation + 14 days (LH + 15),
sets menses_impute = 1 on that row, and scales the days in the imputed columns.
This behavior existed in every release before 0.1.7.
POSIXct dates. Situation: the date column is POSIXct and a value has a
non-midnight time (for example 2024-03-01 09:00). Before: that value became NA and
the row was dropped; if the dropped row was an ovulation day, the ovulation was lost;
if every row had a non-midnight time, the function stopped with an internal error.
Now: the time is dropped and the date is kept. A value that cannot be read as a date
produces a warning.
impute_next_menses with a late closing menses. Situation:
impute_next_menses = TRUE, a confirmed ovulation, and the next observed menses
onset more than 20 days after that ovulation. Before: the observed onset was not
found, and an imputed onset was added at ovulation + 14 days even though an observed
onset existed. Example: an observed 22-day luteal phase received an imputed onset on
day 14. Now: the search for an observed onset extends to the participant's next
confirmed ovulation, including an onset on that ovulation's date, and no onset is
imputed when one is found. next_menses_max_window no longer has an effect and
warns if supplied.
Luteal phase over 18 days or follicular phase over 25 days, with confirmed
ovulation. Situation: a confirmed-ovulation cycle within lower_cyclength_bound
and upper_cyclength_bound in which the luteal phase exceeds 18 days or the
follicular phase exceeds 25 days. Before: the days beyond the cap were NA in
cyclic_time_impute and cyclic_time_imp_ov, as well as in cyclic_time and
cyclic_time_ov. Now: cyclic_time_impute and cyclic_time_imp_ov are computed
for those days using the observed phase length, provided each phase is at least its
minimum (7 days luteal, 8 days follicular). New columns
cyclic_time_impute_extended_phase and cyclic_time_imp_ov_extended_phase equal 1
on the days filled this way and 0 otherwise. cyclic_time and cyclic_time_ov are
unchanged.
luteal_phase_min_days (7), luteal_phase_max_days (18),
follicular_phase_min_days (8), follicular_phase_max_days (25). These were fixed
values before 0.1.7. Defaults give the same output as 0.1.6. Raising a maximum
extends cyclic_time and cyclic_time_ov to the longer phase. Lowering a maximum
does not change cyclic_time_impute or cyclic_time_imp_ov, because the fallback in
the previous bullet does not use the maximum. Details in ?pacts_scaling, section
"Internal phase-length caps".
Input validation. Before: a missing column stopped with an rlang error; menses or
ovtoday coded as text ("yes"/"no") or as bleeding intensity (0, 1, 2, 3)
returned every derived column as NA with no message. Now: each case stops with an
error that names the column and the problem.
?pacts_scaling documents the four new arguments, the phase-length caps, and every
returned column including cyclic_time, cyclic_time_impute, cyclic_time_ov, and
cyclic_time_imp_ov. ?menstrualcycleR added. The vignette's description of
impute_next_menses updated.R CMD check: 0 errors, 0 warnings, 0 notes. Debug print() output removed from
pacts_scaling(). mgcv, shinyjs, and cpass moved from Imports to Suggests.
Regression tests added for each fix.impute_next_menses in pacts_scaling() (default FALSE).
When TRUE, a next-menses onset is imputed forward from a biomarker-confirmed
ovulation that has no recorded closing menses, placed at ovulation + next_menses_luteal_days (default 14 days, the population-average luteal length,
i.e. the "LH+15" / last-follicular-day convention). That cycle then becomes
scalable instead of being dropped for a missing anchor. Imputed onsets are
flagged in a new menses_impute column so imputed and observed onsets stay
distinguishable.next_menses_luteal_days (default 14)
and next_menses_max_window (default 20; skip imputation for an ovulation if an
observed menses already closes the cycle within that many days).FALSE) leaves all previous behavior byte for byte identical; only
callers who opt in see any change. Only the general rule is applied. Study or
protocol specific gating (for example blocking imputation across treatment phases
or documented off-study breaks) remains the caller's responsibility.cycledata help page now documents the daterated
column (it previously said date, which the dataset does not contain), and
cycle_plot()'s align_val argument is now documented under its correct name
(was alignval).docs/) and shinyapps deployment
records (rsconnect/, inst/shiny/rsconnect/) are no longer bundled into the
package tarball; removed a stray R/.Rapp.history; tidied a vignette chunk
label that produced a non-portable figure filename. No effect on installed
functionality.stats::sd()/stats::ave(), imported rlang's
:=, and registered remaining non-standard-evaluation column names, clearing
the "no visible binding for global variable" check notes. No user-facing change.URL: field of DESCRIPTION and added a "How to cite" section
to the README pointing to the published paper (Nagpal et al., 2025,
Psychoneuroendocrinology). The canonical citation remains available via
citation("menstrualcycleR") (see inst/CITATION).Fixed a bug in pacts_scaling() where the user's original date column was
left NA on fabricated calendar rows. To compute cycle lengths,
pacts_scaling() internally densifies each participant's calendar so that
every missing day between their first and last observation becomes a row.
Those fabricated rows received the package's internal canonical date, but
the column you passed in (e.g. daterated) was left NA on them. When a
cycle's ovulation had to be imputed (no biomarker-confirmed ovulation),
the imputed-ovulation day — along with its cyclic_time* / scaled_cycleday*
values — could land on exactly such a fabricated row. A downstream analysis
that joined the PACTS output back to other data by the original date column
name would then silently drop those rows, quietly losing imputed-ovulation
and scaled cycle-time observations. pacts_scaling() now returns a fully
populated original date column: on fabricated rows it is filled from the
internal calendar date, and observed dates are never overwritten. Character-
and factor-typed date columns (a supported input) are coerced to Date the
same way the rest of the pipeline already coerces them; Date and POSIXct
date columns keep their type. A regression test asserts that no row carries a
real date or a scaled/cycle value while the original date column is NA,
across Date, character, factor, and POSIXct date inputs.
Fixed the same class of bug for the user's original ID column. The internal
densify keeps the package's canonical id populated on fabricated calendar rows
(it is the grouping key), but an original id column passed under a different name
(e.g. record_id, subject) was left NA on those rows — so a downstream join
on id + date could still drop imputed-ovulation / scaled rows even after the
date fix. pacts_scaling() now also refills the original id column from the
canonical id on fabricated rows (NA-fill only; observed ids are never changed
and the column's type is preserved). This was latent in typical use because the
package's own examples name the id column id; it surfaces for datasets that
use any other id column name. A regression test covers character and integer id
columns under a non-id name.
Fixed a bug in pacts_scaling() where the last participant in a dataset could receive NA for the scaled cycle-time variables across an entire phase (most visibly the luteal phase, i.e. scaled_cycleday after ovtoday == 1). The internal phase-length loops (lutmax, folmax, folmax_impute in helper.R) detected the end of a phase by looking one row ahead and stopped at nrow(data) - 1, so the final row of the dataset was never treated as the end of a run. The result was position-dependent: reordering participants so that another came last moved the problem to that participant. The loops now treat the final dataset row as a valid run-end, and a regression test asserts that a participant's scaled output is identical regardless of their position in the dataset. Thanks to Elisabeth Conrad (Freie Universität Berlin) for the clear bug report and reproduction. ([reported via PACTS user correspondence, June 2026])
Packaging hygiene: declared purrr in Imports (used in helper.R but previously
undeclared), and added tidyverse and marginaleffects to Suggests so the overview
vignette builds in a clean environment. Continuous integration (GitHub Actions
R-CMD-check) now runs the full R CMD check — including the vignette — on every push
and pull request.
pacts_scaling() and cycle_plot_individual() no longer require dplyr/tidyverse to be attached. Several internal calls to dplyr verbs (ungroup(), filter(), case_when(), first()) and to rlang::sym() were not namespace-qualified, so a bare library(menstrualcycleR) produced Error: could not find function "ungroup". All such calls are now qualified (dplyr::/rlang::), and every exported function works with the package loaded on its own.
First release of menstrualcycleR, the companion R package to:
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. https://doi.org/10.1016/j.psyneuen.2025.107584
pacts_scaling() computes continuous, phase-aligned cycle-time variables — the recommended cyclic_time* measures (which map -1 and +1 to the same hormonal point) plus the scaled_cycleday* measures — centered on menses onset or ovulation, with optional ovulation imputation.cycle_plot() and cycle_plot_individual() visualize outcomes across the standardized cycle at the group and individual level, with rolling-average smoothing.cycledata_check() and summary_ovulation() summarize data availability and confirmed-versus-imputed ovulation.launch_app() opens an interactive Shiny app for cycle scaling, data checks, visualization, and C-PASS (PMDD/MRMD/PME) diagnosis.cycledata provides an example daily-diary dataset.mgcv cyclic cubic regression splines.