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
* 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>
631 lines
26 KiB
C++
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;
|
|
}
|