The pass-1 post-refinement fits the rotation scale k only on the frames the stored angles still track, and a rate error is exactly what stops them tracking the rest: on a sweep whose stage turned ~3 % slow the fit read 0.979 over 94 deg, failed its leave-a-fifth-out test and was thrown away, leaving half the frames unscaled. The pass no longer decides. Between the passes, at the pass-2 detector geometry, the lattice is indexed (index-only probe) under the stored angles and under the fitted k and scored on the validation frames of the whole sweep: share of the spots on the lattice beyond the wrong-spindle null. k is adopted only where it scores higher by more than the binomial noise of the two (ValidationEvidencePrefers - the test the beam-centre arms already used, now one function); the run then integrates and post-refines at k (post-refine-only probe), fits again on top of it and repeats until the next k no longer scores better (WalkRotationScale). The stored angles are the first hypothesis. Measured: 1.000 25.1 %, 0.97874 58.9 %, 0.97041 90.0 %, 0.97006 90.7 % (not significant) -> 0.97041 adopted. Probes restore the experiment, the pass-2 geometry, pass-1 mosaicity and the beam-centre-search flag; a probe opens no beam-centre search. Forced pass-1 results get their axis scaled; the header revert drops the scale. GONIOMETER_ROTATION_SCALE reports the adopted k (SUSPECT = adopted). The leave-a-fifth-out figure stays in the log only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
95 lines
4.1 KiB
C++
95 lines
4.1 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"
|
|
|
|
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);
|
|
}
|
|
}
|