Files
Jungfraujoch/tests/AnalyzeGridScanTest.cpp
T
leonarski_fandClaude Opus 5 aa5a31b345
Build Packages / build:windows:nocuda (push) Failing after 9m18s
Build Packages / build:viewer-tgz:cpu (push) Successful in 14m24s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 13m14s
Build Packages / build:viewer-tgz:cuda (push) Successful in 15m15s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 6m50s
Build Packages / build:rpm (rocky8_nocuda) (push) Failing after 3m42s
Build Packages / build:rpm (rocky9_nocuda) (push) Failing after 3m51s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Failing after 3m35s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Failing after 3m5s
Build Packages / build:rpm (rocky8_sls9) (push) Failing after 3m44s
Build Packages / build:rpm (rocky9_sls9) (push) Failing after 4m3s
Build Packages / build:rpm (rocky8) (push) Failing after 4m2s
Build Packages / build:rpm (rocky9) (push) Failing after 3m55s
Build Packages / build:rpm (ubuntu2204) (push) Failing after 3m54s
Build Packages / build:rpm (ubuntu2404) (push) Failing after 3m55s
Build Packages / Generate python client (push) Successful in 14s
Build Packages / build:rugnux:windows (push) Successful in 17m49s
Build Packages / Build documentation (push) Successful in 48s
Build Packages / Create release (push) Skipped
Build Packages / build:windows:cuda (push) Successful in 20m19s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (durin plugin) (push) Successful in 7m43s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m54s
Build Packages / DIALS test (push) Successful in 12m41s
Build Packages / Unit tests (push) Successful in 59m43s
grid scan: an unstated beam is reported as the grid step, not as zero
A raster is stepped at about the beam size, so the step is the best proxy there is
when neither --beam-size nor the file's incident_beam_size says anything. Zero is the
worse answer: the reported crystal extents still contain a whole beam, and a zero
tells a consumer deconvolving them that they are already exact.

The substitution is in AnalyzeGridScan, so it holds for the broker and for rugnux
alike. The extents themselves do not move - they are measured either way, and the
test pins that. BEAM_SIZE_SOURCE in the raster report gains GRID_STEP, so a reader
can still tell a measured beam from a stood-in one, which matters because removing an
anisotropic beam is a covariance subtraction and a wrong one rotates the crystal axis.

The comment at the rugnux call site had argued for the old behaviour in as many
words; it now describes what the code does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-08 09:11:26 +02:00

357 lines
16 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include <cmath>
#include "../common/GridScanSettings.h"
#include "../common/ScanResult.h"
#include "../image_analysis/grid_scan_analysis/AnalyzeGridScan.h"
namespace {
// A plain raster with positive steps and no snake, so image number == grid index and a test
// can name cells by (column, row) directly.
GridScanSettings MakeGrid(int64_t nx, int64_t ny, float step_x_um, float step_y_um) {
GridScanSettings grid(nx, step_x_um, step_y_um, false, false);
grid.ImageNum(nx * ny);
return grid;
}
// Every cell of the raster is collected; the ones named in hits get a protein score, the
// rest get nothing.
ScanResult MakeScan(int64_t nx, int64_t ny,
const std::vector<std::pair<int64_t, int64_t>> &hits, float score) {
ScanResult scan;
for (int64_t i = 0; i < nx * ny; i++) {
ScanResultElem elem;
elem.number = i;
scan.images.push_back(elem);
}
for (const auto &[x, y]: hits)
scan.images[y * nx + x].protein_score = score;
return scan;
}
}
TEST_CASE("AnalyzeGridScan finds nothing in an empty scan", "[AnalyzeGridScan]") {
auto grid = MakeGrid(6, 6, 10.0f, 10.0f);
SECTION("no image scored at all") {
auto result = AnalyzeGridScan(MakeScan(6, 6, {}, 0.0f), grid, 5.0f, 5.0f);
CHECK(result.crystals.empty());
}
SECTION("every image scored, none above threshold") {
auto scan = MakeScan(6, 6, {}, 0.0f);
for (auto &elem: scan.images)
elem.protein_score = 0.1f;
auto result = AnalyzeGridScan(scan, grid, 5.0f, 5.0f);
CHECK(result.crystals.empty());
}
SECTION("a two-cell patch is below the minimum blob size and its score is not decisive") {
// Above the admission threshold, so the cells are in a patch at all, but under the score at
// which one cell would be evidence enough on its own. RasterReportTest covers the other
// side of that rule - a patch this small kept because its best cell IS decisive.
auto scan = MakeScan(6, 6, {{2, 2}, {3, 2}}, 0.55f);
auto result = AnalyzeGridScan(scan, grid, 5.0f, 5.0f);
CHECK(result.crystals.empty());
}
}
TEST_CASE("AnalyzeGridScan measures a round blob", "[AnalyzeGridScan]") {
// A plus sign centred on cell (3,3): symmetric, so the centre is the centre cell exactly.
auto grid = MakeGrid(7, 7, 10.0f, 10.0f);
auto scan = MakeScan(7, 7, {{3, 2}, {2, 3}, {3, 3}, {4, 3}, {3, 4}}, 0.8f);
auto result = AnalyzeGridScan(scan, grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
CHECK(c.nx == Catch::Approx(3.0f));
CHECK(c.ny == Catch::Approx(3.0f));
CHECK(c.x_um == Catch::Approx(30.0f));
CHECK(c.y_um == Catch::Approx(30.0f));
CHECK(c.image_number == 3 * 7 + 3);
CHECK(c.n_images == 5);
// Score is the mean over the blob, not the peak, and every cell here carries the same 0.8
CHECK(c.score == Catch::Approx(0.8f));
CHECK(c.ice_score == Catch::Approx(0.0f));
CHECK(std::isnan(c.res_A)); // no resolution was measured anywhere
// Round: the two axes come out equal, whatever arbitrary angle the numerics picked
CHECK(c.major_um == Catch::Approx(c.minor_um).margin(1e-3));
CHECK(c.angle_deg >= 0.0f);
CHECK(c.angle_deg < 180.0f);
// The beam is not removed - it is reported so a consumer can remove it itself
CHECK(result.beam_size_x_um == Catch::Approx(5.0f));
CHECK(result.beam_size_y_um == Catch::Approx(5.0f));
}
TEST_CASE("AnalyzeGridScan keeps a corner-touching diagonal needle whole", "[AnalyzeGridScan]") {
// A needle lying at 45 degrees across a raster whose step is coarser than the beam is sampled as
// cells that meet only at their corners. 4-connectivity would cut this into four blobs of one
// cell each and the minimum-size rule would then discard the crystal entirely; 8-connectivity
// keeps it as the one oriented needle it is.
const std::vector<std::pair<int64_t, int64_t>> diagonal = {{1, 1}, {2, 2}, {3, 3}, {4, 4}};
SECTION("square 20 x 20 um steps") {
auto grid = MakeGrid(7, 7, 20.0f, 20.0f);
auto result = AnalyzeGridScan(MakeScan(7, 7, diagonal, 0.9f), grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
CHECK(c.n_images == 4);
CHECK(c.angle_deg == Catch::Approx(45.0f).margin(0.01));
CHECK(c.nx == Catch::Approx(2.5f).margin(1e-4));
CHECK(c.ny == Catch::Approx(2.5f).margin(1e-4));
CHECK(c.major_um == Catch::Approx(113.137f).margin(0.01));
CHECK(c.minor_um == Catch::Approx(28.284f).margin(0.01));
}
SECTION("anisotropic 20 x 16 um steps") {
auto grid = MakeGrid(7, 7, 20.0f, 16.0f);
auto result = AnalyzeGridScan(MakeScan(7, 7, diagonal, 0.9f), grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
CHECK(c.n_images == 4);
// atan(16/20) - the same cells, read on a raster with a shorter y step
CHECK(c.angle_deg == Catch::Approx(38.6598f).margin(0.01));
CHECK(c.x_um == Catch::Approx(50.0f).margin(1e-3));
CHECK(c.y_um == Catch::Approx(40.0f).margin(1e-3));
CHECK(c.major_um == Catch::Approx(102.450f).margin(0.01));
CHECK(c.minor_um == Catch::Approx(24.988f).margin(0.01));
}
}
TEST_CASE("AnalyzeGridScan takes the needle angle from micrometres, not cells", "[AnalyzeGridScan]") {
// The SAME five cells - a staircase running up and to the right - read on two rasters that
// differ only in their y step. A purely diagonal line would not do: it is not 4-connected, and
// a real needle is sampled as a staircase anyway, the beam being wider than one step.
const std::vector<std::pair<int64_t, int64_t>> staircase = {{1, 1}, {2, 1}, {2, 2}, {3, 2}, {3, 3}};
SECTION("square 20 x 20 um steps") {
auto grid = MakeGrid(7, 7, 20.0f, 20.0f);
auto result = AnalyzeGridScan(MakeScan(7, 7, staircase, 0.9f), grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
// Symmetric about the diagonal, so the axis is at exactly 45 degrees
CHECK(c.angle_deg == Catch::Approx(45.0f).margin(0.01));
CHECK(c.nx == Catch::Approx(2.2f).margin(1e-4));
CHECK(c.ny == Catch::Approx(1.8f).margin(1e-4));
CHECK(c.x_um == Catch::Approx(44.0f).margin(1e-3));
CHECK(c.y_um == Catch::Approx(36.0f).margin(1e-3));
CHECK(c.major_um == Catch::Approx(84.853f).margin(0.01));
CHECK(c.minor_um == Catch::Approx(42.426f).margin(0.01));
}
SECTION("anisotropic 20 x 16 um steps") {
auto grid = MakeGrid(7, 7, 20.0f, 16.0f);
auto result = AnalyzeGridScan(MakeScan(7, 7, staircase, 0.9f), grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
// The cells are identical, so second moments taken in CELL units would still say 45 degrees.
// In micrometres the shorter y step tilts the axis down to 37.01 - eight degrees away, which
// is what makes this assertion worth making.
CHECK(c.angle_deg == Catch::Approx(37.010f).margin(0.01));
CHECK(c.angle_deg < 44.0f);
// The centre sits a fifth of a cell off a grid node in both directions
CHECK(c.nx == Catch::Approx(2.2f).margin(1e-4));
CHECK(c.ny == Catch::Approx(1.8f).margin(1e-4));
CHECK(c.x_um == Catch::Approx(44.0f).margin(1e-3));
CHECK(c.y_um == Catch::Approx(28.8f).margin(1e-3));
CHECK(c.major_um == Catch::Approx(76.806f).margin(0.01));
CHECK(c.minor_um == Catch::Approx(38.329f).margin(0.01));
CHECK(c.major_um > c.minor_um);
}
}
TEST_CASE("AnalyzeGridScan snaps a centre that falls outside the blob", "[AnalyzeGridScan]") {
// An L: a vertical arm at column 0 (rows 0..5) and a horizontal arm along row 5 (columns 1..3).
// The centroid of that is (0.667, 3.333), which rounds to cell (1,3) - a cell that is NOT in
// the blob and was never collected as part of this crystal.
auto grid = MakeGrid(6, 6, 10.0f, 10.0f);
auto scan = MakeScan(6, 6, {{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5},
{1, 5}, {2, 5}, {3, 5}}, 0.7f);
auto result = AnalyzeGridScan(scan, grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
CHECK(c.nx == Catch::Approx(6.0f / 9.0f).margin(1e-4));
CHECK(c.ny == Catch::Approx(30.0f / 9.0f).margin(1e-4));
CHECK(c.n_images == 9);
// The reported image is the nearest cell that is actually in the blob, (0,3), not (1,3)
CHECK(c.image_number == 3 * 6 + 0);
}
TEST_CASE("AnalyzeGridScan centres on resolution rank, not resolution value", "[AnalyzeGridScan]") {
// A horizontal bar of five cells. The best resolution sits at one end, so it pulls the centre
// off the geometric middle - but by exactly the same amount whether that end reports a
// plausible 2.0 A or an absurd 0.8 A, because only the rank is used.
auto grid = MakeGrid(7, 3, 10.0f, 10.0f);
const std::vector<std::pair<int64_t, int64_t>> bar = {{1, 1}, {2, 1}, {3, 1}, {4, 1}, {5, 1}};
auto with_best_at_left = [&](float best_res) {
auto scan = MakeScan(7, 3, bar, 0.6f);
const float res[5] = {best_res, 3.0f, 3.5f, 4.0f, 4.5f};
for (int i = 0; i < 5; i++)
scan.images[1 * 7 + 1 + i].res = res[i];
return AnalyzeGridScan(scan, grid, 5.0f, 5.0f).crystals.at(0);
};
const auto plausible = with_best_at_left(2.0f);
const auto artefact = with_best_at_left(0.8f);
// Pulled towards the good end, so left of the geometric centre at 3.0
CHECK(plausible.nx < 3.0f);
// ...and the absurd number moves it not one bit
CHECK(artefact.nx == Catch::Approx(plausible.nx));
CHECK(artefact.ny == Catch::Approx(plausible.ny));
// res_A is the 25th percentile of the five, i.e. the second best (3.0), never the best
CHECK(plausible.res_A == Catch::Approx(3.0f));
CHECK(artefact.res_A == Catch::Approx(3.0f));
}
TEST_CASE("AnalyzeGridScan separates two crystals and sorts them by score", "[AnalyzeGridScan]") {
// Two blobs with a clear gap between them; the weaker one is collected first. Labelling is
// 8-connected, so the gap has to be more than one cell in EVERY direction, diagonals included -
// three empty columns here.
auto grid = MakeGrid(9, 5, 10.0f, 10.0f);
auto scan = MakeScan(9, 5, {}, 0.0f);
for (auto [x, y]: std::vector<std::pair<int64_t, int64_t>>{{1, 1}, {1, 2}, {2, 1}, {2, 2}})
scan.images[y * 9 + x].protein_score = 0.6f;
for (auto [x, y]: std::vector<std::pair<int64_t, int64_t>>{{6, 2}, {7, 2}, {6, 3}})
scan.images[y * 9 + x].protein_score = 0.95f;
scan.images[2 * 9 + 6].ice_score = 0.4f;
auto result = AnalyzeGridScan(scan, grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 2);
// Element 0 is the one to collect
CHECK(result.crystals[0].score == Catch::Approx(0.95f));
CHECK(result.crystals[0].n_images == 3);
CHECK(result.crystals[0].nx > 4.0f);
CHECK(result.crystals[0].ice_score == Catch::Approx(0.4f / 3.0f));
CHECK(result.crystals[1].score == Catch::Approx(0.6f));
CHECK(result.crystals[1].n_images == 4);
CHECK(result.crystals[1].nx < 4.0f);
CHECK(result.crystals[1].ice_score == Catch::Approx(0.0f));
}
TEST_CASE("AnalyzeGridScan reports major_um along angle_deg", "[AnalyzeGridScan]") {
// A hook: two cells down, a step across, one more down. On a 20 x 10 um raster the axis of the
// larger second MOMENT runs at 58.3 degrees, but the blob's measured extent along that axis
// (46.5 um) is SHORTER than across it (49.8 um) - the moment is dominated by two clumps at the
// ends, the extent is not. A consumer draws a major by minor frame rotated by angle_deg, so the
// two facts are kept by turning the frame a quarter turn, not by discarding one of them.
auto grid = MakeGrid(5, 5, 20.0f, 10.0f);
auto scan = MakeScan(5, 5, {{0, 0}, {0, 1}, {1, 1}, {1, 2}, {0, 3}}, 0.8f);
auto result = AnalyzeGridScan(scan, grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
CHECK(c.n_images == 5);
CHECK(c.major_um == Catch::Approx(49.798f).margin(0.01));
CHECK(c.minor_um == Catch::Approx(46.549f).margin(0.01));
// 58.28 + 90, folded back into [0,180)
CHECK(c.angle_deg == Catch::Approx(148.283f).margin(0.01));
}
TEST_CASE("AnalyzeGridScan keeps major_um >= minor_um for every blob", "[AnalyzeGridScan]") {
// The invariant a consumer relies on, over every shape the other cases exercise.
const std::vector<std::pair<int64_t, int64_t>> shapes[] = {
{{3, 2}, {2, 3}, {3, 3}, {4, 3}, {3, 4}}, // round
{{1, 1}, {2, 2}, {3, 3}, {4, 4}}, // diagonal needle
{{1, 1}, {2, 1}, {2, 2}, {3, 2}, {3, 3}}, // staircase needle
{{0, 0}, {0, 1}, {1, 1}, {1, 2}, {0, 3}}, // hook
{{0, 0}, {0, 1}, {0, 2}, {0, 3}, {0, 4}, {0, 5},
{1, 5}, {2, 5}, {3, 5}}, // L
{{1, 1}, {2, 1}, {3, 1}, {4, 1}, {5, 1}}, // bar
};
for (const auto &shape: shapes) {
for (auto [sx, sy]: std::vector<std::pair<float, float>>{{10.0f, 10.0f},
{20.0f, 16.0f},
{20.0f, 10.0f},
{10.0f, 20.0f}}) {
auto grid = MakeGrid(7, 7, sx, sy);
auto result = AnalyzeGridScan(MakeScan(7, 7, shape, 0.8f), grid, 5.0f, 5.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals[0];
CHECK(c.major_um >= c.minor_um);
CHECK(c.angle_deg >= 0.0f);
CHECK(c.angle_deg < 180.0f);
}
}
}
TEST_CASE("AnalyzeGridScan follows the snake and the step signs", "[AnalyzeGridScan]") {
// The same crystal, described once on a plain raster and once on a snake raster with a
// negative slow step. Rearrange puts both on the same display grid, so both have to report
// the same position - only the image numbers differ.
const int64_t nx = 6, ny = 6;
GridScanSettings plain(nx, 10.0f, 10.0f, false, false);
plain.ImageNum(nx * ny);
GridScanSettings snake(nx, 10.0f, -10.0f, true, false);
snake.ImageNum(nx * ny);
const std::vector<std::pair<int64_t, int64_t>> blob = {{2, 1}, {3, 1}, {2, 2}, {3, 2}};
auto scan_plain = MakeScan(nx, ny, blob, 0.8f);
ScanResult scan_snake;
for (int64_t i = 0; i < nx * ny; i++) {
ScanResultElem elem;
elem.number = i;
elem.protein_score = scan_plain.images[snake.Rearrange(i)].protein_score;
scan_snake.images.push_back(elem);
}
auto a = AnalyzeGridScan(scan_plain, plain, 5.0f, 5.0f).crystals.at(0);
auto b = AnalyzeGridScan(scan_snake, snake, 5.0f, 5.0f).crystals.at(0);
CHECK(a.nx == Catch::Approx(b.nx));
CHECK(a.ny == Catch::Approx(b.ny));
CHECK(a.x_um == Catch::Approx(b.x_um));
CHECK(a.y_um == Catch::Approx(b.y_um));
CHECK(snake.Rearrange(b.image_number) == plain.Rearrange(a.image_number));
}
TEST_CASE("AnalyzeGridScan falls back to the grid step for an unstated beam", "[AnalyzeGridScan]") {
auto grid = MakeGrid(8, 8, 20.0f, 16.0f);
const std::vector<std::pair<int64_t, int64_t>> blob{{2, 2}, {3, 2}, {2, 3}, {3, 3}};
const auto scan = MakeScan(8, 8, blob, 0.9f);
// Stated: echoed back untouched, whatever the step is.
const auto stated = AnalyzeGridScan(scan, grid, 80.0f, 20.0f);
CHECK(stated.beam_size_x_um == Catch::Approx(80.0));
CHECK(stated.beam_size_y_um == Catch::Approx(20.0));
// Unstated: the step stands in. Zero would tell a consumer the extents are exact when they
// still contain a whole beam, and the two axes must not collapse to one value.
const auto unstated = AnalyzeGridScan(scan, grid, 0.0f, 0.0f);
CHECK(unstated.beam_size_x_um == Catch::Approx(20.0));
CHECK(unstated.beam_size_y_um == Catch::Approx(16.0));
// The extents themselves are measured and must not move with the beam.
REQUIRE(stated.crystals.size() == 1);
REQUIRE(unstated.crystals.size() == 1);
CHECK(stated.crystals[0].major_um == Catch::Approx(unstated.crystals[0].major_um));
CHECK(stated.crystals[0].minor_um == Catch::Approx(unstated.crystals[0].minor_um));
}