Grid was a mode with no settings. It could be selected and run, but the raster it scored was whatever the file happened to record, what counted as a crystal was fixed at the compile-time defaults, and the settings a Grid run actually uses - the cell, spot finding, indexing - were built inside the MX page and therefore invisible in the mode that uses them. The unit cell, goniometer, spot-finding and indexing sections now sit outside the page stack and are shown for MX and Grid alike, the way the azimuthal section already was. That is not four convenient extras: a raster is scored out of the spots, indexes every cell by default, and is normally run against a known cell on a fixed target - and the goniometer section is where the grid geometry itself is stated. Bragg integration, scaling and the reference dataset stay on the MX page, because a raster integrates, scales and merges nothing. None of them is duplicated; a second control for one setting is a pair of controls that can disagree. The Grid page carries the analysis: the two score thresholds, the minimum cells per crystal, the decisive single-cell score, an optional cap and whether each cell is indexed. Every default is read from GridScanAnalysisSettings rather than written out again, so this panel and the web one cannot drift apart on what a default is; a test pins the numbers so that moving one is a decision rather than a side effect. No cap is spelled as an unticked box and not as a count that happens to mean "all". The settings ride to the run on the experiment, which now survives the panel's round trip through the reader. The grid geometry could already be edited but not trusted. A step of zero was quietly replaced by a default, which is the worst answer available: the map is reshaped, every crystal is reported somewhere else on the sample, and nothing looks wrong. It is now refused - no grid is set, the summary says which field is at fault, and the run is refused with it. The summary also says where the grid came from, the fields are highlighted when they are not the file's, and images that do not fill the last row are called out. The baseline for "not the file's" is taken when a file is opened, because datasetLoaded fires again after every edit and by then the file's grid is indistinguishable from one typed over it. Two defects found while verifying this. A raster is stored with a stationary omega beside its grid, since NXmx cannot say "no rotation" - and the panel read a goniometer of any kind as outranking the grid, so the first grid-scan run to come back carrying that placeholder axis flipped the panel to Rotation, and the next edit dropped the raster and got the following run refused. A goniometer that does not scan no longer outranks a grid. And a grid whose fields cannot make one is a half-finished edit, not a dataset that stopped being a raster, so it no longer throws the mode away under the user mid-edit. The Grid refusal now means what it says: no grid scan in the file AND none set in the panel. A file that records no raster - an older one, a foreign one - can be given one and analysed, which was the point. Verified headlessly on a stored raster: the Grid page shows the file's 13 x 15 grid and the analysis settings at their defaults, a run finds one crystal and draws it on the map, and the same run with the protein score threshold at 0.95 finds none - so the settings reach the analysis rather than merely being drawn. On a rotation dataset the mode is refused, accepted once a grid is entered, and refused again with a zero step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
1230 lines
64 KiB
C++
1230 lines
64 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 <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 "../../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.
|
||
const QString heroStyle =
|
||
"QPushButton { background-color:#1F3A5F; color:white; border:none; border-radius:3px;"
|
||
" padding:5px 10px; } QPushButton:hover { background-color:#16314f; }"
|
||
" QPushButton:checked { background-color:#FA7268; } QPushButton:disabled { background-color:#9aa6b3; }";
|
||
auto *analyzeImageBtn = new QPushButton(FramesIcon(1), " Analyze image", this);
|
||
analyzeImageBtn->setCheckable(true);
|
||
analyzeImageBtn->setStyleSheet(heroStyle);
|
||
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);
|
||
analyzeDatasetBtn_->setStyleSheet(heroStyle);
|
||
analyzeDatasetBtn_->setToolTip("Process the whole dataset (MX, azimuthal, calibration or grid scan, 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 / Grid 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);
|
||
auto *gridButton = new QPushButton("Grid", this);
|
||
gridButton->setToolTip("Grid scan: score every cell of a raster and find the crystals in it. "
|
||
"Needs a dataset collected as a grid scan.");
|
||
for (auto *b : {mxButton, azButton, calibButton, gridButton}) {
|
||
b->setCheckable(true);
|
||
b->setStyleSheet("QPushButton:checked { background-color: #1F3A5F; color: white; }");
|
||
}
|
||
mxButton->setChecked(true);
|
||
auto *group = new QButtonGroup(this);
|
||
group->setExclusive(true);
|
||
group->addButton(mxButton, 0);
|
||
group->addButton(azButton, 1);
|
||
group->addButton(calibButton, 2);
|
||
group->addButton(gridButton, 3);
|
||
|
||
auto *toggleRow = new QHBoxLayout();
|
||
toggleRow->setSpacing(0);
|
||
toggleRow->addWidget(mxButton);
|
||
toggleRow->addWidget(azButton);
|
||
toggleRow->addWidget(calibButton);
|
||
toggleRow->addWidget(gridButton);
|
||
|
||
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());
|
||
stack->addWidget(BuildGridPage());
|
||
connect(group, &QButtonGroup::idClicked, this, [this, stack](int id) {
|
||
mode_ = id == 1 ? AnalysisMode::Azint
|
||
: id == 2 ? AnalysisMode::PowderCalibration
|
||
: id == 3 ? AnalysisMode::Grid : AnalysisMode::MXStills;
|
||
stack->setCurrentIndex(id);
|
||
// MX and Grid are the two crystal modes and share the four sections below; AzInt and Calib
|
||
// need none of them.
|
||
const bool crystal = (id == 0 || id == 3);
|
||
for (auto *w : {cellSection_, goniometerSection_, spotFindingSection_, indexingSection_})
|
||
w->setVisible(crystal);
|
||
azintSection_->setVisible(id != 0);
|
||
});
|
||
|
||
// Geometry is common to all four, so it lives above the toggle rather than per page. Azimuthal
|
||
// integration is common to three of them - a calibration by rings integrates the run in azimuthal
|
||
// sectors and a grid scan's ice score reads the radial profile, and all of them need the same Q
|
||
// range and spacing - so it is shared the same way and hidden only on MX.
|
||
//
|
||
// The crystal, goniometer, spot-finding and indexing sections are shared between MX and Grid for
|
||
// the same reason: a raster is spot-found and (by default) indexed exactly as a still is, and the
|
||
// goniometer section is also where the grid geometry itself is stated. They are built once and
|
||
// shown per mode - a Grid copy of any of them would be a second control for one setting.
|
||
layout->addLayout(toggleRow);
|
||
layout->addWidget(BuildGeometrySection());
|
||
cellSection_ = BuildUnitCellSection();
|
||
goniometerSection_ = BuildGoniometerSection();
|
||
spotFindingSection_ = BuildSpotFindingSection();
|
||
indexingSection_ = BuildIndexingSection();
|
||
for (auto *w : {cellSection_, goniometerSection_, spotFindingSection_, indexingSection_})
|
||
layout->addWidget(w);
|
||
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, calibration or grid scan, 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);
|
||
beamX_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "px", this);
|
||
beamY_ = new NumberLineEdit(-20000.0, 20000.0, 0.0, 1, "px", 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", 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::BuildUnitCellSection() {
|
||
// Shared with the Grid page rather than kept on the MX one: a fixed-target raster is normally run
|
||
// against a known cell, and that cell is what every raster cell is indexed with. The panel is the
|
||
// only place the cell and space group can be stated, so hiding it on Grid would make the one
|
||
// setting a serial experiment cares most about unreachable in the mode it is used in.
|
||
auto *cellSection = new CollapsibleSection("Unit cell", this);
|
||
auto *cell = new QFormLayout();
|
||
cell->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
|
||
cellKnown_ = new QCheckBox("Known unit cell", this);
|
||
cell->addRow("", cellKnown_);
|
||
cellA_ = new NumberLineEdit(1.0, 2000.0, 79.0, 3, "Å", this);
|
||
cellB_ = new NumberLineEdit(1.0, 2000.0, 79.0, 3, "Å", this);
|
||
cellC_ = new NumberLineEdit(1.0, 2000.0, 38.0, 3, "Å", this);
|
||
cellAlpha_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "°", this);
|
||
cellBeta_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "°", this);
|
||
cellGamma_ = new NumberLineEdit(1.0, 179.0, 90.0, 2, "°", this);
|
||
spaceGroup_ = new NumberLineEdit(0.0, 230.0, 0.0, 0, "", this);
|
||
spaceGroup_->setToolTip("Space group number (1–230); 0 = unset.");
|
||
spaceGroupName_ = new QLabel("—", this);
|
||
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);
|
||
|
||
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();
|
||
});
|
||
|
||
return cellSection;
|
||
}
|
||
|
||
QWidget *JFJochViewerSettingsDock::BuildSpotFindingSection() {
|
||
// Shared with the Grid page: a raster is scored out of the spots - the per-cell protein score and
|
||
// the per-cell resolution both come from the spot finder, and the Grid job turns spot finding on
|
||
// unconditionally - so these are the settings a Grid run is actually made of.
|
||
auto *spotSection = new CollapsibleSection("Spot finding", this);
|
||
auto *spot = new QFormLayout();
|
||
spot->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
|
||
auto *snr = new SliderPlusBox(1.0, 10.0, 0.1, 1, this);
|
||
snr->setValue(spot_.signal_to_noise_threshold);
|
||
auto *count = new SliderPlusBox(0.0, 100.0, 1.0, 0, this);
|
||
count->setValue(std::lround(spot_.photon_count_threshold));
|
||
auto *adaptive = new QCheckBox("Adaptive threshold (per-ring, auto)", this);
|
||
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, this);
|
||
highResSpot->setValue(spot_.high_resolution_limit.value_or(1.5f));
|
||
auto *autoHighResSpot = new QCheckBox("To detector edge", this);
|
||
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", this);
|
||
auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (stills)", this);
|
||
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, "", this);
|
||
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);
|
||
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();
|
||
});
|
||
|
||
return spotSection;
|
||
}
|
||
|
||
QWidget *JFJochViewerSettingsDock::BuildIndexingSection() {
|
||
// Shared with the Grid page: a Grid run indexes every cell by default, with the same algorithm and
|
||
// refinement choice an MX run uses. Whether it indexes at all is the Grid page's own switch
|
||
// (GridScanAnalysisSettings::indexing); how it indexes is this section, for both modes.
|
||
auto *idxSection = new CollapsibleSection("Indexing", this);
|
||
auto *idx = new QFormLayout();
|
||
idx->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
|
||
algo_ = new QComboBox(this);
|
||
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(this);
|
||
algoDesc_->setWordWrap(true);
|
||
algoDesc_->setStyleSheet("color: gray;");
|
||
auto *refine = new QComboBox(this);
|
||
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);
|
||
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();
|
||
|
||
return idxSection;
|
||
}
|
||
|
||
QWidget *JFJochViewerSettingsDock::BuildMXPage() {
|
||
// What is left after the four shared sections moved out is MX-only. A raster predicts nothing,
|
||
// integrates nothing and merges nothing - the Grid job disables the process-H5, the merged output
|
||
// and scaling alike - so Bragg integration, scaling and the reference dataset have no meaning
|
||
// there and stay on this page.
|
||
auto *page = new QWidget(this);
|
||
auto *layout = new QVBoxLayout(page);
|
||
layout->setContentsMargins(0, 0, 0, 0);
|
||
|
||
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, "μm", this);
|
||
gridStepY_ = new NumberLineEdit(-10000.0, 10000.0, DEFAULT_GRID_STEP_UM, 1, "μm", 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_->setWordWrap(true); // it carries a provenance line and, when set, a warning
|
||
|
||
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", 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() {
|
||
if (!modeGrid_->isChecked()) {
|
||
gridSummary_->clear();
|
||
MarkGridOverride(false);
|
||
return;
|
||
}
|
||
const auto grid = GridScanFromFields();
|
||
if (!grid) {
|
||
// Refused rather than repaired with a plausible-looking value: a grid built on a step nobody
|
||
// asked for puts every crystal somewhere else on the sample, and nothing about the map looks
|
||
// wrong. With no grid set, a grid-scan run is refused instead.
|
||
gridSummary_->setStyleSheet("color: #C0392B;");
|
||
gridSummary_->setText("Step x and y must both be non-zero. No grid scan is set, so a grid-scan"
|
||
" run will be refused.");
|
||
MarkGridOverride(true);
|
||
return;
|
||
}
|
||
|
||
QString text = QStringLiteral("%1 x %2 points for %3 images")
|
||
.arg(grid->GetGridSizeX_step())
|
||
.arg(grid->GetGridSizeY_step())
|
||
.arg(experiment_.GetImageNum());
|
||
|
||
// Same grid, said the same way: what the file stores is the point count, the steps and the two
|
||
// flags - the row count is derived from the images and cannot differ on its own.
|
||
const bool from_file = file_grid_.has_value()
|
||
&& file_grid_->GetNFast() == grid->GetNFast()
|
||
&& file_grid_->GetGridStepX_um() == grid->GetGridStepX_um()
|
||
&& file_grid_->GetGridStepY_um() == grid->GetGridStepY_um()
|
||
&& file_grid_->IsVerticalScan() == grid->IsVerticalScan()
|
||
&& file_grid_->IsSnakeScan() == grid->IsSnakeScan();
|
||
if (from_file)
|
||
text += "\nfrom the grid scan in the file";
|
||
else if (file_grid_)
|
||
text += "\nEDITED - this is not the grid scan the file describes";
|
||
else
|
||
text += "\nSET HERE - the file describes no grid scan";
|
||
|
||
// The images have to fill the raster: a partly collected scan leaves the last row short, which is
|
||
// legitimate, but a point count that does not divide the images is far more often a wrong one.
|
||
const bool full_rows = grid->GetNElem() == experiment_.GetImageNum();
|
||
if (!full_rows)
|
||
text += QStringLiteral("\n%1 images do not fill %2 grid points - the last row is incomplete")
|
||
.arg(experiment_.GetImageNum()).arg(grid->GetNElem());
|
||
|
||
gridSummary_->setStyleSheet(from_file && full_rows ? "color: gray;" : "color: #B8860B;");
|
||
gridSummary_->setText(text);
|
||
MarkGridOverride(!from_file);
|
||
}
|
||
|
||
void JFJochViewerSettingsDock::MarkGridOverride(bool on) {
|
||
// An edited grid must not read like the file's. Getting one of these wrong reshapes the whole map
|
||
// and moves every crystal it reports, and nothing else on screen changes, so the fields say so
|
||
// themselves rather than leaving it to the summary line alone.
|
||
const QString style = on ? "background-color: #FFF3CD;" : QString();
|
||
for (auto *f : {gridNFast_, gridStepX_, gridStepY_})
|
||
f->setStyleSheet(style);
|
||
gridVertical_->setStyleSheet(style);
|
||
gridSnake_->setStyleSheet(style);
|
||
}
|
||
|
||
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 or a non-positive point count, and an exception thrown
|
||
// out of a widget signal aborts the viewer. Report "no grid" instead of substituting a value: a
|
||
// grid on a step nobody entered is a map that is confidently in the wrong place, and the summary
|
||
// says which field is at fault.
|
||
const auto step_x = static_cast<float>(gridStepX_->value());
|
||
const auto step_y = static_cast<float>(gridStepY_->value());
|
||
const int64_t n_fast = std::llround(gridNFast_->value());
|
||
if (step_x == 0.0f || step_y == 0.0f || n_fast < 1)
|
||
return std::nullopt;
|
||
GridScanSettings grid(n_fast, 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. It is
|
||
// independent of the loaded data (the worker keeps it across file switches); this just lets the
|
||
// user pick the MTZ + column and shows what it contains and whether it matches the data.
|
||
auto *section = new CollapsibleSection("Reference dataset", this);
|
||
auto *form = new QFormLayout();
|
||
form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
|
||
|
||
refButton_ = new QPushButton("Choose MTZ…", this);
|
||
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);
|
||
|
||
form->addRow(refButton_);
|
||
form->addRow("Column", refColumn_);
|
||
form->addRow(refSummary_);
|
||
form->addRow(refWarning_);
|
||
section->setContentLayout(form);
|
||
|
||
connect(refButton_, &QPushButton::clicked, this, [this] {
|
||
const QString path = QFileDialog::getOpenFileName(this, "Reference MTZ", refPath_,
|
||
"MTZ files (*.mtz);;All files (*)");
|
||
if (path.isEmpty())
|
||
return;
|
||
refPath_ = path;
|
||
emit referenceSelected(path, QString()); // empty column -> let the worker auto-select
|
||
});
|
||
// 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;
|
||
}
|
||
|
||
QWidget *JFJochViewerSettingsDock::BuildGridPage() {
|
||
// The Grid page is the raster analysis and nothing else: where the crystals are is decided from
|
||
// the finished map, and these are the knobs of that decision. The same settings are offered by the
|
||
// broker frontend (frontend/src/components/GridScanAnalysisSettings.tsx); the wording, the order
|
||
// and the units follow it, and every default is read out of GridScanAnalysisSettings rather than
|
||
// written again here, so the two panels cannot drift apart on what a default is.
|
||
auto *page = new QWidget(this);
|
||
auto *layout = new QVBoxLayout(page);
|
||
layout->setContentsMargins(0, 0, 0, 0);
|
||
|
||
const GridScanAnalysisSettings defaults;
|
||
|
||
auto *blobSection = new CollapsibleSection("Which cells are a crystal", page);
|
||
auto *blob = new QFormLayout();
|
||
blob->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
|
||
proteinScore_ = new NumberLineEdit(0.0f, 1.0f, defaults.GetProteinScoreThreshold(), 2, "", page);
|
||
proteinScore_->setToolTip("The per-image protein score saturates, so this only has to separate "
|
||
"\"something diffracted here\" from \"nothing did\".");
|
||
growScore_ = new NumberLineEdit(0.0f, 1.0f, defaults.GetGrowScoreThreshold(), 2, "", page);
|
||
growScore_->setToolTip("Once a patch has a cell above the threshold it is grown out to this score, "
|
||
"so one weak cell does not split a crystal in two. Growth cannot start a "
|
||
"patch on its own.");
|
||
minBlobCells_ = new NumberLineEdit(1.0f, 10000.0f, static_cast<float>(defaults.GetMinBlobCells()),
|
||
0, "", page);
|
||
minBlobCells_->setToolTip("Two cells can be the two ends of a single hit on a cell boundary; three "
|
||
"is the smallest patch that is a shape rather than a coincidence.");
|
||
decisiveScore_ = new NumberLineEdit(0.0f, 1.0f, defaults.GetDecisiveSingleCellScore(), 2, "", page);
|
||
decisiveScore_->setToolTip("One cell scoring this high is a crystal on its own - a crystal smaller "
|
||
"than the grid step lights exactly one cell. Keep it well above the "
|
||
"protein score threshold.");
|
||
blob->addRow("Protein score threshold", proteinScore_);
|
||
blob->addRow("Grow score threshold", growScore_);
|
||
blob->addRow("Minimum cells per crystal", minBlobCells_);
|
||
blob->addRow("Decisive single-cell score", decisiveScore_);
|
||
blobSection->setContentLayout(blob);
|
||
layout->addWidget(blobSection);
|
||
|
||
auto *reportSection = new CollapsibleSection("Reporting and indexing", page);
|
||
auto *report = new QFormLayout();
|
||
report->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
|
||
// No cap is the default, and it is spelled as the box being unticked rather than as a count that
|
||
// happens to mean "all": a crystal that was found and then dropped cannot be got back, so the
|
||
// setting that drops them has to be switched on deliberately.
|
||
maxCrystalsOn_ = new QCheckBox("Limit crystals reported", page);
|
||
maxCrystalsOn_->setChecked(defaults.GetMaxCrystals().has_value());
|
||
maxCrystalsOn_->setToolTip("Unticked, every crystal found is reported. Tick it for a loop full of "
|
||
"shards that would otherwise be labelled by the dozen.");
|
||
maxCrystals_ = new NumberLineEdit(1.0f, 10000.0f,
|
||
static_cast<float>(defaults.GetMaxCrystals().value_or(10)), 0, "", page);
|
||
maxCrystals_->setEnabled(maxCrystalsOn_->isChecked());
|
||
maxCrystals_->setToolTip("Crystals are sorted by score, so this keeps the best.");
|
||
gridIndexing_ = new QCheckBox("Index each raster cell", page);
|
||
gridIndexing_->setChecked(defaults.IsIndexing());
|
||
gridIndexing_->setToolTip("Additive: crystals are still found on the protein score, so indexing "
|
||
"only adds what was found in them. Turn it off for a very large raster "
|
||
"where the GPU is the constraint.");
|
||
// Checkbox and value on one line in the field column, as the scaling section's resolution limits.
|
||
auto *maxRow = new QHBoxLayout();
|
||
maxRow->addWidget(maxCrystalsOn_);
|
||
maxRow->addWidget(maxCrystals_, 1);
|
||
report->addRow("", maxRow);
|
||
report->addRow("", gridIndexing_);
|
||
reportSection->setContentLayout(report);
|
||
layout->addWidget(reportSection);
|
||
|
||
for (auto *f : {proteinScore_, growScore_, minBlobCells_, decisiveScore_, maxCrystals_})
|
||
connect(f, &NumberLineEdit::newValue, this, [this] { EmitExperiment(); });
|
||
connect(maxCrystalsOn_, &QCheckBox::toggled, this, [this](bool on) {
|
||
maxCrystals_->setEnabled(on);
|
||
EmitExperiment();
|
||
});
|
||
connect(gridIndexing_, &QCheckBox::toggled, this, [this] { EmitExperiment(); });
|
||
|
||
layout->addStretch();
|
||
return page;
|
||
}
|
||
|
||
GridScanAnalysisSettings JFJochViewerSettingsDock::GridAnalysisFromFields() const {
|
||
GridScanAnalysisSettings settings;
|
||
settings.ProteinScoreThreshold(static_cast<float>(proteinScore_->value()))
|
||
.GrowScoreThreshold(static_cast<float>(growScore_->value()))
|
||
.MinBlobCells(std::llround(minBlobCells_->value()))
|
||
.DecisiveSingleCellScore(static_cast<float>(decisiveScore_->value()))
|
||
.MaxCrystals(maxCrystalsOn_->isChecked()
|
||
? std::optional<int64_t>(std::llround(maxCrystals_->value()))
|
||
: std::nullopt)
|
||
.Indexing(gridIndexing_->isChecked());
|
||
return settings;
|
||
}
|
||
|
||
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, "px", this);
|
||
auto *r2 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR2(), 1, "px", this);
|
||
auto *r3 = new NumberLineEdit(1.0f, 30.0f, bragg_.GetR3(), 1, "px", 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", 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());
|
||
experiment_.ImportGridScanAnalysisSettings(GridAnalysisFromFields());
|
||
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_);
|
||
// Which of the three the panel shows. Two things decide it, and neither is obvious.
|
||
//
|
||
// A grid scan is stored WITH a spindle beside it - NXmx cannot say "no rotation", so a raster
|
||
// carries a stationary omega - so a goniometer that does not scan must not outrank the grid.
|
||
// Read the other way round, the panel flipped to Rotation the moment a grid-scan run came back
|
||
// carrying that placeholder axis, and the next edit then dropped the raster from the experiment
|
||
// and got the following run refused.
|
||
//
|
||
// And a grid the fields cannot make - a step typed to zero - leaves the experiment with no grid
|
||
// scan at all. That is a half-finished edit, not a dataset that stopped being a raster, so the
|
||
// mode stays where the user put it and the summary says what is wrong. Anything else (a file that
|
||
// simply has no grid) does move the mode.
|
||
const bool grid_mode = grid.has_value()
|
||
? !(gonio.has_value() && gonio->IsScanning())
|
||
: (modeGrid_->isChecked() && !GridScanFromFields().has_value());
|
||
modeGrid_->setChecked(grid_mode);
|
||
modeRotation_->setChecked(!grid_mode && gonio.has_value());
|
||
modeStill_->setChecked(!grid_mode && !gonio.has_value());
|
||
EnableRotationFields(modeRotation_->isChecked());
|
||
EnableGridFields(grid_mode);
|
||
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();
|
||
|
||
// The grid-scan analysis settings come back the same way the geometry does, so what the panel
|
||
// shows is always what a run would use: the panel writes them onto the experiment, the reader
|
||
// carries them on the dataset (JFJochReader::UpdateGeomMetadata), and this reads them back.
|
||
const auto analysis = experiment_.GetGridScanAnalysisSettings();
|
||
proteinScore_->setValue(analysis.GetProteinScoreThreshold());
|
||
growScore_->setValue(analysis.GetGrowScoreThreshold());
|
||
minBlobCells_->setValue(static_cast<float>(analysis.GetMinBlobCells()));
|
||
decisiveScore_->setValue(analysis.GetDecisiveSingleCellScore());
|
||
QSignalBlocker blockMaxCrystals(maxCrystalsOn_), blockIndexing(gridIndexing_);
|
||
maxCrystalsOn_->setChecked(analysis.GetMaxCrystals().has_value());
|
||
maxCrystals_->setEnabled(analysis.GetMaxCrystals().has_value());
|
||
if (analysis.GetMaxCrystals())
|
||
maxCrystals_->setValue(static_cast<float>(analysis.GetMaxCrystals().value()));
|
||
gridIndexing_->setChecked(analysis.IsIndexing());
|
||
|
||
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::fileOpened() {
|
||
// The baseline the grid fields are called edited against. It can only be taken here: datasetLoaded
|
||
// fires again after every settings change - the panel's own experiment comes back through the
|
||
// reader - and by then the file's grid scan is indistinguishable from one typed over it.
|
||
file_grid_ = experiment_.GetGridScan();
|
||
UpdateGridSummary();
|
||
}
|
||
|
||
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(info.loaded ? "color: #B8860B;" : "color: #C0392B;");
|
||
refWarning_->setText(info.warning);
|
||
refWarning_->setVisible(true);
|
||
}
|
||
}
|