Files
Jungfraujoch/tests/TwinningAnalysisTest.cpp
T
leonarski_fandClaude Opus 5 8b9598c1c4
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m43s
Build Packages / build:windows:nocuda (push) Successful in 17m56s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 19m4s
Build Packages / build:windows:cuda (push) Successful in 19m47s
Build Packages / build:viewer-tgz:cpu (push) Successful in 21m7s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 23m28s
Build Packages / build:viewer-tgz:cuda (push) Successful in 23m41s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m21s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m26s
Build Packages / build:rugnux:windows (push) Successful in 10m58s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 20m15s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 21m35s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 26m54s
Build Packages / build:rpm (rocky9) (push) Successful in 23m26s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 22m52s
Build Packages / Generate python client (push) Successful in 34s
Build Packages / Build documentation (push) Successful in 1m4s
Build Packages / Create release (push) Skipped
Build Packages / build:rpm (rocky8) (push) Successful in 29m35s
Build Packages / XDS test (durin plugin) (push) Successful in 11m1s
Build Packages / DIALS test (push) Successful in 25m57s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 27m28s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m24s
Build Packages / XDS test (neggia plugin) (push) Successful in 9m28s
Build Packages / Unit tests (push) Successful in 1h27m23s
twinning: the L-test partners are chosen so a pseudo-translation cannot silence it
The L-test compares a reflection with a partner a fixed step away, and the step it
used preserves the parity class of a half-integer pseudo-translation - so a
pseudo-body-centring was invisible to it by luck rather than by design. A translation
of one third is not so lucky: it puts the two members of a pair in different
modulation classes, inflates the statistic past the bound that is read as evidence
AGAINST twinning, and the twin call is lost silently on a crystal that has one.

Choose the partners so every pair stays inside one modulation class, which is what
the half-integer case was already getting by accident. The obvious alternative -
lengthening the step until it clears a third as well - was measured and rejected: it
puts the statistic past that bound on more than two thirds of the corpus against a
seventh today.

Measured over a hundred and thirty-seven crystals: a hundred and twenty-nine
unchanged, seven repaired, and exactly one verdict moves - a crystal whose twinning
was being denied by its own pseudo-translation. On a synthetic perfect twin carrying
a one-third translation, both indicators are destroyed before the change and both
return after it.

Nothing branches on this verdict, so no merged intensity moves; the whole effect is
what the report says about the crystal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-05 20:09:00 +02:00

135 lines
6.7 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 <array>
#include <cmath>
#include <numbers>
#include <vector>
#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<MergedReflection> WithPseudoTranslation(std::vector<MergedReflection> merged,
const std::array<double, 3> &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<float>(r.I * (1.0 + f * std::cos(phase)));
r.sigma = static_cast<float>(0.02 * std::fabs(r.I) + 1.0);
}
return merged;
}
std::vector<MergedReflection> 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<double, 3> 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<double, 3> 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<double, 3> 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<double, 3> 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);
}