Files
Jungfraujoch/viewer/widgets/JFJochViewerSettingsDock.cpp
T
leonarski_f 67dca388bd
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 18m44s
Build Packages / build:viewer-tgz:cpu (push) Successful in 6m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 6m54s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 9m40s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 10m41s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 10m10s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 10m4s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 11m5s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 12m23s
Build Packages / build:rpm (rocky8) (push) Successful in 11m30s
Build Packages / build:rpm (rocky9) (push) Successful in 12m51s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m8s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 11m21s
Build Packages / DIALS test (push) Successful in 13m22s
Build Packages / XDS test (durin plugin) (push) Successful in 9m2s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 7m55s
Build Packages / XDS test (neggia plugin) (push) Successful in 5m57s
Build Packages / Generate python client (push) Successful in 23s
Build Packages / Build documentation (push) Successful in 57s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 10m24s
v1.0.0-rc.160 (#70)
This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use.

* rugnux: Add `--model model.pdb` - score the merged data against an atomic model and compute initial maps. It reports R-work/R-free (scaling the model to the observed amplitudes with an overall scale, an anisotropic B and a flat bulk solvent - the standard few-parameter model, so a batch of maps stays directly comparable) and writes 2Fo-Fc / Fo-Fc electron-density maps (CCP4) plus a map-coefficient MTZ. The structure itself is not refined; the model is only re-fractionalised into the data cell.
* rugnux: The merged reflection output now carries French-Wilson amplitudes (|F| and its sigma) next to the intensities - MTZ `F`/`SIGF`, mmCIF `_refln.F_meas_au`, and the text HKL - computed with the correct centric/acentric Wilson prior and epsilon multiplicity, so a downstream program (e.g. phenix.refine) can refine against amplitudes. The intensity columns are unchanged.
* rugnux: R-free test-set flags are now assigned deterministically and consistently across symmetry - a Bijvoet pair I(+)/I(-) is never split between the work and free sets, and the assignment is a reproducible per-hkl hash that depends only on the reflection index, so every dataset of one crystal form gets the same ~5% free set (what a multi-dataset campaign such as PanDDA needs). On small data the fraction is floored so the test set stays large enough for a stable R-free (~500 reflections, capped at 10%); it stays flat at 5% on ordinary data. When a reference MTZ carries a `FreeR_flag` column its test set is imported instead, letting a whole campaign inherit one shared free set.
* rugnux: A reference MTZ (`--reference-mtz`) can now fix the space group and cell for rotation data too (previously rejected), without being used to scale - the rotation merge stays self-consistent. When the crystal has an indexing (merohedral) ambiguity - a lattice symmetry higher than its Laue symmetry, e.g. P3/P4/P6/C2 - the reference also resolves it: each candidate reindexing (identity plus the twin-law cosets of the metric symmetry) is scored by its intensity correlation against the reference and the data are re-merged in the best-correlating one. This is a metric-preserving relabelling of hkl (the cell is unchanged) and a no-op for a holohedral crystal such as lysozyme.
* rugnux: `--model` validation now aligns the data to the model before scoring - the observed reflections are reindexed into the model's enantiomorph when the two differ only by hand (indistinguishable from merged intensities). A merohedral indexing ambiguity is resolved against the reference MTZ when one is given (so a whole campaign shares one indexing convention); only with a model and no reference does validation fall back to fitting each candidate reindexing and keeping the lowest R-free.
* rugnux: De-novo symmetry - recover a genuine high-symmetry group whose data are imperfectly scaled. Such a merge's within-orbit chi² lands just past the self-consistency bound (each real symmetry step adds a little systematic scatter), right where a merohedral twin also lands, so the chi² ratio alone cannot separate them. The candidate is now rescued when the extra intensity-proportional systematic error it invokes stays small relative to the confirmed subgroup - a genuine symmetry step gains multiplicity without inflating the merge error model's b, whereas a twin forces non-equivalent reflections together and b balloons. Fixes cubic insulin (I23 instead of I222) with no change to any other crystal in the test battery, including the twins that must stay in their lower symmetry.
* Docs: Document the French-Wilson amplitude estimation, R-free flagging, reference-based space-group/ambiguity resolution, and model-based validation/maps in CPU_DATA_ANALYSIS.md.
* Frontend: The status-bar pill now shows a progress bar during detector calibration (previously only during measurement), and the calibration state and its button are labelled "Calibration"/"CALIBRATE" (the internal `Pedestal` state name is unchanged for back-compatibility).Reviewed-on: #70

Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-07-19 09:39:28 +02:00

687 lines
34 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <QLabel>
#include <QSignalBlocker>
#include <QFileDialog>
#include <QIcon>
#include <QPixmap>
#include <QPainter>
#include <cmath>
#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 {
// 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) {
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 or azimuthal, per the toggle below)");
auto *analyzeRow = new QHBoxLayout();
analyzeRow->addWidget(analyzeImageBtn);
analyzeRow->addWidget(analyzeDatasetBtn_);
layout->addLayout(analyzeRow);
layout->addSpacing(10);
connect(analyzeImageBtn, &QPushButton::toggled, this, &JFJochViewerSettingsDock::reanalyzeImage);
connect(analyzeDatasetBtn_, &QPushButton::clicked, this, [this] { emit analyzeDataset(azint_mode_); });
// Segmented MX / AzInt toggle: the two communities pick their page; pages never share a screen.
auto *mxButton = new QPushButton("MX", this);
auto *azButton = new QPushButton("AzInt", this);
for (auto *b : {mxButton, azButton}) {
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);
auto *toggleRow = new QHBoxLayout();
toggleRow->setSpacing(0);
toggleRow->addWidget(mxButton);
toggleRow->addWidget(azButton);
auto *stack = new QStackedWidget(this);
stack->addWidget(BuildMXPage());
stack->addWidget(BuildAzIntPage());
connect(group, &QButtonGroup::idClicked, this, [this, stack](int id) {
azint_mode_ = (id == 1);
stack->setCurrentIndex(id);
});
// Geometry is common to both communities, so it lives above the toggle rather than per page.
layout->addLayout(toggleRow);
layout->addWidget(BuildGeometrySection());
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 or azimuthal, 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);
// 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_);
geom->addRow("Photon energy", energy_);
geom->addRow("Detector distance", distance_);
geom->addRow("Beam origin", beam);
geom->addRow("Detector tilt", tilt);
for (auto *f : {energy_, distance_, beamX_, beamY_, rot1_, rot2_})
connect(f, &NumberLineEdit::newValue, this, [this] { 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 (1230); 0 = unset.");
spaceGroupName_ = new QLabel("—", page);
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();
});
// --- 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 *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page);
highResSpot->setValue(spot_.high_resolution_limit);
auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast<float>(spot_.min_pix_per_spot), 0, "px", page);
auto *maxSpots = new NumberLineEdit(10.0f, 100000.0f, static_cast<float>(max_spots_), 0, "", page);
spot->addRow("Signal/noise", snr);
spot->addRow("Photon count", count);
spot->addRow("High resolution [Å]", highResSpot);
spot->addRow("Min pixels/spot", minPix);
spot->addRow("Max spots/image", maxSpots);
spotSection->setContentLayout(spot);
spotSection->setExpanded(false);
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] {
spot_.min_pix_per_spot = std::llround(minPix->value()); EmitSpotFinding(); });
connect(maxSpots, &NumberLineEdit::newValue, this, [this, maxSpots] {
max_spots_ = std::llround(maxSpots->value()); EmitSpotFinding(); });
// --- Indexing ---
auto *idxSection = new CollapsibleSection("Indexing", page);
auto *idx = new QFormLayout();
idx->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
algo_ = new QComboBox(page);
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->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())));
// One high-level mode switch instead of separate partiality / rot3d / rotation-indexing options:
// for a rotation dataset, unchecked = the rotation good-path (rotation indexing + Rotation
// partiality + rot3d combine + scale-fulls), checked = treat it as stills (fixed partiality,
// per-frame indexing). Disabled (and a no-op) for datasets that are already stills.
stills_ = new QCheckBox("Process as stills", page);
stills_->setEnabled(false); // datasetLoaded enables it only for rotation (goniometer) datasets
stills_->setToolTip("Treat a rotation dataset as independent stills (fixed partiality, per-frame "
"indexing). Unchecked on a rotation dataset = rotation indexing + 3D rotation scaling.");
idx->addRow("Algorithm", algo_);
idx->addRow("", algoDesc_);
idx->addRow("Refinement", refine);
idx->addRow("", stills_);
idxSection->setContentLayout(idx);
idxSection->setExpanded(false);
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();
});
connect(stills_, &QCheckBox::toggled, this, [this] { ApplyProcessingMode(); });
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::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);
section->setExpanded(false); // folded on start (only geometry + unit cell start open)
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::BuildAzIntPage() {
auto *page = new QWidget(this);
auto *layout = new QVBoxLayout(page);
layout->setContentsMargins(0, 0, 0, 0);
auto *azSection = new CollapsibleSection("Azimuthal integration", page);
auto *az = new QFormLayout();
az->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
auto *lowQ = new SliderPlusBox(1e-5, 10.0, 0.001, 4, page);
lowQ->setValue(azint_.GetLowQ_recipA());
auto *highQ = new SliderPlusBox(2e-5, 10.0, 0.001, 4, page);
highQ->setValue(azint_.GetHighQ_recipA());
auto *spacing = new SliderPlusBox(1e-5, 1.0, 0.001, 5, page, SliderPlusBox::ScaleType::Logarithmic);
spacing->setValue(azint_.GetQSpacing_recipA());
auto *azimBins = new QComboBox(page);
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("High Q [Å⁻¹]", highQ);
az->addRow("Q spacing [Å⁻¹]", spacing);
az->addRow("Azimuthal bins", azimBins);
azSection->setContentLayout(az);
azSection->setExpanded(false);
layout->addWidget(azSection);
auto emitAz = [=, this] {
azint_.QRange_recipA(static_cast<float>(lowQ->value()), static_cast<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(); });
// Powder calibration (calibrant rings + geometry refinement) - reuse the existing widget.
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_);
powderSection->setContentLayout(powderLayout);
powderSection->setExpanded(false);
layout->addWidget(powderSection);
layout->addStretch(); // anchor sections to the top so expanding an accordion grows downward
return page;
}
void JFJochViewerSettingsDock::EmitSpotFinding() {
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);
auto *stillPartiality = new QCheckBox("Stills partiality (experimental)", this);
stillPartiality->setChecked(bragg_.GetStillPartiality());
stillPartiality->setToolTip("Experimental, stills only: weight each reflection by a Gaussian "
"excitation-error partiality exp(-d_ewald²/2σ²) instead of treating it as a "
"full. Pairs with \"Partiality uncertainty\" in Scaling.");
// Background trim: replace the r2..r3 ring mean with a symmetric trimmed mean (drop the lowest and
// highest fraction of ring pixels), which removes the high-side bias that over-subtracts weak
// high-angle reflections. Checkbox + fraction; 0 = plain mean.
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 (drop the lowest and highest fraction of pixels) instead of the "
"plain mean, removing the high-side bias that over-subtracts weak high-angle "
"reflections. 0.10 recommended; unchecked = plain mean.");
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("", stillPartiality);
form->addRow("", trimRow);
section->setContentLayout(form);
section->setExpanded(false); // folded on start (only geometry + unit cell start open)
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()));
bragg_.StillPartiality(stillPartiality->isChecked());
bragg_.BackgroundTrimFraction(bkgTrim->isChecked() ? static_cast<float>(bkgTrimFrac->value()) : 0.0f);
emit braggChanged(bragg_);
};
connect(gaussian, &QCheckBox::toggled, this, [emitBragg] { emitBragg(); });
connect(stillPartiality, &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 "Process as stills" (indexing
// 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 *refineB = new QCheckBox("Refine B-factor", this);
refineB->setChecked(scaling_.GetRefineB());
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 *modulation = new QCheckBox("Detector-plane modulation (stills)", this);
modulation->setChecked(scaling_.GetStillsModulation());
modulation->setToolTip("Stills: fit a detector-plane modulation (flat-field) surface over where each "
"reflection lands, cross-validated so it no-ops when the systematic is absent. "
"For rotation, modulation is part of \"Correction surfaces\" above.");
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());
// Stills partiality-uncertainty merge term: adds a systematic sigma ~c*(1-partiality)*<I> on partials,
// so strong low-partiality partials are not over-trusted. Pairs with "Stills partiality" (Bragg); the
// library auto-gates it to strong/medium data. 0 = off; ~2.5 recommended.
const double part_unc = scaling_.GetPartialityUncertaintyCoeff();
auto *partUncertain = new QCheckBox("Partiality uncertainty", this);
partUncertain->setChecked(part_unc > 0.0);
partUncertain->setToolTip("Stills: add a systematic merge σ ~c·(1partiality)·⟨I⟩ to partials so strong "
"low-partiality partials are not over-trusted. Use with \"Stills partiality\"; "
"auto-gated to strong/medium data. ~2.5 recommended; unchecked = off.");
auto *partUncertainCoeff = new NumberLineEdit(0.1f, 10.0f, part_unc > 0.0 ? part_unc : 2.5, 1, "", this);
partUncertainCoeff->setEnabled(partUncertain->isChecked());
form->addRow("", friedel);
form->addRow("", refineB);
form->addRow("", corrections);
form->addRow("", modulation);
// 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 *puRow = new QHBoxLayout();
puRow->addWidget(partUncertain);
puRow->addWidget(partUncertainCoeff, 1);
form->addRow("", puRow);
section->setContentLayout(form);
section->setExpanded(false); // folded on start (only geometry + unit cell start open)
auto emitScaling = [=, this] {
scaling_.MergeFriedel(friedel->isChecked());
scaling_.RefineB(refineB->isChecked());
scaling_.CorrectionSurfaces(corrections->isChecked());
scaling_.StillsModulation(modulation->isChecked());
scaling_.HighResolutionLimit_A(limitRes->isChecked()
? std::optional<double>(highRes->value()) : std::nullopt);
scaling_.PartialityUncertaintyCoeff(partUncertain->isChecked() ? partUncertainCoeff->value() : 0.0);
emit scalingChanged(scaling_);
};
connect(friedel, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); });
connect(refineB, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); });
connect(corrections, &QCheckBox::toggled, this, [emitScaling] { emitScaling(); });
connect(modulation, &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(partUncertain, &QCheckBox::toggled, this, [emitScaling, partUncertainCoeff](bool on) {
partUncertainCoeff->setEnabled(on); emitScaling(); });
connect(partUncertainCoeff, &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));
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 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); // HermannMauguin 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();
// "Process as stills" only applies to a rotation dataset; enable it accordingly and apply the
// resulting indexing/scaling mode so it reaches the worker.
if (stills_) {
stills_->setEnabled(experiment_.GetGoniometer().has_value());
ApplyProcessingMode();
}
}
void JFJochViewerSettingsDock::ApplyProcessingMode() {
// Rotation good-path unless the dataset is stills or the user forces "Process as stills".
const bool rotation_data = experiment_.GetGoniometer().has_value();
const bool rotation_mode = rotation_data && stills_ && !stills_->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);
}
}