Copy number. The hard "Vm >= 4: MULTI_COPY, no decision" rule is gone. Copies are searched one after another up to the largest copy number with a Matthews probability of 1% (a Gumbel distribution with Kantardjieff & Rupp's 2003 mode and mean, no resolution dependence); COPIES_LIKELY reports the most probable. Sequential search. Copy n's translation function carries copies 1..n-1 as a known partial structure: the cross term 2 Re(conj(F_known) F(t)) goes on the same FFT grid (CPU FFTW and GPU cuFFT; KnownStructureFactors, AssemblyCorrelation). Its peaks, past those that clash, are ranked by the correlation of the whole assembly with E^2 (the TF itself, linear in |F|^2, rewards copying a placed copy onto an origin shift), the first that stands out (TF Z >= 8), packs and lowers R-free by 0.01 is kept; each copy is refined beside the others held fixed (RefineRigidBody's fixed copies), and the first copy again beside the second, the earlier ones once more at the end. Packing: ClashFraction counts C-alpha clashes with other copies and all their mates, binned (7mzt: 51 s of clash checks -> 1.9 s). Search quality: rotation-function peaks hill-climbed off the 6 deg sampling (6ze4: RF Z 10.4 -> 16.0, 3.3 deg off the deposited orientation -> 0.2); the first copy goes down the TF list until three placements that pack are refined, so a group is CLASH only where no offered orientation packs; groups are searched side by side, and every group the best one leads is searched again with the best one's copy orientations tried first, the better search kept. tNCS: the native Patterson of E^2 (5-10 A) off the lattice; placed copies' orientations join every further copy's search; and where the tNCS vector turns one group's screw axes into another's (TncsRelatesGroups), a group whose search stopped at fewer copies does not lose to the other - UNDECIDED. Measured on an in-house tNCS crystal (vector c/3 along the 3-fold): both hands fit with three copies to within 0.01 in R-free, and where one hand's search failed the other was taken as ENANTIOMORPH; now UNDECIDED. Null: the replicates are placed by the translation function in their own orientation, as the search placed the real one (a null at the real centroid reads high), and the gate is 4.51 = t(8) sqrt(1 + 1/9), 3 sd of a nine-draw null, the gate --model's MODEL_FIT is now read at. Speed: placements scored on the GPU structure-factor engines; further copies' trials score with the first copy's bulk solvent held, the assembly refitted at the end. Survey of 193 sets (jfjoch_mr_replay on stored merges, RTX 5080 shared with other jobs): MR median 1.15 s, max 24 s; demos 0.8-1.1 s. Report: COPIES and COPIES_LIKELY columns, MODEL_PRIOR_COPIES. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
331 lines
16 KiB
C++
331 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 <cmath>
|
|
#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);
|
|
}
|
|
|
|
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);
|
|
if (i == 0) { // the solution's peak is unique; noise peaks can tie between grid points
|
|
CHECK(tf_gpu[i].t.x == Catch::Approx(cpu.t.x));
|
|
CHECK(tf_gpu[i].t.y == Catch::Approx(cpu.t.y));
|
|
CHECK(tf_gpu[i].t.z == Catch::Approx(cpu.t.z));
|
|
}
|
|
}
|
|
// 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
|