Build Packages / build:rpm (rocky9) (push) Successful in 19m56s
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 16m57s
Build Packages / build:windows:cuda (push) Successful in 19m18s
Build Packages / build:viewer-tgz:cpu (push) Successful in 14m48s
Build Packages / build:viewer-tgz:cuda (push) Successful in 16m18s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 14m19s
Build Packages / build:rugnux:windows (push) Successful in 10m34s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m49s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 20m55s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 17m4s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 20m48s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m15s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 24m26s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 20m32s
Build Packages / build:rpm (rocky8) (push) Successful in 23m39s
Build Packages / Generate python client (push) Successful in 46s
Build Packages / Build documentation (push) Successful in 1m45s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 11m3s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m30s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 20m10s
Build Packages / XDS test (neggia plugin) (push) Successful in 10m17s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 23m12s
Build Packages / DIALS test (push) Successful in 20m12s
* rugnux now tells you whether a crystal diffracts anisotropically and how far it reaches in each direction, without a second program: a new `9. DIFFRACTION ANISOTROPY` section in `<prefix>_report.txt` and matching `_reflns.pdbx_aniso_B_tensor_*` / `_reflns.jfjoch_aniso_*` items in the merged mmCIF report the anisotropic deltaB, the diffraction limit along each principal direction, and a `NOT DETECTED` / `DETECTED` / `CANNOT DETERMINE` verdict measured against the data set's own systematic error. It is a description only - no intensity is corrected, no reflection is removed, and the merged data do not depend on direction.
* rugnux can hand its integrated observations to another scaling program: `--export-unmerged` writes `<prefix>_unmerged.mtz`, an unmerged MTZ readable by aimless, pointless, careless and `iotbx.merging_statistics`, in `--mode mx` and `--mode scale` alike. Each rotation reflection's partials are summed into one full; `--export-unmerged-partials` writes one row per image instead. Intensities carry the Lorentz-polarization factor and nothing else, since those programs scale the data themselves. Lattice-centring absences are not written; screw and glide absences are.
* rugnux integrates crystals with broad spots better - where it changes anything, per-shell mean I/sigma improves by up to 31% and R_meas by up to 24% - because on rotation data the integration signal radius is now taken from the crystal's own measured spot width instead of a fixed 4 px. `--adaptive-integration-radius=off` restores the fixed radius and an explicit `--integration-radius` still overrides both. The widened radius applies to the final integration pass only, and a pattern too dense for it is re-integrated at 4 px with a note in the log.
* rugnux discards fewer stills reflections for want of a background ring, improving per-shell R_meas over most of the signal-bearing range: the stills background ring now runs to 14 px instead of 12. The gain reverses in shells below a mean I/sigma of about 4.
* rugnux determines the space group with thresholds that mean the same thing on a weak crystal as on a strong one: symmetry operators are scored on resolution-normalised intensities (E squared) instead of raw merged intensities, and a reflection counts as genuinely present on its counting significance instead of on the merged I/sigma, which saturates at the merge's own ISa. The search resolution cut is no longer able to move the answer, and the twin-law H bound moves from 1.70 to 1.85, which stops one class of correct high-symmetry assignment being refused as twinning.
* rugnux says what the space-group search tested and what it could not: the twin-law disagreement H is printed for every operator together with the adopted point group's H ratio and its bound; alternatives that are not on the reported lattice are named with how their cell differs; and a lattice centring the data could not test - the crystal having been integrated on the primitive sub-cell, so the reflections it extinguishes were never measured - is marked `UNTESTED` and warned about where it is adopted, as coming from the lattice metric rather than from the intensities.
* rugnux `--mode scale` re-merges a `_process.h5` in the right symmetry without being told it: the file now records the space group on every run - a two-pass rotation run wrote none before, so re-merging defaulted to P1 - together with the change of basis under `/entry/MX/reindexMatrix` where the lattice was re-seated, and `--mode scale` also reports the Wilson B-factor estimate instead of `WILSON_B= nan`. A file written before this stops with a message naming the two cells and the override to use, instead of failing inside the merge. A third-party reader of a `_process.h5` must apply `reindexMatrix` where it is present.
* rugnux installs on its own, as a package called `rugnux` - `dnf install rugnux` or `apt install rugnux` - instead of arriving inside `jfjoch-viewer`. It pulls in none of the acquisition stack, so a machine that only processes data no longer has to carry the broker, the detector libraries or Qt to get it. Installing it over a `jfjoch-viewer` from rc.163 or earlier, which still owns `/usr/bin/rugnux`, upgrades cleanly rather than failing on the duplicate file.
* rugnux is also a standalone download, built for arm64 as well as x86_64: `rugnux-<version>-linux-{x86_64|aarch64}-cuda<major>.tgz` and `rugnux-<version>-win64-cuda<major>.zip` on the release page, for machines that are not managed by a package manager. The aarch64 build targets GH200 and DGX Spark, and is untested on hardware.
* Every portable Linux binary is now a single self-contained file: cuFFT is linked statically instead of being shipped beside the executable and found through an rpath, so `rugnux` and `jfjoch_viewer` need nothing but an NVIDIA driver, and only to use the GPU. The `.rpm`/`.deb` continue to take cuFFT from the distribution. The developer utilities `jfjoch_extract_hkl` and `jfjoch_recompress` are no longer packaged anywhere.
* Jungfraujoch needs six fewer shared libraries on the machine - libopenblas and libmetis, and libgfortran, libquadmath, libgomp and libz behind them - because the Ceres LAPACK, METIS and SuiteSparse back-ends are no longer built. Nothing in the code ever selected them, and results are unchanged.
* The PCIe driver DKMS package builds for the kernel it is being installed for instead of the running one, so a module built while a kernel update is being applied loads after the reboot.
* The PCIe driver builds on RHEL 9.5 and later, and on their CentOS Stream, Rocky and AlmaLinux equivalents, where the `vm_flags` kernel interface was backported into the 5.14 kernel.
* A data collection started with `async_start` that fails to start - a writer refusing to overwrite an existing file, for instance - is reported as an error by `/wait_until_running` and `/wait_till_done` instead of as a timeout and a successful collection respectively. The error message is the one the writer gave.
* A calibration that is cancelled or that fails to collect its pedestals is no longer reported as a successful one. The broker goes to `Inactive` with an error message and has to be initialized again, instead of sitting in `Idle` looking ready to measure while holding partial pedestals - data collected in that state was silently mis-converted.
* A failed `/initialize` is reported to `/wait_until_running` and `/wait_till_done` as soon as it happens, instead of when their timeout expires.
* `space_group_number` accepts space groups up to 230 in the API schema, so cubic space groups can be recorded. The broker always accepted them; the generated clients rejected them before the request was sent.
* The results report's `REPORT_VERSION` is 3, two sections having been added. Existing key names and table columns are unchanged.
* The merged statistics table has **9** resolution shells instead of 10, which is what XDS reports. The bins were already XDS's - equal steps in 1/d^2 between the lowest- and the highest-resolution reflection the merge kept - so at the same resolution limits the two tables now have the same shell boundaries and can be read row for row. `--resolution-shells` sets a different count.
* `rugnux --model` now settles the frame the merged reflections are written in, not only the frame the R-factors and the maps are computed in: the `.mtz`/`.cif`/`.hkl` come out in the model's indexing, and where the data were merged in the model's enantiomorph they take the model's hand and space group - which on anomalous data puts I(+) and I(-) the right way round. The indexing choice is logged with the winning R-free and the runner-up, so a decision made within noise is visible.
* `rugnux --model` can resolve the indexing ambiguity of a **serial stills** run, which a model could not do before: structure factors computed from the model become the per-image reference, the same role a reference MTZ plays. It needs the cell and space group up front (`-C` / `-S`). Without one or the other, a merohedral serial run still merges both hands together and says so.
* The rugnux documentation opens with a quick start - the default run, and runs with a reference MTZ, with a model, or with the space group and cell pinned - and explains the indexing ambiguity: what it costs on rotation and on serial data, and which of `-z` / `--model` resolves it in each case. The long reference pages now carry a table of contents.
Reviewed-on: #74
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
210 lines
11 KiB
C++
210 lines
11 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
|
|
#include <cmath>
|
|
#include <vector>
|
|
|
|
#include "../image_analysis/scale_merge/AnisotropyAnalysis.h"
|
|
#include "gemmi/symmetry.hpp"
|
|
#include "gemmi/unitcell.hpp"
|
|
|
|
namespace {
|
|
gemmi::UnitCell Cell(double a, double b, double c, double al, double be, double ga) {
|
|
gemmi::UnitCell out;
|
|
out.set(a, b, c, al, be, ga);
|
|
return out;
|
|
}
|
|
|
|
// A synthetic merge: Wilson-distributed intensities with an isotropic fall-off and a known
|
|
// deviatoric anisotropy on top, over every hkl inside the resolution limit. The "structure
|
|
// factor" is a hash of the index, so the set is reproduced bit for bit.
|
|
std::vector<MergedReflection> SyntheticMerge(const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg,
|
|
double d_min, double b_iso,
|
|
const gemmi::SMat33<double> &b_dev) {
|
|
const gemmi::GroupOps ops = sg->operations();
|
|
const gemmi::ReciprocalAsu asu(sg);
|
|
std::vector<MergedReflection> out;
|
|
const int hmax = static_cast<int>(cell.a / d_min) + 1;
|
|
const int kmax = static_cast<int>(cell.b / d_min) + 1;
|
|
const int lmax = static_cast<int>(cell.c / d_min) + 1;
|
|
for (int h = -hmax; h <= hmax; ++h)
|
|
for (int k = -kmax; k <= kmax; ++k)
|
|
for (int l = -lmax; l <= lmax; ++l) {
|
|
const gemmi::Op::Miller hkl{{h, k, l}};
|
|
if ((h == 0 && k == 0 && l == 0) || !asu.is_in(hkl) || ops.is_systematically_absent(hkl))
|
|
continue;
|
|
const double d2 = cell.calculate_1_d2(hkl);
|
|
if (d2 <= 0 || 1.0 / std::sqrt(d2) < d_min)
|
|
continue;
|
|
// Wilson draw from a hash of the index: deterministic, and spanning a realistic range.
|
|
const uint32_t seed = static_cast<uint32_t>(h * 73856093 ^ k * 19349663 ^ l * 83492791);
|
|
const double u = ((seed * 2654435761u) >> 8) / static_cast<double>(1 << 24);
|
|
const double wilson = -std::log(std::max(1e-6, u));
|
|
// The tensor is applied in the Cartesian frame, s = F^T h.
|
|
const gemmi::Vec3 s = cell.frac.mat.left_multiply(gemmi::Vec3(h, k, l));
|
|
const double aniso = -0.5 * b_dev.r_u_r(s);
|
|
MergedReflection r;
|
|
r.h = h; r.k = k; r.l = l;
|
|
r.d = static_cast<float>(1.0 / std::sqrt(d2));
|
|
r.I = static_cast<float>(1000.0 * wilson * std::exp(-0.5 * b_iso * d2 + aniso));
|
|
r.sigma = static_cast<float>(0.02 * std::fabs(r.I) + 1.0);
|
|
out.push_back(r);
|
|
}
|
|
return out;
|
|
}
|
|
}
|
|
|
|
// The number of free deviatoric anisotropy parameters is fixed by the Laue class alone. This is the
|
|
// self-test of the constraint basis: 5 / 3 / 2 / 1 / 1 / 0 for triclinic / monoclinic / orthorhombic /
|
|
// tetragonal / trigonal-hexagonal / cubic, and nothing else is possible.
|
|
TEST_CASE("Anisotropy free-parameter count", "[anisotropy]") {
|
|
struct Case {
|
|
const char *space_group;
|
|
gemmi::UnitCell cell;
|
|
int free_directions;
|
|
};
|
|
const std::vector<Case> cases{
|
|
{"P 1", Cell(51, 62, 73, 84.0, 95.0, 103.0), 5},
|
|
{"P 1 21 1", Cell(51, 62, 73, 90.0, 95.0, 90.0), 3},
|
|
{"C 1 2 1", Cell(91, 62, 73, 90.0, 105.0, 90.0), 3},
|
|
{"P 21 21 21", Cell(51, 62, 73, 90.0, 90.0, 90.0), 2},
|
|
{"I 2 2 2", Cell(51, 62, 73, 90.0, 90.0, 90.0), 2},
|
|
{"P 43 21 2", Cell(79, 79, 38, 90.0, 90.0, 90.0), 1}, // lysozyme, the field's test specimen
|
|
{"P 31 2 1", Cell(62, 62, 91, 90.0, 90.0, 120.0), 1},
|
|
{"R 3 :H", Cell(78, 78, 33, 90.0, 90.0, 120.0), 1},
|
|
{"P 63", Cell(62, 62, 91, 90.0, 90.0, 120.0), 1},
|
|
{"I 2 3", Cell(78, 78, 78, 90.0, 90.0, 90.0), 0},
|
|
{"F 4 3 2", Cell(78, 78, 78, 90.0, 90.0, 90.0), 0},
|
|
};
|
|
for (const auto &c : cases) {
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name(c.space_group);
|
|
REQUIRE(sg != nullptr);
|
|
std::vector<MergedReflection> merged(1);
|
|
merged[0].h = 1; merged[0].k = 0; merged[0].l = 0;
|
|
merged[0].I = 1.0f; merged[0].sigma = 1.0f; merged[0].d = 10.0f;
|
|
const auto result = AnalyzeAnisotropy(merged, {}, c.cell, sg);
|
|
INFO(c.space_group);
|
|
CHECK(result.n_free_parameters == c.free_directions);
|
|
}
|
|
}
|
|
|
|
// A refined cell need not obey its space group's metric constraints exactly, and rugnux writes the
|
|
// unconstrained refined cell. An angle a hundredth of a degree off 90 must not create an extra
|
|
// anisotropy direction.
|
|
TEST_CASE("Anisotropy free-parameter count with an off-metric refined cell", "[anisotropy]") {
|
|
std::vector<MergedReflection> merged(1);
|
|
merged[0].h = 1; merged[0].k = 0; merged[0].l = 0;
|
|
merged[0].I = 1.0f; merged[0].sigma = 1.0f; merged[0].d = 10.0f;
|
|
CHECK(AnalyzeAnisotropy(merged, {}, Cell(51, 62, 73, 90.02, 105.0, 89.97),
|
|
gemmi::find_spacegroup_by_name("C 1 2 1")).n_free_parameters == 3);
|
|
CHECK(AnalyzeAnisotropy(merged, {}, Cell(51.0, 62.0, 73.0, 89.98, 90.03, 90.01),
|
|
gemmi::find_spacegroup_by_name("I 2 2 2")).n_free_parameters == 2);
|
|
CHECK(AnalyzeAnisotropy(merged, {}, Cell(78.01, 77.99, 78.02, 90.01, 89.99, 90.0),
|
|
gemmi::find_spacegroup_by_name("I 2 3")).n_free_parameters == 0);
|
|
}
|
|
|
|
// A cubic crystal has no free deviatoric parameter, so its deltaB is exactly zero by symmetry - not
|
|
// small, not measured, zero - and the verdict is a statement about symmetry rather than about data.
|
|
TEST_CASE("Anisotropy is exactly zero in a cubic Laue class", "[anisotropy]") {
|
|
const gemmi::UnitCell cell = Cell(78, 78, 78, 90, 90, 90);
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("I 2 3");
|
|
const auto merged = SyntheticMerge(cell, sg, 2.5, 20.0, {8.0, -4.0, -4.0, 0.0, 0.0, 0.0});
|
|
REQUIRE(merged.size() > 1000);
|
|
const auto result = AnalyzeAnisotropy(merged, {}, cell, sg);
|
|
CHECK(result.n_free_parameters == 0);
|
|
CHECK(result.delta_b == 0.0);
|
|
CHECK(result.verdict == AnisotropyVerdict::NotDetected);
|
|
}
|
|
|
|
// The tensor itself: put a known deviatoric B into a tetragonal merge and read it back. The
|
|
// tetragonal Laue class leaves one free direction, along c*, and its magnitude is what deltaB means.
|
|
TEST_CASE("Anisotropy tensor is recovered from a synthetic merge", "[anisotropy]") {
|
|
const gemmi::UnitCell cell = Cell(79, 79, 38, 90, 90, 90);
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 43 21 2");
|
|
// Uniaxial about c, deltaB = B_zz - B_xx = 15 A^2.
|
|
const gemmi::SMat33<double> b_dev{-5.0, -5.0, 10.0, 0.0, 0.0, 0.0};
|
|
const auto merged = SyntheticMerge(cell, sg, 2.0, 20.0, b_dev);
|
|
REQUIRE(merged.size() > 2000);
|
|
const auto result = AnalyzeAnisotropy(merged, {}, cell, sg);
|
|
REQUIRE(result.n_free_parameters == 1);
|
|
REQUIRE(result.n_cells > 0);
|
|
CHECK(result.delta_b == Catch::Approx(15.0).margin(1.5));
|
|
// c* is the weak direction here, so the largest principal value points along z.
|
|
CHECK(std::fabs(result.eigenvector[0][2]) == Catch::Approx(1.0).margin(0.05));
|
|
// A pure Debye-Waller fall-off is a straight line through the origin in s^2.
|
|
CHECK(result.shape == AnisotropyShape::Linear);
|
|
// With no unmerged observations the systematic-error scale cannot be measured, and the verdict
|
|
// says so rather than falling back on a counting-statistics error bar.
|
|
CHECK(result.verdict == AnisotropyVerdict::CannotDetermine);
|
|
CHECK_FALSE(result.refusal.empty());
|
|
}
|
|
|
|
// An isotropic merge must not produce a tensor, whatever the Laue class allows.
|
|
TEST_CASE("Anisotropy of an isotropic synthetic merge is small", "[anisotropy]") {
|
|
const gemmi::UnitCell cell = Cell(79, 79, 38, 90, 90, 90);
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 43 21 2");
|
|
const auto merged = SyntheticMerge(cell, sg, 2.0, 20.0, {0.0, 0.0, 0.0, 0.0, 0.0, 0.0});
|
|
REQUIRE(merged.size() > 2000);
|
|
const auto result = AnalyzeAnisotropy(merged, {}, cell, sg);
|
|
REQUIRE(result.n_cells > 0);
|
|
CHECK(std::fabs(result.delta_b) < 1.0);
|
|
}
|
|
|
|
namespace {
|
|
// One partial of reflection (h,k,l) on image `frame`, with the fields ScaledObservations reads.
|
|
Reflection Partial(int h, int k, int l, float frame, float I, float partiality) {
|
|
Reflection r{};
|
|
r.h = h; r.k = k; r.l = l;
|
|
r.image_number = frame;
|
|
r.d = 3.0f;
|
|
r.I = I;
|
|
r.sigma = 10.0f;
|
|
r.rlp = 1.0f;
|
|
r.partiality = partiality;
|
|
return r;
|
|
}
|
|
}
|
|
|
|
// ScaledObservations assembles rotation partials into fulls, and - because the floor downstream only
|
|
// ever reads a sample of the unique reflections - it may hand back a sample of them rather than all.
|
|
// This pins the assembly on a run far below the sampling cap, where the sample is everything: the
|
|
// partials of one reflection over consecutive frames become one full on the merge's own scale, a gap
|
|
// wider than the combine's splits the run in two, an event that caught too little of its rocking
|
|
// curve is dropped, and an image with no fitted scale contributes nothing.
|
|
TEST_CASE("Rotation partials are assembled into scaled fulls", "[anisotropy]") {
|
|
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 43 21 2");
|
|
std::vector<IntegrationOutcome> outcomes(8);
|
|
for (auto &o : outcomes)
|
|
o.image_scale_g = 2.0f;
|
|
// (1,2,3) is measured over frames 0-2, then again over frames 6-7 after a four-frame gap.
|
|
outcomes[0].reflections.push_back(Partial(1, 2, 3, 0.0f, 100.0f, 0.25f));
|
|
outcomes[1].reflections.push_back(Partial(1, 2, 3, 1.0f, 300.0f, 0.50f));
|
|
outcomes[2].reflections.push_back(Partial(1, 2, 3, 2.0f, 100.0f, 0.25f));
|
|
outcomes[6].reflections.push_back(Partial(1, 2, 3, 6.0f, 200.0f, 0.40f));
|
|
outcomes[7].reflections.push_back(Partial(1, 2, 3, 7.0f, 300.0f, 0.60f));
|
|
// A second reflection whose two partials sum to less than min_partiality: not a measurement.
|
|
outcomes[3].reflections.push_back(Partial(4, 5, 6, 3.0f, 50.0f, 0.10f));
|
|
outcomes[4].reflections.push_back(Partial(4, 5, 6, 4.0f, 50.0f, 0.15f));
|
|
// A third on an image with no fitted per-image scale.
|
|
outcomes[5].image_scale_g.reset();
|
|
outcomes[5].reflections.push_back(Partial(7, 8, 9, 5.0f, 500.0f, 1.00f));
|
|
|
|
const auto obs = ScaledObservations(outcomes, /*rotation=*/true, sg);
|
|
REQUIRE(obs.size() == 2);
|
|
for (const auto &o : obs) {
|
|
CHECK(o.h == 1);
|
|
CHECK(o.k == 2);
|
|
CHECK(o.l == 3);
|
|
}
|
|
// Both events are complete, so their partialities sum to 1 and the divisor leaves them alone;
|
|
// what is left is the summed intensity over the per-image scale.
|
|
CHECK(obs[0].I == Catch::Approx(250.0)); // (100 + 300 + 100) / 2
|
|
CHECK(obs[1].I == Catch::Approx(250.0)); // (200 + 300) / 2
|
|
// A still is a whole measurement of its reflection, so there is nothing to assemble and nothing
|
|
// to sample: each partial stands or falls on its own partiality, and only two clear the floor.
|
|
const auto stills = ScaledObservations(outcomes, /*rotation=*/false, sg);
|
|
CHECK(stills.size() == 2);
|
|
}
|