// 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 namespace { enum Column { COL_NAME = 0, COL_STARTED, COL_MODE, COL_IMAGES, COL_STATUS, COL_INDEX, COL_CELL, COL_COUNT }; int default_threads() { const unsigned hc = std::thread::hardware_concurrency(); return hc == 0 ? 4 : static_cast(hc); } } 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_->addSeparator(); toolbar_->addAction("View results", this, &JFJochProcessingJobsWindow::viewResults); toolbar_->addAction("Show original", this, &JFJochProcessingJobsWindow::showOriginal); table_ = new QTableWidget(0, COL_COUNT, this); table_->setHorizontalHeaderLabels({"Name", "Started", "Mode", "Images", "Status", "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); }); 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 *images = new QSpinBox(&dlg); images->setRange(0, 1'000'000'000); images->setValue(0); images->setSpecialValueText("all"); 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 *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); }; connect(mode, &QComboBox::currentIndexChanged, &dlg, sync_full); sync_full(); auto *form = new QFormLayout; form->addRow("Mode", mode); form->addRow("Images", images); form->addRow("Threads", threads); form->addRow(save); form->addRow("Output prefix", prefix); form->addRow(rotation); 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.images = images->value(); spec.threads = threads->value(); spec.save = save->isChecked(); spec.prefix = prefix->text(); spec.rotation = rotation->isEnabled() && rotation->isChecked(); 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.end_image = spec.images > 0 ? spec.images : -1; 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.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 auto fixed = [](const QString &text) { // a non-editable cell auto *cell = new QTableWidgetItem(text); cell->setFlags(cell->flags() & ~Qt::ItemIsEditable); return cell; }; table_->setItem(row, COL_NAME, new QTableWidgetItem(label)); // editable (default flags) table_->setItem(row, COL_STARTED, fixed(QDateTime::currentDateTime().toString("HH:mm:ss"))); table_->setItem(row, COL_MODE, fixed(full ? "Full" : "AzInt")); table_->setItem(row, COL_IMAGES, fixed(spec.images > 0 ? QString::number(spec.images) : "all")); table_->setItem(row, COL_STATUS, fixed("queued")); table_->setItem(row, COL_INDEX, fixed("-")); table_->setItem(row, COL_CELL, fixed("-")); // 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, spec.images > 0 ? spec.images : 1); running_bar_->setValue(0); running_bar_->setFormat("queued"); table_->setCellWidget(row, COL_STATUS, running_bar_); running_row_ = row; 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::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::showOriginal() { emit activateSnapshot("Original"); } 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"); } 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); }