// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include #include #include #include #include #include #include #include #include #include #include "../common/Logger.h" #include "../rugnux/ModelFFT.h" #include "../rugnux/ModelGrid.h" #include "../rugnux/ModelValidation.h" #include "../rugnux/RigidBodyRefine.h" #ifdef JFJOCH_USE_CUDA #include "../rugnux/RigidBodyGPU.h" #include "../rugnux/RigidBodyGPUEngine.h" #include "../common/CUDAWrapper.h" #endif #include "../rugnux/SigmaA.h" #include "../rugnux/WriteModel.h" #include "../image_analysis/scale_merge/ReindexAmbiguity.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 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. const char *kCryst = "CRYST1 30.000 34.000 38.000 90.00 90.00 90.00 P 21 21 21 4\n"; // The same cell in a polar group: b is the unique axis, so the origin is free along y alone. const char *kPolarCryst = "CRYST1 30.000 34.000 38.000 90.00 90.00 90.00 P 1 2 1 2\n"; std::string ClusterPdb(const char *cryst = kCryst) { std::string pdb = cryst; std::mt19937 rng(20260902); std::uniform_real_distribution 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> 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 original = ModelPositions(st.models[0]); std::vector 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 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); } // A polar space group leaves the origin free along one direction: moving the whole cell content // along it multiplies every structure factor by a phase and changes no amplitude, so the data // cannot say where the body sits along it and the refinement must not pretend otherwise. The check // is closed - the "observed" amplitudes are the model's own - and the model is displaced in all // three directions at once, so the same run says both what is recovered and what is left alone. TEST_CASE("ModelValidation_RigidBodyLeavesThePolarDirectionAlone", "[ModelValidation]") { Logger logger("ModelValidation_RigidBodyLeavesThePolarDirectionAlone"); const auto path = WriteTemp("rigid_body_polar_test.pdb", ClusterPdb(kPolarCryst).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(); const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger); REQUIRE_FALSE(ref.empty()); gemmi::AsuData> 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(); // b is the unique axis of P 1 2 1, so y is the free direction and x and z are determined. const std::vector original = ModelPositions(st.models[0]); std::vector displaced; for (const gemmi::Position &p : original) displaced.emplace_back(p.x + 0.35, p.y + 0.50, p.z - 0.30); 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 refined = ModelPositions(st.models[0]); gemmi::Vec3 left; for (size_t i = 0; i < original.size(); i++) left += gemmi::Vec3(refined[i]) - gemmi::Vec3(original[i]); left *= 1.0 / static_cast(original.size()); logger.Info("Rigid-body polar test: left over ({:.3f}, {:.3f}, {:.3f}) A", left.x, left.y, left.z); CHECK(std::fabs(left.x) < 0.10); CHECK(std::fabs(left.z) < 0.10); // Along b nothing was refined away, because there is nothing there to refine. CHECK(left.y == Catch::Approx(0.50).margin(0.02)); std::filesystem::remove(path); } // The bulk solvent the placement is scored through has to stay inside the range a flat solvent model // means anything in. gemmi's own scaler is an unbounded Levenberg-Marquardt and reaches b_sol of // hundreds or thousands of A^2, which does not corrupt a reported number here but distorts the target // that decides where the model goes, at every one of the hundreds of evaluations. TEST_CASE("ModelValidation_RigidBodySolventStaysPhysical", "[ModelValidation]") { Logger logger("ModelValidation_RigidBodySolventStaysPhysical"); const auto path = WriteTemp("rigid_body_solvent_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(); const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger); REQUIRE_FALSE(ref.empty()); gemmi::AsuData> 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(); // Turned right around, so the model explains nothing: that is where an unbounded solvent fit // has nothing to hold it and runs away. std::vector turned = ModelPositions(st.models[0]); gemmi::Vec3 centre; for (const gemmi::Position &p : turned) centre += p; centre *= 1.0 / static_cast(turned.size()); for (gemmi::Position &p : turned) p = gemmi::Position(centre - (gemmi::Vec3(p) - centre)); SetModelPositions(st.models[0], turned); const RigidBodyRefineResult result = RefineRigidBody(st.models[0], st.cell, *sg, fobs, 3.0, logger); logger.Info("Rigid-body solvent test: k_sol {:.3f}, b_sol {:.1f} A^2", result.k_sol, result.b_sol); CHECK(result.k_sol >= 0.10); CHECK(result.k_sol <= 0.60); CHECK(result.b_sol >= 10.0); CHECK(result.b_sol <= 80.0); 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 normal(0.0, 1.0); auto weights = [&](bool agreeing) { std::vector 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 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. // A depositor's model and rugnux's own reduction routinely describe the SAME lattice on different // axes - I-centred against C-centred, unique axis b against c, a cyclic permutation of an // orthorhombic cell - and the space-group NUMBER is identical in every one of those, so nothing that // compares numbers can notice. Re-fractionalizing straight across such a pair scrambles the model, // and the rigid body cannot undo it: six parameters about a centroid are not a change of basis. The // check is closed - the data are the model's own structure factors - so a model that reaches the // data's description of the lattice comes out at R near zero, and one that does not comes out near // the 0.6 an unrelated structure gives. TEST_CASE("ModelValidation_FindsTheDatasDescriptionOfTheLattice", "[ModelValidation]") { Logger logger("ModelValidation_FindsTheDatasDescriptionOfTheLattice"); // The data: the cluster's own |F|^2 in its own frame, 30 x 34 x 38, P 21 21 21. const auto data_model = WriteTemp("cob_data_test.pdb", ClusterPdb().c_str()); auto obs = ModelReferenceIntensities(data_model, {}, {}, 2.5, logger); REQUIRE(obs.size() > 500); for (size_t i = 0; i < obs.size(); i++) { obs[i].F = std::sqrt(std::max(0.0f, obs[i].I)); obs[i].rfree_flag = (i % 20) == 0; } const UnitCell data_cell{.a = 30, .b = 34, .c = 38, .alpha = 90, .beta = 90, .gamma = 90}; const gemmi::SpaceGroup *data_sg = gemmi::find_spacegroup_by_name("P 21 21 21"); const std::string prefix = (std::filesystem::temp_directory_path() / "cob_test").string(); // The same structure as a deposition on permuted axes, a' = b, b' = c, c' = a. The cell is // orthogonal, so the Cartesian coordinates permute with the axes. std::string permuted = "CRYST1 34.000 38.000 30.000 90.00 90.00 90.00 P 21 21 21 4\n"; { std::istringstream in(ClusterPdb()); std::string line; char buf[96]; while (std::getline(in, line)) { if (line.compare(0, 4, "ATOM") != 0) continue; std::snprintf(buf, sizeof buf, "%s%8.3f%8.3f%8.3f%s", line.substr(0, 30).c_str(), std::stod(line.substr(38, 8)), std::stod(line.substr(46, 8)), std::stod(line.substr(30, 8)), line.substr(54).c_str()); permuted += buf; permuted += "\n"; } permuted += "END\n"; } const auto other_setting = WriteTemp("cob_model_test.pdb", permuted.c_str()); const auto moved = ValidateAgainstModel(obs, data_cell, other_setting, prefix, logger, data_sg, /*probe_indexing_ambiguity=*/false, 1, 1.0); REQUIRE(moved.ok); logger.Info("Change of basis test: took {} out of {}, R-work {:.4f}", moved.change_of_basis_op.triplet(), moved.setting_as_read, moved.r_work); CHECK_FALSE(moved.change_of_basis_op == gemmi::Op::identity()); CHECK(moved.r_work < 0.15); // The isomorphous case, which is what nearly every run is: the model already describes the // lattice the way the data do, the only operators that map its cell are its own symmetry, and // nothing is scored or moved. const auto same = ValidateAgainstModel(obs, data_cell, data_model, prefix, logger, data_sg, false, 1, 1.0); REQUIRE(same.ok); CHECK(same.change_of_basis_op == gemmi::Op::identity()); CHECK(same.setting_as_read.empty()); CHECK(same.r_work < 0.15); for (const char *suffix : {"_2fofc.ccp4", "_fofc.ccp4", "_anom.ccp4", "_maps.mtz"}) std::filesystem::remove(prefix + suffix); std::filesystem::remove(data_model); std::filesystem::remove(other_setting); } // An alternative indexing is settled by relabelling the DATA into the model's indexing, not by moving // the model into the data's: that is what puts every dataset of one crystal form in one convention. // Only where a reference has already fixed the data's indexing is the model moved instead. TEST_CASE("ModelValidation_ReindexesTheDataIntoTheModelsIndexing", "[ModelValidation]") { Logger logger("ModelValidation_ReindexesTheDataIntoTheModelsIndexing"); // Point group 4 on a tetragonal lattice (4/mmm): one alternative indexing. const auto model = WriteTemp("reidx_model_test.pdb", ClusterPdb("CRYST1 34.000 34.000 38.000 90.00 90.00 90.00 P 4 4\n").c_str()); auto own = ModelReferenceIntensities(model, {}, {}, 2.5, logger); REQUIRE(own.size() > 500); for (size_t i = 0; i < own.size(); i++) { own[i].F = std::sqrt(std::max(0.0f, own[i].I)); own[i].rfree_flag = (i % 20) == 0; } const UnitCell cell{.a = 34, .b = 34, .c = 38, .alpha = 90, .beta = 90, .gamma = 90}; const gemmi::SpaceGroup *p4 = gemmi::find_spacegroup_by_name("P 4"); const auto laws = ReindexAmbiguityOperators(cell, *p4); REQUIRE(laws.size() == 1); const std::string prefix = (std::filesystem::temp_directory_path() / "reidx_test").string(); // The data as a run that picked the other indexing would have merged them. const auto obs = ReindexReflections(own, laws.front()); const auto to_model = ValidateAgainstModel(obs, cell, model, prefix, logger, p4, /*probe_indexing_ambiguity=*/true, 1, 1.0); REQUIRE(to_model.ok); CHECK(to_model.change_of_basis_op == gemmi::Op::identity()); CHECK(to_model.indexing_decided); CHECK(to_model.indexing_op == laws.front()); CHECK(to_model.r_work < 0.15); // A reference already fixed the data's indexing: the data stay, and the model is moved. const auto fixed = ValidateAgainstModel(obs, cell, model, prefix, logger, p4, false, 1, 1.0); REQUIRE(fixed.ok); CHECK_FALSE(fixed.change_of_basis_op == gemmi::Op::identity()); CHECK(fixed.indexing_op == gemmi::Op::identity()); CHECK(fixed.r_work < 0.15); // A near-perfect twin of the mis-indexed data, 45 % of it in the model's indexing: the model // prefers that indexing by less than a model in a random orientation prefers one, so it has decided // nothing and the data must keep the indexing they were merged in. std::vector twinned = obs; { const gemmi::GroupOps gops = p4->operations(); const gemmi::ReciprocalAsu asu(p4); auto key = [&](const gemmi::Miller &h) { return asu.to_asu(h, gops).first; }; std::map by_hkl; for (const auto &r : obs) by_hkl[key({{r.h, r.k, r.l}})] = r.I; for (auto &r : twinned) { const auto mate = by_hkl.find(key(laws.front().apply_to_hkl({{r.h, r.k, r.l}}))); REQUIRE(mate != by_hkl.end()); r.I = 0.55f * r.I + 0.45f * mate->second; r.F = std::sqrt(std::max(0.0f, r.I)); } } const auto twin = ValidateAgainstModel(twinned, cell, model, prefix, logger, p4, true, 1, 1.0); REQUIRE(twin.ok); CHECK_FALSE(twin.indexing_decided); CHECK(twin.indexing_op == gemmi::Op::identity()); CHECK(twin.change_of_basis_op == gemmi::Op::identity()); // Data already in the model's indexing: nothing moves, and there is nothing to arbitrate. const auto same = ValidateAgainstModel(own, cell, model, prefix, logger, p4, true, 1, 1.0); REQUIRE(same.ok); CHECK(same.change_of_basis_op == gemmi::Op::identity()); CHECK(same.indexing_op == gemmi::Op::identity()); CHECK_FALSE(same.fit_tested); CHECK(same.r_work < 0.15); for (const char *suffix : {"_2fofc.ccp4", "_fofc.ccp4", "_anom.ccp4", "_maps.mtz"}) std::filesystem::remove(prefix + suffix); std::filesystem::remove(model); } 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); // The model in the data's own group, which is the ordinary case: the frame it is written in is // then wholly the caller's. The cell it arrives in (40/50/60) is not the data's and is replaced. st.spacegroup_hm = "P 43 21 2"; // 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); } // A model cannot be labelled with a group its own coordinates do not obey. Where the data were merged // in a supergroup of the model's - an over-merge across a pseudo-symmetry operation, or the hand the // model's fit did not earn - that operation would generate atoms the model does not contain, and a // refinement program acts on it silently. The model keeps its own group and the caller is warned. TEST_CASE("WriteModel_DoesNotStampAGroupTheCoordinatesDoNotObey", "[ModelValidation]") { Logger logger("WriteModel_DoesNotStampAGroupTheCoordinatesDoNotObey"); const auto input = WriteTemp("write_model_sg_test_input.pdb", kPdbRich); // P 1 const gemmi::Structure st = gemmi::read_structure_gz(input, gemmi::CoorFormat::Detect); 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_sg_test", logger); const std::string written = "write_model_sg_test_model.cif"; REQUIRE(std::filesystem::exists(written)); const gemmi::Structure back = gemmi::read_structure_gz(written, gemmi::CoorFormat::Detect); // The cell is still the data's - the coordinates do sit in it - but the group is the model's own. 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 == 1); std::filesystem::remove(input); std::filesystem::remove(written); std::filesystem::remove("write_model_sg_test_model.pdb"); } // The other direction is not a mismatch at all: a model whose coordinates obey more symmetry than the // data were merged in obeys the data's group too - the file simply holds more than one asymmetric unit // of it, which is what a model in P1 always does. The reflection files' group is kept, as before. TEST_CASE("WriteModel_KeepsTheDataGroupWhenItIsASubgroup", "[ModelValidation]") { Logger logger("WriteModel_KeepsTheDataGroupWhenItIsASubgroup"); const auto input = WriteTemp("write_model_sub_test_input.pdb", kPdbRich); gemmi::Structure st = gemmi::read_structure_gz(input, gemmi::CoorFormat::Detect); st.spacegroup_hm = "P 43 21 2"; const UnitCell data_cell{.a = 79, .b = 79, .c = 38, .alpha = 90, .beta = 90, .gamma = 90}; const gemmi::SpaceGroup *p1 = gemmi::find_spacegroup_by_name("P 1"); REQUIRE(p1 != nullptr); WritePlacedModel(st, data_cell, *p1, "write_model_sub_test", logger); const std::string written = "write_model_sub_test_model.cif"; REQUIRE(std::filesystem::exists(written)); const gemmi::Structure back = gemmi::read_structure_gz(written, gemmi::CoorFormat::Detect); REQUIRE(back.find_spacegroup() != nullptr); CHECK(back.find_spacegroup()->number == 1); std::filesystem::remove(input); std::filesystem::remove(written); std::filesystem::remove("write_model_sub_test_model.pdb"); } // CC(model, data) has to follow where the signal actually is, or it cannot support the one-sided // claim it exists for. The check is closed: the "observed" intensities are the model's own with // Gaussian noise added, and how much noise is chosen per shell - almost none in the first, some in // the second, enough to bury the signal in the third - so the answer is known before the run. TEST_CASE("ModelValidation_CCModelFollowsTheSignalByShell", "[ModelValidation]") { Logger logger("ModelValidation_CCModelFollowsTheSignalByShell"); const auto path = WriteTemp("cc_model_test.pdb", ClusterPdb().c_str()); auto obs = ModelReferenceIntensities(path, {}, {}, 2.5, logger); REQUIRE(obs.size() > 1000); // The shells the correlation is reported on, coarse to fine, and the noise each one gets as a // multiple of the r.m.s. intensity of that shell. Nothing coarser than the first shell is kept: // the reference intensities carry the bulk solvent at fixed constants while the validation fits // its own, and below about 6 A that difference is a large part of |F| and would decorrelate a // shell this test needs to be clean. const std::vector shells{5.0f, 3.2f, 2.5f}; const double noise[3] = {0.02, 1.0, 30.0}; auto shell_of = [&](float d) { for (size_t s = 0; s < shells.size(); s++) if (d > shells[s]) return s; return shells.size(); }; std::erase_if(obs, [&](const MergedReflection &r) { return r.d > 6.0f || shell_of(r.d) >= shells.size(); }); REQUIRE(obs.size() > 500); std::vector sum_i2(shells.size(), 0.0); std::vector count(shells.size(), 0); for (const auto &r : obs) { sum_i2[shell_of(r.d)] += static_cast(r.I) * r.I; ++count[shell_of(r.d)]; } std::mt19937 rng(20260907); std::normal_distribution normal(0.0, 1.0); for (size_t i = 0; i < obs.size(); i++) { const size_t bin = shell_of(obs[i].d); const double sd = noise[bin] * std::sqrt(sum_i2[bin] / count[bin]); obs[i].I = static_cast(obs[i].I + sd * normal(rng)); obs[i].sigma = static_cast(std::max(1.0, sd)); obs[i].F = std::sqrt(std::max(0.0f, obs[i].I)); obs[i].rfree_flag = (i % 20) == 0; } const std::string prefix = (std::filesystem::temp_directory_path() / "cc_model_test").string(); const auto result = ValidateAgainstModel(obs, UnitCell{.a = 30, .b = 34, .c = 38, .alpha = 90, .beta = 90, .gamma = 90}, path, prefix, logger, gemmi::find_spacegroup_by_name("P 21 21 21"), /*probe_indexing_ambiguity=*/false, 1, 1.0, shells); REQUIRE(result.ok); REQUIRE(result.cc_model_shells.size() == shells.size()); int n_total = 0; for (size_t s = 0; s < shells.size(); s++) { const auto &sh = result.cc_model_shells[s]; logger.Info("CC(model,data) {:.2f} A: {:.3f} on {} refl, {:+.1f} sigma", sh.d_min, sh.cc, sh.n, sh.sigma); CHECK(sh.d_min == shells[s]); CHECK(sh.n > 20); n_total += sh.n; } CHECK(n_total == result.cc_model_n); // Essentially noiseless: the model is the data, so the correlation is high and hugely significant. CHECK(result.cc_model_shells[0].cc > 0.9); CHECK(result.cc_model_shells[0].sigma > 10.0); // Noise at the shell's own r.m.s. still leaves plenty to see. CHECK(result.cc_model_shells[1].cc > 0.25); CHECK(result.cc_model_shells[1].sigma > 5.0); // Buried: the shell must NOT come out significant, or the one-sided test would fire on noise. CHECK(std::fabs(result.cc_model_shells[2].cc) < 0.15); CHECK(std::fabs(result.cc_model_shells[2].sigma) < 4.0); // No shells asked for, none reported: a run that did not measure it writes no key. const auto no_shells = ValidateAgainstModel(obs, UnitCell{.a = 30, .b = 34, .c = 38, .alpha = 90, .beta = 90, .gamma = 90}, path, prefix, logger, gemmi::find_spacegroup_by_name("P 21 21 21"), false, 1, 1.0); CHECK(no_shells.ok); CHECK(no_shells.cc_model_shells.empty()); for (const char *suffix : {"_2fofc.ccp4", "_fofc.ccp4", "_anom.ccp4", "_maps.mtz"}) std::filesystem::remove(prefix + suffix); std::filesystem::remove(path); } // The model path's structure factors come from FFTW; they must be gemmi's own transform to float // precision, in the same layout, so prepare_asu_data() reads the same reflections from either. TEST_CASE("ModelValidation_MapToFPhiMatchesGemmi", "[ModelValidation]") { // An even grid, and an odd one on every axis (FFTW and pocketfft split odd lengths differently). const auto size = GENERATE(std::array{20, 24, 30}, std::array{15, 21, 27}); gemmi::Grid map; map.unit_cell.set(40.0, 50.0, 60.0, 90.0, 95.0, 90.0); map.spacegroup = gemmi::find_spacegroup_by_name("P 1"); map.set_size(size[0], size[1], size[2]); std::mt19937 rng(7); std::uniform_real_distribution dist(-1.0f, 1.0f); for (auto &x : map.data) x = dist(rng); gemmi::FPhiGrid ref = gemmi::transform_map_to_f_phi(map, true); gemmi::FPhiGrid ours = MapToFPhi(map); REQUIRE(ours.nu == ref.nu); REQUIRE(ours.nv == ref.nv); REQUIRE(ours.nw == ref.nw); REQUIRE(ours.half_l == ref.half_l); REQUIRE(ours.data.size() == ref.data.size()); double largest = 0, worst = 0; for (size_t i = 0; i < ref.data.size(); i++) { largest = std::max(largest, static_cast(std::abs(ref.data[i]))); worst = std::max(worst, static_cast(std::abs(ours.data[i] - ref.data[i]))); } CHECK(worst <= 1e-5 * largest); const auto a = ref.prepare_asu_data(4.0, 0, false, false, false); const auto b = ours.prepare_asu_data(4.0, 0, false, false, false); REQUIRE(a.v.size() == b.v.size()); for (size_t i = 0; i < a.v.size(); i++) CHECK(a.v[i].hkl == b.v[i].hkl); } // The output maps come from FFTW too; each must be gemmi's own map to float precision, point for point // in the same layout. Coefficients with arbitrary phases, negative indices, expanded by symmetry and // Friedel, on grids that are odd along u and v (w is even by construction of a half-l grid). TEST_CASE("ModelValidation_MapFromFPhiMatchesGemmi", "[ModelValidation]") { const char *sg_name = GENERATE("P 1", "P 1 21 1", "P 21 21 21"); const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(sg_name); REQUIRE(sg != nullptr); gemmi::AsuData> coef; coef.unit_cell_.set(31.0, 43.0, 57.0, 90.0, sg->number == 4 ? 104.0 : 90.0, 90.0); coef.spacegroup_ = sg; const gemmi::ReciprocalAsu asu(sg); const gemmi::GroupOps gops = sg->operations(); std::mt19937 rng(11); std::uniform_real_distribution amp(0.1f, 10.0f), phase(-3.14159f, 3.14159f); for (int h = -7; h <= 7; h++) for (int k = -9; k <= 9; k++) for (int l = -11; l <= 11; l++) { const gemmi::Op::Miller hkl{{h, k, l}}; if ((h == 0 && k == 0 && l == 0) || !asu.is_in(hkl) || gops.is_systematically_absent(hkl)) continue; coef.v.push_back({hkl, std::polar(amp(rng), phase(rng))}); } REQUIRE(coef.v.size() > 500); // P 1 takes an odd grid on u and v; the screw axes need even factors. const std::array size = sg->number == 1 ? std::array{17, 21, 26} : std::array{18, 24, 26}; gemmi::FPhiGrid grid = gemmi::get_f_phi_on_grid(coef, size, true); grid.data[grid.index_n(2, -3, 4)] = std::complex(1.0f, NAN); // a missing coefficient const gemmi::Grid ours = MapFromFPhi(grid); const gemmi::Grid ref = gemmi::transform_f_phi_grid_to_map(gemmi::FPhiGrid(grid)); REQUIRE(ours.nu == ref.nu); REQUIRE(ours.nv == ref.nv); REQUIRE(ours.nw == ref.nw); REQUIRE(ours.axis_order == ref.axis_order); REQUIRE(ours.spacegroup == ref.spacegroup); REQUIRE(ours.data.size() == ref.data.size()); double largest = 0, worst = 0; for (size_t i = 0; i < ref.data.size(); i++) { REQUIRE(std::isfinite(ours.data[i])); largest = std::max(largest, static_cast(std::abs(ref.data[i]))); worst = std::max(worst, static_cast(std::abs(ours.data[i] - ref.data[i]))); } CHECK(largest > 0); CHECK(worst <= 1e-5 * largest); } // Map -> coefficients -> map is the identity (the V/N and 1/V scales cancel the unnormalised // transforms), on a grid odd along u and v. TEST_CASE("ModelValidation_ModelFFTRoundTrip", "[ModelValidation]") { gemmi::Grid map; map.unit_cell.set(35.0, 45.0, 55.0, 80.0, 95.0, 105.0); map.spacegroup = gemmi::find_spacegroup_by_name("P 1"); map.set_size(15, 21, 28); std::mt19937 rng(3); std::uniform_real_distribution dist(-1.0f, 1.0f); for (auto &x : map.data) x = dist(rng); const gemmi::Grid back = MapFromFPhi(MapToFPhi(map)); REQUIRE(back.nu == map.nu); REQUIRE(back.nv == map.nv); REQUIRE(back.nw == map.nw); double worst = 0; for (size_t i = 0; i < map.data.size(); i++) worst = std::max(worst, static_cast(std::abs(back.data[i] - map.data[i]))); CHECK(worst <= 1e-5); } // The Jacobian's six columns are evaluated in parallel; the placement must be the serial one, bit for // bit. TEST_CASE("ModelValidation_RigidBodySameOnAnyNumberOfThreads", "[ModelValidation]") { Logger logger("ModelValidation_RigidBodySameOnAnyNumberOfThreads"); const auto path = WriteTemp("rigid_body_threads_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(); const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger); REQUIRE_FALSE(ref.empty()); gemmi::AsuData> 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(); std::vector displaced; for (const gemmi::Position &p : ModelPositions(st.models[0])) displaced.emplace_back(p.x + 0.40, p.y - 0.30, p.z + 0.20); gemmi::Model serial = st.models[0], parallel = st.models[0]; SetModelPositions(serial, displaced); SetModelPositions(parallel, displaced); const RigidBodyRefineResult r1 = RefineRigidBody(serial, st.cell, *sg, fobs, 3.0, logger, 1); const RigidBodyRefineResult r6 = RefineRigidBody(parallel, st.cell, *sg, fobs, 3.0, logger, 6); CHECK(r1.evaluations == r6.evaluations); CHECK(r1.angle_deg == r6.angle_deg); CHECK(r1.shift_A == r6.shift_A); const auto p1 = ModelPositions(serial), p6 = ModelPositions(parallel); REQUIRE(p1.size() == p6.size()); for (size_t i = 0; i < p1.size(); i++) CHECK((p1[i].x == p6[i].x && p1[i].y == p6[i].y && p1[i].z == p6[i].z)); std::filesystem::remove(path); } // The rigid body puts each probe placement on the grid with its own parallel copy of gemmi's gridding // (density, solvent mask and their symmetrization). It must give gemmi's grids bit for bit, on any // number of threads, for isotropic and anisotropic atoms and for groups with and without centring. TEST_CASE("ModelValidation_ParallelGriddingMatchesGemmi", "[ModelValidation]") { const char *crysts[] = { "CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n", "CRYST1 40.000 50.000 60.000 90.00 100.00 90.00 C 1 2 1 4\n", "CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 21 21 21 4\n", "CRYST1 60.000 60.000 60.000 90.00 90.00 90.00 I 2 3 24\n", "CRYST1 80.000 80.000 80.000 90.00 90.00 90.00 F 41 3 2 96\n", }; for (const char *cryst : crysts) { const auto path = WriteTemp("parallel_gridding_test.pdb", ClusterPdb(cryst).c_str()); gemmi::Structure st = gemmi::read_structure_gz(path, gemmi::CoorFormat::Detect); std::filesystem::remove(path); const gemmi::SpaceGroup *sg = st.find_spacegroup(); REQUIRE(sg != nullptr); int i = 0; for (gemmi::Chain &ch : st.models[0].chains) for (gemmi::Residue &r : ch.residues) for (gemmi::Atom &a : r.atoms) if (i++ % 3 == 0) a.aniso = {0.30f, 0.25f, 0.20f, 0.02f, -0.01f, 0.03f}; const auto same = [](const gemmi::Grid &a, const gemmi::Grid &b) { return a.data.size() == b.data.size() && std::memcmp(a.data.data(), b.data.data(), a.data.size() * sizeof(float)) == 0; }; for (double d_min : {6.0, 3.5}) { gemmi::DensityCalculator, float> gemmi_dc; gemmi_dc.d_min = d_min; gemmi_dc.rate = 1.5; gemmi_dc.grid.unit_cell = st.cell; gemmi_dc.grid.spacegroup = sg; gemmi_dc.set_refmac_compatible_blur(st.models[0]); gemmi_dc.put_model_density_on_grid(st.models[0]); gemmi::Grid gemmi_mask; gemmi_mask.unit_cell = st.cell; gemmi_mask.spacegroup = sg; gemmi_mask.set_size_from_spacing(gemmi_dc.requested_grid_spacing(), gemmi::GridSizeRounding::Up); gemmi::SolventMasker(gemmi::AtomicRadiiSet::Refmac).put_mask_on_grid(gemmi_mask, st.models[0]); for (size_t nthreads : {1, 4}) { const std::vector leaders = OrbitLeaders(gemmi_mask, nthreads); gemmi::DensityCalculator, float> dc; dc.d_min = d_min; dc.rate = 1.5; dc.grid.unit_cell = st.cell; dc.grid.spacegroup = sg; dc.set_refmac_compatible_blur(st.models[0]); PutModelDensityOnGrid(dc, st.models[0], leaders, nthreads); CHECK(same(dc.grid, gemmi_dc.grid)); gemmi::Grid mask; mask.unit_cell = st.cell; mask.spacegroup = sg; mask.set_size_from_spacing(dc.requested_grid_spacing(), gemmi::GridSizeRounding::Up); PutMaskOnGrid(mask, st.models[0], leaders, nthreads); CHECK(same(mask, gemmi_mask)); } } } } namespace { // The five groups of ModelValidation_ParallelGriddingMatchesGemmi: none, a centring, screws, a // cubic body centring and a cubic face centring with a diamond glide. const char *kRigidBodyCrysts[] = { "CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n", "CRYST1 40.000 50.000 60.000 90.00 100.00 90.00 C 1 2 1 4\n", "CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 21 21 21 4\n", "CRYST1 60.000 60.000 60.000 90.00 90.00 90.00 I 2 3 24\n", "CRYST1 80.000 80.000 80.000 90.00 90.00 90.00 F 41 3 2 96\n", }; // ClusterPdb() in `cryst`, every third atom anisotropic. gemmi::Structure AnisoCluster(const char *cryst) { const auto path = WriteTemp("rigid_body_composition_test.pdb", ClusterPdb(cryst).c_str()); gemmi::Structure st = gemmi::read_structure_gz(path, gemmi::CoorFormat::Detect); std::filesystem::remove(path); st.setup_cell_images(); int i = 0; for (gemmi::Chain &ch : st.models[0].chains) for (gemmi::Residue &r : ch.residues) for (gemmi::Atom &a : r.atoms) if (i++ % 3 == 0) a.aniso = {0.30f, 0.25f, 0.20f, 0.02f, -0.01f, 0.03f}; return st; } // The zone's density calculator, as the rigid body sets it up for `model`. gemmi::DensityCalculator, float> ZoneDensity(const gemmi::Structure &st, const gemmi::Model &model, double d_min) { gemmi::DensityCalculator, float> dc; dc.d_min = d_min; dc.rate = 1.5; dc.grid.unit_cell = st.cell; dc.grid.spacegroup = st.find_spacegroup(); dc.set_refmac_compatible_blur(model); return dc; } } // The rigid body's Fcalc is composed from the transform of one copy of the model instead of being // taken from the symmetrized grid. The two are the same sum rearranged, so they must agree to // rounding, and must give a value to exactly the reflections prepare_asu_data() does - the // systematic absences of the group and a reflection sitting exactly on d_min (the (10 0 0) of the // 60 A cubic cell at 6 A) are left out by both. TEST_CASE("ModelValidation_RigidBodyP1FcalcMatchesSymmetrized", "[ModelValidation]") { for (const char *cryst : kRigidBodyCrysts) { const gemmi::Structure st = AnisoCluster(cryst); const gemmi::Model &model = st.models[0]; for (double d_min : {6.0, 3.5}) { auto sym = ZoneDensity(st, model, d_min); sym.put_model_density_on_grid(model); gemmi::FPhiGrid sym_f = MapToFPhi(sym.grid); const auto ref = sym_f.prepare_asu_data(d_min, sym.blur, false, false, false); // Every index of the ASU in the grid's box, whatever its resolution, absences and 000 too. std::vector candidates; for (const auto &hv : sym_f.prepare_asu_data(0, 0, true, true, false).v) candidates.push_back(hv.hkl); auto copy = ZoneDensity(st, model, d_min); PutModelDensityOnGrid(copy, model, {}, 4); const SymmetryComposition composition(copy.grid, d_min, candidates); std::vector> f; composition.Compose(MapToFPhi(copy.grid), copy.blur, f, nullptr, 4); REQUIRE(composition.Hkl().size() == ref.v.size()); double mean = 0, worst = 0; for (size_t i = 0; i < ref.v.size(); i++) { REQUIRE(composition.Hkl()[i] == ref.v[i].hkl); mean += std::abs(ref.v[i].value) / static_cast(ref.v.size()); worst = std::max(worst, std::abs(f[i] - std::complex(ref.v[i].value))); } INFO(cryst << " at " << d_min << " A: worst " << worst / mean << " of the mean |F|"); CHECK(worst <= 1e-4 * mean); } } } // The derivative of the composed Fcalc with respect to a translation of the body is a phase factor // per term, exact - against a central difference of the regridded, recomposed Fcalc. Along a // direction the origin is free in (all three in P1, b in C2) the amplitude does not change at all. TEST_CASE("ModelValidation_RigidBodyTranslationDerivativeIsExact", "[ModelValidation]") { for (const char *cryst : kRigidBodyCrysts) { const gemmi::Structure st = AnisoCluster(cryst); const double d_min = 3.5; const gemmi::DensityCalculator, float> zone = ZoneDensity(st, st.models[0], d_min); gemmi::Grid grid; grid.unit_cell = zone.grid.unit_cell; grid.spacegroup = zone.grid.spacegroup; grid.set_size_from_spacing(zone.requested_grid_spacing(), gemmi::GridSizeRounding::Up); std::vector candidates; const gemmi::ReciprocalAsu asu(grid.spacegroup); for (int h = -25; h <= 25; h++) for (int k = -25; k <= 25; k++) for (int l = -25; l <= 25; l++) if (asu.is_in({{h, k, l}})) candidates.push_back({{h, k, l}}); const SymmetryComposition composition(grid, d_min, candidates); REQUIRE(composition.Hkl().size() > 200); auto fcalc = [&](const gemmi::Vec3 &shift, std::vector, 3>> *df_dt) { gemmi::Model model = st.models[0]; for (gemmi::Chain &ch : model.chains) for (gemmi::Residue &r : ch.residues) for (gemmi::Atom &a : r.atoms) a.pos += gemmi::Position(shift); auto dc = ZoneDensity(st, model, d_min); PutModelDensityOnGrid(dc, model, {}, 4); std::vector> f; composition.Compose(MapToFPhi(dc.grid), dc.blur, f, df_dt, 4); return f; }; std::vector, 3>> df_dt; const std::vector> f = fcalc({}, &df_dt); // A five-point difference, whose truncation error goes as eps^4: the grid is float, and at the // 1e-3 A of a plain central difference its rounding alone reads as 3e-3 of the derivative. What // is left, about 5e-4, is the sampling - an atom gridded after a move is the moved atom only to // the accuracy of the grid - which the exact derivative does not have and the difference does. const double eps = 0.05; const bool p1 = st.find_spacegroup()->number == 1, c2 = st.find_spacegroup()->number == 5; for (int k = 0; k < 3; k++) { gemmi::Vec3 e; e.at(k) = eps; const auto p_1 = fcalc(e, nullptr), m_1 = fcalc(-e, nullptr); const auto p_2 = fcalc(2 * e, nullptr), m_2 = fcalc(-2 * e, nullptr); double diff = 0, norm = 0, amplitude = 0; for (size_t m = 0; m < f.size(); m++) { const std::complex numeric = (8.0 * (p_1[m] - m_1[m]) - (p_2[m] - m_2[m])) / (12 * eps); diff += std::norm(numeric - df_dt[m][k]); norm += std::norm(df_dt[m][k]); amplitude += gemmi::sq(std::real(std::conj(f[m]) * df_dt[m][k]) / std::abs(f[m])); } INFO(cryst << " axis " << k << ": relative error " << std::sqrt(diff / norm) << ", amplitude part " << std::sqrt(amplitude / norm)); CHECK(std::sqrt(diff / norm) <= 1e-3); if (p1 || (c2 && k == 1)) CHECK(std::sqrt(amplitude / norm) <= 1e-5); } } } #ifdef JFJOCH_USE_CUDA namespace { // A pool of `engines`, or a SKIP where there is none because the card is busy - over half its memory // taken by something else, where the pool falls back to the CPU by design. Anywhere else no pool fails. std::unique_ptr TestPool(const gemmi::Model &model, const gemmi::UnitCell &cell, const gemmi::SpaceGroup &sg, double d_min, size_t observations, size_t engines, Logger &logger) { auto pool = RigidBodyGPUPool::Create(model, cell, sg, d_min, observations, engines, logger); if (!pool) { size_t free = 0, total = 0; RigidBodyGPUEngine::MemoryInfo(free, total); if (free < total / 2) SKIP("No GPU engine: the card is busy (" << free / 1000000 << " of " << total / 1000000 << " MB free)"); } REQUIRE(pool); return pool; } } #endif namespace { // The rigid body's Jacobian at q against a central difference of its own residuals - which // re-fit the scale at every evaluation - with the bulk-solvent mask held, as the Jacobian holds // it. Per column (those in `columns`): the cosine between the two and the ratio of their norms. // Also logged, not checked: how far the difference moves once the mask is let move with the body, // which is what holding it costs. // The model's own amplitudes to `d_min`: "observed" data whose minimum is where the model is. gemmi::AsuData> OwnAmplitudes(const char *cryst, const gemmi::Structure &st, double d_min, Logger &logger) { const auto path = WriteTemp("rigid_body_own_amplitudes.pdb", ClusterPdb(cryst).c_str()); const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger); std::filesystem::remove(path); gemmi::AsuData> fobs; fobs.unit_cell_ = st.cell; fobs.spacegroup_ = st.find_spacegroup(); for (const auto &r : ref) if (st.cell.calculate_d({{r.h, r.k, r.l}}) >= d_min) fobs.v.push_back({{{r.h, r.k, r.l}}, {std::sqrt(r.I), 1.0f}}); fobs.ensure_sorted(); return fobs; } // The rigid body's target on the CPU, or on an engine of `gpu` where it is given. std::unique_ptr MakeTarget(gemmi::Model &model, const gemmi::Structure &st, RigidBodyGPUPool *gpu) { #ifdef JFJOCH_USE_CUDA if (gpu != nullptr) return std::make_unique(*gpu, model, st.cell, *st.find_spacegroup(), 4); #endif return std::make_unique(model, st.cell, *st.find_spacegroup(), 4); } void CheckRigidBodyJacobian(const char *cryst, const double q0[6], const std::vector &columns, double min_cosine, double max_norm_error, bool gpu = false) { Logger logger("CheckRigidBodyJacobian"); const gemmi::Structure st = AnisoCluster(cryst); const gemmi::SpaceGroup *sg = st.find_spacegroup(); // "Observed" amplitudes: the model's own, as placed at q = 0. const auto path = WriteTemp("rigid_body_jacobian_test.pdb", ClusterPdb(cryst).c_str()); const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger); std::filesystem::remove(path); REQUIRE_FALSE(ref.empty()); for (double zone : {6.0, 3.5}) { gemmi::AsuData> fobs; fobs.unit_cell_ = st.cell; fobs.spacegroup_ = sg; for (const auto &r : ref) if (st.cell.calculate_d({{r.h, r.k, r.l}}) >= zone) fobs.v.push_back({{{r.h, r.k, r.l}}, {std::sqrt(r.I), 1.0f}}); fobs.ensure_sorted(); gemmi::Model model = st.models[0]; RigidBodyGPUPool *pool = nullptr; #ifdef JFJOCH_USE_CUDA std::unique_ptr engines; if (gpu) { engines = TestPool(model, st.cell, *sg, zone, fobs.v.size(), 1, logger); pool = engines.get(); } #else (void) gpu; #endif const std::unique_ptr target_backend = MakeTarget(model, st, pool); RigidBodyTargetBase &target = *target_backend; target.SetZone(fobs, zone); const size_t n = target.NumObservations(); std::vector r(n), jacobian(n * 6); REQUIRE(target.Residuals(q0, r.data())); REQUIRE(target.Jacobian(q0, jacobian.data())); auto difference = [&](int j, double h) { double qp[6], qm[6]; std::copy(q0, q0 + 6, qp); std::copy(q0, q0 + 6, qm); qp[j] += h; qm[j] -= h; std::vector rp(n), rm(n), d(n); REQUIRE(target.Residuals(qp, rp.data())); REQUIRE(target.Residuals(qm, rm.data())); for (size_t i = 0; i < n; i++) d[i] = (rp[i] - rm[i]) / (2 * h); return d; }; std::vector> held(6), moving(6); target.hold_mask = true; REQUIRE(target.Residuals(q0, r.data())); // the mask the difference holds is q0's for (int j : columns) held[j] = difference(j, 0.005); // The mask is binary on the grid, so with it moving the difference is taken at the step the // Jacobian had when every column moved it (0.01 of the zone's resolution): at 0.005 A it // mostly measures which grid points a few atoms happened to cross. target.hold_mask = false; for (int j : columns) moving[j] = difference(j, 0.01 * zone); double largest = 0; for (int j : columns) { double s = 0; for (size_t i = 0; i < n; i++) s += gemmi::sq(held[j][i]); largest = std::max(largest, std::sqrt(s)); } for (int j : columns) { double dot = 0, ours = 0, theirs = 0, dot_moving = 0, norm_moving = 0; for (size_t i = 0; i < n; i++) { dot += jacobian[i * 6 + j] * held[j][i]; ours += gemmi::sq(jacobian[i * 6 + j]); theirs += gemmi::sq(held[j][i]); dot_moving += moving[j][i] * held[j][i]; norm_moving += gemmi::sq(moving[j][i]); } ours = std::sqrt(ours); theirs = std::sqrt(theirs); norm_moving = std::sqrt(norm_moving); const double cosine = dot / (ours * theirs); logger.Info("{} at {:.1f} A, column {}: cosine {:.5f}, norm {:.4f} of the difference's; " "with the mask moving, cosine {:.4f} and norm {:.4f}", std::string(cryst).substr(55, 11), zone, j, cosine, ours / theirs, dot_moving / (norm_moving * theirs), norm_moving / theirs); INFO(cryst << " at " << zone << " A, column " << j << ": cosine " << cosine << ", norm ratio " << ours / theirs); if (theirs < 1e-3 * largest) { // A direction the origin is free in: |F| does not change, so neither may the column. CHECK(ours < 1e-2 * largest); continue; } CHECK(cosine >= min_cosine); CHECK(std::fabs(ours / theirs - 1) <= max_norm_error); } } } } // The rotation columns at a placement already rotated (about 1.4 deg, so the step is taken from a // rotated body and not from the model as read) with anisotropic atoms, whose U the placement does not // turn with the body - the columns must differentiate exactly the function the residuals evaluate. TEST_CASE("ModelValidation_RigidBodyRotationDerivativeMatchesDifferences", "[ModelValidation]") { const double q0[6] = {0.12, -0.09, 0.07, 0.15, -0.10, 0.05}; for (const char *cryst : kRigidBodyCrysts) CheckRigidBodyJacobian(cryst, q0, {0, 1, 2}, 0.99, 0.03); } // The whole Jacobian - analytic translation, forward-difference rotation, the scale re-fit folded in by // projection - against the central difference of the residuals it describes. The projection is // Kaufman's, which leaves out a term that grows with the residuals (the scale's derivatives depend on // the placement too, weighted by how badly the model fits): with the body 0.07 A off it agrees with the // difference to about 2e-3 in cosine, with it 0.35 A off to about 2e-2, and the bounds follow. TEST_CASE("ModelValidation_RigidBodyJacobianMatchesNumericScaleRefit", "[ModelValidation]") { const double close[6] = {0.0, 0.0, 0.0, 0.05, -0.04, 0.03}; const double far[6] = {0.0, 0.0, 0.0, 0.25, -0.20, 0.15}; for (const char *cryst : kRigidBodyCrysts) { CheckRigidBodyJacobian(cryst, close, {0, 1, 2, 3, 4, 5}, 0.99, 0.02); CheckRigidBodyJacobian(cryst, far, {0, 1, 2, 3, 4, 5}, 0.975, 0.03); } } // The null checks where a replicate ENDED against the orientations equivalent to the model's, from the // rotation the rigid body reports - which must be the rotation it applied: every atom's offset from the // centroid after the refinement is that rotation of its offset before. TEST_CASE("ModelValidation_RigidBodyReportsTheRotationItApplied", "[ModelValidation]") { Logger logger("ModelValidation_RigidBodyReportsTheRotationItApplied"); const auto path = WriteTemp("rigid_body_rotation_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(); const auto ref = ModelReferenceIntensities(path, {}, {}, 3.0, logger); std::filesystem::remove(path); REQUIRE_FALSE(ref.empty()); gemmi::AsuData> 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(); // Turned 3 deg about z through the centroid, so there is a rotation to take back. std::vector turned = ModelPositions(st.models[0]); gemmi::Vec3 centre; for (const gemmi::Position &p : turned) centre += p; centre *= 1.0 / static_cast(turned.size()); const double a = 3.0 * PI / 180.0; const gemmi::Mat33 rz(std::cos(a), -std::sin(a), 0, std::sin(a), std::cos(a), 0, 0, 0, 1); for (gemmi::Position &p : turned) p = gemmi::Position(rz.multiply(gemmi::Vec3(p) - centre) + centre); SetModelPositions(st.models[0], turned); const RigidBodyRefineResult result = RefineRigidBody(st.models[0], st.cell, *sg, fobs, 3.0, logger); REQUIRE(result.angle_deg > 1.0); const std::vector refined = ModelPositions(st.models[0]); gemmi::Vec3 moved_centre; for (const gemmi::Position &p : refined) moved_centre += p; moved_centre *= 1.0 / static_cast(refined.size()); double worst = 0; for (size_t i = 0; i < refined.size(); i++) { const gemmi::Vec3 expected = result.rotation.multiply(gemmi::Vec3(turned[i]) - centre); worst = std::max(worst, (gemmi::Vec3(refined[i]) - moved_centre - expected).length()); } CHECK(worst < 1e-6); const double trace = result.rotation[0][0] + result.rotation[1][1] + result.rotation[2][2]; CHECK(std::acos(std::clamp((trace - 1) / 2, -1.0, 1.0)) * 180 / PI == Catch::Approx(result.angle_deg).margin(1e-6)); } #ifdef JFJOCH_USE_CUDA // The gather's bound on the bricks a box falls in along one axis, against every box on small grids - // wrapped boxes on grids that are not a multiple of the brick included (n = 17: 15, 16, 0 fall in bricks // 1, 2 and 0). Host arithmetic, no device needed. TEST_CASE("RigidBodyGPU_AxisBrickBoundCoversWrappedBoxes", "[ModelValidation][gpu]") { CHECK(RigidBodyGPUEngine::AxisBrickBound(1, 17) == 3); const int brick = 8; for (int n = 1; n <= 64; n++) for (int d = 0; 2 * d + 1 <= n; d++) for (int c = 0; c < n; c++) { std::set bricks; for (int p = c - d; p <= c + d; p++) bricks.insert(((p % n) + n) % n / brick); INFO("n " << n << ", d " << d << ", c " << c); CHECK(bricks.size() <= RigidBodyGPUEngine::AxisBrickBound(d, n)); } } // The GPU target is the CPU target's function, computed on the device: at the same placement the two // give the same residuals and the same Jacobian to rounding (float distances on the device, cuFFT for // FFTW), on every group of the composition tests, both zones, anisotropic atoms included. TEST_CASE("RigidBodyGPU_MatchesCPU", "[ModelValidation][gpu]") { if (get_gpu_count() == 0) SKIP("No GPU"); Logger logger("RigidBodyGPU_MatchesCPU"); const double q0[6] = {0.12, -0.09, 0.07, 0.15, -0.10, 0.05}; for (const char *cryst : kRigidBodyCrysts) { const gemmi::Structure st = AnisoCluster(cryst); const gemmi::SpaceGroup *sg = st.find_spacegroup(); for (double zone : {6.0, 3.5}) { const auto fobs = OwnAmplitudes(cryst, st, zone, logger); gemmi::Model cpu_model = st.models[0], gpu_model = st.models[0]; auto pool = TestPool(gpu_model, st.cell, *sg, zone, fobs.v.size(), 1, logger); RigidBodyTarget cpu(cpu_model, st.cell, *sg, 4); RigidBodyTargetGPU gpu(*pool, gpu_model, st.cell, *sg, 4); cpu.SetZone(fobs, zone); gpu.SetZone(fobs, zone); const size_t n = cpu.NumObservations(); REQUIRE(gpu.NumObservations() == n); std::vector rc(n), rg(n), jc(6 * n), jg(6 * n); REQUIRE(cpu.Residuals(q0, rc.data())); REQUIRE(gpu.Residuals(q0, rg.data())); REQUIRE(cpu.Jacobian(q0, jc.data())); REQUIRE(gpu.Jacobian(q0, jg.data())); CHECK(gpu.unmatched == cpu.unmatched); CHECK(gpu.k_sol == cpu.k_sol); CHECK(gpu.b_sol == cpu.b_sol); double worst = 0, rms = 0; for (size_t i = 0; i < n; i++) { worst = std::max(worst, std::fabs(rc[i] - rg[i])); rms += rc[i] * rc[i] / static_cast(n); } INFO(cryst << " at " << zone << " A: residuals differ by at most " << worst << ", rms residual " << std::sqrt(rms)); // Most cases agree to a few 1e-6 of . The bound is set by gemmi's scale fit instead: its // Levenberg-Marquardt stops at a relative change of 1e-5, so a near-tie in its accept or stop // decision - which a 1e-5 change of Fcalc can flip, on the CPU alone - moves the scale by up // to about 1e-4 of |F|. CHECK(worst <= 2e-4); for (int j = 0; j < 6; j++) { double diff = 0, norm = 0; for (size_t i = 0; i < n; i++) { diff += gemmi::sq(jc[6 * i + j] - jg[6 * i + j]); norm += gemmi::sq(jc[6 * i + j]); } INFO(cryst << " at " << zone << " A, column " << j << ": relative difference " << std::sqrt(diff / norm)); // Measured at up to 5e-5 (the rotation columns, a forward difference of float-gridded // density; the translation columns agree to about 1e-6). Every column is proportional to // the overall scale, which the near-tie above can move by 1e-4, so that is the floor of // an honest bound; 5e-4 leaves room for the difference's own rounding on another card. CHECK(std::sqrt(diff) <= 5e-4 * std::sqrt(norm) + 1e-9); } } } } // The whole Jacobian on the GPU against the central difference of the GPU's own residuals, with the // bounds of ModelValidation_RigidBodyJacobianMatchesNumericScaleRefit. TEST_CASE("RigidBodyGPU_JacobianMatchesNumericScaleRefit", "[ModelValidation][gpu]") { if (get_gpu_count() == 0) SKIP("No GPU"); const double close[6] = {0.0, 0.0, 0.0, 0.05, -0.04, 0.03}; const double rotated[6] = {0.12, -0.09, 0.07, 0.15, -0.10, 0.05}; for (const char *cryst : kRigidBodyCrysts) { CheckRigidBodyJacobian(cryst, close, {0, 1, 2, 3, 4, 5}, 0.99, 0.02, true); CheckRigidBodyJacobian(cryst, rotated, {0, 1, 2}, 0.99, 0.03, true); } } namespace { // A whole placement on the GPU from the model displaced by 0.54 A and turned 2 deg, against the // model's own amplitudes; the model is left where the fit put it. RigidBodyRefineResult DisplacedFit(const char *cryst, gemmi::Structure &st, RigidBodyGPUPool *pool, Logger &logger) { const gemmi::SpaceGroup *sg = st.find_spacegroup(); const auto fobs = OwnAmplitudes(cryst, st, 3.0, logger); std::vector moved = ModelPositions(st.models[0]); gemmi::Vec3 centre; for (const gemmi::Position &p : moved) centre += p; centre *= 1.0 / static_cast(moved.size()); const double a = 2.0 * PI / 180.0; const gemmi::Mat33 rz(std::cos(a), -std::sin(a), 0, std::sin(a), std::cos(a), 0, 0, 0, 1); for (gemmi::Position &p : moved) p = gemmi::Position(rz.multiply(gemmi::Vec3(p) - centre) + centre + gemmi::Vec3(0.40, -0.30, 0.20)); SetModelPositions(st.models[0], moved); return RefineRigidBody(st.models[0], st.cell, *sg, fobs, 3.0, logger, 4, pool); } } // The GPU fit walks where the CPU fit walks: from the same displaced start, to the same placement. TEST_CASE("RigidBodyGPU_FitAgreesWithCPU", "[ModelValidation][gpu]") { if (get_gpu_count() == 0) SKIP("No GPU"); Logger logger("RigidBodyGPU_FitAgreesWithCPU"); for (const char *cryst : {kCryst, kPolarCryst, kRigidBodyCrysts[3]}) { gemmi::Structure cpu_st = AnisoCluster(cryst), gpu_st = AnisoCluster(cryst); auto pool = TestPool(gpu_st.models[0], gpu_st.cell, *gpu_st.find_spacegroup(), 3.0, 100000, 1, logger); const RigidBodyRefineResult cpu = DisplacedFit(cryst, cpu_st, nullptr, logger); const RigidBodyRefineResult gpu = DisplacedFit(cryst, gpu_st, pool.get(), logger); CHECK(gpu.converged == cpu.converged); const std::vector pc = ModelPositions(cpu_st.models[0]), pg = ModelPositions(gpu_st.models[0]); double rmsd = 0; for (size_t i = 0; i < pc.size(); i++) rmsd += pc[i].dist_sq(pg[i]) / static_cast(pc.size()); INFO(cryst << ": CPU " << cpu.angle_deg << " deg " << cpu.shift_A << " A, GPU " << gpu.angle_deg << " deg " << gpu.shift_A << " A, " << std::sqrt(rmsd) << " A apart"); // Measured at up to 5e-7 A: the endpoint moves only by what the residuals differ by (a few // 1e-6 of ) over the target's curvature. The bound leaves a factor 20 for another card's // cuFFT and float rounding, and is still four orders under anything a placement is judged at. CHECK(std::sqrt(rmsd) < 1e-5); } } // Deterministic: the same fit twice, and on a pool of one engine and of four, gives the same placement // bit for bit. TEST_CASE("RigidBodyGPU_Deterministic", "[ModelValidation][gpu]") { if (get_gpu_count() == 0) SKIP("No GPU"); Logger logger("RigidBodyGPU_Deterministic"); const char *cryst = kRigidBodyCrysts[4]; std::vector> placed; for (size_t engines : {1, 1, 4}) { gemmi::Structure st = AnisoCluster(cryst); auto pool = TestPool(st.models[0], st.cell, *st.find_spacegroup(), 3.0, 100000, engines, logger); const RigidBodyRefineResult r = DisplacedFit(cryst, st, pool.get(), logger); CHECK(r.evaluations > 0); placed.push_back(ModelPositions(st.models[0])); } for (size_t k = 1; k < placed.size(); k++) for (size_t i = 0; i < placed[0].size(); i++) { CHECK(placed[k][i].x == placed[0][i].x); CHECK(placed[k][i].y == placed[0][i].y); CHECK(placed[k][i].z == placed[0][i].z); } } #endif