Merge branch 'af-mr3' into rc175: --af-model searches several copies per asymmetric unit (Matthews probability, sequential translation function, fixed-copy rigid body, tNCS)
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 3m23s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 10m24s
Build Packages / build:portable:linux-x86_64:cuda (push) Failing after 12m20s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Failing after 16m11s
Build Packages / build:portable:windows-x86_64:cuda (push) Failing after 19m38s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 22m18s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 23m27s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 17m12s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 27m57s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 20m3s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 23m18s
Build Packages / Generate python client (push) Successful in 39s
Build Packages / Build documentation (push) Successful in 1m18s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 22m7s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 18m24s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 23m53s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 22m44s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m58s
Validation Battery / Validation Battery (push) Successful in 59m36s
Build Packages / Unit tests (push) Successful in 1h30m17s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-12 01:36:26 +02:00
co-authored by Claude Opus 5.5
16 changed files with 1472 additions and 205 deletions
+9 -1
View File
@@ -465,7 +465,15 @@ crystallographic software" (2007), J. Appl. Cryst. 40, 658-674
[doi:10.1107/S0021889807021206](https://doi.org/10.1107/S0021889807021206). Whether one copy fits
the cell is read off the Matthews coefficient: B. W. Matthews, "Solvent content of protein
crystals" (1968), J. Mol. Biol. 33, 491-497
[doi:10.1016/0022-2836(68)90205-2](https://doi.org/10.1016/0022-2836(68)90205-2).
[doi:10.1016/0022-2836(68)90205-2](https://doi.org/10.1016/0022-2836(68)90205-2). How many copies
to search for is weighed by a distribution of the Matthews coefficient over protein crystals made from
the mode and mean of Kantardjieff and Rupp's: K. A. Kantardjieff and B. Rupp, "Matthews coefficient
probabilities: Improved estimates for unit cell contents of proteins, DNA, and protein-nucleic acid
complex crystals" (2003), Protein Sci. 12, 1865-1871
[doi:10.1110/ps.0350503](https://doi.org/10.1110/ps.0350503). A copy refined beside the others has the
others' placements read off by least-squares superposition: W. Kabsch, "A solution for the best rotation
to relate two sets of vectors" (1976), Acta Cryst. A32, 922-923
[doi:10.1107/S0567739476001873](https://doi.org/10.1107/S0567739476001873).
## Software and computing methods
+25 -12
View File
@@ -974,22 +974,35 @@ nothing else.
`--af-model <file>` takes a predicted model - an AlphaFold mmCIF, with the pLDDT in the B-factor
column - and places it by molecular replacement: residues below pLDDT 70 removed, B-factors from the
pLDDT, then a rotation function over the point group (6 deg sampling, 4-12 A), a translation function
for the 50 best orientations in every space group the merge cannot tell apart, and the best three
placements per group through the rigid body, bulk solvent and R-free (to `SCORE_D_MIN`, 3 A or the
data's limit). One copy in the asymmetric unit only, for now.
pLDDT, then a rotation function over the point group (6 deg sampling, 4-12 A, each of the 50 best
peaks refined off the sampling), a translation function for those orientations in every space group the
merge cannot tell apart, and the best three placements per group that pack through the rigid body, bulk
solvent and R-free (to `SCORE_D_MIN`, 3 A or the data's limit). Further copies follow one at a time, up
to the largest copy number with a Matthews probability of 1% (`COPIES_LIKELY`: the most probable): the
translation function again with the copies already placed as a known partial structure, its peaks
ranked by the correlation of all the copies with the data, each new copy refined beside the others held
fixed, and kept where its `TF_Z` reaches 8, it packs and R-free drops by 0.01 (`COPIES`, and
`MODEL_PRIOR_COPIES=` for the group chosen). The groups are searched side by side; every group the best
one leads is searched once more with the best one's copies' orientations tried first, and the better
search kept.
The table has one row per group and merge. A row is `PLACED` where its translation-function Z-score
(`TF_Z`) reaches 8, at most 5% of its C-alpha atoms clash with symmetry mates (`CLASH`), one copy fits
the cell (`MATTHEWS`: Vm below 1.5 A^3/Da; `MULTI_COPY`: Vm of 4 or more, which is reported but not
supported yet). The null - the same model refitted from random orientations, whose R-work the
placement's has to beat by 3 sd (`NULL_SIGMA`) - is built for the candidate the choice falls on; one
that does not beat it is marked `NULL` and the choice falls on the next. A placement clashing with its
symmetry mates over twice the limit is not refined. The rotation and translation functions run on the
The table has one row per group and merge. A row is `PLACED` where one of its copies' translation-
function Z-scores (`TF_Z`, the highest) reaches 8, at most 5% of the C-alpha atoms clash with another
copy or a symmetry mate (`CLASH`), and one copy fits the cell (`MATTHEWS`: Vm below 1.5 A^3/Da). The
null - the same model refitted from random orientations, each placed by the translation function as the
search placed the real one (all copies turned together about their centroid where there are several) -
is built for the candidate the choice falls on; its R-work has to beat the null's by 4.51 sd
(`NULL_SIGMA`: 3 sd allowing for the null being nine draws, as `MODEL_FIT_SIGMA`), and one that does not
is marked `NULL` and the choice falls on the next. A placement clashing over twice the limit is not
refined. Translational NCS is looked for in the native Patterson (a peak off the lattice of a fifth of
the origin's): with it, a group whose screw axes the tNCS translation turns into the chosen group's and
whose search placed fewer copies leaves the choice `UNDECIDED`, since one copy beside a translated twin
of itself fits the data no better than at random. The rotation and translation functions, the rigid
body and the structure factors the R-factors are taken on run on the
GPU where there is one (`MODEL_PRIOR_SEARCH_DEVICE=`). `MODEL_PRIOR_DECISION=` is then:
- `AGREES` - the placed group with the lowest R-free is the answer's, ahead of every other group of
the merge that packs by 0.03 in R-free;
the merge that packs by 0.03 in R-free (with tNCS, see above);
- `ENANTIOMORPH` - it is the answer's enantiomorph, and the answer's files take that label (a label
only, as with `--model`; no reflection moves). It is taken only by the rule a `--model`'s
enantiomorph is taken by: the model has to fit against its null (the choice already required that)
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,7 @@
#pragma once
// Molecular replacement of ONE copy of a predicted model (rugnux --af-model, experimental): a global
// Molecular replacement of a predicted model (rugnux --af-model, experimental): a global
// search for the model's orientation and position in the cell, so that a model with no placement of
// its own - an AlphaFold prediction - can be scored against the data, and the space groups the data
// cannot tell apart can be scored against it.
@@ -13,11 +13,13 @@
// correlation of the observed E^2 with the model's own symmetry-summed |F|^2 - read off the
// model's molecular transform, computed once by FFT and interpolated per orientation.
// - Translation: the full-symmetry translation function on one FFT per orientation and space group.
// - Further copies, one at a time, as many as the cell can hold: the translation function again with
// the copies already placed as a known partial structure, each new copy refined against the others.
// - Rescoring: the best placements through the rigid body, bulk solvent, R-free, a packing check
// against the symmetry mates and a null of the same model in random orientations.
//
// Everything up to the rescoring runs on the CPU, in both builds; the rigid body runs on the GPU
// where there is one.
// The rotation and translation functions, the rigid body and the structure factors the R-factors are
// taken on run on the GPU where there is one, and on the CPU otherwise; the same functions either way.
#include <complex>
#include <memory>
@@ -114,14 +116,32 @@ struct TranslationSetup {
std::vector<gemmi::Vec3> s; // terms x ops
std::vector<std::complex<double>> phase; // terms x ops
std::vector<double> weight; // terms
std::vector<double> e2, multiplicity; // terms
std::vector<int> bin; // terms
int bins = 0;
std::vector<int> pair_j, pair_k; // the ordered pairs
std::vector<int> target_u, target_v, target_l; // terms x pairs
// With a known partial structure (MakeTranslationSetup's `known_part`): where the cross terms
// conj(F_known) g_j go, at h R_j - or, as their conjugate F_known conj(g_j), at -h R_j where that
// has l >= 0; both where l = 0. One entry per coefficient: its term, operator, index and whether it
// is the conjugate.
std::vector<int> cross_term, cross_op, cross_u, cross_v, cross_l;
std::vector<char> cross_conj;
size_t Terms() const { return weight.size(); }
};
// `known_part`: the grid is sized for the cross terms with a known partial structure as well, which
// reach out to h R_j where the copies' own terms reach h R_j - h R_k (in a polar group no further than
// the plane the polar axis is free in).
TranslationSetup MakeTranslationSetup(const SearchReflections &refl, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg);
const gemmi::SpaceGroup &sg, bool known_part = false);
// F of copies of the model already placed - copy c turned by rotations[c] about its centroid, the
// centroid at fractional positions[c] - at each term of `setup`, summed over the copies and their
// symmetry mates, in the units the translation function's g_j are in.
std::vector<std::complex<double>> KnownStructureFactors(const MolecularTransform &ft, const TranslationSetup &setup,
const gemmi::UnitCell &cell,
const std::vector<gemmi::Mat33> &rotations,
const std::vector<gemmi::Fractional> &positions);
// The translation function of the model in orientation `rotation`: its highest point as a Z-score over
// every point of the function, and where it is (the fractional position of the model's centroid).
@@ -129,20 +149,60 @@ struct TranslationPeak {
double z = 0;
gemmi::Fractional t;
};
// With `known` (KnownStructureFactors on a setup made with known_part), the function of one more copy
// beside the known ones: the overlap of E^2 - 1 with |F_known + F(t)|^2, whose cross term
// 2 Re(conj(F_known) F(t)) is a Fourier series in t too and goes on the same grid.
TranslationPeak TranslationFunction(const MolecularTransform &ft, const TranslationSetup &setup,
const gemmi::Mat33 &rotation);
const gemmi::Mat33 &rotation,
const std::vector<std::complex<double>> *known = nullptr);
// The correlation of E^2 with |F|^2 of the known copies (KnownStructureFactors) and one more copy turned by
// `rotation` with its centroid at `t`, over the terms of `setup`, each weighted by its multiplicity and each
// bin's |F|^2 over its mean: the score the translation function's peaks for a further copy are ranked by.
// Unlike the translation function, which is linear in |F|^2, it does not reward a copy that only repeats
// the known ones - their own copy moved by one of the group's origin shifts doubles F on half the
// reflections and cancels it on the other half, which the overlap with E^2 counts as a gain.
double AssemblyCorrelation(const MolecularTransform &ft, const TranslationSetup &setup, const gemmi::UnitCell &cell,
const std::vector<std::complex<double>> &known, const gemmi::Mat33 &rotation,
const gemmi::Fractional &t);
// The model placed: rotated about its centroid by `rotation`, the centroid at `t`, in `cell` and `sg`.
gemmi::Structure PlaceSearchModel(const SearchModel &model, const gemmi::Mat33 &rotation, const gemmi::Fractional &t,
const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg);
// The fraction of the model's C-alpha atoms within 3 A of a C-alpha of a symmetry mate.
double ClashFraction(const gemmi::Structure &placed);
// The fraction of the C-alpha atoms within 3 A of a C-alpha of another copy: a symmetry mate, or one of
// the other `copies` the structure's chains are split into evenly (in order), or their mates.
double ClashFraction(const gemmi::Structure &placed, size_t copies = 1);
// Copies of the model as one structure: their chains in order, renamed so that no two share a name.
gemmi::Structure AssembleCopies(const std::vector<gemmi::Structure> &copies);
// The probabilities of 1, 2, ... copies of a model of `mass_da` in the asymmetric unit of a cell of
// `asu_volume` A^3, from the distribution of the Matthews coefficient over protein crystals; one entry
// per copy number whose Vm is at least 1.5 A^3/Da.
std::vector<double> MatthewsProbabilities(double asu_volume, double mass_da);
// The space groups with the rotations and the centring of `sg`, in its setting: those merged
// intensities cannot tell apart, apart from systematic absences. `sg` itself first.
std::vector<gemmi::SpaceGroup> SameRotationGroups(const gemmi::SpaceGroup &sg);
// Translational non-crystallographic symmetry: copies in the same orientation, a translation apart, make
// the native Patterson peak off its origin where no lattice vector is. The highest peak of the Patterson
// of E^2 (5-10 A), at least 15 A from the origin and from every centring vector, as a fraction of the
// origin's height, and where it is; tNCS where that is a fifth or more.
struct TranslationalNcs {
bool present = false;
double height = 0; // over the origin's
gemmi::Fractional vector;
};
TranslationalNcs DetectTranslationalNcs(const std::vector<MergedReflection> &merged, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg);
// Whether groups `a` and `b` (the same rotations) differ only in screw translations that the tNCS vector
// `v` makes up: then copies a translation v apart put one group's operators onto the other's, and neither
// is told from the other by a search that has not placed all of them.
bool TncsRelatesGroups(const gemmi::SpaceGroup &a, const gemmi::SpaceGroup &b, const gemmi::Fractional &v);
// One merge the model is searched against: the answer's, or the twin hypothesis' in a subgroup.
struct MRMerge {
std::string name; // "MERGE" or "TWIN_SUBGROUP_MERGE"
@@ -151,20 +211,23 @@ struct MRMerge {
gemmi::SpaceGroup space_group; // the group it was merged in
};
// One space group's best single-copy placement.
// One space group's best placement: its first copy and every further one the search added.
struct MRCandidate {
int merge = 0; // index into the merges searched
gemmi::SpaceGroup space_group;
double vm = 0; // Matthews coefficient of one copy
int copies_likely = 1;
double rf_z = 0; // of the orientation, over the rotation function
double tf_z = 0;
double clash_fraction = 0;
int copies_likely = 1; // the most probable copy number, by Matthews
int copies_max = 1; // the most the search tried (Matthews probability >= 1%)
int copies = 0; // placed
double rf_z = 0; // of the first copy's orientation, over the rotation function
double tf_z = 0; // the highest of the copies'
std::vector<double> copy_tf_z; // each copy's, the later ones beside the earlier
double clash_fraction = 0; // of all the copies
double r_work = NAN, r_free = NAN; // after the rigid body, to d_min_score
double k_sol = NAN, b_sol = NAN; // the bulk solvent R was computed with
double null_sigma = NAN; // R-work above its random-orientation null, in sd
std::string verdict; // PLACED, or why not
std::shared_ptr<gemmi::Structure> placed;
std::shared_ptr<gemmi::Structure> placed; // every copy
};
struct MolecularReplacementResult {
@@ -180,6 +243,7 @@ struct MolecularReplacementResult {
int chosen = -1;
std::string decision;
bool gpu_search = false; // the rotation and translation functions ran on the GPU
TranslationalNcs tncs; // of the answer's merge
// An ENANTIOMORPH decision is taken into the answer's label only as model validation takes a model's
// enantiomorph (ValidateAgainstModel): the placed model has to fit against its null and the anomalous
// map at its atoms must not be inverted. Filled by the caller, which runs that validation.
@@ -199,11 +263,20 @@ struct MolecularReplacementResult {
double t_model = 0, t_transform = 0, t_rotation = 0, t_translation = 0, t_rescore = 0, t_null = 0;
};
// Search `merges` (the answer's first) for one copy of the predicted model at `model_path`, in every
// space group with the rotations of each merge's group, and decide. A candidate is PLACED where its
// translation function stands out (TF Z >= 8), it packs, one copy fits the cell, and its R-work beats
// its own null. The model chooses among the answer merge's groups where one placed candidate leads
// Search `merges` (the answer's first) for the predicted model at `model_path`, in every space group with
// the rotations of each merge's group - one copy, then further copies one at a time while each new one
// stands out, packs and lowers R-free - and decide. A candidate is PLACED where a copy's translation
// function stands out (TF Z >= 8), the copies pack, one copy fits the cell, and its R-work beats its own
// null. The model chooses among the answer merge's groups where one placed candidate leads
// every other by an R-free margin; it chooses the twin hypothesis' merge only where none of the
// answer's groups can hold the model at all (Matthews, packing) and a group of the subgroup can.
MolecularReplacementResult RunMolecularReplacement(const std::string &model_path, const std::vector<MRMerge> &merges,
Logger &logger, size_t nthreads);
// The hand check of an ENANTIOMORPH decision, filled into result.hand_check: taken into the answer by
// --model's rule for a model's enantiomorph - the model has to fit against its own null, which the choice
// already required of it, and the anomalous map at its atoms (`merged`, the answer's merge in
// `data_space_group`) must not come out inverted, read by model validation's own code on the placement
// and the bulk solvent MR scored. Where it is not taken, the decision becomes ENANTIOMORPH_VETOED.
void CheckEnantiomorphHand(MolecularReplacementResult &result, const std::vector<MergedReflection> &merged,
const gemmi::SpaceGroup &data_space_group, Logger &logger, size_t nthreads);
@@ -194,6 +194,28 @@ __global__ void translation_gather(const int *offset, const int *entry, const fl
coef[c] = sum;
}
// The cross terms with a known partial structure, added to the coefficients: w conj(F_known) g_j, or its
// conjugate, for every entry landing on a grid point, in the order they were listed.
__global__ void translation_gather_cross(const int *offset, const int *entry, const float2 *copies,
const float *scaled, const float2 *known, const int *cross_term,
const int *cross_op, const char *cross_conj, int nops, size_t cells,
float2 *coef) {
const size_t c = static_cast<size_t>(blockIdx.x) * blockDim.x + threadIdx.x;
if (c >= cells)
return;
float2 sum = coef[c];
for (int e = offset[c]; e < offset[c + 1]; e++) {
const int x = entry[e], t = cross_term[x];
const float2 k = known[t];
const float2 g = copies[static_cast<size_t>(t) * nops + cross_op[x]];
const float w = scaled[t];
const float re = k.x * g.x + k.y * g.y, im = k.x * g.y - k.y * g.x; // conj(k) g
sum.x += w * re;
sum.y += w * (cross_conj[x] ? -im : im);
}
coef[c] = sum;
}
// Per block: the sum and the sum of squares of the function, and its highest point (the lowest index
// on a tie).
__global__ void translation_stats(const float *map, size_t n, double *sum, double *sum2, float *best,
@@ -329,12 +351,13 @@ std::vector<double> MolecularReplacementGPU::RotationFunction(const SearchReflec
return out;
}
std::vector<TranslationPeak> MolecularReplacementGPU::TranslationFunction(const TranslationSetup &setup,
const std::vector<gemmi::Mat33> &rotations) {
std::vector<TranslationPeak> MolecularReplacementGPU::TranslationFunction(
const TranslationSetup &setup, const std::vector<gemmi::Mat33> &rotations,
const std::vector<std::complex<double>> *known) {
std::vector<TranslationPeak> out(rotations.size());
const int terms = static_cast<int>(setup.Terms()), nops = setup.ops;
const int npairs = static_cast<int>(setup.pair_j.size());
if (nops < 2 || terms == 0 || rotations.empty())
if (terms == 0 || rotations.empty() || (nops < 2 && known == nullptr))
return out;
cudaStream_t stream = state_->stream;
const int nu = setup.nu, nv = setup.nv, nw = setup.nw, nwh = nw / 2 + 1;
@@ -356,6 +379,28 @@ std::vector<TranslationPeak> MolecularReplacementGPU::TranslationFunction(const
entry[fill[(static_cast<size_t>(setup.target_u[e]) * nv + setup.target_v[e]) * nwh
+ setup.target_l[e]]++] = static_cast<int>(e);
}
// The same for the cross terms.
std::vector<int> cross_offset(cells + 1, 0), cross_entry;
std::vector<float2> known_f;
if (known != nullptr) {
for (size_t e = 0; e < setup.cross_term.size(); e++)
cross_offset[(static_cast<size_t>(setup.cross_u[e]) * nv + setup.cross_v[e]) * nwh + setup.cross_l[e] + 1]++;
for (size_t c = 0; c < cells; c++)
cross_offset[c + 1] += cross_offset[c];
cross_entry.resize(cross_offset[cells]);
std::vector<int> fill(cross_offset.begin(), cross_offset.end() - 1);
for (size_t e = 0; e < setup.cross_term.size(); e++)
cross_entry[fill[(static_cast<size_t>(setup.cross_u[e]) * nv + setup.cross_v[e]) * nwh
+ setup.cross_l[e]]++] = static_cast<int>(e);
for (const std::complex<double> &k : *known)
known_f.push_back(make_float2(static_cast<float>(k.real()), static_cast<float>(k.imag())));
}
auto d_cross_offset = upload(cross_offset, stream);
auto d_cross_entry = upload(cross_entry, stream);
auto d_known = upload(known_f, stream);
auto d_cross_term = upload(setup.cross_term, stream);
auto d_cross_op = upload(setup.cross_op, stream);
auto d_cross_conj = upload(setup.cross_conj, stream);
std::vector<float2> phase(setup.phase.size());
for (size_t i = 0; i < phase.size(); i++)
phase[i] = make_float2(static_cast<float>(setup.phase[i].real()), static_cast<float>(setup.phase[i].imag()));
@@ -391,6 +436,10 @@ std::vector<TranslationPeak> MolecularReplacementGPU::TranslationFunction(const
scaled);
translation_gather<<<static_cast<unsigned>((cells + THREADS - 1) / THREADS), THREADS, 0, stream>>>(
d_offset, d_entry, copies, scaled, d_pair_j, d_pair_k, nops, npairs, cells, coef);
if (known != nullptr)
translation_gather_cross<<<static_cast<unsigned>((cells + THREADS - 1) / THREADS), THREADS, 0, stream>>>(
d_cross_offset, d_cross_entry, copies, scaled, d_known, d_cross_term, d_cross_op, d_cross_conj,
nops, cells, coef);
cufft_err(cufftExecC2R(plan, reinterpret_cast<cufftComplex *>(coef.get()), map.get()));
translation_stats<<<stat_blocks, THREADS, 0, stream>>>(
map, points, d_sum.get() + r * stat_blocks, d_sum2.get() + r * stat_blocks,
@@ -28,9 +28,11 @@ public:
std::vector<double> RotationFunction(const SearchReflections &refl, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg, const std::vector<gemmi::Mat33> &rotations);
// The translation function of one group at each of `rotations`.
// The translation function of one group at each of `rotations`; with `known`, beside a known partial
// structure (KnownStructureFactors).
std::vector<TranslationPeak> TranslationFunction(const TranslationSetup &setup,
const std::vector<gemmi::Mat33> &rotations);
const std::vector<gemmi::Mat33> &rotations,
const std::vector<std::complex<double>> *known = nullptr);
private:
std::unique_ptr<MolecularReplacementGPUState> state_;
@@ -280,6 +280,10 @@ void RigidBodyTargetGPU::Placement(const double q[6], double rotation[9], double
}
void RigidBodyTargetGPU::SetZone(const gemmi::AsuData<gemmi::ValueSigma<float>> &fobs, double d_min) {
// The body where this zone starts, for the mask it shares with fixed atoms: the last evaluation's.
std::array<double, 6> q_start{};
if (have_point_)
q_start = q_;
d_min_ = d_min;
solvent_fitted_ = false;
host_scale_ = false;
@@ -287,6 +291,42 @@ void RigidBodyTargetGPU::SetZone(const gemmi::AsuData<gemmi::ValueSigma<float>>
zone_ = pool_.ObservedZone(model_, cell_, sg_, fobs, d_min);
try {
engine_.SetZone(*zone_);
if (fixed != nullptr) {
// The fixed atoms are copies of the body (RefineRigidBody): each one's placement of the body's
// atoms, by the least-squares superposition of the two.
// Following Kabsch (1976) Acta Cryst. A32, 922-923
const std::vector<gemmi::Position> all = ModelPositions(*fixed);
const size_t n = base_.size();
std::vector<std::array<double, 12>> copies;
for (size_t c = 0; c + n <= all.size(); c += n) {
Eigen::Vector3d mean = Eigen::Vector3d::Zero();
for (size_t i = 0; i < n; i++)
mean += Eigen::Vector3d(all[c + i].x, all[c + i].y, all[c + i].z);
mean /= static_cast<double>(n);
Eigen::Matrix3d h = Eigen::Matrix3d::Zero();
for (size_t i = 0; i < n; i++) {
const Eigen::Vector3d p(base_[i].x - centre_.x, base_[i].y - centre_.y, base_[i].z - centre_.z);
const Eigen::Vector3d q(all[c + i].x - mean.x(), all[c + i].y - mean.y(), all[c + i].z - mean.z());
h += p * q.transpose();
}
const Eigen::JacobiSVD<Eigen::Matrix3d> svd(h, Eigen::ComputeFullU | Eigen::ComputeFullV);
Eigen::Matrix3d d = Eigen::Matrix3d::Identity();
d(2, 2) = (svd.matrixV() * svd.matrixU().transpose()).determinant() < 0 ? -1.0 : 1.0;
const Eigen::Matrix3d r = svd.matrixV() * d * svd.matrixU().transpose();
// The body's relative coordinates have their mean at zero, so the copy's centroid is t.
std::array<double, 12> x{};
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
x[3 * i + j] = r(i, j);
x[9] = mean.x();
x[10] = mean.y();
x[11] = mean.z();
copies.push_back(x);
}
std::array<double, 12> moving{};
Placement(q_start.data(), moving.data(), moving.data() + 9);
engine_.SetFixedCopies(copies, moving);
}
} catch (const JFJochException &e) {
throw RigidBodyGPUFailure(e.what());
}
@@ -331,7 +371,7 @@ bool RigidBodyTargetGPU::Residuals(const double q[6], double *residuals) {
if (zone_->row_hkl.empty())
return false;
engine_.Fcalc(rotation, translation);
if (!(hold_mask && have_point_))
if (fixed == nullptr && !(hold_mask && have_point_))
engine_.Fmask();
if (zone_->point_obs.empty())
@@ -106,7 +106,7 @@ __global__ void place_kernel(const double *rel, int n, Placement p, int nu, int
// dF/dt where `df_dt` is not null. `f1` is cuFFT's forward transform, which carries exp(-2 pi i h.x):
// conjugated and scaled by V/N it is gemmi's F1.
__global__ void compose_kernel(const float2 *f1, float norm, const RigidBodyGPUTerm *terms, const double *row_scale,
int rows, int ops, double2 *f, double2 *df_dt) {
int rows, int ops, const double2 *fixed, double2 *f, double2 *df_dt) {
const int m = blockIdx.x * blockDim.x + threadIdx.x;
if (m >= rows)
return;
@@ -126,7 +126,8 @@ __global__ void compose_kernel(const float2 *f1, float norm, const RigidBodyGPUT
}
}
const double scale = row_scale[m];
f[m] = make_double2(scale * sr, scale * si);
f[m] = fixed != nullptr ? make_double2(scale * sr + fixed[m].x, scale * si + fixed[m].y)
: make_double2(scale * sr, scale * si);
if (df_dt != nullptr) {
// times 2 pi i
const double two_pi = 2 * 3.14159265358979323846;
@@ -135,6 +136,12 @@ __global__ void compose_kernel(const float2 *f1, float norm, const RigidBodyGPUT
}
}
__global__ void add_kernel(const double2 *a, int n, double2 *sum) {
const int m = blockIdx.x * blockDim.x + threadIdx.x;
if (m < n)
sum[m] = make_double2(sum[m].x + a[m].x, sum[m].y + a[m].y);
}
// Fmask at each row, read at h directly (the mask is symmetric): gemmi's get_value_by_hkl() on the
// transform of the mask, taking the Friedel mate where h is outside the stored half.
__global__ void fmask_kernel(const float2 *fm, float norm, const int *row_hkl, int rows, int nu, int nv, int nw,
@@ -324,6 +331,8 @@ struct RigidBodyGPUEngineImpl {
CudaDevicePtr<int> mask_slot;
CudaDevicePtr<float> mask_radius;
CudaDevicePtr<double> mask_atoms; // 4 per mask atom: fractional x, y, z in [0,1), radius
CudaDevicePtr<double> copies_mask_atoms; // the same for the body and fixed copies of it, grown as needed
size_t copies_mask_capacity = 0;
CudaDevicePtr<float> grid; // 3 grids back to back
CudaDevicePtr<float2> spectrum; // their 3 transforms
@@ -332,7 +341,8 @@ struct RigidBodyGPUEngineImpl {
CudaDevicePtr<RigidBodyGPUTerm> terms;
CudaDevicePtr<int> row_hkl;
CudaDevicePtr<double> row_scale, row_stol2;
CudaDevicePtr<double2> f, df_dt, shifted;
CudaDevicePtr<double2> f, df_dt, shifted, fixed;
bool has_fixed = false; // the zone's fixed Fcalc is in `fixed`
CudaDevicePtr<float2> fmask;
CudaDevicePtr<int> obs_row, point_obs;
@@ -375,6 +385,7 @@ struct RigidBodyGPUEngineImpl {
f = CudaDevicePtr<double2>(nr, sync);
df_dt = CudaDevicePtr<double2>(3 * nr, sync);
shifted = CudaDevicePtr<double2>(3 * nr, sync);
fixed = CudaDevicePtr<double2>(nr, sync);
fmask = CudaDevicePtr<float2>(nr, sync);
obs_row = CudaDevicePtr<int>(no, sync);
point_obs = CudaDevicePtr<int>(no, sync);
@@ -443,7 +454,7 @@ struct RigidBodyGPUEngineImpl {
void Compose(int slot, double2 *out, double2 *derivative) {
compose_kernel<<<blocks(rows), THREADS, 0, stream>>>(spectrum.get() + slot * ComplexPoints(), Norm(), terms,
row_scale, static_cast<int>(rows), static_cast<int>(ops),
out, derivative);
has_fixed ? fixed.get() : nullptr, out, derivative);
cuda_err(cudaGetLastError());
}
@@ -500,7 +511,7 @@ size_t RigidBodyGPUEngine::DeviceBytes(const RigidBodyGPUCapacity &c) {
bytes += 3 * c.grid_points * sizeof(float) + 3 * c.complex_points * sizeof(float2) + c.fft_work_bytes;
bytes += ModelDensityGPU::DeviceBytes(c.atoms, c.pairs, c.bricks);
bytes += c.terms * sizeof(RigidBodyGPUTerm);
bytes += nr * (3 * sizeof(int) + 2 * sizeof(double) + 7 * sizeof(double2) + sizeof(float2));
bytes += nr * (3 * sizeof(int) + 2 * sizeof(double) + 8 * sizeof(double2) + sizeof(float2));
bytes += no * (2 * sizeof(int) + sizeof(float) + (1 + 6 + MAX_SCALE_PARAMS) * sizeof(double) + 2 * sizeof(float2));
bytes += ModelMaskGPU::DeviceBytes(c.grid_points) + ModelScaleGPU::DeviceBytes(no);
return bytes;
@@ -559,6 +570,7 @@ void RigidBodyGPUEngine::SetZone(const RigidBodyGPUZone &zone) {
e.n_constraints = zone.constraints.size();
e.constraints = zone.constraints;
e.f_mean = zone.f_mean;
e.has_fixed = false;
std::vector<int> slot(zone.atoms.size(), -1);
std::vector<float> radius(zone.atoms.size(), 0.0f);
@@ -607,6 +619,48 @@ void RigidBodyGPUEngine::Fcalc(const double rotation[9], const double translatio
e.Compose(0, e.f, e.df_dt);
}
void RigidBodyGPUEngine::SetFixedCopies(const std::vector<std::array<double, 12>> &fixed,
const std::array<double, 12> &moving) {
RigidBodyGPUEngineImpl &e = *impl_;
cuda_err(cudaSetDevice(e.device));
const size_t copies = fixed.size() + 1;
if (e.copies_mask_capacity < copies * e.n_mask_atoms) {
e.copies_mask_capacity = copies * e.n_mask_atoms;
e.copies_mask_atoms = CudaDevicePtr<double>(4 * std::max<size_t>(e.copies_mask_capacity, 1), CudaAlloc::Synchronous);
}
cuda_err(cudaMemsetAsync(e.fixed, 0, e.rows * sizeof(double2), e.stream));
for (size_t c = 0; c < copies; c++) {
const std::array<double, 12> &x = c < fixed.size() ? fixed[c] : moving;
Placement p{};
std::copy(x.begin(), x.begin() + 9, p.r);
std::copy(x.begin() + 9, x.end(), p.t);
std::copy(e.frac, e.frac + 9, p.frac);
place_kernel<<<blocks(e.n_atoms), THREADS, 0, e.stream>>>(e.rel, e.n_atoms, p, e.geom.nu, e.geom.nv, e.geom.nw,
e.mask_slot, e.mask_radius, e.pos,
e.copies_mask_atoms.get() + 4 * c * e.n_mask_atoms);
cuda_err(cudaGetLastError());
if (c < fixed.size()) {
e.density->Compute(e.pos, e.grid.get());
cufft_err(cufftExecR2C(e.Plan(1), e.grid, reinterpret_cast<cufftComplex *>(e.spectrum.get())));
e.has_fixed = false;
e.Compose(0, e.shifted, nullptr);
add_kernel<<<blocks(e.rows), THREADS, 0, e.stream>>>(e.shifted, static_cast<int>(e.rows), e.fixed);
cuda_err(cudaGetLastError());
}
}
float *mask_grid = e.grid.get() + e.GridPoints();
cufftComplex *mask_spectrum = reinterpret_cast<cufftComplex *>(e.spectrum.get() + e.ComplexPoints());
e.mask->Compute(reinterpret_cast<const ModelMaskAtom *>(e.copies_mask_atoms.get()),
static_cast<int>(copies * e.n_mask_atoms), mask_grid);
cufft_err(cufftExecR2C(e.Plan(1), mask_grid, mask_spectrum));
fmask_kernel<<<blocks(e.rows), THREADS, 0, e.stream>>>(reinterpret_cast<float2 *>(mask_spectrum), e.Norm(),
e.row_hkl, static_cast<int>(e.rows), e.geom.nu, e.geom.nv,
e.geom.nw, e.fmask);
cuda_err(cudaGetLastError());
cuda_err(cudaStreamSynchronize(e.stream));
e.has_fixed = true;
}
void RigidBodyGPUEngine::Fmask() {
RigidBodyGPUEngineImpl &e = *impl_;
cuda_err(cudaSetDevice(e.device));
@@ -109,6 +109,11 @@ public:
// solvent mask of the same placement (ModelMaskGPU) and Fmask.
void Fcalc(const double rotation[9], const double translation[3]);
void Fmask();
// Per zone, after SetZone(): fixed atoms beside the body that are copies of it, each placed at (R, t) -
// rotation row-major, then translation, as Fcalc() takes them. Their Fcalc, composed one copy at a time,
// is added to the body's in every composition from now on, and the bulk-solvent mask of them and of the
// body at `moving` is held at the rows in place of Fmask()'s.
void SetFixedCopies(const std::vector<std::array<double, 12>> &fixed, const std::array<double, 12> &moving);
// The scale at this evaluation's Fcalc and Fmask (ModelScaleGPU): FitModelScale()'s solvent grid, and
// the overall scale and anisotropic B at a fixed solvent. FitSolvent() throws ModelScaleGPUTooFewReflections where
// the grid's fits would chain (too few reflections for the isotropic start), which the host then does.
@@ -274,6 +274,31 @@ void RigidBodyTarget::SetZone(const gemmi::AsuData<gemmi::ValueSigma<float>> &fo
for (const auto &hv : fobs_.v)
hkl.push_back(hv.hkl);
composition_.emplace(grid, d_min, hkl);
if (fixed != nullptr)
fixed_part_ = RigidBodyFixed(model_, *fixed, cell_, sg_, d_min_, *composition_, nthreads_);
}
RigidBodyFixedPart RigidBodyFixed(const gemmi::Model &moving, const gemmi::Model &fixed, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg, double d_min, const SymmetryComposition &composition,
size_t nthreads) {
RigidBodyFixedPart out;
gemmi::DensityCalculator<Table, float> dc;
dc.d_min = d_min;
dc.rate = GRID_RATE;
dc.grid.unit_cell = cell;
dc.grid.spacegroup = &sg;
dc.set_refmac_compatible_blur(fixed);
PutModelDensityOnGrid(dc, fixed, {}, nthreads);
composition.Compose(MapToFPhi(dc.grid), dc.blur, out.fcalc, nullptr, nthreads);
gemmi::Model all = moving;
all.chains.insert(all.chains.end(), fixed.chains.begin(), fixed.chains.end());
gemmi::Grid<float> mask_grid = RigidBodyZoneGrid(cell, sg, d_min);
PutMaskOnGrid(mask_grid, all, OrbitLeaders(mask_grid, nthreads), nthreads);
const gemmi::FPhiGrid<float> fm = MapToFPhi(mask_grid);
for (const gemmi::Miller &h : composition.Hkl())
out.fmask.push_back(fm.get_value_by_hkl(h));
return out;
}
// The model's Fcalc at the zone's composed indices, from one copy of it on the zone's grid.
@@ -300,7 +325,11 @@ bool RigidBodyTarget::Residuals(const double q[6], double *residuals) {
const std::vector<gemmi::Miller> &hkl = composition_->Hkl();
std::vector<std::complex<float>> fmask;
if (hold_mask && have_point_) {
if (fixed != nullptr) {
for (size_t m = 0; m < fc.size(); m++)
fc[m] += fixed_part_.fcalc[m];
fmask = fixed_part_.fmask;
} else if (hold_mask && have_point_) {
fmask = fmask_;
} else {
// The mask is symmetric as gridded, so it is read at h directly.
@@ -400,6 +429,9 @@ bool RigidBodyTarget::Jacobian(const double q[6], double *jacobian) {
qj[j] += step;
Place(qj, column_models_[j]);
CopyFcalc(column_models_[j], shifted[j], nullptr);
if (fixed != nullptr)
for (size_t m = 0; m < shifted[j].size(); m++)
shifted[j][m] += fixed_part_.fcalc[m];
}));
for (std::future<void> &f : running)
f.get();
@@ -498,7 +530,8 @@ RigidBodyRefineResult RefineRigidBody(gemmi::Model &model,
double d_min,
Logger &logger,
size_t nthreads,
RigidBodyGPUPool *gpu) {
RigidBodyGPUPool *gpu,
const gemmi::Model *fixed) {
const auto t0 = std::chrono::steady_clock::now();
RigidBodyRefineResult result;
if (fobs.v.empty())
@@ -513,9 +546,12 @@ RigidBodyRefineResult RefineRigidBody(gemmi::Model &model,
RigidBodyTargetBase &target = *target_backend;
if (!target.Usable())
return result;
target.fixed = fixed;
const std::vector<double> ladder = RigidBodyLadder(d_min);
const gemmi::Mat33 gauge = RigidBodyGaugeProjector(sg, cell);
// With fixed atoms beside the body the origin is theirs, not free.
const gemmi::Mat33 gauge = fixed != nullptr ? gemmi::Mat33(0, 0, 0, 0, 0, 0, 0, 0, 0)
: RigidBodyGaugeProjector(sg, cell);
double q[6] = {0, 0, 0, 0, 0, 0};
bool any_zone_solved = false;
for (double zone : ladder) {
@@ -53,6 +53,11 @@ double RigidBodyReachDeg(const gemmi::Model &model, double d_min);
//
// `gpu`: evaluate the target on an engine of this pool (RigidBodyGPU.h, CUDA builds only) rather than
// on the CPU. The same target either way; null is the CPU.
//
// `fixed`: other atoms of the crystal that do not move - the other copies of a molecular-replacement
// search - whose Fcalc is added to the body's at every evaluation, and which the bulk-solvent mask covers
// as well; that mask is computed once per zone, with the body where the zone starts, and held. With them
// the origin is no longer free, and the translation is refined in all three directions.
class RigidBodyGPUPool;
RigidBodyRefineResult RefineRigidBody(gemmi::Model &model,
const gemmi::UnitCell &cell,
@@ -61,7 +66,8 @@ RigidBodyRefineResult RefineRigidBody(gemmi::Model &model,
double d_min,
Logger &logger,
size_t nthreads = 1,
RigidBodyGPUPool *gpu = nullptr);
RigidBodyGPUPool *gpu = nullptr,
const gemmi::Model *fixed = nullptr);
// --- The pieces RefineRigidBody is built from, exposed for the tests and the GPU backend ---
@@ -123,6 +129,16 @@ private:
std::vector<Term> terms_;
};
// What RefineRigidBody's fixed atoms add to a zone: their Fcalc at the zone's composed indices, and the
// bulk-solvent mask of them and the body together (`moving`, where the zone starts) at the same indices.
struct RigidBodyFixedPart {
std::vector<std::complex<double>> fcalc;
std::vector<std::complex<float>> fmask;
};
RigidBodyFixedPart RigidBodyFixed(const gemmi::Model &moving, const gemmi::Model &fixed, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup &sg, double d_min, const SymmetryComposition &composition,
size_t nthreads);
// The target RefineRigidBody minimises over one resolution zone: the amplitude residuals of the model
// placed at q (the angle-axis rotation about the centroid times the rms radius, then the translation,
// all in angstroms), with the scale re-fitted at every evaluation. The placement and the counters are
@@ -146,6 +162,7 @@ public:
virtual bool Jacobian(const double q[6], double *jacobian) = 0;
bool hold_mask = false; // for the tests: keep the last evaluation's bulk-solvent mask
const gemmi::Model *fixed = nullptr; // RefineRigidBody's fixed atoms; set before the first zone
int evaluations = 0;
int jacobians = 0;
int unmatched = 0; // zone observations with no calculated amplitude to compare against
@@ -186,6 +203,7 @@ private:
double f_mean_ = 1;
std::vector<size_t> orbit_leaders_;
std::optional<SymmetryComposition> composition_; // the zone's
RigidBodyFixedPart fixed_part_; // the zone's, with fixed atoms
bool solvent_fitted_ = false;
// The last evaluation, which the Jacobian is taken at.
+26 -17
View File
@@ -2591,7 +2591,10 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
Add(s, KeyReal("SCORE_D_MIN", mp.d_min_score, "{:.2f}"));
Add(s, KeyText("MODEL_PRIOR_DECISION", mp.decision));
if (mp.chosen >= 0)
{
Add(s, KeyText("MODEL_PRIOR_SPACE_GROUP", mp.candidates[mp.chosen].space_group.xhm()));
Add(s, KeyInt("MODEL_PRIOR_COPIES", mp.candidates[mp.chosen].copies));
}
if (mp.hand_check.performed) {
const auto &h = mp.hand_check;
Add(s, KeyEnum("MODEL_PRIOR_HAND_CHECK", h.adopted ? "ADOPTED" : "VETOED", {"ADOPTED", "VETOED"}));
@@ -2613,22 +2616,25 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
Add(s, Blank());
ReportEntry e;
e.kind = ReportEntry::Kind::Table;
e.table.columns = {"MERGE", "SPACE_GROUP", "VM", "RF_Z", "TF_Z", "CLASH", "R_WORK", "R_FREE",
"NULL_SIGMA", "VERDICT"};
e.table.text_header = " MERGE SPACE_GROUP VM RF_Z TF_Z CLASH R_WORK R_FREE NULL_SIGMA VERDICT\n"
" ------------------- ----------- ----- ----- ----- ----- ------ ------ ---------- ----------";
e.table.columns = {"MERGE", "SPACE_GROUP", "VM", "COPIES", "COPIES_LIKELY", "RF_Z", "TF_Z", "CLASH",
"R_WORK", "R_FREE", "NULL_SIGMA", "VERDICT"};
e.table.text_header = " MERGE SPACE_GROUP VM COPIES LIKELY RF_Z TF_Z CLASH R_WORK R_FREE NULL_SIGMA VERDICT\n"
" ------------------- ----------- ----- ------ ------ ----- ----- ----- ------ ------ ---------- ----------";
for (const auto &c : mp.candidates) {
const std::string merge = c.merge == 0 ? "MERGE" : "TWIN_SUBGROUP_MERGE";
const auto r = [](double v) { return std::isfinite(v) ? fmt::format("{:.3f}", v) : std::string("-"); };
e.table.text_rows.push_back(fmt::format(
" {:<19s} {:<11s} {:5.2f} {:5.1f} {:5.1f} {:4.1f}% {:>6s} {:>6s} {:>10s} {}",
merge, c.space_group.xhm(), c.vm, c.rf_z, c.tf_z, 100 * c.clash_fraction, r(c.r_work),
" {:<19s} {:<11s} {:5.2f} {:6d} {:6d} {:5.1f} {:5.1f} {:4.1f}% {:>6s} {:>6s} {:>10s} {}",
merge, c.space_group.xhm(), c.vm, c.copies, c.copies_likely, c.rf_z, c.tf_z,
100 * c.clash_fraction, r(c.r_work),
r(c.r_free), std::isfinite(c.null_sigma) ? fmt::format("{:+.1f}", c.null_sigma) : std::string("-"),
c.verdict));
std::vector<ReportValue> row;
row.push_back(KeyText("", merge).value);
row.push_back(KeyText("", c.space_group.xhm()).value);
row.push_back(KeyReal("", c.vm, "{:.2f}").value);
row.push_back(KeyInt("", c.copies).value);
row.push_back(KeyInt("", c.copies_likely).value);
row.push_back(KeyReal("", c.rf_z, "{:.1f}").value);
row.push_back(KeyReal("", c.tf_z, "{:.1f}").value);
row.push_back(KeyReal("", c.clash_fraction, "{:.3f}").value);
@@ -2641,17 +2647,20 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
Add(s, std::move(e));
Add(s, Blank());
Add(s, Prose(fmt::format(
" One copy of the predicted model, trimmed to pLDDT >= 70, was searched for in every space group\n"
" each merge cannot tell apart (rotation function on a 6 deg sampling, translation function,\n"
" {:.1f}-{:.1f} A), and the best placements taken through the rigid body; R_WORK and R_FREE are\n"
" to SCORE_D_MIN. PLACED needs TF_Z >= 8,\n"
" at most 5% of the C-alpha atoms clashing with symmetry mates, one copy fitting the cell\n"
" (MATTHEWS: Vm below 1.5; MULTI_COPY: Vm of 4 or more, not supported yet) and, for the\n"
" candidate the choice falls on, an R-work at least 3 sd better than the same model refitted\n"
" from random orientations (NULL where it is not; the next candidate is then taken). The model chooses\n"
" within a merge where its placed group leads every other that packs by 0.03 in R-free, and\n"
" the twin hypothesis' merge - searched only then - where no group of the answer's merge can\n"
" hold it at all. A group one copy cannot fill is checked for packing only, not refined.\n"
" The predicted model, trimmed to pLDDT >= 70, was searched for in every space group each merge\n"
" cannot tell apart (rotation function on a 6 deg sampling, translation function, {:.1f}-{:.1f} A),\n"
" and the best placements taken through the rigid body; then further copies, one at a time, up to\n"
" the largest copy number with a Matthews probability of 1% (COPIES_LIKELY: the most probable),\n"
" each with the translation function beside the copies already placed, kept where its TF_Z is 8\n"
" or more, it packs and R-free drops by 0.01. R_WORK and R_FREE are of all COPIES, to\n"
" SCORE_D_MIN; TF_Z is the highest of the copies'. PLACED needs TF_Z >= 8, at most 5% of the\n"
" C-alpha atoms clashing with another copy or a symmetry mate, one copy fitting the cell\n"
" (MATTHEWS: Vm below 1.5) and, for the candidate the choice falls on, an R-work at least 3 sd\n"
" better than the same copies refitted from random orientations (NULL where it is not; the next\n"
" candidate is then taken). The model chooses within a merge where its placed group leads every\n"
" other that packs by 0.03 in R-free, and the twin hypothesis' merge - searched only then - where\n"
" no group of the answer's merge can hold it at all. A group one copy cannot fit is checked for\n"
" packing only, not refined.\n"
" The answer's files take the enantiomorph from it - only where model validation of the placed\n"
" model accepts it as it accepts a --model's: a fit over its null and an anomalous map at its\n"
" atoms that is not inverted (ENANTIOMORPH_VETOED otherwise) - and nothing else; another group it\n"
+3 -41
View File
@@ -4000,47 +4000,9 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
"The predicted model was not placed: " + mr.failure_reason});
if (mr.chosen >= 0) {
const MRCandidate &c = mr.candidates[mr.chosen];
if (mr.decision == "ENANTIOMORPH") {
// Taken into the answer by --model's rule for a model's enantiomorph: the model has to fit
// against its own null - which the MR choice already required of it - and the anomalous map
// at its atoms must not come out inverted, read by model validation's own code on the
// placement and the bulk solvent MR scored.
const auto t_hand = std::chrono::steady_clock::now();
Logger quiet = Logger::Buffered();
const auto data_sg = experiment_.GetSpaceGroupOrP1();
ModelValidationResult v;
try {
v = ReadModelAnomalousMap(sm.merged, *c.placed, data_sg, c.k_sol, c.b_sol, quiet,
static_cast<size_t>(config_.nthreads));
} catch (const std::runtime_error &e) { // a GPU failure: no reading, so no relabelling
logger.Warning("Model prior: the anomalous map could not be read ({})", e.what());
}
v.r_work_sigma = c.null_sigma;
v.model_fits = c.null_sigma >= 3.0;
v.adopted_model_enantiomorph = v.ok && v.model_fits && !v.anomalous_hands_disagree;
auto &h = mr.hand_check;
h.performed = true;
h.adopted = v.adopted_model_enantiomorph;
h.model_fits = v.model_fits;
h.fit_sigma = v.r_work_sigma;
h.anomalous_inverted = v.anomalous_hands_disagree;
h.anomalous_pairs = v.anomalous_pairs;
h.anomalous_scatterer_mean_sigma = v.anomalous_scatterer_mean_sigma;
h.deepest_site = v.anomalous_deepest_site;
h.deepest_sigma = v.anomalous_deepest_sigma;
h.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - t_hand).count();
if (!h.adopted) {
mr.decision = "ENANTIOMORPH_VETOED";
logger.Info("Model prior: the enantiomorph {} is not taken into the answer - {} ({:.3f} s)",
c.space_group.xhm(),
h.anomalous_inverted ? fmt::format("the anomalous density at the placed model's "
"atoms is inverted ({} at {:.1f} sigma)",
h.deepest_site, h.deepest_sigma)
: fmt::format("the placed model does not fit against its "
"null ({:+.1f} sd)", h.fit_sigma),
h.seconds);
}
}
if (mr.decision == "ENANTIOMORPH")
CheckEnantiomorphHand(mr, sm.merged, experiment_.GetSpaceGroupOrP1(), logger,
static_cast<size_t>(config_.nthreads));
if (mr.decision == "ENANTIOMORPH") {
// A change of label only (see ValidateAgainstModel): the two groups index identically.
experiment_.SetSpaceGroup(c.space_group);
+120 -1
View File
@@ -11,9 +11,12 @@
#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 {
@@ -39,7 +42,7 @@ namespace {
c *= 1.0 / pos.size();
for (const auto &p : pos) {
gemmi::Atom a;
a.name = "C";
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;
@@ -173,6 +176,114 @@ TEST_CASE("MolecularReplacement_SyntheticPlacement", "[MolecularReplacement]") {
}
}
// 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]") {
@@ -207,5 +318,13 @@ TEST_CASE("MolecularReplacement_GPUMatchesCPU", "[MolecularReplacement]") {
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
+5
View File
@@ -12,6 +12,11 @@ TARGET_LINK_LIBRARIES(jfjoch_extract_hkl JFJochReader)
# rugnux, the single offline analysis CLI, lives in rugnux/ next to its library.
# rugnux --af-model's molecular replacement re-run on a run's merged MTZ files (developer utility, not
# installed): a survey of the search over many datasets in minutes instead of a reprocessing each.
ADD_EXECUTABLE(jfjoch_mr_replay jfjoch_mr_replay.cpp)
TARGET_LINK_LIBRARIES(jfjoch_mr_replay JFJochStructureRefinement)
# In-place re-compress /entry/data/data of a _data file from bitshuffle/LZ4 to bitshuffle/zstd.
ADD_EXECUTABLE(jfjoch_recompress jfjoch_recompress.cpp)
TARGET_LINK_LIBRARIES(jfjoch_recompress JFJochHDF5Wrappers)
+112
View File
@@ -0,0 +1,112 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
// Developer utility: rugnux --af-model's molecular replacement re-run on the merged reflections a rugnux
// run wrote, without reprocessing the images - the answer's p.mtz and, where the run made one, the twin
// hypothesis' p_twin_<group>.mtz. The search, the decision and the hand check are rugnux's own code; the
// output is the MODEL PRIOR keys and table of the rugnux report.
//
// jfjoch_mr_replay <model> <answer group> <p.mtz> [<twin group> <p_twin.mtz>]
//
// The answer group is the one the run searched in: where it took the model's enantiomorph into its
// label, the file carries the relabelled group and the original has to be given here.
#include <algorithm>
#include <cmath>
#include <iostream>
#include <thread>
#include <gemmi/mtz.hpp>
#include "../common/Logger.h"
#include "../image_analysis/structure_refinement/MolecularReplacement.h"
namespace {
std::vector<MergedReflection> read_merged(const std::string &path, UnitCell &cell) {
gemmi::Mtz mtz;
mtz.read_file(path);
const gemmi::UnitCell &c = mtz.cell;
cell = UnitCell(c.a, c.b, c.c, c.alpha, c.beta, c.gamma);
const auto col = [&](const char *label) -> int {
const gemmi::Mtz::Column *x = mtz.column_with_label(label);
return x ? static_cast<int>(x->idx) : -1;
};
const int i_mean = col("IMEAN"), s_mean = col("SIGIMEAN"), ip = col("I(+)"), sip = col("SIGI(+)"),
im = col("I(-)"), sim = col("SIGI(-)"), f = col("F"), sf = col("SIGF"), fp = col("F(+)"),
sfp = col("SIGF(+)"), fm = col("F(-)"), sfm = col("SIGF(-)"), free = col("FreeR_flag");
const size_t nc = mtz.columns.size();
const auto get = [&](size_t row, int i) { return i < 0 ? NAN : mtz.data[row * nc + i]; };
std::vector<MergedReflection> out;
for (size_t row = 0; row < static_cast<size_t>(mtz.nreflections); row++) {
MergedReflection r;
r.h = static_cast<int>(get(row, 0));
r.k = static_cast<int>(get(row, 1));
r.l = static_cast<int>(get(row, 2));
r.I = get(row, i_mean);
r.sigma = get(row, s_mean);
r.I_plus = get(row, ip);
r.sigma_plus = get(row, sip);
r.I_minus = get(row, im);
r.sigma_minus = get(row, sim);
r.F = get(row, f);
r.sigmaF = get(row, sf);
r.F_plus = get(row, fp);
r.sigmaF_plus = get(row, sfp);
r.F_minus = get(row, fm);
r.sigmaF_minus = get(row, sfm);
r.rfree_flag = get(row, free) == 0.0f;
r.d = static_cast<float>(1.0 / std::sqrt(c.calculate_1_d2(gemmi::Miller{{r.h, r.k, r.l}})));
out.push_back(r);
}
return out;
}
} // namespace
int main(int argc, char **argv) {
if (argc != 4 && argc != 6) {
std::cerr << "Usage: jfjoch_mr_replay <model> <answer group> <p.mtz> [<twin group> <p_twin.mtz>]\n";
return 1;
}
Logger logger("jfjoch_mr_replay");
const gemmi::SpaceGroup *answer_sg = gemmi::find_spacegroup_by_name(argv[2]);
UnitCell cell{}, twin_cell{};
const std::vector<MergedReflection> merged = read_merged(argv[3], cell);
std::vector<MergedReflection> twin;
std::vector<MRMerge> merges{{"MERGE", &merged, cell, *answer_sg}};
if (argc == 6) {
twin = read_merged(argv[5], twin_cell);
merges.push_back({"TWIN_SUBGROUP_MERGE", &twin, twin_cell, *gemmi::find_spacegroup_by_name(argv[4])});
}
const size_t nthreads = std::max(1u, std::thread::hardware_concurrency());
MolecularReplacementResult mr = RunMolecularReplacement(argv[1], merges, logger, nthreads);
if (mr.chosen >= 0 && mr.decision == "ENANTIOMORPH")
CheckEnantiomorphHand(mr, merged, *answer_sg, logger, nthreads);
std::cout << "MODEL_PRIOR= " << (mr.ok ? "PERFORMED" : "NOT_PERFORMED") << "\n";
if (!mr.ok) {
std::cout << "MODEL_PRIOR_REASON= " << mr.failure_reason << "\n";
return 0;
}
std::cout << "MODEL_PRIOR_DECISION= " << mr.decision << "\n";
if (mr.chosen >= 0) {
std::cout << "MODEL_PRIOR_SPACE_GROUP= " << mr.candidates[mr.chosen].space_group.xhm() << "\n";
std::cout << "MODEL_PRIOR_COPIES= " << mr.candidates[mr.chosen].copies << "\n";
}
if (mr.hand_check.performed)
std::cout << "MODEL_PRIOR_HAND_CHECK= " << (mr.hand_check.adopted ? "ADOPTED" : "VETOED") << "\n";
std::cout << fmt::format("MODEL_PRIOR_SECONDS= {:.2f}\n", mr.t_model + mr.t_transform + mr.t_rotation
+ mr.t_translation + mr.t_rescore + mr.t_null
+ mr.hand_check.seconds);
for (const MRCandidate &c : mr.candidates) {
const auto r = [](double v) { return std::isfinite(v) ? fmt::format("{:.3f}", v) : std::string("-"); };
std::cout << fmt::format(
"# {:<19s} {:<11s} {:5.2f} {:6d} {:6d} {:5.1f} {:5.1f} {:4.1f}% {:>6s} {:>6s} {:>10s} {}\n",
c.merge == 0 ? "MERGE" : "TWIN_SUBGROUP_MERGE", c.space_group.xhm(), c.vm, c.copies, c.copies_likely,
c.rf_z, c.tf_z,
100 * c.clash_fraction, r(c.r_work), r(c.r_free),
std::isfinite(c.null_sigma) ? fmt::format("{:+.1f}", c.null_sigma) : std::string("-"), c.verdict);
}
return 0;
}