Reflection files record their provenance: commit, command line, input file

The merged .mtz and the unmerged .mtz carry, after the existing "From
Rugnux <version>" record, HISTORY records with the git commit and date,
the command line, the input file (absolute path) and a cheap identity of
it - size and modification time, the master file's for HDF5, nothing
hashed - and the collection date. MTZ allows 30 records of 80
characters; a longer value runs on over records starting with '+', cut
so that no record ends in a blank a reader would strip.

The mmCIF names the commit in _software.description and the command
line, input file and identity under _jfjoch_provenance (the local prefix
the other private items use; no PDBx item holds them), the first two as
text fields so any character survives.

OutputProvenance is filled once in the CLI and travels in ProcessConfig
to every writer of a reflection file. Reflection data are unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVmAWnzCmRKAXVUCdc4iNi
This commit is contained in:
2026-10-09 17:52:43 +02:00
co-authored by Claude Opus 5.5
parent fc6b91555f
commit 5584460de0
7 changed files with 166 additions and 17 deletions
+55 -5
View File
@@ -110,6 +110,36 @@ std::string IsoDate(const std::string &timestamp) {
return timestamp.substr(0, 10);
}
// The commit this build was made from, with its date; empty when the build does not know it.
std::string GitDescription() {
return jfjoch_git_sha1().empty() ? std::string() : jfjoch_git_sha1() + " " + jfjoch_git_date();
}
// The MTZ HISTORY records saying where a file came from, after the one naming the program: the
// commit, the command line and the input file. MTZ holds at most 30 records of 80 characters
// (mtzformat.html), so a long value runs on over as many records as it needs, up to that limit, each
// continuation starting with '+'. Readers strip the blanks at both ends of a record, so a record is
// never cut just after a space: the space starts the next one, behind its '+'.
void AddProvenanceHistory(gemmi::Mtz &mtz, const OutputProvenance &provenance) {
const auto add = [&mtz](const std::string &label, const std::string &value) {
if (value.empty())
return;
std::string rest = label + value;
while (!rest.empty() && mtz.history.size() < 30) {
size_t n = std::min<size_t>(80, rest.size());
while (n > 1 && n < rest.size() && rest[n - 1] == ' ')
--n;
mtz.history.push_back(rest.substr(0, n));
rest = n < rest.size() ? "+" + rest.substr(n) : std::string();
}
};
add("Rugnux git ", GitDescription());
add("Command: ", provenance.command_line);
add("Input: ", provenance.input_file);
add("Input file: ", provenance.input_identity);
add("Collected: ", provenance.collection_date);
}
// _entry, _cell and _symmetry: what every data block of the file restates, so each one stands alone.
void WriteBlockHeader(std::ostream &out, const std::string &entry_id, const UnitCell &unitCell,
const gemmi::SpaceGroup &sg) {
@@ -253,14 +283,29 @@ void WriteMmcifReflections(const std::vector<MergedReflection> &reflections,
// ---------- _software ----------
// Rugnux integrated the images (data reduction) and scaled and merged them (data scaling), the
// two rows the wwPDB asks a data-processing program to be named under.
// two rows the wwPDB asks a data-processing program to be named under; the description is the
// commit the build was made from.
const std::string software_description = CifStr(GitDescription().empty() ? "" : "git " + GitDescription());
out << "loop_\n";
out << "_software.pdbx_ordinal\n";
out << "_software.name\n";
out << "_software.version\n";
out << "_software.classification\n";
out << "1 Rugnux " << CifStr(jfjoch_version()) << " 'data reduction'\n";
out << "2 Rugnux " << CifStr(jfjoch_version()) << " 'data scaling'\n";
out << "_software.description\n";
out << "1 Rugnux " << CifStr(jfjoch_version()) << " 'data reduction' " << software_description << "\n";
out << "2 Rugnux " << CifStr(jfjoch_version()) << " 'data scaling' " << software_description << "\n";
out << "#\n";
// ---------- provenance ----------
// How this file was made and from what: the command line and the input file, which no PDBx item
// holds, under the jfjoch local prefix as the other private items here. The two that can carry
// any character are text fields.
if (!provenance.command_line.empty())
out << "_jfjoch_provenance.command_line\n;" << provenance.command_line << "\n;\n";
if (!provenance.input_file.empty())
out << "_jfjoch_provenance.input_file\n;" << provenance.input_file << "\n;\n";
if (!provenance.input_identity.empty())
out << "_jfjoch_provenance.input_identity " << CifStr(provenance.input_identity) << "\n";
out << "#\n";
// ---------- _exptl_crystal / _diffrn ----------
@@ -624,6 +669,7 @@ void WriteMmcifReflections(const std::vector<MergedReflection> &reflections,
void WriteMtzReflections(const std::vector<MergedReflection> &reflections,
const UnitCell &unitCell,
const DiffractionExperiment &experiment,
const OutputProvenance &provenance,
const std::string &filename) {
gemmi::Mtz mtz;
@@ -634,6 +680,7 @@ void WriteMtzReflections(const std::vector<MergedReflection> &reflections,
// Producing-software provenance in the MTZ header (title + HISTORY, the CCP4 convention).
mtz.title = "Rugnux merged reflections";
mtz.history.push_back("From Rugnux " + jfjoch_version() + ", data reduction");
AddProvenanceHistory(mtz, provenance);
// Add dataset. HKL_base first, so the data dataset is id 1: a dataset written as id 0 is the
// reserved base dataset as far as CCP4's mtzlib is concerned, and the wavelength on it is
@@ -954,12 +1001,14 @@ gemmi::Mtz UnmergedMtz(const std::vector<IntegrationOutcome> &outcomes,
const UnitCell &unitCell,
const DiffractionExperiment &experiment,
bool sum_partials,
const OutputProvenance &provenance,
size_t nthreads) {
gemmi::Mtz mtz;
mtz.spacegroup = &experiment.GetSpaceGroupOrP1();
mtz.set_cell_for_all(unitCell);
mtz.title = "Rugnux unmerged observations";
mtz.history.push_back("From Rugnux " + jfjoch_version() + ", data reduction");
AddProvenanceHistory(mtz, provenance);
mtz.add_base(); // the HKL_base dataset and the H K L columns
gemmi::Mtz::Dataset &ds = mtz.add_dataset("unmerged");
ds.crystal_name = experiment.GetSampleName();
@@ -1223,9 +1272,10 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
const UnitCell &unitCell,
const DiffractionExperiment &experiment,
bool sum_partials,
const OutputProvenance &provenance,
const std::string &filename,
size_t nthreads) {
gemmi::Mtz mtz = UnmergedMtz(outcomes, unitCell, experiment, sum_partials, nthreads);
gemmi::Mtz mtz = UnmergedMtz(outcomes, unitCell, experiment, sum_partials, provenance, nthreads);
SetUnmergedMtzMosaicity(mtz, outcomes);
mtz.write_to_file(filename);
}
@@ -1244,7 +1294,7 @@ void WriteReflections(const std::vector<MergedReflection> &reflections,
// phenix reflection tools, mmCIF for deposition and as the self-describing native format, HKLF-4 for
// SHELXL refinement and SHELXC / ANODE substructure solution). The .hkl holds the unmerged scaled
// fulls where the merge provides them (rotation), the merged reflections otherwise (stills).
WriteMtzReflections(reflections, unitCell, experiment, filename + ".mtz");
WriteMtzReflections(reflections, unitCell, experiment, provenance, filename + ".mtz");
WriteMmcifReflections(reflections, unitCell, experiment, statistics, error_model, twinning,
scaled_fulls ? *scaled_fulls : std::vector<ScaledFull>{}, provenance,
filename + ".cif", nthreads);