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
180 lines
9.1 KiB
C++
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);
|
|
}
|