// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "TranslationalNCS.h" #include #include #include #include #include #include #include #include #include #include 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 OriginEquivalents(const gemmi::SpaceGroup *sg) { std::vector 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 &equivalents) { double best = std::numeric_limits::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; } } TranslationalNCSResult AnalyzeTranslationalNCS(const std::vector &merged, const gemmi::UnitCell &cell, const gemmi::SpaceGroup *space_group, int null_draws, uint32_t seed) { 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 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(i)); } result.n_reflections = static_cast(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 e2(n, 0.0); std::vector shell_of(n, 0); for (int s = 0; s < kPattersonShells; ++s) { const int lo = static_cast(static_cast(s) * n / kPattersonShells); const int hi = static_cast(static_cast(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 g{}; const double axis[3] = {cell.a, cell.b, cell.c}; for (;;) { for (int i = 0; i < 3; ++i) g[i] = SmoothSize(static_cast(std::ceil(axis[i] * kGridSampling / patterson_dmin))); const size_t points = static_cast(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(nx) * ny * nz; const size_t n_complex = static_cast(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> fill; // (index into the half-space grid, source reflection) fill.reserve(idx.size() * 8); { std::unordered_set seen; seen.reserve(idx.size() * 16); std::vector 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(((l % nz) + nz) % nz); if (gl > nz / 2) continue; // its Friedel mate is stored instead const int gh = static_cast(((h % nx) + nx) % nx); const int gk = static_cast(((k % ny) + ny) % ny); fill.emplace_back((static_cast(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 = fftwf_plan_dft_c2r_3d(nx, ny, nz, reinterpret_cast(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 near(n_points, 0); std::vector 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(std::ceil(kMinPeakDistanceA * rstar[0] * nx)) + 1, static_cast(std::ceil(kMinPeakDistanceA * rstar[1] * ny)) + 1, static_cast(std::ceil(kMinPeakDistanceA * rstar[2] * nz)) + 1}; const int c0[3] = {static_cast(std::lround(e.x * nx)), static_cast(std::lround(e.y * ny)), static_cast(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(i) / nx - e.x, static_cast(j) / ny - e.y, static_cast(k) / nz - e.z}); if (p.length() >= kMinPeakDistanceA) continue; const size_t gi = static_cast(((i % nx) + nx) % nx); const size_t gj = static_cast(((j % ny) + ny) % ny); const size_t gk = static_cast(((k % nz) + nz) % nz); near[(gi * ny + gj) * nz + gk] = 1; } } }; build_near_mask(); auto transform = [&](const std::vector &values) { std::memset(coeff, 0, 2 * n_complex * sizeof(float)); for (const auto &[at, src] : fill) coeff[2 * at] += static_cast(values[src]); fftwf_execute(plan); }; auto max_far = [&]() { double best = -std::numeric_limits::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::infinity(); std::array 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(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((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(i) / nx, static_cast(j) / ny, static_cast(k) / nz}; } } return std::pair, 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); 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 permuted = e2; std::vector 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(static_cast(s) * n / kPattersonShells); const int hi = static_cast(static_cast(s + 1) * n / kPattersonShells); for (int i = hi - 1; i > lo; --i) std::swap(permuted[i], permuted[lo + static_cast(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; } } 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 // 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> mod_hkl; std::vector 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 mod_shell_bounds{0}; { std::vector 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(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::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> 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(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(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 &e2v, const std::array &u, std::vector *profile) { std::array 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(f * kPhaseBins), kPhaseBins - 1); sum[b] += e2v[i]; cnt[b] += 1.0; } double lo = std::numeric_limits::infinity(), hi = -lo; int populated = 0; if (profile) profile->assign(kPhaseBins, std::numeric_limits::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::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 step0{1.0 / nx, 1.0 / ny, 1.0 / nz}; auto refine = [&](const std::vector &e2v, std::array u) { std::array 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 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, 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{{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. { std::mt19937 rng(seed + 7); std::vector permuted = mod_e2; std::vector nulls; nulls.reserve(kModulationNullDraws); for (int draw = 0; draw < kModulationNullDraws; ++draw) { permuted = mod_e2; 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(permuted[i - 1], permuted[lo + rng() % (i - lo)]); } const auto [v, c] = refine(permuted, result.peak_frac); (void) v; nulls.push_back(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; // 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 p{}; double err = 0.0; bool fractional = false; for (int i = 0; i < 3; ++i) { p[i] = static_cast(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 (const auto &t : result.undeclared_lattice_translations) os << " The data are invariant under (" << std::setprecision(3) << t[0] << ", " << t[1] << ", " << t[2] << ") - the Patterson is as high there as at the origin, so that is a\n" << " LATTICE translation, not a pseudo-symmetry, and the space group in use does not\n" << " declare it. Either the lattice is centred and the merge is not, or the cell is a\n" << " supercell. 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 along that vector: " << std::setprecision(1) << result.modulation << "x between the strongest and weakest phase class [random-vector control " << result.modulation_null << "x]\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(); }