Files
Jungfraujoch/viewer/widgets/JFJochViewerSettingsDock.cpp
T
leonarski_fandClaude Opus 5 16bf3408f0 Address code-review findings; make detection limits detector-driven
One changeset, developed together in response to a review of this branch, so the
files carry several of the changes at once. Full test suite passes (733 cases).

Spot finding
- Split ImageSpotFinder into Detect() (flag strong pixels - the expensive
  per-pixel pass) and ExtractSpots() (CCL + min/max-pix + resolution mask), with
  Run() = both. The per-image min-pix escalation now detects ONCE and repeats
  only the cheap extraction, instead of re-running the whole finder four times
  per frame as it did on the default path. It also keeps the winning attempt's
  spot list rather than re-extracting it, so the frame that is integrated is
  exactly the frame that was scored - which a GPU re-extract could not guarantee
  (float atomic ordering).
- spot_finding_time_s no longer swallows indexing time, and indexing_time_s now
  sums every escalation call instead of reporting only the last.

Detection limits follow the detector
- The azimuthal-integration upper q and the spot-finding high-resolution limit
  are now std::optional, in the C++ structs AND in the OpenAPI schema, and
  resolve to the detector's own maximum (DiffractionExperiment::GetDetectorMaxQ_
  recipA). Adaptive detection reads a pixel's ring from the azimuthal bins, so a
  pixel outside that q range could never be strong - the integration range
  silently bounded what detection could see, regardless of the requested
  resolution limit. Regenerated the C++ and TypeScript clients; the viewer and
  the web frontend each gained a "to detector edge" switch.

Detection defaults are now per workflow (measured, not assumed)
- Stills: adaptive detection, min-pix chosen per image, no resolution clipping.
- Rotation: fixed-threshold finder, min-pix 2, 1.5 A limit.
  On a 33-crystal rotation battery, adaptive detection helped four hard crystals
  but deterministically broke three (a lost space group, a halved indexing rate,
  a collapsed merge), and the detector-edge limit cost indexing on a strong
  rotation set (100.0 -> 96.8%). Each is still overridable by its flag, and
  --no-adaptive-spots is new.

Indexer seed escalation
- Stop escalating once a seed's lattice explains >= 90% of the seed spots.
  Previously any frame with >= 80 spots always paid three indexer calls, online
  broker included.

Merge-consistency filter
- --min-image-cc gated on a per-image CC computed BEFORE the stills partiality
  post-refinement and never refreshed; the refiner now recomputes it, so the
  reported CC describes the data that are actually merged.
- Replaced the per-call cc_mask argument with one MergeOnTheFly flag, so the
  merge, the error model and MergeStats can no longer disagree about which
  images are in (the --scale path merged unfiltered while its statistics were
  filtered).

Per-image B-factor refinement (-B) removed
- Measured on four serial-stills datasets: it is a no-op where the per-image fit
  is well conditioned and actively harmful where it is not (CC1/2 -8.1, R_meas
  +23.2 on the weakest large-cell set, whose fits hit their [-50, 200] bounds on
  14-25% of images). It had also been silently DISCARDED since the partiality
  post-refinement landed - reported but not applied. Rather than fix and keep a
  knob with no demonstrated benefit, the flag and the whole image_scale_b_factor
  chain are gone: setting, scaling fit, message field, CBOR, HDF5 write and
  read-back, per-image plot, OpenAPI enum, viewer column and checkbox, docs.
  ScaleOnTheFly no longer needs Ceres at all - the fit is a linear IRLS.
  (The Wilson per-image b_factor is a different quantity and stays.)

Stills partiality width now fits both of its components
- sigma^2 = gamma0^2 + (gamma_e*d*)^2 instead of a purely angular gamma_e*d*
  with gamma0 pinned to 0. Fitted per crystal by least squares of dist_ewald^2
  on d*^2. The angular-only width is fitted over a d*^2-dense population, so it
  was pinned by the high-resolution edge and collapsed at low d*: median
  partiality 0.008 beyond 13 A for reflections that were plainly recorded, 55%
  of them under the merge's partiality floor, and the survivors divided by those
  values - which inflated the merged low-resolution intensity scale 3.6x
  (~ +9 A^2 of apparent B). Measured on 5000 stills: the ramp flattens to 0.89x,
  no observation is dropped any more (701750 -> 716811), shell-mean CC1/2 and
  R-free improve slightly. Note CC1/2, R_meas, completeness and a B-refining
  R-free are all blind to that ramp, which is why it survived earlier validation;
  the cost is high-resolution R_meas (98.5 -> 101.9 shell-averaged).

Removed dead code from add-then-remove churn
- Prediction-time "still partiality" (unreachable: no setter), the phantom
  IndexingSettings::min_indexed_spot_fraction knob (getter, no setter - now the
  constant it always was), StillsPartialityRefine's caller-less Settings
  constructor and its reference to a long-gone env var, ProcessImage's unread
  bool return, an unused include, and a dead viewer overlay hook.

Also
- Viewer: the magnifier compared a QImage with itself, so its scene rect was set
  once ever and it could not pan into a larger dataset; the hover tail timer
  could fire after leaveEvent and resurrect the resolution readout outside the
  image.
- update_version.sh regenerated the frontend lock file BEFORE bumping the
  version (every release shipped an off-by-one lock), and did git rm/git add on
  a path that has not existed since the client moved to src/client - with no
  set -e, both failed silently.
- fpga/pcie_driver/postinstall.sh tested "[ ! occurrences > 0 ]", which is a
  redirect, not a test, so dkms add never ran.
- Unit tests for the adaptive-threshold host functions, which had none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 09:07:00 +02:00

730 lines
36 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),
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 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 *adaptive = new QCheckBox("Adaptive threshold (per-ring, auto)", page);
adaptive->setChecked(spot_.adaptive_threshold);
adaptive->setToolTip("Self-calibrating detection: the strong-pixel threshold is derived from each "
"image's own per-resolution-ring noise. The signal/noise and photon-count "
"settings are not used while this is on. Best on stills; on rotation data the "
"fixed-threshold finder is the safer choice, which is what rugnux defaults to "
"there.");
auto *highResSpot = new SliderPlusBox(0.5, 5.0, 0.1, 1, page);
highResSpot->setValue(spot_.high_resolution_limit.value_or(1.5f));
auto *autoHighResSpot = new QCheckBox("To detector edge", page);
autoHighResSpot->setChecked(!spot_.high_resolution_limit.has_value());
autoHighResSpot->setToolTip("Find spots as far as the detector reaches, instead of clipping the "
"detection at a fixed resolution. The high-resolution value is not used "
"while this is on.");
auto *minPix = new NumberLineEdit(1.0f, 50.0f, static_cast<float>(min_pix_value_), 0, "px", page);
auto *adaptiveMinPix = new QCheckBox("Adaptive min-pix (stills)", page);
adaptiveMinPix->setChecked(adaptive_min_pix_);
adaptiveMinPix->setToolTip("Choose the minimum pixels/spot per image: index the frame at min-pix "
"3/2/1 and keep whichever maximises indexed count x indexed fraction. "
"Stills only - a rotation dataset builds one lattice from all frames and "
"always uses the fixed min-pixels/spot value below.");
auto *maxSpots = new NumberLineEdit(10.0f, 100000.0f, static_cast<float>(max_spots_), 0, "", page);
spot->addRow("", adaptive);
spot->addRow("Signal/noise", snr);
spot->addRow("Photon count", count);
spot->addRow("", autoHighResSpot);
spot->addRow("High resolution [Å]", highResSpot);
spot->addRow("", adaptiveMinPix);
spot->addRow("Min pixels/spot", minPix);
spot->addRow("Max spots/image", maxSpots);
spotSection->setContentLayout(spot);
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] {
min_pix_value_ = std::llround(minPix->value()); EmitSpotFinding(); });
connect(maxSpots, &NumberLineEdit::newValue, this, [this, maxSpots] {
max_spots_ = std::llround(maxSpots->value()); EmitSpotFinding(); });
// The adaptive finder sets its own threshold from each image's noise, so the signal/noise and
// photon-count sliders do nothing while it is on - grey them out to make that clear.
auto syncAdaptiveEnabled = [snr, count](bool on) {
snr->setEnabled(!on);
count->setEnabled(!on);
};
syncAdaptiveEnabled(spot_.adaptive_threshold);
connect(adaptive, &QCheckBox::toggled, this, [this, syncAdaptiveEnabled](bool on) {
spot_.adaptive_threshold = on;
syncAdaptiveEnabled(on);
EmitSpotFinding();
});
// An unset high-resolution limit (std::nullopt) means "to the detector edge", so the value is unused
// while that is on - grey it out, as for the other automatic settings.
auto syncHighResEnabled = [highResSpot](bool auto_on) { highResSpot->setEnabled(!auto_on); };
syncHighResEnabled(!spot_.high_resolution_limit.has_value());
connect(autoHighResSpot, &QCheckBox::toggled, this, [this, highResSpot, syncHighResEnabled](bool on) {
if (on)
spot_.high_resolution_limit = std::nullopt;
else
spot_.high_resolution_limit = static_cast<float>(highResSpot->value());
syncHighResEnabled(on);
EmitSpotFinding();
});
// The fixed min-pixels/spot field stays live even with adaptive min-pix on: it is what a rotation
// dataset uses, and what stills fall back to when adaptive is switched off.
connect(adaptiveMinPix, &QCheckBox::toggled, this, [this](bool on) {
adaptive_min_pix_ = on;
EmitSpotFinding();
});
// --- Indexing ---
auto *idxSection = new CollapsibleSection("Indexing", page);
auto *idx = new QFormLayout();
idx->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow);
algo_ = new QComboBox(page);
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 *autoHighQ = new QCheckBox("To detector edge", page);
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, 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("", autoHighQ);
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()),
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());
// 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::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: 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("", 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_.BackgroundTrimFraction(bkgTrim->isChecked() ? static_cast<float>(bkgTrimFrac->value()) : 0.0f);
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 "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 *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());
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);
section->setContentLayout(form);
section->setExpanded(false); // folded on start (only geometry + unit cell start open)
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);
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(); });
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);
}
}