v1.0.0-rc.164 (#74)
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>
This commit was merged in pull request #74.
This commit is contained in:
2026-08-26 22:47:00 +02:00
committed by leonarski_f
parent 61c603b274
commit 749db470ca
208 changed files with 7144 additions and 748 deletions
+135 -42
View File
@@ -246,26 +246,16 @@ SearchSpaceGroupResult SearchSpaceGroup(
pass_absence[i] = in_range;
}
// A fixed I/sigma cut is a statement about the error model as much as about a reflection: on a weak
// merge, whose error-model sigmas are large for every reflection, NOTHING passes, every operator is
// left with no pairs and the point group collapses to 1. Measured over the rotation battery, the four
// crystals that lose symmetry are exactly the four whose search merge is weakest - just above this
// cut - and that drop below it when the integration background changes.
// So cap the cut at the merge's own I/sigma quantile: the correlation stage always keeps at least
// its strongest quarter. This is a no-op on any merge where the fixed cut already keeps that many.
constexpr double MIN_PRESENT_FRACTION = 0.25;
// present_i_over_sigma is a cut on the reflection's own significance, and the merged I/sigma is not
// that: it carries the error model's (b*I)^2 term, so it saturates at ISa = 1/b for a reflection
// measured once and stops rising with the intensity above that knee. Convert the cut to the
// quantity the merge exports, once, here - see SearchSpaceGroupOptions::merge_isa for the
// derivation and for what it is worth. An unknown ISa (or b = 0, no systematic term) leaves the cut
// exactly where the caller set it.
double present_cut = opt.present_i_over_sigma;
{
std::vector<double> v;
v.reserve(n);
for (size_t i = 0; i < n; ++i)
if (pass_absence[i])
v.push_back(IoverSigma[i]);
if (!v.empty()) {
const size_t k = static_cast<size_t>((1.0 - MIN_PRESENT_FRACTION) * (v.size() - 1));
std::nth_element(v.begin(), v.begin() + k, v.end());
present_cut = std::min(present_cut, v[k]);
}
if (opt.merge_isa > 0.0) {
const double r = opt.present_i_over_sigma / opt.merge_isa;
present_cut = opt.present_i_over_sigma / std::sqrt(1.0 + r * r);
}
// The correlation stage uses only genuinely-present reflections. Near-zero (systematically
@@ -276,18 +266,24 @@ SearchSpaceGroupResult SearchSpaceGroup(
pass_cc[i] = pass_absence[i] && IoverSigma[i] >= present_cut &&
(opt.min_i_over_sigma <= 0 || IoverSigma[i] >= opt.min_i_over_sigma);
// Resolution-normalised intensity E^2 = I / <I>(shell), from equal-count resolution shells over
// the reflections the absence test uses. Lets the absence test judge "present" by intensity
// magnitude, not by a possibly under-estimated sigma (see present_e_squared).
std::vector<double> Esq(n, 0.0);
{
// Resolution-normalised intensity E^2 = I / <I>(shell), from equal-count resolution shells over a
// given subset of the merge. Which subset matters: E^2 is only free of the resolution fall-off on
// the population it was normalised over, so a caller has to normalise over the reflections it will
// actually use (see Ecc below).
auto shell_normalised = [&](const std::vector<char>& subset) {
std::vector<double> E(n, 0.0);
std::vector<size_t> order;
order.reserve(n);
for (size_t i = 0; i < n; ++i)
if (pass_absence[i])
if (subset[i])
order.push_back(i);
std::sort(order.begin(), order.end(),
[&](size_t a, size_t b) { return merged[a].d > merged[b].d; }); // low res -> high res
// A hundred reflections a shell, at most twenty-five shells - and integer division, so
// below two hundred there is a single shell and E^2 is I over one global mean. That
// leaves the operator correlation exactly where it was, Pearson being invariant to a
// common scale, but not the E^2 overlap cap below: it then cuts on I against the whole
// merge instead of against each reflection's own shell.
const int bins = std::clamp(static_cast<int>(order.size() / 100), 1, 25);
const size_t per = (order.size() + bins - 1) / std::max(1, bins);
for (size_t b = 0; b * per < order.size(); ++b) {
@@ -297,9 +293,14 @@ SearchSpaceGroupResult SearchSpaceGroup(
sum += I[order[j]];
const double mean = (hi > lo) ? sum / static_cast<double>(hi - lo) : 0.0;
for (size_t j = lo; j < hi; ++j)
Esq[order[j]] = mean > 0.0 ? I[order[j]] / mean : 0.0;
E[order[j]] = mean > 0.0 ? I[order[j]] / mean : 0.0;
}
}
return E;
};
// Over the reflections the absence test uses, so it can judge "present" by intensity magnitude
// rather than by a possibly under-estimated sigma (see present_e_squared).
const std::vector<double> Esq = shell_normalised(pass_absence);
// Overlap guard (Stage A / correlation only): drop the extreme resolution-normalised-E tail, which
// on a two-lattice crystal is the one-sided overlap contamination that poisons the operator CC.
@@ -309,6 +310,32 @@ SearchSpaceGroupResult SearchSpaceGroup(
if (pass_cc[i] && Esq[i] > opt.max_e_squared_for_cc)
pass_cc[i] = false;
// The operator correlation is scored on E^2, not on raw I. Both members of a symmetry pair sit at
// the same |s|, so the resolution fall-off is variance shared perfectly between the two arms of
// every pair: a Pearson CC on raw I measures the fall-off as well as the symmetry, and the fall-off
// lifts a FALSE operator's CC as much as a true one's. Measured over the rotation battery with
// shell-matched random pairing as the null for a metrically-allowed false operator, that raw-I noise
// floor has a median of 0.31 and reaches 0.53 on one crystal - above the old bound of 0.5 outright -
// and it varies more between crystals (spread 0.46) than the whole true/false gap is wide (0.38), so
// an absolute bound on it was a different test on every crystal. It also moves with the search
// resolution cut, by a median 0.09 and up to 0.23, which is what made that cut decide symmetries.
// Normalised, that floor has a median of 0.015 and a maximum of 0.06, and moves by a median 0.03.
// Following POINTLESS, which likewise scores each symmetry element on normalised intensities
// (Evans, Acta Cryst. D62, 72-82 (2006)).
//
// Normalised over pass_cc - the reflections the correlation actually pairs - and NOT over the
// pass_absence set Esq uses. pass_cc keeps only the stronger reflections and the fraction it keeps
// itself falls with resolution, so an E^2 built on pass_absence still carries a resolution trend
// inside the pass_cc subset; reusing Esq here makes the coupling WORSE than raw I (floor movement
// 0.14). Esq stays as it is: the absence tests need their own set, and the E^2 cap above helps
// DEFINE pass_cc, so normalising that over pass_cc would be circular.
//
// The price is a new dependence in place of the old one: the CC now moves with whatever defines
// pass_cc, i.e. with present_i_over_sigma and the merge_isa conversion above. That conversion is
// what keeps the dependence harmless - it holds the cut at one counting significance on every
// crystal, so the population this is normalised over means the same thing on all of them.
const std::vector<double> Ecc = shell_normalised(pass_cc);
std::unordered_map<HKLKey, int, HKLKeyHash> key_to_index;
key_to_index.reserve(n * 2);
for (size_t i = 0; i < n; ++i)
@@ -321,7 +348,8 @@ SearchSpaceGroupResult SearchSpaceGroup(
auto score_operator = [&](const gemmi::Op& op, std::vector<uint32_t>& visited,
uint32_t& epoch) -> SpaceGroupOperatorScore {
++epoch;
std::vector<double> x, y;
std::vector<double> x, y; // raw merged I of each pair, for the H statistic
std::vector<double> ex, ey; // the same pairs as E^2, for the correlation
for (size_t i = 0; i < n; ++i) {
if (!pass_cc[i] || visited[i] == epoch)
continue;
@@ -337,14 +365,21 @@ SearchSpaceGroupResult SearchSpaceGroup(
continue;
x.push_back(I[i]);
y.push_back(I[j]);
ex.push_back(Ecc[i]);
ey.push_back(Ecc[j]);
visited[i] = epoch;
visited[j] = epoch;
}
SpaceGroupOperatorScore s;
s.op_triplet_hkl = op.as_hkl().triplet('h');
s.n_pairs = static_cast<int>(x.size());
s.cc = PearsonCC(x, y);
s.cc = PearsonCC(ex, ey);
// Sigma-free disagreement over the same pairs (see SpaceGroupOptions::max_operator_h_ratio).
// On RAW I, deliberately - which is why the pairs are collected twice. The shell divisor cancels
// in |I1-I2|/(I1+I2) exactly for a pair whose members share a shell, but not for one that
// straddles a shell boundary: measured over the battery's operators, normalising moves H by a
// median 0.08% but by 3.7% at p95 and 12% at worst, and max_operator_h_ratio has been decided on
// a margin of 1.8%. H is calibrated on raw I and stays there.
std::vector<double> hv;
hv.reserve(x.size());
for (size_t p = 0; p < x.size(); ++p) {
@@ -375,8 +410,9 @@ SearchSpaceGroupResult SearchSpaceGroup(
// Conjugate rotations (symmetry-equivalent within the point group) relate symmetry-equivalent
// reflection sets, so on real data their CCs cluster; a noisy crystal can push one class member
// below min_operator_cc while the class is unmistakably present (e.g. one cubic 3-fold at 0.48
// among siblings at 0.53-0.66). Judge each conjugacy class by its mean CC, not its weakest
// below min_operator_cc while the class is unmistakably present (this was first seen on a cubic
// crystal whose three 3-folds spread over 0.13 of CC with only the weakest below the bound).
// Judge each conjugacy class by its mean CC, not its weakest
// member, so a genuine high-symmetry point group is not lost to one marginal operator. chi2_under
// (below) remains the safety net against a truly false promotion. Returns {all classes present,
// worst class-mean CC}.
@@ -557,7 +593,10 @@ SearchSpaceGroupResult SearchSpaceGroup(
// Operator-CC-confirmed candidates, each with its merge chi^2 and systematic-error b; chi2_ref =
// the most consistent.
struct PGCand { const PointGroupInfo* pg; int order; double min_class_cc; double chi2; double b_extra; };
struct PGCand { const PointGroupInfo* pg; int order; double min_class_cc; double chi2; double b_extra;
// Filled by the selection loop below and carried so the adopted candidate's H
// ratio can be reported whether or not the bound had anything to say about it.
double h_ratio = std::numeric_limits<double>::quiet_NaN(); };
int refused_order = 0;
std::string refused_pg_hm, refused_why;
std::vector<PGCand> pg_cands;
@@ -601,7 +640,7 @@ SearchSpaceGroupResult SearchSpaceGroup(
const PointGroupInfo* best_pg = nullptr;
int best_pg_order = 0;
double best_pg_min_cc = -2.0;
for (const auto& c : pg_cands) {
for (auto& c : pg_cands) {
// A genuine symmetry operator merges equivalent reflections, so it barely changes the reduced
// chi^2 relative to the best subgroup - across the whole rotation-test battery every correct
// point group stays within ~1.7x, even on weak or badly-integrated data (a cubic F432 chi2_ref
@@ -681,6 +720,7 @@ SearchSpaceGroupResult SearchSpaceGroup(
}
}
}
c.h_ratio = h_ratio;
// The chi^2 ratio is only trustworthy when the error model is calibrated. When even the best
// subgroup's reduced chi^2 (chi2_ref) is far above 1 - weak, low-resolution data whose merged
// sigmas are badly under-estimated - the ratio grows with point-group order for genuine high
@@ -785,6 +825,14 @@ SearchSpaceGroupResult SearchSpaceGroup(
}
result.point_group_order = best_pg_order;
// The H ratio of the promotion that was ADOPTED, reported whether the bound had anything to say
// about it or not. Read after the choice is final, so a fixed_point_group override reports the
// ratio of the group it forced rather than of the one Stage A would have taken.
for (const auto& c : pg_cands)
if (c.pg == best_pg)
result.h_ratio = c.h_ratio;
result.h_ratio_bound = opt.max_operator_h_ratio;
// Only report a refusal that is actually ABOVE what was adopted.
if (refused_order > best_pg_order) {
result.refused_point_group_hm = refused_pg_hm;
@@ -841,10 +889,8 @@ SearchSpaceGroupResult SearchSpaceGroup(
// present_cut, not the fixed cut: on a merge weak enough that nothing clears the fixed cut,
// screw_violations is identically zero, so every screw axis passes unchallenged, and
// present_strong is zero, so the centering rescue below switches itself off on exactly the
// weak data it exists for. Stage A already caps the cut at the merge's own 75th percentile;
// reusing it here keeps the two stages on one definition. Where the fixed cut is already the
// smaller of the two - any merge with a healthy I/sigma - present_cut EQUALS it and this is
// a no-op.
// weak data it exists for. present_cut is the same cut converted to the counting scale the
// two stages share (see merge_isa); on a healthy merge it is the fixed cut to within 1%.
const bool present = IoverSigma[i] > present_cut &&
(opt.present_e_squared <= 0.0 || Esq[i] > opt.present_e_squared);
@@ -928,6 +974,9 @@ SearchSpaceGroupResult SearchSpaceGroup(
screw_control += static_cast<int>(count);
s.absent_violations += screw_violations;
s.centering_absent = centering_absent;
// A centred group whose centering-absent class is EMPTY was not tested, it was skipped: the
// reflections that would decide it are not in this merge (see centering_untested).
s.centering_untested = sg.centring_type() != 'P' && centering_absent == 0;
s.screw_absent = screw_absent;
s.screw_absence_evidence = ScrewAbsenceEvidence(screw_sum_u, screw_scored, screw_control);
@@ -1055,20 +1104,31 @@ std::string SearchSpaceGroupResultToText(const SearchSpaceGroupResult& result,
<< " (from intensity correlations)\n";
os << " " << std::setw(14) << std::left << "operator" << std::right
<< std::setw(9) << "CC" << std::setw(10) << "pairs" << std::setw(9) << "symm" << "\n";
<< std::setw(9) << "CC" << std::setw(10) << "pairs" << std::setw(9) << "symm"
<< std::setw(9) << "H" << "\n";
for (const auto& s : result.operator_scores) {
os << " " << std::setw(14) << std::left << s.op_triplet_hkl << std::right
<< std::setw(9) << std::fixed << std::setprecision(3) << s.cc
<< std::setw(10) << s.n_pairs
<< std::setw(9) << (s.present ? "yes" : "no") << "\n";
<< std::setw(9) << (s.present ? "yes" : "no")
<< std::setw(9) << std::fixed << std::setprecision(3) << s.h_stat << "\n";
}
os << " H = median |I1-I2|/(I1+I2) over the operator's pairs - the disagreement it implies, with\n"
" no sigma in it. The promotion gate is the RATIO of the mean H over the operators a\n"
" promotion adds to the mean over the parent group's own, which is what separates a real\n"
" symmetry (ratio near 1) from a merohedral twin law.\n";
if (std::isfinite(result.h_ratio))
os << " H ratio " << FormatDouble(result.h_ratio, 2) << " for the adopted point group (bound "
<< FormatDouble(result.h_ratio_bound, 2) << ").\n";
else
os << " H ratio not available (no parent group to normalise against, or too few pairs).\n";
os << "\nSpace-group candidates\n";
os << " " << std::setw(10) << std::left << "SG" << std::right
<< std::setw(9) << "absent" << std::setw(7) << "viol"
<< std::setw(11) << "<I/s>abs" << std::setw(11) << "<I/s>pres"
<< std::setw(9) << "screw" << std::setw(11) << "screw evid"
<< std::setw(6) << "OK" << "\n";
<< std::setw(6) << "OK" << std::setw(11) << "centering" << "\n";
const size_t count = std::min(max_candidates_to_print, result.candidates.size());
for (size_t i = 0; i < count; ++i) {
@@ -1080,14 +1140,21 @@ std::string SearchSpaceGroupResultToText(const SearchSpaceGroupResult& result,
<< std::setw(11) << std::fixed << std::setprecision(2) << c.present_mean_i_over_sigma
<< std::setw(9) << c.screw_absent
<< std::setw(11) << std::fixed << std::setprecision(1) << c.screw_absence_evidence
<< std::setw(6) << (c.consistent ? "yes" : "no") << "\n";
<< std::setw(6) << (c.consistent ? "yes" : "no")
<< std::setw(11) << (c.centering_untested ? "UNTESTED"
: (c.centering_absent > 0 ? "tested" : "-"))
<< "\n";
}
os << " absent/viol = reflections the group predicts absent, and how many are nonetheless present.\n"
" screw = how many of those lie on an axial row, i.e. are extinguished by a screw rather than\n"
" by the centering; screw evid = how unlikely that class would be if the screw did not exist,\n"
" judged against the rest of its own axial row (in nats - a real screw reads tens to hundreds,\n"
" a false one at or below zero). The <I/s> columns are the centering evidence; they say little\n"
" about screws, because the merged sigma shrinks with I on absent and present alike.\n";
" about screws, because the merged sigma shrinks with I on absent and present alike.\n"
" centering = whether the group's centering was decided here at all. UNTESTED means this\n"
" merge holds none of the reflections that centering extinguishes - the data are indexed on\n"
" the primitive sub-cell, so those reflections were never predicted or integrated - and the\n"
" candidate scores zero absences for want of evidence, not because the centering is real.\n";
if (result.best_space_group.has_value()) {
os << "Best space group: " << result.best_space_group->short_name();
@@ -1096,6 +1163,32 @@ std::string SearchSpaceGroupResultToText(const SearchSpaceGroupResult& result,
if (!result.alternatives.empty())
os << " (indistinguishable from these data)";
os << "\n";
// A group with a different CENTERING is a different lattice, not just a different group, and
// a run reports one cell - the chosen group's. "C2 or P21 or P2" printed beside a single
// C-centred cell is not something a user can act on: P2 and P21 live on the primitive
// sub-cell, with their own cell constants and their own Miller indices. Name the ones the
// reported cell does not describe, and say how far off it is for each.
const char sel_centring = result.best_space_group->centring_type();
bool said_setting = false;
for (const auto& alt : result.alternatives) {
if (alt.centring_type() == sel_centring)
continue;
if (!said_setting) {
os << " These are NOT all the same lattice, and one cell is reported for this run -\n"
" " << result.best_space_group->short_name() << "'s (centering " << sel_centring
<< "). These need a cell of their own:\n";
said_setting = true;
}
os << " " << std::setw(10) << std::left << alt.short_name() << std::right
<< "centering " << alt.centring_type() << ", cell volume "
<< std::fixed << std::setprecision(2)
<< static_cast<double>(gemmi::centring_vectors(alt.centring_type()).size())
/ static_cast<double>(gemmi::centring_vectors(sel_centring).size())
<< "x the reported one\n";
}
if (said_setting)
os << " Adopting one of those means reindexing to its cell; the cell constants reported\n"
" and the Miller indices written are the chosen group's alone.\n";
} else {
os << "Best space group: none determined\n";
}