Files
Jungfraujoch/common/BraggIntegrationSettings.h
T
leonarski_f 12ae6c5228 Bragg integration: fit a reflection over the pixels it has, not only over all of them
A predicted reflection was discarded outright if ANY pixel of its signal disk was
unreadable - masked, untrusted, in a detector gap, or overloaded. On a battery
crystal that is 11.1% of all predictions, thrown away for a defect in one pixel
of fifty, and the pixels concerned sit at fixed places on the detector, so the
loss is systematic in reciprocal space rather than random.

Neither XDS nor dials does that. Both estimate the missing part from the profile
instead and keep the reflection while enough of it was seen: XDS's MINPK (default
75%, "the missing intensity is estimated from the learned profiles"), dials'
integration.profile.valid_foreground_threshold (default 0.75). MOSFLM is the one
program that rejects by default, and even it relaxes to 50% with PROFILE EDGE.

We already had the argument and the machinery: a profile fit is the amplitude of
a NORMALISED profile, so leaving pixels out renormalises the estimator by
construction - it costs information, which sum P^2/v duly loses and sigma duly
gains, and biases nothing. That is exactly why --overlap exclude drops a
neighbour's pixels from the fit rather than the reflection. Unreadable pixels are
the same case with a different reason, so they take the same treatment, cut on
the same threshold, in the same place: the readable fraction of the expected
profile, measured against the profile mass that lands on the detector at all so a
reflection is judged on the pixels that exist. A box sum has no profile to
renormalise with and keeps the all-or-nothing rule.

Two consequences handled. The summation seed and its variance now count the
pixels actually read, and the runaway guard scales the fit back to that same disk
before comparing - both exactly as before wherever nothing is missing. (Its
fallback then hands back that partial sum unrescaled, which would read low; the
guard fires on 8 of 96 688 recovered reflections, and on none at all on a weak
crystal, so it is not worth a branch.) And the profile, its resolution shells and
their widths are learned from COMPLETE reflections only, as is the box-sum
centroid post-refinement reads as an observed position: a disk with a hole gives
a centroid pulled away from the hole, and the hole does not move between frames.

That sigma gains what the missing pixels carried is the claim the whole change
rests on, and it is measurable. Force the conventional CENTRED cell of a
body-centred crystal in P1: the predictor then enumerates every lattice point,
and the reflections the centring makes systematically absent have a true
intensity of exactly zero, so their scatter about zero must equal their reported
sigma. Over 7.1 M such observations, matched by resolution shell, the trimmed
std(I)/rms(sigma) of the recovered reflections is 0.99 / 1.20 / 2.33 / 1.04 /
1.69 against 0.98 / 1.22 / 2.29 / 1.03 / 1.56 for the reflections that were
complete - the same calibration to a few percent. The lever there is small,
because the typical recovered reflection is missing only 5% of its disk. Lowering
the threshold to 0.50 admits a band missing 25-50%, which is a real lever: there
sigma comes out 8-43% larger than a complete reflection's in the same shell, and
the scatter about zero tracks it, 0.97 / 1.09 / 1.92 / 0.99 / 1.37, at or below
the complete population. Sigma grows, and by the amount it should.

The threshold stays at XDS's and dials' 0.75, on that evidence and on quality.
Below it the estimator starts to run out: on those same zero-intensity
reflections the recovered ones read +0.8 counts high at 0.75 and +1.9 counts high
in the 0.50-0.75 band, against a sigma of 12-17, and at 0.25 the fit degenerates
outright, single reflections carrying sigma in the thousands. Above it there is
nothing to buy: 0.90 leaves a fifth of the recoverable observations behind and
measures no better for them. On the high-multiplicity control, R_rim over
as-shipped / 0.90 / 0.75 / 0.50 runs 4.49% / 4.51% / 4.56% / 4.78% while
<I/sigma> runs 33.47 / 34.02 / 33.89 / 33.43 - 0.50 is where the recovered
observations stop paying for themselves.

Probe against the previous commit, six crystals. The high-multiplicity control
gains 4.2% more observations, 924 803 -> 963 946, which lands it on XDS's 961 379
from the same images, for <I/sigma> 33.47 -> 33.89, R_rim 4.49% -> 4.56% at 4.3%
more multiplicity, CC1/2 unchanged at 0.9998 and ISa 27.80 -> 27.12. Five weaker
crystals gain 3.3-4.8% of their observations and up to 1.0 point of completeness,
for <I/sigma> +0.4 to +3.6%, R_rim between -8.1% and +5.8% relative, CC1/2
+6.6 / +0.3 / +0.2 / -0.0 / -1.2 points, and ISa between +0.3% and -3.4%. Some of
that ISa is the point rather than the price: a reflection integrated over fewer
pixels carries less information, and the absence test above says the sigma that
reports so is honest. The GPU and CPU engines agree as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Full 38-crystal rotation battery, against the same binary without it:

  observations     better 38 / worse  0,  +937 100
  unique refl      better 30 / worse  0,    +9 229
  overall <I/sig>  better 33 / worse  1,     +7.00
  CC1/2            better  5 / worse  1,      +6.2
  space groups     unchanged at 35/38

Every crystal gains observations and not one loses a unique reflection. The two
costs are small and both are understood. Low-resolution R_meas is worse on eight
crystals, by +0.8 pp at most and +3.2 pp summed - a reflection whose own peak
pixel is unreadable loses the part of the profile that carries most of the
amplitude, and that population sits at low resolution; the following commit
handles it. And ISa falls on 32 crystals, by 10.9 summed, which is what admitting
937 000 further observations does to the strong-reflection asymptote: R_meas
excluding the one crystal whose thread-count noise is 1.5 pp is flat.
2026-08-12 02:03:42 +02:00

168 lines
12 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 (Kabsch, Acta Cryst D66, 133-144 (2010)) - 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;
// Diagnostic: the rocking width the PREDICTION window is opened to, in place of the per-image
// sigma_M. Prediction and partiality use one number today, so a sigma_M that moves takes the
// integrated reflection population with it; pinning this holds the population still while the
// partiality keeps using the measured sigma_M, which separates the two effects.
std::optional<float> forced_prediction_mosaicity_deg;
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 the MINPK threshold: the least fraction of a reflection's expected profile
// that must be usable for the reflection to be kept. 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.
//
// As in XDS, one threshold governs both ways a reflection can lose part of its profile. Under
// OverlapMode::Reject it is the fraction that must be cleanly the reflection's own rather than a
// neighbour's. In every profile mode it is also the fraction that must be READABLE - not masked,
// untrusted, in a detector gap or overloaded - because the profile fit renormalises to the pixels
// it can read (dials calls that valid_foreground_threshold, and defaults it to the same 0.75).
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& ForcedPredictionMosaicity_deg(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> GetForcedPredictionMosaicity_deg() 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;
};