Files
Jungfraujoch/gemmi_gph/polyheur.cpp
T
leonarski_f dd0bffb283
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 11m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 10m27s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 10m54s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 9m25s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 10m5s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 11m33s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 11m19s
Build Packages / build:rpm (rocky8) (push) Successful in 12m23s
Build Packages / build:rpm (rocky9) (push) Successful in 13m21s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m30s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 11m55s
Build Packages / DIALS test (push) Successful in 13m42s
Build Packages / XDS test (durin plugin) (push) Successful in 9m26s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m41s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m12s
Build Packages / Generate python client (push) Successful in 19s
Build Packages / Build documentation (push) Successful in 52s
Build Packages / Create release (push) Skipped
Build Packages / build:viewer-tgz:cpu (push) Successful in 5m29s
Build Packages / build:viewer-tgz:cuda (push) Successful in 6m12s
Build Packages / build:windows:cuda (push) Successful in 18m36s
v1.0.0-rc.159 (#69)
This is an UNSTABLE release. It includes many experimental features, as well as many AI generated fixes. We recommend using rc.152 for production use.

* rugnux: Add `--model model.pdb` - score the merged data against an atomic model and compute initial maps. It reports R-work/R-free (scaling the model to the observed amplitudes with an overall scale, an anisotropic B and a flat bulk solvent - the standard few-parameter model, so a batch of maps stays directly comparable) and writes 2Fo-Fc / Fo-Fc electron-density maps (CCP4) plus a map-coefficient MTZ. The structure itself is not refined; the model is only re-fractionalised into the data cell.
* rugnux: The merged reflection output now carries French-Wilson amplitudes (|F| and its sigma) next to the intensities - MTZ `F`/`SIGF`, mmCIF `_refln.F_meas_au`, and the text HKL - computed with the correct centric/acentric Wilson prior and epsilon multiplicity, so a downstream program (e.g. phenix.refine) can refine against amplitudes. The intensity columns are unchanged.
* rugnux: R-free test-set flags are now assigned deterministically and consistently across symmetry - a Bijvoet pair I(+)/I(-) is never split between the work and free sets, and the assignment is a reproducible per-hkl hash that depends only on the reflection index, so every dataset of one crystal form gets the same ~5% free set (what a multi-dataset campaign such as PanDDA needs). On small data the fraction is floored so the test set stays large enough for a stable R-free (~500 reflections, capped at 10%); it stays flat at 5% on ordinary data. When a reference MTZ carries a `FreeR_flag` column its test set is imported instead, letting a whole campaign inherit one shared free set.
* rugnux: A reference MTZ (`--reference-mtz`) can now fix the space group and cell for rotation data too (previously rejected), without being used to scale - the rotation merge stays self-consistent. When the crystal has an indexing (merohedral) ambiguity - a lattice symmetry higher than its Laue symmetry, e.g. P3/P4/P6/C2 - the reference also resolves it: each candidate reindexing (identity plus the twin-law cosets of the metric symmetry) is scored by its intensity correlation against the reference and the data are re-merged in the best-correlating one. This is a metric-preserving relabelling of hkl (the cell is unchanged) and a no-op for a holohedral crystal such as lysozyme.
* rugnux: `--model` validation now aligns the data to the model before scoring - the observed reflections are reindexed into the model's enantiomorph when the two differ only by hand (indistinguishable from merged intensities). A merohedral indexing ambiguity is resolved against the reference MTZ when one is given (so a whole campaign shares one indexing convention); only with a model and no reference does validation fall back to fitting each candidate reindexing and keeping the lowest R-free.
* rugnux: De-novo symmetry - recover a genuine high-symmetry group whose data are imperfectly scaled. Such a merge's within-orbit chi² lands just past the self-consistency bound (each real symmetry step adds a little systematic scatter), right where a merohedral twin also lands, so the chi² ratio alone cannot separate them. The candidate is now rescued when the extra intensity-proportional systematic error it invokes stays small relative to the confirmed subgroup - a genuine symmetry step gains multiplicity without inflating the merge error model's b, whereas a twin forces non-equivalent reflections together and b balloons. Fixes cubic insulin (I23 instead of I222) with no change to any other crystal in the test battery, including the twins that must stay in their lower symmetry.
* Docs: Document the French-Wilson amplitude estimation, R-free flagging, reference-based space-group/ambiguity resolution, and model-based validation/maps in CPU_DATA_ANALYSIS.md.
* Frontend: The status-bar pill now shows a progress bar during detector calibration (previously only during measurement), and the calibration state and its button are labelled "Calibration"/"CALIBRATE" (the internal `Pedestal` state name is unchanged for back-compatibility).Reviewed-on: #69

Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-07-13 13:54:03 +02:00

464 lines
16 KiB
C++

// Copyright 2017-2022 Global Phasing Ltd.
#include <gemmi/polyheur.hpp>
#include <gemmi/resinfo.hpp> // for find_tabulated_residue
#include <gemmi/modify.hpp> // for rename_residues
#include <gemmi/atox.hpp> // for no_sign_atoi
namespace gemmi {
PolymerType check_polymer_type(const ConstResidueSpan& span, bool ignore_entity_type) {
if (span.empty())
return PolymerType::Unknown;
size_t counts[(int)ResidueKind::ELS+1] = {0};
size_t aa = 0;
size_t na = 0;
size_t total = 0;
bool has_atom_record = false;
for (const Residue& r : span)
if (ignore_entity_type ||
r.entity_type == EntityType::Unknown ||
r.entity_type == EntityType::Polymer) {
if (r.het_flag == 'A')
has_atom_record = true;
const ResidueInfo& info = find_tabulated_residue(r.name);
if (info.found()) {
// Exclude water and ions - it can make difference
// if this function is called for the whole chain.
// Components w/o hydrogens are often ions and always non-polymers
// (and almost never are in a polymer - except PO4, PO2 and AZI (N3)
// which in a few PDB entries are included in polymers - but it
// doesn't matter here).
if (info.kind == ResidueKind::HOH || info.hydrogen_count == 0)
continue;
if (info.is_peptide_linking())
++aa;
if (info.is_na_linking())
++na;
counts[(int)info.kind]++;
} else if (r.get_ca()) {
++aa;
} else if (r.get_p()) {
++na;
}
++total;
}
if (total == 0)
return PolymerType::Unknown;
// One residue is not a polymer, but it may happen that only a single residue
// of a chain is modelled. OTOH, a single non-standard residue is usually
// a ligand.
if (total == 1 && !has_atom_record)
return PolymerType::Unknown;
// ATOM records suggest a polymer, so weaken the condition for AA/NA polymers.
size_t bonus = has_atom_record ? 1 : 0;
if (2 * aa + bonus > total)
return counts[(int)ResidueKind::AA] >= counts[(int)ResidueKind::AAD]
? PolymerType::PeptideL : PolymerType::PeptideD;
if (2 * na + bonus > total) {
if (counts[(int)ResidueKind::DNA] == 0)
return PolymerType::Rna;
if (counts[(int)ResidueKind::RNA] == 0)
return PolymerType::Dna;
return PolymerType::DnaRnaHybrid;
}
return PolymerType::Unknown;
}
std::string make_one_letter_sequence(const ConstResidueSpan& polymer) {
std::string seq;
const Residue* prev = nullptr;
PolymerType ptype = check_polymer_type(polymer);
for (const Residue& residue : polymer.first_conformer()) {
ResidueInfo info = find_tabulated_residue(residue.name);
if (prev && !are_connected2(*prev, residue, ptype))
seq += '-';
seq += (info.one_letter_code != ' ' ? info.one_letter_code : 'X');
prev = &residue;
}
return seq;
}
static std::vector<Residue>::iterator infer_polymer_end(Chain& chain, PolymerType ptype) {
auto b = chain.residues.begin();
auto e = chain.residues.end();
// find the last residue w/ record type ATOM
auto last_a = e;
for (auto it = e; it-- != b;)
if (it->het_flag == 'A')
last_a = it;
for (auto it = b; it != e; ++it) {
ResidueInfo info = find_tabulated_residue(it->name);
if (info.found()) {
if (info.is_water()) {
e = it;
break;
}
bool maybe_linking = (is_polypeptide(ptype) && info.is_peptide_linking())
|| (is_polynucleotide(ptype) && info.is_na_linking());
// The first residue could be non-polymer.
if (!maybe_linking && b != chain.residues.begin()) {
e = it;
break;
}
// If a standard residue is HETATM, it should be in the buffer. Although
// it could happen that a non-standard residue was mutated to a standard
// one, but the record type was not updated, so it's not 100% reliable.
if (info.is_standard() && it->het_flag == 'H' && it > last_a) {
e = it;
break;
}
if (maybe_linking && info.is_standard())
b = it;
}
}
if (b == e || b + 1 == e)
return e;
// Ligands are often separated by a significant gap in the sequence ID numeration.
// But such gap can also mean that part of the chain is not modelled.
auto last = std::min(e, chain.residues.end() - 1);
for (auto it = b; it < last; ++it) {
int gap = *(it+1)->seqid.num - *it->seqid.num;
// The gap should be non-negative, but you can find exceptions in the PDB.
if (gap < -1 || gap > 10)
return it+1;
// Usually polymers are longer than 1-2 residues, although there are
// exceptions (example: 1-residue polymers in 5N22), so we can't be sure.
// OTOH a protein can be capped with monomers different from amino-acid
// and are_connected2() may return false negative. So if there is no gap
// in numbering, it seems better to assume the polymer didn't end yet.
if (gap == 1 && it - chain.residues.begin() < 2)
continue;
if (!are_connected2(*it, *(it+1), ptype))
return it+1;
}
return e;
}
void add_entity_types(Chain& chain, bool overwrite) {
if (!overwrite &&
std::all_of(chain.residues.begin(), chain.residues.end(),
[](const Residue& r) { return r.entity_type != EntityType::Unknown; }))
return;
PolymerType ptype = check_polymer_type(chain.whole(), /*ignore_entity_type=*/overwrite);
auto it = chain.residues.begin();
if (ptype != PolymerType::Unknown) {
auto polymer_end = infer_polymer_end(chain, ptype);
for (; it != polymer_end; ++it)
if (overwrite || it->entity_type == EntityType::Unknown)
it->entity_type = EntityType::Polymer;
}
for (; it != chain.residues.end(); ++it)
if (overwrite || it->entity_type == EntityType::Unknown)
it->entity_type = it->is_water() ? EntityType::Water
: EntityType::NonPolymer;
}
void add_entity_types(Structure& st, bool overwrite) {
for (Model& model : st.models)
for (Chain& chain : model.chains)
add_entity_types(chain, overwrite);
}
void remove_entity_types(Structure& st) {
for (Model& model : st.models)
for (Chain& chain : model.chains)
for (Residue& res : chain.residues)
res.entity_type = EntityType::Unknown;
}
void add_entity_ids(Structure& st, bool overwrite) {
for (Model& model : st.models)
for (Chain& chain : model.chains)
for (ResidueSpan& sub : chain.subchains()) {
if (Entity* ent = st.get_entity_of(sub)) {
for (Residue& res : sub)
if (overwrite || res.entity_id.empty())
res.entity_id = ent->name;
} else if (overwrite) {
for (Residue& res : sub)
res.entity_id.clear();
}
}
}
void assign_subchain_names(Chain& chain, int& nonpolymer_counter) {
for (Residue& res : chain.residues) {
res.subchain = chain.name;
// We'd use '-' as a separator (A-p or B-4 is more clear), but although
// such names are valid in mmCIF, OneDep refuses to accept them.
res.subchain += "x";
switch (res.entity_type) {
case EntityType::Polymer:
res.subchain += 'p';
break;
case EntityType::NonPolymer:
++nonpolymer_counter;
// to keep the name short use base36 for 2+ digit numbers:
// 1, 2, ..., 9, 00, 01, ..., 09, 0A, 0B, ..., 0Z, 10, ...
if (nonpolymer_counter < 10) {
res.subchain += char('0' + nonpolymer_counter);
} else {
const char base36[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
int n = nonpolymer_counter - 10;
if (n < 36)
res.subchain += '0';
size_t pos = res.subchain.size();
while (n != 0) {
res.subchain.insert(res.subchain.begin() + pos, base36[n % 36]);
n /= 36;
}
}
break;
case EntityType::Water:
res.subchain += 'w';
break;
// In the wwPDB branched are kept each in separate auth/label chain.
// So we have one subchain in chain.
case EntityType::Branched:
res.subchain += 'b';
break;
case EntityType::Unknown:
break;
}
}
}
static std::pair<bool,bool> has_entity_types_and_subchains(const Chain& chain) {
bool has_entity_types = true;
bool has_subchains = true;
for (const Residue& res : chain.residues) {
if (res.subchain.empty())
has_subchains = false;
if (res.entity_type == EntityType::Unknown)
has_entity_types = false;
}
return {has_entity_types, has_subchains};
}
void assign_subchains(Structure& st, bool force, bool fail_if_unknown) {
for (Model& model : st.models) {
std::map<std::string, int> counters;
for (Chain& chain : model.chains) {
auto has = has_entity_types_and_subchains(chain);
if (force || !has.second) {
if (has.first) // all chain's residues have known entity_type
assign_subchain_names(chain, counters[chain.name]);
else if (fail_if_unknown)
fail("assign_subchains(): missing entity_type in chain " + chain.name);
}
}
}
}
void ensure_entities(Structure& st) {
for (Model& model : st.models)
for (Chain& chain : model.chains)
for (ResidueSpan& sub : chain.subchains()) {
Entity* ent = st.get_entity_of(sub);
if (!ent) {
EntityType etype = sub[0].entity_type;
std::string name = sub[0].entity_id;
if (name.empty()) {
if (etype == EntityType::Polymer || etype == EntityType::Branched)
name = chain.name;
else if (etype == EntityType::NonPolymer)
name = sub[0].name + "!";
else if (etype == EntityType::Water)
name = "water";
}
if (!name.empty()) {
ent = &impl::find_or_add(st.entities, name);
ent->entity_type = etype;
ent->subchains.push_back(sub.subchain_id());
}
}
// ensure we have polymer_type set where needed
if (ent && ent->entity_type == EntityType::Polymer &&
ent->polymer_type == PolymerType::Unknown)
ent->polymer_type = check_polymer_type(sub);
}
}
static bool operator==(const Entity::DbRef& a, const Entity::DbRef& b) {
return a.db_name == b.db_name &&
a.id_code == b.id_code &&
a.isoform == b.isoform &&
a.seq_begin == b.seq_begin && a.seq_end == b.seq_end &&
a.db_begin == b.db_begin && a.db_end == b.db_end;
}
void deduplicate_entities(Structure& st) {
for (auto i = st.entities.begin(); i != st.entities.end(); ++i)
if (!i->full_sequence.empty())
for (auto j = i + 1; j != st.entities.end(); ++j)
if (j->polymer_type == i->polymer_type &&
j->full_sequence == i->full_sequence &&
j->dbrefs == i->dbrefs) {
vector_move_extend(i->subchains, std::move(j->subchains));
st.entities.erase(j--);
}
}
char recommended_het_flag(const Residue& res) {
if (res.entity_type == EntityType::Unknown)
return '\0';
if (res.entity_type == EntityType::Polymer &&
find_tabulated_residue(res.name).is_standard())
return 'A';
return 'H';
}
bool trim_to_alanine(Residue& res) {
static const std::pair<std::string, El> ala_atoms[6] = {
{"N", El::N}, {"CA", El::C}, {"C", El::C}, {"O", El::O}, {"CB", El::C},
{"OXT", El::O}
};
if (res.get_ca() == nullptr)
return false;
vector_remove_if(res.atoms, [](const Atom& a) {
for (const auto& name_el : ala_atoms)
if (a.name == name_el.first && a.element == name_el.second)
return false;
return true;
});
// if non-standard polymer residue was mutated, update het_flag
if (res.entity_type == EntityType::Polymer && res.het_flag == 'H')
res.het_flag = 'A';
return true;
}
template <size_t I, typename T1, typename T2>
static bool in_vector_at(T1& x, std::vector<T2>& v) {
for (const auto& el : v)
if (std::get<I>(el) == x)
return true;
return false;
}
void shorten_ccd_codes(Structure& st) {
// find all long residue names in both models and sequences
for (Model& model : st.models)
for (Chain& chain : model.chains)
for (Residue& res : chain.residues)
if (res.name.size() > 3 && !in_vector_at<0>(res.name, st.shortened_ccd_codes))
st.shortened_ccd_codes.emplace_back(res.name, "");
for (const Entity& ent : st.entities)
for (const std::string& mon_ids : ent.full_sequence) {
for (size_t start = 0;;) {
size_t end = mon_ids.find(',', start);
size_t len = std::min(end, mon_ids.size()) - start;
if (len > 3) {
std::string s(mon_ids, start, len);
if (!in_vector_at<0>(s, st.shortened_ccd_codes))
st.shortened_ccd_codes.emplace_back(s, "");
}
if (end == std::string::npos)
break;
start = end + 1;
}
}
// the first try on renaming: ABCDE -> ~DE
for (auto& old_new : st.shortened_ccd_codes) {
const std::string& old = old_new.first;
char short_code[4] = {'~', *(old.end()-2), *(old.end()-1), '\0'};
if (!in_vector_at<1>(short_code, st.shortened_ccd_codes))
old_new.second = short_code;
}
// pick a new residue name and call rename_residues()
int i = -1;
for (auto& old_new : st.shortened_ccd_codes) {
// If ~DE was not unique, use ~00, ~01, ...
// After ~99, the middle character will be punctuation or letter.
// After ~Z9 (430+ names), we give up and the names will be empty.
while (old_new.second.empty() && ++i < 'Z'*10) {
char short_code[4] = {'~', char('0' + i/10), char('0' + i%10), '\0'};
if (!in_vector_at<1>(short_code, st.shortened_ccd_codes))
old_new.second = short_code;
}
rename_residues(st, old_new.first, old_new.second);
}
}
void restore_full_ccd_codes(Structure& st) {
for (const auto& item : st.shortened_ccd_codes)
rename_residues(st, item.second, item.first);
st.shortened_ccd_codes.clear();
}
// Unlike _entity_poly_seq, SEQRES doesn't contain alternative residue names.
// This function adds the alternative names to full_sequence.
static void add_microhetero_to_sequence(Entity& ent, ConstResidueSpan polymer) {
ent.reflects_microhetero = false;
if (ent.full_sequence.empty())
return;
int max_n = -1; // max label_seq seen so far
for (const Residue& res : polymer) {
int n = *res.label_seq;
if (size_t(n-1) > ent.full_sequence.size())
return;
std::string& seq_item = ent.full_sequence[n-1];
if (n > max_n) {
if (!is_in_list(res.name, seq_item))
return;
max_n = n;
} else { // n < max_n shouldn't happen
if (!is_in_list(res.name, seq_item))
cat_to(seq_item, ',', res.name);
}
}
ent.reflects_microhetero = true;
}
void add_microhetero_to_sequences(Structure& st, bool overwrite) {
if (st.models.empty())
return;
for (Entity& ent : st.entities) {
if (ent.subchains.empty())
continue;
ConstResidueSpan polymer = st.models[0].get_subchain(ent.subchains[0]);
if (!polymer || !polymer.front().label_seq)
continue;
if (overwrite || !ent.reflects_microhetero)
add_microhetero_to_sequence(ent, polymer);
}
}
void add_tls_group_ids(Structure& st) {
std::vector<TlsGroup>* tls_groups = st.meta.get_tls_groups();
if (!tls_groups)
return;
bool has_ids = false;
for (const Model& model : st.models)
for (const Chain& chain : model.chains)
for (const Residue& res : chain.residues)
for (const Atom& atom : res.atoms)
if (atom.tls_group_id >= 0)
has_ids = true;
if (has_ids)
return;
for (const TlsGroup& tls : *tls_groups) {
// assuming that _pdbx_refine_tls.id is a non-negative number
const char* endptr;
short tls_id = (short) no_sign_atoi(tls.id.c_str(), &endptr);
if (endptr == tls.id.c_str() || *endptr != '\0')
continue;
for (const TlsGroup::Selection& sel : tls.selections) {
// for now we don't use selection_details, only chains and sequence ids
for (Model& model : st.models)
for (Chain& chain : model.chains)
if (chain.name == sel.chain) {
for (Residue& res : chain.residues)
if (sel.res_begin <= res.seqid && res.seqid <= sel.res_end) {
for (Atom& atom : res.atoms)
atom.tls_group_id = tls_id;
}
}
}
}
}
} // namespace gemmi