Files
Jungfraujoch/viewer/windows/JFJochProcessingJobsWindow.cpp
T
leonarski_f 538f3504d3
Build Packages / build:windows:nocuda (push) Successful in 20m4s
Build Packages / Unit tests (push) Skipped
Build Packages / build:viewer-tgz:cpu (push) Successful in 16m5s
Build Packages / build:viewer-tgz:cuda (push) Successful in 17m26s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 27m46s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 20m17s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 26m13s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 23m17s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 28m11s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m30s
Build Packages / build:rpm (rocky8) (push) Successful in 24m34s
Build Packages / build:rpm (rocky9) (push) Successful in 21m30s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 23m33s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m18s
Build Packages / DIALS test (push) Successful in 18m23s
Build Packages / XDS test (durin plugin) (push) Successful in 11m30s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m16s
Build Packages / XDS test (neggia plugin) (push) Successful in 8m2s
Build Packages / Generate python client (push) Successful in 49s
Build Packages / Build documentation (push) Successful in 1m21s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 29m45s
v1.0.0.rc-161 (#71)
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: significantly better quality of results, and faster.** A large rework of integration, scaling, merging, geometry refinement and space-group determination, together with measurements the program previously made no attempt at - the direct beam before indexing, the beam stop, the goniometer rotation scale, and the stretches of a sweep the crystal did not deliver. A rotation dataset typically gains observations at better <I/sigma> and R_meas, and every `mx` and `scale` run writes a `<prefix>_report.txt` results report modelled on XDS's `CORRECT.LP`. Many defaults moved with it: spot detection is self-calibrating, beam-stop detection and rotation geometry post-refinement are on, resolution limits default to as far as the detector reaches, and ice-ring handling engages only where the crystal is measured to have ice.
* **jfjoch_viewer:** the beam-stop shadow, the detector calibration and the beam-centre measurement are reachable from "Analyze dataset"; the settings panel reports how the sample moved and how polarized the beam was; image rendering and interaction are faster.
* **Performance:** bitshuffle+LZ4 images are decoded on the GPU rather than on the host, with the bitshuffle inverse fused into preprocessing so the decompressed frame is never held in device memory.
* **Broker, writer, packaging and build:** image-slot lifetime and locking fixes, per-image datasets sized by the images actually written, the Debian/Ubuntu broker package renamed to `jfjoch`, and `image_analysis` compiling under MSVC again.

**Breaking change to the rugnux command line:**
* `--azint-only` and `--scale` are **removed**, replaced by `--mode azint` and `--mode scale`; the full pipeline is `--mode mx` and remains the default. A script passing the old flags now fails with the list of valid modes rather than silently running the wrong one.
* `-t`/`--stride` is **refused on rotation data**: skipping frames cuts every reflection's rocking curve, so the combined fulls and their partiality would be measured over frames the sweep never recorded. Select a contiguous range with `-s`/`-e` instead. `--mode azint` and `--force-still` still take a stride.

**Breaking changes to OpenAPI** - regenerate the client (`jfjoch-client` 1.0.0-rc.161, `frontend/src/client`) or read the affected fields as optional:
* `image_scale_b` is removed from the `plot_type` enum, so a client requesting that plot now gets an error rather than a curve.
* `azim_int_settings.high_q_recipA`, `spot_finding_settings.high_resolution_limit` and `spot_finding_settings.low_resolution_limit` are no longer `required`. All three mean "no limit at that end" when unset and are omitted from the response instead of carrying a placeholder value, which raises in a client generated from an rc.160-or-earlier spec. A value of 0 is still accepted and means the same thing.

**Breaking changes to the stored formats** - a consumer reading these fields must treat them as optional:
* The per-image image-scale B factor is no longer computed, so `/entry/MX/imageScaleBFactor` is absent from newly written HDF5 files and the corresponding key is absent from the CBOR DataMessage and END blocks. Files written by rc.160 and earlier still contain it and still open; nothing in the pipeline reads it any more.
* `_reflns.jfjoch_diffrn_ISa` now carries the whole-range `1/sqrt(a*b)` that XDS's ISa denotes, and the error-model `a` and `b` are reported in XDS's convention; the strong-reflection asymptote moves to `_reflns.jfjoch_diffrn_ISa_asymptotic`. **A file written by an earlier version carries the asymptote under the plain `ISa` name.**

Reviewed-on: #71
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-08-13 17:03:10 +02:00

779 lines
37 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 "../../rugnux/RugnuxDefaults.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;
}
// 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("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) {
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(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 = 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. The refined pass is the "
"canonical <prefix> output; the header-geometry pass is kept as <prefix>_01. 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(60);
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();
// Beam centre, measured in the same pre-scan and after the shadow: from the symmetry of the spots
// where the sweep reaches half a turn, from the radial background where it does not, and it replaces
// the header value only where it comes out precisely enough. Off by default, as in the rugnux CLI.
auto *beam_center = new QCheckBox("Measure beam centre before indexing", &dlg);
beam_center->setChecked(false);
beam_center->setToolTip(
"Measure the direct beam from the symmetry of the spots where the sweep reaches half a turn and "
"from the radial background where it does not, keeping the header value where neither can "
"measure it.");
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);
form->addRow(beam_center);
// 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 (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();
spec.estimate_beam_center = beam_center->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. A calibration always
// keeps the prefix - the .poni written next to it is the whole point of the run.
config.output_prefix = (spec.save_h5 || spec.save_merged || spec.mode == ProcessMode::Calibration)
? 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.detect_beam_stop)
config.detect_beam_stop = spec.detect_beam_stop_frames;
// `rugnux --estimate-beam-center` fits the spindle's skew about the beam along with the centre
// (--no-fit-spindle is the deviation), so the dialog's single switch sets both.
config.estimate_beam_center = spec.estimate_beam_center;
config.fit_spindle = spec.estimate_beam_center;
if (spec.mode == ProcessMode::Calibration) {
config.calibration_method = spec.calibration.method;
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); 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(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;
const ProcessConfig config = buildConfig(spec, inputs);
// 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`). The panel is filled from the file when one is
// opened, so what a run will use is always the crystal shown on screen - including after a finished
// job's _process.h5 becomes the active snapshot, where a run that ended in P1 shows P1 and can be
// cleared, rather than silently pinning every later run to it.
DiffractionExperiment experiment = inputs.experiment;
const auto polarization = experiment.GetPolarizationFactor();
ApplyRugnuxExperimentDefaults(experiment);
experiment.PolarizationFactor(polarization); // the panel's setting, not the rugnux default
// 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(),
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(config.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);
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_number, j.space_group_search,
j.merged_i_sigma, 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
}
// 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].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;
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);
}