v1.0.0-rc.166 (#76)
Build Packages / Unit tests (push) Successful in 1h22m15s
Build Packages / build:windows:nocuda (push) Successful in 18m0s
Build Packages / build:windows:cuda (push) Successful in 20m30s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m32s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m39s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:rugnux:windows (push) Successful in 11m25s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m27s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m19s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 15m34s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m25s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m36s
Build Packages / build:rpm (rocky8) (push) Successful in 17m43s
Build Packages / build:rpm (rocky9) (push) Successful in 13m34s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m28s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 18m19s
Build Packages / DIALS test (push) Successful in 12m36s
Build Packages / XDS test (durin plugin) (push) Successful in 6m56s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 6m48s
Build Packages / XDS test (neggia plugin) (push) Successful in 6m7s
Build Packages / Generate python client (push) Successful in 11s
Build Packages / Build documentation (push) Successful in 36s
Build Packages / Create release (push) Skipped
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m11s

* `rugnux --mode calibration` writes `<prefix>.json` beside the `.poni`, whose `dataset_settings` member is a `jfjoch_broker` `dataset_settings` body as it stands.
* `rugnux` and `jfjoch_viewer` read PILATUS miniCBF sweeps natively, without conversion.
* Masters written by other facilities open, including Eiger 1.x and third-party NXmx variants.
* `rugnux` measures the beam centre on every run, and indexes with it when the file's value indexes nothing.
* A detector swung out on a 2theta arm is placed where the file says it stands, and the calibration can hold the tilt fixed.
* `rugnux` writes the unmerged MTZ by default, and a P1 merge beside it, so a wrong space group can be re-merged without reprocessing.
* Significant improvements to symmetry handling in `rugnux`: the lattice, the point group, the setting and the systematic absences.
* The `rugnux` report gives the resolution the CC1/2 fit reached, beside the range the reflections were written to.
* The `rugnux` report gives the twinning statistics measured before the space group was decided, beside the ones measured after.
* The `rugnux` report gives the strong-direction diffraction limit, and warns when CC1/2 is not monotone with resolution.
* `rugnux` ranks screw axes on the evidence their absences carry, rather than on how many control reflections a candidate happens to have.
* Twinning is no longer reported when the L-test contradicts it.
* The `rugnux` report gives the detector tilt, the measured tilt and the direct beam beside the beam centre, and a post-refined beam centre is judged against the run's own measurement rather than the file's.
* `--no-refine-tilt` holds the detector tilt at the value in the file, instead of zeroing it, when the calibration starts from the spots.
* The `jfjoch_viewer` grid scan view draws the cells in the proportion of the scan steps, so the map has the shape of the scanned area.

Reviewed-on: #76
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
This commit was merged in pull request #76.
This commit is contained in:
2026-09-02 21:17:31 +02:00
committed by leonarski_f
parent 511be0c366
commit 680c36c20d
383 changed files with 20910 additions and 3936 deletions
+59 -34
View File
@@ -12,7 +12,6 @@
#include <cmath>
#include <cstdio>
#include <map>
#include <set>
#include <tuple>
#include <fstream>
#include <iomanip>
@@ -75,7 +74,14 @@ 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;
// The value written into FreeR_flag: 0 for the TEST set, 1 for the working set. That is the CCP4
// convention the column NAME belongs to - CCP4's own freerflag writes 0..19 with 0 as the ~5%
// test bin, and REFMAC's default FREE 0 reads it that way, so the opposite (phenix/CNS) numbering
// under this name stops REFMAC dead: "more than half of reflections are in free R set", then
// "Cannot switch free R flag", exit 1. phenix auto-detects either numbering with equal confidence
// (measured on both), and rugnux's own reference-MTZ reader already takes 0 as the test set, so
// this is the numbering that works everywhere and the one the rest of the code assumes.
int rfree = 1;
};
std::vector<MergedOutRow> BuildMergedRows(const std::vector<MergedReflection> &reflections,
@@ -96,24 +102,24 @@ std::vector<MergedOutRow> BuildMergedRows(const std::vector<MergedReflection> &r
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});
r.sigmaF_minus, r.rfree_flag ? 0 : 1});
} 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));
const HKLKeyGenerator key_gen(false, experiment.GetSpaceGroupOrP1());
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;
int rfree = 1;
};
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;
row.rfree = r.rfree_flag ? 0 : 1;
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; }
}
@@ -186,13 +192,11 @@ void WriteMmcifReflections(const std::vector<MergedReflection> &reflections,
out << "_cell.angle_beta " << Fmt(unitCell.beta, 2) << "\n";
out << "_cell.angle_gamma " << Fmt(unitCell.gamma, 2) << "\n";
auto *sg = gemmi::find_spacegroup_by_number(experiment.GetSpaceGroupNumber().value_or(1));
if (sg == nullptr)
throw std::runtime_error("WriteMmcifReflections: invalid space group number");
const gemmi::SpaceGroup &sg = experiment.GetSpaceGroupOrP1();
// ---------- _symmetry ----------
out << "_symmetry.space_group_name_H-M " << CifStr(sg->hm) << "\n";
out << "_symmetry.Int_Tables_number " << sg->number << "\n";
out << "_symmetry.space_group_name_H-M " << CifStr(sg.hm) << "\n";
out << "_symmetry.Int_Tables_number " << sg.number << "\n";
out << "#\n";
// ---------- _diffrn_source / _diffrn_detector ----------
@@ -378,7 +382,6 @@ void WriteMmcifReflections(const std::vector<MergedReflection> &reflections,
out << "_refln.pdbx_F_plus_sigma\n";
out << "_refln.pdbx_F_minus\n";
out << "_refln.pdbx_F_minus_sigma\n";
out << "_refln.status_free\n";
out << "_refln.status\n";
// One row per unique reflection, twelve formatted floats each - tens of megabytes on a crowded
@@ -418,8 +421,10 @@ void WriteMmcifReflections(const std::vector<MergedReflection> &reflections,
column(Fmt(r.sigmaF_plus, 4), 14);
column(Fmt(r.F_minus, 4), 14);
column(Fmt(r.sigmaF_minus, 4), 14);
s.push_back(r.rfree_flag ? '1' : '0');
s.append(" o\n"); // 'o' = observed
// PDBx/mmCIF _refln.status: 'f' marks the free (test) set, 'o' the working
// set. This is the only place the free set is carried in the file - cif2mtz and
// gemmi both read it, and neither knows any other reflection-level free-set item.
s.append(r.rfree_flag ? "f\n" : "o\n");
}
}
});
@@ -439,15 +444,18 @@ void WriteMtzReflections(const std::vector<MergedReflection> &reflections,
gemmi::Mtz mtz;
// Optional but recommended metadata
mtz.spacegroup = gemmi::find_spacegroup_by_number(
experiment.GetSpaceGroupNumber().value_or(1));
mtz.spacegroup = &experiment.GetSpaceGroupOrP1();
mtz.set_cell_for_all(unitCell);
// 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");
// Add dataset
// 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
// dropped - mtzinfo then reports the Cu K-alpha default 1.54187 A, which is what a program that
// takes f'/f'' from the file would phase on.
mtz.add_base(); // the HKL_base dataset and the H K L columns
gemmi::Mtz::Dataset& ds = mtz.add_dataset("native");
ds.crystal_name = experiment.GetSampleName();
ds.wavelength = experiment.GetWavelength_A();
@@ -459,9 +467,6 @@ void WriteMtzReflections(const std::vector<MergedReflection> &reflections,
bool has_anom = true;
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);
mtz.add_column("L", 'H', dataset_id, -1, false);
mtz.add_column("IMEAN", 'J', dataset_id, -1, false);
mtz.add_column("SIGIMEAN", 'Q', dataset_id, -1, false);
if (has_anom) {
@@ -606,15 +611,26 @@ float DetectorY(const Reflection &r) { return std::isfinite(r.observed_y) ? r.ob
// 8-41% of events have parts summing to zero or less and 10-21% of the intensity-weighted centroids
// fall outside the event's own frame range. Partialities are the rocking curve's own weights and are
// positive by construction.
// min_partiality is the combine's own floor on the assembled full (--min-partiality, default 0.02,
// 0 = off). An event that caught a thousandth of its rocking curve is not a measurement of that
// reflection - rugnux does not merge one either - and writing it as a full hands the reading program
// a whole observation whose intensity is noise and whose sigma is small, so it is weighted heavily.
// On a crystal whose rocking curves span twenty frames those events are 7% of the file and cost
// POINTLESS the point group. Everything above the floor is written with its honest FRACTIONCALC,
// truncated edge-of-sweep events included, for the reader to cut where it wants.
// The floor is the SAME pair the 3D combine applies to the same event - --min-partiality (default
// 0.02) and --min-captured-fraction (default 0.7 on rotation) - so what is exported as a full is
// exactly what rugnux merges as a full. The captured-fraction floor is what makes the FULL
// declaration true: the batch header says LDTYPE=2 and M/ISYM carries M=0, AIMLESS reads that as
// "all runs have only fulls" and ignores FRACTIONCALC entirely, so an event that caught a twentieth
// of its rocking curve does not enter the merge at a twentieth of its intensity - it enters whole,
// with a small sigma, and is weighted heavily. There is no cut for the reader to make: the column
// that would let it make one is one the reading program has been told to ignore. Measured, dropping
// those events moves AIMLESS's Rmerge at 1.8 A from 1.353 to 0.694 and CC(1/2) from 0.985 to 0.993.
//
// FRACTIONCALC itself is written as summed, above 1 included. Each part's partiality is the erf pair
// the predictor computed on THAT frame, from that frame's own refined lattice and its own mosaicity
// estimate, so the parts of one event do not tile the rocking curve exactly - measured, the offset
// from one part to the next steps by the wedge to within 12% of it, and the sums land in a peak at
// 1.000 whose 95th percentile is 1.09. It is the honest estimate of a captured fraction and it is
// not the number rugnux scales on (the merge recomputes every partiality from a frame-order-smoothed
// mosaicity, RotationScaleMerge::SmoothMosaicity), so clamping it to 1 would hide the spread and buy
// a reading program nothing.
std::vector<Reflection> SumRockingEvents(const std::vector<IntegrationOutcome> &outcomes,
double min_partiality) {
double min_partiality, double min_captured_fraction) {
constexpr float MAX_FRAME_GAP = 2.0f; // == RotationScaleMerge's: what makes one rocking event
// The sort key travels with the part instead of being read back through the pointer, the way the
@@ -668,7 +684,7 @@ std::vector<Reflection> SumRockingEvents(const std::vector<IntegrationOutcome> &
}
Reflection full = *parts[i].r;
i = j;
if (sum_p < min_partiality)
if (sum_p < min_partiality || sum_p < min_captured_fraction)
continue;
// The Lorentz-polarization factor is applied by the writer, which multiplies I by rlp, so
@@ -698,7 +714,7 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
bool sum_partials,
const std::string &filename) {
gemmi::Mtz mtz;
mtz.spacegroup = gemmi::find_spacegroup_by_number(experiment.GetSpaceGroupNumber().value_or(1));
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");
@@ -751,7 +767,13 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
// way to recover it. The partiality is NOT divided out - that is a scale, FRACTIONCALC carries
// it, and every program this file is for wants to handle it its own way.
gemmi::UnmergedHklMover hkl_mover(mtz.spacegroup);
std::set<int> batch_numbers;
// The batch headers are written for every image the observations span, not only for the images
// that produced one. AIMLESS starts a new run wherever the phi series jumps, so an image that
// indexed nothing leaves a hole that cuts the sweep in two: measured on a 360 deg sweep with 67
// unindexed images, AIMLESS made 23 runs of it and its scale model diverged - CC(1/2) 0.811,
// Rmeas overflowed to -1266, and the result no longer correlated with rugnux's own merge
// (Pearson 0.0018). Filling the gaps gives one run and the numbers the sweep deserves.
int first_batch = 0, last_batch = -1;
// Lattice-centring absences are integrated on purpose - prediction runs in P so the space-group
// search can confirm or disprove the centring - but they are not written here. POINTLESS reads
@@ -769,7 +791,9 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
// A summed full's image_number is its rocking-curve centroid, so this is the batch the
// reflection is centred on - which is what a batch means for a full everywhere else.
const int batch = 1 + static_cast<int>(std::lround(r.image_number));
batch_numbers.insert(batch);
if (last_batch < first_batch) { first_batch = batch; last_batch = batch; }
first_batch = std::min(first_batch, batch);
last_batch = std::max(last_batch, batch);
mtz.data.push_back(static_cast<float>(hkl[0]));
mtz.data.push_back(static_cast<float>(hkl[1]));
mtz.data.push_back(static_cast<float>(hkl[2]));
@@ -790,7 +814,8 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
};
if (scanning && sum_partials) {
for (const auto &r : SumRockingEvents(outcomes,
experiment.GetScalingSettings().GetMinPartiality()))
experiment.GetScalingSettings().GetMinPartiality(),
experiment.GetScalingSettings().GetMinCapturedFraction()))
add_row(r);
} else {
for (const auto &outcome : outcomes)
@@ -864,7 +889,7 @@ void WriteUnmergedMtzReflections(const std::vector<IntegrationOutcome> &outcomes
batch.floats[115] = 1.0f;
batch.floats[116] = static_cast<float>(experiment.GetYPixelsNum());
for (const int number : batch_numbers) {
for (int number = first_batch; number <= last_batch; number++) {
batch.number = number;
batch.floats[36] = phi_start_deg(static_cast<float>(number - 1)); // phistt
batch.floats[37] = batch.floats[36] + wedge_deg; // phiend