Files
Jungfraujoch/viewer/windows/JFJochProcessingJobsWindow.cpp
leonarski_fandjungfrau 4dc2534dbf
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 18m57s
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 16m55s
Build Packages / build:windows:cuda (push) Successful in 18m48s
Build Packages / build:viewer-tgz:cpu (push) Successful in 13m10s
Build Packages / build:viewer-tgz:cuda (push) Successful in 14m45s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 22m23s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 20m12s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 23m7s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 20m43s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 23m9s
Build Packages / XDS test (durin plugin) (push) Successful in 12m26s
Build Packages / build:rpm (rocky9) (push) Successful in 24m58s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 23m20s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (JFJoch plugin) (push) Successful in 12m37s
Build Packages / build:rpm (rocky8) (push) Successful in 27m58s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m38s
Build Packages / Build documentation (push) Successful in 59s
Build Packages / DIALS test (push) Successful in 23m16s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m38s
v1.0.0.rc-162 (#72)
**Files written by Jungfraujoch now import correctly in DIALS, XDS and pyFAI.** A tilted detector, a grid scan, a still recorded at a goniometer position, and saturated or unreadable pixels were each described in a way that a third-party program acted on wrongly. If you process Jungfraujoch data outside Jungfraujoch, prefer this release to any earlier one.

* HDF5: the detector tilt (`rot1`/`rot2`/`rot3`) is exported correctly in the NXmx transformation chain; untilted geometries are unaffected.
* HDF5: a still recorded at a goniometer position is no longer read back as a single image, and a grid scan records a stationary spindle so a program that requires a rotation axis can open it.
* HDF5: the sample transformation chain is written in mounting order, with a Smargon head position told apart from the spindle, one entry per image, `module_offset` as a float unit vector, and `offset_units` on every offset.
* HDF5: saturated, underloaded and unreadable pixels are described so a downstream program masks them - `saturation_value`, `underload_value`, `error_value` and `bit_depth_readout` are written correctly, and a data file missing next to a VDS master reads as the error marker rather than as zero counts.
* HDF5: the rotation axis is read back under whatever name it carries, and `mirror_y` records whether the assembled image is mirrored in Y relative to the detector's raw readout.
* A grid scan and a goniometer axis can both be set; they are no longer alternatives.
* `images_per_file` is chosen from the acquisition when it is not given: a rotation sweep of at most 20000 images goes into a single data file, a grid scan splits on whole fast-axis rows, and stills and serial keep 1000.
* The writer refuses a stream whose start message declares a different pixel format than its images carry, and a DECTRIS detector sending signed images is no longer declared unsigned.
* The image stream can carry the sample transformation chain (`transformations`, in the END message); a producer that does not send it gets the same chain built by the writer.
* rugnux: fixing the space group with `-S` no longer prevents the lattice from being found - a lattice indexed in a different setting is reindexed into that group's own setting, and a run whose crystal does not have that group's lattice stops and names the cell it indexed as, rather than reporting statistics that cannot describe it.
* rugnux: the per-image resolution estimate now predicts the resolution the merged data reach rather than the highest-resolution spot found, and is reported as `SPOT_RESOLUTION_ESTIMATE`.
* rugnux: two runs of the same command on the same images produce the same merged intensities; the azimuthal profile written alongside them is not yet reproducible in the same way.
* rugnux: the offline lattice refinement is bounded by iterations rather than by a wall clock, so a loaded machine can no longer refine to a different lattice; a live acquisition keeps its real-time bound.
* rugnux: the detector-frame modulation correction is fitted on a grid spanning the detector, so whether it is applied no longer depends on how far integration reached.
* rugnux: the geometry pre-pass no longer writes `<prefix>_01.mtz`, `_01.cif`, `_01.hkl` and `_01_image.dat`; the refined second pass writes those files under `<prefix>`, and that is the result to use.
* rugnux: `_process.h5` describes the pixel format of the images it links to, and is written on a thread of its own.
* rugnux: the detector geometry is also logged in XDS's convention (`ORGX`/`ORGY`, detector axis vectors, rotation axis), so it can be compared with an XDS refinement.
* rugnux: an image integrated in pyFAI through the `.poni` file written by `--mode calibration` comes out with the correct azimuth, and the file declares pyFAI's `orientation`, which needs pyFAI 2024.01 or newer. Radial integration is unchanged.
* rugnux: a rotation run is substantially faster throughout - beam-stop detection, first-pass indexing, geometry refinement, integration, scaling and merging - and observations outside the scaling resolution range are dropped as they are ingested. The refined geometry, the space group chosen and the merged statistics are unchanged.
* Faster spot finding and indexing, on the broker as well as in rugnux; the spots found and the lattices indexed are unchanged.
* A run reserves substantially less GPU memory: nothing is allocated for buffers that are never read, and a worker builds only the engines it uses.
* rugnux: with `-N` left at its default the per-image loop of `--mode mx` uses at most 16 workers per GPU, rather than one per hardware thread; an explicit `-N` is obeyed as given.
* CUDA 12 builds now contain device code for Volta, so the RHEL 8 packages and the portable Linux `.tgz` run on a V100; the CUDA 13 artefacts (RHEL 9, Ubuntu, Windows) remain Turing and newer.
* The build resolves a single Eigen for the whole project, and refuses to configure if Ceres picks up a different one; a build that mixed two Eigen versions was undefined behaviour and crashed at -O2.
* Documentation: a security page, and the supported GPU generations and minimum NVIDIA driver version of every released artefact.

**Breaking change to OpenAPI** - regenerate the client (`jfjoch-client` 1.0.0-rc.162, `frontend/src/client`):
* `dataset_settings.images_per_file` is no longer `default: 1000` and no longer accepts `0`; it is optional, and its minimum is 1. A client sending `0` (previously "one file for the whole run") is now rejected - omit the field instead, which for a rotation sweep gives the same single file.
* `file_writer_format` now defaults to `NXmxVDS`, matching the server's own default and the layout recommended for DIALS, XDS and CrystFEL. A generated client that fills in schema defaults and does not set the format explicitly will write VDS masters where it previously wrote legacy ones; set `NXmxLegacy` explicitly to keep them.

---------

Co-authored-by: jungfrau <jungfrau@mx-aare-test.psi.ch>
Reviewed-on: #72
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-08-25 08:21:39 +02:00

779 lines
36 KiB
C++
Raw Permalink 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 (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(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);
}