Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/changes/dev/13109.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix reading annotations with :func:`mne.read_annotations` from .csv files containing nanoseconds in times, and drop nanoseconds from times returned from :meth:`mne.Annotations.to_data_frame` and saved in .csv files by :meth:`mne.Annotations.save`, by `Thomas Binns`_.
27 changes: 24 additions & 3 deletions mne/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ class Annotations:
the annotations with raw data if their acquisition is started at the
same time. If it is a string, it should conform to the ISO8601 format.
More precisely to this '%%Y-%%m-%%d %%H:%%M:%%S.%%f' particular case of
the ISO8601 format where the delimiter between date and time is ' '.
the ISO8601 format where the delimiter between date and time is ' ' and at most
microsecond precision (nanoseconds are not supported).
%(ch_names_annot)s

.. versionadded:: 0.23
Expand Down Expand Up @@ -390,6 +391,20 @@ def __init__(
extras=None,
):
self._orig_time = _handle_meas_date(orig_time)
if isinstance(orig_time, str) and self._orig_time is None:
try: # only warn if `orig_time` is not the default '1970-01-01 00:00:00'
if _handle_meas_date(0) == datetime.strptime(
orig_time, "%Y-%m-%d %H:%M:%S"
).replace(tzinfo=timezone.utc):
pass
except ValueError: # error if incorrect datetime format AND not the default
warn(
"The format of the `orig_time` string is not recognised. It "
"must conform to the ISO8601 format with at most microsecond "
"precision and where the delimiter between date and time is "
f"' '. Got: {orig_time}. Defaulting `orig_time` to None.",
RuntimeWarning,
)
self.onset, self.duration, self.description, self.ch_names, self._extras = (
_check_o_d_s_c_e(onset, duration, description, ch_names, extras)
)
Expand Down Expand Up @@ -615,7 +630,7 @@ def to_data_frame(self, time_format="datetime"):
dt = _handle_meas_date(0)
time_format = _check_time_format(time_format, valid_time_formats, dt)
dt = dt.replace(tzinfo=None)
times = _convert_times(self.onset, time_format, dt)
times = _convert_times(self.onset, time_format, meas_date=dt, drop_nano=True)
df = dict(onset=times, duration=self.duration, description=self.description)
if self._any_ch_names():
df.update(ch_names=self.ch_names)
Expand Down Expand Up @@ -1486,7 +1501,13 @@ def _read_annotations_csv(fname):
"onsets in seconds."
)
except ValueError:
pass
# remove nanoseconds for ISO8601 (microsecond) compliance
timestamp = pd.Timestamp(orig_time)
timespec = "microseconds"
if timestamp == pd.Timestamp(_handle_meas_date(0)).astimezone(None):
timespec = "auto" # use default timespec for `orig_time=None`
orig_time = timestamp.isoformat(sep=" ", timespec=timespec)

onset_dt = pd.to_datetime(df["onset"])
onset = (onset_dt - onset_dt[0]).dt.total_seconds()
duration = df["duration"].values.astype(float)
Expand Down
2 changes: 1 addition & 1 deletion mne/epochs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2861,7 +2861,7 @@ def to_data_frame(
# prepare extra columns / multiindex
mindex = list()
times = np.tile(times, n_epochs)
times = _convert_times(times, time_format, self.info["meas_date"])
times = _convert_times(times, time_format, meas_date=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]]
Expand Down
2 changes: 1 addition & 1 deletion mne/evoked.py
Original file line number Diff line number Diff line change
Expand Up @@ -1392,7 +1392,7 @@ def to_data_frame(
data = _scale_dataframe_data(self, data, picks, scalings)
# prepare extra columns / multiindex
mindex = list()
times = _convert_times(times, time_format, self.info["meas_date"])
times = _convert_times(times, time_format, meas_date=self.info["meas_date"])
mindex.append(("time", times))
# build DataFrame
df = _build_data_frame(
Expand Down
5 changes: 4 additions & 1 deletion mne/io/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2472,7 +2472,10 @@ def to_data_frame(
# prepare extra columns / multiindex
mindex = list()
times = _convert_times(
times, time_format, self.info["meas_date"], self.first_time
times,
time_format,
meas_date=self.info["meas_date"],
first_time=self.first_time,
)
mindex.append(("time", times))
# build DataFrame
Expand Down
38 changes: 37 additions & 1 deletion mne/tests/test_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,35 @@ def test_broken_csv(tmp_path):
read_annotations(fname)


def test_nanosecond_in_times(tmp_path):
"""Test onsets with ns read correctly for csv and caught as init argument."""
pd = pytest.importorskip("pandas")

# Test bad format onset sanitised when loading from csv
onset = (
pd.Timestamp(_ORIG_TIME)
.astimezone(None)
.isoformat(sep=" ", timespec="nanoseconds")
)
content = f"onset,duration,description\n{onset},1.0,AA"
fname = tmp_path / "annotations_broken.csv"
with open(fname, "w") as f:
f.write(content)
annot = read_annotations(fname)
assert annot.orig_time == _ORIG_TIME

# Test bad format `orig_time` str -> `None` raises warning in `Annotation` init
with pytest.warns(
RuntimeWarning, match="The format of the `orig_time` string is not recognised."
):
bad_orig_time = (
pd.Timestamp(_ORIG_TIME)
.astimezone(None)
.isoformat(sep=" ", timespec="nanoseconds")
)
Annotations([0], [1], ["test"], bad_orig_time)


# Test for IO with .txt files


Expand Down Expand Up @@ -1564,7 +1593,8 @@ def test_repr():
@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")
pd = pytest.importorskip("pandas")

onset = np.arange(1, 10)
durations = np.full_like(onset, [4, 5, 6, 4, 5, 6, 4, 5, 6])
description = ["yy"] * onset.shape[0]
Expand All @@ -1584,6 +1614,12 @@ def test_annotation_to_data_frame(time_format):
assert want == got
assert df.groupby("description").count().onset["yy"] == 9

# Check nanoseconds omitted from onset times
if time_format == "datetime":
a.onset += 1e-7 # >6 decimals to trigger nanosecond component
df = a.to_data_frame(time_format=time_format)
assert pd.Timestamp(df.onset[0]).nanosecond == 0


def test_annotation_ch_names():
"""Test annotation ch_names updating and pruning."""
Expand Down
2 changes: 1 addition & 1 deletion mne/time_frequency/tfr.py
Original file line number Diff line number Diff line change
Expand Up @@ -2753,7 +2753,7 @@ def to_data_frame(
# prepare extra columns / multiindex
mindex = list()
default_index = list()
times = _convert_times(times, time_format, self.info["meas_date"])
times = _convert_times(times, time_format, meas_date=self.info["meas_date"])
times = np.tile(times, n_epochs * n_freqs * n_tapers)
freqs = np.tile(np.repeat(freqs, n_times), n_epochs * n_tapers)
mindex.append(("time", times))
Expand Down
9 changes: 8 additions & 1 deletion mne/utils/dataframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ def _scale_dataframe_data(inst, data, picks, scalings):
return data


def _convert_times(times, time_format, meas_date=None, first_time=0):
def _convert_times(
times, time_format, *, meas_date=None, first_time=0, drop_nano=False
):
"""Convert vector of time in seconds to ms, datetime, or timedelta."""
# private function; pandas already checked in calling function
from pandas import to_timedelta
Expand All @@ -47,6 +49,11 @@ def _convert_times(times, time_format, meas_date=None, first_time=0):
times = to_timedelta(times, unit="s")
elif time_format == "datetime":
times = to_timedelta(times + first_time, unit="s") + meas_date
if drop_nano:
tz_name = ""
if meas_date is not None and meas_date.tzinfo is not None:
tz_name = f", {meas_date.tzinfo.tzname(meas_date)}" # timezone as str
times = times.astype(f"datetime64[us{tz_name}]") # cap at microseconds
return times


Expand Down