Files
Jungfraujoch/tests/BraggStencilTest.cpp
T
leonarski_f 84228bf8be
Build Packages / Create release (push) Successful in 24s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m43s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m27s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 9m58s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m22s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 13m39s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m32s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m11s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m41s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m0s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m38s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 17m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m38s
Build Packages / Unit tests (push) Successful in 1h46m14s
v1.0.0-rc.173 (#83)
* jfjoch_broker: Optional per-dataset authentication - statistics, images and plots can require a bearer token, which jfjoch_viewer supports.
* jfjoch_viewer: Dark mode and a theme-matched colour scheme, a magnifier panel, and simpler contrast and background controls.
* Rugnux: Multiple performance improvements on GPU and CPU (CPU-only processing up to 40% faster, faster image decoding on ARM), with unchanged results.
* Rugnux: `--model` rigid-body refinement runs on the GPU, and the model-validation check is faster and more reliable.
* Rugnux: Improved scaling and merging - error model, outlier rejection, absorption correction and French-Wilson amplitudes now agree more closely with XDS and ctruncate.
* Rugnux: Improved integration - radial background on powder and ice rings, crowded rotation data keep their reflections, and CPU-only builds integrate large unit cells as GPU builds do.
* Rugnux: More robust detector geometry - measured beam centre, X-ray bandwidth and goniometer rate, and geometry refinement accepted only on significant evidence.
* Rugnux: Merged files are written in the standard setting, or in the setting of a reference MTZ, structure-factor mmCIF or model, with its free-R flags.
* Rugnux: Richer report - ice and powder rings, further lattices, superstructure candidates and mosaicity, with warnings worded as prompts to check.
* Rugnux: Clear error messages when a data set needs more GPU or host memory than is available.

Reviewed-on: #83
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-29 15:57:32 +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);
}
}
}