Files
Jungfraujoch/tests/WilsonOutliersTest.cpp
T
leonarski_fandClaude Opus 5.5 257686c6fd Merge: Wilson outlier test
The median outlier test needs three observations, so a reflection measured
once or twice (one good observation and one artefact, the usual case after
Friedel merging) was never tested, and where there were more, a precise-looking
artefact (a hot pixel, thousands of counts) could outweigh mates from weak
frames and become the weighted median itself.

Every observation of the written merge is now also judged against Wilson
statistics beyond 4 A: E^2 = I / (epsilon <I/epsilon>_shell), centric and
acentric laws, a bound set by alpha = 0.01 expected false rejections per
dataset (ln(2N/alpha); twice that for centrics), the lower confidence limit
I - z*sigma (own sigma, z from the same budget) required to exceed it, shells
whose <I> is not established at that significance not judged, and the bound
widened by the dataset's measured tail scale (peaks over threshold on the
well-measured shells; 1 for a Wilson crystal, larger under tNCS/anisotropy).
An improbable singleton is rejected; one with company only when most of its
reflection's other observations are probable and it disagrees with them. The
reflection is the group, or the Friedel pair under -A. Search merges are not
tested (epsilon is 1 in P1, and the screw rows are the reflections a wrong
epsilon would call improbable). The flags are handed to the device merge as
pre-rejections, so CPU and GPU agree.

Report: OBSERVATIONS_REJECTED_WILSON= (part of OBSERVATIONS_REJECTED=), and the
developer report lists each rejected observation (hkl, d, E^2, image, x, y).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-24 07:26:42 +02:00

126 lines
5.3 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include <cmath>
#include <random>
#include <vector>
#include "../image_analysis/scale_merge/WilsonOutliers.h"
namespace {
// Acentric Wilson intensities of mean `mean` between 3.9 and 2.0 A, measured `per_unit` times each
// with Poisson-like noise of variance I + bkg_var.
std::vector<WilsonObservation> WilsonPopulation(int n_units, int per_unit, double mean, double bkg_var,
uint32_t seed) {
std::mt19937 rng(seed);
std::exponential_distribution<double> wilson(1.0 / mean);
std::normal_distribution<double> gauss(0.0, 1.0);
std::vector<WilsonObservation> v;
for (int u = 0; u < n_units; ++u) {
const double I_true = wilson(rng);
const float d = 3.9f - 1.9f * static_cast<float>(u) / n_units;
for (int m = 0; m < per_unit; ++m) {
const double sigma = std::sqrt(I_true + bkg_var);
v.push_back({static_cast<float>(I_true + sigma * gauss(rng)), static_cast<float>(sigma), d,
1.0f, false, u});
}
}
return v;
}
}
TEST_CASE("WilsonOutliers: the artefact member of a discordant pair is dropped", "[wilson_outliers]") {
auto obs = WilsonPopulation(10000, 2, 1000.0, 100.0, 1);
// A pair of one ordinary observation and one two hundred times the shell mean.
const int32_t unit = 10000;
obs.push_back({800.0f, 30.0f, 2.5f, 1.0f, false, unit});
obs.push_back({200000.0f, 450.0f, 2.5f, 1.0f, false, unit});
const auto r = WilsonOutliers(obs, 0.01);
CHECK(r.n_tested == obs.size());
CHECK(r.tail_scale == Catch::Approx(1.0).margin(0.2));
CHECK(r.rejected[obs.size() - 1] == 1);
CHECK(r.rejected[obs.size() - 2] == 0);
CHECK(r.n_rejected == 1);
}
TEST_CASE("WilsonOutliers: two large observations of one reflection confirm each other", "[wilson_outliers]") {
auto obs = WilsonPopulation(10000, 2, 1000.0, 100.0, 2);
const int32_t unit = 10000;
obs.push_back({60000.0f, 300.0f, 2.5f, 1.0f, false, unit});
obs.push_back({61000.0f, 300.0f, 2.5f, 1.0f, false, unit});
const auto r = WilsonOutliers(obs, 0.01);
CHECK(r.n_rejected == 0);
}
TEST_CASE("WilsonOutliers: the symmetry enhancement factor keeps an axial reflection", "[wilson_outliers]") {
auto obs = WilsonPopulation(10000, 2, 1000.0, 100.0, 3);
// Measured once, 12x <I/epsilon> at epsilon 4: 48x the shell mean, but only E^2 = 12.
obs.push_back({48000.0f, 250.0f, 2.5f, 4.0f, false, 10000});
auto r = WilsonOutliers(obs, 0.01);
CHECK(r.rejected.back() == 0);
CHECK(r.e2.back() == Catch::Approx(12.0).epsilon(0.1));
CHECK(r.n_rejected == 0);
// The same observation on a general reflection is improbable.
obs.back().epsilon = 1.0f;
r = WilsonOutliers(obs, 0.01);
CHECK(r.rejected.back() == 1);
}
TEST_CASE("WilsonOutliers: a weak shell does not misfire", "[wilson_outliers]") {
// <I> = 20 under a background of sd 100: noise alone reaches twenty times <I>, while the shell mean
// is still established.
const auto obs = WilsonPopulation(20000, 1, 20.0, 10000.0, 4);
const auto r = WilsonOutliers(obs, 0.01);
CHECK(r.n_tested == obs.size());
CHECK(r.n_rejected == 0);
}
TEST_CASE("WilsonOutliers: an artefact among several mates is dropped", "[wilson_outliers]") {
auto obs = WilsonPopulation(5000, 4, 1000.0, 100.0, 5);
// One precise-looking artefact beside three ordinary mates: the mates out-vote it.
obs[0].I = 500000.0f;
obs[0].sigma = 700.0f;
const auto r = WilsonOutliers(obs, 0.01);
CHECK(r.n_tested == obs.size());
CHECK(r.rejected[0] == 1);
CHECK(r.n_rejected == 1);
}
TEST_CASE("WilsonOutliers: a reflection whose observations are mostly large is kept", "[wilson_outliers]") {
auto obs = WilsonPopulation(5000, 3, 1000.0, 100.0, 8);
// Two of three observations large, the third low (a partial that caught little, say).
obs[0].I = 60000.0f; obs[0].sigma = 300.0f;
obs[1].I = 62000.0f; obs[1].sigma = 300.0f;
obs[2].I = 500.0f; obs[2].sigma = 30.0f;
const auto r = WilsonOutliers(obs, 0.01);
CHECK(r.n_rejected == 0);
}
TEST_CASE("WilsonOutliers: a shell without a measured mean is not judged", "[wilson_outliers]") {
// Pure noise: <I> = 0 within its error, so nothing can be improbable against it.
auto obs = WilsonPopulation(10000, 1, 1e-6, 10000.0, 9);
obs[0].I = 5000.0f; obs[0].sigma = 100.0f;
const auto r = WilsonOutliers(obs, 0.01);
CHECK(r.n_tested == 0);
CHECK(r.n_rejected == 0);
}
TEST_CASE("WilsonOutliers: a heavier-tailed population widens the bound", "[wilson_outliers]") {
// Half the reflections at twice the mean, half at a tenth - the intensity classes of a strong
// pseudo-translation. Wilson's single exponential would call the top of the strong class improbable.
auto strong = WilsonPopulation(10000, 1, 2000.0, 100.0, 6);
const auto weak = WilsonPopulation(10000, 1, 100.0, 100.0, 7);
for (auto o : weak) {
o.unit += 10000;
strong.push_back(o);
}
const auto r = WilsonOutliers(strong, 0.01);
CHECK(r.tail_scale > 1.5);
CHECK(r.n_rejected == 0);
}