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>
1098 lines
55 KiB
C++
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);
|
|
}
|
|
}
|