Files
Jungfraujoch/image_analysis/lattice_search/LePageLattice.cpp
T
leonarski_f 84228bf8be
Build Packages / Create release (push) Successful in 24s
Build Packages / build:viewer:macos-arm64:nocuda (push) Successful in 3m29s
Build Packages / build:rugnux:macos-arm64:nocuda (push) Successful in 2m43s
Build Packages / build:rugnux:linux-aarch64:cuda (push) Successful in 8m27s
Build Packages / build:rugnux:linux-x86_64:cuda (push) Successful in 9m53s
Build Packages / build:viewer:linux-x86_64:nocuda (push) Successful in 9m58s
Build Packages / build:viewer:linux-x86_64:cuda (push) Successful in 11m22s
Build Packages / build:jfjoch:rocky8:nocuda (push) Successful in 13m39s
Build Packages / build:viewer:windows-x86_64:nocuda (push) Successful in 18m37s
Build Packages / build:jfjoch:rocky9:nocuda (push) Successful in 16m32s
Build Packages / build:viewer:windows-x86_64:cuda (push) Successful in 24m11s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m30s
Build Packages / build:jfjoch:ubuntu2404:nocuda (push) Successful in 19m3s
Build Packages / build:jfjoch:ubuntu2204:nocuda (push) Successful in 20m23s
Build Packages / build:jfjoch:rocky8:cuda-sls9 (push) Successful in 19m41s
Build Packages / Generate python client (push) Successful in 50s
Build Packages / Build documentation (push) Successful in 1m16s
Build Packages / build:jfjoch:rocky9:cuda-sls9 (push) Successful in 21m0s
Build Packages / build:jfjoch:rocky8:cuda (push) Successful in 18m38s
Build Packages / build:rugnux:windows-x86_64:cuda (push) Successful in 14m33s
Build Packages / build:jfjoch:rocky9:cuda (push) Successful in 17m55s
Build Packages / build:jfjoch:ubuntu2204:cuda (push) Successful in 20m50s
Build Packages / build:jfjoch:ubuntu2404:cuda (push) Successful in 18m38s
Build Packages / Unit tests (push) Successful in 1h46m14s
v1.0.0-rc.173 (#83)
* jfjoch_broker: Optional per-dataset authentication - statistics, images and plots can require a bearer token, which jfjoch_viewer supports.
* jfjoch_viewer: Dark mode and a theme-matched colour scheme, a magnifier panel, and simpler contrast and background controls.
* Rugnux: Multiple performance improvements on GPU and CPU (CPU-only processing up to 40% faster, faster image decoding on ARM), with unchanged results.
* Rugnux: `--model` rigid-body refinement runs on the GPU, and the model-validation check is faster and more reliable.
* Rugnux: Improved scaling and merging - error model, outlier rejection, absorption correction and French-Wilson amplitudes now agree more closely with XDS and ctruncate.
* Rugnux: Improved integration - radial background on powder and ice rings, crowded rotation data keep their reflections, and CPU-only builds integrate large unit cells as GPU builds do.
* Rugnux: More robust detector geometry - measured beam centre, X-ray bandwidth and goniometer rate, and geometry refinement accepted only on significant evidence.
* Rugnux: Merged files are written in the standard setting, or in the setting of a reference MTZ, structure-factor mmCIF or model, with its free-R flags.
* Rugnux: Richer report - ice and powder rings, further lattices, superstructure candidates and mosaicity, with warnings worded as prompts to check.
* Rugnux: Clear error messages when a data set needs more GPU or host memory than is available.

Reviewed-on: #83
Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
2026-09-29 15:57:32 +02:00

631 lines
26 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
// Bravais lattice from the metric symmetry OPERATORS, rather than from a table of reduced-cell
// characters. The two-fold search is Le Page's (1982) J. Appl. Cryst. 15, 255-259, vendored in
// gemmi (twin.hpp); what is here is the step after it - turning the rotation group it finds into
// a conventional cell, a centring letter and an integral change of basis.
//
// Following Le Page (1982) J. Appl. Cryst. 15, 255-259 and
// Grosse-Kunstleve (1999) Acta Cryst. A55, 383-395
#include "LePageLattice.h"
#include "../../common/JFJochMath.h"
#include <gemmi/cellred.hpp>
#include <gemmi/twin.hpp>
#include <algorithm>
#include <array>
#include <cmath>
#include <numeric>
namespace {
using Mat3i = std::array<int, 9>; // row-major
using Vec3i = std::array<int, 3>;
constexpr Mat3i kIdentity3 = {1, 0, 0, 0, 1, 0, 0, 0, 1};
int At(const Mat3i &m, int r, int c) { return m[3 * r + c]; }
Mat3i MatMul(const Mat3i &a, const Mat3i &b) {
Mat3i r{};
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
int s = 0;
for (int k = 0; k < 3; k++)
s += At(a, i, k) * At(b, k, j);
r[3 * i + j] = s;
}
return r;
}
int Det3(const Mat3i &m) {
return At(m,0,0) * (At(m,1,1)*At(m,2,2) - At(m,1,2)*At(m,2,1))
- At(m,0,1) * (At(m,1,0)*At(m,2,2) - At(m,1,2)*At(m,2,0))
+ At(m,0,2) * (At(m,1,0)*At(m,2,1) - At(m,1,1)*At(m,2,0));
}
// adj(M), so that M^-1 = adj(M) / det(M) - kept integral to determine the centring exactly.
Mat3i Adjugate(const Mat3i &m) {
Mat3i a{};
a[0] = (At(m,1,1)*At(m,2,2) - At(m,1,2)*At(m,2,1));
a[1] = -(At(m,0,1)*At(m,2,2) - At(m,0,2)*At(m,2,1));
a[2] = (At(m,0,1)*At(m,1,2) - At(m,0,2)*At(m,1,1));
a[3] = -(At(m,1,0)*At(m,2,2) - At(m,1,2)*At(m,2,0));
a[4] = (At(m,0,0)*At(m,2,2) - At(m,0,2)*At(m,2,0));
a[5] = -(At(m,0,0)*At(m,1,2) - At(m,0,2)*At(m,1,0));
a[6] = (At(m,1,0)*At(m,2,1) - At(m,1,1)*At(m,2,0));
a[7] = -(At(m,0,0)*At(m,2,1) - At(m,0,1)*At(m,2,0));
a[8] = (At(m,0,0)*At(m,1,1) - At(m,0,1)*At(m,1,0));
return a;
}
Vec3i MulVec(const Mat3i &m, const Vec3i &v) {
return {At(m,0,0)*v[0] + At(m,0,1)*v[1] + At(m,0,2)*v[2],
At(m,1,0)*v[0] + At(m,1,1)*v[1] + At(m,1,2)*v[2],
At(m,2,0)*v[0] + At(m,2,1)*v[1] + At(m,2,2)*v[2]};
}
Vec3i Cross(const Vec3i &a, const Vec3i &b) {
return {a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2], a[0]*b[1] - a[1]*b[0]};
}
int Gcd3(const Vec3i &v) {
int g = std::gcd(std::gcd(std::abs(v[0]), std::abs(v[1])), std::abs(v[2]));
return g;
}
// The shortest lattice vector along a direction is the direction divided by the gcd of its
// components, and its sign is fixed so the same axis is always the same vector.
bool MakePrimitive(Vec3i &v) {
const int g = Gcd3(v);
if (g == 0)
return false;
for (int &x : v)
x /= g;
for (int i = 0; i < 3; i++) {
if (v[i] > 0) break;
if (v[i] < 0) { v[0] = -v[0]; v[1] = -v[1]; v[2] = -v[2]; break; }
}
return true;
}
int Trace(const Mat3i &m) { return m[0] + m[4] + m[8]; }
// A proper rotation has trace 1 + 2 cos(theta): 3, -1, 0, 1, 2 for orders 1, 2, 3, 4, 6.
int RotationOrder(const Mat3i &m) {
switch (Trace(m)) {
case 3: return 1;
case -1: return 2;
case 0: return 3;
case 1: return 4;
case 2: return 6;
default: return 0;
}
}
// The +1 eigenvector of a rotation of order >= 2: (R - I) has rank 2, so the cross product of any
// two independent of its rows spans the kernel. Exact in integers.
bool RotationAxis(const Mat3i &m, Vec3i &axis) {
Mat3i d = m;
d[0] -= 1; d[4] -= 1; d[8] -= 1;
const Vec3i rows[3] = {{d[0], d[1], d[2]}, {d[3], d[4], d[5]}, {d[6], d[7], d[8]}};
Vec3i best{0, 0, 0};
long long best_norm = 0;
for (int i = 0; i < 3; i++)
for (int j = i + 1; j < 3; j++) {
const Vec3i c = Cross(rows[i], rows[j]);
const long long n = 1LL*c[0]*c[0] + 1LL*c[1]*c[1] + 1LL*c[2]*c[2];
if (n > best_norm) { best_norm = n; best = c; }
}
if (best_norm == 0)
return false;
axis = best;
return MakePrimitive(axis);
}
// Metric tensor of the reduced primitive cell, so integer lattice vectors can be measured.
struct Metric {
double g[3][3];
double Dot(const Vec3i &u, const Vec3i &v) const {
double s = 0;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
s += g[i][j] * u[i] * v[j];
return s;
}
double Len(const Vec3i &u) const { return std::sqrt(std::max(0.0, Dot(u, u))); }
};
Metric MetricOf(const gemmi::UnitCell &c) {
Metric m{};
const double ca = std::cos(gemmi::rad(c.alpha)), cb = std::cos(gemmi::rad(c.beta)),
cg = std::cos(gemmi::rad(c.gamma));
m.g[0][0] = c.a * c.a; m.g[1][1] = c.b * c.b; m.g[2][2] = c.c * c.c;
m.g[0][1] = m.g[1][0] = c.a * c.b * cg;
m.g[0][2] = m.g[2][0] = c.a * c.c * cb;
m.g[1][2] = m.g[2][1] = c.b * c.c * ca;
return m;
}
// The rotation group generated by the two-folds Le Page found, built one generator at a time in
// order of increasing obliquity and only while the result is still a possible lattice rotation
// group. Two-folds accepted at a loose obliquity need not be consistent with each other - closing
// over an inconsistent set runs away - so a generator whose closure is not one of the seven orders
// a holohedry can have is dropped instead of aborting the whole search.
bool IsHolohedryOrder(size_t n) {
return n == 1 || n == 2 || n == 4 || n == 6 || n == 8 || n == 12 || n == 24;
}
std::vector<Mat3i> CloseGroup(const std::vector<Mat3i> &gens) {
std::vector<Mat3i> group{kIdentity3};
for (const Mat3i &g : gens) {
if (std::find(group.begin(), group.end(), g) != group.end())
continue;
std::vector<Mat3i> trial = group;
trial.push_back(g);
bool ok = true;
for (size_t i = 0; i < trial.size() && ok; i++)
for (size_t j = 0; j < trial.size() && ok; j++) {
const Mat3i p = MatMul(trial[i], trial[j]);
if (std::find(trial.begin(), trial.end(), p) == trial.end()) {
if (trial.size() >= 24) { ok = false; break; }
trial.push_back(p);
}
}
if (ok && IsHolohedryOrder(trial.size()))
group = std::move(trial);
}
return group;
}
// All integer vectors v with r . v = 0 form a plane lattice; a pair of them is a BASIS of it
// exactly when their cross product is +/- r (r primitive). Enumerating short v and taking the two
// shortest that pass that test gives the reduced basis of the plane - in two dimensions the
// successive minima are always reachable by a basis.
bool PlaneBasis(const Vec3i &normal, const Metric &metric, Vec3i &p, Vec3i &q) {
Vec3i r = normal;
if (!MakePrimitive(r))
return false;
for (int box = 4; box <= 24; box *= 2) {
std::vector<Vec3i> cand;
for (int i = -box; i <= box; i++)
for (int j = -box; j <= box; j++)
for (int k = -box; k <= box; k++) {
const Vec3i v{i, j, k};
if (v[0] == 0 && v[1] == 0 && v[2] == 0)
continue;
if (r[0]*i + r[1]*j + r[2]*k != 0)
continue;
cand.push_back(v);
}
// Keys computed once, not inside the comparator: v and -v tie exactly, and an FMA-contracted
// Dot() rounding differently at two inlined sites would let std::sort run off the array.
std::vector<std::pair<double, Vec3i>> by_norm;
by_norm.reserve(cand.size());
for (const Vec3i &v : cand)
by_norm.emplace_back(metric.Dot(v, v), v);
std::stable_sort(by_norm.begin(), by_norm.end(),
[](const auto &a, const auto &b) { return a.first < b.first; });
for (size_t i = 0; i < cand.size(); i++)
cand[i] = by_norm[i].second;
for (const Vec3i &v1 : cand) {
for (const Vec3i &v2 : cand) {
const Vec3i c = Cross(v1, v2);
if ((c[0] == r[0] && c[1] == r[1] && c[2] == r[2]) ||
(c[0] == -r[0] && c[1] == -r[1] && c[2] == -r[2])) {
p = v1; q = v2;
return true;
}
}
break; // v1 is the shortest vector of the plane; only it can start a reduced basis
}
}
return false;
}
// The centring translations of the primitive lattice inside the conventional cell M: the rows of
// M^-1 generate them, and |det M| of them exist. Kept in units of 1/det so the arithmetic is exact.
std::vector<std::array<int, 3>> CentringTranslations(const Mat3i &M, int &den) {
den = std::abs(Det3(M));
const Mat3i adj = Adjugate(M);
const int sign = Det3(M) > 0 ? 1 : -1;
std::vector<std::array<int, 3>> out;
for (int i = 0; i < den; i++)
for (int j = 0; j < den; j++)
for (int k = 0; k < den; k++) {
std::array<int, 3> t{};
for (int c = 0; c < 3; c++) {
int s = sign * (i * At(adj, 0, c) + j * At(adj, 1, c) + k * At(adj, 2, c));
s %= den;
if (s < 0) s += den;
t[c] = s;
}
if (std::find(out.begin(), out.end(), t) == out.end())
out.push_back(t);
}
std::sort(out.begin(), out.end());
return out;
}
// Name the centring from those translations. Anything not on this list is not a Bravais lattice
// and means the conventional axes were built wrongly.
char CentringSymbol(const std::vector<std::array<int, 3>> &t, int den) {
auto has = [&](int x, int y, int z) {
return std::find(t.begin(), t.end(), std::array<int, 3>{x, y, z}) != t.end();
};
if (den == 1)
return 'P';
if (den == 2) {
if (has(1, 1, 0)) return 'C';
if (has(0, 1, 1)) return 'A';
if (has(1, 0, 1)) return 'B';
if (has(1, 1, 1)) return 'I';
}
if (den == 3) {
if (has(1, 2, 2)) return 'R'; // obverse
if (has(2, 1, 2)) return 'r'; // reverse - caller converts
}
if (den == 4 && has(0, 2, 2) && has(2, 0, 2) && has(2, 2, 0))
return 'F';
return '?';
}
Mat3i Transpose(const Mat3i &m) {
return {m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]};
}
Mat3i FromRows(const Vec3i &a, const Vec3i &b, const Vec3i &c) {
return {a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]};
}
gemmi::Mat33 ToMat33(const Mat3i &m) {
return gemmi::Mat33(m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8]);
}
} // namespace
std::optional<LePageResult> LePageLattice(const CrystalLattice &L, double max_obliquity_deg,
const LePageOperatorFilter &keep_operator) {
const UnitCell uc = L.GetUnitCell();
gemmi::UnitCell g_uc(uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma);
gemmi::GruberVector gv(g_uc, 'P', true);
// Same scaled epsilon as the character table: the type decision is on scalar products of order
// 10^3-10^5 A^2 carried in a float cell.
gv.niggli_reduce(1e-5 * std::max({gv.A, gv.B, gv.C}));
// The reduction's change of basis, C, as an integer matrix. CrystalLattice::Multiply combines
// basis ROWS, so a_red[i] = sum_j C[i][j] a_L[j]; fractional coordinates then map x_L = C^T x_red
// and Miller indices h_red = C h_L, and an operator's Miller-index matrix maps M_L = C^-1 M_red C.
// A Niggli reduction is unimodular, so C^-1 = det(C) * adj(C) is integral too - which is what lets
// the filter below be offered an exact integer matrix in the caller's own basis.
Mat3i C = kIdentity3;
if (gv.change_of_basis) {
const gemmi::Mat33 c = gemmi::rot_as_mat33(gv.change_of_basis->rot).transpose();
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
C[3 * i + j] = static_cast<int>(std::lround(c[i][j]));
if (std::fabs(c[i][j] - C[3 * i + j]) > 1e-6)
return std::nullopt;
}
}
const int det_C = Det3(C);
if (det_C != 1 && det_C != -1)
return std::nullopt;
Mat3i C_inv = Adjugate(C);
for (int &x : C_inv)
x *= det_C;
CrystalLattice L_red = L;
if (gv.change_of_basis)
L_red = L.Multiply(gemmi::rot_as_mat33(gv.change_of_basis->rot).transpose());
const gemmi::UnitCell reduced = gv.get_cell();
const Metric metric = MetricOf(reduced);
// Le Page: the 81 two-folds a reduced cell can carry, ranked by obliquity.
const std::vector<gemmi::OpObliquity> two_folds = gemmi::find_lattice_2fold_ops(reduced, max_obliquity_deg);
std::vector<Mat3i> gens;
gens.reserve(two_folds.size());
double worst_obliquity = 0;
for (const auto &[op, delta] : two_folds) {
Mat3i m{};
bool ok = true;
for (int i = 0; i < 3 && ok; i++)
for (int j = 0; j < 3 && ok; j++) {
const int v = op.rot[i][j];
if (v % gemmi::Op::DEN != 0) ok = false;
m[3 * i + j] = v / gemmi::Op::DEN;
}
if (!ok)
continue;
if (keep_operator) {
// A two-fold is its own inverse, so the Miller-index matrix of the operation x -> R x is
// R^T; carry it into the basis of L as M_L = C^-1 M_red C (see C above).
const Mat3i m_L = MatMul(C_inv, MatMul(Transpose(m), C));
if (!keep_operator(ToMat33(m_L)))
continue;
}
gens.push_back(m);
}
const std::vector<Mat3i> group = CloseGroup(gens);
// A filtered search may promote only on two-folds the filter kept. Closure can bring back one it
// rejected - two two-folds sixty degrees apart generate a three-fold and with it the whole
// hexagonal group - and a sub-lattice whose own two-folds were scored and refused is not one the
// evidence supports. Refuse rather than hand back a group resting on a rejected operator.
if (keep_operator)
for (const Mat3i &g : group)
if (RotationOrder(g) == 2 && std::find(gens.begin(), gens.end(), g) == gens.end())
return std::nullopt;
for (const auto &[op, delta] : two_folds) {
Mat3i m{};
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
m[3 * i + j] = op.rot[i][j] / gemmi::Op::DEN;
if (std::find(group.begin(), group.end(), m) != group.end())
worst_obliquity = std::max(worst_obliquity, delta);
}
// Sort the group's operators by rotation order, and record the axis of each.
struct Axis { int order; Vec3i dir; Mat3i rot; };
std::vector<Axis> axes;
int n_order[7] = {0, 0, 0, 0, 0, 0, 0};
for (const Mat3i &m : group) {
const int order = RotationOrder(m);
if (order == 0 || Det3(m) != 1)
return std::nullopt; // not a proper rotation - refuse rather than guess
n_order[order]++;
if (order == 1)
continue;
Vec3i dir{};
if (!RotationAxis(m, dir))
return std::nullopt;
axes.push_back({order, dir, m});
}
gemmi::CrystalSystem system;
switch (group.size()) {
case 1: system = gemmi::CrystalSystem::Triclinic; break;
case 2: system = gemmi::CrystalSystem::Monoclinic; break;
case 4: system = gemmi::CrystalSystem::Orthorhombic; break;
case 6: system = gemmi::CrystalSystem::Trigonal; break;
case 8: system = gemmi::CrystalSystem::Tetragonal; break;
case 12: system = n_order[6] > 0 ? gemmi::CrystalSystem::Hexagonal : gemmi::CrystalSystem::Cubic; break;
case 24: system = gemmi::CrystalSystem::Cubic; break;
default: return std::nullopt;
}
// Conventional axes. Every one of them is the SHORTEST lattice vector along a symmetry
// direction, which is what makes them integral combinations of the reduced primitive basis and
// the change of basis an integer matrix.
auto shortest_along = [&](const Vec3i &d) { Vec3i v = d; MakePrimitive(v); return v; };
// The two-folds perpendicular to the principal axis are exactly those that send it to minus
// itself - an integer test, no angle tolerance.
auto perpendicular_two_folds = [&](const Vec3i &principal) {
std::vector<Vec3i> out;
for (const Axis &ax : axes) {
if (ax.order != 2)
continue;
const Vec3i w = MulVec(ax.rot, principal);
if (w[0] == -principal[0] && w[1] == -principal[1] && w[2] == -principal[2])
out.push_back(shortest_along(ax.dir));
}
std::sort(out.begin(), out.end(), [&](const Vec3i &a, const Vec3i &b) {
return metric.Dot(a, a) < metric.Dot(b, b);
});
return out;
};
auto principal_of_order = [&](int order) -> std::optional<Axis> {
for (const Axis &ax : axes)
if (ax.order == order)
return ax;
return std::nullopt;
};
Mat3i M = kIdentity3;
switch (system) {
case gemmi::CrystalSystem::Triclinic:
M = kIdentity3;
break;
case gemmi::CrystalSystem::Monoclinic: {
const Vec3i b = shortest_along(axes.front().dir);
// The lattice vectors perpendicular to the two-fold are exactly those it negates, so the
// plane they span is the kernel of (R + I) - exact, and NOT the coordinate vectors
// orthogonal to the axis, which is a different set on a non-orthogonal basis. (R + I)
// has rank one there, so any non-zero row of it is the plane's reciprocal normal.
Mat3i s = axes.front().rot;
s[0] += 1; s[4] += 1; s[8] += 1;
Vec3i normal{0, 0, 0};
for (int i = 0; i < 3; i++)
if (s[3*i] != 0 || s[3*i+1] != 0 || s[3*i+2] != 0) {
normal = {s[3*i], s[3*i+1], s[3*i+2]};
break;
}
Vec3i p{}, q{};
if (!PlaneBasis(normal, metric, p, q))
return std::nullopt;
if (Det3(FromRows(p, b, q)) < 0)
for (int t = 0; t < 3; t++) q[t] = -q[t];
// Any unimodular pair drawn from that plane is a valid a and c. They differ in how
// oblique beta comes out and in whether the centring then reads C or I - which are the
// same lattice in two settings, not two lattices. Of those two namings only C is the
// REFERENCE setting, and the space-group search this lattice is offered to enumerates
// reference settings only, so a lattice named I carries a centring no candidate of its
// point group can match and the promotion it earned is refused for its name. So the
// least oblique cell is taken among the P and C namings; I is accepted only if the plane
// offers no other, which would otherwise lose the lattice altogether. An oblique cell is
// a real cost downstream, where the constrained refinement bounds the cell angles at 30
// and 150 degrees, so the obliquity is still minimised - within the naming, not across it.
double best_beta = 1e30, best_len = 1e30;
bool found = false;
const auto search = [&](bool allow_i) {
for (int i = -2; i <= 2; i++)
for (int j = -2; j <= 2; j++)
for (int k = -2; k <= 2; k++)
for (int l = -2; l <= 2; l++) {
if (i * l - j * k != 1)
continue;
Vec3i a{}, c{};
for (int t = 0; t < 3; t++) {
a[t] = i * p[t] + j * q[t];
c[t] = k * p[t] + l * q[t];
}
const double len = metric.Dot(a, a) + metric.Dot(c, c);
const double cos_beta = metric.Dot(a, c) / (metric.Len(a) * metric.Len(c));
const double beta = std::acos(std::clamp(std::fabs(cos_beta), 0.0, 1.0));
const double obtuse = 180.0 - beta * 180.0 / PI; // beta stated obtuse
if (found && (obtuse > best_beta + 1e-6 ||
(obtuse > best_beta - 1e-6 && len >= best_len - 1e-6)))
continue;
const Mat3i cand = FromRows(a, b, c);
int d = 0;
const auto t = CentringTranslations(cand, d);
const char ce = CentringSymbol(t, d);
if (ce != 'P' && ce != 'C' && !(allow_i && ce == 'I'))
continue; // an A- or I-centred naming of the same lattice
best_beta = obtuse; best_len = len; M = cand; found = true;
}
};
search(false);
if (!found)
search(true);
if (!found)
return std::nullopt;
// beta obtuse: negating a and b keeps the handedness, keeps b on the two-fold, and leaves
// every half-integer centring vector where it was.
{
const Vec3i a{M[0], M[1], M[2]}, c{M[6], M[7], M[8]};
if (metric.Dot(a, c) > 0)
for (int t = 0; t < 6; t++) M[t] = -M[t];
}
break;
}
case gemmi::CrystalSystem::Orthorhombic: {
std::vector<Vec3i> u;
for (const Axis &ax : axes)
u.push_back(shortest_along(ax.dir));
if (u.size() != 3)
return std::nullopt;
std::sort(u.begin(), u.end(), [&](const Vec3i &a, const Vec3i &b) {
return metric.Dot(a, a) < metric.Dot(b, b);
});
M = FromRows(u[0], u[1], u[2]);
break;
}
case gemmi::CrystalSystem::Tetragonal: {
const auto four = principal_of_order(4);
if (!four)
return std::nullopt;
const Vec3i c = shortest_along(four->dir);
const auto in_plane = perpendicular_two_folds(four->dir);
if (in_plane.empty())
return std::nullopt;
const Vec3i a = in_plane.front();
const Vec3i b = MulVec(four->rot, a); // the 4-fold carries a onto b
M = FromRows(a, b, c);
break;
}
case gemmi::CrystalSystem::Hexagonal:
case gemmi::CrystalSystem::Trigonal: {
const auto principal = system == gemmi::CrystalSystem::Hexagonal ? principal_of_order(6)
: principal_of_order(3);
if (!principal)
return std::nullopt;
const Vec3i c = shortest_along(principal->dir);
const auto in_plane = perpendicular_two_folds(principal->dir);
if (in_plane.empty())
return std::nullopt;
const Vec3i a = in_plane.front();
// gamma must come out 120, so b is a turned by the THREE-fold, not by the six-fold.
const Mat3i three = principal->order == 6 ? MatMul(principal->rot, principal->rot)
: principal->rot;
const Vec3i b = MulVec(three, a);
M = FromRows(a, b, c);
break;
}
case gemmi::CrystalSystem::Cubic: {
std::vector<Vec3i> u;
for (const Axis &ax : axes)
if (ax.order == 4)
u.push_back(shortest_along(ax.dir));
std::sort(u.begin(), u.end());
u.erase(std::unique(u.begin(), u.end()), u.end());
if (u.size() != 3) {
// 23 has no four-folds; its conventional axes are the three two-folds.
u.clear();
for (const Axis &ax : axes)
if (ax.order == 2)
u.push_back(shortest_along(ax.dir));
std::sort(u.begin(), u.end());
u.erase(std::unique(u.begin(), u.end()), u.end());
}
if (u.size() != 3)
return std::nullopt;
M = FromRows(u[0], u[1], u[2]);
break;
}
default:
return std::nullopt;
}
if (Det3(M) == 0)
return std::nullopt;
if (Det3(M) < 0) { // keep the basis right-handed
for (int j = 0; j < 3; j++)
M[6 + j] = -M[6 + j];
}
int den = 0;
auto trans = CentringTranslations(M, den);
char centring = CentringSymbol(trans, den);
// Bring the answer into the conventional setting of its Bravais class: monoclinic and
// orthorhombic centrings are named C, and a rhombohedral lattice is described obverse. Each is a
// relabelling of the same lattice by a unimodular matrix, so the cell it names is unchanged.
auto apply = [&](const Mat3i &u) {
M = MatMul(u, M);
trans = CentringTranslations(M, den);
centring = CentringSymbol(trans, den);
};
if (system == gemmi::CrystalSystem::Orthorhombic) {
// All three axes are equivalent, so name the centred face ab by permuting them, then put the
// two axes the centring does not pin back in length order.
if (centring == 'A')
apply({0, 1, 0, 0, 0, 1, 1, 0, 0}); // a,b,c -> b,c,a
else if (centring == 'B')
apply({1, 0, 0, 0, 0, 1, 0, -1, 0}); // a,b,c -> a,c,-b
const Vec3i a{M[0], M[1], M[2]}, b{M[3], M[4], M[5]};
if (metric.Dot(a, a) > metric.Dot(b, b))
apply({0, 1, 0, 1, 0, 0, 0, 0, -1}); // a <-> b
}
if (centring == 'r')
apply({-1, 0, 0, 0, -1, 0, 0, 0, 1}); // reverse -> obverse
if (centring == '?' || centring == 'r')
return std::nullopt;
LePageResult r;
r.system = system;
r.centering = centring;
r.reindex = ToMat33(M);
r.primitive_reduced = L_red;
r.conventional = L_red.Multiply(r.reindex);
r.max_obliquity_deg = worst_obliquity;
r.n_operators = static_cast<int>(group.size());
return r;
}