Molecular replacement: a model's chains searched by themselves; tNCS half-vector moves told apart by a paired test

A model of several chains is searched for as a whole and then, side by side, as one chain of each
molecule in it (chains with the same residue at every number they share are one molecule; the most
complete one is searched), with the PAE domains' machinery. The whole model's choice stands unless a
chain's search chose a placement better by the R-free margin (0.03), or the whole model chose none; the
chains that chose must agree on the space group. MR_SEARCH_MODEL names the winner ("chain A, one of 2
alike"). An experimental dimer from another crystal form, placed as one rigid unit where the target
crystal holds the two copies at another separation (tNCS), was placed 3.4 A off per chain (R-free
0.47); the chain search places both copies (R-free 0.28 before refinement, refined 0.27), and where the
dimer is right the whole model is kept (R-free 0.26 against the chain's 0.29). MR takes about twice
as long on such a model (0.55 -> 1.1 s).

With tNCS by half a lattice vector, the placement and its moves by (v + o)/2 are now told apart by a
paired per-reflection test of |F_obs - F_calc| on the weak reflections (3 standard errors, as the
equal-copies group comparison) instead of a 0.01 margin in R over them; UNDECIDED only where the best
is not ahead of every other by that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQsU5a8AJUdJPgWevAK
(cherry picked from commit 690ee4784ddd8e046fec19edb558f28808d0a825)
This commit is contained in:
leonarski_f committed 2026-11-09 18:12:08 +01:00
1 parent 344e569500
commit 3d905099ca
7 files changed
+287 -67

No files matched your search

+6 -2
View File
@@ -314,8 +314,12 @@ Translational NCS changes what the search can tell apart (Read, Adams & McCoy, 2
pseudo-translation, one copy beside a translated twin of itself fits the data as well in a group whose
screw axes the translation turns into the chosen group's, so such a choice is left undecided unless the
copies, carried into the other group and refined there, fit clearly worse; and where the vector is half
a lattice vector, the placement is compared with every move of itself the vector allows, on the weak
reflections it modulates.
a lattice vector, the placement is compared with every move of itself the vector allows, each refined,
on the weak reflections it modulates, and left undecided only where the best is not ahead of every other
by 3 standard errors of the paired per-reflection difference there. A model of several chains is searched
for as a whole and, side by side, as one chain of each molecule in it: the chains of an experimental model
from another crystal form need not sit in this crystal as they sat in that one, and the whole model's
choice gives way to a chain's where the chain's fits better by the R-free margin.
The choice between groups and merges is a hypothesis comparison like §14.5: the candidate the choice
falls on has to beat a null of the same model placed from random orientations, and lead the other
+1 -1
View File
@@ -313,7 +313,7 @@ serial stills; `fft` (GPU) / `fftw` (CPU) index **de novo** and suit strong rota
| `--free-flags <file>` | Take only the R-free test set of this MTZ or structure-factor mmCIF - e.g. the deposited reflections of the `--model` - for the validation, the refinement and the merged files; nothing else of the file is used, so unlike `-z` it fixes no space group, cell or frame. Taken on the model's axes where the merge matches the file's intensities (`R_FREE_FLAGS=`, see [Free set and model bias](RUGNUX_MODEL_AND_REFERENCE.md#free-set-and-model-bias)) |
| `--reference-column <label>` | Reference column to use, by MTZ label — an mmCIF under the labels `gemmi cif2mtz` gives it (default: auto — F-model, else IMEAN/I/…, else FP/FOBS/F) |
| `--model <file>` | Validate the merged intensities against this atomic model (PDB or mmCIF, gzipped or not; the format is taken from the file's content) — R-work / R-free and maps (see [Validating against a model](RUGNUX_ADVANCED.md#validating-against-a-model-rugnux---model)). An experimental model is placed as a rigid body, and by molecular replacement where the rigid body does not place it; a prediction (AlphaFold or ModelCIF) goes to molecular replacement straight away (`MODEL_PLACEMENT=`). It also settles the frame the reflections are written in: the enantiomorph, and the [indexing ambiguity](RUGNUX_ADVANCED.md#the-indexing-ambiguity) where no `-z` did; another space group or the twin hypothesis' subgroup it chooses is written beside the answer as `<prefix>_mr.mtz`/`.cif`. It does not change the processing: the merge is the same with and without it. A placed model is then [refined](RUGNUX_MODEL_AND_REFERENCE.md#refinement) against the merge it was placed on (`<prefix>_refined_model.cif`, `<prefix>_refined_maps.mtz`, `<prefix>_blobs.json`, `REFINE_` keys) - seconds on a GPU, typically 2-15 minutes on the CPU - which decides nothing about the processing |
| `--mr <file>` | As `--model`, but placed by molecular replacement straight away, without trying the rigid body first: as many copies as the cell holds, in every space group the merge cannot tell apart and in the twin hypothesis' subgroup. A prediction is trimmed to the residues of pLDDT 70 and above, and where the AlphaFold DB's predicted-aligned-error JSON sits beside it under its own name it is cut into rigid domains; any other PDB or mmCIF is taken as an experimental structure, its polymer only (no waters, ligands, hydrogens or alternative conformers). It may relabel the enantiomorph of the answer, and a different space group it chooses is written beside the answer as `<prefix>_mr.mtz`/`.cif` with the placed model `<prefix>_mr_model.pdb`. `--mode mx` only; not with `--model` |
| `--mr <file>` | As `--model`, but placed by molecular replacement straight away, without trying the rigid body first: as many copies as the cell holds, in every space group the merge cannot tell apart and in the twin hypothesis' subgroup. A prediction is trimmed to the residues of pLDDT 70 and above, and where the AlphaFold DB's predicted-aligned-error JSON sits beside it under its own name it is cut into rigid domains; any other PDB or mmCIF is taken as an experimental structure, its polymer only (no waters, ligands, hydrogens or alternative conformers). A model of several chains is searched for as a whole and, beside it, one chain of each molecule in it by itself, and the better placement kept (`MR_SEARCH_MODEL=`). It may relabel the enantiomorph of the answer, and a different space group it chooses is written beside the answer as `<prefix>_mr.mtz`/`.cif` with the placed model `<prefix>_mr_model.pdb`. `--mode mx` only; not with `--model` |
| `--no-refine` | Do not refine the placed model (for debugging): by default a model that `--model` or `--mr` places is refined |
| `--refine-solvent` | Also build the solvent of the refined model: waters added at difference-map peaks and removed where the maps no longer hold them, waters and ions relabelled, and each site audited (the `REFINE_SOLVENT_` keys and `<prefix>_solvent.json`). Off by default for now: the model is refined with its solvent as given, and the report says `REFINE_SOLVENT=NOT_PERFORMED`. Also with `--mode refine` |
| `--no-refine-solvent` | The default: no solvent build or audit (turns off an earlier `--refine-solvent`) |
+3 -1
View File
@@ -246,7 +246,9 @@ The safeguards behind 2 and 3:
- **Molecular replacement's own rules.** Its choice of group has its own null (`NULL_SIGMA`); a
placement must pack (`CLASH`, `MATTHEWS`); a choice that translational NCS could explain, or one
where the merge's own group was searched to fewer copies, is left `UNDECIDED`; a single domain of
a prediction cut by its PAE can only confirm the answer's group, never change it; in P1 the first
a prediction cut by its PAE can only confirm the answer's group, never change it; a model of several
chains is searched for as a whole and, beside it, one chain of each molecule by itself, and a chain
replaces the whole only where it fits better by 0.03 in R-free or the whole was placed nowhere; in P1 the first
copy's origin is fixed and it counts only on its rotation function. The details are in
[Molecular replacement](RUGNUX_REPORT.md#molecular-replacement).
- **The wrong-model test.** Rugnux's validation battery runs a set of datasets once without a model
+13 -4
View File
@@ -1320,8 +1320,16 @@ Where the AlphaFold DB's predicted-aligned-error file sits beside the model unde
into rigid domains by average linkage on the PAE (domains a mean 10 A apart, of 40 residues or more;
`MR_PAE_DOMAINS=`; a prediction only). Where the whole model is placed nowhere, each domain is searched by itself, side by
side; the domains placed have to agree, and a domain alone can only confirm the answer's group
(`AGREES`), never change it (`UNDECIDED` otherwise). `MR_SEARCH_MODEL=` names what the decision
was made with.
(`AGREES`), never change it (`UNDECIDED` otherwise).
A model of several chains is searched for as a whole and, beside it, one chain of each molecule in it
by itself (the most complete of the chains with the same residue at every number they share), since the
crystal may hold the molecules in another arrangement than the model has them in - another crystal form,
or a tNCS the model's crystal did not have. The chain's search places its own copies (`COPIES`) by the
same rules. The whole model's choice stands unless a chain's search chose a placement better by 0.03 in
R-free, or the whole model chose none; the chains that chose have to agree on the space group.
`MR_SEARCH_MODEL=` names what the decision was made with: `the whole model`, a chain (`chain A`, or
`chain A, one of 2 alike`) or a PAE domain; the table is that search's.
The table has one row per group and merge. A row is `PLACED` where one of its copies stands out, 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
@@ -1342,8 +1350,9 @@ in both. The group the merge was made in, searched to fewer copies than the chos
the tNCS vector v is half a lattice vector, the reflections with h.v half-integral are weak, and a
placement moved by (v + o) / 2 (o an origin shift of the group) fits the strong ones exactly as well:
the candidate the choice falls on is compared with every such move of itself, refined and packing, by
R-work and R-free together over the weak reflections alone; a move better by 0.01 there is taken
instead, and where the two best are within 0.01 the choice is `UNDECIDED`. The rotation and translation functions, the rigid
R-work and R-free together over the weak reflections alone: the best there is taken where its
|F_obs - F_calc| on those reflections is lower than every other's by 3 standard errors of the paired
per-reflection difference, and the choice is `UNDECIDED` only where it is not. 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 (`MR_SEARCH_DEVICE=`). `MR_DECISION=` is then:
@@ -114,18 +114,19 @@ constexpr double NULL_SIGMA_MIN = 4.51;
constexpr double ELLG_MIN = 10.0;
// tNCS by half a lattice vector v: copies v apart make the reflections with h.v half-integral weak, and a
// placement moved by (v + o) / 2, o an origin shift of the group, fits the strong ones as well as the right one
// does; only the weak ones tell them apart. A placement is compared with every such move of itself on those
// reflections (R_tncs_weak), and one of them that fits them better by TNCS_SHIFT_MARGIN is taken instead; where the
// two best are closer than that, the placement is not decided on. A vector is taken as half-integral within
// TNCS_HALF_TOLERANCE.
constexpr double TNCS_SHIFT_MARGIN = 0.01;
// does; only the weak ones tell them apart. A placement is compared with every such move of itself, each refined,
// on those reflections (R_tncs_weak), and the best is taken where it fits them better than every other by
// PAIRED_Z_MIN standard errors of the paired difference (paired_z); where it does not, the placement is not
// decided on. A vector is taken as half-integral within TNCS_HALF_TOLERANCE.
// The modulation of the intensities by tNCS following Read, Adams & McCoy (2013) Acta Cryst. D69, 176-183
constexpr double TNCS_HALF_TOLERANCE = 0.05;
// The chosen copies carried into a group with tNCS rule it out only where they fit the data themselves: a
// placement at R-free CARRY_R_FREE_MAX or above, little better than a model at random, is not the crystal, and
// how it reads in another group says nothing about that group.
constexpr double CARRY_R_FREE_MAX = 0.50;
constexpr double TNCS_HALF_TOLERANCE = 0.05;
// Two placements with as many copies within R_FREE_MARGIN of each other: the one that fits every reflection
// better by this many standard errors of the paired difference (paired_r_z) is taken.
// better by this many standard errors of the paired difference (paired_r_z) is taken; the same for a placement
// and its moves by half a tNCS vector, on the reflections the vector weakens.
constexpr double PAIRED_Z_MIN = 3.0;
double seconds_since(std::chrono::steady_clock::time_point t0) {
@@ -263,7 +264,8 @@ PlacementScore r_factors(const gemmi::AsuData<std::complex<float>> &fmodel, cons
if (it == obs.end())
continue;
const double fo = it->second.f, fc = std::abs(hv.value);
if (residuals != nullptr)
// With a tNCS vector, of the reflections it weakens only.
if (residuals != nullptr && (tncs == nullptr || it->second.weak))
(*residuals)[hkl_key(hv.hkl)] = {fo, std::fabs(fo - fc)};
if (it->second.free) {
num_f += std::fabs(fo - fc);
@@ -696,14 +698,10 @@ PlacementScore carry_copies(const gemmi::Structure &assembly, int copies, const
return score_placement(AssembleCopies(body), sg, merged, d_min, nthreads, scorers);
}
// Two placements of one merge compared on every reflection to d_min, work and free together: how far b's
// R lies below a's, in standard errors of that difference, the reflections paired.
double paired_r_z(const gemmi::Structure &a, const gemmi::SpaceGroup &a_sg, const gemmi::Structure &b,
const gemmi::SpaceGroup &b_sg, const std::vector<MergedReflection> &merged, double d_min,
size_t nthreads, ScoreEngines *scorers) {
std::unordered_map<long, std::pair<double, double>> ra, rb;
score_placement(a, a_sg, merged, d_min, nthreads, scorers, nullptr, nullptr, nullptr, &ra);
score_placement(b, b_sg, merged, d_min, nthreads, scorers, nullptr, nullptr, nullptr, &rb);
// How far the residuals |F_obs - F_calc| of b lie below those of a (score_placement's `residuals`), over the
// reflections both have, in standard errors of their paired difference.
double paired_z(const std::unordered_map<long, std::pair<double, double>> &ra,
const std::unordered_map<long, std::pair<double, double>> &rb) {
double sum_fo = 0, sum = 0, sum2 = 0;
size_t n = 0;
for (const auto &[key, x] : ra) {
@@ -723,6 +721,17 @@ double paired_r_z(const gemmi::Structure &a, const gemmi::SpaceGroup &a_sg, cons
return sum / std::sqrt(var * static_cast<double>(n));
}
// Two placements of one merge compared on every reflection to d_min, work and free together: how far b's
// R lies below a's, in standard errors of that difference, the reflections paired.
double paired_r_z(const gemmi::Structure &a, const gemmi::SpaceGroup &a_sg, const gemmi::Structure &b,
const gemmi::SpaceGroup &b_sg, const std::vector<MergedReflection> &merged, double d_min,
size_t nthreads, ScoreEngines *scorers) {
std::unordered_map<long, std::pair<double, double>> ra, rb;
score_placement(a, a_sg, merged, d_min, nthreads, scorers, nullptr, nullptr, nullptr, &ra);
score_placement(b, b_sg, merged, d_min, nthreads, scorers, nullptr, nullptr, nullptr, &rb);
return paired_z(ra, rb);
}
// The fractional position of the model's centroid.
gemmi::Fractional centroid(const gemmi::Structure &st) {
const std::vector<gemmi::Position> pos = ModelPositions(st.models[0]);
@@ -1085,6 +1094,78 @@ SearchModel DomainModel(const SearchModel &model, const std::vector<int> &residu
return out;
}
// --- the chains of a model of several ---
namespace {
// Whether two chains are copies of one molecule: the same residue at every number both have, and those at least
// half of the shorter chain's.
bool same_molecule(const gemmi::Chain &a, const gemmi::Chain &b) {
std::map<std::string, std::string> names;
for (const gemmi::Residue &r : a.residues)
names[r.seqid.str()] = r.name;
size_t shared = 0;
for (const gemmi::Residue &r : b.residues) {
const auto it = names.find(r.seqid.str());
if (it == names.end())
continue;
if (it->second != r.name)
return false;
shared++;
}
return 2 * shared >= std::min(a.residues.size(), b.residues.size());
}
} // namespace
std::vector<SearchModel> ChainModels(const SearchModel &model) {
const std::vector<gemmi::Chain> &chains = model.st.models[0].chains;
if (chains.size() < 2)
return {};
// The chains grouped into molecules, each group in model order.
std::vector<std::vector<size_t>> molecules;
for (size_t c = 0; c < chains.size(); c++) {
auto it = std::find_if(molecules.begin(), molecules.end(), [&](const std::vector<size_t> &m) {
return same_molecule(chains[m[0]], chains[c]);
});
if (it == molecules.end())
molecules.push_back({c});
else
it->push_back(c);
}
std::vector<SearchModel> out;
for (const std::vector<size_t> &m : molecules) {
// The most complete chain of the molecule.
size_t keep = m[0];
for (size_t c : m)
if (chains[c].residues.size() > chains[keep].residues.size())
keep = c;
SearchModel one = model;
one.residue_index.clear();
one.residues_kept = 0;
std::vector<gemmi::Chain> kept;
size_t i = 0;
for (size_t c = 0; c < chains.size(); c++) {
const size_t n = chains[c].residues.size();
if (c == keep) {
kept.push_back(chains[c]);
one.residues_kept = static_cast<int>(n);
if (model.predicted)
one.residue_index.assign(model.residue_index.begin() + i, model.residue_index.begin() + i + n);
}
i += n;
}
one.st.models[0].chains = std::move(kept);
finish_search_model(one);
if (!model.predicted)
one.error_A = model.error_A; // not from the B-factors (PrepareSearchModel)
one.description = m.size() > 1 ? fmt::format("chain {}, one of {} alike", chains[keep].name, m.size())
: fmt::format("chain {}", chains[keep].name);
out.push_back(std::move(one));
}
return out;
}
// --- rotations ---
std::vector<gemmi::Mat33> UniformRotations(double step_deg) {
@@ -3310,8 +3391,8 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
// by side, each taken where the choice comes to it.
std::vector<double> nulls(result.candidates.size(), NAN);
bool first = true;
// With tNCS by half a lattice vector, candidate i against its moves by half that vector (TNCS_SHIFT_MARGIN):
// the better taken into it, and whether the two best are too close to tell apart.
// With tNCS by half a lattice vector, candidate i against its moves by half that vector (TNCS_HALF_TOLERANCE):
// the best taken into it, and whether it cannot be told from the others.
// The tNCS vector is the answer's merge's, in its cell.
const std::optional<gemmi::Fractional> half = merge == 0 ? half_tncs_vector(result.tncs) : std::nullopt;
std::vector<char> shift_checked(result.candidates.size(), 0);
@@ -3325,10 +3406,13 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
std::shared_ptr<gemmi::Structure> placed;
PlacementScore score;
double clash = 0;
std::unordered_map<long, std::pair<double, double>> residuals; // on the weak reflections
};
std::vector<Option> options{{gemmi::Fractional(0, 0, 0), c.placed,
score_placement(*c.placed, c.space_group, merged, d_min, nthreads, &scorers,
nullptr, nullptr, &v), c.clash_fraction}};
std::vector<Option> options(1);
options[0].placed = c.placed;
options[0].clash = c.clash_fraction;
options[0].score = score_placement(*c.placed, c.space_group, merged, d_min, nthreads, &scorers, nullptr,
nullptr, &v, &options[0].residuals);
for (int o = 0; o < 8; o++) {
const gemmi::Fractional shift((v.x + 0.5 * (o & 1)) / 2, (v.y + 0.5 * ((o >> 1) & 1)) / 2,
(v.z + 0.5 * ((o >> 2) & 1)) / 2);
@@ -3344,12 +3428,12 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
for (gemmi::Position &p : pos)
p = gemmi::Position(p + by);
SetModelPositions(moved->models[0], pos);
Option opt{shift, moved, {}, ClashFraction(*moved, c.copies)};
Option opt{shift, moved, {}, ClashFraction(*moved, c.copies), {}};
if (opt.clash <= 2 * CLASH_MAX) {
refine_rigid_body(*moved, c.space_group, merged, d_min, rigid_body_pool(merge), nthreads);
opt.clash = ClashFraction(*moved, c.copies);
opt.score = score_placement(*moved, c.space_group, merged, d_min, nthreads, &scorers, nullptr,
nullptr, &v);
nullptr, &v, &opt.residuals);
}
logger.Info("Molecular replacement: {} moved by {:.2f} {:.2f} {:.2f}: clash {:.1f}%, R-free {:.3f}, R on the "
"reflections tNCS weakens {:.3f} against {:.3f}", c.space_group.xhm(), shift.x, shift.y,
@@ -3364,12 +3448,26 @@ MolecularReplacementResult search_model(const SearchModel &model, const std::vec
std::stable_sort(packing.begin(), packing.end(), [](const Option *a, const Option *b) {
return a->score.r_tncs_weak < b->score.r_tncs_weak;
});
if (packing.empty() || packing[0] == &options[0])
return packing.size() > 1 && packing[1]->score.r_tncs_weak - packing[0]->score.r_tncs_weak < TNCS_SHIFT_MARGIN;
if (packing.empty())
return false;
// The best on the weak reflections is taken where it fits them better than every other that packs by
// PAIRED_Z_MIN standard errors of the paired difference: the moves differ on those reflections alone, and
// with thousands of them a difference too small to read in R is still measured.
const Option &b = *packing[0];
if (options[0].score.r_tncs_weak - b.score.r_tncs_weak < TNCS_SHIFT_MARGIN
|| (packing.size() > 1 && packing[1]->score.r_tncs_weak - b.score.r_tncs_weak < TNCS_SHIFT_MARGIN))
double z = INFINITY;
for (size_t k = 1; k < packing.size(); k++)
z = std::fmin(z, paired_z(packing[k]->residuals, b.residuals));
if (packing.size() > 1)
logger.Info("Molecular replacement: {} on the reflections tNCS weakens: {} fits them better than the {} "
"other(s) that pack by {:.1f} standard errors or more", c.space_group.xhm(),
&b == &options[0] ? std::string("the placement")
: fmt::format("the move by {:.2f} {:.2f} {:.2f}", b.shift.x, b.shift.y,
b.shift.z),
packing.size() - 1, z);
if (!(z >= PAIRED_Z_MIN))
return true;
if (&b == &options[0])
return false;
logger.Info("Molecular replacement: {} taken moved by {:.2f} {:.2f} {:.2f}", c.space_group.xhm(), b.shift.x,
b.shift.y, b.shift.z);
c.placed = b.placed;
@@ -3807,33 +3905,94 @@ MolecularReplacementResult run_molecular_replacement(const RugnuxModel &input, c
result.model_name = input.name;
result.pae_domains = static_cast<int>(domains.size());
result.t_model += t_model;
// Search models besides the whole one, each on a thread of its own, its log kept and replayed in order, and with
// an equal share of the budget on every card.
const auto side_by_side = [&](const std::vector<SearchModel> &parts) {
std::vector<size_t> other_shares;
for (size_t b : other_budgets)
other_shares.push_back(b / parts.size());
std::vector<Logger> logs;
for (size_t i = 0; i < parts.size(); i++)
logs.push_back(Logger::Buffered());
std::vector<std::future<MolecularReplacementResult>> running;
for (size_t i = 0; i < parts.size(); i++)
running.push_back(std::async(std::launch::async, [&, i] {
return search_model(parts[i], merges, logs[i], nthreads, gpu_budget / parts.size(), other_shares);
}));
std::vector<MolecularReplacementResult> out;
for (size_t i = 0; i < parts.size(); i++) {
out.push_back(running[i].get());
logs[i].ReplayInto(logger);
}
return out;
};
// The search model the decision was made with taken over from another search, the whole model's times kept.
const auto take = [&](MolecularReplacementResult chosen, double seconds) {
chosen.model_name = input.name;
chosen.pae_domains = result.pae_domains;
chosen.t_model = result.t_model;
chosen.t_transform = result.t_transform;
chosen.t_rotation = result.t_rotation;
chosen.t_translation = result.t_translation;
chosen.t_rescore = result.t_rescore;
chosen.t_null = result.t_null;
chosen.t_domains = result.t_domains + seconds;
result = std::move(chosen);
};
// A model of several chains: one chain of each molecule in it searched by itself as well, beside the whole - the
// crystal may hold the molecules in another arrangement than the model has them in (another crystal form, a tNCS
// that is not the model's). The whole model's choice stands unless a chain's search chose a placement that fits
// better by R_FREE_MARGIN in R-free, or the whole model chose none; the chains that chose have to agree on the
// space group, and the one with the lowest R-free is taken.
const std::vector<SearchModel> chains = result.ok ? ChainModels(model) : std::vector<SearchModel>();
if (!chains.empty()) {
const auto t_chains = std::chrono::steady_clock::now();
logger.Info("Molecular replacement: {} chains in the model, {} molecule(s): each searched by itself as well",
model.st.models[0].chains.size(), chains.size());
std::vector<MolecularReplacementResult> found = side_by_side(chains);
const double chain_seconds = seconds_since(t_chains);
const auto summary = [](const MolecularReplacementResult &r) {
if (r.chosen < 0)
return r.ok ? r.decision : r.failure_reason;
const MRCandidate &c = r.candidates[r.chosen];
return fmt::format("{} {}, {} {}, R-free {:.3f}", r.decision, c.space_group.xhm(), c.copies,
c.copies == 1 ? "copy" : "copies", c.r_free);
};
logger.Info("Molecular replacement: {}: {}", result.search_model, summary(result));
int best = -1;
bool agree = true;
for (size_t i = 0; i < found.size(); i++) {
logger.Info("Molecular replacement: {}: {}", found[i].search_model, summary(found[i]));
if (!found[i].ok || found[i].chosen < 0)
continue;
const MRCandidate &c = found[i].candidates[found[i].chosen];
if (best >= 0 && c.space_group.xhm() != found[best].candidates[found[best].chosen].space_group.xhm())
agree = false;
if (best < 0 || c.r_free < found[best].candidates[found[best].chosen].r_free)
best = static_cast<int>(i);
}
const double whole_r_free = result.chosen >= 0 ? result.candidates[result.chosen].r_free : INFINITY;
if (best >= 0 && agree && found[best].candidates[found[best].chosen].r_free <= whole_r_free - R_FREE_MARGIN) {
take(std::move(found[best]), chain_seconds);
} else {
result.t_domains += chain_seconds;
}
logger.Info("Molecular replacement: decided with {}{}", result.search_model,
best >= 0 && !agree ? " (the chains that chose disagree on the space group)" : "");
}
// Where the whole model is placed nowhere and its prediction falls apart into domains whose relative
// placement it is not confident of, each domain is searched by itself; the domains that are placed have
// to agree on the space group, and the one with the lowest R-free is taken where that is the answer's
// own.
if (result.ok && result.chosen < 0 && result.decision.starts_with("NOT_PLACED") && domains.size() >= 2) {
const auto t_domains = std::chrono::steady_clock::now();
// Each domain on a thread of its own, its log kept and replayed in order, and with an equal share of the
// budget on every card.
std::vector<size_t> other_shares;
for (size_t b : other_budgets)
other_shares.push_back(b / domains.size());
std::vector<Logger> logs;
for (size_t i = 0; i < domains.size(); i++)
logs.push_back(Logger::Buffered());
std::vector<std::future<MolecularReplacementResult>> running;
for (size_t i = 0; i < domains.size(); i++)
running.push_back(std::async(std::launch::async, [&, i] {
return search_model(DomainModel(model, domains[i]), merges, logs[i], nthreads,
gpu_budget / domains.size(), other_shares);
}));
std::vector<SearchModel> parts;
for (const std::vector<int> &d : domains)
parts.push_back(DomainModel(model, d));
std::vector<MolecularReplacementResult> found;
for (size_t i = 0; i < domains.size(); i++) {
MolecularReplacementResult r = running[i].get();
logs[i].ReplayInto(logger);
for (MolecularReplacementResult &r : side_by_side(parts))
if (r.ok && (r.chosen >= 0 || r.decision.starts_with("UNDECIDED")))
found.push_back(std::move(r));
}
const double domain_seconds = seconds_since(t_domains);
int best = -1;
bool agree = true;
@@ -3853,19 +4012,9 @@ MolecularReplacementResult run_molecular_replacement(const RugnuxModel &input, c
result.decision = fmt::format("UNDECIDED: a PAE domain alone chooses {}",
found[best].candidates[found[best].chosen].space_group.xhm());
} else if (best >= 0 && agree) {
MolecularReplacementResult chosen = std::move(found[best]);
chosen.model_name = input.name;
chosen.pae_domains = result.pae_domains;
chosen.t_model = result.t_model;
chosen.t_transform = result.t_transform;
chosen.t_rotation = result.t_rotation;
chosen.t_translation = result.t_translation;
chosen.t_rescore = result.t_rescore;
chosen.t_null = result.t_null;
result = std::move(chosen);
result.t_domains = domain_seconds;
take(std::move(found[best]), domain_seconds);
} else {
result.t_domains = domain_seconds;
result.t_domains += domain_seconds;
if (!found.empty())
result.decision = "UNDECIDED: the PAE domains placed disagree";
}
@@ -67,6 +67,11 @@ std::vector<std::vector<int>> PaeDomains(const std::vector<std::vector<double>>
// The search model of one domain: the residues of `model` at those positions, recentred.
SearchModel DomainModel(const SearchModel &model, const std::vector<int> &residues);
// The search models of a model of several chains: one chain of each molecule in it, the most complete, recentred
// ("chain A"). Two chains are one molecule where they have the same residue at every number both have, and those
// are at least half of the shorter chain's. Empty for a model of one chain.
std::vector<SearchModel> ChainModels(const SearchModel &model);
// Rotations covering SO(3) with neighbours about `step_deg` apart: the identity, then shells of
// rotation angle kappa = step, 2 step, ... 180 deg, each with its axes on a golden-spiral lattice of
// the sphere as dense as 2 sin(kappa/2) - the distance a change of axis moves a rotation - needs.
@@ -348,7 +353,7 @@ struct MolecularReplacementResult {
std::string model_name; // RugnuxModel::name
int residues_read = 0, residues_kept = 0;
double mass_da = 0;
std::string search_model; // the whole model, or the PAE domain the decision was made with
std::string search_model; // the whole model, or the chain or PAE domain the decision was made with
bool predicted_model = true; // SearchModel::predicted
int pae_domains = 0; // the rigid domains of the prediction's PAE; 0 without a PAE file
double d_min_search = 0, d_max_search = 0;
@@ -376,7 +381,7 @@ struct MolecularReplacementResult {
} hand_check;
// Seconds per step.
double t_model = 0, t_transform = 0, t_rotation = 0, t_translation = 0, t_rescore = 0, t_null = 0;
double t_domains = 0; // the PAE domains' own searches, wall clock (they run side by side)
double t_domains = 0; // the chains' and the PAE domains' own searches, wall clock (each kind side by side)
// Searched on another merge of the crystal and carried over to this one (CarryMolecularReplacement): the
// chosen placement's R-free on the merge it was found on, NaN where this merge was searched itself.
double r_free_searched = NAN;
+51
View File
@@ -526,6 +526,57 @@ TEST_CASE("MolecularReplacement_PaeDomains", "[MolecularReplacement]") {
CHECK(d.description == "PAE domain of 50 residues (1-50)");
}
// A model of three chains: A and B one molecule (B one residue shorter, at the start), C another. One chain of each
// molecule, the most complete, recentred and keeping the experimental structure's coordinate error.
TEST_CASE("MolecularReplacement_ChainModels", "[MolecularReplacement]") {
const auto chain = [](const std::string &name, int first, int last, const std::vector<std::string> &names,
double x0) {
gemmi::Chain ch(name);
for (int i = first; i <= last; i++) {
gemmi::Residue r;
r.name = names[i % names.size()];
r.seqid = gemmi::SeqId(i, ' ');
gemmi::Atom a;
a.name = "CA";
a.element = gemmi::El::C;
a.pos = gemmi::Position(x0 + 3.8 * i, 0.0, 0.0);
a.b_iso = 30.0f;
a.occ = 1.0f;
r.atoms.push_back(a);
ch.residues.push_back(r);
}
return ch;
};
SearchModel m;
m.predicted = false;
m.error_A = 0.67;
gemmi::Model model(1);
model.chains.push_back(chain("A", 1, 30, {"ALA", "GLY", "LYS"}, 0.0));
model.chains.push_back(chain("B", 2, 30, {"ALA", "GLY", "LYS"}, 200.0));
model.chains.push_back(chain("C", 1, 20, {"SER"}, 400.0));
m.st.models.push_back(model);
m.residues_read = m.residues_kept = 79;
const std::vector<SearchModel> parts = ChainModels(m);
REQUIRE(parts.size() == 2);
CHECK(parts[0].description == "chain A, one of 2 alike");
CHECK(parts[0].residues_kept == 30);
CHECK(parts[0].residues_read == 79);
CHECK(parts[0].mass_da == Catch::Approx(30 * 110.0));
CHECK(parts[0].error_A == Catch::Approx(0.67));
CHECK(parts[0].st.models[0].chains.size() == 1);
CHECK(parts[0].st.models[0].chains[0].residues.front().atoms[0].pos.x == Catch::Approx(-3.8 * 14.5));
CHECK(parts[1].description == "chain C");
CHECK(parts[1].residues_kept == 20);
// The same residue numbers with another residue at one of them: another molecule.
m.st.models[0].chains[1].residues[5].name = "TRP";
CHECK(ChainModels(m).size() == 3);
// One chain: nothing besides the whole model.
m.st.models[0].chains.resize(1);
CHECK(ChainModels(m).empty());
}
// 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.