From 6194fe6fbf00b513c07cac090c72cfe8d0430b0f Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Fri, 7 Aug 2026 10:45:39 +0200 Subject: [PATCH] viewer: calibrate the whole dataset from "Analyze dataset" The powder panel could only calibrate the image on screen. Calibration is now a third page beside MX and AzInt, so the dataset button runs it over every image the same way it runs the other two - which is the point, since a powder ring is measured far better by summing a run than by one frame. The page carries the calibrant and the method (rings or spots); the interactive Guess/Refine buttons stay where they were and now share the one calibrant selection, so there is no second combo to drift. analyzeDataset() carries the ProcessMode rather than a bool: a third state was coming, and two bools would have had one combination that cannot be valid. The calibrant list gains ICE, which it could not offer before: the widget worked in unit cells, and hexagonal ice has none that generates its rings correctly (P6_3/mmc would include systematically absent ones). FindCenter now takes the ring list its first line used to derive, so the interactive path gets ice as well. The result window leads with the residual rms rather than the fitted sigma. The sigma is a formal scatter estimate and understates a bad fit badly - measured on ice, 0.215 px reported against a 1.70 px residual - while the rms separates a usable fit from one that has locked onto the wrong thing. A rings run needs the profile binned in azimuth; below four sectors it returns nothing at all. The viewer raises the count to 32 exactly as the CLI does, and says so in the panel and in the job dialog rather than doing it silently. Also fixes a CLI inconsistency this comparison exposed: rugnux's calibration branch never applied the standard offline analysis defaults, so it measured the rings in a profile built with the file's polarization factor while every other mode - and the viewer - uses 0.99. Found because the two disagreed by 0.005 px in PONI x, and confirmed by reproducing the viewer exactly with --polarization 0.99. With it applied the CLI and the viewer write byte-identical .poni files on LaB6 by rings, LaB6 by spots, and an iced dataset over 1800 images. Co-Authored-By: Claude Opus 5 (1M context) --- docs/CHANGELOG.md | 1 + docs/JFJOCH_VIEWER.md | 25 +++- rugnux/RugnuxCommandLine.cpp | 18 ++- rugnux/RugnuxCommandLine.h | 12 +- rugnux/rugnux_cli.cpp | 5 + viewer/CMakeLists.txt | 2 + viewer/JFJochImageReadingWorker.cpp | 4 +- viewer/JFJochImageReadingWorker.h | 4 +- viewer/JFJochViewerSidePanel.h | 2 +- viewer/widgets/JFJochViewerSettingsDock.cpp | 52 +++++-- viewer/widgets/JFJochViewerSettingsDock.h | 18 ++- viewer/widgets/PowderCalibrationWidget.cpp | 65 +++++---- viewer/widgets/PowderCalibrationWidget.h | 19 ++- .../windows/JFJochCalibrationResultWindow.cpp | 104 ++++++++++++++ .../windows/JFJochCalibrationResultWindow.h | 26 ++++ viewer/windows/JFJochProcessingJobsWindow.cpp | 128 +++++++++++++++--- viewer/windows/JFJochProcessingJobsWindow.h | 16 ++- 17 files changed, 420 insertions(+), 81 deletions(-) create mode 100644 viewer/windows/JFJochCalibrationResultWindow.cpp create mode 100644 viewer/windows/JFJochCalibrationResultWindow.h diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8df97d56..31dcd9f6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,7 @@ This is an UNSTABLE release. It includes many experimental features, as well as * rugnux: Rotation indexing **no longer keeps a metric symmetry that indexes almost nothing**. The Bravais class is decided from the unrefined FFT candidate against a fixed 3° tolerance, so a lattice pseudo-symmetric to a few tenths of a degree is promoted a class too far and the constraint then snaps a real angle to the ideal one - measured, one crystal's promoted cell indexed 2 of 60 validation frames where its own primitive cell indexed 39, and the run died. The first pass now drops such a promotion. Battery: 33/37 space groups matching XDS with one hard failure becomes 34/37 with none, every other crystal identical. * rugnux: New **detector-calibration mode** (`--mode calibration`) - fit PONI x/y, the two tilts and the distance to a calibrant's powder rings over the whole dataset and write a pyFAI `.poni` file. `--calibrant lab6|agbh|ceo2|si|ice` (ice calibrates a real experiment against its own ice rings, so no calibrant exposure is needed); `--calibration rings` (default) sums the azimuthally-binned profile over every image and fits the ring arcs in it, `--calibration spots` pools the found spots instead. Measured on a LaB6 distance series, both routes land within 0.1 px of an independent reference, and on a crystal with smooth ice the beam centre moves from 5.5 px off to 0.3 px. * rugnux: **`--mode` replaces `--azint-only` and `--scale`** (see below). +* jfjoch_viewer: **"Analyze dataset" can run the detector calibration** over the whole dataset. The settings panel's MX/AzInt toggle gains a third *Calib* page carrying the calibrant (now including `ice`, matching the CLI) and the rings/spots method; the run writes a `.poni` next to the other outputs and reports the fitted PONI, tilts and distance against the header values in their own window. * Powder calibration: the ring geometry fit can now read its rings off an **azimuthally-binned profile summed over a run** instead of a spot list from one image (`RingsFromAzimuthalProfile`). A powder ring is an arc, not a set of spots, and its roundness fixes the beam centre without reference to the calibrant's d-spacings or the detector distance - the one parameter Bragg data constrain worst. The extraction window is capped at half the gap to the neighbouring ring (hexagonal ice has three rings within 0.06 1/A, which a fixed window merged into one), and where only one ring is in reach the tilts are held rather than fitted, since a single ring cannot separate them from the centre. * rugnux: De-novo **space-group search** substantially more robust - centering ranked by net absences and judged on absent-class strength, merohedral-twin over-promotion vetoed, and genuine high-symmetry groups recovered on weak data. * rugnux: The space-group search takes systematic absences from the merge of all observations, needs at least three control reflections on an axial row to claim a **screw axis**, and no longer alters the production merge. diff --git a/docs/JFJOCH_VIEWER.md b/docs/JFJOCH_VIEWER.md index 8d881078..b355d7de 100644 --- a/docs/JFJOCH_VIEWER.md +++ b/docs/JFJOCH_VIEWER.md @@ -26,12 +26,25 @@ install it. - Runs an **embedded data-processing pipeline** — the same analysis code as the rest of Jungfraujoch — performing spot finding, indexing and integration on the displayed image, with the result drawn over it. This interactive analysis is not written anywhere. -- Runs **full processing jobs** on the open dataset from the *Processing* panel, on the same - [`rugnux`](RUGNUX.md) engine and off the GUI thread: full analysis or azimuthal integration only, - over a chosen image range, optionally writing `_process.h5` and the merged `.mtz`/`.cif`. A - finished run becomes a selectable view of the dataset, so several processing runs can be compared - against each other, and its merging statistics open in their own window. The equivalent `rugnux` - command line can also be copied out to run the same job on a cluster instead. +- Runs **full processing jobs** on the open dataset with *Analyze dataset*, on the same + [`rugnux`](RUGNUX.md) engine and off the GUI thread. The settings panel's **MX / AzInt / Calib** + toggle decides what a run does — full analysis, azimuthal integration only, or a detector + calibration — over a chosen image range, optionally writing `_process.h5` and the merged + `.mtz`/`.cif`. A finished run becomes a selectable view of the dataset, so several processing runs + can be compared against each other, and its merging statistics (or, for a calibration, its fitted + geometry) open in their own window; the *Processing* panel lists the runs and reopens those + results. The equivalent `rugnux` command line can also be copied out to run the same job on a + cluster instead. +- **Detector calibration** against a powder standard, on the *Calib* page: pick the calibrant + (`LaB6`, `AgBh`, `CeO2`, `Si`, `ice`, or the open dataset's own unit cell) and fit either the image + on screen (*Guess* / *Refine detector calibration*) or the whole dataset (*Analyze dataset*, which + writes a pyFAI `.poni`). The whole-dataset fit measures the rings either from the + azimuthally-binned profile summed over the run (*Rings*, the default) or from the pooled spot lists + (*Spots*), and reports PONI x/y, the two tilts and the distance against the header values. Judge it + by the **radial rms**, not the beam-centre sigma: the sigma shrinks with the number of ring points, + so a fit that sits a couple of pixels off every ring can still report a small one. *Rings* needs + the run to be integrated in azimuthal sectors — with the AzInt page's *Azimuthal bins* below 4 the + calibration run raises it to 32, as `rugnux --mode calibration` does, and says so. - **Settings** panel for the geometry, unit cell, spot finding, indexing, azimuthal integration, Bragg integration, scaling, powder calibration and a reference dataset — the same settings the CLI takes. diff --git a/rugnux/RugnuxCommandLine.cpp b/rugnux/RugnuxCommandLine.cpp index fbeabf84..05a10cab 100644 --- a/rugnux/RugnuxCommandLine.cpp +++ b/rugnux/RugnuxCommandLine.cpp @@ -51,13 +51,18 @@ namespace { std::string RugnuxCommandLine(const ProcessConfig &config, const DiffractionExperiment &experiment, - const std::string &input_file) { + const std::string &input_file, + const std::string &calibrant_name) { std::vector args; const bool azint = (config.mode == ProcessMode::AzimuthalIntegration); + const bool calibration = (config.mode == ProcessMode::Calibration); args.emplace_back("rugnux"); if (azint) { args.emplace_back("--mode"); args.emplace_back("azint"); + } else if (calibration) { + args.emplace_back("--mode"); + args.emplace_back("calibration"); } auto add = [&](const std::string &flag, const std::string &val) { @@ -75,7 +80,16 @@ std::string RugnuxCommandLine(const ProcessConfig &config, if (config.stride != 1) add("-t", std::to_string(config.stride)); - if (azint) { + if (calibration) { + if (!calibrant_name.empty()) + add("--calibrant", calibrant_name); + add("--calibration", config.calibration_method == CalibrationMethod::Rings ? "rings" : "spots"); + // The rings method reads the ring at every azimuth, and the GUI may have raised the sector + // count for this run, so emit the value actually used rather than relying on the CLI default. + if (config.calibration_method == CalibrationMethod::Rings) + add("--azim-phi-bins", + std::to_string(experiment.GetAzimuthalIntegrationSettings().GetAzimuthalBinCount())); + } else if (azint) { const auto a = experiment.GetAzimuthalIntegrationSettings(); add("--azim-min-q", num(a.GetLowQ_recipA())); // An unset maximum Q means "to the detector edge"; emitting the resolved number would pin it diff --git a/rugnux/RugnuxCommandLine.h b/rugnux/RugnuxCommandLine.h index 203204d7..f387a168 100644 --- a/rugnux/RugnuxCommandLine.h +++ b/rugnux/RugnuxCommandLine.h @@ -9,10 +9,12 @@ class DiffractionExperiment; -// Reconstruct an equivalent rugnux command line (including --mode azint) for a configured run, so a -// job set up in the GUI can be handed off to a cluster. Covers the settings that matter for the -// run, not every obscure flag; geometry is taken from the input file, so geometry overrides are -// not emitted. +// Reconstruct an equivalent rugnux command line (including --mode azint / calibration) for a +// configured run, so a job set up in the GUI can be handed off to a cluster. Covers the settings that +// matter for the run, not every obscure flag; geometry is taken from the input file, so geometry +// overrides are not emitted. calibrant_name is only used by ProcessMode::Calibration - the config +// carries the calibrant's rings, and --calibrant takes the name they were resolved from. std::string RugnuxCommandLine(const ProcessConfig &config, const DiffractionExperiment &experiment, - const std::string &input_file); + const std::string &input_file, + const std::string &calibrant_name = ""); diff --git a/rugnux/rugnux_cli.cpp b/rugnux/rugnux_cli.cpp index 61ddfc1b..43bfeb9c 100644 --- a/rugnux/rugnux_cli.cpp +++ b/rugnux/rugnux_cli.cpp @@ -1522,6 +1522,11 @@ static int RunRugnux(int argc, char **argv) { // --mode calibration: fit the detector geometry to the calibrant's powder rings, measured over // every selected image, and write it as a PONI file. if (mode == RugnuxMode::Calibration) { + // The same analysis defaults every other mode gets (configure_offline_output applies them for + // the MX path further down). The polarization factor is one of them and it enters the summed + // azimuthal profile the rings are measured in, so without this the CLI and the viewer's + // "Analyze dataset", which always applies them, would fit slightly different ring positions. + ApplyRugnuxExperimentDefaults(experiment); if (polarization_factor) experiment.PolarizationFactor(polarization_factor.value()); ProcessConfig config; diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 59858047..a1ac5c72 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -56,6 +56,8 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView windows/JFJochViewerMetadataWindow.h windows/JFJochMergeStatsWindow.cpp windows/JFJochMergeStatsWindow.h + windows/JFJochCalibrationResultWindow.cpp + windows/JFJochCalibrationResultWindow.h widgets/NumberLineEdit.cpp widgets/NumberLineEdit.h widgets/SliderPlusBox.cpp diff --git a/viewer/JFJochImageReadingWorker.cpp b/viewer/JFJochImageReadingWorker.cpp index adcf4f34..1fb1f8fd 100644 --- a/viewer/JFJochImageReadingWorker.cpp +++ b/viewer/JFJochImageReadingWorker.cpp @@ -532,14 +532,14 @@ void JFJochImageReadingWorker::Analyze() { EmitImageLoaded_i(current_image_ptr); } -void JFJochImageReadingWorker::FindCenter(const UnitCell& calibrant, bool guess) { +void JFJochImageReadingWorker::FindCenter(QVector ring_q, bool guess) { QMutexLocker locker(&m); if (!current_image_ptr) return; logger.Info("Finding center"); DiffractionGeometry geom = current_image_ptr->Dataset().experiment.GetDiffractionGeometry(); - const std::vector ring_Q = CalculateXtalRings(calibrant); + const std::vector ring_Q(ring_q.begin(), ring_q.end()); try { if (guess) GuessGeometry(geom, current_image_ptr->ImageData().spots, ring_Q); diff --git a/viewer/JFJochImageReadingWorker.h b/viewer/JFJochImageReadingWorker.h index be9c5cb8..28c7180c 100644 --- a/viewer/JFJochImageReadingWorker.h +++ b/viewer/JFJochImageReadingWorker.h @@ -216,7 +216,9 @@ public slots: void UpdateDataset(const DiffractionExperiment& experiment); - void FindCenter(const UnitCell& calibrant, bool guess); + // The calibrant as its ring q values (2*pi/d, 1/A) rather than a cell, so a standard whose rings + // are measured rather than enumerated - hexagonal ice - can be calibrated against too. + void FindCenter(QVector ring_q, bool guess); void Analyze(); diff --git a/viewer/JFJochViewerSidePanel.h b/viewer/JFJochViewerSidePanel.h index baf1f249..c067f0f5 100644 --- a/viewer/JFJochViewerSidePanel.h +++ b/viewer/JFJochViewerSidePanel.h @@ -41,7 +41,7 @@ signals: void downloadROIs(); void uploadROIs(); void maskFromROI(QString name, bool add); - void findBeamCenter(const UnitCell &input, bool guess); + void findBeamCenter(QVector ring_q, bool guess); void analyze(); void imageLoaded(std::shared_ptr image); diff --git a/viewer/widgets/JFJochViewerSettingsDock.cpp b/viewer/widgets/JFJochViewerSettingsDock.cpp index aab113cc..72325525 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.cpp +++ b/viewer/widgets/JFJochViewerSettingsDock.cpp @@ -75,19 +75,22 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp " image / settings change while active"); analyzeDatasetBtn_ = new QPushButton(FramesIcon(3), " Analyze dataset", this); analyzeDatasetBtn_->setStyleSheet(heroStyle); - analyzeDatasetBtn_->setToolTip("Process the whole dataset (MX or azimuthal, per the toggle below)"); + analyzeDatasetBtn_->setToolTip("Process the whole dataset (MX, azimuthal or calibration, per the toggle below)"); auto *analyzeRow = new QHBoxLayout(); analyzeRow->addWidget(analyzeImageBtn); analyzeRow->addWidget(analyzeDatasetBtn_); layout->addLayout(analyzeRow); layout->addSpacing(10); connect(analyzeImageBtn, &QPushButton::toggled, this, &JFJochViewerSettingsDock::reanalyzeImage); - connect(analyzeDatasetBtn_, &QPushButton::clicked, this, [this] { emit analyzeDataset(azint_mode_); }); + connect(analyzeDatasetBtn_, &QPushButton::clicked, this, + [this] { emit analyzeDataset(mode_, powder_->Selection()); }); - // Segmented MX / AzInt toggle: the two communities pick their page; pages never share a screen. + // Segmented MX / AzInt / Calib toggle: each community picks its page; pages never share a screen. + // The page is also the mode "Analyze dataset" runs, so there is one control, not two. auto *mxButton = new QPushButton("MX", this); auto *azButton = new QPushButton("AzInt", this); - for (auto *b : {mxButton, azButton}) { + auto *calibButton = new QPushButton("Calib", this); + for (auto *b : {mxButton, azButton, calibButton}) { b->setCheckable(true); b->setStyleSheet("QPushButton:checked { background-color: #1F3A5F; color: white; }"); } @@ -96,17 +99,21 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp group->setExclusive(true); group->addButton(mxButton, 0); group->addButton(azButton, 1); + group->addButton(calibButton, 2); auto *toggleRow = new QHBoxLayout(); toggleRow->setSpacing(0); toggleRow->addWidget(mxButton); toggleRow->addWidget(azButton); + toggleRow->addWidget(calibButton); auto *stack = new QStackedWidget(this); stack->addWidget(BuildMXPage()); stack->addWidget(BuildAzIntPage()); + stack->addWidget(BuildCalibrationPage()); connect(group, &QButtonGroup::idClicked, this, [this, stack](int id) { - azint_mode_ = (id == 1); + mode_ = id == 1 ? ProcessMode::AzimuthalIntegration + : id == 2 ? ProcessMode::Calibration : ProcessMode::FullAnalysis; stack->setCurrentIndex(id); }); @@ -121,7 +128,7 @@ void JFJochViewerSettingsDock::setHttpConnection(bool connected, QString) { analyzeDatasetBtn_->setEnabled(!connected); analyzeDatasetBtn_->setToolTip(connected ? "Dataset re-processing is only available for an open file, not a live HTTP stream" - : "Process the whole dataset (MX or azimuthal, per the toggle below)"); + : "Process the whole dataset (MX, azimuthal or calibration, per the toggle below)"); } QWidget *JFJochViewerSettingsDock::BuildGeometrySection() { @@ -449,7 +456,18 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { }); highQ->setEnabled(!autoHighQ->isChecked()); - // Powder calibration (calibrant rings + geometry refinement) - reuse the existing widget. + layout->addStretch(); // anchor sections to the top so expanding an accordion grows downward + return page; +} + +QWidget *JFJochViewerSettingsDock::BuildCalibrationPage() { + auto *page = new QWidget(this); + auto *layout = new QVBoxLayout(page); + layout->setContentsMargins(0, 0, 0, 0); + + // The powder-calibration widget is the whole page: it already carries the calibrant and the two + // interactive (current-image) fits, and it now also carries the method a whole-dataset run uses, + // so the interactive and dataset paths share one calibrant selection. auto *powderSection = new CollapsibleSection("Powder calibration", page); auto *powderLayout = new QVBoxLayout(); powderLayout->setContentsMargins(0, 0, 0, 0); @@ -458,10 +476,26 @@ QWidget *JFJochViewerSettingsDock::BuildAzIntPage() { connect(powder_, &PowderCalibrationWidget::ringsFromCalibration, this, &JFJochViewerSettingsDock::ringsFromCalibration); powderLayout->addWidget(powder_); powderSection->setContentLayout(powderLayout); - powderSection->setExpanded(false); layout->addWidget(powderSection); - layout->addStretch(); // anchor sections to the top so expanding an accordion grows downward + // The rings method reads the ring's position at every azimuth, so the run has to be integrated in + // azimuthal sectors; one sector is a plain radial profile and cannot locate the ring. The bin count + // is the AzInt page's single setting - say here what it is set to rather than duplicating it. + auto *bins = new QLabel(page); + bins->setWordWrap(true); + bins->setStyleSheet("color: gray;"); + layout->addWidget(bins); + auto refreshBins = [this, bins] { + bins->setText(QStringLiteral("Rings method: azimuthal bins (AzInt page) = %1%2") + .arg(azint_.GetAzimuthalBinCount()) + .arg(azint_.GetAzimuthalBinCount() < 4 + ? QStringLiteral(" — too few to locate a ring; the run will use 32") + : QString())); + }; + refreshBins(); + connect(this, &JFJochViewerSettingsDock::azintChanged, this, [refreshBins] { refreshBins(); }); + + layout->addStretch(); return page; } diff --git a/viewer/widgets/JFJochViewerSettingsDock.h b/viewer/widgets/JFJochViewerSettingsDock.h index c95b67d1..dca2908a 100644 --- a/viewer/widgets/JFJochViewerSettingsDock.h +++ b/viewer/widgets/JFJochViewerSettingsDock.h @@ -14,7 +14,9 @@ #include "../../image_analysis/spot_finding/SpotFindingSettings.h" #include "../../reader/JFJochReaderDataset.h" #include "../../reader/JFJochReaderImage.h" +#include "../../rugnux/Rugnux.h" // ProcessMode #include "../ReferenceMtzInfo.h" +#include "PowderCalibrationWidget.h" // CalibrationSelection class QStackedWidget; class QCheckBox; @@ -23,11 +25,10 @@ class QLabel; class QPushButton; class SliderPlusBox; class NumberLineEdit; -class PowderCalibrationWidget; -// The single, always-visible settings panel: an MX / AzInt toggle picks the page, and the panel is -// the one place all processing settings live (there is no separate window, so no synchronisation). -// "Analyze image" / "Analyze dataset" sit on top; the MX/AzInt choice decides what a dataset run does. +// The single, always-visible settings panel: an MX / AzInt / Calib toggle picks the page, and the panel +// is the one place all processing settings live (there is no separate window, so no synchronisation). +// "Analyze image" / "Analyze dataset" sit on top; the page choice decides what a dataset run does. // Edits feed straight back into the running analysis via the worker. class JFJochViewerSettingsDock : public QWidget { Q_OBJECT @@ -50,11 +51,13 @@ signals: void braggChanged(BraggIntegrationSettings settings); void scalingChanged(ScalingSettings settings); void experimentChanged(const DiffractionExperiment &experiment); - void findBeamCenter(const UnitCell &calibrant, bool guess); + void findBeamCenter(QVector ring_q, bool guess); void ringsFromCalibration(QVector rings); void referenceSelected(QString path, QString column); // user picked a reference MTZ / column void reanalyzeImage(bool armed); // "Analyze image" toggle - void analyzeDataset(bool azint); // "Analyze dataset"; azint = AzInt page selected + // "Analyze dataset". The mode is the selected page; the calibrant selection is only read for + // ProcessMode::Calibration and is carried along so the job needs no second copy of the controls. + void analyzeDataset(ProcessMode mode, CalibrationSelection calibration); private: SpotFindingSettings spot_; @@ -70,7 +73,7 @@ private: // turns the pair into that field, and per-image only ever applies to stills. bool adaptive_min_pix_ = false; int64_t min_pix_value_ = 2; - bool azint_mode_ = false; // false = MX page, true = AzInt page (drives "Analyze dataset") + ProcessMode mode_ = ProcessMode::FullAnalysis; // the selected page, and what "Analyze dataset" runs // "Analyze dataset" hero button, disabled while a live HTTP source is connected. QPushButton *analyzeDatasetBtn_ = nullptr; @@ -112,6 +115,7 @@ private: QWidget *BuildScalingSection(); QWidget *BuildReferenceSection(); QWidget *BuildAzIntPage(); + QWidget *BuildCalibrationPage(); void SyncMinPix(); void EmitSpotFinding(); void EmitExperiment(); diff --git a/viewer/widgets/PowderCalibrationWidget.cpp b/viewer/widgets/PowderCalibrationWidget.cpp index 77c73c79..12b4081d 100644 --- a/viewer/widgets/PowderCalibrationWidget.cpp +++ b/viewer/widgets/PowderCalibrationWidget.cpp @@ -16,6 +16,15 @@ PowderCalibrationWidget::PowderCalibrationWidget(QWidget *parent) : QWidget(pare calibrantCombo = new QComboBox(this); updateCalibrantList(); + // How a whole-dataset run measures the rings. The two buttons below always work from the spots of + // the image on screen, so this only takes effect for "Analyze dataset". + methodCombo = new QComboBox(this); + methodCombo->addItem("Rings (summed profile)", static_cast(CalibrationMethod::Rings)); + methodCombo->addItem("Spots (pooled spot lists)", static_cast(CalibrationMethod::Spots)); + methodCombo->setToolTip("How \"Analyze dataset\" measures the powder rings: rings sums the " + "(q x azimuth) azimuthal profile over every image and fits the ring arcs in it; " + "spots pools the found spots and fits those."); + auto findBeamCenterButton = new QPushButton("Guess detector calibration", this); connect(findBeamCenterButton, &QPushButton::clicked,this, &PowderCalibrationWidget::findBeamCenterClicked); @@ -24,12 +33,10 @@ PowderCalibrationWidget::PowderCalibrationWidget(QWidget *parent) : QWidget(pare auto calibrantRingsButton = new QPushButton("Display calibrant rings", this); connect(calibrantRingsButton, &QPushButton::clicked, this, [this]() { - std::vector rings = CalculateXtalRings(GetCalibrant(), 10); - QVector q_rings; - for (float ring : rings) { - q_rings.append(2 * PI / ring); - } - emit ringsFromCalibration(q_rings); + QVector d_rings; + for (float q : GetCalibrantRings()) + d_rings.append(2 * PI / q); + emit ringsFromCalibration(d_rings); }); auto refine_row = new QGridLayout(); @@ -41,32 +48,41 @@ PowderCalibrationWidget::PowderCalibrationWidget(QWidget *parent) : QWidget(pare refine_row->addWidget(calibrantRingsButton,2, 0); + refine_row->addWidget(new QLabel("Dataset method:"),3,0); + refine_row->addWidget(methodCombo,3,1); + + auto hint = new QLabel("The two buttons fit the image on screen; \"Analyze dataset\" fits the whole " + "run and writes a .poni.", this); + hint->setWordWrap(true); + hint->setStyleSheet("color: gray;"); + layout->addLayout(refine_row); + layout->addWidget(hint); setLayout(layout); } -UnitCell PowderCalibrationWidget::GetCalibrant() const { - // The combo lists the shared calibrant table (in its order), then the current sample's own cell. - // Ice is skipped: it is a ring list rather than a cell, and this panel works in cells. - int idx = calibrantCombo->currentIndex(); - for (const auto &c : Calibrants()) { - if (!c.cell) - continue; - if (idx == 0) - return *c.cell; - idx--; - } +std::vector PowderCalibrationWidget::GetCalibrantRings() const { + // The combo lists the shared calibrant table (in its order), then the current sample's own cell, + // which is not a powder standard but is what a single-crystal image's spots actually sit on. + const int idx = calibrantCombo->currentIndex(); + const auto &table = Calibrants(); + if (idx >= 0 && idx < static_cast(table.size())) + return CalibrantRings(table[idx].name); if (sample_cell) - return sample_cell.value(); - return *Calibrants().front().cell; + return CalculateXtalRings(sample_cell.value()); + return CalibrantRings(table.front().name); +} + +CalibrationSelection PowderCalibrationWidget::Selection() const { + return {calibrantCombo->currentText(), GetCalibrantRings(), + static_cast(methodCombo->currentData().toInt())}; } void PowderCalibrationWidget::updateCalibrantList() { calibrantCombo->clear(); for (const auto &c : Calibrants()) - if (c.cell) - calibrantCombo->addItem(QString::fromStdString(c.name)); + calibrantCombo->addItem(QString::fromStdString(c.name)); if (sample_cell) calibrantCombo->addItem(QString("Current sample (%1 %2 %3 %4 %5 %6)") .arg(QString::number(sample_cell->a, 'f', 1)) @@ -85,10 +101,11 @@ void PowderCalibrationWidget::loadImage(std::shared_ptr } void PowderCalibrationWidget::findBeamCenterClicked() { - emit findBeamCenter(GetCalibrant(), true); + const std::vector rings = GetCalibrantRings(); + emit findBeamCenter(QVector(rings.begin(), rings.end()), true); } void PowderCalibrationWidget::optimizeBeamCenterClicked() { - emit findBeamCenter(GetCalibrant(), false); + const std::vector rings = GetCalibrantRings(); + emit findBeamCenter(QVector(rings.begin(), rings.end()), false); } - diff --git a/viewer/widgets/PowderCalibrationWidget.h b/viewer/widgets/PowderCalibrationWidget.h index 7e9ed4c9..217f30e8 100644 --- a/viewer/widgets/PowderCalibrationWidget.h +++ b/viewer/widgets/PowderCalibrationWidget.h @@ -5,23 +5,38 @@ #include #include +#include #include "../../common/UnitCell.h" #include "../../reader/JFJochReaderImage.h" +#include "../../rugnux/RugnuxCalibration.h" // CalibrationMethod +// What a whole-dataset calibration run is set to. The analysis library takes the calibrant as a ring +// list rather than a name (ice has no cell to enumerate rings from), so the name is resolved here. +struct CalibrationSelection { + QString name; + std::vector ring_q; // q = 2*pi/d [1/A], ascending + CalibrationMethod method = CalibrationMethod::Rings; +}; + +// Detector geometry from a powder standard: the calibrant, and how its rings are measured. The two +// buttons fit the image currently on screen; "Analyze dataset" reads Selection() and fits the whole +// dataset the same way rugnux --mode calibration does. class PowderCalibrationWidget : public QWidget { Q_OBJECT std::optional sample_cell; QComboBox* calibrantCombo{nullptr}; // stores current calibrant selection + QComboBox* methodCombo{nullptr}; // rings / spots, for the whole-dataset run - UnitCell GetCalibrant() const; + std::vector GetCalibrantRings() const; void updateCalibrantList(); signals: void ringsFromCalibration(QVector v); - void findBeamCenter(const UnitCell &input, bool guess); + void findBeamCenter(QVector ring_q, bool guess); public: PowderCalibrationWidget(QWidget* parent); + CalibrationSelection Selection() const; public slots: void loadImage(std::shared_ptr image); private slots: diff --git a/viewer/windows/JFJochCalibrationResultWindow.cpp b/viewer/windows/JFJochCalibrationResultWindow.cpp new file mode 100644 index 00000000..8000c81d --- /dev/null +++ b/viewer/windows/JFJochCalibrationResultWindow.cpp @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "JFJochCalibrationResultWindow.h" + +#include +#include +#include +#include +#include +#include + +#include "../../common/JFJochMath.h" // PI + +namespace { + constexpr double RAD_TO_DEG = 180.0 / PI; + + // A big-number "card" for the hero row, as in the merge-statistics window. + QWidget *MakeCard(const QString &value, const QString &caption, QWidget *parent) { + auto *card = new QFrame(parent); + card->setFrameShape(QFrame::StyledPanel); + auto *l = new QVBoxLayout(card); + l->setContentsMargins(12, 8, 12, 8); + l->setSpacing(0); + auto *v = new QLabel(value, card); + QFont f = v->font(); + f.setPointSizeF(f.pointSizeF() * 2.2); + f.setBold(true); + v->setFont(f); + v->setStyleSheet("color: #1F3A5F;"); + v->setAlignment(Qt::AlignCenter); + auto *c = new QLabel(caption, card); + c->setStyleSheet("color: gray;"); + c->setAlignment(Qt::AlignCenter); + l->addWidget(v); + l->addWidget(c); + return card; + } +} + +JFJochCalibrationResultWindow::JFJochCalibrationResultWindow(const QString &title, + const CalibrationResult &calibration, + const DiffractionGeometry &header, + const QString &poni_path, + QWidget *parent) + : QWidget(parent, Qt::Window) { + setWindowTitle("Detector calibration — " + title); + setAttribute(Qt::WA_DeleteOnClose); + resize(640, 380); + + const DiffractionGeometry &g = calibration.geometry; + const double pxl_mm = g.GetPixelSize_mm(); + + auto *layout = new QVBoxLayout(this); + + auto *hero = new QHBoxLayout(); + hero->addWidget(MakeCard(QString::number(calibration.rms_radial_pxl, 'f', 2), "Radial rms [px]", this)); + hero->addWidget(MakeCard(QString::number(calibration.beam_sigma_pxl, 'f', 3), "Beam σ [px]", this)); + hero->addWidget(MakeCard(QString::number(calibration.ring_points), "Ring points", this)); + layout->addLayout(hero); + + auto *table = new QTableWidget(6, 4, this); + table->setHorizontalHeaderLabels({"Quantity", "Fitted", "From header", "Change"}); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + + const struct { QString name; double fitted; double header; int digits; } rows[] = { + {"PONI x [px]", g.GetBeamX_pxl(), header.GetBeamX_pxl(), 3}, + {"PONI y [px]", g.GetBeamY_pxl(), header.GetBeamY_pxl(), 3}, + {"Rot1 [°]", g.GetPoniRot1_rad() * RAD_TO_DEG, header.GetPoniRot1_rad() * RAD_TO_DEG, 4}, + {"Rot2 [°]", g.GetPoniRot2_rad() * RAD_TO_DEG, header.GetPoniRot2_rad() * RAD_TO_DEG, 4}, + {"Distance [mm]", g.GetDetectorDistance_mm(), header.GetDetectorDistance_mm(), 4}, + }; + + for (int i = 0; i < 5; i++) { + const double change = rows[i].fitted - rows[i].header; + table->setItem(i, 0, new QTableWidgetItem(rows[i].name)); + table->setItem(i, 1, new QTableWidgetItem(QString::number(rows[i].fitted, 'f', rows[i].digits))); + table->setItem(i, 2, new QTableWidgetItem(QString::number(rows[i].header, 'f', rows[i].digits))); + table->setItem(i, 3, new QTableWidgetItem( + QStringLiteral("%1%2").arg(change >= 0 ? "+" : "").arg(change, 0, 'f', rows[i].digits))); + } + + // The PONI is the point of normal incidence, which is what the .poni file stores; the direct beam + // is where the beam actually lands, which is what every other program calls the beam centre. They + // part company by distance*tan(rot) as soon as the detector is tilted, so show both. + const auto [beam_x, beam_y] = g.GetDirectBeam_pxl(); + table->setItem(5, 0, new QTableWidgetItem("Direct beam [px]")); + table->setItem(5, 1, new QTableWidgetItem(QStringLiteral("%1, %2") + .arg(beam_x, 0, 'f', 3).arg(beam_y, 0, 'f', 3))); + table->setItem(5, 2, new QTableWidgetItem("—")); + table->setItem(5, 3, new QTableWidgetItem("—")); + layout->addWidget(table); + + auto *note = new QLabel(this); + note->setWordWrap(true); + note->setStyleSheet("color: gray;"); + note->setText(QStringLiteral("PONI x = %1 mm, PONI y = %2 mm.%3") + .arg(g.GetBeamX_pxl() * pxl_mm, 0, 'f', 4) + .arg(g.GetBeamY_pxl() * pxl_mm, 0, 'f', 4) + .arg(poni_path.isEmpty() ? QString() : " Written to " + poni_path)); + layout->addWidget(note); +} diff --git a/viewer/windows/JFJochCalibrationResultWindow.h b/viewer/windows/JFJochCalibrationResultWindow.h new file mode 100644 index 00000000..61a5107a --- /dev/null +++ b/viewer/windows/JFJochCalibrationResultWindow.h @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include + +#include "../../common/DiffractionGeometry.h" +#include "../../rugnux/RugnuxCalibration.h" // CalibrationResult + +// Pop-up result window for a finished whole-dataset detector calibration: the headline numbers that +// say whether the fit can be believed (radial rms, ring points, beam-centre sigma) above a table of +// every fitted quantity next to the header value it replaces. Opened automatically when a calibration +// job finishes and on demand from the processing-jobs dock. Modeless; deletes on close. +// +// The rms is the number to read, not the sigma: the sigma is what the scatter implies about the beam +// centre and shrinks with the number of ring points, so a fit with thousands of badly-scattered points +// reports a small sigma while sitting a couple of pixels off every ring. The rms says that outright. +class JFJochCalibrationResultWindow : public QWidget { + Q_OBJECT +public: + JFJochCalibrationResultWindow(const QString &title, const CalibrationResult &calibration, + const DiffractionGeometry &header, const QString &poni_path, + QWidget *parent = nullptr); +}; diff --git a/viewer/windows/JFJochProcessingJobsWindow.cpp b/viewer/windows/JFJochProcessingJobsWindow.cpp index 6aff0ed7..7598fa6f 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.cpp +++ b/viewer/windows/JFJochProcessingJobsWindow.cpp @@ -2,6 +2,7 @@ // SPDX-License-Identifier: GPL-3.0-only #include "JFJochProcessingJobsWindow.h" +#include "JFJochCalibrationResultWindow.h" #include "JFJochMergeStatsWindow.h" #include "../../rugnux/RugnuxDefaults.h" #include "../widgets/ToolbarIcons.h" @@ -52,6 +53,21 @@ namespace { 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) @@ -121,11 +137,14 @@ void JFJochProcessingJobsWindow::onHttpConnectionChanged(bool connected, QString toolbar_->setEnabled(!connected); } -int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec &spec, bool azint) { +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 (MX full analysis vs azimuthal integration) comes from the panel's MX/AzInt - // toggle, so the dialog only collects the run options. - dlg.setWindowTitle(azint ? "New azimuthal-integration job" : "New full-analysis job"); + // 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); @@ -144,11 +163,12 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec // 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(true); + 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); // no merged output in azimuthal-integration mode + 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, @@ -181,13 +201,13 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec // "Process as stills"; 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); + 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 = !azint + 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); @@ -215,7 +235,8 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec // cell + rotation axis from the whole sweep, then re-integrates at that geometry (the refined pass is the // canonical _* output, the header-geometry pass is kept as _01_*). Rotation-only; // default on there, to match the rugnux CLI. - const bool rotation_run = !azint && inputs.experiment.GetIndexingSettings().GetRotationIndexing(); + 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); @@ -235,6 +256,26 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec form->addRow(refineRow); form->addRow(postrefine); + // 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 .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); @@ -256,7 +297,6 @@ int JFJochProcessingJobsWindow::askJob(const ReprocessingInputs &inputs, JobSpec dlg.exec(); if (result != 0) { - spec.mode = azint ? ProcessMode::AzimuthalIntegration : ProcessMode::FullAnalysis; spec.start_image = start_image->value(); spec.end_image = end_image->value(); spec.threads = threads->value(); @@ -278,11 +318,21 @@ ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const 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. - config.output_prefix = (spec.save_h5 || spec.save_merged) ? spec.prefix.toStdString() : std::string(); + // 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.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 "Process as stills" (= the experiment's indexing // setting); a rotation run uses 60 first-pass images to find the lattice. @@ -302,7 +352,7 @@ ProcessConfig JFJochProcessingJobsWindow::buildConfig(const JobSpec &spec, const return config; } -void JFJochProcessingJobsWindow::newJob(bool azint) { +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)."); @@ -310,7 +360,9 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { } JobSpec spec; - const int action = askJob(inputs, spec, azint); + spec.mode = mode; + spec.calibration = std::move(calibration); + const int action = askJob(inputs, spec); if (action == 0) return; @@ -329,6 +381,15 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { // cleared, rather than silently pinning every later run to it. DiffractionExperiment experiment = inputs.experiment; ApplyRugnuxExperimentDefaults(experiment); + // 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. @@ -342,7 +403,8 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { if (action == 2) { // copy command line const QString cmd = QString::fromStdString( - RugnuxCommandLine(config, experiment, inputs.file.toStdString())); + RugnuxCommandLine(config, experiment, inputs.file.toStdString(), + spec.calibration.name.toStdString())); QApplication::clipboard()->setText(cmd); QMessageBox::information(this, "Command line", cmd + "\n\n(copied to clipboard)"); return; @@ -353,9 +415,8 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { 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 label = QStringLiteral("%1 %2").arg(mode_name(spec.mode)).arg(run_number); const QString id = QStringLiteral("run-%1").arg(run_number); JobInfo info; @@ -363,6 +424,11 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { 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); @@ -376,7 +442,7 @@ void JFJochProcessingJobsWindow::newJob(bool azint) { 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_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("-")); @@ -435,8 +501,8 @@ void JFJochProcessingJobsWindow::addRowActions(int row, const QString &id) { auto *graph = new QToolButton(w); graph->setIcon(ToolbarIcons::linePlot()); graph->setAutoRaise(true); - graph->setToolTip("Show merge statistics"); - graph->setEnabled(false); // enabled once a scaling/merging result arrives for this run + 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); @@ -454,6 +520,12 @@ void JFJochProcessingJobsWindow::addRowActions(int row, const QString &id) { 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, @@ -603,6 +675,22 @@ void JFJochProcessingJobsWindow::onFinished(ProcessResult result) { 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) { diff --git a/viewer/windows/JFJochProcessingJobsWindow.h b/viewer/windows/JFJochProcessingJobsWindow.h index c042bc43..384f0671 100644 --- a/viewer/windows/JFJochProcessingJobsWindow.h +++ b/viewer/windows/JFJochProcessingJobsWindow.h @@ -12,6 +12,7 @@ #include "../JFJochProcessController.h" #include "../JFJochImageReadingWorker.h" // ReprocessingInputs +#include "../widgets/PowderCalibrationWidget.h" // CalibrationSelection class QTableWidget; class QProgressBar; @@ -43,7 +44,9 @@ public slots: void onHttpConnectionChanged(bool connected, QString addr); void clearJobs(); // reset the table on a new file (re-adds the Original row) void setActiveRun(QString active_id); // bold the row of the run currently shown in the plots - void newJob(bool azint = false); // the panel's "Analyze dataset" action; azint = AzInt page + // The panel's "Analyze dataset" action; the mode is the panel's selected page. The calibrant + // selection is only used by ProcessMode::Calibration. + void newJob(ProcessMode mode = ProcessMode::FullAnalysis, CalibrationSelection calibration = {}); private slots: void cancelJob(); @@ -69,6 +72,11 @@ private: std::optional space_group_number; // final space group (searched or fixed) std::optional space_group_search; // ranked candidates, when a search ran std::vector> merged_i_sigma; // ISa diagnostic scatter + // Detector calibration result (Calibration mode): the fit, the header geometry it is compared + // against, and where the .poni was written. + std::optional calibration; + std::optional calibration_header; + QString poni_path; QToolButton *graph_btn = nullptr; // per-row "show statistics" button (enabled once stats exist) }; struct JobSpec { @@ -85,10 +93,11 @@ private: bool refine_geometry = false; // stills-only global geometry bundle-adjust (needs a known cell) int refine_geometry_frames = 200; // strong indexed frames fed to that bundle adjustment bool rotation_postrefine = true; // rotation-only two-pass geometry post-refine (default on) + CalibrationSelection calibration; // Calibration mode: calibrant rings + rings/spots method }; // Returns 0 = cancel, 1 = run locally, 2 = copy command line; fills spec from the dialog. - int askJob(const ReprocessingInputs &inputs, JobSpec &spec, bool azint); + int askJob(const ReprocessingInputs &inputs, JobSpec &spec); ProcessConfig buildConfig(const JobSpec &spec, const ReprocessingInputs &inputs) const; void setStatus(int row, const QString &text); void addOriginalRow(); // the file's own data as the first, always-present run @@ -98,6 +107,9 @@ private: JFJochImageReadingWorker *worker_; JFJochProcessController *controller_; + // The experiment a running calibration job was launched with: WritePoniFile needs the detector + // size and wavelength from it once the fit comes back. + DiffractionExperiment calibration_experiment_; QToolBar *toolbar_; QStackedWidget *stack_; // page 0: jobs table, page 1: HTTP-mode message QTableWidget *table_;