`--model` re-fractionalizes the model into the data cell and then places it as one rigid body, but the placed coordinates never reached disk. On a lysozyme sweep against a non-isomorphous deposited model the move is 3.058 deg and 1.035 A, so a user overlaying their input model on rugnux's maps was out by exactly that, and no file on disk corresponded to the maps at all. `<prefix>_model.cif` is that file: the input's chains, residues, ligands, waters, B-factors, occupancies and anisotropic Us, at the coordinates the maps were computed from. `<prefix>.cif` is already the merged reflections, hence the suffix. The cell and space group come from the same two values WriteReflections is given - the unit cell and DiffractionExperiment::GetSpaceGroupOrP1() after AdoptModelFrame has settled the enantiomorph - so the coordinate file and the .mtz beside it always agree. Taking them from the input model would not: with data merged in P4(1)2(1)2 and a P4(3)2(1)2 model, the written reflections take the model's group, which is neither the data's original label nor, when the model is rejected, the model's own. Written whenever the maps are, not only where the rigid-body step was committed. The model is re-fractionalized and may be relabelled whatever the placement decided, so an unmoved model is still not the input file; and a model the null rejected is scored, placed and mapped like any other - the negative result, and the case where the density is most worth looking at. Nothing in the tree could write coordinates: gemmi_gph declared to_mmcif.hpp but src/to_mmcif.cpp had been trimmed from the vendored subset. Both it and to_pdb.cpp (to_mmcif.cpp calls its use_hetatm) are vendored from the same gemmi 0.7.5 the rest of gemmi_gph comes from, unmodified, and every header they include was already there. Same package, same MPL-2.0, same LICENSE.txt already collected into licenses/gemmi.txt and already listed against `gemmi_gph/` in THIRD_PARTY_NOTICES.md, so no new row and no new licence text. Verified end to end: read back with gemmi the file differs from the re-fractionalized input by exactly the reported 3.058 deg / 1.035 A with 0.0000 A rms about that rigid move, and REFMAC5 at zero cycles against rugnux's own .mtz starts at R-free 0.3667 where rugnux reports 0.3826 - against 0.5832 for the unplaced input model, where rugnux reports 0.5911 before the placement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
278 lines
12 KiB
C++
278 lines
12 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 <cstdio>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <random>
|
|
|
|
#include <gemmi/mmread_gz.hpp>
|
|
|
|
#include "../common/Logger.h"
|
|
#include "../rugnux/ModelValidation.h"
|
|
#include "../rugnux/RigidBodyRefine.h"
|
|
#include "../rugnux/SigmaA.h"
|
|
#include "../rugnux/WriteModel.h"
|
|
|
|
namespace {
|
|
// A synthetic P1 cell with two carbon atoms - enough for a reader to produce a Structure with
|
|
// atoms, a cell and a space group, which is all these tests are about. Neutral by construction:
|
|
// no real specimen's parameters are involved.
|
|
const char *kMmcif = R"(data_test
|
|
_cell.length_a 40.000
|
|
_cell.length_b 50.000
|
|
_cell.length_c 60.000
|
|
_cell.angle_alpha 90.000
|
|
_cell.angle_beta 90.000
|
|
_cell.angle_gamma 90.000
|
|
_symmetry.space_group_name_H-M 'P 1'
|
|
loop_
|
|
_atom_site.group_PDB
|
|
_atom_site.id
|
|
_atom_site.type_symbol
|
|
_atom_site.label_alt_id
|
|
_atom_site.label_atom_id
|
|
_atom_site.label_comp_id
|
|
_atom_site.label_asym_id
|
|
_atom_site.label_seq_id
|
|
_atom_site.Cartn_x
|
|
_atom_site.Cartn_y
|
|
_atom_site.Cartn_z
|
|
_atom_site.occupancy
|
|
_atom_site.B_iso_or_equiv
|
|
ATOM 1 C . CA GLY A 1 10.000 12.000 14.000 1.00 20.00
|
|
ATOM 2 C . CB GLY A 1 12.000 14.000 16.000 1.00 20.00
|
|
)";
|
|
|
|
const char *kPdb =
|
|
"CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n"
|
|
"ATOM 1 CA GLY A 1 10.000 12.000 14.000 1.00 20.00 C\n"
|
|
"ATOM 2 CB GLY A 1 12.000 14.000 16.000 1.00 20.00 C\n"
|
|
"END\n";
|
|
|
|
// A synthetic "protein": carbons filling one asymmetric unit of a small P2(1)2(1)2(1) cell. The
|
|
// space group matters - in P1 the origin is free in all three directions, so |F| does not change
|
|
// when the whole content is translated and there is no translation to recover. No specimen is
|
|
// involved; the positions come out of a fixed seed.
|
|
std::string ClusterPdb() {
|
|
std::string pdb = "CRYST1 30.000 34.000 38.000 90.00 90.00 90.00 P 21 21 21 4\n";
|
|
std::mt19937 rng(20260902);
|
|
std::uniform_real_distribution<double> x(2, 14), y(2, 16), z(2, 18);
|
|
char line[96];
|
|
for (int i = 1; i <= 150; i++) {
|
|
std::snprintf(line, sizeof line,
|
|
"ATOM %5d C UNK A 1 %8.3f%8.3f%8.3f 1.00 20.00 C\n",
|
|
i, x(rng), y(rng), z(rng));
|
|
pdb += line;
|
|
}
|
|
return pdb + "END\n";
|
|
}
|
|
|
|
// The same two atoms as kPdb, plus an anisotropic U, a partial occupancy and a water: a written
|
|
// model has to carry what the input carried, not only its coordinates.
|
|
const char *kPdbRich =
|
|
"CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n"
|
|
"ATOM 1 CA GLY A 1 10.000 12.000 14.000 1.00 20.00 C\n"
|
|
"ANISOU 1 CA GLY A 1 1000 1200 1400 100 200 300 C\n"
|
|
"ATOM 2 CB GLY A 1 12.000 14.000 16.000 0.60 25.00 C\n"
|
|
"HETATM 3 O HOH A 101 20.000 22.000 24.000 1.00 30.00 O\n"
|
|
"END\n";
|
|
|
|
std::string WriteTemp(const std::string &name, const char *content) {
|
|
std::ofstream f(name);
|
|
f << content;
|
|
f.close();
|
|
return name;
|
|
}
|
|
}
|
|
|
|
// --model used to call gemmi::read_pdb unconditionally, so a deposited model handed over as mmCIF -
|
|
// which is how the PDB serves coordinates by default - was refused outright. Both formats now go
|
|
// through the same reader, which decides on the file's content.
|
|
TEST_CASE("ModelValidation_ReadsPdbAndMmcif", "[ModelValidation]") {
|
|
Logger logger("ModelValidation_ReadsPdbAndMmcif");
|
|
|
|
const auto pdb = WriteTemp("model_validation_test.pdb", kPdb);
|
|
const auto cif = WriteTemp("model_validation_test.cif", kMmcif);
|
|
|
|
const auto from_pdb = ModelReferenceIntensities(pdb, {}, {}, 4.0, logger);
|
|
const auto from_cif = ModelReferenceIntensities(cif, {}, {}, 4.0, logger);
|
|
|
|
REQUIRE_FALSE(from_pdb.empty());
|
|
REQUIRE_FALSE(from_cif.empty());
|
|
// The same structure either way, so the same reflections come out of it.
|
|
CHECK(from_cif.size() == from_pdb.size());
|
|
|
|
// The extension is not what decides: the same mmCIF under a .pdb name still reads.
|
|
const auto misnamed = WriteTemp("model_validation_test_misnamed.pdb", kMmcif);
|
|
CHECK_FALSE(ModelReferenceIntensities(misnamed, {}, {}, 4.0, logger).empty());
|
|
|
|
std::filesystem::remove(pdb);
|
|
std::filesystem::remove(cif);
|
|
std::filesystem::remove(misnamed);
|
|
}
|
|
|
|
// A model that cannot be used must say why. Returning an empty result and logging was enough to lose
|
|
// the fact entirely: the run finished successfully with no R-free and no maps, which is exactly what
|
|
// a run without --model looks like.
|
|
TEST_CASE("ModelValidation_UnusableModelGivesAReason", "[ModelValidation]") {
|
|
Logger logger("ModelValidation_UnusableModelGivesAReason");
|
|
|
|
const auto empty_file = WriteTemp("model_validation_test_bogus.pdb", "not a coordinate file\n");
|
|
|
|
const auto result = ValidateAgainstModel({}, UnitCell{.a = 40, .b = 50, .c = 60,
|
|
.alpha = 90, .beta = 90, .gamma = 90},
|
|
empty_file, "", logger);
|
|
CHECK_FALSE(result.ok);
|
|
CHECK_FALSE(result.failure_reason.empty());
|
|
CHECK_THAT(result.failure_reason, Catch::Matchers::ContainsSubstring(empty_file));
|
|
|
|
const auto missing = ValidateAgainstModel({}, UnitCell{.a = 40, .b = 50, .c = 60,
|
|
.alpha = 90, .beta = 90, .gamma = 90},
|
|
"model_validation_test_does_not_exist.pdb", "", logger);
|
|
CHECK_FALSE(missing.ok);
|
|
CHECK_FALSE(missing.failure_reason.empty());
|
|
|
|
std::filesystem::remove(empty_file);
|
|
}
|
|
|
|
// A rigid-body step is only worth taking if it can find a shift it was not told about. The check is
|
|
// closed: the "observed" amplitudes are the model's own, so the answer is known to be zero shift.
|
|
TEST_CASE("ModelValidation_RigidBodyRecoversASmallShift", "[ModelValidation]") {
|
|
Logger logger("ModelValidation_RigidBodyRecoversASmallShift");
|
|
|
|
const auto path = WriteTemp("rigid_body_test.pdb", ClusterPdb().c_str());
|
|
gemmi::Structure st = gemmi::read_structure_gz(path, gemmi::CoorFormat::Detect);
|
|
const gemmi::SpaceGroup *sg = st.find_spacegroup();
|
|
REQUIRE(sg != nullptr);
|
|
st.setup_cell_images();
|
|
|
|
// "Observed" amplitudes: the model's own structure factors, so the target's minimum is exactly
|
|
// where the model started.
|
|
const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger);
|
|
REQUIRE_FALSE(ref.empty());
|
|
gemmi::AsuData<gemmi::ValueSigma<float>> fobs;
|
|
fobs.unit_cell_ = st.cell;
|
|
fobs.spacegroup_ = sg;
|
|
for (const auto &r : ref)
|
|
fobs.v.push_back({{{r.h, r.k, r.l}}, {std::sqrt(r.I), 1.0f}});
|
|
fobs.ensure_sorted();
|
|
|
|
const std::vector<gemmi::Position> original = ModelPositions(st.models[0]);
|
|
std::vector<gemmi::Position> displaced;
|
|
for (const gemmi::Position &p : original)
|
|
displaced.emplace_back(p.x + 0.40, p.y - 0.30, p.z + 0.20); // 0.54 A off
|
|
SetModelPositions(st.models[0], displaced);
|
|
|
|
const RigidBodyRefineResult result =
|
|
RefineRigidBody(st.models[0], st.cell, *sg, fobs, 3.0, logger);
|
|
CHECK(result.converged);
|
|
|
|
const std::vector<gemmi::Position> refined = ModelPositions(st.models[0]);
|
|
double before = 0, after = 0;
|
|
for (size_t i = 0; i < original.size(); i++) {
|
|
before += original[i].dist_sq(displaced[i]);
|
|
after += original[i].dist_sq(refined[i]);
|
|
}
|
|
before = std::sqrt(before / original.size());
|
|
after = std::sqrt(after / original.size());
|
|
logger.Info("Rigid-body test: rmsd from the truth {:.3f} A -> {:.3f} A", before, after);
|
|
CHECK(after < 0.2 * before);
|
|
|
|
std::filesystem::remove(path);
|
|
}
|
|
|
|
// sigma_A is what says how much of the model to believe, so the two ends of its range are what the
|
|
// weighting has to get right: a model that explains the data completely, and one that explains none
|
|
// of it.
|
|
TEST_CASE("ModelValidation_SigmaAWeightsFollowTheModelsAgreement", "[ModelValidation]") {
|
|
gemmi::UnitCell cell(40, 50, 60, 90, 90, 90);
|
|
std::mt19937 rng(12345);
|
|
std::normal_distribution<double> normal(0.0, 1.0);
|
|
|
|
auto weights = [&](bool agreeing) {
|
|
std::vector<SigmaAReflection> refl;
|
|
for (int i = 0; i < 2000; i++) {
|
|
SigmaAReflection r;
|
|
r.f_calc = std::fabs(normal(rng)) * 100;
|
|
r.f_obs = agreeing ? r.f_calc : std::fabs(normal(rng)) * 100;
|
|
r.inv_d2 = 0.01 + 0.2 * (i / 2000.0);
|
|
r.free = (i % 20) == 0; // the usual 5 %
|
|
refl.push_back(r);
|
|
}
|
|
return EstimateSigmaA(refl, cell);
|
|
};
|
|
|
|
const SigmaAResult perfect = weights(true);
|
|
const SigmaAResult useless = weights(false);
|
|
CHECK(perfect.mean_fom > 0.85);
|
|
CHECK(useless.mean_fom < 0.2);
|
|
CHECK(perfect.shells == 2); // 100 free reflections, 50 to a shell
|
|
|
|
// No free reflections to estimate on: the coefficients are left alone rather than weighted by a
|
|
// number that was never measured.
|
|
std::vector<SigmaAReflection> no_free;
|
|
for (int i = 0; i < 100; i++)
|
|
no_free.push_back({100.0, 100.0, 0.05, 1, false, false});
|
|
const SigmaAResult unweighted = EstimateSigmaA(no_free, cell);
|
|
CHECK(unweighted.weight.size() == no_free.size());
|
|
CHECK(unweighted.weight[0].m == 1.0);
|
|
CHECK(unweighted.weight[0].d == 1.0);
|
|
}
|
|
|
|
// The model rugnux scored has to reach disk, or a user overlaying their input model on rugnux's maps
|
|
// is wrong by the whole rigid-body shift. Two things have to hold: the file carries what the input
|
|
// carried, and it carries the cell and space group the reflection files beside it are written in -
|
|
// which, once --model has adopted the model's enantiomorph, is neither the data's original label nor
|
|
// necessarily the input model's.
|
|
TEST_CASE("WriteModel_KeepsTheContentAndTakesTheGivenFrame", "[ModelValidation]") {
|
|
Logger logger("WriteModel_KeepsTheContentAndTakesTheGivenFrame");
|
|
|
|
const auto input = WriteTemp("write_model_test_input.pdb", kPdbRich);
|
|
gemmi::Structure st = gemmi::read_structure_gz(input, gemmi::CoorFormat::Detect);
|
|
|
|
// A frame that is neither the model's (P 1, 40/50/60) nor anything derived from it: the tetragonal
|
|
// lysozyme cell and one enantiomorph of its group, standing in for what AdoptModelFrame settled.
|
|
const UnitCell data_cell{.a = 79, .b = 79, .c = 38, .alpha = 90, .beta = 90, .gamma = 90};
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 43 21 2");
|
|
REQUIRE(sg != nullptr);
|
|
|
|
WritePlacedModel(st, data_cell, *sg, "write_model_test", logger);
|
|
const std::string written = "write_model_test_model.cif";
|
|
REQUIRE(std::filesystem::exists(written));
|
|
|
|
const gemmi::Structure back = gemmi::read_structure_gz(written, gemmi::CoorFormat::Detect);
|
|
CHECK(back.cell.a == Catch::Approx(79.0));
|
|
CHECK(back.cell.c == Catch::Approx(38.0));
|
|
REQUIRE(back.find_spacegroup() != nullptr);
|
|
CHECK(back.find_spacegroup()->number == 96);
|
|
|
|
REQUIRE(back.models.size() == 1);
|
|
REQUIRE(back.models[0].chains.size() == 1);
|
|
const gemmi::Chain &chain = back.models[0].chains[0];
|
|
REQUIRE(chain.residues.size() == 2); // GLY and the water
|
|
REQUIRE(chain.residues[0].atoms.size() == 2);
|
|
REQUIRE(chain.residues[1].atoms.size() == 1);
|
|
|
|
// The coordinates are written as they stand - WritePlacedModel changes the label, not the atoms.
|
|
const gemmi::Atom &ca = chain.residues[0].atoms[0];
|
|
const gemmi::Atom &cb = chain.residues[0].atoms[1];
|
|
CHECK(ca.pos.x == Catch::Approx(10.0));
|
|
CHECK(ca.pos.z == Catch::Approx(14.0));
|
|
CHECK(cb.pos.y == Catch::Approx(14.0));
|
|
// B-factors, occupancies and the anisotropic U survive the round trip.
|
|
CHECK(ca.b_iso == Catch::Approx(20.0));
|
|
CHECK(cb.b_iso == Catch::Approx(25.0));
|
|
CHECK(cb.occ == Catch::Approx(0.60));
|
|
CHECK(ca.aniso.nonzero());
|
|
CHECK(ca.aniso.u11 == Catch::Approx(0.1000).margin(1e-4));
|
|
CHECK(ca.aniso.u12 == Catch::Approx(0.0100).margin(1e-4));
|
|
CHECK(chain.residues[1].name == "HOH");
|
|
|
|
std::filesystem::remove(input);
|
|
std::filesystem::remove(written);
|
|
}
|