Files
leonarski_f a9d5367013
Build Packages / Create release (push) Successful in 26s
Build Packages / Build documentation (push) Successful in 1m43s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 5m7s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 11m48s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 7m22s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 12m24s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 25m29s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 29m41s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 21m44s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 24m41s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 26m9s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 26m3s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 28m28s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 31m16s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m44s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 26m27s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m52s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 27m44s
Build Packages / Unit tests (push) Successful in 1h46m55s
Build Packages / Validation Battery (push) Successful in 52m50s
v1.0.0-rc.181 (#92)
* Rugnux: Several rotation sweeps of one crystal are merged as one dataset (`rugnux a_master.h5 b_master.h5`), on a common indexing and cell, with a non-isomorphous sweep left out and reported; `--sweeps separate` processes each sweep on its own. Bruker `.sfrm` frames and imgCIF files with a full goniometer axis chain are read.
* Rugnux: More robust rotation processing - a retry with a more sensitive spot finder when no lattice is found, a self-consistent detector distance, long axes and doubled cells tested as hypotheses, a `-C` supercell multiple stepped down, and each ice ring merged, down-weighted or rejected on its own.
* Rugnux: Serial stills are merged with a partiality-aware error model, reported as `ERROR_MODEL_A/B/C` with no ISa or R_merge/R_meas/R_pim; runs without `-S` have an overall intensity scale about 0.86-0.88 times that of earlier versions, with unchanged statistics. A second crystal form is left out of the merge and reported, the indexing ambiguity is resolved more reliably, and `--model` no longer influences processing.
* Rugnux: The reported CC1/2 is the standard unweighted one, which can be lower than before on sweeps with weak frames; ISa comes with a 95% interval (`ISA_LOW`/`ISA_HIGH`).
* Rugnux: Model refinement adds anisotropic/TLS, local NCS and twin refinement where R-free supports them; building waters and relabelling ions is now off by default (`--refine-solvent` turns it on).
* NXmx: A detector of several modules is written as `module_0` ... `module_N` only, so DIALS reads each module as its own panel.
* Other: JPEG previews use a bundled encoder (libjpeg-turbo no longer needed), Rugnux prints how to cite it, and the release archives contain the HTML documentation.

Reviewed-on: #92
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-11-02 12:05:20 +01:00

273 lines
12 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "RefinementNcs.h"
#include <algorithm>
#include <cmath>
#include <limits>
#include <map>
#include <set>
#include <unordered_map>
#include "gemmi/polyheur.hpp" // check_polymer_type
#include "gemmi/qcp.hpp" // superpose_positions
#include "gemmi/seqalign.hpp" // align_string_sequences
#include "RefinementMath.h"
namespace {
// The distances restrained: those shorter than this in either copy, the bonded and angle-related pairs left out.
// Following Nicholls, Long & Murshudov (2012) Acta Cryst. D68, 404-417
constexpr double NCS_DISTANCE_MAX = 4.2; // A
// Where a pair's restraint lets go (NcsDistanceTerm): copies whose distance differs by this much are taken to differ.
constexpr float NCS_RELEASE = 0.5f; // A
// Copies: more than this many residues aligned, at this identity at least (of the longer chain).
// Following Murshudov et al. (2011) Acta Cryst. D67, 355-367
constexpr int NCS_MIN_ALIGNED = 15;
constexpr double NCS_MIN_IDENTITY = 80.0; // %
// and their C-alpha (P) atoms no further apart than this after superposition: the same sequence further away (a
// quasi-equivalent subunit of a virus capsid) is another molecule.
// Following Murshudov et al. (2011) Acta Cryst. D67, 355-367
constexpr double NCS_MAX_RMSD = 2.5; // A
// One polymer chain: its residues and, per residue, its atoms' indices by name.
struct NcsChain {
std::string name;
gemmi::PolymerType type = gemmi::PolymerType::Unknown;
std::vector<std::string> sequence;
std::vector<int> residue; // numbered over the model
std::vector<std::map<std::string, int>> atoms; // name -> atom index
std::vector<int> anchor; // CA or P, -1 for none
};
// Two copies' corresponding atoms (aligned residues of the same name, atoms of the same name), the residue of each in
// the first copy, and their anchors.
void Correspond(const NcsChain &a, const NcsChain &b, std::vector<std::array<int, 2>> &atoms,
std::vector<int> &residue_of, std::vector<std::array<int, 2>> &anchors) {
const gemmi::AlignmentResult al = gemmi::align_string_sequences(a.sequence, b.sequence, {},
gemmi::AlignmentScoring::simple());
size_t i = 0, j = 0;
for (const gemmi::AlignmentResult::Item &item : al.cigar)
for (uint32_t k = 0; k < item.len(); k++) {
const char op = item.op();
if (op == 'M' && a.sequence[i] == b.sequence[j]) {
for (const auto &[name, ia] : a.atoms[i]) {
auto it = b.atoms[j].find(name);
if (it != b.atoms[j].end()) {
atoms.push_back({ia, it->second});
residue_of.push_back(a.residue[i]);
}
}
if (a.anchor[i] >= 0 && b.anchor[j] >= 0)
anchors.push_back({a.anchor[i], b.anchor[j]});
}
if (op == 'M' || op == 'I')
i++;
if (op == 'M' || op == 'D')
j++;
}
}
// The C-alpha (P) rmsd of two chains after superposition where their sequences make them copies of one molecule,
// infinity where they do not.
double CopyRmsd(const NcsChain &a, const NcsChain &b, const std::vector<gemmi::Position> &pos) {
const double none = std::numeric_limits<double>::infinity();
if (a.type != b.type || a.sequence.size() <= NCS_MIN_ALIGNED || b.sequence.size() <= NCS_MIN_ALIGNED)
return none;
const gemmi::AlignmentResult al = gemmi::align_string_sequences(a.sequence, b.sequence, {},
gemmi::AlignmentScoring::simple());
// identity over the longer chain: over the shorter one, a short RNA aligned with gaps into a long one finds most of
// its four letters matched
const size_t longer = std::max(a.sequence.size(), b.sequence.size());
if (al.match_count <= NCS_MIN_ALIGNED || 100.0 * al.match_count < NCS_MIN_IDENTITY * double(longer))
return none;
std::vector<std::array<int, 2>> atoms, anchors;
std::vector<int> residue_of;
Correspond(a, b, atoms, residue_of, anchors);
if (anchors.size() < 3)
return none;
std::vector<gemmi::Position> pa, pb;
for (const std::array<int, 2> &p : anchors) {
pa.push_back(pos[p[0]]);
pb.push_back(pos[p[1]]);
}
return gemmi::superpose_positions(pa.data(), pb.data(), pa.size(), nullptr).rmsd;
}
// The pairs (i, j), i < j, of `atoms` closer than `reach` at `pos`, by a grid of cells of that size.
std::set<std::pair<int, int>> PairsWithin(const std::vector<int> &atoms, const std::vector<gemmi::Position> &pos,
double reach) {
std::map<std::array<int, 3>, std::vector<int>> cells; // cell -> positions in `atoms`
auto cell_of = [&](const gemmi::Position &p) {
return std::array<int, 3>{static_cast<int>(std::floor(p.x / reach)), static_cast<int>(std::floor(p.y / reach)),
static_cast<int>(std::floor(p.z / reach))};
};
for (size_t k = 0; k < atoms.size(); k++)
cells[cell_of(pos[atoms[k]])].push_back(static_cast<int>(k));
std::set<std::pair<int, int>> out;
for (size_t k = 0; k < atoms.size(); k++) {
const std::array<int, 3> c = cell_of(pos[atoms[k]]);
for (int dx = -1; dx <= 1; dx++)
for (int dy = -1; dy <= 1; dy++)
for (int dz = -1; dz <= 1; dz++) {
auto it = cells.find({c[0] + dx, c[1] + dy, c[2] + dz});
if (it == cells.end())
continue;
for (int l : it->second)
if (atoms[l] > atoms[k] && pos[atoms[k]].dist(pos[atoms[l]]) < reach)
out.insert({atoms[k], atoms[l]});
}
}
return out;
}
} // namespace
NcsCopies FindNcsCopies(const gemmi::Model &model, const RefinementTopology &topo) {
NcsCopies out;
std::vector<gemmi::Position> pos;
std::vector<NcsChain> chains;
int atom = 0, residue = 0;
for (const gemmi::Chain &ch : model.chains) {
NcsChain c;
c.name = ch.name;
const gemmi::ConstResidueSpan polymer = ch.get_polymer();
c.type = gemmi::check_polymer_type(polymer);
const bool na = gemmi::is_polynucleotide(c.type);
for (const gemmi::Residue &r : ch.residues) {
const bool in_polymer = !polymer.empty() && &r >= &polymer.front() && &r <= &polymer.back();
std::map<std::string, int> names;
int anchor = -1;
for (const gemmi::Atom &a : r.atoms) {
pos.push_back(a.pos);
if (in_polymer && !a.is_hydrogen() && a.altloc == '\0' && a.occ > 0 && topo.refinable[atom]) {
names[a.name] = atom;
if (a.name == (na ? "P" : "CA"))
anchor = atom;
}
atom++;
}
if (in_polymer) {
c.sequence.push_back(r.name);
c.residue.push_back(residue);
c.atoms.push_back(std::move(names));
c.anchor.push_back(anchor);
}
residue++;
}
if (gemmi::is_polypeptide(c.type) || gemmi::is_polynucleotide(c.type))
chains.push_back(std::move(c));
}
// The molecules: each chain joins the first group whose first chain it is a copy of.
std::vector<std::vector<int>> groups;
for (size_t k = 0; k < chains.size(); k++) {
bool placed = false;
for (std::vector<int> &g : groups)
if (CopyRmsd(chains[g[0]], chains[k], pos) <= NCS_MAX_RMSD) {
g.push_back(static_cast<int>(k));
placed = true;
break;
}
if (!placed)
groups.push_back({static_cast<int>(k)});
}
// The distances the dictionary fixes: bonded atoms and those bonded to a common atom.
std::set<std::pair<int, int>> fixed;
for (const RestraintRecord &r : topo.geometry.records)
if (r.kind == static_cast<int>(RestraintKind::Bond))
fixed.insert(std::minmax(r.atom[0], r.atom[1]));
else if (r.kind == static_cast<int>(RestraintKind::Angle))
fixed.insert(std::minmax(r.atom[0], r.atom[2]));
for (const std::vector<int> &g : groups) {
if (g.size() < 2)
continue;
out.groups++;
out.copies += static_cast<int>(g.size());
std::string names;
for (int k : g)
names += (names.empty() ? "" : "=") + chains[k].name;
out.description += (out.description.empty() ? "" : " ") + names;
// each copy with the next, and the last with the first where that is a pair not made already
const size_t n_pairs = g.size() == 2 ? 1 : g.size();
for (size_t p = 0; p < n_pairs; p++) {
const NcsChain &a = chains[g[p]], &b = chains[g[(p + 1) % g.size()]];
std::vector<std::array<int, 2>> corr, anchors;
std::vector<int> residue_of;
Correspond(a, b, corr, residue_of, anchors);
out.anchors.push_back(std::move(anchors));
std::unordered_map<int, int> to_b, res_of;
std::vector<int> atoms_a, atoms_b;
for (size_t k = 0; k < corr.size(); k++) {
to_b[corr[k][0]] = corr[k][1];
res_of[corr[k][0]] = residue_of[k];
atoms_a.push_back(corr[k][0]);
atoms_b.push_back(corr[k][1]);
}
std::set<std::pair<int, int>> pairs = PairsWithin(atoms_a, pos, NCS_DISTANCE_MAX);
std::unordered_map<int, int> to_a;
for (const auto &[ia, ib] : to_b)
to_a[ib] = ia;
for (const auto &[i, j] : PairsWithin(atoms_b, pos, NCS_DISTANCE_MAX))
pairs.insert(std::minmax(to_a[i], to_a[j]));
for (const auto &[i, j] : pairs) {
if (fixed.count({i, j}) > 0)
continue;
RestraintRecord r;
r.kind = static_cast<int>(RestraintKind::NcsDistance);
r.atom[0] = i;
r.atom[1] = j;
r.atom[2] = to_b[i];
r.atom[3] = to_b[j];
r.ideal = NCS_RELEASE;
r.sigma = 1.0f;
out.distances.Add(r, 4);
out.record_residue.push_back(res_of[i]);
}
}
}
return out;
}
RestraintSet NcsRestraintsAt(const NcsCopies &ncs, double sigma) {
RestraintSet set = ncs.distances;
for (RestraintRecord &r : set.records)
r.sigma = static_cast<float>(sigma);
return set;
}
double NcsRmsd(const NcsCopies &ncs, const std::vector<std::array<double, 4>> &xyzb) {
double sum = 0;
int n = 0;
for (const std::vector<std::array<int, 2>> &anchors : ncs.anchors) {
if (anchors.size() < 3)
continue;
std::vector<gemmi::Position> pa, pb;
for (const std::array<int, 2> &p : anchors) {
pa.emplace_back(xyzb[p[0]][0], xyzb[p[0]][1], xyzb[p[0]][2]);
pb.emplace_back(xyzb[p[1]][0], xyzb[p[1]][1], xyzb[p[1]][2]);
}
sum += gemmi::superpose_positions(pa.data(), pb.data(), pa.size(), nullptr).rmsd;
n++;
}
return n > 0 ? sum / n : NAN;
}
int NcsReleasedResidues(const NcsCopies &ncs, const std::vector<std::array<double, 4>> &xyzb) {
std::map<int, std::array<int, 2>> count; // residue -> restraints released, all
for (size_t k = 0; k < ncs.distances.records.size(); k++) {
const RestraintRecord &r = ncs.distances.records[k];
const double delta = RestraintValue(ncs.distances, r, xyzb);
std::array<int, 2> &c = count[ncs.record_residue[k]];
c[0] += std::fabs(delta) > r.ideal;
c[1]++;
}
int released = 0;
for (const auto &[res, c] : count)
released += 2 * c[0] > c[1];
return released;
}