The Le Page namer took the least oblique cell across the P, C and I namings of a monoclinic lattice, while the space-group search it feeds enumerates reference settings only - whose order-2 Sohncke groups are P2, P2_1 and C2, never I-centred. So a lattice the plane-basis search named I could earn its promotion on the intensities and still be refused for its name: the re-ask's chosen->centring_type() == cand.centering has no candidate to match, on every such run. The (a, c) plane-basis search now runs twice: first accepting only the P and C namings, then, only if the plane offers no other, accepting I as well. The obliquity is still minimised - within the reference naming rather than across namings. That is a search for the least oblique C, not a relabel: a -> a + c on such a lattice turns beta 120 into beta 32, outside the 30/150-degree bounds of the constrained refinement, which is what the replaced comment warned about. A new test hands the namer a lattice whose least oblique naming is I and requires the C setting back, with beta inside the bounds; it fails on the previous code, which returns I on all twenty presentations. Blast radius: LePageLattice() has two callers, both in the metric re-ask, and the branch this repairs was a guaranteed refusal, so no run that already adopts a promotion can move. Across all 99 run logs of the external corpus, three runs produce an I-centred monoclinic metric candidate, and all three were re-run with the change. One moves from a primitive triclinic group to the C-centred monoclinic group deposited for it, on the same lattice (overall CC1/2 0.30 -> 0.95, completeness 93.6 -> 98.8 %, multiplicity 2.2 -> 4.0); a second, whose lattice is separately wrong, now at least carries its deposited space group; the third asks in the C naming, is refused on the intensities exactly as before, and its results report is byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
210 lines
11 KiB
C++
210 lines
11 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 "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());
|
|
}
|