Files
Jungfraujoch/tests/BraggStencilTest.cpp
T
leonarski_fandClaude Opus 5.5 a8c559bf6b tests: jfjoch_portable_test, quick [portable] checks for the macOS and Windows jobs
A Catch2 executable that builds in the portable configurations (JFJOCH_VIEWER_ONLY /
JFJOCH_RUGNUX_ONLY) and links only what those build - JFJochRugnux, JFJochReader,
JFJochImageAnalysis, JFJochWriter, JFJochCommon - so it can run on the macOS arm64 and
Windows x64 jobs, where the receiver/broker/FPGA/HLS sources are not built and there is no GPU.
EXCLUDE_FROM_ALL, so a product build does not pay for it; catch2 is now made available in the
portable configure as well (it is EXCLUDE_FROM_ALL too).

The cases tagged [portable] cover what depends on the architecture, the compiler or the
standard library: bitshuffle/LZ4/zstd, HDF5 read-back (legacy/VDS/integrated, the direct-chunk
path), miniCBF/marCCD/SMV header parsing, CBOR, CPU spot finding, azimuthal mapping, Bragg
prediction/integration, gemmi MTZ/mmCIF. New in tests/PortableTest.cpp:
- a golden FNV-1a hash of two frames of compression_benchmark.h5, decoded from the raw chunk by
  the hperf and the classic bitshuffle and through the HDF5 filter (x86 hashes
  affea29c511b6ec2 / e46913009c95a1f1);
- a golden hash of a bitshuffle/LZ4 encode (the writer must produce the same bytes everywhere);
- the shipped bitshuffle block selector against the classic reference over elem 1/2/4/8 and
  block tails;
- the FFTW indexer, named explicitly, on a synthetic orthorhombic lattice (the existing FFT
  indexer lattice tests run only under CUDA);
- a 4-frame end-to-end rugnux run on the git-LFS rotation dataset (HDF5 via external links, CPU
  spot finding, indexing, integration); SKIPs when LFS was not pulled.
All 45 take ~4 s on Linux (~2 s without the LFS case), CPU-only build.

M_PI replaced by PI (common/JFJochMath.h) in the two tagged files that used it, as MSVC does not
define M_PI. The CBF gzip test shells out to gzip and is left untagged.

CI: build and run jfjoch_portable_test "[portable]" in build-windows (both variants),
build-rugnux-windows, build-macos-viewer and build-rugnux-macos, after the build and before
packaging.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-27 23:44:53 +02:00

161 lines
8.1 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 <catch2/matchers/catch_matchers_floating_point.hpp>
#include <cmath>
#include "../image_analysis/bragg_integration/BraggStencil.h"
namespace {
BraggStencilParams Params(float k_sigma, float bw_sigma = 0.002f) {
BraggStencilParams p;
p.beam_x = 400.0f;
p.beam_y = 400.0f;
p.r2 = 6.0f;
p.r3 = 10.0f;
p.bw_sigma = bw_sigma;
p.k_sigma = k_sigma;
p.max_grow = 2.0f * p.r3;
return p;
}
} // namespace
// The whole change rests on this: with no elongation asked for, the three squared distances the
// integrator tests against must be the SAME BITS as the plain circular distance used before, so
// that every pixel is classified exactly as it was, not merely nearly.
TEST_CASE("BraggStencil_ZeroElongationIsExactlyCircular", "[Integration]") {
const BraggStencilParams p = Params(0.0f); // a bandwidth, but k_sigma = 0
for (float py = 0.0f; py < 800.0f; py += 37.0f)
for (float px = 0.0f; px < 800.0f; px += 41.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
REQUIRE(s.q_in == 0.0f);
REQUIRE(s.q_out == 0.0f);
for (int dy = -12; dy <= 12; ++dy)
for (int dx = -12; dx <= 12; ++dx) {
const auto d = BraggStencilDistances(s, static_cast<float>(dx), static_cast<float>(dy));
const float circular = static_cast<float>(dx) * dx + static_cast<float>(dy) * dy;
REQUIRE(d.signal == circular);
REQUIRE(d.inner == circular);
REQUIRE(d.outer == circular);
}
}
}
// A monochromatic beam has no streak, so nothing is elongated whatever k_sigma says - which is what
// makes the feature inert on every monochromatic dataset rather than merely small.
TEST_CASE("BraggStencil_MonochromaticIsInert", "[Integration]") {
const BraggStencilParams p = Params(4.0f, 0.0f);
for (float py = 0.0f; py < 800.0f; py += 53.0f)
for (float px = 0.0f; px < 800.0f; px += 59.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
REQUIRE(s.grow == 0.0f);
REQUIRE(s.q_in == 0.0f);
REQUIRE(s.q_out == 0.0f);
}
}
// The elongated region really is the ellipse it claims: radial semi-axis r + grow, tangential r.
TEST_CASE("BraggStencil_ElongatedSemiAxes", "[Integration]") {
const BraggStencilParams p = Params(3.0f);
for (float py = 120.0f; py < 800.0f; py += 91.0f)
for (float px = 120.0f; px < 800.0f; px += 97.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const float grow = s.grow;
REQUIRE(grow > 0.0f);
REQUIRE(grow <= p.max_grow);
REQUIRE(s.grow == BraggStencilGrow_px(s.r0, p)); // the kernel table indexes on this
// On the radial axis the inner boundary sits at r2 + grow, the outer at r3 + grow.
const auto rad_in = BraggStencilDistances(s, (p.r2 + grow) * s.ux, (p.r2 + grow) * s.uy);
const auto rad_out = BraggStencilDistances(s, (p.r3 + grow) * s.ux, (p.r3 + grow) * s.uy);
CHECK_THAT(rad_in.inner, Catch::Matchers::WithinRel(p.r2 * p.r2, 1e-4f));
CHECK_THAT(rad_out.outer, Catch::Matchers::WithinRel(p.r3 * p.r3, 1e-4f));
// Across it, at the untouched tangential half-widths r2 and r3. Testing on the exact
// tangential axis would be a tautology - rad is 0 there, so q never enters - so the
// point that matters is that the SAME offset is inside the region radially and outside
// it tangentially. That is the anisotropy, and it fails if q is built from the wrong
// radius or from a constant.
const float probe = p.r2 + 0.5f * grow;
const auto radial_probe = BraggStencilDistances(s, probe * s.ux, probe * s.uy);
const auto tangent_probe = BraggStencilDistances(s, -probe * s.uy, probe * s.ux);
CHECK(radial_probe.inner < p.r2 * p.r2); // still signal, the ring starts further out
CHECK(tangent_probe.inner > p.r2 * p.r2); // already background across the streak
const auto tan_in = BraggStencilDistances(s, -p.r2 * s.uy, p.r2 * s.ux);
const auto tan_out = BraggStencilDistances(s, -p.r3 * s.uy, p.r3 * s.ux);
CHECK_THAT(tan_in.inner, Catch::Matchers::WithinRel(p.r2 * p.r2, 1e-4f));
CHECK_THAT(tan_out.outer, Catch::Matchers::WithinRel(p.r3 * p.r3, 1e-4f));
}
}
// The bounding boxes the engines scan must contain the regions they classify - a box one pixel too
// small silently drops background pixels on one side of every reflection, which no parity test
// between two engines making the same mistake would catch.
TEST_CASE("BraggStencil_BoundingBoxContainsRegion", "[Integration]") {
const BraggStencilParams p = Params(3.0f);
const float r2_sq = p.r2 * p.r2, r3_sq = p.r3 * p.r3;
for (float py = 0.0f; py < 800.0f; py += 53.0f)
for (float px = 0.0f; px < 800.0f; px += 59.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const int span = static_cast<int>(std::ceil(p.r3 + p.max_grow)) + 4;
for (int dy = -span; dy <= span; ++dy)
for (int dx = -span; dx <= span; ++dx) {
const auto d = BraggStencilDistances(s, static_cast<float>(dx), static_cast<float>(dy));
const float ax = std::fabs(static_cast<float>(dx)), ay = std::fabs(static_cast<float>(dy));
// No slack: the offsets are integers from an exactly centred stencil, so the
// extents bound them outright. A tolerance of a pixel here would accept a box
// one pixel too small, which is the error this exists to catch.
if (d.inner < r2_sq) {
INFO("inner region outside its box at " << dx << "," << dy);
REQUIRE(ax <= s.ex_in);
REQUIRE(ay <= s.ey_in);
}
if (d.inner >= r2_sq && d.outer < r3_sq) {
INFO("ring outside its box at " << dx << "," << dy);
REQUIRE(ax <= s.ex_out);
REQUIRE(ay <= s.ey_out);
}
}
}
}
// The growth is capped, so a mis-declared bandwidth cannot run away with the bounding box.
TEST_CASE("BraggStencil_GrowthIsCapped", "[Integration]") {
BraggStencilParams p = Params(3.0f, 0.5f); // an absurdly declared bandwidth
for (float r0 = 0.0f; r0 < 4000.0f; r0 += 17.0f)
REQUIRE(BraggStencilGrow_px(r0, p) <= p.max_grow);
const BraggStencil s = MakeBraggStencil(4000.0f, 4000.0f, p);
REQUIRE(s.ex_out <= p.r3 + p.max_grow + 1e-3f);
REQUIRE(s.ey_out <= p.r3 + p.max_grow + 1e-3f);
}
// The kernel table is indexed by the growth rounded to whole pixels, so the table has to have a row
// for every index any reflection on the detector can produce. An off-by-one here is an out-of-range
// read of k_diff - on the GPU, a device-side one.
TEST_CASE("BraggStencil_KernelIndexInRange", "[Integration][portable]") {
for (const float k : {0.0f, 0.4f, 1.0f, 2.5f, 3.0f, 6.0f}) {
const BraggStencilParams p = Params(k);
const float r_max = std::hypot(800.0f - p.beam_x, 800.0f - p.beam_y);
const int n_kern = static_cast<int>(std::lround(BraggStencilGrow_px(r_max, p))) + 1;
REQUIRE(n_kern >= 1);
for (float py = 0.0f; py <= 800.0f; py += 13.0f)
for (float px = 0.0f; px <= 800.0f; px += 17.0f) {
const BraggStencil s = MakeBraggStencil(px, py, p);
const int idx = BraggStencilKernelIndex(s, n_kern);
INFO("k " << k << " at " << px << "," << py << " grow " << s.grow);
REQUIRE(idx >= 0);
REQUIRE(idx < n_kern);
// The clamp must never be what saves it: the table is sized so the row exists.
REQUIRE(static_cast<int>(std::lround(s.grow)) == idx);
}
}
}