Files
Jungfraujoch/viewer/windows/JFJochProcessingJobsWindow.cpp
T
leonarski_fandClaude Opus 4.8 0ed943dd4d viewer: show the space group in the merge-statistics window
The de-novo space-group search (SearchSpaceGroup) already ran inside Rugnux but
only its chosen number survived on ProcessResult, and the viewer dropped even
that. Carry the structured SearchSpaceGroupResult (point group + ranked
candidate scores) on ProcessResult and thread it into JFJochMergeStatsWindow,
which now shows a 'Space group' hero card (the final group, searched or fixed)
plus a compact table of candidate groups and their absence scores.

The library no longer renders the search to text (it used to embed it in
merge_statistics_text); rugnux_cli formats it for stdout instead, so the CLI
keeps its text table while the viewer draws a proper table and does not spew it
to stdout. Also surface a user-fixed space group on the result so the card
shows it too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:52:25 +02:00

542 lines
22 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochProcessingJobsWindow.h"
#include "JFJochMergeStatsWindow.h"
#include "../widgets/ToolbarIcons.h"
#include "../../rugnux/RugnuxCommandLine.h"
#include <QApplication>
#include <QCheckBox>
#include <QClipboard>
#include <QComboBox>
#include <QDateTime>
#include <QDialog>
#include <QDir>
#include <QFont>
#include <QIcon>
#include <QDialogButtonBox>
#include <QFileInfo>
#include <QFormLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QProgressBar>
#include <QPushButton>
#include <QSpinBox>
#include <QStackedWidget>
#include <QStyle>
#include <QPainter>
#include <QPixmap>
#include <QTableWidget>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout>
#include <thread>
namespace {
// Status (the progress bar) is column 2 so it stays visible in the narrow dock.
enum Column { COL_NAME = 0, COL_STATUS, COL_STARTED, COL_MODE, COL_IMAGES, COL_INDEX, COL_CELL, COL_ACTIONS, COL_COUNT };
int default_threads() {
const unsigned hc = std::thread::hardware_concurrency();
return hc == 0 ? 4 : static_cast<int>(hc);
}
QTableWidgetItem *fixedItem(const QString &text) { // a non-editable cell
auto *cell = new QTableWidgetItem(text);
cell->setFlags(cell->flags() & ~Qt::ItemIsEditable);
return cell;
}
}
JFJochProcessingJobsWindow::JFJochProcessingJobsWindow(JFJochImageReadingWorker *worker, QWidget *parent)
: QWidget(parent), worker_(worker) {
setWindowTitle("Processing");
controller_ = new JFJochProcessController(this);
connect(controller_, &JFJochProcessController::phaseChanged, this, &JFJochProcessingJobsWindow::onPhase);
connect(controller_, &JFJochProcessController::progress, this, &JFJochProcessingJobsWindow::onProgress);
connect(controller_, &JFJochProcessController::finished, this, &JFJochProcessingJobsWindow::onFinished);
connect(controller_, &JFJochProcessController::failed, this, &JFJochProcessingJobsWindow::onFailed);
connect(controller_, &JFJochProcessController::liveDataset, this, &JFJochProcessingJobsWindow::liveDataset);
toolbar_ = new QToolBar("Jobs", this);
toolbar_->setMovable(false);
// "New job" is launched by the "Reanalyze dataset" hero button, not from this dock's toolbar.
toolbar_->addAction("Cancel", this, &JFJochProcessingJobsWindow::cancelJob);
toolbar_->addSeparator();
toolbar_->addAction("Show selected", this, &JFJochProcessingJobsWindow::viewResults);
table_ = new QTableWidget(0, COL_COUNT, this);
table_->setMinimumHeight(60); // let the bottom dock shrink freely
table_->setHorizontalHeaderLabels({"Name", "Status", "Started", "Mode", "Images", "Index %", "Unit cell", ""});
table_->horizontalHeader()->setStretchLastSection(false);
table_->horizontalHeader()->setSectionResizeMode(COL_CELL, QHeaderView::Stretch);
table_->horizontalHeader()->setSectionResizeMode(COL_ACTIONS, QHeaderView::Fixed);
table_->setColumnWidth(COL_ACTIONS, 56);
table_->setSelectionBehavior(QAbstractItemView::SelectRows);
table_->setSelectionMode(QAbstractItemView::SingleSelection);
// Only the Name column is editable (per-item flags); editing it renames the run's legend label.
table_->setEditTriggers(QAbstractItemView::DoubleClicked | QAbstractItemView::EditKeyPressed);
connect(table_, &QTableWidget::itemChanged, this, [this](QTableWidgetItem *item) {
if (item->column() != COL_NAME)
return;
const int row = item->row();
if (row < 0 || row >= static_cast<int>(jobs_.size()) || item->text() == jobs_[row].label)
return;
jobs_[row].label = item->text();
emit renameRun(jobs_[row].id, jobs_[row].label);
});
// Double-click a row (other than its editable Name) to show that run in the plots/image.
connect(table_, &QTableWidget::cellDoubleClicked, this, [this](int row, int col) {
if (col == COL_NAME)
return; // double-clicking Name edits the label
if (row >= 0 && row < static_cast<int>(jobs_.size()) && jobs_[row].has_result)
emit activateSnapshot(jobs_[row].id);
});
auto *message = new QLabel("Dataset re-processing is currently available only in File mode.\n\n"
"Open a stored HDF5 file to run processing jobs.", this);
message->setAlignment(Qt::AlignCenter);
message->setWordWrap(true);
stack_ = new QStackedWidget(this);
stack_->addWidget(table_); // page 0
stack_->addWidget(message); // page 1
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->addWidget(toolbar_);
layout->addWidget(stack_);
}
void JFJochProcessingJobsWindow::onHttpConnectionChanged(bool connected, QString addr) {
// Re-processing reads a stored HDF5 file; it is not available for the live HTTP stream.
stack_->setCurrentIndex(connected ? 1 : 0);
toolbar_->setEnabled(!connected);
}
int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec &spec, bool azint) {
QDialog dlg(window()); // centre on the main window, not inside the processing dock
// The kind of job (MX full analysis vs azimuthal integration) comes from the panel's MX/AzInt
// toggle, so the dialog only collects the run options.
dlg.setWindowTitle(azint ? "New azimuthal-integration job" : "New full-analysis job");
auto *start_image = new QSpinBox(&dlg);
start_image->setRange(0, 1'000'000'000);
start_image->setValue(0);
auto *end_image = new QSpinBox(&dlg);
end_image->setRange(0, 1'000'000'000);
end_image->setValue(0);
end_image->setSpecialValueText("end"); // 0 => to the last image
auto *threads = new QSpinBox(&dlg);
threads->setRange(1, 256);
threads->setValue(default_threads());
// Two independent outputs: the large per-image _process.h5 (updates the viewer with spots/results)
// and the small merged .mtz/.cif. Either can be turned off; the ISa/R-meas window is shown
// regardless (it needs only the in-memory merge statistics), so a stats-only run turns both off.
auto *save_h5 = new QCheckBox("Save _process.h5 (per-image results; updates viewer)", &dlg);
save_h5->setChecked(true);
auto *save_merged = new QCheckBox("Write merged .mtz/.cif", &dlg);
save_merged->setChecked(true);
save_merged->setEnabled(!azint); // no merged output in azimuthal-integration mode
// Default the output next to the input file, not the viewer's working directory (the viewer starts
// wherever it was installed). This is an absolute path; Rugnux writes it via the trusted setter,
// and toNativeSeparators keeps a UNC/Windows path openable afterwards.
const QFileInfo fi(inputs.file);
const QString stem = fi.completeBaseName().remove(QStringLiteral("_master"));
const QString default_prefix = QDir::toNativeSeparators(
QStringLiteral("%1/%2_run%3").arg(fi.absolutePath(), stem).arg(job_counter_ + 1));
auto *prefix = new QLineEdit(default_prefix, &dlg);
// Rotation-vs-stills mode (rotation indexing + partiality + rot3d) is set in the settings panel via
// "Process as stills"; the dialog only collects run options. Scaling applies to MX full analysis.
auto *scaling = new QCheckBox("Scale && merge", &dlg);
scaling->setChecked(true);
scaling->setEnabled(!azint);
auto *form = new QFormLayout;
form->addRow("Start image", start_image);
form->addRow("End image", end_image);
form->addRow("Threads", threads);
form->addRow(save_h5);
form->addRow(save_merged);
form->addRow("Output prefix", prefix);
form->addRow(scaling);
int result = 0;
auto *run = new QPushButton("Run locally", &dlg);
auto *copy = new QPushButton("Copy command", &dlg);
auto *cancel = new QPushButton("Cancel", &dlg);
run->setDefault(true);
connect(run, &QPushButton::clicked, &dlg, [&] { result = 1; dlg.accept(); });
connect(copy, &QPushButton::clicked, &dlg, [&] { result = 2; dlg.accept(); });
connect(cancel, &QPushButton::clicked, &dlg, [&] { result = 0; dlg.reject(); });
auto *buttons = new QHBoxLayout;
buttons->addStretch();
buttons->addWidget(run);
buttons->addWidget(copy);
buttons->addWidget(cancel);
auto *layout = new QVBoxLayout(&dlg);
layout->addLayout(form);
layout->addLayout(buttons);
dlg.exec();
if (result != 0) {
spec.mode = azint ? ProcessMode::AzimuthalIntegration : ProcessMode::FullAnalysis;
spec.start_image = start_image->value();
spec.end_image = end_image->value();
spec.threads = threads->value();
spec.save_h5 = save_h5->isChecked();
spec.save_merged = save_merged->isEnabled() && save_merged->isChecked();
spec.prefix = prefix->text();
spec.scaling = scaling->isEnabled() && scaling->isChecked();
}
return result;
}
ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const ReprocessingInputs &inputs) const {
ProcessConfig config;
config.mode = spec.mode;
config.nthreads = spec.threads;
config.start_image = spec.start_image;
config.end_image = spec.end_image > 0 ? spec.end_image : -1; // 0 => to the end
// Files land at output_prefix; leave it empty (write nothing, stats only) when neither output is
// wanted. The two flags then select which files are actually written there.
config.output_prefix = (spec.save_h5 || spec.save_merged) ? spec.prefix.toStdString() : std::string();
config.write_process_h5 = spec.save_h5;
config.write_merged = spec.save_merged;
config.spot_finding = inputs.spot_finding;
if (spec.mode == ProcessMode::FullAnalysis) {
// Rotation indexing follows the panel's "Process as stills" (= the experiment's indexing
// setting); a rotation run uses 60 first-pass images to find the lattice.
config.rotation_indexing = inputs.experiment.GetIndexingSettings().GetRotationIndexing();
config.two_pass_rotation = true;
if (config.rotation_indexing)
config.rotation_indexing_image_count = 60;
config.run_scaling = spec.scaling;
config.reference_data = inputs.reference_data; // enables CCref / reference-based scaling
}
return config;
}
void JFJochProcessingJobsWindow::newJob(bool azint) {
const ReprocessingInputs inputs = worker_->GetReprocessingInputs();
if (!inputs.valid) {
QMessageBox::information(this, "Processing", "Open a file first (processing is not available for live HTTP data).");
return;
}
JobSpec spec;
const int action = askJob(inputs, spec, azint);
if (action == 0)
return;
const ProcessConfig config = buildConfig(spec, inputs);
// The experiment already carries the panel's indexing settings — including RotationIndexing set by
// "Process as stills" (needed so IndexAndRefine builds a rotation indexer) — so it is used as-is.
const DiffractionExperiment &experiment = inputs.experiment;
if (action == 2) { // copy command line
const QString cmd = QString::fromStdString(
RugnuxCommandLine(config, experiment, inputs.file.toStdString()));
QApplication::clipboard()->setText(cmd);
QMessageBox::information(this, "Command line", cmd + "\n\n(copied to clipboard)");
return;
}
if (controller_->running()) {
QMessageBox::information(this, "Processing", "A job is already running; wait for it to finish or cancel it.");
return;
}
const bool full = spec.mode == ProcessMode::FullAnalysis;
const int run_number = ++job_counter_;
const QString label = QStringLiteral("%1 %2").arg(full ? "Full" : "AzInt").arg(run_number);
const QString id = QStringLiteral("run-%1").arg(run_number);
JobInfo info;
info.id = id;
info.label = label;
if (spec.save_h5)
info.snapshot_path = QString::fromStdString(config.output_prefix) + "_process.h5";
const int row = table_->rowCount();
table_->insertRow(row);
jobs_.push_back(info); // jobs_[row] must exist before setItem(COL_NAME) fires itemChanged
const QString range_text = (spec.start_image == 0 && spec.end_image == 0)
? QStringLiteral("all")
: QStringLiteral("%1%2").arg(spec.start_image)
.arg(spec.end_image > 0 ? QString::number(spec.end_image) : QStringLiteral("end"));
const int expected = spec.end_image > spec.start_image ? spec.end_image - spec.start_image : 0;
table_->setItem(row, COL_NAME, new QTableWidgetItem(label)); // editable (default flags)
table_->setItem(row, COL_STARTED, fixedItem(QDateTime::currentDateTime().toString("HH:mm:ss")));
table_->setItem(row, COL_MODE, fixedItem(full ? "Full" : "AzInt"));
table_->setItem(row, COL_IMAGES, fixedItem(range_text));
table_->setItem(row, COL_STATUS, fixedItem("queued"));
table_->setItem(row, COL_INDEX, fixedItem("-"));
table_->setItem(row, COL_CELL, fixedItem("-"));
addRowActions(row, id);
// A progress bar lives in the Status cell while the job runs; it shows the phase as text until
// image processing starts, then "<done> / <expected>" with the bar filling in the background.
running_bar_ = new QProgressBar(table_);
running_bar_->setAlignment(Qt::AlignCenter);
running_bar_->setRange(0, expected > 0 ? expected : 1);
running_bar_->setValue(0);
running_bar_->setFormat("queued");
table_->setCellWidget(row, COL_STATUS, running_bar_);
running_row_ = row;
job_timer_.start();
controller_->start(inputs.file, experiment, inputs.pixel_mask, config);
emit jobStarted();
emit writeStatusBar("Started processing job " + label);
}
void JFJochProcessingJobsWindow::cancelJob() {
if (controller_->running()) {
controller_->cancel();
emit writeStatusBar("Cancelling processing job…");
}
}
void JFJochProcessingJobsWindow::removeResult() {
const int row = table_->currentRow();
if (row < 0 || row >= static_cast<int>(jobs_.size()))
return;
if (jobs_[row].id == "Original")
return; // the original file is always kept
if (row == running_row_) {
QMessageBox::information(this, "Processing", "Cancel the running job before removing it.");
return;
}
emit removeRun(jobs_[row].id); // drops the snapshot from the reader
QSignalBlocker block(table_); // row removal must not look like a rename
table_->removeRow(row);
jobs_.erase(jobs_.begin() + row);
if (running_row_ > row)
--running_row_;
}
void JFJochProcessingJobsWindow::addRowActions(int row, const QString &id) {
auto *w = new QWidget(table_);
auto *l = new QHBoxLayout(w);
l->setContentsMargins(2, 0, 2, 0);
l->setSpacing(2);
auto *graph = new QToolButton(w);
graph->setIcon(ToolbarIcons::linePlot());
graph->setAutoRaise(true);
graph->setToolTip("Show merge statistics");
graph->setEnabled(false); // enabled once a scaling/merging result arrives for this run
connect(graph, &QToolButton::clicked, this, [this, id] { showStats(id); });
auto *trash = new QToolButton(w);
trash->setIcon(style()->standardIcon(QStyle::SP_TrashIcon));
trash->setAutoRaise(true);
trash->setToolTip("Remove this run");
trash->setEnabled(id != "Original"); // the original file is always kept
connect(trash, &QToolButton::clicked, this, [this, id] { removeRunById(id); });
l->addWidget(graph);
l->addWidget(trash);
table_->setCellWidget(row, COL_ACTIONS, w);
jobs_[row].graph_btn = graph;
}
void JFJochProcessingJobsWindow::showStats(const QString &id) {
for (const auto &j: jobs_) {
if (j.id == id && j.has_merge_stats) {
auto *win = new JFJochMergeStatsWindow(j.label, j.merge_stats, j.isa, j.merge_has_reference,
j.twinning, j.space_group_number, j.space_group_search,
window());
win->show();
return;
}
}
}
void JFJochProcessingJobsWindow::removeRunById(const QString &id) {
for (int row = 0; row < static_cast<int>(jobs_.size()); ++row) {
if (jobs_[row].id != id)
continue;
if (id == "Original")
return;
if (row == running_row_) {
QMessageBox::information(this, "Processing", "Cancel the running job before removing it.");
return;
}
emit removeRun(id);
QSignalBlocker block(table_);
table_->removeRow(row);
jobs_.erase(jobs_.begin() + row);
if (running_row_ > row)
--running_row_;
return;
}
}
void JFJochProcessingJobsWindow::clearJobs() {
if (controller_->running())
controller_->cancel();
emit liveDataset(nullptr);
QSignalBlocker block(table_);
table_->setRowCount(0);
jobs_.clear();
job_counter_ = 0;
running_row_ = -1;
running_bar_ = nullptr;
addOriginalRow();
}
void JFJochProcessingJobsWindow::addOriginalRow() {
// The file's own data, listed as the first run so it can be shown like any reprocessing run.
JobInfo info;
info.id = "Original";
info.label = "Original";
info.has_result = true;
const int row = table_->rowCount();
table_->insertRow(row);
jobs_.push_back(info);
QSignalBlocker block(table_);
table_->setItem(row, COL_NAME, fixedItem("Original")); // reserved name, not editable
table_->setItem(row, COL_STATUS, fixedItem(""));
table_->setItem(row, COL_STARTED, fixedItem("—"));
table_->setItem(row, COL_MODE, fixedItem("HDF5"));
table_->setItem(row, COL_IMAGES, fixedItem("all"));
table_->setItem(row, COL_INDEX, fixedItem("-"));
table_->setItem(row, COL_CELL, fixedItem("-"));
addRowActions(row, info.id);
}
void JFJochProcessingJobsWindow::setActiveRun(QString active_id) {
QSignalBlocker block(table_);
for (int row = 0; row < static_cast<int>(jobs_.size()); row++) {
auto *item = table_->item(row, COL_NAME);
if (!item)
continue;
QFont f = item->font();
f.setBold(jobs_[row].id == active_id);
item->setFont(f);
}
}
void JFJochProcessingJobsWindow::viewResults() {
const int row = table_->currentRow();
if (row < 0 || row >= static_cast<int>(jobs_.size()))
return;
if (!jobs_[row].has_result) {
QMessageBox::information(this, "Processing", "This job has no saved results to view.");
return;
}
emit activateSnapshot(jobs_[row].id);
}
void JFJochProcessingJobsWindow::setStatus(int row, const QString &text) {
if (row >= 0 && row < table_->rowCount())
table_->item(row, COL_STATUS)->setText(text);
}
void JFJochProcessingJobsWindow::onPhase(QString phase) {
// The "Processing images" phase is shown by onProgress; other phases (first pass, scaling, ...)
// show their name as text over an empty bar.
if (!running_bar_ || phase == "Processing images")
return;
running_bar_->setRange(0, 1);
running_bar_->setValue(0);
running_bar_->setFormat(phase);
}
void JFJochProcessingJobsWindow::onProgress(quint64 done, quint64 total) {
if (!running_bar_)
return;
running_bar_->setRange(0, static_cast<int>(total));
running_bar_->setValue(static_cast<int>(done));
running_bar_->setFormat("%v / %m");
// Processing rate and ETA in the status bar.
if (job_timer_.isValid() && done > 0) {
const double secs = job_timer_.elapsed() / 1000.0;
if (secs > 0.0) {
const double hz = done / secs;
const double remaining = hz > 0.0 ? (total - done) / hz : 0.0;
emit writeStatusBar(QStringLiteral("Processing %1 Hz — ~%2 s remaining")
.arg(hz, 0, 'f', 1).arg(qRound(remaining)));
}
}
}
void JFJochProcessingJobsWindow::onFinished(ProcessResult result) {
emit liveDataset(nullptr); // the finished run takes over from the live overlay
const int row = running_row_;
running_row_ = -1;
if (row >= 0)
table_->removeCellWidget(row, COL_STATUS); // deletes running_bar_
running_bar_ = nullptr;
if (row < 0 || row >= static_cast<int>(jobs_.size()))
return;
setStatus(row, result.cancelled ? "cancelled" : "done");
if (result.indexing_rate.has_value())
table_->item(row, COL_INDEX)->setText(QStringLiteral("%1%").arg(result.indexing_rate.value() * 100.0, 0, 'f', 0));
if (result.consensus_cell.has_value()) {
const auto &c = result.consensus_cell.value();
table_->item(row, COL_CELL)->setText(
QStringLiteral("%1 %2 %3 %4 %5 %6")
.arg(c.a, 0, 'f', 1).arg(c.b, 0, 'f', 1).arg(c.c, 0, 'f', 1)
.arg(c.alpha, 0, 'f', 1).arg(c.beta, 0, 'f', 1).arg(c.gamma, 0, 'f', 1));
}
if (!result.cancelled && result.written_master_path.has_value() && !jobs_[row].snapshot_path.isEmpty()) {
jobs_[row].has_result = true;
emit registerSnapshot(jobs_[row].id, jobs_[row].label, jobs_[row].snapshot_path); // also activates it
}
// Capture merge statistics and surface the analysis window (auto-open once; recall later via the
// row's graph icon).
if (!result.cancelled && result.has_merge_statistics) {
jobs_[row].has_merge_stats = true;
jobs_[row].merge_stats = result.merge_statistics;
jobs_[row].isa = result.error_model_isa;
jobs_[row].merge_has_reference = result.has_reference;
jobs_[row].twinning = result.twinning;
jobs_[row].space_group_number = result.space_group_number;
jobs_[row].space_group_search = result.space_group_search;
if (jobs_[row].graph_btn)
jobs_[row].graph_btn->setEnabled(true);
showStats(jobs_[row].id);
}
emit writeStatusBar(result.cancelled ? "Processing cancelled" : "Processing finished");
}
void JFJochProcessingJobsWindow::onFailed(QString error) {
emit liveDataset(nullptr); // clear the live overlay
const int row = running_row_;
running_row_ = -1;
if (row >= 0)
table_->removeCellWidget(row, COL_STATUS); // deletes running_bar_
running_bar_ = nullptr;
setStatus(row, "failed");
QMessageBox::warning(this, "Processing failed", error);
}