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_;