// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "JFJochProcessingJobsWindow.h" #include "../../process/JFJochProcessCommandLine.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include 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_COUNT }; int default_threads() { const unsigned hc = std::thread::hardware_concurrency(); return hc == 0 ? 4 : static_cast(hc); } QTableWidgetItem *fixedItem(const QString &text) { // a non-editable cell auto *cell = new QTableWidgetItem(text); cell->setFlags(cell->flags() & ~Qt::ItemIsEditable); return cell; } } JFJochProcessingJobsWindow::JFJochProcessingJobsWindow(JFJochImageReadingWorker *worker, QWidget *parent) : QWidget(parent), worker_(worker) { setWindowTitle("Processing"); controller_ = new JFJochProcessController(this); connect(controller_, &JFJochProcessController::phaseChanged, this, &JFJochProcessingJobsWindow::onPhase); connect(controller_, &JFJochProcessController::progress, this, &JFJochProcessingJobsWindow::onProgress); connect(controller_, &JFJochProcessController::finished, this, &JFJochProcessingJobsWindow::onFinished); connect(controller_, &JFJochProcessController::failed, this, &JFJochProcessingJobsWindow::onFailed); connect(controller_, &JFJochProcessController::liveDataset, this, &JFJochProcessingJobsWindow::liveDataset); toolbar_ = new QToolBar("Jobs", this); toolbar_->setMovable(false); toolbar_->addAction("New job…", this, &JFJochProcessingJobsWindow::newJob); toolbar_->addAction("Cancel", this, &JFJochProcessingJobsWindow::cancelJob); toolbar_->addAction("Remove result", this, &JFJochProcessingJobsWindow::removeResult); 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(true); 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(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(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(this); dlg.setWindowTitle("New processing job"); auto *mode = new QComboBox(&dlg); mode->addItem("Full analysis", static_cast(ProcessMode::FullAnalysis)); mode->addItem("Azimuthal integration", static_cast(ProcessMode::AzimuthalIntegration)); 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()); auto *save = new QCheckBox("Save _process.h5", &dlg); save->setChecked(true); const QString stem = QFileInfo(inputs.file).completeBaseName().remove(QStringLiteral("_master")); auto *prefix = new QLineEdit(QStringLiteral("%1_run%2").arg(stem).arg(job_counter_ + 1), &dlg); auto *rotation = new QCheckBox("Rotation indexing (two-pass)", &dlg); rotation->setChecked(inputs.experiment.GetGoniometer().has_value()); auto *rotation_images = new QSpinBox(&dlg); // images used to find the rotation lattice rotation_images->setRange(2, 1'000'000); rotation_images->setValue(spec.rotation_images); // carries the default (30) auto *scaling = new QCheckBox("Scale && merge", &dlg); auto sync_full = [&] { const bool full = mode->currentData().toInt() == static_cast(ProcessMode::FullAnalysis); rotation->setEnabled(full); scaling->setEnabled(full); rotation_images->setEnabled(full && rotation->isChecked()); }; connect(mode, &QComboBox::currentIndexChanged, &dlg, sync_full); connect(rotation, &QCheckBox::toggled, &dlg, sync_full); sync_full(); auto *form = new QFormLayout; form->addRow("Mode", mode); form->addRow("Start image", start_image); form->addRow("End image", end_image); form->addRow("Threads", threads); form->addRow(save); form->addRow("Output prefix", prefix); form->addRow(rotation); form->addRow("Rotation images", rotation_images); form->addRow(scaling); int result = 0; auto *run = new QPushButton("Run locally", &dlg); auto *copy = new QPushButton("Copy command", &dlg); auto *cancel = new QPushButton("Cancel", &dlg); run->setDefault(true); connect(run, &QPushButton::clicked, &dlg, [&] { result = 1; dlg.accept(); }); connect(copy, &QPushButton::clicked, &dlg, [&] { result = 2; dlg.accept(); }); connect(cancel, &QPushButton::clicked, &dlg, [&] { result = 0; dlg.reject(); }); auto *buttons = new QHBoxLayout; buttons->addStretch(); buttons->addWidget(run); buttons->addWidget(copy); buttons->addWidget(cancel); auto *layout = new QVBoxLayout(&dlg); layout->addLayout(form); layout->addLayout(buttons); dlg.exec(); if (result != 0) { spec.mode = static_cast(mode->currentData().toInt()); spec.start_image = start_image->value(); spec.end_image = end_image->value(); spec.threads = threads->value(); spec.save = save->isChecked(); spec.prefix = prefix->text(); spec.rotation = rotation->isEnabled() && rotation->isChecked(); spec.rotation_images = rotation_images->value(); spec.scaling = scaling->isEnabled() && scaling->isChecked(); } return result; } ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const ReprocessingInputs &inputs) const { ProcessConfig config; config.mode = spec.mode; config.nthreads = spec.threads; config.start_image = spec.start_image; config.end_image = spec.end_image > 0 ? spec.end_image : -1; // 0 => to the end config.output_prefix = spec.save ? spec.prefix.toStdString() : std::string(); config.spot_finding = inputs.spot_finding; if (spec.mode == ProcessMode::FullAnalysis) { config.rotation_indexing = spec.rotation; config.two_pass_rotation = true; config.rotation_indexing_image_count = spec.rotation_images; config.run_scaling = spec.scaling; } return config; } void JFJochProcessingJobsWindow::newJob() { const ReprocessingInputs inputs = worker_->GetReprocessingInputs(); if (!inputs.valid) { QMessageBox::information(this, "Processing", "Open a file first (processing is not available for live HTTP data)."); return; } JobSpec spec; const int action = askJob(inputs, spec); if (action == 0) return; const ProcessConfig config = buildConfig(spec, inputs); // Rotation indexing must be enabled on the indexing settings too (config.rotation_indexing only // drives the two-pass pre-pass); otherwise IndexAndRefine never builds a rotation indexer. DiffractionExperiment experiment = inputs.experiment; if (spec.mode == ProcessMode::FullAnalysis) { auto idx = experiment.GetIndexingSettings(); idx.RotationIndexing(spec.rotation); experiment.ImportIndexingSettings(idx); } if (action == 2) { // copy command line const QString cmd = QString::fromStdString( JFJochProcessCommandLine(config, experiment, inputs.file.toStdString())); QApplication::clipboard()->setText(cmd); QMessageBox::information(this, "Command line", cmd + "\n\n(copied to clipboard)"); return; } if (controller_->running()) { QMessageBox::information(this, "Processing", "A job is already running; wait for it to finish or cancel it."); return; } const bool full = spec.mode == ProcessMode::FullAnalysis; const int run_number = ++job_counter_; const QString label = QStringLiteral("%1 %2").arg(full ? "Full" : "AzInt").arg(run_number); const QString id = QStringLiteral("run-%1").arg(run_number); JobInfo info; info.id = id; info.label = label; if (spec.save) info.snapshot_path = QString::fromStdString(config.output_prefix) + "_process.h5"; const int row = table_->rowCount(); table_->insertRow(row); jobs_.push_back(info); // jobs_[row] must exist before setItem(COL_NAME) fires itemChanged const QString range_text = (spec.start_image == 0 && spec.end_image == 0) ? QStringLiteral("all") : QStringLiteral("%1–%2").arg(spec.start_image) .arg(spec.end_image > 0 ? QString::number(spec.end_image) : QStringLiteral("end")); const int expected = spec.end_image > spec.start_image ? spec.end_image - spec.start_image : 0; table_->setItem(row, COL_NAME, new QTableWidgetItem(label)); // editable (default flags) table_->setItem(row, COL_STARTED, fixedItem(QDateTime::currentDateTime().toString("HH:mm:ss"))); table_->setItem(row, COL_MODE, fixedItem(full ? "Full" : "AzInt")); table_->setItem(row, COL_IMAGES, fixedItem(range_text)); table_->setItem(row, COL_STATUS, fixedItem("queued")); table_->setItem(row, COL_INDEX, fixedItem("-")); table_->setItem(row, COL_CELL, fixedItem("-")); // A progress bar lives in the Status cell while the job runs; it shows the phase as text until // image processing starts, then " / " 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 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(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::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("-")); } void JFJochProcessingJobsWindow::setActiveRun(QString active_id) { QSignalBlocker block(table_); for (int row = 0; row < static_cast(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(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(total)); running_bar_->setValue(static_cast(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(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 } 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); }