Build Packages / Create release (push) Successful in 16s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m27s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m15s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m44s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m1s
Build Packages / build:windows:nocuda (push) Successful in 17m29s
Build Packages / build:windows:cuda (push) Successful in 19m58s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 24m7s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m8s
Build Packages / build:rugnux:windows (push) Successful in 10m58s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m46s
Build Packages / Generate python client (push) Successful in 53s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m13s
Build Packages / Build documentation (push) Successful in 1m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m57s
Build Packages / build:rpm (rocky8) (push) Successful in 18m7s
Build Packages / build:rpm (rocky9) (push) Successful in 18m54s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 19m32s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 17m30s
Build Packages / Unit tests (push) Successful in 1h39m2s
* Fixed `jfjoch_broker` cancelling every data collection with a CUDA "out of memory" error after long operation: GPU memory no longer leaks with each collection. * Rugnux scales a rotation sweep until the per-frame scales settle instead of for a fixed three rounds, and says so when they did not - merged intensities, and the space group, resolution cut and frame rejection read off them, change accordingly; `--scaling-iterations` is now the cap on that loop (default 100). * Rugnux places every frame of a marCCD, SMV or miniCBF series at the spindle angle its own header states, so a series with missing frames, or with angles written modulo 360, is no longer read at the wrong geometry or refused. * Every rotation run writes two diagnostic files beside its reflections: `<prefix>_detector.jpg`, the detector projection with the pixel mask and the detected beam-stop shadow drawn on it, and `<prefix>_plot.txt`, one row per image. Reviewed-on: #82 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
185 lines
7.5 KiB
C++
185 lines
7.5 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() == 10);
|
|
CHECK(legend[0] == "#image");
|
|
CHECK(legend[1] == "angle_deg");
|
|
CHECK(legend[6] == "sigma_M_deg");
|
|
CHECK(legend[8] == "cc_n");
|
|
CHECK(legend[9] == "merged");
|
|
|
|
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");
|
|
|
|
// 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");
|
|
|
|
// No goniometer: the angle column is present and unplottable, so the columns never shift.
|
|
CHECK(Columns(lines[1]).size() == 10);
|
|
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");
|
|
// The columns a run without a merge still fills are there.
|
|
CHECK(std::stof(Columns(lines[1])[2]) == 12.0f);
|
|
}
|