Skip to contents

The getting-started vignette runs every method on one steady recording, where a single cosine and one set of nonparametric numbers describe the rhythm well. Real recordings are rarely that tidy. A rhythm can drift in period, advance or delay its phase partway through, or fragment as the rest period breaks up. When any of that happens, a summary that collapses the whole recording to one number averages the change away. This vignette builds a recording that is not stationary and works through the methods made to see what a static summary misses.

A recording that shifts

There is no nonstationary file to bundle, so we synthesise one: fourteen days at one-minute epochs, with an active block by day and quiet nights, whose peak advances about two and a half hours earlier across day 7, and whose nights fragment more as the recording goes on. A few methods that locate per-day landmarks read the minute series directly; the heavier decompositions read a ten-minute binned copy, the same split the getting-started vignette uses.

set.seed(42)
days <- 14
t    <- as.POSIXct("2024-03-04", tz = "UTC") + (seq_len(days * 1440) - 1) * 60
hour <- as.numeric(format(t, "%H")) + as.numeric(format(t, "%M")) / 60
day  <- (seq_along(t) - 1) %/% 1440 + 1

acro  <- 15 - 2.5 * pmin(pmax(day - 7, 0), 1)            # peak advances over day 7
block <- plogis(4 * cos(2 * pi * (hour - acro) / 24))    # active by day, quiet at night
frag  <- (day - 1) / (days - 1)                          # 0 to 1 across the recording
counts <- rpois(length(t), 5 + 300 * block)
night_break <- runif(length(t)) < 0.10 * frag & block < 0.2
counts[night_break] <- rpois(sum(night_break), 150)      # nights fragment late on

agg      <- tapply(counts, as.integer(as.numeric(t) %/% 600), sum)   # 10-minute bins
counts10 <- as.numeric(agg)
t10      <- as.POSIXct(as.numeric(names(agg)) * 600, origin = "1970-01-01", tz = "UTC")

The double-plotted actogram tells the story at a glance. The active band sits at the same clock time for the first week, then steps to the left around day 7 as the peak moves earlier, and the later rows scatter as the nights fragment.

plot_actogram(counts, t, scale = "sqrt")
Double-plotted actogram of the synthetic recording. The activity band advances about two and a half hours earlier around day 7, and the later days fragment. A single cosine fit to all fourteen days cannot represent either change.

Double-plotted actogram of the synthetic recording. The activity band advances about two and a half hours earlier around day 7, and the later days fragment. A single cosine fit to all fourteen days cannot represent either change.

What a static summary misses

The usual descriptors still run, and they still return one answer for the whole recording. The cosinor averages all fourteen days onto a single 24-hour profile and fits one cosine to it, so the two phase regimes blur into one smeared peak. The nonparametric metrics give one interdaily-stability and one intradaily-variability number: fragmentation pushes them toward a less stable, more variable rhythm, but that single value is a compromise between the clean early days and the broken late ones, and neither number says that the phase moved.

cosinor.analysis(counts, t, period = 24)
#> Cosinor Analysis Results
#> 
#> Period:     24 hours
#> N obs:      20160 (14.0 days)
#> 
#> Parameters:
#>   MESOR:      157.59 (rhythm-adjusted mean)
#>   Amplitude:  154.72 (half peak-to-trough)
#>   Acrophase:  13:47 (13.78 h, time of peak)
#> 
#> Model Fit:
#>   R-squared:  0.9901
#>   F-statistic: 1053.60
#>   P-value:    0e+00
rhythm <- circadian.rhythm(counts, t)
c(IS = rhythm$IS, IV = rhythm$IV, RA = rhythm$RA)
#>     IS     IV     RA 
#> 0.8778 0.0858 0.8533

Seeing the drift over time

circadian.spectrogram() slides a window across the recording and computes a chi-square periodogram (Sokolove & Bushell, 1978) in each, so the dominant period and its strength become a surface over time rather than a single estimate. The circadian band stays near 24 hours here, but its power tracks the regime change instead of averaging through it.

circadian.spectrogram(counts10, t10, window_hours = 48, step_hours = 12,
                      epoch_length = 600)$plot
Sliding-window periodogram. Each column is one 48-hour window stepped every 12 hours; colour is periodogram power relative to the significance threshold (Qp / threshold). The circadian band is visible throughout, and its strength shifts as the recording moves from the stable first week into the fragmented second.

Sliding-window periodogram. Each column is one 48-hour window stepped every 12 hours; colour is periodogram power relative to the significance threshold (Qp / threshold). The circadian band is visible throughout, and its strength shifts as the recording moves from the stable first week into the fragmented second.

Locating the shift

When the question is when the pattern changed rather than how, sleep.changepoints() finds the per-night rest onsets and wake times directly from the activity, anchored to a cosinor threshold (Chen & Sun, 2024). Because the active peak advances, the detected sleep onsets move earlier in the second half of the recording.

cp <- sleep.changepoints(counts, t)
ep <- cp$sleep_episodes
ep$onset_hour <- as.numeric(format(ep$sleep_onset, "%H")) +
                 as.numeric(format(ep$sleep_onset, "%M")) / 60
ggplot(ep, aes(seq_along(onset_hour), onset_hour)) +
  geom_line(colour = "grey60") + geom_point(colour = "#236192", size = 2) +
  labs(x = "Night", y = "Sleep onset (clock hour)") +
  theme_actiRhythm()
Detected sleep onset for each night, as a clock hour. The onset holds steady through the first week and then jumps earlier once the active phase advances, which the spectrogram and the actogram show from different angles.

Detected sleep onset for each night, as a clock hour. The onset holds steady through the first week and then jumps earlier once the active phase advances, which the spectrogram and the actogram show from different angles.

The same detection as a track: the activity series with each sleep onset (navy) and wake onset (orange) marked and the rest episodes shaded. The rest band slides earlier across the recording.

The same detection as a track: the activity series with each sleep onset (navy) and wake onset (orange) marked and the rest episodes shaded. The rest band slides earlier across the recording.

Decomposing the signal

Singular spectrum analysis separates the series into additive pieces without assuming a shape: a slow trend, the circadian oscillation, and a residual (Golyandina & Zhigljavsky, 2013; Vautard et al., 1992). circadian.ssa() returns each piece on the original time axis, so the circadian component can be read on its own while the trend and residual carry the parts the cosinor would have forced into one curve.

ssa <- circadian.ssa(counts10, t10)
parts <- rbind(
  data.frame(t = t10, value = ssa$circadian, part = "circadian"),
  data.frame(t = t10, value = ssa$trend,     part = "trend"),
  data.frame(t = t10, value = ssa$residual,  part = "residual"))
parts$part <- factor(parts$part, c("circadian", "trend", "residual"))
ggplot(parts, aes(t, value)) +
  geom_line(colour = "#236192", linewidth = 0.3) +
  facet_wrap(~part, ncol = 1, scales = "free_y") +
  labs(x = NULL, y = NULL) +
  theme_actiRhythm()
The singular-spectrum decomposition. The circadian component holds the 24-hour rhythm and bends with the phase advance, the trend follows the slow drift in overall activity, and the residual collects the rising fragmentation.

The singular-spectrum decomposition. The circadian component holds the 24-hour rhythm and bends with the phase advance, the trend follows the slow drift in overall activity, and the residual collects the rising fragmentation.

plot_ssa_wcor(counts10, t10, window_hours = 24)
The SSA w-correlation matrix that justifies the grouping above: bright off-diagonal 2x2 blocks are oscillatory pairs (the circadian rhythm), a single bright cell is the trend, and the diffuse tail is noise.

The SSA w-correlation matrix that justifies the grouping above: bright off-diagonal 2x2 blocks are oscillatory pairs (the circadian rhythm), a single bright cell is the trend, and the diffuse tail is noise.

Empirical mode decomposition reaches the same idea from the other direction (Huang et al., 1998). It pulls out data-adaptive oscillatory modes, and hilbert.huang() turns the circadian mode into an instantaneous period, one value per epoch, so a period that wanders shows up as a moving line rather than a single number.

emd <- circadian.emd(counts, t)
hht <- hilbert.huang(emd)
ggplot(data.frame(t = emd$times, period = hht$period), aes(t, period)) +
  geom_line(colour = "#236192") +
  geom_hline(yintercept = 24, linetype = 2, colour = "grey50") +
  coord_cartesian(ylim = c(18, 30)) +
  labs(x = NULL, y = "Instantaneous period (h)") +
  theme_actiRhythm()
#> Warning: Removed 108 rows containing missing values or values outside the scale range
#> (`geom_line()`).
Instantaneous period of the circadian mode from the Hilbert-Huang transform. It sits somewhat above 24 hours and wanders cycle to cycle, reflecting the phase advance and some mode mixing on this noisy series.

Instantaneous period of the circadian mode from the Hilbert-Huang transform. It sits somewhat above 24 hours and wanders cycle to cycle, reflecting the phase advance and some mode mixing on this noisy series.

Quantifying the complexity

Fragmentation is a change in structure, not just in level, so it shows up in the multiscale measures. Splitting the recording into its clean first half and its broken second half, the late half carries more short-scale sample entropy (Costa et al., 2002) and a narrower multifractal spectrum (Kantelhardt et al., 2002), both signs that its dynamics have shifted toward noise: less predictable from epoch to epoch, and less richly self-similar across scales.

half  <- length(counts10) %/% 2
early <- counts10[1:half]; late <- counts10[(half + 1):length(counts10)]
mse_e <- multiscale.entropy(early, scales = 1:10)
mse_l <- multiscale.entropy(late,  scales = 1:10)
c(early_sampen1 = mse_e$mse[1], late_sampen1 = mse_l$mse[1])
#> early_sampen1  late_sampen1 
#>     0.1283535     0.2420052
c(early_mf_width = mfdfa(early)$width, late_mf_width = mfdfa(late)$width)
#> early_mf_width  late_mf_width 
#>       1.654179       1.084617
mse <- rbind(data.frame(scale = mse_e$scales, sampen = mse_e$mse, half = "first half"),
             data.frame(scale = mse_l$scales, sampen = mse_l$mse, half = "second half"))
ggplot(mse, aes(scale, sampen, colour = half)) +
  geom_line() + geom_point(size = 1.5) +
  scale_colour_manual(values = c("first half" = "#236192", "second half" = "#E69F00")) +
  labs(x = "Scale factor", y = "Sample entropy", colour = NULL) +
  theme_actiRhythm()
Sample entropy against coarse-graining scale for the two halves. The fragmented second half carries more entropy, a less predictable and more broken rhythm.

Sample entropy against coarse-graining scale for the two halves. The fragmented second half carries more entropy, a less predictable and more broken rhythm.

A phase that survives the drift

When the phase moves, the averaged acrophase is a poor summary of any single day. curve.registration() instead finds each day’s own activity landmark, aligns the days on it, and reports a phase marker plus how much that landmark varies (Ramsay & Silverman, 2005). The variability is large here precisely because the phase advanced, which is the honest answer.

reg <- curve.registration(counts, t)
c(mean_M10_hour = reg$mean_M10, phase_variability_h = reg$phase_sd)
#>       mean_M10_hour phase_variability_h 
#>           13.797765            1.213938
ggplot(reg$landmarks, aes(seq_along(M10_center_h), M10_center_h)) +
  geom_line(colour = "grey60") + geom_point(colour = "#236192", size = 2) +
  labs(x = "Day", y = "M10 centre (clock hour)") +
  theme_actiRhythm()
The most-active-window centre for each day. It is steady through the first week and then jumps earlier, so the across-day phase variability that registration reports is real structure, not noise.

The most-active-window centre for each day. It is steady through the first week and then jumps earlier, so the across-day phase variability that registration reports is real structure, not noise.

When to reach for each

Start with the actogram and the static summary. If the band is straight and the nonparametric numbers sit comfortably, a cosinor and the interdaily and intradaily metrics are enough. When the band bends, fragments, or wanders, the static numbers become a compromise and the dynamic tools earn their place. Reach for the spectrogram to watch the period and its strength over time, sleep.changepoints() to date a regime change, singular spectrum analysis or empirical mode decomposition to read the circadian component apart from the trend and the noise, multiscale entropy and the multifractal spectrum to measure how the structure itself changes, and curve registration for a phase that does not blur when the days disagree. Each one answers a question that a single number cannot.

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
Costa, M., Goldberger, A. L., & Peng, C.-K. (2002). Multiscale entropy analysis of complex physiologic time series. Physical Review Letters, 89(6), 068102. https://doi.org/10.1103/PhysRevLett.89.068102
Golyandina, N., & Zhigljavsky, A. (2013). Singular spectrum analysis for time series. Springer. https://doi.org/10.1007/978-3-642-34913-3
Huang, N. E., Shen, Z., Long, S. R., Wu, M. C., Shih, H. H., Zheng, Q., Yen, N. C., Tung, C. C., & Liu, H. H. (1998). The empirical mode decomposition and the Hilbert spectrum for nonlinear and non-stationary time series analysis. Proceedings of the Royal Society A, 454(1971), 903–995. https://doi.org/10.1098/rspa.1998.0193
Kantelhardt, J. W., Zschiegner, S. A., Koscielny-Bunde, E., Havlin, S., Bunde, A., & Stanley, H. E. (2002). Multifractal detrended fluctuation analysis of nonstationary time series. Physica A: Statistical Mechanics and Its Applications, 316(1-4), 87–114. https://doi.org/10.1016/S0378-4371(02)01383-3
Ramsay, J. O., & Silverman, B. W. (2005). Functional data analysis (2nd ed.). Springer. https://doi.org/10.1007/b98888
Sokolove, P. G., & Bushell, W. N. (1978). The chi square periodogram: Its utility for analysis of circadian rhythms. Journal of Theoretical Biology, 72(1), 131–160. https://doi.org/10.1016/0022-5193(78)90022-X
Vautard, R., Yiou, P., & Ghil, M. (1992). Singular-spectrum analysis: A toolkit for short, noisy chaotic signals. Physica D: Nonlinear Phenomena, 58(1-4), 95–126. https://doi.org/10.1016/0167-2789(92)90103-T