Files
Jungfraujoch/common/BraggIntegrationSettings.h
T
leonarski_fandClaude Opus 5 61d24db59f Bragg integration: elongate the background ring per reflection
The signal disk and the r2..r3 background ring were fixed pixel circles, identical for every
reflection at every resolution. A reflection is not round: a finite bandwidth streaks it radially by
bw_sigma*Rpx, so at high resolution the ring sits within 1.3-2.2 sigma of the reflection's own
profile and measures its tails as background.

--integration-stencil <k> makes the RING an ellipse, elongated along the beam->reflection direction
by k times that streak, capped at 2*r3. The tangential half-widths stay r2 and r3, and the r1 signal
disk stays a circle: r1 drives the all-or-nothing n_inner_valid == n_inner gate, so growing it
rejects any reflection carrying one bad pixel along a long streak, and the flux a circular r1 loses
is a function of resolution alone, which the per-shell scale absorbs.

The geometry lives in one shared header compiled by both the host compiler and nvcc, so the seven
pixel-classification sites - the CPU mask/main/clip loops and the GPU mark_mask/main/trim/clip
kernels - cannot drift apart. Rather than evaluate an ellipse, each pixel's squared distance has its
radial part scaled down, d2 - q*rad^2 against r2^2/r3^2 with q = 1 - (r/(r+grow))^2, so grow = 0
gives q = 0 and both tests collapse onto d2 exactly in floating point.

The width is the bandwidth streak alone, not the profile's full radial variance, which also carries
the sensor parallax and weak-spot capture terms. Deriving the growth from those was implemented
first and measured on the rotation battery: at k=1 it took Thau_9's high-shell CC1/2 from 75.8 to
27.9 and Benas_3's from 14.1 to 6.0, against cytC_10 +1.2 and lyso_ref flat. On a monochromatic beam
they are the only terms there are, and C_CAPTURE is 64% of them. Keeping only the streak also makes
the option exactly inert without a bandwidth, rather than merely small.

Default 0. Measured on broadband rotation data with the bandwidth set to its spectroscopic value,
matched resolution limits: high-shell CC1/2 30.6 -> 46.4 at k=4, and better in EVERY shell in both
CC1/2 and R_meas (top shell R_meas 194.7% -> 138.7%), with completeness, multiplicity and space
group unchanged and 28 of 98833 unique reflections lost. Anomalous peak height over 18 sites
+0.107 +- 0.039 sigma (p = 0.013). The full 38-crystal rotation battery is unchanged to every
reported digit, base against k=3.

Two consequences of an elongated ring are handled rather than inherited. The neighbour exclusion
marks the inner ELLIPSE in each neighbour's own frame, or an elongated neighbour leaks its tails
into this reflection's ring. And the radial-background curvature kernel becomes a small table
indexed by the growth, because its azimuthal average makes one kernel serve every reflection only
while their stencils are identical; the GPU's radial window, previously a fixed 32 bins, is now
sized on the host from the widest ring on the detector.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:19:28 +02:00

128 lines
8.6 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <optional>
// Spot-intensity extraction method used by the Bragg integration engine. ProfileGaussian (default)
// profile-fits with a measured-width Gaussian (Kabsch-style) - more accurate intensities than the
// classical uniform BoxSum; validated on anomalous data (stronger S/Cl peaks vs box-sum). BoxSum is
// the simpler, faster fallback. ProfileEmpirical learns the profile per resolution shell from strong
// spots - see docs/CPU_DATA_ANALYSIS.md (Bragg integration).
enum class IntegratorMode { BoxSum, ProfileGaussian, ProfileEmpirical };
// The hkl half-width the broker bootstraps when a config carries no bragg_integration block. Matches
// the max_hkl default in broker/jfjoch_api.yaml, so an omitting client and an omitting config agree.
constexpr int BRAGG_ONLINE_DEFAULT_MAX_HKL = 100;
class BraggIntegrationSettings {
IntegratorMode integrator_mode = IntegratorMode::ProfileGaussian;
float r_1 = 4;
float r_2 = 6;
float r_3 = 10;
// How many times the beam's radial streak to push the r2..r3 background ring out by, per
// reflection. A bandwidth streaks a spot radially by bw_sigma*Rpx, and against a fixed pixel ring
// that puts the background annulus on the reflection's own tails at high resolution, where it
// measures signal as background. The ring's radial semi-axes become r2 + this*bw_sigma*Rpx and
// r3 + this*bw_sigma*Rpx, the tangential ones stay r2 and r3, and the r1 signal disk stays a
// circle (growing it trips the all-or-nothing n_inner_valid gate). 0 reproduces the fixed
// circular stencil exactly, and so does any monochromatic beam, where the streak is zero.
float stencil_k_sigma = 0.0f;
// Integration/prediction resolution limit. Unset means "as far as the detector reaches", resolved
// from the geometry where it is used. The predictor independently rejects any reflection that misses
// the detector, so this is a bound on how far the lattice walk goes rather than a second opinion on
// what is measurable - a fixed default simply truncated every experiment whose detector reached
// past it.
std::optional<float> d_min_limit_A;
std::optional<float> fixed_profile_radius;
float minimum_sigma_in_regards_to_i = 0.02;
// The r2..r3 background ring is estimated with ONE of two robust means, never both: a high-side
// sigma-clip (bkg_clip_nsigma, the default) or a symmetric trimmed mean (bkg_trim_fraction). Setting
// either through its setter clears the other, so whichever was asked for last is the one in force;
// with both at 0 the ring is a plain mean.
//
// Symmetric trimmed-mean fraction: drop the lowest and highest this fraction of ring pixels before
// averaging. Robust to the high-side contamination (neighbour-spot wings, tails, zingers) that
// biases a plain ring mean up, but a symmetric trim is NOT a consistent estimator of the mean of a
// right-skewed (Poisson) sample - it sits ~0.1 ct/px low at every level, which with ~50 ring pixels
// adds ~5 counts to every partial. Kept reachable (rugnux --background-trim) for back compatibility;
// 0.10 was the shipped value.
float bkg_trim_fraction = 0.0f;
// High-side-only sigma clip: reject ring pixels above mean + this many sqrt(mean). Rejects the same
// contamination as the trim - measurably better, in fact - without cutting the low side, so it does
// not carry the trim's skew bias. Measured empty-aperture pedestal, counts: plain mean -0.03..-0.20,
// 10% symmetric trim +5.05..+6.34, 4 sigma clip +0.02..+0.54. Whatever is set here is what the
// engine applies (rugnux --background-clip); the front end picks the default, and rugnux lowers it
// to 3 sigma for broadband (non-zero bandwidth) data, where longer spots leak further into the ring.
float bkg_clip_nsigma = 4.0f;
// Radial background curvature correction. The signal disk and the background annulus are
// concentric, so for ANY background linear in position their means are equal - a plane fit buys
// nothing and the leading error is the CURVATURE of the radial background, which the flat annulus
// mean is structurally blind to. Sitting on an ice ring that reaches +26 counts on a single
// reflection. When on, a radial background curve is accumulated per image from the annulus pixels
// that are already read, and each reflection's background is corrected by
// mean_annulus(B) - mean_disk(B), evaluated as a fixed kernel over radial offset (O(1), no extra
// pixel reads). Measured empty-aperture bias over 9 bands on 3 crystals: 4.33 -> 0.79 counts mean
// |bias|, scatter unchanged.
//
// Unset means AUTO: apply it per image where that image's peak-excluded ice score says a SMOOTH
// powder ring is present, and not otherwise. NOT the default - see below. The correction models the
// background as a function of radius alone, so it helps exactly where that is true and not
// elsewhere. Measured against a fixed external model, band-versus-decoy-band: on a crystal with
// pure smooth ice it removes 43% of the ice bands' excess amplitude, with the effect 7x stronger
// inside the bands than outside; on a crystal whose ice is discrete crystallite SPOTS - no smooth
// radial ring to model - the excess amplitude instead GREW by half; on clean data it is inert to
// four decimal places. The ice score's two channels separate those two morphologies, so the
// correction is gated on the smooth one. Auto only engages where a peak-excluded score exists
// (adaptive spot finding); the plain profile carries the Bragg peaks and cannot support a
// threshold, so without it auto stays off.
//
// OFF by default. Auto targets correctly - over the rotation battery it fires on ten crystals and
// every one of them is ice-positive - but it costs 1.35x the wall clock, and on the merge
// statistics it is the familiar sign-mixed trade rather than a win: high-shell CC1/2 worse on
// three of the four crystals that move materially. The case for it rests on agreement with an
// external model, which is the better arbiter but a narrower one, so it stays opt-in until that
// is settled on its own evidence.
std::optional<bool> bkg_radial_correction = false;
// Half-width of the hkl cube the predictor walks: every reflection with |h|,|k|,|l| <= this is
// tested against the Ewald sphere, and nothing outside it can ever be predicted. An axis is
// truncated once a/d_min exceeds this, and the GPU cost is the cube (2n+1)^3 of candidates, so
// neither a small nor a large fixed value is right for every crystal.
//
// Unset (the default) means "take it from the refined cell", which is exact: the predictor keeps
// only |q| <= 1/d_min and h = a.q, so no reflection can have |h| > a/d_min. See MaxHKLForCell.
// Offline that is what is wanted. ONLINE it is not: the broker bootstraps a concrete value
// (BRAGG_ONLINE_DEFAULT_MAX_HKL) so per-image cost stays predictable across samples.
std::optional<int> max_hkl;
public:
BraggIntegrationSettings& R1(float input);
BraggIntegrationSettings& R2(float input);
BraggIntegrationSettings& R3(float input);
BraggIntegrationSettings& StencilKSigma(float input);
BraggIntegrationSettings& DMinLimit_A(std::optional<float> input);
BraggIntegrationSettings& FixedProfileRadius_recipA(std::optional<float> input);
BraggIntegrationSettings& Integrator(IntegratorMode input);
BraggIntegrationSettings& BackgroundTrimFraction(float input);
BraggIntegrationSettings& BackgroundClipNSigma(float input);
BraggIntegrationSettings& BackgroundRadialCorrection(std::optional<bool> input);
BraggIntegrationSettings& MaxHKL(std::optional<int> input);
[[nodiscard]] IntegratorMode GetIntegrator() const;
[[nodiscard]] float GetR1() const;
[[nodiscard]] float GetR2() const;
[[nodiscard]] float GetR3() const;
[[nodiscard]] float GetStencilKSigma() const;
[[nodiscard]] std::optional<float> GetFixedProfileRadius_recipA() const;
[[nodiscard]] std::optional<float> GetDMinLimit_A() const;
[[nodiscard]] float GetMinimumSigmaInRegardsToI() const;
[[nodiscard]] float GetBackgroundTrimFraction() const;
[[nodiscard]] float GetBackgroundClipNSigma() const;
// Unset = auto (gate per image on the smooth-ice score); see bkg_radial_correction.
[[nodiscard]] std::optional<bool> GetBackgroundRadialCorrection() const;
[[nodiscard]] std::optional<int> GetMaxHKL() const;
};