Files
Jungfraujoch/viewer/windows/JFJochProcessingJobsWindow.cpp
T
leonarski_f 67dca388bd
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 18m44s
Build Packages / build:viewer-tgz:cpu (push) Successful in 6m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 6m54s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 9m40s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 10m41s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 10m10s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 10m4s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 11m5s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 12m23s
Build Packages / build:rpm (rocky8) (push) Successful in 11m30s
Build Packages / build:rpm (rocky9) (push) Successful in 12m51s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m8s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 11m21s
Build Packages / DIALS test (push) Successful in 13m22s
Build Packages / XDS test (durin plugin) (push) Successful in 9m2s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 7m55s
Build Packages / XDS test (neggia plugin) (push) Successful in 5m57s
Build Packages / Generate python client (push) Successful in 23s
Build Packages / Build documentation (push) Successful in 57s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 10m24s
v1.0.0-rc.160 (#70)
This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use.

* rugnux: Add `--model model.pdb` - score the merged data against an atomic model and compute initial maps. It reports R-work/R-free (scaling the model to the observed amplitudes with an overall scale, an anisotropic B and a flat bulk solvent - the standard few-parameter model, so a batch of maps stays directly comparable) and writes 2Fo-Fc / Fo-Fc electron-density maps (CCP4) plus a map-coefficient MTZ. The structure itself is not refined; the model is only re-fractionalised into the data cell.
* rugnux: The merged reflection output now carries French-Wilson amplitudes (|F| and its sigma) next to the intensities - MTZ `F`/`SIGF`, mmCIF `_refln.F_meas_au`, and the text HKL - computed with the correct centric/acentric Wilson prior and epsilon multiplicity, so a downstream program (e.g. phenix.refine) can refine against amplitudes. The intensity columns are unchanged.
* rugnux: R-free test-set flags are now assigned deterministically and consistently across symmetry - a Bijvoet pair I(+)/I(-) is never split between the work and free sets, and the assignment is a reproducible per-hkl hash that depends only on the reflection index, so every dataset of one crystal form gets the same ~5% free set (what a multi-dataset campaign such as PanDDA needs). On small data the fraction is floored so the test set stays large enough for a stable R-free (~500 reflections, capped at 10%); it stays flat at 5% on ordinary data. When a reference MTZ carries a `FreeR_flag` column its test set is imported instead, letting a whole campaign inherit one shared free set.
* rugnux: A reference MTZ (`--reference-mtz`) can now fix the space group and cell for rotation data too (previously rejected), without being used to scale - the rotation merge stays self-consistent. When the crystal has an indexing (merohedral) ambiguity - a lattice symmetry higher than its Laue symmetry, e.g. P3/P4/P6/C2 - the reference also resolves it: each candidate reindexing (identity plus the twin-law cosets of the metric symmetry) is scored by its intensity correlation against the reference and the data are re-merged in the best-correlating one. This is a metric-preserving relabelling of hkl (the cell is unchanged) and a no-op for a holohedral crystal such as lysozyme.
* rugnux: `--model` validation now aligns the data to the model before scoring - the observed reflections are reindexed into the model's enantiomorph when the two differ only by hand (indistinguishable from merged intensities). A merohedral indexing ambiguity is resolved against the reference MTZ when one is given (so a whole campaign shares one indexing convention); only with a model and no reference does validation fall back to fitting each candidate reindexing and keeping the lowest R-free.
* rugnux: De-novo symmetry - recover a genuine high-symmetry group whose data are imperfectly scaled. Such a merge's within-orbit chi² lands just past the self-consistency bound (each real symmetry step adds a little systematic scatter), right where a merohedral twin also lands, so the chi² ratio alone cannot separate them. The candidate is now rescued when the extra intensity-proportional systematic error it invokes stays small relative to the confirmed subgroup - a genuine symmetry step gains multiplicity without inflating the merge error model's b, whereas a twin forces non-equivalent reflections together and b balloons. Fixes cubic insulin (I23 instead of I222) with no change to any other crystal in the test battery, including the twins that must stay in their lower symmetry.
* Docs: Document the French-Wilson amplitude estimation, R-free flagging, reference-based space-group/ambiguity resolution, and model-based validation/maps in CPU_DATA_ANALYSIS.md.
* Frontend: The status-bar pill now shows a progress bar during detector calibration (previously only during measurement), and the calibration state and its button are labelled "Calibration"/"CALIBRATE" (the internal `Pedestal` state name is unchanged for back-compatibility).Reviewed-on: #70

Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-07-19 09:39:28 +02:00

613 lines
27 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 <QFileDialog>
#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);
// "Browse…" opens a file dialog to choose where the outputs go. The chosen name is used as a prefix
// (suffixes like _process.h5 / .cif are appended), so drop a trailing extension.
auto *browse = new QPushButton("Browse…", &dlg);
connect(browse, &QPushButton::clicked, &dlg, [&dlg, prefix] {
const QString sel = QFileDialog::getSaveFileName(&dlg, "Output file prefix", prefix->text(),
"All Files (*)", nullptr,
QFileDialog::DontConfirmOverwrite);
if (sel.isEmpty())
return;
const QFileInfo pf(sel);
prefix->setText(QDir::toNativeSeparators(pf.dir().filePath(pf.completeBaseName())));
});
auto *prefixRow = new QWidget(&dlg);
auto *prefixRowLayout = new QHBoxLayout(prefixRow);
prefixRowLayout->setContentsMargins(0, 0, 0, 0);
prefixRowLayout->addWidget(prefix);
prefixRowLayout->addWidget(browse);
// 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);
// Stills geometry-refinement two-pass: an extra first pass bundle-adjusts the shared beam/distance/
// cell from the strongest indexed frames, then the main pass re-indexes with it. It is stills-only
// and anchors on a known cell, so offer it only for a stills-with-cell run; default it on there, to
// match the rugnux CLI (a no-op for rotation / de-novo stills). Stills mode = rotation indexing off.
const bool stills_with_cell = !azint
&& !inputs.experiment.GetIndexingSettings().GetRotationIndexing()
&& inputs.experiment.GetUnitCell().has_value();
auto *refine_geometry = new QCheckBox("Refine geometry (stills)", &dlg);
refine_geometry->setChecked(stills_with_cell);
refine_geometry->setEnabled(stills_with_cell);
refine_geometry->setToolTip(
"Stills only: an extra first pass bundle-adjusts the shared beam / detector distance / cell from "
"the strongest indexed frames, then re-indexes every frame with it (lifts weak/sparse-stills "
"indexing). Needs a known unit cell; a no-op for rotation data.");
auto *refine_frames = new QSpinBox(&dlg);
refine_frames->setRange(10, 100000);
refine_frames->setValue(200);
refine_frames->setEnabled(stills_with_cell);
refine_frames->setToolTip("Number of strongly-indexed frames fed to the joint bundle adjustment.");
connect(refine_geometry, &QCheckBox::toggled, refine_frames, &QWidget::setEnabled);
auto *refineRow = new QWidget(&dlg);
auto *refineRowLayout = new QHBoxLayout(refineRow);
refineRowLayout->setContentsMargins(0, 0, 0, 0);
refineRowLayout->addWidget(refine_geometry);
refineRowLayout->addWidget(new QLabel("frames:", &dlg));
refineRowLayout->addWidget(refine_frames);
refineRowLayout->addStretch();
// Rotation two-pass geometry post-refine: a first pass post-refines the detector distance / beam /
// cell + rotation axis from the whole sweep, then re-integrates at that geometry (the refined pass is the
// canonical <prefix>_* output, the header-geometry pass is kept as <prefix>_01_*). Rotation-only;
// default on there, to match the rugnux CLI.
const bool rotation_run = !azint && inputs.experiment.GetIndexingSettings().GetRotationIndexing();
auto *postrefine = new QCheckBox("Post-refine geometry (rotation, two-pass)", &dlg);
postrefine->setChecked(rotation_run);
postrefine->setEnabled(rotation_run);
postrefine->setToolTip(
"Rotation only: a first pass post-refines the detector distance / beam centre / cell / rotation "
"axis from the whole sweep, then re-integrates at the refined geometry. The refined pass is the "
"canonical <prefix> output; the header-geometry pass is kept as <prefix>_01. Default on; a no-op for stills.");
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", prefixRow);
form->addRow(scaling);
form->addRow(refineRow);
form->addRow(postrefine);
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();
spec.refine_geometry = refine_geometry->isEnabled() && refine_geometry->isChecked();
spec.refine_geometry_frames = refine_frames->value();
spec.rotation_postrefine = postrefine->isEnabled() && postrefine->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
// Stills geometry-refinement two-pass. The dialog only offers it for a stills-with-cell run
// (Rugnux no-ops it otherwise), so honour it whenever set; unset leaves the pass off.
if (spec.refine_geometry)
config.refine_geometry = spec.refine_geometry_frames;
// Rotation two-pass geometry post-refine (rotation-only; the dialog defaults it on for rotation).
config.rotation_postrefine_geometry = spec.rotation_postrefine;
}
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);
}