rugnux: also write merged reflections as SHELX HKLF-4 (.hkl)
Build Packages / Unit tests (push) Successful in 1h19m28s
Build Packages / build:windows:cuda (push) Successful in 13m16s
Build Packages / build:viewer-tgz:cpu (push) Successful in 6m54s
Build Packages / build:viewer-tgz:cuda (push) Successful in 7m28s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 9m13s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 9m53s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 9m36s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 8m55s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 10m18s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 11m23s
Build Packages / build:rpm (rocky8) (push) Successful in 10m55s
Build Packages / build:rpm (rocky9) (push) Successful in 11m37s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 10m38s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 10m37s
Build Packages / DIALS test (push) Successful in 11m59s
Build Packages / XDS test (durin plugin) (push) Successful in 7m25s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 8m0s
Build Packages / XDS test (neggia plugin) (push) Successful in 7m16s
Build Packages / Generate python client (push) Successful in 25s
Build Packages / Build documentation (push) Successful in 56s
Build Packages / Create release (push) Skipped
Build Packages / Unit tests (pull_request) Successful in 1h2m9s
Build Packages / build:windows:nocuda (pull_request) Successful in 11m23s
Build Packages / build:viewer-tgz:cpu (pull_request) Successful in 6m33s
Build Packages / build:viewer-tgz:cuda (pull_request) Successful in 7m26s
Build Packages / build:rpm (rocky8_nocuda) (pull_request) Successful in 9m29s
Build Packages / build:rpm (rocky9_nocuda) (pull_request) Successful in 9m40s
Build Packages / build:rpm (ubuntu2204_nocuda) (pull_request) Successful in 8m58s
Build Packages / build:rpm (ubuntu2404_nocuda) (pull_request) Successful in 8m35s
Build Packages / build:rpm (rocky8_sls9) (pull_request) Successful in 9m47s
Build Packages / build:rpm (rocky9_sls9) (pull_request) Successful in 10m32s
Build Packages / build:rpm (rocky8) (pull_request) Successful in 9m44s
Build Packages / build:rpm (rocky9) (pull_request) Successful in 11m1s
Build Packages / build:rpm (ubuntu2204) (pull_request) Successful in 10m11s
Build Packages / build:rpm (ubuntu2404) (pull_request) Successful in 9m49s
Build Packages / DIALS test (pull_request) Successful in 11m41s
Build Packages / XDS test (durin plugin) (pull_request) Successful in 7m25s
Build Packages / XDS test (JFJoch plugin) (pull_request) Successful in 7m2s
Build Packages / XDS test (neggia plugin) (pull_request) Successful in 6m25s
Build Packages / Generate python client (pull_request) Successful in 19s
Build Packages / Build documentation (pull_request) Successful in 52s
Build Packages / Create release (pull_request) Skipped
Build Packages / build:windows:nocuda (push) Successful in 10m34s
Build Packages / build:windows:cuda (pull_request) Successful in 18m35s

WriteReflections now emits a third format alongside the MTZ and mmCIF: a
SHELX HKLF-4 text file (<prefix>.hkl), the direct input for SHELXC / ANODE /
SHELXD. Fixed FORMAT(3I4,2F8.2), one record per reflection as h k l I sig(I),
ending with the 0 0 0 terminator.

Two things make it a valid substructure-solution input:
- Bijvoet mates are written separately (I(+) at +hkl, I(-) at -hkl) so the
  anomalous differences survive; a reflection with no anomalous split is
  written once as its mean.
- Intensities are put on a common scale so the largest value fits the fixed
  F8.2 field (negative weak intensities would otherwise overflow it). I and
  sigma share the scale, so the anomalous signal is untouched; the absolute
  scale is irrelevant to SHELXC/ANODE, which use only ratios.

The MTZ writer's Bijvoet row-building is factored into a shared BuildMergedRows
helper so the MTZ and HKL outputs agree (MTZ output is byte-equivalent to
before). docs/RUGNUX.md updated to describe the three reflection formats.

Validated end-to-end on lysozyme: SHELXC reads the .hkl and reports real
anomalous signal (<d"/sig> 1.6 at low res), and ANODE locates the Met/Cys S
and a chloride at 6.5-9 sigma from the unmodified file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-19 07:56:55 +02:00
co-authored by Claude Opus 4.8
parent 55efd42045
commit a79f05134b
3 changed files with 150 additions and 79 deletions
+128 -72
View File
@@ -59,6 +59,84 @@ std::string CifStr(const std::string& s) {
return s;
}
// One output row per reflection in the standard CCP4 anomalous layout: the merged mean (IMEAN / F)
// plus the two Bijvoet mates (I(+)/I(-), F(+)/F(-)). The merge keeps the two mates as separate rows
// (I+ under the ASU representative hkl, I- under -hkl); this collapses them into one row so the MTZ
// and SHELX writers share one row list. has_anom is set false when NO reflection carries an anomalous
// split (e.g. the stills path) - callers then omit the +/- columns.
struct MergedOutRow {
int h = 0, k = 0, l = 0;
float Imean = NAN, sImean = NAN, Ip = NAN, sIp = NAN, Im = NAN, sIm = NAN;
float Fmean = NAN, sFmean = NAN, Fp = NAN, sFp = NAN, Fm = NAN, sFm = NAN;
int rfree = 0;
};
std::vector<MergedOutRow> BuildMergedRows(const std::vector<MergedReflection> &reflections,
const DiffractionExperiment &experiment,
bool &has_anom) {
std::vector<MergedOutRow> out_rows;
has_anom = true;
if (experiment.GetScalingSettings().GetMergeFriedel()) {
// Friedel-merged: IMEAN is the already-merged intensity (r.I). I(+)/I(-) are carried verbatim
// from the Bijvoet split the merge kept (rotation always does; scaled non-anomalously), so a weak
// anomalous signal is preserved without reprocessing. A reflection with only one mate, or a
// centric, gets a missing value (NaN) for the absent hand. When NO reflection has an anomalous
// split (e.g. the stills path, which does not compute one) the anomalous columns are omitted.
has_anom = std::any_of(reflections.begin(), reflections.end(),
[](const MergedReflection& r){ return std::isfinite(r.I_plus) || std::isfinite(r.I_minus); });
out_rows.reserve(reflections.size());
for (const auto& r : reflections)
out_rows.push_back({r.h, r.k, r.l, r.I, r.sigma, r.I_plus, r.sigma_plus, r.I_minus,
r.sigma_minus, r.F, r.sigmaF, r.F_plus, r.sigmaF_plus, r.F_minus,
r.sigmaF_minus, r.rfree_flag ? 1 : 0});
} else {
// Anomalous: group the two mates by their (shared) Friedel-merged ASU representative, then form
// IMEAN / F as their inverse-variance Friedel mean. A single generator gives both the group key
// (its hkl, identical for +hkl and -hkl) and which mate this row is (.plus).
const HKLKeyGenerator key_gen(false, experiment.GetSpaceGroupNumber().value_or(1));
struct AnomRow {
int h = 0, k = 0, l = 0;
float Ip = NAN, sIp = NAN, Im = NAN, sIm = NAN;
float Fp = NAN, sFp = NAN, Fm = NAN, sFm = NAN;
int rfree = 0;
};
std::map<std::tuple<int, int, int>, AnomRow> rows;
for (const auto& r : reflections) {
const HKLKey key = key_gen(r);
AnomRow& row = rows[{key.h, key.k, key.l}];
row.h = key.h; row.k = key.k; row.l = key.l;
row.rfree = r.rfree_flag ? 1 : 0;
if (key.plus) { row.Ip = r.I; row.sIp = r.sigma; row.Fp = r.F; row.sFp = r.sigmaF; }
else { row.Im = r.I; row.sIm = r.sigma; row.Fm = r.F; row.sFm = r.sigmaF; }
}
// Friedel-mean of the two mates by inverse variance (the single mate, if only one was measured).
const auto combine = [](float a, float sa, float b, float sb, float& val, float& sig) {
const bool ok_a = std::isfinite(a) && sa > 0.0f;
const bool ok_b = std::isfinite(b) && sb > 0.0f;
if (ok_a && ok_b) {
const double wa = 1.0 / (static_cast<double>(sa) * sa);
const double wb = 1.0 / (static_cast<double>(sb) * sb);
val = static_cast<float>((wa * a + wb * b) / (wa + wb));
sig = static_cast<float>(1.0 / std::sqrt(wa + wb));
} else if (ok_a) { val = a; sig = sa; }
else if (ok_b) { val = b; sig = sb; }
else { val = NAN; sig = NAN; }
};
out_rows.reserve(rows.size());
for (const auto& [hkl, row] : rows) {
float i_mean, sig_i_mean, f_mean, sig_f_mean;
combine(row.Ip, row.sIp, row.Im, row.sIm, i_mean, sig_i_mean);
combine(row.Fp, row.sFp, row.Fm, row.sFm, f_mean, sig_f_mean);
out_rows.push_back({row.h, row.k, row.l, i_mean, sig_i_mean, row.Ip, row.sIp, row.Im,
row.sIm, f_mean, sig_f_mean, row.Fp, row.sFp, row.Fm, row.sFm, row.rfree});
}
}
return out_rows;
}
} // namespace
@@ -286,77 +364,10 @@ void WriteMtzReflections(const std::vector<MergedReflection> &reflections,
const int dataset_id = ds.id;
// The merge keeps the two Friedel mates as separate rows (I+ under the ASU representative hkl, I-
// under -hkl). Both output modes collapse them into one row per reflection with the standard CCP4
// anomalous layout (IMEAN + I(+)/I(-), and the same split for the French-Wilson amplitude), which
// aimless / ctruncate / mtz2sca / ANODE read directly. The modes differ only in how each row is
// formed, so they build one common row list and share the column/row emission below.
struct OutRow {
int h = 0, k = 0, l = 0;
float Imean = NAN, sImean = NAN, Ip = NAN, sIp = NAN, Im = NAN, sIm = NAN;
float Fmean = NAN, sFmean = NAN, Fp = NAN, sFp = NAN, Fm = NAN, sFm = NAN;
int rfree = 0;
};
std::vector<OutRow> out_rows;
// One row per reflection in the CCP4 anomalous layout (IMEAN + I(+)/I(-), and the same split for
// the French-Wilson amplitude), which aimless / ctruncate / mtz2sca / ANODE read directly.
bool has_anom = true;
if (experiment.GetScalingSettings().GetMergeFriedel()) {
// Friedel-merged: IMEAN is the already-merged intensity (r.I). I(+)/I(-) are carried verbatim
// from the Bijvoet split the merge kept (rotation always does; scaled non-anomalously), so a weak
// anomalous signal is preserved without reprocessing. A reflection with only one mate, or a
// centric, gets a missing value (NaN) for the absent hand. When NO reflection has an anomalous
// split (e.g. the stills path, which does not compute one) the anomalous columns are omitted.
has_anom = std::any_of(reflections.begin(), reflections.end(),
[](const MergedReflection& r){ return std::isfinite(r.I_plus) || std::isfinite(r.I_minus); });
out_rows.reserve(reflections.size());
for (const auto& r : reflections)
out_rows.push_back({r.h, r.k, r.l, r.I, r.sigma, r.I_plus, r.sigma_plus, r.I_minus,
r.sigma_minus, r.F, r.sigmaF, r.F_plus, r.sigmaF_plus, r.F_minus,
r.sigmaF_minus, r.rfree_flag ? 1 : 0});
} else {
// Anomalous: group the two mates by their (shared) Friedel-merged ASU representative, then form
// IMEAN / F as their inverse-variance Friedel mean. A single generator gives both the group key
// (its hkl, identical for +hkl and -hkl) and which mate this row is (.plus).
const HKLKeyGenerator key_gen(false, experiment.GetSpaceGroupNumber().value_or(1));
struct AnomRow {
int h = 0, k = 0, l = 0;
float Ip = NAN, sIp = NAN, Im = NAN, sIm = NAN;
float Fp = NAN, sFp = NAN, Fm = NAN, sFm = NAN;
int rfree = 0;
};
std::map<std::tuple<int, int, int>, AnomRow> rows;
for (const auto& r : reflections) {
const HKLKey key = key_gen(r);
AnomRow& row = rows[{key.h, key.k, key.l}];
row.h = key.h; row.k = key.k; row.l = key.l;
row.rfree = r.rfree_flag ? 1 : 0;
if (key.plus) { row.Ip = r.I; row.sIp = r.sigma; row.Fp = r.F; row.sFp = r.sigmaF; }
else { row.Im = r.I; row.sIm = r.sigma; row.Fm = r.F; row.sFm = r.sigmaF; }
}
// Friedel-mean of the two mates by inverse variance (the single mate, if only one was measured).
const auto combine = [](float a, float sa, float b, float sb, float& val, float& sig) {
const bool ok_a = std::isfinite(a) && sa > 0.0f;
const bool ok_b = std::isfinite(b) && sb > 0.0f;
if (ok_a && ok_b) {
const double wa = 1.0 / (static_cast<double>(sa) * sa);
const double wb = 1.0 / (static_cast<double>(sb) * sb);
val = static_cast<float>((wa * a + wb * b) / (wa + wb));
sig = static_cast<float>(1.0 / std::sqrt(wa + wb));
} else if (ok_a) { val = a; sig = sa; }
else if (ok_b) { val = b; sig = sb; }
else { val = NAN; sig = NAN; }
};
out_rows.reserve(rows.size());
for (const auto& [hkl, row] : rows) {
float i_mean, sig_i_mean, f_mean, sig_f_mean;
combine(row.Ip, row.sIp, row.Im, row.sIm, i_mean, sig_i_mean);
combine(row.Fp, row.sFp, row.Fm, row.sFm, f_mean, sig_f_mean);
out_rows.push_back({row.h, row.k, row.l, i_mean, sig_i_mean, row.Ip, row.sIp, row.Im,
row.sIm, f_mean, sig_f_mean, row.Fp, row.sFp, row.Fm, row.sFm, row.rfree});
}
}
const std::vector<MergedOutRow> out_rows = BuildMergedRows(reflections, experiment, has_anom);
mtz.add_column("H", 'H', dataset_id, -1, false);
mtz.add_column("K", 'H', dataset_id, -1, false);
@@ -406,6 +417,49 @@ void WriteMtzReflections(const std::vector<MergedReflection> &reflections,
mtz.write_to_file(filename);
}
void WriteShelxHklReflections(const std::vector<MergedReflection> &reflections,
const DiffractionExperiment &experiment,
const std::string &filename) {
bool has_anom = true;
const std::vector<MergedOutRow> rows = BuildMergedRows(reflections, experiment, has_anom);
// SHELX HKLF 4 (SHELXC / ANODE input): fixed FORMAT(3I4,2F8.2), one record per reflection as
// h k l I sigma(I). The Bijvoet mates are written separately - I(+) at +hkl, I(-) at -hkl - so the
// anomalous differences survive; a reflection with no anomalous split is written once as its mean.
// Intensities are put on a common scale so the largest value fits the F8.2 field (the absolute scale
// is irrelevant to SHELXC / ANODE, which use only ratios); I and sigma share the scale, so the
// anomalous signal is untouched. The file ends with a 0 0 0 terminator record.
const auto usable = [](float v, float s) { return std::isfinite(v) && std::isfinite(s) && s > 0.0f; };
double max_abs = 0.0;
for (const auto& r : rows) {
if (usable(r.Ip, r.sIp)) max_abs = std::max({max_abs, std::fabs(double(r.Ip)), double(r.sIp)});
if (usable(r.Im, r.sIm)) max_abs = std::max({max_abs, std::fabs(double(r.Im)), double(r.sIm)});
if (!usable(r.Ip, r.sIp) && !usable(r.Im, r.sIm) && usable(r.Imean, r.sImean))
max_abs = std::max({max_abs, std::fabs(double(r.Imean)), double(r.sImean)});
}
const double scale = (std::isfinite(max_abs) && max_abs > 0.0) ? 9999.0 / max_abs : 1.0;
std::ofstream out(filename);
if (!out)
throw std::runtime_error("WriteShelxHklReflections: cannot open " + filename);
out << std::fixed << std::setprecision(2);
const auto emit = [&out, scale](int h, int k, int l, float I, float sigma) {
out << std::setw(4) << h << std::setw(4) << k << std::setw(4) << l
<< std::setw(8) << scale * I << std::setw(8) << scale * sigma << "\n";
};
for (const auto& r : rows) {
const bool plus = usable(r.Ip, r.sIp);
const bool minus = usable(r.Im, r.sIm);
if (plus) emit(r.h, r.k, r.l, r.Ip, r.sIp);
if (minus) emit(-r.h, -r.k, -r.l, r.Im, r.sIm);
if (!plus && !minus && usable(r.Imean, r.sImean))
emit(r.h, r.k, r.l, r.Imean, r.sImean);
}
emit(0, 0, 0, 0.0f, 0.0f); // HKLF-4 end-of-data marker
out.close();
}
void WriteReflections(const std::vector<MergedReflection> &reflections,
const UnitCell &unitCell,
const DiffractionExperiment &experiment,
@@ -413,8 +467,10 @@ void WriteReflections(const std::vector<MergedReflection> &reflections,
const std::string &isa,
const TwinningAnalysisResult &twinning,
const std::string &filename) {
// Always write both an MTZ and an mmCIF - each has its uses downstream (MTZ for the CCP4 /
// phenix reflection tools, mmCIF for deposition and as the self-describing native format).
// Write an MTZ, an mmCIF and a SHELX HKLF-4 .hkl - each has its uses downstream (MTZ for the CCP4 /
// phenix reflection tools, mmCIF for deposition and as the self-describing native format, HKLF-4 as
// the SHELXC / ANODE substructure-solution input).
WriteMtzReflections(reflections, unitCell, experiment, filename + ".mtz");
WriteMmcifReflections(reflections, unitCell, experiment, statistics, isa, twinning, filename + ".cif");
WriteShelxHklReflections(reflections, experiment, filename + ".hkl");
}