Files
Jungfraujoch/image_analysis/indexing/SpindleBlindFraction.cpp
T
leonarski_fandClaude Opus 5 89962574ef spindle: the severity no longer rides on the indexing seed or on which indexer is configured
The score gated on 60 spots, but the seed escalation stops at the leanest seed that indexes -
30 spots on precisely the clean frames a grid scan produces - so the value was absent exactly
where beamline automation most needs it, and absence maps to "engage": the protocol would have
fired on every good frame, which degenerates the trigger into "always". The floor itself stays
where it was calibrated; what changes is what it gates. When no escalation pass could answer,
one severity-only pass runs over the full spot list - the row search alone, no reduction, no
refinement - purely to produce the number.

The same was true of the indexer choice: only the FFT family computes a row shortlist, so a
deployment configured with the known-cell indexer - the ordinary online stills path - never
produced the score at all. Where the severity-only pass has no row search to run, the severity
is read off the rows of the winning lattice instead, which any indexer produces: the lattice's
shortest few distinct directions, as many as the FFT shortlist resolves in practice, fed through
the same window and scoring with equal magnitudes. The count parity is load-bearing - a worst
case over every enumerable lattice direction fires on 100% of harmless mounts of a generic
triclinic cell against 74% for this selection at theta_max = 15 deg, and an always-firing
trigger decides nothing - while the diad-detection rate stays 1.00 on the monoclinic classes
either way, a dropped axis row being recovered by the pair normals exactly as an invisible one
is. A frame that neither indexed nor reached the spot floor still reports nothing, which is the
honest answer and maps to the recoverable error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-04 10:59:05 +02:00

180 lines
9.1 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"
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 / M_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 / M_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 / M_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));
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<float>(M_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);
}