Files
Jungfraujoch/image_analysis/scale_merge/SublatticeRelabel.cpp
T
leonarski_fandClaude Opus 5.5 093069be29 rugnux stills: a given cell is matched in any setting, and per-image reindexing moves the lattice with the indices
Three defects that stopped known-cell stills runs:

- The consensus cell compared every frame's cell to -C axis by axis. A run whose frames index in another
  setting of the same lattice (axes in another order, another monoclinic choice) had no frame accepted, no
  consensus cell and no output ("No crystal lattice was determined"), although it merged fine. A frame
  whose lattice contains the given cell now counts on the given cell's axes (CellOnReference, the same
  FindSublatticeBases the merge-stage relabel uses).
- The per-image ambiguity resolver relabelled a frame's reflections but left its lattice on the old axes.
  Everything after it predicts from index and lattice: the sublattice relabel put such a frame through a
  second transformation, and on a monoclinic cell with a near-symmetry (a+c nearly as long as a) the
  merge came out at indices finer than anything measured, shells over 100% complete. The lattice now
  follows the indices (ReindexLattice, B' = R^-1 B).
- With -C/-S the stills path replaced the known-cell indexer's lattice, already on the given axes, by
  LatticeSearch's conventional choice, which on that near-symmetric cell is the other description on half
  the frames. The indexer's lattice is now kept when it is close to the given cell.

Measured (cell + space group given, dimple against the deposited model and free set):
- monoclinic MbCO-type still set: was over-complete/broken with --model; now 2.06 A, R/Rfree 0.233/0.314
  at 2.11 A (P1 merge before: 0.275/0.306).
- C-centred orthorhombic set: no MTZ before; now 3.27 A, R/Rfree 0.298/0.403 at 3.68 A (de novo 0.336/0.448).
- monoclinic two-form set, one form: cell P 1 21 1 with beta (was alpha); Rfree 0.310 -> 0.260, 3.05 -> 2.72 A.
- No change on seven other stills sets, nor on two rotation battery sets (5reo, lyso_x10sa_90deg_1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:56:07 +02:00

184 lines
8.3 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "SublatticeRelabel.h"
#include <algorithm>
#include <cmath>
#include <limits>
namespace {
// Largest coefficient tried: a reference axis up to four frame axes long, which covers the 2x, 3x
// and 4x axis multiples a few-spot frame gets indexed in.
constexpr int MAX_COEFFICIENT = 4;
// Largest volume ratio frame / reference accepted: the frame's lattice a 2x, 3x or 4x supercell (or
// the reference itself in another setting). A denser lattice holds near-fits of almost any cell.
constexpr int MAX_INDEX = 4;
float AngleDeg(const Coord &u, const Coord &v) {
const float c = (u * v) / (u.Length() * v.Length());
return std::acos(std::clamp(c, -1.0f, 1.0f)) * (180.0f / 3.14159265f);
}
int Det(const std::array<std::array<int, 3>, 3> &m) {
return m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
}
}
std::vector<std::array<std::array<int, 3>, 3>> FindSublatticeBases(const CrystalLattice &frame,
const UnitCell &reference,
const IndexingSettings &settings) {
const CrystalLattice ref(reference);
const Coord ref_star[3] = {ref.Astar(), ref.Bstar(), ref.Cstar()};
const Coord frame_star[3] = {frame.Astar(), frame.Bstar(), frame.Cstar()};
const float dist_tol = settings.GetUnitCellDistTolerance();
const float angle_tol = settings.GetUnitCellAngleTolerance_deg();
// The frame's reciprocal lattice vectors as long as each reference axis.
struct Candidate { std::array<int, 3> n; Coord v; };
std::vector<Candidate> axis[3];
for (int i = -MAX_COEFFICIENT; i <= MAX_COEFFICIENT; ++i)
for (int j = -MAX_COEFFICIENT; j <= MAX_COEFFICIENT; ++j)
for (int k = -MAX_COEFFICIENT; k <= MAX_COEFFICIENT; ++k) {
const Coord v = frame_star[0] * i + frame_star[1] * j + frame_star[2] * k;
for (int a = 0; a < 3; ++a)
if (std::fabs(v.Length() - ref_star[a].Length()) <= dist_tol * ref_star[a].Length())
axis[a].push_back({{i, j, k}, v});
}
const float ref_angle[3] = {AngleDeg(ref_star[1], ref_star[2]), AngleDeg(ref_star[0], ref_star[2]),
AngleDeg(ref_star[0], ref_star[1])};
std::vector<std::array<std::array<int, 3>, 3>> bases;
for (const auto &a: axis[0])
for (const auto &b: axis[1]) {
if (std::fabs(AngleDeg(a.v, b.v) - ref_angle[2]) > angle_tol)
continue;
for (const auto &c: axis[2]) {
if (std::fabs(AngleDeg(b.v, c.v) - ref_angle[0]) > angle_tol
|| std::fabs(AngleDeg(a.v, c.v) - ref_angle[1]) > angle_tol)
continue;
const std::array<std::array<int, 3>, 3> m = {a.n, b.n, c.n};
if (Det(m) > 0 && Det(m) <= MAX_INDEX)
bases.push_back(m);
}
}
return bases;
}
namespace {
// The reference basis in this frame's orientation: the reciprocal axes are rows of M times the
// frame's, and the real axes their reciprocal.
CrystalLattice OnBasis(const CrystalLattice &frame, const std::array<std::array<int, 3>, 3> &M) {
const Coord fs[3] = {frame.Astar(), frame.Bstar(), frame.Cstar()};
Coord rs[3];
for (int i = 0; i < 3; ++i)
rs[i] = fs[0] * M[i][0] + fs[1] * M[i][1] + fs[2] * M[i][2];
const float v_star = rs[0] * (rs[1] % rs[2]);
return {(rs[1] % rs[2]) / v_star, (rs[2] % rs[0]) / v_star, (rs[0] % rs[1]) / v_star};
}
// The frame's reflections at nodes of the lattice M picks out, re-indexed on it: a frame index h'
// is h = h' M^-1 = h' adj(M) / det(M) there, a node only where all three are integers.
ReflectionVector NodesOf(const ReflectionVector &reflections, const std::array<std::array<int, 3>, 3> &M,
float d_min) {
const int det = Det(M);
int adj[3][3];
for (int i = 0; i < 3; ++i)
for (int j = 0; j < 3; ++j) {
const int r0 = (j + 1) % 3, r1 = (j + 2) % 3, c0 = (i + 1) % 3, c1 = (i + 2) % 3;
adj[i][j] = M[r0][c0] * M[r1][c1] - M[r0][c1] * M[r1][c0];
}
ReflectionVector nodes;
for (const auto &r: reflections) {
const int hp[3] = {r.h, r.k, r.l};
int h[3];
bool node = true;
for (int j = 0; j < 3; ++j) {
const int s = hp[0] * adj[0][j] + hp[1] * adj[1][j] + hp[2] * adj[2][j];
node = node && s % det == 0;
h[j] = s / det;
}
if (!node || !(r.d >= d_min))
continue;
Reflection relabelled = r;
relabelled.h = h[0];
relabelled.k = h[1];
relabelled.l = h[2];
nodes.push_back(relabelled);
}
return nodes;
}
double MeanISigma(const ReflectionVector &reflections) {
double sum = 0.0;
size_t n = 0;
for (const auto &r: reflections)
if (std::isfinite(r.I) && r.sigma > 0.0f) {
sum += r.I / r.sigma;
++n;
}
return n > 0 ? sum / static_cast<double>(n) : -std::numeric_limits<double>::infinity();
}
}
SublatticeRelabelResult RelabelOnSublattice(std::vector<IntegrationOutcome> &outcomes,
const UnitCell &reference,
const IndexingSettings &settings) {
const auto on_reference = [&](const IntegrationOutcome &o) {
return o.latt.GetUnitCell().is_close(reference, settings.GetUnitCellDistTolerance(),
settings.GetUnitCellAngleTolerance_deg());
};
// A frame indexed in the wrong lattice was also predicted to the wrong resolution - its limit read
// off spots that were mostly noise - so its reflections are kept only as fine as the frames already
// on the reference cell reach.
float d_min = std::numeric_limits<float>::infinity();
for (const auto &o: outcomes)
if (on_reference(o))
for (const auto &r: o.reflections)
if (std::isfinite(r.d) && r.d > 0.0f)
d_min = std::min(d_min, r.d);
SublatticeRelabelResult result;
for (auto &o: outcomes) {
if (o.reflections.empty() || on_reference(o))
continue;
// A lattice can hold the reference in more than one way - a supercell whose metric is nearly
// symmetric fits it along either of two axes - and only the true one has signal at its nodes:
// the reflections between them are empty. So the bases are told apart by the data.
std::optional<std::array<std::array<int, 3>, 3>> best;
ReflectionVector best_nodes;
double best_signal = 0.0;
for (const auto &m: FindSublatticeBases(o.latt, reference, settings)) {
auto nodes = NodesOf(o.reflections, m, d_min);
const double signal = MeanISigma(nodes);
if (!best || signal > best_signal) {
best = m;
best_nodes = std::move(nodes);
best_signal = signal;
}
}
if (!best)
continue;
result.reflections_dropped += o.reflections.size() - best_nodes.size();
result.reflections_kept += best_nodes.size();
o.reflections = std::move(best_nodes);
o.latt = OnBasis(o.latt, *best);
++result.frames;
}
return result;
}
std::optional<UnitCell> CellOnReference(const UnitCell &cell, const UnitCell &reference,
const IndexingSettings &settings) {
auto bases = FindSublatticeBases(CrystalLattice(cell), reference, settings);
if (bases.empty())
return {};
// The reference in another setting (index 1) before any axis multiple of it.
std::sort(bases.begin(), bases.end(), [](const auto &x, const auto &y) { return Det(x) < Det(y); });
return OnBasis(CrystalLattice(cell), bases.front()).GetUnitCell();
}