"""
The Status (Initialization) view of the PypeIt Dashboard (Stage 2).
This is the landing, *state-first* view (design R3, R7–R13, R15–R18). It
renders a Stage 1 :class:`~pypeit.dashboard.model.DashboardModel` and stays
thin: all data comes from the model, all colors/glyphs from
:mod:`pypeit.dashboard.palette`. Top to bottom it shows a global summary
strip, a scope toolbar (calibration-group + detector drop-downs, a stale
badge, a Refresh button), a configuration-overview navigator grid, the scoped
calibration status table, and a (placeholder) Science-frames section.
Generated by JXP and Claude.
"""
from qtpy.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGridLayout,
QLabel, QComboBox, QPushButton, QTableWidget,
QTableWidgetItem, QHeaderView, QAbstractItemView,
QFrame, QScrollArea)
from qtpy.QtCore import Qt, Signal
from qtpy.QtGui import QColor, QPalette
from pypeit.dashboard import palette
from pypeit.dashboard import model as dash_model
# The four science macro-steps shown as a mini strip in each navigator cell
# (Stage 6 Round-1 #1, S6-Q13(b)).
_SCI_STRIP_STEPS = ('process', 'findobj', 'skysub', 'extract')
[docs]
def _text_on(hexcolor):
"""
Pick a readable text color (black/white) for a background hex color.
Generated by JXP and Claude.
Args:
hexcolor (:obj:`str`): Background color, e.g. ``#2E7D32``.
Returns:
str: ``'#000000'`` or ``'#FFFFFF'``.
"""
c = QColor(hexcolor)
lum = 0.299 * c.redF() + 0.587 * c.greenF() + 0.114 * c.blueF()
return '#000000' if lum > 0.6 else '#FFFFFF'
[docs]
class ScienceNavCell(QFrame):
"""
One clickable science-navigator cell for the Status view (Stage 6
Round-1 #1): a four-segment mini strip (``process`` / ``findobj`` /
``skysub`` / ``extract``, each colored+glyphed by the palette) above a
compact frame caption. Clicking it emits :attr:`clicked` with the
``(frame, det)`` so the window can switch to the Science tab.
Generated by JXP and Claude.
Args:
frame (:obj:`str`): The exposure basename.
det: Detector (int) or mosaic (tuple/list).
det_name (:obj:`str`): Human detector name (e.g. ``DET01``).
objtype (:obj:`str`): ``'science'`` or ``'standard'``.
statuses (:obj:`dict`): ``{step: status}`` for the four macro-steps.
theme (:obj:`str`): ``'light'`` or ``'dark'``.
parent (:obj:`QWidget`, optional): The parent widget.
"""
#: Emitted as ``clicked(frame, det)`` when the cell is clicked.
clicked = Signal(object, object)
def __init__(self, frame, det, det_name, objtype, statuses, theme='light',
parent=None):
super().__init__(parent=parent)
self._frame = frame
self._det = det
self.setFrameShape(QFrame.StyledPanel)
self.setCursor(Qt.PointingHandCursor)
self.setToolTip('\n'.join(
[f'{frame} ({det_name}) — {objtype}']
+ [f' {s}: {statuses.get(s, "undone")}'
for s in _SCI_STRIP_STEPS]))
layout = QVBoxLayout(self)
layout.setContentsMargins(4, 4, 4, 4)
layout.setSpacing(2)
# The four-segment status strip.
strip = QHBoxLayout()
strip.setSpacing(1)
for step in _SCI_STRIP_STEPS:
style = palette.slit_style(statuses.get(step, 'undone'),
theme=theme)
seg = QLabel(style.glyph)
seg.setAlignment(Qt.AlignCenter)
seg.setFixedSize(22, 20)
seg.setToolTip(f'{step}: {statuses.get(step, "undone")}')
seg.setStyleSheet(
f'background-color: {style.color}; '
f'color: {_text_on(style.color)}; border: 1px solid #888;')
strip.addWidget(seg)
strip.addStretch(1)
layout.addLayout(strip)
# Compact caption: a shortened frame name + a standard tag.
short = frame if len(frame) <= 18 else frame[:17] + '…'
tag = ' ⭑' if objtype == 'standard' else ''
caption = QLabel(f'{short}{tag}')
caption.setStyleSheet('font-size: 11px;')
caption.setToolTip(f'{frame} ({det_name})')
layout.addWidget(caption)
[docs]
def mousePressEvent(self, event):
"""
Emit :attr:`clicked` with ``(frame, det)`` on any mouse press.
Generated by JXP and Claude.
Args:
event (:obj:`QMouseEvent`): The mouse event.
Returns:
None.
"""
self.clicked.emit(self._frame, self._det)
super().mousePressEvent(event)
# Columns of the scoped calibration status table (R3).
_TABLE_COLUMNS = ['Step', 'Required', 'Status', 'Output']
# The science macro-step columns (for the compact summary; the full Science
# view is the Science tab, Stage 6).
_SCI_STEP_COLS = ('process', 'findobj', 'skysub', 'extract')
# Human-readable messages for the edge states (R11).
_NOT_STARTED_MSG = ('This reduction has not been started — no calibration '
'state found.')
_EDGE_MESSAGES = {
dash_model.LOAD_NOT_STARTED: _NOT_STARTED_MSG,
# A state file that loaded but has no entries is also "not started".
dash_model.LOAD_STATE_FILE: _NOT_STARTED_MSG,
dash_model.LOAD_FILE_NOT_FOUND:
'The PypeIt file was not found.',
dash_model.LOAD_MALFORMED:
'The reduction state file could not be read (malformed or '
'out-of-date).',
dash_model.LOAD_ERROR:
'The reduction state could not be derived.',
}
[docs]
def _clear_layout(layout):
"""
Remove and delete every item from a Qt layout (used to re-render).
Generated by JXP and Claude.
Args:
layout (``QLayout``): The layout to empty.
Returns:
None.
"""
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
else:
child = item.layout()
if child is not None:
_clear_layout(child)
[docs]
class StatusView(QWidget):
"""
The Status tab: the state-first overview of a reduction (Stage 2).
Generated by JXP and Claude.
Args:
model (:class:`~pypeit.dashboard.model.DashboardModel`):
The reduction-state model to render.
parent (:obj:`QWidget`, optional):
The parent widget.
"""
#: Emitted as ``scienceFrameActivated(frame, det)`` when the user clicks a
#: science-navigator cell (Round-1 #1); the window switches to the Science
#: tab and selects that frame.
scienceFrameActivated = Signal(object, object)
def __init__(self, model, activity=None, parent=None):
super().__init__(parent=parent)
self._model = model
self._activity = activity
self._theme = 'light'
# The currently scoped (calibration_group, detector) pair.
self._scope = None
# Kept so the navigator/drop-down handlers can update without
# rebuilding the whole view.
self._group_combo = None
self._det_combo = None
self._table = None
self._outer = QVBoxLayout(self)
self._build()
# -- public API ------------------------------------------------------
[docs]
def set_model(self, model):
"""
Swap in a new model and re-render (used by Refresh).
Generated by JXP and Claude.
Args:
model (:class:`~pypeit.dashboard.model.DashboardModel`):
The new model.
Returns:
None.
"""
self._model = model
self._scope = None
_clear_layout(self._outer)
self._build()
# -- build -----------------------------------------------------------
[docs]
def _build(self):
"""
Build the view for the current model (edge message or full view).
Generated by JXP and Claude.
Returns:
None.
"""
self._theme = self._detect_theme()
if not self._model.is_started():
self._build_edge_message()
self._outer.addStretch(1)
return
self._build_summary()
self._build_scope_toolbar()
self._build_navigator()
self._build_table_section()
self._build_science_placeholder()
# Initial scope: the first available pair.
pairs = self._model.calib_det_pairs()
if pairs:
self._set_scope(pairs[0])
[docs]
def _detect_theme(self):
"""
Detect light vs dark from the widget's palette (so the palette's
dark color set is used under a dark Qt theme).
Generated by JXP and Claude.
Returns:
str: ``'dark'`` or ``'light'``.
"""
color = self.palette().color(QPalette.Window)
luminance = (0.299 * color.redF() + 0.587 * color.greenF()
+ 0.114 * color.blueF())
return 'dark' if luminance < 0.5 else 'light'
[docs]
def _build_edge_message(self):
"""
Render a clear, centered message for an empty/edge state (R11).
Generated by JXP and Claude.
Returns:
None.
"""
msg = _EDGE_MESSAGES.get(self._model.load_status,
'No reduction state available.')
label = QLabel(msg)
label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True)
label.setStyleSheet('color: grey; font-style: italic; '
'font-size: 16px;')
self._outer.addStretch(1)
self._outer.addWidget(label)
[docs]
def _build_summary(self):
"""
Build the always-visible global summary strip (R7).
Generated by JXP and Claude.
Returns:
None.
"""
table = self._model.status_table()
# Whole-run health: required steps that are in the pipeline.
req = table[(table['required'] == True) & table['in_pipeline']]
n_req = len(req)
n_ok = int((req['status'].isin(['success', 'complete'])).sum())
n_fail = int((table['status'] == 'fail').sum())
n_run = int((table['status'] == 'running').sum())
n_undone = int((req['status'].isin(['undone', 'absent'])).sum())
text = (f'Calibrations: {n_ok}/{n_req} required succeeded'
f' · {n_fail} failed · {n_run} running'
f' · {n_undone} to-do')
# Extend the summary to span science frames too (R7, S6-Q9).
sci = self._model.science_table()
if not sci.empty:
n_sci = len(sci)
n_extracted = int((sci['extract'] == 'success').sum())
n_sci_fail = int((sci[list(_SCI_STEP_COLS)] == 'fail')
.any(axis=1).sum())
text += (f'\nScience: {n_extracted}/{n_sci} extracted'
f' · {n_sci_fail} failed')
label = QLabel(text)
label.setObjectName('summaryStrip')
label.setStyleSheet('font-weight: bold; padding: 6px;')
self._outer.addWidget(label)
[docs]
def _build_navigator(self):
"""
Build the configuration-overview navigator grid (R17): one cell per
``(group, detector)``, colored by the worst step status, clickable
to scope.
Generated by JXP and Claude.
Returns:
None.
"""
pairs = self._model.calib_det_pairs()
groups = sorted({g for g, _ in pairs})
dets = sorted({d for _, d in pairs}, key=str)
grid = QGridLayout()
grid.addWidget(QLabel(''), 0, 0)
for col, det in enumerate(dets, start=1):
grid.addWidget(QLabel(self._model.det_name(det)), 0, col)
for rrow, group in enumerate(groups, start=1):
grid.addWidget(QLabel(f'Group {group}'), rrow, 0)
for col, det in enumerate(dets, start=1):
if (group, det) in pairs:
grid.addWidget(self._navigator_cell(group, det),
rrow, col)
# Keep the navigator compact (left-aligned), not stretched wide.
wrap = QHBoxLayout()
wrap.addLayout(grid)
wrap.addStretch(1)
self._outer.addLayout(wrap)
[docs]
def _navigator_cell(self, group, det):
"""
Build one clickable, status-colored navigator cell (R17).
Generated by JXP and Claude.
Args:
group (:obj:`int`): Calibration group ID.
det: Detector (int) or mosaic (tuple).
Returns:
``QPushButton``: The cell button.
"""
category = self._cell_category(group, det)
color = palette.LIGHT_COLORS[category] if self._theme == 'light' \
else palette.DARK_COLORS[category]
glyph, _ = palette.GLYPHS[category]
button = QPushButton(glyph)
button.setFixedSize(40, 40)
# Color the cell by the worst status; pair with the glyph (R10).
button.setStyleSheet(
f'background-color: {color}; border: 1px solid #888;')
button.setToolTip(f'Group {group}, {self._model.det_name(det)}')
button.clicked.connect(
lambda _checked=False, g=group, d=det: self._set_scope((g, d)))
return button
[docs]
def _cell_category(self, group, det):
"""
The worst palette category among a cell's steps (R17).
Generated by JXP and Claude.
Args:
group (:obj:`int`): Calibration group ID.
det: Detector (int) or mosaic (tuple).
Returns:
str: The worst palette category for that ``(group, det)``.
"""
table = self._scoped_table(group, det)
cats = [palette.classify(row.required, row.status, row.in_pipeline)
for row in table.itertuples(index=False)]
return palette.worst_category(cats)
[docs]
def _build_table_section(self):
"""
Build the scoped calibration status table (R3, R8, R9, R10).
Generated by JXP and Claude.
Returns:
None.
"""
heading = QLabel('Calibrations')
heading.setStyleSheet('font-size: 15px; font-weight: bold; '
'padding-top: 8px;')
self._outer.addWidget(heading)
self._table = QTableWidget()
self._table.setColumnCount(len(_TABLE_COLUMNS))
self._table.setHorizontalHeaderLabels(_TABLE_COLUMNS)
self._table.verticalHeader().setVisible(False)
self._table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self._table.setSelectionMode(QAbstractItemView.NoSelection)
self._table.setAlternatingRowColors(True)
header = self._table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.Stretch)
# Neutral selection fill, consistent with the science tables (Round-1
# #2, applied for consistency even though this table is non-selectable).
self._table.setStyleSheet(palette.selection_style(self._theme))
# Let the table grow to show its rows (stretch in the column).
self._outer.addWidget(self._table, stretch=1)
[docs]
def _build_science_placeholder(self):
"""
Build the Science-frames section of the Status view: a one-line count
plus a **science navigator grid** mirroring the calibration navigator
(Round-1 #1, S6-Q13) — one clickable four-segment cell per
``(frame, det)``, science first then standards, scrollable.
Generated by JXP and Claude.
Returns:
None.
"""
heading = QLabel('Science frames')
heading.setStyleSheet('font-size: 15px; font-weight: bold; '
'padding-top: 8px;')
self._outer.addWidget(heading)
sci = self._model.science_table()
if sci.empty:
stub = QLabel('No science frames reduced yet.')
stub.setStyleSheet('color: grey; font-style: italic;')
self._outer.addWidget(stub)
return
n_sci = len(sci)
n_std = int((sci['objtype'] == 'standard').sum())
n_extracted = int((sci['extract'] == 'success').sum())
n_fail = int((sci[list(_SCI_STEP_COLS)] == 'fail').any(axis=1).sum())
summary = QLabel(
f'{n_sci} frame(s) ({n_std} standard) · {n_extracted} extracted · '
f'{n_fail} failed — click a cell for the Science tab.')
self._outer.addWidget(summary)
self._build_science_navigator(sci)
[docs]
def _build_science_navigator(self, sci):
"""
Build the science navigator grid (Round-1 #1, S6-Q13): a wrapped grid
of :class:`ScienceNavCell`, science frames first then standards, in a
capped scroll area so many frames stay compact.
Generated by JXP and Claude.
Args:
sci (:obj:`pandas.DataFrame`): The science table (:meth:`science_table`).
Returns:
None.
"""
# Science frames first, then standards (stable within each group).
rows = list(sci.itertuples(index=False))
rows.sort(key=lambda r: r.objtype == 'standard')
grid = QGridLayout()
grid.setSpacing(4)
n_cols = 4 # wrap into rows of four cells
for i, row in enumerate(rows):
statuses = {s: getattr(row, s) for s in _SCI_STRIP_STEPS}
cell = ScienceNavCell(
row.frame, row.detector, self._model.det_name(row.detector),
row.objtype, statuses, theme=self._theme)
cell.clicked.connect(self.scienceFrameActivated)
grid.addWidget(cell, i // n_cols, i % n_cols)
holder = QWidget()
holder.setLayout(grid)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setWidget(holder)
# Cap the height so a long list scrolls rather than dominating the view.
scroll.setMaximumHeight(220)
self._outer.addWidget(scroll)
# -- scope handling --------------------------------------------------
[docs]
def _scoped_table(self, group, det):
"""
Return the status table restricted to one ``(group, det)``, ordered
by the path-aware step order.
Generated by JXP and Claude.
Args:
group (:obj:`int`): Calibration group ID.
det: Detector (int) or mosaic (tuple).
Returns:
:obj:`pandas.DataFrame`: The scoped, step-ordered rows.
"""
table = self._model.status_table()
mask = (table['calibration_group'] == group) \
& (table['detector'] == det)
scoped = table[mask]
order = {s: i for i, s in enumerate(self._model.step_order())}
# Keep only steps in the button-row order (drops bpm and any step
# not part of the pipeline's display order), sorted by that order.
scoped = scoped[scoped['step'].isin(order)]
return scoped.sort_values('step', key=lambda c: c.map(order))
[docs]
def _set_scope(self, pair):
"""
Set the scoped ``(group, det)``, syncing the drop-downs and table.
Generated by JXP and Claude.
Args:
pair (:obj:`tuple`): The ``(group, det)`` to scope to.
Returns:
None.
"""
self._scope = pair
group, det = pair
# Sync the drop-downs without retriggering a re-render loop.
self._group_combo.blockSignals(True)
idx = self._group_combo.findData(group)
if idx >= 0:
self._group_combo.setCurrentIndex(idx)
self._group_combo.blockSignals(False)
self._reload_det_combo(select=det)
self._render_table()
[docs]
def _reload_det_combo(self, select=None):
"""
Repopulate the detector drop-down for the current group.
Generated by JXP and Claude.
Args:
select: Detector to select after repopulating, if present.
Returns:
None.
"""
if self._det_combo is None or self._group_combo is None:
return
group = self._group_combo.currentData()
dets = [d for g, d in self._model.calib_det_pairs() if g == group]
self._det_combo.blockSignals(True)
self._det_combo.clear()
for d in dets:
self._det_combo.addItem(self._model.det_name(d), d)
if select is not None:
idx = self._det_combo.findData(select)
if idx >= 0:
self._det_combo.setCurrentIndex(idx)
self._det_combo.blockSignals(False)
[docs]
def _on_group_changed(self, _index):
"""
Handle a calibration-group change: reload detectors, re-render.
Generated by JXP and Claude.
Args:
_index (:obj:`int`): The new combo index (unused).
Returns:
None.
"""
self._reload_det_combo()
self._render_table()
[docs]
def _on_det_changed(self, _index):
"""
Handle a detector change: re-render the table.
Generated by JXP and Claude.
Args:
_index (:obj:`int`): The new combo index (unused).
Returns:
None.
"""
self._render_table()
[docs]
def _on_refresh(self):
"""
Re-acquire the state via a fresh model and re-render (R12).
Generated by JXP and Claude.
Returns:
None.
"""
fresh = dash_model.DashboardModel(self._model.pypeit_file,
redux_path=str(self._model.redux_dir))
self.set_model(fresh)
# Report what the refresh did to the shared activity area (S3-Q9).
if self._activity is not None:
if fresh.load_status == dash_model.LOAD_STATE_FILE:
self._activity.set_build('Reloaded state file.')
elif fresh.load_status == dash_model.LOAD_DERIVED:
self._activity.set_build('Re-derived state (no state file).')
else:
self._activity.set_build(f'Refreshed: {fresh.load_status}.')
[docs]
def _render_table(self):
"""
Render the scoped status table from the current drop-down selection.
Generated by JXP and Claude.
Returns:
None.
"""
if self._table is None or self._group_combo is None:
return
group = self._group_combo.currentData()
det = self._det_combo.currentData()
if group is None or det is None:
return
self._scope = (group, det)
scoped = self._scoped_table(group, det)
self._table.setRowCount(len(scoped))
for r, row in enumerate(scoped.itertuples(index=False)):
self._set_table_row(r, row)
[docs]
def _set_table_row(self, r, row):
"""
Populate one table row (Step | Required | Status | Output) with the
palette color + glyph (R9, R10).
Generated by JXP and Claude.
Args:
r (:obj:`int`): The row index.
row: A namedtuple row from the scoped status table.
Returns:
None.
"""
category = palette.classify(row.required, row.status, row.in_pipeline)
style = palette.step_style(row.required, row.status, row.in_pipeline,
theme=self._theme)
step_item = QTableWidgetItem(row.step)
req_text = 'Yes' if row.required else \
('No' if row.required is False else '—')
req_item = QTableWidgetItem(req_text)
status_item = QTableWidgetItem(f'{style.glyph} {style.label}')
# Color the status text by the palette, but keep "required, not
# done" (white fill) readable as text via a neutral color; the ○
# glyph + label still convey it (R10).
if category != palette.REQUIRED_UNDONE:
status_item.setForeground(QColor(style.color))
# output_file is a basename string or missing (None/NaN); show an
# em-dash for anything that is not a real string.
out_text = row.output_file if isinstance(row.output_file, str) \
and row.output_file else '—'
out_item = QTableWidgetItem(out_text)
out_item.setToolTip(out_text if out_text != '—' else '')
# Dim optional (not-required) rows so an undone optional never reads
# as a problem (R9).
if row.required is False:
for it in (step_item, req_item):
it.setForeground(QColor('#9E9E9E'))
for col, it in enumerate((step_item, req_item, status_item,
out_item)):
# Center the cell contents (Round-2 #6).
it.setTextAlignment(Qt.AlignCenter)
self._table.setItem(r, col, it)