The centred-lattice test (run after a triclinic indexing whose search arms could test no operator) takes its candidate from the Niggli character table, which names a class on 3 deg relative tolerances. Le Page, which measures each two-fold's obliquity on the lattice itself at the 1 deg bound every other metric question of the run uses, can find fewer rotations than that class carries. The test then adopted a group the lattice does not host: on a triclinic crystal whose C-monoclinic two-fold is 1.7 deg oblique (XDS refines the same angle), C2 was adopted at operator CC 0.46 and written on a cell the group cannot describe, and the Le Page re-ask that follows cannot demote. The test now runs only where Le Page carries the candidate's holohedry; otherwise it logs why and leaves the question to the Le Page re-ask. Over the rc181 open, in-house and private battery rows this was the one final point group with more rotations than the metric hosts; every other centred-test adoption is hosted and unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UqXYdL4qBGYRiSXSGXfCLu
232 lines
12 KiB
C++
232 lines
12 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <catch2/catch_all.hpp>
|
|
#include "../common/CrystalLattice.h"
|
|
#include "../common/Coord.h"
|
|
#include "../common/UnitCell.h"
|
|
#include "../image_analysis/lattice_search/LePageLattice.h"
|
|
#include "../image_analysis/lattice_search/LatticeSearch.h"
|
|
#include "gemmi/symmetry.hpp"
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <random>
|
|
|
|
namespace {
|
|
int CentringMultiplicity(char c) {
|
|
switch (c) {
|
|
case 'A': case 'B': case 'C': case 'I': return 2;
|
|
case 'R': return 3;
|
|
case 'F': return 4;
|
|
default: return 1;
|
|
}
|
|
}
|
|
|
|
// The lattice is what has to come back, not the axes: a conventional cell of the right class whose
|
|
// PRIMITIVE volume is the one we started from. Comparing lengths element-wise would fail a correct
|
|
// answer given in another setting.
|
|
void CheckLattice(const std::optional<LePageResult> &r, gemmi::CrystalSystem system, char centering,
|
|
float primitive_volume) {
|
|
REQUIRE(r.has_value());
|
|
CHECK(r->system == system);
|
|
CHECK(r->centering == centering);
|
|
const float v = std::fabs(r->conventional.CalcVolume()) / CentringMultiplicity(r->centering);
|
|
CHECK(v == Catch::Approx(primitive_volume).epsilon(0.02));
|
|
}
|
|
|
|
// The Miller-index matrix a filter is handed, against the integer matrix expected there.
|
|
bool SameMatrix(const gemmi::Mat33 &m, const std::array<int, 9> &expected) {
|
|
for (int i = 0; i < 3; i++)
|
|
for (int j = 0; j < 3; j++)
|
|
if (std::fabs(m[i][j] - expected[3 * i + j]) > 1e-6)
|
|
return false;
|
|
return true;
|
|
}
|
|
|
|
// The lattice as an indexer hands it over: some basis of it, in some orientation, with noise.
|
|
CrystalLattice Present(const CrystalLattice &L, std::mt19937 &rng, float noise_A) {
|
|
std::uniform_int_distribution<int> pick(0, 2), amount(-1, 1);
|
|
std::uniform_real_distribution<float> uni(0, 1);
|
|
std::normal_distribution<float> gauss(0, noise_A);
|
|
gemmi::Mat33 m(1, 0, 0, 0, 1, 0, 0, 0, 1);
|
|
for (int n = 0; n < 4; n++) {
|
|
const int i = pick(rng), j = pick(rng);
|
|
if (i == j)
|
|
continue;
|
|
gemmi::Mat33 shear(1, 0, 0, 0, 1, 0, 0, 0, 1);
|
|
shear.a[i][j] = amount(rng);
|
|
m = shear.multiply(m);
|
|
}
|
|
const float theta = 2 * (float)M_PI * uni(rng), phi = std::acos(2 * uni(rng) - 1);
|
|
const Coord axis(std::sin(phi) * std::cos(theta), std::sin(phi) * std::sin(theta), std::cos(phi));
|
|
CrystalLattice out = L.Multiply(m).Multiply(RotMatrix(2 * (float)M_PI * uni(rng), axis));
|
|
Coord v[3] = {out.Vec0(), out.Vec1(), out.Vec2()};
|
|
for (auto &k : v) { k.x += gauss(rng); k.y += gauss(rng); k.z += gauss(rng); }
|
|
return CrystalLattice(v[0], v[1], v[2]);
|
|
}
|
|
} // namespace
|
|
|
|
TEST_CASE("LePageLattice - the fourteen Bravais lattices") {
|
|
struct Case { gemmi::CrystalSystem system; char centering; float a, b, c, al, be, ga; };
|
|
const Case cases[] = {
|
|
{gemmi::CrystalSystem::Triclinic, 'P', 23, 31, 41, 81, 95, 71},
|
|
{gemmi::CrystalSystem::Monoclinic, 'P', 31, 43, 57, 90, 103, 90},
|
|
{gemmi::CrystalSystem::Monoclinic, 'C', 91, 43, 57, 90, 103, 90},
|
|
{gemmi::CrystalSystem::Orthorhombic, 'P', 31, 43, 57, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Orthorhombic, 'C', 31, 43, 57, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Orthorhombic, 'I', 31, 43, 57, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Orthorhombic, 'F', 31, 43, 57, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Tetragonal, 'P', 47, 47, 71, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Tetragonal, 'I', 47, 47, 71, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Trigonal, 'R', 61, 61, 133, 90, 90, 120},
|
|
{gemmi::CrystalSystem::Hexagonal, 'P', 61, 61, 97, 90, 90, 120},
|
|
{gemmi::CrystalSystem::Cubic, 'P', 71, 71, 71, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Cubic, 'I', 71, 71, 71, 90, 90, 90},
|
|
{gemmi::CrystalSystem::Cubic, 'F', 71, 71, 71, 90, 90, 90},
|
|
};
|
|
for (const Case &c : cases) {
|
|
const CrystalLattice conventional(c.a, c.b, c.c, c.al, c.be, c.ga);
|
|
const CrystalLattice primitive = conventional.ToPrimitive(c.centering);
|
|
const float primitive_volume = std::fabs(primitive.CalcVolume());
|
|
std::mt19937 rng(20260831);
|
|
for (int i = 0; i < 20; i++) {
|
|
INFO("class " << (int)c.system << c.centering << " presentation " << i);
|
|
CheckLattice(LePageLattice(Present(primitive, rng, 0.02f)), c.system, c.centering,
|
|
primitive_volume);
|
|
}
|
|
}
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - a cubic F lattice on the Niggli type boundary") {
|
|
// An fcc lattice has both a 60/60/60 and a ~120/90/120 shortest-vector basis, so it sits ON the
|
|
// boundary between the two Niggli types by construction and the reduction lands on either side
|
|
// according to the last bits of the cell it is given. Reading the symmetry off the metric has no
|
|
// forms to fall between, so the answer does not depend on which side it landed on.
|
|
const CrystalLattice conventional(121.0f * std::sqrt(2.0f), 121.0f * std::sqrt(2.0f),
|
|
121.0f * std::sqrt(2.0f), 90, 90, 90);
|
|
const CrystalLattice primitive = conventional.ToPrimitive('F');
|
|
const float primitive_volume = std::fabs(primitive.CalcVolume());
|
|
std::mt19937 rng(7);
|
|
for (int i = 0; i < 40; i++) {
|
|
INFO("presentation " << i);
|
|
CheckLattice(LePageLattice(Present(primitive, rng, 0.1f)), gemmi::CrystalSystem::Cubic, 'F',
|
|
primitive_volume);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - a tetragonal I description of a cubic F lattice") {
|
|
// Same lattice as above, handed over in the setting a, a, a*sqrt(2) that describes it as body-
|
|
// centred tetragonal. Both descriptions are the same lattice, and the answer has to be the same.
|
|
const float a = 120.5f;
|
|
const CrystalLattice tetragonal(a, a, a * std::sqrt(2.0f), 90, 90, 90);
|
|
const CrystalLattice primitive = tetragonal.ToPrimitive('I');
|
|
auto r = LePageLattice(primitive);
|
|
REQUIRE(r.has_value());
|
|
CHECK(r->system == gemmi::CrystalSystem::Cubic);
|
|
CHECK(r->centering == 'F');
|
|
CHECK(std::fabs(r->conventional.CalcVolume()) / 4 ==
|
|
Catch::Approx(std::fabs(primitive.CalcVolume())).epsilon(0.01));
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - a monoclinic lattice is named C even where I is less oblique") {
|
|
// The least oblique naming of this lattice's (a, c) plane is body-centred, beta a few degrees
|
|
// from 90. The answer must still be the C-centred reference setting - the space-group search
|
|
// enumerates reference settings only, so a promotion earned by a lattice named I can never be
|
|
// confirmed. And it must be the least oblique C the plane offers, not a relabel: on this
|
|
// lattice a -> a + c puts beta past the 150-degree bound of the constrained refinement.
|
|
const CrystalLattice conventional(40, 54, 76, 90, 93, 90); // the I naming of the lattice
|
|
const CrystalLattice primitive = conventional.ToPrimitive('I');
|
|
const float primitive_volume = std::fabs(primitive.CalcVolume());
|
|
std::mt19937 rng(20260908);
|
|
for (int i = 0; i < 20; i++) {
|
|
INFO("presentation " << i);
|
|
const auto r = LePageLattice(Present(primitive, rng, 0.02f));
|
|
CheckLattice(r, gemmi::CrystalSystem::Monoclinic, 'C', primitive_volume);
|
|
const UnitCell uc = r->conventional.GetUnitCell();
|
|
CHECK(uc.beta > 90.0f);
|
|
CHECK(uc.beta < 150.0f);
|
|
}
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - the change of basis is integral and right-handed") {
|
|
const CrystalLattice conventional(47, 47, 71, 90, 90, 90);
|
|
const CrystalLattice primitive = conventional.ToPrimitive('I');
|
|
auto r = LePageLattice(primitive);
|
|
REQUIRE(r.has_value());
|
|
for (int i = 0; i < 3; i++)
|
|
for (int j = 0; j < 3; j++)
|
|
CHECK(r->reindex[i][j] == Catch::Approx(std::round(r->reindex[i][j])).margin(1e-9));
|
|
CHECK(r->reindex.determinant() > 0);
|
|
CHECK(r->conventional.CalcVolume() > 0);
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - a pseudo-symmetric metric is not promoted") {
|
|
// A monoclinic cell whose beta sits a few degrees from 90 is not orthorhombic, however close the
|
|
// reduced form is to an orthorhombic character.
|
|
const CrystalLattice L(31, 43, 57, 90, 93, 90);
|
|
auto r = LePageLattice(L);
|
|
REQUIRE(r.has_value());
|
|
CHECK(r->system == gemmi::CrystalSystem::Monoclinic);
|
|
CHECK(r->centering == 'P');
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - an operator filter picks out a sub-lattice of the metric") {
|
|
// A hexagonal metric carries twelve rotations, and asked as it stands that is the answer. Keeping
|
|
// only the three two-folds that close into 222 - the one along c and the two in the plane, along
|
|
// a+b and a-b - leaves the orthorhombic sub-lattice, whose conventional cell is (a+b, a-b, c) on
|
|
// twice the volume and so C-centred.
|
|
const CrystalLattice L(100, 100, 70, 90, 90, 120);
|
|
const auto whole_metric = LePageLattice(L);
|
|
REQUIRE(whole_metric.has_value());
|
|
CHECK(whole_metric->system == gemmi::CrystalSystem::Hexagonal);
|
|
|
|
const std::array<int, 9> along_c = {-1, 0, 0, 0, -1, 0, 0, 0, 1}; // -h,-k,l
|
|
const std::array<int, 9> along_a_plus_b = {0, 1, 0, 1, 0, 0, 0, 0, -1}; // k,h,-l
|
|
const std::array<int, 9> along_a_minus_b = {0, -1, 0, -1, 0, 0, 0, 0, -1}; // -k,-h,-l
|
|
const auto r = LePageLattice(L, LATTICE_MAX_OBLIQUITY_DEG, [&](const gemmi::Mat33 &m) {
|
|
return SameMatrix(m, along_c) || SameMatrix(m, along_a_plus_b) ||
|
|
SameMatrix(m, along_a_minus_b);
|
|
});
|
|
REQUIRE(r.has_value());
|
|
CHECK(r->system == gemmi::CrystalSystem::Orthorhombic);
|
|
CHECK(r->centering == 'C');
|
|
const UnitCell uc = r->conventional.GetUnitCell();
|
|
CHECK(uc.a == Catch::Approx(100).epsilon(0.01));
|
|
CHECK(uc.b == Catch::Approx(100 * std::sqrt(3.0)).epsilon(0.01));
|
|
CHECK(uc.c == Catch::Approx(70).epsilon(0.01));
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - a filtered group may not rest on an operator the filter refused") {
|
|
// Two two-folds sixty degrees apart generate a three-fold, so the pair closes into the whole
|
|
// hexagonal group - four of whose two-folds the filter rejected. A sub-lattice whose own operators
|
|
// were offered and refused is not one the filter supports, so there is no answer to give.
|
|
const CrystalLattice L(100, 100, 70, 90, 90, 120);
|
|
const std::array<int, 9> along_a_plus_b = {0, 1, 0, 1, 0, 0, 0, 0, -1}; // k,h,-l
|
|
const std::array<int, 9> along_a = {1, 0, 0, -1, -1, 0, 0, 0, -1}; // h,-h-k,-l
|
|
const auto r = LePageLattice(L, LATTICE_MAX_OBLIQUITY_DEG, [&](const gemmi::Mat33 &m) {
|
|
return SameMatrix(m, along_a_plus_b) || SameMatrix(m, along_a);
|
|
});
|
|
CHECK(!r.has_value());
|
|
}
|
|
|
|
TEST_CASE("LePageLattice - a two-fold the character table admits but the metric does not host") {
|
|
// A C-centred monoclinic description whose alpha is 1.7 deg off 90: the Niggli character table,
|
|
// on its relative tolerances, still names the class, while the two-fold is more oblique than
|
|
// Le Page's bound, so the lattice does not host it. The centred-lattice test in the rotation merge
|
|
// asks the intensities about a class only where both agree. A lattice 0.3 deg off is hosted.
|
|
const CrystalLattice oblique = CrystalLattice(120, 100, 70, 88.3, 115, 90).ToPrimitive('C');
|
|
const auto named = LatticeSearch(oblique);
|
|
CHECK(named.system == gemmi::CrystalSystem::Monoclinic);
|
|
CHECK(named.centering == 'C');
|
|
const auto metric = LePageLattice(oblique);
|
|
REQUIRE(metric.has_value());
|
|
CHECK(metric->n_operators == 1);
|
|
CHECK(metric->system == gemmi::CrystalSystem::Triclinic);
|
|
|
|
const auto hosted = LePageLattice(CrystalLattice(120, 100, 70, 89.7, 115, 90).ToPrimitive('C'));
|
|
REQUIRE(hosted.has_value());
|
|
CHECK(hosted->n_operators == 2);
|
|
CHECK(hosted->system == gemmi::CrystalSystem::Monoclinic);
|
|
CHECK(hosted->centering == 'C');
|
|
}
|