Files
Jungfraujoch/tests/RotationScaleWalkTest.cpp
T
leonarski_fandClaude Opus 5.5 a29edb69cf rugnux: decide whether there is a lattice on distinct reflections, against every wrong angle
The first pass's last gate - is the lattice this crystal's at all - compared the validation spots
on the lattice with the median of five displaced-spindle-angle nulls, against the null's binomial
noise floored at one spot. On the two in-house no-crystal controls it is fed a handful of genuine
stray reflections from something rotating with the sample (the same detector positions, a pair
symmetric about the beam 198 frames apart, in both sweeps, offset by the 15-frame start difference),
plus one reflection near the spindle axis that stays in diffraction for ~46 frames. With the
decisions taken from the first part of the sweep the 60 validation frames are 3 deg apart instead
of 6, so that one reflection is counted on three validation frames, and the gate passed on
4 spots of 151 (null median 0) and 7 of 72, where the whole-sweep flow had seen 3 and 3.

Now the evidence unit is the distinct Miller index - a reflection on several validation frames
is one piece of evidence - and the test uses every displaced angle's count: with no crystal the
recorded angles are one of six exchangeable arrangements, so the gate is the binomial tail of the
recorded arrangement's share at p = 1/6, at the same 0.001 significance (SPOT_BUDGET_SIGNIFICANCE_Z).
No threshold is added; the median and the one-spot floor go from the gate. The comparisons between
lattices (ValidationEvidencePrefers) are unchanged.

  no-crystal control 1: 4 distinct vs 2 over 5 wrong angles, tail 0.009 -> no lattice (was P 1)
  no-crystal control 2: 5 distinct vs 2, tail 0.002 -> no lattice (was P 1)
p.mtz md5 identical to rc175 7c9a6c7d1 (GPU) on 5nw5, 5reo, 7atg, 8v4o, 9qw8, 9rci, 5ky6,
insu_I_x06da_low_isa, myob_x06da_powder_2; cytidine and myob_x06da_split pass. The weakest real
lattice in the open/in-house/private battery logs has 117 spots against a null of 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-08 21:00:40 +02:00

150 lines
7.2 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <cmath>
#include <catch2/catch_all.hpp>
#include "../rugnux/Rugnux.h"
#include "../image_analysis/scale_merge/RotationScaleMerge.h"
namespace {
// A synthetic sweep whose stage turned `true_scale` times the stored angles. Scored at a scale k,
// the validation spots stay on the lattice as long as the angles track the rotation, and the share
// that does falls off with the relative rate error; a wrong spindle angle keeps half a percent.
struct SyntheticSweep {
double true_scale;
int64_t spots = 35000;
int index_calls = 0;
int refit_calls = 0;
ValidationSpotEvidence IndexAt(float k) {
++index_calls;
const double error = std::fabs(true_scale / k - 1.0);
const double on = 0.9 * std::max(0.0, 1.0 - 30.0 * error);
return ValidationSpotEvidence{spots, std::llround(on * spots), std::llround(0.005 * spots)};
}
// The post-refinement at k, relative to k. It reads only 70 % of the error that is left: it
// sees only the frames the angles at k still track.
std::optional<double> RefitAt(float k) {
++refit_calls;
return 1.0 + 0.7 * (true_scale / k - 1.0);
}
RotationScaleWalk Walk(double first_fit) {
return WalkRotationScale(first_fit, [this](float k) { return IndexAt(k); },
[this](float k) { return RefitAt(k); }, 8);
}
};
}
TEST_CASE("ValidationEvidencePrefers", "[RotationScale]") {
const ValidationSpotEvidence base{10000, 3000, 50};
// 1 % more of the spots beyond chance is under the noise of two 30 % shares over 10000 spots
// (sqrt(2 * 0.3 * 0.7 / 10000) = 0.65 %, times 3.29); 5 % is well over it.
CHECK_FALSE(ValidationEvidencePrefers(base, {10000, 3100, 50}));
CHECK(ValidationEvidencePrefers(base, {10000, 3500, 50}));
// A candidate is judged against its own null: more spots on the lattice bought by a null that
// rose just as much is no gain.
CHECK_FALSE(ValidationEvidencePrefers(base, {10000, 3500, 550}));
// Never against itself, and nothing that scored nothing wins.
CHECK_FALSE(ValidationEvidencePrefers(base, base));
CHECK_FALSE(ValidationEvidencePrefers(base, {}));
CHECK(ValidationEvidencePrefers({}, base));
}
TEST_CASE("WalkRotationScale_ReachesTheFixedPoint", "[RotationScale]") {
// A stage 3 % slow. The first fit reads 70 % of that; each refit at the adopted scale reads 70 %
// of what is left, and the walk goes on as long as the validation frames prefer the new scale.
SyntheticSweep sweep{0.97};
const auto walk = sweep.Walk(sweep.RefitAt(1.0f).value());
CHECK(walk.scale == Catch::Approx(0.97).margin(0.001));
CHECK(walk.scale != 1.0f);
CHECK(walk.evidence.on_lattice > sweep.IndexAt(1.0f).on_lattice);
CHECK(sweep.refit_calls > 2);
CHECK_FALSE(walk.trail.empty());
}
TEST_CASE("WalkRotationScale_StoredAnglesStand", "[RotationScale]") {
SECTION("A healthy stage: a fit off by noise scores no better than the stored angles") {
SyntheticSweep sweep{1.0};
const auto walk = sweep.Walk(1.0002);
CHECK(walk.scale == 1.0f);
CHECK(sweep.index_calls == 2); // the stored angles and the fit, nothing more
CHECK(sweep.refit_calls == 0);
}
SECTION("A real but small error the spots cannot resolve beyond their noise") {
SyntheticSweep sweep{0.999};
sweep.spots = 400;
const auto walk = sweep.Walk(0.9993);
CHECK(walk.scale == 1.0f);
}
SECTION("A fit that tracks something other than the rotation scores worse, and is refused") {
SyntheticSweep sweep{1.0};
const auto walk = sweep.Walk(0.98);
CHECK(walk.scale == 1.0f);
CHECK(sweep.refit_calls == 0);
}
SECTION("A fit of exactly one asks for no probe at all") {
SyntheticSweep sweep{1.0};
const auto walk = sweep.Walk(1.0);
CHECK(walk.scale == 1.0f);
CHECK(walk.trail.empty());
CHECK(sweep.index_calls == 0);
}
}
TEST_CASE("SmoothLogScale_FollowsInformationBridgesGaps", "[RotationScale]") {
const int n = 60;
// A ramp is no curvature, so any amount of smoothing keeps it exactly.
std::vector<double> ramp(n), J(n, 1.0);
for (int f = 0; f < n; ++f) ramp[f] = -0.1 * f;
auto x = RotationScaleMerge::SmoothLogScale(ramp, J, 1e6);
for (int f = 0; f < n; ++f) CHECK(x[f] == Catch::Approx(ramp[f]).margin(1e-6));
// A stretch with no information is bridged by the straight line through its neighbours.
std::vector<double> Jgap(J);
for (int f = 20; f < 40; ++f) Jgap[f] = 0.0;
x = RotationScaleMerge::SmoothLogScale(ramp, Jgap, 1.0);
CHECK(x[30] == Catch::Approx(-3.0).margin(1e-6));
// A frame with far more information than its neighbours keeps its own value.
std::vector<double> y(n, 0.0), Jone(n, 1.0);
y[30] = 1.0; Jone[30] = 1e6;
x = RotationScaleMerge::SmoothLogScale(y, Jone, 10.0);
CHECK(x[30] == Catch::Approx(1.0).margin(1e-3));
// Fewer than two frames with information: nothing to smooth against.
std::vector<double> Jsingle(n, 0.0);
Jsingle[5] = 1.0;
CHECK(RotationScaleMerge::SmoothLogScale(y, Jsingle, 1.0) == y);
}
TEST_CASE("ChooseLogScaleSmoothing_SmoothsNoiseFollowsSignal", "[RotationScale]") {
const int n = 400;
std::vector<double> J(n, 1.0), noisy(n), step(n);
// Deterministic noise about a flat scale: the chosen curve is close to flat.
for (int f = 0; f < n; ++f) noisy[f] = 0.3 * std::sin(12.9898 * f) * std::cos(78.233 * f);
const double l_noise = RotationScaleMerge::ChooseLogScaleSmoothing(noisy, J, 1);
const auto flat = RotationScaleMerge::SmoothLogScale(noisy, J, l_noise);
double rms = 0.0;
for (double v : flat) rms += v * v;
CHECK(std::sqrt(rms / n) < 0.05);
// A precise slow wave is followed.
for (int f = 0; f < n; ++f) step[f] = 2.0 * std::sin(f / 30.0);
const double l_wave = RotationScaleMerge::ChooseLogScaleSmoothing(step, J, 1);
const auto wave = RotationScaleMerge::SmoothLogScale(step, J, l_wave);
CHECK(wave[47] == Catch::Approx(step[47]).margin(0.01));
CHECK(l_wave < l_noise);
}
TEST_CASE("ValidationEvidenceBeatsChance_ExchangeableArrangements", "[RotationScale]") {
// Four reflections taken at the recorded angles and none at any wrong one: (1/6)^4 < 0.001.
CHECK(ValidationEvidenceBeatsChance({.reflections = 4, .reflections_by_chance = 0}));
CHECK_FALSE(ValidationEvidenceBeatsChance({.reflections = 3, .reflections_by_chance = 0}));
// The same four with two more spread over the wrong angles is what chance does once in ~100.
CHECK_FALSE(ValidationEvidenceBeatsChance({.reflections = 4, .reflections_by_chance = 2}));
CHECK_FALSE(ValidationEvidenceBeatsChance({}));
// A real lattice: thousands against a handful.
CHECK(ValidationEvidenceBeatsChance({.reflections = 3000, .reflections_by_chance = 250}));
// As many at the recorded angles as at an average wrong one is no lattice.
CHECK_FALSE(ValidationEvidenceBeatsChance({.reflections = 100, .reflections_by_chance = 500}));
}