Files
Jungfraujoch/tests/TranslationalNCSTest.cpp
leonarski_f 6dfe065365
Build Packages / Create release (push) Successful in 16s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m27s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m15s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m44s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m1s
Build Packages / build:windows:nocuda (push) Successful in 17m29s
Build Packages / build:windows:cuda (push) Successful in 19m58s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m7s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m8s
Build Packages / build:rugnux:windows (push) Successful in 10m58s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m46s
Build Packages / Generate python client (push) Successful in 53s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m13s
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m57s
Build Packages / build:rpm (rocky8) (push) Successful in 18m7s
Build Packages / build:rpm (rocky9) (push) Successful in 18m54s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 19m32s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 17m30s
Build Packages / Unit tests (push) Successful in 1h39m2s
v1.0.0-rc.172 (#82)
* Fixed `jfjoch_broker` cancelling every data collection with a CUDA "out of memory" error after long operation: GPU memory no longer leaks with each collection.
* Rugnux scales a rotation sweep until the per-frame scales settle instead of for a fixed three rounds, and says so when they did not - merged intensities, and the space group, resolution cut and frame rejection read off them, change accordingly; `--scaling-iterations` is now the cap on that loop (default 100).
* Rugnux places every frame of a marCCD, SMV or miniCBF series at the spindle angle its own header states, so a series with missing frames, or with angles written modulo 360, is no longer read at the wrong geometry or refused.
* Every rotation run writes two diagnostic files beside its reflections: `<prefix>_detector.jpg`, the detector projection with the pixel mask and the detected beam-stop shadow drawn on it, and `<prefix>_plot.txt`, one row per image.

Reviewed-on: #82
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-22 06:48:37 +02:00

275 lines
14 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/TranslationalNCS.h"
#include "gemmi/symmetry.hpp"
#include "gemmi/unitcell.hpp"
namespace {
// For a half-integer pseudo-translation the classes carry (1 +- f) times the mean intensity, and
// the Patterson peak at that vector comes out at exactly f of the origin. f = 0.6 therefore models
// a 60% peak - as strong as the strongest genuine pseudo-symmetry in a 137-dataset corpus. f = 1
// would mean the weak class is EXTINCT, which is a lattice translation, not a pseudo-symmetry.
constexpr double kRealisticDepth = 0.6;
gemmi::UnitCell Cell(double a, double b, double c) {
gemmi::UnitCell out;
out.set(a, b, c, 90.0, 90.0, 90.0);
return out;
}
// A synthetic merge over the reciprocal asymmetric unit: Wilson-distributed intensities from a
// hash of the index (so the crystal is reproduced bit for bit), optionally multiplied by the
// factor a translational pseudo-symmetry u imposes, |1 + exp(2 pi i h.u)|^2 / 2 written with a
// depth f so the weak class is suppressed rather than extinguished.
std::vector<MergedReflection> Synthetic(const gemmi::UnitCell &cell, const char *space_group,
const std::array<double, 3> *u, double f,
double d_min = 3.0) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(space_group);
REQUIRE(sg != nullptr);
const gemmi::GroupOps ops = sg->operations();
const gemmi::ReciprocalAsu asu(sg);
std::vector<MergedReflection> out;
const int hmax = static_cast<int>(cell.a / d_min) + 1;
const int kmax = static_cast<int>(cell.b / d_min) + 1;
const int lmax = static_cast<int>(cell.c / d_min) + 1;
for (int h = -hmax; h <= hmax; ++h)
for (int k = -kmax; k <= kmax; ++k)
for (int l = -lmax; l <= lmax; ++l) {
const gemmi::Op::Miller hkl{{h, k, l}};
if ((h == 0 && k == 0 && l == 0) || !asu.is_in(hkl)
|| ops.is_systematically_absent(hkl))
continue;
const double d2 = cell.calculate_1_d2(hkl);
if (d2 <= 0 || 1.0 / std::sqrt(d2) < d_min)
continue;
const uint32_t seed = static_cast<uint32_t>(h * 73856093 ^ k * 19349663
^ l * 83492791);
const double uni = ((seed * 2654435761u) >> 8) / static_cast<double>(1 << 24);
double I = 1000.0 * -std::log(std::max(1e-6, uni)) * std::exp(-0.5 * 20.0 * d2);
if (u != nullptr) {
const double phase = 2.0 * std::numbers::pi
* (h * (*u)[0] + k * (*u)[1] + l * (*u)[2]);
I *= 1.0 + f * std::cos(phase);
}
MergedReflection r;
r.h = h; r.k = k; r.l = l;
r.d = static_cast<float>(1.0 / std::sqrt(d2));
r.I = static_cast<float>(I);
r.sigma = static_cast<float>(0.02 * std::fabs(r.I) + 1.0);
out.push_back(r);
}
return out;
}
}
// The detector must find a pseudo-translation that is there, and name it.
TEST_CASE("A pseudo-translation is detected and its vector recovered", "[tncs]") {
const gemmi::UnitCell cell = Cell(70.0, 85.0, 110.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 21 21 21");
const std::array<double, 3> u{0.5, 0.5, 0.5};
const auto merged = Synthetic(cell, "P 21 21 21", &u, kRealisticDepth);
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
REQUIRE(r.measurable);
CHECK(r.detected);
CHECK(r.peak_percent > 20.0);
CHECK(r.undeclared_lattice_translations.empty());
CHECK(r.peak_z > 5.0);
CHECK(r.modulation > 2.5 * r.modulation_null);
// The vector is refined against the intensities of the whole dataset, so it is good to about a
// thousandth of a cell edge - which is what the phase classes below need, and a good deal better
// than a molecular-replacement program needs as a starting point.
for (int i = 0; i < 3; ++i)
CHECK(std::fabs(r.vector_frac[i] - 0.5) < 0.01);
CHECK(r.commensurate);
CHECK(r.commensurate_denominator == 2);
// A half-integer pseudo-translation is pseudo-centring, not a wrong cell: the suppressed class is
// weak, not absent, so the claim "your cell may be a supercell" must NOT be made here.
CHECK_FALSE(r.near_extinct_class);
CHECK(TranslationalNCSToText(r).find("supercell candidate") == std::string::npos);
}
// The false-positive rate is the whole product: the same crystal without a pseudo-translation must
// say nothing.
TEST_CASE("A crystal without a pseudo-translation stays silent", "[tncs]") {
const gemmi::UnitCell cell = Cell(70.0, 85.0, 110.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 21 21 21");
const auto merged = Synthetic(cell, "P 21 21 21", nullptr, 0.0);
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
REQUIRE(r.measurable);
CHECK_FALSE(r.detected);
}
// The case that would make the detector useless if it were got wrong. A centred lattice has a real,
// full-height off-origin Patterson peak at its centring translation BY CONSTRUCTION - every C-, I-
// and F-centred crystal has one, and none of them has a pseudo-symmetry because of it. The centring
// vectors are origin-equivalent points and must be excluded along with the origin.
TEST_CASE("A genuinely centred lattice is not called", "[tncs]") {
struct Case { const char *sg; gemmi::UnitCell cell; };
const std::vector<Case> cases{
{"C 1 2 1", Cell(120.0, 75.0, 90.0)},
{"I 2 2 2", Cell(80.0, 95.0, 115.0)},
{"F 2 2 2", Cell(110.0, 125.0, 140.0)},
};
for (const auto &c : cases) {
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(c.sg);
REQUIRE(sg != nullptr);
const auto merged = Synthetic(c.cell, c.sg, nullptr, 0.0);
const auto r = AnalyzeTranslationalNCS(merged, c.cell, sg);
INFO(c.sg << ": peak " << r.peak_percent << "% z " << r.peak_z << " modulation "
<< r.modulation << " vs null " << r.modulation_null);
REQUIRE(r.measurable);
CHECK_FALSE(r.detected);
}
}
// A centred lattice that ALSO has a pseudo-translation must still be called - excluding the centring
// vectors must not blind the test to a real one somewhere else.
TEST_CASE("A centred lattice with a real pseudo-translation is still called", "[tncs]") {
const gemmi::UnitCell cell = Cell(120.0, 75.0, 90.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("C 1 2 1");
const std::array<double, 3> u{0.0, 0.0, 0.5};
const auto merged = Synthetic(cell, "C 1 2 1", &u, kRealisticDepth);
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
INFO("peak " << r.peak_percent << "% z " << r.peak_z << " u " << r.vector_frac[0] << " "
<< r.vector_frac[1] << " " << r.vector_frac[2]);
REQUIRE(r.measurable);
CHECK(r.detected);
CHECK(std::fabs(r.vector_frac[2] - 0.5) < 0.03);
}
// A cell too small to have anything in the 20-5 A band must say so, and must NOT say "no tNCS".
TEST_CASE("A small-molecule cell reports that it could not be measured", "[tncs]") {
const gemmi::UnitCell cell = Cell(9.0, 11.0, 13.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 21 21 21");
const auto merged = Synthetic(cell, "P 21 21 21", nullptr, 0.0, 0.8);
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
CHECK_FALSE(r.measurable);
CHECK_FALSE(r.detected);
CHECK_FALSE(r.refusal.empty());
CHECK(TranslationalNCSToText(r).find("not a statement") != std::string::npos);
}
// P1 - no symmetry to expand with, and no centric reflections. The commonest case for a merge the
// space-group search declined to promote, so it must work rather than divide by zero.
TEST_CASE("P1 data are handled with no space group at all", "[tncs]") {
const gemmi::UnitCell cell = Cell(70.0, 85.0, 110.0);
const std::array<double, 3> u{0.5, 0.5, 0.5};
const auto merged = Synthetic(cell, "P 1", &u, kRealisticDepth);
const auto r = AnalyzeTranslationalNCS(merged, cell, nullptr);
REQUIRE(r.measurable);
CHECK(r.detected);
}
// The failure this detector would be useless without. A C-centred crystal MERGED IN P1 - which is
// what --mode scale does on a file with no space group, and what the pre-promotion merge is - still
// carries its centring vector, and the Patterson there is as high as at the origin. With no space
// group to name the centring it cannot be excluded from a list, so it has to be recognised for what
// it is: a translation the data are exactly invariant under is a LATTICE vector, not a pseudo-
// symmetry. Measured before this was handled, 11 of 95 P1 merges in the corpus were falsely called.
TEST_CASE("A centred lattice merged in P1 is reported as a lattice translation, not tNCS", "[tncs]") {
const gemmi::UnitCell cell = Cell(120.0, 75.0, 90.0);
// Generated with C-centring (so the absences are real), then handed over as if it were P1.
const auto merged = Synthetic(cell, "C 1 2 1", nullptr, 0.0);
const auto r = AnalyzeTranslationalNCS(merged, cell, nullptr);
REQUIRE(r.measurable);
REQUIRE_FALSE(r.undeclared_lattice_translations.empty());
const auto &t = r.undeclared_lattice_translations.front();
CHECK(std::fabs(t[0] - 0.5) < 0.03);
CHECK(std::fabs(t[1] - 0.5) < 0.03);
CHECK_FALSE(r.detected);
CHECK(TranslationalNCSToText(r).find("LATTICE translation") != std::string::npos);
}
// ...and a real pseudo-translation hiding underneath that centring vector must still be found, or
// the guard above would have traded one blind spot for another.
TEST_CASE("A pseudo-translation under an undeclared centring is still found", "[tncs]") {
const gemmi::UnitCell cell = Cell(120.0, 75.0, 90.0);
const std::array<double, 3> u{0.0, 0.0, 0.5};
const auto merged = Synthetic(cell, "C 1 2 1", &u, kRealisticDepth);
const auto r = AnalyzeTranslationalNCS(merged, cell, nullptr);
REQUIRE(r.measurable);
REQUIRE_FALSE(r.undeclared_lattice_translations.empty());
INFO("peak " << r.peak_percent << "% u " << r.vector_frac[0] << " " << r.vector_frac[1] << " "
<< r.vector_frac[2]);
CHECK(r.detected);
CHECK(std::fabs(r.vector_frac[2] - 0.5) < 0.05);
}
// The other end of the same scale: a translation the data are EXACTLY invariant under - the weak
// class extinct rather than weak - is a lattice vector however it arose, and must be reported as one
// even when the space group declares no centring at all.
TEST_CASE("An exact translation is a lattice vector, not a strong pseudo-symmetry", "[tncs]") {
const gemmi::UnitCell cell = Cell(70.0, 85.0, 110.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 21 21 21");
const std::array<double, 3> u{0.5, 0.5, 0.5};
const auto merged = Synthetic(cell, "P 21 21 21", &u, 1.0); // weak class exactly zero
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
REQUIRE(r.measurable);
REQUIRE_FALSE(r.undeclared_lattice_translations.empty());
CHECK_FALSE(r.detected);
}
// The most valuable thing this detector can say is not "there is a pseudo-symmetry" but "your cell
// is twice the size it should be". That case does NOT land on a simple fraction of the reported
// axes when the true sub-cell is triclinic, so it is recognised from the depth of the modulation
// instead: a class that is nearly extinct rather than merely weak is a lattice translation in
// disguise.
TEST_CASE("A near-extinct class is reported as a sub-lattice", "[tncs]") {
const gemmi::UnitCell cell = Cell(70.0, 85.0, 110.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 1 2 1");
const std::array<double, 3> u{0.0, 0.425, 0.5}; // no p/q with q <= 6 fits the whole vector
const auto merged = Synthetic(cell, "P 1 2 1", &u, 0.96); // weak class at 2% of the strong
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
REQUIRE(r.measurable);
REQUIRE(r.detected);
CHECK_FALSE(r.commensurate);
CHECK(r.near_extinct_class);
CHECK(TranslationalNCSToText(r).find("supercell candidate") != std::string::npos);
}
// The vector is not only reported: the L-test picks its partner steps with it and the twin-immune
// zone normalises within the phase classes cos(2 pi h.u) it assigns, at FULL resolution. The phase
// error of a class is |h| times the error in the vector, so a vector refined only on the band the
// detection gate reads (d >= 4 A, |h| under about 15) classifies nothing beyond that band. Pin the
// accuracy the classes need, on a vector that is NOT a simple fraction of the cell - a translation
// between two copies generally is not, and the accuracy must not come from rounding to one.
TEST_CASE("The pseudo-translation vector classifies the highest orders, not only the gate's band",
"[tncs]") {
const gemmi::UnitCell cell = Cell(70.0, 85.0, 110.0);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 1");
// P1, because that is the merge the twin-immune zone reads, and because a vector with all three
// components irrational is only compatible with the intensities of a crystal that has no rotation
// to make it periodic in.
const std::array<double, 3> u{0.2917, 0.5883, 0.1234};
const auto merged = Synthetic(cell, "P 1", &u, kRealisticDepth);
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
REQUIRE(r.detected);
CHECK_FALSE(r.commensurate);
// What the accuracy is for: the class of a reflection well past the gate's band.
auto cls = [](const std::array<double, 3> &v, const MergedReflection &m) {
return std::cos(2.0 * std::numbers::pi * (m.h * v[0] + m.k * v[1] + m.l * v[2])) >= 0.0;
};
int wrong = 0, n = 0;
for (const auto &m : merged) {
if (std::max({std::abs(m.h), std::abs(m.k), std::abs(m.l)}) < 15)
continue;
wrong += cls(r.vector_frac, m) != cls(u, m);
++n;
}
REQUIRE(n > 1000);
CHECK(wrong < n / 20);
}