Estimate the resolution the merged data will reach, not the furthest spot found

The per-image resolution estimate was the 5th percentile of the spot d spacings -
an extreme order statistic, so it measured where detection stops rather than how
well the crystal diffracts. A large cell puts more reflections past the same
threshold and scored better than a small cell that diffracts further; intensity
was not used at all, so a weak crystal padded with spurious high-resolution
detections ran away; and nothing clamped the answer to what the detector can
deliver. Against the resolution the merged data actually reach it was 42% out in
log-RMS, with 1 of 38 rotation datasets inside 0.2 A.

Take instead the 1/d^2 beyond which 30% of the sum of sqrt(I) over the image's
non-ice spots lies, report 1/(2.25 sqrt of it), clamp at the detector corner, and
take the median over images. A quantile from the middle of the distribution
measures the shape of the falloff - the crystal's own exp(-B/2d^2) - where an
extreme one measures the threshold. sqrt(I) is the Poisson significance of a
summed photon count, so a marginal high-resolution detection cannot carry the
answer and neither can a handful of very strong low-resolution reflections. The
2.25 is the multiplicity gain: merging keeps measuring intensities a fixed factor
in 1/d past the point where a single frame detects them.

Spearman 0.881 -> 0.954, log-RMS 42% -> 8.9%, median error 0.79 -> 0.07 A, and 32
of 38 within 0.2 A. Both constants sit on a broad plateau, the scale is stable
across dataset halves and across resolution ranges, and no second predictor
survives leave-one-out. The residual is around 9%, set by multiplicity, symmetry
and radiation damage - none of which a spot list can see.

The estimate feeds only reporting: the image stream, HDF5, the plots, the scan
result and the preview ring. It sets no cutoff and no search limit, and the
scaling and merging output is byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NNnL26LAvruQ9eLUUWvrJ
This commit is contained in:
2026-08-24 18:50:50 +02:00
co-authored by Claude Opus 5
parent baf302f813
commit 54adcaafcc
13 changed files with 133 additions and 21 deletions
+29
View File
@@ -27,3 +27,32 @@ TEST_CASE("FilterSpuriousHighResolutionSpots") {
CHECK(spots[3].d_A == Catch::Approx(6.0));
}
TEST_CASE("GetResolution") {
// Eleven equally strong spots at 1/d^2 = 0.1, 0.2, ... 1.1. Walking in from the highest-resolution
// one, four of the eleven are the first to carry 30% of the weight, so the quantile is the fourth
// spot in, 1/d^2 = 0.8. The estimate is that resolution taken 2.25x further in 1/d.
std::vector<SpotToSave> spots;
for (int i = 1; i <= 11; i++)
spots.push_back(SpotToSave{.intensity = 100.0f, .d_A = 1.0f / std::sqrt(0.1f * static_cast<float>(i))});
const auto d = GetResolution(spots);
REQUIRE(d.has_value());
CHECK(*d == Catch::Approx(1.0 / (2.25 * std::sqrt(0.8))).epsilon(1e-4));
// The merged data cannot beat the corner of the detector.
CHECK(*GetResolution(spots, 2.0f) == Catch::Approx(2.0));
// Ice-flagged spots take no part, however strong they are.
std::vector<SpotToSave> with_ice = spots;
with_ice.push_back(SpotToSave{.intensity = 1e6f, .d_A = 0.5f, .ice_ring = true});
CHECK(*GetResolution(with_ice) == Catch::Approx(*d));
// A weak high-resolution spot moves the answer far less than a strong one, which is the point of
// weighting by sqrt(I) rather than counting: the old order statistic would follow it entirely.
std::vector<SpotToSave> with_spur = spots;
with_spur.push_back(SpotToSave{.intensity = 1.0f, .d_A = 0.5f});
CHECK(*GetResolution(with_spur) == Catch::Approx(*d).epsilon(0.02));
// Too few spots to have a fall-off at all.
CHECK_FALSE(GetResolution(std::vector<SpotToSave>(3)).has_value());
}