// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #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(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(2.0 / PI) * (std::acos(x) - x * std::sqrt(1.0f - x * x)); } std::optional SpindleBlindFraction(const std::vector &rows, const std::vector &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 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(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 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 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(u) + lattice.Vec1() * static_cast(v) + lattice.Vec2() * static_cast(w)); std::sort(all.begin(), all.end(), [](const Coord &a, const Coord &b) { return a.Length() < b.Length(); }); constexpr size_t MAX_LATTICE_ROWS = 6; const float cos_5_deg = std::cos(5.0f * static_cast(PI) / 180.0f); std::vector 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 magnitudes(rows.size(), 1.0f); return SpindleBlindFraction(rows, magnitudes, spindle, theta_max_deg); }