spindle: three trigger states, and the threshold is geometry rather than tuning

The score exists so beamline automation can engage a recovery protocol - a two-sweep collection,
a goniometer reorientation - with no human in the loop, so its canonical reading is fixed in one
place with nothing for a beamline to tune: engage at 0.5, do not below, and NO VALUE is a third
state that automation must treat as engage. The error costs are asymmetric - a false negative
leaves the data permanently short, a false positive costs minutes of beamtime - and a frame
nobody could measure must not be read as a frame measured safe.

The 0.5 is derived, not tuned: the score is monotone in the folded miss-angle, so any threshold
is a fold-angle gate, and 0.5 gates at fold <= 0.4040 * theta_max (verified root). Engaging on
any overlap at all would gate at fold < theta_max, whose perpendicular band alone spans
sin(theta_max) per row - 26% of orientation space at 15 deg - and unions over a frame's rows to
well over half of all mountings, degenerating the trigger into "always"; at 0.5 the residual
missed loss stays below half the cone.

Also correct the message-field comment that said the whole cone means "1 - cos theta_max of
every shell": an inner shell loses 1 - cos theta(d) at its own, smaller theta(d).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
leonarski_fandClaude Opus 5 committed 2026-09-04 10:59:05 +02:00
1 parent 89962574ef
commit bc3d693721
3 files changed
+49 -3

No files matched your search

+5 -3
View File
@@ -130,9 +130,11 @@ struct DataMessage {
std::optional<float> ice_ring_score; // strongest ice ring over the smooth radial background (1 = none)
// How much of a single sweep's blind cone this orientation makes unrecoverable: 0 = one sweep
// about the spindle reaches everything the point group can give, 1 = a short lattice row lies on
// the spindle and the whole cone (1 - cos theta_max of every shell) is lost coherently. Absent
// when the frame gives nothing to decide on - too few spots, no lattice rows, no spindle - which
// is a third state, not a value of one half.
// the spindle and the whole cone is lost coherently - each shell losing its own 1 - cos theta(d),
// theta(d) = asin(lambda/2d), up to theta_max at the resolution edge. Absent when the frame gives
// nothing to decide on - too few spots, no lattice rows, no spindle - which is a third state, not
// a value of one half: automation must treat absence as "engage" (see SpindleTrigger in
// image_analysis/indexing/SpindleBlindFraction.h).
std::optional<float> spindle_blind_fraction;
std::optional<bool> indexing_result;
@@ -63,6 +63,35 @@ constexpr size_t SPINDLE_MIN_SPOTS = 60;
// sweep collected without moving the detector - see spindle_theta_max_deg in Indexer.h for why.
float SpindleThetaMax_deg(float wavelength_A, float d_min_A);
// The trigger the score exists for: beamline automation engaging a recovery protocol - a two-sweep
// collection, a goniometer reorientation - with no human in the loop. The stored and transported
// quantity stays the continuous score; these three states are the canonical reading of it, fixed
// by design with nothing for a beamline to tune:
//
// Engage score >= 0.5
// DontEngage score < 0.5
// CannotSay no score at all - too few spots, no shortlist, the consistency guard refused,
// the path never computed one. Automation MUST treat CannotSay as Engage: the error
// costs are asymmetric. A false negative is unrecoverable - the protocol does not
// fire, one sweep is collected, and the data are permanently short - while a false
// positive costs an extra wedge, minutes of beamtime.
//
// The 0.5 threshold is geometry, not tuning. The score is monotone in the folded miss-angle, so
// any threshold is a fold-angle gate; 0.5 gates at fold <= 0.4040 * theta_max. Engaging on any
// overlap at all (score > 0) would gate at fold < theta_max, whose perpendicular band alone spans
// sin(theta_max) of orientation space per row - 26% at theta_max = 15 deg - and unions over a
// frame's rows to well over half of all mountings, degenerating the trigger into "always". At 0.5
// the residual missed loss is bounded below half the cone.
enum class SpindleTrigger { Engage, DontEngage, CannotSay };
constexpr float SPINDLE_ENGAGE_THRESHOLD = 0.5f;
inline SpindleTrigger SpindleTriggerState(const std::optional<float> &score) {
if (!score.has_value())
return SpindleTrigger::CannotSay;
return *score >= SPINDLE_ENGAGE_THRESHOLD ? SpindleTrigger::Engage : SpindleTrigger::DontEngage;
}
struct SpindleSeverity {
float score = 0.0f; // [0,1]
float row_length_A = 0; // the row the score was taken on; 0 = a direction inferred from the
+15
View File
@@ -156,3 +156,18 @@ TEST_CASE("SpindleBlindFraction_FromLattice", "[Indexing][Spindle]") {
CHECK_FALSE(SpindleBlindFractionFromLattice(latt, spindle, 0.0f).has_value());
}
}
TEST_CASE("SpindleTrigger_States", "[Indexing][Spindle]") {
// The 0.5 threshold is a fold-angle gate: the score is monotone in the folded miss-angle and
// crosses 0.5 at fold = 0.4040 * theta_max (verified root of the overlap closed form).
CHECK_THAT(BlindConeSelfOverlap(0.4040f), WithinAbs(0.5f, 1e-3));
CHECK(SpindleTriggerState(0.5f) == SpindleTrigger::Engage);
CHECK(SpindleTriggerState(1.0f) == SpindleTrigger::Engage);
CHECK(SpindleTriggerState(0.49f) == SpindleTrigger::DontEngage);
CHECK(SpindleTriggerState(0.0f) == SpindleTrigger::DontEngage);
// No value is a third state, and the one automation must treat as Engage: a measured 0 says
// "one sweep loses nothing", absence says "nobody could look" - taking the second for the
// first is the unrecoverable error.
CHECK(SpindleTriggerState(std::nullopt) == SpindleTrigger::CannotSay);
}