Build Packages / Create release (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m43s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m7s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m48s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m22s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 12m24s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 25m29s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m41s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m44s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 26m9s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 26m3s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m28s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m16s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m44s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 26m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m52s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 27m44s
Build Packages / Unit tests (push) Successful in 1h46m55s
Build Packages / Validation Battery (push) Successful in 52m50s
* Rugnux: Several rotation sweeps of one crystal are merged as one dataset (`rugnux a_master.h5 b_master.h5`), on a common indexing and cell, with a non-isomorphous sweep left out and reported; `--sweeps separate` processes each sweep on its own. Bruker `.sfrm` frames and imgCIF files with a full goniometer axis chain are read. * Rugnux: More robust rotation processing - a retry with a more sensitive spot finder when no lattice is found, a self-consistent detector distance, long axes and doubled cells tested as hypotheses, a `-C` supercell multiple stepped down, and each ice ring merged, down-weighted or rejected on its own. * Rugnux: Serial stills are merged with a partiality-aware error model, reported as `ERROR_MODEL_A/B/C` with no ISa or R_merge/R_meas/R_pim; runs without `-S` have an overall intensity scale about 0.86-0.88 times that of earlier versions, with unchanged statistics. A second crystal form is left out of the merge and reported, the indexing ambiguity is resolved more reliably, and `--model` no longer influences processing. * Rugnux: The reported CC1/2 is the standard unweighted one, which can be lower than before on sweeps with weak frames; ISa comes with a 95% interval (`ISA_LOW`/`ISA_HIGH`). * Rugnux: Model refinement adds anisotropic/TLS, local NCS and twin refinement where R-free supports them; building waters and relabelling ions is now off by default (`--refine-solvent` turns it on). * NXmx: A detector of several modules is written as `module_0` ... `module_N` only, so DIALS reads each module as its own panel. * Other: JPEG previews use a bundled encoder (libjpeg-turbo no longer needed), Rugnux prints how to cite it, and the release archives contain the HTML documentation. Reviewed-on: #92 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
307 lines
16 KiB
C++
307 lines
16 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);
|
|
}
|
|
|
|
|
|
// A perfect twin of a P6 crystal whose pseudo-translation is not along c: the twin law and the
|
|
// crystal's own 6-fold carry u to (2/3, 1/3, w) and back to -u, so a reflection's intensity follows the
|
|
// mean of cos(2 pi h.Ru) over the hexagonal lattice's rotations, and binning by h.u alone averages
|
|
// the very classes it looks for. The call has to stand on the averaged classes, and hand the L-test
|
|
// every image of the vector so its partner steps keep them all.
|
|
TEST_CASE("A pseudo-translation under a perfect twin is called on the lattice-averaged classes", "[tncs]") {
|
|
gemmi::UnitCell cell;
|
|
cell.set(70.0, 70.0, 137.0, 90.0, 90.0, 120.0);
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 31 2 1");
|
|
const std::array<double, 3> u{1.0 / 3.0, 2.0 / 3.0, 0.15};
|
|
std::vector<std::array<double, 3>> images;
|
|
for (const auto &op : gemmi::find_spacegroup_by_name("P 6 2 2")->operations().sym_ops) {
|
|
std::array<double, 3> v{};
|
|
for (int i = 0; i < 3; ++i)
|
|
v[i] = (op.rot[i][0] * u[0] + op.rot[i][1] * u[1] + op.rot[i][2] * u[2]) / gemmi::Op::DEN;
|
|
images.push_back(v);
|
|
}
|
|
auto merged = Synthetic(cell, "P 31 2 1", nullptr, 0.0);
|
|
for (auto &m : merged)
|
|
m.I = static_cast<float>(m.I * (1.0 + kRealisticDepth * TncsClass(m.h, m.k, m.l, images)));
|
|
const auto r = AnalyzeTranslationalNCS(merged, cell, sg);
|
|
INFO("peak " << r.peak_percent << "% z " << r.peak_z << " vector " << r.modulation << "/"
|
|
<< r.modulation_null << " averaged " << r.modulation_averaged << "/" << r.modulation_averaged_null);
|
|
REQUIRE(r.detected);
|
|
CHECK(r.detected_on_averaged_classes);
|
|
CHECK(r.averaged_rotations == 12);
|
|
REQUIRE(r.class_vectors.size() == 12);
|
|
// The x,y part of the vector is the one the classes hinge on: (1/3, 2/3) or its mate (2/3, 1/3).
|
|
const double s = r.vector_frac[0] + r.vector_frac[1];
|
|
CHECK(std::fabs(s - std::round(s)) < 0.02);
|
|
}
|