v1.0.0-rc.175 (#85)
Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m56s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m10s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m51s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m56s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m59s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m25s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m17s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 14m14s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m59s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 15m52s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m33s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m19s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m49s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m31s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m24s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m5s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m19s
Build Packages / Unit tests (push) Failing after 2h28m24s
Build Packages / Validation Battery (push) Successful in 40m33s

* Rugnux: Processes a collection live while jfjoch_broker writes it (`rugnux <master.h5> <broker URL>`).
* Rugnux: Processing starts on the first images and confirms its choices once all are in, so analysis can begin during collection.
* Rugnux: Faster processing, especially on servers with several GPUs and CPU sockets and on computers without a GPU (`--no-gpu`, macOS).
* Rugnux: Uses much less GPU memory, so large data sets stay on the GPU of smaller cards, and a data set too large for the card is processed on the CPU instead.
* Rugnux: On macOS, Bragg integration runs on the Apple GPU.
* Rugnux: Better handling of twinning and pseudo-symmetry, with the twin hypothesis written as `<prefix>_twin_<SG>.mtz`, and a wrong `--model` no longer confirms the data or decides the hand.
* Rugnux: `--model` accepts predicted (AlphaFold) models and falls back to molecular replacement when the model is not placed as a rigid body (experimental `--mr` forces it), settling space group, hand and twinning and writing a different answer as `<prefix>_mr.mtz`.
* Rugnux: Molecular replacement is faster and settles the space group in more cases with several copies in the asymmetric unit.
* Rugnux: Deposition-ready mmCIF, a richer report, and numbered steps on the terminal with the full log in `<prefix>_log.txt`; `_P1.mtz`, `--estimate-beam-center` and `--beam-center-search` are removed.
* Rugnux: The beam-stop shadow is found correctly on tilted detectors.
* Rugnux: Stills files that record a stationary goniometer axis get the default stills geometry refinement.
* jfjoch_viewer: "Analyze dataset" uses the same defaults as a bare `rugnux <file>`, and shows the results of processed CBF, marCCD and SMV sweeps.
* jfjoch_viewer: The Files panel refreshes on F5 and every few seconds, and the plot type and other selections stay put during live collection.
* jfjoch_writer: Memory stays bounded and old data files are closed efficiently on long runs, any failure ends the run in an error state the broker is told about, and the master file appears only after its data files; the ZeroMQ pusher logs when a stalled writer makes it drop images.
* Fixes: The TCP image pusher no longer blocks the broker's receiver, GPU out-of-memory failures stop with a clear message, and the XDS plugin masks module and chip edges.

---------

Co-authored-by: Filip Leonarski <jungfrau@filips-mac-mini.home>
Reviewed-on: #85
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
This commit was merged in pull request #85.
This commit is contained in:
leonarski_fandFilip Leonarski authored and leonarski_f committed 2026-10-16 15:34:43 +02:00
1 parent a395f358ef
commit 19b135186d
779 files changed
+201230 -17809

No files matched your search

+356 -21
View File
@@ -3,6 +3,7 @@
#include <catch2/catch_all.hpp>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
@@ -18,17 +19,18 @@
#include <gemmi/solmask.hpp>
#include "../common/Logger.h"
#include "../rugnux/ModelFFT.h"
#include "../rugnux/ModelGrid.h"
#include "../rugnux/ModelValidation.h"
#include "../rugnux/RigidBodyRefine.h"
#include "../image_analysis/structure_refinement/ModelFFT.h"
#include "../image_analysis/structure_refinement/ModelGrid.h"
#include "../image_analysis/structure_refinement/ModelValidation.h"
#include "../image_analysis/structure_refinement/RigidBodyRefine.h"
#ifdef JFJOCH_USE_CUDA
#include "../rugnux/RigidBodyGPU.h"
#include "../rugnux/RigidBodyGPUEngine.h"
#include "../image_analysis/structure_refinement/ModelStructureFactorsGPU.h"
#include "../image_analysis/structure_refinement/RigidBodyGPU.h"
#include "../image_analysis/structure_refinement/RigidBodyGPUEngine.h"
#include "../common/CUDAWrapper.h"
#endif
#include "../rugnux/SigmaA.h"
#include "../rugnux/WriteModel.h"
#include "../image_analysis/structure_refinement/SigmaA.h"
#include "../writer/WriteModel.h"
#include "../image_analysis/scale_merge/ReindexAmbiguity.h"
namespace {
@@ -75,9 +77,9 @@ ATOM 2 C . CB GLY A 1 12.000 14.000 16.000 1.00 20.00
// 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 ClusterPdb(const char *cryst = kCryst, unsigned seed = 20260902) {
std::string pdb = cryst;
std::mt19937 rng(20260902);
std::mt19937 rng(seed);
std::uniform_real_distribution<double> x(2, 14), y(2, 16), z(2, 18);
char line[96];
for (int i = 1; i <= 150; i++) {
@@ -110,6 +112,100 @@ ATOM 2 C . CB GLY A 1 12.000 14.000 16.000 1.00 20.00
// --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.
namespace {
constexpr size_t GB = 1000000000;
// A validation's parts, sized as a large-cell case measures: structure factors 1.40 GB to d_min and
// 0.89 GB to the null's limit, rigid-body engines of 1.88 GB (ten wanted), scale fits of 0.25 GB (four).
ModelValidationGPUNeed LargeCellNeed(size_t card) {
ModelValidationGPUNeed need;
need.card = card;
need.structure_factors = 1400000000;
need.null_structure_factors = 890000000;
need.rigid_body = 1882000000;
need.rigid_body_engines = 10;
need.scale_fit = 252500000;
need.scale_fit_engines = 4;
return need;
}
}
TEST_CASE("ModelValidation_GPUPlanOnCardSizes", "[ModelValidation]") {
size_t last_rb = 0, last_scale = 0;
for (size_t card : {8 * GB, 16 * GB, 24 * GB, 128 * GB}) {
const ModelValidationGPUPlan plan = PlanModelValidationGPU(LargeCellNeed(card));
CAPTURE(card, plan.rigid_body_engines, plan.scale_fit_engines, plan.bytes);
CHECK(plan.budget == card / 5 * 4);
CHECK(plan.bytes <= plan.budget);
// The parts stay on the GPU on every one of these cards; only the engine counts grow with it.
CHECK(plan.structure_factors);
CHECK(plan.null_structure_factors);
CHECK(plan.rigid_body_engines >= std::max<size_t>(last_rb, 1));
CHECK(plan.scale_fit_engines >= std::max<size_t>(last_scale, 1));
last_rb = plan.rigid_body_engines;
last_scale = plan.scale_fit_engines;
}
// 8 GB, a 6.40 GB budget: one engine of each, 4.42 GB, already over half of it, so no more engines.
const ModelValidationGPUPlan small = PlanModelValidationGPU(LargeCellNeed(8 * GB));
CHECK(small.rigid_body_engines == 1);
CHECK(small.scale_fit_engines == 1);
CHECK(small.bytes == size_t(4424500000));
// 16 GB, half of the 12.8 GB budget: a second rigid-body engine, 6.31 GB, and no second scale fit.
const ModelValidationGPUPlan mid = PlanModelValidationGPU(LargeCellNeed(16 * GB));
CHECK(mid.rigid_body_engines == 2);
CHECK(mid.scale_fit_engines == 1);
CHECK(!small.may_run_beside);
// 128 GB: every engine wanted, and room for a second validation of the same size.
const ModelValidationGPUPlan large = PlanModelValidationGPU(LargeCellNeed(128 * GB));
CHECK(large.rigid_body_engines == 10);
CHECK(large.scale_fit_engines == 4);
CHECK(large.may_run_beside);
}
TEST_CASE("ModelValidation_GPUPlanPartThatDoesNotFitGoesToTheCPU", "[ModelValidation]") {
ModelValidationGPUNeed need = LargeCellNeed(8 * GB);
need.structure_factors = 7 * GB; // over the 6.4 GB budget on its own
const ModelValidationGPUPlan plan = PlanModelValidationGPU(need);
CHECK(!plan.structure_factors);
CHECK(plan.null_structure_factors);
CHECK(plan.rigid_body_engines >= 1);
CHECK(plan.scale_fit_engines >= 1);
CHECK(plan.bytes <= plan.budget);
// A part the GPU cannot grid (0 bytes) is never placed.
need = LargeCellNeed(16 * GB);
need.null_structure_factors = 0;
need.rigid_body = 0;
const ModelValidationGPUPlan no_parts = PlanModelValidationGPU(need);
CHECK(no_parts.structure_factors);
CHECK(!no_parts.null_structure_factors);
CHECK(no_parts.rigid_body_engines == 0);
}
TEST_CASE("ModelValidation_GPUPlanBesideChangesOnlyEngines", "[ModelValidation]") {
ModelValidationGPUNeed need = LargeCellNeed(24 * GB);
const ModelValidationGPUPlan alone = PlanModelValidationGPU(need);
need.beside = alone.budget; // another validation holding the whole budget
const ModelValidationGPUPlan beside = PlanModelValidationGPU(need);
CHECK(beside.structure_factors == alone.structure_factors);
CHECK(beside.null_structure_factors == alone.null_structure_factors);
CHECK(beside.rigid_body_engines == 1);
CHECK(beside.scale_fit_engines == 1);
CHECK(alone.rigid_body_engines > 1);
// What is resident does decide: with most of the card held by a merge, 2.2 GB are left, which take
// the structure factors and the scale fits but not the rigid body or the null's structure factors.
need = LargeCellNeed(24 * GB);
need.resident = 17 * GB;
const ModelValidationGPUPlan full = PlanModelValidationGPU(need);
CHECK(full.budget == 24 * GB / 5 * 4 - 17 * GB);
CHECK(full.bytes <= full.budget);
CHECK(full.structure_factors);
CHECK(full.rigid_body_engines == 0);
CHECK(!full.null_structure_factors);
CHECK(full.scale_fit_engines == 1);
}
TEST_CASE("ModelValidation_ReadsPdbAndMmcif", "[ModelValidation]") {
Logger logger("ModelValidation_ReadsPdbAndMmcif");
@@ -133,6 +229,46 @@ TEST_CASE("ModelValidation_ReadsPdbAndMmcif", "[ModelValidation]") {
std::filesystem::remove(misnamed);
}
// A deposition can hold part of the asymmetric unit and the strict-NCS operators that generate the rest
// (MTRIX without the given flag). The model it describes is the expanded one: here the two atoms and
// their copy half a cell along a, which the explicit four-atom model spells out.
TEST_CASE("ModelValidation_ExpandsStrictNcs", "[ModelValidation]") {
Logger logger("ModelValidation_ExpandsStrictNcs");
const char *with_ncs =
"CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n"
"MTRIX1 1 1.000000 0.000000 0.000000 0.00000 1\n"
"MTRIX2 1 0.000000 1.000000 0.000000 0.00000 1\n"
"MTRIX3 1 0.000000 0.000000 1.000000 0.00000 1\n"
"MTRIX1 2 1.000000 0.000000 0.000000 20.00000\n"
"MTRIX2 2 0.000000 1.000000 0.000000 0.00000\n"
"MTRIX3 2 0.000000 0.000000 1.000000 0.00000\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";
const char *expanded =
"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"
"ATOM 3 CA GLY B 1 30.000 12.000 14.000 1.00 20.00 C\n"
"ATOM 4 CB GLY B 1 32.000 14.000 16.000 1.00 20.00 C\n"
"END\n";
const auto ncs_path = WriteTemp("model_validation_test_ncs.pdb", with_ncs);
const auto full_path = WriteTemp("model_validation_test_ncs_full.pdb", expanded);
const auto from_ncs = ModelReferenceIntensities(ncs_path, {}, {}, 4.0, logger);
const auto from_full = ModelReferenceIntensities(full_path, {}, {}, 4.0, logger);
REQUIRE(from_ncs.size() == from_full.size());
REQUIRE_FALSE(from_ncs.empty());
for (size_t i = 0; i < from_ncs.size(); i++) {
CHECK(from_ncs[i].h == from_full[i].h);
CHECK(from_ncs[i].I == Catch::Approx(from_full[i].I).margin(1e-3 * from_full[i].I + 1e-6));
}
std::filesystem::remove(ncs_path);
std::filesystem::remove(full_path);
}
// 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.
@@ -469,12 +605,14 @@ TEST_CASE("ModelValidation_ReindexesTheDataIntoTheModelsIndexing", "[ModelValida
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.
// Data already in the model's indexing: nothing moves. The null is run all the same, as on every
// validation that judges its model (ModelValidationSchedule::fit_test), and the model fits.
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.fit_tested);
CHECK(same.model_fits);
CHECK(same.r_work < 0.15);
for (const char *suffix : {"_2fofc.ccp4", "_fofc.ccp4", "_anom.ccp4", "_maps.mtz"})
@@ -679,6 +817,79 @@ TEST_CASE("ModelValidation_CCModelFollowsTheSignalByShell", "[ModelValidation]")
// 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.
// A wrong model may come in a group that does not keep the data's metric - a cubic group on an
// orthorhombic cell - and then its ASU holds an observation at another resolution than the data's own:
// more of them land inside the rigid body's finest zone than the data put there. The GPU engines are
// reserved for the zone as it is counted, so the validation runs instead of failing on the device.
TEST_CASE("ModelValidation_WrongModelInAGroupOffTheDataMetric", "[ModelValidation]") {
Logger logger("ModelValidation_WrongModelInAGroupOffTheDataMetric");
const auto data_path = WriteTemp("off_metric_data.pdb", ClusterPdb().c_str());
const auto wrong_path = WriteTemp("off_metric_wrong.pdb",
ClusterPdb("CRYST1 40.000 40.000 40.000 90.00 90.00 90.00 I 21 3 24\n", 7).c_str());
auto obs = ModelReferenceIntensities(data_path, {}, {}, 2.0, logger);
REQUIRE(obs.size() > 1000);
for (size_t i = 0; i < obs.size(); i++) {
obs[i].sigma = std::max(1.0f, 0.05f * obs[i].I);
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() / "off_metric_test").string();
const auto result =
ValidateAgainstModel(obs, UnitCell{.a = 30, .b = 34, .c = 38, .alpha = 90, .beta = 90, .gamma = 90},
wrong_path, prefix, logger, gemmi::find_spacegroup_by_name("P 21 21 21"));
CHECK(result.ok);
CHECK(!result.adopted_model_enantiomorph);
for (const char *suffix : {"_2fofc.ccp4", "_fofc.ccp4", "_anom.ccp4", "_maps.mtz"})
std::filesystem::remove(prefix + suffix);
std::filesystem::remove(data_path);
std::filesystem::remove(wrong_path);
}
// A model of something else entirely, scaled to the data, shares their falloff across every shell, and
// by Fisher's transform on independent reflections that alone reads as a significant correlation. Against
// the null of the data at h with the model at h + d - which shares the falloff too - it must not.
TEST_CASE("ModelValidation_CCModelUnrelatedModelConfirmsNothing", "[ModelValidation]") {
Logger logger("ModelValidation_CCModelUnrelatedModelConfirmsNothing");
const auto data_path = WriteTemp("cc_null_data.pdb", ClusterPdb().c_str());
const auto wrong_path = WriteTemp("cc_null_wrong.pdb", ClusterPdb(kCryst, 7).c_str());
auto obs = ModelReferenceIntensities(data_path, {}, {}, 2.0, logger);
std::erase_if(obs, [](const MergedReflection &r) { return r.d > 8.0f; });
REQUIRE(obs.size() > 1000);
for (size_t i = 0; i < obs.size(); i++) {
// An anisotropic falloff, B 30 A^2 more along a* than along b* and c*: the validation fits an
// anisotropic B to the data and puts it on the model, whatever the model is.
const double s_a = obs[i].h / 30.0;
obs[i].I = static_cast<float>(obs[i].I * std::exp(-30.0 * s_a * s_a / 2.0));
obs[i].sigma = std::max(1.0f, 0.05f * obs[i].I);
obs[i].F = std::sqrt(std::max(0.0f, obs[i].I));
obs[i].rfree_flag = (i % 20) == 0;
}
const std::vector<float> shells{4.0f, 3.0f, 2.5f, 2.2f, 2.0f};
const std::string prefix = (std::filesystem::temp_directory_path() / "cc_null_test").string();
const auto result =
ValidateAgainstModel(obs, UnitCell{.a = 30, .b = 34, .c = 38, .alpha = 90, .beta = 90, .gamma = 90},
wrong_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());
double fisher_max = 0;
for (const auto &sh : result.cc_model_shells) {
const double fisher = std::atanh(sh.cc) * std::sqrt(sh.n - 3.0);
fisher_max = std::max(fisher_max, fisher);
logger.Info("unrelated model {:.2f} A: CC {:.3f} (null {:.3f}) on {}: {:+.1f} sigma, {:+.1f} by Fisher alone",
sh.d_min, sh.cc, sh.cc_null, sh.n, sh.sigma, fisher);
CHECK(sh.sigma < 3.0);
}
// The trap is there: on independent reflections some shell would have read as significant.
CHECK(fisher_max > 3.0);
for (const char *suffix : {"_2fofc.ccp4", "_fofc.ccp4", "_anom.ccp4", "_maps.mtz"})
std::filesystem::remove(prefix + suffix);
std::filesystem::remove(data_path);
std::filesystem::remove(wrong_path);
}
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<int, 3>{20, 24, 30}, std::array<int, 3>{15, 21, 27});
@@ -887,6 +1098,42 @@ TEST_CASE("ModelValidation_ParallelGriddingMatchesGemmi", "[ModelValidation]") {
}
}
// gemmi's radius search starts at an IT92 approximation of the radius, and for some B the density there
// is exactly the cutoff: the search then stopped on that first point and divided 0 by 0. A nitrogen at
// B = 277.64563 is one such B; its NaN radius gave the gridding a box it overran (the heap overflow
// that killed molecular replacement on a large deposited model).
TEST_CASE("ModelValidation_GriddingAnAtomWhoseRadiusStartsAtTheCutoff", "[ModelValidation]") {
const auto path = WriteTemp("cutoff_radius_test.pdb",
ClusterPdb("CRYST1 40.000 50.000 60.000 90.00 90.00 90.00 P 1 1\n").c_str());
gemmi::Structure st = gemmi::read_structure_gz(path, gemmi::CoorFormat::Detect);
std::filesystem::remove(path);
for (gemmi::Chain &ch : st.models[0].chains)
for (gemmi::Residue &r : ch.residues)
for (gemmi::Atom &a : r.atoms) {
a.element = gemmi::Element(gemmi::El::N);
a.b_iso = 277.64563f;
}
gemmi::DensityCalculator<gemmi::IT92<float>, float> dc;
dc.d_min = 3.5;
dc.rate = 1.5;
dc.grid.unit_cell = st.cell;
dc.grid.spacegroup = st.find_spacegroup();
dc.blur = 0; // the atoms' B is the B gridded
const auto precal = gemmi::IT92<float>::get(gemmi::El::N, 0, -1).precalculate_density_iso(277.64563f, 0.0f);
const float radius = dc.estimate_radius(precal, 277.64563f);
REQUIRE(std::isfinite(radius));
CHECK(radius == gemmi::it92_radius_approx(277.64563f));
CHECK(precal.calculate(radius * radius) == dc.cutoff);
gemmi::DensityCalculator<gemmi::IT92<float>, float> gemmi_dc = dc;
gemmi_dc.put_model_density_on_grid(st.models[0]);
PutModelDensityOnGrid(dc, st.models[0], {}, 4);
REQUIRE(dc.grid.data.size() == gemmi_dc.grid.data.size());
CHECK(std::memcmp(dc.grid.data.data(), gemmi_dc.grid.data.data(), dc.grid.data.size() * sizeof(float)) == 0);
CHECK(std::all_of(dc.grid.data.begin(), dc.grid.data.end(), [](float v) { return std::isfinite(v); }));
}
namespace {
// The five groups of ModelValidation_ParallelGriddingMatchesGemmi: none, a centring, screws, a
// cubic body centring and a cubic face centring with a diamond glide.
@@ -1029,19 +1276,13 @@ TEST_CASE("ModelValidation_RigidBodyTranslationDerivativeIsExact", "[ModelValida
#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.
// A pool of `engines`, wherever there is a GPU.
std::unique_ptr<RigidBodyGPUPool> 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)");
}
auto pool = RigidBodyGPUPool::Plan(model, cell, sg, d_min, observations, logger);
REQUIRE(pool);
pool->Reserve(engines);
return pool;
}
}
@@ -1414,4 +1655,98 @@ TEST_CASE("RigidBodyGPU_Deterministic", "[ModelValidation][gpu]") {
}
}
// The model's structure factors on the GPU against the CPU path model validation takes - gemmi's density and
// mask on the symmetrized grid, transformed by FFTW and read off by prepare_asu_data() - in the five groups,
// at a resolution where the mask's shrink step does nothing and at one where it does. The same reflections
// in the same order; F_calc to rounding; F_mask to the few grid points at an atom's mask radius that float
// and double distances put on different sides. The same atoms twice give the same bits.
TEST_CASE("ModelStructureFactorsGPU_MatchesCPU", "[ModelValidation][gpu]") {
if (get_gpu_count() == 0)
SKIP("No GPU");
for (const char *cryst : kRigidBodyCrysts) {
const gemmi::Structure st = AnisoCluster(cryst);
const gemmi::Model &model = st.models[0];
const gemmi::SpaceGroup *sg = st.find_spacegroup();
for (double d_min : {3.5, 1.5}) {
auto dc = ZoneDensity(st, model, d_min);
dc.put_model_density_on_grid(model);
const auto ref_fc = MapToFPhi(dc.grid).prepare_asu_data(d_min, dc.blur, false, false, false);
gemmi::Grid<float> mask;
mask.unit_cell = st.cell;
mask.spacegroup = sg;
mask.set_size_from_spacing(dc.requested_grid_spacing(), gemmi::GridSizeRounding::Up);
gemmi::SolventMasker(gemmi::AtomicRadiiSet::Refmac).put_mask_on_grid(mask, model);
const auto ref_fm = MapToFPhi(mask).prepare_asu_data(d_min, 0);
ModelStructureFactorsGPU gpu(0, model, st.cell, *sg, d_min);
REQUIRE(gpu.Supported());
gpu.Reserve();
gemmi::AsuData<std::complex<float>> fc, fm, fc2, fm2;
gpu.Compute(model, fc, fm);
gpu.Compute(model, fc2, fm2);
REQUIRE(fc.v.size() == ref_fc.v.size());
REQUIRE(fm.v.size() == ref_fm.v.size());
double fc_mean = 0, fc_worst = 0, fm_norm = 0, fm_diff = 0;
bool repeat_same = true;
for (size_t i = 0; i < fc.v.size(); i++) {
REQUIRE(fc.v[i].hkl == ref_fc.v[i].hkl);
REQUIRE(fm.v[i].hkl == ref_fm.v[i].hkl);
fc_mean += std::abs(ref_fc.v[i].value) / static_cast<double>(fc.v.size());
fc_worst = std::max(fc_worst, static_cast<double>(std::abs(fc.v[i].value - ref_fc.v[i].value)));
fm_norm += std::norm(ref_fm.v[i].value);
fm_diff += std::norm(fm.v[i].value - ref_fm.v[i].value);
repeat_same = repeat_same && fc2.v[i].value == fc.v[i].value && fm2.v[i].value == fm.v[i].value;
}
INFO(cryst << " at " << d_min << " A: F_calc worst " << fc_worst / fc_mean << " of the mean |F|, F_mask "
<< std::sqrt(fm_diff / fm_norm) << " relative rms");
CHECK(fc_worst <= 1e-4 * fc_mean);
CHECK(std::sqrt(fm_diff) <= 1e-3 * std::sqrt(fm_norm));
CHECK(repeat_same);
}
}
}
// A map from coefficients on the GPU against MapFromFPhi() of gemmi's get_f_phi_on_grid(): the same grid,
// the same values to rounding, the same bits twice.
TEST_CASE("ModelStructureFactorsGPU_MapMatchesCPU", "[ModelValidation][gpu]") {
if (get_gpu_count() == 0)
SKIP("No GPU");
for (const char *cryst : kRigidBodyCrysts) {
const gemmi::Structure st = AnisoCluster(cryst);
const gemmi::Model &model = st.models[0];
const gemmi::SpaceGroup *sg = st.find_spacegroup();
const double d_min = 2.0;
ModelStructureFactorsGPU gpu(0, model, st.cell, *sg, d_min);
REQUIRE(gpu.Supported());
gpu.Reserve();
gemmi::AsuData<std::complex<float>> coef, fm;
gpu.Compute(model, coef, fm);
// Coefficients on every second reflection only, as a map's are on the observed ones.
gemmi::AsuData<std::complex<float>> some = coef;
some.v.clear();
for (size_t i = 0; i < coef.v.size(); i += 2)
some.v.push_back(coef.v[i]);
gemmi::AsuData<std::complex<float>> cpu_coef = some;
cpu_coef.ensure_sorted();
const auto size = gemmi::get_size_for_hkl(cpu_coef, {{0, 0, 0}}, 3.0);
const gemmi::Grid<float> cpu = MapFromFPhi(gemmi::get_f_phi_on_grid<float>(cpu_coef, size, true));
gemmi::AsuData<std::complex<float>> gpu_coef = some;
const gemmi::Grid<float> map = gpu.Map(gpu_coef);
const gemmi::Grid<float> map2 = gpu.Map(gpu_coef);
REQUIRE(map.nu == cpu.nu);
REQUIRE(map.nv == cpu.nv);
REQUIRE(map.nw == cpu.nw);
double rms = 0, worst = 0;
for (size_t i = 0; i < cpu.data.size(); i++) {
rms += gemmi::sq(cpu.data[i]) / static_cast<double>(cpu.data.size());
worst = std::max(worst, static_cast<double>(std::fabs(map.data[i] - cpu.data[i])));
}
INFO(cryst << ": map differs by at most " << worst / std::sqrt(rms) << " of its rms");
CHECK(worst <= 1e-4 * std::sqrt(rms));
CHECK(std::memcmp(map.data.data(), map2.data.data(), map.data.size() * sizeof(float)) == 0);
}
}
#endif