rugnux: fix de-novo rotation indexing adopting a spurious axis-multiple supercell

Two-pass rotation indexing scored each first-pass scheme by how many
validation frames it indexes, but a spurious axis multiple (2x/3x...)
indexes every frame its true sub-cell does, so the count saturates - both
schemes reach the same frame total - and the tie fell to whichever scheme
ran first. When that first scheme's full-rotation FFT resolves a true
axis only as a 2x/3x harmonic of its length (the fundamental can sit far
below the harmonic in the full-360 cloud), it commits a multiplied cell
and the dataset collapses to P1, even though the other scheme already
found the true cell.

Add an integer-supercell cross-scheme tie-break: when two schemes tie on
frames but their cell volumes differ by a near-integer factor >=2, the
larger is the spurious supercell and the smaller true cell is adopted,
independent of scheme order. The near-integer test distinguishes a real
axis multiplication from a centering coincidence (a rhombohedral H cell
vs its C2 sub-cell is 1.5x and is left alone).

Validated de-novo across the rotation regression battery: the affected
dataset now indexes its correct space group, the override triggers only
where an axis was multiplied, and every other dataset is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 16:51:57 +02:00
co-authored by Claude Opus 4.8
parent bd094e585f
commit 0f3ccda779
+31 -3
View File
@@ -466,6 +466,7 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) {
f.get();
int best_score = -1;
double best_vol = 0.0;
std::string best_name;
std::optional<RotationIndexerResult> best_result;
for (size_t i = 0; i < ris.size(); i++) {
@@ -475,13 +476,40 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) {
if (!result.has_value())
continue;
const int score = count_indexed(*result);
const double vol = std::abs(result->lattice.CalcVolume());
const std::string &name = schemes[i].first;
logger.Info("First-pass scheme '{}': indexes {}/{} validation frames", name, score,
static_cast<int>(validation.size()));
// The first scheme sets the baseline; a later one wins only if it indexes clearly more
// frames (>10%), so a scheme that merely ties the default never displaces it.
if (!best_result.has_value() || static_cast<float>(score) > best_score * 1.1f + 0.5f) {
// A later scheme wins if it indexes clearly more frames (>10%).
const bool clearly_more = static_cast<float>(score) > best_score * 1.1f + 0.5f;
// Integer-supercell tie-break. The validation-frame count saturates - a spurious axis
// multiple (2x/3x...) indexes every frame its true cell does, so both schemes reach the same
// frame total and the count alone cannot tell them apart; the default then keeps whichever
// ran first. When the two schemes tie on frames but their cell volumes are related by an
// integer factor >=2, the larger cell is that spurious supercell (one FFT resolved a true
// axis only as a 2x/3x harmonic of its length - the full-rotation 'spread' cloud is prone to
// this) and the smaller is the true reduced cell: take it, regardless of scheme order.
// Requiring a near-integer ratio is what separates a real axis multiplication from a mere
// centering coincidence: a rhombohedral H cell vs its C2 sub-cell is 1.5x (non-integer) and
// is correctly left alone.
bool integer_subcell = false;
if (best_result.has_value() && !clearly_more && vol > 1.0 && best_vol > 1.0) {
const bool tied = static_cast<float>(score) >= best_score * 0.9f - 0.5f;
const double ratio = (vol < best_vol) ? best_vol / vol : vol / best_vol;
const double nearest = std::round(ratio);
const bool integer_multiple = nearest >= 2.0 && std::abs(ratio - nearest) < 0.15;
integer_subcell = tied && integer_multiple && vol < best_vol;
}
if (!best_result.has_value() || clearly_more || integer_subcell) {
if (integer_subcell)
logger.Info("Scheme '{}' cell (vol {:.0f}) is a {:.0f}x sub-cell of '{}' (vol {:.0f}) at "
"equal frame count - adopting the smaller true cell", name, vol,
std::round(best_vol / vol), best_name, best_vol);
best_score = score;
best_vol = vol;
best_name = name;
best_result = result;
}