Files
Jungfraujoch/viewer/windows/JFJochMergeStatsWindow.cpp
T
leonarski_f 680c36c20d
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s
v1.0.0-rc.166 (#76)
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands.
* `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion.
* Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants.
* `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing.
* A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed.
* `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing.
* Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences.
* The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to.
* The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after.
* The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution.
* `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have.
* Twinning is no longer reported when the L-test contradicts it.
* The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's.
* `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots.
* The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area.

Reviewed-on: #76
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-02 21:17:31 +02:00

377 lines
17 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 "JFJochMergeStatsWindow.h"
#include <cmath>
#include <limits>
#include <QButtonGroup>
#include <QComboBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QStackedWidget>
#include <QStringList>
#include <QTableWidget>
#include <QToolButton>
#include <QVBoxLayout>
#include <QtCharts/QChart>
#include <QtCharts/QChartView>
#include <QtCharts/QLineSeries>
#include <QtCharts/QLogValueAxis>
#include <QtCharts/QScatterSeries>
#include <QtCharts/QValueAxis>
#include "../charts/JFJochSimpleChartView.h"
#include "../widgets/ToolbarIcons.h"
namespace {
// Per-shell metric value (NaN -> dropped by the chart). Keep in sync with the combo entries.
double ShellMetric(const MergeStatisticsShell &s, int metric) {
switch (metric) {
case 0: return s.cc_half * 100.0; // CC1/2 (%)
case 1: return s.r_meas * 100.0; // R-meas (%)
case 2: return s.mean_i_over_sigma; // <I/sigma>
case 3: return s.possible_unique_reflections > 0 // Completeness (%)
? 100.0 * s.unique_reflections / s.possible_unique_reflections : NAN;
case 4: return s.unique_reflections > 0 // Multiplicity
? static_cast<double>(s.total_observations) / s.unique_reflections : NAN;
case 5: return s.cc_ref * 100.0; // CCref (%)
default: return NAN;
}
}
// A big-number "card" for the hero row: large navy value over a small grey caption.
QWidget *MakeCard(const QString &value, const QString &caption, QWidget *parent) {
auto *card = new QFrame(parent);
card->setFrameShape(QFrame::StyledPanel);
auto *l = new QVBoxLayout(card);
l->setContentsMargins(12, 8, 12, 8);
l->setSpacing(0);
auto *v = new QLabel(value, card);
QFont f = v->font();
f.setPointSizeF(f.pointSizeF() * 2.2);
f.setBold(true);
v->setFont(f);
v->setStyleSheet("color: #1F3A5F;");
v->setAlignment(Qt::AlignCenter);
auto *c = new QLabel(caption, card);
c->setStyleSheet("color: gray;");
c->setAlignment(Qt::AlignCenter);
l->addWidget(v);
l->addWidget(c);
return card;
}
}
JFJochMergeStatsWindow::JFJochMergeStatsWindow(const QString &title, const MergeStatistics &stats,
double isa, bool has_reference,
const TwinningAnalysisResult &twinning,
const std::optional<gemmi::SpaceGroup> &space_group,
const std::optional<SearchSpaceGroupResult> &space_group_search,
const std::vector<std::pair<float, float>> &merged_i_sigma,
QWidget *parent)
: QWidget(parent, Qt::Window), stats_(stats), has_reference_(has_reference) {
setWindowTitle("Merge statistics — " + title);
setAttribute(Qt::WA_DeleteOnClose);
resize(740, 560);
auto *layout = new QVBoxLayout(this);
// --- Hero row: overall numbers ---
const auto &o = stats_.overall;
const double completeness = o.possible_unique_reflections > 0
? 100.0 * o.unique_reflections / o.possible_unique_reflections : NAN;
const double multiplicity = o.unique_reflections > 0
? static_cast<double>(o.total_observations) / o.unique_reflections : NAN;
auto pct = [](double v) { return std::isfinite(v) ? QString::number(v, 'f', 1) + "%" : QStringLiteral("—"); };
auto num = [](double v, int d) { return std::isfinite(v) ? QString::number(v, 'f', d) : QStringLiteral("—"); };
auto *hero = new QHBoxLayout();
// High-resolution limit of the merge (d_min). When no scaling resolution limit is given it is set
// by the CC1/2 logistic cutoff, so it varies per run - show it first.
if (o.d_min < o.d_max)
hero->addWidget(MakeCard(QString::asprintf("%.2f", o.d_min), "Resolution [Å]", this));
hero->addWidget(MakeCard(num(isa, 1), "ISa", this));
hero->addWidget(MakeCard(pct(o.cc_half * 100.0), "CC½", this));
hero->addWidget(MakeCard(pct(o.r_meas * 100.0), "R-meas", this));
hero->addWidget(MakeCard(pct(completeness), "Completeness", this));
hero->addWidget(MakeCard(num(multiplicity, 1), "Multiplicity", this));
// Wilson B describes how fast the intensities fall off with resolution; the radiation-damage
// Delta-B is how much MORE they fall off by the end of the run than at the start (rotation only).
if (std::isfinite(stats_.wilson_b))
hero->addWidget(MakeCard(num(stats_.wilson_b, 1), "Wilson B [Ų]", this));
if (std::isfinite(stats_.radiation_damage_delta_b)) {
const double db = stats_.radiation_damage_delta_b;
hero->addWidget(MakeCard((db > 0 ? "+" : "") + num(db, 1), "ΔB, damage [Ų]", this));
}
if (has_reference_)
hero->addWidget(MakeCard(pct(o.cc_ref * 100.0), "CCref", this));
if (space_group.has_value())
hero->addWidget(MakeCard(QString::fromStdString(space_group->short_name()), "Space group", this));
layout->addLayout(hero);
// De-novo space-group search: the point group + the ranked candidates it scored (only present when
// the space group was not fixed by the user). A summary line above a compact table.
if (space_group_search.has_value() && !space_group_search->candidates.empty()) {
const auto &sgs = *space_group_search;
QString summary = "Point group " + QString::fromStdString(sgs.point_group_hm.empty() ? "?" : sgs.point_group_hm);
if (!sgs.alternatives.empty()) {
QStringList alts;
for (const auto &alt : sgs.alternatives)
alts << QString::fromStdString(alt.short_name());
summary += " · indistinguishable from these data: " + alts.join(", ");
}
auto *sgLabel = new QLabel(summary, this);
sgLabel->setStyleSheet("color: gray;");
layout->addWidget(sgLabel);
auto *sgTable = new QTableWidget(this);
sgTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
sgTable->verticalHeader()->setVisible(false);
const QStringList sgHeaders{"Space group", "Absent", "Viol.", "⟨I/σ⟩ abs", "⟨I/σ⟩ pres", "Consistent"};
sgTable->setColumnCount(sgHeaders.size());
sgTable->setHorizontalHeaderLabels(sgHeaders);
sgTable->setRowCount(static_cast<int>(sgs.candidates.size()));
for (int i = 0; i < static_cast<int>(sgs.candidates.size()); ++i) {
const auto &c = sgs.candidates[i];
int col = 0;
auto set = [&](const QString &t, bool leftAlign = false) {
auto *it = new QTableWidgetItem(t);
it->setTextAlignment((leftAlign ? Qt::AlignLeft : Qt::AlignRight) | Qt::AlignVCenter);
if (c.selected) { QFont f = it->font(); f.setBold(true); it->setFont(f); }
sgTable->setItem(i, col++, it);
};
set(QString::fromStdString(c.space_group.short_name()) + (c.selected ? " *" : ""), true);
set(QString::number(c.absent_observed));
set(QString::number(c.absent_violations));
set(QString::number(c.absent_mean_i_over_sigma, 'f', 2));
set(QString::number(c.present_mean_i_over_sigma, 'f', 2));
set(c.consistent ? QStringLiteral("yes") : QStringLiteral("no"));
}
sgTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
sgTable->setMaximumHeight(160); // a secondary detail — keep the shells plot the focus
layout->addWidget(sgTable);
}
// --- Twinning test (Padilla-Yeates L-test + second moment) ---
// A red-outlined banner flags suspected twinning; otherwise a quiet grey "all clear".
if (twinning.l_test_pairs > 0) {
QString text = QString("Twinning: ⟨|L|⟩ = %1 ⟨I²⟩/⟨I⟩² = %2")
.arg(twinning.mean_abs_l, 0, 'f', 3)
.arg(twinning.second_moment, 0, 'f', 2);
if (twinning.twinning_suspected)
text += QString(" — twinning suspected (estimated fraction ≈ %1)")
.arg(twinning.estimated_twin_fraction, 0, 'f', 2);
else
text += " — no twinning indicated";
auto *banner = new QLabel(text, this);
banner->setContentsMargins(10, 6, 10, 6);
if (twinning.twinning_suspected)
banner->setStyleSheet("color: rgb(160,0,0); border: 1px solid red; border-radius: 4px;");
else
banner->setStyleSheet("color: gray;");
layout->addWidget(banner);
}
// --- Controls: plot/table view toggle (icon buttons) + per-resolution metric selector ---
auto *row = new QHBoxLayout();
auto *plotBtn = new QToolButton(this);
plotBtn->setIcon(ToolbarIcons::linePlot());
plotBtn->setCheckable(true);
plotBtn->setChecked(true);
plotBtn->setToolTip("Per-resolution plot");
plotBtn->setStyleSheet(ToolbarIcons::buttonStyle());
auto *tableBtn = new QToolButton(this);
tableBtn->setIcon(ToolbarIcons::table());
tableBtn->setCheckable(true);
tableBtn->setToolTip("Per-shell table");
tableBtn->setStyleSheet(ToolbarIcons::buttonStyle());
auto *isaBtn = new QToolButton(this);
isaBtn->setIcon(ToolbarIcons::linePlot());
isaBtn->setCheckable(true);
isaBtn->setToolTip("ISa diagnostic: merged I/σ against I");
isaBtn->setStyleSheet(ToolbarIcons::buttonStyle());
auto *viewGroup = new QButtonGroup(this);
viewGroup->setExclusive(true);
viewGroup->addButton(plotBtn, 0);
viewGroup->addButton(tableBtn, 1);
viewGroup->addButton(isaBtn, 2);
row->addWidget(plotBtn);
row->addWidget(tableBtn);
row->addWidget(isaBtn);
row->addSpacing(12);
metricLabel_ = new QLabel("Metric:", this);
row->addWidget(metricLabel_);
metric_ = new QComboBox(this);
metric_->addItem("CC½ (%)", 0);
metric_->addItem("R-meas (%)", 1);
metric_->addItem("⟨I/σ⟩", 2);
metric_->addItem("Completeness (%)", 3);
metric_->addItem("Multiplicity", 4);
if (has_reference_)
metric_->addItem("CCref (%)", 5);
row->addWidget(metric_);
row->addStretch();
layout->addLayout(row);
// --- Stacked plot / table ---
stack_ = new QStackedWidget(this);
chart_ = new JFJochSimpleChartView(this);
table_ = new QTableWidget(this);
table_->setEditTriggers(QAbstractItemView::NoEditTriggers);
table_->verticalHeader()->setVisible(false);
stack_->addWidget(chart_); // page 0
stack_->addWidget(table_); // page 1
isa_page_ = buildIsaDiagnostic(merged_i_sigma, isa);
if (isa_page_)
stack_->addWidget(isa_page_); // page 2
else
isaBtn->setEnabled(false);
layout->addWidget(stack_, 1);
connect(metric_, &QComboBox::currentIndexChanged, this, [this](int) { updatePlot(); });
connect(viewGroup, &QButtonGroup::idClicked, this, [this](int idx) {
stack_->setCurrentIndex(idx);
metric_->setVisible(idx == 0); // the metric selector + its label only apply to the plot
metricLabel_->setVisible(idx == 0);
});
buildTable();
updatePlot();
}
// Merged I/sigma against I. The error model says sigma -> b*I for strong reflections, so I/sigma
// flattens off at 1/b = ISa: the plot shows whether the reported number is where the data actually
// level out. Log intensity axis, because the reflections span several decades and the asymptote lives
// at the strong end.
QWidget *JFJochMergeStatsWindow::buildIsaDiagnostic(const std::vector<std::pair<float, float>> &i_sigma,
double isa) {
auto *points = new QScatterSeries();
points->setName("merged reflections");
points->setMarkerSize(4.0);
points->setBorderColor(Qt::transparent);
points->setColor(QColor(0x1F, 0x3A, 0x5F, 0x60));
double i_lo = std::numeric_limits<double>::max(), i_hi = 0.0, r_hi = 0.0;
for (const auto &[I, sigma] : i_sigma) {
if (!(I > 0.0f) || !(sigma > 0.0f))
continue; // the log axis cannot show a non-positive intensity
const double ratio = I / sigma;
points->append(I, ratio);
i_lo = std::min(i_lo, static_cast<double>(I));
i_hi = std::max(i_hi, static_cast<double>(I));
r_hi = std::max(r_hi, ratio);
}
if (points->count() < 2 || !(i_hi > i_lo)) {
delete points;
return nullptr;
}
auto *chart = new QChart();
chart->legend()->setVisible(false);
chart->addSeries(points);
auto *ax = new QLogValueAxis();
ax->setBase(10.0);
ax->setTitleText("merged I");
ax->setRange(i_lo, i_hi);
auto *ay = new QValueAxis();
ay->setTitleText("merged I/σ");
ay->setRange(0.0, std::max(r_hi, std::isfinite(isa) ? isa * 1.2 : 0.0) * 1.05);
chart->addAxis(ax, Qt::AlignBottom);
chart->addAxis(ay, Qt::AlignLeft);
points->attachAxis(ax);
points->attachAxis(ay);
if (std::isfinite(isa) && isa > 0.0) {
auto *line = new QLineSeries();
line->append(i_lo, isa);
line->append(i_hi, isa);
QPen pen(QColor(0xC0, 0x39, 0x2B));
pen.setStyle(Qt::DashLine);
pen.setWidth(2);
line->setPen(pen);
chart->addSeries(line);
line->attachAxis(ax);
line->attachAxis(ay);
}
auto *view = new QChartView(chart, this);
view->setRenderHint(QPainter::Antialiasing);
view->setToolTip(std::isfinite(isa) && isa > 0.0
? QString("Dashed line: reported ISa = %1. The strong reflections should level "
"off there.").arg(isa, 0, 'f', 1)
: QString("No ISa was determined for this merge."));
return view;
}
void JFJochMergeStatsWindow::updatePlot() {
const int metric = metric_->currentData().toInt();
std::vector<float> x, y;
double dmax = 0.0;
x.reserve(stats_.shells.size());
y.reserve(stats_.shells.size());
for (const auto &s: stats_.shells) {
x.push_back(s.mean_one_over_d2);
const double v = ShellMetric(s, metric);
y.push_back(static_cast<float>(v));
if (std::isfinite(v))
dmax = std::max(dmax, v);
}
// Absolute y-axis so positions are comparable: CC1/2 + CCref are 0..100, completeness 0..(>=100),
// multiplicity / R-meas / I-sigma start at 0. It has to be given to UpdateData rather than set
// on the axis afterwards, or the tick labels still describe the range of the data.
double ymax = 1.0;
switch (metric) {
case 0: case 5: ymax = 100.0; break; // CC1/2, CCref
case 3: ymax = std::max(100.0, dmax * 1.05); break; // Completeness
default: ymax = dmax > 0.0 ? dmax * 1.05 : 1.0; break; // R-meas, I/sigma, multiplicity
}
chart_->UpdateData(x, y, "Resolution [Å]", metric_->currentText(), true,
std::make_pair(0.0, ymax));
}
void JFJochMergeStatsWindow::buildTable() {
QStringList headers{"Res. [Å]", "N_obs", "Unique", "Compl. %", "Mult.", "⟨I/σ⟩", "R-meas %", "CC½ %"};
if (has_reference_)
headers << "CCref %";
table_->setColumnCount(headers.size());
table_->setHorizontalHeaderLabels(headers);
table_->setRowCount(static_cast<int>(stats_.shells.size()) + 1); // shells + overall
auto fillRow = [&](int rowIdx, const MergeStatisticsShell &s, const QString &resLabel) {
int c = 0;
auto set = [&](const QString &t) {
auto *it = new QTableWidgetItem(t);
it->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); // numbers right-aligned
table_->setItem(rowIdx, c++, it);
};
const double compl_ = s.possible_unique_reflections > 0
? 100.0 * s.unique_reflections / s.possible_unique_reflections : NAN;
const double mult = s.unique_reflections > 0
? static_cast<double>(s.total_observations) / s.unique_reflections : NAN;
set(resLabel);
set(QString::number(s.total_observations));
set(QString::number(s.unique_reflections));
set(std::isfinite(compl_) ? QString::number(compl_, 'f', 1) : QStringLiteral("—"));
set(std::isfinite(mult) ? QString::number(mult, 'f', 1) : QStringLiteral("—"));
set(QString::number(s.mean_i_over_sigma, 'f', 1));
set(QString::number(s.r_meas * 100.0, 'f', 1));
set(QString::number(s.cc_half * 100.0, 'f', 1));
if (has_reference_)
set(QString::number(s.cc_ref * 100.0, 'f', 1));
};
int rowIdx = 0;
for (const auto &s: stats_.shells)
fillRow(rowIdx++, s, QString::number(s.d_min, 'f', 2));
fillRow(rowIdx, stats_.overall, QStringLiteral("Overall"));
// Equal-width columns that fill the table (no super-wide last column).
table_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
}