title: “OmicsChrono: temporal metadata for longitudinal omics” author: “Mateo Pedersen” date: “2026-10-07” output: BiocStyle::html_document vignette: > % % % —

Introduction

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.

Relationship to existing Bioconductor software

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.

Real RNA-seq time-course data

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 id

Inspect 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.25

Summarize 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     matched

The 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] TRUE

Civil timestamps and daylight saving

The 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         FALSE

For 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:00

Zeitgeber time and photoperiod

In 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                              11

Analysis-ready design data

When 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.997859

Limitations

  • Civil timestamp parsing accepts local ISO timestamps to whole seconds and requires an IANA time zone. Numeric offsets embedded in strings are not interpreted; convert them to an absolute POSIXct instant first.
  • A daylight-saving repeated time is ambiguous without an offset, fold marker, or collection record. OmicsChrono reports the ambiguity and never assumes which occurrence was intended.
  • Time-zone rules describe civil time; they do not model experimental clock drift or deviations in an animal facility’s light controller.
  • Zeitgeber annotation uses a supplied local lights-on time and a 24-hour cycle. It does not discover phase, periodicity, or rhythmicity.
  • Alignment reports metadata correspondence only. It does not impute missing assay measurements or change sample order.

Session information

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