The length sort in FFTIndexer::FilterFFTResults recomputed Coord::Length() inside its comparator. Under LTO with -march=x86-64-v3 (CI and production builds) GCC inlines Length() at several sites in std::__introsort_loop and contracts x*x+y*y+z*z into FMAs differently at each (fma(x,x,y*y) for the element keys, fma(y,y,x*x) for the pivot recomputed after a swap). The same element's key then differs by one ulp between comparisons. Shortlist lengths come from quantised FFT bins, so exact ties are common on noise frames; on such a tie the unguarded partition scan passes its sentinel and runs off the index array. Evidence: - rc.172 jfjoch_broker disassembly: pivot key after swap at 0x925a83 is rounded differently from the scan keys. - The deployed rc.172 introsort, called directly on finite golden-spiral directions x binned lengths, faults at binary +0x5259ca (the journal's crash address) in up to ~0.5% of sorts. No NaN is involved; the earlier NaN guards could not help. - New test FFTIndexer_ManyNoiseFrames: unfixed rc.172 built with the CI flags (-march=x86-64-v3 -flto=auto) segfaults in the same introsort from FilterFFTResults on the GPU FFT path (noise frame 1632), 3/3 runs; passes with this fix. A non-LTO build keeps Length() out of line and cannot crash, which is why the suite never caught it. Keys are now precomputed and sorted with stable_sort. The same pattern was fixed in SpindleBlindFraction (broker-reachable, 62 lattice rows, |a|=|b| ties) and LePageLattice::PlaneBasis (rugnux, v/-v exact ties); tie order in the latter may change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
191 lines
9.8 KiB
C++
191 lines
9.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 "SpindleBlindFraction.h"
|
|
|
|
#include "../../common/JFJochMath.h" // PI - M_PI is not standard, and MSVC does not define it
|
|
|
|
namespace {
|
|
// Only rows short enough to be a plausible symmetry axis count. The cut is relative to the
|
|
// crystal's own shortest row, not an absolute length, so it works the same for a 40 A cell and
|
|
// a 200 A one: measured over 107 solved cells, 2.5 x the shortest row covers 88% of all
|
|
// symmetry axes and 98% of crystals' shortest one.
|
|
constexpr float MAX_ROW_LENGTH_RATIO = 2.5f;
|
|
// The reference length is taken over the strong rows only. A long-cell still makes the pass
|
|
// invent short spurious rows - measured at 0.4-0.6 of the true row's peak - and taking the
|
|
// reference over every row would let one of those shrink the window until the real aligned row
|
|
// fell outside it, turning a severe orientation into a reported zero.
|
|
constexpr float MIN_MAGNITUDE_RATIO = 0.5f;
|
|
// Where the search grid can no longer resolve the crystal's rows, the pass stops returning them
|
|
// and starts returning short spurious ones instead, and the shortlist becomes internally
|
|
// inconsistent: its strong rows are many times longer than its shortest entry. Measured on the
|
|
// stills of 22 solved crystals - 22 independent mounts, so 22 is the sample size, not the
|
|
// several hundred frames they contributed - the ratio never exceeded 2.34 and was 1.00 at the
|
|
// median; on a
|
|
// synthetic still whose cell is past the grid's reach it runs 4-13. Past this the score would be
|
|
// reporting a cone it cannot see into, so it reports nothing instead.
|
|
constexpr float MAX_REFERENCE_LENGTH_RATIO = 3.0f;
|
|
}
|
|
|
|
float SpindleThetaMax_deg(float wavelength_A, float d_min_A) {
|
|
if (wavelength_A <= 0 || d_min_A <= 0)
|
|
return 0;
|
|
const float sin_theta = std::min(1.0f, wavelength_A / (2.0f * d_min_A));
|
|
return static_cast<float>(std::asin(sin_theta) * 180.0 / PI);
|
|
}
|
|
|
|
float BlindConeSelfOverlap(float x) {
|
|
if (x >= 1.0f)
|
|
return 0.0f;
|
|
if (x <= 0.0f)
|
|
return 1.0f;
|
|
return static_cast<float>(2.0 / PI) *
|
|
(std::acos(x) - x * std::sqrt(1.0f - x * x));
|
|
}
|
|
|
|
std::optional<SpindleSeverity> SpindleBlindFraction(const std::vector<Coord> &rows,
|
|
const std::vector<float> &magnitudes,
|
|
const Coord &spindle,
|
|
float theta_max_deg) {
|
|
if (rows.empty() || rows.size() != magnitudes.size() || theta_max_deg <= 0)
|
|
return {};
|
|
|
|
const float axis_length = spindle.Length();
|
|
if (axis_length < 1e-6f)
|
|
return {};
|
|
const Coord axis = spindle.Normalize();
|
|
|
|
float max_magnitude = 0;
|
|
for (const auto &m : magnitudes)
|
|
max_magnitude = std::max(max_magnitude, m);
|
|
|
|
float reference_length = 0;
|
|
for (size_t i = 0; i < rows.size(); i++)
|
|
if (magnitudes[i] >= MIN_MAGNITUDE_RATIO * max_magnitude) {
|
|
const float l = rows[i].Length();
|
|
if (reference_length == 0 || l < reference_length)
|
|
reference_length = l;
|
|
}
|
|
if (reference_length == 0)
|
|
return {};
|
|
|
|
float shortest_length = reference_length;
|
|
for (const auto &r : rows)
|
|
shortest_length = std::min(shortest_length, r.Length());
|
|
if (reference_length > MAX_REFERENCE_LENGTH_RATIO * shortest_length)
|
|
return {};
|
|
|
|
std::vector<size_t> eligible;
|
|
for (size_t i = 0; i < rows.size(); i++) {
|
|
const float length = rows[i].Length();
|
|
if (length <= 0 || length > MAX_ROW_LENGTH_RATIO * reference_length)
|
|
continue;
|
|
if (magnitudes[i] < MIN_MAGNITUDE_RATIO * max_magnitude)
|
|
continue;
|
|
eligible.push_back(i);
|
|
}
|
|
|
|
SpindleSeverity ret;
|
|
bool scored = false;
|
|
const auto consider = [&](const Coord &direction, float row_length_A) {
|
|
const float len = direction.Length();
|
|
const float cos_beta = std::min(1.0f, std::fabs(direction * axis) / len);
|
|
const float beta_deg = static_cast<float>(std::acos(cos_beta) * 180.0 / PI);
|
|
// A direction PERPENDICULAR to the spindle is as damaging as one along it, and far more
|
|
// common: a lone 2-fold about it carries the blind cone onto the cone's opposite lobe, which
|
|
// the same sweep leaves equally unmeasured. That is Friedel's rescue, which is no rescue -
|
|
// the cone is double-sided. (An axis of order >= 3 there DOES repair the cone - measured
|
|
// unrepaired fraction 0.000 for orders 3, 4 and 6 against 1.000 for order 2 - but a still
|
|
// cannot know the order, and the lone diad is the worst case this bound assumes.) Both ends
|
|
// of the range are the bad case and the safe zone lies between them, so the miss-angle is
|
|
// folded about 45 deg. Checked against a Monte-Carlo of the true spherical overlap the
|
|
// folded form is within 0.006 to theta_max = 20 deg and 0.024 to 45 deg; unfolded it is
|
|
// wrong by a full 1.0 at beta = 90 deg, reporting the worst case as the best.
|
|
const float fold_deg = std::min(beta_deg, 90.0f - beta_deg);
|
|
const float score = BlindConeSelfOverlap(fold_deg / theta_max_deg);
|
|
if (!scored || score > ret.score) {
|
|
ret.score = score;
|
|
ret.row_length_A = row_length_A;
|
|
ret.miss_angle_deg = beta_deg;
|
|
scored = true;
|
|
}
|
|
};
|
|
|
|
for (const auto i : eligible)
|
|
consider(rows[i], rows[i].Length());
|
|
|
|
// A lone 2-fold on an axis LONGER than the length window is invisible above - not among the
|
|
// shortlist's rows, and excluded by the window even when it is - but its direction is still
|
|
// recoverable: the normal to two direct-lattice rows is itself a reciprocal-lattice row, and a
|
|
// symmetry axis is parallel in the direct and reciprocal bases, so for a monoclinic cell
|
|
// cross(a, c) IS the unique-axis direction whatever the length of b. Score the normals of the
|
|
// strong in-window row pairs alongside the rows themselves; measured on a synthetic lone-diad
|
|
// crystal with a 300 A unique axis, the fraction of severe mounts reported severe at the 0.5
|
|
// trigger rises from 0.60 to 1.00 and the engagement rate on harmless mounts of that class
|
|
// does not move. The guard only rejects a numerically degenerate normal; the shortlist already
|
|
// keeps its rows 5 deg apart.
|
|
for (size_t a = 0; a < eligible.size(); a++)
|
|
for (size_t b = a + 1; b < eligible.size(); b++) {
|
|
const Coord n = rows[eligible[a]] % rows[eligible[b]];
|
|
if (n.Length() > 1e-4f * rows[eligible[a]].Length() * rows[eligible[b]].Length())
|
|
consider(n, 0.0f); // 0 = a direction inferred from a pair, not a measured row
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
std::optional<SpindleSeverity> SpindleBlindFractionFromLattice(const CrystalLattice &lattice,
|
|
const Coord &spindle,
|
|
float theta_max_deg) {
|
|
// Candidate rows: the direct lattice's shortest few distinct directions, drawn from the index
|
|
// box up to +/-2 - the range in which the symmetry axes of a reduced or conventional basis
|
|
// lie. The count matches what the FFT shortlist resolves in practice (four or five distinct
|
|
// rows - see FilterFFTResults), so the bound is taken over comparable evidence on either path.
|
|
// That parity is load-bearing: a worst case over every enumerable direction saturates towards
|
|
// "always engage" - measured on a generic triclinic cell it fires on 100% of harmless mounts,
|
|
// against 74% for this selection at theta_max = 15 deg - and an always-firing trigger decides
|
|
// nothing. The diad-detection rate stays 1.00 on the monoclinic classes either way, because a
|
|
// dropped axis row is recovered by the pair normals exactly as an invisible one is.
|
|
std::vector<Coord> all;
|
|
all.reserve(62);
|
|
for (int u = 0; u <= 2; u++)
|
|
for (int v = (u == 0) ? 0 : -2; v <= 2; v++)
|
|
for (int w = (u == 0 && v == 0) ? 1 : -2; w <= 2; w++)
|
|
all.push_back(lattice.Vec0() * static_cast<float>(u)
|
|
+ lattice.Vec1() * static_cast<float>(v)
|
|
+ lattice.Vec2() * static_cast<float>(w));
|
|
// Keys computed once, not inside the comparator - see FilterFFTResults: an FMA-contracted Length()
|
|
// can round differently at different inlined sites, and on the ties a lattice is full of
|
|
// (|a| = |b|, |a+b| = |a-b|) that lets std::sort run off the array.
|
|
std::vector<std::pair<float, Coord>> by_length;
|
|
by_length.reserve(all.size());
|
|
for (const auto &c : all)
|
|
by_length.emplace_back(c.Length(), c);
|
|
std::stable_sort(by_length.begin(), by_length.end(),
|
|
[](const auto &a, const auto &b) { return a.first < b.first; });
|
|
for (size_t i = 0; i < all.size(); i++)
|
|
all[i] = by_length[i].second;
|
|
|
|
constexpr size_t MAX_LATTICE_ROWS = 6;
|
|
const float cos_5_deg = std::cos(5.0f * static_cast<float>(PI) / 180.0f);
|
|
std::vector<Coord> rows;
|
|
for (const auto &r : all) {
|
|
if (rows.size() >= MAX_LATTICE_ROWS
|
|
|| r.Length() > MAX_ROW_LENGTH_RATIO * all.front().Length())
|
|
break;
|
|
bool distinct = true;
|
|
for (const auto &k : rows)
|
|
if (std::fabs(r * k) / (r.Length() * k.Length()) > cos_5_deg) {
|
|
distinct = false;
|
|
break;
|
|
}
|
|
if (distinct)
|
|
rows.push_back(r);
|
|
}
|
|
const std::vector<float> magnitudes(rows.size(), 1.0f);
|
|
return SpindleBlindFraction(rows, magnitudes, spindle, theta_max_deg);
|
|
}
|