The adopted space group travelled the pipeline as a bare int and was rebuilt downstream with find_spacegroup_by_number, which returns the reference setting. So every setting a number cannot name was destroyed one line after it was determined: P 1 1 2 came back as P 1 2 1, I 1 1 2 as C 1 2 1, R 3:R as R 3:H. DatasetSettings now holds the gemmi::SpaceGroup itself, DiffractionExperiment exposes it as GetGemmiSpaceGroup() / GetSpaceGroupOrP1(), and everything that used to take an int - HKLKeyGenerator (its int constructor is gone, so the compiler finds the callers), the merge, the R-free flags, French-Wilson, the reindexing ambiguity, the completeness enumeration, the MTZ and mmCIF exports, the model validation - takes the group. -S keeps the setting the symbol names rather than reducing it to a number. The end message carries both spellings and a reader prefers the name, since only the name keeps the setting while the number is what a reader written before the name understands. It carries them over CBOR too: the determined group was never serialised at all, so a group rugnux chose reached the master file only when the same process wrote it, and an online writer fell back to whatever the user had supplied at the start. Both keys are optional additions, so an older reader skips them and a newer one reads an older sender. On disk the master's /entry/sample/space_group carries the extended Hermann-Mauguin name and is what the reader takes the group from, so a setting survives a _process.h5 and the --mode scale that re-reads it; the number stays beside it and is the fallback for files written before. Every one of the 230 reference settings the old writer could produce reads back as itself, so older files are unaffected. Stage A and Stage B of the search still enumerate reference settings only, so this determines no group differently today - it is what the enumeration needs before it can be widened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
281 lines
13 KiB
C++
281 lines
13 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 "../common/DiffractionExperiment.h"
|
|
#include "../image_analysis/scale_merge/Merge.h"
|
|
#include "../reader/JFJochHDF5Reader.h"
|
|
#include "../rugnux/ResultReport.h"
|
|
#include "../common/CUDAWrapper.h"
|
|
#include "../writer/FileWriter.h"
|
|
|
|
// Two synthetic ranges, one of each shape the report has to handle.
|
|
namespace {
|
|
SweepQuality TestSweepQuality() {
|
|
SweepQuality out;
|
|
out.measured = true;
|
|
out.sweep_deg = 60.0f;
|
|
out.ranges.push_back(SweepQualityRange{.first_image = 100, .last_image = 149,
|
|
.reason = SweepQualityReason::CrystalOutOfBeam,
|
|
.severity = 0.83f, .rotation_deg = 5.0f,
|
|
.mean_relative_scale = 0.12f, .mean_relative_cc = 0.30f,
|
|
.indexed_fraction = 0.015f});
|
|
out.ranges.push_back(SweepQualityRange{.first_image = 400, .last_image = 499,
|
|
.reason = SweepQualityReason::RadiationDamage,
|
|
.severity = 0.41f, .rotation_deg = 10.0f,
|
|
.mean_relative_scale = 0.55f, .mean_relative_cc = 0.80f,
|
|
.indexed_fraction = 0.62f});
|
|
return out;
|
|
}
|
|
|
|
std::vector<std::string> ReasonVocabulary() {
|
|
std::vector<std::string> out;
|
|
for (int r = 0; r <= static_cast<int>(SweepQualityReason::RadiationDamage); ++r)
|
|
out.emplace_back(SweepQualityReasonCode(static_cast<SweepQualityReason>(r)));
|
|
return out;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("SweepQuality_ReasonVocabulary", "[Diagnostics]") {
|
|
// The codes are an interface - they are written verbatim into <prefix>_report.txt and into HDF5,
|
|
// where the per-image codes index this list from 1. A rename or a reorder silently breaks every
|
|
// consumer, so pin both the spelling and the order here.
|
|
const auto codes = ReasonVocabulary();
|
|
REQUIRE(codes.size() == 5);
|
|
CHECK(codes[0] == "no_diffraction");
|
|
CHECK(codes[1] == "crystal_out_of_beam");
|
|
CHECK(codes[2] == "weak_diffraction");
|
|
CHECK(codes[3] == "loss_of_centring");
|
|
CHECK(codes[4] == "radiation_damage");
|
|
}
|
|
|
|
TEST_CASE("ResultReport_Render", "[Diagnostics]") {
|
|
DiffractionExperiment x(DetJF(1));
|
|
x.ImagesPerTrigger(600);
|
|
|
|
ProcessResult result;
|
|
result.images_processed = 600;
|
|
result.indexing_rate = 0.87f;
|
|
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
|
|
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
|
|
result.space_group = *gemmi::find_spacegroup_by_number(96);
|
|
result.used_beam_x_pxl = 766.62f;
|
|
result.used_beam_y_pxl = 846.87f;
|
|
result.used_distance_mm = 243.53f;
|
|
result.has_merge_statistics = true;
|
|
result.merge_statistics.sweep_quality = TestSweepQuality();
|
|
|
|
const auto text = RenderResultReport("prefix", "in.h5", x, result);
|
|
|
|
// The stable keys a consumer greps for.
|
|
CHECK(text.find("\nREPORT_VERSION= 4\n") != std::string::npos);
|
|
CHECK(text.find("\nOUTPUT_PREFIX= prefix\n") != std::string::npos);
|
|
CHECK(text.find("\nINDEXING_RATE= 0.8700\n") != std::string::npos);
|
|
CHECK(text.find("\nSPACE_GROUP_NUMBER= 96\n") != std::string::npos);
|
|
CHECK(text.find("\nSWEEP_QUALITY_STATUS= COMPUTED\n") != std::string::npos);
|
|
CHECK(text.find("\nSWEEP_QUALITY_COUNT= 2\n") != std::string::npos);
|
|
CHECK(text.find("\nSWEEP_QUALITY_REASONS= no_diffraction crystal_out_of_beam weak_diffraction "
|
|
"loss_of_centring radiation_damage\n") != std::string::npos);
|
|
|
|
// One table row per range, with the reason code verbatim.
|
|
CHECK(text.find(" 100 149 50 5.0 crystal_out_of_beam ")
|
|
!= std::string::npos);
|
|
CHECK(text.find(" 400 499 100 10.0 radiation_damage ")
|
|
!= std::string::npos);
|
|
|
|
// ... and one plain-English WARNING line per range, greppable by the marker alone.
|
|
CHECK(text.find("\nWARNING_COUNT= 2\n") != std::string::npos);
|
|
CHECK(text.find("\nWARNING: Frames 100-149 out of beam (5.0 deg,") != std::string::npos);
|
|
CHECK(text.find("\nWARNING: Frames 400-499 radiation damage (10.0 deg,") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("ResultReport_RenderEmpty", "[Diagnostics]") {
|
|
// A clean run and a run that never looked must be distinguishable: both have a count of 0, and
|
|
// only the STATUS key separates them. This is the property a consumer relies on.
|
|
DiffractionExperiment x(DetJF(1));
|
|
|
|
ProcessResult clean;
|
|
clean.has_merge_statistics = true;
|
|
clean.merge_statistics.sweep_quality.measured = true;
|
|
const auto clean_text = RenderResultReport("p", "in.h5", x, clean);
|
|
CHECK(clean_text.find("\nSWEEP_QUALITY_STATUS= COMPUTED\n") != std::string::npos);
|
|
CHECK(clean_text.find("\nSWEEP_QUALITY_COUNT= 0\n") != std::string::npos);
|
|
CHECK(clean_text.find("FIRST_IMAGE LAST_IMAGE") != std::string::npos);
|
|
|
|
ProcessResult not_merged;
|
|
const auto not_merged_text = RenderResultReport("p", "in.h5", x, not_merged);
|
|
CHECK(not_merged_text.find("\nSWEEP_QUALITY_STATUS= NOT_COMPUTED\n") != std::string::npos);
|
|
CHECK(not_merged_text.find("\nMERGE= NOT_PERFORMED\n") != std::string::npos);
|
|
CHECK(not_merged_text.find("FIRST_IMAGE LAST_IMAGE") != std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("ResultReport_RadiationDamage", "[Diagnostics]") {
|
|
// RADIATION_DAMAGE_RELATIVE_B is a number only when there is one. A curve that was measured but
|
|
// that no straight line describes, and a monitor that could not run at all, are different answers,
|
|
// and a consumer has to be able to tell them apart - and both from a measured zero.
|
|
DiffractionExperiment x(DetJF(1));
|
|
ProcessResult result;
|
|
result.has_merge_statistics = true;
|
|
result.radiation_damage_text = "per-batch relative-B";
|
|
result.merge_statistics.radiation_damage_batch_deg = 10.0;
|
|
result.merge_statistics.radiation_damage_b_batch = {0.0f, 4.0f, NAN};
|
|
|
|
result.merge_statistics.radiation_damage_delta_b = 8.25;
|
|
CHECK(RenderResultReport("p", "in.h5", x, result).find("\nRADIATION_DAMAGE_RELATIVE_B= 8.25\n")
|
|
!= std::string::npos);
|
|
|
|
result.merge_statistics.radiation_damage_delta_b = NAN;
|
|
CHECK(RenderResultReport("p", "in.h5", x, result).find("\nRADIATION_DAMAGE_RELATIVE_B= NOT_A_TREND\n")
|
|
!= std::string::npos);
|
|
|
|
result.merge_statistics.radiation_damage_b_batch.clear();
|
|
CHECK(RenderResultReport("p", "in.h5", x, result).find("\nRADIATION_DAMAGE_RELATIVE_B= NOT_MEASURED\n")
|
|
!= std::string::npos);
|
|
}
|
|
|
|
TEST_CASE("SweepQuality_HDF5RoundTrip", "[HDF5][Full][Diagnostics]") {
|
|
// The per-image codes have to survive the writer and come back out of the reader. Without an
|
|
// assertion here the field can ship as all-zeros without anyone noticing.
|
|
DiffractionExperiment x(DetJF(1));
|
|
x.ImagesPerTrigger(6).Compression(CompressionAlgorithm::NO_COMPRESSION)
|
|
.FilePrefix("sweep_quality_roundtrip");
|
|
x.SetFileWriterFormat(FileWriterFormat::NXmxIntegrated).OverwriteExistingFiles(true);
|
|
|
|
// Images 2-3 out of beam, image 5 dead from radiation damage; the rest in no flagged range.
|
|
const std::vector<uint8_t> expected{0, 0,
|
|
static_cast<uint8_t>(SweepQualityReason::CrystalOutOfBeam) + 1,
|
|
static_cast<uint8_t>(SweepQualityReason::CrystalOutOfBeam) + 1,
|
|
0,
|
|
static_cast<uint8_t>(SweepQualityReason::RadiationDamage) + 1};
|
|
|
|
{
|
|
RegisterHDF5Filter();
|
|
StartMessage start_message;
|
|
x.FillMessage(start_message);
|
|
|
|
EndMessage end_message;
|
|
end_message.max_image_number = x.GetImageNum();
|
|
end_message.sweep_quality = expected;
|
|
end_message.sweep_quality_reasons = ReasonVocabulary();
|
|
|
|
FileWriter writer(start_message);
|
|
std::vector<int16_t> image(x.GetPixelsNum(), 42);
|
|
for (int i = 0; i < x.GetImageNum(); i++) {
|
|
DataMessage message{};
|
|
message.image = CompressedImage(image, x.GetXPixelsNum(), x.GetYPixelsNum());
|
|
message.number = i;
|
|
REQUIRE_NOTHROW(writer.Write(message));
|
|
}
|
|
writer.WriteHDF5(end_message);
|
|
writer.Finalize();
|
|
}
|
|
|
|
{
|
|
JFJochHDF5Reader reader;
|
|
REQUIRE_NOTHROW(reader.ReadFile("sweep_quality_roundtrip_master.h5"));
|
|
auto dataset = reader.GetDataset();
|
|
CHECK(dataset->sweep_quality == expected);
|
|
// The vocabulary travels with the codes, so a consumer can name them without this source.
|
|
CHECK(dataset->sweep_quality_reasons == ReasonVocabulary());
|
|
}
|
|
|
|
REQUIRE(H5Fget_obj_count(H5F_OBJ_ALL, H5F_OBJ_ALL) == 0);
|
|
remove("sweep_quality_roundtrip_master.h5");
|
|
}
|
|
|
|
// The command line, the wall time and the GPUs say how the result was produced, what it cost and what
|
|
// it ran on, so the report can be read on its own once the shell history is gone. They come from the
|
|
// CLI, which is the only caller that knows them; a caller that does not - the library, the viewer -
|
|
// must get a report without them rather than one claiming the run was invoked by nobody, took no
|
|
// time, and saw no GPU.
|
|
TEST_CASE("ResultReport_ProvenanceKeys", "[Diagnostics]") {
|
|
DiffractionExperiment x(DetJF(1));
|
|
x.ImagesPerTrigger(600);
|
|
|
|
ProcessResult result;
|
|
result.images_processed = 600;
|
|
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
|
|
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
|
|
|
|
RunProvenance provenance;
|
|
provenance.command_line = "rugnux -o prefix in.h5";
|
|
provenance.wall_time_s = 262.409;
|
|
provenance.gpu_count = 4;
|
|
provenance.gpu_description = "4x NVIDIA A100-SXM4-80GB";
|
|
|
|
const auto text = RenderResultReport("prefix", "in.h5", x, result, provenance);
|
|
CHECK(text.find("\nCOMMAND_LINE= rugnux -o prefix in.h5\n") != std::string::npos);
|
|
CHECK(text.find("\nWALL_TIME= 262.41\n") != std::string::npos);
|
|
CHECK(text.find("\nGPU_COUNT= 4\n") != std::string::npos);
|
|
CHECK(text.find("\nGPU= 4x NVIDIA A100-SXM4-80GB\n") != std::string::npos);
|
|
|
|
// A machine with no GPU says so - GPU_COUNT= 0 is a statement about why the run took as long as
|
|
// it did, and only the name list has nothing to report.
|
|
RunProvenance cpu_only;
|
|
cpu_only.gpu_count = 0;
|
|
const auto cpu_text = RenderResultReport("prefix", "in.h5", x, result, cpu_only);
|
|
CHECK(cpu_text.find("\nGPU_COUNT= 0\n") != std::string::npos);
|
|
CHECK(cpu_text.find("\nGPU= ") == std::string::npos);
|
|
|
|
const auto without = RenderResultReport("prefix", "in.h5", x, result);
|
|
CHECK(without.find("COMMAND_LINE=") == std::string::npos);
|
|
CHECK(without.find("WALL_TIME=") == std::string::npos);
|
|
CHECK(without.find("GPU_COUNT=") == std::string::npos);
|
|
}
|
|
|
|
// A model that was asked for gets a section either way: the R-factors when it worked, and why it did
|
|
// not when it did not. A run that never asked for one has no section at all.
|
|
TEST_CASE("ResultReport_ModelValidationSection", "[Diagnostics]") {
|
|
DiffractionExperiment x(DetJF(1));
|
|
x.ImagesPerTrigger(600);
|
|
|
|
ProcessResult result;
|
|
result.images_processed = 600;
|
|
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
|
|
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
|
|
|
|
CHECK(RenderResultReport("p", "in.h5", x, result).find("MODEL VALIDATION") == std::string::npos);
|
|
|
|
ModelValidationResult good;
|
|
good.ok = true;
|
|
good.model_path = "model.cif";
|
|
good.model_space_group_number = 96;
|
|
good.r_work = 0.1823;
|
|
good.r_free = 0.2145;
|
|
good.n_work = 16682;
|
|
good.n_free = 928;
|
|
good.mean_atom_density_sigma = 1.42;
|
|
result.model_validation = good;
|
|
|
|
const auto text = RenderResultReport("p", "in.h5", x, result);
|
|
CHECK(text.find("10. MODEL VALIDATION") != std::string::npos);
|
|
CHECK(text.find("\nMODEL_FILE= model.cif\n") != std::string::npos);
|
|
CHECK(text.find("\nR_FREE= 0.2145\n") != std::string::npos);
|
|
CHECK(text.find("\nR_FREE_REFLECTIONS= 928\n") != std::string::npos);
|
|
CHECK(text.find("MODEL_VALIDATION= NOT_PERFORMED") == std::string::npos);
|
|
|
|
ModelValidationResult failed;
|
|
failed.model_path = "broken.pdb";
|
|
failed.failure_reason = "model broken.pdb has no atoms or no unit cell";
|
|
result.model_validation = failed;
|
|
|
|
const auto failed_text = RenderResultReport("p", "in.h5", x, result);
|
|
CHECK(failed_text.find("\nMODEL_VALIDATION= NOT_PERFORMED\n") != std::string::npos);
|
|
CHECK(failed_text.find("has no atoms") != std::string::npos);
|
|
CHECK(failed_text.find("R_FREE=") == std::string::npos);
|
|
}
|
|
|
|
// get_gpu_description collapses repeats, so a four-card machine reads as one line rather than the
|
|
// same name four times. Build-independent: without CUDA there are no names and it is empty.
|
|
TEST_CASE("ResultReport_GpuDescription", "[Diagnostics]") {
|
|
const auto names = get_gpu_names();
|
|
CHECK(names.size() == static_cast<size_t>(std::max(0, get_gpu_count())));
|
|
|
|
const auto description = get_gpu_description();
|
|
if (names.empty())
|
|
CHECK(description.empty());
|
|
else
|
|
CHECK(description.find(names.front()) != std::string::npos);
|
|
}
|