// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include "../common/DiffractionExperiment.h" #include "../image_analysis/scale_merge/Merge.h" #include "../reader/JFJochHDF5Reader.h" #include "../rugnux/ResultReport.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 ReasonVocabulary() { std::vector out; for (int r = 0; r <= static_cast(SweepQualityReason::RadiationDamage); ++r) out.emplace_back(SweepQualityReasonCode(static_cast(r))); return out; } } TEST_CASE("SweepQuality_ReasonVocabulary", "[Diagnostics]") { // The codes are an interface - they are written verbatim into _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_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= 1\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("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 expected{0, 0, static_cast(SweepQualityReason::CrystalOutOfBeam) + 1, static_cast(SweepQualityReason::CrystalOutOfBeam) + 1, 0, static_cast(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 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"); }