A rotation sweep never records a double cone of half-angle asin(lambda/2d) about the spindle. That loss is normally repaired by the point group; it is not repaired when an operator's axis lies inside the cone, because the cone then maps onto itself. A still cannot know the point group, but it can see where the crystal's short lattice rows are, and a symmetry axis is always one of them - measured over 107 solved cells its length is 1.4x the shortest row at the median. The number is the fraction of the blind cone that a 2-fold about the nearest short row carries back into the blind cone: two equal caps of angular radius theta_max whose centres are 2*beta apart, which is the circle-lens area in beta/theta_max and within 0.035 of the spherical value even for a 55 deg cone. 0 means one sweep reaches everything symmetry could give; 1 means the row is on the spindle and the whole cone is lost coherently. No goniometer geometry enters, so the number describes the problem and leaves the remedy to the beamline. Free: it rides on the FilterFFTResults shortlist the indexer already builds, needing only the spindle, the wavelength and the frame's own resolution. FilterFFTResults gains an optional out-parameter for each row's peak prominence, which the length window is taken over. Measured on 384 stills of 22 solved crystals against the frames' own symmetry axes, with the same procedure re-run along five decoy directions per frame as the null: 0.93 of severe orientations reported severe, 0.010 of harmless ones reported severe, AUC 0.948, and the value tracks the true severity to 0.10 at the 90th percentile. Below 60 spots the misses triple, so that is where it stops answering. It is blind to a symmetry axis much longer than the crystal's shortest row: measured on synthetic stills the search grid finds a 150 A row every time, a 200 A row half the time and a 300 A row once in sixteen. Two cheap consistency tests refuse to answer for part of that regime and cost nothing on real frames, but they do not cover it - the reach is a documented property of the number, not something every frame can detect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
96 lines
4.7 KiB
C++
96 lines
4.7 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_test_macros.hpp>
|
|
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
|
|
|
#include "../image_analysis/indexing/SpindleBlindFraction.h"
|
|
|
|
using Catch::Matchers::WithinAbs;
|
|
|
|
TEST_CASE("SpindleBlindFraction_Overlap", "[Indexing][Spindle]") {
|
|
// A row on the spindle leaves the whole cone unrecoverable; a row on the cone edge leaves none.
|
|
CHECK_THAT(BlindConeSelfOverlap(0.0f), WithinAbs(1.0f, 1e-6));
|
|
CHECK_THAT(BlindConeSelfOverlap(1.0f), WithinAbs(0.0f, 1e-6));
|
|
CHECK_THAT(BlindConeSelfOverlap(2.0f), WithinAbs(0.0f, 1e-6));
|
|
// Half way into the cone the two caps still share 39% of their area.
|
|
CHECK_THAT(BlindConeSelfOverlap(0.5f), WithinAbs(0.3910f, 1e-3));
|
|
// Monotone decreasing
|
|
for (int i = 0; i < 20; i++)
|
|
CHECK(BlindConeSelfOverlap(i / 20.0f) >= BlindConeSelfOverlap((i + 1) / 20.0f));
|
|
}
|
|
|
|
TEST_CASE("SpindleBlindFraction_Rows", "[Indexing][Spindle]") {
|
|
const Coord spindle(0, 0, 1);
|
|
const float theta_max = 20.0f;
|
|
|
|
SECTION("a short row on the spindle is the worst case") {
|
|
const std::vector<Coord> rows = {Coord(0, 0, 50), Coord(60, 0, 0), Coord(0, 70, 0)};
|
|
const std::vector<float> mag = {100, 90, 80};
|
|
const auto s = SpindleBlindFraction(rows, mag, spindle, theta_max);
|
|
REQUIRE(s.has_value());
|
|
CHECK_THAT(s->score, WithinAbs(1.0f, 1e-5));
|
|
CHECK_THAT(s->miss_angle_deg, WithinAbs(0.0f, 1e-3));
|
|
CHECK_THAT(s->row_length_A, WithinAbs(50.0f, 1e-3));
|
|
}
|
|
|
|
SECTION("no short row inside the cone scores zero") {
|
|
const std::vector<Coord> rows = {Coord(50, 0, 0), Coord(0, 60, 0), Coord(0, 60, 25)};
|
|
const std::vector<float> mag = {100, 90, 80};
|
|
const auto s = SpindleBlindFraction(rows, mag, spindle, theta_max);
|
|
REQUIRE(s.has_value());
|
|
CHECK_THAT(s->score, WithinAbs(0.0f, 1e-6));
|
|
}
|
|
|
|
SECTION("a row too long to be a symmetry axis is ignored") {
|
|
// 300 A along the spindle, in a crystal whose own rows are 50-70 A: 6x the shortest row is
|
|
// not a plausible symmetry axis, and the cone it sits in is not the crystal's problem.
|
|
const std::vector<Coord> rows = {Coord(50, 0, 0), Coord(0, 60, 0), Coord(0, 0, 300)};
|
|
const std::vector<float> mag = {100, 90, 80};
|
|
const auto s = SpindleBlindFraction(rows, mag, spindle, theta_max);
|
|
REQUIRE(s.has_value());
|
|
CHECK_THAT(s->score, WithinAbs(0.0f, 1e-6));
|
|
}
|
|
|
|
SECTION("the same row in a crystal that IS that big is not ignored") {
|
|
const std::vector<Coord> rows = {Coord(250, 0, 0), Coord(0, 280, 0), Coord(0, 0, 300)};
|
|
const std::vector<float> mag = {100, 90, 80};
|
|
const auto s = SpindleBlindFraction(rows, mag, spindle, theta_max);
|
|
REQUIRE(s.has_value());
|
|
CHECK_THAT(s->score, WithinAbs(1.0f, 1e-5));
|
|
}
|
|
|
|
SECTION("a weak spurious short row does not shrink the length window") {
|
|
// What a long-cell still produces: the real rows near 300 A plus a weaker short peak. Taking
|
|
// the window off that peak would hide the aligned row and report the orientation harmless.
|
|
const std::vector<Coord> rows = {Coord(100, 5, 0), Coord(250, 0, 0), Coord(0, 0, 300)};
|
|
const std::vector<float> mag = {40, 100, 95};
|
|
const auto s = SpindleBlindFraction(rows, mag, spindle, theta_max);
|
|
REQUIRE(s.has_value());
|
|
CHECK_THAT(s->score, WithinAbs(1.0f, 1e-5));
|
|
}
|
|
|
|
SECTION("a shortlist the pass could not resolve gives no answer at all") {
|
|
// Strong rows ten times longer than the shortest entry: the grid has lost the crystal's real
|
|
// rows and is returning spurious short ones. Reporting zero here would be a silent "safe".
|
|
const std::vector<Coord> rows = {Coord(12, 5, 0), Coord(250, 0, 0), Coord(0, 0, 300)};
|
|
const std::vector<float> mag = {40, 100, 95};
|
|
CHECK_FALSE(SpindleBlindFraction(rows, mag, spindle, theta_max).has_value());
|
|
}
|
|
|
|
SECTION("no rows, no answer") {
|
|
CHECK_FALSE(SpindleBlindFraction({}, {}, spindle, theta_max).has_value());
|
|
CHECK_FALSE(SpindleBlindFraction({Coord(0, 0, 50)}, {1.0f}, Coord(0, 0, 0), theta_max).has_value());
|
|
}
|
|
|
|
SECTION("a wider cone at long wavelength makes the same miss-angle worse") {
|
|
const std::vector<Coord> rows = {Coord(0, 20, 50), Coord(60, 0, 0)};
|
|
const std::vector<float> mag = {100, 90};
|
|
const auto narrow = SpindleBlindFraction(rows, mag, spindle, 10.0f);
|
|
const auto wide = SpindleBlindFraction(rows, mag, spindle, 35.0f);
|
|
REQUIRE(narrow.has_value());
|
|
REQUIRE(wide.has_value());
|
|
CHECK(wide->score > narrow->score);
|
|
}
|
|
}
|