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
59 lines
2.8 KiB
C++
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());
|
|
}
|