Build Packages / Create release (push) Successful in 24s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m43s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m27s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 9m58s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m22s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 13m39s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m32s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m11s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m41s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m0s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m38s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 17m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m38s
Build Packages / Unit tests (push) Successful in 1h46m14s
* jfjoch_broker: Optional per-dataset authentication - statistics, images and plots can require a bearer token, which jfjoch_viewer supports. * jfjoch_viewer: Dark mode and a theme-matched colour scheme, a magnifier panel, and simpler contrast and background controls. * Rugnux: Multiple performance improvements on GPU and CPU (CPU-only processing up to 40% faster, faster image decoding on ARM), with unchanged results. * Rugnux: `--model` rigid-body refinement runs on the GPU, and the model-validation check is faster and more reliable. * Rugnux: Improved scaling and merging - error model, outlier rejection, absorption correction and French-Wilson amplitudes now agree more closely with XDS and ctruncate. * Rugnux: Improved integration - radial background on powder and ice rings, crowded rotation data keep their reflections, and CPU-only builds integrate large unit cells as GPU builds do. * Rugnux: More robust detector geometry - measured beam centre, X-ray bandwidth and goniometer rate, and geometry refinement accepted only on significant evidence. * Rugnux: Merged files are written in the standard setting, or in the setting of a reference MTZ, structure-factor mmCIF or model, with its free-R flags. * Rugnux: Richer report - ice and powder rings, further lattices, superstructure candidates and mosaicity, with warnings worded as prompts to check. * Rugnux: Clear error messages when a data set needs more GPU or host memory than is available. Reviewed-on: #83 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
1061 lines
54 KiB
C++
1061 lines
54 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,
|
||
[this] { emit analyzeDataset(mode_, powder_->Selection()); });
|
||
|
||
// 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(1.0, 179.0, 90.0, 2, "", page);
|
||
cellBeta_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "", page);
|
||
cellGamma_ = new NumberLineEdit(1.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 (GPU, 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_, rotIncrement_})
|
||
connect(f, &NumberLineEdit::newValue, this, [this] { 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);
|
||
|
||
auto emitAz = [=, this] {
|
||
azint_.QRange_recipA(static_cast<float>(lowQ->value()),
|
||
autoHighQ->isChecked() ? std::nullopt
|
||
: std::optional<float>(highQ->value()));
|
||
azint_.QSpacing_recipA(static_cast<float>(spacing->value()));
|
||
azint_.AzimuthalBinCount(azimBins->currentData().toInt());
|
||
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 = "GPU, needs a known cell — 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 = !gpu ? IndexingAlgorithmEnum::FFTW
|
||
: (cell_known ? IndexingAlgorithmEnum::FFBIDX : IndexingAlgorithmEnum::FFT);
|
||
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, 30.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);
|
||
|
||
auto emitBragg = [=, this] {
|
||
bragg_.Integrator(gaussian->isChecked() ? IntegratorMode::ProfileGaussian : IntegratorMode::BoxSum);
|
||
bragg_.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())
|
||
bragg_.BackgroundTrimFraction(static_cast<float>(bkgTrimFrac->value()));
|
||
else
|
||
bragg_.BackgroundClipNSigma(BraggIntegrationSettings().GetBackgroundClipNSigma());
|
||
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;
|
||
experiment_.IncidentEnergy_keV(static_cast<float>(energy_->value()));
|
||
experiment_.DetectorDistance_mm(static_cast<float>(distance_->value()));
|
||
experiment_.BeamX_pxl(static_cast<float>(beamX_->value()));
|
||
experiment_.BeamY_pxl(static_cast<float>(beamY_->value()));
|
||
experiment_.PoniRot1_rad(static_cast<float>(rot1_->value() * PI / 180.0));
|
||
experiment_.PoniRot2_rad(static_cast<float>(rot2_->value() * PI / 180.0));
|
||
experiment_.PolarizationFactor(polarizationOn_->isChecked()
|
||
? std::optional<float>(static_cast<float>(polarization_->value())) : std::nullopt);
|
||
experiment_.Goniometer(GoniometerFromFields());
|
||
experiment_.GridScan(GridScanFromFields());
|
||
if (cellKnown_->isChecked()) {
|
||
experiment_.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()));
|
||
experiment_.SpaceGroupNumber(sg > 0 ? std::optional<int64_t>(sg) : std::nullopt);
|
||
} else {
|
||
experiment_.SetUnitCell(std::nullopt);
|
||
experiment_.SpaceGroupNumber(std::nullopt);
|
||
}
|
||
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::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::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.
|
||
const bool rotation_mode = modeRotation_ && modeRotation_->isChecked();
|
||
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);
|
||
}
|
||
}
|