"""Real-time neurofeedback signal monitor.
Dark-themed scrolling window built on Qt (via qtpy) + pyqtgraph.
Classes
-------
NFPlot
Scrolling multi-channel real-time NF signal monitor.
"""
from __future__ import annotations
import datetime
from pathlib import Path
from typing import Optional
import numpy as np
import pyqtgraph as pg
import pyqtgraph.exporters
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import (
QCheckBox,
QComboBox,
QGroupBox,
QHBoxLayout,
QLabel,
QMainWindow,
QPushButton,
QScrollArea,
QSizePolicy,
QVBoxLayout,
QWidget,
)
# --------------------------------------------------------------------------
# Constants
# --------------------------------------------------------------------------
_COLORS = [
"#5DA5A4", # teal
"#FF6B6B", # coral
"#FFD93D", # yellow
"#6BCB77", # green
"#4D96FF", # blue
"#FF922B", # orange
"#CC5DE8", # purple
"#F8BBD9", # pink
]
_LABELS = {
"sensor_power": "Sensor Power",
"band_ratio": "Band Ratio",
"source_power": "Source Power",
"sensor_connectivity": "Sensor Connectivity",
"source_connectivity": "Source Connectivity",
"sensor_graph": "Sensor Graph",
"source_graph": "Source Graph",
"entropy": "Entropy",
"argmax_freq": "Peak Frequency",
"individual_peak_power": "Peak Power",
"cfc_sensor": "Sensor CFC",
"erd_ers": "ERD/ERS",
"laterality": "Laterality",
"hjorth": "Hjorth",
"spectral_centroid": "Spectral Centroid",
}
_UNITS = {
"sensor_power": "V²/Hz",
"band_ratio": "",
"source_power": "a.u.",
"sensor_connectivity": "",
"source_connectivity": "",
"sensor_graph": "",
"source_graph": "",
"entropy": "",
"argmax_freq": "Hz",
"individual_peak_power": "V²/Hz",
"cfc_sensor": "",
"erd_ers": "%",
"laterality": "",
"hjorth": "",
"spectral_centroid": "Hz",
}
_TIME_WINDOW_OPTIONS = [5, 10, 20, 30, 60]
_THRESHOLD_COLOR = "#FFB454" # warm gold, distinct from the _COLORS trace palette
_REWARD_COLOR = (40, 235, 100) # vivid, high-visibility green -- unmistakable at a glance
_REWARD_ALPHA = 31 # 0-255 (~0.12 opacity); light wash, trace/grid read clearly through it
_QSS = """
QMainWindow, QWidget {
background-color: #1a1a2e;
color: #e0e0e0;
font-family: "Segoe UI", sans-serif;
}
QPushButton {
background-color: #16213e;
color: #d0d0e8;
border: 1px solid #0f3460;
border-radius: 5px;
padding: 5px 10px;
font-size: 12px;
}
QPushButton:hover { background-color: #0f3460; }
QPushButton:pressed { background-color: #533483; }
QPushButton:checked {
background-color: #533483;
border-color: #a882dd;
color: #ffffff;
}
QComboBox {
background-color: #16213e;
color: #d0d0e8;
border: 1px solid #0f3460;
border-radius: 4px;
padding: 3px 6px;
}
QComboBox QAbstractItemView {
background-color: #16213e;
color: #d0d0e8;
selection-background-color: #0f3460;
}
QGroupBox {
border: 1px solid #2a2a4a;
border-radius: 6px;
margin-top: 10px;
padding-top: 6px;
font-weight: bold;
font-size: 11px;
color: #8888aa;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 8px;
padding: 0 4px;
}
QLabel { color: #b0b0c8; font-size: 11px; }
QCheckBox { color: #b0b0c8; font-size: 11px; }
QScrollArea { border: none; }
QStatusBar { background-color: #0d0d1a; color: #606080; font-size: 10px; }
"""
[docs]
class NFPlot(QMainWindow):
"""Scrolling real-time neurofeedback signal monitor.
Displays one colour-coded trace per active NF modality in a dark-themed
:class:`pyqtgraph.PlotWidget`. Designed to be driven by
:meth:`~mne_rt.RTStream.record_main` via :meth:`push`.
Parameters
----------
modalities : list of str
Names of the active NF modalities. One scrolling trace is shown
per entry. Names must match entries in :data:`_LABELS` for a
human-readable legend, or the raw string is used as a fallback.
scales_dict : dict[str, float]
Mapping from modality name to its physical display scale. The raw
NF value is divided by this scale before plotting so that all
traces occupy a similar vertical range.
sfreq : float
Nominal update rate in Hz. Used to size the ring buffer.
time_window : float, default 30.0
Visible time range in seconds at startup (can be changed at runtime
from the control panel).
verbose : bool | str | None, default None
Verbosity level. See :func:`~mne_rt._logging.set_log_level`.
See Also
--------
mne_rt.viz.BrainPlot : 3D brain activation display.
mne_rt.viz.RawPlot : Scrolling raw M/EEG channel viewer.
mne_rt.RTStream.record_main : Drives both plots from the NF loop.
Notes
-----
The control panel (right sidebar) provides:
* **Playback** — pause/resume, clear buffer, screenshot.
* **Display** — time-window selector, grid toggle, threshold-line
toggle, reward-zone toggle, auto-range.
* **Channel Scales** — per-modality amplitude scaling with ``+`` / ``−``
buttons and a live ``×N`` readout.
Each subplot can show a dashed horizontal **threshold line** marking the
reward boundary of the protocol driving that modality (fixed or
adaptive), passed per-update via the ``thresholds`` argument of
:meth:`push`. A modality with no associated protocol, or one whose
protocol has no single-level threshold (e.g.
:class:`~mne_rt.protocols.LinearTrendProtocol`), simply shows no line.
Each subplot can also show a translucent green **reward span** behind
the trace, scrolling in lock-step with it, marking windows where the
driving protocol currently rewards the subject — passed per-update via
the ``rewards`` argument of :meth:`push`. A modality with no driving
protocol simply shows no span.
Status bar shows the latest value — and, when available, the current
threshold — for every active modality, prefixed with 🟢 during reward.
Examples
--------
Minimal offline usage:
>>> app = QApplication([])
>>> plot = NFPlot(["sensor_power"], {"sensor_power": 1e-12}, sfreq=100)
>>> plot.show()
>>> plot.push([3.2e-13], thresholds=[2.0e-13], rewards=[True])
>>> app.exec()
.. versionadded:: 1.0.0
"""
#: Emitted when the window is closed, *before* Qt tears down the widget.
#: :meth:`~mne_rt.RTStream.record_main` connects this to stop pumping
#: new data into a closed window while other plot windows stay open.
closed = Signal()
[docs]
def __init__(
self,
modalities: list[str],
scales_dict: dict[str, float],
sfreq: float,
time_window: float = 30.0,
display_smoothing: float = 0.3,
verbose=None,
) -> None:
from mne_rt._logging import set_log_level
set_log_level(verbose)
super().__init__()
self._mods = modalities
self._scales = scales_dict
self._sfreq = sfreq
self._time_window = time_window
self._n = len(modalities)
self._channel_scales = [1.0] * self._n
self._paused = False
self._display_alpha = float(np.clip(display_smoothing, 0.0, 1.0))
self._ema = np.zeros(self._n)
self._show_threshold = True
self._threshold_active = [False] * self._n
self._show_reward = True
self._reward_active = [False] * self._n
# Data buffers — 30 fps × time_window gives real-time resolution
n_pts = max(int(sfreq * time_window), 30)
self._time_axis = np.linspace(0.0, time_window, n_pts)
self._buf = np.zeros((self._n, n_pts))
self._reward_buf = np.zeros((self._n, n_pts), dtype=np.uint8)
pg.setConfigOptions(antialias=True, foreground="#c0c0d8", background="#0d0d1a")
self._build_ui()
self.setWindowTitle("MNE-RT — NF Signal")
self.resize(1440, max(500, 200 + self._n * 150))
# ------------------------------------------------------------------
# UI construction
# ------------------------------------------------------------------
def _build_ui(self) -> None:
self.setStyleSheet(_QSS)
central = QWidget()
self.setCentralWidget(central)
root = QHBoxLayout(central)
root.setContentsMargins(8, 8, 4, 8)
root.setSpacing(6)
root.addWidget(self._build_plot_widget(), stretch=5)
root.addWidget(self._build_control_panel(), stretch=0)
self._status = self.statusBar()
self._status.showMessage("Waiting for data …")
def _build_plot_widget(self) -> pg.GraphicsLayoutWidget:
glw = pg.GraphicsLayoutWidget()
glw.setBackground("#0d0d1a")
self._plots: list[pg.PlotItem] = []
self._curves: list[pg.PlotDataItem] = []
self._threshold_lines: list[pg.InfiniteLine] = []
# Per-modality pool of reusable reward-span regions (grown lazily
# in _update_reward_regions as concurrent on-segments require).
self._reward_regions: list[list[pg.LinearRegionItem]] = []
for i, mod in enumerate(self._mods):
color = _COLORS[i % len(_COLORS)]
is_bottom = i == self._n - 1
pi = glw.addPlot(row=i, col=0)
self._apply_grid_style(pi, visible=True)
pi.setMouseEnabled(x=False, y=False)
self._reward_regions.append([])
# Label the left axis with the modality name in its colour
pi.setLabel("left", _LABELS.get(mod, mod), color=color, size="10pt")
pi.getAxis("left").setWidth(110)
for ax_name in ("left", "bottom"):
ax = pi.getAxis(ax_name)
ax.setPen(pg.mkPen("#303050"))
ax.setTextPen(pg.mkPen("#9090aa"))
# Only the bottom plot shows the time axis label and tick values
if is_bottom:
pi.setLabel("bottom", "Time", units="s", color="#9090aa")
else:
pi.getAxis("bottom").setStyle(showValues=False)
pi.getAxis("bottom").setHeight(0)
# Fine time-axis ticks so minor grid lines appear
self._apply_x_tick_spacing(pi, self._time_window)
# Zero reference line
pi.addItem(pg.InfiniteLine(pos=0, angle=0, pen=pg.mkPen("#252545", width=1)))
# Threshold line — hidden until push() supplies a real value.
# The label text is a static placeholder here: InfiniteLine's
# "{value}" would auto-fill from the line's *position* (a
# normalised/rescaled plot coordinate), not the physical
# threshold, so push() overwrites it with the real value on
# every update instead of relying on the placeholder.
thr_pen = pg.mkPen(_THRESHOLD_COLOR, width=2)
# Coarse, explicit dash pattern (long dash / gap in pixels) so
# the line reads as unmistakably dashed at any display DPI --
# Qt.PenStyle.DashLine's default pattern is fine-grained enough
# that thin anti-aliased strokes can look almost solid.
thr_pen.setDashPattern([8, 6])
thr_line = pg.InfiniteLine(
pos=0,
angle=0,
movable=False,
pen=thr_pen,
label="thr",
labelOpts={
"color": _THRESHOLD_COLOR,
"position": 0.97,
"fill": (13, 13, 26, 180),
},
)
thr_line.setVisible(False)
pi.addItem(thr_line)
self._threshold_lines.append(thr_line)
# Signal curve
curve = pi.plot(
self._time_axis,
self._buf[i],
pen=pg.mkPen(color=color, width=2),
)
self._curves.append(curve)
pi.setXRange(0.0, self._time_window, padding=0.01)
pi.enableAutoRange(axis="y")
# Link all X axes to the first plot
if i > 0:
pi.setXLink(self._plots[0])
self._plots.append(pi)
self._glw = glw
return glw
def _build_control_panel(self) -> QScrollArea:
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFixedWidth(210)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
panel = QWidget()
layout = QVBoxLayout(panel)
layout.setSpacing(8)
layout.setContentsMargins(6, 6, 6, 6)
layout.addWidget(self._grp_playback())
layout.addWidget(self._grp_display())
layout.addWidget(self._grp_channels())
layout.addStretch()
scroll.setWidget(panel)
return scroll
def _grp_playback(self) -> QGroupBox:
grp = QGroupBox("Playback")
lay = QVBoxLayout(grp)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setCheckable(True)
self._btn_pause.clicked.connect(self._toggle_pause)
btn_clear = QPushButton("⟳ Clear")
btn_clear.clicked.connect(self._clear)
btn_shot = QPushButton("📷 Screenshot")
btn_shot.clicked.connect(self._screenshot)
for w in (self._btn_pause, btn_clear, btn_shot):
lay.addWidget(w)
return grp
def _grp_display(self) -> QGroupBox:
grp = QGroupBox("Display")
lay = QVBoxLayout(grp)
# Time-window selector
row = QHBoxLayout()
row.addWidget(QLabel("Time window:"))
self._cmb_tw = QComboBox()
for secs in _TIME_WINDOW_OPTIONS:
self._cmb_tw.addItem(f"{secs} s", secs)
# Select closest match
best = min(_TIME_WINDOW_OPTIONS, key=lambda s: abs(s - self._time_window))
self._cmb_tw.setCurrentIndex(_TIME_WINDOW_OPTIONS.index(best))
self._cmb_tw.currentIndexChanged.connect(self._change_time_window)
row.addWidget(self._cmb_tw)
lay.addLayout(row)
# Grid toggle
chk = QCheckBox("Show grid")
chk.setChecked(True)
chk.toggled.connect(self._set_grid)
lay.addWidget(chk)
# Threshold-line toggle
chk_thr = QCheckBox("Show threshold")
chk_thr.setChecked(True)
chk_thr.setToolTip(
"Dashed line marking the reward threshold of the protocol\n"
"driving each modality (fixed or adaptive)."
)
chk_thr.toggled.connect(self._set_threshold_visible)
lay.addWidget(chk_thr)
# Reward-span toggle, with a small colour-swatch legend
rwd_row = QHBoxLayout()
rwd_row.setSpacing(4)
chk_rwd = QCheckBox("Show reward zones")
chk_rwd.setChecked(True)
chk_rwd.setToolTip(
"Translucent green span marking windows where the subject\n"
"is currently being rewarded by the driving protocol."
)
chk_rwd.toggled.connect(self._set_reward_visible)
rwd_row.addWidget(chk_rwd)
swatch = QLabel("●")
r, g, b = _REWARD_COLOR
swatch.setStyleSheet(f"color: rgb({r},{g},{b}); font-size: 13px;")
swatch.setToolTip("Reward-on colour")
rwd_row.addWidget(swatch)
lay.addLayout(rwd_row)
# Auto-range button
btn_ar = QPushButton("⤢ Auto-range")
btn_ar.clicked.connect(self._auto_range)
lay.addWidget(btn_ar)
return grp
def _grp_channels(self) -> QGroupBox:
grp = QGroupBox("Channel Scales")
lay = QVBoxLayout(grp)
lay.setSpacing(4)
self._scale_labels: list[QLabel] = []
for i, mod in enumerate(self._mods):
color = _COLORS[i % len(_COLORS)]
row = QHBoxLayout()
row.setSpacing(3)
lbl = QLabel(_LABELS.get(mod, mod))
lbl.setStyleSheet(f"color: {color}; font-weight: bold;")
lbl.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
scale_lbl = QLabel("×1.0")
scale_lbl.setFixedWidth(38)
scale_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
scale_lbl.setStyleSheet("color: #707090; font-size: 10px;")
self._scale_labels.append(scale_lbl)
btn_up = QPushButton("+")
btn_dn = QPushButton("−")
for btn in (btn_up, btn_dn):
btn.setFixedSize(22, 22)
btn_up.clicked.connect(lambda _, idx=i: self._scale_up(idx))
btn_dn.clicked.connect(lambda _, idx=i: self._scale_down(idx))
row.addWidget(lbl)
row.addWidget(scale_lbl)
row.addWidget(btn_up)
row.addWidget(btn_dn)
lay.addLayout(row)
return grp
# ------------------------------------------------------------------
# Callbacks
# ------------------------------------------------------------------
@staticmethod
def _apply_x_tick_spacing(pi: pg.PlotItem, secs: float) -> None:
"""Set major/minor X tick spacing so the grid is dense but readable."""
if secs <= 5:
major, minor = 1.0, 0.25
elif secs <= 10:
major, minor = 2.0, 0.5
elif secs <= 20:
major, minor = 5.0, 1.0
elif secs <= 30:
major, minor = 5.0, 1.0
else:
major, minor = 10.0, 2.0
pi.getAxis("bottom").setTickSpacing(major=major, minor=minor)
@staticmethod
def _apply_grid_style(pi: pg.PlotItem, visible: bool = True) -> None:
"""Show grid with white lines and set pen directly on the GridItem."""
pi.showGrid(x=visible, y=visible, alpha=0.45 if visible else 0.0)
if visible:
grid_pen = pg.mkPen(color=(220, 220, 255, 110), width=1, style=Qt.PenStyle.SolidLine)
for item in pi.items:
if isinstance(item, pg.GridItem):
item.setPen(grid_pen)
def _set_grid(self, checked: bool) -> None:
for pi in self._plots:
self._apply_grid_style(pi, visible=checked)
def _set_threshold_visible(self, checked: bool) -> None:
"""Toggle threshold-line visibility (only shown for active modalities)."""
self._show_threshold = checked
for i, line in enumerate(self._threshold_lines):
line.setVisible(checked and self._threshold_active[i])
def _set_reward_visible(self, checked: bool) -> None:
"""Toggle reward-span visibility (only shown for active modalities)."""
self._show_reward = checked
for i in range(self._n):
self._update_reward_regions(i)
def _toggle_pause(self, checked: bool) -> None:
self._paused = checked
self._btn_pause.setText("▶ Resume" if checked else "⏸ Pause")
def _update_reward_regions(self, i: int) -> None:
"""Sync modality ``i``'s reward-span :class:`pyqtgraph.LinearRegionItem`
pool to the contiguous "on" runs currently in ``self._reward_buf``.
Reuses existing pooled regions (repositioning them) rather than
recreating items every push, and hides any pooled regions beyond
the number of runs currently needed.
"""
buf = self._reward_buf[i]
padded = np.concatenate(([0], buf.astype(int), [0]))
edges = np.diff(padded)
starts = np.flatnonzero(edges == 1)
ends = np.flatnonzero(edges == -1) # exclusive end index
pool = self._reward_regions[i]
show = self._show_reward and self._reward_active[i]
for k, (s, e) in enumerate(zip(starts, ends)):
t0 = self._time_axis[s]
t1 = self._time_axis[min(e, len(self._time_axis) - 1)]
if k < len(pool):
region = pool[k]
else:
region = pg.LinearRegionItem(
brush=pg.mkBrush(*_REWARD_COLOR, _REWARD_ALPHA),
pen=pg.mkPen(None),
movable=False,
)
region.setZValue(-20)
region.setAcceptHoverEvents(False)
self._plots[i].addItem(region, ignoreBounds=True)
pool.append(region)
region.setRegion((t0, t1))
region.setVisible(show)
for k in range(len(starts), len(pool)):
pool[k].setVisible(False)
def _clear(self) -> None:
self._buf[:] = 0.0
self._reward_buf[:] = 0
for i, curve in enumerate(self._curves):
curve.setData(self._time_axis, self._buf[i])
self._update_reward_regions(i)
def _screenshot(self) -> None:
from qtpy.QtWidgets import QFileDialog
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
default = str(Path.home() / f"nf_plot_{ts}.png")
path, _ = QFileDialog.getSaveFileName(self, "Save Screenshot", default, "PNG Image (*.png)")
if not path:
return
exp = pg.exporters.ImageExporter(self._glw.scene())
exp.parameters()["width"] = 1920
exp.export(path)
def _change_time_window(self, idx: int) -> None:
secs = float(self._cmb_tw.itemData(idx))
self._time_window = secs
n_pts = max(int(self._sfreq * secs), 30)
self._time_axis = np.linspace(0.0, secs, n_pts)
self._buf = np.zeros((self._n, n_pts))
self._reward_buf = np.zeros((self._n, n_pts), dtype=np.uint8)
for i, pi in enumerate(self._plots):
pi.setXRange(0.0, secs, padding=0.01)
self._apply_x_tick_spacing(pi, secs)
self._update_reward_regions(i)
def _auto_range(self) -> None:
for i, pi in enumerate(self._plots):
row = self._buf[i][self._buf[i] != 0]
vals = list(row) if row.size > 0 else []
# Include the threshold line so auto-range doesn't scroll it
# out of view when it sits outside the trace's current span.
if self._threshold_active[i]:
vals.append(self._threshold_lines[i].value())
if not vals:
continue
lo, hi = min(vals), max(vals)
margin = (hi - lo) * 0.1 or 1.0
pi.setYRange(lo - margin, hi + margin, padding=0)
def _scale_up(self, idx: int) -> None:
self._channel_scales[idx] *= 2.0
self._scale_labels[idx].setText(f"×{self._channel_scales[idx]:.3g}")
def _scale_down(self, idx: int) -> None:
self._channel_scales[idx] /= 2.0
self._scale_labels[idx].setText(f"×{self._channel_scales[idx]:.3g}")
# ------------------------------------------------------------------
# Public interface
# ------------------------------------------------------------------
[docs]
def push(
self,
new_vals: list[float],
thresholds: Optional[list[Optional[float]]] = None,
rewards: Optional[list[Optional[bool]]] = None,
) -> None:
"""Append one new sample per modality and refresh all traces.
This is the main update entry point, called at ~30 fps by the
Qt pump timer inside :meth:`~mne_rt.RTStream.record_main`.
Parameters
----------
new_vals : list of float
Latest NF value for each active modality, in the same order as
the ``modalities`` list passed to :meth:`__init__`.
thresholds : list of (float or None), optional
Current reward-threshold value for each modality, in the same
raw units as ``new_vals`` (i.e. **not** pre-normalised). Omit
entirely to leave threshold lines untouched; use ``None`` or
``nan`` for an individual entry to hide that modality's line
(e.g. a modality with no protocol, or a protocol with no
single-level threshold such as
:class:`~mne_rt.protocols.LinearTrendProtocol`). Typically
sourced from a protocol's ``current_threshold`` property — see
:meth:`~mne_rt.RTStream.record_main`.
rewards : list of (bool or None), optional
Whether the driving protocol is currently rewarding the subject
for each modality, i.e. the ``crossed`` half of that protocol's
``evaluate()`` return value. Rendered as a translucent green
span behind the trace, scrolling in lock-step with it. Omit
entirely to leave reward spans untouched; use ``None`` for an
individual entry when the modality has no driving protocol.
Notes
-----
The call is a no-op when the plot is paused (⏸ button pressed).
Each value is normalised by its entry in ``scales_dict`` before
being written into the ring buffer, so all traces share a common
vertical scale. Threshold values are normalised the same way so
the dashed threshold line stays aligned with its trace, including
after per-channel ``+`` / ``−`` scale adjustments.
"""
if self._paused:
return
arr = np.asarray(new_vals, dtype=float)
norm = np.array(
[
(arr[i] / (self._scales[self._mods[i]] + 1e-300)) * self._channel_scales[i]
for i in range(self._n)
]
)
if self._display_alpha < 1.0:
self._ema = self._display_alpha * norm + (1.0 - self._display_alpha) * self._ema
norm = self._ema
self._buf = np.roll(self._buf, -1, axis=1)
self._buf[:, -1] = norm
self._reward_buf = np.roll(self._reward_buf, -1, axis=1)
self._reward_buf[:, -1] = 0
status_parts: list[str] = []
for i, curve in enumerate(self._curves):
curve.setData(self._time_axis, self._buf[i])
val = arr[i]
unit = _UNITS.get(self._mods[i], "")
part = f"{_LABELS.get(self._mods[i], self._mods[i])}: {val:.4g}" + (
f" {unit}" if unit else ""
)
if thresholds is not None:
thr = thresholds[i]
thr_f = None if thr is None else float(thr)
line = self._threshold_lines[i]
if thr_f is None or not np.isfinite(thr_f):
self._threshold_active[i] = False
line.setVisible(False)
else:
thr_norm = (
thr_f / (self._scales[self._mods[i]] + 1e-300)
) * self._channel_scales[i]
line.setPos(thr_norm)
# InfiniteLine's "{value}" label placeholder auto-fills
# from the line's *position* (thr_norm, the normalised/
# rescaled plot coordinate) on every setPos() call, not
# the physical threshold -- so the on-plot label is
# overridden here with the real value every push().
line.label.setText(f"thr {thr_f:.3g}{f' {unit}' if unit else ''}")
self._threshold_active[i] = True
line.setVisible(self._show_threshold)
part += f" (thr {thr_f:.4g}{f' {unit}' if unit else ''})"
if rewards is not None:
rwd = rewards[i]
if rwd is None:
self._reward_active[i] = False
self._update_reward_regions(i)
else:
self._reward_buf[i, -1] = 1 if rwd else 0
self._reward_active[i] = True
self._update_reward_regions(i)
if rwd:
part = "🟢 " + part
status_parts.append(part)
self._status.showMessage(" | ".join(status_parts))
[docs]
def closeEvent(self, event) -> None:
self.closed.emit()
super().closeEvent(event)