From bc3d69372167e07f352b3cdc27afbc186aa84593 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Fri, 4 Sep 2026 10:08:15 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N --- common/JFJochMessages.h | 8 +++-- .../indexing/SpindleBlindFraction.h | 29 +++++++++++++++++++ tests/SpindleBlindFractionTest.cpp | 15 ++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/common/JFJochMessages.h b/common/JFJochMessages.h index a811bca17..317d98eb2 100644 --- a/common/JFJochMessages.h +++ b/common/JFJochMessages.h @@ -130,9 +130,11 @@ struct DataMessage { std::optional 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 spindle_blind_fraction; std::optional indexing_result; diff --git a/image_analysis/indexing/SpindleBlindFraction.h b/image_analysis/indexing/SpindleBlindFraction.h index 9ac03a55d..197a5e23f 100644 --- a/image_analysis/indexing/SpindleBlindFraction.h +++ b/image_analysis/indexing/SpindleBlindFraction.h @@ -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 &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 diff --git a/tests/SpindleBlindFractionTest.cpp b/tests/SpindleBlindFractionTest.cpp index 4823ba27b..3f03d2e8c 100644 --- a/tests/SpindleBlindFractionTest.cpp +++ b/tests/SpindleBlindFractionTest.cpp @@ -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); +}