Skip to contents

The idea, and a rule to remember

Sleep and rest detection turns a stream of activity counts into an answer to two different questions. The epoch scorers (Cole-Kripke and Sadeh) label every single minute as sleep or wake, giving a per-epoch state you can total into sleep time. The bout detectors (change-point, Roenneberg, Crespo, and the state-space model) step back and mark the spans of rest, the onset and offset clock times of each consolidated period. One tells you how much sleep, the other when it happened.

The rule to carry through this article: decide first whether you want one main sleep window per night or every rest bout the recording contains, because that choice, not the algorithm’s accuracy, is what separates these functions. sleep.changepoints() finds the single dominant rest bout of each circadian cycle; rest.periods() and rest.crespo() return every consolidated bout, naps included. Asking a one-bout method for naps, or a many-bout method for “the night”, is the most common way to misread the output.

The math

The two epoch scorers are weighted windows over the counts. Cole-Kripke (Cole et al., 1992) forms a sleep index from a seven-epoch window: four epochs before the current one (P4P1P_4 \dots P_1), the current epoch CC, and two after (N1,N2N_1, N_2). The weights are Cole’s mean-activity variant (Table 6); the /100/100 scaling and cap are implementation conventions: D=0.001(106P4+54P3+58P2+76P1+230C+74N1+67N2), D = 0.001\,(106\,P_4 + 54\,P_3 + 58\,P_2 + 76\,P_1 + 230\,C + 74\,N_1 + 67\,N_2), and scores the epoch sleep when D<1D < 1. Webster’s rescoring rules (Webster et al., 1982) then re-label short sleep bouts bracketed by sustained wake.

Sadeh (Sadeh et al., 1994) uses an eleven-epoch window (five each side): SI=7.6010.065A¯1.08NATS0.056s0.703log(C+1), SI = 7.601 - 0.065\,\overline{A} - 1.08\,\mathrm{NATS} - 0.056\,s - 0.703\,\log(C+1), where A¯\overline{A} is the window mean, NATS\mathrm{NATS} the count of epochs in [50,100)[50,100), ss the standard deviation over the current and five preceding epochs, and CC the current count. The epoch is scored sleep when SI0SI \ge 0; set wake_threshold = -4 and clip = 300 for ActiLife parity.

The change-point detector (Chen & Sun, 2024) is geometric rather than weighted: it fits a 24-hour cosinor to bound each rest and active span roughly, then places the exact transition inside each bound at the split kk minimising the within-segment residual sum of squares, k*=argminkik(xixk)2+i>k(xix>k)2. k^* = \arg\min_k \;\sum_{i \le k}(x_i - \bar x_{\le k})^2 + \sum_{i > k}(x_i - \bar x_{>k})^2 .

Assumptions, and when they break

  • One-minute epochs. Cole-Kripke and Sadeh were validated on 60-second epochs and their coefficients are tied to that resolution; both warn if epoch_length differs. Re-bin to one minute before scoring.
  • Counts, not raw acceleration. The scorers expect ActiGraph-style activity counts on the vertical axis; feed them counts (agd$axis1), not raw g.
  • Non-wear is not sleep. A taken-off device reads as deep rest. The scorers’ na_action argument governs this: the default "na" emits NA for missing-count epochs so a device-off gap is not silently scored as sleep.
  • A clear day-night contrast for the bout detectors. sleep.changepoints(), rest.periods(), and rest.crespo() all need enough rhythm to separate rest from activity; on a flat or very short record they return an empty result rather than erroring.

Recovering known truth

Before trusting any of this on real data, plant an answer and check it comes back. We build a seven-day recording with a known nightly sleep window from 23:00 to 07:00 (near-zero activity at night, a busy day) and ask whether the epoch scorers label the window sleep and whether sleep.changepoints() recovers the 23:00 onset and 07:00 wake.

ts  <- seq(as.POSIXct("2024-01-01", tz = "UTC"), by = 60, length.out = 7 * 1440)
hod <- as.numeric(format(ts, "%H")) + as.numeric(format(ts, "%M")) / 60
set.seed(1)
asleep <- hod >= 23 | hod < 7                       # the planted sleep window
counts <- ifelse(asleep, 0, 250) + pmax(0, rnorm(length(ts), 0, 8))

ck <- sleep.cole.kripke(counts)
sd <- sleep.sadeh(counts)
knitr::kable(
  data.frame(
    scorer        = c("Cole-Kripke", "Sadeh"),
    sleep_recall  = c(mean(ck[asleep] == "S"),  mean(sd[asleep] == "S")),
    wake_recall   = c(mean(ck[!asleep] == "W"), mean(sd[!asleep] == "W"))),
  digits = 3,
  caption = "Both scorers label the planted 23:00-07:00 window sleep and the day wake."
)
Both scorers label the planted 23:00-07:00 window sleep and the day wake.
scorer sleep_recall wake_recall
Cole-Kripke 0.992 0.999
Sadeh 0.988 1.000

Both scorers tag essentially all of the night as sleep and essentially all of the day as wake. Now the change-point detector, which should place onset near 23:00 and wake near 07:00 every night:

cp <- sleep.changepoints(counts, ts)
cps_h <- as.numeric(format(cp$changepoints$time, "%H")) +
         as.numeric(format(cp$changepoints$time, "%M")) / 60
knitr::kable(
  data.frame(
    transition = c("sleep onset", "wake onset"),
    planted    = c(23, 7),
    recovered  = c(mean(cps_h[cp$changepoints$type == "sleep onset"]),
                   mean(cps_h[cp$changepoints$type == "wake onset"]))),
  digits = 2,
  caption = "Mean recovered transition time across the seven nights vs the planted values."
)
Mean recovered transition time across the seven nights vs the planted values.
transition planted recovered
sleep onset 23 22.98
wake onset 7 6.98
c(n_episodes = cp$n_episodes, mean_sleep_duration_h = cp$mean_sleep_duration)
#>            n_episodes mean_sleep_duration_h 
#>                     6                     8

The recovered onset and wake land within a minute of the planted 23:00 and 07:00, the detector pairs them into six complete episodes (the seventh night’s 23:00 onset has no wake onset inside the record, so it is left unpaired), and the mean duration is the eight hours we built in. The method recovers the truth we planted.

On a real recording

The bundled recording runs the same calls. The two scorers return a per-epoch state vector; sleep.changepoints() returns a per-night episode table.

agd <- agd.counts(read.agd(example_agd(1), verbose = FALSE))
table(cole_kripke = sleep.cole.kripke(agd$axis1))
#> cole_kripke
#>    S    W 
#> 7641 2278
table(sadeh       = sleep.sadeh(agd$axis1))
#> sadeh
#>    S    W 
#> 6757 3162
cp_real <- sleep.changepoints(agd$axis1, agd$timestamp)
cp_real
#> Change-Point Sleep/Wake Detection
#> 
#>   Span:           6.9 days (9919 epochs)
#>   Cosinor acrophase: 20.5 h
#>   Change points:  14 (7 sleep episodes)
#>   Mean sleep duration: 11.1 h
#> 
#>   First sleep episodes:
#>     sleep 10-07 22:36  ->  wake 10-08 07:09  (8.6 h)
#>     sleep 10-08 23:30  ->  wake 10-09 07:50  (8.3 h)
#>     sleep 10-09 21:30  ->  wake 10-10 12:11  (14.7 h)
#> 
#>   Reference: Chen and Sun (2024)

The two scorers agree closely on the total sleep fraction, and the change-point detector resolves one main sleep episode per night with onset and wake clock times.

Reading the numbers

  • sleep_state (“S”/“W”) from the scorers is a per-epoch label. Its useful summaries are the count of sleep epochs (multiply by the epoch length for sleep time) and the runs of consecutive sleep (sleep periods and their lengths).
  • Onset and wake times from sleep.changepoints() are clock times; read them as “when did the main rest bout begin and end”, and read mean_sleep_duration as the average nightly rest length.
  • Cole-Kripke vs Sadeh. Cole-Kripke was validated on adults (Cole et al., 1992), Sadeh on adults and adolescents (Sadeh et al., 1994). They will not agree epoch-for-epoch, and the disagreement is largest at the sleep-wake boundary, not in the middle of the night.

One bout per night, or every bout? The rest detectors compared

This is the rule in action, and it stands in for the usual “wrong-way” demo: there is no wrong algorithm here, only a wrong question. The same recording, run through four detectors, gives four different bout counts, because they answer different questions, not because three of them are wrong.

cp <- sleep.changepoints(agd$axis1, agd$timestamp)   # one main bout per night
rp <- rest.periods(agd$axis1, agd$timestamp)         # every bout (Roenneberg/MASDA)
rc <- rest.crespo(agd$axis1, agd$timestamp)          # main rest periods (Crespo morphology)
hm <- rest.hmm(agd$axis1, agd$timestamp, seed = 1)   # state-space alternative

knitr::kable(
  data.frame(
    detector = c("sleep.changepoints", "rest.periods", "rest.crespo", "rest.hmm"),
    question = c("one main bout / night", "every bout (naps incl.)",
                 "main rest periods", "latent rest state"),
    bouts    = c(cp$n_episodes, rp$n_bouts, rc$n_rest_periods, NA),
    family   = c("CircaCP", "Roenneberg", "Crespo", "Gaussian HMM")),
  caption = "Four detectors, four counts: the difference is the question, not the accuracy."
)
Four detectors, four counts: the difference is the question, not the accuracy.
detector question bouts family
sleep.changepoints one main bout / night 7 CircaCP
rest.periods every bout (naps incl.) 9 Roenneberg
rest.crespo main rest periods 4 Crespo
rest.hmm latent rest state NA Gaussian HMM
plot_rest_comparison(agd$axis1, agd$timestamp)
The four detectors as a strip: the activity series on top, then each detector's rest bands on a shared time axis. sleep.changepoints and rest.crespo report the main night, while rest.periods and rest.hmm also catch the daytime nap.

The four detectors as a strip: the activity series on top, then each detector’s rest bands on a shared time axis. sleep.changepoints and rest.crespo report the main night, while rest.periods and rest.hmm also catch the daytime nap.

sleep.changepoints() (Chen & Sun, 2024) returns one episode per circadian cycle: the single dominant night-time rest bout, and rest.crespo() (Crespo et al., 2012) likewise reports the main rest and activity periods, its roughly eight-hour median window (Eq 4) suppressing short within-period transitions. rest.periods() (Loock et al., 2021; Roenneberg et al., 2015) instead returns every consolidated bout, so a daytime nap or a fragmented night becomes its own row, by Roenneberg’s trend-and-correlation route. rest.hmm() (Huang et al., 2018) is the state-space alternative: rather than thresholding each epoch, it fits a Gaussian hidden Markov model whose latent rest state persists, and reports a 24-hour rest-probability profile.

ggplot(hm$tod_profile, aes(hour, p_rest)) +
  geom_col(fill = "#236192") +
  scale_x_continuous(breaks = seq(0, 24, 6)) +
  labs(x = "Hour of day", y = "P(rest state)") +
  theme_actiRhythm()
The HMM's probability of being in the rest state across the day. The state-space model infers the rest band, centred on the night, without a fixed count threshold.

The HMM’s probability of being in the rest state across the day. The state-space model infers the rest band, centred on the night, without a fixed count threshold.

The lesson is the rule: sleep.changepoints() and rest.crespo() report the main rest period; rest.periods() reports all rest, naps and all; rest.hmm() reports a latent-state probability. Choose the one whose question matches yours, rather than treating the differing bout counts as a contradiction.

The wider sleep-and-rest family

The state-space view in detail. Beyond the occupancy profile above, rest.hmm() returns the per-state emission means, the transition matrix, and a decoded state_path with its own sleep_state vector, plus AIC/BIC for choosing between a two- and three-state model (Huang et al., 2018).

hm$emission[, c("state", "label", "mean_transformed")]
#>   state  label mean_transformed
#> 1     1   rest    6.326781e-295
#> 2     2 active     2.812874e+01
c(time_at_rest = mean(hm$state_path == 1L),
  rest_persistence = hm$transition[1, 1], AIC = hm$AIC)
#>     time_at_rest rest_persistence              AIC 
#>     7.373727e-01     9.514564e-01    -1.604701e+05

The ultradian structure within sleep. Once you have sleep windows, lids() describes the sleep-cycle oscillation inside them. It needs a sleep_periods data frame with in_bed_time and out_bed_time columns (which we build directly from the change-point episodes), transforms activity to locomotor inactivity (100/(activity+1)100/(\text{activity}+1)), and fits an ultradian cosine, reporting the best period and its Munich Rhythmicity Index (Winnebeck et al., 2018).

sleep_periods <- data.frame(
  in_bed_time  = cp$sleep_episodes$sleep_onset,
  out_bed_time = cp$sleep_episodes$wake_onset)
li <- lids(agd$axis1, agd$timestamp, sleep_periods)
li
#> Locomotor Inactivity During Sleep (LIDS)
#> 
#>   Sleep periods:    7
#>   Mean LIDS period: 115.7 min
#>   Mean MRI:         5.474
plot_lids(agd$axis1, agd$timestamp, sleep_periods)
The LIDS ultradian cycle across one sleep period: smoothed inactivity (navy) with the best-fit cosine (orange). The roughly 90-to-130-minute oscillation is the sleep-cycle rhythm read from movement alone.

The LIDS ultradian cycle across one sleep period: smoothed inactivity (navy) with the best-fit cosine (orange). The roughly 90-to-130-minute oscillation is the sleep-cycle rhythm read from movement alone.

The mean LIDS period lands in the roughly 90-to-130-minute band, near Winnebeck’s ~110 min median, recovered from the inactivity signal alone.

Limitations

  • Epoch length is fixed for the scorers. Cole-Kripke and Sadeh are only valid at one-minute epochs; do not port their coefficients to other resolutions.
  • Bout counts are not comparable across detectors. A larger bout count from rest.periods() than from sleep.changepoints() is by design, not a sign one is better; compare a detector only against itself or against its own reference implementation.
  • No in-bed truth. None of these methods see a diary or a true lights-off; they infer rest from movement, so a still-but-awake subject reads as rest and an active sleeper as wake. Where a diary exists, gate on it.
  • lids() depends on the supplied sleep windows. Feeding it poorly placed windows propagates straight into the period estimate; build the windows from a detector you trust on the recording at hand.

Reference and validation

The epoch scorers follow Cole et al. (1992) with the Webster rescoring rules (Webster et al., 1982) and Sadeh et al. (1994). The bout detectors follow the two-stage CircaCP design of Chen & Sun (2024) (cosinor segmentation, then a single change point inside each segment), with a least-squares mean-shift cost in place of CircaCP’s gamma-scale MIC criterion, the Roenneberg / MASDA consolidation (Roenneberg et al., 2015) with the open re-implementation of Loock et al. (2021), the Crespo et al. (2012) morphology pipeline, and the hidden-Markov rest-activity model of Huang et al. (2018); lids() follows Winnebeck et al. (2018). actiRhythm’s scorers and detectors are cross-checked against their reference implementations (to the printed precision) in the Validation article and the package’s test suite.

References

Chen, S., & Sun, X. (2024). Validating CircaCP: A generic sleep-wake cycle detection algorithm for unlabelled actigraphy data. Royal Society Open Science, 11(5), 231468. https://doi.org/10.1098/rsos.231468
Cole, R. J., Kripke, D. F., Gruen, W., Mullaney, D. J., & Gillin, J. C. (1992). Automatic sleep/wake identification from wrist activity. Sleep, 15(5), 461–469. https://doi.org/10.1093/sleep/15.5.461
Crespo, C., Aboy, M., Fernandez, J. R., & Mojon, A. (2012). Automatic identification of activity-rest periods based on actigraphy. Medical & Biological Engineering & Computing, 50(4), 329–340. https://doi.org/10.1007/s11517-012-0875-y
Huang, Q., Cohen, D., Komarzynski, S., Li, X.-M., Innominato, P., Levi, F., & Finkenstadt, B. (2018). Hidden Markov models for monitoring circadian rhythmicity in telemetric activity data. Journal of the Royal Society Interface, 15(139), 20170885. https://doi.org/10.1098/rsif.2017.0885
Loock, A.-S., Khan Sullivan, A., Reis, C., Paiva, T., Ghotbi, N., Pilz, L. K., Biller, A. M., Molenda, C., Vuori-Brodowski, M. T., Roenneberg, T., & Winnebeck, E. C. (2021). Validation of the Munich actimetry sleep detection algorithm for estimating sleep-wake patterns from activity recordings. Journal of Sleep Research, 30(6), e13371. https://doi.org/10.1111/jsr.13371
Roenneberg, T., Keller, L. K., Fischer, D., Matera, J. L., Vetter, C., & Winnebeck, E. C. (2015). Human activity and rest in situ. Methods in Enzymology, 552, 257–283. https://doi.org/10.1016/bs.mie.2014.11.028
Sadeh, A., Sharkey, K. M., & Carskadon, M. A. (1994). Activity-based sleep-wake identification: An empirical test of methodological issues. Sleep, 17(3), 201–207. https://doi.org/10.1093/sleep/17.3.201
Webster, J. B., Kripke, D. F., Messin, S., Mullaney, D. J., & Wyborney, G. (1982). An activity based sleep monitor system for ambulatory use. Sleep, 5(4), 389–399. https://doi.org/10.1093/sleep/5.4.389
Winnebeck, E. C., Fischer, D., Leise, T., & Roenneberg, T. (2018). Dynamics and ultradian structure of human sleep in real life. Current Biology, 28(1), 49–59. https://doi.org/10.1016/j.cub.2017.11.063