title: “OmicsChrono: temporal metadata for longitudinal omics” author: “Mateo Pedersen” date: “2026-10-07” output: BiocStyle::html_document vignette: > % % % —
Longitudinal and circadian studies often describe sample time using a mixture of elapsed durations, civil timestamps, subject-specific baselines, and light cycle coordinates. These quantities have different meanings. In particular, civil clock readings may be ambiguous or nonexistent around daylight-saving transitions, while replicate samples may not land exactly on planned sampling windows.
OmicsChrono is an upstream metadata layer for making these temporal
assumptions explicit in SummarizedExperiment and its
derived classes. It stores sample annotations in colData()
and does not read or alter assay values. The result is suitable for
downstream modeling workflows that expect an explicit temporal
design.
Several Bioconductor packages analyze temporal biological measurements:
| Package | Main temporal task | OmicsChrono’s boundary |
|---|---|---|
rain |
Non-parametric detection of rhythmic patterns in time series | Validates sample-time metadata before measurements are modeled |
DiscoRhythm |
Rhythm detection and exploratory/quality-control visualizations | Represents civil and experimental time and reports sampling coverage |
timeOmics |
Integration and clustering of longitudinal multi-omics profiles | Standardizes sample-level time metadata without fitting profiles |
Rnits, splineTimeR,
TMixClust, timecourse |
Time-course rhythm, spline, clustering, or developmental-series analyses | Does not duplicate statistical models; supplies checked design inputs |
These tools address downstream analysis of biological signal. OmicsChrono addresses the preceding data-engineering step: checking what each sample’s time means and whether the observed design matches its stated schedule. Package scope is limited to metadata; it does not identify rhythmic genes or estimate molecular phase.
The fission experiment-data package supplies a
RangedSummarizedExperiment of fission yeast RNA-seq samples
collected at 0, 15, 30, 60, 120, and 180 minutes after oxidative stress,
in wild-type and atf21-deletion strains. These are elapsed
experimental minutes rather than civil timestamps.
data("fission", package = "fission")
fission
#> Loading required namespace: SummarizedExperiment
#> class: RangedSummarizedExperiment
#> dim: 7039 36
#> metadata(1): ''
#> assays(1): counts
#> rownames(7039): SPAC212.11 SPAC212.09c ... SPMITTRNAGLU.01 SPMIT.11
#> rowData names(2): symbol biotype
#> colnames(36): GSM1368273 GSM1368274 ... GSM1368307 GSM1368308
#> colData names(4): strain minute replicate idInspect sample-level metadata and the planned time points:
cd <- SummarizedExperiment::colData(fission)
names(cd)
#> [1] "strain" "minute" "replicate" "id"
sort(unique(cd$minute))
#> [1] 0 15 30 60 120 180
#> Levels: 0 15 30 60 120 180
fission_subject <- paste(cd$strain, cd$replicate, sep = "_")
## Rebuild the packaged snapshot with the current assay wrapper while retaining
## its real counts, genomic ranges, and sample annotations.
fission_current <- SummarizedExperiment::SummarizedExperiment(
assays = list(counts = SummarizedExperiment::assay(fission)),
rowRanges = SummarizedExperiment::rowRanges(fission),
colData = S4Vectors::DataFrame(as.data.frame(cd))
)Validate the minute column and annotate hours from the first sample. The sample order is preserved; if the order is not chronological, the validation report records that fact rather than reordering samples.
validation <- validateSampleTime(
fission_current, time = "minute", subject = fission_subject, time_unit = "minutes"
)
validation
#> DataFrame with 0 rows and 6 columns
fission_timed <- annotateSampleTime(
fission_current, time = "minute", subject = fission_subject, time_unit = "minutes"
)
head(SummarizedExperiment::colData(fission_timed)[,
c("minute", "chrono_elapsed_hours", "chrono_sampling_interval_hours")])
#> DataFrame with 6 rows and 3 columns
#> minute chrono_elapsed_hours chrono_sampling_interval_hours
#> <factor> <numeric> <numeric>
#> GSM1368273 0 0.00 NA
#> GSM1368274 0 0.00 NA
#> GSM1368275 0 0.00 NA
#> GSM1368276 15 0.25 0.25
#> GSM1368277 15 0.25 0.25
#> GSM1368278 15 0.25 0.25Summarize the available time points by strain and check one-to-one alignment for a single strain. Replicate measurements remain separate observations and are never combined or modified by alignment.
temporalCoverage(
fission_timed,
time = "chrono_elapsed_hours",
subject = fission_subject,
condition = "strain"
)
#> DataFrame with 6 rows and 10 columns
#> subject condition n_samples n_unique_times time_start time_end
#> <character> <character> <integer> <integer> <numeric> <numeric>
#> 1 mut_r1 mut 6 6 0 3
#> 2 mut_r2 mut 6 6 0 3
#> 3 mut_r3 mut 6 6 0 3
#> 4 wt_r1 wt 6 6 0 3
#> 5 wt_r2 wt 6 6 0 3
#> 6 wt_r3 wt 6 6 0 3
#> median_interval min_interval max_interval interval_cv
#> <numeric> <numeric> <numeric> <numeric>
#> 1 0.5 0.25 1 0.631906
#> 2 0.5 0.25 1 0.631906
#> 3 0.5 0.25 1 0.631906
#> 4 0.5 0.25 1 0.631906
#> 5 0.5 0.25 1 0.631906
#> 6 0.5 0.25 1 0.631906
wt <- cd$strain == "wt" & cd$replicate == "r1"
alignSampleTimes(
observed = as.numeric(as.character(cd$minute[wt])),
expected = c(0, 15, 30, 60, 120, 180),
tolerance = 0.01,
sample = colnames(fission)[wt]
)
#> DataFrame with 6 rows and 7 columns
#> subject sample expected_time observed_time offset
#> <character> <character> <numeric> <numeric> <numeric>
#> 1 .all GSM1368273 0 0 0
#> 2 .all GSM1368276 15 15 0
#> 3 .all GSM1368279 30 30 0
#> 4 .all GSM1368282 60 60 0
#> 5 .all GSM1368285 120 120 0
#> 6 .all GSM1368288 180 180 0
#> within_tolerance status
#> <logical> <character>
#> 1 TRUE matched
#> 2 TRUE matched
#> 3 TRUE matched
#> 4 TRUE matched
#> 5 TRUE matched
#> 6 TRUE matchedThe container’s structure and assay values are preserved:
class(fission)
#> [1] "RangedSummarizedExperiment"
#> attr(,"package")
#> [1] "SummarizedExperiment"
class(fission_timed)
#> [1] "RangedSummarizedExperiment"
#> attr(,"package")
#> [1] "SummarizedExperiment"
identical(dim(fission_current), dim(fission_timed))
#> [1] TRUE
identical(colnames(fission_current), colnames(fission_timed))
#> [1] TRUE
identical(SummarizedExperiment::assay(fission_current),
SummarizedExperiment::assay(fission_timed))
#> [1] TRUE
identical(SummarizedExperiment::rowData(fission_current),
SummarizedExperiment::rowData(fission_timed))
#> [1] TRUEThe following tiny example is synthetic and is used only to
illustrate civil time behavior. A timestamp string such as
2024-10-27 02:30:00 in Europe/Oslo occurs
twice when clocks move back. The default diagnostic does not silently
choose one instant. The analyst can explicitly select the first or
second occurrence after reviewing the experiment’s collection
records.
synthetic <- SummarizedExperiment::SummarizedExperiment(
assays = list(counts = matrix(c(3L, 7L), nrow = 1,
dimnames = list("gene1", c("sample1", "sample2")))),
colData = S4Vectors::DataFrame(
collection_time = c("2024-10-27 02:30:00", "2024-10-27 03:30:00"),
timezone = c("Europe/Oslo", "Europe/Oslo")
)
)
validateSampleTime(
synthetic, "collection_time", timezone = synthetic$timezone
)
#> DataFrame with 1 row and 6 columns
#> sample subject code severity
#> <character> <character> <character> <character>
#> 1 sample1 NA ambiguous_local_time error
#> message value
#> <character> <character>
#> 1 This local time occu.. 2024-10-27 02:30:00
resolved <- annotateSampleTime(
synthetic, "collection_time", timezone = synthetic$timezone,
ambiguous = "earliest"
)
SummarizedExperiment::colData(resolved)[,
c("chrono_timestamp_utc", "chrono_timestamp_local", "chrono_is_dst")]
#> DataFrame with 2 rows and 3 columns
#> chrono_timestamp_utc chrono_timestamp_local chrono_is_dst
#> <POSIXct> <character> <logical>
#> sample1 2024-10-27 00:30:00 2024-10-27 02:30:00 TRUE
#> sample2 2024-10-27 02:30:00 2024-10-27 03:30:00 FALSEFor a spring-forward gap, the local time does not exist. It is
reported as nonexistent_local_time and cannot be
converted:
spring_gap <- SummarizedExperiment::SummarizedExperiment(
assays = list(counts = matrix(1L, nrow = 1)),
colData = S4Vectors::DataFrame(collection_time = "2024-03-31 02:30:00")
)
validateSampleTime(spring_gap, "collection_time", timezone = "Europe/Oslo")
#> DataFrame with 1 row and 6 columns
#> sample subject code severity
#> <character> <character> <character> <character>
#> 1 1 NA nonexistent_local_time error
#> message value
#> <character> <character>
#> 1 This local time does.. 2024-03-31 02:30:00In a controlled light schedule, ZT0 is the supplied lights-on time. This is metadata transformation based on the experimental protocol; OmicsChrono does not infer circadian phase from gene expression.
light_study <- SummarizedExperiment::SummarizedExperiment(
assays = list(counts = matrix(1:3, nrow = 1)),
colData = S4Vectors::DataFrame(
collection_time = c("2024-06-01 07:00:00",
"2024-06-01 18:00:00",
"2024-06-01 20:00:00")
)
)
light_study <- addZeitgeberTime(
light_study, "collection_time", lights_on = "07:00",
timezone = "Europe/Oslo", photoperiod = 12
)
SummarizedExperiment::colData(light_study)[,
c("chrono_zeitgeber_hours", "chrono_light_phase",
"chrono_hours_until_phase_change")]
#> DataFrame with 3 rows and 3 columns
#> chrono_zeitgeber_hours chrono_light_phase chrono_hours_until_phase_change
#> <numeric> <character> <numeric>
#> 1 0 light 12
#> 2 11 light 1
#> 3 13 dark 11When cyclical covariates are useful to a downstream method, the cycle length must be supplied explicitly. The helper returns sample-level covariates in input order and does not fit a statistical model.
design <- temporalDesignData(
fission_timed, time = "chrono_elapsed_hours", condition = "strain",
cycle_length = 24
)
head(design)
#> DataFrame with 6 rows and 4 columns
#> time condition cycle_sin cycle_cos
#> <numeric> <factor> <numeric> <numeric>
#> 1 0.00 wt 0.0000000 1.000000
#> 2 0.00 wt 0.0000000 1.000000
#> 3 0.00 wt 0.0000000 1.000000
#> 4 0.25 wt 0.0654031 0.997859
#> 5 0.25 wt 0.0654031 0.997859
#> 6 0.25 wt 0.0654031 0.997859POSIXct instant first.sessionInfo()
#> R version 4.6.1 (2026-06-24)
#> Platform: x86_64-pc-linux-gnu
#> Running under: Ubuntu 26.04.1 LTS
#>
#> Matrix products: default
#> BLAS: /usr/lib/x86_64-linux-gnu/openblas-pthread/libblas.so.3
#> LAPACK: /usr/lib/x86_64-linux-gnu/openblas-pthread/libopenblasp-r0.3.32.so; LAPACK version 3.12.0
#>
#> locale:
#> [1] LC_CTYPE=en_US.UTF-8 LC_NUMERIC=C
#> [3] LC_TIME=en_US.UTF-8 LC_COLLATE=en_US.UTF-8
#> [5] LC_MONETARY=en_US.UTF-8 LC_MESSAGES=en_US.UTF-8
#> [7] LC_PAPER=en_US.UTF-8 LC_NAME=C
#> [9] LC_ADDRESS=C LC_TELEPHONE=C
#> [11] LC_MEASUREMENT=en_US.UTF-8 LC_IDENTIFICATION=C
#>
#> time zone: Etc/UTC
#> tzcode source: system (glibc)
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] OmicsChrono_0.99.0
#>
#> loaded via a namespace (and not attached):
#> [1] Matrix_1.7-6 jsonlite_2.0.0
#> [3] compiler_4.6.1 SummarizedExperiment_1.43.0
#> [5] Biobase_2.73.2 GenomicRanges_1.65.4
#> [7] jquerylib_0.1.4 IRanges_2.47.5
#> [9] Seqinfo_1.3.2 fastmap_1.2.0
#> [11] lattice_0.23-1 R6_2.6.1
#> [13] XVector_0.53.0 S4Arrays_1.13.2
#> [15] generics_0.1.4 knitr_1.52
#> [17] BiocGenerics_0.59.12 DelayedArray_0.39.8
#> [19] MatrixGenerics_1.25.0 maketools_1.3.2
#> [21] tzdb_0.5.0 bslib_0.12.0
#> [23] rlang_1.3.0 clock_0.7.4
#> [25] cachem_1.1.0 xfun_0.61
#> [27] sass_0.4.10 sys_3.4.3
#> [29] otel_0.2.0 SparseArray_1.13.4
#> [31] cli_3.6.6 digest_0.6.39
#> [33] grid_4.6.1 lifecycle_1.0.5
#> [35] vctrs_0.7.3 S4Vectors_0.51.10
#> [37] evaluate_1.0.5 buildtools_1.0.0
#> [39] abind_1.4-8 stats4_4.6.1
#> [41] rmarkdown_2.32 matrixStats_1.5.0
#> [43] tools_4.6.1 htmltools_0.5.9