"""Wide widget giving an overview of the recordings with markers and
annotations (bookmarks, events, and sleep scores)
"""
from datetime import timedelta
from logging import getLogger
from math import ceil, floor
from PyQt5.QtCore import Qt
from PyQt5.QtGui import (QBrush,
QColor,
QPen,
)
from PyQt5.QtWidgets import (QFormLayout,
QGraphicsItem,
QGraphicsRectItem,
QGraphicsScene,
QGraphicsView,
QGroupBox,
QVBoxLayout,
)
from .settings import Config
from .utils import convert_name_to_color, FormInt, LINE_WIDTH
lg = getLogger(__name__)
NoPen = QPen()
NoPen.setStyle(Qt.NoPen)
NoBrush = QBrush()
NoBrush.setStyle(Qt.NoBrush)
STAGES = {'Wake': {'pos0': 5, 'pos1': 25, 'color': Qt.black},
'Movement': {'pos0': 5, 'pos1': 25, 'color': Qt.darkGray},
'Artefact': {'pos0': 5, 'pos1': 25, 'color': Qt.darkGray},
'REM': {'pos0': 10, 'pos1': 20, 'color': Qt.magenta},
'NREM1': {'pos0': 15, 'pos1': 15, 'color': Qt.cyan},
'NREM2': {'pos0': 20, 'pos1': 10, 'color': Qt.blue},
'NREM3': {'pos0': 25, 'pos1': 5, 'color': Qt.darkBlue},
'Undefined': {'pos0': 0, 'pos1': 30, 'color': Qt.gray},
'Unknown': {'pos0': 30, 'pos1': 0, 'color': NoBrush},
'cycle': {'pos0': 42, 'pos1': 43, 'color': Qt.darkRed}
}
BARS = {'markers': {'pos0': 0, 'pos1': 10, 'tip': 'Markers in Dataset'},
'quality': {'pos0': 15, 'pos1': 10, 'tip': 'Signal quality'},
'annot': {'pos0': 30, 'pos1': 10,
'tip': 'Annotations (bookmarks and events)'},
'stage': {'pos0': 45, 'pos1': 30, 'tip': 'Sleep Stage'},
}
CURR = {'pos0': 0, 'pos1': 90}
TIME_HEIGHT = 92
TOTAL_HEIGHT = 100
[docs]class ConfigOverview(Config):
"""Widget with preferences in Settings window for Overview."""
def __init__(self, update_widget):
super().__init__('overview', update_widget)
[docs] def create_config(self):
box0 = QGroupBox('Overview')
self.index['timestamp_steps'] = FormInt()
self.index['overview_scale'] = FormInt()
form_layout = QFormLayout()
form_layout.addRow('Steps in overview (in s)',
self.index['timestamp_steps'])
form_layout.addRow('One pixel corresponds to (s)',
self.index['overview_scale'])
box0.setLayout(form_layout)
main_layout = QVBoxLayout()
main_layout.addWidget(box0)
main_layout.addStretch(1)
self.setLayout(main_layout)
[docs]class Overview(QGraphicsView):
"""Show an overview of data, such as hypnogram and data in memory.
Attributes
----------
parent : instance of QMainWindow
the main window.
config : ConfigChannels
preferences for this widget
minimum : int or float
start time of the recording, from the absolute time of start_time in s
maximum : int or float
length of the recordings in s
start_time : datetime
absolute start time of the recording
scene : instance of QGraphicsScene
to keep track of the objects
idx_current : QGraphicsRectItem
instance of the current time window
idx_markers : list of QGraphicsRectItem
list of markers in the dataset
idx_annot : list of QGraphicsRectItem
list of user-made annotations
"""
def __init__(self, parent):
super().__init__()
self.parent = parent
self.config = ConfigOverview(self.update_settings)
self.minimum = None
self.maximum = None
self.start_time = None # datetime, absolute start time
self.scene = None
self.idx_current = None
self.idx_markers = []
self.idx_annot = []
self.idx_poi = []
self.setMinimumHeight(TOTAL_HEIGHT + 30)
[docs] def update(self, reset=True):
"""Read full duration and update maximum.
Parameters
----------
reset: bool
If True, current window start time is reset to 0.
"""
if self.parent.info.dataset is not None:
# read from the dataset, if available
header = self.parent.info.dataset.header
maximum = header['n_samples'] / header['s_freq'] # in s
self.minimum = 0
self.maximum = maximum
self.start_time = self.parent.info.dataset.header['start_time']
elif self.parent.notes.annot is not None:
# read from annotations
annot = self.parent.notes.annot
self.minimum = annot.first_second
self.maximum = annot.last_second
self.start_time = annot.start_time
# make it time-zone unaware
self.start_time = self.start_time.replace(tzinfo=None)
if reset:
self.parent.value('window_start', 0) # the only value that is reset
self.display()
[docs] def display(self):
"""Updates the widgets, especially based on length of recordings."""
lg.debug('GraphicsScene is between {}s and {}s'.format(self.minimum,
self.maximum))
x_scale = 1 / self.parent.value('overview_scale')
lg.debug('Set scene x-scaling to {}'.format(x_scale))
self.scale(1 / self.transform().m11(), 1) # reset to 1
self.scale(x_scale, 1)
self.scene = QGraphicsScene(self.minimum, 0,
self.maximum,
TOTAL_HEIGHT)
self.setScene(self.scene)
# reset annotations
self.idx_markers = []
self.idx_annot = []
self.display_current()
for name, pos in BARS.items():
item = QGraphicsRectItem(self.minimum, pos['pos0'],
self.maximum, pos['pos1'])
item.setToolTip(pos['tip'])
self.scene.addItem(item)
self.add_timestamps()
[docs] def add_timestamps(self):
"""Add timestamps at the bottom of the overview."""
transform, _ = self.transform().inverted()
stamps = _make_timestamps(self.start_time, self.minimum, self.maximum,
self.parent.value('timestamp_steps'))
for stamp, xpos in zip(*stamps):
text = self.scene.addSimpleText(stamp)
text.setFlag(QGraphicsItem.ItemIgnoresTransformations)
# set xpos and adjust for text width
text_width = text.boundingRect().width() * transform.m11()
text.setPos(xpos - text_width / 2, TIME_HEIGHT)
[docs] def update_settings(self):
"""After changing the settings, we need to recreate the whole image."""
self.display()
self.display_markers()
if self.parent.notes.annot is not None:
self.parent.notes.display_notes()
[docs] def update_position(self, new_position=None):
"""Update the cursor position and much more.
Parameters
----------
new_position : int or float
new position in s, for plotting etc.
Notes
-----
This is a central function. It updates the cursor, then updates
the traces, the scores, and the power spectrum. In other words, this
function is responsible for keep track of the changes every time
the start time of the window changes.
"""
if new_position is not None:
lg.debug('Updating position to {}'.format(new_position))
self.parent.value('window_start', new_position)
self.idx_current.setPos(new_position, 0)
current_time = (self.start_time +
timedelta(seconds=new_position))
msg = 'Current time: ' + current_time.strftime('%H:%M:%S')
msg2 = f' ({new_position} seconds from start)'
self.parent.statusBar().showMessage(msg + msg2)
lg.debug(msg)
else:
lg.debug('Updating position at {}'
''.format(self.parent.value('window_start')))
if self.parent.info.dataset is not None:
self.parent.traces.read_data()
if self.parent.traces.data is not None:
self.parent.traces.display()
self.parent.spectrum.display_window()
if self.parent.notes.annot is not None:
self.parent.notes.set_stage_index()
self.parent.notes.set_quality_index()
self.display_current()
[docs] def display_current(self):
"""Create a rectangle showing the current window."""
if self.idx_current in self.scene.items():
self.scene.removeItem(self.idx_current)
item = QGraphicsRectItem(0,
CURR['pos0'],
self.parent.value('window_length'),
CURR['pos1'])
# it's necessary to create rect first, and then move it
item.setPos(self.parent.value('window_start'), 0)
item.setPen(QPen(Qt.lightGray))
item.setBrush(QBrush(Qt.lightGray))
item.setZValue(-10)
self.scene.addItem(item)
self.idx_current = item
[docs] def display_markers(self):
"""Mark all the markers, from the dataset.
This function should be called only when we load the dataset or when
we change the settings.
"""
for rect in self.idx_markers:
self.scene.removeItem(rect)
self.idx_markers = []
markers = []
if self.parent.info.markers is not None:
if self.parent.value('marker_show'):
markers = self.parent.info.markers
for mrk in markers:
rect = QGraphicsRectItem(mrk['start'],
BARS['markers']['pos0'],
mrk['end'] - mrk['start'],
BARS['markers']['pos1'])
self.scene.addItem(rect)
color = self.parent.value('marker_color')
rect.setPen(QPen(QColor(color)))
rect.setBrush(QBrush(QColor(color)))
rect.setZValue(-5)
self.idx_markers.append(rect)
[docs] def display_annotations(self):
"""Mark all the bookmarks/events, from annotations.
This function is similar to display_markers, but they are called at
different stages (f.e. when loading annotations file), so we keep them
separate
"""
for rect in self.idx_annot:
self.scene.removeItem(rect)
self.idx_annot = []
if self.parent.notes.annot is None:
return
bookmarks = []
events = []
if self.parent.value('annot_show'):
bookmarks = self.parent.notes.annot.get_bookmarks()
events = self.parent.notes.get_selected_events()
annotations = bookmarks + events
for annot in annotations:
rect = QGraphicsRectItem(annot['start'],
BARS['annot']['pos0'],
annot['end'] - annot['start'],
BARS['annot']['pos1'])
self.scene.addItem(rect)
if annot in bookmarks:
color = self.parent.value('annot_bookmark_color')
if annot in events:
color = convert_name_to_color(annot['name'])
rect.setPen(QPen(QColor(color), LINE_WIDTH))
rect.setBrush(QBrush(QColor(color)))
rect.setZValue(-5)
self.idx_annot.append(rect)
for epoch in self.parent.notes.annot.epochs:
self.mark_stages(epoch['start'],
epoch['end'] - epoch['start'],
epoch['stage'])
self.mark_quality(epoch['start'],
epoch['end'] - epoch['start'],
epoch['quality'])
cycles = self.parent.notes.annot.rater.find('cycles')
cyc_starts = [float(mrkr.text) for mrkr in cycles.findall('cyc_start')]
cyc_ends = [float(mrkr.text) for mrkr in cycles.findall('cyc_end')]
for mrkr in cyc_starts:
self.mark_cycles(mrkr, 30) # TODO: better width solution
for mrkr in cyc_ends:
self.mark_cycles(mrkr, 30, end=True)
[docs] def mark_stages(self, start_time, length, stage_name):
"""Mark stages, only add the new ones.
Parameters
----------
start_time : int
start time in s of the epoch being scored.
length : int
duration in s of the epoch being scored.
stage_name : str
one of the stages defined in global stages.
"""
y_pos = BARS['stage']['pos0']
current_stage = STAGES.get(stage_name, STAGES['Unknown'])
# the -1 is really important, otherwise we stay on the edge of the rect
old_score = self.scene.itemAt(start_time + length / 2,
y_pos +
current_stage['pos0'] +
current_stage['pos1'] - 1,
self.transform())
# check we are not removing the black border
if old_score is not None and old_score.pen() == NoPen:
lg.debug('Removing old score at {}'.format(start_time))
self.scene.removeItem(old_score)
self.idx_annot.remove(old_score)
rect = QGraphicsRectItem(start_time,
y_pos + current_stage['pos0'],
length,
current_stage['pos1'])
rect.setPen(NoPen)
rect.setBrush(current_stage['color'])
self.scene.addItem(rect)
self.idx_annot.append(rect)
[docs] def mark_quality(self, start_time, length, qual_name):
"""Mark signal quality, only add the new ones.
Parameters
----------
start_time : int
start time in s of the epoch being scored.
length : int
duration in s of the epoch being scored.
qual_name : str
one of the stages defined in global stages.
"""
y_pos = BARS['quality']['pos0']
height = 10
# the -1 is really important, otherwise we stay on the edge of the rect
old_score = self.scene.itemAt(start_time + length / 2,
y_pos + height - 1,
self.transform())
# check we are not removing the black border
if old_score is not None and old_score.pen() == NoPen:
lg.debug('Removing old score at {}'.format(start_time))
self.scene.removeItem(old_score)
self.idx_annot.remove(old_score)
if qual_name == 'Poor':
rect = QGraphicsRectItem(start_time, y_pos, length, height)
rect.setPen(NoPen)
rect.setBrush(Qt.black)
self.scene.addItem(rect)
self.idx_annot.append(rect)
[docs] def mark_cycles(self, start_time, length, end=False):
"""Mark cycle bound, only add the new one.
Parameters
----------
start_time: int
start time in s of the bounding epoch
length : int
duration in s of the epoch being scored.
end: bool
If True, marker will be a cycle end marker; otherwise, it's start.
"""
y_pos = STAGES['cycle']['pos0']
height = STAGES['cycle']['pos1']
color = STAGES['cycle']['color']
# the -1 is really important, otherwise we stay on the edge of the rect
old_rect = self.scene.itemAt(start_time + length / 2,
y_pos + height - 1,
self.transform())
# check we are not removing the black border
if old_rect is not None and old_rect.pen() == NoPen:
lg.debug('Removing old score at {}'.format(start_time))
self.scene.removeItem(old_rect)
self.idx_annot.remove(old_rect)
rect = QGraphicsRectItem(start_time, y_pos, 30, height)
rect.setPen(NoPen)
rect.setBrush(color)
self.scene.addItem(rect)
self.idx_annot.append(rect)
if end:
start_time -= 120
kink_hi = QGraphicsRectItem(start_time, y_pos, 150, 1)
kink_hi.setPen(NoPen)
kink_hi.setBrush(color)
self.scene.addItem(kink_hi)
self.idx_annot.append(kink_hi)
kink_lo = QGraphicsRectItem(start_time, y_pos + height, 150, 1)
kink_lo.setPen(NoPen)
kink_lo.setBrush(color)
self.scene.addItem(kink_lo)
self.idx_annot.append(kink_lo)
[docs] def mark_poi(self, times=None):
"""Mark selected signal, from list of start and end times.
Parameters
----------
times : list of tuple of float
start and end times, in sec form rec start
"""
y_pos = BARS['quality']['pos0']
height = 5
for rect in self.idx_poi:
self.scene.removeItem(rect)
self.idx_poi = []
if not times:
return
for beg, end in times:
rect = QGraphicsRectItem(beg, y_pos, end - beg, height)
rect.setPen(NoPen)
rect.setBrush(Qt.darkRed)
self.scene.addItem(rect)
self.idx_poi.append(rect)
[docs] def mousePressEvent(self, event):
"""Jump to window when user clicks on overview.
Parameters
----------
event : instance of QtCore.QEvent
it contains the position that was clicked.
"""
if self.scene is not None:
x_in_scene = self.mapToScene(event.pos()).x()
window_length = self.parent.value('window_length')
window_start = int(floor(x_in_scene / window_length) *
window_length)
if self.parent.notes.annot is not None:
window_start = self.parent.notes.annot.get_epoch_start(
window_start)
self.update_position(window_start)
[docs] def reset(self):
"""Reset the widget, and clear the scene."""
self.minimum = None
self.maximum = None
self.start_time = None # datetime, absolute start time
self.idx_current = None
self.idx_markers = []
self.idx_annot = []
if self.scene is not None:
self.scene.clear()
self.scene = None
def _make_timestamps(start_time, minimum, maximum, steps):
"""Create timestamps on x-axis, every so often.
Parameters
----------
start_time : instance of datetime
actual start time of the dataset
minimum : int
start time of the recording from start_time, in s
maximum : int
end time of the recording from start_time, in s
steps : int
how often you want a label, in s
Returns
-------
dict
where the key is the label and the value is the time point where the
label should be placed.
Notes
-----
This function takes care that labels are placed at the meaningful time, not
at random values.
"""
t0 = start_time + timedelta(seconds=minimum)
t1 = start_time + timedelta(seconds=maximum)
t0_midnight = t0.replace(hour=0, minute=0, second=0, microsecond=0)
d0 = t0 - t0_midnight
d1 = t1 - t0_midnight
first_stamp = ceil(d0.total_seconds() / steps) * steps
last_stamp = ceil(d1.total_seconds() / steps) * steps
stamp_label = []
stamp_time = []
for stamp in range(first_stamp, last_stamp, steps):
stamp_as_datetime = t0_midnight + timedelta(seconds=stamp)
stamp_label.append(stamp_as_datetime.strftime('%H:%M'))
stamp_time.append(stamp - d0.total_seconds())
return stamp_label, stamp_time