Files
Jungfraujoch/tests/BackgroundGainTest.cpp
T
leonarski_fandClaude Opus 5.5 aec6f6aabc rugnux: background clip scaled by the CCD gain measured in the pre-scan
The background ring's high-side clip, mean + 4*sqrt(mean), assumes counted photons. A CCD reports
ADU: on a Rigaku Saturn a background pixel scatters 4-5x its mean, so the clip sat at ~2 sigma, cut
the upper tail of every ring and added a positive offset to every reflection, compressing the
intensity statistics (a false 422 on one twinned I41 crystal).

No header carries a usable gain (SMV states none, every marCCD file the writer default of 1 ADU per
photon), so it is measured: background_gain::MeasureFrame takes the variance per unit mean of the
difference of pixels two columns apart, per level, clipped, on every fourth row of up to 16 of the
pre-scan's frames; Gain is the pair-weighted median over the levels. The clip becomes
mean + n*sqrt(max(g,1)*mean) (BraggIntegrationSettings::bkg_pixel_gain, CPU and GPU engines).
Only for SMV/marCCD input (ProcessConfig::counts_in_adu, set from the file format); HDF5 and CBF
keep g = 1, the identity. One log line reports the value. No new option.

Measured g: Saturn 944+ 4.7-5.2, Saturn 944 2.7, A200 0.68, ADSC ~0.3, marCCD ~0.3 - so the
ADSC/marCCD/A200 sets keep the photon clip exactly.

Checks (reflection data via gemmi and p.hkl, against the rc175 battery outputs / base build):
identical on 11if, 7qis, 8a1a, myob_x10sa, cytc_x10sa (photon counting), 4bwl, 5epe (ADSC,
marCCD) and 6cee (A200). Saturn sets, battery baseline -> this change:
  3r6o  I4122 R_meas 19.3% ISa 4.4 <|L|> .321 -> I41 (correct) 12.1% 8.1 .384
  3mc4  R_meas 10.2 -> 9.8 %, ISa 12.1 -> 12.7, d_min 1.79 -> 1.75 A, <|L|> .360 -> .391
  5uth  12.6 -> 11.8 %, ISa 10.4 -> 10.1, <|L|> .426 -> .483, offset vs |Fc|^2 0.21 -> 0.00
  5vml  10.2 -> 9.6 %, ISa 11.0 -> 11.6, <|L|> .426 -> .475, offset 0.15 -> 0.05
  5cc8  8.1 -> 7.9 %, ISa 10.8 -> 11.2, <|L|> .469 -> .494
  3meb  14.5 -> 14.6 %, ISa 13.2 -> 13.2, <|L|> .442 -> .457
Space groups unchanged except 3r6o. Pre-scan cost: +0.5 s on a 9 Mpx ADSC sweep, within noise on
a 15 Mpx marCCD sweep, nothing on HDF5/CBF.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-09 21:18:12 +02:00

76 lines
2.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_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include <cmath>
#include <random>
#include "../image_analysis/bragg_integration/BackgroundGain.h"
#include "../common/BraggIntegrationSettings.h"
#include "../common/JFJochException.h"
namespace {
constexpr size_t W = 512, H = 512;
// A smooth background of 100..2000 photons per pixel, read out as bias + gain * photons + read noise,
// with a grid of bright 5x5 "Bragg" spots and a masked strip.
std::vector<int32_t> Frame(double gain, uint32_t seed, std::vector<uint32_t> &mask) {
std::mt19937 rng(seed);
std::normal_distribution<double> read_noise(0.0, 3.0);
std::vector<int32_t> img(W * H);
mask.assign(W * H, 0);
for (size_t y = 0; y < H; y++)
for (size_t x = 0; x < W; x++) {
const double photons = 100.0 + 1900.0 * (x + y) / static_cast<double>(W + H);
std::poisson_distribution<int> p(photons);
double v = (gain > 1.0 ? 20.0 : 0.0) + gain * p(rng) + (gain > 1.0 ? read_noise(rng) : 0.0);
if (x % 32 >= 14 && x % 32 < 19 && y % 32 >= 14 && y % 32 < 19)
v += 50.0 * gain * photons;
img[y * W + x] = static_cast<int32_t>(std::lround(v));
if (y >= 200 && y < 210)
mask[y * W + x] = 1;
}
return img;
}
}
TEST_CASE("BackgroundGain_CCD_gain4") {
std::vector<background_gain::Levels> frames;
std::vector<uint32_t> mask;
for (uint32_t seed = 1; seed <= 3; seed++) {
const auto img = Frame(4.0, seed, mask);
frames.push_back(background_gain::MeasureFrame(img, W, H, mask, 1 << 30));
}
const auto g = background_gain::Gain(frames);
REQUIRE(g.has_value());
CHECK_THAT(*g, Catch::Matchers::WithinAbs(4.0, 0.25));
}
TEST_CASE("BackgroundGain_photon_counting") {
std::vector<uint32_t> mask;
const auto img = Frame(1.0, 7, mask);
const auto g = background_gain::Gain({background_gain::MeasureFrame(img, W, H, mask, 1 << 30)});
REQUIRE(g.has_value());
CHECK_THAT(*g, Catch::Matchers::WithinAbs(1.0, 0.1));
}
TEST_CASE("BackgroundGain_saturated_and_empty") {
std::vector<uint32_t> mask;
const auto img = Frame(4.0, 11, mask);
// Everything at or above the saturation limit: nothing to measure.
CHECK_FALSE(background_gain::Gain({background_gain::MeasureFrame(img, W, H, mask, 1)}).has_value());
CHECK_FALSE(background_gain::Gain({}).has_value());
}
TEST_CASE("BackgroundGain_settings") {
BraggIntegrationSettings s;
CHECK(s.GetBackgroundPixelGain() == 1.0f);
s.BackgroundPixelGain(4.5f);
CHECK(s.GetBackgroundPixelGain() == 4.5f);
CHECK_THROWS_AS(s.BackgroundPixelGain(0.5f), JFJochException);
}