The detector tilt is refined by default, and on a pattern that cannot separate it from the beam centre the fit returns one anyway - there was nothing to stop it. Both displace a ring's radius as cos(phi), and only how that amplitude grows with the ring's radius tells them apart, which takes two well-sampled rings. At 500 mm on the LaB6 series only two rings reach the detector and the outer one is barely there: the tilt came out at the opposite sign to every shorter distance, dragged the PONI 28 px, and bought a residual of 0.960 px against 0.962 pinned. The covariance says so plainly - 0.1 sigma, and a beam centre quoted to +-180 px. So ask it. A tilt is kept only where the fit had it free AND it stands at least three times its own uncertainty; otherwise rot1/rot2 go back to the header's values and the beam centre and distance are refitted around them. Over the series the tilt stands at 50, 33, 15 and 8 sigma at 110 to 300 mm and 0.1 at 500 mm, so any threshold between 2 and 5 gives the same verdict on all five - this says which regime a fit is in, not where a line was drawn. The declined 500 mm fit lands on a direct beam of 773.53 px, against 773.56 for the pinned fit measured independently. It is a rejection criterion and nothing more. Clearing it does not certify a tilt: that estimator is limited by systematics rather than by this sigma, and a coherent half-pixel error in the ring positions fakes a tilt of the usual size while leaving sigma small. The report says "refined", never "verified". Writing the gate turned up a related fault in the pass loop. RingOptimizer pins the tilt by itself when every point it is given lies on one ring, and on a barely-sampled pattern a later pass lands in exactly that state - which froze the tilt at whatever the FIRST pass had produced and returned it with sigma zero, an unmeasured tilt wearing the appearance of a fixed one. The gate reads that as "not measured" and refits pinned, which is why it is stated over the geometry that gets reported rather than over what the last fit happened to do. Both paths are covered, profile and spots; the spots path was reporting a refined tilt as declined for the same reason. Two things measured and NOT taken: A robust loss. A Cauchy loss scaled to the previous pass's median residual changed nothing on the series - rms 0.415 to 0.421 at 110 mm, no case improved, every direct beam within 0.06 px. Ring points are per-sector peaks that already had to stand 3 sigma clear of their own background, so there are no gross outliers left to reject. Recorded at the call site rather than left as an unused option. A quality gate that refuses a bad calibration. Three candidate signals, all measured against naming the wrong standard on LaB6 data: sigma(PONI) does not see it at all (0.52-0.65 px, indistinguishable from healthy); the residual only half sees it (3.2-3.6 px wrong against 0.4-1.0 right, but a correct run from a wrong header sits at 1.0-2.4 and would be caught too); and the seed's match score is dominated by how many rings the calibrant lists, scoring 0.29 for a perfect LaB6 fit against 0.21 for a wrongly named silicon. None of the three separates, so no gate is shipped. What the run does say is the recovered distance against the header, and a wrong standard moves that to 446 mm on a 110 mm exposure - unmissable, and the operator's call rather than a threshold's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NfuDvf5ipV3Hi8TiCUKD27
309 lines
15 KiB
C++
309 lines
15 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 "PowderAutoSeed.h"
|
|
#include "../../common/JFJochMath.h"
|
|
|
|
namespace {
|
|
|
|
// Radius in pixels of the ring at q, averaged over four azimuths. The average is the point: a beam
|
|
// centre that is wrong by (dx, dy) moves the ring's apparent radius by dx cos(phi) + dy sin(phi), which
|
|
// four azimuths a quarter turn apart cancel exactly. So these radii survive a wrong beam centre as well
|
|
// as a wrong distance, which is what lets the distance be measured before the centre is known.
|
|
float MeanRingRadius_pxl(const DiffractionGeometry &geom, float q) {
|
|
const float cx = geom.GetBeamX_pxl();
|
|
const float cy = geom.GetBeamY_pxl();
|
|
// ResPhiToPxl THROWS past the Ewald limit rather than returning NaN, and a q range wide enough to
|
|
// reach it is a setting, not a fault - so decide here instead of letting it out of the seed.
|
|
if (!(q > 0.0f) || !(static_cast<float>(2.0 * PI) / q > geom.GetWavelength_A() / 2.0f))
|
|
return NAN;
|
|
double sum = 0.0;
|
|
int n = 0;
|
|
for (int i = 0; i < 4; ++i) {
|
|
const auto [x, y] = geom.ResPhiToPxl(static_cast<float>(2.0 * PI) / q,
|
|
static_cast<float>(i * PI / 2.0));
|
|
if (!std::isfinite(x) || !std::isfinite(y))
|
|
continue;
|
|
sum += std::hypot(x - cx, y - cy);
|
|
++n;
|
|
}
|
|
return n > 0 ? static_cast<float>(sum / n) : NAN;
|
|
}
|
|
|
|
// The predicted radius of a ring at q, for a detector at D. Rings past the Ewald limit (q too large for
|
|
// this wavelength) have no radius at all and are reported as NaN rather than silently folded back.
|
|
float PredictedRadius_pxl(float q, float distance_mm, float wavelength_A, float pixel_mm) {
|
|
const float sin_theta = wavelength_A * q / static_cast<float>(4.0 * PI);
|
|
if (!(sin_theta > 0.0f) || sin_theta >= 1.0f)
|
|
return NAN;
|
|
const float two_theta = 2.0f * std::asin(sin_theta);
|
|
// Past 90 degrees the ring is on the back of the detector, which is not a case a flat detector has.
|
|
if (two_theta >= static_cast<float>(PI / 2.0))
|
|
return NAN;
|
|
return distance_mm * std::tan(two_theta) / pixel_mm;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
float ProfileQForRing(float q_cal, float d_true_mm, float d_binned_mm,
|
|
float wavelength_A, float pixel_mm) {
|
|
const float r = PredictedRadius_pxl(q_cal, d_true_mm, wavelength_A, pixel_mm);
|
|
if (!std::isfinite(r) || !(d_binned_mm > 0.0f) || !(wavelength_A > 0.0f))
|
|
return NAN;
|
|
// Straight back through the flat-detector relation the binning used. The tilt is not carried: at
|
|
// seeding time it is whatever the header says, which is zero for every header that has not already
|
|
// been calibrated, and a tenth of a degree moves a ring by well under the search window.
|
|
const float two_theta = std::atan(r * pixel_mm / d_binned_mm);
|
|
return static_cast<float>(4.0 * PI) * std::sin(0.5f * two_theta) / wavelength_A;
|
|
}
|
|
|
|
std::pair<float, float> ProfileRadiusRange_pxl(const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom) {
|
|
const auto &settings = mapping.Settings();
|
|
const float lo = MeanRingRadius_pxl(geom, settings.GetLowQ_recipA());
|
|
const float hi = MeanRingRadius_pxl(geom, settings.GetHighQ_recipA());
|
|
if (!std::isfinite(lo) || !std::isfinite(hi))
|
|
return {0.0f, 0.0f};
|
|
return {std::min(lo, hi), std::max(lo, hi)};
|
|
}
|
|
|
|
std::vector<ObservedRingRadius> RingRadiiFromProfile(const std::vector<float> &profile,
|
|
const AzimuthalIntegrationMapping &mapping,
|
|
const DiffractionGeometry &geom,
|
|
float min_peak_over_noise) {
|
|
std::vector<ObservedRingRadius> out;
|
|
|
|
const int32_t q_bins = mapping.GetQBinCount();
|
|
const int32_t azim_bins = mapping.GetAzimuthalBinCount();
|
|
if (q_bins < 16 || azim_bins < 1
|
|
|| profile.size() != static_cast<size_t>(q_bins) * static_cast<size_t>(azim_bins))
|
|
return out;
|
|
|
|
// Average over azimuth. A ring is a ring at every azimuth, so this is the profile with the most
|
|
// counts behind it; the sectors are only needed later, to tell the beam centre from the tilt.
|
|
// Bins no pixel fell in are NaN and are left out of their own average rather than counted as zero,
|
|
// which would dig a hole where a module gap crosses the ring.
|
|
std::vector<float> radial(q_bins, NAN);
|
|
for (int32_t i = 0; i < q_bins; ++i) {
|
|
double sum = 0.0;
|
|
int n = 0;
|
|
for (int32_t j = 0; j < azim_bins; ++j) {
|
|
const float v = profile[static_cast<size_t>(j) * q_bins + i];
|
|
if (std::isfinite(v)) { sum += v; ++n; }
|
|
}
|
|
if (n > 0)
|
|
radial[i] = static_cast<float>(sum / n);
|
|
}
|
|
|
|
const auto &settings = mapping.Settings();
|
|
const float low_q = settings.GetLowQ_recipA();
|
|
const float q_spacing = settings.GetQSpacing_recipA();
|
|
|
|
// Peaks are found in RADIUS, not in q, and that is the whole reason this works without knowing the
|
|
// distance. Bin i was filled by the pixels whose q under the binning geometry is q_i, which is to
|
|
// say the pixels at radius MeanRingRadius(q_i) - so the radius of a bin is a fact about the
|
|
// detector, identical whatever distance was assumed, while its q is not. A window measured in
|
|
// pixels therefore means the same thing at every assumed distance; a window measured in bins does
|
|
// not, and at a wrongly large distance the whole q axis compresses until neighbouring rings fall
|
|
// inside one window and no peak is the largest in it.
|
|
std::vector<float> radius(q_bins, NAN);
|
|
for (int32_t i = 0; i < q_bins; ++i)
|
|
radius[i] = MeanRingRadius_pxl(geom, low_q + (static_cast<float>(i) + 0.5f) * q_spacing);
|
|
|
|
// Half the width of the window a peak has to dominate, in pixels of radius. A powder ring is a few
|
|
// pixels wide; two rings closer than twice this are not separated, which is a real resolution limit
|
|
// rather than a tuning knob.
|
|
// ...but the window still has to hold enough BINS to have a background and a peak in it. How many
|
|
// bins eight pixels spans depends on the assumed distance - the further away the detector is
|
|
// assumed to be, the more the q axis compresses and the fewer bins cover the same piece of the
|
|
// detector - so a window that is only physical would collapse below three bins a side at a wrongly
|
|
// large distance and find nothing at all. Take whichever of the two is wider.
|
|
constexpr float HALF_WIDTH_PXL = 8.0f;
|
|
constexpr int HALF_WIDTH_MIN_BINS = 4;
|
|
for (int32_t i = 0; i < q_bins; ++i) {
|
|
if (!std::isfinite(radius[i]) || !std::isfinite(radial[i]))
|
|
continue;
|
|
int lo = i, hi = i;
|
|
while (lo > 0 && std::isfinite(radius[lo - 1])
|
|
&& (radius[i] - radius[lo - 1] <= HALF_WIDTH_PXL || i - lo < HALF_WIDTH_MIN_BINS)) --lo;
|
|
while (hi + 1 < q_bins && std::isfinite(radius[hi + 1])
|
|
&& (radius[hi + 1] - radius[i] <= HALF_WIDTH_PXL || hi - i < HALF_WIDTH_MIN_BINS)) ++hi;
|
|
// Two background bins at each end and a peak between them is the least this can work with.
|
|
if (hi - lo < 6 || !std::isfinite(radial[lo]) || !std::isfinite(radial[hi]))
|
|
continue;
|
|
|
|
const auto bkg_at = [&](int k) {
|
|
const float t = static_cast<float>(k - lo) / static_cast<float>(hi - lo);
|
|
return radial[lo] + t * (radial[hi] - radial[lo]);
|
|
};
|
|
|
|
bool is_max = true;
|
|
for (int k = lo; k <= hi && is_max; ++k)
|
|
if (std::isfinite(radial[k]) && radial[k] > radial[i]) is_max = false;
|
|
if (!is_max)
|
|
continue;
|
|
|
|
const float height = radial[i] - bkg_at(i);
|
|
if (!(height > 0.0f))
|
|
continue;
|
|
|
|
// The scatter of the window's own ends, as the noise this peak has to stand clear of - the same
|
|
// measure SectorPeakQ uses, and for the same reason: an absolute cut would need a value per
|
|
// detector and per exposure.
|
|
float s = 0.0f;
|
|
int n = 0;
|
|
for (int k : {lo, lo + 1, hi - 1, hi}) {
|
|
if (!std::isfinite(radial[k])) continue;
|
|
const float r = radial[k] - bkg_at(k);
|
|
s += r * r;
|
|
++n;
|
|
}
|
|
if (n == 0)
|
|
continue;
|
|
const float noise = std::sqrt(s / static_cast<float>(n));
|
|
if (!(height > min_peak_over_noise * noise))
|
|
continue;
|
|
|
|
// Intensity-weighted centroid over the bins above half height, in radius - the same estimator
|
|
// SectorPeakQ uses in q, and for the same reason: it needs no line shape.
|
|
double sum_wr = 0.0, sum_w = 0.0;
|
|
for (int k = i; k >= lo && radial[k] - bkg_at(k) >= 0.5f * height; --k) {
|
|
const double w = radial[k] - bkg_at(k);
|
|
sum_wr += w * radius[k];
|
|
sum_w += w;
|
|
}
|
|
for (int k = i + 1; k <= hi && radial[k] - bkg_at(k) >= 0.5f * height; ++k) {
|
|
const double w = radial[k] - bkg_at(k);
|
|
sum_wr += w * radius[k];
|
|
sum_w += w;
|
|
}
|
|
if (sum_w > 0.0)
|
|
out.push_back({static_cast<float>(sum_wr / sum_w), height});
|
|
}
|
|
|
|
std::sort(out.begin(), out.end(),
|
|
[](const ObservedRingRadius &a, const ObservedRingRadius &b) { return a.height > b.height; });
|
|
return out;
|
|
}
|
|
|
|
std::vector<DistanceCandidate> CandidateDistancesFromPowderRings(
|
|
const std::vector<ObservedRingRadius> &observed,
|
|
const std::vector<float> &calibrant_ring_q,
|
|
const DiffractionGeometry &geom,
|
|
float radius_min_pxl, float radius_max_pxl,
|
|
size_t max_candidates) {
|
|
if (observed.size() < 2 || calibrant_ring_q.empty() || !(radius_max_pxl > radius_min_pxl))
|
|
return {};
|
|
|
|
const float wavelength_A = geom.GetWavelength_A();
|
|
const float pixel_mm = geom.GetPixelSize_mm();
|
|
if (!(wavelength_A > 0.0f) || !(pixel_mm > 0.0f))
|
|
return {};
|
|
|
|
double total_weight = 0.0;
|
|
for (const auto &o : observed)
|
|
total_weight += o.height;
|
|
if (!(total_weight > 0.0))
|
|
return {};
|
|
|
|
// Half a per cent of the radius, floored at two pixels: a ring's own width and the profile's bin
|
|
// both scale with neither, so the looser of the two is what a match has to survive.
|
|
const auto tolerance = [](float r) { return std::max(2.0f, 0.005f * r); };
|
|
|
|
const auto score_at = [&](float distance) {
|
|
std::vector<float> predicted;
|
|
for (const float q : calibrant_ring_q) {
|
|
const float r = PredictedRadius_pxl(q, distance, wavelength_A, pixel_mm);
|
|
if (std::isfinite(r) && r >= radius_min_pxl && r <= radius_max_pxl)
|
|
predicted.push_back(r);
|
|
}
|
|
if (predicted.empty())
|
|
return 0.0;
|
|
|
|
double explained = 0.0;
|
|
for (const auto &o : observed) {
|
|
float nearest = std::numeric_limits<float>::max();
|
|
for (const float p : predicted)
|
|
nearest = std::min(nearest, std::abs(p - o.radius_pxl));
|
|
if (nearest < tolerance(o.radius_pxl))
|
|
explained += o.height;
|
|
}
|
|
|
|
size_t seen = 0;
|
|
for (const float p : predicted) {
|
|
float nearest = std::numeric_limits<float>::max();
|
|
for (const auto &o : observed)
|
|
nearest = std::min(nearest, std::abs(p - o.radius_pxl));
|
|
if (nearest < tolerance(p))
|
|
++seen;
|
|
}
|
|
|
|
// Both halves, multiplied. Only rewarding explained peaks would choose the shortest distance on
|
|
// offer, where the predicted rings are packed so tightly that every peak has one within
|
|
// tolerance; only rewarding seen rings would choose the longest, where a single predicted ring
|
|
// sits on a single peak and nothing else is asked of it.
|
|
return (explained / total_weight)
|
|
* (static_cast<double>(seen) / static_cast<double>(predicted.size()));
|
|
};
|
|
|
|
// Scanned in log steps so the resolution is relative: 2000 steps over 20-2000 mm is 0.23% each,
|
|
// which only has to be fine enough to land in a basin - the value is solved for below. A linear
|
|
// scan would be needlessly fine at 2 m and too coarse at 30 mm.
|
|
constexpr int STEPS = 2000;
|
|
constexpr double MIN_MM = 20.0, MAX_MM = 2000.0;
|
|
std::vector<double> score(STEPS + 1);
|
|
std::vector<float> grid(STEPS + 1);
|
|
for (int step = 0; step <= STEPS; ++step) {
|
|
grid[step] = static_cast<float>(
|
|
MIN_MM * std::pow(MAX_MM / MIN_MM, static_cast<double>(step) / STEPS));
|
|
score[step] = score_at(grid[step]);
|
|
}
|
|
|
|
// Local maxima, best first. A basin is many steps wide, so taking the grid's maxima directly would
|
|
// return the same distance three times over; candidates closer together than 5% are the same answer
|
|
// and only the better one is kept.
|
|
std::vector<int> peaks;
|
|
for (int step = 1; step < STEPS; ++step)
|
|
if (score[step] > 0.0 && score[step] >= score[step - 1] && score[step] > score[step + 1])
|
|
peaks.push_back(step);
|
|
std::sort(peaks.begin(), peaks.end(), [&](int a, int b) { return score[a] > score[b]; });
|
|
|
|
std::vector<DistanceCandidate> out;
|
|
for (const int step : peaks) {
|
|
if (out.size() >= max_candidates)
|
|
break;
|
|
const bool distinct = std::none_of(out.begin(), out.end(), [&](const DistanceCandidate &c) {
|
|
return std::abs(grid[step] - c.distance_mm) < 0.05f * c.distance_mm;
|
|
});
|
|
if (!distinct)
|
|
continue;
|
|
|
|
// The scan fixes the BASIN, not the value: its steps are 0.23% apart, which at 110 mm is a
|
|
// quarter of a millimetre and enough to move the outer rings by more than a pixel. With the
|
|
// pairing settled the distance is linear - r = D tan(2theta) / p with tan(2theta) known per
|
|
// ring - so solve it outright over the pairs this basin matched, weighted by peak height.
|
|
const float coarse = grid[step];
|
|
double num = 0.0, den = 0.0;
|
|
for (const auto &o : observed) {
|
|
float nearest = std::numeric_limits<float>::max(), nearest_t = 0.0f;
|
|
for (const float q : calibrant_ring_q) {
|
|
const float r = PredictedRadius_pxl(q, coarse, wavelength_A, pixel_mm);
|
|
if (!std::isfinite(r)) continue;
|
|
if (std::abs(r - o.radius_pxl) < nearest) {
|
|
nearest = std::abs(r - o.radius_pxl);
|
|
nearest_t = r / coarse; // the ring's radius per mm of distance
|
|
}
|
|
}
|
|
if (nearest < tolerance(o.radius_pxl) && nearest_t > 0.0f) {
|
|
num += static_cast<double>(o.height) * nearest_t * o.radius_pxl;
|
|
den += static_cast<double>(o.height) * nearest_t * nearest_t;
|
|
}
|
|
}
|
|
out.push_back({den > 0.0 ? static_cast<float>(num / den) : coarse, score[step]});
|
|
}
|
|
return out;
|
|
}
|