Files
Jungfraujoch/viewer/widgets/JFJochViewerSettingsDock.cpp
leonarski_fandClaude Opus 5.5 18e50c590b viewer: no uncaught exceptions from slots (grid-scan "Image out of bounds" crash)
An exception escaping a Qt slot terminates the viewer. At the beamline it died with
"Input parameter invalid (Image out of bounds)": the grid-scan map in the dataset-info
panel asked GridScanSettings for the cell of an image outside the grid. Image and dataset
can disagree - live, the broker's image and dataset are fetched apart and can belong to
different collections, and the worker re-emits its current image against a newer dataset.
The map now marks no cell for such an image.

Opening a file with an image number it does not have (a D-Bus caller keeping its
position from the previous dataset) showed no image and kept the previous file's image,
which was then re-emitted against the new dataset. The file now opens at image 0 and the
previous image is dropped.

The rest of the viewer was audited for the same failure and guarded:
- worker slots that were not covered: dataset update, azimuthal/Bragg settings, analysis
  (Analyze, re-analysis toggle, spot-finding settings), beam-centre fit (incl. the setters
  rejecting a diverged fit), user mask update/save/upload, ROI definition, run removal;
  azimuthal settings with more bins than the mapping supports are refused up front, so they
  cannot be kept and fail every later file open;
- settings dock: azimuthal Q range, Bragg radii and experiment fields are built on a copy
  and an invalid combination keeps the previous settings; R1 and cell-angle field ranges
  now match the setters;
- azimuthal-image double-click on a bin with no pixel, azimuthal ROI handle dragged onto
  the beam centre (infinite d), ROI editor given inf/nan, non-numeric
  JUNGFRAUJOCH_HTTP_PORT.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:23:55 +01:00

1098 lines
55 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerSettingsDock.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QPushButton>
#include <QButtonGroup>
#include <QStackedWidget>
#include <QCheckBox>
#include <QComboBox>
#include <QRadioButton>
#include <QLabel>
#include <QLineEdit>
#include <QSignalBlocker>
#include <QFileDialog>
#include <QFileInfo>
#include <QIcon>
#include <QPixmap>
#include <QPainter>
#include <cmath>
#include "../../common/Definitions.h" // MAX_SPOT_COUNT
#include "SliderPlusBox.h"
#include "NumberLineEdit.h"
#include "PowderCalibrationWidget.h"
#include "CollapsibleSection.h"
#include "../ViewerTheme.h"
#include "../../common/JFJochMath.h"
#include "../../common/CUDAWrapper.h"
#include "../../gemmi_gph/gemmi/symmetry.hpp"
namespace {
// What the goniometer fields offer when the file describes no sample motion, so picking a mode
// on a still file starts from something sensible: the usual single rotation axis, and a raster
// of the size beamlines commonly use.
const char *DEFAULT_AXIS_NAME = "omega";
constexpr float DEFAULT_AXIS[3] = {-1.0f, 0.0f, 0.0f};
constexpr float DEFAULT_INCREMENT_DEG = 0.1f;
constexpr float DEFAULT_GRID_N_FAST = 10.0f;
constexpr float DEFAULT_GRID_STEP_UM = 20.0f;
// Polarized fraction of a synchrotron beam - the same default a bare `rugnux` run applies.
constexpr float DEFAULT_POLARIZATION = 0.99f;
// A single diffraction frame (image) vs a stack of frames (dataset), drawn white so they read on
// the navy "Analyze" hero buttons.
QIcon FramesIcon(int frames) {
const int S = 28;
QPixmap pm(S, S);
pm.fill(Qt::transparent);
QPainter p(&pm);
p.setRenderHint(QPainter::Antialiasing);
p.setPen(QPen(Qt::white, 2.0));
p.setBrush(Qt::NoBrush);
const double side = 13.0, step = 4.0;
for (int i = frames - 1; i >= 0; --i)
p.drawRoundedRect(QRectF(4 + i * step, 4 + i * step, side, side), 2.5, 2.5);
p.setBrush(Qt::white); // a diffraction "spot" in the front frame
p.drawEllipse(QPointF(4 + side / 2.0, 4 + side / 2.0), 2.0, 2.0);
p.end();
return QIcon(pm);
}
}
JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &spot,
const IndexingSettings &indexing,
const AzimuthalIntegrationSettings &azint,
const BraggIntegrationSettings &bragg,
const ScalingSettings &scaling,
QWidget *parent)
: QWidget(parent), spot_(spot), indexing_(indexing), azint_(azint), bragg_(bragg), scaling_(scaling),
adaptive_min_pix_(!spot.min_pix_per_spot.has_value()),
min_pix_value_(spot.min_pix_per_spot.value_or(2)) {
auto *layout = new QVBoxLayout(this);
// The two analysis actions sit on top of the panel. "Analyze image" is a toggle (re-analyse the
// current frame now and on every change while armed); "Analyze dataset" launches a processing job
// whose kind (MX vs azimuthal) is decided by the MX/AzInt toggle below — no separate switch.
auto *analyzeImageBtn = new QPushButton(FramesIcon(1), " Analyze image", this);
analyzeImageBtn->setCheckable(true);
SetThemedStyleSheet(analyzeImageBtn, HeroButtonStyle);
analyzeImageBtn->setToolTip("Re-analyse the current image now, and keep re-analysing on every"
" image / settings change while active");
analyzeDatasetBtn_ = new QPushButton(FramesIcon(3), " Analyze dataset", this);
SetThemedStyleSheet(analyzeDatasetBtn_, HeroButtonStyle);
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, &JFJochViewerSettingsDock::requestAnalyzeDataset);
// 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);
auto *calibButton = new QPushButton("Calib", this);
for (auto *b : {mxButton, azButton, calibButton}) {
b->setCheckable(true);
SetThemedStyleSheet(b, [] {
return QString("QPushButton:checked { background-color: %1; color: white; }")
.arg(AccentFillColor().name());
});
}
mxButton->setChecked(true);
auto *group = new QButtonGroup(this);
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(new QWidget(stack)); // AzInt: the shared section below is the whole page
stack->addWidget(BuildCalibrationPage());
connect(group, &QButtonGroup::idClicked, this, [this, stack](int id) {
mode_ = id == 1 ? ProcessMode::AzimuthalIntegration
: id == 2 ? ProcessMode::Calibration : ProcessMode::FullAnalysis;
stack->setCurrentIndex(id);
azintSection_->setVisible(id != 0);
});
// Geometry is common to all three, so it lives above the toggle rather than per page. Azimuthal
// integration is common to two of them - a calibration by rings integrates the run in azimuthal
// sectors, and needs the same Q range and spacing - so it is shared the same way and hidden on MX.
layout->addLayout(toggleRow);
layout->addWidget(BuildGeometrySection());
azintSection_ = BuildAzIntSection();
azintSection_->setVisible(false); // MX is the page selected on start
layout->addWidget(azintSection_);
layout->addWidget(stack);
layout->addStretch();
}
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, azimuthal or calibration, per the toggle below)");
}
QWidget *JFJochViewerSettingsDock::BuildGeometrySection() {
auto *section = new CollapsibleSection("Geometry", this);
auto *geom = new QFormLayout();
geom->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); // fields fill the panel width
energy_ = new NumberLineEdit(1.0, 200.0, 12.4, 4, "keV", this);
distance_ = new NumberLineEdit(10.0, 5000.0, 100.0, 2, "mm", this);
// Fields that share a row carry their unit in the row label instead of in a suffix: a spin box
// asks to be as wide as its longest possible value, and two or three of those abreast are wider
// than the dock. The panel already names units this way ("High resolution [Å]").
beamX_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "", this);
beamY_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "", this);
rot1_ = new NumberLineEdit(-180.0, 180.0, 0.0, 3, "", this);
rot2_ = new NumberLineEdit(-180.0, 180.0, 0.0, 3, "", this);
// Both are overwritten from the experiment as soon as a dataset is open; this is what they show
// until then.
polarizationOn_ = new QCheckBox("Polarization correction", this);
polarizationOn_->setChecked(true);
polarization_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_POLARIZATION, 3, "", this);
const QString polarizationTip =
"Polarized fraction of the beam in the horizontal plane (XDS FRACTION_OF_POLARIZATION): 0.99 "
"for a synchrotron, 0 for an unpolarized source. Divides out the Lp modulation in both the "
"azimuthal profile and the integrated Bragg intensities; unchecked = no correction at all.";
polarizationOn_->setToolTip(polarizationTip);
polarization_->setToolTip(polarizationTip);
// The detector origin is the PONI (PyFAI) point; in MX (XDS-style) terms it is the beam origin,
// which coincides with the beam center only when the detector is untilted.
const QString beamTip =
"Beam origin (XDS convention): the PONI point where the un-tilted beam meets the detector. "
"Equals the beam center only when the detector tilt is zero.";
beamX_->setToolTip(beamTip);
beamY_->setToolTip(beamTip);
const QString tiltTip = "Detector tilt about the two in-plane axes (PyFAI PONI rot1 / rot2), in degrees.";
rot1_->setToolTip(tiltTip);
rot2_->setToolTip(tiltTip);
auto *beam = new QHBoxLayout();
beam->addWidget(beamX_);
beam->addWidget(beamY_);
auto *tilt = new QHBoxLayout();
tilt->addWidget(rot1_);
tilt->addWidget(rot2_);
auto *polarization = new QHBoxLayout();
polarization->addWidget(polarizationOn_);
polarization->addWidget(polarization_, 1);
geom->addRow("Photon energy", energy_);
geom->addRow("Detector distance", distance_);
geom->addRow("Beam origin [px]", beam);
geom->addRow("Detector tilt [°]", tilt);
geom->addRow(polarization);
for (auto *f : {energy_, distance_, beamX_, beamY_, rot1_, rot2_, polarization_})
connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); });
connect(polarizationOn_, &QCheckBox::toggled, this, [this](bool on) {
polarization_->setEnabled(on);
EmitExperiment();
});
section->setContentLayout(geom);
return section;
}
QWidget *JFJochViewerSettingsDock::BuildMXPage() {
auto *page = new QWidget(this);
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(0, 0, 0, 0);
// --- Unit cell + space group (new: no input existed before) ---
auto *cellSection = new CollapsibleSection("Unit cell", page);
auto *cell = new QFormLayout();
cell->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
cellKnown_ = new QCheckBox("Known unit cell", page);
cell->addRow(cellKnown_);
cellA_ = new NumberLineEdit(1.0, 2000.0, 79.0, 3, "", page);
cellB_ = new NumberLineEdit(1.0, 2000.0, 79.0, 3, "", page);
cellC_ = new NumberLineEdit(1.0, 2000.0, 38.0, 3, "", page);
cellAlpha_ = new NumberLineEdit(5.0, 179.0, 90.0, 2, "", page);
cellBeta_ = new NumberLineEdit(5.0, 179.0, 90.0, 2, "", page);
cellGamma_ = new NumberLineEdit(5.0, 179.0, 90.0, 2, "", page);
spaceGroup_ = new NumberLineEdit(0.0, 230.0, 0.0, 0, "", page);
spaceGroup_->setToolTip("Space group number (1–230); 0 = unset.");
spaceGroupName_ = new QLabel("—", page);
// Three fields to a row, and a cell length can be four digits: let the row divide the width it
// has instead of each field demanding the width of its longest possible value, which is what
// made this the one section that still pushed the dock wider than it is.
for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_})
f->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Fixed);
auto *abc = new QHBoxLayout();
abc->addWidget(cellA_); abc->addWidget(cellB_); abc->addWidget(cellC_);
auto *angles = new QHBoxLayout();
angles->addWidget(cellAlpha_); angles->addWidget(cellBeta_); angles->addWidget(cellGamma_);
// Number and symbol split the line evenly, like the beam-origin row.
auto *sgRow = new QHBoxLayout();
sgRow->addWidget(spaceGroup_, 1);
sgRow->addWidget(spaceGroupName_, 1);
cell->addRow("a, b, c [Å]", abc);
cell->addRow("α, β, γ [°]", angles);
cell->addRow("Space group", sgRow);
cellSection->setContentLayout(cell);
layout->addWidget(cellSection);
auto enableCellFields = [this](bool on) {
for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_, spaceGroup_})
f->setEnabled(on);
};
enableCellFields(false);
connect(cellKnown_, &QCheckBox::toggled, this, [this, enableCellFields](bool on) {
enableCellFields(on);
UpdateSpaceGroupName();
UpdateAlgorithmDescription(); // Auto resolves to FFBIDX vs FFT depending on a known cell
EmitExperiment();
});
for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_})
connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); });
connect(spaceGroup_, &NumberLineEdit::newValue, this, [this] {
UpdateSpaceGroupName();
EmitExperiment();
});
layout->addWidget(BuildGoniometerSection());
// --- Spot finding ---
auto *spotSection = new CollapsibleSection("Spot finding", page);
auto *spot = new QFormLayout();
spot->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
auto *snr = new SliderPlusBox(1.0, 10.0, 0.1, 1, page);
snr->setValue(spot_.signal_to_noise_threshold);
auto *count = new SliderPlusBox(0.0, 100.0, 1.0, 0, page);
count->setValue(std::lround(spot_.photon_count_threshold));
auto *adaptive = new QCheckBox("Adaptive threshold (per-ring, auto)", page);
adaptive->setChecked(spot_.adaptive_threshold);
adaptive->setToolTip("Self-calibrating detection: the strong-pixel threshold is derived from each "
"image's own per-resolution-ring noise, so one setting adapts across datasets. "
"The signal/noise and photon-count settings are not used while this is on. "
"Default for stills and rotation alike, as in rugnux.");
auto *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page);
highResSpot->setValue(spot_.high_resolution_limit.value_or(1.5f));
auto *autoHighResSpot = new QCheckBox("To detector edge", page);
autoHighResSpot->setChecked(!spot_.high_resolution_limit.has_value());
autoHighResSpot->setToolTip("Find spots as far as the detector reaches, instead of clipping the "
"detection at a fixed resolution. The high-resolution value is not used "
"while this is on.");
auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast<float>(min_pix_value_), 0, "px", page);
auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (stills)", page);
adaptiveMinPix->setChecked(adaptive_min_pix_);
adaptiveMinPix->setToolTip("Choose the minimum pixels/spot per image: index the frame at min-pix "
"3/2/1 and keep whichever maximises indexed count x indexed fraction. "
"Stills only - a rotation dataset builds one lattice from all frames and "
"always uses the fixed min-pixels/spot value below.");
// The same bounds DatasetSettings::MaxSpotCount enforces - offering more than it accepts only got
// the value rejected further down.
auto *maxSpots = new NumberLineEdit(10.0f, static_cast<float>(MAX_SPOT_COUNT),
static_cast<float>(max_spots_), 0, "", page);
spot->addRow(adaptive);
spot->addRow("Signal/noise", snr);
spot->addRow("Photon count", count);
spot->addRow(autoHighResSpot);
spot->addRow("High resolution [Å]", highResSpot);
spot->addRow(adaptiveMinPix);
spot->addRow("Min pixels/spot", minPix);
spot->addRow("Max spots/image", maxSpots);
spotSection->setContentLayout(spot);
layout->addWidget(spotSection);
connect(snr, &SliderPlusBox::valueChanged, this, [this](double v) {
spot_.signal_to_noise_threshold = static_cast<float>(v); EmitSpotFinding(); });
connect(count, &SliderPlusBox::valueChanged, this, [this](double v) {
spot_.photon_count_threshold = std::llround(v); EmitSpotFinding(); });
connect(highResSpot, &SliderPlusBox::valueChanged, this, [this](double v) {
spot_.high_resolution_limit = static_cast<float>(v); EmitSpotFinding(); });
connect(minPix, &NumberLineEdit::newValue, this, [this, minPix] {
min_pix_value_ = std::llround(minPix->value()); EmitSpotFinding(); });
connect(maxSpots, &NumberLineEdit::newValue, this, [this, maxSpots] {
max_spots_ = std::llround(maxSpots->value()); EmitSpotFinding(); });
// The adaptive finder sets its own threshold from each image's noise, so the signal/noise and
// photon-count sliders do nothing while it is on - grey them out to make that clear.
auto syncAdaptiveEnabled = [snr, count](bool on) {
snr->setEnabled(!on);
count->setEnabled(!on);
};
syncAdaptiveEnabled(spot_.adaptive_threshold);
connect(adaptive, &QCheckBox::toggled, this, [this, syncAdaptiveEnabled](bool on) {
spot_.adaptive_threshold = on;
syncAdaptiveEnabled(on);
EmitSpotFinding();
});
// An unset high-resolution limit (std::nullopt) means "to the detector edge", so the value is unused
// while that is on - grey it out, as for the other automatic settings.
auto syncHighResEnabled = [highResSpot](bool auto_on) { highResSpot->setEnabled(!auto_on); };
syncHighResEnabled(!spot_.high_resolution_limit.has_value());
connect(autoHighResSpot, &QCheckBox::toggled, this, [this, highResSpot, syncHighResEnabled](bool on) {
if (on)
spot_.high_resolution_limit = std::nullopt;
else
spot_.high_resolution_limit = static_cast<float>(highResSpot->value());
syncHighResEnabled(on);
EmitSpotFinding();
});
// The fixed min-pixels/spot field stays live even with adaptive min-pix on: it is what a rotation
// dataset uses, and what stills fall back to when adaptive is switched off.
connect(adaptiveMinPix, &QCheckBox::toggled, this, [this](bool on) {
adaptive_min_pix_ = on;
EmitSpotFinding();
});
// --- Indexing ---
auto *idxSection = new CollapsibleSection("Indexing", page);
auto *idx = new QFormLayout();
idx->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
algo_ = new QComboBox(page);
// As in the calibrant combo: a combo is otherwise never narrower than its longest entry, which
// for these descriptions is wider than the dock. The current entry elides instead.
algo_->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
algo_->setMinimumContentsLength(12);
algo_->addItem("Auto", static_cast<int>(IndexingAlgorithmEnum::Auto));
algo_->addItem("FFBIDX (known cell)", static_cast<int>(IndexingAlgorithmEnum::FFBIDX));
algo_->addItem("FFT (GPU, de-novo)", static_cast<int>(IndexingAlgorithmEnum::FFT));
algo_->addItem("FFTW (CPU, de-novo)", static_cast<int>(IndexingAlgorithmEnum::FFTW));
algo_->addItem("None", static_cast<int>(IndexingAlgorithmEnum::None));
algo_->setCurrentIndex(algo_->findData(static_cast<int>(indexing_.GetAlgorithm())));
algoDesc_ = new QLabel(page);
algoDesc_->setWordWrap(true);
algoDesc_->setStyleSheet("color: gray;");
auto *refine = new QComboBox(page);
refine->setSizeAdjustPolicy(QComboBox::AdjustToMinimumContentsLengthWithIcon);
refine->setMinimumContentsLength(12);
refine->addItem("None", static_cast<int>(GeomRefinementAlgorithmEnum::None));
refine->addItem("Orientation only", static_cast<int>(GeomRefinementAlgorithmEnum::OrientationOnly));
refine->addItem("Beam center + lattice", static_cast<int>(GeomRefinementAlgorithmEnum::BeamCenter));
// "Flex" (the CLI's -r flex): tries all per-image refinements and keeps whichever indexes the most
// spots — lets the pipeline decide. Named "Flex", not "multi", to avoid the CrystFEL sense of multi
// (multi-lattice integration).
refine->addItem("Flex (best per-image refinement)", static_cast<int>(GeomRefinementAlgorithmEnum::Flex));
refine->setCurrentIndex(refine->findData(static_cast<int>(indexing_.GetGeomRefinementAlgorithm())));
idx->addRow("Algorithm", algo_);
idx->addRow(algoDesc_);
idx->addRow("Refinement", refine);
idxSection->setContentLayout(idx);
layout->addWidget(idxSection);
connect(algo_, &QComboBox::currentIndexChanged, this, [this] {
indexing_.Algorithm(static_cast<IndexingAlgorithmEnum>(algo_->currentData().toInt()));
UpdateAlgorithmDescription();
EmitSpotFinding();
});
connect(refine, &QComboBox::currentIndexChanged, this, [this, refine] {
indexing_.GeomRefinementAlgorithm(static_cast<GeomRefinementAlgorithmEnum>(refine->currentData().toInt()));
EmitSpotFinding();
});
UpdateAlgorithmDescription();
layout->addWidget(BuildBraggSection());
layout->addWidget(BuildScalingSection());
layout->addWidget(BuildReferenceSection());
layout->addStretch(); // anchor sections to the top so expanding an accordion grows downward
return page;
}
QWidget *JFJochViewerSettingsDock::BuildGoniometerSection() {
// How the sample moved between images, in the same three cases a file stores: a rotation axis
// (/entry/sample/transformations/omega), a grid scan (/entry/sample/grid_scan), or neither. The
// mode IS the rotation/stills switch, so there is no second "process as stills" control, and it
// is not tied to what the file says: a still file can be given an axis or a grid, and a rotation
// file can be processed as stills by choosing Still.
auto *section = new CollapsibleSection("Goniometer", this);
auto *form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
// Exclusive, so radio buttons rather than checkboxes: a dataset is one of the three, never two.
modeStill_ = new QRadioButton("Still", this);
modeStill_->setToolTip("The sample does not move between images: every image is an independent "
"still (per-frame indexing, fixed partiality).");
modeRotation_ = new QRadioButton("Rotation", this);
modeRotation_->setToolTip("The sample rotates between images: rotation indexing, Ewald-crossing "
"partiality and 3D rotation scaling.");
modeGrid_ = new QRadioButton("Grid scan", this);
modeGrid_->setToolTip("The sample translates between images on a raster. Processed as stills; the "
"grid is what the dataset-info panel maps the per-image results onto.");
auto *modes = new QButtonGroup(this);
modes->setExclusive(true);
for (auto *b : {modeStill_, modeRotation_, modeGrid_})
modes->addButton(b);
modeStill_->setChecked(true);
axisName_ = new QLineEdit(DEFAULT_AXIS_NAME, this);
axisName_->setToolTip("Name of the rotated goniometer axis, as it is written to the output.");
axisX_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[0], 3, "", this);
axisY_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[1], 3, "", this);
axisZ_ = new NumberLineEdit(-1.0, 1.0, DEFAULT_AXIS[2], 3, "", this);
const QString axisTip = "Rotation axis in the laboratory frame (x, y, z); normalised on use.";
for (auto *f : {axisX_, axisY_, axisZ_})
f->setToolTip(axisTip);
rotStart_ = new NumberLineEdit(-3600.0, 3600.0, 0.0, 3, "°", this);
rotStart_->setToolTip("Angle of the first image.");
rotIncrement_ = new NumberLineEdit(-10.0, 10.0, DEFAULT_INCREMENT_DEG, 3, "°", this);
rotIncrement_->setToolTip("Angle rotated per image.");
// Only the fast axis has a point count: the slow one is however many rows the images fill, which
// is also why the file stores n_fast alone. The steps are signed - the sign is the direction the
// scan runs in - so only zero is rejected.
gridNFast_ = new NumberLineEdit(1.0, 100000.0, DEFAULT_GRID_N_FAST, 0, "", this);
gridNFast_->setToolTip("Points along the fast axis. The number of slow rows follows from the "
"number of images.");
gridStepX_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "", this);
gridStepY_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "", this);
const QString stepTip = "Distance between neighbouring points, in x and y. Negative = the scan "
"runs towards decreasing x / y.";
gridStepX_->setToolTip(stepTip);
gridStepY_->setToolTip(stepTip);
gridVertical_ = new QCheckBox("Fast axis vertical", this);
gridVertical_->setToolTip("The scan fills a column before moving to the next one (fast axis = y) "
"instead of filling a row (fast axis = x).");
gridSnake_ = new QCheckBox("Snake scan", this);
gridSnake_->setToolTip("Every second row/column is collected in the reverse direction (boustrophedon) "
"instead of returning to the start of the next one.");
gridSummary_ = new QLabel(this);
gridSummary_->setStyleSheet("color: gray;");
auto *axis = new QHBoxLayout();
axis->addWidget(axisX_); axis->addWidget(axisY_); axis->addWidget(axisZ_);
auto *step = new QHBoxLayout();
step->addWidget(gridStepX_); step->addWidget(gridStepY_);
auto *gridFlags = new QHBoxLayout();
gridFlags->addWidget(gridVertical_); gridFlags->addWidget(gridSnake_);
form->addRow(modeStill_);
form->addRow(modeRotation_);
form->addRow("Axis name", axisName_);
form->addRow("Axis vector", axis);
form->addRow("Start angle", rotStart_);
form->addRow("Increment", rotIncrement_);
form->addRow(modeGrid_);
form->addRow("Points (fast)", gridNFast_);
form->addRow("Step x, y [μm]", step);
form->addRow(gridFlags);
form->addRow(gridSummary_);
section->setContentLayout(form);
EnableRotationFields(false); // datasetLoaded picks the mode the file describes
EnableGridFields(false);
// One handler for the three: whichever is now on decides which fields are live, what the
// experiment carries, and whether the run is a rotation or stills one.
for (auto *b : {modeStill_, modeRotation_, modeGrid_})
connect(b, &QRadioButton::toggled, this, [this](bool on) {
if (!on)
return; // the button being switched off reports first; act once, on the new mode
EnableRotationFields(modeRotation_->isChecked());
EnableGridFields(modeGrid_->isChecked());
UpdateGridSummary();
ApplyProcessingMode();
EmitExperiment();
});
connect(axisName_, &QLineEdit::editingFinished, this, [this] { EmitExperiment(); });
for (auto *f : {axisX_, axisY_, axisZ_, rotStart_})
connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); });
// A zero increment is a stationary axis, which is processed as stills.
connect(rotIncrement_, &NumberLineEdit::newValue, this, [this] { ApplyProcessingMode(); EmitExperiment(); });
for (auto *f : {gridNFast_, gridStepX_, gridStepY_})
connect(f, &NumberLineEdit::newValue, this, [this] { UpdateGridSummary(); EmitExperiment(); });
for (auto *b : {gridVertical_, gridSnake_})
connect(b, &QCheckBox::toggled, this, [this] { UpdateGridSummary(); EmitExperiment(); });
return section;
}
void JFJochViewerSettingsDock::EnableRotationFields(bool on) {
axisName_->setEnabled(on);
for (auto *f : {axisX_, axisY_, axisZ_, rotStart_, rotIncrement_})
f->setEnabled(on);
}
void JFJochViewerSettingsDock::EnableGridFields(bool on) {
for (auto *f : {gridNFast_, gridStepX_, gridStepY_})
f->setEnabled(on);
gridVertical_->setEnabled(on);
gridSnake_->setEnabled(on);
}
void JFJochViewerSettingsDock::UpdateGridSummary() {
const auto grid = GridScanFromFields();
gridSummary_->setText(grid ? QStringLiteral("%1 x %2 points for %3 images")
.arg(grid->GetGridSizeX_step())
.arg(grid->GetGridSizeY_step())
.arg(experiment_.GetImageNum())
: QString());
}
std::optional<GoniometerAxis> JFJochViewerSettingsDock::GoniometerFromFields() const {
if (!modeRotation_->isChecked())
return std::nullopt;
// GoniometerAxis throws on an empty name or a zero-length axis, and an exception thrown out of a
// widget signal aborts the viewer - fall back to the defaults for a half-typed entry.
std::string name = axisName_->text().trimmed().toStdString();
if (name.empty())
name = DEFAULT_AXIS_NAME;
Coord axis(static_cast<float>(axisX_->value()), static_cast<float>(axisY_->value()),
static_cast<float>(axisZ_->value()));
if (axis.Length() == 0.0f)
axis = Coord(DEFAULT_AXIS);
GoniometerAxis gonio(name, static_cast<float>(rotStart_->value()),
static_cast<float>(rotIncrement_->value()), axis, gonioHelicalStep_);
gonio.ScreeningWedge(gonioScreeningWedge_);
return gonio;
}
std::optional<GridScanSettings> JFJochViewerSettingsDock::GridScanFromFields() const {
if (!modeGrid_->isChecked())
return std::nullopt;
// GridScanSettings throws on a zero step, and an exception thrown out of a widget signal aborts
// the viewer - fall back to the default step for a half-typed entry.
float step_x = static_cast<float>(gridStepX_->value());
float step_y = static_cast<float>(gridStepY_->value());
if (step_x == 0.0f)
step_x = DEFAULT_GRID_STEP_UM;
if (step_y == 0.0f)
step_y = DEFAULT_GRID_STEP_UM;
GridScanSettings grid(std::llround(gridNFast_->value()), step_x, step_y,
gridSnake_->isChecked(), gridVertical_->isChecked());
// The rows are however many the images fill, exactly as the reader does when a file carries a
// grid: the file stores n_fast alone.
grid.ImageNum(experiment_.GetImageNum());
return grid;
}
QWidget *JFJochViewerSettingsDock::BuildReferenceSection() {
// A reference dataset for scaling: drives CCref and reference-based scaling, and fixes the cell
// and space-group setting the merge is written in. It is independent of the loaded data (the
// worker keeps it across file switches); this just lets the user pick the file (MTZ or
// structure-factor mmCIF, recognised by content) + column and shows what it contains and whether
// it matches the data. Below it, an atomic model the merged data is validated against at the end
// of a job (R-free and maps, as `rugnux --model`).
auto *section = new CollapsibleSection("Reference dataset and model", this);
auto *form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
refButton_ = new QPushButton("Choose reference…", this);
refButton_->setToolTip("Reference reflections: an MTZ or a structure-factor mmCIF (.cif), gzipped or not.");
refColumn_ = new QComboBox(this);
refColumn_->setEnabled(false);
refColumn_->setToolTip("Reference intensity / structure-factor column (F is squared to an intensity).");
refSummary_ = new QLabel("No reference loaded", this);
refSummary_->setWordWrap(true);
refWarning_ = new QLabel(this);
refWarning_->setWordWrap(true);
refWarning_->setVisible(false);
modelButton_ = new QPushButton("Choose model…", this);
modelButton_->setToolTip("Atomic model (PDB or mmCIF) the merged intensities are validated against after "
"merging: R-free and 2mFo-DFc / mFo-DFc maps, written beside the job's output. "
"Needs a saved output (_process.h5 or merged files).");
modelClear_ = new QPushButton("Clear", this);
modelClear_->setEnabled(false);
modelLabel_ = new QLabel("No model", this);
modelLabel_->setWordWrap(true);
auto *modelRow = new QHBoxLayout();
modelRow->addWidget(modelButton_, 1);
modelRow->addWidget(modelClear_);
form->addRow(refButton_);
form->addRow("Column", refColumn_);
form->addRow(refSummary_);
form->addRow(refWarning_);
form->addRow(modelRow);
form->addRow(modelLabel_);
section->setContentLayout(form);
connect(refButton_, &QPushButton::clicked, this, [this] {
const QString path = QFileDialog::getOpenFileName(
this, "Reference data (MTZ or structure-factor mmCIF)", refPath_,
"Reference data (*.mtz *.cif *.mtz.gz *.cif.gz *.ent.gz);;MTZ files (*.mtz *.mtz.gz);;"
"Structure-factor mmCIF (*.cif *.cif.gz);;All files (*)");
if (path.isEmpty())
return;
refPath_ = path;
emit referenceSelected(path, QString()); // empty column -> let the worker auto-select
});
connect(modelButton_, &QPushButton::clicked, this, [this] {
const QString path = QFileDialog::getOpenFileName(
this, "Atomic model (PDB or mmCIF)", modelPath_,
"Coordinates (*.pdb *.cif *.ent *.mmcif *.pdb.gz *.cif.gz *.ent.gz);;All files (*)");
if (path.isEmpty())
return;
modelPath_ = path;
modelLabel_->setText(QFileInfo(path).fileName());
modelClear_->setEnabled(true);
emit modelSelected(path);
});
connect(modelClear_, &QPushButton::clicked, this, [this] {
modelPath_.clear();
modelLabel_->setText("No model");
modelClear_->setEnabled(false);
emit modelSelected(QString());
});
// activated (not currentIndexChanged) so re-populating the combo on load doesn't re-trigger.
connect(refColumn_, &QComboBox::activated, this, [this] {
if (!refPath_.isEmpty())
emit referenceSelected(refPath_, refColumn_->currentText());
});
return section;
}
QWidget *JFJochViewerSettingsDock::BuildAzIntSection() {
auto *azSection = new CollapsibleSection("Azimuthal integration", this);
auto *az = new QFormLayout();
az->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
auto *lowQ = new SliderPlusBox(1e-5, 10.0, 0.001, 4, this);
lowQ->setValue(azint_.GetLowQ_recipA());
auto *highQ = new SliderPlusBox(2e-5, 10.0, 0.001, 4, this);
highQ->setValue(azint_.GetHighQ_recipA());
auto *autoHighQ = new QCheckBox("To detector edge", this);
autoHighQ->setChecked(!azint_.GetRequestedHighQ_recipA().has_value());
autoHighQ->setToolTip("Integrate out to the highest Q the detector reaches. The high-Q value is not "
"used while this is on.");
auto *spacing = new SliderPlusBox(1e-5, 1.0, 0.001, 5, this, SliderPlusBox::ScaleType::Logarithmic);
spacing->setValue(azint_.GetQSpacing_recipA());
auto *azimBins = new QComboBox(this);
for (int b : {1, 2, 4, 8, 16, 32, 64, 128})
azimBins->addItem(QString::number(b), b);
azimBins->setCurrentIndex(azimBins->findData(azint_.GetAzimuthalBinCount()));
az->addRow("Low Q [Å⁻¹]", lowQ);
az->addRow(autoHighQ);
az->addRow("High Q [Å⁻¹]", highQ);
az->addRow("Q spacing [Å⁻¹]", spacing);
az->addRow("Azimuthal bins", azimBins);
azSection->setContentLayout(az);
// A combination can still be invalid (Low Q above High Q): the setters throw, and an exception
// escaping a slot terminates the viewer. Build on a copy and keep the previous settings instead.
auto emitAz = [=, this] {
AzimuthalIntegrationSettings s = azint_;
try {
s.QRange_recipA(static_cast<float>(lowQ->value()),
autoHighQ->isChecked() ? std::nullopt
: std::optional<float>(highQ->value()));
s.QSpacing_recipA(static_cast<float>(spacing->value()));
s.AzimuthalBinCount(azimBins->currentData().toInt());
} catch (const std::exception &) {
return;
}
azint_ = s;
emit azintChanged(azint_);
};
connect(lowQ, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); });
connect(highQ, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); });
connect(spacing, &SliderPlusBox::valueChanged, this, [emitAz] { emitAz(); });
connect(azimBins, &QComboBox::currentIndexChanged, this, [emitAz] { emitAz(); });
connect(autoHighQ, &QCheckBox::toggled, this, [emitAz, highQ](bool on) {
highQ->setEnabled(!on);
emitAz();
});
highQ->setEnabled(!autoHighQ->isChecked());
return azSection;
}
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);
powder_ = new PowderCalibrationWidget(page);
connect(powder_, &PowderCalibrationWidget::findBeamCenter, this, &JFJochViewerSettingsDock::findBeamCenter);
connect(powder_, &PowderCalibrationWidget::ringsFromCalibration, this, &JFJochViewerSettingsDock::ringsFromCalibration);
powderLayout->addWidget(powder_);
// 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 sector
// count is the azimuthal-integration section above - say here when it is set too low to fit with.
auto *bins = new QLabel(page);
bins->setWordWrap(true);
bins->setStyleSheet("color: gray;");
powderLayout->addWidget(bins);
powderSection->setContentLayout(powderLayout);
layout->addWidget(powderSection);
auto refreshBins = [this, bins] {
bins->setText(azint_.GetAzimuthalBinCount() < 4
? QStringLiteral("Rings method: too few azimuthal bins (%1) to locate a ring;"
" the run will use 32.").arg(azint_.GetAzimuthalBinCount())
: QString());
};
refreshBins();
connect(this, &JFJochViewerSettingsDock::azintChanged, this, [refreshBins] { refreshBins(); });
layout->addStretch();
return page;
}
void JFJochViewerSettingsDock::SyncMinPix() {
// Per-image min-pix indexes each frame on its own, which only means something for stills; rotation
// indexing builds one lattice from all frames, so it keeps the fixed value.
spot_.min_pix_per_spot = (adaptive_min_pix_ && !indexing_.GetRotationIndexing())
? std::optional<int64_t>()
: std::optional<int64_t>(min_pix_value_);
}
void JFJochViewerSettingsDock::EmitSpotFinding() {
SyncMinPix();
emit spotFindingChanged(spot_, indexing_, max_spots_);
}
void JFJochViewerSettingsDock::UpdateAlgorithmDescription() {
if (!algo_ || !algoDesc_)
return;
const auto a = static_cast<IndexingAlgorithmEnum>(algo_->currentData().toInt());
const bool gpu = get_gpu_count() > 0;
const bool cell_known = cellKnown_ && cellKnown_->isChecked();
QString text;
switch (a) {
case IndexingAlgorithmEnum::FFBIDX:
text = "needs a known cell, GPU or CPU — best for sparse serial stills"; break;
case IndexingAlgorithmEnum::FFT:
text = "GPU, de-novo — best for strong rotation data"; break;
case IndexingAlgorithmEnum::FFTW:
text = "CPU, de-novo — no GPU needed"; break;
case IndexingAlgorithmEnum::Auto: {
const IndexingAlgorithmEnum r = cell_known ? IndexingAlgorithmEnum::FFBIDX
: (gpu ? IndexingAlgorithmEnum::FFT : IndexingAlgorithmEnum::FFTW);
const QString rn = r == IndexingAlgorithmEnum::FFBIDX ? "FFBIDX"
: r == IndexingAlgorithmEnum::FFT ? "FFT" : "FFTW";
text = QString("resolves to %1 here (%2, cell %3)")
.arg(rn, gpu ? "GPU" : "no GPU", cell_known ? "known" : "unknown");
break;
}
default:
text = "no indexing"; break;
}
algoDesc_->setText(text);
}
QWidget *JFJochViewerSettingsDock::BuildBraggSection() {
auto *section = new CollapsibleSection("Bragg integration", this);
auto *form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
auto *gaussian = new QCheckBox("Gaussian profile fit", this);
gaussian->setChecked(bragg_.GetIntegrator() != IntegratorMode::BoxSum);
gaussian->setToolTip("Profile-fit the spots (more accurate intensities); off = classical box-sum.");
auto *r1 = new NumberLineEdit(1.0f, 20.0f, bragg_.GetR1(), 1, "", this);
auto *r2 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR2(), 1, "", this);
auto *r3 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR3(), 1, "", this);
auto *radii = new QHBoxLayout();
radii->addWidget(r1); radii->addWidget(r2); radii->addWidget(r3);
// Background trim: use the old symmetric trimmed mean for the r2..r3 ring (drop the lowest and
// highest fraction of ring pixels) in place of the default high-side sigma clip. Setting the trim
// clears the clip, so unchecked simply leaves the default estimator in place.
const float trim = bragg_.GetBackgroundTrimFraction();
auto *bkgTrim = new QCheckBox("Background trim", this);
bkgTrim->setChecked(trim > 0.0f);
bkgTrim->setToolTip("Estimate the local Bragg background with a symmetric trimmed mean of the "
"background ring instead of the default high-side sigma clip. A symmetric trim "
"is biased low on Poisson data and adds a few counts to every partial, so this "
"is for back compatibility; unchecked = the 4 sigma clip.");
auto *bkgTrimFrac = new NumberLineEdit(0.01f, 0.49f, trim > 0.0f ? trim : 0.10f, 2, "", this);
bkgTrimFrac->setEnabled(bkgTrim->isChecked());
auto *trimRow = new QHBoxLayout();
trimRow->addWidget(bkgTrim);
trimRow->addWidget(bkgTrimFrac, 1);
form->addRow(gaussian);
form->addRow("Radii r1/r2/r3 [px]", radii);
form->addRow(trimRow);
section->setContentLayout(form);
// The radii must grow r1 < r2 < r3, which a single typed value can break: the setters throw, and an
// exception escaping a slot terminates the viewer. Build on a copy and keep the previous settings.
auto emitBragg = [=, this] {
BraggIntegrationSettings s = bragg_;
try {
s.Integrator(gaussian->isChecked() ? IntegratorMode::ProfileGaussian : IntegratorMode::BoxSum);
s.R1(static_cast<float>(r1->value())).R2(static_cast<float>(r2->value()))
.R3(static_cast<float>(r3->value()));
// Either estimator clears the other, so unchecking has to put the default clip back explicitly.
if (bkgTrim->isChecked())
s.BackgroundTrimFraction(static_cast<float>(bkgTrimFrac->value()));
else
s.BackgroundClipNSigma(BraggIntegrationSettings().GetBackgroundClipNSigma());
} catch (const std::exception &) {
return;
}
bragg_ = s;
emit braggChanged(bragg_);
};
connect(gaussian, &QCheckBox::toggled, this, [emitBragg] { emitBragg(); });
connect(bkgTrim, &QCheckBox::toggled, this,
[bkgTrim, bkgTrimFrac, emitBragg] { bkgTrimFrac->setEnabled(bkgTrim->isChecked()); emitBragg(); });
connect(bkgTrimFrac, &NumberLineEdit::newValue, this, [emitBragg] { emitBragg(); });
for (auto *f : {r1, r2, r3})
connect(f, &NumberLineEdit::newValue, this, [emitBragg] { emitBragg(); });
return section;
}
QWidget *JFJochViewerSettingsDock::BuildScalingSection() {
// The partiality model + rot3d combine + scale-fulls are driven by the rotation axis (goniometer
// section); the panel keeps the full scaling_ so those fields are preserved here, not reset.
auto *section = new CollapsibleSection("Scaling", this);
auto *form = new QFormLayout();
form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
auto *friedel = new QCheckBox("Merge Friedel pairs", this);
friedel->setChecked(scaling_.GetMergeFriedel());
auto *corrections = new QCheckBox("Correction surfaces (decay + absorption)", this);
corrections->setChecked(scaling_.GetCorrectionSurfaces());
corrections->setToolTip("Rotation only: fit a radiation-damage decay and a goniometer-frame absorption "
"surface on the fulls. Cross-validated, so they no-op when their systematic is "
"absent. On by default.");
auto *partRefine = new QCheckBox("Partiality post-refinement (stills)", this);
partRefine->setChecked(scaling_.GetStillsPartialityRefine());
partRefine->setToolTip("Stills: refine a per-crystal orientation tilt against the running merge and "
"recompute each reflection's partiality (physical Ewald-proximity model), then "
"re-scale/merge. On by default; uncheck for the simple model (each reflection a "
"full, single-pass). No effect on rotation data.");
auto *limitRes = new QCheckBox("High-resolution limit", this);
limitRes->setChecked(scaling_.GetHighResolutionLimit_A().has_value());
auto *highRes = new NumberLineEdit(0.3f, 5.0f, scaling_.GetHighResolutionLimit_A().value_or(2.0), 1, "Å", this);
highRes->setEnabled(limitRes->isChecked());
auto *limitLowRes = new QCheckBox("Low-resolution limit", this);
limitLowRes->setChecked(scaling_.GetLowResolutionLimit_A().has_value());
limitLowRes->setToolTip("Drop reflections coarser than this from scaling and merging. They sit behind "
"or beside the beam stop and are measured on a background it has eaten into. "
"On by default at 50 Å, the value XDS configurations use.");
auto *lowRes = new NumberLineEdit(5.0f, 500.0f, scaling_.GetLowResolutionLimit_A().value_or(50.0), 1, "Å", this);
lowRes->setEnabled(limitLowRes->isChecked());
form->addRow(friedel);
form->addRow(corrections);
form->addRow(partRefine);
// Compact, and aligned with the checkboxes above: the limit checkbox + value sit together in the
// field column (not as a row label, which would indent it differently).
auto *resRow = new QHBoxLayout();
resRow->addWidget(limitRes);
resRow->addWidget(highRes, 1);
form->addRow(resRow);
auto *lowResRow = new QHBoxLayout();
lowResRow->addWidget(limitLowRes);
lowResRow->addWidget(lowRes, 1);
form->addRow(lowResRow);
section->setContentLayout(form);
auto emitScaling = [=, this] {
scaling_.MergeFriedel(friedel->isChecked());
scaling_.CorrectionSurfaces(corrections->isChecked());
scaling_.StillsPartialityRefine(partRefine->isChecked());
scaling_.HighResolutionLimit_A(limitRes->isChecked()
? std::optional<double>(highRes->value()) : std::nullopt);
scaling_.LowResolutionLimit_A(limitLowRes->isChecked()
? std::optional<double>(lowRes->value()) : std::nullopt);
emit scalingChanged(scaling_);
};
connect(friedel, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); });
connect(corrections, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); });
connect(partRefine, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); });
connect(limitRes, &QCheckBox::toggled, this, [emitScaling, highRes](bool on) {
highRes->setEnabled(on); emitScaling(); });
connect(highRes, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); });
connect(limitLowRes, &QCheckBox::toggled, this, [emitScaling, lowRes](bool on) {
lowRes->setEnabled(on); emitScaling(); });
connect(lowRes, &NumberLineEdit::newValue, this, [emitScaling] { emitScaling(); });
return section;
}
void JFJochViewerSettingsDock::EmitExperiment() {
if (!have_experiment_)
return;
// The setters validate and throw; an exception escaping a slot terminates the viewer, so
// an invalid entry keeps the previous experiment instead.
DiffractionExperiment e = experiment_;
try {
e.IncidentEnergy_keV(static_cast<float>(energy_->value()));
e.DetectorDistance_mm(static_cast<float>(distance_->value()));
e.BeamX_pxl(static_cast<float>(beamX_->value()));
e.BeamY_pxl(static_cast<float>(beamY_->value()));
e.PoniRot1_rad(static_cast<float>(rot1_->value() * PI / 180.0));
e.PoniRot2_rad(static_cast<float>(rot2_->value() * PI / 180.0));
e.PolarizationFactor(polarizationOn_->isChecked()
? std::optional<float>(static_cast<float>(polarization_->value())) : std::nullopt);
e.Goniometer(GoniometerFromFields());
e.GridScan(GridScanFromFields());
if (cellKnown_->isChecked()) {
e.SetUnitCell(UnitCell{
static_cast<float>(cellA_->value()), static_cast<float>(cellB_->value()),
static_cast<float>(cellC_->value()), static_cast<float>(cellAlpha_->value()),
static_cast<float>(cellBeta_->value()), static_cast<float>(cellGamma_->value())});
const int sg = static_cast<int>(std::lround(spaceGroup_->value()));
e.SpaceGroupNumber(sg > 0 ? std::optional<int64_t>(sg) : std::nullopt);
} else {
e.SetUnitCell(std::nullopt);
e.SpaceGroupNumber(std::nullopt);
}
} catch (const std::exception &) {
return;
}
experiment_ = e;
emit experimentChanged(experiment_);
}
void JFJochViewerSettingsDock::RefreshGeometryFields() {
// Populate fields from the loaded experiment. NumberLineEdit::setValue does not emit newValue
// (that fires only on user editing), so this cannot feed back into EmitExperiment.
energy_->setValue(experiment_.GetIncidentEnergy_keV());
distance_->setValue(experiment_.GetDetectorDistance_mm());
beamX_->setValue(experiment_.GetBeamX_pxl());
beamY_->setValue(experiment_.GetBeamY_pxl());
rot1_->setValue(experiment_.GetPoniRot1_rad() * 180.0 / PI);
rot2_->setValue(experiment_.GetPoniRot2_rad() * 180.0 / PI);
const auto polarization = experiment_.GetPolarizationFactor();
QSignalBlocker blockPolarization(polarizationOn_);
polarizationOn_->setChecked(polarization.has_value());
polarization_->setEnabled(polarization.has_value());
if (polarization)
polarization_->setValue(polarization.value());
// The mode follows what the experiment describes, and the described one's fields are filled from
// it. The other two keep what they show - the generic defaults, or what the user last entered -
// so switching away and back does not lose an axis or a grid.
const auto gonio = experiment_.GetGoniometer();
const auto grid = experiment_.GetGridScan();
QSignalBlocker blockStill(modeStill_), blockRotation(modeRotation_), blockGrid(modeGrid_);
modeRotation_->setChecked(gonio.has_value());
modeGrid_->setChecked(!gonio.has_value() && grid.has_value());
modeStill_->setChecked(!gonio.has_value() && !grid.has_value());
EnableRotationFields(gonio.has_value());
EnableGridFields(modeGrid_->isChecked());
if (gonio) {
axisName_->setText(QString::fromStdString(gonio->GetName()));
axisX_->setValue(gonio->GetAxis().x);
axisY_->setValue(gonio->GetAxis().y);
axisZ_->setValue(gonio->GetAxis().z);
rotStart_->setValue(gonio->GetStart_deg());
rotIncrement_->setValue(gonio->GetIncrement_deg());
gonioHelicalStep_ = gonio->GetHelicalStep();
gonioScreeningWedge_ = gonio->GetScreeningWedge();
}
if (grid) {
gridNFast_->setValue(grid->GetNFast());
gridStepX_->setValue(grid->GetGridStepX_um());
gridStepY_->setValue(grid->GetGridStepY_um());
QSignalBlocker blockVertical(gridVertical_), blockSnake(gridSnake_);
gridVertical_->setChecked(grid->IsVerticalScan());
gridSnake_->setChecked(grid->IsSnakeScan());
}
UpdateGridSummary();
const auto cell = experiment_.GetUnitCell();
QSignalBlocker blockKnown(cellKnown_);
cellKnown_->setChecked(cell.has_value());
for (auto *f : {cellA_, cellB_, cellC_, cellAlpha_, cellBeta_, cellGamma_, spaceGroup_})
f->setEnabled(cell.has_value());
if (cell) {
cellA_->setValue(cell->a); cellB_->setValue(cell->b); cellC_->setValue(cell->c);
cellAlpha_->setValue(cell->alpha); cellBeta_->setValue(cell->beta); cellGamma_->setValue(cell->gamma);
spaceGroup_->setValue(experiment_.GetSpaceGroupNumber().value_or(0));
}
UpdateSpaceGroupName();
}
void JFJochViewerSettingsDock::UpdateSpaceGroupName() {
if (!cellKnown_->isChecked()) {
spaceGroupName_->setText("—");
return;
}
const int n = static_cast<int>(std::lround(spaceGroup_->value()));
if (n >= 1 && n <= 230) {
try {
const auto &sg = gemmi::get_spacegroup_by_number(n); // Hermann–Mauguin short symbol
spaceGroupName_->setText(QString::fromStdString(sg.short_name()));
} catch (...) {
spaceGroupName_->setText("invalid");
}
} else {
spaceGroupName_->setText(n == 0 ? "—" : "invalid");
}
}
void JFJochViewerSettingsDock::requestAnalyzeDataset() {
if (analyzeDatasetBtn_->isEnabled())
emit analyzeDataset(mode_, powder_->Selection());
}
void JFJochViewerSettingsDock::datasetLoaded(std::shared_ptr<const JFJochReaderDataset> dataset) {
if (!dataset)
return;
experiment_ = dataset->experiment;
have_experiment_ = true;
RefreshGeometryFields();
ApplyProcessingMode(); // the mode just read back decides rotation vs stills
}
void JFJochViewerSettingsDock::forgetStoredCrystal() {
cellKnown_->setChecked(false); // a change emits the experiment without the crystal
}
void JFJochViewerSettingsDock::ApplyProcessingMode() {
// Rotation is the only mode that changes how the data is processed: the rotation good-path
// (rotation indexing + Ewald partiality + rot3d combine + scale-fulls). A grid scan is a raster
// of stills, so it runs exactly as Still does.
// As in rugnux, an axis that does not turn is processed as stills: it records where the head was,
// not that the crystal turned, and rotation indexing would see a zero stride and never index.
const auto gonio = modeRotation_ ? GoniometerFromFields() : std::nullopt;
const bool rotation_mode = gonio.has_value() && gonio->IsScanning();
indexing_.RotationIndexing(rotation_mode); // rotation indexing -> rotation scaling/merge downstream
scaling_.ScaleFulls(rotation_mode);
EmitSpotFinding(); // carries indexing_ (incl. RotationIndexing) to the worker
emit scalingChanged(scaling_);
}
void JFJochViewerSettingsDock::loadImage(std::shared_ptr<const JFJochReaderImage> image) {
if (powder_)
powder_->loadImage(image);
}
void JFJochViewerSettingsDock::referenceLoaded(ReferenceMtzInfo info) {
{
QSignalBlocker block(refColumn_); // re-populating must not emit referenceSelected
refColumn_->clear();
refColumn_->addItems(info.columns);
const int idx = refColumn_->findText(info.used_column);
if (idx >= 0)
refColumn_->setCurrentIndex(idx);
}
refColumn_->setEnabled(info.loaded && !info.columns.isEmpty());
refSummary_->setText(info.loaded ? info.summary : "No reference loaded");
if (info.warning.isEmpty()) {
refWarning_->setVisible(false);
} else {
// Amber = a loaded-but-mismatched reference (a caution); red = a load failure.
refWarning_->setStyleSheet(QString("color: %1;")
.arg((info.loaded ? CautionTextColor() : AlertTextColor()).name()));
refWarning_->setText(info.warning);
refWarning_->setVisible(true);
}
}