Files
Jungfraujoch/common/BraggIntegrationSettings.h
T
leonarski_fandClaude Opus 5 6f7b136ec2 Bragg integration: a shared signal pixel belongs to the nearer reflection
Nothing kept a neighbour's flux out of a reflection's own signal disk. The union mask
keeps neighbour cores out of the BACKGROUND ring, but the r1 disk was read whole, so on
a dense pattern a crowded reflection measures part of its neighbour as its own.

Ownership is decided once per image into a per-pixel (quantised distance, reflection)
key written with an atomic minimum, so the nearest predicted centre wins whatever order
the writes arrive in and the lowest index breaks a tie. `--overlap exclude`, now the
default, drops the pixels a nearer neighbour owns from the profile fit. A profile fit is
the amplitude of a normalised profile, so leaving pixels out renormalises the estimator
by construction and the reflection stays unbiased rather than being discarded; the
summation-fallback guard is scaled back to the disk the box-sum seed actually read, so
it still compares like with like. `--overlap reject` is the XDS MINPK alternative - drop
the reflection when less than `--overlap-minpk` of its expected profile is cleanly its
own. A box sum has no profile to renormalise with, so `exclude` is a no-op there and
only `reject` acts on it.

Widening the split - keeping a pixel only where no other centre is within its distance
PLUS a margin - was built and measured, and it is worse monotonically: the residual bias
of the pixels that were kept grows from +0.072 to +0.209 in ln intensity at 0 to 3 px of
margin. What the margin removes is the reflection's own profile, not the neighbour's
tail, so the plain nearest-centre split is the rule.

Measured on the full 38-crystal rotation battery against the same binary with the
treatment off: ISa better 15 / worse 8, summed shortfall against XDS 39.7 -> 28.1. Three
of the losses are the two-pass loop taking its other branch - their median mosaicity
moves between the two known attractors - rather than the change under test; excluding
those it is better 15 / worse 5 and the shortfall goes 31.3 -> 14.4. The two crowded
crystals gain 38% and 52% of their ISa, one of them passing XDS. High-shell CC1/2 over
the 35 crystals that neither flipped branch nor carry a collapsed error model is better
7 / worse 7. Space groups unchanged at 35/38. The owner map is built only when a
treatment is asked for and costs 1.1% of the battery's wall clock - 23% on a genuinely
crowded crystal, nothing where no two predictions touch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 05:02:31 +02:00

154 lines
11 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 };
// What the integrator does about a signal region shared with a neighbouring reflection. Off is the
// historical behaviour: a neighbour's signal is kept out of this reflection's BACKGROUND ring, but
// nothing keeps it out of the reflection's own SIGNAL region, so on a dense pattern a crowded
// reflection reads high. Reject is XDS's MINPK - drop the reflection when too little of its expected
// profile is cleanly its own. Exclude - the default - drops only the shared PIXELS from the fit; a
// profile fit is the amplitude of a normalised profile, so leaving pixels out renormalises it by
// construction and the reflection is kept unbiased rather than discarded. A box sum has no profile
// to renormalise with, so Exclude does nothing there; only Reject acts on it.
enum class OverlapMode { Off, Reject, Exclude };
// 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;
// The background ring's precision is set by how many pixels it averages, not by how big the
// reflection is: the ring mean's error enters the intensity n_inner times over, so var(b)/n_B is
// a first-order term in sigma. At r3 = 10 the ring holds ~200 px against the r1 disk's ~50, and
// widening it to 13 roughly doubles that for no cost in signal - the disk is untouched, and the
// extra pixels sit further from the reflection, not closer.
float r_3 = 13;
// 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;
// Overlap treatment and, for OverlapMode::Reject, the least fraction of a reflection's expected
// profile that must be cleanly its own for the reflection to be kept (XDS calls it MINPK).
// Excluding the shared pixels is the default: over the rotation battery it costs 1.1% of the wall
// clock (23% on a genuinely crowded crystal, nothing where no two predictions touch) and buys ISa
// on 15 crystals against 5, cutting the summed shortfall against XDS by a third.
OverlapMode overlap_mode = OverlapMode::Exclude;
float overlap_min_peak = 0.75f;
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);
BraggIntegrationSettings& Overlap(OverlapMode input);
BraggIntegrationSettings& OverlapMinPeak(float 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;
[[nodiscard]] OverlapMode GetOverlap() const;
[[nodiscard]] float GetOverlapMinPeak() const;
};