Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 9m6s
Build Packages / build:windows:nocuda (push) Successful in 17m37s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 19m17s
Build Packages / build:windows:cuda (push) Successful in 19m51s
Build Packages / build:viewer-tgz:cpu (push) Successful in 21m44s
Build Packages / build:viewer-tgz:cuda (push) Successful in 22m37s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 23m29s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m10s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m29s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m33s
Build Packages / build:rugnux:windows (push) Successful in 11m12s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 21m51s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 26m39s
Build Packages / build:rpm (rocky9) (push) Successful in 23m58s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 22m59s
Build Packages / build:rpm (rocky8) (push) Successful in 29m25s
Build Packages / Generate python client (push) Successful in 35s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m41s
Build Packages / XDS test (durin plugin) (push) Successful in 10m37s
Build Packages / DIALS test (push) Successful in 26m5s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 27m49s
Build Packages / XDS test (neggia plugin) (push) Successful in 8m38s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m19s
Build Packages / Unit tests (push) Successful in 2h3m15s
The signal disk and the background ring are concentric, which is the whole reason a linear background cancels between them. Within the outer ring radius of the edge of the sensor array that concentricity is gone: the ring loses its outer part while the disk barely loses anything, so what is left of the ring sits further into the detector, where the radial background is higher, and the reflection reads low. Measured at signal-free positions four pixels from a border: the ring reads 162.35 counts per pixel against a true background over the disk of 160.57, which over a hundred disk pixels is 182 counts of deficit, against 216 to 239 observed. <I/sigma> runs -1.83, -2.34 and -1.18 at nought to three, three to six and six to nine pixels from the border, and recovers exactly at the outer ring radius. The same reflection measured at a border reads 179 counts lower than in the interior over seven thousand matched pairs. A masked module gap does the same thing but signed by the direction of the displacement, which is why nothing has caught this: at a gap the two populations cancel in the mean, while at the sensor border the truncation is always inward, so the bias is always negative. The background is now the intercept of a straight line in radial offset over whatever ring pixels survive, read at the reflection's centre. Three extra sums per ring pixel and no extra reads; the radial distance was already computed there. It is exact under any truncation and reduces to the mean when the ring is whole, so it is unconditional rather than a mode: a badly truncated ring pays in sigma, through the variance the fit honestly reports, rather than in a rejection. On the crystal where this surfaced the outermost shell's correlation with a deposited model goes from -0.234 to +0.004, and the shell above it from -0.091 to +0.179. Correcting beats discarding: dropping every observation within fifteen pixels of a border reached only -0.019 and +0.127, because the corrected observations still carry signal. Interior reflections do not move. The cost is one geometry: where a neighbour mask has already truncated the ring almost everywhere, the fit roughly doubles the variance of the background estimate while finding no gradient worth removing, and a crowded small detector loses one to two points of CC1/2 in its finest shells. Also: the MINPK denominator counted only profile mass that lands on the detector, so a reflection whose peak is off the sensor scored a perfect one and no guard could fire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
168 lines
7.8 KiB
C++
168 lines
7.8 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 <cstdint>
|
|
#include <vector>
|
|
|
|
#include "../common/BraggIntegrationSettings.h"
|
|
#include "../common/DetectorSetup.h"
|
|
#include "../common/DiffractionExperiment.h"
|
|
#include "../common/Reflection.h"
|
|
#include "../image_analysis/bragg_integration/BraggIntegrationEngineCPU.h"
|
|
#include "../image_analysis/image_preprocessing/ImagePreprocessorBuffer.h"
|
|
|
|
// The background under a reflection is estimated from a ring that is CONCENTRIC with the signal disk,
|
|
// which is the whole reason a background varying across the reflection cancels between them. Within r3
|
|
// of the edge of the array the ring loses its outer part and stops being concentric: what is left sits
|
|
// off-centre along the radius, where a radial background has a different value, and the ring mean
|
|
// carries that difference into every disk pixel it is subtracted from. These tests put a known radial
|
|
// ramp under reflections at a range of distances from the border and ask for the background at the
|
|
// reflection's own centre back.
|
|
|
|
namespace {
|
|
|
|
constexpr double BKG_LEVEL = 200.0; // counts/px at the beam centre
|
|
constexpr double BKG_GRADIENT = 2.0; // counts/px per pixel of radius
|
|
|
|
Reflection MakeReflection(float x, float y, int hkl) {
|
|
Reflection r{};
|
|
r.h = hkl; r.k = hkl; r.l = hkl;
|
|
r.predicted_x = x;
|
|
r.predicted_y = y;
|
|
r.d = 2.0f;
|
|
r.prescaling_corr = 1.0f;
|
|
r.partiality = 1.0f;
|
|
return r;
|
|
}
|
|
|
|
double TrueBackground(double x, double y, double beam_x, double beam_y) {
|
|
return BKG_LEVEL + BKG_GRADIENT * std::hypot(x - beam_x, y - beam_y);
|
|
}
|
|
|
|
DiffractionExperiment MakeExperiment(IntegratorMode mode, float beam_x, float beam_y) {
|
|
DiffractionExperiment experiment(DetJF(2));
|
|
experiment.DetectorDistance_mm(100.0f).IncidentEnergy_keV(WVL_1A_IN_KEV)
|
|
.BeamX_pxl(beam_x).BeamY_pxl(beam_y);
|
|
BraggIntegrationSettings settings;
|
|
settings.Integrator(mode);
|
|
experiment.ImportBraggIntegrationSettings(settings);
|
|
return experiment;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// The ramp is radial and there is no signal anywhere, so the answer is known exactly: the background
|
|
// the engine reports has to be the ramp's value at the reflection's own centre, at the border as much
|
|
// as in the middle of the array. Averaging the surviving ring instead reports its centroid's value,
|
|
// which at the border is several pixels of radius away.
|
|
TEST_CASE("BraggBackground_TruncatedRingIsNotBiased", "[Integration]") {
|
|
const float beam_x = 400.0f, beam_y = 400.0f;
|
|
const DiffractionExperiment experiment = MakeExperiment(IntegratorMode::BoxSum, beam_x, beam_y);
|
|
const int W = static_cast<int>(experiment.GetXPixelsNum());
|
|
const int H = static_cast<int>(experiment.GetYPixelsNum());
|
|
|
|
ImagePreprocessorBuffer image(experiment.GetPixelsNum());
|
|
for (int y = 0; y < H; ++y)
|
|
for (int x = 0; x < W; ++x)
|
|
image[static_cast<size_t>(y) * W + x] =
|
|
static_cast<int32_t>(std::lround(TrueBackground(x, y, beam_x, beam_y)));
|
|
|
|
// Reflections marching in towards the array from the bottom edge, at three azimuths so the border
|
|
// cuts the ring at a different angle to the radius each time, plus an interior control.
|
|
std::vector<Reflection> predicted;
|
|
int hkl = 1;
|
|
for (int x : {400, 700, 1000})
|
|
for (int inset : {2, 4, 6, 9, 12, 16, 40})
|
|
predicted.push_back(MakeReflection(static_cast<float>(x) + 0.3f,
|
|
static_cast<float>(H - 1 - inset) - 0.2f, hkl++));
|
|
|
|
BraggIntegrationEngineCPU engine(experiment);
|
|
const auto out = engine.Run(image, predicted, predicted.size(), 1);
|
|
REQUIRE(out.size() == predicted.size());
|
|
|
|
for (const auto &r : out) {
|
|
const double expected = TrueBackground(r.predicted_x, r.predicted_y, beam_x, beam_y);
|
|
INFO("reflection at " << r.predicted_x << "," << r.predicted_y
|
|
<< " expected bkg " << expected << " got " << r.bkg);
|
|
CHECK(r.bkg == Catch::Approx(expected).margin(0.5));
|
|
// A flat background under the disk means the box sum has nothing above it to report.
|
|
CHECK(std::abs(r.I) < 6.0f * r.sigma);
|
|
}
|
|
}
|
|
|
|
// The same reflections read against a FLAT background: the fit must not invent a correction where
|
|
// there is no gradient to correct, at the border or anywhere else.
|
|
TEST_CASE("BraggBackground_FlatBackgroundIsUnchangedAtTheBorder", "[Integration]") {
|
|
const float beam_x = 400.0f, beam_y = 400.0f;
|
|
const DiffractionExperiment experiment = MakeExperiment(IntegratorMode::BoxSum, beam_x, beam_y);
|
|
const int H = static_cast<int>(experiment.GetYPixelsNum());
|
|
|
|
ImagePreprocessorBuffer image(experiment.GetPixelsNum());
|
|
for (size_t i = 0; i < experiment.GetPixelsNum(); ++i)
|
|
image[i] = static_cast<int32_t>(BKG_LEVEL);
|
|
|
|
std::vector<Reflection> predicted;
|
|
int hkl = 1;
|
|
for (int inset : {2, 4, 6, 9, 12, 16, 40}) {
|
|
predicted.push_back(MakeReflection(700.3f, static_cast<float>(H - 1 - inset) - 0.2f, hkl++));
|
|
predicted.push_back(MakeReflection(static_cast<float>(inset) + 0.3f, 700.2f, hkl++));
|
|
}
|
|
|
|
BraggIntegrationEngineCPU engine(experiment);
|
|
const auto out = engine.Run(image, predicted, predicted.size(), 1);
|
|
REQUIRE(out.size() == predicted.size());
|
|
for (const auto &r : out) {
|
|
INFO("reflection at " << r.predicted_x << "," << r.predicted_y);
|
|
CHECK(r.bkg == Catch::Approx(BKG_LEVEL).margin(1e-3));
|
|
}
|
|
}
|
|
|
|
// MINPK is a fraction of the profile the fit can see, and the denominator has to be the WHOLE profile:
|
|
// counted over the grid cells that land on the array it is 1 by construction for a reflection whose
|
|
// peak is off the sensor, and then no threshold on it can fire.
|
|
TEST_CASE("BraggBackground_ProfileMassOffTheArrayIsRejected", "[Integration]") {
|
|
const float beam_x = 400.0f, beam_y = 400.0f;
|
|
const DiffractionExperiment experiment =
|
|
MakeExperiment(IntegratorMode::ProfileGaussian, beam_x, beam_y);
|
|
const int W = static_cast<int>(experiment.GetXPixelsNum());
|
|
const int H = static_cast<int>(experiment.GetYPixelsNum());
|
|
|
|
ImagePreprocessorBuffer image(experiment.GetPixelsNum());
|
|
for (size_t i = 0; i < experiment.GetPixelsNum(); ++i)
|
|
image[i] = static_cast<int32_t>(BKG_LEVEL);
|
|
// Strong, well-formed spots so the profile is learned and the fit has something to work on.
|
|
auto add_spot = [&](float cx, float cy) {
|
|
for (int dy = -6; dy <= 6; ++dy)
|
|
for (int dx = -6; dx <= 6; ++dx) {
|
|
const int x = static_cast<int>(std::lround(cx)) + dx;
|
|
const int y = static_cast<int>(std::lround(cy)) + dy;
|
|
if (x < 0 || y < 0 || x >= W || y >= H) continue;
|
|
const double ex = x - cx, ey = y - cy;
|
|
image[static_cast<size_t>(y) * W + x] +=
|
|
static_cast<int32_t>(std::lround(4000.0 * std::exp(-(ex * ex + ey * ey) / (2.0 * 1.3 * 1.3))));
|
|
}
|
|
};
|
|
|
|
std::vector<Reflection> predicted;
|
|
int hkl = 1;
|
|
for (int gy = 0; gy < 8; ++gy)
|
|
for (int gx = 0; gx < 8; ++gx) {
|
|
const float cx = 100.0f + 60.0f * gx, cy = 100.0f + 60.0f * gy;
|
|
add_spot(cx, cy);
|
|
predicted.push_back(MakeReflection(cx, cy, hkl++));
|
|
}
|
|
// A reflection whose predicted centre sits just outside the array: nearly all of its profile,
|
|
// its peak included, is off the sensor.
|
|
const size_t off_array = predicted.size();
|
|
predicted.push_back(MakeReflection(700.0f, static_cast<float>(H) + 2.0f, hkl++));
|
|
|
|
BraggIntegrationEngineCPU engine(experiment);
|
|
const auto out = engine.Run(image, predicted, predicted.size(), 1);
|
|
for (const auto &r : out)
|
|
CHECK(r.h != static_cast<int>(off_array) + 1);
|
|
CHECK(out.size() == off_array);
|
|
}
|