- Likelihood (Read 2001; Z-score over random positions as McCoy et al. 2005): log-likelihood gain of Rice/Woolfson E distributions, sigma_A from the pLDDT coordinate error with its scale refined, to 2.5 A (or the 20000 lowest-resolution reflections). Further-copy TF peaks ranked by the gain on the search's reflections (peaks that lower it dropped); a copy whose TF Z is below 8 also stands out where its refined likelihood Z-score reaches 8 - only where the copies placed are as many as Matthews makes most likely. The likelihood is computed only where a translation function does not stand out. Copy structure factors exact from one FFT of the cell's grid, or read off the molecular transform where it reaches the likelihood's resolution. - PAE domains: the AlphaFold DB's predicted_aligned_error JSON beside the model (same name pattern) is read automatically; average linkage on the symmetrised PAE (10 A, domains >= 40 residues). Where the whole model is placed nowhere, each domain is searched by itself side by side; placed domains must agree, and a domain alone may only confirm the answer's group. - Twin comparison: where the answer's merge chose and the twin hypothesis' merge exists, the chosen placement read as a crystal in the subgroup (its symmetry images as copies, the subgroup's setting found from the two cells) against the model searched for afresh in the subgroup, both against the data twinned by the law (fraction refined on the working set, Yeates 1997); the subgroup is taken by a 0.03 R-free lead plus its own null/margin -> TWIN_SUBGROUP, written to p_mr with law and fraction. With --af-model rugnux makes the subgroup merge wherever a metric re-ask promoted the indexed group. - tNCS rule: a group with fewer copies beside tNCS (screws bridged by the vector, or the enantiomorph) no longer leaves the choice undecided where the chosen copies, carried into it and refined, fit worse by 0.03 in R-free. - Speed: the molecular transform's FFT on the GPU (cuFFT, serialised), no rigid body for trials that lower the likelihood, re-search only with tNCS, PAE domains searched in parallel. Survey (193 sets, MR replay on stored merges): single GREEN 58 -> 68, multi 48 -> 58, partial 8 -> 9; 4bwl (and, in full runs, 2wnq and a withdrawn entry of the same protein) TWIN_SUBGROUP P21 instead of a wrong AGREES; a P21212 set with tNCS now SPACE_GROUP P21212 instead of UNDECIDED; no wrong call. CPU and GPU builds make the same decisions on 18 demo/multi-copy/twin sets. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
463 lines
22 KiB
C++
463 lines
22 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 <cmath>
|
|
#include <cstdio>
|
|
#include <fstream>
|
|
#include <random>
|
|
|
|
#include <gemmi/dencalc.hpp>
|
|
#include <gemmi/it92.hpp>
|
|
|
|
#include "../image_analysis/structure_refinement/ModelFFT.h"
|
|
#include "../image_analysis/structure_refinement/MolecularReplacement.h"
|
|
#include "../image_analysis/structure_refinement/RigidBodyRefine.h"
|
|
#include "../common/Logger.h"
|
|
#ifdef JFJOCH_USE_CUDA
|
|
#include "../common/CUDAWrapper.h"
|
|
#include "../image_analysis/structure_refinement/MolecularReplacementGPU.h"
|
|
#include "../image_analysis/structure_refinement/RigidBodyGPU.h"
|
|
#endif
|
|
|
|
namespace {
|
|
// A synthetic body: carbon atoms scattered in a 12 A sphere, centroid at the origin.
|
|
SearchModel synthetic_model() {
|
|
SearchModel m;
|
|
std::mt19937 rng(7);
|
|
std::uniform_real_distribution<double> u(-12.0, 12.0);
|
|
gemmi::Model model(1);
|
|
gemmi::Chain chain("A");
|
|
gemmi::Residue res;
|
|
res.name = "UNK";
|
|
res.seqid = gemmi::SeqId(1, ' ');
|
|
std::vector<gemmi::Vec3> pos;
|
|
while (pos.size() < 300) {
|
|
const gemmi::Vec3 p(u(rng), u(rng), u(rng));
|
|
if (p.length() < 12.0)
|
|
pos.push_back(p);
|
|
}
|
|
gemmi::Vec3 c;
|
|
for (const auto &p : pos)
|
|
c += p;
|
|
c *= 1.0 / pos.size();
|
|
for (const auto &p : pos) {
|
|
gemmi::Atom a;
|
|
a.name = "CA"; // every atom counts in the clash check
|
|
a.element = gemmi::El::C;
|
|
a.pos = gemmi::Position(p - c);
|
|
a.b_iso = 15.0f;
|
|
a.occ = 1.0f;
|
|
res.atoms.push_back(a);
|
|
m.radius_A = std::max(m.radius_A, (p - c).length());
|
|
}
|
|
chain.residues.push_back(res);
|
|
model.chains.push_back(chain);
|
|
m.st.models.push_back(model);
|
|
m.residues_read = m.residues_kept = 1;
|
|
m.mass_da = 3600;
|
|
return m;
|
|
}
|
|
|
|
gemmi::Mat33 axis_angle(gemmi::Vec3 axis, double deg) {
|
|
axis = axis.normalized();
|
|
const double k = deg * M_PI / 180.0, s = std::sin(k), c1 = 1.0 - std::cos(k);
|
|
const double x = axis.x, y = axis.y, z = axis.z;
|
|
return {1 + c1 * (x * x - 1), -s * z + c1 * x * y, s * y + c1 * x * z,
|
|
s * z + c1 * x * y, 1 + c1 * (y * y - 1), -s * x + c1 * y * z,
|
|
-s * y + c1 * x * z, s * x + c1 * y * z, 1 + c1 * (z * z - 1)};
|
|
}
|
|
|
|
// |F|^2 of the placed crystal to 4 A, as merged reflections.
|
|
std::vector<MergedReflection> observed(const gemmi::Structure &crystal) {
|
|
gemmi::DensityCalculator<gemmi::IT92<float>, float> dc;
|
|
dc.d_min = 3.5;
|
|
dc.rate = 1.5;
|
|
dc.set_grid_cell_and_spacegroup(crystal);
|
|
dc.put_model_density_on_grid(crystal.models[0]);
|
|
const auto f = MapToFPhi(dc.grid).prepare_asu_data(3.5, 0, false, false, false);
|
|
std::vector<MergedReflection> out;
|
|
for (const auto &hv : f.v) {
|
|
MergedReflection r;
|
|
r.h = hv.hkl[0];
|
|
r.k = hv.hkl[1];
|
|
r.l = hv.hkl[2];
|
|
r.I = std::norm(hv.value);
|
|
r.sigma = 1;
|
|
r.F = std::abs(hv.value);
|
|
r.d = 1.0 / std::sqrt(crystal.cell.calculate_1_d2(hv.hkl));
|
|
out.push_back(r);
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_UniformRotationsCoverSO3", "[MolecularReplacement]") {
|
|
const auto rotations = UniformRotations(6.0);
|
|
const double step = 6.0 * M_PI / 180.0;
|
|
// 8 pi^2 is the volume of SO(3) in the measure a 6 deg cube of Euler-like angles samples.
|
|
const double expected = 8 * M_PI * M_PI / (step * step * step);
|
|
CHECK(rotations.size() > 0.8 * expected);
|
|
CHECK(rotations.size() < 1.2 * expected);
|
|
// Every rotation has a sampled one nearby.
|
|
std::mt19937 rng(11);
|
|
std::normal_distribution<double> g;
|
|
const std::vector<gemmi::Mat33> identity{gemmi::Mat33()};
|
|
double worst = 0;
|
|
for (int i = 0; i < 100; i++) {
|
|
const gemmi::Mat33 r = axis_angle({g(rng), g(rng), g(rng)}, std::uniform_real_distribution<double>(0, 180)(rng));
|
|
double best = 180;
|
|
for (const auto &s : rotations)
|
|
best = std::min(best, RotationDistanceDeg(r, s, identity));
|
|
worst = std::max(worst, best);
|
|
}
|
|
CHECK(worst < 6.0);
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_RotationAsu", "[MolecularReplacement]") {
|
|
const gemmi::UnitCell cell(40, 40, 60, 90, 90, 90);
|
|
const auto pg = CartesianPointGroup(gemmi::get_spacegroup_by_name("P 41 2 2"), cell);
|
|
REQUIRE(pg.size() == 8);
|
|
const auto all = UniformRotations(10.0);
|
|
const auto asu = RotationAsu(all, pg);
|
|
// One class in eight, give or take the boundaries.
|
|
CHECK(asu.size() > all.size() / 8 * 0.9);
|
|
CHECK(asu.size() < all.size() / 8 * 1.3);
|
|
// Every sampled rotation has a class member in the ASU close by.
|
|
for (size_t i = 0; i < all.size(); i += 37) {
|
|
double best = 180;
|
|
for (const auto &s : asu)
|
|
best = std::min(best, RotationDistanceDeg(s, all[i], pg));
|
|
CHECK(best < 10.0);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_SameRotationGroups", "[MolecularReplacement]") {
|
|
const auto groups = SameRotationGroups(gemmi::get_spacegroup_by_name("P 41"));
|
|
REQUIRE(groups.size() == 4);
|
|
CHECK(groups[0].xhm() == "P 41");
|
|
const auto i = SameRotationGroups(gemmi::get_spacegroup_by_name("I 41 2 2"));
|
|
CHECK(i.size() == 2); // I 4 2 2, I 41 2 2
|
|
// Each group once, not again on another origin: P 2 2 2, P 2 2 21 in three settings, P 21 21 2 in
|
|
// three, P 21 21 21.
|
|
CHECK(SameRotationGroups(gemmi::get_spacegroup_by_name("P 21 21 2")).size() == 8);
|
|
CHECK(SameRotationGroups(gemmi::get_spacegroup_by_name("I 2 2 2")).size() == 2);
|
|
CHECK(SameRotationGroups(gemmi::get_spacegroup_by_name("P 42 21 2")).size() == 8);
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_SyntheticPlacement", "[MolecularReplacement]") {
|
|
const SearchModel model = synthetic_model();
|
|
const gemmi::UnitCell cell(42, 50, 58, 90, 90, 90);
|
|
const gemmi::SpaceGroup &sg = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::Mat33 truth = axis_angle({1, 2, 3}, 50);
|
|
const gemmi::Fractional t(0.17, 0.31, 0.09);
|
|
const gemmi::Structure crystal = PlaceSearchModel(model, truth, t, cell, sg);
|
|
const auto merged = observed(crystal);
|
|
const SearchReflections refl = MakeSearchReflections(merged, cell, sg, 4.0, 12.0);
|
|
REQUIRE(refl.hkl.size() > 300);
|
|
const MolecularTransform ft(model, 4.0, 4);
|
|
|
|
// The rotation function's best orientation is the true one, to the sampling.
|
|
const auto pg = CartesianPointGroup(sg, cell);
|
|
const auto rotations = RotationAsu(UniformRotations(6.0), pg);
|
|
const auto rf = RotationFunction(ft, refl, cell, sg, rotations, 4);
|
|
size_t best = 0;
|
|
for (size_t i = 1; i < rf.size(); i++)
|
|
if (rf[i] > rf[best])
|
|
best = i;
|
|
CHECK(RotationDistanceDeg(rotations[best], truth, pg) < 6.0);
|
|
|
|
// At the true orientation the translation function peaks at the true position, up to the
|
|
// origins P 21 21 21 allows (a half in each coordinate), and stands far out of its own noise.
|
|
const TranslationPeak peak = TranslationFunction(ft, MakeTranslationSetup(refl, cell, sg), truth);
|
|
CHECK(peak.z > 10.0);
|
|
for (double d : {peak.t.x - t.x, peak.t.y - t.y, peak.t.z - t.z}) {
|
|
const double half = 2 * d - std::round(2 * d);
|
|
CHECK(std::fabs(half) < 0.06);
|
|
}
|
|
}
|
|
|
|
// Two copies in the asymmetric unit: with the first one known, the translation function of the second
|
|
// at its true orientation peaks at its true position - not merely at one of the group's origins, which
|
|
// only the cross term with the known copy tells apart.
|
|
TEST_CASE("MolecularReplacement_SecondCopyBesideKnownFirst", "[MolecularReplacement]") {
|
|
const SearchModel model = synthetic_model();
|
|
const gemmi::UnitCell cell(52, 60, 70, 90, 90, 90);
|
|
const gemmi::SpaceGroup &sg = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::Mat33 r1 = axis_angle({1, 2, 3}, 50), r2 = axis_angle({-2, 1, 1}, 110);
|
|
const gemmi::Fractional t1(0.17, 0.31, 0.09), t2(0.41, 0.12, 0.33);
|
|
const gemmi::Structure crystal = AssembleCopies({PlaceSearchModel(model, r1, t1, cell, sg),
|
|
PlaceSearchModel(model, r2, t2, cell, sg)});
|
|
const auto merged = observed(crystal);
|
|
const SearchReflections refl = MakeSearchReflections(merged, cell, sg, 4.0, 12.0);
|
|
const MolecularTransform ft(model, 4.0, 4);
|
|
const TranslationSetup setup = MakeTranslationSetup(refl, cell, sg, true);
|
|
const auto known = KnownStructureFactors(ft, setup, cell, {r1}, {t1});
|
|
const TranslationPeak peak = TranslationFunction(ft, setup, r2, &known);
|
|
CHECK(peak.z > 10.0);
|
|
for (double d : {peak.t.x - t2.x, peak.t.y - t2.y, peak.t.z - t2.z})
|
|
CHECK(std::fabs(d - std::round(d)) < 0.04);
|
|
// Two copies of the same body in one cell do not clash where they are placed apart.
|
|
CHECK(ClashFraction(crystal, 2) < 0.05);
|
|
CHECK(ClashFraction(AssembleCopies({PlaceSearchModel(model, r1, t1, cell, sg),
|
|
PlaceSearchModel(model, r2, t1, cell, sg)}), 2) > 0.5);
|
|
}
|
|
|
|
// The likelihood of the true placement stands out over the same orientation elsewhere, and the gain is zero
|
|
// where sigma_A is.
|
|
TEST_CASE("MolecularReplacement_LikelihoodPlacement", "[MolecularReplacement]") {
|
|
const SearchModel model = synthetic_model();
|
|
const gemmi::UnitCell cell(42, 50, 58, 90, 90, 90);
|
|
const gemmi::SpaceGroup &sg = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::Mat33 truth = axis_angle({1, 2, 3}, 50);
|
|
const gemmi::Fractional t(0.17, 0.31, 0.09);
|
|
const auto merged = observed(PlaceSearchModel(model, truth, t, cell, sg));
|
|
const LikelihoodSetup setup = MakeLikelihoodSetup(MakeSearchReflections(merged, cell, sg, 3.5, 12.0), cell, sg);
|
|
REQUIRE(setup.Terms() > 500);
|
|
const std::vector<std::complex<double>> none(setup.Terms(), 0.0);
|
|
const auto g = CopyFactors(model, setup, cell, truth, 3.5, 4);
|
|
|
|
const LikelihoodPeak peak = LikelihoodTranslationZ(setup, none, 0.0, g, t, 0.3, 4);
|
|
CHECK(peak.llg > 100.0);
|
|
CHECK(peak.z > 8.0);
|
|
CHECK(peak.scale > 0.8);
|
|
const double wrong = LikelihoodGain(setup, none, 0.0, g, gemmi::Fractional(0.4, 0.05, 0.3), 0.3);
|
|
CHECK(wrong < peak.llg / 4);
|
|
std::vector<std::complex<double>> f = none;
|
|
AddCopy(setup, g, t, f);
|
|
CHECK(LogLikelihoodGain(setup, f, 0.3, 0.0) == Catch::Approx(0.0).margin(1e-9));
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_PaeDomains", "[MolecularReplacement]") {
|
|
CHECK(PaePathFor("/x/AF-P12345-F1-model_v6.cif") == "/x/AF-P12345-F1-predicted_aligned_error_v6.json");
|
|
CHECK(PaePathFor("AF-P12345-F1-model_v4.pdb.gz") == "AF-P12345-F1-predicted_aligned_error_v4.json");
|
|
CHECK(PaePathFor("/x/model.cif").empty());
|
|
|
|
// A prediction of 120 residues: two confident blocks, 1-50 and 61-120, placed relative to each other
|
|
// with no confidence, and a loop 51-60 the trim removed.
|
|
SearchModel m;
|
|
gemmi::Model model(1);
|
|
gemmi::Chain chain("A");
|
|
for (int i = 0; i < 120; i++) {
|
|
if (i >= 50 && i < 60)
|
|
continue;
|
|
gemmi::Residue r;
|
|
r.name = "ALA";
|
|
r.seqid = gemmi::SeqId(i + 1, ' ');
|
|
gemmi::Atom a;
|
|
a.name = "CA";
|
|
a.element = gemmi::El::C;
|
|
a.pos = gemmi::Position(3.8 * i, i < 50 ? 0.0 : 20.0, 0.0);
|
|
a.b_iso = 10.0f;
|
|
a.occ = 1.0f;
|
|
r.atoms.push_back(a);
|
|
chain.residues.push_back(r);
|
|
m.residue_index.push_back(i);
|
|
}
|
|
model.chains.push_back(chain);
|
|
m.st.models.push_back(model);
|
|
m.residues_read = 120;
|
|
m.residues_kept = 110;
|
|
const std::string path = "MolecularReplacement_PaeDomains.json";
|
|
{
|
|
std::ofstream out(path);
|
|
out << "[{\"predicted_aligned_error\":[";
|
|
for (int i = 0; i < 120; i++) {
|
|
out << (i ? "," : "") << "[";
|
|
for (int j = 0; j < 120; j++)
|
|
out << (j ? "," : "") << ((i < 55) == (j < 55) ? 2 : 25);
|
|
out << "]";
|
|
}
|
|
out << "],\"max_predicted_aligned_error\":31.75}]";
|
|
}
|
|
const auto domains = PaeDomains(path, m);
|
|
REQUIRE(domains.size() == 2);
|
|
CHECK(domains[0].size() == 60);
|
|
CHECK(domains[0].front() == 60);
|
|
CHECK(domains[1].size() == 50);
|
|
const SearchModel d = DomainModel(m, domains[1]);
|
|
CHECK(d.residues_kept == 50);
|
|
CHECK(d.description == "PAE domain of 50 residues (1-50)");
|
|
std::remove(path.c_str());
|
|
}
|
|
|
|
// A placement of a group carried into a subgroup's merge: in P 21 21 21 read as P 1 21 1 on the same cell each
|
|
// copy becomes two, the second its image under a two-fold the subgroup does not have; in C 2 2 21 read as
|
|
// P 1 21 1 on the primitive monoclinic cell half the volume holds the same two.
|
|
TEST_CASE("MolecularReplacement_ExpandIntoSubgroup", "[MolecularReplacement]") {
|
|
const SearchModel model = synthetic_model();
|
|
{
|
|
const gemmi::UnitCell cell(42, 50, 58, 90, 90, 90);
|
|
const gemmi::SpaceGroup &g = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::SpaceGroup &h = gemmi::get_spacegroup_by_name("P 1 21 1");
|
|
const auto setting = SubgroupBasis(cell, g, cell, h);
|
|
REQUIRE(setting);
|
|
const gemmi::Structure placed = PlaceSearchModel(model, axis_angle({1, 2, 3}, 50), gemmi::Fractional(0.17, 0.31, 0.09), cell, g);
|
|
int n = 0;
|
|
const gemmi::Structure expanded = ExpandIntoSubgroup(placed, 1, g, *setting, cell, h, &n);
|
|
CHECK(n == 2);
|
|
// The same crystal: the copies and their P 1 21 1 mates are the P 21 21 21 crystal, which packs.
|
|
CHECK(ClashFraction(expanded, 2) < 0.05);
|
|
}
|
|
{
|
|
const gemmi::UnitCell g_cell(56, 158, 144, 90, 90, 90);
|
|
const gemmi::SpaceGroup &g = gemmi::get_spacegroup_by_name("C 2 2 21");
|
|
const double half = std::sqrt(56.0 * 56.0 + 158.0 * 158.0) / 2;
|
|
const double beta = 180.0 - std::acos(28.0 / half) * 180.0 / M_PI;
|
|
const gemmi::UnitCell h_cell(half, 144, 56, 90, beta, 90);
|
|
const gemmi::SpaceGroup &h = gemmi::get_spacegroup_by_name("P 1 21 1");
|
|
const auto setting = SubgroupBasis(g_cell, g, h_cell, h);
|
|
REQUIRE(setting);
|
|
CHECK(std::fabs(setting->basis.determinant() - 0.5) < 1e-9);
|
|
const gemmi::Structure placed = PlaceSearchModel(model, axis_angle({1, 2, 3}, 50), gemmi::Fractional(0.17, 0.31, 0.09), g_cell, g);
|
|
int n = 0;
|
|
const gemmi::Structure expanded = ExpandIntoSubgroup(placed, 1, g, *setting, h_cell, h, &n);
|
|
CHECK(n == 2);
|
|
CHECK(ClashFraction(expanded, 2) < 0.05);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_MatthewsProbabilities", "[MolecularReplacement]") {
|
|
// Vm 2.5 A^3/Da for one copy: one copy is by far the most probable, four impossible (Vm 0.6).
|
|
const auto p = MatthewsProbabilities(2.5 * 20000, 20000);
|
|
REQUIRE(p.size() == 1);
|
|
CHECK(p[0] == Catch::Approx(1.0));
|
|
// Vm 9 for one copy: three or four copies (Vm 3, 2.25) are the likely ones, one copy is not.
|
|
const auto q = MatthewsProbabilities(9.0 * 20000, 20000);
|
|
REQUIRE(q.size() == 6);
|
|
CHECK(q[0] < 0.01);
|
|
CHECK(std::max_element(q.begin(), q.end()) - q.begin() == 3);
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_TranslationalNcs", "[MolecularReplacement]") {
|
|
const SearchModel model = synthetic_model();
|
|
const gemmi::UnitCell cell(52, 60, 90, 90, 90, 90);
|
|
const gemmi::SpaceGroup &sg = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::Mat33 r = axis_angle({1, 2, 3}, 50);
|
|
const gemmi::Fractional t(0.17, 0.31, 0.09), v(0.0, 0.0, 0.5);
|
|
const auto one = observed(PlaceSearchModel(model, r, t, cell, sg));
|
|
CHECK_FALSE(DetectTranslationalNcs(one, cell, sg).present);
|
|
const auto two = observed(AssembleCopies({PlaceSearchModel(model, r, t, cell, sg),
|
|
PlaceSearchModel(model, r, gemmi::Fractional(t.x, t.y, t.z + v.z), cell, sg)}));
|
|
const TranslationalNcs tncs = DetectTranslationalNcs(two, cell, sg);
|
|
REQUIRE(tncs.present);
|
|
CHECK(std::fabs(tncs.vector.x - std::round(tncs.vector.x)) < 0.03);
|
|
CHECK(std::fabs(tncs.vector.y - std::round(tncs.vector.y)) < 0.03);
|
|
CHECK(std::fabs(tncs.vector.z - 0.5) < 0.03);
|
|
}
|
|
|
|
TEST_CASE("MolecularReplacement_TncsRelatesGroups", "[MolecularReplacement]") {
|
|
const auto &p3121 = gemmi::get_spacegroup_by_name("P 31 2 1"), &p3221 = gemmi::get_spacegroup_by_name("P 32 2 1");
|
|
// A translation of c/3 turns a 3(1) axis into a 3(2) one, wherever it lies in the plane.
|
|
CHECK(TncsRelatesGroups(p3121, p3221, gemmi::Fractional(0.3, 0.1, 1.0 / 3)));
|
|
CHECK_FALSE(TncsRelatesGroups(p3121, p3221, gemmi::Fractional(0.3, 0.1, 0.1)));
|
|
// A translation of a/2 turns 2(1) along a into 2, and nothing else.
|
|
const auto &p212121 = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
CHECK(TncsRelatesGroups(gemmi::get_spacegroup_by_name("P 2 21 21"), p212121, gemmi::Fractional(0.5, 0, 0)));
|
|
CHECK_FALSE(TncsRelatesGroups(gemmi::get_spacegroup_by_name("P 21 2 2"), p212121, gemmi::Fractional(0.5, 0, 0)));
|
|
}
|
|
|
|
// A copy refined beside another one held fixed goes back where it belongs, on the CPU and on the GPU alike.
|
|
TEST_CASE("MolecularReplacement_RigidBodyBesideFixedCopy", "[MolecularReplacement]") {
|
|
const SearchModel model = synthetic_model();
|
|
const gemmi::UnitCell cell(52, 60, 70, 90, 90, 90);
|
|
const gemmi::SpaceGroup &sg = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::Mat33 r1 = axis_angle({1, 2, 3}, 50), r2 = axis_angle({-2, 1, 1}, 110);
|
|
const gemmi::Fractional t1(0.17, 0.31, 0.09), t2(0.41, 0.12, 0.33);
|
|
const gemmi::Structure first = PlaceSearchModel(model, r1, t1, cell, sg);
|
|
const gemmi::Structure truth = PlaceSearchModel(model, r2, t2, cell, sg);
|
|
const auto merged = observed(AssembleCopies({first, truth}));
|
|
gemmi::AsuData<gemmi::ValueSigma<float>> fobs;
|
|
fobs.unit_cell_ = cell;
|
|
fobs.spacegroup_ = &sg;
|
|
for (const MergedReflection &m : merged)
|
|
fobs.v.push_back({{{m.h, m.k, m.l}}, {m.F, 1.0f}});
|
|
fobs.ensure_asu();
|
|
fobs.ensure_sorted();
|
|
// 3 deg and 0.5 A out.
|
|
const gemmi::Structure start = PlaceSearchModel(model, axis_angle({0, 0, 1}, 3).multiply(r2),
|
|
gemmi::Fractional(t2.x + 0.5 / 52, t2.y, t2.z), cell, sg);
|
|
const auto worst = [&](const gemmi::Structure &st) {
|
|
const auto a = ModelPositions(st.models[0]), b = ModelPositions(truth.models[0]);
|
|
double w = 0;
|
|
for (size_t i = 0; i < a.size(); i++)
|
|
w = std::max(w, a[i].dist(b[i]));
|
|
return w;
|
|
};
|
|
Logger logger("MolecularReplacement_RigidBodyBesideFixedCopy");
|
|
gemmi::Structure cpu = start;
|
|
RefineRigidBody(cpu.models[0], cell, sg, fobs, 3.0, logger, 4, nullptr, &first.models[0]);
|
|
CHECK(worst(start) > 1.0);
|
|
CHECK(worst(cpu) < 0.1);
|
|
#ifdef JFJOCH_USE_CUDA
|
|
if (get_gpu_count() > 0) {
|
|
gemmi::Structure gpu = start;
|
|
auto pool = RigidBodyGPUPool::Create(gpu.models[0], cell, sg, 3.0, fobs.v.size(), 1, logger);
|
|
REQUIRE(pool);
|
|
RefineRigidBody(gpu.models[0], cell, sg, fobs, 3.0, logger, 4, pool.get(), &first.models[0]);
|
|
CHECK(worst(gpu) < 0.1);
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#ifdef JFJOCH_USE_CUDA
|
|
TEST_CASE("MolecularReplacement_GPUMatchesCPU", "[MolecularReplacement]") {
|
|
if (get_gpu_count() == 0)
|
|
SKIP("no GPU");
|
|
const SearchModel model = synthetic_model();
|
|
const gemmi::UnitCell cell(42, 50, 58, 90, 90, 90);
|
|
const gemmi::SpaceGroup &sg = gemmi::get_spacegroup_by_name("P 21 21 21");
|
|
const gemmi::Mat33 truth = axis_angle({1, 2, 3}, 50);
|
|
const gemmi::Fractional t(0.17, 0.31, 0.09);
|
|
const auto merged = observed(PlaceSearchModel(model, truth, t, cell, sg));
|
|
const SearchReflections refl = MakeSearchReflections(merged, cell, sg, 4.0, 12.0);
|
|
const MolecularTransform ft(model, 4.0, 4);
|
|
MolecularReplacementGPU gpu(ft);
|
|
|
|
const auto rotations = RotationAsu(UniformRotations(15.0), CartesianPointGroup(sg, cell));
|
|
const auto rf_cpu = RotationFunction(ft, refl, cell, sg, rotations, 4);
|
|
const auto rf_gpu = gpu.RotationFunction(refl, cell, sg, rotations);
|
|
REQUIRE(rf_gpu.size() == rf_cpu.size());
|
|
for (size_t i = 0; i < rf_cpu.size(); i++)
|
|
CHECK(std::fabs(rf_gpu[i] - rf_cpu[i]) < 1e-4);
|
|
|
|
const TranslationSetup setup = MakeTranslationSetup(refl, cell, sg);
|
|
std::vector<gemmi::Mat33> orientations{truth, rotations[3], rotations[17]};
|
|
const auto tf_gpu = gpu.TranslationFunction(setup, orientations);
|
|
for (size_t i = 0; i < orientations.size(); i++) {
|
|
const TranslationPeak cpu = TranslationFunction(ft, setup, orientations[i]);
|
|
CHECK(std::fabs(tf_gpu[i].z - cpu.z) < 1e-3);
|
|
// The solution's peak is the same up to the origins P 21 21 21 allows, which tie exactly; noise peaks
|
|
// can tie between grid points.
|
|
if (i == 0)
|
|
for (double d : {tf_gpu[i].t.x - cpu.t.x, tf_gpu[i].t.y - cpu.t.y, tf_gpu[i].t.z - cpu.t.z})
|
|
CHECK(std::fabs(2 * d - std::round(2 * d)) < 1e-6);
|
|
}
|
|
// The molecular transform's FFT.
|
|
gemmi::DensityCalculator<gemmi::IT92<float>, float> dc;
|
|
dc.d_min = 4.0;
|
|
dc.rate = 1.5;
|
|
gemmi::Structure st = PlaceSearchModel(model, truth, t, cell, gemmi::get_spacegroup_p1());
|
|
dc.set_grid_cell_and_spacegroup(st);
|
|
dc.put_model_density_on_grid(st.models[0]);
|
|
REQUIRE(MapToFPhiGPUFits(dc.grid));
|
|
const auto f_cpu = MapToFPhi(dc.grid), f_gpu = MapToFPhiGPU(dc.grid);
|
|
REQUIRE(f_gpu.data.size() == f_cpu.data.size());
|
|
double worst = 0, largest = 0;
|
|
for (size_t i = 0; i < f_cpu.data.size(); i++) {
|
|
worst = std::max(worst, static_cast<double>(std::abs(f_gpu.data[i] - f_cpu.data[i])));
|
|
largest = std::max(largest, static_cast<double>(std::abs(f_cpu.data[i])));
|
|
}
|
|
CHECK(worst < 1e-4 * largest);
|
|
// Beside a known copy.
|
|
const TranslationSetup setup2 = MakeTranslationSetup(refl, cell, sg, true);
|
|
const auto known = KnownStructureFactors(ft, setup2, cell, {rotations[5]}, {gemmi::Fractional(0.3, 0.1, 0.4)});
|
|
const auto tf2_gpu = gpu.TranslationFunction(setup2, orientations, &known);
|
|
for (size_t i = 0; i < orientations.size(); i++) {
|
|
const TranslationPeak cpu = TranslationFunction(ft, setup2, orientations[i], &known);
|
|
CHECK(std::fabs(tf2_gpu[i].z - cpu.z) < 1e-3);
|
|
}
|
|
}
|
|
#endif
|