A new last column, "disposition", gives each frame's merge disposition as a number to plot directly: 1 merged, 0.5 downgraded, 0 rejected; nan for a frame that never got a scale (never reached the merge) and on a run that merged nothing, as the file writes every value nothing measured. Existing columns keep their indices. 0.5 is a marker, not a weight. "Downgraded" is a label applied after the merge (RotationScaleMerge::CountSweepDisposition marks the merged frames inside a flagged sweep-quality range); the merge applies no factor of its own to them - they are carried at whatever weight their own scale and sigmas give - so there is no fixed or per-frame reduction factor that could honestly be written instead. Writer-only change: on three open-arm sets with downgraded and rejected frames p.hkl is identical to the same commit without it, the first ten columns are unchanged, and the column's 1/0.5/0 counts equal the report's merged/downgraded/rejected counts. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
197 lines
8.0 KiB
C++
197 lines
8.0 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include <cmath>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
|
|
#include "../rugnux/DiagnosticOutput.h"
|
|
#include "../image_analysis/scale_merge/Merge.h"
|
|
|
|
namespace {
|
|
bool same(const rgb &a, const rgb &b) {
|
|
return a.r == b.r && a.g == b.g && a.b == b.b;
|
|
}
|
|
|
|
// The columns of one row of <prefix>_plot.txt, whitespace-separated.
|
|
std::vector<std::string> Columns(const std::string &line) {
|
|
std::istringstream is(line);
|
|
std::vector<std::string> ret;
|
|
for (std::string token; is >> token;)
|
|
ret.push_back(token);
|
|
return ret;
|
|
}
|
|
|
|
std::vector<std::string> ReadLines(const std::string &path) {
|
|
std::ifstream f(path);
|
|
std::vector<std::string> ret;
|
|
for (std::string line; std::getline(f, line);)
|
|
ret.push_back(line);
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("DiagnosticImage_Colors") {
|
|
ColorScale scale;
|
|
|
|
// One pixel of each kind, in the order the renderer tests them. Two measured pixels read zero,
|
|
// so the median is zero and the third one saturates.
|
|
std::vector<uint32_t> mask_bits{
|
|
0, // plain, no counts
|
|
0, // plain, no counts
|
|
0, // plain, the brightest pixel
|
|
1u << PixelMask::ModuleGapPixelBit,
|
|
1u << PixelMask::BeamStopPixelBit,
|
|
1u << PixelMask::UserMaskedPixelBit,
|
|
(1u << PixelMask::ModuleGapPixelBit) | (1u << PixelMask::BeamStopPixelBit),
|
|
(1u << PixelMask::BeamStopPixelBit) | (1u << PixelMask::NoisyPixelBit)};
|
|
const PixelMask mask(mask_bits);
|
|
const std::vector<float> projection{0.0f, 0.0f, 1000.0f, NAN, NAN, NAN, NAN, NAN};
|
|
|
|
const auto image = DetectorDiagnosticRGB(mask, projection);
|
|
REQUIRE(image.size() == mask_bits.size());
|
|
|
|
CHECK(same(image[0], scale.Apply(0.0f))); // white: no counts
|
|
CHECK(same(image[2], scale.Apply(1.0f))); // indigo: the saturation value
|
|
CHECK(same(image[3], scale.Apply(ColorScaleSpecial::Gap))); // grey
|
|
CHECK(same(image[4], scale.Apply(ColorScaleSpecial::BeamStop))); // coral
|
|
CHECK(same(image[5], scale.Apply(ColorScaleSpecial::BadPixel))); // magenta
|
|
// A gap cannot be shadowed - it carries no background for the detection to miss - so the gap
|
|
// wins; every other mask bit loses to the shadow, which is what the picture is there to show.
|
|
CHECK(same(image[6], scale.Apply(ColorScaleSpecial::Gap)));
|
|
CHECK(same(image[7], scale.Apply(ColorScaleSpecial::BeamStop)));
|
|
}
|
|
|
|
TEST_CASE("DiagnosticImage_Contrast") {
|
|
ColorScale scale;
|
|
|
|
// The ramp saturates at eight times the MEDIAN of the measured pixels - 128 here, so 1024 - so
|
|
// the background spans the ramp and the peak clips. Set from the brightest pixels instead, as
|
|
// the preview and the viewer set their contrast, the peak would take the whole range and this
|
|
// background would sit at the very bottom of the scale, a flat white: a missing background is
|
|
// what a shadow is read off, so that picture cannot be judged.
|
|
std::vector<uint32_t> mask_bits{0, 0, 0, 0, 0};
|
|
const PixelMask mask(mask_bits);
|
|
const std::vector<float> projection{0.0f, 128.0f, 128.0f, 1024.0f, 5000.0f};
|
|
|
|
const auto image = DetectorDiagnosticRGB(mask, projection);
|
|
const auto &lut = scale.LUTData();
|
|
REQUIRE(image.size() == projection.size());
|
|
|
|
CHECK(same(image[0], lut.front()));
|
|
CHECK(same(image[1], lut[lut.size() / 8]));
|
|
CHECK(same(image[2], lut[lut.size() / 8]));
|
|
CHECK(same(image[3], lut.back()));
|
|
// Clipped, and that costs nothing here - the spots are not what the picture is judged on.
|
|
CHECK(same(image[4], lut.back()));
|
|
}
|
|
|
|
TEST_CASE("DiagnosticPlot_Format") {
|
|
const std::string prefix = "diagnostic_plot_format_test";
|
|
|
|
PerImagePlot plot;
|
|
plot.bkg_estimate = {1.0f, 2.0f, 3.0f, 4.0f};
|
|
plot.resolution_A = {2.5f, 2.4f, NAN, 2.6f};
|
|
plot.spot_count = {100.0f, 120.0f, 0.0f, 90.0f};
|
|
plot.scale_g = {1.0f, 1.1f, NAN, 0.9f};
|
|
plot.mosaicity_deg = {0.12f, 0.12f, NAN, 0.13f};
|
|
plot.cc_to_merge = {0.98f, 0.97f, NAN, 0.5f};
|
|
plot.cc_n = {500, 480, 0, 120};
|
|
plot.frame_disposition = {static_cast<uint8_t>(FrameDisposition::Merged),
|
|
static_cast<uint8_t>(FrameDisposition::Downgraded),
|
|
static_cast<uint8_t>(FrameDisposition::Rejected),
|
|
static_cast<uint8_t>(FrameDisposition::Rejected)};
|
|
|
|
const GoniometerAxis goniometer("omega", 10.0f, 0.5f, {0, 1, 0}, {});
|
|
WritePerImagePlot(prefix, plot, goniometer, 4);
|
|
|
|
const auto lines = ReadLines(prefix + "_plot.txt");
|
|
REQUIRE(lines.size() == 5);
|
|
|
|
// Exactly one legend row, and it is a comment.
|
|
REQUIRE(lines[0][0] == '#');
|
|
for (size_t i = 1; i < lines.size(); i++)
|
|
CHECK(lines[i].find('#') == std::string::npos);
|
|
|
|
const auto legend = Columns(lines[0]);
|
|
REQUIRE(legend.size() == 11);
|
|
CHECK(legend[0] == "#image");
|
|
CHECK(legend[1] == "angle_deg");
|
|
CHECK(legend[6] == "sigma_M_deg");
|
|
CHECK(legend[8] == "cc_n");
|
|
CHECK(legend[9] == "merged");
|
|
CHECK(legend[10] == "disposition");
|
|
|
|
for (size_t i = 1; i < lines.size(); i++) {
|
|
const auto row = Columns(lines[i]);
|
|
REQUIRE(row.size() == legend.size());
|
|
CHECK(row[0] == std::to_string(i - 1));
|
|
}
|
|
|
|
// Mid-exposure angle: start + ordinal * increment + increment / 2.
|
|
CHECK(std::stof(Columns(lines[1])[1]) == 10.25f);
|
|
CHECK(std::stof(Columns(lines[3])[1]) == 11.25f);
|
|
|
|
// Merged and downgraded frames are in the merged data; a rejected one is not.
|
|
CHECK(Columns(lines[1])[9] == "1");
|
|
CHECK(Columns(lines[2])[9] == "1");
|
|
CHECK(Columns(lines[3])[9] == "0");
|
|
CHECK(Columns(lines[4])[9] == "0");
|
|
|
|
// Graded: merged 1, downgraded 0.5, rejected 0 - and a rejected frame that never got a scale
|
|
// never reached the merge, so nothing is known of it.
|
|
CHECK(Columns(lines[1])[10] == "1");
|
|
CHECK(Columns(lines[2])[10] == "0.5");
|
|
CHECK(Columns(lines[3])[10] == "nan");
|
|
CHECK(Columns(lines[4])[10] == "0");
|
|
|
|
// The count the correlation beside it was taken over.
|
|
CHECK(Columns(lines[1])[8] == "500");
|
|
|
|
// A frame nothing measured leaves its own columns unplottable, not the row missing.
|
|
CHECK(Columns(lines[3])[3] == "nan");
|
|
}
|
|
|
|
TEST_CASE("DiagnosticPlot_NoDisposition") {
|
|
const std::string prefix = "diagnostic_plot_nodisp_test";
|
|
|
|
// Without the sweep-quality diagnostic, a frame counts as merged when it was scaled at all.
|
|
PerImagePlot plot;
|
|
plot.scale_g = {1.0f, NAN, 0.8f};
|
|
WritePerImagePlot(prefix, plot, {}, 3);
|
|
|
|
const auto lines = ReadLines(prefix + "_plot.txt");
|
|
REQUIRE(lines.size() == 4);
|
|
CHECK(Columns(lines[1])[9] == "1");
|
|
CHECK(Columns(lines[2])[9] == "0");
|
|
CHECK(Columns(lines[3])[9] == "1");
|
|
CHECK(Columns(lines[1])[10] == "1");
|
|
CHECK(Columns(lines[2])[10] == "nan");
|
|
CHECK(Columns(lines[3])[10] == "1");
|
|
|
|
// No goniometer: the angle column is present and unplottable, so the columns never shift.
|
|
CHECK(Columns(lines[1]).size() == 11);
|
|
CHECK(Columns(lines[1])[1] == "nan");
|
|
}
|
|
|
|
TEST_CASE("DiagnosticPlot_NoMerge") {
|
|
const std::string prefix = "diagnostic_plot_nomerge_test";
|
|
|
|
// Nothing was merged - --no-merge, or a sweep nothing indexed on - so "merged" has no answer to
|
|
// give and must not read as a sweep every frame of which was thrown out.
|
|
PerImagePlot plot;
|
|
plot.bkg_estimate = {12.0f, 13.0f};
|
|
plot.scale_g = {NAN, NAN};
|
|
WritePerImagePlot(prefix, plot, {}, 2);
|
|
|
|
const auto lines = ReadLines(prefix + "_plot.txt");
|
|
REQUIRE(lines.size() == 3);
|
|
CHECK(Columns(lines[1])[9] == "nan");
|
|
CHECK(Columns(lines[2])[9] == "nan");
|
|
CHECK(Columns(lines[1])[10] == "nan");
|
|
// The columns a run without a merge still fills are there.
|
|
CHECK(std::stof(Columns(lines[1])[2]) == 12.0f);
|
|
}
|