Files
Jungfraujoch/tests/SolventBuildTest.cpp
leonarski_fandClaude Opus 5.5 47f4a603a6 RESULT-CHANGING: Solvent: waters and ions relabelled by their shell and their density, metal records decided once
The solvent audit gains the evidence the species need beyond the shell, and the build acts on it:

- Electrons: the site's own density fitted to 2mFo-DFc with the neighbours' held (one amplitude at the
  environment's B, the other atoms' IT92 densities to the data's resolution subtracted out to 4.5 A), on the
  scale of the macromolecule's C/N/O nearby (not the waters', which read about half their model's height).
  Depositions read Mg 11, Na 10, Cl 19, K 21-23, Ca 22-26, Zn 33, waters 4-6. Term: log-normal against the
  species' expectation (an ion at full occupancy, a water at the model's waters' median) in the waters' spread.
  An ion is read where this fit puts it (within 0.6 A), not where its label's restraints hold it; a plain
  2mFo-DFc maximum was tried first and pulled ions into their merged hydration density (1HR2 A5 1.7 A from a
  water).
- Anomalous: f'' per species at the wavelength (gemmi fprime, vendored fprime.cpp, Larch-derived, notice
  added), on the scale of the model's S and P, Gaussian in the map's rms - replacing the fixed 4 sigma.
- N7: a divalent ion on a purine N7 more than 1.0 A out of the base plane, or not 3.5-3.9 A from its O6/N6
  (NAR 2017), costs Mg and Ca 2 per such ligand.
- Halides: a missing donor costs Cl 0.5 (as a missing ligand does Mg/Na/Ca) and the positive-group requirement
  is gone - halides bind at hydrophobic surfaces too; the density decides.
- The build relabels a water or single-atom ion (no altloc, once, with density of its own) whose best species is
  another, not ambiguously: residue, element, atom name, scattering charge; its metal records replaced by
  inner-sphere ones (rugnux_metalcN). REFINE_SOLVENT_RELABELLED, per-site reasons in the report and the JSON.
- Metal LINK records left as contacts are decided once, on the model as given, and kept through the build's
  refinements (MetalRecordKey, BuildRefinementTopology outer_records) - the P2 ratchet.

1HR2 deposited-model audit vs Cat_Wiz / NAR 2017: the eight hexahydrated Mg A14-A27 MG; none of the ten
speculative A51-A64 supported as Mg (6 water, 4 Cl?/water?); A5 ambiguous Na/Mg (NAR 2017: probably monovalent).
Build with relabelling vs without (same binary, deposited SF): R-free 1hr2 -0.0019, 2r8s -0.0021, 7l6j -0.0016,
8agq -0.0006, 36gk 0, 8sa8 +0.0001, 193l +0.0003, 6hwj +0.0008. Relabelled: 1hr2 18 (14 speculative Mg ->
water), 8agq 4 Na -> water, 8sa8 Mg -> Na at Glu-bidentate 2.3-2.5 A shells; waters -> Cl at donor sites.

Tests: SolventSites_electrons_decide_halide_from_water, SolventSites_N7_out_of_plane, SolventBuild_relabel,
the decided-once records in RefinementRestraints_metal_links_outer_sphere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
2026-10-27 17:38:10 +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());
}