Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 8m45s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 18m40s
Build Packages / build:windows:nocuda (push) Successful in 19m33s
Build Packages / build:windows:cuda (push) Successful in 21m41s
Build Packages / build:viewer-tgz:cpu (push) Successful in 21m41s
Build Packages / build:viewer-tgz:cuda (push) Successful in 22m0s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 23m26s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 28m34s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 20m6s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m51s
Build Packages / build:rugnux:windows (push) Successful in 11m3s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 26m32s
Build Packages / XDS test (durin plugin) (push) Failing after 12s
Build Packages / XDS test (JFJoch plugin) (push) Failing after 11s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 24m38s
Build Packages / Generate python client (push) Successful in 41s
Build Packages / build:rpm (rocky9) (push) Successful in 25m7s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m49s
Build Packages / build:rpm (rocky8) (push) Successful in 27m57s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 22m2s
Build Packages / DIALS test (push) Successful in 24m47s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 25m30s
Build Packages / XDS test (neggia plugin) (push) Successful in 8m51s
Build Packages / Unit tests (push) Successful in 1h23m15s
The class is named by Niggli-reducing the indexed cell and looking the reduced cell up in the 44 lattice characters, and that lookup is a coin flip for any lattice whose Buerger cells straddle the Niggli type-I/type-II boundary. An F-centred cubic lattice does so by construction: it has reduced forms on both sides, the two sides carry different characters, and which side the reduction lands on is set by the last digits of whatever refinement produced the cell. Measured over 600 perturbations of one such lattice: 43% cubic F, 36% tetragonal I, 21% orthorhombic I, and the split is flat over a factor of ten in the noise. The class then caps the point-group search, so from the body-centred sub-cell the cubic three-fold is never enumerated and the run reports that nothing was refused - which is accurate, because nothing was asked. Le Page's two-fold search has no such key: it measures each rotation's obliquity on the lattice itself, in a primitive basis. LePageLattice turns the rotation group it finds into a conventional cell, a centring letter and an integral change of basis, and where that group is larger than the adopted class's holohedry the merge is reindexed into that cell and the space-group search is run again there, on both merges, with every gate live. Nothing here decides: the reindex is committed only where the search in the new setting confirms a strictly higher point group AND the centring the new cell describes, so a pseudo-symmetric metric leaves the answer already in hand standing. Measured as a paired battery over 113 rotation datasets: the re-ask fires on 7 and adopts on 1, and that one crystal - an F-centred cubic lattice the reduction had named body-centred tetragonal - moves to its deposited group, gaining 0.10 A of resolution and 2.8x the multiplicity at R_meas 0.117 -> 0.120. Nothing else moves, in space group, resolution, CC1/2, R_meas, multiplicity, I/sigma or completeness. A second such crystal, named body-centred orthorhombic, is offered the same cubic cell and confirms all 23 added operators at CC 0.96 with an H ratio of 1.00, and is still refused, on the merge chi^2 ratio at 2.50x a bound of 1.85. Letting H rescue that refusal is the one-line change an earlier round measured and rejected - it promotes the synthetic P 4_3 2_1 2 in the test suite to point group 432 - so it is not here, and that crystal is left where it was. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
141 lines
6.8 KiB
C++
141 lines
6.8 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 <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 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 - 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');
|
|
}
|