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.
cycledata_specialA 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).
impute_leading_ovulationpacts_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.
ovtoday = NULL
for studies with no ovulation biomarkerpacts_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.
summary_ovulation()
reports cycles outside 21-35 daysovstatus_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.
pacts_scaling()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.