Files
Jungfraujoch/image_analysis/scale_merge/AnisotropyAnalysis.cpp
T
leonarski_f 511be0c366
Build Packages / build:rpm (rocky8) (push) Successful in 24m0s
Build Packages / Unit tests (push) Skipped
Build Packages / build:windows:nocuda (push) Successful in 16m54s
Build Packages / build:windows:cuda (push) Successful in 19m25s
Build Packages / build:viewer-tgz:cpu (push) Successful in 14m44s
Build Packages / build:viewer-tgz:cuda (push) Successful in 16m3s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 13m15s
Build Packages / build:rugnux:windows (push) Successful in 10m45s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 9m34s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 19m7s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 18m9s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 24m48s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 18m13s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 24m51s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 22m58s
Build Packages / build:rpm (rocky9) (push) Successful in 21m23s
Build Packages / Generate python client (push) Successful in 1m2s
Build Packages / Build documentation (push) Successful in 1m23s
Build Packages / Create release (push) Skipped
Build Packages / XDS test (durin plugin) (push) Successful in 9m45s
Build Packages / XDS test (neggia plugin) (push) Successful in 10m19s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m10s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 22m15s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 17m37s
Build Packages / DIALS test (push) Successful in 17m16s
v1.0.0-rc.165 (#75)
* `rugnux --model` adopts the model's space group as a label where the data were merged in its enantiomorph, instead of reindexing the reflections - which swapped I(+) with I(-).
* `rugnux --model` warns, naming the atom, when the anomalous density at the model's atoms comes out inverted, which means the data and the model are in opposite hands.
* `rugnux --model` writes an anomalous difference map (`<prefix>_anom.ccp4`) when the merge kept the Bijvoet split, and names the ten model atoms it peaks highest on as `ANOMALOUS_SITE_01`..`_10`.
* `MEAN_ATOM_DENSITY_SIGMA` is read from the map by cubic rather than linear interpolation and comes out around a tenth higher; it is no longer comparable with the figure earlier versions printed.
* `rugnux --model` reads an mmCIF coordinate file as well as a PDB one, gzipped or not, taking the format from the file's content rather than its name.
* A model `rugnux --model` cannot use is reported as a `WARNING:` line in the results report instead of only in the log.
* The rugnux results report has a `10. MODEL VALIDATION` section when `--model` was given; `REPORT_VERSION` is 4, `WARNINGS` moves to section 11 and no existing key changed.
* The rugnux results report records how the run was invoked, what it cost and what it ran on: `COMMAND_LINE=`, `WALL_TIME=` and `GPU_COUNT=` / `GPU=`.
* rugnux says which GPUs it can see before it starts processing.
* `rugnux --export-unmerged` also writes `<prefix>_unmerged.mtz` on a `--no-merge` run, and is ignored on a run with no output prefix instead of writing a file called `_unmerged.mtz`.
* `/start` asks the writer whether the run can be written before the detector is armed, so a run whose master file already exists, or whose output directory cannot be created, is refused up front with the writer's own message. This needs the TCP image stream or the built-in HDF5 writer; the ZeroMQ stream is unchanged.
* A calibration that fails goes to `Error` carrying the reason instead of `Inactive`, so `/wait_till_done` and `/wait_until_running` report it; a cancelled calibration still goes to `Inactive`.
* `/wait_till_done` answers 500 with the message when a collection ended in an error. A cancelled collection and a collection that only triggered a warning still answer 200.
* A pending start failure is discarded by `/cancel` and `/deactivate`, as it already was by `/start` and `/initialize`.
* `/scan_result` no longer reports the previous run's images after a collection that failed to start, or after `/deactivate`.
* The TCP image stream protocol version is 4. `jfjoch_writer` and `jfjoch_broker` have to be of the same release, as before.

Reviewed-on: #75
2026-08-27 22:16:54 +02:00

1476 lines
74 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "AnisotropyAnalysis.h"
#include <algorithm>
#include <array>
#include <bit>
#include <cmath>
#include <cstdint>
#include <iomanip>
#include <numeric>
#include <tuple>
#include <sstream>
#include <Eigen/Dense>
#include "HKLKey.h"
#include "gemmi/eig3.hpp"
#include "gemmi/scaling.hpp"
namespace {
using Tensor = gemmi::SMat33<double>;
// ---------------------------------------------------------------- tuned constants
// The binning is the one the shape test was validated on: 12 equal-count resolution shells x 60
// directions on a hemisphere, a cell entering the fit once it holds 12 independent reflections.
// Shell count, direction count and the cell-population floor were each varied by a factor of two
// either way without moving a verdict, so none of the three is sharp.
constexpr int N_SHELLS = 12;
constexpr int N_DIRECTIONS = 60;
constexpr int MIN_CELL_REFLECTIONS = 12;
constexpr int MIN_CELLS = 40; // fewer than this and no tensor is fitted at all
constexpr int MIN_SHAPE_SHELLS = 5; // fewer than this and the s^2 signature is not fitted
constexpr int MIN_CELLS_PER_SHAPE_SHELL = 8;
// The whole diagnostic is refused below this: at <I/sigma> ~ 1 neither the tensor nor its shape is
// interpretable, and one battery dataset at 0.65 produced numbers that mean nothing.
constexpr double MIN_MEAN_I_OVER_SIGMA = 1.0;
// Below ~90 deg of OBSERVED rotation a lab-fixed systematic reaches a second and a third tensor
// direction (canonical correlation 0.74-0.85 against 0.05 at a full sweep), the anisotropy such a
// systematic manufactures grows 5-9x, and the power to detect a real 10 A^2 falls to <= 0.25.
constexpr double MIN_ROTATION_DEG = 90.0;
// Symmetry-expanded reflections are capped by striding the unique list: the estimator is a fit of a
// few tens of cell means and does not improve past this, while the memory does grow.
constexpr size_t MAX_ENTRIES = 4000000;
// The unmerged arm strides whole unique reflections down to this many. The systematic-error floor is
// set by the systematic, not by counting: over a 12x range in the number of unique reflections and a
// 10x range in <I/sigma> the smallest anisotropy the gate can establish moves by less than 2x, so
// more than this buys nothing and costs time and memory.
constexpr int MAX_CLUSTERS = 20000;
// The most partials ScaledObservations assembles on rotation data (see there). In partials because
// partials are what its sort costs, and generous because how many unique reflections that is
// depends on how many frames a rocking curve spans and how many mates the Laue class gives it,
// which together vary by an order of magnitude: over the rotation battery it leaves between 25
// thousand and 183 thousand of them, against the MAX_CLUSTERS the floor above actually reads.
constexpr size_t MAX_SAMPLE_PARTS = 8u << 20;
// splitmix64's finalizer. Sampling on this rather than on the key itself matters: a packed hkl key
// has h in its low bits, so any direct test on it selects a set of lattice planes, not a sample.
uint64_t MixKey(uint64_t x) {
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9ULL;
x = (x ^ (x >> 27)) * 0x94d049bb133111ebULL;
return x ^ (x >> 31);
}
// The cone half-angle of the directional diffraction limits, and the <I/sigma(I)> the limit is read
// at. Both are AIMLESS's shipped defaults (ANALYSIS CONE 20, ISIGMINIMUM 1.5 -> here 2.0, the value
// this project already uses for a resolution statement).
constexpr double CONE_HALF_ANGLE_DEG = 20.0;
constexpr double CONE_I_OVER_SIGMA = 2.0;
// The s^2 signature. FLAT is declared at c0 > +0.70 with c0/sigma(c0) > 6: over a 38-crystal battery
// that fires on six of the seven crystals whose deficit does not follow a Debye-Waller B, with no
// false positive among the other 24, and the false-positive count stays 0 anywhere between z > 5 and
// z > 8. A pure-B simulation on the same reflection lists returns |c0| <= 0.18, so the machinery
// cannot invent an intercept of this size. The curvature arm uses the same z for symmetry; it has no
// false-positive calibration of its own, and it is reported rather than acted on.
constexpr double FLAT_INTERCEPT = 0.70;
constexpr double SHAPE_Z = 6.0;
// CONVEX asks for the s^4 term to carry more than half the deficit at the resolution limit, not
// merely to be significant: a mild positive curvature is common and is not a different mechanism.
constexpr double CONVEX_SHARE = 0.5;
// Gate bands. The denominator is this dataset's own systematic error scale, not a counting-statistics
// error bar: against known ground truth a counting-statistics gate calls "real" in 15-58% of clean
// isotropic datasets, because the symmetry-forbidden directions are determined from contrasts BETWEEN
// symmetry mates of one reflection (which share their true |F|^2, so Wilson scatter cancels) while
// the symmetry-allowed ones can only be determined BETWEEN different reflections (which do not).
// 3.5 is a 2-5% false-positive rate pooled over six artefact classes and 6-14% in the worst of them;
// 5.0 is 0-0.5% and 0-3%.
constexpr double GATE_MARGINAL = 2.0;
constexpr double GATE_ESTABLISHED = 3.5;
constexpr double GATE_STRONG = 5.0;
// The forbidden subspace of a real dataset is dominated by systematics, not by counting noise, so
// this ratio is above 1 on essentially every dataset: measured over the battery its median is 6.5
// and only two of 22 exceed 20, so "it is above its counting null" would fire on almost all of
// them. This is the upper tail. Together with p == 1 and a gate that established nothing it picks
// out two datasets in the battery - one of them the crystal whose space-group determination is
// itself known to be unstable - where the unconditional p == 1 caution fired on 17 of 38.
constexpr double FORBIDDEN_Z_SUSPECT = 20.0;
// E[dB] / sigma_per_component for a zero-mean Gaussian tensor confined to a subspace: the eigenvalues
// of a noisily estimated tensor repel, so dB has a strictly positive expectation even when the tensor
// is exactly zero. Per-subspace Monte-Carlo medians, indexed by the dimension of the subspace;
// K_ALLOWED is for the symmetry-allowed subspaces, K_FORBIDDEN for the symmetry-forbidden ones (the
// two differ at the same dimension because they are different subspaces of the deviatoric space).
constexpr double K_ALLOWED[6] = {0.0, 0.824, 1.583, 2.075, 0.0, 2.872};
constexpr double K_FORBIDDEN[6] = {0.0, 0.0, 1.66, 2.13, 2.53, 2.87};
// ---------------------------------------------------------------- tensor algebra
double FrobDot(const Tensor &a, const Tensor &b) {
return a.u11 * b.u11 + a.u22 * b.u22 + a.u33 * b.u33
+ 2.0 * (a.u12 * b.u12 + a.u13 * b.u13 + a.u23 * b.u23);
}
Tensor Scale(const Tensor &a, double s) {
return {a.u11 * s, a.u22 * s, a.u33 * s, a.u12 * s, a.u13 * s, a.u23 * s};
}
Tensor Deviatoric(const Tensor &a) { return a.added_kI(-a.trace() / 3.0); }
// s^T E s from the cell-averaged outer product q = <(sx^2, sy^2, sz^2, sx sy, sx sz, sy sz)>.
double QuadForm(const Tensor &e, const std::array<double, 6> &q) {
return e.u11 * q[0] + e.u22 * q[1] + e.u33 * q[2]
+ 2.0 * (e.u12 * q[3] + e.u13 * q[4] + e.u23 * q[5]);
}
// Gram-Schmidt in the Frobenius inner product; directions that the earlier ones already span drop out.
std::vector<Tensor> Orthonormalise(const std::vector<Tensor> &raw) {
std::vector<Tensor> out;
for (Tensor t : raw) {
for (const auto &o : out)
t = t - Scale(o, FrobDot(t, o));
const double n = std::sqrt(FrobDot(t, t));
if (n > 1e-9)
out.push_back(Scale(t, 1.0 / n));
}
return out;
}
// The cell with its lengths and angles snapped to what the crystal system requires.
gemmi::UnitCell MetricIdealCell(const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg) {
if (!sg)
return cell;
double a = cell.a, b = cell.b, c = cell.c;
double alpha = cell.alpha, beta = cell.beta, gamma = cell.gamma;
switch (sg->crystal_system()) {
case gemmi::CrystalSystem::Triclinic:
break;
case gemmi::CrystalSystem::Monoclinic:
if (sg->monoclinic_unique_axis() == 'a') { beta = 90.0; gamma = 90.0; }
else if (sg->monoclinic_unique_axis() == 'c') { alpha = 90.0; beta = 90.0; }
else { alpha = 90.0; gamma = 90.0; }
break;
case gemmi::CrystalSystem::Orthorhombic:
alpha = beta = gamma = 90.0;
break;
case gemmi::CrystalSystem::Tetragonal:
a = b = 0.5 * (a + b);
alpha = beta = gamma = 90.0;
break;
case gemmi::CrystalSystem::Trigonal:
case gemmi::CrystalSystem::Hexagonal:
if (sg->ext == 'R') {
a = b = c = (a + b + c) / 3.0;
alpha = beta = gamma = (alpha + beta + gamma) / 3.0;
} else {
a = b = 0.5 * (a + b);
alpha = beta = 90.0;
gamma = 120.0;
}
break;
case gemmi::CrystalSystem::Cubic:
a = b = c = (a + b + c) / 3.0;
alpha = beta = gamma = 90.0;
break;
}
gemmi::UnitCell out;
out.set(a, b, c, alpha, beta, gamma);
return out;
}
// The symmetry-allowed anisotropy directions, deviatoric, in the Cartesian frame.
//
// gemmi::adp_symmetry_constraints gives the standard ADP symmetry constraints in the crystal-axis
// (B*) parameterisation - 6 / 4 / 3 / 2 / 2 / 1 vectors for triclinic / monoclinic / orthorhombic /
// tetragonal / trigonal-hexagonal / cubic. Transforming each to Cartesian is a bijection on symmetric
// matrices, and the isotropic direction is always allowed, so removing the trace leaves exactly
// 5 / 3 / 2 / 1 / 1 / 0 free deviatoric parameters.
//
// Following Sheriff & Hendrickson (1987) Acta Cryst. A43, 118-121
std::vector<Tensor> AllowedDeviatoricBasis(const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg) {
std::vector<Tensor> raw;
// The constraints are a statement about the IDEAL metric, and rugnux writes the unconstrained
// refined cell: a beta of 89.97 deg in a C2 crystal leaves the isotropic tensor outside the span
// of the monoclinic constraints, the trace is then not fully removed, and the count comes out
// one too high. A cell whose angle is a hundredth of a degree off does not create a fourth
// anisotropy direction, so the basis is built on the idealised metric.
const gemmi::UnitCell ideal = MetricIdealCell(cell, sg);
for (const gemmi::Vec6 &v : gemmi::adp_symmetry_constraints(sg)) {
const Tensor b_star{v[0], v[1], v[2], v[3], v[4], v[5]};
raw.push_back(Deviatoric(b_star.transformed_by<double>(ideal.orth.mat)));
}
return Orthonormalise(raw);
}
std::vector<Tensor> FullDeviatoricBasis() {
const std::vector<Tensor> raw{{1, 0, 0, 0, 0, 0}, {0, 1, 0, 0, 0, 0}, {0, 0, 1, 0, 0, 0},
{0, 0, 0, 1, 0, 0}, {0, 0, 0, 0, 1, 0}, {0, 0, 0, 0, 0, 1}};
std::vector<Tensor> dev;
dev.reserve(raw.size());
for (const auto &t : raw)
dev.push_back(Deviatoric(t));
return Orthonormalise(dev);
}
// What is left of the deviatoric space once the symmetry-allowed directions are taken out. In those
// directions the true tensor is exactly zero by symmetry, whatever the crystal is - so whatever is
// measured there is this dataset's own systematic error, measured on this dataset.
std::vector<Tensor> ForbiddenBasis(const std::vector<Tensor> &full, const std::vector<Tensor> &allowed) {
std::vector<Tensor> raw;
for (Tensor t : full) {
for (const auto &a : allowed)
t = t - Scale(a, FrobDot(t, a));
raw.push_back(t);
}
return Orthonormalise(raw);
}
// ---------------------------------------------------------------- binning
// A Fibonacci spiral on the hemisphere: near-uniform, and no pole or seam where a lattice direction
// could pile up.
std::vector<gemmi::Vec3> HemisphereDirections(int n) {
std::vector<gemmi::Vec3> d;
d.reserve(n);
const double golden = gemmi::pi() * (1.0 + std::sqrt(5.0));
for (int i = 0; i < n; ++i) {
const double z = (i + 0.5) / n;
const double r = std::sqrt(std::max(0.0, 1.0 - z * z));
const double phi = golden * (i + 0.5);
d.emplace_back(r * std::cos(phi), r * std::sin(phi), z);
}
return d;
}
struct Cells {
std::vector<double> mu, se, s2;
std::vector<std::array<double, 6>> q;
std::vector<int> shell, n;
// The cluster structure, kept so the covariance can be made robust to it: one entry per
// measurement that went into a cell, grouped by the unique reflection it belongs to.
std::vector<int32_t> entry_cell, entry_cluster;
std::vector<float> entry_value;
int n_shells = 0;
};
struct Usable {
std::array<int, 3> hkl;
double value; // epsilon-corrected merged intensity, negatives kept
double s2;
int shell = 0;
};
std::vector<Usable> SelectReflections(const std::vector<MergedReflection> &merged,
const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg,
int n_shells) {
const gemmi::GroupOps gops = sg ? sg->operations() : gemmi::GroupOps{};
std::vector<Usable> u;
u.reserve(merged.size());
for (const auto &r : merged) {
if (!std::isfinite(r.I) || !std::isfinite(r.sigma) || r.sigma <= 0.0f || !(r.d > 0.0f))
continue;
const std::array<int, 3> hkl{r.h, r.k, r.l};
const double eps = sg ? std::max(1, gops.epsilon_factor(hkl)) : 1;
u.push_back({hkl, r.I / eps, 1.0 / (static_cast<double>(r.d) * r.d), 0});
}
if (u.empty())
return u;
// Equal-count shells in s^2. Every symmetry copy of a reflection has the same |s|, so equal-count
// over the expanded set and over the unique set are the same edges.
std::vector<int> order(u.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int a, int b) { return u[a].s2 < u[b].s2; });
for (size_t i = 0; i < order.size(); ++i)
u[order[i]].shell = std::min(n_shells - 1, static_cast<int>(i * n_shells / order.size()));
return u;
}
// Keep the cells that hold enough measurements, renumber them, and carry the entry list over.
void FinishCells(Cells &c, const std::vector<double> &sum, const std::vector<double> &sumsq,
const std::vector<double> &s2sum, const std::vector<std::array<double, 6>> &qsum,
const std::vector<int> &count, const std::vector<int32_t> &raw_cell,
const std::vector<int32_t> &raw_cluster, const std::vector<float> &raw_value) {
std::vector<int32_t> remap(count.size(), -1);
for (size_t cid = 0; cid < count.size(); ++cid) {
if (count[cid] < MIN_CELL_REFLECTIONS)
continue;
const double n = count[cid];
const double mean = sum[cid] / n;
const double var = std::max(0.0, (sumsq[cid] - n * mean * mean) / (n - 1.0));
remap[cid] = static_cast<int32_t>(c.mu.size());
c.mu.push_back(mean);
c.se.push_back(std::sqrt(var / n));
c.s2.push_back(s2sum[cid] / n);
c.q.push_back({qsum[cid][0] / n, qsum[cid][1] / n, qsum[cid][2] / n,
qsum[cid][3] / n, qsum[cid][4] / n, qsum[cid][5] / n});
c.shell.push_back(static_cast<int>(cid) / N_DIRECTIONS);
c.n.push_back(count[cid]);
}
// A cell whose measurements happen to agree exactly has no error bar of its own; give it the
// typical one rather than an infinite weight.
std::vector<double> positive;
for (double s : c.se)
if (s > 0.0)
positive.push_back(s);
if (!positive.empty()) {
std::nth_element(positive.begin(), positive.begin() + positive.size() / 2, positive.end());
const double median = positive[positive.size() / 2];
for (double &s : c.se)
if (!(s > 0.0))
s = median;
}
for (size_t e = 0; e < raw_cell.size(); ++e)
if (remap[raw_cell[e]] >= 0) {
c.entry_cell.push_back(remap[raw_cell[e]]);
c.entry_cluster.push_back(raw_cluster[e]);
c.entry_value.push_back(raw_value[e]);
}
}
Cells BuildCells(const std::vector<Usable> &refl, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup *sg, int n_shells) {
Cells c;
c.n_shells = n_shells;
const std::vector<gemmi::Op> ops = sg ? sg->operations().sym_ops : std::vector<gemmi::Op>{gemmi::Op::identity()};
const auto dirs = HemisphereDirections(N_DIRECTIONS);
// Friedel needs no separate operation: -s folds onto the same hemisphere direction as s.
const size_t stride = std::max<size_t>(1, (refl.size() * ops.size() + MAX_ENTRIES - 1) / MAX_ENTRIES);
const int n_cells_total = n_shells * N_DIRECTIONS;
std::vector<double> sum(n_cells_total, 0.0), sumsq(n_cells_total, 0.0), s2sum(n_cells_total, 0.0);
std::vector<std::array<double, 6>> qsum(n_cells_total, {0, 0, 0, 0, 0, 0});
std::vector<int> count(n_cells_total, 0);
std::vector<int32_t> raw_cell, raw_cluster;
std::vector<float> raw_value;
std::vector<std::pair<int, gemmi::Vec3>> hits; // (cell, s) of this reflection's copies
for (size_t i = 0; i < refl.size(); i += stride) {
const auto &r = refl[i];
hits.clear();
for (const auto &op : ops) {
const gemmi::Op::Miller m = op.apply_to_hkl(r.hkl);
gemmi::Vec3 s = cell.frac.mat.left_multiply(gemmi::Vec3(m[0], m[1], m[2]));
if (s.z < 0)
s = gemmi::Vec3(-s.x, -s.y, -s.z);
const double len = s.length();
if (!(len > 0))
continue;
const gemmi::Vec3 unit(s.x / len, s.y / len, s.z / len);
int best = 0;
double best_dot = -1.0;
for (int j = 0; j < N_DIRECTIONS; ++j) {
const double dot = std::fabs(unit.dot(dirs[j]));
if (dot > best_dot) { best_dot = dot; best = j; }
}
hits.emplace_back(r.shell * N_DIRECTIONS + best, s);
}
// A reflection contributes to a cell ONCE however many of its symmetry copies land there, so
// a cell's standard error counts independent measurements.
std::sort(hits.begin(), hits.end(), [](const auto &a, const auto &b) { return a.first < b.first; });
const int32_t cluster = static_cast<int32_t>(i);
int previous = -1;
for (const auto &[cid, s] : hits) {
if (cid == previous)
continue;
previous = cid;
sum[cid] += r.value;
sumsq[cid] += r.value * r.value;
s2sum[cid] += r.s2;
qsum[cid][0] += s.x * s.x; qsum[cid][1] += s.y * s.y; qsum[cid][2] += s.z * s.z;
qsum[cid][3] += s.x * s.y; qsum[cid][4] += s.x * s.z; qsum[cid][5] += s.y * s.z;
count[cid] += 1;
raw_cell.push_back(static_cast<int32_t>(cid));
raw_cluster.push_back(cluster);
raw_value.push_back(static_cast<float>(r.value));
}
}
FinishCells(c, sum, sumsq, s2sum, qsum, count, raw_cell, raw_cluster, raw_value);
return c;
}
// ---------------------------------------------------------------- the tensor fit
struct TensorFit {
bool ok = false;
Tensor b{0, 0, 0, 0, 0, 0};
Eigen::MatrixXd cov; // nb x nb, cluster-robust
double chi2red = NAN;
};
// mu_cell = K_shell * exp(-1/2 s^T B s), fitted on the INTENSITY scale by weighted Gauss-Newton with
// weights 1/SE^2, one free K per resolution shell profiled alongside the tensor. Because K is free per
// shell, every isotropic feature - the Wilson curve, an ice ring, a noise floor, a scaling error - is
// absorbed exactly, and only the l=2 angular part drives the tensor. Nothing is censored: a cell whose
// mean is zero or negative is kept, which is what keeps the fit honest in a direction that has died.
//
// The covariance is a cluster-robust sandwich, clustering by unique reflection. That is not a detail:
// symmetry mates of one reflection share their true |F|^2, so a symmetry-forbidden tensor direction -
// determined from contrasts WITHIN a cluster - carries none of the Wilson scatter that a
// symmetry-allowed direction carries, and a covariance that assumed independent cells would get the
// ratio of the two backwards by a factor of several.
//
// Following Popov & Bourenkov (2003) Acta Cryst. D59, 1145-1153
TensorFit FitTensor(const Cells &c, const std::vector<Tensor> &basis, bool want_covariance) {
TensorFit fit;
const int nb = static_cast<int>(basis.size());
const int nc = static_cast<int>(c.mu.size());
if (nb == 0 || nc < MIN_CELLS)
return fit;
// Shells that carry a positive mean intensity: ln K is undefined for one that does not, and a
// shell with no signal at all cannot say anything about direction either.
std::vector<double> shell_num(c.n_shells, 0.0), shell_den(c.n_shells, 0.0);
for (int i = 0; i < nc; ++i) {
const double w = 1.0 / (c.se[i] * c.se[i]);
shell_num[c.shell[i]] += w * c.mu[i];
shell_den[c.shell[i]] += w;
}
std::vector<int> slot(c.n_shells, -1);
int ns = 0;
std::vector<double> ln_k;
for (int s = 0; s < c.n_shells; ++s)
if (shell_den[s] > 0.0 && shell_num[s] / shell_den[s] > 0.0) {
slot[s] = ns++;
ln_k.push_back(std::log(shell_num[s] / shell_den[s]));
}
std::vector<int> keep;
for (int i = 0; i < nc; ++i)
if (slot[c.shell[i]] >= 0)
keep.push_back(i);
if (static_cast<int>(keep.size()) < MIN_CELLS || ns == 0)
return fit;
const int n = static_cast<int>(keep.size());
const int np = ns + nb;
Eigen::MatrixXd G(n, nb);
Eigen::VectorXd mu(n), se(n);
std::vector<int> row_slot(n);
for (int i = 0; i < n; ++i) {
const int ci = keep[i];
for (int k = 0; k < nb; ++k)
G(i, k) = -0.5 * QuadForm(basis[k], c.q[ci]);
mu(i) = c.mu[ci];
se(i) = c.se[ci];
row_slot[i] = slot[c.shell[ci]];
}
Eigen::VectorXd beta = Eigen::VectorXd::Zero(np);
for (int s = 0; s < ns; ++s)
beta(s) = ln_k[s];
auto predict = [&](const Eigen::VectorXd &b, Eigen::VectorXd &pred) {
for (int i = 0; i < n; ++i) {
double e = b(row_slot[i]);
for (int k = 0; k < nb; ++k)
e += G(i, k) * b(ns + k);
pred(i) = std::exp(std::clamp(e, -60.0, 60.0));
}
};
auto chi2_of = [&](const Eigen::VectorXd &pred) {
double s = 0.0;
for (int i = 0; i < n; ++i) {
const double r = (mu(i) - pred(i)) / se(i);
s += r * r;
}
return s;
};
Eigen::VectorXd pred(n), resid(n);
predict(beta, pred);
double chi2 = chi2_of(pred);
Eigen::MatrixXd J(n, np);
for (int iter = 0; iter < 60; ++iter) {
J.setZero();
for (int i = 0; i < n; ++i) {
const double f = -pred(i) / se(i);
J(i, row_slot[i]) = f;
for (int k = 0; k < nb; ++k)
J(i, ns + k) = f * G(i, k);
resid(i) = (mu(i) - pred(i)) / se(i);
}
const Eigen::MatrixXd A = J.transpose() * J;
const Eigen::VectorXd grad = J.transpose() * resid;
const Eigen::VectorXd step =
A.ldlt().solve(-grad); // Gauss-Newton on chi2/2
if (!step.allFinite())
break;
// Halve the step until chi2 falls; a fit that cannot improve at all is converged.
double t = 1.0;
bool improved = false;
Eigen::VectorXd trial(np), trial_pred(n);
for (int back = 0; back < 20; ++back) {
trial = beta + t * step;
predict(trial, trial_pred);
const double trial_chi2 = chi2_of(trial_pred);
if (trial_chi2 < chi2) {
const bool converged = chi2 - trial_chi2 < 1e-10 * std::max(1.0, chi2);
beta = trial;
pred = trial_pred;
chi2 = trial_chi2;
improved = !converged;
break;
}
t *= 0.5;
}
if (!improved)
break;
}
Tensor b{0, 0, 0, 0, 0, 0};
for (int k = 0; k < nb; ++k)
b = b + Scale(basis[k], beta(ns + k));
fit.b = b;
fit.chi2red = chi2 / std::max(1, n - np);
fit.ok = true;
if (!want_covariance)
return fit;
// Sandwich: A^-1 B A^-1 with A the Gauss-Newton normal matrix and B the sum of outer products of
// per-cluster scores. A cell mean is the mean of its reflections, so a reflection's share of the
// cell's score is its own share of the cell's residual.
for (int i = 0; i < n; ++i) {
const double f = -pred(i) / se(i);
J(i, row_slot[i]) = f;
for (int k = 0; k < nb; ++k)
J(i, ns + k) = f * G(i, k);
}
const Eigen::MatrixXd A = J.transpose() * J;
std::vector<int32_t> cell_row(c.mu.size(), -1);
for (int i = 0; i < n; ++i)
cell_row[keep[i]] = i;
Eigen::MatrixXd B = Eigen::MatrixXd::Zero(np, np);
Eigen::VectorXd score = Eigen::VectorXd::Zero(np);
int32_t current = -1;
auto flush = [&]() {
if (current >= 0)
B.noalias() += score * score.transpose();
score.setZero();
};
for (size_t e = 0; e < c.entry_cell.size(); ++e) {
if (c.entry_cluster[e] != current) {
flush();
current = c.entry_cluster[e];
}
const int i = cell_row[c.entry_cell[e]];
if (i < 0)
continue;
const int ci = keep[i];
const double w = (c.entry_value[e] - pred(i)) * pred(i)
/ (c.n[ci] * c.se[ci] * c.se[ci]);
score(row_slot[i]) += w;
for (int k = 0; k < nb; ++k)
score(ns + k) += w * G(i, k);
}
flush();
const Eigen::MatrixXd ainv = A.ldlt().solve(Eigen::MatrixXd::Identity(np, np));
const Eigen::MatrixXd full = ainv * B * ainv;
fit.cov = full.bottomRightCorner(nb, nb);
return fit;
}
struct Eigen3 {
double value[3];
double vec[3][3];
};
Eigen3 Decompose(const Tensor &b) {
Eigen3 out{};
double d[3];
const gemmi::Mat33 v = gemmi::eigen_decomposition(b, d);
int idx[3] = {0, 1, 2};
std::sort(idx, idx + 3, [&](int a, int c) { return d[a] > d[c]; });
for (int n = 0; n < 3; ++n) {
out.value[n] = d[idx[n]];
for (int j = 0; j < 3; ++j)
out.vec[n][j] = v[j][idx[n]]; // eigenvectors are the columns
}
return out;
}
double DeltaB(const Tensor &b) {
double d[3];
gemmi::eigen_decomposition(b, d);
return *std::max_element(d, d + 3) - *std::min_element(d, d + 3);
}
// The dB that counting statistics alone would produce in a subspace: E[dB] = k * sigma, with sigma the
// per-component standard error and k the constant for that subspace's dimension.
double CountingNull(const Eigen::MatrixXd &cov, double k) {
if (cov.rows() == 0)
return 0.0;
return k * std::sqrt(std::max(0.0, cov.trace() / cov.rows()));
}
// ---------------------------------------------------------------- the s^2 signature
struct ShapeFit {
// The regressions below all ran. Distinct from shells: there can be plenty of shells and
// still no fit, and a ShapeFit that returned early carries NaN coefficients with flat and
// convex both false - which reads as a clean "Linear" to anyone testing those two alone.
bool ok = false;
int shells = 0;
double c0 = NAN, c0_err = NAN, c1 = NAN, slope_through_origin = NAN;
double curvature = NAN, curvature_err = NAN, curvature_share = NAN;
double residual = NAN;
bool flat = false, convex = false;
};
// One shell's l=2 amplitude: mu_cell = K exp(A u_cell), u = -1/2 (s^T bhat s)/|s|^2, K profiled out,
// fitted on the intensity scale so no cell is dropped. A genuine tensor gives A = |B_dev| s^2, i.e. a
// straight line through the origin; the fit makes no assumption at all about how A depends on s.
bool FitShellAmplitude(const std::vector<double> &u, const std::vector<double> &mu,
const std::vector<double> &se, double &a_out, double &a_err) {
auto chi2 = [&](double a) {
double num = 0.0, den = 0.0;
std::vector<double> g(u.size());
for (size_t i = 0; i < u.size(); ++i) {
g[i] = std::exp(std::clamp(a * u[i], -60.0, 60.0));
const double w = 1.0 / (se[i] * se[i]);
num += mu[i] * g[i] * w;
den += g[i] * g[i] * w;
}
const double k = den > 0.0 ? num / den : 0.0;
double s = 0.0;
for (size_t i = 0; i < u.size(); ++i) {
const double r = (mu[i] - k * g[i]) / se[i];
s += r * r;
}
return s;
};
// A coarse scan brackets the minimum, golden section refines it. A is |B_dev| s^2, so a few
// hundred covers any crystal; the search is on a smooth one-parameter curve.
double lo = -300.0, hi = 300.0, best = 0.0, best_chi2 = chi2(0.0);
for (int i = 0; i <= 120; ++i) {
const double a = lo + (hi - lo) * i / 120.0;
const double v = chi2(a);
if (v < best_chi2) { best_chi2 = v; best = a; }
}
lo = best - 5.0;
hi = best + 5.0;
constexpr double INV_PHI = 0.6180339887498949;
double x1 = hi - INV_PHI * (hi - lo), x2 = lo + INV_PHI * (hi - lo);
double f1 = chi2(x1), f2 = chi2(x2);
for (int i = 0; i < 60; ++i) {
if (f1 < f2) { hi = x2; x2 = x1; f2 = f1; x1 = hi - INV_PHI * (hi - lo); f1 = chi2(x1); }
else { lo = x1; x1 = x2; f1 = f2; x2 = lo + INV_PHI * (hi - lo); f2 = chi2(x2); }
}
a_out = 0.5 * (lo + hi);
// One-parameter Gauss-Newton error bar, scaled by the residual so a shell the model does not
// describe reports a large one.
double num = 0.0, den = 0.0;
std::vector<double> g(u.size());
for (size_t i = 0; i < u.size(); ++i) {
g[i] = std::exp(std::clamp(a_out * u[i], -60.0, 60.0));
const double w = 1.0 / (se[i] * se[i]);
num += mu[i] * g[i] * w;
den += g[i] * g[i] * w;
}
const double k = den > 0.0 ? num / den : 0.0;
double jtj = 0.0, ss = 0.0;
for (size_t i = 0; i < u.size(); ++i) {
const double dr = k * g[i] * u[i] / se[i];
jtj += dr * dr;
const double r = (mu[i] - k * g[i]) / se[i];
ss += r * r;
}
if (!(jtj > 0.0) || u.size() < 3)
return false;
a_err = std::sqrt(ss / (u.size() - 2) / jtj);
return std::isfinite(a_out) && std::isfinite(a_err) && a_err > 0.0;
}
// Weighted least squares of `y` on the columns of `x`; returns the parameters and the covariance.
bool Wls(const Eigen::MatrixXd &x, const Eigen::VectorXd &y, const Eigen::VectorXd &w,
Eigen::VectorXd &p, Eigen::MatrixXd &cov, double &chi2) {
const Eigen::MatrixXd xtw = x.transpose() * w.asDiagonal();
const Eigen::MatrixXd normal = xtw * x;
const Eigen::LDLT<Eigen::MatrixXd> ldlt(normal);
if (ldlt.info() != Eigen::Success)
return false;
p = ldlt.solve(xtw * y);
const Eigen::VectorXd r = y - x * p;
chi2 = r.transpose() * w.asDiagonal() * r;
cov = ldlt.solve(Eigen::MatrixXd::Identity(x.cols(), x.cols()));
return p.allFinite();
}
ShapeFit FitShape(const Cells &c, const Tensor &tensor) {
ShapeFit out;
const double norm = std::sqrt(FrobDot(tensor, tensor));
if (!(norm > 0.0))
return out;
const Tensor bhat = Scale(tensor, 1.0 / norm);
std::vector<double> xs, as, errs;
for (int s = 0; s < c.n_shells; ++s) {
std::vector<double> u, mu, se;
double s2sum = 0.0;
for (size_t i = 0; i < c.mu.size(); ++i) {
if (c.shell[i] != s)
continue;
u.push_back(-0.5 * QuadForm(bhat, c.q[i]) / std::max(c.s2[i], 1e-12));
mu.push_back(c.mu[i]);
se.push_back(c.se[i]);
s2sum += c.s2[i];
}
if (static_cast<int>(u.size()) < MIN_CELLS_PER_SHAPE_SHELL)
continue;
const auto [lo, hi] = std::minmax_element(u.begin(), u.end());
if (*hi - *lo < 1e-6)
continue;
double a = 0.0, err = 0.0;
if (!FitShellAmplitude(u, mu, se, a, err))
continue;
xs.push_back(s2sum / u.size());
as.push_back(a);
errs.push_back(err);
}
out.shells = static_cast<int>(xs.size());
if (out.shells < MIN_SHAPE_SHELLS)
return out;
const int n = out.shells;
Eigen::VectorXd y(n), w(n);
for (int i = 0; i < n; ++i) {
y(i) = as[i];
w(i) = 1.0 / (errs[i] * errs[i]);
}
Eigen::VectorXd p;
Eigen::MatrixXd cov;
double chi2 = 0.0;
Eigen::MatrixXd x1(n, 1), x2(n, 2), xq(n, 2);
for (int i = 0; i < n; ++i) {
x1(i, 0) = xs[i];
x2(i, 0) = 1.0;
x2(i, 1) = xs[i];
xq(i, 0) = xs[i];
xq(i, 1) = xs[i] * xs[i];
}
if (!Wls(x1, y, w, p, cov, chi2))
return out;
out.slope_through_origin = p(0);
out.residual = chi2 / std::max(1, n - 1);
if (!Wls(x2, y, w, p, cov, chi2))
return out;
out.c0 = p(0);
out.c0_err = std::sqrt(std::max(0.0, cov(0, 0)));
out.c1 = p(1);
if (Wls(xq, y, w, p, cov, chi2)) {
out.curvature = p(1);
out.curvature_err = std::sqrt(std::max(0.0, cov(1, 1)));
// How much of the deficit at the resolution limit the s^4 term carries.
const double s2max = *std::max_element(xs.begin(), xs.end());
const double total = p(0) * s2max + p(1) * s2max * s2max;
out.curvature_share = total != 0.0 ? p(1) * s2max * s2max / total : NAN;
}
out.flat = out.c0 > FLAT_INTERCEPT && out.c0_err > 0.0 && out.c0 / out.c0_err > SHAPE_Z;
out.convex = !out.flat && out.curvature > 0.0 && out.curvature_err > 0.0
&& out.curvature / out.curvature_err > SHAPE_Z
&& out.curvature_share > CONVEX_SHARE;
out.ok = true;
return out;
}
// ---------------------------------------------------------------- directional limits
// <I/sigma(I)> in a cone about each principal direction, read where it falls through 2.0. This uses no
// model of the fall-off at all, which is why it and the tensor are reported side by side.
//
// Following Evans & Murshudov (2013) Acta Cryst. D69, 1204-1214
void ConeLimits(const std::vector<MergedReflection> &merged, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup *sg, const Eigen3 &axes, int n_shells,
double (&d_min)[3], bool (&censored)[3]) {
const std::vector<gemmi::Op> ops = sg ? sg->operations().sym_ops
: std::vector<gemmi::Op>{gemmi::Op::identity()};
std::vector<double> s2;
s2.reserve(merged.size());
for (const auto &r : merged)
if (std::isfinite(r.I) && std::isfinite(r.sigma) && r.sigma > 0.0f && r.d > 0.0f)
s2.push_back(1.0 / (static_cast<double>(r.d) * r.d));
if (s2.size() < static_cast<size_t>(n_shells * MIN_CELL_REFLECTIONS))
return;
std::vector<double> sorted = s2;
std::sort(sorted.begin(), sorted.end());
std::vector<double> edge(n_shells + 1);
for (int i = 0; i <= n_shells; ++i)
edge[i] = sorted[std::min(sorted.size() - 1, sorted.size() * i / n_shells)];
const double cos_cone = std::cos(CONE_HALF_ANGLE_DEG * gemmi::pi() / 180.0);
std::vector<std::vector<double>> isig_sum(3, std::vector<double>(n_shells, 0.0));
std::vector<std::vector<int>> isig_n(3, std::vector<int>(n_shells, 0));
size_t iu = 0;
for (const auto &r : merged) {
if (!std::isfinite(r.I) || !std::isfinite(r.sigma) || r.sigma <= 0.0f || !(r.d > 0.0f))
continue;
const double this_s2 = s2[iu++];
int shell = 0;
while (shell + 1 < n_shells && this_s2 > edge[shell + 1])
++shell;
bool in_cone[3] = {false, false, false};
for (const auto &op : ops) {
const gemmi::Op::Miller m = op.apply_to_hkl({{r.h, r.k, r.l}});
const gemmi::Vec3 s = cell.frac.mat.left_multiply(gemmi::Vec3(m[0], m[1], m[2]));
const double len = s.length();
if (!(len > 0))
continue;
for (int n = 0; n < 3; ++n) {
const double dot = (s.x * axes.vec[n][0] + s.y * axes.vec[n][1] + s.z * axes.vec[n][2]) / len;
if (std::fabs(dot) >= cos_cone)
in_cone[n] = true;
}
}
for (int n = 0; n < 3; ++n)
if (in_cone[n]) {
isig_sum[n][shell] += r.I / r.sigma;
isig_n[n][shell] += 1;
}
}
for (int n = 0; n < 3; ++n) {
double last_s2 = NAN, last_isig = NAN, limit_s2 = NAN;
bool crossed = false;
for (int s = 0; s < n_shells; ++s) {
if (isig_n[n][s] < MIN_CELL_REFLECTIONS)
continue;
const double mid = 0.5 * (edge[s] + edge[s + 1]);
const double isig = isig_sum[n][s] / isig_n[n][s];
if (isig >= CONE_I_OVER_SIGMA) {
last_s2 = mid;
last_isig = isig;
limit_s2 = edge[s + 1];
} else if (std::isfinite(last_s2)) {
// Linear interpolation of <I/sigma> in s^2 between the last shell above the threshold
// and the first below it.
const double f = (last_isig - CONE_I_OVER_SIGMA) / (last_isig - isig);
limit_s2 = last_s2 + f * (mid - last_s2);
crossed = true;
break;
} else {
break;
}
}
if (std::isfinite(limit_s2) && limit_s2 > 0.0) {
d_min[n] = 1.0 / std::sqrt(limit_s2);
// <I/sigma> never fell through the threshold: the outermost shell that carries this cone
// is still above it, so the value is the edge of the measured data and not this crystal's
// own limit along that direction.
censored[n] = !crossed;
}
}
}
const char *Band(double g) {
if (g < GATE_MARGINAL) return "not established";
if (g < GATE_ESTABLISHED) return "marginal";
if (g < GATE_STRONG) return "established";
return "strong";
}
// ---------------------------------------------------------------- the systematic-error floor
// The same cells, built from UNMERGED observations at the Miller index each was measured at, with
// the unique reflection they reduce to as the cluster. Nothing is symmetry-expanded and nothing is
// de-duplicated: two observations of the same reflection recorded on different frames are two
// measurements, and the difference between them is exactly the signal this arm is after.
Cells BuildObservationCells(const std::vector<AnisotropyObservation> &obs, const gemmi::UnitCell &cell,
const gemmi::SpaceGroup *sg, int n_shells) {
Cells c;
c.n_shells = n_shells;
const HKLKeyGenerator key(/*merge_friedel=*/true, sg ? *sg : *gemmi::find_spacegroup_by_number(1));
// Order the observations by the unique reflection they belong to, so the cluster-robust sum
// below can walk them one cluster at a time; then stride whole clusters if there are more than
// the fit needs. The estimate is flat in the number of unique reflections over a factor of 12,
// so striding costs nothing and bounds the work.
std::vector<std::pair<uint64_t, int32_t>> order;
order.reserve(obs.size());
for (size_t i = 0; i < obs.size(); ++i) {
const auto &o = obs[i];
if (!std::isfinite(o.I) || !std::isfinite(o.sigma) || o.sigma <= 0.0f || !(o.d > 0.0f))
continue;
order.emplace_back(key(o.h, o.k, o.l).pack(), static_cast<int32_t>(i));
}
if (order.empty())
return c;
std::sort(order.begin(), order.end());
int32_t n_clusters = 1;
for (size_t i = 1; i < order.size(); ++i)
if (order[i].first != order[i - 1].first)
++n_clusters;
const int cluster_stride = std::max<int>(1, n_clusters / MAX_CLUSTERS);
const auto dirs = HemisphereDirections(N_DIRECTIONS);
const int n_cells_total = n_shells * N_DIRECTIONS;
std::vector<double> sum(n_cells_total, 0.0), sumsq(n_cells_total, 0.0), s2sum(n_cells_total, 0.0);
std::vector<std::array<double, 6>> qsum(n_cells_total, {0, 0, 0, 0, 0, 0});
std::vector<int> count(n_cells_total, 0);
std::vector<int32_t> raw_cell, raw_cluster;
std::vector<float> raw_value;
std::vector<int32_t> kept;
std::vector<int32_t> cluster_of;
uint64_t previous_key = 0;
int32_t seen = -1;
for (const auto &[k, idx] : order) {
if (seen < 0 || k != previous_key) {
previous_key = k;
++seen;
}
if (seen % cluster_stride != 0)
continue;
kept.push_back(idx);
cluster_of.push_back(seen / cluster_stride);
}
if (kept.size() < static_cast<size_t>(MIN_CELLS * MIN_CELL_REFLECTIONS))
return c;
// Equal-count shells over the observations that are kept.
std::vector<double> s2(kept.size());
for (size_t i = 0; i < kept.size(); ++i) {
const auto &o = obs[kept[i]];
s2[i] = 1.0 / (static_cast<double>(o.d) * o.d);
}
std::vector<int32_t> by_s2(kept.size());
std::iota(by_s2.begin(), by_s2.end(), 0);
std::sort(by_s2.begin(), by_s2.end(), [&](int32_t a, int32_t b) { return s2[a] < s2[b]; });
std::vector<int> shell(kept.size());
for (size_t i = 0; i < by_s2.size(); ++i)
shell[by_s2[i]] = std::min(n_shells - 1, static_cast<int>(i * n_shells / by_s2.size()));
raw_cell.reserve(kept.size());
raw_value.reserve(kept.size());
raw_cluster.reserve(kept.size());
for (size_t i = 0; i < kept.size(); ++i) {
const auto &o = obs[kept[i]];
gemmi::Vec3 s = cell.frac.mat.left_multiply(gemmi::Vec3(o.h, o.k, o.l));
if (s.z < 0)
s = gemmi::Vec3(-s.x, -s.y, -s.z);
const double len = s.length();
if (!(len > 0))
continue;
const gemmi::Vec3 unit(s.x / len, s.y / len, s.z / len);
int best = 0;
double best_dot = -1.0;
for (int j = 0; j < N_DIRECTIONS; ++j) {
const double dot = std::fabs(unit.dot(dirs[j]));
if (dot > best_dot) { best_dot = dot; best = j; }
}
const int cid = shell[i] * N_DIRECTIONS + best;
sum[cid] += o.I;
sumsq[cid] += static_cast<double>(o.I) * o.I;
s2sum[cid] += s2[i];
qsum[cid][0] += s.x * s.x; qsum[cid][1] += s.y * s.y; qsum[cid][2] += s.z * s.z;
qsum[cid][3] += s.x * s.y; qsum[cid][4] += s.x * s.z; qsum[cid][5] += s.y * s.z;
count[cid] += 1;
raw_cell.push_back(cid);
raw_cluster.push_back(cluster_of[i]);
raw_value.push_back(o.I);
}
FinishCells(c, sum, sumsq, s2sum, qsum, count, raw_cell, raw_cluster, raw_value);
return c;
}
// The systematic error scale this dataset carries, and the part of it that is merely counting
// noise, both measured in the tensor directions the Laue class forbids.
struct SystematicFloor {
bool ok = false;
double sigma_excess = NAN; // per tensor component, A^2
double forbidden_z = NAN; // that scatter over its own counting null
int n_observations = 0;
};
SystematicFloor MeasureSystematicFloor(const std::vector<AnisotropyObservation> &obs,
const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg,
const std::vector<Tensor> &full,
const std::vector<Tensor> &forbidden) {
SystematicFloor out;
const int q = static_cast<int>(forbidden.size());
if (q == 0 || obs.empty())
return out;
const Cells cells = BuildObservationCells(obs, cell, sg, N_SHELLS);
if (cells.mu.empty())
return out;
const TensorFit fit = FitTensor(cells, full, true);
if (!fit.ok || fit.cov.rows() != static_cast<int>(full.size()))
return out;
Eigen::MatrixXd basis_map(static_cast<int>(full.size()), q);
for (int j = 0; j < q; ++j)
for (size_t k = 0; k < full.size(); ++k)
basis_map(static_cast<int>(k), j) = FrobDot(full[k], forbidden[j]);
Tensor forb{0, 0, 0, 0, 0, 0};
Eigen::VectorXd theta(static_cast<int>(full.size()));
for (size_t k = 0; k < full.size(); ++k)
theta(static_cast<int>(k)) = FrobDot(fit.b, full[k]);
const Eigen::VectorXd theta_forb = basis_map.transpose() * theta;
for (int j = 0; j < q; ++j)
forb = forb + Scale(forbidden[j], theta_forb(j));
const Eigen::MatrixXd cov_forb = basis_map.transpose() * fit.cov * basis_map;
const double k_q = K_FORBIDDEN[q];
if (!(k_q > 0.0))
return out;
const double sigma_sys = DeltaB(forb) / k_q;
const double sigma_stat = CountingNull(cov_forb, k_q) / k_q;
// Take the counting part out: what is left is the systematic alone.
out.sigma_excess = std::sqrt(std::max(0.0, sigma_sys * sigma_sys - sigma_stat * sigma_stat));
if (sigma_stat > 0.0)
out.forbidden_z = sigma_sys / sigma_stat;
out.n_observations = static_cast<int>(cells.entry_cell.size());
out.ok = true;
return out;
}
}
std::vector<AnisotropyObservation> ScaledObservations(const std::vector<IntegrationOutcome> &outcomes,
bool rotation, const gemmi::SpaceGroup *space_group,
double min_partiality) {
// Per-image scale, indexed the way the outcomes are.
std::vector<double> g(outcomes.size(), 0.0);
for (size_t i = 0; i < outcomes.size(); ++i)
if (outcomes[i].image_scale_g.has_value() && std::isfinite(*outcomes[i].image_scale_g)
&& *outcomes[i].image_scale_g > 0.0f)
g[i] = *outcomes[i].image_scale_g;
std::vector<AnisotropyObservation> out;
// The same acceptance the merge itself uses (Merge.cpp): everything in an IntegrationOutcome came
// out of the integrator, and Reflection::observed is not set by the _process.h5 reader, so testing
// it would silently empty this on the --mode scale path.
auto usable = [](const Reflection &r) {
return std::isfinite(r.I) && std::isfinite(r.sigma) && r.sigma > 0.0f && r.d > 0.0f
&& std::isfinite(r.rlp) && r.rlp > 0.0f && std::isfinite(r.partiality);
};
if (!rotation) {
// A still is a whole measurement of its reflection, and consecutive stills are different
// crystals, so there is nothing to assemble.
for (size_t i = 0; i < outcomes.size(); ++i) {
if (!(g[i] > 0.0))
continue;
for (const auto &r : outcomes[i].reflections) {
if (!usable(r) || !(r.partiality >= min_partiality))
continue;
const double corr = r.rlp / (r.partiality * g[i]);
out.push_back({r.h, r.k, r.l, static_cast<float>(r.I * corr),
static_cast<float>(r.sigma * corr), r.d});
}
}
return out;
}
// A rotation reflection is integrated image by image, so it arrives as a run of partials over
// consecutive frames. Assemble each run into one full the way the 3D combine and the unmerged
// export do - same raw hkl, frames no further apart than the combine's own gap, parts added with
// their variances in quadrature - because a single partial divided by its own partiality carries
// the rocking-curve model's error as well as its intensity, and that error is a function of the
// reflection's direction relative to the spindle, which is precisely the direction this
// diagnostic is measuring.
constexpr float MAX_FRAME_GAP = 2.0f;
// The sort key travels with the part instead of being read back through the pointer, the way the
// merge's own ingest sort carries it (RotationScaleMerge's SortKey): there are millions of parts
// and an indirect compare is a cache miss on every one of them. The keys are the same values in
// the same order, so introsort makes the same comparisons and the same swaps and leaves the same
// order - which matters, because two parts can genuinely share (h,k,l) and image_number and the
// event sums below are floating point.
struct Part {
int32_t h, k, l;
float image_number;
const Reflection *r;
size_t image;
};
// Assemble a SAMPLE of the unique reflections, not all of them. Everything below exists to feed
// MeasureSystematicFloor, which strides whole unique reflections down to MAX_CLUSTERS and on a
// large sweep keeps about one in seventy of what it is handed; taking the sample here instead of
// there removes the sort rather than shrinking it. Two things decide what the sample may be:
// * whole unique reflections. The floor is measured from the contrast BETWEEN a reflection's
// symmetry mates, so a reflection is taken with all of its mates or not at all - which is why
// this needs the space group the floor will be measured in, and samples on the ASU key.
// * coverage in direction and in resolution, which is what the fit's 12 shells x 60 directions
// read. A hash of the ASU key is a uniform sample of unique reflections and carries both; a
// stride over the parts, or over hkl order, carries neither.
// How many unique reflections the partials cover is not known here, and does not need to be: one
// part in 2^n is one unique reflection in 2^n whatever their number, so the rate comes from the
// partial count, which is.
size_t n_parts = 0;
for (size_t i = 0; i < outcomes.size(); ++i)
if (g[i] > 0.0)
n_parts += outcomes[i].reflections.size();
const HKLKeyGenerator asu_key(/*merge_friedel=*/true,
space_group ? *space_group : *gemmi::find_spacegroup_by_number(1));
const uint64_t sample_mask =
std::bit_ceil((n_parts + MAX_SAMPLE_PARTS - 1) / MAX_SAMPLE_PARTS) - 1;
std::vector<Part> parts;
// Reserve what the sample holds rather than letting the vector grow: the growth chain copies a few
// hundred megabytes on a large sweep.
parts.reserve(std::min(n_parts, MAX_SAMPLE_PARTS));
for (size_t i = 0; i < outcomes.size(); ++i) {
if (!(g[i] > 0.0))
continue;
for (const auto &r : outcomes[i].reflections) {
if (!usable(r))
continue;
if ((MixKey(asu_key(r.h, r.k, r.l).pack()) & sample_mask) != 0)
continue;
parts.push_back({r.h, r.k, r.l, r.image_number, &r, i});
}
}
std::sort(parts.begin(), parts.end(), [](const Part &a, const Part &b) {
return std::tie(a.h, a.k, a.l, a.image_number) < std::tie(b.h, b.k, b.l, b.image_number);
});
for (size_t i = 0; i < parts.size();) {
size_t j = i + 1;
while (j < parts.size() && parts[j].h == parts[i].h && parts[j].k == parts[i].k
&& parts[j].l == parts[i].l
&& parts[j].image_number - parts[j - 1].image_number <= MAX_FRAME_GAP)
++j;
double sum_p = 0.0, sum_I = 0.0, sum_var = 0.0, p_g = 0.0;
for (size_t m = i; m < j; ++m) {
const Reflection &r = *parts[m].r;
sum_p += r.partiality;
sum_I += static_cast<double>(r.I) * r.rlp;
sum_var += static_cast<double>(r.sigma) * r.sigma * r.rlp * r.rlp;
p_g += r.partiality * g[parts[m].image];
}
const Reflection &first = *parts[i].r;
i = j;
// The scale is the rocking curve's own weighted mean over the frames the event spans; the
// partiality divisor only corrects an event the sweep cut short, since a complete one sums
// to 1 by construction.
if (!(sum_p >= min_partiality) || !(p_g > 0.0))
continue;
const double scale = p_g / sum_p; // partiality-weighted mean G
const double corr = 1.0 / (sum_p * scale);
out.push_back({first.h, first.k, first.l, static_cast<float>(sum_I * corr),
static_cast<float>(std::sqrt(sum_var) * corr), first.d});
}
return out;
}
const char *AnisotropyShapeCode(AnisotropyShape shape) {
switch (shape) {
case AnisotropyShape::Linear: return "LINEAR";
case AnisotropyShape::Flat: return "FLAT";
case AnisotropyShape::Convex: return "CONVEX";
default: return "UNDETERMINED";
}
}
const char *AnisotropyVerdictCode(AnisotropyVerdict verdict) {
switch (verdict) {
case AnisotropyVerdict::NotDetected: return "NOT_DETECTED";
case AnisotropyVerdict::Detected: return "DETECTED";
default: return "CANNOT_DETERMINE";
}
}
AnisotropyResult AnalyzeAnisotropy(const std::vector<MergedReflection> &merged,
const std::vector<AnisotropyObservation> &unmerged,
const gemmi::UnitCell &cell,
const gemmi::SpaceGroup *space_group,
const AnisotropyRunInfo &run) {
AnisotropyResult result;
if (merged.empty() || !cell.is_crystal())
return result;
const auto allowed = AllowedDeviatoricBasis(cell, space_group);
const auto full = FullDeviatoricBasis();
const auto forbidden = ForbiddenBasis(full, allowed);
const int p = static_cast<int>(allowed.size());
const int q = static_cast<int>(forbidden.size());
result.n_free_parameters = p;
if (p == 0) {
// Cubic. The anisotropy tensor is forced isotropic by symmetry, so its deviatoric part is exactly
// zero - there is nothing to fit and nothing to gate.
result.n_reflections = static_cast<int>(merged.size());
result.delta_b = 0.0;
result.delta_b_linear = 0.0;
result.delta_b_flat = 0.0;
result.eigenvalue[0] = result.eigenvalue[1] = result.eigenvalue[2] = 0.0;
result.verdict = AnisotropyVerdict::NotDetected;
result.band = "no anisotropy is possible in this Laue class";
return result;
}
double isig_sum = 0.0;
int isig_n = 0;
for (const auto &r : merged)
if (std::isfinite(r.I) && std::isfinite(r.sigma) && r.sigma > 0.0f) {
isig_sum += r.I / r.sigma;
++isig_n;
}
const double mean_isig = isig_n > 0 ? isig_sum / isig_n : 0.0;
const auto refl = SelectReflections(merged, cell, space_group, N_SHELLS);
if (refl.size() < static_cast<size_t>(MIN_CELLS * MIN_CELL_REFLECTIONS))
return result;
const Cells cells = BuildCells(refl, cell, space_group, N_SHELLS);
const TensorFit constrained = FitTensor(cells, allowed, true);
if (!constrained.ok)
return result;
result.n_reflections = static_cast<int>(refl.size());
result.n_cells = static_cast<int>(cells.mu.size());
const Eigen3 axes = Decompose(constrained.b);
result.delta_b = axes.value[0] - axes.value[2];
for (int n = 0; n < 3; ++n) {
result.eigenvalue[n] = axes.value[n];
for (int j = 0; j < 3; ++j)
result.eigenvector[n][j] = axes.vec[n][j];
}
double d_min = 0.0;
for (const auto &r : merged)
if (r.d > 0.0f && (d_min == 0.0 || r.d < d_min))
d_min = r.d;
if (d_min > 0.0)
result.fold_weakening = std::exp(result.delta_b / (2.0 * d_min * d_min));
ConeLimits(merged, cell, space_group, axes, N_SHELLS, result.d_min_axis, result.d_min_censored);
{
double lo = INFINITY, hi = -INFINITY;
for (double d : result.d_min_axis)
if (std::isfinite(d)) { lo = std::min(lo, d); hi = std::max(hi, d); }
if (std::isfinite(lo) && std::isfinite(hi))
result.d_min_spread = hi - lo;
}
// --- the resolution signature, and whether the binning decides it ---
const ShapeFit shape = FitShape(cells, constrained.b);
// A fit that did not run was fitted over no shells. Reported that way rather than as the shells
// it had available, so the renderer and the cautions below do not print NaN coefficients beside a
// shell count that suggests they mean something.
result.shape_shells = shape.ok ? shape.shells : 0;
result.shape_intercept = shape.c0;
result.shape_intercept_z = shape.c0_err > 0.0 ? shape.c0 / shape.c0_err : NAN;
result.shape_slope = shape.c1;
result.shape_curvature_z = shape.curvature_err > 0.0 ? shape.curvature / shape.curvature_err : NAN;
result.shape_curvature_share = shape.curvature_share;
result.shape_residual = shape.residual;
if (shape.ok && shape.shells >= MIN_SHAPE_SHELLS) {
result.shape = shape.flat ? AnisotropyShape::Flat
: shape.convex ? AnisotropyShape::Convex : AnisotropyShape::Linear;
if (std::isfinite(shape.slope_through_origin) && shape.slope_through_origin != 0.0) {
result.delta_b_linear = result.delta_b * shape.c1 / shape.slope_through_origin;
result.delta_b_flat = result.delta_b - result.delta_b_linear;
}
// Rebin at 8 and at 16 shells and refit the tensor each time, so the direction the amplitude is
// measured along is re-derived too. A verdict that moves is not a measurement.
result.shape_stable = true;
for (int alt : {8, 16}) {
const auto alt_refl = SelectReflections(merged, cell, space_group, alt);
const Cells alt_cells = BuildCells(alt_refl, cell, space_group, alt);
const TensorFit alt_fit = FitTensor(alt_cells, allowed, false);
if (!alt_fit.ok) {
result.shape_stable = false;
break;
}
const ShapeFit alt_shape = FitShape(alt_cells, alt_fit.b);
if (!alt_shape.ok || alt_shape.shells < MIN_SHAPE_SHELLS || alt_shape.flat != shape.flat) {
result.shape_stable = false;
break;
}
}
if (!result.shape_stable)
result.shape = AnisotropyShape::Undetermined;
}
// The gate is applied to the part of delta_b that behaves as a Debye-Waller B, when that could be
// separated; to delta_b itself when it could not.
// A FLAT crystal can fit a negative linear slope; a negative anisotropy is not a smaller one, it is
// no established Debye-Waller component at all.
const double magnitude = std::max(0.0, std::isfinite(result.delta_b_linear) ? result.delta_b_linear
: result.delta_b);
// --- the gate ---
auto refuse = [&](std::string why) {
result.verdict = AnisotropyVerdict::CannotDetermine;
result.refusal = std::move(why);
};
if (mean_isig < MIN_MEAN_I_OVER_SIGMA) {
refuse("the merged data are at the noise floor (<I/sigma(I)> below 1), where neither the tensor "
"nor its resolution signature means anything");
} else if (q == 0) {
// Triclinic. Every other Laue class leaves directions in which the true tensor is zero by
// symmetry, and measuring those is how this dataset's systematic error is established. Triclinic
// leaves none, and three candidate substitutes (a degree-4 spherical-harmonic block, a
// low/high-resolution split, a floor borrowed from similar datasets) were built and measured
// against known truth: the first two are blind to real anisotropy, as a denominator must be, but
// neither delivers a usable scale, and the borrowed floor spans three orders of magnitude.
refuse("the Laue class is triclinic, which leaves no symmetry-forbidden tensor direction, so this "
"dataset carries no measurement of its own systematic error");
} else if (std::isfinite(run.observed_rotation_deg) && run.observed_rotation_deg < MIN_ROTATION_DEG) {
refuse("the observed rotation range is too short for the tensor to be separated from a lab-fixed "
"systematic");
} else if (std::isfinite(run.observed_rotation_deg) && !run.dose_term_in_scale_model) {
// A dose ramp is not a significance failure - it is a confident false detection. A simulated
// isotropic crystal given a 25 A^2 relative-B ramp over 180 deg returns dB ~ 9 A^2 with a
// significance no test can see through, because the ramp really is a smooth quadratic function of
// direction once the sweep geometry is folded in.
refuse("the scale model carried no dose term, and an uncorrected dose ramp manufactures anisotropy "
"that no significance test can see through");
} else if (unmerged.empty()) {
// Merged intensities have exact Laue symmetry by construction, so the symmetry-forbidden tensor
// directions - the only place a dataset can measure its own systematic error - are identically
// zero there whatever the crystal carries. Gating on what is left would be gating on counting
// statistics, which against known ground truth calls "real" in 15-58% of clean isotropic datasets.
refuse("the systematic-error scale can only be measured on unmerged observations, and none were "
"available for this merge");
} else {
const SystematicFloor sys = MeasureSystematicFloor(unmerged, cell, space_group, full, forbidden);
result.n_observations = sys.n_observations;
result.forbidden_z = sys.forbidden_z;
if (!sys.ok) {
refuse("the symmetry-forbidden tensor directions of the unmerged observations could not be "
"measured, so this dataset's systematic-error scale is unknown");
} else {
const double k_p = K_ALLOWED[p];
result.sigma_systematic = sys.sigma_excess;
const double allowed_null = CountingNull(constrained.cov, k_p);
result.floor = std::sqrt(k_p * result.sigma_systematic * k_p * result.sigma_systematic
+ allowed_null * allowed_null);
if (result.floor > 0.0) {
result.significance = magnitude / result.floor;
result.detection_limit = GATE_ESTABLISHED * result.floor;
result.band = Band(result.significance);
result.verdict = result.significance > GATE_ESTABLISHED ? AnisotropyVerdict::Detected
: AnisotropyVerdict::NotDetected;
} else {
refuse("the systematic-error floor could not be measured");
}
}
}
// --- cautions: measured things that bias the verdict, in either direction ---
if (result.shape == AnisotropyShape::Flat)
result.cautions.emplace_back(
"the directional deficit does not follow exp(-1/2 s^T B s), so the fitted deltaB is a fit "
"of the wrong functional form and may be an under-estimate");
if (result.shape == AnisotropyShape::Undetermined && result.shape_shells >= MIN_SHAPE_SHELLS)
result.cautions.emplace_back(
"the resolution signature changed when the shells were rebinned, so it is reported as "
"undetermined rather than as a measurement");
if (std::isfinite(run.observed_rotation_deg) && run.observed_rotation_deg < 180.0)
result.cautions.emplace_back(
"the observed rotation range is under 180 deg, which leaves a second tensor direction "
"reachable by a lab-fixed systematic and reduces the power of the test");
if (std::isfinite(run.radiation_damage_relative_b) && std::fabs(run.radiation_damage_relative_b) > 10.0)
result.cautions.emplace_back(
"the run carries substantial radiation damage; the scale model removes its average "
"monotone part, but a non-monotone dose ramp needs --relative-b to be taken out too");
// R2's Laue-class caution, fired on its measured signature rather than on the Laue class alone.
// Too high a symmetry assignment pushes real anisotropy into the directions the systematic error is
// measured in, so sigma_sys is inflated by the signal itself and the gate collapses (measured: a
// 42x inflation of sigma_sys, a gate of 106 falling to 3.1). The signature is a forbidden subspace
// far above its own counting noise TOGETHER with a gate that has established nothing. p == 1 on its
// own is not the signature - it is true of every tetragonal, trigonal and hexagonal dataset, 17 of
// 38 in the battery, most of them correctly assigned.
if (p == 1 && result.verdict == AnisotropyVerdict::NotDetected
&& std::isfinite(result.forbidden_z) && result.forbidden_z > FORBIDDEN_Z_SUSPECT)
result.cautions.emplace_back(
"the space group is worth checking before this verdict is believed: the tensor directions "
"this Laue class forbids carry far more scatter than their own counting noise, yet nothing "
"was established in the single direction it allows - which is what a too-high symmetry "
"assignment looks like, real anisotropy being measured as systematic error");
return result;
}
std::string AnisotropyToText(const AnisotropyResult &result) {
std::ostringstream os;
if (result.n_reflections == 0)
return os.str();
os << std::fixed;
os << "Diffraction anisotropy\n";
if (result.n_free_parameters == 0) {
os << " The Laue class forces the anisotropy tensor to be isotropic: deltaB = 0 exactly, with no\n"
<< " free parameter to fit. This is symmetry, not a measurement.\n";
return os.str();
}
os << std::setprecision(2);
os << " Anisotropic deltaB (range of principal components) = " << result.delta_b << " A^2"
<< " [" << result.n_free_parameters << " free direction"
<< (result.n_free_parameters == 1 ? "" : "s") << " in this Laue class]\n";
os << " Principal components (A^2, relative):";
for (double v : result.eigenvalue)
os << " " << (v - result.eigenvalue[2]);
os << "\n";
if (std::isfinite(result.fold_weakening))
os << " Strongest / weakest direction at the resolution limit: " << result.fold_weakening << "x\n";
// Every axis, not just the outer two: ConeLimits fills each independently, so a crystal whose
// first and third directions fall in cones too sparse to measure - but whose second is well
// covered - would otherwise suppress the whole line, d_min_spread included.
if (std::isfinite(result.d_min_axis[0]) || std::isfinite(result.d_min_axis[1])
|| std::isfinite(result.d_min_axis[2])) {
bool any_censored = false;
os << " d_min along the principal directions (<I/sigma(I)> = " << CONE_I_OVER_SIGMA
<< " in a " << CONE_HALF_ANGLE_DEG << " deg cone):";
for (int n = 0; n < 3; ++n) {
if (!std::isfinite(result.d_min_axis[n])) {
os << " -";
} else if (result.d_min_censored[n]) {
os << " <" << result.d_min_axis[n];
any_censored = true;
} else {
os << " " << result.d_min_axis[n];
}
}
os << " A\n";
if (any_censored)
os << " \"<\" marks a direction in which <I/sigma(I)> had not fallen to "
<< CONE_I_OVER_SIGMA << " where the measured data\n"
<< " end: that figure is the edge of the data, not this crystal's limit along that\n"
<< " direction, and the spread between the directions is therefore a lower bound.\n";
}
if (result.shape_shells >= MIN_SHAPE_SHELLS) {
os << " Resolution signature of the deficit over " << result.shape_shells << " shells: "
<< AnisotropyShapeCode(result.shape) << " (intercept " << std::showpos << result.shape_intercept
<< std::noshowpos << ", z = " << result.shape_intercept_z << "; slope " << result.shape_slope
<< " A^2; through-origin chi2/dof " << result.shape_residual << ")\n";
if (result.shape == AnisotropyShape::Flat)
os << " The deficit does not follow exp(-1/2 s^T B s): " << result.delta_b_flat
<< " A^2 of the deltaB above does not behave as a Debye-Waller B. The fall-off is being\n"
<< " described by the wrong functional form, so the number may be an UNDER-estimate,\n"
<< " not an over-estimate.\n";
else if (result.shape == AnisotropyShape::Convex)
os << " The deficit grows faster than s^2, which a Debye-Waller B cannot do.\n";
}
if (result.verdict == AnisotropyVerdict::CannotDetermine) {
os << " => CANNOT DETERMINE: " << result.refusal << ".\n";
} else {
// The magnitude judged is the Debye-Waller part of the deltaB, which is what the floor is a
// floor on; on a crystal whose deficit is not a Debye-Waller fall-off the two differ. Which of
// the two numbers is which is said here rather than left to the reader: deltaB_linear exceeds
// the deltaB on roughly a third of a real battery, and a verdict line that quoted the larger
// one without saying so read as a contradiction of the headline above.
const bool linear = std::isfinite(result.delta_b_linear);
const double judged = std::max(0.0, linear ? result.delta_b_linear : result.delta_b);
os << std::setprecision(2)
<< (result.verdict == AnisotropyVerdict::Detected ? " => DETECTED (" : " => NOT DETECTED (")
<< result.band << "): ratio " << result.significance << " = "
<< (linear ? "deltaB_linear " : "deltaB ") << judged
<< " A^2 / this dataset's own systematic-error floor of " << result.floor << " A^2.\n";
if (linear) {
os << " The gate is applied to deltaB_linear, the part of the deltaB that follows\n"
<< " exp(-1/2 s^T B s)";
if (result.delta_b_linear > result.delta_b)
os << " - it exceeds the deltaB here because the fitted line passes below\n"
<< " the origin";
else if (result.delta_b_linear < 0.0)
os << " - the fitted linear slope is negative here, which is no established\n"
<< " Debye-Waller component at all rather than a small one, so it enters as zero";
os << ". The anisotropy to quote is the deltaB of " << result.delta_b << " A^2 above.\n";
}
}
if (std::isfinite(result.detection_limit))
os << " Below about " << result.detection_limit << " A^2 nothing could be established on this\n"
<< " dataset. That limit is set by the systematic error, not by counting, so it does NOT\n"
<< " improve with more reflections or a longer exposure.\n";
for (const auto &c : result.cautions)
os << " Note: " << c << "\n";
os << " Reported and not corrected: no intensity is changed and no reflection is removed.\n";
return os.str();
}