Repository: mne-tools/mne-python

Issue:
ENH: make use of time-stamps optional in `mne.Annotations.to_data_frame()`
### Describe the new feature or enhancement

Hi folks!

I find the `.to_data_frame()` method for annotations very practical but I realize that the default behavior of having onsets in timestamps is not always what is practical from a perspective of data analysis where you might want to keep selecting data based on times in seconds (float).

I was wondering how you would feel about including an enhancment that would make following snippet of code work:

```Python
annot = mne.Annotations(onset=[0, 10, 20], duration=[2] * 3, description='test')
annot_df = annot.to_data_frame(self, onset_as_time_stamp=False)
assert annot_df['onset'] == annot.onset
```


### Describe your proposed implementation

I would make conversion to time-stamps optional.

### Describe possible alternatives

I cannot think of another implementation. One can of course post-hoc edit the data frame but the point of a convenience method is to be convenient.

### Additional context

This can be helpful when analyzing continuous data in a fashion that does not involve epoching, e. g., in richly annotated long-term EEG recordings.

Discussion:
drammock: @dengemann does this meet your needs?
dengemann: Looks awesome! This will facilitate interactive prototyping of analyses of signal windows with irregular duration. LGTM!
larsoner: Nice, I think it just needs a `devel.rst` update, feel free to mark for merge-when-green once you push one @drammock !

Patch:
diff --git a/doc/changes/devel.rst b/doc/changes/devel.rst
index 3fd579ad4..feae12dcb 100644
--- a/doc/changes/devel.rst
+++ b/doc/changes/devel.rst
@@ -39,6 +39,7 @@ Enhancements
 - We added type hints for the return values of :func:`mne.read_evokeds` and :func:`mne.io.read_raw`. Development environments like VS Code or PyCharm will now provide more help when using these functions in your code. (:gh:`12250` by `Richard Höchenberger`_ and `Eric Larson`_)
 - Add ``method="polyphase"`` to :meth:`mne.io.Raw.resample` and related functions to allow resampling using :func:`scipy.signal.upfirdn` (:gh:`12268` by `Eric Larson`_)
 - The package build backend was switched from ``setuptools`` to ``hatchling``. This will only affect users who build and install MNE-Python from source. (:gh:`12269`, :gh:`12281` by `Richard Höchenberger`_)
+- :meth:`mne.Annotations.to_data_frame` can now output different formats for the ``onset`` column: seconds, milliseconds, datetime objects, and timedelta objects. (:gh:`12289` by `Daniel McCloy`_)
 
 Bugs
 ~~~~
diff --git a/mne/annotations.py b/mne/annotations.py
index 20ee351e7..8a44a84f5 100644
--- a/mne/annotations.py
+++ b/mne/annotations.py
@@ -38,6 +38,8 @@ from .utils import (
     _check_fname,
     _check_option,
     _check_pandas_installed,
+    _check_time_format,
+    _convert_times,
     _DefaultEventParser,
     _dt_to_stamp,
     _is_numeric,
@@ -442,9 +444,16 @@ class Annotations:
         self.description = np.delete(self.description, idx)
         self.ch_names = np.delete(self.ch_names, idx)
 
-    def to_data_frame(self):
+    @fill_doc
+    def to_data_frame(self, time_format="datetime"):
         """Export annotations in tabular structure as a pandas DataFrame.
 
+        Parameters
+        ----------
+        %(time_format_df_raw)s
+
+            .. versionadded:: 1.7
+
         Returns
         -------
         result : pandas.DataFrame
@@ -453,12 +462,14 @@ class Annotations:
             annotations are channel-specific.
         """
         pd = _check_pandas_installed(strict=True)
+        valid_time_formats = ["ms", "timedelta", "datetime"]
         dt = _handle_meas_date(self.orig_time)
         if dt is None:
             dt = _handle_meas_date(0)
+        time_format = _check_time_format(time_format, valid_time_formats, dt)
         dt = dt.replace(tzinfo=None)
-        onsets_dt = [dt + timedelta(seconds=o) for o in self.onset]
-        df = dict(onset=onsets_dt, duration=self.duration, description=self.description)
+        times = _convert_times(self.onset, time_format, dt)
+        df = dict(onset=times, duration=self.duration, description=self.description)
         if self._any_ch_names():
             df.update(ch_names=self.ch_names)
         df = pd.DataFrame(df)
diff --git a/mne/epochs.py b/mne/epochs.py
index 915670af5..0ae911ac5 100644
--- a/mne/epochs.py
+++ b/mne/epochs.py
@@ -2661,7 +2661,7 @@ class BaseEpochs(
         # prepare extra columns / multiindex
         mindex = list()
         times = np.tile(times, n_epochs)
-        times = _convert_times(self, times, time_format)
+        times = _convert_times(times, time_format, self.info["meas_date"])
         mindex.append(("time", times))
         rev_event_id = {v: k for k, v in self.event_id.items()}
         conditions = [rev_event_id[k] for k in self.events[:, 2]]
diff --git a/mne/evoked.py b/mne/evoked.py
index 93583edb0..b23a4fc11 100644
--- a/mne/evoked.py
+++ b/mne/evoked.py
@@ -1256,7 +1256,7 @@ class Evoked(
         data = _scale_dataframe_data(self, data, picks, scalings)
         # prepare extra columns / multiindex
         mindex = list()
-        times = _convert_times(self, times, time_format)
+        times = _convert_times(times, time_format, self.info["meas_date"])
         mindex.append(("time", times))
         # build DataFrame
         df = _build_data_frame(
diff --git a/mne/io/base.py b/mne/io/base.py
index 6bd92607e..94cd2ffcd 100644
--- a/mne/io/base.py
+++ b/mne/io/base.py
@@ -2271,7 +2271,9 @@ class BaseRaw(
         data = _scale_dataframe_data(self, data, picks, scalings)
         # prepare extra columns / multiindex
         mindex = list()
-        times = _convert_times(self, times, time_format)
+        times = _convert_times(
+            times, time_format, self.info["meas_date"], self.first_time
+        )
         mindex.append(("time", times))
         # build DataFrame
         df = _build_data_frame(
diff --git a/mne/source_estimate.py b/mne/source_estimate.py
index 507348174..b2d197d7b 100644
--- a/mne/source_estimate.py
+++ b/mne/source_estimate.py
@@ -1398,7 +1398,7 @@ class _BaseSourceEstimate(TimeMixin):
         if self.subject is not None:
             default_index = ["subject", "time"]
             mindex.append(("subject", np.repeat(self.subject, data.shape[0])))
-        times = _convert_times(self, times, time_format)
+        times = _convert_times(times, time_format)
         mindex.append(("time", times))
         # triage surface vs volume source estimates
         col_names = list()
diff --git a/mne/time_frequency/tfr.py b/mne/time_frequency/tfr.py
index 400b71151..279e2c798 100644
--- a/mne/time_frequency/tfr.py
+++ b/mne/time_frequency/tfr.py
@@ -1333,7 +1333,7 @@ class _BaseTFR(ContainsMixin, UpdateChannelsMixin, SizeMixin, ExtendedTimeMixin)
         # prepare extra columns / multiindex
         mindex = list()
         times = np.tile(times, n_epochs * n_freqs)
-        times = _convert_times(self, times, time_format)
+        times = _convert_times(times, time_format, self.info["meas_date"])
         mindex.append(("time", times))
         freqs = self.freqs
         freqs = np.tile(np.repeat(freqs, n_times), n_epochs)
diff --git a/mne/utils/dataframe.py b/mne/utils/dataframe.py
index 599a2f881..2f70c57c6 100644
--- a/mne/utils/dataframe.py
+++ b/mne/utils/dataframe.py
@@ -35,7 +35,7 @@ def _scale_dataframe_data(inst, data, picks, scalings):
     return data
 
 
-def _convert_times(inst, times, time_format):
+def _convert_times(times, time_format, meas_date=None, first_time=0):
     """Convert vector of time in seconds to ms, datetime, or timedelta."""
     # private function; pandas already checked in calling function
     from pandas import to_timedelta
@@ -45,7 +45,7 @@ def _convert_times(inst, times, time_format):
     elif time_format == "timedelta":
         times = to_timedelta(times, unit="s")
     elif time_format == "datetime":
-        times = to_timedelta(times + inst.first_time, unit="s") + inst.info["meas_date"]
+        times = to_timedelta(times + first_time, unit="s") + meas_date
     return times
 
 


Test patch:
diff --git a/mne/tests/test_annotations.py b/mne/tests/test_annotations.py
index 1a351de55..8f3124d6a 100644
--- a/mne/tests/test_annotations.py
+++ b/mne/tests/test_annotations.py
@@ -1416,7 +1416,8 @@ def test_repr():
     assert r == "<Annotations | 0 segments>"
 
 
-def test_annotation_to_data_frame():
+@pytest.mark.parametrize("time_format", (None, "ms", "datetime", "timedelta"))
+def test_annotation_to_data_frame(time_format):
     """Test annotation class to data frame conversion."""
     pytest.importorskip("pandas")
     onset = np.arange(1, 10)
@@ -1427,11 +1428,15 @@ def test_annotation_to_data_frame():
         onset=onset, duration=durations, description=description, orig_time=0
     )
 
-    df = a.to_data_frame()
+    df = a.to_data_frame(time_format=time_format)
     for col in ["onset", "duration", "description"]:
         assert col in df.columns
     assert df.description[0] == "yy"
-    assert (df.onset[1] - df.onset[0]).seconds == 1
+    want = 1000 if time_format == "ms" else 1
+    got = df.onset[1] - df.onset[0]
+    if time_format in ("datetime", "timedelta"):
+        got = got.seconds
+    assert want == got
     assert df.groupby("description").count().onset["yy"] == 9