// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #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 Series(const std::vector &angles, double increment) { std::vector 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 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 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(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 angles; for (int i = 0; i < 2700; i++) angles.push_back(std::fmod(static_cast(static_cast(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 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 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); }