rugnux: the joint merge writes its unmerged MTZ in per-sweep batches, and its report names every sweep

Unmerged MTZ of a joint merge (JointUnmergedMtzTable): its scaled fulls, each in the batch of its own
sweep's frame - sweep s of the merge is batches s * base + frame + 1, base the power of ten past the
longest sweep (1001.., 2001.. for sweeps of up to 999 frames) - under the batch headers that sweep's own
run wrote (its orientation, axis and angles), one for every frame of it (a frame the sweep's file has no
header for takes its nearest, turned by the frames between). Lattice-centring absences left out, as in
a single sweep's file. On the in-house lysozyme pair POINTLESS reads two runs of 900 batches and P41212,
and AIMLESS (onlymerge) merges them at R_meas 0.180, I/sigma 10.5, CC1/2 0.999 - rugnux's own numbers.

Report section 1b. SWEEPS, on a joint merge only: SWEEP_COUNT, SWEEP_REFERENCE, SWEEP_DECAY_MODEL, and per
sweep SWEEP_k_INPUT, IMAGES, INCLUDED, SPACE_GROUP_ALONE, CELL, CC_HALF_ALONE, COMPLETENESS_ALONE,
RESOLUTION_ALONE, REINDEX_OP, REINDEX_CC, BATCH_FIRST, BATCH_LAST, SCALE, RELATIVE_B and DOSE_RANK (the
joint merge's segment results, now carried to ProcessResult); a SWEEP_LEFT_OUT warning names a sweep given
but not merged. A run of one input has no such section or keys. Documented in RUGNUX_REPORT.md and
RUGNUX_CLI.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HVeDQqN9qRD57vnShzTC37
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-10-28 22:47:54 +01:00
1 parent e839cd08c7
commit 6766884513
9 files changed
+274 -4

No files matched your search

+4 -2
View File
@@ -114,8 +114,10 @@ scaled and merged together: a per-frame scale within each sweep, the sweeps' dos
their relative B factors (not from the input order or the header times), a decay along that order or
one B per sweep, whichever predicts held-out frames better, then the space-group search, the
resolution cut and everything after it on the joint data, into `<prefix>.*`. The detector-dependent
corrections (absorption and detector surfaces) are each sweep's own; the joint merge writes no
unmerged MTZ yet. `--sweeps separate` processes each sweep alone instead, as if it were the only
corrections (absorption and detector surfaces) are each sweep's own. The unmerged MTZ holds the joint
merge's scaled observations in the batches of their own sweeps (sweep `s` of the merge: batches
`s * base + frame + 1`, `base` the power of ten past the longest sweep), under the batch headers that
sweep's own run wrote; the report names each sweep in section `1b. SWEEPS`. `--sweeps separate` processes each sweep alone instead, as if it were the only
input, into `<prefix>_s<k>_*` (k = 1, 2, ... in the order given).
What each format needs is in [What Rugnux reads](RUGNUX_FORMATS.md); serial XFEL stills are read
+26 -2
View File
@@ -120,7 +120,8 @@ The header block above section 1 records **how the result was produced**: `RUGNU
Rates, per-image costs and progress remain on stdout only.
Sections, in order: the `SUMMARY` ([below](#the-summary-and-what-the-run-decided)), then
`1. DATA SET AND GEOMETRY`, `2. CRYSTAL`, `3. MERGED DATA`, `4. DIAGNOSTICS` and, only where a model
`1. DATA SET AND GEOMETRY`, on a joint merge of several sweeps `1b. SWEEPS`
([below](#several-sweeps-merged-as-one)), `2. CRYSTAL`, `3. MERGED DATA`, `4. DIAGNOSTICS` and, only where a model
was given (`--model`, `--mr`, or a model file among the inputs), `5. MODEL VALIDATION` - which also
holds the keys of [molecular replacement](#molecular-replacement) and of the
[refinement](#refinement-refine_) where they ran. A `--mode refine` report has one section,
@@ -252,7 +253,7 @@ and a run that produced a textbook data set read identically for their first thr
`GONIO_SCALE`, `SPINDLE_CAP`, `ANISOTROPY`, `TWINNING`, `PSEUDO_TRANSLATION`,
`LATTICE_TRANSLATION`, `MODEL_HAND`, `MODEL_NOT_VALIDATED`, `MODEL_CELL_MISMATCH`, `MODEL_FREE_SET`, `MODEL_SPACE_GROUP`, `REFERENCE_MISMATCH`, `CANCELLED`, `RESOLUTION_FIT`,
`FLIGHT_PATH`, `GEOMETRY_NOT_CONVERGED`, `SCALING_NOT_CONVERGED`, `HARMONIC_CONTAMINATION`,
`SUPERCELL_POSSIBLE`, `MULTIPLE_LATTICES`, `LATTICE_EXPLAINS_FEW_SPOTS`, `ICE_RINGS`, `POWDER_RINGS`, `BEAM_CENTRE_OFF`, `OVERLOADS`, `OVERLAPS`. `NONE` when nothing fired. A code appears if and only if its
`SUPERCELL_POSSIBLE`, `MULTIPLE_LATTICES`, `LATTICE_EXPLAINS_FEW_SPOTS`, `ICE_RINGS`, `POWDER_RINGS`, `BEAM_CENTRE_OFF`, `OVERLOADS`, `OVERLAPS`, `SWEEP_LEFT_OUT`. `NONE` when nothing fired. A code appears if and only if its
warning fired, so the flags and the `WARNING:` lines are two renderings of one list. Thresholds are
deliberately low: a warning is a prompt to check something, not a verdict, and some of them fire on
data that turn out fine — the closed
@@ -286,6 +287,29 @@ and a run that produced a textbook data set read identically for their first thr
Then the numbered sections: **1. DATA SET AND GEOMETRY**, **2. CRYSTAL**,
**3. MERGED DATA**, **4. DIAGNOSTICS**, and **5. MODEL VALIDATION** only where a model was given.
## Several sweeps merged as one
A run of several sweeps merged as one (`--sweeps joint`, the default with several rotation inputs, see
[Command-line options](RUGNUX_CLI.md)) describes the joint merge everywhere, and adds section
**1b. SWEEPS**; a run of one input has no such section and no such keys.
- **`SWEEP_COUNT=`** the sweeps given, **`SWEEP_REFERENCE=`** the one (1-based) the others were put on
the axes of - the one with the most reflections - and **`SWEEP_DECAY_MODEL=`** how the decay over the
sweeps was modelled: `dose-linear` (a B rising along the dose order), `per-sweep B` (one B a sweep) or
`none`, whichever predicted held-out frames better.
- Per sweep `k`: **`SWEEP_k_INPUT=`**, **`SWEEP_k_IMAGES=`**, **`SWEEP_k_INCLUDED=`**; what the sweep
gave processed on its own - **`SWEEP_k_SPACE_GROUP_ALONE=`**, **`SWEEP_k_CELL=`**,
**`SWEEP_k_CC_HALF_ALONE=`**, **`SWEEP_k_COMPLETENESS_ALONE=`**, **`SWEEP_k_RESOLUTION_ALONE=`**; and,
for a sweep in the joint merge, **`SWEEP_k_REINDEX_OP=`** (onto the reference's axes) with
**`SWEEP_k_REINDEX_CC=`** (the merged-intensity correlation under it), **`SWEEP_k_BATCH_FIRST=`** /
**`SWEEP_k_BATCH_LAST=`** (its batches in the unmerged MTZ: sweep `s` of the merge is
`s * base + frame + 1`, `base` the power of ten past the longest sweep), **`SWEEP_k_SCALE=`** (the
median scale of its frames in the joint merge), **`SWEEP_k_RELATIVE_B=`** (its B against the joint
merge, the least damaged sweep at 0) and **`SWEEP_k_DOSE_RANK=`** (its place, from 1, in the dose
order those B factors decide - not the order the sweeps were given in, nor their header times).
- A sweep given but not merged - it gave no merge, or its intensities correlate with the reference
sweep's on no axes - is named by a `SWEEP_LEFT_OUT` warning.
## The reference-range table
`--report-resolution <dmin>[,<dmax>]` adds a second block of merging statistics to section 3, the
+1
View File
@@ -27,6 +27,7 @@ namespace PathologyCode {
constexpr const char *SYMMETRY_AMBIGUITY = "SYMMETRY_AMBIGUITY"; // the data do not pick a point group
constexpr const char *CENTERING_UNTESTED = "CENTERING_UNTESTED"; // centring from the metric only
constexpr const char *UNUSABLE_MERGE = "UNUSABLE_MERGE"; // the merged data carry no signal
constexpr const char *SWEEP_LEFT_OUT = "SWEEP_LEFT_OUT"; // a sweep given is not in the joint merge
constexpr const char *LOW_COMPLETENESS = "LOW_COMPLETENESS"; // too little of reciprocal space
constexpr const char *SWEEP_GAPS = "SWEEP_GAPS"; // ranges that delivered much less
constexpr const char *GONIO_SCALE = "GONIO_SCALE"; // the stage rotation is mis-scaled
+58
View File
@@ -941,6 +941,64 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
" the post-refined geometry. Every number in this report describes the pass named above,\n"
" whose files are {}.*; the header-geometry pass is not written.", output_prefix), true));
doc.sections.push_back(std::move(s));
}
// ---------------------------------------------- 1b. SWEEPS (a joint merge of several)
if (!result.joint_sweeps.empty()) {
ReportSection s;
s.title = "1b. SWEEPS";
Add(s, KeyInt("SWEEP_COUNT", static_cast<int64_t>(result.joint_sweeps.size())));
Add(s, KeyInt("SWEEP_REFERENCE", result.joint_reference_sweep + 1));
if (!result.segment_decay_model.empty())
Add(s, KeyText("SWEEP_DECAY_MODEL", result.segment_decay_model));
for (size_t k = 0; k < result.joint_sweeps.size(); ++k) {
const auto &w = result.joint_sweeps[k];
const std::string p = "SWEEP_" + std::to_string(k + 1) + "_";
const auto key = [&p](const char *name) { return p + name; };
std::vector<std::string> names; // the keys outlive the calls that take their c_str()
auto text = [&](const char *name, const std::string &v) {
names.push_back(key(name));
Add(s, KeyText(names.back().c_str(), v));
};
auto real = [&](const char *name, double v, const char *fmt_) {
names.push_back(key(name));
Add(s, KeyReal(names.back().c_str(), v, fmt_));
};
auto integer = [&](const char *name, int64_t v) {
names.push_back(key(name));
Add(s, KeyInt(names.back().c_str(), v));
};
text("INPUT", w.input);
integer("IMAGES", w.images);
text("INCLUDED", w.included ? "TRUE" : "FALSE");
if (!w.space_group_alone.empty())
text("SPACE_GROUP_ALONE", w.space_group_alone);
if (w.cell_alone)
text("CELL", CellString(*w.cell_alone));
if (std::isfinite(w.cc_half_alone))
real("CC_HALF_ALONE", w.cc_half_alone, "{:.4f}");
if (std::isfinite(w.completeness_alone))
real("COMPLETENESS_ALONE", w.completeness_alone, "{:.1f}");
if (std::isfinite(w.d_min_alone))
real("RESOLUTION_ALONE", w.d_min_alone, "{:.2f}");
if (w.included) {
text("REINDEX_OP", w.reindex_op);
real("REINDEX_CC", w.reindex_cc, "{:.4f}");
integer("BATCH_FIRST", w.batch_first);
integer("BATCH_LAST", w.batch_last);
if (w.segment >= 0 && w.segment < static_cast<int>(result.segment_results.size())) {
const auto &seg = result.segment_results[w.segment];
if (std::isfinite(seg.scale))
real("SCALE", seg.scale, "{:.4f}");
if (std::isfinite(seg.relative_b))
real("RELATIVE_B", seg.relative_b, "{:.2f}");
if (seg.dose_rank >= 0)
integer("DOSE_RANK", seg.dose_rank + 1);
}
}
}
doc.sections.push_back(std::move(s));
}
// ------------------------------------------------- XDS GEOMETRY KEYWORDS
+20
View File
@@ -913,6 +913,26 @@ struct ProcessResult {
// spindle, an indexing ambiguity). The same messages the log warns about, each carrying a closed
// PathologyCode for machinery beside the open sentence for a person.
std::vector<ReportWarning> warnings;
// A joint merge of several sweeps (Rugnux::RunJoint, --sweeps joint): what its merge measured per sweep
// segment (RotationScaleMerge::Result::segments, in segment order) and how it modelled the decay, and
// what the run knows of each sweep (filled by the caller, in input order). Empty on a run of one input.
struct JointSegment { double scale = NAN, relative_b = NAN; int dose_rank = -1; };
std::vector<JointSegment> segment_results;
std::string segment_decay_model;
struct JointSweep {
std::string input;
int64_t images = 0;
bool included = false;
int segment = -1; // its segment of the joint merge, where it is in it
std::string space_group_alone;
std::optional<UnitCell> cell_alone;
std::string reindex_op; // onto the reference sweep's axes
double reindex_cc = NAN;
int64_t batch_first = 0, batch_last = -1;
double cc_half_alone = NAN, completeness_alone = NAN, d_min_alone = NAN;
};
std::vector<JointSweep> joint_sweeps;
int joint_reference_sweep = -1;
// How the crystal sat on the spindle: the angle to the nearest symmetry axis and that axis's
// order. A property of the mounting rather than of the data, and the one number that says whether
+93
View File
@@ -5,9 +5,12 @@
#include <algorithm>
#include <cmath>
#include <map>
#include <numeric>
#include "../common/CrystalLattice.h"
#include "../image_analysis/scale_merge/ReindexAmbiguity.h"
#include "../gemmi_gph/gemmi/mtz.hpp"
JointSweepRelation RelateJointSweeps(const std::vector<JointMemberSweep> &sweeps) {
JointSweepRelation out;
@@ -87,3 +90,93 @@ JointMergeInput BuildJointMergeInput(const std::vector<JointMemberSweep> &sweeps
}
return out;
}
int64_t JointBatchBase(const JointMergeInput &input) {
int64_t longest = 0;
for (const auto &seg : input.segments)
longest = std::max<int64_t>(longest, seg.n_frames);
int64_t base = 1;
while (base <= longest)
base *= 10;
return base;
}
MtzTable JointUnmergedMtzTable(const std::vector<ScaledFull> &fulls, const UnitCell &cell,
const gemmi::SpaceGroup &space_group, const std::vector<JointMemberSweep> &sweeps,
const JointMergeInput &input, float wavelength_A,
const std::vector<std::string> &history) {
MtzTable mtz;
mtz.space_group_name = space_group.xhm();
mtz.unit_cell = cell;
mtz.title = "Rugnux unmerged observations, scaled, joint merge of several sweeps";
mtz.history = history;
mtz.datasets = {{"HKL_base", "HKL_base", "HKL_base", 0.0f}, {"unmerged", "", "unmerged", wavelength_A}};
for (const auto &[label, type] : std::vector<std::pair<const char *, char>>{
{"H", 'H'}, {"K", 'H'}, {"L", 'H'}, {"M/ISYM", 'Y'}, {"BATCH", 'B'}, {"I", 'J'}, {"SIGI", 'Q'},
{"FRACTIONCALC", 'R'}, {"ROT", 'R'}})
mtz.columns.push_back({label, type, 0, {}});
const int64_t base = JointBatchBase(input);
// Per segment, a header for every frame: the sweep's own where its file has one, else its nearest one
// turned by the frames between them (a sweep's file holds the frames its observations span, and a
// batch with no header stops a reading program).
std::vector<std::map<int32_t, MtzTable::Batch>> own(input.segments.size());
for (size_t s = 0; s < input.segments.size(); ++s) {
const auto &batches = sweeps[input.segment_sweep[s]].batches;
if (batches.empty())
continue;
for (int32_t n = 1; n <= input.segments[s].n_frames; ++n) {
const auto nearest = std::min_element(batches.begin(), batches.end(), [n](const auto &a, const auto &b) {
return std::abs(a.number - n) < std::abs(b.number - n);
});
MtzTable::Batch copy = *nearest;
if (copy.number != n && copy.floats.size() > 47) {
const float shift = static_cast<float>(n - copy.number) * copy.floats[47];
copy.floats[36] += shift;
copy.floats[37] += shift;
}
own[s][n] = copy;
copy.number = static_cast<int32_t>((s + 1) * base + n);
mtz.batches.push_back(std::move(copy));
}
}
const gemmi::GroupOps ops = space_group.operations();
const auto centring_absent = [&](int h, int k, int l) {
for (const auto &t : ops.cen_ops)
if ((h * t[0] + k * t[1] + l * t[2]) % gemmi::Op::DEN != 0)
return true;
return false;
};
gemmi::UnmergedHklMover mover(&space_group);
std::vector<std::array<float, 9>> rows;
for (const ScaledFull &f : fulls) {
if (centring_absent(f.h, f.k, f.l))
continue;
const auto frame = static_cast<int32_t>(std::lround(f.image_number));
size_t s = 0;
while (s + 1 < input.segments.size() && frame >= input.segments[s + 1].first_frame)
++s;
const int32_t local = frame - input.segments[s].first_frame + 1; // the sweep's own batch
float rot = NAN;
if (const auto it = own[s].find(local); it != own[s].end() && it->second.floats.size() > 47)
rot = it->second.floats[36] + it->second.floats[47] / 2.0f;
std::array<int, 3> hkl{f.h, f.k, f.l};
const int isym = mover.move_to_asu(hkl);
rows.push_back({static_cast<float>(hkl[0]), static_cast<float>(hkl[1]), static_cast<float>(hkl[2]),
static_cast<float>(isym), static_cast<float>((s + 1) * base + local), f.I, f.sigma, 1.0f,
rot});
}
std::vector<size_t> order(rows.size());
std::iota(order.begin(), order.end(), 0);
std::stable_sort(order.begin(), order.end(), [&](size_t a, size_t b) {
return std::lexicographical_compare(rows[a].begin(), rows[a].begin() + 5, rows[b].begin(), rows[b].begin() + 5);
});
for (size_t c = 0; c < mtz.columns.size(); ++c) {
mtz.columns[c].values.resize(rows.size());
for (size_t i = 0; i < order.size(); ++i)
mtz.columns[c].values[i] = rows[order[i]][c];
}
mtz.sort_order = {1, 2, 3, 4, 5};
return mtz;
}
+17
View File
@@ -6,6 +6,7 @@
#include <string>
#include <vector>
#include "../common/MtzTable.h"
#include "../common/Reflection.h"
#include "../common/ScalingSettings.h"
#include "../common/UnitCell.h"
@@ -27,6 +28,8 @@ struct JointMemberSweep {
gemmi::SpaceGroup space_group; // the group its run determined
std::vector<MergedReflection> merged;
std::vector<ScaledFull> fulls; // all its fulls, unmerged, on the same axes
std::vector<MtzTable::Batch> batches; // the batch headers of its own unmerged MTZ
double cc_half = NAN, completeness = NAN, d_min = NAN; // its merge on its own
};
// How each sweep is put on the axes of the reference - the sweep with the most fulls: the change of basis
@@ -56,3 +59,17 @@ struct JointMergeInput {
};
JointMergeInput BuildJointMergeInput(const std::vector<JointMemberSweep> &sweeps,
const JointSweepRelation &relation);
// The batch numbers of the joint merge: segment s (from 0) is batches (s + 1) * base + frame + 1, base the
// power of ten past the longest sweep - 1001.. and 2001.. for sweeps of up to 999 frames - so a sweep's
// batches read as its own frames and never run into the next sweep's.
int64_t JointBatchBase(const JointMergeInput &input);
// The joint merge's unmerged MTZ: its scaled fulls (MergedOutput::scaled_fulls, on the merge's axes and
// frames), each in the batch of its sweep's frame, under the batch headers that sweep's own run wrote -
// its orientation, axis and angles - renumbered (JointBatchBase). Lattice-centring absences are left out,
// as in a single sweep's file.
MtzTable JointUnmergedMtzTable(const std::vector<ScaledFull> &fulls, const UnitCell &cell,
const gemmi::SpaceGroup &space_group, const std::vector<JointMemberSweep> &sweeps,
const JointMergeInput &input, float wavelength_A,
const std::vector<std::string> &history);
+4
View File
@@ -1671,6 +1671,10 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
result.scaling_iterations_partials = r.scaling_iterations_partials;
result.scaling_iterations_fulls = r.scaling_iterations_fulls;
result.scaling_converged = r.scaling_converged;
result.segment_results.clear();
for (const auto &seg : r.segments)
result.segment_results.push_back({seg.scale, seg.relative_b, seg.dose_rank});
result.segment_decay_model = r.segment_decay_model;
if (for_search)
result.search_scaling_converged = r.scaling_converged;
Note(fmt::format("merge in {}: {} unique", label, r.merged.size()));
+51
View File
@@ -1018,6 +1018,9 @@ struct RugnuxInputRun : RugnuxCli {
char centring = 'P';
};
std::optional<JointRequest> joint_request;
// What the joint merge adds to its result before anything is written (the sweeps' report keys, the
// unmerged MTZ of its batches).
std::function<void(ProcessResult &)> joint_finish;
// Reads the input and sets up the experiment and the run's configuration: RUGNUX_CONTINUE, or the
// exit code of a mode that ends here (refine, azint, calibration, scale).
@@ -3672,6 +3675,8 @@ int RugnuxInputRun::RunPreparedInput() {
throw RugnuxInputFailed{EXIT_FAILURE};
}
g_active_process = nullptr;
if (joint_finish)
joint_finish(result);
if (sweep_member) {
member_result = std::move(result);
return EXIT_SUCCESS;
@@ -4011,6 +4016,13 @@ int RugnuxCli::RunJointSweeps(const RugnuxSweepPlan &plan) {
m.space_group = merged.experiment.GetSpaceGroupOrP1();
m.merged = merged.reflections;
m.fulls = merged.scaled_fulls;
if (sweep.member_result->outputs->unmerged)
m.batches = sweep.member_result->outputs->unmerged->batches;
m.cc_half = merged.statistics.overall.cc_half;
m.completeness = merged.statistics.overall.possible_unique_reflections > 0
? 100.0 * merged.statistics.overall.unique_reflections
/ merged.statistics.overall.possible_unique_reflections : NAN;
m.d_min = merged.statistics.overall.d_min;
logger.Info("Sweep {}: {} frames, {} in {:.3f} {:.3f} {:.3f} {:.2f} {:.2f} {:.2f}, {} fulls, {} merged",
k + 1, m.n_frames, m.space_group.xhm(), m.cell.a, m.cell.b, m.cell.c, m.cell.alpha,
m.cell.beta, m.cell.gamma, m.fulls.size(), m.merged.size());
@@ -4063,6 +4075,45 @@ int RugnuxCli::RunJointSweeps(const RugnuxSweepPlan &plan) {
ref.space_group.crystal_system(),
ref.space_group.centring_type()};
logger.Info("Joint merge of {} sweeps, {} frames", input.segments.size(), joint.joint_request->outcomes.size());
joint.joint_finish = [&](ProcessResult &result) {
const int64_t base = JointBatchBase(input);
result.joint_reference_sweep = relation.reference;
for (size_t k = 0; k < members.size(); ++k) {
ProcessResult::JointSweep w;
w.input = inputs[k];
w.images = members[k].n_frames;
w.included = relation.sweeps[k].included;
if (!members[k].merged.empty()) {
w.space_group_alone = members[k].space_group.xhm();
w.cell_alone = members[k].cell;
w.cc_half_alone = members[k].cc_half;
w.completeness_alone = members[k].completeness;
w.d_min_alone = members[k].d_min;
}
for (size_t s = 0; s < input.segment_sweep.size(); ++s)
if (input.segment_sweep[s] == static_cast<int>(k)) {
w.segment = static_cast<int>(s);
w.batch_first = (s + 1) * base + 1;
w.batch_last = (s + 1) * base + input.segments[s].n_frames;
}
w.reindex_op = relation.sweeps[k].cob.as_hkl().triplet('h');
w.reindex_cc = relation.sweeps[k].cc;
if (!w.included)
result.warnings.push_back({PathologyCode::SWEEP_LEFT_OUT, fmt::format(
"Sweep {} ({}) is not in the joint merge: {}", k + 1, inputs[k],
relation.sweeps[k].why_left_out.empty() ? std::string("it gave no merge")
: relation.sweeps[k].why_left_out)});
result.joint_sweeps.push_back(std::move(w));
}
if (result.outputs && result.outputs->merged && export_unmerged) {
const MergedOutput &merged = *result.outputs->merged;
std::vector<std::string> history{"From Rugnux " + jfjoch_version() + ", data reduction",
fmt::format("Joint merge of {} sweeps", input.segments.size())};
result.outputs->unmerged = JointUnmergedMtzTable(merged.scaled_fulls, merged.cell,
merged.experiment.GetSpaceGroupOrP1(), members, input,
merged.experiment.GetWavelength_A(), history);
}
};
rc = joint.RunPreparedInput();
} catch (const RugnuxInputFailed &f) {
rc = f.code;