// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #pragma once #include // 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 d_min_limit_A; std::optional 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 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 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 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 input); BraggIntegrationSettings& FixedProfileRadius_recipA(std::optional input); BraggIntegrationSettings& ForcedPredictionMosaicity_deg(std::optional input); BraggIntegrationSettings& Integrator(IntegratorMode input); BraggIntegrationSettings& BackgroundTrimFraction(float input); BraggIntegrationSettings& BackgroundClipNSigma(float input); BraggIntegrationSettings& BackgroundRadialCorrection(std::optional input); BraggIntegrationSettings& MaxHKL(std::optional 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 GetFixedProfileRadius_recipA() const; [[nodiscard]] std::optional GetForcedPredictionMosaicity_deg() const; [[nodiscard]] std::optional 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 GetBackgroundRadialCorrection() const; [[nodiscard]] std::optional GetMaxHKL() const; [[nodiscard]] OverlapMode GetOverlap() const; [[nodiscard]] float GetOverlapMinPeak() const; };