merge: the completeness denominator is the declared range, not the surviving one

CalcPossibleReflections was handed d_min/d_max derived from the reflections that
came out of the merge, so a loss at either extreme took the numerator and the
denominator with it. At the high end that is right: d_min is the finest d reached
anywhere and the denominator is the full sphere down to it, so anisotropic loss
shows. At the low end it was a tautology - d_max was the coarsest reflection that
happened to survive, so anything the beam-stop shadow mask (on by default), a
detector mask or the low-resolution limit itself removed left the denominator
along with the data and could not be reported as missing.

Both statistics paths now bin, and count, between the DECLARED low-resolution
limit and the finest d reached: MergeOnTheFly::MergeStats (stills) and
RotationScaleMerge::MergeAndStats (rotation). The grid and the denominator keep
sharing their bounds, so no possible reflection falls outside a shell. An
undeclared low limit is the whole sphere - 1/d^2 down to 0 - spelled as an
infinite d_max, which ResolutionShells already handles and which gemmi's
for_all_reflections special-cases; the change therefore reads correctly whether
or not the 50 A default stays. The innermost shell keeps a finite d_max label,
falling back to the coarsest reflection measured when the bound is infinite.

This makes the shell boundaries the ones the integration document already claims:
XDS lays its nine 1/d^2 bins between INCLUDE_RESOLUTION_RANGE's two values, not
between the extremes of the surviving data, and counts POSSIBLE against the
declared low limit - which is why its innermost shell reports the beam stop's
loss. Verified against a CORRECT.LP: all nine boundaries reproduce to the printed
precision from the declared 50 A, and not from the coarsest observed reflection.

Measured on stored merges of seven rotation datasets, small-molecule and protein,
re-scaled with --mode scale: the overall denominator moves by 0 to 2 reflections
out of 70,000-100,000, because on every one of them the coarsest reflection the
declared limit allows was itself measured - the corpus has no dataset whose stop
eats a whole low-resolution class. What does move is the shell grid: the
innermost boundary shifts by 0.1-0.4% in d (e.g. 7.21 -> 7.22 A), which changes
the innermost shell's counts by up to a few per cent and its R_meas by around
0.1 percentage points. Stored battery baselines for rmeas_lo must therefore be
regenerated, not compared across this commit.

Two decisions read merged completeness (the two-pass wrong-cell guard, which only
fires above 100.5% and only under -S); a larger denominator can only lower the
figure, so the guard can fire less often, never more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-02 16:39:40 +02:00
co-authored by Claude Opus 5
parent a0db11193a
commit 0c3e1462ef
7 changed files with 203 additions and 12 deletions
+2
View File
@@ -12,6 +12,8 @@ class ResolutionShells {
const float one_over_dmin2, one_over_dmax2;
const int32_t nshells;
public:
// Shells of equal volume in 1/d^2 between d_min (exclusive) and d_max (inclusive). d_max may be
// infinite, which is "no low-resolution bound": the shells then start at 1/d^2 = 0.
ResolutionShells(float d_min, float d_max, int32_t nshells);
[[nodiscard]] std::optional<int32_t> GetShell(float d) const;
[[nodiscard]] std::vector<float> GetShellMeanOneOverResSq() const;
+1
View File
@@ -14,6 +14,7 @@
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, holding the geometry as a `jfjoch_broker` `dataset_settings` body, and refuses a fit that is not a measurement - no `.poni`, a non-zero exit, `converged` recorded in the `.json`; `--no-refine-tilt` holds the detector tilt at the file's value instead of zeroing it.
* A snake grid scan with a negative slow step and an even number of rows no longer has its positions mirrored along the fast axis in the HDF5 master and the grid map, so the positions recorded for that configuration change; `jfjoch_viewer` draws grid scan cells in the proportion of the scan steps, and labels the merge-statistics plot over the range the axis is drawn on.
* The HDF5 master records `direct_beam_x`/`direct_beam_y` - where the undeflected beam lands, sent on the CBOR start message too - the beam size at the sample as `incident_beam_size` from the new `dataset_settings` `beam_size_x_um`/`beam_size_y_um`, and `/entry/MX/peakCountUnfiltered`; `dataset_settings` accepts any `smargon.chi_deg`, which was restricted to 0-90 degrees.
* Reported completeness counts the reflections the beam stop, a detector mask or the low-resolution limit kept out of the merge as missing: the denominator, and the resolution shells it is binned into, now span the run's declared resolution range rather than the range of the reflections that survived, so the innermost shell boundary moves and its numbers are not comparable with those of an earlier release.
* The rugnux manual is reorganised into task pages with a run overview and worked phenix / REFMAC5 / Phaser / SHELXC/D/E / POINTLESS-AIMLESS / careless examples, and the HDF5 and API documentation say how a grid scan records the angle its spindle stood at: the goniometer axis with a step of 0.
### 1.0.0-rc.165
+5 -2
View File
@@ -261,7 +261,8 @@ An internal-consistency term can inflate uncertainties when multiple observation
### 10.5 Merging statistics
The shells are **nine bins of equal width in $1/d^2$**, laid between the lowest- and the
The shells are **nine bins of equal width in $1/d^2$**, laid between the **declared** low-resolution
limit (`--scaling-low-resolution`, or the whole sphere where it is switched off) and the
highest-resolution reflection the merge actually kept — XDS's rule and XDS's count, so at the same
resolution limits the two programs' tables have the same shell boundaries and can be read row for
row. `--resolution-shells` changes the count; the binning rule does not change with it.
@@ -271,7 +272,9 @@ Per-shell and overall merging statistics are computed on corrected intensities,
- mean $I/\sigma(I)$,
- $R_\mathrm{meas}$ (the redundancy-independent DiederichsKarplus form) from withinHKL deviations,
- $\mathrm{CC}_{1/2}$ (half-set correlation) and, when a reference dataset is supplied, $\mathrm{CC}_\mathrm{ref}$,
- completeness against the enumerated reflections for the cell and symmetry,
- completeness against the reflections the cell and symmetry can give over that same declared range,
so low-resolution terms lost to the beam stop, to a detector mask or to the low-resolution limit
itself count as missing instead of leaving the denominator along with the data,
- the anomalous signal-to-noise $\mathrm{SigAno}$ (below).
The error model is refined as $\sigma_\mathrm{corr}^2 = a\,\sigma^2 + (b\,\langle I\rangle)^2$, with $a$ set by the scatter of weak (counting-limited) reflections and $b$ the intensity-proportional systematic scatter of the strong ones. On the **rotation** path, **ISa** is the asymptotic ($I\to\infty$) signal-to-noise — by definition the reproducibility limit of the strongest reflections (Diederichs, *Acta Cryst.* **D66** (2010) 733) — and is read directly from the strong symmetry equivalents as the counting-subtracted fractional scatter of well-measured reflection groups (a robust median over strong groups; the $I/\sigma$ threshold is relaxed on weak or radiation-damaged data that has few strong reflections), rather than as $1/b$ of the whole-range fit, whose $b$ is raised slightly by an intermediate-intensity excess and so understates the limit. The asymptotic value is **report-only** — nothing downstream reads it, and the merged $\sigma$ is not floored at $b|I|$ (that floor was removed). The per-observation $\sigma_\mathrm{corr}$ (the merge weights) uses the whole-range $a,b$. The **stills** path has no asymptotic estimate and reports $\mathrm{ISa}=1/b$ directly.
+18 -5
View File
@@ -539,10 +539,23 @@ MergeStatistics MergeOnTheFly::MergeStats(const std::vector<MergedReflection> &m
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"MergeStats: Error in resolution calculation");
const float d_min_pad = d_min * 0.999f;
const float d_max_pad = d_max * 1.001f;
// Completeness is a claim about how much of a defined set was measured, so the set has to be
// defined by something other than what was measured. The shell grid - and with it the
// denominator, which shares its bounds so nothing is enumerated outside a shell - runs to the
// low resolution the run DECLARES, not to the coarsest reflection that happened to survive the
// beam stop, a detector mask and the limit itself: what those ate now counts as missing, which
// is what it is. An undeclared low limit is the whole sphere, i.e. 1/d^2 down to 0, and an
// infinite d_max is how that is spelled. The high end stays the finest d reached anywhere -
// that is the resolution the run reports, and the full sphere down to it already shows
// anisotropic loss.
const float d_min_pad = d_min * 0.999f; // GetShell rejects d <= d_min; the finest reflection needs the room
const float d_max_declared = d_max_limit_A ? static_cast<float>(*d_max_limit_A)
: std::numeric_limits<float>::infinity();
// The innermost shell is labelled by the bound it reaches, except when that bound is infinite
// and the coarsest reflection measured is the only finite thing to say.
const float d_max_label = std::isfinite(d_max_declared) ? d_max_declared : d_max;
ResolutionShells shells(d_min_pad, d_max_pad, n_shells);
ResolutionShells shells(d_min_pad, d_max_declared, n_shells);
const auto shell_mean_1_d2 = shells.GetShellMeanOneOverResSq();
const auto shell_min_res = shells.GetShellMinRes();
@@ -550,7 +563,7 @@ MergeStatistics MergeOnTheFly::MergeStats(const std::vector<MergedReflection> &m
if (reference_cell.has_value())
CalcPossibleReflections(space_group, reference_cell.value(),
d_min_pad, d_max_pad, shells, acc, scaling_settings.GetMergeFriedel());
d_min_pad, d_max_declared, shells, acc, scaling_settings.GetMergeFriedel());
CorrelationCoefficient cc_half_overall;
CorrelationCoefficient cc_ref_overall;
@@ -657,7 +670,7 @@ MergeStatistics MergeOnTheFly::MergeStats(const std::vector<MergedReflection> &m
ss.mean_one_over_d2 = shell_mean_1_d2[s];
ss.d_min = shell_min_res[s];
ss.d_max = s == 0 ? d_max_pad : shell_min_res[s - 1];
ss.d_max = s == 0 ? d_max_label : shell_min_res[s - 1];
ss.total_observations = sa.total_obs;
ss.unique_reflections = sa.unique;
ss.possible_unique_reflections = sa.possible;
@@ -3224,8 +3224,18 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
if (!(sd_min < sd_max && sd_min > 0.0f))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"RotationScaleMerge: resolution calculation failed");
const float d_min_pad = sd_min * 0.999f, d_max_pad = sd_max * 1.001f;
ResolutionShells shells(d_min_pad, d_max_pad, n_shells);
// The completeness denominator is counted over the range the run DECLARES, not the range that
// survived: the shell grid shares its bounds (so nothing is enumerated outside a shell) and runs
// to the declared low-resolution limit, or over the whole sphere - 1/d^2 down to 0, spelled as an
// infinite d_max - when no limit was declared. Reflections the beam stop, a mask or the limit ate
// then count as missing. The high end stays the finest d reached. See MergeStats in Merge.cpp.
const float d_min_pad = sd_min * 0.999f;
const float d_max_declared = d_max_limit ? static_cast<float>(*d_max_limit)
: std::numeric_limits<float>::infinity();
// The innermost shell is labelled by the bound it reaches, except when that bound is infinite
// and the coarsest reflection measured is the only finite thing to say.
const float d_max_label = std::isfinite(d_max_declared) ? d_max_declared : sd_max;
ResolutionShells shells(d_min_pad, d_max_declared, n_shells);
const auto shell_mean_1_d2 = shells.GetShellMeanOneOverResSq();
const auto shell_min_res = shells.GetShellMinRes();
@@ -3242,7 +3252,7 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
// than an asymmetric unit of it - so enumerating it there is the largest single piece of work in
// the merge that nothing goes on to read.
if (reference_cell && !for_search)
PossiblePerShell(x.GetSpaceGroupOrP1(), *reference_cell, d_min_pad, d_max_pad,
PossiblePerShell(x.GetSpaceGroupOrP1(), *reference_cell, d_min_pad, d_max_declared,
shells, merge_friedel, possible, nthreads);
for (int s = 0; s < n_shells; ++s) sa[s].possible = possible[s];
@@ -3404,7 +3414,7 @@ RotationScaleMerge::Result RotationScaleMerge::MergeAndStats(int n_groups, bool
auto &ss = out.shells[s];
ss.mean_one_over_d2 = shell_mean_1_d2[s];
ss.d_min = shell_min_res[s];
ss.d_max = s == 0 ? d_max_pad : shell_min_res[s - 1];
ss.d_max = s == 0 ? d_max_label : shell_min_res[s - 1];
ss.total_observations = sa[s].total_obs;
ss.unique_reflections = sa[s].unique;
ss.possible_unique_reflections = sa[s].possible;
+143
View File
@@ -3,6 +3,8 @@
#include <catch2/catch_all.hpp>
#include "../image_analysis/scale_merge/HKLKey.h"
#include "../image_analysis/scale_merge/Merge.h"
#include "gemmi/reciproc.hpp"
TEST_CASE("HKLKey_NoSG_noMergeFriedel") {
HKLKeyGenerator hkl_key_gen(false, *gemmi::find_spacegroup_by_number(1));
@@ -112,3 +114,144 @@ TEST_CASE("AcceptReflection_ResolutionLimits") {
CHECK_FALSE(AcceptReflection(r, 0.0, 15.0));
CHECK(AcceptReflection(r, 2.0, 50.0));
}
// --- Completeness denominator --------------------------------------------------------------------
namespace {
// A merged set built straight out of the reflections the cell and space group can give, so the
// test says exactly which of them were measured: every unique reflection between d_min and
// d_max_measured and none outside. Without Friedel merging an acentric contributes both hands.
std::vector<MergedReflection> MeasuredBetween(const gemmi::SpaceGroup &sg, const UnitCell &cell,
double d_min, double d_max_measured,
bool merge_friedel) {
const gemmi::UnitCell gemmi_cell = cell;
const gemmi::GroupOps gops = sg.operations();
std::vector<MergedReflection> out;
for (const auto &hkl: gemmi::make_miller_vector(gemmi_cell, &sg, d_min, d_max_measured, true)) {
MergedReflection r;
r.h = hkl[0];
r.k = hkl[1];
r.l = hkl[2];
r.d = static_cast<float>(gemmi_cell.calculate_d(hkl));
r.I = 100.0f;
r.sigma = 10.0f;
r.I_half[0] = 100.0f;
r.I_half[1] = 100.0f;
out.push_back(r);
if (!merge_friedel && !gops.is_reflection_centric(hkl)) {
r.h = -hkl[0];
r.k = -hkl[1];
r.l = -hkl[2];
out.push_back(r);
}
}
return out;
}
MergeStatistics StatsWithLowLimit(const gemmi::SpaceGroup &sg, const std::optional<UnitCell> &cell,
const std::vector<MergedReflection> &merged,
std::optional<double> low_limit, bool merge_friedel) {
DiffractionExperiment x;
x.SetSpaceGroup(sg);
ScalingSettings s = x.GetScalingSettings();
s.LowResolutionLimit_A(low_limit);
s.MergeFriedel(merge_friedel);
x.ImportScalingSettings(s);
MergeOnTheFly merge(x);
merge.ReferenceCell(cell);
return merge.MergeStats(merged, {});
}
double Completeness(const MergeStatisticsShell &s) {
return s.possible_unique_reflections > 0
? 100.0 * s.unique_reflections / s.possible_unique_reflections : 0.0;
}
const gemmi::SpaceGroup &TestSpaceGroup() { return gemmi::get_spacegroup_by_name("P 1 2 1"); }
constexpr UnitCell TEST_CELL{40, 45, 50, 90, 100, 90}; // synthetic; coarsest reflection ~49 A
}
// The low-resolution terms a beam stop ate must count as missing: the denominator is the declared
// range, so widening the declared range lowers completeness rather than leaving it alone.
TEST_CASE("MergeStats_CompletenessFallsWhenTheLowBoundCrossesAMaskedRegion") {
const auto &sg = TestSpaceGroup();
// Nothing coarser than 20 A was measured - it is all behind the stop.
const auto merged = MeasuredBetween(sg, TEST_CELL, 2.0, 20.0, true);
REQUIRE(!merged.empty());
const auto at_20 = StatsWithLowLimit(sg, TEST_CELL, merged, 20.0, true);
const auto at_50 = StatsWithLowLimit(sg, TEST_CELL, merged, 50.0, true);
// Declared exactly where the data stop: everything possible was measured.
CHECK(Completeness(at_20.overall) > 99.0);
// Declared out to 50 A: the 20-50 A shell is in the denominator and in nothing else.
CHECK(at_50.overall.possible_unique_reflections > at_20.overall.possible_unique_reflections);
CHECK(at_50.overall.unique_reflections == at_20.overall.unique_reflections);
CHECK(Completeness(at_50.overall) < Completeness(at_20.overall));
// The innermost shell is where it bites.
CHECK(Completeness(at_50.shells.front()) < Completeness(at_20.shells.front()));
}
// No low-resolution limit means the whole sphere. The cell has no reflection coarser than 50 A, so
// freeing the 50 A limit must count the same set - the fix does not presuppose either default.
TEST_CASE("MergeStats_CompletenessWithNoDeclaredLowLimit") {
const auto &sg = TestSpaceGroup();
const auto merged = MeasuredBetween(sg, TEST_CELL, 2.0, 20.0, true);
const auto at_50 = StatsWithLowLimit(sg, TEST_CELL, merged, 50.0, true);
const auto unlimited = StatsWithLowLimit(sg, TEST_CELL, merged, std::nullopt, true);
CHECK(unlimited.overall.possible_unique_reflections == at_50.overall.possible_unique_reflections);
CHECK(Completeness(unlimited.overall) == Catch::Approx(Completeness(at_50.overall)));
// The shell table stays finite even though the bound is not.
CHECK(std::isfinite(unlimited.shells.front().d_max));
}
// Counting the two Bijvoet mates of an acentric separately doubles the denominator too, so a fully
// measured anomalous set is 100% complete and not 200%.
TEST_CASE("MergeStats_CompletenessNeverExceeds100") {
const auto &sg = TestSpaceGroup();
for (const bool merge_friedel: {true, false}) {
const auto merged = MeasuredBetween(sg, TEST_CELL, 2.0, 50.0, merge_friedel);
const auto stats = StatsWithLowLimit(sg, TEST_CELL, merged, 50.0, merge_friedel);
INFO("merge_friedel = " << merge_friedel);
CHECK(Completeness(stats.overall) <= 100.0);
CHECK(Completeness(stats.overall) > 99.0);
for (const auto &sh: stats.shells)
CHECK(Completeness(sh) <= 100.0);
}
}
// The shell grid and the denominator share their bounds, so every possible reflection lands in a
// shell: the shells sum to the overall, and the overall is the sphere the run declared.
TEST_CASE("MergeStats_PossibleSumsOverTheShellsToTheDeclaredSphere") {
const auto &sg = TestSpaceGroup();
const auto merged = MeasuredBetween(sg, TEST_CELL, 2.0, 20.0, true);
const auto stats = StatsWithLowLimit(sg, TEST_CELL, merged, 50.0, true);
int sum = 0;
for (const auto &sh: stats.shells)
sum += sh.possible_unique_reflections;
CHECK(sum == stats.overall.possible_unique_reflections);
// Counted independently over the same declared range - nothing is lost between the two.
const gemmi::UnitCell gemmi_cell = TEST_CELL;
const int expected = gemmi::count_reflections(gemmi_cell, &sg, stats.overall.d_min * 0.999, 50.0, true);
CHECK(stats.overall.possible_unique_reflections == expected);
}
// Without a reference cell there is no set to count against; completeness stays unmeasured rather
// than becoming a number, with or without a declared low limit.
TEST_CASE("MergeStats_NoReferenceCellLeavesCompletenessUnmeasured") {
const auto &sg = TestSpaceGroup();
const auto merged = MeasuredBetween(sg, TEST_CELL, 2.0, 20.0, true);
for (const std::optional<double> low_limit: {std::optional<double>(50.0), std::optional<double>()}) {
const auto stats = StatsWithLowLimit(sg, std::nullopt, merged, low_limit, true);
CHECK(stats.overall.possible_unique_reflections == 0);
CHECK(stats.overall.unique_reflections > 0);
CHECK(Completeness(stats.overall) == 0.0);
}
}
+20 -1
View File
@@ -2,6 +2,8 @@
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include <cmath>
#include <limits>
#include "../common/ResolutionShells.h"
TEST_CASE("ResolutionShells_WrongInput") {
@@ -22,4 +24,21 @@ TEST_CASE("ResolutionShells") {
float one_over_d_sq = 1/(50. * 50.) + 10.2 * (1/(1 * 1) - 1/(50 * 50)) / 20;
REQUIRE(shells.GetShell(1/sqrtf(one_over_d_sq)) == 10);
}
}
// An infinite d_max is "no low-resolution bound": the shells start at 1/d^2 = 0, so every finite
// resolution coarser than d_min falls in shell 0 and the shell boundaries stay finite.
TEST_CASE("ResolutionShells_NoLowBound") {
const ResolutionShells shells(1, std::numeric_limits<float>::infinity(), 20);
REQUIRE(shells.GetShell(1e6f) == 0);
REQUIRE(shells.GetShell(50.0) == 0);
REQUIRE(shells.GetShell(1.001) == 19);
REQUIRE(!shells.GetShell(1.0));
REQUIRE(!shells.GetShell(std::numeric_limits<float>::infinity()));
const auto min_res = shells.GetShellMinRes();
REQUIRE(min_res.size() == 20);
for (const auto d: min_res)
REQUIRE(std::isfinite(d));
// With the low bound at 1/d^2 = 0 the first boundary is at 1/20th of the sphere.
REQUIRE(min_res.front() == Catch::Approx(std::sqrt(20.0)));
}