Files
Jungfraujoch/image_analysis/bragg_integration/BraggStencil.h
T
leonarski_fandClaude Opus 5 0f5a6e41d6 rugnux: report the Wilson B in --mode scale, and correct what the docs claim about the fixed integration radius
--mode scale called AnalyzeTwinning but not CalcGlobalWilsonB, so WILSON_B=
read nan there. Mirrors what --mode mx does; on one crystal scale mode now
reports 72.03 against mx mode's 71.91 on the same data, and where the fitted
B is not positive it still reads nan, which is the same verdict --mode mx
reaches. The merged .mtz and .hkl are byte-identical before and after.

docs/CPU_DATA_ANALYSIS.md and BraggStencil.h both said the flux the fixed r1
disk does not capture "is a function of resolution alone, which the per-shell
scale absorbs". Neither half is true. The loss carries a directional component
of several A^2 on top of the isotropic part. There is no per-shell scale, and
there cannot usefully be one: every scale is fitted against a reference built
from a reflection's own symmetry equivalents, equivalents share s^2 exactly,
so any function of s^2 lies in the exact null space of the scaling model. What
actually absorbs the isotropic part is the Wilson B, which is degenerate with
it - so WILSON_B and _reflns.B_iso_Wilson_estimate carry an r1-dependent bias,
measured at 18 A^2 of spread across four radius triplets on one crystal.

The text also corrected a third error: in the default gaussian mode r1 is not
the integration domain at all - the intensity is a profile-fit amplitude over
a grid set by r2 - and a note now sits at the section a reader looking up the
Wilson B actually lands on.

Comments and documentation only for that part; verified by rebuilding and
confirming byte-identical output with no flags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CHMmeM1d489zvNFT7ZMN2P
2026-08-25 18:51:06 +02:00

188 lines
10 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// =============================================================================
// BraggStencil - the per-reflection signal/background geometry, shared by both engines
// =============================================================================
//
// The integrator classifies every pixel of a reflection's neighbourhood as signal (inside r1),
// background (the r2..r3 ring) or neither. Those three radii used to be plain circles of a fixed
// pixel size, which is wrong for a spot that is not round: a bandwidth streaks a reflection
// radially by sigma_bw = bw_sigma*Rpx, so at high resolution the r2..r3 ring sits on the
// reflection's own tails and measures them as background.
//
// So the RING is an ellipse, elongated along the beam->reflection direction by k_sigma times that
// streak, and left alone tangentially. The SIGNAL disk stays a circle: r1 governs n_inner, var_bkg,
// the box-sum acceptance gate (all-or-nothing `n_inner_valid == n_inner`, so growing it rejects every
// reflection with one bad pixel anywhere along a long streak) and the domain the profile WIDTH is
// learned over. In the default gaussian mode it does not set the intensity - the fit grid, ceil(r2),
// does.
//
// What the fixed circular disk loses is flux, and the loss is NOT a function of resolution alone:
// measured, it carries a directional component worth several A^2 with a definite principal axis. Nor
// is there anything to absorb it. There is no per-shell scale in the merge, and there cannot be a
// useful one: every scale is fitted against a reference built from a reflection's own symmetry
// equivalents, and equivalents share s^2 exactly, so any function of s^2 lies in the exact null space
// of the whole scaling model. The isotropic part is instead degenerate with the Wilson B and is
// silently reported as part of it - measured across a radius sweep, 0.5 A^2 on sharp strong data and
// up to ~10 A^2 on weak wide-spot data. Most of the loss is matched by a proportional sigma, so it
// moves no CC1/2 and no R_meas.
//
// The width is the BANDWIDTH STREAK ALONE, and deliberately not the profile's full radial variance
// (which also carries a sensor parallax term and a weak-spot capture term). Those two were measured
// on the rotation battery: they widen the ring on monochromatic data, where they are the only terms
// there are, and that neither helped the two crystals with clean high-resolution shells nor left the
// two weak ones alone. The bandwidth streak, by contrast, is a measured elongation of the recorded
// spot - principal axis along the radius to within a couple of degrees, azimuth-independent - and it
// is the term that carries the pink-beam case. Keeping only it also makes the whole feature exactly
// inert on a monochromatic beam, where bw_sigma is 0 and so is the growth.
//
// Rather than evaluate an ellipse, each pixel's squared distance has the radial part scaled down:
// with rad the pixel's radial offset and d2 its plain squared distance,
//
// d2_inner = d2 - q_in *rad^2 tested against r2^2 (q_in = 1 - (r2/(r2+grow))^2)
// d2_outer = d2 - q_out*rad^2 tested against r3^2 (q_out = 1 - (r3/(r3+grow))^2)
//
// which is the ellipse (radial semi-axis r+grow, tangential semi-axis r) written so that grow = 0
// gives q = 0 and hence d2_inner == d2_outer == d2 EXACTLY, in floating point, not merely to within
// rounding. k_sigma = 0 therefore reproduces the old circular stencil bit for bit.
//
// This header is compiled by both the host compiler and nvcc, so the geometry has exactly one
// definition: the two engines classify by the same rule, built from the same inputs. They are not
// bit-identical to each other - nvcc contracts a*b+c into an FMA and the host baseline has no FMA
// instruction to contract into - so a pixel within a rounding of a boundary can still fall either
// way, which is what the parity test's tolerances are for.
// =============================================================================
#include <cmath>
#include <cstdint>
#ifdef __CUDACC__
#define BRAGG_STENCIL_HD __host__ __device__ inline
#else
#define BRAGG_STENCIL_HD inline
#endif
// Fixed per-experiment inputs to the stencil law (mirrors BraggIntegrationEngine's members).
struct BraggStencilParams {
float beam_x = 0.0f, beam_y = 0.0f;
float r2 = 6.0f, r3 = 10.0f;
float bw_sigma = 0.0f; // radial streak per pixel of radius (bandwidth sigma, dimensionless)
float k_sigma = 0.0f; // radial sigmas to push the ring out by; 0 = the old circular stencil
float max_grow = 0.0f; // hard cap on the radial growth [px]; <= 0 means uncapped
};
// One reflection's stencil, in its own radial/tangential frame.
struct BraggStencil {
float ux = 1.0f, uy = 0.0f; // unit vector beam -> reflection
float r0 = 0.0f; // distance from the beam centre [px]
float grow = 0.0f; // radial growth of the ring [px] (0 = circular)
float q_in = 0.0f, q_out = 0.0f; // radial shrink coefficients (0 = circular)
float ex_in = 0.0f, ey_in = 0.0f; // axis-aligned half-extent of the inner (r2) ellipse
float ex_out = 0.0f, ey_out = 0.0f; // axis-aligned half-extent of the outer (r3) ellipse
};
// Radial growth of the ring at Rpx, in pixels: k_sigma times the bandwidth streak, capped. Shared
// so the stencil and the radial-background kernel table agree on what "grown by this much" means.
BRAGG_STENCIL_HD float BraggStencilGrow_px(float Rpx, const BraggStencilParams &p) {
if (!(p.k_sigma > 0.0f)) return 0.0f;
// The cap bounds what a mis-declared bandwidth can do to the bounding box. A caller that
// forgets to set it gets the uncapped growth rather than silently no growth at all.
const float grow = p.k_sigma * p.bw_sigma * Rpx;
return (p.max_grow > 0.0f && grow > p.max_grow) ? p.max_grow : grow;
}
BRAGG_STENCIL_HD BraggStencil MakeBraggStencil(float px_x, float px_y, const BraggStencilParams &p) {
BraggStencil s;
const float rx = px_x - p.beam_x, ry = px_y - p.beam_y;
const float r0 = sqrtf(rx * rx + ry * ry);
s.r0 = r0;
if (r0 > 1e-6f) {
s.ux = rx / r0;
s.uy = ry / r0;
}
s.grow = BraggStencilGrow_px(r0, p);
const float grow = s.grow;
if (grow == 0.0f) { // the common case, and the only one the online path can reach
s.ex_in = p.r2; s.ey_in = p.r2;
s.ex_out = p.r3; s.ey_out = p.r3;
return s;
}
const float a_in = p.r2 + grow, a_out = p.r3 + grow;
const float si = p.r2 / a_in, so = p.r3 / a_out;
s.q_in = 1.0f - si * si;
s.q_out = 1.0f - so * so;
// Axis-aligned half-extents of an ellipse with semi-axes (a along u, b across it).
const float ux2 = s.ux * s.ux, uy2 = s.uy * s.uy;
s.ex_in = sqrtf(a_in * a_in * ux2 + p.r2 * p.r2 * uy2);
s.ey_in = sqrtf(a_in * a_in * uy2 + p.r2 * p.r2 * ux2);
s.ex_out = sqrtf(a_out * a_out * ux2 + p.r3 * p.r3 * uy2);
s.ey_out = sqrtf(a_out * a_out * uy2 + p.r3 * p.r3 * ux2);
return s;
}
// Which of the n_kern radial-background kernels this reflection uses: one per whole pixel of growth.
// Taken from the stencil, so the kernel and the ring it corrects are built from the same radius -
// deriving it from a separately computed r0 let the two engines round differently and pick
// different rows.
BRAGG_STENCIL_HD int BraggStencilKernelIndex(const BraggStencil &s, int n_kern) {
if (n_kern <= 1) return 0;
int idx = (int) lroundf(s.grow);
if (idx < 0) idx = 0;
if (idx >= n_kern) idx = n_kern - 1;
return idx;
}
// The three squared distances a pixel at offset (ddx, ddy) is tested by, plus its radial offset
// (which the radial-background curve bins on). `signal` is the plain circular distance.
struct BraggStencilDist {
float signal, inner, outer, rad;
};
// --- Ownership of a shared signal region ---------------------------------------------------------
// Two reflections whose centres are closer than 2*r1 have signal disks that intersect, and a pixel in
// the intersection carries both. It belongs to the NEARER centre. The union mask above cannot say
// that (it also marks a reflection's own core), so the decision is made once per image into an owner
// map: every reflection writes (quantised distance << 24) | index over its claim disk with an atomic
// minimum, so the nearest centre wins whatever order the writes arrive in and the lowest index breaks
// a tie. Eight bits of distance over the claim radius is 1/40 px at the default r2 = 6 px, far finer
// than the prediction itself. A pixel nobody claimed is nobody's neighbour, hence the owner's.
constexpr uint32_t BRAGG_OWNER_NONE = 0xffffffffu;
constexpr uint32_t BRAGG_OWNER_IDX_MASK = 0x00ffffffu;
BRAGG_STENCIL_HD int BraggOwnerQuant(float d, float inv_claim) {
int q = (int) (d * inv_claim * 255.0f);
return q > 255 ? 255 : q;
}
BRAGG_STENCIL_HD uint32_t BraggOwnerKey(float d, float inv_claim, int index) {
return ((uint32_t) BraggOwnerQuant(d, inv_claim) << 24)
| ((uint32_t) index & BRAGG_OWNER_IDX_MASK);
}
BRAGG_STENCIL_HD bool BraggOwnedBy(uint32_t owner, int index) {
return owner == BRAGG_OWNER_NONE
|| (owner & BRAGG_OWNER_IDX_MASK) == ((uint32_t) index & BRAGG_OWNER_IDX_MASK);
}
// Widening that 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: on the crowded crystal the residual
// bias of the pixels that were kept grew from +0.072 at no margin to +0.087, +0.144 and +0.209 in ln
// intensity at 1, 2 and 3 px, and the merge fell apart with it. What the margin removes is the
// reflection's own profile, not the neighbour's tail, so the plain nearest-centre split is the rule.
BRAGG_STENCIL_HD BraggStencilDist BraggStencilDistances(const BraggStencil &s, float ddx, float ddy) {
BraggStencilDist d;
d.rad = ddx * s.ux + ddy * s.uy;
d.signal = ddx * ddx + ddy * ddy;
const float rad2 = d.rad * d.rad;
d.inner = d.signal - s.q_in * rad2;
d.outer = d.signal - s.q_out * rad2;
return d;
}