rugnux: report how close a symmetry axis lies to the spindle
Build Packages / build:viewer-tgz:cpu (push) Successful in 7m11s
Build Packages / build:viewer-tgz:cuda (push) Successful in 8m48s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 13m57s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 14m20s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 14m22s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 14m31s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 14m36s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 12m59s
Build Packages / build:rpm (rocky8) (push) Successful in 11m47s
Build Packages / XDS test (durin plugin) (push) Successful in 7m42s
Build Packages / Generate python client (push) Successful in 27s
Build Packages / Build documentation (push) Successful in 1m8s
Build Packages / Create release (push) Skipped
Build Packages / build:rpm (ubuntu2404) (push) Successful in 13m6s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 13m18s
Build Packages / build:rpm (rocky9) (push) Successful in 13m58s
Build Packages / DIALS test (push) Successful in 14m9s
Build Packages / XDS test (neggia plugin) (push) Successful in 8m11s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 9m6s
Build Packages / Unit tests (push) Successful in 1h2m16s
Build Packages / build:windows:nocuda (push) Canceled after 0s
Build Packages / build:windows:cuda (push) Canceled after 0s

A rotation sweep never records the reflections whose reciprocal vector lies
within the Bragg angle of the spindle - the blind cusp. Symmetry normally
supplies them from an equivalent elsewhere in reciprocal space, so the hole
closes. It cannot when a symmetry axis IS the spindle: the cusp is then mapped
onto itself, every reflection in it is equivalent only to other reflections in
it, and it stays empty however long the sweep runs. The user can fix this at
the microscope - re-mount, or add a sweep on another axis - but only if they
are told, and nothing in the output mentioned it.

Report the smallest angle between any proper rotation axis of the adopted
space group and the goniometer axis, always on rotation data, and warn when it
falls under 15 deg. The axis is found by projecting onto each operator's
invariant direction (the sum of its powers annihilates everything else) and
mapping that fractional direction through the refined lattice into the lab
frame; the angle is invariant under the sweep, so the reference orientation is
enough. Cross-check: this reports 30.6 deg for a crystal whose 4-fold an
independent analysis of the XDS orientation matrix put at 30.5 deg.

Measured on three rotation sets: 13.6 deg (2-fold, warns), 16.2 deg (2-fold,
99.7% complete) and 30.6 deg (4-fold). The 15 deg bound is practical rather
than derived - the blind cone's half-angle is the maximum Bragg angle, ~15 deg
for 2 A data at 1 A wavelength - and the wording says what the diagnostic can
honestly support: the angle is a risk indicator, the loss is confined to the
cone rather than spread over the data, and overall completeness may still look
reasonable while the region near the spindle is empty. It does not promise a
completeness number, because across those three sets the overall figure does
not track the angle (99.7% at 16.2 deg, 92.6% at 30.6 deg).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:59:47 +02:00
co-authored by Claude Opus 5
parent b81c6f00b7
commit eb70684fa9
+87
View File
@@ -291,6 +291,60 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) {
return RunPipeline(observer, /*write_output=*/true, /*geometry_prepass=*/false);
}
namespace {
// Angle between the crystal's symmetry axes and the spindle. A rotation sweep never records the
// reflections whose reciprocal vector lies close to the spindle - the blind cusp - and normally
// symmetry fills it in from an equivalent elsewhere in reciprocal space. It cannot when a symmetry
// axis IS the spindle: the cusp then maps onto itself, every reflection in it is equivalent only to
// other reflections in it, and the hole stays empty however long the sweep runs. Returns the
// smallest angle in degrees between any proper rotation axis of the space group and the goniometer
// axis, with the order of the axis that achieves it.
std::optional<std::pair<double, int>> ClosestSymmetryAxisToSpindle(const gemmi::SpaceGroup &sg,
const CrystalLattice &lattice,
const Coord &spindle) {
const double spindle_len = spindle.Length();
if (spindle_len < 1e-9)
return std::nullopt;
std::optional<std::pair<double, int>> best;
for (const auto &op : sg.operations().derive_symmorphic().sym_ops) {
if (op.rot == gemmi::Op::identity().rot)
continue;
// Order of the rotation, then project onto its invariant direction by summing its powers:
// (1/n) sum_k W^k annihilates everything except the axis.
gemmi::Op acc = gemmi::Op::identity(), w{op.rot, {0, 0, 0}, op.notation};
int order = 0;
gemmi::Op cur = gemmi::Op::identity();
std::array<std::array<double, 3>, 3> sum{};
for (int k = 0; k < 6; ++k) {
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j)
sum[i][j] += static_cast<double>(cur.rot[i][j]) / gemmi::Op::DEN;
cur = cur.combine(w).wrap();
++order;
if (cur.rot == gemmi::Op::identity().rot)
break;
}
if (order < 2)
continue;
// Any non-degenerate column of the projector is the axis in fractional direct coordinates.
for (int c = 0; c < 3; ++c) {
const Coord axis = lattice.Vec0() * static_cast<float>(sum[0][c])
+ lattice.Vec1() * static_cast<float>(sum[1][c])
+ lattice.Vec2() * static_cast<float>(sum[2][c]);
const double len = axis.Length();
if (len < 1e-6 * lattice.Vec0().Length())
continue;
const double cosang = std::abs((axis * spindle) / (len * spindle_len));
const double ang = std::acos(std::min(1.0, cosang)) * 180.0 / M_PI;
if (!best.has_value() || ang < best->first)
best = std::make_pair(ang, order);
break;
}
}
return best;
}
}
ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, bool geometry_prepass) {
Logger logger("Rugnux");
ProcessResult result;
@@ -1370,6 +1424,39 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
result.twinning.laue_class_was_chosen_by_promotion = promoted_point_group;
stats_text << TwinningAnalysisToText(result.twinning) << "\n";
// Symmetry axis vs spindle. Reported always on rotation data (it is a property of how the
// crystal was mounted, which the user can change), warned about when the two nearly coincide.
if (experiment_.IsRotationIndexing() && twin_sg && end_msg.rotation_lattice.has_value()) {
if (const auto gonio = experiment_.GetGoniometer()) {
const auto closest = ClosestSymmetryAxisToSpindle(*twin_sg, *end_msg.rotation_lattice,
gonio->GetAxis());
if (closest.has_value()) {
// Practical bound, not a derived one: the blind cone's half-angle is the maximum
// Bragg angle (~15 deg for 2 A data at 1 A), and measured on this battery a 13.6 deg
// case shows the loss while a 16.2 deg one is 99.7% complete.
constexpr double WARN_DEG = 15.0;
const auto [angle, order] = *closest;
if (angle < WARN_DEG) {
const std::string msg = fmt::format(
"The crystal's {}-fold axis is only {:.1f} deg from the spindle. A rotation sweep "
"never records the reflections whose reciprocal vector lies within the Bragg angle "
"of the spindle, and symmetry normally supplies them from an equivalent elsewhere; "
"it cannot here, because that axis maps the blind region onto itself. Those "
"reflections stay missing however long the sweep runs. The loss is confined to "
"that cone rather than spread over the data, so overall completeness may still "
"look reasonable - check it near the spindle direction. A second sweep on a "
"different axis, or re-mounting, recovers them.",
order, angle);
logger.Warning("{}", msg);
stats_text << " !! " << msg << "\n\n";
} else {
stats_text << "Closest symmetry axis to the spindle: " << order << "-fold at "
<< std::fixed << std::setprecision(1) << angle << " deg\n\n";
}
}
}
}
// Indexing-ambiguity (alternative-indexing) advisory. When the lattice metric symmetry exceeds
// the Laue symmetry the crystal can be validly indexed in several hands related by twin-law
// operators. For an obvious merohedral case (P3/P4/P6...) users expect this; but a PSEUDO-merohedral