rugnux: the external reference MTZ is never a scale anchor (stills + rotation)

An external reference MTZ is a poor per-image scale reference: it is a different crystal/dataset,
so scaling against it injects cross-dataset systematics rather than removing per-image scale error
(measured: reference-scaled R-free 0.410 vs self-scaled 0.388 on OCP; 0.378 vs 0.350 on LOV). Its
real value is fixing the cell/space group, breaking the merohedral indexing ambiguity, reporting
CCref and providing the R-free test set - none of which need it to be a scale anchor.

Make that consistent across both workflows:
- Stills: the per-image pass (ScaleImage) now uses the reference ONLY to break the indexing ambiguity
  (once, for good), not to fit G. Scaling self-references at the post-measurement merge for every run,
  whether or not a reference is given (Rugnux + rugnux_cli --scale).
- Rotation: ScaleImage no longer scales against the reference either (it was dead code - RotationScaleMerge
  recomputes the per-frame scale, so the reference never actually moved the result). Rotation resolves
  the ambiguity globally (ChooseReindex / ReferenceIntensityCC) and self-scales in RotationScaleMerge.
- A reference-as-scale mode, if ever wanted, would be a dedicated later step, not this per-image pass.

Validated: OCP CCref 56.5%->64.6%, R-free 0.400->0.393 (honest 2.36 A cutoff instead of a reference-
propped 1.5 A); LOV CCref 88.3%->90.1%, R-free 0.336->0.331 with more reflections; rotation lyso_ref
with the reference gives ISa 17.8 == de-novo 15.6 (same 15975 reflections, CC1/2 99.8%) - the reference
demonstrably does not touch rotation scaling, only cell/SG + ambiguity + CCref.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 13:01:56 +02:00
co-authored by Claude Opus 4.8
parent 9527fa6e2c
commit 95af137146
4 changed files with 30 additions and 27 deletions
+12 -8
View File
@@ -495,15 +495,19 @@ void IndexAndRefine::ScaleImage(DataMessage &msg, IntegrationOutcome& outcome) {
if (!scaling_engine)
return;
auto scaling_start_time = std::chrono::steady_clock::now();
scaling_engine->Scale(outcome);
auto scaling_end_time = std::chrono::steady_clock::now();
// The external reference fixes the cell/space group, breaks the indexing ambiguity and reports CCref,
// but is NEVER a scale anchor: scaling an image against a foreign dataset injects cross-dataset
// systematics and is a worse reference than the data's own merge, so scaling self-references at the
// post-measurement merge for both workflows. Rotation resolves the ambiguity globally and self-scales
// in RotationScaleMerge (ChooseReindex / ReferenceIntensityCC), so there is nothing to do per image.
// Stills resolve the merohedral ambiguity per image here (each crystal indexes in a random hand; pick
// the hand best-correlated with the reference, once and for good).
if (experiment.IsRotationIndexing())
return;
scale_cc[msg.number] = outcome.image_scale_cc.value_or(NAN);
msg.image_scale_b_factor = outcome.image_scale_b_factor_Ang2;
msg.image_scale_factor = outcome.image_scale_g;
msg.image_scale_mosaicity = outcome.mosaicity_deg;
msg.image_scale_cc = outcome.image_scale_cc;
auto scaling_start_time = std::chrono::steady_clock::now();
scaling_engine->ResolveIndexingAmbiguity(outcome.reflections);
auto scaling_end_time = std::chrono::steady_clock::now();
msg.image_scale_time_s = std::chrono::duration<float>(scaling_end_time - scaling_start_time).count();
}
+4 -2
View File
@@ -39,8 +39,6 @@ class ScaleOnTheFly {
bool Accept(const Reflection &r) const;
[[nodiscard]] std::pair<double, size_t> CalculateGlobalCC(const std::vector<Reflection> &reflections) const;
// Reindex this image's reflections into the ambiguity hand that best correlates with the reference.
void ResolveIndexingAmbiguity(std::vector<Reflection> &reflections) const;
public:
// resolve_ambiguity: when the reference is an external, correctly-handed dataset (not the data's own
// running merge), pick per image the reindexing that agrees best with it - serial stills index each
@@ -48,6 +46,10 @@ public:
ScaleOnTheFly(const DiffractionExperiment &x, const std::vector<MergedReflection> &ref,
bool resolve_ambiguity = false);
// Reindex this image's reflections into the merohedral-ambiguity hand that best correlates with the
// external reference (stills; done once per image at integration time, decoupled from scaling).
void ResolveIndexingAmbiguity(std::vector<Reflection> &reflections) const;
void Scale(IntegrationOutcome &integration_outcome) const;
void Scale(std::vector<IntegrationOutcome> &integration_outcome, size_t nthreads = 0) const;
};
+8 -6
View File
@@ -591,12 +591,14 @@ ProcessResult Rugnux::Run(RugnuxObserver *observer) {
return ScaleMergeResult{std::move(r.merged), std::move(r.statistics)};
}
// Stills (rotation goes through RotationScaleMerge above): self-scale each image against the
// running merge with ScaleOnTheFly (fixed partiality), then merge directly. With an external
// reference each image is already scaled against it during the per-image pass, so skip.
if (config_.reference_data.empty()) {
// One pass: the per-image scale G is the exact one-pass solution, so iterating only
// rebuilds the reference from the freshly-scaled (noisy) data - degrading weak stills
// instead of converging (measured CC1/2 collapse at the default 3 iters on weak data).
// running merge with ScaleOnTheFly (fixed partiality), then merge directly. This runs even
// with an external reference: the reference is used only to break the per-image indexing
// ambiguity and to report CCref / inherit the R-free set, NOT as a scale anchor (scaling each
// image against a foreign dataset injects cross-dataset systematics and is a worse reference
// than the data's own merge). One pass: the per-image scale G is the exact one-pass solution,
// so iterating would only rebuild the reference from the freshly-scaled (noisy) data and
// degrade weak stills (measured CC1/2 collapse at the default 3 iters).
{
phase("Scaling images (" + label + ")");
auto merge_result = MergeAll(experiment_, indexer->GetIntegrationOutcome(), false);
indexer->ScaleAllImages(merge_result);
+6 -11
View File
@@ -1035,17 +1035,12 @@ int main(int argc, char **argv) {
merged_statistics = std::move(r.statistics);
error_model_isa = r.isa;
} else {
// The per-image scale G is the exact one-pass solution (Scale re-solves from raw I and never
// reads the prior correction), so with a self-rebuilt reference iterating only re-fits the
// freshly-scaled noise and degrades weak stills (measured CC1/2 collapse at the default 3
// iters). Scale the self-reference merge once; a fixed external reference keeps scaling_iter.
const int n_self = reference_data.empty() ? 1 : scaling_iter;
for (int i = 0; i < n_self; i++) {
if (reference_data.empty())
ScaleOnTheFly(experiment, MergeAll(experiment, reflections)).Scale(reflections, nthreads);
else
ScaleOnTheFly(experiment, reference_data).Scale(reflections, nthreads);
}
// Scaling self-references: the reference MTZ (if any) fixes the cell/space group, reports
// CCref and provides the R-free test set, but is NOT a scale anchor - scaling each image
// against a foreign dataset injects cross-dataset systematics and is a worse reference than
// the data's own merge. The per-image scale G is the exact one-pass solution, so one pass
// (iterating a self-rebuilt reference only re-fits the freshly-scaled noise on weak stills).
ScaleOnTheFly(experiment, MergeAll(experiment, reflections)).Scale(reflections, nthreads);
MergeOnTheFly merge_engine(experiment);
merge_engine.ReferenceCell(experiment.GetUnitCell());
// Fit the (a, b) error model from symmetry-mate scatter before merging, exactly as the full