Files
Jungfraujoch/tests/SweepLayoutTest.cpp
T
leonarski_fandClaude Opus 5 5c6dedea71 Do not read the oscillation width as the rotation step
SweepLayout rejected any inter-frame difference below half the header's
oscillation width as read-back jitter. A series that steps by less than
it exposes - overlapping wedges, or a writer that puts the whole sweep's
range in that field - then had every difference rejected, no step was
found, and the files fell into the "series that never turns" branch,
which lays them out end to end and, unlike the gapped path, said
nothing. The jitter bound is now absolute (1e-4 deg, below any step an
instrument makes and above 32-bit float read-back noise), and the
end-to-end fallback warns when the angles do span a rotation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
2026-09-20 18:59:59 +02:00

177 lines
7.5 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 "../common/JFJochException.h"
#include "../reader/SweepLayout.h"
namespace {
// A series of frames all taken at the same instrument setting, at the angles given.
std::vector<sweep::Frame> Series(const std::vector<double> &angles, double increment) {
std::vector<sweep::Frame> out;
out.reserve(angles.size());
for (size_t i = 0; i < angles.size(); i++)
out.push_back({"f" + std::to_string(i) + ".cbf", angles[i], increment, 0.2, 1000, 1000, 1.0});
return out;
}
} // namespace
TEST_CASE("SweepLayout_Contiguous") {
std::vector<double> angles;
for (int i = 0; i < 100; i++)
angles.push_back(20.0 + 0.1 * i);
const auto l = sweep::Place(Series(angles, 0.1), "test");
CHECK(l.files.size() == 100);
CHECK(l.present == 100);
CHECK(l.start_deg == Catch::Approx(20.0));
CHECK(l.increment_deg == Catch::Approx(0.1));
for (size_t i = 0; i < l.files.size(); i++)
CHECK(l.files[i] == "f" + std::to_string(i) + ".cbf");
}
TEST_CASE("SweepLayout_Gapped") {
// Frames 3, 4 and 7 of a ten-frame sweep never made it into the archive. The sweep is still ten
// steps wide and every frame keeps its own angle.
const auto l = sweep::Place(Series({0.0, 0.1, 0.2, 0.5, 0.6, 0.8, 0.9}, 0.1), "test");
REQUIRE(l.files.size() == 10);
CHECK(l.present == 7);
CHECK(l.increment_deg == Catch::Approx(0.1));
CHECK(l.files[2] == "f2.cbf");
CHECK(l.files[3].empty());
CHECK(l.files[4].empty());
CHECK(l.files[5] == "f3.cbf");
CHECK(l.files[7].empty());
CHECK(l.files[9] == "f6.cbf");
}
TEST_CASE("SweepLayout_PastFullTurn") {
// A writer that starts over at 0 rather than counting on past 360.
const auto l = sweep::Place(Series({359.7, 359.8, 359.9, 0.0, 0.1}, 0.1), "test");
CHECK(l.files.size() == 5);
CHECK(l.present == 5);
CHECK(l.increment_deg == Catch::Approx(0.1));
}
TEST_CASE("SweepLayout_MultiTurn") {
// One continuous 540 degree sweep, 2700 frames of 0.2 deg, whose headers write the start angle
// modulo 360: from frame 1476 on, every angle repeats one the first revolution already had. The
// second and third revolutions must occupy fresh slots, not land on top of the first.
std::vector<double> angles;
for (int i = 0; i < 2700; i++)
angles.push_back(std::fmod(65.0 + 0.2 * i, 360.0));
const auto l = sweep::Place(Series(angles, 0.2), "test");
REQUIRE(l.files.size() == 2700);
CHECK(l.present == 2700);
CHECK(l.start_deg == Catch::Approx(65.0));
CHECK(l.increment_deg == Catch::Approx(0.2));
// The sweep really is 540 degrees wide, and no frame was dropped on top of another.
CHECK(l.increment_deg * static_cast<double>(l.files.size() - 1) == Catch::Approx(539.8));
CHECK(l.files.back() == "f2699.cbf");
}
TEST_CASE("SweepLayout_MultiTurnWithReadbackNoise") {
// The same sweep as a real header writes it: the recorded angles come from 32-bit floats, so
// each is a few parts in 100000 off the grid. Estimating the step from ONE recorded difference
// scales that noise by the frame number - the series it was measured on drifted a quarter of a
// step by frame 1924 and was refused as scattered.
std::vector<double> angles;
for (int i = 0; i < 2700; i++)
angles.push_back(std::fmod(static_cast<double>(static_cast<float>(65.0 + 0.2 * i)), 360.0));
const auto l = sweep::Place(Series(angles, 0.2), "test");
REQUIRE(l.files.size() == 2700);
CHECK(l.present == 2700);
CHECK(l.increment_deg == Catch::Approx(0.2).epsilon(1e-6));
CHECK(l.start_deg == Catch::Approx(65.0).epsilon(1e-6));
}
TEST_CASE("SweepLayout_MultiTurnGapped") {
// Past 360 AND missing frames: the two have to work together, because a gap is what the step
// guess has to survive and a full turn is what the step count has to carry through.
std::vector<double> angles;
for (int i = 0; i < 2000; i++)
if (i % 7 != 3)
angles.push_back(std::fmod(10.0 + 0.25 * i, 360.0));
const auto l = sweep::Place(Series(angles, 0.25), "test");
CHECK(l.files.size() == 2000);
CHECK(l.present == angles.size());
CHECK(l.increment_deg == Catch::Approx(0.25));
CHECK(l.files[3].empty());
CHECK(l.files[1999] == "f" + std::to_string(angles.size() - 1) + ".cbf");
}
TEST_CASE("SweepLayout_ConcatenatedSweepsStillRefused") {
// Two 90 degree sweeps of one crystal in one directory. Unlike a sweep that runs past 360, this
// one goes BACK: the second series returns to angles the first already covered without having
// stepped forward through a whole turn to get there.
std::vector<double> angles;
for (int i = 0; i < 90; i++) angles.push_back(0.5 * i);
for (int i = 0; i < 90; i++) angles.push_back(0.5 * i);
CHECK_THROWS_AS(sweep::Place(Series(angles, 0.5), "test"), JFJochException);
}
TEST_CASE("SweepLayout_Reversed") {
const auto l = sweep::Place(Series({10.0, 9.5, 9.0, 8.0}, 0.5), "test");
REQUIRE(l.files.size() == 5);
CHECK(l.increment_deg == Catch::Approx(-0.5));
CHECK(l.files[3].empty());
CHECK(l.files[4] == "f3.cbf");
}
TEST_CASE("SweepLayout_Stills") {
// Nothing turns: the files are the images, and the header's nominal increment stands.
const auto l = sweep::Place(Series({45.0, 45.0, 45.0}, 0.1), "test");
CHECK(l.files.size() == 3);
CHECK(l.present == 3);
CHECK(l.start_deg == Catch::Approx(45.0));
CHECK(l.increment_deg == Catch::Approx(0.1));
}
TEST_CASE("SweepLayout_ScreeningImagesRefused") {
// Five shots at scattered angles are not a sweep, and must not be laid out as one.
CHECK_THROWS_AS(sweep::Place(Series({0.0, 90.0, 45.0, 300.0, 270.0}, 0.5), "test"),
JFJochException);
}
TEST_CASE("SweepLayout_MovedDetectorRefused") {
auto frames = Series({0.0, 0.1, 0.2, 0.3}, 0.1);
frames[2].distance_m = 0.3;
CHECK_THROWS_AS(sweep::Place(frames, "test"), JFJochException);
}
TEST_CASE("SweepLayout_SecondWavelengthRefused") {
auto frames = Series({0.0, 0.1, 0.2, 0.3}, 0.1);
frames[3].wavelength_A = 1.9;
CHECK_THROWS_AS(sweep::Place(frames, "test"), JFJochException);
}
TEST_CASE("SweepLayout_RepeatedAngleRefused") {
// Two sweeps of the same crystal concatenated: the second covers angles the first already has.
CHECK_THROWS_AS(sweep::Place(Series({0.0, 0.1, 0.2, 0.1, 0.2}, 0.1), "test"), JFJochException);
}
TEST_CASE("SweepLayout_StepFinerThanTheOscillationWidth") {
// The header field is the oscillation WIDTH, and a series can step by far less than it exposes -
// or, on some writers, hold the whole sweep's range in that field. Taking it for the step
// rejected every inter-frame difference as jitter: the sweep then read as one that never turns
// and was laid out end to end, every frame past the first at the wrong angle.
std::vector<double> angles;
for (int i = 0; i < 100; i++)
angles.push_back(30.0 + 0.1 * i);
const auto wedges = sweep::Place(Series(angles, 1.0), "test"); // 1 deg wedges, 0.1 deg apart
REQUIRE(wedges.files.size() == 100);
CHECK(wedges.present == 100);
CHECK(wedges.start_deg == Catch::Approx(30.0));
CHECK(wedges.increment_deg == Catch::Approx(0.1));
const auto total = sweep::Place(Series(angles, 10.0), "test"); // the field holds the range
REQUIRE(total.files.size() == 100);
CHECK(total.present == 100);
CHECK(total.increment_deg == Catch::Approx(0.1));
}