// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute // 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 #include #include #include #include #include namespace { using Mat3i = std::array; // row-major using Vec3i = std::array; 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 CloseGroup(const std::vector &gens) { std::vector group{kIdentity3}; for (const Mat3i &g : gens) { if (std::find(group.begin(), group.end(), g) != group.end()) continue; std::vector 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 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> 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> 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> out; for (int i = 0; i < den; i++) for (int j = 0; j < den; j++) for (int k = 0; k < den; k++) { std::array 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> &t, int den) { auto has = [&](int x, int y, int z) { return std::find(t.begin(), t.end(), std::array{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 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(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 two_folds = gemmi::find_lattice_2fold_ops(reduced, max_obliquity_deg); std::vector 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 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 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 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 { 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 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 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(group.size()); return r; }