Files
Jungfraujoch/image_analysis/indexing/SpindleBlindFraction.cpp
T
leonarski_fandClaude Opus 5 0ee1e5e070 spindle: an axis too long to see still shows its direction in the normals of the rows that are not
The length window is deliberate - a row 2.5x the crystal's shortest is not a plausible symmetry
axis in a crystal that small - but it made the score blind to a lone 2-fold on an axis LONGER
than the window: a monoclinic crystal with a long unique axis, mounted near-perpendicular at an
unlucky azimuth, returned a confident 0 rather than a refusal. The axis is recoverable without
ever seeing its row: 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 cross(a, c) is the
unique-axis direction whatever the length of b. The normals of the strong in-window row pairs
are now scored alongside the rows themselves, with row_length_A = 0 marking a direction the
frame inferred rather than measured.

Measured on a synthetic lone-diad crystal with a 300 A unique axis over random mounts, the
fraction of severe mounts reported severe at the 0.5 trigger rises from 0.60 to 1.00, the
engagement rate on harmless mounts of that class does not move, and the recovered direction
reproduces the true axis exactly (every visible row is perpendicular to it).

Also state the shortlist-consistency calibration on its per-crystal basis - 22 independent
mounts, not the several hundred frames they contributed - and carry the conditioning the
perpendicular case needs: an axis of order >= 3 there fully repairs the cone (measured 0.000
unrepaired for orders 3, 4, 6 against 1.000 for a diad), which a still cannot know, so the lone
diad stays the operative worst case and the bound stays deliberately pessimistic on
higher-symmetry crystals.

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

137 lines
6.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"
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;
}