// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "AnisotropyAnalysis.h" #include #include #include #include #include #include #include #include #include #include "HKLKey.h" #include "gemmi/eig3.hpp" #include "gemmi/scaling.hpp" namespace { using Tensor = gemmi::SMat33; // ---------------------------------------------------------------- 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 ~ 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 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 cone half-angle of the directional diffraction limits, and the 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; // 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 &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 Orthonormalise(const std::vector &raw) { std::vector 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 AllowedDeviatoricBasis(const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg) { std::vector 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(ideal.orth.mat))); } return Orthonormalise(raw); } std::vector FullDeviatoricBasis() { const std::vector 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 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 ForbiddenBasis(const std::vector &full, const std::vector &allowed) { std::vector 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 HemisphereDirections(int n) { std::vector 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 mu, se, s2; std::vector> q; std::vector 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 entry_cell, entry_cluster; std::vector entry_value; int n_shells = 0; }; struct Usable { std::array hkl; double value; // epsilon-corrected merged intensity, negatives kept double s2; int shell = 0; }; std::vector SelectReflections(const std::vector &merged, const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg, int n_shells) { const gemmi::GroupOps gops = sg ? sg->operations() : gemmi::GroupOps{}; std::vector 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 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(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 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(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 &sum, const std::vector &sumsq, const std::vector &s2sum, const std::vector> &qsum, const std::vector &count, const std::vector &raw_cell, const std::vector &raw_cluster, const std::vector &raw_value) { std::vector 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(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(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 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 &refl, const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg, int n_shells) { Cells c; c.n_shells = n_shells; const std::vector ops = sg ? sg->operations().sym_ops : std::vector{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(1, (refl.size() * ops.size() + MAX_ENTRIES - 1) / MAX_ENTRIES); const int n_cells_total = n_shells * N_DIRECTIONS; std::vector sum(n_cells_total, 0.0), sumsq(n_cells_total, 0.0), s2sum(n_cells_total, 0.0); std::vector> qsum(n_cells_total, {0, 0, 0, 0, 0, 0}); std::vector count(n_cells_total, 0); std::vector raw_cell, raw_cluster; std::vector raw_value; std::vector> 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(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(cid)); raw_cluster.push_back(cluster); raw_value.push_back(static_cast(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 &basis, bool want_covariance) { TensorFit fit; const int nb = static_cast(basis.size()); const int nc = static_cast(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 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 slot(c.n_shells, -1); int ns = 0; std::vector 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 keep; for (int i = 0; i < nc; ++i) if (slot[c.shell[i]] >= 0) keep.push_back(i); if (static_cast(keep.size()) < MIN_CELLS || ns == 0) return fit; const int n = static_cast(keep.size()); const int np = ns + nb; Eigen::MatrixXd G(n, nb); Eigen::VectorXd mu(n), se(n); std::vector 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 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 { 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 &u, const std::vector &mu, const std::vector &se, double &a_out, double &a_err) { auto chi2 = [&](double a) { double num = 0.0, den = 0.0; std::vector 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 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 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 xs, as, errs; for (int s = 0; s < c.n_shells; ++s) { std::vector 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(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(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; return out; } // ---------------------------------------------------------------- directional limits // 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 &merged, const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg, const Eigen3 &axes, int n_shells, double (&d_min)[3]) { const std::vector ops = sg ? sg->operations().sym_ops : std::vector{gemmi::Op::identity()}; std::vector 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(r.d) * r.d)); if (s2.size() < static_cast(n_shells * MIN_CELL_REFLECTIONS)) return; std::vector sorted = s2; std::sort(sorted.begin(), sorted.end()); std::vector 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> isig_sum(3, std::vector(n_shells, 0.0)); std::vector> isig_n(3, std::vector(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; 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 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); break; } else { break; } } if (std::isfinite(limit_s2) && limit_s2 > 0.0) d_min[n] = 1.0 / std::sqrt(limit_s2); } } 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 &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> 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(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(1, n_clusters / MAX_CLUSTERS); const auto dirs = HemisphereDirections(N_DIRECTIONS); const int n_cells_total = n_shells * N_DIRECTIONS; std::vector sum(n_cells_total, 0.0), sumsq(n_cells_total, 0.0), s2sum(n_cells_total, 0.0); std::vector> qsum(n_cells_total, {0, 0, 0, 0, 0, 0}); std::vector count(n_cells_total, 0); std::vector raw_cell, raw_cluster; std::vector raw_value; std::vector kept; std::vector 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(MIN_CELLS * MIN_CELL_REFLECTIONS)) return c; // Equal-count shells over the observations that are kept. std::vector s2(kept.size()); for (size_t i = 0; i < kept.size(); ++i) { const auto &o = obs[kept[i]]; s2[i] = 1.0 / (static_cast(o.d) * o.d); } std::vector 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 shell(kept.size()); for (size_t i = 0; i < by_s2.size(); ++i) shell[by_s2[i]] = std::min(n_shells - 1, static_cast(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(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 int n_observations = 0; }; SystematicFloor MeasureSystematicFloor(const std::vector &obs, const gemmi::UnitCell &cell, const gemmi::SpaceGroup *sg, const std::vector &full, const std::vector &forbidden) { SystematicFloor out; const int q = static_cast(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(full.size())) return out; Eigen::MatrixXd basis_map(static_cast(full.size()), q); for (int j = 0; j < q; ++j) for (size_t k = 0; k < full.size(); ++k) basis_map(static_cast(k), j) = FrobDot(full[k], forbidden[j]); Tensor forb{0, 0, 0, 0, 0, 0}; Eigen::VectorXd theta(static_cast(full.size())); for (size_t k = 0; k < full.size(); ++k) theta(static_cast(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)); out.n_observations = static_cast(cells.entry_cell.size()); out.ok = true; return out; } } std::vector ScaledObservations(const std::vector &outcomes, bool rotation, double min_partiality) { // Per-image scale, indexed the way the outcomes are. std::vector 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 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(r.I * corr), static_cast(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; struct Part { const Reflection *r; size_t image; }; std::vector 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)) parts.push_back({&r, i}); } std::sort(parts.begin(), parts.end(), [](const Part &a, const Part &b) { return std::tie(a.r->h, a.r->k, a.r->l, a.r->image_number) < std::tie(b.r->h, b.r->k, b.r->l, b.r->image_number); }); for (size_t i = 0; i < parts.size();) { size_t j = i + 1; while (j < parts.size() && parts[j].r->h == parts[i].r->h && parts[j].r->k == parts[i].r->k && parts[j].r->l == parts[i].r->l && parts[j].r->image_number - parts[j - 1].r->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(r.I) * r.rlp; sum_var += static_cast(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(sum_I * corr), static_cast(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 &merged, const std::vector &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(allowed.size()); const int q = static_cast(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(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(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(refl.size()); result.n_cells = static_cast(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); { 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); result.shape_shells = shape.shells; 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.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.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 ( 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; 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 && 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"); if (p == 1) result.cautions.emplace_back( "this Laue class leaves a single free anisotropy direction; if the space group has been " "assigned too high a symmetry, real anisotropy is pushed into the directions used to " "measure the systematic error and the test is biased towards reporting none"); 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"; if (std::isfinite(result.d_min_axis[0]) || std::isfinite(result.d_min_axis[2])) { os << " d_min along the principal directions ( = " << CONE_I_OVER_SIGMA << " in a " << CONE_HALF_ANGLE_DEG << " deg cone):"; for (double d : result.d_min_axis) { if (std::isfinite(d)) os << " " << d; else os << " -"; } os << " A\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. const double judged = std::max(0.0, std::isfinite(result.delta_b_linear) ? result.delta_b_linear : result.delta_b); os << std::setprecision(2) << (result.verdict == AnisotropyVerdict::Detected ? " => DETECTED (" : " => NOT DETECTED (") << result.band << "): a Debye-Waller deltaB of " << judged << " A^2 against this dataset's own systematic-error floor of " << result.floor << " A^2, ratio " << result.significance << ".\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(); }