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:
1 parent
89962574ef
commit
bc3d693721
3 files changed
+49
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user