Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ENH: Add time_format to Raw.plot() #9419

Merged
merged 51 commits into from
Jun 9, 2021
Merged
Show file tree
Hide file tree
Changes from 47 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
f13a26c
Add show_real_time to Raw.plot()
marsipu May 21, 2021
eb2860f
Fixed bugs and added warning for invalid datetime-format-strings.
marsipu May 22, 2021
185dbef
Add to latest.inc
marsipu May 24, 2021
2834e0f
Update docstrings
marsipu May 24, 2021
b1d601d
Update docstring in mne/viz/ica.py
marsipu May 24, 2021
2b064eb
Remove dashes
marsipu May 24, 2021
a1beeca
Update ica docstring
marsipu May 24, 2021
9c98119
Add requested changes
marsipu May 24, 2021
356bb1c
Fix too long line
marsipu May 24, 2021
39a00d6
Rename parameter and improve accuracy
marsipu May 26, 2021
60ac0f5
Add toggle-behaviour
marsipu May 26, 2021
e7d3280
Fix default value.
marsipu May 26, 2021
ab28e21
Introduce small offset in favor of visualization.
marsipu May 27, 2021
6560808
Fix flake issues.
marsipu May 27, 2021
d173094
Remove offset and trailing zeros
marsipu May 27, 2021
a62a590
Set time-accuracy depending on duration
marsipu May 29, 2021
1104635
Change to trim-value
marsipu May 30, 2021
633cabb
Fix ms/us heuristic for zero-values and add trailing-zero-removal
marsipu Jun 1, 2021
ad9bcf7
Fix unused import
marsipu Jun 1, 2021
ae21c12
Remove every trailing zero at the end
marsipu Jun 2, 2021
a111c5a
Update mne/viz/_figure.py
marsipu Jun 2, 2021
306b6f4
Apply suggestions from code review
marsipu Jun 2, 2021
45f020e
Add comment to decimal-acquisition for clarification
marsipu Jun 2, 2021
da98c3d
Simplify code
marsipu Jun 2, 2021
f8b2d52
Add suggestions(style, docstring)
marsipu Jun 2, 2021
b916411
Improve docstring
marsipu Jun 2, 2021
e4e31a2
Add WIP Test
marsipu Jun 2, 2021
e9e7148
Show hscroll without microseconds
marsipu Jun 2, 2021
fee9b9b
Fix wrong argument in test
marsipu Jun 2, 2021
7827943
Fix test docstring
marsipu Jun 2, 2021
c908905
Keep zoom-levels constant
marsipu Jun 2, 2021
1cbf22c
Push vline-text up
marsipu Jun 2, 2021
f4f83a4
fix line limit
marsipu Jun 2, 2021
97dfa0a
Avoid too long vline-text and unify format-check
marsipu Jun 3, 2021
8e466d2
Merge branch 'main' into x_time_ticks
marsipu Jun 3, 2021
846f0aa
Apply suggestions from code review
marsipu Jun 3, 2021
358a48e
better truncation threshold
drammock Jun 3, 2021
a8d905e
fix test
drammock Jun 3, 2021
45723b4
Include float and vline into format and increase vline-decimals
marsipu Jun 4, 2021
bc86348
Taring dynamic decimals to avoid unequal difference
marsipu Jun 4, 2021
c524586
better dur_delta + comments
drammock Jun 4, 2021
761fbe5
don't show help text for t if epochs
drammock Jun 4, 2021
a092e6b
simpler vline text placement
drammock Jun 4, 2021
a7cdd07
Merge branch 'main' into x_time_ticks
marsipu Jun 7, 2021
faf8cfa
Fix test_plot_annotations
marsipu Jun 7, 2021
abb8fcf
Fix test_scale_bar, test_plot_epochs_scale_bar
marsipu Jun 7, 2021
31cbc10
Merge branch 'main' into x_time_ticks
marsipu Jun 7, 2021
4e56b74
comments + code tweaks
drammock Jun 8, 2021
29b819f
Fix off-by-one-error
marsipu Jun 9, 2021
d9166e3
Add additional sample to times plotted
marsipu Jun 9, 2021
ef29892
Fix test
marsipu Jun 9, 2021
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
4 changes: 3 additions & 1 deletion doc/changes/latest.inc
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,12 @@ Enhancements
- Add support for exporting MFF evoked files using `mne.export.export_evokeds` and `mne.export.export_evokeds_mff` (:gh:`9406` by `Evan Hathaway`_)

- :func:`mne.concatenate_raws`, :func:`mne.concatenate_epochs`, and func:`mne.write_evokeds` gained a new parameter ``on_mismatch``, which controls behavior in case not all of the supplied instances share the same device-to-head transformation (:gh:`9438` by `Richard Höchenberger`_)

- Add support for multiple datablocks (acquistions with pauses) in :func:`mne.io.read_raw_nihon` (:gh:`9437` by `Federico Raimondo`_)

- Add new function :func:`mne.preprocessing.annotate_break` to automatically detect and mark "break" periods without any marked experimental events in the continuous data (:gh:`9445` by `Richard Höchenberger`_)

- Add "time_format" to :meth:`mne.io.Raw.plot` and :meth:`mne.preprocessing.ICA.plot_sources` to display acquisition time on x-axis (:gh:`9419` by `Martin Schulz`_)

Bugs
~~~~
Expand Down
10 changes: 6 additions & 4 deletions mne/io/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1492,14 +1492,16 @@ def plot(self, events=None, duration=10.0, start=0.0, n_channels=20,
highpass=None, lowpass=None, filtorder=4, clipping=_RAW_CLIP_DEF,
show_first_samp=False, proj=True, group_by='type',
butterfly=False, decim='auto', noise_cov=None, event_id=None,
show_scrollbars=True, show_scalebars=True, verbose=None):
show_scrollbars=True, show_scalebars=True, time_format='float',
verbose=None):
return plot_raw(self, events, duration, start, n_channels, bgcolor,
color, bad_color, event_color, scalings, remove_dc,
order, show_options, title, show, block, highpass,
lowpass, filtorder, clipping, show_first_samp, proj,
group_by, butterfly, decim, noise_cov=noise_cov,
lowpass, filtorder, clipping, show_first_samp,
proj, group_by, butterfly, decim, noise_cov=noise_cov,
marsipu marked this conversation as resolved.
Show resolved Hide resolved
event_id=event_id, show_scrollbars=show_scrollbars,
show_scalebars=show_scalebars, verbose=verbose)
show_scalebars=show_scalebars,
time_format=time_format, verbose=verbose)

@verbose
@copy_function_doc_to_method_doc(plot_raw_psd)
Expand Down
6 changes: 4 additions & 2 deletions mne/preprocessing/ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -1839,11 +1839,13 @@ def plot_properties(self, inst, picks=None, axes=None, dB=True,
@copy_function_doc_to_method_doc(plot_ica_sources)
def plot_sources(self, inst, picks=None, start=None,
stop=None, title=None, show=True, block=False,
show_first_samp=False, show_scrollbars=True):
show_first_samp=False, show_scrollbars=True,
time_format='float'):
return plot_ica_sources(self, inst=inst, picks=picks,
start=start, stop=stop, title=title, show=show,
block=block, show_first_samp=show_first_samp,
show_scrollbars=show_scrollbars)
show_scrollbars=show_scrollbars,
time_format=time_format)

@copy_function_doc_to_method_doc(plot_ica_scores)
def plot_scores(self, scores, exclude=None, labels=None, axhline=None,
Expand Down
10 changes: 10 additions & 0 deletions mne/utils/docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1366,6 +1366,16 @@
.. versionadded:: 0.19.0
"""

docdict['time_format'] = """
time_format : 'float' | 'clock'
Style of time labels on the horizontal axis. If ``'float'``, labels will be
number of seconds from the start of the recording. If ``'clock'``,
labels will show "clock time" (hours/minutes/seconds) inferred from
``raw.info['meas_date']``. Default is ``'float'``.

.. versionadded:: 0.24
"""

# PSD plotting
docdict["plot_psd_doc"] = """
Plot the power spectral density across channels.
Expand Down
79 changes: 71 additions & 8 deletions mne/viz/_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
# Authors: Daniel McCloy <dan@mccloy.info>
#
# License: Simplified BSD

import datetime
from contextlib import contextmanager
import platform
from copy import deepcopy
Expand Down Expand Up @@ -447,6 +447,17 @@ def __init__(self, inst, figsize, ica=None, xlabel='Time (s)', **kwargs):
# hide some ticks
ax_main.tick_params(axis='x', which='major', bottom=False)
ax_hscroll.tick_params(axis='x', which='both', bottom=False)
else:
marsipu marked this conversation as resolved.
Show resolved Hide resolved
# RAW / ICA X-AXIS TICK & LABEL FORMATTING
ax_main.xaxis.set_major_formatter(
FuncFormatter(partial(self._xtick_formatter,
ax_type='main')))
ax_hscroll.xaxis.set_major_formatter(
FuncFormatter(partial(self._xtick_formatter,
ax_type='hscroll')))
if self.mne.time_format != 'float':
for _ax in (ax_main, ax_hscroll):
_ax.set_xlabel('Time (HH:MM:SS)')

# VERTICAL SCROLLBAR PATCHES (COLORED BY CHANNEL TYPE)
ch_order = self.mne.ch_order
Expand Down Expand Up @@ -490,8 +501,9 @@ def __init__(self, inst, figsize, ica=None, xlabel='Time (s)', **kwargs):
vline = ax_main.axvline(0, color=vline_color, **vline_kwargs)
vline_hscroll = ax_hscroll.axvline(0, color=vline_color,
**vline_kwargs)
vline_text = ax_hscroll.text(
self.mne.first_time, 1.2, '', fontsize=10, ha='right', va='bottom',
vline_text = ax_main.annotate(
'', xy=(0, 0), xycoords='axes fraction', xytext=(-2, 0),
textcoords='offset points', fontsize=10, ha='right', va='center',
color=vline_color, **vline_kwargs)

# HELP BUTTON: initialize in the wrong spot...
Expand Down Expand Up @@ -695,16 +707,21 @@ def _keypress(self, event):
self._redraw(annotations=True)
# change duration
elif key in ('home', 'end'):
old_dur = self.mne.duration
dur_delta = 1 if key == 'end' else -1
if self.mne.is_epochs:
# prevent from showing zero epochs, or more epochs than we have
self.mne.n_epochs = np.clip(self.mne.n_epochs + dur_delta,
1, len(self.mne.inst))
# use the length of one epoch as duration change
min_dur = len(self.mne.inst.times) / self.mne.info['sfreq']
dur_delta *= min_dur
new_dur = self.mne.duration + dur_delta * min_dur
else:
# never show fewer than 3 samples
min_dur = 3 * np.diff(self.mne.inst.times[:2])[0]
old_dur = self.mne.duration
new_dur = self.mne.duration + dur_delta
# use multiplicative dur_delta
dur_delta = 5 / 4 if dur_delta > 0 else 4 / 5
new_dur = self.mne.duration * dur_delta
self.mne.duration = np.clip(new_dur, min_dur, last_time)
if self.mne.duration != old_dur:
if self.mne.t_start + self.mne.duration > last_time:
Expand Down Expand Up @@ -743,6 +760,8 @@ def _keypress(self, event):
elif key == 'z': # zen mode: hide scrollbars and buttons
self._toggle_scrollbars()
self._redraw(update_data=False)
elif key == 't':
self._toggle_time_format()
else: # check for close key / fullscreen toggle
super()._keypress(event)

Expand Down Expand Up @@ -1039,6 +1058,7 @@ def _get_help_text(self):
('p', 'Toggle draggable annotations' if is_raw else None),
('s', 'Toggle scalebars' if not is_ica else None),
('z', 'Toggle scrollbars'),
('t', 'Toggle time format' if not is_epo else None),
('F11', 'Toggle fullscreen' if not is_mac else None),
('?', 'Open this help window'),
('esc', 'Close focused figure or dialog window'),
Expand Down Expand Up @@ -1775,7 +1795,7 @@ def _check_update_vscroll_clicked(self, event):
return False

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# SCALEBARS & Y-AXIS LABELS
# SCALEBARS & AXIS LABELS
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #

def _show_scalebars(self):
Expand Down Expand Up @@ -1854,6 +1874,48 @@ def _update_yaxis_labels(self):
else 'normal')
text.set_style(sty)

def _xtick_formatter(self, x, pos=None, ax_type='main'):
"""Change the x-axis labels."""
tickdiff = np.diff(self.mne.ax_main.get_xticks())[0]
digits = np.ceil(-np.log10(tickdiff) + 1).astype(int)
# Increase decimals of vline by 2
# (showing milliseconds at max. zoom-level)
if ax_type == 'vline':
digits += 2
if self.mne.time_format == 'float':
if ax_type == 'hscroll' or int(x) == x:
return int(x)
rounded_x = round(x, digits or None)
if ax_type == 'vline':
return f'{rounded_x} s'
return rounded_x
meas_date = self.mne.inst.info['meas_date']
first_time = datetime.timedelta(seconds=self.mne.inst.first_time)
xtime = datetime.timedelta(seconds=x)
xdatetime = meas_date + first_time + xtime
xdtstr = xdatetime.strftime('%H:%M:%S')
if digits and ax_type != 'hscroll' and int(xdatetime.microsecond) != 0:
xdtstr += f'{round(xdatetime.microsecond * 1e-6, digits)}'[1:]
return xdtstr

def _toggle_time_format(self):
if self.mne.time_format == 'float':
self.mne.time_format = 'clock'
x_axis_label = 'Time (HH:MM:SS)'
else:
self.mne.time_format = 'float'
x_axis_label = 'Time (s)'

# Change x-axis label
for _ax in (self.mne.ax_main, self.mne.ax_hscroll):
_ax.set_xlabel(x_axis_label)

self._redraw(update_data=False, annotations=False)

# Update vline-text if displayed
if self.mne.vline is not None and self.mne.vline.get_visible():
self._show_vline(self.mne.vline.get_xdata())

# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
# DATA TRACES
# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
Expand Down Expand Up @@ -2204,7 +2266,8 @@ def _show_vline(self, xdata):
else:
self.mne.vline.set_xdata(xdata)
self.mne.vline_hscroll.set_xdata(xdata)
self.mne.vline_text.set_text(f'{xdata:0.2f} s ')
text = self._xtick_formatter(xdata, ax_type='vline')[:12]
self.mne.vline_text.set_text(text)
self._toggle_vline(True)

def _toggle_vline(self, visible):
Expand Down
1 change: 1 addition & 0 deletions mne/viz/epochs.py
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,7 @@ def plot_epochs(epochs, picks=None, scalings=None, n_epochs=20, n_channels=20,
duration=duration,
n_times=n_times,
first_time=0,
time_format='float',
decim=decim,
boundary_times=boundary_times,
# events
Expand Down
10 changes: 7 additions & 3 deletions mne/viz/ica.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
@fill_doc
def plot_ica_sources(ica, inst, picks=None, start=None,
stop=None, title=None, show=True, block=False,
show_first_samp=False, show_scrollbars=True):
show_first_samp=False, show_scrollbars=True,
time_format='float'):
"""Plot estimated latent sources given the unmixing matrix.

Typical usecases:
Expand Down Expand Up @@ -60,6 +61,7 @@ def plot_ica_sources(ica, inst, picks=None, start=None,
show_first_samp : bool
If True, show time axis relative to the ``raw.first_samp``.
%(show_scrollbars)s
%(time_format)s

Returns
-------
Expand All @@ -85,7 +87,8 @@ def plot_ica_sources(ica, inst, picks=None, start=None,
fig = _plot_sources(ica, inst, picks, exclude, start=start, stop=stop,
show=show, title=title, block=block,
show_first_samp=show_first_samp,
show_scrollbars=show_scrollbars)
show_scrollbars=show_scrollbars,
time_format=time_format)
elif isinstance(inst, Evoked):
if start is not None or stop is not None:
inst = inst.copy().crop(start, stop)
Expand Down Expand Up @@ -923,7 +926,7 @@ def _plot_ica_overlay_evoked(evoked, evoked_cln, title, show):


def _plot_sources(ica, inst, picks, exclude, start, stop, show, title, block,
show_scrollbars, show_first_samp):
show_scrollbars, show_first_samp, time_format):
"""Plot the ICA components as a RawArray or EpochsArray."""
from ._figure import _browse_figure
from .. import EpochsArray, BaseEpochs
Expand Down Expand Up @@ -1033,6 +1036,7 @@ def _plot_sources(ica, inst, picks, exclude, start, stop, show, title, block,
duration=duration,
n_times=inst.n_times if is_raw else n_times,
first_time=first_time,
time_format=time_format,
decim=1,
# events
event_times=None if is_raw else event_times,
Expand Down
10 changes: 6 additions & 4 deletions mne/viz/raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20,
event_color='cyan', scalings=None, remove_dc=True, order=None,
show_options=False, title=None, show=True, block=False,
highpass=None, lowpass=None, filtorder=4,
clipping=_RAW_CLIP_DEF,
show_first_samp=False, proj=True, group_by='type',
butterfly=False, decim='auto', noise_cov=None, event_id=None,
show_scrollbars=True, show_scalebars=True, verbose=None):
clipping=_RAW_CLIP_DEF, show_first_samp=False,
proj=True, group_by='type', butterfly=False, decim='auto',
noise_cov=None, event_id=None, show_scrollbars=True,
show_scalebars=True, time_format='float', verbose=None):
"""Plot raw data.

Parameters
Expand Down Expand Up @@ -162,6 +162,7 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20,
Whether or not to show the scale bars. Defaults to True.

.. versionadded:: 0.20.0
%(time_format)s
%(verbose)s

Returns
Expand Down Expand Up @@ -311,6 +312,7 @@ def plot_raw(raw, events=None, duration=10.0, start=0.0, n_channels=20,
duration=duration,
n_times=raw.n_times,
first_time=first_time,
time_format=time_format,
decim=decim,
# events
event_color_dict=event_color_dict,
Expand Down
5 changes: 3 additions & 2 deletions mne/viz/tests/test_epochs.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ def test_plot_epochs_scale_bar(epochs):
fig = epochs.plot()
fig.canvas.key_press_event('s') # default is to not show scalebars
ax = fig.mne.ax_main
assert len(ax.texts) == 2 # only mag & grad in this instance
# only empty vline-text, mag & grad in this instance
assert len(ax.texts) == 3
texts = tuple(t.get_text().strip() for t in ax.texts)
wants = ('800.0 fT/cm', '2000.0 fT')
wants = ('', '800.0 fT/cm', '2000.0 fT')
assert texts == wants


Expand Down
23 changes: 18 additions & 5 deletions mne/viz/tests/test_raw.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ def _annotation_helper(raw, events=False):
data_ax = fig.mne.ax_main
fig.canvas.key_press_event('a') # annotation mode
assert len(plt.get_fignums()) == 2
# +2 from the scale bars
n_scale = 2
# +3 from the scale bars
n_scale = 3
assert len(data_ax.texts) == n_anns + n_events + n_scale
# modify description to create label "BAD test"
ann_fig = fig.mne.fig_annotation
Expand Down Expand Up @@ -206,9 +206,9 @@ def test_scale_bar():
raw = RawArray(data, info)
fig = raw.plot()
ax = fig.mne.ax_main
assert len(ax.texts) == 3 # our labels
assert len(ax.texts) == 4 # empty vline-text + ch_type scale-bars
texts = tuple(t.get_text().strip() for t in ax.texts)
wants = ('800.0 fT/cm', '2000.0 fT', '40.0 µV')
wants = ('', '800.0 fT/cm', '2000.0 fT', '40.0 µV')
assert texts == wants
assert len(ax.lines) == 7 # 1 green vline, 3 data, 3 scalebars
for data, bar in zip(fig.mne.traces, fig.mne.scalebars.values()):
Expand Down Expand Up @@ -361,7 +361,8 @@ def test_plot_raw_keypresses(raw):
# test twice → once in normal, once in butterfly view.
# NB: keys a, j, and ? are tested in test_plot_raw_child_figures()
keys = ('pagedown', 'down', 'up', 'down', 'right', 'left', '-', '+', '=',
'd', 'd', 'pageup', 'home', 'end', 'z', 'z', 's', 's', 'f11', 'b')
'd', 'd', 'pageup', 'home', 'end', 'z', 'z', 's', 's', 'f11', 'b',
't')
# test for group_by='original'
for key in 2 * keys + ('escape',):
fig.canvas.key_press_event(key)
Expand Down Expand Up @@ -731,3 +732,15 @@ def test_scalings_int():
"""Test that auto scalings access samples using integers."""
raw = RawArray(np.zeros((1, 500)), create_info(1, 1000., 'eeg'))
raw.plot(scalings='auto')


@pytest.mark.parametrize('dur, n_dec', [(20, 1), (4.5, 2), (0.01, 4)])
def test_clock_xticks(raw, dur, n_dec):
"""Test if decimal seconds of xticks have appropriate length."""
fig = raw.plot(duration=dur, time_format='clock')
fig.canvas.draw()
ticklabels = fig.mne.ax_main.get_xticklabels()
tick_texts = [tl.get_text() for tl in ticklabels]
assert tick_texts[0].startswith('19:01:53')
if len(tick_texts[0].split('.')) > 1:
assert len(tick_texts[0].split('.')[1]) == n_dec