// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include #include #include "../image_analysis/scale_merge/TwinningAnalysis.h" #include "SyntheticMergedReflections.h" namespace { // A translational pseudo-symmetry u puts the factor |1 + exp(2 pi i h.u)|^2 on every intensity. // Modelled here with a depth f so the weak class is suppressed rather than extinguished, which is // what a real pair of copies with different orientations gives. std::vector WithPseudoTranslation(std::vector merged, const std::array &u, double f) { for (auto &r : merged) { const double phase = 2.0 * std::numbers::pi * (r.h * u[0] + r.k * u[1] + r.l * u[2]); r.I = static_cast(r.I * (1.0 + f * std::cos(phase))); r.sigma = static_cast(0.02 * std::fabs(r.I) + 1.0); } return merged; } std::vector Crystal(double twin_fraction) { jfjoch_test::SyntheticMergeParams p; p.true_space_group = "P 1 2 1"; p.twin_supergroup = "P 2 2 2"; p.twin_fraction = twin_fraction; p.d_min_A = 3.0; return jfjoch_test::GenerateSyntheticMerged(p); } } // The L-test pairs a reflection with a partner two steps along an axis, and that choice is // load-bearing for a reason the code did not state until now: an even step preserves the class of a // HALF-INTEGER pseudo-translation, so the commonest tNCS leaves <|L|> alone by construction. A // one-third pseudo-translation along the same axis does not, and it moves <|L|> by enough to change // the verdict. This is the regression test for both halves of that. TEST_CASE("L-test partner steps and a pseudo-translation", "[twinning][tncs]") { const auto clean = Crystal(0.0); const double l_clean = AnalyzeTwinning(clean, nullptr).mean_abs_l; REQUIRE(l_clean == Catch::Approx(0.5).margin(0.02)); SECTION("a half-integer pseudo-translation does not move it") { const auto modulated = WithPseudoTranslation(clean, {0.5, 0.0, 0.0}, 0.8); const auto r = AnalyzeTwinning(modulated, nullptr); CHECK(r.mean_abs_l == Catch::Approx(l_clean).margin(0.005)); } SECTION("a one-third pseudo-translation moves it a long way up") { const auto modulated = WithPseudoTranslation(clean, {1.0 / 3.0, 0.0, 0.0}, 0.8); const auto r = AnalyzeTwinning(modulated, nullptr); CHECK(r.mean_abs_l > l_clean + 0.03); CHECK(r.mean_abs_l > 0.50); // into the "contradicts a twin" branch } SECTION("declaring the vector repairs it") { const std::array u{1.0 / 3.0, 0.0, 0.0}; const auto modulated = WithPseudoTranslation(clean, u, 0.8); const auto r = AnalyzeTwinning(modulated, nullptr, 20, &u); CHECK(r.l_test_tncs_step_restricted); CHECK_FALSE(r.l_test_contaminated_by_tncs); // The control is the SAME crystal without the pseudo-translation measured with the SAME // restricted steps: <|L|> differs slightly between step directions (they are different // distances in reciprocal space), so comparing against the unrestricted number would be // measuring that instead of the repair. const auto control = AnalyzeTwinning(clean, nullptr, 20, &u); REQUIRE(control.l_test_tncs_step_restricted); CHECK(r.mean_abs_l == Catch::Approx(control.mean_abs_l).margin(0.005)); CHECK(r.l_test_pairs > 0.9 * AnalyzeTwinning(modulated, nullptr).l_test_pairs); } SECTION("a vector no step can preserve is declared unreadable, not repaired") { const std::array u{1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0}; const auto modulated = WithPseudoTranslation(clean, u, 0.8); const auto r = AnalyzeTwinning(modulated, nullptr, 20, &u); CHECK(r.l_test_contaminated_by_tncs); CHECK_FALSE(r.l_test_tncs_step_restricted); CHECK(r.l_test_pairs > 0); // still reported, just not read } SECTION("declaring a half-integer vector changes nothing") { const std::array u{0.5, 0.0, 0.0}; const auto modulated = WithPseudoTranslation(clean, u, 0.8); const auto with = AnalyzeTwinning(modulated, nullptr, 20, &u); const auto without = AnalyzeTwinning(modulated, nullptr); CHECK(with.mean_abs_l == Catch::Approx(without.mean_abs_l).margin(0.002)); CHECK_FALSE(with.l_test_contaminated_by_tncs); } } // The defect the repair exists for: a twinned crystal that also carries a one-third // pseudo-translation loses its twin call entirely, because the pseudo-symmetry pushes <|L|> up out // of the twinned range AND pushes the second moment up out of it at the same time. TEST_CASE("A pseudo-translation can hide a twin, and declaring it restores the call", "[twinning][tncs]") { const auto twin = Crystal(0.5); const auto baseline = AnalyzeTwinning(twin, nullptr); REQUIRE(baseline.mean_abs_l < 0.44); // an unambiguous twin when nothing masks it REQUIRE(baseline.twinning_suspected); const std::array u{1.0 / 3.0, 0.0, 0.0}; const auto masked = WithPseudoTranslation(twin, u, 0.8); const auto undeclared = AnalyzeTwinning(masked, nullptr); CHECK(undeclared.mean_abs_l > baseline.mean_abs_l + 0.05); CHECK_FALSE(undeclared.twinning_suspected); // the twin call is lost const auto declared = AnalyzeTwinning(masked, nullptr, 20, &u); // Against the same twin without the pseudo-translation, measured with the same restricted steps. const auto control = AnalyzeTwinning(twin, nullptr, 20, &u); CHECK(declared.mean_abs_l == Catch::Approx(control.mean_abs_l).margin(0.005)); CHECK(declared.mean_abs_l < 0.44); CHECK(declared.twinning_suspected); // and restored } // Standing behaviour that had no test at all. A holohedral Laue class admits no merohedral twin law, // so whatever the intensity statistics do there they must not be reported as twinning. TEST_CASE("No twin is called in a holohedral Laue class", "[twinning]") { jfjoch_test::SyntheticMergeParams p; p.true_space_group = "P 4 21 2"; p.twin_supergroup = "P 4 21 2"; p.d_min_A = 3.0; const auto merged = jfjoch_test::GenerateSyntheticMerged(p); const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 4 21 2"); REQUIRE(sg != nullptr); const auto r = AnalyzeTwinning(merged, sg); CHECK_FALSE(r.merohedral_twinning_possible); CHECK_FALSE(r.twinning_suspected); }