Files
Jungfraujoch/tests/SolventBuildTest.cpp
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

189 lines
7.8 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include <cmath>
#include <string>
#include <vector>
#include "../image_analysis/structure_refinement/SolventBuild.h"
namespace {
constexpr double EDGE = 24.0; // A, a P1 box
gemmi::Structure box() {
gemmi::Structure st;
st.cell = gemmi::UnitCell(EDGE, EDGE, EDGE, 90, 90, 90);
st.spacegroup_hm = "P 1";
st.setup_cell_images();
st.models.emplace_back(1);
st.models[0].chains.emplace_back("A");
return st;
}
void add_atom(gemmi::Structure &st, const std::string &resname, const std::string &atom, gemmi::El el,
const gemmi::Position &pos) {
gemmi::Chain &ch = st.models[0].chains[0];
gemmi::Residue r;
r.name = resname;
r.seqid = gemmi::SeqId(static_cast<int>(ch.residues.size()) + 1, ' ');
gemmi::Atom a;
a.name = atom;
a.element = gemmi::Element(el);
a.pos = pos;
a.occ = 1.0f;
a.b_iso = 20.0f;
r.atoms.push_back(a);
ch.residues.push_back(r);
}
// A map of the box, 0.3 A grid, in units of its rms (1), with a Gaussian peak of `height` at each of `peaks`.
gemmi::Grid<float> map_with(const gemmi::UnitCell &cell, const std::vector<gemmi::Position> &peaks, float height,
float base = 0.0f) {
gemmi::Grid<float> g;
g.unit_cell = cell;
g.spacegroup = gemmi::find_spacegroup_by_name("P 1");
g.set_size(80, 80, 80);
for (int w = 0; w < g.nw; w++)
for (int v = 0; v < g.nv; v++)
for (int u = 0; u < g.nu; u++) {
const gemmi::Position p = g.get_position(u, v, w);
float value = base;
for (const gemmi::Position &q : peaks) {
const double d = cell.find_nearest_image(p, q, gemmi::Asu::Any).dist();
value += static_cast<float>(height * std::exp(-d * d / (2 * 0.35 * 0.35)));
}
g.data[g.index_q(u, v, w)] = value;
}
return g;
}
int count_kind(const SolventRound &r, SolventEdit::Kind kind) {
int n = 0;
for (const SolventEdit &e : r.edits)
n += e.kind == kind;
return n;
}
} // namespace
// A difference peak 2.8 A from a water becomes a water; one with only a carbon 3.3 A away (no hydrogen-bond partner)
// does not, and is not reported either; the new water goes into the model as HOH.
TEST_CASE("SolventBuild_peak_with_and_without_partner", "[Solvent]") {
gemmi::Structure st = box();
add_atom(st, "HOH", "O", gemmi::El::O, {6, 6, 6});
add_atom(st, "UNL", "C1", gemmi::El::C, {16, 16, 16});
const gemmi::Position with_partner(8.8, 6, 6), without(16, 16, 19.3);
const gemmi::Grid<float> diff = map_with(st.cell, {with_partner, without}, 6.0f);
const gemmi::Grid<float> map = map_with(st.cell, {}, 0.0f, 1.5f);
const SolventMaps maps{&map, 1.0, &diff, 1.0, nullptr, 0};
SolventRound r = PickSolvent(st, maps, {}, {}, {}, 300, 1);
REQUIRE(r.added == 1);
CHECK(r.not_solvent == 0);
CHECK(r.refused["no hydrogen-bond partner"] == 1);
const SolventEdit &e = r.edits.at(0);
CHECK(e.kind == SolventEdit::Kind::Added);
CHECK(st.cell.find_nearest_image(e.pos, with_partner, gemmi::Asu::Any).dist() < 0.25);
CHECK(e.difference_sigma == Catch::Approx(6.0).margin(0.5));
ApplySolventEdits(st, r.edits);
const gemmi::Residue &added = st.models[0].chains[0].residues.back();
CHECK(added.name == "HOH");
CHECK(added.seqid.num == 3);
CHECK(added.atoms.at(0).b_iso == Catch::Approx(20.0)); // its partner's
CHECK(r.edits.at(0).site == "HOH A3");
}
// A sulfate-like cluster - a peak with four more at 1.5 A on a tetrahedron - beside a water is no water: the centre
// is reported as a tetrahedral group, its corners as atoms of one molecule; nothing is added.
TEST_CASE("SolventBuild_tetrahedral_cluster_refused", "[Solvent]") {
gemmi::Structure st = box();
add_atom(st, "HOH", "O", gemmi::El::O, {6, 6, 6});
const gemmi::Position centre(9.2, 6, 6);
const double t = 1.5 / std::sqrt(3.0);
std::vector<gemmi::Position> peaks = {centre};
for (const gemmi::Position &d : {gemmi::Position(t, t, t), gemmi::Position(t, -t, -t), gemmi::Position(-t, t, -t),
gemmi::Position(-t, -t, t)})
peaks.push_back(centre + d);
const gemmi::Grid<float> diff = map_with(st.cell, peaks, 6.0f);
const gemmi::Grid<float> map = map_with(st.cell, {}, 0.0f, 1.5f);
const SolventMaps maps{&map, 1.0, &diff, 1.0, nullptr, 0};
const SolventRound r = PickSolvent(st, maps, {}, {}, {}, 300, 1);
CHECK(r.added == 0);
CHECK(count_kind(r, SolventEdit::Kind::NotSolvent) >= 1);
bool tetrahedral = false;
for (const SolventEdit &e : r.edits)
if (e.kind == SolventEdit::Kind::NotSolvent && st.cell.find_nearest_image(e.pos, centre, gemmi::Asu::Any).dist() < 0.3)
tetrahedral = e.reason.find("tetrahedral") != std::string::npos;
CHECK(tetrahedral);
}
// A water in negative difference density is taken out; one without a partner only where the build added it.
TEST_CASE("SolventBuild_removal", "[Solvent]") {
gemmi::Structure st = box();
add_atom(st, "HOH", "O", gemmi::El::O, {6, 6, 6});
add_atom(st, "HOH", "O", gemmi::El::O, {8.8, 6, 6});
add_atom(st, "HOH", "O", gemmi::El::O, {16, 16, 16});
const gemmi::Grid<float> diff = map_with(st.cell, {}, 0.0f);
const gemmi::Grid<float> map = map_with(st.cell, {}, 0.0f, 1.5f);
const SolventMaps maps{&map, 1.0, &diff, 1.0, nullptr, 0};
std::vector<SolventSite> sites(3);
for (int k = 0; k < 3; k++) {
sites[k].label = "HOH";
sites[k].chain = "A";
sites[k].seqid = std::to_string(k + 1);
sites[k].pos = st.models[0].chains[0].residues[k].atoms[0].pos;
sites[k].map_sigma = 0.5; // weak, which alone takes nothing out
sites[k].n_26_32 = k < 2;
sites[k].nearest_ion = INFINITY;
sites[k].b_iso = 20;
}
sites[1].difference_sigma = -4.0;
CHECK(PickSolvent(st, maps, sites, {}, {}, 300, 1).removed == 1);
SolventRound r = PickSolvent(st, maps, sites, {"HOH A3"}, {}, 300, 1);
CHECK(r.removed == 2);
ApplySolventEdits(st, r.edits);
REQUIRE(st.models[0].chains[0].residues.size() == 1);
CHECK(st.models[0].chains[0].residues[0].seqid.num == 1);
}
// A water among six waters at 2.07 A relabelled Mg: the residue, element and atom name change and it gets a metal
// record for each of its six ligands; relabelled water again, the records go.
TEST_CASE("SolventBuild_relabel", "[Solvent]") {
gemmi::Structure st = box();
const gemmi::Position c(12, 12, 12);
add_atom(st, "HOH", "O", gemmi::El::O, c);
for (int axis = 0; axis < 3; axis++)
for (double sign : {-1.0, 1.0}) {
gemmi::Position p = c;
(axis == 0 ? p.x : axis == 1 ? p.y : p.z) += sign * 2.07;
add_atom(st, "HOH", "O", gemmi::El::O, p);
}
std::vector<SolventEdit> edits(1);
edits[0].kind = SolventEdit::Kind::Relabelled;
edits[0].site = "HOH A1";
edits[0].chain = "A";
edits[0].seqid = gemmi::SeqId(1, ' ');
edits[0].species = "MG";
ApplySolventEdits(st, edits);
const gemmi::Residue &r = st.models[0].chains[0].residues[0];
CHECK(r.name == "MG");
CHECK(r.atoms[0].name == "MG");
CHECK(r.atoms[0].element == gemmi::El::Mg);
REQUIRE(st.connections.size() == 6);
for (const gemmi::Connection &conn : st.connections)
CHECK(conn.partner2.res_id.name == "MG");
edits[0].site = "MG A1";
edits[0].species = "HOH";
ApplySolventEdits(st, edits);
CHECK(st.models[0].chains[0].residues[0].name == "HOH");
CHECK(st.models[0].chains[0].residues[0].atoms[0].element == gemmi::El::O);
CHECK(st.connections.empty());
}