From 4e4de57aae336db0259f4b0042fc7f036a49c646 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Sun, 20 Sep 2026 17:41:59 +0200 Subject: [PATCH] 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: _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. _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. _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) Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT --- docs/RUGNUX_REPORT.md | 2 +- docs/RUGNUX_TUTORIAL.md | 49 ++++++++++-- rugnux/CMakeLists.txt | 4 +- rugnux/DiagnosticOutput.cpp | 128 +++++++++++++++++++++++++++++++ rugnux/DiagnosticOutput.h | 47 ++++++++++++ rugnux/Rugnux.cpp | 45 ++++++++++- rugnux/Rugnux.h | 7 ++ tests/CMakeLists.txt | 1 + tests/DiagnosticOutputTest.cpp | 135 +++++++++++++++++++++++++++++++++ 9 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 rugnux/DiagnosticOutput.cpp create mode 100644 rugnux/DiagnosticOutput.h create mode 100644 tests/DiagnosticOutputTest.cpp diff --git a/docs/RUGNUX_REPORT.md b/docs/RUGNUX_REPORT.md index e46b6555f..9b50f8697 100644 --- a/docs/RUGNUX_REPORT.md +++ b/docs/RUGNUX_REPORT.md @@ -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 `_image.dat` and of every other per-image array `rugnux` writes; with `-s`/`--stride` the +of `_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 diff --git a/docs/RUGNUX_TUTORIAL.md b/docs/RUGNUX_TUTORIAL.md index eca24607c..5c3b61e20 100644 --- a/docs/RUGNUX_TUTORIAL.md +++ b/docs/RUGNUX_TUTORIAL.md @@ -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 `_*` 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 `_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 `_01_process.h5` appears beside `_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 - `_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). -- `_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. +- `_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 '_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** σM 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 ΔCC1/2 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. σM 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. +- `_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. +- `_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 diff --git a/rugnux/CMakeLists.txt b/rugnux/CMakeLists.txt index 37d0b3e0d..a496e1088 100644 --- a/rugnux/CMakeLists.txt +++ b/rugnux/CMakeLists.txt @@ -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). diff --git a/rugnux/DiagnosticOutput.cpp b/rugnux/DiagnosticOutput.cpp new file mode 100644 index 000000000..1c4d45e86 --- /dev/null +++ b/rugnux/DiagnosticOutput.cpp @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include "DiagnosticOutput.h" + +#include +#include +#include + +#include + +#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 &projection, const std::vector &mask) { + std::vector 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(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 &v, size_t i) { + return i < v.size() ? v[i] : NAN; + } +} + +std::vector DetectorDiagnosticRGB(const PixelMask &pixel_mask, + const std::vector &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(lut.size()); + const float inv_range = static_cast(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 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(mean_projection[i] * inv_range + 0.5f); + image[i] = lut[std::clamp(idx, 0, lut_size - 1)]; + } + } + + return image; +} + +std::string RenderDetectorDiagnostic(const DiffractionExperiment &experiment, + const PixelMask &pixel_mask, + const std::vector &mean_projection) { + // Named, because CompressedImage does not own the pixels it is handed. + const std::vector 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 &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(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(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); + } +} diff --git a/rugnux/DiagnosticOutput.h b/rugnux/DiagnosticOutput.h new file mode 100644 index 000000000..21009f81d --- /dev/null +++ b/rugnux/DiagnosticOutput.h @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include + +#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 DetectorDiagnosticRGB(const PixelMask &pixel_mask, + const std::vector &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 &mean_projection); + +// The columns of _plot.txt. Every vector is indexed by the processed-image ordinal - the +// numbering of _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 bkg_estimate; + std::vector resolution_A; + std::vector spot_count; + std::vector scale_g; + std::vector mosaicity_deg; + std::vector cc_to_merge; + std::vector frame_disposition; +}; + +// Writes _plot.txt: one '#' legend line naming the columns, then one whitespace- +// separated row per image, so `plot '' using 1:4` works with nothing in between. +void WritePerImagePlot(const std::string &output_prefix, const PerImagePlot &plot, + const std::optional &goniometer, size_t image_count); diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index fa13e6200..eacda1e79 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -7,6 +7,7 @@ #include "WriteModel.h" #include "SpindleCuspLoss.h" #include "SpotWidth.h" +#include "DiagnosticOutput.h" #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -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 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(shadowed) / static_cast(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(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 _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(images_to_process)); + logger.Info("Per-image table written to {}_plot.txt", config_.output_prefix); + } + if (writer) { writer->WriteHDF5(end_msg); writer->Finalize(); diff --git a/rugnux/Rugnux.h b/rugnux/Rugnux.h index 12fa96e32..1aadb6cb2 100644 --- a/rugnux/Rugnux.h +++ b/rugnux/Rugnux.h @@ -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 prescan_spot_quantile_d_A_; + // _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 force_rotation_result_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fa5452b7e..6e90cd2bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -55,6 +55,7 @@ ADD_EXECUTABLE(jfjoch_test ParallelForTest.cpp RugnuxTest.cpp ResultReportTest.cpp + DiagnosticOutputTest.cpp PostRefineTest.cpp RugnuxLargeTest.cpp TestData.h diff --git a/tests/DiagnosticOutputTest.cpp b/tests/DiagnosticOutputTest.cpp new file mode 100644 index 000000000..bee2592fa --- /dev/null +++ b/tests/DiagnosticOutputTest.cpp @@ -0,0 +1,135 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include + +#include +#include +#include + +#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 _plot.txt, whitespace-separated. + std::vector Columns(const std::string &line) { + std::istringstream is(line); + std::vector ret; + for (std::string token; is >> token;) + ret.push_back(token); + return ret; + } + + std::vector ReadLines(const std::string &path) { + std::ifstream f(path); + std::vector 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 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 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(FrameDisposition::Merged), + static_cast(FrameDisposition::Downgraded), + static_cast(FrameDisposition::Rejected), + static_cast(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"); +}