Files
Jungfraujoch/tests/SpotFootprintTest.cpp
leonarski_fandClaude Opus 5.5 fb9d4992bd Integration: the spot footprint includes how far the spots sit from their predicted reflections
A crystal of slightly misaligned domains (a ferroelastic domain twin below a phase transition, a
split crystal) records each reflection as two or more compact spots around the averaged lattice's
prediction, moving apart with resolution. The pre-scan footprint is measured about each spot, so it
saw compact spots; the r1 disk held the gap between them and the background ring sat on them.

The geometry pre-pass now compares every indexed spot with the predicted position of its own
reflection on the same frame and adds the mean square offset (radial and tangential, by distance
from the beam) to the pre-scan widths; the canonical pass integrates with that table. Where spots sit
on their predictions this moves the widths by the prediction error alone (lysozyme: 0.2-1.0 px, no
reflection outgrows r1); on a 100 K KDP domain twin the offsets reach 8-16 px at the edge.

KDP (kdp_x10sa_20keV), battery SHELXL recipe on the COD model: R1 0.272 -> 0.048, wR2 0.685 ->
0.129, EXTI 26.9 -> 0.025, GooF 3.2 -> 1.29 (XDS: 0.112 / 0.333 / 0.054 / 1.25); R_meas 21.6% -> 5.3%.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5K8jvPPbmCrbqnWkddTuB
2026-10-04 22:48:04 +02:00

105 lines
5.4 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 <vector>
#include "../image_analysis/bragg_integration/SpotFootprint.h"
// Spots drawn as Gaussians elongated along and across the radius are measured back at their widths,
// whatever their azimuth, and tabulated by distance from the beam.
TEST_CASE("SpotFootprint_MeasuresRadialAndTangentialWidths", "[Integration][portable]") {
const int W = 1200, H = 1200;
const float bx = 600.0f, by = 600.0f;
std::vector<int32_t> img(static_cast<size_t>(W) * H, 10); // flat background
std::vector<float> xs, ys;
// Width grows with distance: sigma_rad = 1 + r/200, sigma_tan = 1 + r/100.
for (int k = 0; k < 400; ++k) {
const float r = 60.0f + 480.0f * static_cast<float>(k % 20) / 20.0f;
const float phi = 0.61f * static_cast<float>(k);
const float x = bx + r * std::cos(phi), y = by + r * std::sin(phi);
const float ux = std::cos(phi), uy = std::sin(phi);
const float sr = 1.0f + r / 200.0f, st = 1.0f + r / 100.0f;
for (int py = static_cast<int>(y) - 30; py <= static_cast<int>(y) + 30; ++py)
for (int px = static_cast<int>(x) - 30; px <= static_cast<int>(x) + 30; ++px) {
if (px < 0 || py < 0 || px >= W || py >= H) continue;
const float dx = px - x, dy = py - y;
const float rad = dx * ux + dy * uy, tn = -dx * uy + dy * ux;
img[static_cast<size_t>(py) * W + px] += static_cast<int32_t>(
std::lround(2000.0f * std::exp(-rad * rad / (2 * sr * sr) - tn * tn / (2 * st * st))));
}
xs.push_back(x);
ys.push_back(y);
}
// Overlapping spots are not what this checks: keep those far from every other one.
std::vector<float> kx, ky;
for (size_t i = 0; i < xs.size(); ++i) {
bool alone = true;
for (size_t j = 0; j < xs.size(); ++j)
if (i != j && std::hypot(xs[i] - xs[j], ys[i] - ys[j]) < 45.0f) alone = false;
if (alone) { kx.push_back(xs[i]); ky.push_back(ys[i]); }
}
REQUIRE(kx.size() > 40);
std::vector<FootprintSpot> spots;
MeasureFootprintSpots(img.data(), W, H, bx, by, kx, ky, spots);
REQUIRE(spots.size() > 30);
for (const auto &s : spots) {
CHECK_THAT(s.sigma_rad, Catch::Matchers::WithinRel(1.0f + s.r_px / 200.0f, 0.12f));
CHECK_THAT(s.sigma_tan, Catch::Matchers::WithinRel(1.0f + s.r_px / 100.0f, 0.12f));
}
}
TEST_CASE("SpotFootprint_TableFillsSparseBinsFromNeighbours", "[Integration][portable]") {
std::vector<FootprintSpot> spots;
for (int i = 0; i < FOOTPRINT_MIN_SPOTS_PER_BIN; ++i) {
spots.push_back({50.0f, 1.0f, 1.5f}); // bin 0
spots.push_back({1150.0f, 3.0f, 4.0f}); // last bin
}
const SpotFootprint fp = FootprintFromSpots(spots, 1200.0f);
REQUIRE(fp.sigma_rad.size() == static_cast<size_t>(FOOTPRINT_BINS));
REQUIRE(fp.sigma_rad.front() == 1.0f);
REQUIRE(fp.sigma_tan.back() == 4.0f);
REQUIRE(fp.sigma_rad[2] == 1.0f); // nearer the first bin
REQUIRE(fp.sigma_rad[FOOTPRINT_BINS - 3] == 3.0f); // nearer the last
REQUIRE(FootprintFromSpots({}, 1200.0f).empty());
}
// A reflection recorded as a doublet: two indexed spots on either side of the prediction, along the
// radius. The offsets are found against the reflection of the same hkl, and their mean square is
// added to the widths of the bin they fall in.
TEST_CASE("SpotFootprint_OffsetsFromPredictionWidenTheTable", "[Integration][portable]") {
const float bx = 600.0f, by = 600.0f;
std::vector<Reflection> refl(1);
refl[0].h = 1; refl[0].k = 2; refl[0].l = 3;
refl[0].predicted_x = bx + 1000.0f; // on +x, so radial = x
refl[0].predicted_y = by;
std::vector<SpotToSave> spots;
spots.push_back({.x = bx + 1004.0f, .y = by, .lattice = 0, .h = 1, .k = 2, .l = 3, .indexed = true});
spots.push_back({.x = bx + 996.0f, .y = by, .lattice = 0, .h = 1, .k = 2, .l = 3, .indexed = true});
spots.push_back({.x = bx + 1000.0f, .y = by + 30.0f, .lattice = 0, .h = 3, .k = 2, .l = 1, .indexed = true}); // no such prediction
spots.push_back({.x = bx + 1000.0f, .y = by + 30.0f, .lattice = -1, .h = 1, .k = 2, .l = 3, .indexed = false}); // not indexed
std::vector<FootprintOffset> offsets;
MeasureFootprintOffsets(spots, refl, bx, by, offsets);
REQUIRE(offsets.size() == 2);
REQUIRE_THAT(offsets[0].off_rad, Catch::Matchers::WithinAbs(4.0, 1e-4));
REQUIRE_THAT(offsets[0].off_tan, Catch::Matchers::WithinAbs(0.0, 1e-4));
std::vector<FootprintOffset> pool;
for (int i = 0; i < FOOTPRINT_MIN_SPOTS_PER_BIN; ++i)
pool.insert(pool.end(), offsets.begin(), offsets.end());
SpotFootprint widths;
widths.bin_px = 100.0f;
widths.sigma_rad.assign(12, 1.0f);
widths.sigma_tan.assign(12, 2.0f);
const SpotFootprint fp = FootprintWithOffsets(widths, pool);
for (int b = 0; b < 12; ++b) { // one filled bin (10) serves them all
REQUIRE_THAT(fp.sigma_rad[b], Catch::Matchers::WithinAbs(std::sqrt(17.0), 1e-4));
REQUIRE_THAT(fp.sigma_tan[b], Catch::Matchers::WithinAbs(2.0, 1e-4));
}
REQUIRE(FootprintWithOffsets(widths, {}).sigma_rad[3] == 1.0f);
REQUIRE(FootprintWithOffsets(SpotFootprint{}, pool).empty());
}