Files
Jungfraujoch/tests/RotationScaleWalkTest.cpp
leonarski_fandClaude Opus 5.5 495f6a97b3 Rotation scaling: penalised per-frame scale of the fulls; fulls-only partials get flux and partiality
Three defects in the per-frame scaling of sparse (small-molecule, weak) rotation sweeps:

1. The fulls' per-frame scale pooled sparse frames by their RAW full count, but in a
   high-symmetry group with many systematic absences most usable counts stayed under the
   minimum, so most frames were never fitted and kept corr = 1 beside pinned, fitted frames
   (two gauges in one reference). The release step then read the gauge offset (a constant
   134x on a cubic Ia-3d small-molecule sweep) as signal and gave each frame exp(kept_f * 4.9) - the e^-5
   errors on a quarter of the frames, R1 0.62. A fixed box window also cannot follow a
   100x absorption ramp over a few degrees, and frames under the credible floor, exempt
   from the window, ran away to 1e-7.
   Now each round fits every frame on its own fulls (no pooling, no minimum), and the scale
   is a penalised second-difference smoother of log G (Whittaker/Eilers), each frame at
   the information of its fit, lambda by cross-validation over blocks one rocking curve
   wide (interleaved single frames leak through shared rocking curves and chose to follow
   every frame). After convergence the existing ShrinkToRestrained hands back the per-frame
   deviation its neighbour shares. Pooling and the box window are gone from the fulls loop;
   the partials loop is unchanged.

2. With partial scaling off (< 50 rocking events per frame) the partials kept the
   integration-time corr: no incident-flux correction and not the partiality of the
   ingest-smoothed geometry, because only the partial scaling loop rewrote corr. They now
   get corr = prescaling_corr / partiality at G = 1 (host, and a device kernel).

3. The flux meter (per-frame mean background) jumped 30x between neighbouring frames of a
   sparse sweep - on a few reflections it measures which reflections the frame holds. It
   is read through the same smoother at the precision of each frame's mean.

SHELXL R1(>4sig) against the published structures, rc174-cand -> this, on the in-house
small-molecule sweeps: cubic Ia-3d 0.615 -> 0.119 (XDS 0.088); four organic sweeps
(monoclinic / orthorhombic) 0.0515 -> 0.0459, 0.0451 -> 0.0396, 0.0747 -> 0.0709,
0.0452 -> 0.0413; ISa up to 11.6 -> 31. Raw flux instead of smoothed costs 0.002-0.003 R1 on
the first two. A weak, decaying protein sweep on the fulls-only path: ISa 6.7 -> 27.1.
CPU and GPU paths agree.

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

137 lines
6.3 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);
}