Files
Jungfraujoch/tests/SpotUtilsTest.cpp
T
leonarski_fandClaude Opus 5 54adcaafcc 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
2026-08-24 18:50:50 +02:00

59 lines
2.8 KiB
C++

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../image_analysis/spot_finding/SpotUtils.h"
TEST_CASE("FilterSpuriousHighResolutionSpots") {
std::vector<SpotToSave> spots;
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 18.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 20.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 30.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 6.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 2.0, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 1.9, .indexed = false});
spots.push_back(SpotToSave{.x = 1, .y = 2, .intensity = 3, .d_A = 1.3, .indexed = false});
FilterSpuriousHighResolutionSpots(spots, 1.57); // roughly 0.25 in 1/d
REQUIRE(spots.size() == 4);
// Spots are sorted by resolution
CHECK(spots[0].d_A == Catch::Approx(30.0));
CHECK(spots[1].d_A == Catch::Approx(20.0));
CHECK(spots[2].d_A == Catch::Approx(18.0));
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());
}