A powder calibration is run because nobody is sure the header is right, and the header's distance was the one number the fit could not survive being wrong about. The ring search is local - each ring is looked for inside a window a few pixels of radius wide - so a distance more than a percent or two out puts every ring outside its own window, and the fit then converges on whatever background fluctuation each window contains. It does not fail: a 110 mm exposure told the detector was at 150 mm reported 149.8 mm, with 146 ring points and exit 0. Only its residual said anything, 5.3 px against 0.4 px, and nothing read it. Measure the distance from the rings instead. The peaks of the azimuthally averaged profile give ring RADII, and a radius does not depend on the assumed distance at all - bin i holds the pixels at one particular radius whatever q that radius was called - so the radii are a property of the image. Against the calibrant's d-spacings, r = D tan(2 asin(lambda/2d)) then has one unknown. It is scanned rather than solved because the pairing of observed rings to d-spacings is unknown too, and the winning basin is solved in closed form. Nothing here reads the header distance except to bin the profile; it needs only the wavelength, the pixel size and the detector's extent. A powder pattern has genuine distance aliases, so one answer is not enough. A cubic primitive standard puts its rings at radii proportional to sqrt(N), and scaling the distance by sqrt(2) maps ring N onto ring 2N - most of the comb still lands on peaks. Measured: the 110 mm exposure with a 115 mm header scored its best at 156.5 mm, which is 110*sqrt(2). No adjustment of the score removes an alias the lattice really has, so the scan hands back the few best distances and each is fitted, the header among them as one hypothesis of several. The residual then separates them - 0.4 px against 5.2 px on that case - subject to an attempt explaining a comparable share of the pattern first, because a start so wrong that one ring point survives leaves a residual of exactly zero. Each attempt re-extracts at the geometry it converged to and fits again. The seed is measured from blended peaks and is good to about a per cent, close enough to converge from but far enough to sit every search window a few pixels off its ring, and an off-centre window takes its background off the ring's own flank. Nothing is re-read from disk, so the loop is free. Measured on the LaB6 distance series. A 110 mm dataset now recovers 110.03-110.17 mm from any header between 25 and 1200 mm, against +-2 mm before. All five datasets recover their own distance from a fixed wrong 250 mm header. With correct headers, four of the five are bit-identical to before and the 500 mm one moves by a single ring point - the two-ring fit whose tilt is 0.1 sigma anyway. Run time is unchanged at 0.62 s. The residual is larger on a run whose header was wrong (1.1 px against 0.4 px on the 110 mm case), because the profile was still binned at the wrong distance and its radial sampling is correspondingly coarse. The geometry is right; only the scatter about it is inflated. Re-running with the recovered distance recovers the residual too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
154 lines
7.8 KiB
C++
154 lines
7.8 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
|
|
#include "RingsFromProfile.h"
|
|
#include "AssignSpotsToRings.h" // RingMatchWindow
|
|
#include "../../common/JFJochMath.h"
|
|
|
|
namespace {
|
|
|
|
// Peak position of one ring in one azimuthal sector, in q, or NaN if there is no peak worth using.
|
|
//
|
|
// The window is narrow and centred on where the ring is expected, so the background under it is close
|
|
// to a straight line: take it from the two bins at each end and interpolate. The position itself is the
|
|
// intensity-weighted centroid of everything above half the peak height, which is insensitive to the
|
|
// exact half-maximum crossing and needs no line-shape assumption - a powder ring is not Gaussian, it is
|
|
// the instrumental profile convolved with whatever strain and size broadening the standard has.
|
|
float SectorPeakQ(const std::vector<float> &profile, int32_t q_bins, int phi_bin,
|
|
int lo_bin, int hi_bin, float low_q, float q_spacing, float min_peak_over_noise) {
|
|
const size_t row = static_cast<size_t>(phi_bin) * static_cast<size_t>(q_bins);
|
|
const auto value = [&](int i) { return profile[row + static_cast<size_t>(i)]; };
|
|
const auto q_of = [&](int i) { return low_q + (static_cast<float>(i) + 0.5f) * q_spacing; };
|
|
|
|
// A bin no pixel fell in is NaN, not zero (AzimuthalIntegrationProfile::GetResult), and the four
|
|
// background bins are where a module gap or the beam stop shows up first. Say so rather than
|
|
// relying on NaN comparisons to fail the peak test further down: a sector whose background cannot
|
|
// be measured has no measurable peak either.
|
|
for (int i : {lo_bin, lo_bin + 1, hi_bin - 1, hi_bin}) {
|
|
if (!std::isfinite(value(i)))
|
|
return NAN;
|
|
}
|
|
|
|
const float bkg_lo = 0.5f * (value(lo_bin) + value(lo_bin + 1));
|
|
const float bkg_hi = 0.5f * (value(hi_bin) + value(hi_bin - 1));
|
|
const auto bkg_at = [&](int i) {
|
|
const float t = static_cast<float>(i - lo_bin) / static_cast<float>(hi_bin - lo_bin);
|
|
return bkg_lo + t * (bkg_hi - bkg_lo);
|
|
};
|
|
|
|
int peak = -1;
|
|
float peak_height = 0.0f;
|
|
for (int i = lo_bin + 2; i <= hi_bin - 2; ++i) {
|
|
const float h = value(i) - bkg_at(i);
|
|
if (h > peak_height) { peak_height = h; peak = i; }
|
|
}
|
|
if (peak < 0)
|
|
return NAN;
|
|
|
|
// Scatter of the background shoulders, as the noise this peak has to stand clear of. A sector with
|
|
// no ring in it has a "peak" that is just the largest background fluctuation, and this is what
|
|
// rejects it - the alternative, an absolute intensity cut, would need a value per detector and beam.
|
|
float s = 0.0f;
|
|
int n = 0;
|
|
for (int i : {lo_bin, lo_bin + 1, hi_bin - 1, hi_bin}) {
|
|
const float r = value(i) - bkg_at(i);
|
|
s += r * r;
|
|
++n;
|
|
}
|
|
const float noise = std::sqrt(s / static_cast<float>(n));
|
|
if (!(peak_height > min_peak_over_noise * noise))
|
|
return NAN;
|
|
|
|
const float half = 0.5f * peak_height;
|
|
double sum_wq = 0.0, sum_w = 0.0;
|
|
for (int i = peak; i >= lo_bin && value(i) - bkg_at(i) >= half; --i) {
|
|
const double w = value(i) - bkg_at(i);
|
|
sum_wq += w * q_of(i);
|
|
sum_w += w;
|
|
}
|
|
for (int i = peak + 1; i <= hi_bin && value(i) - bkg_at(i) >= half; ++i) {
|
|
const double w = value(i) - bkg_at(i);
|
|
sum_wq += w * q_of(i);
|
|
sum_w += w;
|
|
}
|
|
if (!(sum_w > 0.0))
|
|
return NAN;
|
|
return static_cast<float>(sum_wq / sum_w);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::vector<RingOptimizerInput> RingsFromAzimuthalProfile(const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
float q_window_recipA,
|
|
float min_peak_over_noise,
|
|
const std::vector<float> &profile_ring_q) {
|
|
std::vector<RingOptimizerInput> out;
|
|
|
|
const int32_t q_bins = mapping.GetQBinCount();
|
|
const int32_t azim_bins = mapping.GetAzimuthalBinCount();
|
|
// One azimuthal bin is a plain radial profile: the ring is averaged over every direction at once, so
|
|
// nothing remains to say where its centre is. This needs the run to have been integrated with
|
|
// azimuthal bins (jfjoch_broker azim_int_settings.azimuthal_bins, rugnux --azim-phi-bins).
|
|
if (azim_bins < 4 || q_bins < 8
|
|
|| profile.size() != static_cast<size_t>(q_bins) * static_cast<size_t>(azim_bins))
|
|
return out;
|
|
|
|
const auto &settings = mapping.Settings();
|
|
const float low_q = settings.GetLowQ_recipA();
|
|
const float q_spacing = settings.GetQSpacing_recipA();
|
|
const float high_q = low_q + static_cast<float>(q_bins) * q_spacing;
|
|
|
|
// Where to LOOK, which is the calibrant's own q unless the caller has measured that this profile
|
|
// was binned at the wrong distance. Everything below searches in `search`; the points it emits are
|
|
// labelled with `calibrant_ring_q`, which is what the fit drives the geometry to.
|
|
const bool have_search = profile_ring_q.size() == calibrant_ring_q.size();
|
|
const std::vector<float> &search = have_search ? profile_ring_q : calibrant_ring_q;
|
|
|
|
for (size_t i = 0; i < calibrant_ring_q.size(); ++i) {
|
|
const float q_ring = search[i];
|
|
if (!std::isfinite(q_ring))
|
|
continue;
|
|
// Never let the window reach into the neighbouring ring. SectorPeakQ takes the background under
|
|
// the peak from the two bins at each end of the window, so a window wider than half the gap to
|
|
// the next ring measures that ring's flank as this one's background. Hexagonal ice has three
|
|
// rings within 0.06 1/A of one another, which the fixed window merges into a single peak.
|
|
// Measured on the search list, since that is where the rings sit in THIS profile.
|
|
const float window = RingMatchWindow(search, i, q_window_recipA);
|
|
|
|
if (!(q_ring - window > low_q) || !(q_ring + window < high_q))
|
|
continue;
|
|
const int window_bins = static_cast<int>(std::lround(window / q_spacing));
|
|
const int centre_bin = static_cast<int>((q_ring - low_q) / q_spacing);
|
|
const int lo_bin = std::max(0, centre_bin - window_bins);
|
|
const int hi_bin = std::min(q_bins - 1, centre_bin + window_bins);
|
|
// Two background bins at each end and a peak between them is the least this can work with; a
|
|
// ring whose window is narrower than that is not resolved at this q spacing.
|
|
if (hi_bin - lo_bin < 6)
|
|
continue;
|
|
|
|
for (int phi_bin = 0; phi_bin < azim_bins; ++phi_bin) {
|
|
const float q_obs = SectorPeakQ(profile, q_bins, phi_bin, lo_bin, hi_bin,
|
|
low_q, q_spacing, min_peak_over_noise);
|
|
if (!std::isfinite(q_obs))
|
|
continue;
|
|
|
|
// The sector's CENTRE, not its lower edge: GetBin() floors phi into the sector, so a bin
|
|
// stands for [j, j+1) and taking its edge would rotate every ring point by half a sector -
|
|
// which is exactly the cos(phi) signal the beam centre is read from.
|
|
const float phi_rad = static_cast<float>((static_cast<double>(phi_bin) + 0.5)
|
|
* 2.0 * PI / static_cast<double>(azim_bins));
|
|
const auto [x, y] = geom.ResPhiToPxl(static_cast<float>(2.0 * PI) / q_obs, phi_rad);
|
|
if (!std::isfinite(x) || !std::isfinite(y))
|
|
continue;
|
|
out.push_back({x, y, calibrant_ring_q[i]});
|
|
}
|
|
}
|
|
return out;
|
|
}
|