Rugnux: two diagnostic outputs a person can check the run by eye with

Written on every rotation run, next to the reflections, with no flag to
switch them on:

<prefix>_detector.jpg - the mean projection the beam-stop pre-scan built,
which is the very image the shadow detection tested, coloured with the
viewer's own default map from common/ColorScale.h: white-to-indigo for the
counts, grey for the module and chip gaps, coral for the detected shadow,
magenta for the rest of the pixel mask. A shadow claimed where the
background is unbroken, or a dark patch left uncoloured, is then visible at
a glance instead of being a pixel count in the log. The projection is the
one the pre-scan already holds, so no frame is read twice; the picture is
rendered there and held as JPEG bytes until the canonical pass writes it,
because on a two-pass run the pre-scan belongs to the superseded pass.

<prefix>_plot.txt - one row per processed image, whitespace-separated with
a single '#' legend line over its own columns, so gnuplot plots it as it
stands: image, spindle angle, background, resolution, spot count, scale,
sigma_M, CC to merge, and whether the image's observations reached the
merged data. A quantity nothing measured is written nan, so the columns
never shift and no row is ever left out.

sigma_M is emitted as the pipeline produces it - a per-image Gaussian
rocking width, smoothed in frame order and filled with the run median where
a frame could not be fitted - and the documentation says so rather than
presenting it as a crystal property.

<prefix>_image.dat stays: the in-tree comparison scripts read it and it
carries the smoothing wedge, which the new file does not. The docs name
_plot.txt as the one to script against.

Measured on a 1800-image rotation sweep: 29.6/30.4 s before against
29.8/30.5 s after, and the merged reflections are byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
This commit is contained in:
leonarski_fandClaude Opus 5 committed 2026-09-20 18:45:18 +02:00
1 parent 6dd9824eec
commit 4e4de57aae
9 files changed
+410 -8

No files matched your search

+1 -1
View File
@@ -286,7 +286,7 @@ The vocabulary is **closed and stable**: a code is never renamed, and never reus
meaning. New codes are only ever added, and adding one moves `REPORT_VERSION` at the next release.
The columns are: `FIRST_IMAGE`/`LAST_IMAGE` — inclusive, in processed-image ordinals (the numbering
of `<prefix>_image.dat` and of every other per-image array `rugnux` writes; with `-s`/`--stride` the
of `<prefix>_plot.txt` and of every other per-image array `rugnux` writes; with `-s`/`--stride` the
source image is `start + ordinal * stride`); `ROTATION` — the width of the range in degrees;
`SEVERITY` — the fraction of the run's typical diffracting power missing over the range, 0 (as good
as the run) to 1 (nothing at all); `SCALE` and `CC` — the range's mean per-image scale and
+44 -5
View File
@@ -116,7 +116,7 @@ or of the run's own measured centre, whichever is nearer), and the second pass
re-indexes de novo and re-integrates at the refined geometry. The refined pass is the canonical
`<prefix>_*` output; the header-geometry pass merges only to choose the space group and to judge the
refined pass against, and writes no merged files of its own — no `<prefix>_01.mtz`, `.cif`, `.hkl` or
`_01_image.dat`. (Where a process file is asked for at all, with `--no-merge` or
`_01_image.dat`, and no `_01_plot.txt` or `_01_detector.jpg`. (Where a process file is asked for at all, with `--no-merge` or
`--write-process-h5`, each pass still writes its own, so `<prefix>_01_process.h5` appears beside
`<prefix>_process.h5`.) Disable it with `--rotation-no-postrefine`.
@@ -244,10 +244,49 @@ goniometer axis but you want per-frame stills processing anyway, add `--force-st
- `<prefix>_report.txt` — the **results report**: what the run determined, in a form both a person and
a beamline script can read. Always written, next to the files above. See
[The results report](RUGNUX_REPORT.md#the-results-report).
- `<prefix>_image.dat` — one row per processed image, whitespace-separated, with a `#` header line
naming the columns: `image_number scale_G mosaicity_deg wedge_deg cc_to_merge cc_n` — the
per-image scale, mosaicity, smoothing wedge and correlation-to-merge behind the sweep-quality
table, ready for gnuplot or numpy.
- `<prefix>_plot.txt` — **the per-image table**, one row per processed image, whitespace-separated,
with a single `#` legend line over its own columns so gnuplot plots it as it stands
(`plot '<prefix>_plot.txt' using 1:4`). Rotation runs only. The columns are:
| Column | Meaning |
|--------|---------|
| `image` | Processed-image ordinal, the numbering every per-image array `rugnux` writes uses; with `-s`/`--stride` the source image is `start + ordinal * stride`. |
| `angle_deg` | Spindle angle at mid-exposure. |
| `bkg` | Background estimate from the azimuthal profile (the `bkgEstimate` of the HDF5 file). |
| `resolution_A` | How far the image's spots reach, from spot finding alone. |
| `spots` | Spots found. |
| `scale_G` | Per-image scale factor fitted by scaling. |
| `sigma_M_deg` | Mosaicity as the rocking-curve **standard deviation** σ<sub>M</sub> in degrees — not a FWHM, and not XDS's `REFLECTING_RANGE`. See the note below. |
| `cc_to_merge` | Correlation of the image's own observations with the merged data. |
| `merged` | 1 where the image's observations are in the merged data, 0 where they are not — an image that never indexed, one an earlier guard dropped, and one ΔCC<sub>1/2</sub> convicted all read 0. |
A quantity nothing measured for an image is written `nan`, which gnuplot and numpy both skip; the
columns never shift and a row is never left out.
**Reading `sigma_M_deg`.** It is fitted per image — a maximum-likelihood Gaussian rocking width
over that image's own indexed spots — but it is then **smoothed in frame order** over the same
rotation window as the per-image scale (`--smooth-g`), and any frame too sparse to fit one of its
own is given the run's median. So the column varies only on the smoothing window's scale, and on a
weakly diffracting crystal it is close to, or exactly, a constant: that flatness is the honest
statement that the run measured no per-frame variation, not a placeholder. The value is the one
scaling used to recompute partialities. σ<sub>M</sub> here is the width of a Gaussian
rocking curve and nothing more — it is not a physical crystal mosaic spread, it absorbs beam
divergence and every other source of angular spread, and it reads systematically **low** against
XDS's `SIGMAR` (about 0.9× on the regression corpus). Use it to see whether the run's rocking
width moves across the sweep, not as a number to quote.
- `<prefix>_detector.jpg` — **the detector as the run saw it**, so the beam stop and the module gaps
can be checked by eye. Rotation runs only, and only where `--detect-beam-stop` was left on (it is
by default). The picture is the mean projection over the frames the beam-stop detection sampled —
the very image it tested — coloured with `jfjoch_viewer`'s default map: white to indigo for the
counts, **grey** for the module and chip gaps, **coral** for the shadow the run detected, and
**magenta** for everything else the pixel mask holds. A shadow drawn where the background is
unbroken, or a dark patch the run left uncoloured, is visible at a glance; read it against the
`Beam stop shadow: N pixels ...` line in the log.
- `<prefix>_image.dat` — the per-image **scaling** table: `image_number scale_G mosaicity_deg
wedge_deg cc_to_merge cc_n`, whitespace-separated with a `#` header line. It predates `_plot.txt`
and is kept because the in-tree comparison scripts read it and because it carries the per-image
mosaicity and smoothing wedge, which `_plot.txt` does not. **For anything new, script against
`_plot.txt`.**
Merged statistics (⟨I/σ⟩, CC1/2, completeness, …), the error model and timing are printed to the
console. By default the written resolution is trimmed automatically where CC1/2 falls off
+3 -1
View File
@@ -23,13 +23,15 @@ ADD_LIBRARY(Rugnux STATIC
ReportDocument.h
SpotWidth.cpp
SpotWidth.h
DiagnosticOutput.cpp
DiagnosticOutput.h
SpindleCuspLoss.cpp
SpindleCuspLoss.h
WriteModel.cpp
WriteModel.h
)
TARGET_LINK_LIBRARIES(Rugnux JFJochReader JFJochImageAnalysis JFJochWriter gemmi fftw3f)
TARGET_LINK_LIBRARIES(Rugnux JFJochReader JFJochImageAnalysis JFJochWriter JFJochPreview gemmi fftw3f)
# rugnux is the single offline analysis CLI; --mode picks what it does: mx (the full pipeline, the
# default), azint, scale (re-scale/merge stored reflections) or calibration (powder-ring geometry).
+128
View File
@@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "DiagnosticOutput.h"
#include <algorithm>
#include <cmath>
#include <fstream>
#include <spdlog/fmt/fmt.h>
#include "../common/ColorScale.h"
#include "../common/CompressedImage.h"
#include "../common/JFJochException.h"
#include "../image_analysis/scale_merge/Merge.h"
#include "../preview/JFJochJPEG.h"
namespace {
// The brightest 0.1 % of the measured pixels saturate, as the preview and the viewer set their
// contrast (preview/PreviewImage.cpp): a handful of hot pixels would otherwise take the whole
// range and leave the background - which is what the shadow is read off - a flat white.
float SaturationValue(const std::vector<float> &projection, const std::vector<uint32_t> &mask) {
std::vector<float> valid;
valid.reserve(projection.size());
for (size_t i = 0; i < projection.size(); i++)
if (mask[i] == 0 && std::isfinite(projection[i]))
valid.push_back(projection[i]);
if (valid.empty())
return 1.0f;
const size_t rank = valid.size() - std::max<size_t>(1, valid.size() / 1000);
std::nth_element(valid.begin(), valid.begin() + rank, valid.end());
return std::max(valid[rank], 1.0f);
}
float Element(const std::vector<float> &v, size_t i) {
return i < v.size() ? v[i] : NAN;
}
}
std::vector<rgb> DetectorDiagnosticRGB(const PixelMask &pixel_mask,
const std::vector<float> &mean_projection) {
constexpr uint32_t gap_bits = (1u << PixelMask::ModuleGapPixelBit)
| (1u << PixelMask::ChipGapPixelBit)
| (1u << PixelMask::ModuleEdgePixelBit);
constexpr uint32_t beam_stop_bit = 1u << PixelMask::BeamStopPixelBit;
const auto &mask = pixel_mask.GetMask();
if (mask.size() != mean_projection.size())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Mask and projection are of different size");
// Untouched: the viewer's defaults are the point, so that the JPEG and the picture the user
// knows from jfjoch_viewer say the same thing with the same colours.
ColorScale scale;
const rgb gap_color = scale.Apply(ColorScaleSpecial::Gap);
const rgb bad_color = scale.Apply(ColorScaleSpecial::BadPixel);
const rgb beam_stop_color = scale.Apply(ColorScaleSpecial::BeamStop);
const auto &lut = scale.LUTData();
const auto lut_size = static_cast<int64_t>(lut.size());
const float inv_range = static_cast<float>(lut_size - 1) / SaturationValue(mean_projection, mask);
// The shadow is tested BEFORE the rest of the mask, so a pixel the detection claims is never
// hidden under a noisy- or user-masked bit it also carries - the whole point is to see what
// the detection did. Gaps come first because nothing can be shadowed there: they carry no
// background for the comparison to miss.
std::vector<rgb> image(mean_projection.size());
for (size_t i = 0; i < image.size(); i++) {
if ((mask[i] & gap_bits) != 0)
image[i] = gap_color;
else if ((mask[i] & beam_stop_bit) != 0)
image[i] = beam_stop_color;
else if (mask[i] != 0 || !std::isfinite(mean_projection[i]))
image[i] = bad_color;
else {
const auto idx = static_cast<int64_t>(mean_projection[i] * inv_range + 0.5f);
image[i] = lut[std::clamp<int64_t>(idx, 0, lut_size - 1)];
}
}
return image;
}
std::string RenderDetectorDiagnostic(const DiffractionExperiment &experiment,
const PixelMask &pixel_mask,
const std::vector<float> &mean_projection) {
// Named, because CompressedImage does not own the pixels it is handed.
const std::vector<rgb> image = DetectorDiagnosticRGB(pixel_mask, mean_projection);
const CompressedImage rgb_image(image, experiment.GetXPixelsNumConv(),
experiment.GetYPixelsNumConv());
return WriteJPEGToMem(rgb_image, 85);
}
void WritePerImagePlot(const std::string &output_prefix, const PerImagePlot &plot,
const std::optional<GoniometerAxis> &goniometer, size_t image_count) {
const std::string path = output_prefix + "_plot.txt";
std::ofstream file(path, std::ofstream::out | std::ofstream::trunc);
if (!file)
throw JFJochException(JFJochExceptionCategory::FileWriteError,
"Cannot open " + path + " for writing");
// One comment line, its names over their own columns, and nothing else a plotting program has
// to be told to skip. The '#' takes the first character of the image-number field.
file << "#" << fmt::format("{:>5} {:>10} {:>12} {:>12} {:>8} {:>12} {:>13} {:>12} {:>7}\n",
"image", "angle_deg", "bkg", "resolution_A", "spots", "scale_G",
"sigma_M_deg", "cc_to_merge", "merged");
for (size_t i = 0; i < image_count; i++) {
const float angle = goniometer
? goniometer->GetAngle_deg(static_cast<float>(i))
+ goniometer->GetWedge_deg() / 2.0f
: NAN;
const float scale_g = Element(plot.scale_g, i);
// Did this image's observations reach the merged data? The sweep-quality diagnostic says so
// outright; where it did not run, an image with no fitted scale contributed nothing, which
// is the same statement by a weaker route.
const int merged = i < plot.frame_disposition.size()
? plot.frame_disposition[i]
!= static_cast<uint8_t>(FrameDisposition::Rejected)
: std::isfinite(scale_g);
file << fmt::format("{:>6} {:>10.4f} {:>12.4g} {:>12.4g} {:>8.0f} {:>12.5g} {:>13.4f}"
" {:>12.4f} {:>7}\n",
i, angle, Element(plot.bkg_estimate, i), Element(plot.resolution_A, i),
Element(plot.spot_count, i), scale_g, Element(plot.mosaicity_deg, i),
Element(plot.cc_to_merge, i), merged);
}
}
+47
View File
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <string>
#include <vector>
#include "../common/ColorScale.h"
#include "../common/DiffractionExperiment.h"
#include "../common/PixelMask.h"
// Two files a person reads to check by eye what the run decided, written next to the reflections on
// every rotation run: the detector as the pipeline saw it, and one row per image.
// The mean projection the beam-stop pre-scan built, coloured with the viewer's own default map
// (common/ColorScale.h): white-to-indigo for the counts, grey for the module and chip gaps, coral
// for the detected shadow, magenta for everything else the mask holds. The shadow is a place where
// the background is missing, so drawing the mask on top of the very projection the detection tested
// is what makes a wrong shadow visible - a coral patch over unbroken background, or a dark patch
// left uncoloured.
std::vector<rgb> DetectorDiagnosticRGB(const PixelMask &pixel_mask,
const std::vector<float> &mean_projection);
// The same picture as JPEG bytes, at the detector's own pixel count.
std::string RenderDetectorDiagnostic(const DiffractionExperiment &experiment,
const PixelMask &pixel_mask,
const std::vector<float> &mean_projection);
// The columns of <prefix>_plot.txt. Every vector is indexed by the processed-image ordinal - the
// numbering of <prefix>_image.dat and of every other per-image array rugnux writes - and a value
// nothing measured is NAN. frame_disposition holds FrameDisposition (Merge.h) and is empty when the
// sweep-quality diagnostic did not run.
struct PerImagePlot {
std::vector<float> bkg_estimate;
std::vector<float> resolution_A;
std::vector<float> spot_count;
std::vector<float> scale_g;
std::vector<float> mosaicity_deg;
std::vector<float> cc_to_merge;
std::vector<uint8_t> frame_disposition;
};
// Writes <output_prefix>_plot.txt: one '#' legend line naming the columns, then one whitespace-
// separated row per image, so `plot '<f>' using 1:4` works with nothing in between.
void WritePerImagePlot(const std::string &output_prefix, const PerImagePlot &plot,
const std::optional<GoniometerAxis> &goniometer, size_t image_count);
+44 -1
View File
@@ -7,6 +7,7 @@
#include "WriteModel.h"
#include "SpindleCuspLoss.h"
#include "SpotWidth.h"
#include "DiagnosticOutput.h"
#include <algorithm>
#include <atomic>
@@ -14,6 +15,7 @@
#include <cmath>
#include <condition_variable>
#include <deque>
#include <fstream>
#include <functional>
#include <future>
#include <mutex>
@@ -1183,8 +1185,9 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
// frame of its own, and an unmasked shadow biases it by a few pixels at most, which is
// three orders below what this comparison would notice. The centre the run reports and
// consumes is the one fitted below instead, with the shadow out of the way.
const std::vector<float> projection = finder.GetMeanProjection();
if (const auto ring_center = FindBeamCenterFromBackground(experiment_, pixel_mask_,
finder.GetMeanProjection())) {
projection)) {
finder.BeamCenter(ring_center->beam_x_pxl, ring_center->beam_y_pxl);
logger.Info("Beam stop: comparing each pixel against its ring about the measured "
"centre ({:.2f},{:.2f}) +- {:.2f} px, {:.2f} px from the file's",
@@ -1211,6 +1214,12 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
logger.Info("Beam stop shadow: {} pixels ({:.2f}% of the detector) found in {} images",
shadowed, 100.0 * static_cast<double>(shadowed) / static_cast<double>(shadow.size()),
finder.GetFrameCount());
// The diagnostic picture, rendered here because this is where the projection and the mask
// the detection has just written into it are both in hand; the file is written later, under
// the canonical prefix, since on a two-pass run this is the pre-pass.
if (config_.mode == ProcessMode::FullAnalysis && experiment_.IsRotationIndexing())
detector_diagnostic_jpeg_ = RenderDetectorDiagnostic(experiment_, pixel_mask_, projection);
}
// The beam centre is fitted again here, so that the shadow is in the mask by the time it is
@@ -7488,6 +7497,40 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
/*trusted_path=*/true);
}
// The two files a person checks the run by eye with. Rotation only - a stills run has no spindle
// angle and no sweep to project - and not on the geometry pre-pass or a pass that is thrown
// away, which would otherwise leave a stale _01 copy beside the real one.
if (full && write_files && !cancelled_ && !geometry_prepass && !superseded
&& experiment_.IsRotationIndexing()) {
if (!detector_diagnostic_jpeg_.empty()) {
const std::string path = config_.output_prefix + "_detector.jpg";
std::ofstream f(path, std::ofstream::out | std::ofstream::trunc | std::ofstream::binary);
if (f) {
f.write(detector_diagnostic_jpeg_.data(),
static_cast<std::streamsize>(detector_diagnostic_jpeg_.size()));
logger.Info("Detector diagnostic image written to {}", path);
} else
logger.Warning("Cannot open {} for writing", path);
}
PerImagePlot plot;
plots.GetPlotRaw(plot.bkg_estimate, PlotType::BkgEstimate, "");
plots.GetPlotRaw(plot.resolution_A, PlotType::ResolutionEstimate, "");
plots.GetPlotRaw(plot.spot_count, PlotType::SpotCount, "");
if (indexer) {
// The same source as <prefix>_image.dat, so the two files cannot disagree.
const ScalingResult scaling(indexer->GetIntegrationOutcome());
plot.scale_g = scaling.image_scale_g;
plot.mosaicity_deg = scaling.mosaicity_deg;
plot.cc_to_merge = scaling.image_cc;
}
if (result.has_merge_statistics)
plot.frame_disposition = result.merge_statistics.sweep_quality.frame_disposition;
WritePerImagePlot(config_.output_prefix, plot, experiment_.GetGoniometer(),
static_cast<size_t>(images_to_process));
logger.Info("Per-image table written to {}_plot.txt", config_.output_prefix);
}
if (writer) {
writer->WriteHDF5(end_msg);
writer->Finalize();
+7
View File
@@ -491,6 +491,13 @@ class Rugnux {
// first-pass ladder: they say how DEEP a pass reads without a resolution written into the program.
std::vector<float> prescan_spot_quantile_d_A_;
// <prefix>_detector.jpg, rendered in the pre-scan from the projection the beam-stop detection
// tested and held until the canonical pass writes its files. Kept as the finished JPEG rather
// than as the projection: the picture is a few hundred kilobytes where the projection is four
// bytes a pixel, and the pre-scan is the only place where both the projection and the shadow it
// has just decided on exist. Empty outside a rotation run, and where the pre-scan did not look.
std::string detector_diagnostic_jpeg_;
// Set by Run()'s supercell-collapse guard to force pass-1's full result into the second-pass indexer
// (RunPipeline calls indexer->ForceRotationIndexerResult when this is set); reset right after that re-run.
std::optional<RotationIndexerResult> force_rotation_result_;
+1
View File
@@ -55,6 +55,7 @@ ADD_EXECUTABLE(jfjoch_test
ParallelForTest.cpp
RugnuxTest.cpp
ResultReportTest.cpp
DiagnosticOutputTest.cpp
PostRefineTest.cpp
RugnuxLargeTest.cpp
TestData.h
+135
View File
@@ -0,0 +1,135 @@
// 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.
std::vector<uint32_t> mask_bits{
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, 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[1], scale.Apply(1.0f))); // indigo: the saturation value
CHECK(same(image[2], scale.Apply(ColorScaleSpecial::Gap))); // grey
CHECK(same(image[3], scale.Apply(ColorScaleSpecial::BeamStop))); // coral
CHECK(same(image[4], 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[5], scale.Apply(ColorScaleSpecial::Gap)));
CHECK(same(image[6], scale.Apply(ColorScaleSpecial::BeamStop)));
}
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.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() == 9);
CHECK(legend[0] == "#image");
CHECK(legend[1] == "angle_deg");
CHECK(legend[6] == "sigma_M_deg");
CHECK(legend[8] == "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])[8] == "1");
CHECK(Columns(lines[2])[8] == "1");
CHECK(Columns(lines[3])[8] == "0");
CHECK(Columns(lines[4])[8] == "0");
// 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])[8] == "1");
CHECK(Columns(lines[2])[8] == "0");
CHECK(Columns(lines[3])[8] == "1");
// No goniometer: the angle column is present and unplottable, so the columns never shift.
CHECK(Columns(lines[1]).size() == 9);
CHECK(Columns(lines[1])[1] == "nan");
}