Files
Jungfraujoch/image_analysis/bragg_integration/SpotWidth.h
T
leonarski_fandClaude Opus 5.5 0c1c963379 Move SpotWidth to bragg_integration/, SpindleCuspLoss and LeftoverLattices to indexing/
A pure move of three method files out of rugnux/: SpotWidth (spot width and
bandwidth, which set the integration radius) joins JFJochBraggIntegration, which now
links JFJochImagePreprocessing for the ImagePreprocessorBuffer it reads;
SpindleCuspLoss and LeftoverLattices join JFJochIndexing. Only include paths and CMake
lists change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
2026-10-07 14:05:37 +02:00

192 lines
11 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
// =============================================================================
// SpotWidth - how wide the recorded spots actually are, in detector pixels
// =============================================================================
//
// The integrator's signal radius r1 is not the integration domain (the profile fit runs over a
// (2*ceil(r2)+1)^2 grid), but it IS the aperture the profile WIDTH is learned over, and a second
// moment taken over a disk of radius a saturates at a^2/4 - so at the shipped r1 = 4 the learned
// Gaussian sigma can never exceed 2 px whatever the spot is. A crystal whose spots are wider than
// that is integrated with a profile the model cannot represent, and it is exactly those crystals
// that gain from a larger r1. Measured over the rotation battery, one threshold on the recorded
// width separates the two populations cleanly.
//
// This measures that width WITHOUT asking the integrator, so the answer can be used to set r1:
// r80, the radius holding 80 % of a spot's flux, is read off the encircled-flux curve of isolated
// strong spots over a FIXED 14 px aperture normalised at 8 px. Nothing here depends on r1, r2 or
// r3, or on any quantity the integrator computes; the aperture is a constant of this file. The
// curve is built about the spot's own flux-weighted centroid, no shape is assumed, and the
// candidates are stratified by resolution - the strongest spots of a weak crystal sit at high
// angle and of a strong one at low angle, and the mosaic footprint grows as 1/d, so an unstratified
// sample compares crystals at different resolutions.
// =============================================================================
#include <array>
#include <optional>
#include <vector>
#include "../../common/DiffractionGeometry.h"
#include "../../common/DiffractionSpot.h"
#include "../image_preprocessing/ImagePreprocessorBuffer.h"
namespace spot_width {
// The encircled-flux curve runs to R_MAX px and is normalised at R_NORM, clear of the noisy tail.
// R_NORM = 8 also fixes what this estimator can see: a spot needs r80 <= 8 px to be measured at
// all, which is five times the width the decision below is taken at.
constexpr int R_MAX = 14;
constexpr int R_NORM = 8;
// The background under a spot is the median of the R_MAX+2 .. R_MAX+6 px ring around it.
constexpr int R_BKG_IN = R_MAX + 2;
constexpr int R_BKG_OUT = R_MAX + 6;
// Resolution the widths are compared at. Inside the measured range of essentially every crystal.
constexpr float D_REF_A = 5.0f;
// One spot's second moments along and across its own radius, and the exact geometry that turns
// them into a bandwidth (EstimateBandwidth). jr and jt are the pixels one radian of 2theta, and of
// the angle across the scattering plane, moves the spot by; cos_psi is the angle of the ray to the
// sensor normal, and par_u/par_v are tan^2(psi) times the square of the in-plane ray direction's
// component along the radius and across it - the conversion depth's lateral smear, per um^2 of depth
// variance, in the two directions.
struct SpotShape {
bool valid = false;
float two_theta = 0.0f, jr = 0.0f, jt = 0.0f;
float m_rad = 0.0f, m_tan = 0.0f; // px^2, about the flux-weighted centroid
float cos_psi = 1.0f, par_u = 0.0f, par_v = 0.0f;
};
// One spot's encircled flux C(1), C(2), ... C(R_MAX), divided by C(R_NORM), with the resolution it
// was recorded at, and its shape.
struct FluxCurve {
float d_A = 0.0f;
std::array<float, R_MAX> c{};
SpotShape shape;
};
// The fitted width law: r80(d) = c0 + c1/d over resolution bands, weighted by the spots in each, so
// crystals whose strong spots sit at different resolutions are compared like with like. The fit is
// over 1/d because the mosaic contribution to the detector footprint grows that way, so c1 is the
// crystal's own answer to how much wider its spots get at high angle - a quantity the fit has always
// computed and nothing has ever been able to read, because only the value at one resolution was
// returned.
struct R80Fit {
// Kept in double, and evaluated in double, because At() has to reproduce the single number the
// caller used to get bit for bit: rounding the coefficients to float first and adding them after
// moves the last bit, and r1 = round(2*r80) can turn on it.
double c0 = 0.0, c1 = 0.0;
// What the bands actually measured, widened the way this fit has always widened it. The law is
// HELD at these outside the measured range rather than extrapolated - it has two coefficients and
// as few as two points, so a slope read off a short lever arm must not be allowed to run away.
double lo = 0.0, hi = 0.0;
[[nodiscard]] float At(double d_A) const;
};
// The width law from a pool of curves, or nothing where too few spots were measurable.
[[nodiscard]] std::optional<R80Fit> FitR80(const std::vector<FluxCurve> &curves);
// r80 at D_REF_A, which is the law above read at one resolution.
[[nodiscard]] std::optional<float> R80AtReference(const std::vector<FluxCurve> &curves);
// The signal radius the measured width asks for: r1 = clamp(round(2*r80), 4, 6). The floor is the
// shipped default and the ceiling is set by pattern density - r2 = r1 + 2 also drives the
// neighbour-ownership radius and the inner edge of the background ring, and past 6 px a dense
// pattern starts losing reflections whose ring falls below six clean pixels.
[[nodiscard]] float R1ForWidth(float r80);
// When a growing sample has told us all it is going to about the radius, so the measurement can
// stop. R1ForWidth is a three-way choice and it moves only where 2*r80 crosses 4.5 and 5.5, i.e. at
// r80 = 2.25 and 2.75 px, so "settled" is two things and not one:
//
// * the estimate has STOPPED MOVING - this sample is within SETTLED_STEP_PX of what the smaller
// sample before it said; and
// * it is not sitting ON a switch - it is SWITCH_CLEARANCE_PX clear of both of them.
//
// Neither carries the rotation battery on its own. Clearance without the step test loses the
// crystal whose r80 is 0.07 px from a switch and whose smaller samples read across it; the step test
// without the clearance lets a sample that has settled AT a switch stop there. Both bounds are set
// inside the range that is right on 38 of 38 crystals rather than at its edge: over every phase of
// the sub-sample the answer is exact for a clearance of 0.20-0.30 px and a step of 0.20-0.60 px.
//
// A quarter of a pixel is also half the width of the whole r1 = 5 band, so a sample landing inside
// that band never stops early - which is deliberate, because that band and its edges are where a
// reduced sample gets a crystal wrong.
constexpr float SWITCH_CLEARANCE_PX = 0.25f;
constexpr float SETTLED_STEP_PX = 0.40f;
// True where this sample's r80 has settled against the smaller sample's before it.
[[nodiscard]] bool WidthSettled(float r80, float r80_before);
// Most of the predicted reflections a widened radius may leave without a background ring, measured
// (BraggIntegrationCounts::bkg_starved_by_neighbour) rather than predicted. Above this the widening
// has cost the pattern more than it can be worth and the shipped radius is restored.
//
// The bound is read off the rotation battery, not chosen. At the radius the rule picks, the eleven
// crystals whose observation count survives the widening sit between 0.000 % and 0.315 %; the one
// crystal that loses a quarter of its observations sits at 4.082 %. That is the only gap in the
// distribution, and this is its midpoint in log space - the maximum-margin split, a factor 3.6
// clear of the nearest measurement on either side.
//
// It has to be the NEIGHBOUR count and not the total: the total also carries the detector's own
// floor (module gaps, the beam stop, the resolution mask), which reaches 2.3 % on some geometries,
// does not move with r1, and would put four of the rule's six wins within a factor of two of the
// loss. Splitting the two apart takes the separation from 2x to 13x.
constexpr double BKG_STARVED_MAX_FRACTION = 0.0113;
// -----------------------------------------------------------------------------------------------
// The X-ray bandwidth, read off the same spots.
//
// A spread dlambda/lambda of sigma smears a reflection along its own radius by 2 tan(theta) sigma
// radians of 2theta and not at all across it. Per spot, with u along the radius and v across it,
//
// m_rad = 1/12 + par_u + jr^2 (s_ang^2 + 4 tan^2(theta) sigma^2)
// m_tan = 1/12 + par_v + jt^2 s_ang^2
//
// (1/12: the pixel; par: the sensor parallax, fixed from the sensor's physics; s_ang: everything
// isotropic in angle - divergence, crystal size, mosaic spread seen edge-on). So
//
// y = (m_rad - 1/12 - par_u) - (jr/jt)^2 (m_tan - 1/12 - par_v) = a + sigma^2 (2 jr tan theta)^2
//
// is a straight line whose slope is the bandwidth. It is fitted over eight equal-count bins of the
// abscissa, each a 20 %-trimmed mean, weighted by its own scatter, and the slope's error is the
// spread of 200 bootstrap re-draws of the spots, inflated by the fit's reduced chi^2 where the line
// fits worse than the scatter says. The slope is significant at BANDWIDTH_Z standard errors - over
// the whole battery that separates the multilayer beamlines from every monochromatic one.
//
// It is a LOWER bound: mosaic spread seen along the radius subtracts, and a spread of cell edges
// (strain) is exactly degenerate with it. What it measures is the effective radial broadening, and
// that is what the consumers of the bandwidth need. The run uses it where it is significant and
// neither the file nor --bandwidth states one.
//
// The width settles on fewer spots than the slope needs, so the pre-scan keeps measuring spots for the
// bandwidth alone until the pool holds BANDWIDTH_POOL_SPOTS or the sample runs out.
constexpr size_t BANDWIDTH_MIN_SPOTS = 80;
constexpr size_t BANDWIDTH_POOL_SPOTS = 1000;
constexpr double BANDWIDTH_Z = 3.0;
struct BandwidthEstimate {
double fwhm = 0.0; // relative FWHM of dlambda/lambda; negative where the slope is
double fwhm_floor = 0.0; // the FWHM one standard error of sigma^2 amounts to
double z = 0.0; // slope / its standard error
double chi2 = 0.0; // reduced chi^2 of the binned line
size_t spots = 0;
};
// The bandwidth from the shapes of a pool of spots, or nothing where too few were measured.
// attenuation_um is the sensor's attenuation length at this wavelength, thickness_um its thickness
// (0 = no depth, no parallax), pixel_um the pixel size.
[[nodiscard]] std::optional<BandwidthEstimate> EstimateBandwidth(const std::vector<FluxCurve> &curves,
double attenuation_um, double thickness_um,
double pixel_um);
} // namespace spot_width
// Every isolated, strong, fully readable spot of one image, as a normalised encircled-flux curve
// with its shape, appended to `out`. Called on one worker's own vector, so the pool is independent of the order the
// workers finish in.
void MeasureSpotFluxCurves(const ImagePreprocessorBuffer &image, int width, int height,
const DiffractionGeometry &geometry,
const std::vector<DiffractionSpot> &spots,
std::vector<spot_width::FluxCurve> &out);