// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #pragma once #include #include #include #include "../../common/Coord.h" #include "../../common/CrystalLattice.h" // How much of a single sweep's blind cone this orientation makes unrecoverable. // // A sweep about the spindle never brings a reciprocal point closer than theta_max = // asin(lambda / 2d) to the axis onto the Ewald sphere, so a double cone of half-angle theta_max // is missing from every resolution shell whatever the crystal does. That loss is normally // repaired by the point group, which maps the cone onto measured territory. It is not repaired // when the operator's axis lies inside the cone, because then the cone maps onto itself - and a // still cannot know the point group, but it can measure where the crystal's short lattice rows // are, and a symmetry axis is always a lattice row, and usually among the short ones (over 107 // solved cells, 88% of symmetry axes fall inside the 2.5x length window below). // // Score = the fraction of the blind cone that a 2-fold about the worst plausible row direction // carries back into the blind cone: two equal caps of angular radius theta_max whose centres are // 2*beta apart. 0 means any operator about that direction moves the cone entirely off itself and // one sweep loses nothing symmetry could have given; 1 means the whole cone is lost. Nothing about // the goniometer enters, so the number describes the problem and leaves the remedy - a chi offset, // a second sweep, accepting the loss - to the beamline. // // The score is a WORST-CASE BOUND under an assumption of no symmetry, not an estimate: a still // cannot rule out that the nearest plausible row is a lone 2-fold, so it is scored as one. BOTH // ends of the miss-angle range are that diad's bad case. A row ON the spindle maps the cone onto // itself whatever its order; a row PERPENDICULAR to the spindle maps it onto the cone's opposite // lobe - equally unmeasured, and a band rather than a cap, so the commoner of the two - but only // for a LONE DIAD: an axis of order >= 3 there fully repairs the cone (measured unrepaired // fraction 0.000 for orders 3, 4 and 6 against 1.000 for order 2), which a still cannot know. // The safe zone is the middle, and the miss-angle is folded about 45 deg accordingly. On // higher-symmetry crystals the bound is therefore deliberately pessimistic, and the cost model // accepts that: at theta_max = 15 deg it engages, at the 0.5 trigger threshold, on roughly a // quarter of harmless mountings (a single strong row's perpendicular band alone covers ~11% of // orientation space), against a false negative that costs the dataset a hole no amount of sweep // can fill. // Fraction of a cap of angular radius theta that its own image under a 2-fold covers, as a function // of x = (folded miss-angle) / theta_max; the caller does the folding. Exact in the flat limit. // Checked against a Monte-Carlo of the true spherical overlap the folded score is within 0.006 up to // theta_max = 20 deg and within 0.024 up to 45 deg, so the closed form is used as is. Only past // 45 deg - which needs lambda > 1.41 * d_min, so a long-wavelength beamline - does the sphere's // curvature start to tell, and there it UNDER-reports, by 0.05 at theta_max = 50 deg and 0.13 at // 55 deg. There the score is a lower bound on the loss rather than an estimate of it. float BlindConeSelfOverlap(float x); // Below this many spots the row shortlist is no longer trustworthy: measured on the stills of // 22 solved crystals - 22 independent mounts, however many frames each contributed - the fraction // of severe orientations reported harmless falls from 0.22 at 30 spots to 0.05 at 60 and is flat // above. Fewer spots means NO value - which automation must treat as "engage", the recoverable // error - never a middling score. constexpr size_t SPINDLE_MIN_SPOTS = 60; // asin(lambda/2d) in degrees; 0 when either input is unusable. d_min_A should be the setup's // GEOMETRIC resolution (DiffractionExperiment::GetDetectorMaxResolution_A), the upper bound on any // 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 // normal of a row pair, whose own row length the frame cannot see float miss_angle_deg = 0; }; // rows/magnitudes: the FilterFFTResults shortlist and each entry's FFT peak prominence. // Returns nothing when there is no shortlist to decide on. std::optional SpindleBlindFraction(const std::vector &rows, const std::vector &magnitudes, const Coord &spindle, float theta_max_deg); // The same severity read off an indexed lattice instead of an FFT shortlist, for the paths that // index without a row search (a known-cell indexer on the online stills path). The candidate rows // are the lattice's shortest few distinct directions - as many as the FFT shortlist resolves in // practice, so the bound is over comparable evidence on either path - with equal magnitudes: a // lattice does not rank its rows, and all of them are equally real. std::optional SpindleBlindFractionFromLattice(const CrystalLattice &lattice, const Coord &spindle, float theta_max_deg);