Build Packages / Create release (push) Successful in 7s
Build Packages / build:portable:macos-arm64:nocuda (push) Successful in 4m56s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 9m10s
Build Packages / build:portable:linux-x86_64:nocuda (push) Successful in 13m51s
Build Packages / build:portable:linux-x86_64:cuda (push) Successful in 16m56s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 20m59s
Build Packages / build:portable:windows-x86_64:nocuda (push) Successful in 21m25s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 21m17s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 14m14s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m59s
Build Packages / build:portable:windows-x86_64:cuda (push) Successful in 25m48s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 15m52s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m33s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / Build documentation (push) Successful in 1m19s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m49s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 25m31s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 25m24s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 26m5s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 22m19s
Build Packages / Unit tests (push) Failing after 2h28m24s
Build Packages / Validation Battery (push) Successful in 40m33s
* Rugnux: Processes a collection live while jfjoch_broker writes it (`rugnux <master.h5> <broker URL>`). * Rugnux: Processing starts on the first images and confirms its choices once all are in, so analysis can begin during collection. * Rugnux: Faster processing, especially on servers with several GPUs and CPU sockets and on computers without a GPU (`--no-gpu`, macOS). * Rugnux: Uses much less GPU memory, so large data sets stay on the GPU of smaller cards, and a data set too large for the card is processed on the CPU instead. * Rugnux: On macOS, Bragg integration runs on the Apple GPU. * Rugnux: Better handling of twinning and pseudo-symmetry, with the twin hypothesis written as `<prefix>_twin_<SG>.mtz`, and a wrong `--model` no longer confirms the data or decides the hand. * Rugnux: `--model` accepts predicted (AlphaFold) models and falls back to molecular replacement when the model is not placed as a rigid body (experimental `--mr` forces it), settling space group, hand and twinning and writing a different answer as `<prefix>_mr.mtz`. * Rugnux: Molecular replacement is faster and settles the space group in more cases with several copies in the asymmetric unit. * Rugnux: Deposition-ready mmCIF, a richer report, and numbered steps on the terminal with the full log in `<prefix>_log.txt`; `_P1.mtz`, `--estimate-beam-center` and `--beam-center-search` are removed. * Rugnux: The beam-stop shadow is found correctly on tilted detectors. * Rugnux: Stills files that record a stationary goniometer axis get the default stills geometry refinement. * jfjoch_viewer: "Analyze dataset" uses the same defaults as a bare `rugnux <file>`, and shows the results of processed CBF, marCCD and SMV sweeps. * jfjoch_viewer: The Files panel refreshes on F5 and every few seconds, and the plot type and other selections stay put during live collection. * jfjoch_writer: Memory stays bounded and old data files are closed efficiently on long runs, any failure ends the run in an error state the broker is told about, and the master file appears only after its data files; the ZeroMQ pusher logs when a stalled writer makes it drop images. * Fixes: The TCP image pusher no longer blocks the broker's receiver, GPU out-of-memory failures stop with a clear message, and the XDS plugin masks module and chip edges. --------- Co-authored-by: Filip Leonarski <jungfrau@filips-mac-mini.home> Reviewed-on: #85 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
819 lines
43 KiB
C++
819 lines
43 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "TranslationalNCS.h"
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <iomanip>
|
|
#include <limits>
|
|
#include <numbers>
|
|
#include <numeric>
|
|
#include <random>
|
|
#include <sstream>
|
|
#include <unordered_set>
|
|
|
|
#include <fftw3.h>
|
|
|
|
#include "../../common/FFTWPlannerLock.h"
|
|
#include "../../common/ParallelFor.h"
|
|
|
|
namespace {
|
|
// The Patterson is measured in a band, not over the whole dataset: below ~20 A the bulk solvent
|
|
// and the beam stop dominate, above ~5 A the map answers a question about atoms rather than about
|
|
// the arrangement of molecules, which is what a pseudo-translation is.
|
|
constexpr double kPattersonDMin = 5.0;
|
|
constexpr double kPattersonDMax = 20.0;
|
|
// A pseudo-translation shorter than this is a vector inside one molecule, not a translation
|
|
// between two of them; the origin peak's own shoulder also reaches out this far.
|
|
constexpr double kMinPeakDistanceA = 15.0;
|
|
// A peak this high is not a pseudo-symmetry: the origin peak is the total, so a peak at this
|
|
// fraction of it means essentially every reflection is invariant under the translation, which
|
|
// makes it a lattice vector. Measured over 137 datasets the two populations do not overlap - the
|
|
// strongest genuine pseudo-translation reaches 62% of the origin, while an undeclared centring
|
|
// (a C-, I- or F-centred crystal merged in P1) reads 83-102%.
|
|
constexpr double kLatticeTranslationPct = 75.0;
|
|
constexpr double kGridSampling = 2.5; // grid spacing = d_min / this
|
|
constexpr int kPattersonShells = 20; // equal-count shells for the E^2 normalisation
|
|
constexpr int kMinPattersonReflections = 50;
|
|
|
|
constexpr double kModulationDMin = 4.0; // coarse enough that a small error in u does not
|
|
// scramble the phase h.u
|
|
constexpr int kModulationShells = 10;
|
|
constexpr int kPhaseBins = 12;
|
|
constexpr int kMinModulationReflections = 500;
|
|
constexpr double kWilsonOutlierE2 = 8.0;
|
|
constexpr double kMinShellISig = 1.0;
|
|
// 20 rather than a handful: the null is the median of that many greedy refinements from random
|
|
// starts, and with 6 draws its scatter was enough to move datasets sitting near the gate across
|
|
// it from one RNG seed to the next (measured: three of them). The refinement is a dot product
|
|
// per trial, so the extra draws cost a few ms.
|
|
constexpr int kModulationNullDraws = 20;
|
|
|
|
// Gate constants. Read off a corpus of 128 merged datasets, on which the pair fires on 7.0% of
|
|
// them - a pathology rate, and close to the ~6% of PDB entries Read, Adams & McCoy report with a
|
|
// large off-origin Patterson peak. Either criterion ALONE fires on roughly twice as many: a 40%
|
|
// peak can carry no modulation at all, and on a 70,000-reflection dataset a 4% peak reaches
|
|
// z = 18 while modulating nothing.
|
|
constexpr double kPeakZThreshold = 5.0;
|
|
constexpr double kModulationRatioThreshold = 2.5;
|
|
// A modulation this deep means the suppressed class carries under 5% of the other, i.e. it is
|
|
// very nearly systematically absent - which is what a LATTICE translation of a smaller cell looks
|
|
// like measured in the larger one. Over 137 datasets exactly one crystal reaches it (at 89x, the
|
|
// next-deepest genuine pseudo-symmetry being 14x), and its deposited entry is indeed a cell of
|
|
// half the volume, whose axis length this detector reports to within 0.2 A.
|
|
constexpr double kNearExtinctModulation = 20.0;
|
|
|
|
// Above this many grid points the null is cut from 30 draws to 10. A deterministic function of
|
|
// the cell, so the answer does not depend on the machine; no dataset in the corpus reaches it
|
|
// (the largest grid measured was 2.5 M points on a 545 A cell).
|
|
constexpr size_t kLargeGridPoints = 4u << 20;
|
|
|
|
struct Vec3 { double x, y, z; };
|
|
|
|
// Round up to the next even number whose only prime factors are 2, 3 and 5 - the sizes FFTW has
|
|
// codelets for.
|
|
int SmoothSize(int v) {
|
|
v = std::max(2, v);
|
|
for (;; ++v) {
|
|
if (v % 2 != 0)
|
|
continue;
|
|
int x = v;
|
|
for (int p : {2, 3, 5})
|
|
while (x % p == 0)
|
|
x /= p;
|
|
if (x == 1)
|
|
return v;
|
|
}
|
|
}
|
|
|
|
// Origin-equivalent points of the lattice: the origin plus the centring translations, which
|
|
// produce a large Patterson peak by construction and are not pseudo-translations.
|
|
std::vector<Vec3> OriginEquivalents(const gemmi::SpaceGroup *sg) {
|
|
std::vector<Vec3> out{{0.0, 0.0, 0.0}};
|
|
if (!sg)
|
|
return out;
|
|
for (const auto &t : sg->operations().cen_ops) {
|
|
const Vec3 v{t[0] / 24.0, t[1] / 24.0, t[2] / 24.0};
|
|
if (v.x != 0.0 || v.y != 0.0 || v.z != 0.0)
|
|
out.push_back(v);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Shortest distance in A from a fractional position to any origin-equivalent lattice point.
|
|
double DistanceToOrigin(const gemmi::UnitCell &cell, const Vec3 &frac,
|
|
const std::vector<Vec3> &equivalents) {
|
|
double best = std::numeric_limits<double>::infinity();
|
|
for (const auto &c : equivalents)
|
|
for (int dx = -1; dx <= 1; ++dx)
|
|
for (int dy = -1; dy <= 1; ++dy)
|
|
for (int dz = -1; dz <= 1; ++dz) {
|
|
const gemmi::Position p = cell.orthogonalize(
|
|
gemmi::Fractional{frac.x - c.x + dx, frac.y - c.y + dy, frac.z - c.z + dz});
|
|
best = std::min(best, p.length());
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// How well a vector explains the intensities of the first `n` reflections under the physical
|
|
// model <E^2> = A (1 + f cos(2 pi h.u)): the correlation between E^2 and cos(2 pi h.u).
|
|
//
|
|
// The gate above scores a vector by the max/min ratio of the phase-bin means instead. That is the
|
|
// right statistic for the gate - it is what the null is drawn against - but it is the wrong one to
|
|
// REFINE on a full-resolution population, because a ratio is maximised by emptying one bin: on a
|
|
// 118,000-reflection merge the max/min search walked to a vector 0.015 away from the correlation's
|
|
// answer while reporting 1.2e4. The fitted amplitude f itself is no better, for the mirror reason:
|
|
// f is a slope divided by an intercept, so it runs away wherever the cosine has little variance
|
|
// over the data, and the search drifts to exactly such a vector (measured: 0.06 off a synthetic
|
|
// half-integer translation). The correlation has neither failure - it is 1 only where the cosine
|
|
// follows the intensities.
|
|
double CosineCorrelation(const std::vector<std::array<int, 3>> &hkl, const std::vector<double> &e2,
|
|
const std::array<double, 3> &u, size_t n) {
|
|
double sc = 0.0, scc = 0.0, sy = 0.0, syy = 0.0, scy = 0.0;
|
|
for (size_t i = 0; i < n; ++i) {
|
|
const double c = std::cos(2.0 * std::numbers::pi
|
|
* (hkl[i][0] * u[0] + hkl[i][1] * u[1] + hkl[i][2] * u[2]));
|
|
sc += c;
|
|
scc += c * c;
|
|
sy += e2[i];
|
|
syy += e2[i] * e2[i];
|
|
scy += c * e2[i];
|
|
}
|
|
const double nn = static_cast<double>(n);
|
|
const double vc = scc - sc * sc / nn, vy = syy - sy * sy / nn;
|
|
if (!(vc > 0.0) || !(vy > 0.0))
|
|
return 0.0;
|
|
return (scy - sc * sy / nn) / std::sqrt(vc * vy);
|
|
}
|
|
}
|
|
|
|
TranslationalNCSResult AnalyzeTranslationalNCS(const std::vector<MergedReflection> &merged,
|
|
const gemmi::UnitCell &cell,
|
|
const gemmi::SpaceGroup *space_group,
|
|
int null_draws, uint32_t seed, size_t nthreads) {
|
|
TranslationalNCSResult result;
|
|
if (cell.a <= 0.0 || cell.b <= 0.0 || cell.c <= 0.0) {
|
|
result.refusal = "no unit cell";
|
|
return result;
|
|
}
|
|
|
|
const gemmi::GroupOps gops = space_group ? space_group->operations() : gemmi::GroupOps{};
|
|
auto acentric = [&](const MergedReflection &r) {
|
|
return !space_group || !gops.is_reflection_centric(gemmi::Op::Miller{{r.h, r.k, r.l}});
|
|
};
|
|
|
|
// ------------------------------------------------------------------ (a) the Patterson peak
|
|
// Following the native-Patterson construction of Read, Adams & McCoy (2013) Acta Cryst. D69,
|
|
// 176-183, with the fixed peak-height table replaced by a per-dataset permutation null: the
|
|
// noise floor of this statistic runs from ~1% of the origin on a 74,000-reflection merge to ~18%
|
|
// on a 138-reflection one, so no fixed percentage can serve both.
|
|
std::vector<int> idx;
|
|
for (size_t i = 0; i < merged.size(); ++i) {
|
|
const auto &r = merged[i];
|
|
if (std::isfinite(r.I) && std::isfinite(r.d) && r.d >= kPattersonDMin && r.d <= kPattersonDMax)
|
|
idx.push_back(static_cast<int>(i));
|
|
}
|
|
result.n_reflections = static_cast<int>(idx.size());
|
|
if (result.n_reflections < kMinPattersonReflections) {
|
|
std::ostringstream os;
|
|
os << "only " << result.n_reflections << " reflections between " << kPattersonDMax << " and "
|
|
<< kPattersonDMin << " A - too few for a Patterson";
|
|
result.refusal = os.str();
|
|
return result;
|
|
}
|
|
|
|
// E^2: divide each intensity by the mean of an equal-count resolution shell, so the map is not
|
|
// dominated by the Wilson fall-off and the peak percentage is comparable between datasets.
|
|
std::sort(idx.begin(), idx.end(),
|
|
[&](int a, int b) { return merged[a].d != merged[b].d ? merged[a].d > merged[b].d : a < b; });
|
|
const int n = result.n_reflections;
|
|
std::vector<double> e2(n, 0.0);
|
|
std::vector<int> shell_of(n, 0);
|
|
for (int s = 0; s < kPattersonShells; ++s) {
|
|
const int lo = static_cast<int>(static_cast<int64_t>(s) * n / kPattersonShells);
|
|
const int hi = static_cast<int>(static_cast<int64_t>(s + 1) * n / kPattersonShells);
|
|
if (hi <= lo)
|
|
continue;
|
|
double sum = 0.0;
|
|
for (int i = lo; i < hi; ++i)
|
|
sum += merged[idx[i]].I;
|
|
const double mean = sum / (hi - lo);
|
|
for (int i = lo; i < hi; ++i) {
|
|
e2[i] = mean != 0.0 ? merged[idx[i]].I / mean : 0.0;
|
|
shell_of[i] = s;
|
|
}
|
|
}
|
|
|
|
// Grid. Sampled at d_min / 2.5, rounded up to an FFT-friendly size. A very large cell is measured
|
|
// on a coarser map rather than refused: the map only has to resolve a peak 15 A from the origin.
|
|
double patterson_dmin = kPattersonDMin;
|
|
std::array<int, 3> g{};
|
|
const double axis[3] = {cell.a, cell.b, cell.c};
|
|
for (;;) {
|
|
for (int i = 0; i < 3; ++i)
|
|
g[i] = SmoothSize(static_cast<int>(std::ceil(axis[i] * kGridSampling / patterson_dmin)));
|
|
const size_t points = static_cast<size_t>(g[0]) * g[1] * g[2];
|
|
if (points <= (64u << 20) || patterson_dmin >= 12.0)
|
|
break;
|
|
patterson_dmin *= 1.25;
|
|
}
|
|
result.grid = g;
|
|
const int nx = g[0], ny = g[1], nz = g[2];
|
|
const int nzh = nz / 2 + 1;
|
|
const size_t n_points = static_cast<size_t>(nx) * ny * nz;
|
|
const size_t n_complex = static_cast<size_t>(nx) * ny * nzh;
|
|
if (n_points > kLargeGridPoints)
|
|
null_draws = std::min(null_draws, 10);
|
|
|
|
// Expand the asymmetric unit onto the full sphere: every rotation of the space group and both
|
|
// Friedel mates, deduplicated. Only the half-space l' <= nz/2 is stored - the Patterson
|
|
// coefficients are real and centrosymmetric, so the other half is what the c2r transform infers.
|
|
std::vector<std::pair<size_t, int>> fill; // (index into the half-space grid, source reflection)
|
|
fill.reserve(idx.size() * 8);
|
|
{
|
|
std::unordered_set<int64_t> seen;
|
|
seen.reserve(idx.size() * 16);
|
|
std::vector<gemmi::Op> ops;
|
|
if (space_group)
|
|
ops = gops.sym_ops;
|
|
else
|
|
ops.push_back(gemmi::Op::identity());
|
|
for (const auto &op : ops)
|
|
for (int sign : {1, -1})
|
|
for (int i = 0; i < n; ++i) {
|
|
const auto &r = merged[idx[i]];
|
|
const gemmi::Op::Miller m = op.apply_to_hkl({{r.h, r.k, r.l}});
|
|
const int64_t h = sign * m[0], k = sign * m[1], l = sign * m[2];
|
|
const int64_t key = ((h + 1048576) << 42) | ((k + 1048576) << 21) | (l + 1048576);
|
|
if (!seen.insert(key).second)
|
|
continue;
|
|
const int gl = static_cast<int>(((l % nz) + nz) % nz);
|
|
if (gl > nz / 2)
|
|
continue; // its Friedel mate is stored instead
|
|
const int gh = static_cast<int>(((h % nx) + nx) % nx);
|
|
const int gk = static_cast<int>(((k % ny) + ny) % ny);
|
|
fill.emplace_back((static_cast<size_t>(gh) * ny + gk) * nzh + gl, i);
|
|
}
|
|
}
|
|
|
|
float *coeff = fftwf_alloc_real(2 * n_complex);
|
|
float *map = fftwf_alloc_real(n_points);
|
|
if (coeff == nullptr || map == nullptr) {
|
|
if (coeff) fftwf_free(coeff);
|
|
if (map) fftwf_free(map);
|
|
result.refusal = "could not allocate the Patterson grid";
|
|
return result;
|
|
}
|
|
fftwf_plan plan;
|
|
{
|
|
std::lock_guard lock(FFTWPlannerMutex());
|
|
plan = fftwf_plan_dft_c2r_3d(nx, ny, nz, reinterpret_cast<fftwf_complex *>(coeff), map,
|
|
FFTW_ESTIMATE);
|
|
}
|
|
|
|
// The near-origin exclusion, as a set of grid points rather than a per-point distance test: only
|
|
// the points inside a 15 A ball around each origin-equivalent point can be excluded, and there
|
|
// are a few thousand of those however large the grid is.
|
|
std::vector<uint8_t> near(n_points, 0);
|
|
std::vector<Vec3> equivalents = OriginEquivalents(space_group);
|
|
auto build_near_mask = [&]() {
|
|
std::fill(near.begin(), near.end(), 0);
|
|
const gemmi::UnitCell &c = cell;
|
|
// Fractional half-width of a sphere of radius R along axis i is R * |a*_i|, so the ball is
|
|
// enumerated directly instead of testing every grid point: the cost is a few thousand points
|
|
// however large the grid is.
|
|
const double rstar[3] = {c.ar, c.br, c.cr};
|
|
for (const auto &e : equivalents) {
|
|
const int span[3] = {static_cast<int>(std::ceil(kMinPeakDistanceA * rstar[0] * nx)) + 1,
|
|
static_cast<int>(std::ceil(kMinPeakDistanceA * rstar[1] * ny)) + 1,
|
|
static_cast<int>(std::ceil(kMinPeakDistanceA * rstar[2] * nz)) + 1};
|
|
const int c0[3] = {static_cast<int>(std::lround(e.x * nx)),
|
|
static_cast<int>(std::lround(e.y * ny)),
|
|
static_cast<int>(std::lround(e.z * nz))};
|
|
for (int i = c0[0] - span[0]; i <= c0[0] + span[0]; ++i)
|
|
for (int j = c0[1] - span[1]; j <= c0[1] + span[1]; ++j)
|
|
for (int k = c0[2] - span[2]; k <= c0[2] + span[2]; ++k) {
|
|
const gemmi::Position p = cell.orthogonalize(gemmi::Fractional{
|
|
static_cast<double>(i) / nx - e.x, static_cast<double>(j) / ny - e.y,
|
|
static_cast<double>(k) / nz - e.z});
|
|
if (p.length() >= kMinPeakDistanceA)
|
|
continue;
|
|
const size_t gi = static_cast<size_t>(((i % nx) + nx) % nx);
|
|
const size_t gj = static_cast<size_t>(((j % ny) + ny) % ny);
|
|
const size_t gk = static_cast<size_t>(((k % nz) + nz) % nz);
|
|
near[(gi * ny + gj) * nz + gk] = 1;
|
|
}
|
|
}
|
|
};
|
|
build_near_mask();
|
|
|
|
auto transform = [&](const std::vector<double> &values) {
|
|
std::memset(coeff, 0, 2 * n_complex * sizeof(float));
|
|
for (const auto &[at, src] : fill)
|
|
coeff[2 * at] += static_cast<float>(values[src]);
|
|
fftwf_execute(plan);
|
|
};
|
|
auto max_far = [&]() {
|
|
double best = -std::numeric_limits<double>::infinity();
|
|
for (size_t i = 0; i < n_points; ++i)
|
|
if (!near[i] && map[i] > best)
|
|
best = map[i];
|
|
return best;
|
|
};
|
|
|
|
transform(e2);
|
|
const double origin = map[0];
|
|
|
|
// The vector to report is the largest off-origin LOCAL maximum, which is not always the largest
|
|
// off-origin grid value: the tail of the origin peak can be higher right at the 15 A boundary,
|
|
// and that is a shoulder, not a peak.
|
|
auto top_local_max = [&]() {
|
|
double best = -std::numeric_limits<double>::infinity();
|
|
std::array<double, 3> at_frac{{0.0, 0.0, 0.0}};
|
|
for (int i = 0; i < nx; ++i)
|
|
for (int j = 0; j < ny; ++j)
|
|
for (int k = 0; k < nz; ++k) {
|
|
const size_t at = (static_cast<size_t>(i) * ny + j) * nz + k;
|
|
if (near[at] || map[at] <= best)
|
|
continue;
|
|
bool is_max = true;
|
|
for (int dx = -1; dx <= 1 && is_max; ++dx)
|
|
for (int dy = -1; dy <= 1 && is_max; ++dy)
|
|
for (int dz = -1; dz <= 1; ++dz) {
|
|
if (dx == 0 && dy == 0 && dz == 0)
|
|
continue;
|
|
const size_t nb = (static_cast<size_t>((i + dx + nx) % nx) * ny
|
|
+ (j + dy + ny) % ny) * nz + (k + dz + nz) % nz;
|
|
if (map[nb] > map[at]) { is_max = false; break; }
|
|
}
|
|
if (is_max) {
|
|
best = map[at];
|
|
at_frac = {static_cast<double>(i) / nx, static_cast<double>(j) / ny,
|
|
static_cast<double>(k) / nz};
|
|
}
|
|
}
|
|
return std::pair<std::array<double, 3>, double>{at_frac, best};
|
|
};
|
|
|
|
// Absorb any translation the data are invariant under - a centring the space group in use does
|
|
// not declare - into the exclusion, and look again underneath it. Without this, a C-centred
|
|
// crystal merged in P1 reports its own centring vector as a pseudo-symmetry (measured: 11 of the
|
|
// 95 P1 merges in the corpus), and a genuine pseudo-translation hiding below a centring vector is
|
|
// never reached.
|
|
double observed = 0.0;
|
|
for (int pass = 0; pass < 5; ++pass) {
|
|
observed = max_far();
|
|
result.peak_percent = origin > 0.0 ? 100.0 * observed / origin : 0.0;
|
|
const auto [frac, height] = top_local_max();
|
|
result.peak_frac = frac;
|
|
if (result.peak_percent < kLatticeTranslationPct || !std::isfinite(height))
|
|
break;
|
|
result.undeclared_lattice_translations.push_back(frac);
|
|
result.undeclared_lattice_translation_pct.push_back(100.0 * height / origin);
|
|
equivalents.push_back(Vec3{frac[0], frac[1], frac[2]});
|
|
build_near_mask();
|
|
}
|
|
|
|
// Permutation null: the same reflections, the same map, with the intensities shuffled inside each
|
|
// resolution shell. Everything that could make a Patterson peak except the correlation between
|
|
// reflections is preserved, so this is the noise floor of THIS dataset.
|
|
{
|
|
std::mt19937 rng(seed);
|
|
std::vector<double> permuted = e2;
|
|
std::vector<double> nulls;
|
|
nulls.reserve(null_draws);
|
|
for (int draw = 0; draw < null_draws; ++draw) {
|
|
permuted = e2;
|
|
for (int s = 0; s < kPattersonShells; ++s) {
|
|
const int lo = static_cast<int>(static_cast<int64_t>(s) * n / kPattersonShells);
|
|
const int hi = static_cast<int>(static_cast<int64_t>(s + 1) * n / kPattersonShells);
|
|
for (int i = hi - 1; i > lo; --i)
|
|
std::swap(permuted[i], permuted[lo + static_cast<int>(rng() % (i - lo + 1))]);
|
|
}
|
|
transform(permuted);
|
|
if (map[0] > 0.0)
|
|
nulls.push_back(100.0 * max_far() / map[0]);
|
|
}
|
|
if (!nulls.empty()) {
|
|
const double mean = std::accumulate(nulls.begin(), nulls.end(), 0.0) / nulls.size();
|
|
double var = 0.0;
|
|
for (double v : nulls)
|
|
var += (v - mean) * (v - mean);
|
|
var /= nulls.size();
|
|
result.null_mean = mean;
|
|
result.null_sd = std::sqrt(var);
|
|
result.peak_z = result.null_sd > 0.0 ? (result.peak_percent - mean) / result.null_sd : 0.0;
|
|
}
|
|
}
|
|
|
|
{
|
|
std::lock_guard lock(FFTWPlannerMutex());
|
|
fftwf_destroy_plan(plan);
|
|
}
|
|
fftwf_free(coeff);
|
|
fftwf_free(map);
|
|
result.measurable = true;
|
|
|
|
// ------------------------------------------------------------------ (b) the modulation depth
|
|
// A pure translation u between two copies multiplies the intensity by |1 + exp(2 pi i h.u)|^2, so
|
|
// <I> depends on the phase h.u: strong where it is near an integer, extinct near a half-integer.
|
|
// The Patterson peak says a vector exists; this says whether it modulates the data.
|
|
std::vector<std::array<int, 3>> mod_hkl;
|
|
std::vector<double> mod_e2;
|
|
// Where each kept resolution shell ends in the two arrays above. The null below shuffles the
|
|
// intensities inside a shell, exactly as the Patterson null does, and the shells are written
|
|
// consecutively, so their boundaries are all it needs.
|
|
std::vector<size_t> mod_shell_bounds{0};
|
|
{
|
|
std::vector<int> mod_idx;
|
|
for (size_t i = 0; i < merged.size(); ++i) {
|
|
const auto &r = merged[i];
|
|
if (std::isfinite(r.I) && std::isfinite(r.d) && r.d >= kModulationDMin && acentric(r))
|
|
mod_idx.push_back(static_cast<int>(i));
|
|
}
|
|
if (!mod_idx.empty()) {
|
|
// Shells linear in 1/d^2, and the same defensive normalisation the twinning statistics
|
|
// use: noise-only shells skipped, Wilson outliers rejected, the shell mean re-fitted once
|
|
// so the outlier does not corrupt the mean it is measured against.
|
|
double min_s = std::numeric_limits<double>::infinity(), max_s = -min_s;
|
|
for (int i : mod_idx) {
|
|
const double s = 1.0 / (merged[i].d * merged[i].d);
|
|
min_s = std::min(min_s, s);
|
|
max_s = std::max(max_s, s);
|
|
}
|
|
if (max_s > min_s) {
|
|
std::vector<std::vector<int>> bins(kModulationShells);
|
|
for (int i : mod_idx) {
|
|
const double t = (1.0 / (merged[i].d * merged[i].d) - min_s) / (max_s - min_s);
|
|
bins[std::clamp(static_cast<int>(t * kModulationShells), 0, kModulationShells - 1)]
|
|
.push_back(i);
|
|
}
|
|
for (const auto &b : bins) {
|
|
if (b.empty())
|
|
continue;
|
|
double isig = 0.0;
|
|
int n_isig = 0;
|
|
for (int i : b)
|
|
if (std::isfinite(merged[i].sigma) && merged[i].sigma > 0.0) {
|
|
isig += merged[i].I / merged[i].sigma;
|
|
++n_isig;
|
|
}
|
|
if (n_isig == 0 || isig / n_isig < kMinShellISig)
|
|
continue;
|
|
double sum = 0.0;
|
|
for (int i : b)
|
|
sum += merged[i].I;
|
|
double mean = sum / b.size();
|
|
if (mean <= 0.0)
|
|
continue;
|
|
sum = 0.0;
|
|
int kept = 0;
|
|
for (int i : b)
|
|
if (merged[i].I / mean <= kWilsonOutlierE2) { sum += merged[i].I; ++kept; }
|
|
if (kept == 0)
|
|
continue;
|
|
mean = sum / kept;
|
|
if (mean <= 0.0)
|
|
continue;
|
|
for (int i : b) {
|
|
const double v = merged[i].I / mean;
|
|
if (v > kWilsonOutlierE2)
|
|
continue;
|
|
mod_hkl.push_back({merged[i].h, merged[i].k, merged[i].l});
|
|
mod_e2.push_back(v);
|
|
}
|
|
mod_shell_bounds.push_back(mod_e2.size());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
result.n_modulation_reflections = static_cast<int>(mod_e2.size());
|
|
if (result.n_modulation_reflections < kMinModulationReflections)
|
|
return result;
|
|
|
|
const int floor_count = std::max(50, result.n_modulation_reflections / (4 * kPhaseBins));
|
|
auto contrast = [&](const std::vector<double> &e2v, const std::array<double, 3> &u,
|
|
std::vector<double> *profile) {
|
|
std::array<double, kPhaseBins> sum{}, cnt{};
|
|
sum.fill(0.0);
|
|
cnt.fill(0.0);
|
|
for (size_t i = 0; i < e2v.size(); ++i) {
|
|
const double t = mod_hkl[i][0] * u[0] + mod_hkl[i][1] * u[1] + mod_hkl[i][2] * u[2];
|
|
const double f = t - std::floor(t);
|
|
const int b = std::min(static_cast<int>(f * kPhaseBins), kPhaseBins - 1);
|
|
sum[b] += e2v[i];
|
|
cnt[b] += 1.0;
|
|
}
|
|
double lo = std::numeric_limits<double>::infinity(), hi = -lo;
|
|
int populated = 0;
|
|
if (profile)
|
|
profile->assign(kPhaseBins, std::numeric_limits<double>::quiet_NaN());
|
|
for (int b = 0; b < kPhaseBins; ++b) {
|
|
if (cnt[b] < floor_count)
|
|
continue;
|
|
const double m = sum[b] / cnt[b];
|
|
if (profile)
|
|
(*profile)[b] = m;
|
|
lo = std::min(lo, m);
|
|
hi = std::max(hi, m);
|
|
++populated;
|
|
}
|
|
if (populated < 2 || lo <= 0.0)
|
|
return std::numeric_limits<double>::quiet_NaN();
|
|
return hi / lo;
|
|
};
|
|
|
|
// Coarse-to-fine local search for the vector that modulates the data most strongly, started from
|
|
// the Patterson peak. The Patterson grid is coarse and a real pseudo-translation is rarely at an
|
|
// exactly rational position, so the peak position alone understates the modulation.
|
|
const std::array<double, 3> step0{1.0 / nx, 1.0 / ny, 1.0 / nz};
|
|
auto refine = [&](const std::vector<double> &e2v, std::array<double, 3> u) {
|
|
std::array<double, 3> s = step0;
|
|
double best = contrast(e2v, u, nullptr);
|
|
for (int round = 0; round < 3; ++round) {
|
|
for (bool improved = true; improved;) {
|
|
improved = false;
|
|
for (int i = 0; i < 3; ++i)
|
|
for (double gstep : {-1.0, -0.5, 0.0, 0.5, 1.0}) {
|
|
std::array<double, 3> v = u;
|
|
v[i] += gstep * s[i];
|
|
const double c = contrast(e2v, v, nullptr);
|
|
if (std::isfinite(c) && c > best + 1e-6) {
|
|
best = c;
|
|
u = v;
|
|
improved = true;
|
|
}
|
|
}
|
|
}
|
|
for (double &x : s)
|
|
x /= 2.5;
|
|
}
|
|
return std::pair<std::array<double, 3>, double>{u, best};
|
|
};
|
|
|
|
const auto [u_refined, best_contrast] = refine(mod_e2, result.peak_frac);
|
|
if (!std::isfinite(best_contrast))
|
|
return result;
|
|
for (int i = 0; i < 3; ++i)
|
|
result.vector_frac[i] = u_refined[i] - std::floor(u_refined[i]);
|
|
result.modulation_measured = true;
|
|
result.modulation = best_contrast;
|
|
contrast(mod_e2, u_refined, &result.phase_bin_mean_e2);
|
|
result.vector_length_A = DistanceToOrigin(cell, Vec3{result.vector_frac[0], result.vector_frac[1],
|
|
result.vector_frac[2]},
|
|
std::vector<Vec3>{{0.0, 0.0, 0.0}});
|
|
|
|
// The modulation's own null: the SAME search, from the SAME start, on the same reflections with
|
|
// the intensities shuffled inside each resolution shell - the permutation the Patterson null
|
|
// above already uses, applied to the second half of the evidence. A greedy search over three
|
|
// free parameters can manufacture contrast out of nothing, and this measures how much rather
|
|
// than assuming it is negligible; the shuffle leaves the reflection count, the E^2 distribution
|
|
// and the phase-bin populations exactly as they were and removes only the one thing the
|
|
// modulation claims to see, the correlation between a reflection's intensity and h.u.
|
|
//
|
|
// Drawing the null from the unshuffled data instead - random starting vectors, the crystal's own
|
|
// intensities - measured the search AND whatever modulation the data carry, because a greedy
|
|
// search started anywhere walks into a real modulation's basin. Measured over 110 merged
|
|
// datasets: single draws reached 199x and 6162x where the crystal's own modulation read 4.0x and
|
|
// 67x, and 28 of the 110 had the control at or above the signal it was controlling for. The
|
|
// median was robust enough that this changes no verdict anywhere in that corpus - what it
|
|
// removes is a control that reads the same signal it is subtracted from, and with it the one
|
|
// way an upstream change can move the control while the modulation stands still.
|
|
{
|
|
// The shuffles are drawn in order from the one generator, then searched on several workers:
|
|
// each search reads only its own copy, so the nulls are the serial loop's, bit for bit.
|
|
std::mt19937 rng(seed + 7);
|
|
std::vector<std::vector<double>> permuted(kModulationNullDraws, mod_e2);
|
|
for (auto &p : permuted)
|
|
for (size_t s = 1; s < mod_shell_bounds.size(); ++s) {
|
|
const size_t lo = mod_shell_bounds[s - 1], hi = mod_shell_bounds[s];
|
|
for (size_t i = hi; i > lo + 1; --i)
|
|
std::swap(p[i - 1], p[lo + rng() % (i - lo)]);
|
|
}
|
|
std::vector<double> nulls(kModulationNullDraws);
|
|
ParallelFor(kModulationNullDraws, nthreads, [&](int draw) {
|
|
const double c = refine(permuted[draw], result.peak_frac).second;
|
|
nulls[draw] = std::isfinite(c) ? c : 1.0;
|
|
});
|
|
std::sort(nulls.begin(), nulls.end());
|
|
const size_t m = nulls.size();
|
|
result.modulation_null = m % 2 ? nulls[m / 2] : 0.5 * (nulls[m / 2 - 1] + nulls[m / 2]);
|
|
}
|
|
|
|
result.detected = result.peak_z >= kPeakZThreshold && result.modulation_null > 0.0 &&
|
|
result.modulation / result.modulation_null >= kModulationRatioThreshold;
|
|
|
|
result.near_extinct_class = result.detected && result.modulation >= kNearExtinctModulation;
|
|
|
|
// ------------------------------------------------ the vector the phase CLASSES are assigned with
|
|
//
|
|
// Everything above is measured on d >= 4 A, where |h| stays under about 15 and an error of 0.02 in
|
|
// u costs the contrast almost nothing - the gate's vector is refined only to the accuracy the gate
|
|
// needs. But the consumers of this vector (the L-test's partner steps, and the twin-immune zone's
|
|
// per-class normalisation) assign a CLASS, cos(2 pi h.u), to reflections at FULL resolution, and
|
|
// the phase error there is |h| times the error in u. At |h| = 20 an error of 0.02 is a third of a
|
|
// turn and the classes are no longer the physical ones. Measured as the correlation between E^2
|
|
// and cos(2 pi h.u) in bands of |h| on two of this corpus's pseudo-translations, the gate's
|
|
// vector against this one: 0.57/0.61 at |h| < 8, 0.42/0.52, 0.19/0.30, 0.14/0.18 past |h| = 22 on
|
|
// the first; 0.14/0.52, -0.15/0.42, 0.00/0.20, -0.02/0.09 on the second, whose classes the gate's
|
|
// vector gets ANTI-correlated with the real ones over a whole band.
|
|
//
|
|
// So u is re-refined on the whole data, up a ladder: each step reads twice as many reflections as
|
|
// the one before, in order of |h|, and starts from the previous step's answer with a search step
|
|
// of a quarter of the phase ambiguity 1/|h| of the highest order it reads. The vector is never
|
|
// asked to extrapolate further than it is known, which is what lets a greedy search cross a
|
|
// resolution range whose objective has a local maximum every 1/|h| in each direction. Refining on
|
|
// the whole data in one go instead walks into the nearest of those.
|
|
//
|
|
// Nothing above is re-read: `modulation`, `modulation_null` and `detected` stay the gate's, so
|
|
// which crystals are called stays exactly as it was. Only where the vector points changes.
|
|
if (result.detected) {
|
|
std::vector<std::array<int, 3>> cls_hkl;
|
|
std::vector<double> cls_e2;
|
|
{
|
|
std::vector<int> idx;
|
|
for (size_t i = 0; i < merged.size(); ++i)
|
|
if (std::isfinite(merged[i].I) && std::isfinite(merged[i].d) && merged[i].d > 0.0
|
|
&& acentric(merged[i]))
|
|
idx.push_back(static_cast<int>(i));
|
|
// Equal-count shells sorted by |h|, so that every stage of the ladder reads a population
|
|
// that is already normalised and can be taken as a prefix.
|
|
std::sort(idx.begin(), idx.end(), [&](int a, int b) {
|
|
return std::max({std::abs(merged[a].h), std::abs(merged[a].k), std::abs(merged[a].l)})
|
|
< std::max({std::abs(merged[b].h), std::abs(merged[b].k), std::abs(merged[b].l)});
|
|
});
|
|
std::vector<int> by_d = idx;
|
|
std::sort(by_d.begin(), by_d.end(), [&](int a, int b) { return merged[a].d > merged[b].d; });
|
|
std::vector<double> e2_of(merged.size(), std::numeric_limits<double>::quiet_NaN());
|
|
const size_t n_shells = std::max<size_t>(1, by_d.size() / 500);
|
|
for (size_t s = 0; s < n_shells; ++s) {
|
|
const size_t lo = s * by_d.size() / n_shells, hi = (s + 1) * by_d.size() / n_shells;
|
|
double sum = 0.0;
|
|
for (size_t i = lo; i < hi; ++i)
|
|
sum += merged[by_d[i]].I;
|
|
const double mean = sum / static_cast<double>(hi - lo);
|
|
if (mean <= 0.0)
|
|
continue;
|
|
for (size_t i = lo; i < hi; ++i)
|
|
e2_of[by_d[i]] = merged[by_d[i]].I / mean;
|
|
}
|
|
for (int i : idx)
|
|
if (std::isfinite(e2_of[i]) && e2_of[i] <= kWilsonOutlierE2) {
|
|
cls_hkl.push_back({merged[i].h, merged[i].k, merged[i].l});
|
|
cls_e2.push_back(e2_of[i]);
|
|
}
|
|
}
|
|
// The steps of the ladder double the number of reflections read rather than the |h| ceiling,
|
|
// so that the first step always has a population to refine on whatever the cell is and no
|
|
// step is skipped for being empty.
|
|
std::array<double, 3> u = u_refined;
|
|
for (size_t n = kMinModulationReflections; !cls_hkl.empty(); n *= 2) {
|
|
n = std::min(n, cls_hkl.size());
|
|
const int h_top = std::max({std::abs(cls_hkl[n - 1][0]), std::abs(cls_hkl[n - 1][1]),
|
|
std::abs(cls_hkl[n - 1][2])});
|
|
// A quarter of the phase ambiguity 1/|h| of the highest order this step reads: large
|
|
// enough to walk to the maximum, small enough that the first probe cannot land in the
|
|
// neighbouring one.
|
|
double step = h_top > 0 ? 0.25 / h_top : std::max({step0[0], step0[1], step0[2]});
|
|
double best = CosineCorrelation(cls_hkl, cls_e2, u, n);
|
|
// All 26 neighbours, not the 6 along the axes: the phase is h.u, so a vector whose class
|
|
// comes mostly from one combination of the axes - h/3 + 2k/3, say - sits in a valley that
|
|
// runs diagonally, and a search that only moves one component at a time stalls on its
|
|
// wall (measured: 0.005 short in two components at once, a quarter of a turn by |h| = 60).
|
|
constexpr int NEIGHBOURS = 26;
|
|
const auto neighbour = [&](int j) {
|
|
const int k = j < 13 ? j : j + 1; // the 27 offsets in dx, dy, dz order, centre skipped
|
|
const int dx = k / 9 - 1, dy = k / 3 % 3 - 1, dz = k % 3 - 1;
|
|
return std::array<double, 3>{u[0] + dx * step, u[1] + dy * step, u[2] + dz * step};
|
|
};
|
|
for (int round = 0; round < 8; ++round) {
|
|
for (bool improved = true; improved;) {
|
|
improved = false;
|
|
// The sweep takes each neighbour of the current vector in turn and moves to it
|
|
// when it improves, so the neighbours after a move are those of the new vector.
|
|
// They are evaluated together, the first improvement in sweep order is taken,
|
|
// and the rest are evaluated again about it - the serial sweep's moves exactly.
|
|
std::array<double, NEIGHBOURS> f{};
|
|
for (int j = 0; j < NEIGHBOURS;) {
|
|
ParallelFor(NEIGHBOURS - j, nthreads, [&](int i) {
|
|
f[j + i] = CosineCorrelation(cls_hkl, cls_e2, neighbour(j + i), n);
|
|
});
|
|
int k = j;
|
|
while (k < NEIGHBOURS && !(f[k] > best + 1e-9))
|
|
++k;
|
|
if (k == NEIGHBOURS)
|
|
break;
|
|
best = f[k];
|
|
u = neighbour(k);
|
|
improved = true;
|
|
j = k + 1;
|
|
}
|
|
}
|
|
step *= 0.5;
|
|
}
|
|
if (n == cls_hkl.size())
|
|
break;
|
|
}
|
|
// A greedy climb up the ladder can still end on an alias: a stage whose population is small
|
|
// and whose objective is flat over the step it searches with can hand the next stage a start
|
|
// one phase turn out, and from there the finer steps cannot come back. The two candidates are
|
|
// carried to the end and the whole data decide between them, which is the one place where
|
|
// both are directly comparable.
|
|
if (!cls_hkl.empty()
|
|
&& CosineCorrelation(cls_hkl, cls_e2, u_refined, cls_hkl.size())
|
|
> CosineCorrelation(cls_hkl, cls_e2, u, cls_hkl.size()))
|
|
u = u_refined;
|
|
for (int i = 0; i < 3; ++i)
|
|
result.vector_frac[i] = u[i] - std::floor(u[i]);
|
|
contrast(mod_e2, u, &result.phase_bin_mean_e2);
|
|
result.vector_length_A = DistanceToOrigin(cell, Vec3{result.vector_frac[0], result.vector_frac[1],
|
|
result.vector_frac[2]},
|
|
std::vector<Vec3>{{0.0, 0.0, 0.0}});
|
|
}
|
|
|
|
// Is the vector a rational translation of the reported cell? If it is, the cell contains a
|
|
// sub-lattice: the pseudo-translation is a candidate for a REAL lattice translation the indexing
|
|
// missed, and the reported cell is a supercell candidate rather than a settled result.
|
|
for (int q = 2; q <= 6 && !result.commensurate; ++q) {
|
|
std::array<int, 3> p{};
|
|
double err = 0.0;
|
|
bool fractional = false;
|
|
for (int i = 0; i < 3; ++i) {
|
|
p[i] = static_cast<int>(std::lround(result.vector_frac[i] * q));
|
|
err = std::max(err, std::fabs(result.vector_frac[i] * q - p[i]));
|
|
if (p[i] % q != 0)
|
|
fractional = true;
|
|
}
|
|
if (err / q <= 0.05 && fractional) {
|
|
result.commensurate = true;
|
|
result.commensurate_denominator = q;
|
|
result.commensurate_numerator = p;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::string TranslationalNCSToText(const TranslationalNCSResult &result) {
|
|
std::ostringstream os;
|
|
os << "Translational pseudo-symmetry\n";
|
|
if (!result.measurable) {
|
|
os << " Not measured: " << result.refusal << ".\n"
|
|
<< " => This is not a statement that the crystal has none.\n";
|
|
return os.str();
|
|
}
|
|
os << std::fixed;
|
|
for (size_t i = 0; i < result.undeclared_lattice_translations.size(); ++i) {
|
|
const auto &t = result.undeclared_lattice_translations[i];
|
|
os << " The Patterson at (" << std::setprecision(3) << t[0] << ", " << t[1] << ", " << t[2]
|
|
<< ") is " << std::setprecision(0) << result.undeclared_lattice_translation_pct[i]
|
|
<< "% of the origin: nearly every reflection is invariant under it,\n"
|
|
<< " so it is a LATTICE translation the space group in use does not declare (a centred\n"
|
|
<< " lattice merged without its centring, or a supercell) - or, short of ~90%, a\n"
|
|
<< " pseudo-translation so strong it is nearly one. Excluded, and the search continued past it.\n";
|
|
}
|
|
os << " Native Patterson off-origin peak " << std::setprecision(1) << result.peak_percent
|
|
<< "% of the origin";
|
|
if (result.vector_length_A > 0.0)
|
|
os << " at (" << std::setprecision(3) << result.vector_frac[0] << ", " << result.vector_frac[1]
|
|
<< ", " << result.vector_frac[2] << "), " << std::setprecision(1) << result.vector_length_A
|
|
<< " A";
|
|
os << "\n [z = " << std::setprecision(1) << result.peak_z << " against a permutation null of "
|
|
<< result.null_mean << " +- " << std::setprecision(2) << result.null_sd << "%]\n";
|
|
if (result.modulation_measured)
|
|
os << " Intensity modulation: " << std::setprecision(1) << result.modulation
|
|
<< "x between the strongest and weakest phase class [random-vector control "
|
|
<< result.modulation_null << "x]\n"
|
|
<< " [read on the search's own vector over d >= 4 A, which is what the control is drawn\n"
|
|
<< " against; the vector reported above is that one re-refined on the whole data, so it\n"
|
|
<< " points more accurately and this number is not re-read off it]\n";
|
|
if (!result.modulation_measured) {
|
|
os << " The modulation test could not be run - too few acentric reflections beyond 4 A - so\n"
|
|
<< " the peak above stands alone.\n"
|
|
<< " => Inconclusive. A Patterson peak on its own is not enough to call a pseudo-symmetry\n"
|
|
<< " (peaks of this size occur without one), and this dataset cannot supply the second\n"
|
|
<< " half of the evidence.\n";
|
|
return os.str();
|
|
}
|
|
if (!result.detected) {
|
|
os << " => No translational pseudo-symmetry indicated.\n";
|
|
return os.str();
|
|
}
|
|
os << " => Translational pseudo-symmetry. Molecular replacement will need it declared - Phaser\n"
|
|
<< " reads a tNCS vector and corrects the likelihood for it; without that the search can\n"
|
|
<< " fail on data that are otherwise good.\n";
|
|
if (result.near_extinct_class)
|
|
os << " => The class this vector suppresses is not weak but very nearly EXTINCT, which is\n"
|
|
<< " what a lattice translation of a smaller cell looks like measured in a larger one.\n"
|
|
<< " The reported cell is therefore a supercell candidate rather than a settled\n"
|
|
<< " result, and the rival hypothesis is a cell with this translation as a lattice\n"
|
|
<< " vector.\n";
|
|
else if (result.commensurate)
|
|
os << " => The vector is a 1/" << result.commensurate_denominator
|
|
<< " translation of the cell, so the crystal is pseudo-centred. The cell itself is not in\n"
|
|
<< " question - the suppressed class is weak, not absent, and a smaller cell would\n"
|
|
<< " contradict it.\n";
|
|
return os.str();
|
|
}
|