Files
Jungfraujoch/tests/ResultReportTest.cpp
leonarski_fandClaude Opus 5.5 4bf41983ed rugnux: the model is parsed by the caller and handed in as RugnuxModel
The analysis read the --model file itself, four to five times per run
(the model kind, every validation, molecular replacement, the stills
reference intensities) and located the PAE sidecar from the file name.
The library is to do no file I/O, so the model now arrives in memory:

- RugnuxModel (image_analysis/structure_refinement/RugnuxModel.h): the
  gemmi structure as read, whether it is ModelCIF, the PAE matrix where a
  prediction came with one, and the name the report gives it.
- ParseRugnuxModel / ReadRugnuxModelFile (writer/RugnuxModelFile.h, in
  JFJochRugnuxFiles): bytes in, gzip by magic, PDB/mmCIF by content,
  _ma_qa_metric detection as before; the file reader takes the PAE JSON
  from the same place PaePathFor named.
- ProcessConfig::model replaces model_path; PrepareSearchModel,
  RunMolecularReplacement, ValidateAgainstModel and
  ModelReferenceIntensities take the model; result fields name it by
  model_name.
- rugnux reads the model before the run starts and stops with an error
  where the file holds no model (it used to run and report the model as
  not validated). The viewer reads it when the job is set up.
- The broker parses an uploaded model at once and answers 400 where it is
  not one; it keeps the parsed model in memory, with no temporary
  directory or file. It takes no PAE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 16:11:58 +02:00

1351 lines
72 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,
.disposition = FrameDisposition::Rejected,
.delta_cc_half = -0.0312f, .delta_cc_half_se = 0.0060f});
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,
.disposition = FrameDisposition::Downgraded,
.delta_cc_half = 0.0011f, .delta_cc_half_se = 0.0042f});
out.frames_merged = 450;
out.frames_downgraded = 100;
out.frames_rejected = 50;
out.rejected_deg = 5.0f;
return out;
}
std::vector<std::string> ReasonVocabulary() {
std::vector<std::string> out;
for (int r = 0; r <= static_cast<int>(SweepQualityReason::InconsistentWithMerge); ++r)
out.emplace_back(SweepQualityReasonCode(static_cast<SweepQualityReason>(r)));
return out;
}
std::vector<std::string> DispositionVocabulary() {
std::vector<std::string> out;
for (int d = 0; d <= static_cast<int>(FrameDisposition::Rejected); ++d)
out.emplace_back(FrameDispositionCode(static_cast<FrameDisposition>(d)));
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() == 6);
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");
CHECK(codes[5] == "inconsistent_with_merge");
// The disposition vocabulary is an interface for the same reasons, and the per-image HDF5 array
// indexes it from 0.
CHECK(std::string(FrameDispositionCode(FrameDisposition::Merged)) == "merged");
CHECK(std::string(FrameDispositionCode(FrameDisposition::Downgraded)) == "downgraded");
CHECK(std::string(FrameDispositionCode(FrameDisposition::Rejected)) == "rejected");
}
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. The version is pinned on purpose: a key added to the
// report is a contract change, and this line is where it has to be acknowledged.
CHECK(text.find("\nREPORT_VERSION= 8\n") != std::string::npos);
// SOHNCKE_SPACE_GROUP names the best group a chiral crystal could have, and it comes from the
// space-group SEARCH. This fixture is given its group rather than searching for one, so there is
// no Sohncke candidate to name and the key is absent - which is the honest behaviour and the
// reason the changelog says "every run that determined a space group" and not "every run".
CHECK(text.find("\nSOHNCKE_SPACE_GROUP=") == std::string::npos);
// The flight-path medium is an ASSUMPTION - no file states it - so the report has to say which
// one was made and what it was worth, or the user cannot know a choice was taken on their behalf.
CHECK(text.find("\nFLIGHT_PATH= AIR\n") != std::string::npos);
CHECK(text.find("\nFLIGHT_PATH_WILSON_B= ") != 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);
// What the data could NOT decide, beside what they did. The group here was given rather
// than searched for, and it is one of the 22 that come in enantiomorphic pairs.
CHECK(text.find("\nSPACE_GROUP_ALTERNATIVES= NONE\n") != std::string::npos);
CHECK(text.find("\nSPACE_GROUP_ENANTIOMORPH= GIVEN\n") != std::string::npos);
// Written only where there WAS a refusal: "NONE" on every other run answers a question nobody
// asked. The vocabulary and the status live in the developer report now, and are pinned there.
CHECK(text.find("SPACE_GROUP_REFUSED_POINT_GROUP=") == std::string::npos);
CHECK(text.find("\nSWEEP_QUALITY_COUNT= 2\n") != std::string::npos);
CHECK(text.find("SWEEP_QUALITY_STATUS=") == std::string::npos);
CHECK(text.find("SWEEP_QUALITY_REASONS=") == std::string::npos);
RunProvenance dev;
dev.developer = true;
const auto dev_text = RenderResultReport("prefix", "in.h5", x, result, dev);
CHECK(dev_text.find("\nSWEEP_QUALITY_STATUS= COMPUTED\n") != std::string::npos);
CHECK(dev_text.find("\nSWEEP_QUALITY_REASONS= no_diffraction crystal_out_of_beam weak_diffraction "
"loss_of_centring radiation_damage inconsistent_with_merge\n") != std::string::npos);
CHECK(dev_text.find("\nSWEEP_DISPOSITIONS= merged downgraded rejected\n") != std::string::npos);
// The disposition headline: frames AND degrees, so that re-slicing cannot move the percentage
// without the degrees moving with it.
CHECK(text.find("\nFRAMES_MERGED= 450\n") != std::string::npos);
CHECK(text.find("\nFRAMES_DOWNGRADED= 100\n") != std::string::npos);
CHECK(text.find("\nFRAMES_REJECTED= 50\n") != std::string::npos);
CHECK(text.find("\nFRAMES_REJECTED_PCT= 8.33\n") != std::string::npos);
CHECK(text.find("\nROTATION_REJECTED_DEG= 5.0\n") != std::string::npos);
CHECK(text.find("600 frames / 60.0 deg; 450 merged (75.0%), 100 downgraded (16.7%)")
!= 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 its disposition beside what keeping it costs the merged intensities.
CHECK(text.find("rejected -0.0312 0.0060") != std::string::npos);
CHECK(text.find("downgraded +0.0011 0.0042") != std::string::npos);
// ... and one plain-English WARNING line per range, greppable by the marker alone. They sit in
// the SUMMARY at the top of the file now, which is what the verdict is composed from.
CHECK(text.find("\nWARNING_COUNT= 2\n") != std::string::npos);
CHECK(text.find("\nVERDICT= WARNINGS\n") != std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= SWEEP_GAPS\n") != std::string::npos);
CHECK(text.find("VERDICT=") < text.find("SWEEP_QUALITY_COUNT="));
CHECK(text.find("\n# WARNING: Frames 100-149 out of beam (5.0 deg,") != std::string::npos);
CHECK(text.find("\n# WARNING: Frames 400-499 radiation damage (10.0 deg,") != std::string::npos);
CHECK(text.find("REJECTED: keeping them lowered CC1/2 by 0.0312 +/- 0.0060 over the "
"reflections they touch") != std::string::npos);
CHECK(text.find("kept at their own reduced weight; removing them would change CC1/2 by -0.0011")
!= std::string::npos);
}
// The reference-range table: REFRES_* keys beside the run's own, the shells past the run's own limit
// reported as not merged rather than as zeros, and no REFRES_ key at all when no range was given.
TEST_CASE("ResultReport_ReferenceRangeTable", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
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.has_merge_statistics = true;
auto &own = result.merge_statistics.overall;
own.d_max = 45.0f; own.d_min = 1.6f; // the run's own limit
own.unique_reflections = 1000; own.possible_unique_reflections = 1000; own.total_observations = 5000;
own.mean_i_over_sigma = 10.0; own.r_meas = 0.1; own.cc_half = 0.99;
CHECK(RenderResultReport("prefix", "in.h5", x, result).find("REFRES_") == std::string::npos);
ScalingSettings s = x.GetScalingSettings();
s.ReportResolutionRange_A(ReportResolutionRange{1.2, 50.0});
x.ImportScalingSettings(s);
MergeStatistics rs;
auto shell = [](float d_max, float d_min, int unique, int possible, int obs) {
MergeStatisticsShell sh;
sh.d_max = d_max; sh.d_min = d_min;
sh.unique_reflections = unique; sh.possible_unique_reflections = possible; sh.total_observations = obs;
sh.mean_i_over_sigma = unique > 0 ? 8.0 : 0.0;
sh.cc_half = unique > 0 ? 0.9 : NAN;
sh.r_meas = unique > 0 ? 0.2 : NAN;
return sh;
};
rs.shells = {shell(50.0f, 2.0f, 600, 600, 3000), // within the run's own range
shell(2.0f, 1.5f, 300, 500, 1200), // the run's limit of 1.6 A falls inside
shell(1.5f, 1.2f, 0, 700, 0)}; // past it: not merged
rs.overall = shell(45.0f, 1.6f, 900, 1800, 4200);
result.reference_merge_statistics = rs;
result.reference_isa = 12.5;
const auto text = RenderResultReport("prefix", "in.h5", x, result);
CHECK(text.find("\nREFRES_RANGE= 50.000 1.200\n") != std::string::npos);
CHECK(text.find("\nREFRES_MEASURED_RANGE= 45.000 1.600\n") != std::string::npos);
CHECK(text.find("\nREFRES_SHELLS_PAST_LIMIT= 1\n") != std::string::npos);
CHECK(text.find("\nREFRES_UNIQUE_REFLECTIONS= 900\n") != std::string::npos);
CHECK(text.find("\nREFRES_COMPLETENESS= 50.0\n") != std::string::npos);
CHECK(text.find("\nREFRES_MULTIPLICITY= 4.67\n") != std::string::npos);
CHECK(text.find("\nREFRES_R_MEAS= 0.2000\n") != std::string::npos);
CHECK(text.find("\nREFRES_CC_HALF= 0.9000\n") != std::string::npos);
CHECK(text.find("\nREFRES_ISA= 12.50\n") != std::string::npos);
// The run's own keys are untouched by the second table.
CHECK(text.find("\nCOMPLETENESS= 100.0\n") != std::string::npos);
CHECK(text.find("\nINCLUDE_RESOLUTION_RANGE= 45.000 1.600\n") != std::string::npos);
// The shell past the limit is words, not zeros; the shell holding the limit is marked.
CHECK(text.find("1.20 past the run's own limit of 1.60 A: not merged (700 possible)") != std::string::npos);
CHECK(text.find("<- the run's own limit, 1.60 A, is inside this shell") != std::string::npos);
CHECK(text.find("the 1 shell\n") != 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;
RunProvenance dev;
dev.developer = true;
const auto clean_text = RenderResultReport("p", "in.h5", x, clean);
CHECK(clean_text.find("\nSWEEP_QUALITY_COUNT= 0\n") != std::string::npos);
CHECK(RenderResultReport("p", "in.h5", x, clean, dev)
.find("\nSWEEP_QUALITY_STATUS= COMPUTED\n") != std::string::npos);
// An empty table used to print its header and two rules around nothing on every clean run.
CHECK(clean_text.find("FIRST_IMAGE LAST_IMAGE") == std::string::npos);
CHECK(clean_text.find("no degraded ranges") != std::string::npos);
ProcessResult not_merged;
const auto not_merged_text = RenderResultReport("p", "in.h5", x, not_merged);
CHECK(RenderResultReport("p", "in.h5", x, not_merged, dev)
.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);
// No lattice was found, and that is the FIRST thing the report says.
CHECK(not_merged_text.find("\nVERDICT= FAILED\n") != 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};
// What was DONE about them: the out-of-beam pair is dropped, the damaged tail stays in at its own
// reduced weight, the rest merges. Indexed from 0, unlike the reason codes above.
const std::vector<uint8_t> expected_disposition{
static_cast<uint8_t>(FrameDisposition::Merged),
static_cast<uint8_t>(FrameDisposition::Merged),
static_cast<uint8_t>(FrameDisposition::Rejected),
static_cast<uint8_t>(FrameDisposition::Rejected),
static_cast<uint8_t>(FrameDisposition::Merged),
static_cast<uint8_t>(FrameDisposition::Downgraded)};
{
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();
end_message.frame_disposition = expected_disposition;
end_message.frame_disposition_codes = DispositionVocabulary();
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());
CHECK(dataset->frame_disposition == expected_disposition);
CHECK(dataset->frame_disposition_codes == DispositionVocabulary());
}
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_name = "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;
good.fit_tested = true;
good.model_fits = true;
good.null_replicates = 5;
good.null_r_work_mean = 0.6031;
good.null_r_work_sd = 0.0094;
good.r_work_sigma = 4.12;
good.model_enantiomorph_candidate = true;
good.adopted_model_enantiomorph = true;
result.model_validation = good;
result.space_group = *gemmi::find_spacegroup_by_number(96);
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("5. 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("\nMODEL_VALIDATION= PERFORMED\n") != std::string::npos);
CHECK(text.find("MODEL_VALIDATION= NOT_PERFORMED") == std::string::npos);
CHECK(text.find("\nMODEL_FIT= ACCEPTED\n") != std::string::npos);
// The null's internals are the gate's own workings; MODEL_FIT and MODEL_FIT_SIGMA carry the
// answer they were computed for and stay in the default report.
CHECK(text.find("MODEL_FIT_STATISTIC=") == std::string::npos);
CHECK(text.find("MODEL_FIT_NULL_REPLICATES=") == std::string::npos);
{
RunProvenance dev;
dev.developer = true;
const auto dev_text = RenderResultReport("p", "in.h5", x, result, dev);
CHECK(dev_text.find("\nMODEL_FIT_STATISTIC= R_WORK\n") != std::string::npos);
CHECK(dev_text.find("\nMODEL_FIT_NULL_REPLICATES= 5\n") != std::string::npos);
}
CHECK(text.find("\nMODEL_FIT_SIGMA= +4.12\n") != std::string::npos);
// A run that measured no CC(model, data) writes no key for it, and the shell-scaled R shares
// its shells, so it is absent too.
CHECK(text.find("CC_MODEL_OVERALL=") == std::string::npos);
CHECK(text.find("R_MODEL_SHELL_SCALED=") == std::string::npos);
CHECK(text.find("MODEL_RADIAL_MISFIT=") == std::string::npos);
CHECK(text.find("CC_MODEL_CONFIRMED_TO_D_MIN=") == std::string::npos);
CHECK(text.find("\nMODEL_DECISIONS_TAKEN= ENANTIOMORPH\n") != std::string::npos);
// The hand is ASSUMED from the model, never determined: merged intensities cannot see it.
CHECK(text.find("\nSPACE_GROUP_ENANTIOMORPH= ASSUMED_FROM_MODEL\n") != std::string::npos);
// The same model, not accepted by the data: it still has R-factors and maps, but it decides
// nothing - the reflections stay in the group and the indexing they were merged in.
ModelValidationResult rejected = good;
rejected.model_fits = false;
rejected.r_work_sigma = 0.59;
rejected.adopted_model_enantiomorph = false;
result.model_validation = rejected;
const auto rejected_text = RenderResultReport("p", "in.h5", x, result);
CHECK(rejected_text.find("\nMODEL_FIT= REJECTED\n") != std::string::npos);
CHECK(rejected_text.find("\nMODEL_DECISIONS_TAKEN= NONE\n") != std::string::npos);
CHECK(rejected_text.find("\nR_WORK= 0.1823\n") != std::string::npos);
CHECK(rejected_text.find("\nSPACE_GROUP_ENANTIOMORPH= ASSUMED_FROM_MODEL\n") == std::string::npos);
CHECK(rejected_text.find("byte for byte") != std::string::npos);
// A model no rigid body was tried on - a prediction placed by molecular replacement - has no rigid-body
// verdict at all: no MODEL_FIT, rather than one that reads like a test.
ModelValidationResult untested = good;
untested.fit_tested = false;
untested.model_fits = false;
untested.model_enantiomorph_candidate = false;
untested.adopted_model_enantiomorph = false;
result.model_validation = untested;
const auto untested_text = RenderResultReport("p", "in.h5", x, result);
CHECK(untested_text.find("MODEL_FIT=") == std::string::npos);
CHECK(untested_text.find("\nMODEL_DECISIONS_TAKEN= NONE\n") != std::string::npos);
CHECK(untested_text.find("\nR_WORK= 0.1823\n") != std::string::npos);
CHECK(untested_text.find("MODEL_FIT_NULL_MEAN=") == std::string::npos);
CHECK(untested_text.find("was tried and REJECTED") == std::string::npos);
// Placed by molecular replacement where the rigid body was rejected: the rigid body's verdict stays, the
// route and the search's keys are in this section, and the hand it took is recorded as the search's.
{
ProcessResult mr_result = result;
mr_result.model_validation = rejected;
mr_result.model_placement = "MR";
MolecularReplacementResult mr;
mr.ok = true;
mr.model_name = "model.cif";
mr.predicted_model = false;
mr.decision = "ENANTIOMORPH";
MRCandidate c;
c.space_group = *gemmi::find_spacegroup_by_number(96);
c.copies = 1;
c.verdict = "PLACED";
mr.candidates = {c};
mr.chosen = 0;
mr.hand_check.performed = true;
mr.hand_check.adopted = true;
mr_result.model_prior = mr;
const auto mr_text = RenderResultReport("p", "in.h5", x, mr_result);
CHECK(mr_text.find("\nMODEL_PLACEMENT= MR\n") != std::string::npos);
CHECK(mr_text.find("\nMODEL_FIT= REJECTED\n") != std::string::npos);
CHECK(mr_text.find("\nMODEL_DECISIONS_TAKEN= MR_ENANTIOMORPH\n") != std::string::npos);
CHECK(mr_text.find("\nMODEL_ENANTIOMORPH_ADOPTED= TRUE\n") != std::string::npos);
CHECK(mr_text.find("\nSPACE_GROUP_ENANTIOMORPH= ASSUMED_FROM_MODEL\n") != std::string::npos);
const size_t section = mr_text.find("5. MODEL VALIDATION");
const size_t decision = mr_text.find("\nMR_DECISION= ENANTIOMORPH\n");
REQUIRE(section != std::string::npos);
REQUIRE(decision != std::string::npos);
CHECK(decision > section);
CHECK(mr_text.find("MOLECULAR REPLACEMENT (EXPERIMENTAL)") == std::string::npos);
// A prediction molecular replacement did not place: no validation, and it says so in this section.
mr_result.model_validation.reset();
mr_result.model_placement = "NOT_PLACED";
mr_result.model_prior->chosen = -1;
mr_result.model_prior->decision = "NOT_PLACED: no group of the answer's merge holds a placed model";
const auto unplaced_text = RenderResultReport("p", "in.h5", x, mr_result);
CHECK(unplaced_text.find("\nMODEL_FILE= model.cif\n") != std::string::npos);
CHECK(unplaced_text.find("\nMODEL_PLACEMENT= NOT_PLACED\n") != std::string::npos);
CHECK(unplaced_text.find("\nMODEL_VALIDATION= NOT_PERFORMED\n") != std::string::npos);
CHECK(unplaced_text.find("\nMR= PERFORMED\n") != std::string::npos);
CHECK(unplaced_text.find("R_FREE=") == std::string::npos);
}
// CC(model, data) by shell, with the deepest shell short of significance: the confirmed limit is
// the last shell that reached it beside a coarser one that did, and it is a lower bound - the
// shell past it is not thereby empty.
ModelValidationResult with_cc = good;
with_cc.cc_model_shells = {{3.20f, 0.9412, 4210, 41.4, 0.1211}, {2.10f, 0.5533, 3980, 20.2, 0.0832},
{1.80f, 0.0412, 2110, 1.9, 0.0105}};
with_cc.cc_model_overall = 0.8123;
with_cc.cc_model_n = 10300;
result.model_validation = with_cc;
const auto cc_text = RenderResultReport("p", "in.h5", x, result);
CHECK(cc_text.find("\nCC_MODEL_OVERALL= 0.8123\n") != std::string::npos);
CHECK(cc_text.find("\nCC_MODEL_REFLECTIONS= 10300\n") != std::string::npos);
CHECK(cc_text.find("\nCC_MODEL_CONFIRMED_TO_D_MIN= 2.10\n") != std::string::npos);
CHECK(cc_text.find("D_MIN CC_MODEL N CC_NULL SIGMA") != std::string::npos);
CHECK(cc_text.find(" 1.80 0.0412 2110 0.0105 +1.9") != std::string::npos);
CHECK(cc_text.find("The table reads in ONE direction only") != std::string::npos);
// The comparable R rides on the same shells. R_WORK and R_FREE are what they always were - the
// maps' own scale - and the new keys sit beside them rather than in place of them.
ModelValidationResult with_r = with_cc;
with_r.r_model = 0.2011;
with_r.r_model_shell_scaled = 0.1734;
with_r.n_model = 17610;
with_r.radial_misfit = 0.312;
with_r.shell_scale_bins = 3;
result.model_validation = with_r;
const auto r_text = RenderResultReport("p", "in.h5", x, result);
CHECK(r_text.find("\nR_WORK= 0.1823\n") != std::string::npos);
CHECK(r_text.find("\nR_FREE= 0.2145\n") != std::string::npos);
CHECK(r_text.find("\nR_MODEL= 0.2011\n") != std::string::npos);
CHECK(r_text.find("\nR_MODEL_SHELL_SCALED= 0.1734\n") != std::string::npos);
CHECK(r_text.find("\nR_MODEL_REFLECTIONS= 17610\n") != std::string::npos);
CHECK(r_text.find("\nMODEL_RADIAL_MISFIT= 0.312\n") != std::string::npos);
CHECK(r_text.find("the number to compare between runs") != std::string::npos);
// The shell count is a working detail of the rescale, not part of the reading.
CHECK(r_text.find("MODEL_RADIAL_SHELLS=") == std::string::npos);
{
RunProvenance dev;
dev.developer = true;
CHECK(RenderResultReport("p", "in.h5", x, result, dev)
.find("\nMODEL_RADIAL_SHELLS= 3\n") != std::string::npos);
}
// Nothing significant anywhere: the key still has to be written, saying so.
ModelValidationResult no_signal = with_cc;
for (auto &sh : no_signal.cc_model_shells)
sh.sigma = 1.0;
result.model_validation = no_signal;
CHECK(RenderResultReport("p", "in.h5", x, result)
.find("\nCC_MODEL_CONFIRMED_TO_D_MIN= NONE\n") != std::string::npos);
// One shell over the bar on its own, its neighbours not: chance among a dozen shells, not signal.
ModelValidationResult lone = with_cc;
lone.cc_model_shells[0].sigma = 0.4;
lone.cc_model_shells[1].sigma = 5.7;
result.model_validation = lone;
CHECK(RenderResultReport("p", "in.h5", x, result)
.find("\nCC_MODEL_CONFIRMED_TO_D_MIN= NONE\n") != std::string::npos);
result.model_validation = good;
ModelValidationResult failed;
failed.model_name = "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);
}
// The verdict is the first evaluative line in the file, and it is composed from the warnings the
// sections produced - which is only possible because the report is built as a document and rendered
// afterwards. Its vocabulary is closed, so a consumer switches on it.
TEST_CASE("ResultReport_Verdict", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
// No lattice at all: the run failed, and the report says so on the first screen rather than in
// its last line.
ProcessResult nothing;
nothing.images_processed = 100;
nothing.indexing_rate = 0.0f;
const auto failed = RenderResultReport("p", "in.h5", x, nothing);
CHECK(failed.find("\nVERDICT= FAILED\n") != std::string::npos);
CHECK(failed.find("\nPATHOLOGY_FLAGS= NO_LATTICE\n") != std::string::npos);
CHECK(failed.find("WARNING: No image indexed") != std::string::npos);
// The verdict comes before the section that produced the warning behind it.
CHECK(failed.find("VERDICT=") < failed.find("LATTICE_FOUND="));
// A --mode scale run has a lattice from its input file and never fills indexing_rate. The
// "No image indexed" warning used to fire on every one of them, on reports whose own
// LATTICE_FOUND said TRUE - a warning that is false on a whole run mode is how users learn to
// skip the section.
ProcessResult rescaled;
rescaled.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
const auto scale_text = RenderResultReport("p", "in.h5", x, rescaled);
CHECK(scale_text.find("\nLATTICE_FOUND= TRUE\n") != std::string::npos);
CHECK(scale_text.find("No image indexed") == std::string::npos);
CHECK(scale_text.find("INDEXING_RATE=") == std::string::npos);
}
// Two conditions that left every other number in the report looking ordinary: a merge whose
// observations do not correlate, and a merge that measured too little of reciprocal space.
TEST_CASE("ResultReport_UnusableMergeAndCompleteness", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
auto &o = result.merge_statistics.overall;
o.d_max = 50.0f;
o.d_min = 2.00f;
o.unique_reflections = 1000;
o.possible_unique_reflections = 1100;
o.total_observations = 4000;
o.mean_i_over_sigma = 12.0; // strong...
o.cc_half = -0.005; // ...and uncorrelated: not equivalent reflections
o.r_meas = 1.09;
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nVERDICT= UNUSABLE\n") != std::string::npos);
CHECK(text.find("WARNING: These merged data are not usable") != std::string::npos);
CHECK(text.find("UNUSABLE_MERGE") != std::string::npos);
// Rejecting more observations than were kept is the other way a merge throws the data away, and
// it improves every other statistic in the block while it does so.
ProcessResult rejected = result;
rejected.merge_statistics.overall.cc_half = 0.98;
rejected.merge_statistics.n_observations_rejected = 5000;
CHECK(RenderResultReport("p", "in.h5", x, rejected).find("threw away") != std::string::npos);
// Completeness is judged inside the fitted resolution, not over the deliberately generous cut,
// so a corner-limited detector on good data does not fire it.
ProcessResult thin = result;
thin.merge_statistics.overall.cc_half = 0.98;
thin.resolution_fit_A = 2.0;
MergeStatisticsShell shell;
shell.d_max = 50.0f;
shell.d_min = 2.0f;
shell.cc_half = 0.98;
shell.unique_reflections = 300;
shell.possible_unique_reflections = 2000;
thin.merge_statistics.shells.push_back(shell);
const auto thin_text = RenderResultReport("p", "in.h5", x, thin);
CHECK(thin_text.find("LOW_COMPLETENESS") != std::string::npos);
CHECK(thin_text.find("\nVERDICT= WARNINGS\n") != std::string::npos);
}
// FITTED_RESOLUTION is presented as "the number to quote", so it must not be quotable when the CC1/2
// curve it was read off never behaved like a fall-off.
TEST_CASE("ResultReport_FittedResolutionSuppressed", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 7.0f, .b = 7.0f, .c = 7.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
result.merge_statistics.overall.d_max = 6.0f;
result.merge_statistics.overall.d_min = 0.80f;
result.merge_statistics.overall.unique_reflections = 500;
result.merge_statistics.overall.possible_unique_reflections = 520;
result.merge_statistics.overall.total_observations = 2000;
result.merge_statistics.overall.cc_half = 0.90;
result.merge_statistics.overall.mean_i_over_sigma = 8.0;
// Signal to 0.90 A and nothing beyond it...
MergeStatisticsShell inner;
inner.d_max = 6.0f;
inner.d_min = 0.90f;
inner.cc_half = 0.95;
inner.unique_reflections = 250;
inner.possible_unique_reflections = 260;
MergeStatisticsShell outer = inner;
outer.d_max = 0.90f;
outer.d_min = 0.80f;
outer.cc_half = 0.02;
result.merge_statistics.shells.push_back(inner);
result.merge_statistics.shells.push_back(outer);
// ...and a fit that claims 0.70 A, finer than any shell that still reaches the target.
result.resolution_fit_A = 0.70;
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("FITTED_RESOLUTION=") == std::string::npos);
CHECK(text.find("No FITTED_RESOLUTION is given") != std::string::npos);
CHECK(text.find("RESOLUTION_FIT") != std::string::npos);
// A fit the shells DO support is written as before.
result.resolution_fit_A = 0.95;
CHECK(RenderResultReport("p", "in.h5", x, result).find("\nFITTED_RESOLUTION= 0.95\n")
!= std::string::npos);
// A shell's CC1/2 is its average, so the crossing lies inside the first shell below the target:
// a fit there is what the table shows, not something it refutes.
result.resolution_fit_A = 0.85;
CHECK(RenderResultReport("p", "in.h5", x, result).find("\nFITTED_RESOLUTION= 0.85\n")
!= std::string::npos);
result.resolution_fit_A = 0.95;
// CC1/2 that drops through the target and climbs back above it is not a fall-off either, and the
// finest shell reaching the target is then noise correlating with noise - so a fit the test above
// accepts must still be suppressed, and the user told so in the default report.
ProcessResult climbs = result;
climbs.merge_statistics.shells.back().cc_half = 0.05;
MergeStatisticsShell noise = climbs.merge_statistics.shells.back();
noise.d_max = 0.80f;
noise.d_min = 0.70f;
noise.cc_half = 0.60;
climbs.merge_statistics.shells.push_back(noise);
const auto climbs_text = RenderResultReport("p", "in.h5", x, climbs);
CHECK(climbs_text.find("FITTED_RESOLUTION=") == std::string::npos);
CHECK(climbs_text.find("climbs back above it at 0.70 A") != std::string::npos);
CHECK(climbs_text.find("RESOLUTION_FIT") != std::string::npos);
}
// ISa of a b that is zero within its error is the noise in b; the report says undetermined, not a number.
TEST_CASE("ResultReport_IsaUndetermined", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
result.merge_statistics.overall.d_max = 50.0f;
result.merge_statistics.overall.d_min = 1.50f;
result.merge_statistics.overall.cc_half = 0.99;
result.error_model_a = 1.1;
result.error_model_b = 1e-4;
result.error_model_isa = 95.0;
result.error_model_isa_asymptotic = 120.0;
const auto resolved = RenderResultReport("p", "in.h5", x, result);
CHECK(resolved.find("\nISA= 95.00\n") != std::string::npos);
result.error_model_isa_resolved = false;
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nISA= undetermined\n") != std::string::npos);
CHECK(text.find("ISA_ASYMPTOTIC") == std::string::npos);
}
// The completeness of the range where the signal is, beside the completeness of the range that was
// written. Users compare the single overall figure against another program's and conclude the run
// lost data, when it is the deliberately generous cut diluting the denominator - so the report says
// both. But only when they are different numbers: on a detector-limited crystal every shell still
// carries signal, and printing "82.4 % to 1.22 A; 82.4 % over the full written range" says one
// thing twice.
TEST_CASE("ResultReport_CompletenessTwoRanges", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
auto &o = result.merge_statistics.overall;
o.d_max = 50.0f;
o.d_min = 1.20f;
o.unique_reflections = 900;
o.possible_unique_reflections = 1200;
o.total_observations = 9000;
o.cc_half = 0.95;
o.mean_i_over_sigma = 10.0;
MergeStatisticsShell inner; // signal here
inner.d_max = 50.0f;
inner.d_min = 1.50f;
inner.cc_half = 0.95;
inner.unique_reflections = 700;
inner.possible_unique_reflections = 720;
MergeStatisticsShell outer = inner; // written, but past the fall-off
outer.d_max = 1.50f;
outer.d_min = 1.20f;
outer.cc_half = 0.02;
outer.unique_reflections = 200;
outer.possible_unique_reflections = 480;
result.merge_statistics.shells.push_back(inner);
result.merge_statistics.shells.push_back(outer);
// The signal stops at 1.50 A and the reflections were written to 1.20 A: two different ranges,
// so both numbers are reported and the outer shells are visibly what dilutes the overall one.
const auto narrower = RenderResultReport("p", "in.h5", x, result);
CHECK(narrower.find("% to 1.50 A;") != std::string::npos);
CHECK(narrower.find("\nCOMPLETENESS= 75.0\n") != std::string::npos);
// Detector-limited: every shell still reaches the target, so the range where the signal is IS
// the written range and the second number would repeat the first.
result.merge_statistics.shells[1].cc_half = 0.95;
result.merge_statistics.shells[1].possible_unique_reflections = 200;
const auto same = RenderResultReport("p", "in.h5", x, result);
CHECK(same.find(" % to ") == std::string::npos);
CHECK(same.find("over the full written range") != std::string::npos);
}
// The TNCS_* block is an interface in the same way the keys above are, and the distinction between
// FALSE, INCONCLUSIVE and NOT_MEASURED is the whole reason it is an enum rather than a boolean: a
// merge the detector could not run on must not read as a crystal that was tested and found clean.
TEST_CASE("ResultReport_TranslationalNCS", "[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};
result.space_group = *gemmi::find_spacegroup_by_number(96);
result.has_merge_statistics = true;
{
const auto text = RenderResultReport("prefix", "in.h5", x, result);
CHECK(text.find("\nTNCS_DETECTED= NOT_MEASURED\n") != std::string::npos);
CHECK(text.find("not a\n# statement that this crystal has none") != std::string::npos);
}
result.tncs.measurable = true;
result.tncs.n_reflections = 4000;
result.tncs.peak_percent = 54.2;
result.tncs.peak_z = 42.3;
result.tncs.null_mean = 9.9;
result.tncs.null_sd = 1.05;
result.tncs.modulation_measured = true;
result.tncs.vector_frac = {0.0, 0.5, 0.25};
result.tncs.vector_length_A = 40.7;
result.tncs.modulation = 12.50;
result.tncs.modulation_null = 1.48;
result.tncs.detected = true;
result.tncs.near_extinct_class = true;
const auto text = RenderResultReport("prefix", "in.h5", x, result);
CHECK(text.find("\nTNCS_DETECTED= TRUE\n") != std::string::npos);
CHECK(text.find("\nTNCS_PATTERSON_PEAK_PCT= 54.2\n") != std::string::npos);
CHECK(text.find("\nTNCS_PATTERSON_PEAK_Z= 42.3\n") != std::string::npos);
CHECK(text.find("\nTNCS_PATTERSON_NULL_PCT= 9.9 +- 1.05\n") != std::string::npos);
CHECK(text.find("\nTNCS_VECTOR= 0.000 0.500 0.250\n") != std::string::npos);
CHECK(text.find("\nTNCS_VECTOR_LENGTH= 40.7\n") != std::string::npos);
CHECK(text.find("\nTNCS_MODULATION= 12.50\n") != std::string::npos);
CHECK(text.find("\nTNCS_MODULATION_NULL= 1.48\n") != std::string::npos);
CHECK(text.find("\nTNCS_SUBLATTICE= NEAR_EXTINCT_CLASS\n") != std::string::npos);
// A detection is a condition a consumer switches on, not only a sentence to read.
CHECK(text.find("\nPATHOLOGY_FLAGS= PSEUDO_TRANSLATION LATTICE_TRANSLATION\n")
!= std::string::npos);
CHECK(text.find("\nVERDICT= WARNINGS\n") != std::string::npos);
// TNCS_REFLECTIONS is a pipeline internal and stays out of the default report.
CHECK(text.find("\nTNCS_REFLECTIONS=") == std::string::npos);
RunProvenance dev;
dev.developer = true;
CHECK(RenderResultReport("prefix", "in.h5", x, result, dev).find("\nTNCS_REFLECTIONS= 4000\n")
!= std::string::npos);
// A translation the data are EXACTLY invariant under is a lattice vector, and is reported as one
// rather than as a pseudo-symmetry - this is the centred-lattice-merged-in-P1 case.
result.tncs.detected = false;
result.tncs.near_extinct_class = false;
result.tncs.undeclared_lattice_translations.push_back({0.5, 0.5, 0.0});
result.tncs.undeclared_lattice_translation_pct.push_back(98.0);
const auto lat = RenderResultReport("prefix", "in.h5", x, result);
CHECK(lat.find("\nTNCS_DETECTED= FALSE\n") != std::string::npos);
CHECK(lat.find("\nUNDECLARED_LATTICE_TRANSLATION= 0.500 0.500 0.000\n") != std::string::npos);
CHECK(lat.find("\nUNDECLARED_LATTICE_TRANSLATION_PCT= 98.0\n") != std::string::npos);
CHECK(lat.find("\nPATHOLOGY_FLAGS= LATTICE_TRANSLATION\n") != std::string::npos);
CHECK(lat.find("\nTNCS_SUBLATTICE=") == std::string::npos);
// Admitted at 75% but short of a lattice translation's height: still a prompt to check, and the
// wording names the very strong pseudo-translation as the other reading.
result.tncs.undeclared_lattice_translation_pct = {80.0};
const auto strong = RenderResultReport("prefix", "in.h5", x, result);
CHECK(strong.find("\nPATHOLOGY_FLAGS= LATTICE_TRANSLATION\n") != std::string::npos);
CHECK(strong.find("or a very strong pseudo-translation") != std::string::npos);
// Significant but weak: warned about, and worded as a check.
result.tncs.undeclared_lattice_translations.clear();
result.tncs.undeclared_lattice_translation_pct.clear();
result.tncs.detected = true;
result.tncs.peak_percent = 8.1;
const auto weak = RenderResultReport("prefix", "in.h5", x, result);
CHECK(weak.find("\nTNCS_DETECTED= TRUE\n") != std::string::npos);
CHECK(weak.find("\nPATHOLOGY_FLAGS= PSEUDO_TRANSLATION\n") != std::string::npos);
CHECK(weak.find("significant but weak; check whether molecular replacement needs it declared") != std::string::npos);
}
TEST_CASE("ResultReport_GeometryNotConverged", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.pass_number = 10;
result.pass_count = 10;
result.pass_decision = "post-refined geometry walked 8 rounds and kept round 8";
CHECK(RenderResultReport("p", "in.h5", x, result).find("GEOMETRY_NOT_CONVERGED") == std::string::npos);
result.geometry_not_converged = true;
const auto text = RenderResultReport("p", "in.h5", x, result);
const auto flags = text.find("\nPATHOLOGY_FLAGS= ");
REQUIRE(flags != std::string::npos);
CHECK(text.substr(flags, text.find('\n', flags + 1) - flags).find("GEOMETRY_NOT_CONVERGED")
!= std::string::npos);
}
TEST_CASE("ResultReport_RotationScale", "[Diagnostics]") {
// A run that adopted a goniometer rotation scale integrated at the scaled oscillation, so that
// is the oscillation the report gives; the starting angle is the stage's own and stays.
DiffractionExperiment x(DetJF(1));
x.Goniometer(GoniometerAxis("omega", 10.0f, 0.1f, Coord(1, 0, 0), {}));
ProcessResult result;
result.post_refine = PostRefineResult{};
result.post_refine->rotation_scale = 0.97;
result.post_refine->rotation_scale_suspect = true;
RunProvenance dev;
dev.developer = true;
const auto text = RenderResultReport("prefix", "in.h5", x, result, dev);
CHECK(text.find("\nOSCILLATION_RANGE= 0.0970\n") != std::string::npos);
CHECK(text.find("\nSTARTING_ANGLE= 10.000\n") != std::string::npos);
CHECK(text.find("\nGONIOMETER_ROTATION_SCALE= 0.97000\n") != std::string::npos);
CHECK(text.find("\nGONIOMETER_ROTATION_SCALE_SUSPECT= TRUE\n") != std::string::npos);
// A fit the run did not act on is reported as a fit, and the file's oscillation stands.
result.post_refine->rotation_scale_suspect = false;
const auto kept = RenderResultReport("prefix", "in.h5", x, result, dev);
CHECK(kept.find("\nOSCILLATION_RANGE= 0.1000\n") != std::string::npos);
CHECK(kept.find("\nGONIOMETER_ROTATION_SCALE_SUSPECT= FALSE\n") != std::string::npos);
}
TEST_CASE("LeftoverLattice_Misorientation", "[Diagnostics]") {
const CrystalLattice triclinic(52.0f, 61.0f, 73.0f, 81.0f, 76.0f, 69.0f);
const auto rotated = [](const CrystalLattice &l, float deg, const Coord &axis) {
const RotMatrix r(deg * static_cast<float>(PI) / 180.0f, axis);
return CrystalLattice(r * l.Vec0(), r * l.Vec1(), r * l.Vec2());
};
const CrystalLattice turned = rotated(triclinic, 3.0f, Coord(0.3f, -0.5f, 0.8f));
CHECK(LatticeMisorientationDeg(triclinic, turned) == Catch::Approx(3.0).margin(0.01));
// The same lattice in another basis of the same metric (a cyclic relabelling written back) is
// still 3 deg away.
const CrystalLattice relabelled(turned.Vec1(), turned.Vec2(), turned.Vec0());
CHECK(LatticeMisorientationDeg(triclinic, relabelled) == Catch::Approx(3.0).margin(0.01));
// Turned by 180 deg about an axis that is no symmetry of a triclinic lattice: a twin domain.
CHECK(LatticeMisorientationDeg(triclinic, rotated(triclinic, 180.0f, Coord(0.2f, 0.9f, 0.1f)))
== Catch::Approx(180.0).margin(0.01));
// A lattice symmetry is not a misorientation: tetragonal turned 90 deg about c.
const CrystalLattice tetragonal(40.0f, 40.0f, 90.0f, 90.0f, 90.0f, 90.0f);
CHECK(LatticeMisorientationDeg(tetragonal, rotated(tetragonal, 90.0f, tetragonal.Vec2())) < 0.01);
// An unrelated cell has no matching basis.
CHECK(std::isnan(LatticeMisorientationDeg(triclinic, CrystalLattice(45.0f, 45.0f, 120.0f, 90.0f, 90.0f, 90.0f))));
}
TEST_CASE("LeftoverLattice_Classify", "[Diagnostics]") {
CHECK(ClassifyLeftoverLattice(0.4, 1.0, 0.3) == LeftoverLatticeKind::Self);
CHECK(ClassifyLeftoverLattice(2.7, 1.0, 0.3) == LeftoverLatticeKind::Domain);
CHECK(ClassifyLeftoverLattice(179.9, 1.0, 0.3) == LeftoverLatticeKind::TwinDomain);
CHECK(ClassifyLeftoverLattice(71.0, 1.0, 0.8) == LeftoverLatticeKind::Segmented);
CHECK(ClassifyLeftoverLattice(NAN, 0.5, 0.3) == LeftoverLatticeKind::RelatedCell);
CHECK(ClassifyLeftoverLattice(NAN, 3.02, 0.3) == LeftoverLatticeKind::RelatedCell);
CHECK(ClassifyLeftoverLattice(NAN, 1.34, 0.3) == LeftoverLatticeKind::Foreign);
}
TEST_CASE("ResultReport_LeftoverLattices", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 50.0f, .b = 60.0f, .c = 70.0f,
.alpha = 90.0f, .beta = 100.0f, .gamma = 90.0f};
LeftoverLatticeCensus census;
census.primary_spots_pct = 60.0;
census.primary_intensity_pct = 70.0;
result.leftover_lattices = census;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("EXTRA_LATTICE_COUNT= 0") != std::string::npos);
CHECK(text.find("Multiple lattices none found") != std::string::npos);
CHECK(text.find("MULTIPLE_LATTICES= FALSE") != std::string::npos);
// A related cell carries intensity but is a question about the main cell, not a second crystal.
LeftoverLattice related{.kind = LeftoverLatticeKind::RelatedCell, .cell = *result.consensus_cell,
.volume_ratio = 0.5, .misorientation_deg = NAN, .z = 40.0,
.spots_pct = 15.0, .intensity_pct = 12.0, .sweep_concentration = 0.3};
result.leftover_lattices->lattices = {related};
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("EXTRA_LATTICE_1_KIND= RELATED_CELL") != std::string::npos);
CHECK(text.find("EXTRA_LATTICE_1_MISORIENTATION_DEG") == std::string::npos);
CHECK(text.find("MULTIPLE_LATTICES= FALSE") != std::string::npos);
// An unrelated cell is listed but not warned about: it is as often a wrong main lattice or tNCS.
LeftoverLattice foreign{.kind = LeftoverLatticeKind::Foreign, .cell = *result.consensus_cell,
.volume_ratio = 1.34, .misorientation_deg = NAN, .z = 40.0,
.spots_pct = 20.0, .intensity_pct = 30.0, .sweep_concentration = 0.3};
result.leftover_lattices->lattices = {foreign};
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("EXTRA_LATTICE_1_KIND= FOREIGN") != std::string::npos);
CHECK(text.find("MULTIPLE_LATTICES= FALSE") != std::string::npos);
LeftoverLattice domain{.kind = LeftoverLatticeKind::Domain, .cell = *result.consensus_cell,
.volume_ratio = 1.0, .misorientation_deg = 2.7, .z = 250.0,
.spots_pct = 20.0, .intensity_pct = 25.0, .sweep_concentration = 0.3};
result.leftover_lattices->lattices = {domain, related};
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("EXTRA_LATTICE_COUNT= 2") != std::string::npos);
CHECK(text.find("EXTRA_LATTICE_1_MISORIENTATION_DEG= 2.70") != std::string::npos);
CHECK(text.find("EXTRA_LATTICE_INTENSITY_PCT= 25.0") != std::string::npos);
CHECK(text.find("MULTIPLE_LATTICES= TRUE") != std::string::npos);
CHECK(text.find("split crystal, 2.7 deg from the main lattice") != std::string::npos);
const auto flags = text.find("\nPATHOLOGY_FLAGS= ");
REQUIRE(flags != std::string::npos);
CHECK(text.substr(flags, text.find('\n', flags + 1) - flags).find("MULTIPLE_LATTICES") != std::string::npos);
}
TEST_CASE("ResultReport_Mosaicity", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
CHECK(RenderResultReport("p", "in.h5", x, result).find("MOSAICITY_DEG") == std::string::npos);
result.mosaicity_deg = 0.091;
result.mosaicity_deg_p10 = 0.080;
result.mosaicity_deg_p90 = 0.105;
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("MOSAICITY_DEG= 0.091") != std::string::npos);
CHECK(text.find("MOSAICITY_DEG_P10= 0.080") != std::string::npos);
CHECK(text.find("MOSAICITY_DEG_P90= 0.105") != std::string::npos);
CHECK(text.find("Mosaicity 0.091 deg") != std::string::npos);
}
TEST_CASE("ResultReport_SupercellCheck", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 50.0f, .b = 60.0f, .c = 70.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
ProcessResult::SupercellClass sc;
sc.h = 0; sc.k = 0; sc.l = 1;
sc.occupancy_pct = 18.0;
sc.rock_pct = 5.0;
sc.rock_se_pct = 0.4;
sc.i_over_sigma = 1.2;
sc.doubled_cell = UnitCell{.a = 50.0f, .b = 60.0f, .c = 140.0f, .alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.supercell = sc;
// A weak rocking part, as a real doubled cell can show: a prompt to check, worded as one.
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nSUPERCELL_POSSIBLE= TRUE\n") != std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= SUPERCELL_POSSIBLE\n") != std::string::npos);
CHECK(text.find("POSSIBLE, check - class 0 0 1") != std::string::npos);
CHECK(text.find("WARNING: Check the cell") != std::string::npos);
CHECK(text.find("may put spots there") == std::string::npos);
// Further domains of the crystal can put spots on the half-integer nodes, and the line says so.
LeftoverLatticeCensus census;
census.primary_intensity_pct = 70.0;
census.lattices.push_back(LeftoverLattice{.kind = LeftoverLatticeKind::TwinDomain, .cell = *result.consensus_cell,
.volume_ratio = 1.0, .misorientation_deg = 179.5, .z = 50.0,
.spots_pct = 8.0, .intensity_pct = 7.0, .sweep_concentration = 0.3});
result.leftover_lattices = census;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("the further lattice domains found may put spots there") != std::string::npos);
CHECK(text.find("spots of the further lattice domains found land on those positions") != std::string::npos);
// Not measured to rock: no indication.
result.supercell->rock_pct = 1.0;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("Supercell no indication") != std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= NONE\n") != std::string::npos);
}
TEST_CASE("ResultReport_TwinningOutsidePhysicalRange", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 70.0f, .b = 70.0f, .c = 70.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
result.twinning.l_test_pairs = 5000;
result.twinning.mean_abs_l = 0.355;
result.twinning.second_moment = 1.60;
result.twinning.twinning_suspected = true;
result.twinning.adopted_operators_suspect = true;
// Below the 0.375 of a perfect twin: no twin fraction can give it, so neither a twin nor a false
// operator is read from it.
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nTWINNING_VERDICT= NOT_READABLE\n") != std::string::npos);
CHECK(text.find("TWINNING ") == std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= NONE\n") != std::string::npos);
CHECK(text.find("Twinning not readable (<|L|> 0.355") != std::string::npos);
CHECK(text.find("SYMMETRY SUSPECT") == std::string::npos);
// Inside the range on the adopted merge but far above 0.5 on the merge before the search: the
// statistic is distorted on these data, and the low adopted reading is not evidence either.
result.twinning.mean_abs_l = 0.395;
TwinningAnalysisResult pre;
pre.l_test_pairs = 5000;
pre.mean_abs_l = 0.590;
result.pre_promotion_twinning = pre;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nTWINNING_VERDICT= NOT_READABLE\n") != std::string::npos);
CHECK(text.find("0.590 before the space-group search") != std::string::npos);
// An ordinary reading keeps its verdict.
result.pre_promotion_twinning.reset();
result.twinning.adopted_operators_suspect = false;
result.twinning.mean_abs_l = 0.419;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nTWINNING_VERDICT= INDICATED\n") != std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= TWINNING\n") != std::string::npos);
}
TEST_CASE("ResultReport_RotationLatticeWithoutIndexedFrames", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
// A rotation run that indexed the sweep as a whole while no frame indexed on its own.
ProcessResult result;
result.images_processed = 1800;
result.indexing_rate = 0.0f;
result.rotation_lattice_found = true;
result.consensus_cell = UnitCell{.a = 50.0f, .b = 60.0f, .c = 70.0f,
.alpha = 90.0f, .beta = 100.0f, .gamma = 90.0f};
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("NO_LATTICE") == std::string::npos);
CHECK(text.find("No image indexed") == std::string::npos);
CHECK(text.find("\nLATTICE_FOUND= TRUE\n") != std::string::npos);
// The no-crystal exit: the CLI hands over only the reason. The verdict has no image count to quote.
ProcessResult none;
none.warnings.push_back({PathologyCode::NO_LATTICE, "No crystal lattice was found: none"});
const auto failed = RenderResultReport("p", "in.h5", x, none);
CHECK(failed.find("\nVERDICT= FAILED\n") != std::string::npos);
CHECK(failed.find("\nPATHOLOGY_FLAGS= NO_LATTICE\n") != std::string::npos);
CHECK(failed.find("No crystal lattice was found in these frames") != std::string::npos);
CHECK(failed.find("UNIT_CELL_CONSTANTS=") == std::string::npos);
CHECK(failed.find("SPACE_GROUP_NUMBER=") == std::string::npos);
}
TEST_CASE("ResultReport_PowderAndIce", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("Powder no rings detected") != std::string::npos);
CHECK(text.find("ICE_RINGS_DETECTED") == std::string::npos);
result.powder.rings_q_recipA = {1.61f, 1.71f, 1.82f};
result.powder.spot_fraction = 0.04f;
result.powder.ice_ring_count = 2;
result.powder.ice_spot_fraction = 0.03f;
result.powder.non_ice_spot_fraction = 0.01f;
result.ice_ring_score = 2.69f;
result.ice_spot_ratio = 1.08f;
result.ice_rings_detected = true;
result.ice_flagged_fraction = 0.257;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("Powder 3 rings holding 4% of the spots found - 2 ice (3%), 1 other (1%)") != std::string::npos);
CHECK(text.find("\nICE_RINGS_DETECTED= TRUE\n") != std::string::npos);
CHECK(text.find("\nICE_REFLECTIONS_ON_RINGS_PCT= 25.7\n") != std::string::npos);
CHECK(text.find("Ice rings DETECTED: the 25.7% of the reflections") != std::string::npos);
// The merge found ice: a prompt to check the ice shells, and not a powder warning.
CHECK(text.find("\nPATHOLOGY_FLAGS= ICE_RINGS\n") != std::string::npos);
// Rings that are not ice are a separate prompt, in their own words.
result.ice_rings_detected = false;
result.powder.non_ice_spot_fraction = 0.20f;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= POWDER_RINGS\n") != std::string::npos);
CHECK(text.find("Powder rings that are not ice: 1 rings hold 20%") != std::string::npos);
result.powder.ice_spot_fraction = 0.30f;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= ICE_RINGS POWDER_RINGS\n") != std::string::npos);
}
TEST_CASE("ResultReport_SweepGapsMaterial", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.has_merge_statistics = true;
result.merge_statistics.sweep_quality = TestSweepQuality();
// Two frames of a long sweep: in the table and the summary, not a condition of the data set.
result.merge_statistics.sweep_quality.sweep_deg = 360.0f;
result.merge_statistics.sweep_quality.ranges.resize(1);
result.merge_statistics.sweep_quality.ranges[0].rotation_deg = 1.0f;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("SWEEP_GAPS") == std::string::npos);
CHECK(text.find("Frames 100-149") == std::string::npos);
RunProvenance dev;
dev.developer = true;
CHECK(RenderResultReport("p", "in.h5", x, result, dev).find("Frames 100-149") != std::string::npos);
result.merge_statistics.sweep_quality.ranges[0].rotation_deg = 40.0f;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= SWEEP_GAPS\n") != std::string::npos);
}
TEST_CASE("ResultReport_SecondCrystal", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
LeftoverLatticeCensus census;
census.primary_intensity_pct = 80.0;
census.lattices.push_back(LeftoverLattice{.kind = LeftoverLatticeKind::Domain, .cell = *result.consensus_cell,
.volume_ratio = 1.0, .misorientation_deg = 38.9, .z = 50.0,
.spots_pct = 12.0, .intensity_pct = 14.0, .sweep_concentration = 0.3});
result.leftover_lattices = census;
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("second crystal, 38.9 deg from the main lattice") != std::string::npos);
CHECK(text.find("split crystal, ") == std::string::npos);
}
// The summary's checklist: a status per pathology, PRESENT exactly where its code is flagged, and no
// flag left without a check of its own.
TEST_CASE("ResultReport_Checks", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
result.pass_number = 10;
result.pass_count = 10;
result.geometry_not_converged = true;
result.harmonic.measurable = true;
const auto doc = BuildReportDocument("p", "in.h5", x, result);
const auto find = [&](const std::string &label) {
return std::ranges::find(doc.checks, label, &ReportCheck::label);
};
REQUIRE(find("Beam") != doc.checks.end());
CHECK(find("Beam")->status == ReportCheck::Status::Absent);
REQUIRE(find("Multiple lattices") != doc.checks.end());
CHECK(find("Multiple lattices")->status == ReportCheck::Status::Undetermined);
// GEOMETRY_NOT_CONVERGED has no summary row: it gets a check named after its code.
REQUIRE(find("Geometry not converged") != doc.checks.end());
CHECK(find("Geometry not converged")->status == ReportCheck::Status::Present);
CHECK(!find("Geometry not converged")->text.empty());
for (const auto &code : doc.pathology_flags)
CHECK(std::ranges::any_of(doc.checks, [&](const ReportCheck &c) {
return c.status == ReportCheck::Status::Present
&& std::ranges::find(c.codes, code) != c.codes.end(); }));
for (const auto &c : doc.checks)
if (c.status == ReportCheck::Status::Present)
CHECK(std::ranges::any_of(c.codes, [&](const std::string &code) {
return std::ranges::find(doc.pathology_flags, code) != doc.pathology_flags.end(); }));
}
TEST_CASE("ResultReport_BeamCentreOff", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
x.BeamX_pxl(500.0f).BeamY_pxl(400.0f);
const auto file = x.GetDiffractionGeometry().GetDirectBeam_pxl();
ProcessResult result;
result.post_refine = PostRefineResult{};
result.beam_centre_need_pxl = 2.0f;
result.background_beam_centre = BeamCenterEstimate{.beam_x_pxl = static_cast<float>(file.first) + 7.0f,
.beam_y_pxl = static_cast<float>(file.second),
.sigma_pxl = 0.5f};
// Refined within the tolerance: the background's word is not enough where the centre was refined.
result.used_direct_beam_x_pxl = file.first + 1.0;
result.used_direct_beam_y_pxl = file.second;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("BEAM_CENTRE_OFF") == std::string::npos);
CHECK(text.find("\nBEAM_CENTRE_SHIFT= 1.00\n") != std::string::npos);
CHECK(text.find("\nBEAM_CENTRE_TOLERANCE= 2.00\n") != std::string::npos);
// 4 px off: over the tolerance but under three times it - stated, not warned.
result.used_direct_beam_x_pxl = file.first;
result.used_direct_beam_y_pxl = file.second + 4.0;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("BEAM_CENTRE_OFF") == std::string::npos);
CHECK(text.find("under three times it") != std::string::npos);
// Refined 8 px off the file's centre: warned, with both centres, px and mm, and the advice.
result.used_direct_beam_y_pxl = file.second + 8.0;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= BEAM_CENTRE_OFF\n") != std::string::npos);
CHECK(text.find("8.0 px (0.60 mm)") != std::string::npos);
CHECK(text.find("correct the beam centre at the beamline") != std::string::npos);
// Nothing refined (stills): the background alone warns, worded as a check.
result.post_refine.reset();
result.used_direct_beam_x_pxl = file.first;
result.used_direct_beam_y_pxl = file.second;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= BEAM_CENTRE_OFF\n") != std::string::npos);
CHECK(text.find("the background's word alone") != std::string::npos);
}
TEST_CASE("ResultReport_OverloadsAndOverlaps", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
ProcessResult result;
result.integrated_reflections = 100000;
result.integrated_reflections_low_res = 5000;
result.overloaded_reflections = 5;
result.overloaded_reflections_low_res = 5;
result.predicted_reflections = 100000;
result.overlapped_reflections = 5000;
result.overlapped_reflections_major = 500;
auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nOVERLOADED_REFLECTIONS= 5\n") != std::string::npos);
CHECK(text.find("\nOVERLOADED_REFLECTIONS_LOW_RES_PCT= 0.100\n") != std::string::npos);
CHECK(text.find("\nOVERLAPPED_REFLECTIONS_MAJOR_PCT= 0.50\n") != std::string::npos);
CHECK(text.find("\nPATHOLOGY_FLAGS= NONE\n") != std::string::npos);
// 1.2% of the low-resolution reflections overloaded, 1.5% left under MINPK: both warn.
result.overloaded_reflections = 60;
result.overloaded_reflections_low_res = 60;
result.overlapped_reflections_major = 1500;
text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("\nPATHOLOGY_FLAGS= OVERLOADS OVERLAPS\n") != std::string::npos);
CHECK(text.find("Attenuate the beam") != std::string::npos);
CHECK(text.find("Move the detector back") != std::string::npos);
}
TEST_CASE("ResultReport_XDSGeometryKeywords", "[Diagnostics]") {
DiffractionExperiment x(DetJF(1));
x.BeamX_pxl(500.0f).BeamY_pxl(400.0f).DetectorDistance_mm(100.0f);
x.Goniometer(GoniometerAxis("omega", 10.0f, 0.1f, Coord(1, 0, 0), {}));
ProcessResult result;
result.consensus_cell = UnitCell{.a = 79.0f, .b = 79.0f, .c = 38.0f,
.alpha = 90.0f, .beta = 90.0f, .gamma = 90.0f};
// The refined geometry, not the file's, goes out - 1-based, untilted here.
result.used_beam_x_pxl = 510.0f;
result.used_beam_y_pxl = 395.5f;
result.used_distance_mm = 120.0f;
result.used_rotation_axis = Coord(-1, 0, 0);
const auto text = RenderResultReport("p", "in.h5", x, result);
CHECK(text.find("XDS GEOMETRY KEYWORDS") != std::string::npos);
CHECK(text.find("\n# XDS.INP> ORGX= 511.00 ORGY= 396.50\n") != std::string::npos);
CHECK(text.find("\n# XDS.INP> DETECTOR_DISTANCE= 120.000\n") != std::string::npos);
// XDS turns the crystal the other way about the axis it is given.
CHECK(text.find("\n# XDS.INP> ROTATION_AXIS= 1.000000 0.000000 0.000000\n") != std::string::npos);
// Untilted: no detector axes; and never the cell or the space group.
CHECK(text.find("XDS.INP> DIRECTION_OF_DETECTOR") == std::string::npos);
CHECK(text.find("XDS.INP> UNIT_CELL") == std::string::npos);
}