From c48eb12e5ea643e22ab34cbacd37ba311961e6a7 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Tue, 21 Jul 2020 13:21:54 +0200 Subject: [PATCH 01/24] Fix min/max chan amps for all-positive(/neg) data if a channel's min and max values are the same sign, the definition of minimum and maximum displayed values was previously improper --- visbrain/gui/sleep/interface/ui_elements/ui_panels.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py index 8278e8a32..e30e8ac73 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py @@ -241,16 +241,16 @@ def _fcn_chan_check_and_create_w(self): # Add ymin spinbox : self._yminSpin[i] = QtWidgets.QDoubleSpinBox(self._PanScrollChan) self._yminSpin[i].setDecimals(1) - self._yminSpin[i].setMinimum(10. * self['min'][i]) - self._yminSpin[i].setMaximum(10. * self['max'][i]) + self._yminSpin[i].setMinimum(-10. * abs(self['min'][i])) + self._yminSpin[i].setMaximum(10. * abs(self['max'][i])) self._yminSpin[i].setProperty("value", -int(fact * self['std'][i])) self._yminSpin[i].setSingleStep(1.) self._PanChanLay.addWidget(self._yminSpin[i], i, 3, 1, 1) # Add ymax spinbox : self._ymaxSpin[i] = QtWidgets.QDoubleSpinBox(self._PanScrollChan) self._ymaxSpin[i].setDecimals(1) - self._ymaxSpin[i].setMinimum(10. * self['min'][i]) - self._ymaxSpin[i].setMaximum(10. * self['max'][i]) + self._ymaxSpin[i].setMinimum(- 10. * abs(self['min'][i])) + self._ymaxSpin[i].setMaximum(10. * abs(self['max'][i])) self._ymaxSpin[i].setSingleStep(1.) self._ymaxSpin[i].setProperty("value", int(fact * self['std'][i])) self._PanChanLay.addWidget(self._ymaxSpin[i], i, 4, 1, 1) From c6e0b7960d8c921e3a321f52034fa7c968f79e07 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Fri, 29 Jan 2021 16:22:55 +0100 Subject: [PATCH 02/24] Load and check states_config_file (incomplete) Save values as attributes in sleep as: self._hstates = hstates self._hvalues = hvalues self._hcolors = hcolors self._horder = horder self._hshortcuts = hshortcuts --- visbrain/gui/sleep/sleep.py | 19 ++++++----- visbrain/io/read_sleep.py | 58 ++++++++++++++-------------------- visbrain/io/read_states_cfg.py | 58 ++++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 42 deletions(-) create mode 100644 visbrain/io/read_states_cfg.py diff --git a/visbrain/gui/sleep/sleep.py b/visbrain/gui/sleep/sleep.py index 04aee4d77..96bcf8bb1 100644 --- a/visbrain/gui/sleep/sleep.py +++ b/visbrain/gui/sleep/sleep.py @@ -52,9 +52,12 @@ class Sleep(_PyQtModule, ReadSleepData, UiInit, Visuals, UiElements, axis : bool | False Specify if each axis have to contains its own axis. Be carefull with this option, the rendering can be much slower. - href : list | ['art', 'wake', 'rem', 'n1', 'n2', 'n3'] - List of sleep stages. This list can be used to changed the display - order into the GUI. + states_config_file : string | #TODO + Path to the configuration file (.json) describing the vigilance states + and associated value, color, shortcut, and display order. Refer to + #TODO for details on expected format. Default configuration file + #contains the following states: ['art', 'wake', 'rem', 'n1', 'n2', + #'n3'] preload : bool | True Preload data into memory. For large datasets, turn this parameter to True. @@ -80,8 +83,8 @@ class Sleep(_PyQtModule, ReadSleepData, UiInit, Visuals, UiElements, def __init__(self, data=None, hypno=None, config_file=None, annotations=None, channels=None, sf=None, downsample=100., - axis=True, href=['art', 'wake', 'rem', 'n1', 'n2', 'n3'], - preload=True, use_mne=False, kwargs_mne={}, verbose=None): + axis=True, states_config_file=None, preload=True, + use_mne=False, kwargs_mne={}, verbose=None): """Init.""" _PyQtModule.__init__(self, verbose=verbose, icon='sleep_icon.svg') # ====================== APP CREATION ====================== @@ -95,9 +98,9 @@ def __init__(self, data=None, hypno=None, config_file=None, # ====================== LOAD FILE ====================== PROFILER("Import file", as_type='title') - ReadSleepData.__init__(self, data, channels, sf, hypno, href, preload, - use_mne, downsample, kwargs_mne, - annotations) + ReadSleepData.__init__(self, data, channels, sf, hypno, + states_config_file, preload, use_mne, + downsample, kwargs_mne, annotations) # ====================== VARIABLES ====================== # Check all data : diff --git a/visbrain/io/read_sleep.py b/visbrain/io/read_sleep.py index 47b39687e..f776c2fda 100644 --- a/visbrain/io/read_sleep.py +++ b/visbrain/io/read_sleep.py @@ -21,6 +21,7 @@ from visbrain.io.mneio import mne_switch from visbrain.io.rw_hypno import (read_hypno, oversample_hypno) from visbrain.io.rw_utils import get_file_ext +from visbrain.io.read_states_cfg import load_states_cfg from visbrain.io.write_data import write_csv from visbrain.io import merge_annotations @@ -38,8 +39,8 @@ class ReadSleepData(object): """Main class for reading sleep data.""" - def __init__(self, data, channels, sf, hypno, href, preload, use_mne, - downsample, kwargs_mne, annotations): + def __init__(self, data, channels, sf, hypno, states_config_file, preload, + use_mne, downsample, kwargs_mne, annotations): """Init.""" # ========================== LOAD DATA ========================== # Dialog window if data is None : @@ -116,6 +117,16 @@ def __init__(self, data, channels, sf, hypno, href, preload, use_mne, time = np.arange(n)[::dsf] / sf self._sf = float(downsample) if downsample is not None else float(sf) + # ---------- LOAD STATES CONFIG ---------- + states_cfg = load_states_cfg(states_config_file) + hstates = list(states_cfg.keys()) + hvalues, hcolors, hYranks, hshortcuts = zip(*[ + [ + states_cfg[state][field] + for field in ['value', 'color', 'display_order', 'shortcut'] + ] for state in hstates + ]) + # ========================== LOAD HYPNOGRAM ========================== # Dialog window for hypnogram : if hypno is None: @@ -149,41 +160,16 @@ def __init__(self, data, channels, sf, hypno, href, preload, use_mne, "channel names will be used instead.") channels = ['chan' + str(k) for k in range(nchan)] - # ---------- STAGE ORDER ---------- - # href checking : - absref = ['art', 'wake', 'n1', 'n2', 'n3', 'rem'] - absint = [-1, 0, 1, 2, 3, 4] - if href is None: - href = absref - elif (href is not None) and isinstance(href, list): - # Force lower case : - href = [k.lower() for k in href] - # Check that all stage are present : - for k in absref: - if k not in href: - raise ValueError(k + " not found in href.") - # Force capitalize : - href = [k.capitalize() for k in href] - href[href.index('Rem')] = 'REM' - else: - raise ValueError("The href parameter must be a list of string and" - " must contain 'art', 'wake', 'n1', 'n2', 'n3' " - "and 'rem'") - # Conversion variable : - absref = ['Art', 'Wake', 'N1', 'N2', 'N3', 'REM'] - conv = {absint[absref.index(k)]: absint[i] for i, k in enumerate(href)} - # ---------- HYPNOGRAM ---------- if hypno is None: hypno = np.zeros((npts,), dtype=np.float32) else: n = len(hypno) - # Check hypno values : - if (hypno.min() < -1.) or (hypno.max() > 4) or (n != npts): - warn("\nHypnogram values must be comprised between -1 and 4 " - "(see Iber et al. 2007). Use:\n-1 -> Art (optional)\n 0 " - "-> Wake\n 1 -> N1\n 2 -> N2\n 3 -> N4\n 4 -> REM\nEmpty " - "hypnogram will be used instead") + # Check all hypno values are recognized + if not all([v in hvalues for v in np.unique(hypno)]): + warn("\nSome hypnogram values are not recognized. Check your " + f"states config: {states_cfg}.\n\n" + "Empty hypnogram will be used instead") hypno = np.zeros((npts,), dtype=np.float32) # ---------- SCALING ---------- @@ -204,8 +190,12 @@ def __init__(self, data, channels, sf, hypno, href, preload, use_mne, self._hypno = vispy_array(hypno) self._time = vispy_array(time) self._channels = channels - self._href = href - self._hconv = conv + self._hstates = np.array(hstates) # Names + self._hvalues = np.array(hvalues) + self._hcolors = np.array(hcolors) + self._hshortcuts = np.array(hshortcuts) + self._hYranks = np.array(hYranks) # Display order on hyp (top to bottm) + self._hYrankperm = np.argsort(hYranks) # Display index to real index PROFILER("Check data", level=1) diff --git a/visbrain/io/read_states_cfg.py b/visbrain/io/read_states_cfg.py new file mode 100644 index 000000000..5f705fd2a --- /dev/null +++ b/visbrain/io/read_states_cfg.py @@ -0,0 +1,58 @@ +"""Load and check states config dictionary.""" + +from .rw_config import load_config_json + + +DF_STATES_CFG = { + "Art": { + "color": "black", + "shortcut": "a", + "value": -1, + "display_order": 0, + }, + "Wake": { + "color": "black", + "shortcut": "w", + "value": 0, + "display_order": 1, + }, + "REM": { + "color": "green", + "shortcut": "r", + "value": 4, + "display_order": 2, + }, + "N1": { + "color": "red", + "shortcut": "1", + "value": 1, + "display_order": 5, + }, + "N2": { + "color": "blue", + "shortcut": "2", + "value": 2, + "display_order": 3, + }, + "N3": { + "color": "blue", + "shortcut": "2", + "value": 3, + "display_order": 4, + }, +} + + +def load_states_cfg(states_config_file): + if states_config_file is None: + return DF_STATES_CFG + cfg = load_config_json(states_config_file) + return check_states_cfg(cfg) + + +def check_states_cfg(states_cfg): + # TODO + # Unique states: + if not len(set(states_cfg.keys())) == len(states_cfg.keys()): + raise ValueError(f"Duplicate keys in states cfg dict: {states_cfg}") + return states_cfg From 8c31e7037d1bb2b0e771bd6f4b41c134a461ea07 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Fri, 29 Jan 2021 18:36:20 +0100 Subject: [PATCH 03/24] Update Hypnogram object to use new state cfg --- .../sleep/interface/ui_elements/ui_menu.py | 6 +- .../sleep/interface/ui_elements/ui_panels.py | 3 +- .../interface/ui_elements/ui_settings.py | 3 +- visbrain/gui/sleep/sleep.py | 10 -- visbrain/gui/sleep/visuals/visuals.py | 119 +++++++++--------- visbrain/io/read_sleep.py | 8 +- 6 files changed, 72 insertions(+), 77 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_menu.py b/visbrain/gui/sleep/interface/ui_elements/ui_menu.py index 20e362ab6..73afee37c 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_menu.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_menu.py @@ -574,8 +574,10 @@ def _disptog_zoom(self): self._spec.freq[-1] - self._spec.freq[0]) self._specInd.mesh.visible = self.menuDispIndic.isChecked() # Hypnogram camera : - self._hypcam.rect = (self._time.min(), -5., - self._time.max() - self._time.min(), 7.) + self._hypcam.rect = (self._time.min(), + -len(self._hvalues), + self._time.max() - self._time.min(), + len(self._hvalues) + 1) # Time camera : self._timecam.rect = (self._time.min(), 0., self._time.max() - self._time.min(), 1.) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py index e30e8ac73..3b4895ded 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py @@ -112,8 +112,9 @@ def __init__(self): self._hypLabel = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout(self._hypLabel) layout.setContentsMargins(0, 0, 0, 0) + # TODO: Set smaller margins if many states self._hypYLabels = [] - for k in [''] + self._href + ['']: + for k in [''] + list(self._hstates[self._hYrankperm]) + ['']: label = QtWidgets.QLabel() label.setText(self._addspace + k) label.setFont(self._font) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py index 477da6365..c9efde629 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py @@ -241,7 +241,8 @@ def _fcn_slider_move(self): if iszoom: xlim_diff = xlim[1] - xlim[0] # Histogram : - self._hypcam.rect = (xlim[0], -5, xlim_diff, 7.) + self._hypcam.rect = (xlim[0], -len(self._hvalues), + xlim_diff, len(self._hvalues) + 1) # Spectrogram : self._speccam.rect = (xlim[0], self._spec.freq[0], xlim_diff, self._spec.freq[-1] - self._spec.freq[0]) diff --git a/visbrain/gui/sleep/sleep.py b/visbrain/gui/sleep/sleep.py index 96bcf8bb1..818a14502 100644 --- a/visbrain/gui/sleep/sleep.py +++ b/visbrain/gui/sleep/sleep.py @@ -106,7 +106,6 @@ def __init__(self, data=None, hypno=None, config_file=None, # Check all data : self._config_file = config_file self._annot_mark = np.array([]) - self._hconvinv = {v: k for k, v in self._hconv.items()} self._ax = axis # ---------- Default line width ---------- self._lw = 1. @@ -115,15 +114,6 @@ def __init__(self, data=None, hypno=None, config_file=None, self._defstd = 5. # ---------- Default colors ---------- self._chancolor = '#292824' - # self._hypcolor = '#292824' - # Hypnogram color : - self._hypcolor = {-1: '#8bbf56', 0: '#56bf8b', 1: '#aabcce', - 2: '#405c79', 3: '#0b1c2c', 4: '#bf5656'} - # Convert color : - if self._hconv != self._hconvinv: - hypc = self._hypcolor.copy() - for k in self._hconv.keys(): - self._hypcolor[k] = hypc[self._hconvinv[k]] self._indicol = '#e74c3c' # Default spectrogram colormap : self._defcmap = 'viridis' diff --git a/visbrain/gui/sleep/visuals/visuals.py b/visbrain/gui/sleep/visuals/visuals.py index 31d26fd6e..f5d2f8b51 100644 --- a/visbrain/gui/sleep/visuals/visuals.py +++ b/visbrain/gui/sleep/visuals/visuals.py @@ -3,19 +3,21 @@ This file contains and initialize visual objects (channel plot, spectrogram, hypnogram, indicator, shortcuts) """ -import numpy as np -import scipy.signal as scpsig import itertools import logging -from vispy import scene +import numpy as np +import scipy.signal as scpsig +from scipy.stats import rankdata + import vispy.visuals.transforms as vist +from visbrain.config import PROFILER +from visbrain.utils import PrepareData, cmap_to_glsl, color2vb +from visbrain.utils.sleep.event import _index_to_events +from visbrain.visuals import TFmapsMesh, TopoMesh +from vispy import scene from .marker import Markers -from visbrain.utils import (color2vb, PrepareData, cmap_to_glsl) -from visbrain.utils.sleep.event import _index_to_events -from visbrain.visuals import TopoMesh, TFmapsMesh -from visbrain.config import PROFILER logger = logging.getLogger('visbrain') @@ -586,19 +588,30 @@ def interp(self, value): class Hypnogram(object): """Create a hypnogram object.""" - def __init__(self, time, camera, color='#292824', width=2., parent=None, - hconv=None): + def __init__(self, time, camera, width=2., hcolors=None, hvalues=None, + hYranks=None, parent=None): # Keep camera : self._camera = camera + # Display Y position for each of the vigilance state values: + # 0 (first, top) to -(n-1) (last, bottom) + self.hYpos = { + value: -float(rank) + for value, rank in zip( + hvalues, + rankdata(hYranks) - 1 # rankdata is 1-indexed + ) + } + # Camera rectangle. yPos are 0 to -(nstates - 1) self._rect = (0., 0., 0., 0.) - self.rect = (time.min(), -5., time.max() - time.min(), 7.) + self.rect = (time.min(), -len(hvalues), + time.max() - time.min(), len(hvalues) + 1) self.width = width self.n = len(time) - self._hconv = hconv - self._hconvinv = {v: k for k, v in self._hconv.items()} - # Get color : - self.color = {k: color2vb(color=i) for k, i in zip(color.keys(), - color.values())} + # Color for each of the vigilance state value + self.hcolors = { + value: color2vb(color=col) + for value, col in zip(hvalues, hcolors) + } # Display color per vigilance state: {int: (1,4)-nparray} # Create a default line : pos = np.array([[0, 0], [0, 100]]) self.mesh = scene.visuals.Line(pos, name='hypnogram', method='gl', @@ -623,7 +636,7 @@ def __len__(self): # ------------------------------------------------------------------------- # SETTING METHODS # ------------------------------------------------------------------------- - def set_data(self, sf, data, time, convert=True): + def set_data(self, sf, data, time): """Set data to the hypnogram. Parameters @@ -631,48 +644,38 @@ def set_data(self, sf, data, time, convert=True): sf: float The sampling frequency. data: array_like - The data to send. Must be a row vector. + Vigilance state values to sent. Must be a row vector. time: array_like The time vector - convert : bool | True - Specify if hypnogram data have to be converted. """ - # Hypno conversion : - if (self._hconv != self._hconvinv) and convert: - data = self.hyp_to_gui(data) - # Build color array : - color = np.zeros((len(data), 4), dtype=np.float32) - for k, v in zip(self.color.keys(), self.color.values()): - # Set the stage color : - color[data == k, :] = v - # Avoid gradient color : - color[1::, :] = color[0:-1, :] + data_pos = np.array([self.hYpos[v] for v in data]) + # Build color array: (nsamples, 4)-nparray + data_colors = np.array([self.hcolors[v] for v in data]).squeeze() # Set data to the mesh : - self.mesh.set_data(pos=np.vstack((time, -data)).T, width=self.width, - color=color) + self.mesh.set_data(pos=np.vstack((time, data_pos)).T, + width=self.width, color=data_colors) self.mesh.update() - def set_stage(self, stfrom, stend, stage): - """Add a stage in a specific interval. + def set_state(self, stfrom, stend, value): + """Add a vigilance state in a specific interval. - This method only set the stage without updating the entire + This method only set the vigilance state without updating the entire hypnogram. Parameters ---------- stfrom : int - The index where the stage start. + The index where the state start. stend : int - The index where the stage end. - stage : int - Stage value. + The index where the state end. + value : int + State value. """ - # Convert the stage : - stagec = self._hconv[stage] + state_ypos = self.hYpos[value] # Update color : - self.mesh.color[stfrom + 1:stend + 1, :] = self.color[stagec] + self.mesh.color[stfrom + 1:stend + 1, :] = self.hcolors[value] # Only update the needed part : - self.mesh.pos[stfrom:stend, 1] = -float(stagec) + self.mesh.pos[stfrom:stend, 1] = state_ypos self.mesh.update() def set_grid(self, time, length=30., y=1.): @@ -687,7 +690,7 @@ def set_grid(self, time, length=30., y=1.): # CONVERSION METHODS # ------------------------------------------------------------------------- def hyp_to_gui(self, data): - """Convert hypnogram data to the GUI. + """Convert hypnogram data to Y position on the GUI Parameters ---------- @@ -696,19 +699,12 @@ def hyp_to_gui(self, data): Returns ------- - datac : array_like - Converted data + data_rank : array_like """ - # Backup copy : - datac = data.copy() - data = np.zeros_like(datac) - # Fill new data : - for k in self._hconv.keys(): - data[datac == k] = self._hconv[k] - return data + return np.array([self.hYpos[v] for v in data]) def gui_to_hyp(self): - """Convert GUI hypnogram into data. + """Convert GUI hypnogram Y positions into hypnogram state values. Returns ------- @@ -716,12 +712,12 @@ def gui_to_hyp(self): The converted data. """ # Get latest data version : - datac = -self.mesh.pos[:, 1] - data = np.zeros_like(datac) - # Fill new data : - for k in self._hconvinv.keys(): - data[datac == k] = self._hconvinv[k] - return data + data_ypos = self.mesh.pos[:, 1] + # Value from Y position + hYpos_inv = { + pos: value for value, pos in self.hYpos.items() + } + return np.array([hYpos_inv[r] for r in data_ypos]) def clean(self, sf, time): """Clean indicators.""" @@ -1159,8 +1155,9 @@ def __init__(self): # =================== HYPNOGRAM =================== # Create a hypnogram object : - self._hyp = Hypnogram(time, camera=cameras[2], color=self._hypcolor, - width=self._lwhyp, hconv=self._hconv, + self._hyp = Hypnogram(time, camera=cameras[2], width=self._lwhyp, + hcolors=self._hcolors, hvalues=self._hvalues, + hYranks=self._hYranks, parent=self._hypCanvas.wc.scene) self._hyp.set_data(sf, hypno, time) PROFILER('Hypnogram', level=1) diff --git a/visbrain/io/read_sleep.py b/visbrain/io/read_sleep.py index f776c2fda..a3a657a18 100644 --- a/visbrain/io/read_sleep.py +++ b/visbrain/io/read_sleep.py @@ -14,7 +14,7 @@ import datetime import numpy as np -from scipy.stats import iqr +from scipy.stats import iqr, rankdata from visbrain.io.dependencies import is_mne_installed from visbrain.io.dialog import dialog_load @@ -194,7 +194,11 @@ def __init__(self, data, channels, sf, hypno, states_config_file, preload, self._hvalues = np.array(hvalues) self._hcolors = np.array(hcolors) self._hshortcuts = np.array(hshortcuts) - self._hYranks = np.array(hYranks) # Display order on hyp (top to bottm) + # Display order on hyp from top (0) to bottom (nstates - 1) + self._hYranks = np.array([ + int(rank - 1) + for rank in rankdata(np.array(hYranks)) + ]) # -1 because 1-indexed after rankdata() self._hYrankperm = np.argsort(hYranks) # Display index to real index PROFILER("Check data", level=1) From 75803d4ce0579146c1eb2b51faf19b15f4400fd5 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Thu, 4 Feb 2021 14:01:05 +0100 Subject: [PATCH 04/24] Update ui_panels and ui_settings for flex states config --- .../sleep/interface/ui_elements/ui_panels.py | 14 +++--- .../interface/ui_elements/ui_settings.py | 45 ++++++++++--------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py index 3b4895ded..4aecad24a 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py @@ -495,15 +495,17 @@ def _fcn_set_hypno_lw(self): def _fcn_set_hypno_color(self): """Change the color of the hypnogram.""" if not(self._PanHypnoColor.isChecked()): + # TODO color = {-1: '#292824', 0: '#292824', 1: '#292824', 2: '#292824', 3: '#292824', 4: '#292824'} else: - color = self._hypcolor - # Get color : - zp = zip(color.keys(), color.values()) - self._hyp.color = {k: color2vb(color=i) for k, i in zp} - # Update hypnogram - self._hyp.set_data(self._sf, self._hypno, self._time) + color = self._hcolors + # TODO + # # Get color : + # zp = zip(color.keys(), color.values()) + # self._hyp.color = {k: color2vb(color=i) for k, i in zp} + # # Update hypnogram + # self._hyp.set_data(self._sf, self._hypno, self._time) def _fcn_hypno_clean(self): """Clean the hypnogram.""" diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py index c9efde629..0ecfb6354 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py @@ -39,7 +39,7 @@ def __init__(self): self._slTxtFormat = ( "Window : [ {start} ; {end} ] {unit} || " + "Scoring : [ {start_scor} ; {end_scor} ] {unit} || " + - "Sleep stage : {conv}" + "Vigilance state : {state}" ) # Absolute time : self._slAbsTime.clicked.connect(self._fcn_slider_move) @@ -86,26 +86,27 @@ def data_index(self, xlim): @property def _hypref(self): - """Return ref value of "current" stage.""" + """Return ref value of "current" vigilance state.""" # "Current" is at start of scoring window xlim_scor = self._xlim_scor t = self.data_index(xlim_scor) return int(self._hypno[t[0]]) @property - def _hypconv(self): - """Return converted value of "current" stage.""" - return self._hconv[self._hypref] + def _state_yrank(self): + """Return y rank of "current" vigilance state.""" + return self._hYranks[np.where(self._hvalues == self._hypref)[0]][0] @property - def _stage_name(self): - """Return name of "current" stage.""" - return str(self._hypYLabels[self._hypconv + 2].text()) + def _state_name(self): + """Return name of "current" vigilance state.""" + return self._hstates[np.where(self._hvalues == self._hypref)[0]][0] @property - def _stage_color(self): - """Return color of "current" stage.""" - return self._hypcolor[self._hypconv] + def _state_color(self): + """Return color of "current" vigilance state.""" + return self._hcolors[np.where(self._hvalues == self._hypref)[0]][0] + # ===================================================================== # SLIDER, DISPLAY WINDOW AND SCORING WINDOW @@ -114,8 +115,8 @@ def _update_text_info(self): """Redraw the text info in the settings pane.""" xlim = self._xlim xlim_scor = self._xlim_scor - stage = self._stage_name - hypcol = self._stage_color + state = self._state_name + hypcol = self._state_color # Get unit and convert: if self._slAbsTime.isChecked(): xlim = np.asarray(xlim) + self._toffset @@ -129,7 +130,7 @@ def _update_text_info(self): xlim_scor[1])).split(' ')[1] txt = "Window : [ " + start + " ; " + stend + " ] || " + \ "Scoring : [ " + start_scor + " ; " + stend_scor + " ] || " + \ - "Sleep stage : " + stage + "Vigilance state : " + state else: unit = self._slRules.currentText() if unit == 'seconds': @@ -145,7 +146,7 @@ def _update_text_info(self): start=str(xconv[0]), end=str(xconv[1]), start_scor=str(xconv_scor[0]), end_scor=str(xconv_scor[1]), unit=unit, - conv=stage) + state=state) # Set text : self._SlText.setText(txt) self._SlText.setFont(self._font) @@ -187,8 +188,8 @@ def _fcn_slider_move(self): # Find closest data time index for display window start/end t = self.data_index(xlim) # Hypnogram info : - hypconv = self._hypconv - hypcol = self._stage_color + hypYrank = self._state_yrank + hypcol = self._state_color # ================= MESH UPDATES ================= # --------------------------------------- @@ -257,8 +258,8 @@ def _fcn_slider_move(self): # ================= HYPNO LABELS ================= for k in self._hypYLabels: k.setStyleSheet("QLabel") - self._hypYLabels[hypconv + 2].setStyleSheet("QLabel {color: " + - hypcol + ";}") + self._hypYLabels[hypYrank + 1].setStyleSheet("QLabel {color: " + + hypcol + ";}") def _fcn_slider_settings(self): """Function applied to change slider settings.""" @@ -399,15 +400,15 @@ def on_mouse_wheel(self, event): # ===================================================================== # HYPNO # ===================================================================== - def _add_stage_on_scorwin(self, stage): + def _add_stage_on_scorwin(self, state): """Change the stage on the current scoring window.""" # Get the scoring window xlim xlim_scor = self._xlim_scor # Find closest data time index from xlim t = self.data_index(xlim_scor) # Set the stage : - self._hypno[t[0]:t[1]] = stage - self._hyp.set_stage(t[0], t[1], stage) + self._hypno[t[0]:t[1]] = state + self._hyp.set_state(t[0], t[1], state) # # Update info table : self._fcn_info_update() # Update scoring table : From ecedebb36927c6c93d1d1e7ea838f56adb177bc4 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Thu, 4 Feb 2021 19:35:22 +0100 Subject: [PATCH 05/24] Use user-defined shortcuts for scoring --- .../interface/ui_elements/ui_settings.py | 3 +- visbrain/gui/sleep/visuals/visuals.py | 113 +++++++++--------- 2 files changed, 57 insertions(+), 59 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py index 0ecfb6354..55c5ba350 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py @@ -412,7 +412,8 @@ def _add_stage_on_scorwin(self, state): # # Update info table : self._fcn_info_update() # Update scoring table : - self._fcn_hypno_to_score() + # TODO + # self._fcn_hypno_to_score() # self._fcn_score_to_hypno() # ===================================================================== diff --git a/visbrain/gui/sleep/visuals/visuals.py b/visbrain/gui/sleep/visuals/visuals.py index f5d2f8b51..f2811341d 100644 --- a/visbrain/gui/sleep/visuals/visuals.py +++ b/visbrain/gui/sleep/visuals/visuals.py @@ -921,34 +921,50 @@ class CanvasShortcuts(object): def __init__(self, canvas): """Init.""" - self.sh = [('n', 'Go to the next window'), - ('b', 'Go to the previous window'), - ('-', 'Decrease amplitude'), - ('+', 'Increase amplitude'), - ('s', 'Display / hide spectrogram'), - ('t', 'Display / hide topoplot'), - ('h', 'Display / hide hypnogram'), - ('p', 'Display / hide navigation bar'), - ('x', 'Display / hide time axis'), - ('g', 'Display / hide time grid'), - ('z', 'Enable / disable zooming'), - ('i', 'Enable / disable indicators'), - ('a', 'Scoring: set current window to Art (-1)'), - ('w', 'Scoring: set current window to Wake (0)'), - ('1', 'Scoring: set current window to N1 (1)'), - ('2', 'Scoring: set current window to N2 (2)'), - ('3', 'Scoring: set current window to N3 (3)'), - ('r', 'Scoring: set current window to REM (4)'), - ('Double clik', 'Insert annotation'), - ('CTRL + left click', 'Magnify signal under the cursor'), - ('CTRL + Num', 'Display the channel Num'), - ('CTRL + s', 'Save hypnogram'), - ('CTRL + t', 'Display shortcuts'), - ('CTRL + e', 'Display documentation'), - ('CTRL + d', 'Display / hide setting panel'), - ('CTRL + n', 'Take a screenshot'), - ('CTRL + q', 'Close Sleep graphical interface'), - ] + # Hardcoded shortcuts + sh = [ + ('n', 'Go to the next window'), + ('b', 'Go to the previous window'), + ('-', 'Decrease amplitude'), + ('+', 'Increase amplitude'), + ('s', 'Display / hide spectrogram'), + ('t', 'Display / hide topoplot'), + ('h', 'Display / hide hypnogram'), + ('p', 'Display / hide navigation bar'), + ('x', 'Display / hide time axis'), + ('g', 'Display / hide time grid'), + ('z', 'Enable / disable zooming'), + ('i', 'Enable / disable indicators'), + ('Double clik', 'Insert annotation'), + ('CTRL + left click', 'Magnify signal under the cursor'), + ('CTRL + Num', 'Display the channel Num'), + ('CTRL + s', 'Save hypnogram'), + ('CTRL + t', 'Display shortcuts'), + ('CTRL + e', 'Display documentation'), + ('CTRL + d', 'Display / hide setting panel'), + ('CTRL + n', 'Take a screenshot'), + ('CTRL + q', 'Close Sleep graphical interface'), + ] + # Check and add flexible scoring shortcuts + other_sh = [s for s, _ in sh] + if any([scoring_sh in other_sh for scoring_sh in self._hshortcuts]): + raise ValueError( + "One of the user-defined shortcuts for scoring is reserved. " + "Please select another key in vigilance state config. \n\n" + f"User-defined scoring keys: {self._hshortcuts}\n" + f"Reserved keys: {other_sh}" + ) + if not all([len(sh) == 1 for sh in self._hshortcuts]): + raise ValueError( + "Please use only simple keys as scoring shortcuts. \n\n" + f"User-defined scoring keys: {self._hshortcuts}\n" + ) + self.sh = [ + (f"'{sh}'", f"Scoring: set current window to {state} ({value})") + for sh, state, value in zip( + self._hshortcuts, self._hstates, self._hvalues + ) + ] + sh # Add shortcuts to vbCanvas : @canvas.events.key_press.connect @@ -992,36 +1008,17 @@ def on_key_press(event): self._fcn_grid_toggle() # ------------ SCORING ------------ - elif event.text.lower() == 'a': # Art - self._add_stage_on_scorwin(-1) - self._SlGoto.setValue(self._SlGoto.value( - ) + self._SigSlStep.value()) - logger.info("Art stage inserted") - elif event.text.lower() == 'w': # Wake - self._add_stage_on_scorwin(0) - self._SlGoto.setValue(self._SlGoto.value( - ) + self._SigSlStep.value()) - logger.info("Wake stage inserted") - elif event.text == '1': - self._add_stage_on_scorwin(1) - self._SlGoto.setValue(self._SlGoto.value( - ) + self._SigSlStep.value()) - logger.info("N1 stage inserted") - elif event.text == '2': - self._add_stage_on_scorwin(2) - self._SlGoto.setValue(self._SlGoto.value( - ) + self._SigSlStep.value()) - logger.info("N2 stage inserted") - elif event.text == '3': - self._add_stage_on_scorwin(3) - self._SlGoto.setValue(self._SlGoto.value( - ) + self._SigSlStep.value()) - logger.info("N3 stage inserted") - elif event.text.lower() == 'r': - self._add_stage_on_scorwin(4) - self._SlGoto.setValue(self._SlGoto.value( - ) + self._SigSlStep.value()) - logger.info("REM stage inserted") + else: + for sh, state, value in zip( + self._hshortcuts, self._hstates, self._hvalues + ): + if event.text.lower() == sh.lower(): + self._add_stage_on_scorwin(value) + self._SlGoto.setValue(self._SlGoto.value( + ) + self._SigSlStep.value()) + logger.info( + f"`{state}` vigilance state inserted ({value})" + ) @canvas.events.mouse_release.connect def on_mouse_release(event): From bd3456b0e801379282ee4112ac6ebd7de211d1d8 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Thu, 4 Feb 2021 21:01:11 +0100 Subject: [PATCH 06/24] Update scor to and from table for flex state --- .../sleep/interface/ui_elements/ui_scoring.py | 55 +++++++++++-------- .../interface/ui_elements/ui_settings.py | 3 +- 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_scoring.py b/visbrain/gui/sleep/interface/ui_elements/ui_scoring.py index d2afda2b2..7324d0728 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_scoring.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_scoring.py @@ -25,42 +25,46 @@ def _fcn_hypno_to_score(self): self._hypno = self._hyp.gui_to_hyp() # Avoid updating data while setting cell : self._scoreSet = False - items = ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art'] # Remove every info in the table : self._scoreTable.setRowCount(0) # Find unit conversion : fact = self._get_fact_from_unit() - # Find transients : - _, idx, stages = transient(self._hypno, self._time / fact) + # Find transient's index, state value and state label + _, idx, state_values = transient(self._hypno, self._time / fact) + labels_map = { + value: lbl for value, lbl in zip(self._hvalues, self._hstates) + } + state_labels = [labels_map[value] for value in state_values] idx = np.round(10. * idx) / 10. # Set length of the table : - self._scoreTable.setRowCount(len(stages)) + self._scoreTable.setRowCount(len(state_values)) # Fill the table : - for k in range(len(stages)): + for k in range(len(state_values)): # Add stage start / end : self._scoreTable.setItem(k, 0, QtWidgets.QTableWidgetItem( str(idx[k, 0]))) self._scoreTable.setItem(k, 1, QtWidgets.QTableWidgetItem( str(idx[k, 1]))) - # Add stage : + # Add state : self._scoreTable.setItem(k, 2, QtWidgets.QTableWidgetItem( - items[stages[k]])) + state_labels[k])) self._scoreSet = True def _fcn_score_to_hypno(self): """Update hypno data from hypno score.""" if self._scoreSet: - # Reset hypnogram : - self._hypno = np.zeros((len(self._time)), dtype=np.float32) + # Reset hypnogram (not directly to avoid losing data if failure) + hypno = np.zeros((len(self._time)), dtype=np.float32) # Loop over table row : for k in range(self._scoreTable.rowCount()): # Get tstart / tend / stage : - tstart, tend, stage = self._get_score_marker(k) + tstart, tend, value = self._get_score_marker(k) # Update pos if not None : if tstart is not None: - self._hypno[tstart:tend] = stage - self._hyp.set_stage(tstart, tend, stage) + hypno[tstart:tend] = value + self._hyp.set_state(tstart, tend, value) self._hyp.edit.update() + self._hypno = hypno # Update sleep info : self._fcn_info_update() @@ -81,10 +85,13 @@ def _get_score_marker(self, idx): Time start (in sample) tend : float Time end (in sample). - stage : int - The stage. + value : int + The state value """ - it = {'art': -1., 'wake': 0., 'n1': 1., 'n2': 2., 'n3': 3., 'rem': 4.} + state_values_map = { + lbl.lower(): value + for lbl, value in zip(self._hstates, self._hvalues) + } # Get unit : fact = self._get_fact_from_unit() # Get selected row : @@ -93,23 +100,27 @@ def _get_score_marker(self, idx): # Define error message if bad editing : errmsg = "\nTable score error. Starting and ending time must be " + \ "float numbers (with time start < time end) and stage " + \ - "must be Wake, N1, N2, N3, REM or Art" + f"must be in {self._hstates}" # Get row data and update if possible: tstart_item = self._scoreTable.item(idx, 0) tend_item = self._scoreTable.item(idx, 1) - stage_item = self._scoreTable.item(idx, 2) - if tstart_item and tend_item and stage_item: + state_item = self._scoreTable.item(idx, 2) + if tstart_item and tend_item and state_item: # ============= PROPER FORMAT ============= - if all([bool(str(tstart_item.text())), bool(str(tend_item.text())), - str(stage_item.text()).lower() in it.keys()]): + if all([ + bool(str(tstart_item.text())), + bool(str(tend_item.text())), + str(state_item.text()).lower() in [st.lower() + for st in self._hstates] + ]): try: # Get start / end / stage : tstart = int(float(str(tstart_item.text( ))) * fact * self._sf) tend = int(float(str(tend_item.text())) * fact * self._sf) - stage = it[str(stage_item.text()).lower()] + value = state_values_map[str(state_item.text()).lower()] - return tstart, tend, stage + return tstart, tend, value except: raise ValueError(errmsg) else: diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py index 55c5ba350..0ecfb6354 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_settings.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_settings.py @@ -412,8 +412,7 @@ def _add_stage_on_scorwin(self, state): # # Update info table : self._fcn_info_update() # Update scoring table : - # TODO - # self._fcn_hypno_to_score() + self._fcn_hypno_to_score() # self._fcn_score_to_hypno() # ===================================================================== From 35ab9afe013e66981341ca1b71c2b8cdf83f17a5 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Fri, 5 Feb 2021 16:40:35 +0100 Subject: [PATCH 07/24] Update hypnoprocessing and info table --- .../sleep/interface/ui_elements/ui_info.py | 3 +- visbrain/utils/sleep/hypnoprocessing.py | 35 ++++++++++--------- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_info.py b/visbrain/gui/sleep/interface/ui_elements/ui_info.py index e00efc9ca..56e4e1135 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_info.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_info.py @@ -17,7 +17,8 @@ def _fcn_info_update(self): """Complete the table sleep info.""" table = self._infoTable # Get sleep stats : - stats = sleepstats(self._hyp.gui_to_hyp(), self._sf) + stats = sleepstats(self._hyp.gui_to_hyp(), self._sf, + self._hstates, self._hvalues) # Add global informations to stats dict is_file = isinstance(self._file, str) diff --git a/visbrain/utils/sleep/hypnoprocessing.py b/visbrain/utils/sleep/hypnoprocessing.py index 1741308f1..ec0a46b34 100644 --- a/visbrain/utils/sleep/hypnoprocessing.py +++ b/visbrain/utils/sleep/hypnoprocessing.py @@ -23,15 +23,15 @@ def transient(data, xvec=None): st : array_like Either the transient index (as type int) if xvec is None, or the converted version if xvec is not None. - stages : array_like - The stages for each segment. + values : array_like + The vigilance state value for each segment. """ # Transient detection : t = list(np.nonzero(np.abs(data[:-1] - data[1:]))[0]) # Add first and last points : idx = np.vstack((np.array([-1] + t) + 1, np.array(t + [len(data) - 1]))).T - # Get stages : - stages = data[idx[:, 0]] + # Get state values : + states = data[idx[:, 0]] # Convert (if needed) : if (xvec is not None) and (len(xvec) == len(data)): st = idx.copy().astype(float) @@ -40,10 +40,10 @@ def transient(data, xvec=None): else: st = idx - return np.array(t), st, stages.astype(int) + return np.array(t), st, states.astype(int) -def sleepstats(hypno, sf_hyp): +def sleepstats(hypno, sf_hyp, hstates, hvalues): """Compute sleep stats from an hypnogram vector. Sleep statistics specifications: @@ -78,6 +78,10 @@ def sleepstats(hypno, sf_hyp): Hypnogram vector sf_hyp : float The sampling frequency of the hypnogram + hstates: list + List of vigilance state labels + hvalues: list + List of vigilance state values in hypnogram Returns ------- @@ -94,19 +98,18 @@ def sleepstats(hypno, sf_hyp): stats['TDT'] = np.where(hypno != 0)[0].max() if np.nonzero( hypno)[0].size else tov + state_values = { + label: value for label, value in zip(hstates, hvalues) + } + # Duration of each sleep stages - stats['Art'] = hypno[hypno == -1].size - stats['W'] = hypno[hypno == 0].size - stats['N1'] = hypno[hypno == 1].size - stats['N2'] = hypno[hypno == 2].size - stats['N3'] = hypno[hypno == 3].size - stats['REM'] = hypno[hypno == 4].size + for label, value in state_values.items(): + stats[label] = hypno[hypno == value].size # Sleep stage latencies - stats['LatN1'] = np.where(hypno == 1)[0].min() if 1 in hypno else tov - stats['LatN2'] = np.where(hypno == 2)[0].min() if 2 in hypno else tov - stats['LatN3'] = np.where(hypno == 3)[0].min() if 3 in hypno else tov - stats['LatREM'] = np.where(hypno == 4)[0].min() if 4 in hypno else tov + for label, value in state_values.items(): + stats[f'Lat{label}'] = \ + np.where(hypno == value)[0].min() if value in hypno else tov if not np.isnan(stats['LatN1']) and not np.isnan(stats['TDT']): hypno_s = hypno[stats['LatN1']:stats['TDT']] From 9163a2f3e372022b538f7924504c9cea304f07b2 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Fri, 5 Feb 2021 17:15:49 +0100 Subject: [PATCH 08/24] Connect hypno color modification --- .../sleep/interface/ui_elements/ui_panels.py | 21 ++++++++++--------- visbrain/gui/sleep/visuals/visuals.py | 19 +++++++++++++++-- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py index 4aecad24a..ed4900b57 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py @@ -495,17 +495,18 @@ def _fcn_set_hypno_lw(self): def _fcn_set_hypno_color(self): """Change the color of the hypnogram.""" if not(self._PanHypnoColor.isChecked()): - # TODO - color = {-1: '#292824', 0: '#292824', 1: '#292824', - 2: '#292824', 3: '#292824', 4: '#292824'} + hcolors = { + hvalue: color2vb('#292824') + for hvalue in self._hvalues + } else: - color = self._hcolors - # TODO - # # Get color : - # zp = zip(color.keys(), color.values()) - # self._hyp.color = {k: color2vb(color=i) for k, i in zp} - # # Update hypnogram - # self._hyp.set_data(self._sf, self._hypno, self._time) + hcolors = { + hvalue: color2vb(hcolor) + for hvalue, hcolor in zip(self._hvalues, self._hcolors) + } + # Set new color map and redraw + self._hyp.hcolors = hcolors + def _fcn_hypno_clean(self): """Clean the hypnogram.""" diff --git a/visbrain/gui/sleep/visuals/visuals.py b/visbrain/gui/sleep/visuals/visuals.py index f2811341d..382c61cfd 100644 --- a/visbrain/gui/sleep/visuals/visuals.py +++ b/visbrain/gui/sleep/visuals/visuals.py @@ -608,7 +608,7 @@ def __init__(self, time, camera, width=2., hcolors=None, hvalues=None, self.width = width self.n = len(time) # Color for each of the vigilance state value - self.hcolors = { + self._hcolors = { value: color2vb(color=col) for value, col in zip(hvalues, hcolors) } # Display color per vigilance state: {int: (1,4)-nparray} @@ -728,7 +728,7 @@ def clean(self, sf, time): posedit = np.full((1, 3), -10., dtype=np.float32) self.edit.set_data(pos=posedit, face_color='gray') - # ----------- RECT ----------- + # ----------- Properties ----------- @property def rect(self): """Get the rect value.""" @@ -740,6 +740,21 @@ def rect(self, value): self._rect = value self._camera.rect = value + @property + def hcolors(self): + """Return {value: (4,1)-array} hypnogram colors map.""" + return self._hcolors + + @hcolors.setter + def hcolors(self, value): + """Set new {value: (4,1)-array} hypnogram colors map and redraw hyp""" + self._hcolors = value + # Redraw hypnogram + hdata = self.gui_to_hyp() # (nsamples,1) array + data_colors = np.array([self.hcolors[v] for v in hdata]).squeeze() + self.mesh.set_data(color=data_colors) + self.mesh.update() + """ ############################################################################### From 64c33aeab131b6c5eed557c322bbc22dc75df57b Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Tue, 9 Feb 2021 14:58:22 +0100 Subject: [PATCH 09/24] Update `write_hypno` for flexible state cfg NB: hyp can only be saved in Elan format for the default states cfg --- .../sleep/interface/ui_elements/ui_menu.py | 3 +- visbrain/io/rw_hypno.py | 125 +++++++++++++++--- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_menu.py b/visbrain/gui/sleep/interface/ui_elements/ui_menu.py index 73afee37c..d3381af9a 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_menu.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_menu.py @@ -124,7 +124,8 @@ def saveHypData(self, *args, filename=None, reply=None): # noqa if isinstance(self._file, str): info['Datafile'] = self._file write_hypno(filename, self._hypno, version=version, sf=self._sfori, - npts=self._N, time=self._time, info=info) + npts=self._N, time=self._time, info=info, + hstates=self._hstates, hvalues=self._hvalues) def _save_hyp_fig(self, *args, filename=None, **kwargs): """Save a 600 dpi .png figure of the hypnogram.""" diff --git a/visbrain/io/rw_hypno.py b/visbrain/io/rw_hypno.py index e97d01254..6a7e484da 100644 --- a/visbrain/io/rw_hypno.py +++ b/visbrain/io/rw_hypno.py @@ -17,13 +17,16 @@ -> Tieme : -> .txt, .hyp """ -import os import logging +import os + import numpy as np -from ..utils.sleep.hypnoprocessing import transient -from ..utils.mesh import vispy_array +from PyQt5 import QtWidgets + from ..io import is_pandas_installed, is_xlrd_installed +from ..utils.mesh import vispy_array +from ..utils.sleep.hypnoprocessing import transient __all__ = ('oversample_hypno', 'write_hypno', 'read_hypno') @@ -36,7 +39,7 @@ ############################################################################### ############################################################################### -def hypno_time_to_sample(df, npts): +def hypno_time_to_sample(df, npts, hstates, hvalues): """Convert the hypnogram from a defined timings to a number of samples. Parameters @@ -46,6 +49,10 @@ def hypno_time_to_sample(df, npts): npts : int, array_like Number of time points in the final hypnogram. Alternatively, if npts is an array it will be interprated as the time vector. + hstates: list[str] + List of vigilance state labels. + hvalues: list[int] + Hypnogram value for each vigilance state. Returns ------- @@ -61,11 +68,9 @@ def hypno_time_to_sample(df, npts): df = df.iloc[drop_rows.astype(bool)] df.is_copy = False # avoid pandas warning # Replace text by numerical values : - to_replace = ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art'] - values = [0, 1, 2, 3, 4, -1] - df.replace(to_replace, values, inplace=True) - # Get stages and time index : - stages = np.array(df['Stage']).astype(str) + df.replace(hstates, hvalues, inplace=True) + # Get states and time index : + states = np.array(df['State']).astype(str) time_idx = np.array(df['Time']).astype(float) # Compute time vector and sampling frequency : if isinstance(npts, np.ndarray): @@ -84,11 +89,11 @@ def hypno_time_to_sample(df, npts): # Fill the hypnogram : hypno = np.zeros((len(time),), dtype=int) for k in range(len(index) - 1): - hypno[index[k]:index[k + 1]] = int(stages[k]) + hypno[index[k]:index[k + 1]] = int(states[k]) return hypno, time, sf_hyp -def hypno_sample_to_time(hypno, time): +def hypno_sample_to_time(hypno, time, hstates, hvalues): """Convert the hypnogram from a number of samples to a defined timings. Parameters @@ -97,6 +102,10 @@ def hypno_sample_to_time(hypno, time): Hypnogram data. time : array_like The time vector. + hstates: list[str] + List of vigilance state labels. + hvalues: list[int] + Hypnogram value for each vigilance state. Returns ------- @@ -107,10 +116,14 @@ def hypno_sample_to_time(hypno, time): is_pandas_installed(True) import pandas as pd # Transient detection : - _, tr, stages = transient(hypno, time) + _, tr, values = transient(hypno, time) + # Corresponding states + states_map = { + value: lbl for lbl, value in zip(hstates, hvalues) + } + states = np.array([states_map[value] for value in values]) # Save the hypnogram : - items = np.array(['Wake', 'N1', 'N2', 'N3', 'REM', 'Art']) - return pd.DataFrame({'Stage': items[stages], 'Time': tr[:, 1]}) + return pd.DataFrame({'State': states, 'Time': tr[:, 1]}) def oversample_hypno(hypno, n): @@ -150,8 +163,41 @@ def oversample_hypno(hypno, n): ############################################################################### ############################################################################### +def test_compatible_with_df_hyp(hstates, hvalues, test_equal=True): + """Test that hypnogram config compatible with Sleep's default. + + Default state config is { + 'Wake': 0, + 'N1': 1, + 'N2': 2, + 'N3': 3, + 'REM': 4, + 'Art': -1 + } + + Parameters + ---------- + hstates: list[str] + List of vigilance state labels + (default ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art']) + hvalues: list[int] + Hypnogram value for each vigilance state (default [0, 1, 2, 3, 4, -1]). + test_equal: bool + If true, we test for equality of state cfg. If false, we test that + the inputted config is a superset of the default config (ie, all + default keys are present with identical values) + """ + value_map = { + lbl: value for lbl, value in zip(hstates, hvalues) + } + df_map = {'Wake': 0, 'N1': 1, 'N2': 2, 'N3': 3, 'REM': 4, 'Art': -1} + if test_equal: + return value_map == df_map + return df_map.items() <= value_map.items() + + def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., - time=None, info=None): + time=None, info=None, hstates=None, hvalues=None): """Save hypnogram data. Parameters @@ -172,11 +218,26 @@ def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., The time vector. info : dict | None Additional informations to add to the file (prepend with *). + hstates: list[str] + List of vigilance state labels + (default ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art']) + hvalues: list[int] + Hypnogram value for each vigilance state (default [0, 1, 2, 3, 4, -1]). """ # Checking : assert isinstance(filename, str) assert isinstance(hypno, np.ndarray) assert version in ['time', 'sample'] + if hstates is None and hvalues is None: + hstates = ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art'] + hvalues = [0, 1, 2, 3, 4, -1] + else: + if not (hstates is not None and hvalues is not None): + raise ValueError( + "All or none of the `hstates` and `hvalues` kwargs should be " + "specified." + ) + assert len(hstates) == len(hvalues) # Extract file extension : _, ext = os.path.splitext(filename) # Switch between time and sample version : @@ -186,17 +247,30 @@ def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., hypno = hypno[::step].astype(int) # Export : if ext == '.txt': - _write_hypno_txt_sample(filename, hypno, window=window) + _write_hypno_txt_sample(filename, hypno, hstates, hvalues, + window=window) elif ext == '.hyp': - _write_hypno_hyp_sample(filename, hypno, sf=sf, npts=npts) + # Only for default hypnogram + if test_compatible_with_df_hyp(hstates, hvalues, test_equal=True): + _write_hypno_hyp_sample(filename, hypno, sf=sf, npts=npts) + else: + msg = (f"Elan `.hyp` hypnogram format is only available for " + f"Sleep's default vigilance state configuration: " + f"(['Art' (-1), 'Wake' (0), 'N1' (1), 'N2' (2), 'N3' " + f"(3), 'REM' (4)]). Please try again using a different" + f" format.") + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() + raise ValueError(msg) elif version is 'time': # v2 = time # Get the DataFrame : - df = hypno_sample_to_time(hypno, time) + df = hypno_sample_to_time(hypno, time, hstates, hvalues) if isinstance(info, dict): is_pandas_installed(True) import pandas as pd info = {'*' + k: i for k, i in info.items()} - df_info = pd.DataFrame({'Stage': list(info.keys()), + df_info = pd.DataFrame({'State': list(info.keys()), 'Time': list(info.values())}) df = df_info.append(df) if ext in ['.txt', '.csv']: @@ -211,7 +285,7 @@ def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., logger.info("Hypnogram saved (%s)" % filename) -def _write_hypno_txt_sample(filename, hypno, window=1.): +def _write_hypno_txt_sample(filename, hypno, hstates, hvalues, window=1.): """Save hypnogram in txt file format (txt). Header is in file filename_description.txt @@ -222,6 +296,10 @@ def _write_hypno_txt_sample(filename, hypno, window=1.): Filename (with full path) of the file to save hypno : array_like Hypnogram array, same length as data + hstates: list[str] + List of vigilance state labels + hvalues: list[int] + Hypnogram value for each vigilance state window : float | 1 Time window (second) of each point in the hypno Default is one value per second @@ -236,8 +314,11 @@ def _write_hypno_txt_sample(filename, hypno, window=1.): np.savetxt(filename, hypno, fmt='%s') # Save header file - hdr = np.array([['time ' + str(window)], ['W 0'], ['N1 1'], ['N2 2'], - ['N3 3'], ['REM 4'], ['Art -1']]).flatten() + hdr = np.array([ + ['time ' + str(window)] + [ + f"{lbl} {value}" for lbl, value in zip(hstates, hvalues) + ] + ]).flatten() np.savetxt(descript, hdr, fmt='%s') From 029023b9e9ff3d455082904b3a9cc1a2b68a1d71 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Wed, 10 Feb 2021 14:21:18 +0100 Subject: [PATCH 10/24] Update read_hypno methods for flexible state cfg EDF+ (`.edf`/`.txt`) and Elan (`.hyp`) style hypnograms can only be loaded with Sleep's default configuration. EDF-style formatting for .txt files is detected from the presence of the following state mapping ('W': 5.0, 'N1': 3.0, 'N2': 2.0, 'N3': 1.0, 'N4': 0.0, 'REM': 4.0) in the hypnogram metadata For all other hypnogram formats ( `.txt`, `.csv`, ...), we check before loading that the loaded hypnogram's states are exist in Sleep's current vigilance state configuration. --- .../sleep/interface/ui_elements/ui_menu.py | 4 +- visbrain/io/read_sleep.py | 4 +- visbrain/io/rw_hypno.py | 164 ++++++++++++++++-- 3 files changed, 155 insertions(+), 17 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_menu.py b/visbrain/gui/sleep/interface/ui_elements/ui_menu.py index d3381af9a..78bc06da6 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_menu.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_menu.py @@ -322,7 +322,9 @@ def _load_hypno(self, *args, filename=None): "All files (*.*)") if filename: # Load the hypnogram : - self._hypno, _ = read_hypno(filename, time=self._time) + self._hypno, _ = read_hypno(filename, time=self._time, + hstates=self._hstates, + hvalues=self._hvalues) self._hypno = oversample_hypno(self._hypno, self._N)[::self._dsf] self._hyp.set_data(self._sf, self._hypno, self._time) # Update info table : diff --git a/visbrain/io/read_sleep.py b/visbrain/io/read_sleep.py index a3a657a18..35f369458 100644 --- a/visbrain/io/read_sleep.py +++ b/visbrain/io/read_sleep.py @@ -142,7 +142,9 @@ def __init__(self, data, channels, sf, hypno, states_config_file, preload, raise ValueError("Then length of the hypnogram must be the " "same as raw data") if isinstance(hypno, str): # (*.hyp / *.txt / *.csv) - hypno, _ = read_hypno(hypno, time=time, datafile=file) + hypno, _ = read_hypno(hypno, time=time, datafile=file, + hstates=np.array(hstates), + hvalues=np.array(hvalues)) # Oversample then downsample : hypno = oversample_hypno(hypno, self._N)[::dsf] PROFILER("Hypnogram file loaded", level=1) diff --git a/visbrain/io/rw_hypno.py b/visbrain/io/rw_hypno.py index 6a7e484da..06caead04 100644 --- a/visbrain/io/rw_hypno.py +++ b/visbrain/io/rw_hypno.py @@ -45,7 +45,7 @@ def hypno_time_to_sample(df, npts, hstates, hvalues): Parameters ---------- df : pandas.DataFrame - The data frame that contains timing. + The data frame that contains timing. Columns = ['State', 'Time'] npts : int, array_like Number of time points in the final hypnogram. Alternatively, if npts is an array it will be interprated as the time vector. @@ -64,7 +64,7 @@ def hypno_time_to_sample(df, npts, hstates, hvalues): Sampling frequency of the hypnogram. """ # Drop lines that contains * : - drop_rows = np.char.find(np.array(df['Stage']).astype(str), '*') + drop_rows = np.char.find(np.array(df['State']).astype(str), '*') df = df.iloc[drop_rows.astype(bool)] df.is_copy = False # avoid pandas warning # Replace text by numerical values : @@ -355,11 +355,16 @@ def _write_hypno_hyp_sample(filename, hypno, sf=100., npts=1): ############################################################################### -def read_hypno(filename, time=None, datafile=None): +def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): """Load hypnogram file. - Sleep stages in the hypnogram should be scored as follow - see Iber et al. 2007 + EDF+ (`.edf`/`.txt`) and Elan (`.hyp`) style hypnograms can only be loaded + with Sleep's default configuration. For all other hypnogram formats ( + `.txt`, `.csv`, ...), we check before loading that the loaded hypnogram's + states are exist in Sleep's current vigilance state configuration. + + By default, the vigilance states in the hypnogram are interpreted as + follows (see Iber et al. 2007): Wake: 0 N1: 1 @@ -376,6 +381,12 @@ def read_hypno(filename, time=None, datafile=None): The time vector (used to interpolate Excel files). datafile : string | None Filename (with full path) to the data file. + hstates: list[str] + List of vigilance state labels in Sleep GUI + (default ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art']) + hvalues: list[int] + Hypnogram value for each vigilance state in Sleep GUI (default + [0, 1, 2, 3, 4, -1]). Returns ------- @@ -384,30 +395,95 @@ def read_hypno(filename, time=None, datafile=None): sf_hyp: float The hypnogram original sampling frequency (Hz) """ + # Check states cfg + if hstates is None and hvalues is None: + hstates = ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art'] + hvalues = [0, 1, 2, 3, 4, -1] + else: + if not (hstates is not None and hvalues is not None): + raise ValueError( + "All or none of the `hstates` and `hvalues` kwargs should be " + "specified." + ) + assert len(hstates) == len(hvalues) + # Test if file exist : assert os.path.isfile(filename), "No hypnogram file %s" % filename # Extract file extension : file, ext = os.path.splitext(filename) + # Check we're using the default config for Elan and edf formats + if ext == '.hyp' or ext == '.edf': + # Only for default hypnogram config + if not test_compatible_with_df_hyp(hstates, hvalues, test_equal=True): + msg = (f"Elan `.hyp` and EDF+ `.edf` hypnogram formats can only " + f"be loaded with Sleep's default vigilance state " + f"configuration: (['Art' (-1), 'Wake' (0), 'N1' (1), " + f"'N2' (2), 'N3' (3), 'REM' (4)]). Please try again using " + f"the default config " + f"(`Sleep(.., states_config_file=None)`).\n\n" + f"Current config: {list(zip(hstates, hvalues))}.\n") + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() + raise ValueError(msg) # Load the hypnogram : - if ext == '.hyp': # v1 = ELAN + if ext == '.hyp': # v1 = Elan hypno, sf_hyp = _read_hypno_hyp_sample(filename) elif ext == '.edf': # v1 = EDF+ hypno, sf_hyp = _read_hypno_edf_sample(filename, datafile) elif ext in ['.txt', '.csv']: # [v1, v2] = TXT / CSV header = os.path.splitext(filename)[0] + '_description.txt' if os.path.isfile(header): # if there's a header -> v1 - hypno, sf_hyp = _read_hypno_txt_sample(filename) + # Check that the hyp was saved with a compatible + # state_label/state_value mapping while loading + hypno, sf_hyp = _read_hypno_txt_sample(filename, hstates=hstates, + hvalues=hvalues) else: # v2 + msg = (f"Error while loading hypnogram at `{filename}`: " + "Some hypnogram states present in file are absent in " + "Sleep's vigilance state config. Please check your " + "config and try again.\n\n" + "Unique states in file: {loaded_states}\n" + f"Current states config: {list(zip(hstates, hvalues))}") import pandas as pd df = pd.read_csv(filename, delim_whitespace=True, header=None, - names=['Stage', 'Time']) - hypno, _, sf_hyp = hypno_time_to_sample(df, len(time)) + names=['State', 'Time']) + # Check that all the hypnogram states are recognized + loaded_hyp_states = [ + state for state in df.State.unique() + if not state.startswith("*") + ] + if not all([state in hstates for state in loaded_hyp_states]): + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg.format( + **{'loaded_states': loaded_hyp_states} + )) + msgBox.exec() + raise ValueError(msg.format( + **{'loaded_states': loaded_hyp_states} + )) + hypno, _, sf_hyp = hypno_time_to_sample(df, len(time), hstates, + hvalues) elif ext == '.xlsx': # v2 = Excel import pandas as pd - df = pd.read_excel(filename, header=None, names=['Stage', 'Time']) - hypno, _, sf_hyp = hypno_time_to_sample(df, len(time)) + df = pd.read_excel(filename, header=None, names=['State', 'Time']) + loaded_hyp_states = [ + state for state in df.State.unique() + if not state.startswith("*") + ] + if not all([state in hstates for state in loaded_hyp_states]): + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg.format( + **{'loaded_states': loaded_hyp_states} + )) + msgBox.exec() + raise ValueError(msg.format( + **{'loaded_states': loaded_hyp_states} + )) + hypno, _, sf_hyp = hypno_time_to_sample(df, len(time), hstates, + hvalues) logger.info("Hypnogram successfully loaded (%s)" % filename) @@ -446,7 +522,7 @@ def _read_hypno_hyp_sample(path): return hypno, sf_hyp -def _read_hypno_txt_sample(path): +def _read_hypno_txt_sample(path, hstates=None, hvalues=None): """Read text files (.txt / .csv) hypnogram. Parameters @@ -468,15 +544,23 @@ def _read_hypno_txt_sample(path): header = file + '_description.txt' assert os.path.isfile(header) + # Sleep GUI state-value mapping + values_map = { + lbl: value for lbl, value in zip(hstates, hvalues) + } + # Load header file labels = np.genfromtxt(header, dtype=str, delimiter=" ", usecols=0, encoding='utf-8') values = np.genfromtxt(header, dtype=float, delimiter=" ", usecols=1, encoding='utf-8') - desc = {label: row for label, row in zip(labels, values)} + desc = { + label: row for label, row in zip(labels, values) + } # State-value mapping from loaded metadata + hyp_time = float(desc.pop('time')) # First line is usually "time" # Get sampling frequency of hypnogram - sf_hyp = 1. / float(desc['time']) + sf_hyp = 1. / hyp_time # Load hypnogram file hyp = np.genfromtxt(path, delimiter='\n', usecols=[0], @@ -488,7 +572,57 @@ def _read_hypno_txt_sample(path): else: hypno = hyp.astype(int) - hypno = swap_hyp_values(hypno, desc) + # Recognize "edf"-style hypnograms. This is hacky but consistent with + # previous behaviour (Tom Bugnon 2021): + # Swap the hypnogram edf-style values to match Sleep's default state + # config. + # Fail if not using Sleep's default state config + edf_style_min_desc = { + 'W': 5.0, 'N1': 3.0, 'N2': 2.0, 'N3': 1.0, 'N4': 0.0, 'REM': 4.0 + } # recognize as EDF style if mapping contains these keys + if desc.items() >= edf_style_min_desc.items(): + if not test_compatible_with_df_hyp(hstates, hvalues, test_equal=True): + msg = (f"Error while loading hypnogram at `{path}`: " + "The hypnogram was recognized as edf-style and can only be " + f"loaded with Sleep's default vigilance state " + f"configuration: (['Art' (-1), 'Wake' (0), 'N1' (1), " + f"'N2' (2), 'N3' (3), 'REM' (4)]). Please try again using " + f"the default config " + f"(`Sleep(.., states_config_file=None)`).\n\n" + f"Current config: {values_map}.\n") + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() + raise ValueError(msg) + keys_to_swap = [ + key for key in [ + 'Art', 'Nde', 'Mt', 'W', 'N1', 'N2', 'N3', 'N4', 'REM' + ] if key in desc + ] + msg = ( + f"The loaded hypnogram at {path} was recognized as edf-style " + f"format. Please note the hypnogram values for all the " + f"following states will be swapped to match Sleep's default " + f"config: {keys_to_swap}") + import warnings + warnings.warn(msg) + hypno = swap_hyp_values(hypno, desc) + + # Normal behavior: hypnogram values are returned without swapping but + # we check that the mapping on file is compatible with Sleep's current + # mapping + else: + if not desc.items() <= values_map.items(): + msg = (f"Error while loading hypnogram at `{path}`: The hypnogram " + "states encoded in the file are incompatible with Sleep's " + "vigilance state config. Please check your config and try " + f"again.\n\n" + f"Config in file: {desc}\n" + f"Sleep's states config: {values_map}") + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() + raise ValueError(msg) return hypno, sf_hyp From 8c181a58e87bce72628a06398f344d624a85a255 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Wed, 10 Feb 2021 17:43:48 +0100 Subject: [PATCH 11/24] Allow disabling of info popups in rw_hypno --- visbrain/io/rw_hypno.py | 67 +++++++++++++++++++++++++---------------- 1 file changed, 41 insertions(+), 26 deletions(-) diff --git a/visbrain/io/rw_hypno.py b/visbrain/io/rw_hypno.py index 06caead04..9083abdb9 100644 --- a/visbrain/io/rw_hypno.py +++ b/visbrain/io/rw_hypno.py @@ -197,7 +197,8 @@ def test_compatible_with_df_hyp(hstates, hvalues, test_equal=True): def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., - time=None, info=None, hstates=None, hvalues=None): + time=None, info=None, hstates=None, hvalues=None, + popup=True): """Save hypnogram data. Parameters @@ -223,6 +224,9 @@ def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., (default ['Wake', 'N1', 'N2', 'N3', 'REM', 'Art']) hvalues: list[int] Hypnogram value for each vigilance state (default [0, 1, 2, 3, 4, -1]). + popup: bool + Display info window for error messages, avoids dangerous silent + failing when writing hypnogram (default True) """ # Checking : assert isinstance(filename, str) @@ -259,9 +263,10 @@ def write_hypno(filename, hypno, version='time', sf=100., npts=1, window=1., f"(['Art' (-1), 'Wake' (0), 'N1' (1), 'N2' (2), 'N3' " f"(3), 'REM' (4)]). Please try again using a different" f" format.") - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg) - msgBox.exec() + if popup: + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec raise ValueError(msg) elif version is 'time': # v2 = time # Get the DataFrame : @@ -355,7 +360,8 @@ def _write_hypno_hyp_sample(filename, hypno, sf=100., npts=1): ############################################################################### -def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): +def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None, + popup=True): """Load hypnogram file. EDF+ (`.edf`/`.txt`) and Elan (`.hyp`) style hypnograms can only be loaded @@ -387,6 +393,9 @@ def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): hvalues: list[int] Hypnogram value for each vigilance state in Sleep GUI (default [0, 1, 2, 3, 4, -1]). + popup: bool + Display info window for error messages, avoids dangerous silent + failing when writing hypnogram (default True) Returns ------- @@ -424,9 +433,10 @@ def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): f"the default config " f"(`Sleep(.., states_config_file=None)`).\n\n" f"Current config: {list(zip(hstates, hvalues))}.\n") - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg) - msgBox.exec() + if popup: + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() raise ValueError(msg) # Load the hypnogram : if ext == '.hyp': # v1 = Elan @@ -439,7 +449,8 @@ def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): # Check that the hyp was saved with a compatible # state_label/state_value mapping while loading hypno, sf_hyp = _read_hypno_txt_sample(filename, hstates=hstates, - hvalues=hvalues) + hvalues=hvalues, + popup=popup) else: # v2 msg = (f"Error while loading hypnogram at `{filename}`: " "Some hypnogram states present in file are absent in " @@ -456,11 +467,12 @@ def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): if not state.startswith("*") ] if not all([state in hstates for state in loaded_hyp_states]): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg.format( - **{'loaded_states': loaded_hyp_states} - )) - msgBox.exec() + if popup: + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg.format( + **{'loaded_states': loaded_hyp_states} + )) + msgBox.exec() raise ValueError(msg.format( **{'loaded_states': loaded_hyp_states} )) @@ -474,11 +486,12 @@ def read_hypno(filename, time=None, datafile=None, hstates=None, hvalues=None): if not state.startswith("*") ] if not all([state in hstates for state in loaded_hyp_states]): - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg.format( - **{'loaded_states': loaded_hyp_states} - )) - msgBox.exec() + if popup: + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg.format( + **{'loaded_states': loaded_hyp_states} + )) + msgBox.exec() raise ValueError(msg.format( **{'loaded_states': loaded_hyp_states} )) @@ -522,7 +535,7 @@ def _read_hypno_hyp_sample(path): return hypno, sf_hyp -def _read_hypno_txt_sample(path, hstates=None, hvalues=None): +def _read_hypno_txt_sample(path, hstates=None, hvalues=None, popup=True): """Read text files (.txt / .csv) hypnogram. Parameters @@ -590,9 +603,10 @@ def _read_hypno_txt_sample(path, hstates=None, hvalues=None): f"the default config " f"(`Sleep(.., states_config_file=None)`).\n\n" f"Current config: {values_map}.\n") - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg) - msgBox.exec() + if popup: + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() raise ValueError(msg) keys_to_swap = [ key for key in [ @@ -619,9 +633,10 @@ def _read_hypno_txt_sample(path, hstates=None, hvalues=None): f"again.\n\n" f"Config in file: {desc}\n" f"Sleep's states config: {values_map}") - msgBox = QtWidgets.QMessageBox() - msgBox.setText(msg) - msgBox.exec() + if popup: + msgBox = QtWidgets.QMessageBox() + msgBox.setText(msg) + msgBox.exec() raise ValueError(msg) return hypno, sf_hyp From 79390d31ab80f6a7754341a5b5a154d8924fca28 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Wed, 10 Feb 2021 17:44:13 +0100 Subject: [PATCH 12/24] Update test_rw_hypno --- visbrain/io/tests/test_rw_hypno.py | 101 +++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 26 deletions(-) diff --git a/visbrain/io/tests/test_rw_hypno.py b/visbrain/io/tests/test_rw_hypno.py index dd5ff7461..340446964 100644 --- a/visbrain/io/tests/test_rw_hypno.py +++ b/visbrain/io/tests/test_rw_hypno.py @@ -1,11 +1,19 @@ """Test functions in rw_hypno.py.""" import numpy as np +import pytest +from visbrain.io.rw_hypno import (hypno_sample_to_time, hypno_time_to_sample, + oversample_hypno, read_hypno, write_hypno) from visbrain.tests._tests_visbrain import _TestVisbrain -from visbrain.io.rw_hypno import (hypno_time_to_sample, hypno_sample_to_time, - oversample_hypno, write_hypno, read_hypno) -versions = dict(time=['.txt', '.csv', '.xlsx'], sample=['.txt', '.hyp']) +state_cfgs = { + 'df': (['Art', 'Wake', 'N1', 'N2', 'N3', 'REM'], [-1, 0, 1, 2, 3, 4]), + 'None': (None, None), + 'superset': (['Art', 'Wake', 'N1', 'N2', 'N3', 'REM', 'other'], + [-1, 0, 1, 2, 3, 4, 100]), + 'subset': (['Art', 'Wake'], [-1, 0]), + 'other': (['1', '2', '3'], [1, 2, 3]), +} # (hstates, hvalues) class TestRwHypno(_TestVisbrain): @@ -15,16 +23,27 @@ class TestRwHypno(_TestVisbrain): def _get_hypno(): return np.array([-1, 4, 2, 3, 0]) - def test_hypno_conversion(self): + @pytest.mark.parametrize( + "hcfg, hyp", [ + ('df', [-1, -1, 4, 2, 3, 3, 0, 0, 0, 0, 1, 1, 1, -1, -1]), + ('superset', [-1, -1, 4, 2, 3, 3, 0, 0, 0, 0, 1, 1, 1, -1, -1]), + ] + ) + def test_hypno_conversion(self, hcfg, hyp): """Test conversion functions.""" - hyp = np.array([-1, -1, 4, 4, 2, 2, 3, 3, 0, 0, 0, 0, 1, 1, 1, -1, -1]) + hstates, hvalues = state_cfgs[hcfg] + hyp = np.array(hyp) sf = 100. time = np.arange(len(hyp)) / sf # Sample -> time : - df = hypno_sample_to_time(hyp, time) + df = hypno_sample_to_time(hyp, time, hstates, hvalues) # Time -> sample : - hyp_new, time_new, sf_new = hypno_time_to_sample(df.copy(), len(hyp)) - hyp_new_2, _, _ = hypno_time_to_sample(df.copy(), time) + hyp_new, time_new, sf_new = hypno_time_to_sample( + df.copy(), len(hyp), hstates, hvalues + ) + hyp_new_2, _, _ = hypno_time_to_sample( + df.copy(), time, hstates, hvalues + ) # Test : np.testing.assert_array_equal(hyp, hyp_new) np.testing.assert_array_equal(hyp_new, hyp_new_2) @@ -38,25 +57,55 @@ def test_oversample_hypno(self): to_hyp = np.array([-1, -1, 4, 4, 2, 2, 3, 3, 0, 0, 0, 0]) assert np.array_equal(hyp_over, to_hyp) - def test_write_hypno(self): + @pytest.mark.parametrize( + "hcfg, hyp, version, ext, expected", [ + + # Default states cfg: works with all versions + ['None', [-1, -1, 0, 1, 2], 'sample', '.txt', None], + ['df', [-1, -1, 0, 1, 2], 'sample', '.txt', None], + ['df', [-1, -1, 0, 1, 2], 'sample', '.hyp', None], + ['df', [-1, -1, 0, 1, 2], 'time', '.txt', None], + ['df', [-1, -1, 0, 1, 2], 'time', '.csv', None], + ['df', [-1, -1, 0, 1, 2], 'time', '.xlsx', None], + # Check: + # - Non default state config can't be saved in elan format + # - Hypnogram can't contain non-mapped keys + ['subset', [-1, -1, 0, ], 'sample', '.hyp', ValueError], + ['subset', [-1, -1, 0, ], 'sample', '.txt', None], + ['subset', [-1, -1, 0, ], 'time', '.txt', None], + ['subset', [-1, -1, 0, ], 'time', '.csv', None], + ['subset', [-1, -1, 0, 1], 'time', '.txt', KeyError], + ['superset', [-1, -1, 0, ], 'sample', '.hyp', ValueError], + ['superset', [-1, -1, 0, ], 'sample', '.txt', None], + ['superset', [-1, -1, 0, ], 'time', '.txt', None], + ['superset', [-1, -1, 0, ], 'time', '.csv', None], + ['superset', [-2, -1, 0, 1], 'time', '.txt', KeyError], + ['other', [1, 1, 3, ], 'sample', '.hyp', ValueError], + ['other', [1, 1, 3, ], 'sample', '.txt', None], + ['other', [1, 1, 3, ], 'time', '.txt', None], + ['other', [1, 1, 3, ], 'time', '.csv', None], + ['other', [2, 1, 3, -2], 'time', '.txt', KeyError], + ] + ) + def test_rw_hypno(self, hcfg, hyp, version, ext, expected): """Test function write_hypno_txt.""" - hyp = self._get_hypno() + hstates, hvalues = state_cfgs[hcfg] + hyp = np.array(hyp) sf, npts = 1., len(hyp) time = np.arange(npts) / sf info = {'Info_1': 10, 'Info_2': 'coco', 'Info_3': 'veut_un_gateau'} - for k in versions.keys(): - for e in versions[k]: - filename = self.to_tmp_dir('hyp_test_' + k + e) - write_hypno(filename, hyp, version=k, sf=sf, npts=npts, - window=1., time=time, info=info) - - def test_read_hypno(self): - """Test function read_hypno.""" - hyp = self._get_hypno() - sf, npts = 1., len(hyp) - time = np.arange(npts) / sf - for k in versions.keys(): - for e in versions[k]: - filename = self.to_tmp_dir('hyp_test_' + k + e) - hyp_new, _ = read_hypno(filename, time=time) - np.testing.assert_array_equal(hyp, hyp_new) + filename = self.to_tmp_dir('hyp_test_' + hcfg + version + ext) + if expected is None: + # Write + write_hypno(filename, hyp, version=version, sf=sf, npts=npts, + window=1., time=time, info=info, hstates=hstates, + hvalues=hvalues, popup=False) + # Read + hyp_new, _ = read_hypno(filename, time=time, hstates=hstates, + hvalues=hvalues, popup=False) + np.testing.assert_array_equal(hyp, hyp_new) + else: + with pytest.raises(expected): + write_hypno(filename, hyp, version=version, sf=sf, npts=npts, + window=1., time=time, info=info, hstates=hstates, + hvalues=hvalues, popup=False) From e56109993d5f8ed3708576fdc38590c3042a9681 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Wed, 10 Feb 2021 19:10:19 +0100 Subject: [PATCH 13/24] Check user state config and use yaml rather than json --- setup.py | 2 +- visbrain/io/read_states_cfg.py | 69 +++++++++++++++++++++++++++------- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/setup.py b/setup.py index c64443c9a..5df57742f 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ def read(fname): extras_require={ 'full': ["mne", "tensorpac", "pandas", "xlrd", "scikit-image", "nibabel", "imageio"], - 'sleep': ["mne", "tensorpac"], + 'sleep': ["mne", "tensorpac", "pyyaml"], 'roi': ["pandas", "xlrd"], 'topo': ["scikit-image"] }, diff --git a/visbrain/io/read_states_cfg.py b/visbrain/io/read_states_cfg.py index 5f705fd2a..acac81d42 100644 --- a/visbrain/io/read_states_cfg.py +++ b/visbrain/io/read_states_cfg.py @@ -1,11 +1,12 @@ """Load and check states config dictionary.""" -from .rw_config import load_config_json +import os.path +import yaml DF_STATES_CFG = { "Art": { - "color": "black", + "color": "red", "shortcut": "a", "value": -1, "display_order": 0, @@ -23,36 +24,76 @@ "display_order": 2, }, "N1": { - "color": "red", + "color": "lightblue", "shortcut": "1", "value": 1, - "display_order": 5, + "display_order": 3, }, "N2": { "color": "blue", "shortcut": "2", "value": 2, - "display_order": 3, + "display_order": 4, }, "N3": { - "color": "blue", - "shortcut": "2", + "color": "darkblue", + "shortcut": "3", "value": 3, - "display_order": 4, + "display_order": 5, }, } -def load_states_cfg(states_config_file): - if states_config_file is None: +def load_states_cfg(states_cfg_file): + if states_cfg_file is None: return DF_STATES_CFG - cfg = load_config_json(states_config_file) + if not os.path.exists(states_cfg_file): + raise FileNotFoundError( + f"No state cfg file found at {states_cfg_file}" + ) + with open(states_cfg_file, 'r') as f: + cfg = yaml.load(f, Loader=yaml.SafeLoader) return check_states_cfg(cfg) def check_states_cfg(states_cfg): - # TODO - # Unique states: + + bad = False + msg = ("Incorrect formatting/values for user-specified states config " + "dictionary:\n\n") + + if not len(states_cfg): + bad = True + msg += f"- States cfg dict can't be empty\n" + + # States are unique: if not len(set(states_cfg.keys())) == len(states_cfg.keys()): - raise ValueError(f"Duplicate keys in states cfg dict: {states_cfg}") + bad = True + msg += f"- There are duplicate keys in states cfg dict\n" + + # Some keys are mandatory and allow no ties + mandatory = ['shortcut', 'value', 'display_order'] + for key in mandatory: + # Mandatory + if not all([key in d for d in states_cfg.values()]): + bad = True + msg += ("- Mandatory key `{key}` is missing. The following keys " + f"are mandatory for each state: {mandatory}\n") + else: + # No ties + key_values = [d[key] for d in states_cfg.values()] + if not len(set(key_values)) == len(key_values): + bad = True + msg += (f"- Value across states should be unique for " + f"the following key: `{key}.\n") + + # Default color is black: + for state_dict in states_cfg.values(): + if "color" not in state_dict: + state_dict["color"] = 'black' + + if bad: + msg = msg + f"\n\nLoaded states cfg: {states_cfg}" + raise ValueError(msg) + return states_cfg From 3700819edf87368cae77bba476fee5fb2183e9ea Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Wed, 10 Feb 2021 19:11:56 +0100 Subject: [PATCH 14/24] Add example with user states config --- examples/gui_sleep/example_states_cfg.yml | 45 +++++++++++++++++++ .../gui_sleep/load_edf_user_states_cfg.py | 35 +++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 examples/gui_sleep/example_states_cfg.yml create mode 100644 examples/gui_sleep/load_edf_user_states_cfg.py diff --git a/examples/gui_sleep/example_states_cfg.yml b/examples/gui_sleep/example_states_cfg.yml new file mode 100644 index 000000000..433e65690 --- /dev/null +++ b/examples/gui_sleep/example_states_cfg.yml @@ -0,0 +1,45 @@ +???: + color: yellow + shortcut: 6 + value: 42 + display_order: -3.14 +Art: + color: red + shortcut: a + value: -1 + display_order: 0 +Wake: + color: black + shortcut: w + value: 0 + display_order: 1 +REM: + color: green + shortcut: r + value: 4 + display_order: 2 +N1: + color: lightblue + shortcut: 1 + value: 1 + display_order: 3 +N2: + color: blue + shortcut: 2 + value: 2 + display_order: 4 +N3: + color: darkblue + shortcut: 3 + value: 3 + display_order: 5 +N4: + color: darkblue + shortcut: 4 + value: 6 + display_order: 6 +N5: + color: darkblue + shortcut: 5 + value: 7 + display_order: 7 diff --git a/examples/gui_sleep/load_edf_user_states_cfg.py b/examples/gui_sleep/load_edf_user_states_cfg.py new file mode 100644 index 000000000..ddd0428d0 --- /dev/null +++ b/examples/gui_sleep/load_edf_user_states_cfg.py @@ -0,0 +1,35 @@ +""" +Load EDF file with custom vigilance state configuration +============= + +This example demonstrate how to specify custom configuration +for vigilance states. + +Required dataset at : +https://www.dropbox.com/s/bj1ra95rbksukro/sleep_edf.zip?dl=1 + +.. image:: ../../_static/examples/ex_LoadEDF.png +""" +import os +import os.path +from visbrain.gui import Sleep +from visbrain.io import download_file, path_to_visbrain_data + +############################################################################### +# LOAD YOUR FILE +############################################################################### +download_file('sleep_edf.zip', unzip=True, astype='example_data') +target_path = path_to_visbrain_data(folder='example_data') + +dfile = os.path.join(target_path, 'excerpt2.edf') +cfile = os.path.join(target_path, 'excerpt2_config.txt') + +# Path to states_cfg yaml +sfile = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + 'example_states_cfg.yml' +) + +# Open the GUI : +sleep = Sleep(data=dfile, config_file=cfile, states_config_file=sfile) +sleep.show() From 8df7fb9ad764ea8b011fd019e4c21278d6033381 Mon Sep 17 00:00:00 2001 From: TomBugnon Date: Wed, 10 Feb 2021 19:33:14 +0100 Subject: [PATCH 15/24] Display states' shortcut on GUI --- visbrain/gui/sleep/interface/ui_elements/ui_panels.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py index ed4900b57..619913e7e 100644 --- a/visbrain/gui/sleep/interface/ui_elements/ui_panels.py +++ b/visbrain/gui/sleep/interface/ui_elements/ui_panels.py @@ -112,9 +112,15 @@ def __init__(self): self._hypLabel = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout(self._hypLabel) layout.setContentsMargins(0, 0, 0, 0) - # TODO: Set smaller margins if many states + # State labels: "