Files
Jungfraujoch/viewer/windows/JFJochProcessingJobsWindow.cpp
T
leonarski_fandClaude Opus 5.5 5f731db81d rugnux: the library returns its outputs in memory; the callers write the files
The rugnux library wrote its results itself: _process.h5 through a
FileWriter, the merged/twin/_mr reflection files, the unmerged MTZs, the
maps and their MTZ, the placed models, _detector.jpg and _plot.txt, and
on a doubled cell it renamed files on disk. It now writes none of them.

- MtzTable (common/MtzTable.h): an MTZ file's header, columns and batch
  headers with no file behind it. It replaces MergedReflectionsMessage
  and MapCoefficientsMessage in the END message, with one CBOR encoding.
  WriteMtz (writer/WriteRugnuxFiles.h) is the one serialiser, used by
  rugnux and jfjoch_writer alike; it refuses a table whose columns differ
  in length, name a missing dataset, carry a malformed batch header or
  name a space group gemmi does not know. MergedMtzTable and
  UnmergedMtzTable build the tables the old writers built.
- ProcessResult::outputs (RugnuxOutputs) holds, for the output pass only:
  the answer's merge (MTZ table plus what the .cif/.hkl are written from),
  the twin hypothesis' and the model's choice's merges, the unmerged and
  unmerged-partials tables, the maps (coefficients always; the grids only
  with ProcessConfig::keep_map_grids), the placed models, the detector
  JPEG and the per-image plot table, and the start message of the
  no-lattice _process.h5. Map RMS is taken from the grid in memory, as the
  CCP4 header stated it, whether or not the map is kept.
- The per-image analysis goes to a caller-supplied RugnuxImageSink;
  rugnux and the viewer give a FileWriter-based ProcessFileSink. The
  geometry pre-pass no longer makes a _01_process.h5.
- The doubled cell keeps both output sets: the answer's in outputs, the
  other's in DoubledCell::other_outputs with its suffix; the report names
  files by suffix after the caller's prefix.
- The output prefix, write_process_h5 and the observation dump path are
  gone from ProcessConfig; --dump-observations is a callback.
- What used to be gated on having files to write (model validation and
  MR, the twin-hypothesis merges, the finalist ledger, the scaled fulls,
  the unmerged tables) is gated on the output pass instead, so a caller
  with no prefix gets the same results.
- RugnuxOutputFiles (new library JFJochRugnuxOutput, linked by rugnux,
  the viewer and the tests) writes every file from the result with the
  names and formats rugnux has always used. The viewer writes its job's
  files the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:51:20 +02:00

816 lines
39 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 "JFJochCalibrationResultWindow.h"
#include "JFJochMergeStatsWindow.h"
#include "../widgets/ToolbarIcons.h"
#include "../../rugnux/RugnuxCommandLine.h"
#include "../../writer/RugnuxModelFile.h"
#include "../../common/ThreadAffinity.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>
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() {
return static_cast<int>(AvailableCpus());
}
// Radii left where the settings panel starts them are not a choice, so the run takes rugnux's
// per-workflow default, as `rugnux` does without --integration-radius.
bool radii_set_by_hand(const DiffractionExperiment &experiment) {
const BraggIntegrationSettings shipped;
const auto &b = experiment.GetBraggIntegrationSettings();
return b.GetR1() != shipped.GetR1() || b.GetR2() != shipped.GetR2() || b.GetR3() != shipped.GetR3();
}
QTableWidgetItem *fixedItem(const QString &text) { // a non-editable cell
auto *cell = new QTableWidgetItem(text);
cell->setFlags(cell->flags() & ~Qt::ItemIsEditable);
return cell;
}
// Short name of a run mode, for the dialog title, the Mode column and the run label.
const char *mode_name(ProcessMode mode) {
switch (mode) {
case ProcessMode::AzimuthalIntegration: return "AzInt";
case ProcessMode::Calibration: return "Calib";
case ProcessMode::FullAnalysis:
default: return "Full";
}
}
// Azimuthal sectors a calibration by rings falls back to when the panel has too few of them: with
// one sector the profile has averaged the ring over every direction and cannot locate it. Same
// value, for the same reason, as rugnux --mode calibration.
constexpr int CALIBRATION_AZIM_BINS_DEFAULT = 32;
}
JFJochProcessingJobsWindow::JFJochProcessingJobsWindow(JFJochImageReadingWorker *worker, QWidget *parent)
: QWidget(parent), worker_(worker) {
setWindowTitle("Jobs");
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) {
QDialog dlg(window()); // centre on the main window, not inside the processing dock
// The kind of job comes from the panel's MX / AzInt / Calib toggle, so the dialog only collects
// the run options.
const bool azint = spec.mode == ProcessMode::AzimuthalIntegration;
const bool calibration = spec.mode == ProcessMode::Calibration;
dlg.setWindowTitle(calibration ? "New detector-calibration job"
: 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(!calibration);
save_h5->setEnabled(!calibration); // a calibration's output is the .poni, not per-image results
auto *save_merged = new QCheckBox("Write merged .mtz/.cif", &dlg);
save_merged->setChecked(true);
save_merged->setEnabled(!azint && !calibration); // merged output only in full analysis
// 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 by
// the goniometer rotation axis; 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 && !calibration);
// 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 = spec.mode == ProcessMode::FullAnalysis
&& !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(RUGNUX_REFINE_GEOMETRY_FRAMES);
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 (only the refined pass
// is written, as the canonical <prefix>_* output). Rotation-only;
// default on there, to match the rugnux CLI.
const bool rotation_run = spec.mode == ProcessMode::FullAnalysis
&& 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. Only the refined pass is "
"written, as the canonical <prefix> output. Default on; a no-op for stills.");
// Beam-stop shadow: a projection of a few frames shows where the stop and its holder shadow the
// detector; those pixels go into the mask (bit 9) so nothing behind them is integrated. Cheap and
// useful in every mode, so it is offered for all of them and defaulted on.
auto *beam_stop = new QCheckBox("Detect beam stop", &dlg);
beam_stop->setChecked(true);
beam_stop->setToolTip(
"Project a few frames, find where the beam stop and its holder shadow the detector, and add them "
"to the pixel mask (bit 9, cleared at the start of every run). Reflections behind the stop are "
"attenuated but not flagged, so they otherwise integrate low with a plausible sigma.");
auto *beam_stop_frames = new QSpinBox(&dlg);
beam_stop_frames->setRange(3, 100000);
beam_stop_frames->setValue(RUGNUX_DETECT_BEAM_STOP_FRAMES);
beam_stop_frames->setToolTip("Frames projected to find the shadow. Fewer leaves the background too "
"sparsely counted to tell a shadow from noise.");
connect(beam_stop, &QCheckBox::toggled, beam_stop_frames, &QWidget::setEnabled);
auto *beamStopRow = new QWidget(&dlg);
auto *beamStopRowLayout = new QHBoxLayout(beamStopRow);
beamStopRowLayout->setContentsMargins(0, 0, 0, 0);
beamStopRowLayout->addWidget(beam_stop);
beamStopRowLayout->addWidget(new QLabel("images:", &dlg));
beamStopRowLayout->addWidget(beam_stop_frames);
beamStopRowLayout->addStretch();
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);
form->addRow(beamStopRow);
// Calibration: the calibrant and method come from the panel's Calib page; state them here (with the
// azimuthal sector count the rings method depends on) so the run is not a surprise.
if (calibration) {
const bool rings = spec.calibration.method == CalibrationMethod::Rings;
const int panel_bins = inputs.experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount();
QString text = QStringLiteral("%1, %2 rings, method %3")
.arg(spec.calibration.name)
.arg(spec.calibration.ring_q.size())
.arg(rings ? "rings" : "spots");
if (!spec.calibration.refine_tilt)
text += ", detector tilt held fixed";
if (rings)
text += panel_bins < 4
? QStringLiteral("\nAzimuthal bins: %1 is too few to locate a ring — using %2")
.arg(panel_bins).arg(CALIBRATION_AZIM_BINS_DEFAULT)
: QStringLiteral("\nAzimuthal bins: %1").arg(panel_bins);
text += "\nWrites <output prefix>.poni";
auto *summary = new QLabel(text, &dlg);
summary->setWordWrap(true);
form->addRow("Calibration", summary);
}
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.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();
spec.detect_beam_stop = beam_stop->isChecked();
spec.detect_beam_stop_frames = beam_stop_frames->value();
}
return result;
}
ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const ReprocessingInputs &inputs) const {
ProcessConfig config;
config.mode = spec.mode;
config.nthreads = spec.threads;
// Left at every CPU, as `rugnux` without -N: the per-image loop is then capped where more workers
// than a card can feed only slow it down.
config.nthreads_auto = spec.threads == default_threads();
config.start_image = spec.start_image;
config.end_image = spec.end_image > 0 ? spec.end_image : -1; // 0 => to the end
// The files themselves are written by the controller, at the job's prefix (output_prefix below).
config.write_merged = spec.save_merged;
// The unmerged MTZ is a merged-output artefact - it is what another scaling program is handed
// instead of our merge - so it follows the merged switch rather than appearing beside a run that
// only asked for the per-image _process.h5.
config.export_unmerged = spec.save_merged;
config.spot_finding = inputs.spot_finding;
if (spec.detect_beam_stop)
config.detect_beam_stop = spec.detect_beam_stop_frames;
if (spec.mode == ProcessMode::Calibration) {
config.calibration_method = spec.calibration.method;
config.calibration_refine_tilt = spec.calibration.refine_tilt;
config.calibrant_ring_q = spec.calibration.ring_q;
// Spot finding for the spots method. Indexing is off: a calibration wants the spot positions and
// nothing else, and a calibrant is a powder with no lattice to index.
config.spot_finding.enable = true;
config.spot_finding.indexing = false;
}
if (spec.mode == ProcessMode::FullAnalysis) {
// Rotation indexing follows the panel's rotation axis (= the experiment's indexing
// setting); the first pass finds the lattice on as many images as rugnux's.
config.rotation_indexing = inputs.experiment.GetIndexingSettings().GetRotationIndexing();
config.two_pass_rotation = true;
config.rotation_indexing_image_count = RUGNUX_ROTATION_INDEXING_IMAGES;
// Measured in the pre-scan on rotation data, unless the panel's radii were changed by hand.
config.adaptive_integration_radius = config.rotation_indexing && !radii_set_by_hand(inputs.experiment);
// The spot budget is measured from the data, unless the panel moved it off rugnux's default
// (what --max-spots does on the command line).
config.measure_spot_budget = inputs.experiment.GetMaxSpotCount() == RUGNUX_MAX_SPOT_COUNT;
config.run_scaling = spec.scaling;
config.reference_data = inputs.reference_data; // enables CCref / reference-based scaling
config.reference_has_free_flags = inputs.reference_has_free_flags;
config.reference_cell = inputs.reference_cell;
config.reference_space_group = inputs.reference_space_group;
config.reference_path = inputs.reference_path.toStdString();
config.reference_column = inputs.reference_column.toStdString();
// 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(ProcessMode mode, CalibrationSelection calibration) {
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;
spec.mode = mode;
spec.calibration = std::move(calibration);
const int action = askJob(inputs, spec);
if (action == 0)
return;
ProcessConfig config = buildConfig(spec, inputs);
// 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. A calibration always
// keeps the prefix - the .poni written next to it is the whole point of the run.
const std::string output_prefix = (spec.save_h5 || spec.save_merged || spec.mode == ProcessMode::Calibration)
? spec.prefix.toStdString() : std::string();
// Validated at the end of the job, against the merge. Read here, so that a file holding no model is
// said at once rather than after the run.
if (spec.mode == ProcessMode::FullAnalysis && !inputs.model_path.isEmpty()) {
try {
config.model = std::make_shared<const RugnuxModel>(ReadRugnuxModelFile(inputs.model_path.toStdString()));
} catch (const std::exception &e) {
QMessageBox::warning(this, "Processing", QString("Cannot read the model: %1").arg(e.what()));
return;
}
}
// The experiment carries the panel's indexing settings — including RotationIndexing set by the
// rotation axis (needed so IndexAndRefine builds a rotation indexer). On top of that it gets
// exactly the defaults `rugnux` with no options would apply, so the two front ends agree on the
// analysis policy — except where the panel states its own, which wins (the polarization factor,
// and the scaling fields below).
//
// The cell and space group come from the settings panel and nowhere else: with "Unit cell known"
// ticked they are indexed and merged with, unticked the panel clears both and the run determines
// them from the data (-C / -S versus bare `rugnux`). A newly opened file, and a run's results when
// they are shown, start unticked - the crystal they store is shown but not used, as `rugnux` forgets
// it - so what a run uses is always the crystal ticked on screen.
DiffractionExperiment experiment = inputs.experiment;
const auto polarization = experiment.GetPolarizationFactor();
ApplyRugnuxExperimentDefaults(experiment);
experiment.PolarizationFactor(polarization); // the panel's setting, not the rugnux default
if (spec.mode == ProcessMode::FullAnalysis) {
// The panel's azimuthal-integration settings are for an azimuthal-integration job. In MX the
// profile feeds the ice-ring score and the background estimate, and no file states its
// settings, so the run takes the ones `rugnux` runs with: the defaults.
experiment.ImportAzimuthalIntegrationSettings(AzimuthalIntegrationSettings());
// Neither of these is on the panel, so both are what a bare `rugnux` decides.
experiment.DetectIceRings(RugnuxDetectIceRings(inputs.file_detect_ice_rings, config.rotation_indexing));
if (!radii_set_by_hand(experiment))
ApplyRugnuxIntegrationRadii(experiment, config.rotation_indexing);
// The interactive viewer searches fewer FFT directions on the CPU (JFJochViewerWindow); a run
// takes rugnux's.
IndexingSettings indexing = experiment.GetIndexingSettings();
indexing.FFT_NumVectors(IndexingSettings().GetFFT_NumVectors());
experiment.ImportIndexingSettings(indexing);
}
// Calibrating from the run-summed profile needs the profile to be binned in azimuth; the panel's
// bin count is an azimuthal-integration setting and defaults to a plain radial profile, which
// carries no information about where the ring centre is. Same fallback as the rugnux CLI.
if (spec.mode == ProcessMode::Calibration && spec.calibration.method == CalibrationMethod::Rings
&& experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount() < 4) {
AzimuthalIntegrationSettings azint = experiment.GetAzimuthalIntegrationSettings();
azint.AzimuthalBinCount(CALIBRATION_AZIM_BINS_DEFAULT);
experiment.ImportAzimuthalIntegrationSettings(azint);
}
if (spec.mode == ProcessMode::FullAnalysis && spec.scaling) {
ScalingSettings scaling = RugnuxDefaultScalingSettings(config.rotation_indexing);
// Keep what the settings dock does expose; take the rest from the shared defaults.
const auto &dock = inputs.experiment.GetScalingSettings();
scaling.MergeFriedel(dock.GetMergeFriedel());
scaling.CorrectionSurfaces(dock.GetCorrectionSurfaces());
scaling.StillsPartialityRefine(dock.GetStillsPartialityRefine());
scaling.HighResolutionLimit_A(dock.GetHighResolutionLimit_A());
scaling.LowResolutionLimit_A(dock.GetLowResolutionLimit_A());
experiment.ImportScalingSettings(scaling);
}
if (action == 2) { // copy command line
// The command line is run against the file, so it has to state what the panel changed. The
// one thing it cannot state is the rotation axis (rugnux has no flag for it) - but switching
// the axis off is `--force-still`, which RugnuxCommandLine emits from the axis the file has,
// not the one the panel cleared. Put the file's back for the command line only.
DiffractionExperiment cmdline_experiment = experiment;
if (!cmdline_experiment.GetGoniometer().has_value())
cmdline_experiment.Goniometer(inputs.file_goniometer);
const QString cmd = QString::fromStdString(
RugnuxCommandLine(config, cmdline_experiment, inputs.file.toStdString(), output_prefix,
spec.save_h5, spec.calibration.name.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 int run_number = ++job_counter_;
const QString label = QStringLiteral("%1 %2").arg(mode_name(spec.mode)).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(output_prefix) + "_process.h5";
if (spec.mode == ProcessMode::Calibration) {
info.poni_path = spec.prefix + ".poni";
info.calibration_header = experiment.GetDiffractionGeometry();
calibration_experiment_ = experiment;
}
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(mode_name(spec.mode)));
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, QString::fromStdString(output_prefix),
spec.save_h5);
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 results");
graph->setEnabled(false); // enabled once a merge / calibration 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.calibration.has_value() && j.calibration_header.has_value()) {
auto *win = new JFJochCalibrationResultWindow(j.label, *j.calibration, *j.calibration_header,
j.poni_path, window());
win->show();
return;
}
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, j.space_group_search,
j.merged_i_sigma, j.report, j.checks, 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, QString report, std::vector<ReportCheck> checks) {
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 && !jobs_[row].snapshot_path.isEmpty() && QFileInfo::exists(jobs_[row].snapshot_path)) {
jobs_[row].has_result = true;
emit registerSnapshot(jobs_[row].id, jobs_[row].label, jobs_[row].snapshot_path); // also activates it
}
// A finished calibration: write the .poni (the library leaves that to the caller, as in the CLI)
// and surface the fitted geometry, on the same auto-open-once / recall-by-icon pattern as merging.
if (!result.cancelled && result.calibration.has_value()) {
jobs_[row].calibration = result.calibration;
try {
WritePoniFile(jobs_[row].poni_path.toStdString(), calibration_experiment_,
result.calibration->geometry);
} catch (const std::exception &e) {
jobs_[row].poni_path.clear();
QMessageBox::warning(this, "Calibration", QString::fromStdString(e.what()));
}
if (jobs_[row].graph_btn)
jobs_[row].graph_btn->setEnabled(true);
showStats(jobs_[row].id);
}
// 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].report = report;
jobs_[row].checks = std::move(checks);
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 = result.space_group;
jobs_[row].space_group_search = result.space_group_search;
jobs_[row].merged_i_sigma = result.merged_i_sigma;
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);
}