docs: the spindle severity documented as the worst-case trigger it is
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m28s
Build Packages / build:windows:nocuda (push) Failing after 12m50s
Build Packages / build:windows:cuda (push) Failing after 13m18s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 19m20s
Build Packages / build:viewer-tgz:cpu (push) Successful in 21m51s
Build Packages / build:rugnux:windows (push) Failing after 9m1s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 21m59s
Build Packages / build:viewer-tgz:cuda (push) Successful in 23m20s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 27m8s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m2s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 20m4s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 24m28s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 27m15s
Build Packages / build:rpm (rocky9) (push) Successful in 24m13s
Build Packages / build:rpm (rocky8) (push) Successful in 29m0s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 23m21s
Build Packages / Generate python client (push) Successful in 53s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m18s
Build Packages / DIALS test (push) Successful in 26m44s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 28m11s
Build Packages / XDS test (durin plugin) (push) Successful in 10m6s
Build Packages / XDS test (neggia plugin) (push) Successful in 9m8s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 10m15s
Build Packages / Unit tests (push) Failing after 1h26m58s

Section 5.5 of the indexing documentation describes the per-image score as shipped: the folded
formula, the geometric cone width, the pair-normal recovery of an axis too long to see, and the
three trigger states with the rule that CANNOT SAY must engage. It states plainly that the score
is a worst-case bound under an assumption of no symmetry rather than an estimate - an axis of
order three or higher perpendicular to the spindle in fact repairs the cone, which a still
cannot know - and gives the engagement rates actually measured instead of the decoy-null figure,
which showed only that the estimator does not hallucinate rows and was never a false-alarm rate
over harmless mountings. The run-level orbit-based fraction is pointed to as the exact measure
available once the group is known.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-04 10:59:05 +02:00
co-authored by Claude Opus 5
parent 307987c865
commit 2adf5d2e28
2 changed files with 82 additions and 0 deletions
+2
View File
@@ -16,6 +16,8 @@
* `rugnux` writes reflection files in the conventions downstream programs read: `FreeR_flag` is 0 for the test set and 1 for the working set - it was the other way round - the merged and P1 MTZ carry the reserved `HKL_base` dataset so a CCP4 program reads the wavelength instead of falling back to 1.54187 A, and the merged mmCIF marks the free set as `_refln.status` = `f`.
* The rugnux results report carries the space groups the data cannot separate and the enantiomorph state, the model's verdict and what it was allowed to decide, the detector geometry measured and what a single sweep cannot determine, the resolution the CC1/2 fit reached, which reciprocal axis each anisotropic diffraction limit belongs to, and twinning measured before and after the space group was decided; `REPORT_VERSION` is 7, and `SPACE_GROUP_ENANTIOMORPH= DETERMINED_FROM_MODEL` is now `ASSUMED_FROM_MODEL`.
* The rugnux results report records how the crystal sat on the goniometer as `SPINDLE_SYMMETRY_AXIS_ANGLE_DEG=` and `SPINDLE_SYMMETRY_AXIS_ORDER=` - the angle from the spindle to the nearest symmetry axis and that axis's order - on every rotation run that determined a space group, not only when the angle was small enough to warn about.
* The rugnux results report writes `SPINDLE_LOST_UNIQUE_FRACTION=` - the fraction (0-1) of unique reflections the mounting made unmeasurable under the measured point group - with the same number in the HDF5 master as `/entry/MX/spindleLostUniqueFraction`, and the spindle-mounting warning fires on that exact number instead of on the angle to the nearest symmetry axis.
* Stills and grid scans carry a per-image `spindle_blind_fraction` - how much of a rotation sweep's blind cone the crystal's orientation would make unrecoverable, 0.5 and above calling for a second orientation - through the CBOR stream, the HDF5 files as `/entry/MX/spindleBlindFraction`, the REST plot and scan-result APIs, and the viewer and frontend plots; an absent value means the frame could not be assessed and is not a 0.
* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, holding the geometry as a `jfjoch_broker` `dataset_settings` body, and refuses a fit that is not a measurement - no `.poni`, a non-zero exit, `converged` recorded in the `.json`; `--no-refine-tilt` holds the detector tilt at the file's value instead of zeroing it.
* A snake grid scan with a negative slow step and an even number of rows no longer has its positions mirrored along the fast axis in the HDF5 master and the grid map, so the positions recorded for that configuration change; `jfjoch_viewer` draws grid scan cells in the proportion of the scan steps, labels the merge-statistics plot over the range the axis is drawn on, and builds its powder-calibration ring list from the loaded dataset's space group as well as its cell, so a centred sample cell no longer scales the whole fit.
* The HDF5 master records `direct_beam_x`/`direct_beam_y` - where the undeflected beam lands, sent on the CBOR start message too - the beam size at the sample as `incident_beam_size` from the new `dataset_settings` `beam_size_x_um`/`beam_size_y_um`, and `/entry/MX/peakCountUnfiltered`; `dataset_settings` accepts any `smargon.chi_deg`, which was restricted to 0-90 degrees.
+80
View File
@@ -105,6 +105,86 @@ Selection is **not limited to a single lattice**: after the best cell is accepte
An optional reference unit cell (if supplied) restricts acceptance to cells within a relative distance tolerance in edge lengths (permutation-invariant).
### 5.5 Spindle alignment: the part of the blind cone symmetry cannot repair
A sweep about the spindle $\hat{\mathbf{e}}$ never brings a reciprocal point closer than
$\theta_\mathrm{max}=\arcsin(\lambda/2d)$ to the axis onto the Ewald sphere, so a double cone of
half-angle $\theta_\mathrm{max}$ is missing from every resolution shell — each shell losing its own
$1-\cos\theta(d)$ — however long the sweep runs. Crystal symmetry normally repairs that loss by
mapping the cone onto measured territory. It fails to when a symmetry axis lies inside the cone (the
cone maps onto itself) — and, for a **2-fold**, equally when the axis is perpendicular to the
spindle, because the diad carries the cone onto its opposite lobe, which the sweep leaves just as
unmeasured. Friedel never helps: the cone is double-sided. The loss is a coherent cap rather than a
scatter of absences, so it costs a map more than the same percentage lost at random.
The per-image score asks how much of that cone the frame's own orientation makes unrecoverable.
The crystal's short lattice rows are read off the FFT row shortlist of §5.2 (a symmetry axis is
always a lattice row, and usually among the short ones), and each plausible direction is scored as
if it carried a lone 2-fold:
$$ \text{spindle blind fraction} = \frac{2}{\pi}\left(\arccos x - x\sqrt{1-x^{2}}\right),
\qquad x = \min(\beta,\,90^\circ-\beta)\,/\,\theta_\mathrm{max}, $$
where $\beta$ is the direction's miss-angle from the spindle. The **fold** of $\beta$ about
$45^\circ$ is the diad geometry above: both ends of the range are the bad case, and the closed form
reproduces a Monte Carlo of the true double-cone self-overlap to 0.004 at
$\theta_\mathrm{max}=15^\circ$ and 0.008 at $25^\circ$ (past $45^\circ$ it under-reports, by 0.05
at $50^\circ$). $\theta_\mathrm{max}$ is taken from the **geometric** resolution of the setup — the
detector corner at the recorded distance and wavelength — an upper bound on any sweep collected
without moving the detector; the still's own spot resolution would understate the cone on exactly the
weak frames that mislead. The worst direction wins, and the directions scored are the strong
in-window rows **and the normals of their pairs** — the normal to two lattice rows is itself a
reciprocal-lattice row and a symmetry axis is parallel in both bases, so a lone 2-fold on an axis far
beyond the length window (a long monoclinic unique axis) is still seen by direction: measured on a
synthetic lone-diad crystal with a 300 Å unique axis, the fraction of severe mounts reported severe
rises from 0.60 to 1.00 with the pair normals, at no extra engagement on that class's harmless
mounts. Nothing about the goniometer enters: the number describes the problem and leaves the remedy —
a second sweep, a reorientation — to the beamline.
**This is a worst-case bound under an assumption of no symmetry, not an estimate.** A still cannot
know the point group, so the nearest plausible row is scored as a lone 2-fold. An axis of order
$\geq 3$ perpendicular to the spindle in fact **fully repairs** the cone (measured unrepaired
fraction 0.000 for orders 3, 4 and 6, against 1.000 for a diad), which a still cannot see, so the
bound is deliberately pessimistic on higher-symmetry crystals — that is the intended trade, because
the number exists as a **trigger** for beamline automation, not as a physical quantity a user
interprets.
**Trigger states.** The stored quantity is the continuous score; automation reads it through three
fixed states with nothing to tune (`SpindleTrigger` in `SpindleBlindFraction.h`): **engage** at
score ≥ 0.5, **don't engage** below, and **cannot say** when there is no value at all — too few
spots, no shortlist, the consistency guard refused, the path never computed one. **Automation must
treat CANNOT SAY as ENGAGE**: the error costs are asymmetric — a false negative is unrecoverable
(one sweep is collected and the data stay short forever) while a false positive costs minutes of
beamtime. Every transport keeps absence distinguishable from a measured zero (an absent CBOR key, a
NaN in the HDF5 arrays, an absent optional after read-back). The 0.5 threshold is geometry, not
tuning: the score is monotone in the folded miss-angle, so a threshold is a fold-angle gate, and 0.5
gates at $\min(\beta,90^\circ-\beta) \le 0.404\,\theta_\mathrm{max}$; engaging on any overlap at
all would gate at the cone edge, whose perpendicular band alone spans $\sin\theta_\mathrm{max}$ of
orientation space per row (26 % at $15^\circ$) and unions over a frame's rows to well over half of
all mountings — a trigger that always fires decides nothing.
**Reach and honest rates.** The score needs 60 spots (calibrated per crystal — 22 independent
mounts — misses triple below it); below that, a frame that still indexed answers from the winning
lattice's shortest rows, and otherwise the state is *cannot say*. Because the bound is pessimistic
by design, it engages on a substantial share of harmless mountings: a single strong row's
perpendicular band alone covers ~11 % of orientation space at the severe level
($\theta_\mathrm{max}=15^\circ$), and the union over a frame's rows and pair normals reaches
roughly a quarter to three quarters of random mountings depending on cone width and row count
(measured 0.74 on a generic triclinic cell at $15^\circ$ via the lattice path). That is accepted:
the cheap error is the extra wedge. An earlier figure of ~1 % false alarms (AUC 0.948) came from a
null of five *decoy directions per frame* — it shows the estimator does not hallucinate rows near
arbitrary directions, which is worth knowing, but it is **not** a false-alarm rate over harmless
mountings, which geometry forbids to be that low.
**Offline, the guessing stops.** Once `rugnux` has merged a rotation run it holds the measured
point group and the exact indexed orientation, and the run-level number is computed exactly instead:
the group's proper rotations are applied to the blind double cone in the crystal's actual
orientation, and the fraction of unique reflections no operator can recover is reported as
`SPINDLE_LOST_UNIQUE_FRACTION` in the processing report and `/entry/MX/spindleLostUniqueFraction` in
the master file (§ docs/RUGNUX_REPORT.md). That number clears or convicts a mounting the per-image
bound can only be pessimistic about: a dihedral crystal with an in-plane diad on the spindle, or any
cubic crystal in any orientation, loses nothing at all.
---
## 6. Bravais lattice / centering inference (“lattice search”)