symmetry: the cell a group is adopted on is refined under that group
Build Packages / Create release (push) Successful in 15s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m21s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m28s
Build Packages / build:viewer-tgz:cpu (push) Successful in 9m53s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m57s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m12s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 15m50s
Build Packages / build:windows:nocuda (push) Successful in 17m15s
Build Packages / build:windows:cuda (push) Successful in 19m50s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 23m14s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 19m9s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m12s
Build Packages / build:rugnux:windows (push) Successful in 10m50s
Build Packages / Generate python client (push) Successful in 44s
Build Packages / Build documentation (push) Successful in 1m23s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 20m10s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m23s
Build Packages / build:rpm (rocky8) (push) Successful in 18m2s
Build Packages / build:rpm (rocky9) (push) Successful in 18m22s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 13m46s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 12m25s
Build Packages / Unit tests (push) Successful in 1h12m4s

A space group confirmed from the intensities AFTER integration is one no
constrained fit has produced. The Bravais class is decided on the unrefined
indexing candidate, so a two-fold the spot positions never offered leaves the
freely refined metric standing in the group's own setting - a C 1 2 1 whose
alpha is 88.5 - and that cell goes to the report, the master file and the MTZ.

At adoption, re-refine the lattice under the group's constraint against the
accumulated rotation spots, at the geometry the images were integrated at, and
keep it when the spots do - the bar the indexer's own pseudo-symmetry guard
uses. Where they refuse it, report the nearest metric the group fixes and say
that a deviation that size is not refinement noise. A cell whose violation is
above MAX_METRIC_VIOLATION is the WRONG cell for its group rather than an
unconstrained one, and nothing here touches it: a visible mismatch must not
become a plausible-looking one.

The projection is applied as a change of basis, not as three rebuilt vectors:
the three-Coord constructor enforces a right-handed basis, and a left-handed
lattice came back with an axis flipped and its free angle replaced by the
supplement, which failed the merge outright.

Battery over 151 datasets, base against this: 144 byte-identical, 7 cells moved
onto their group's metric, no space group changed, no run gained or lost, open
arm 94/99 both ways. Every merge statistic of the seven is unchanged except
completeness, which rises on four and falls 0.1 % on one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KWkZ1o2aoQ9EimF2wtzBky
This commit is contained in:
2026-09-15 15:57:12 +02:00
co-authored by Claude Opus 5
parent 22b4700c1e
commit 0fccbe21b5
11 changed files with 380 additions and 9 deletions
+1
View File
@@ -6,6 +6,7 @@
* Building Jungfraujoch no longer needs zlib or Eigen installed on the machine, and the dependencies the build fetches are pinned and updated to current releases.
* rugnux: improvements in indexing, lattice selection and geometry post-refinement, which index crystals that previously returned no lattice and keep the better of the two geometries a run measures.
* rugnux: improvements in beam-centre measurement, beam-stop detection and space-group determination.
* rugnux: the unit cell reported with a determined space group now obeys that group - a cell whose symmetry was confirmed from the intensities is re-refined under it, and a cell the group cannot describe is reported with a warning rather than as it stands.
* rugnux drops the stretches of a rotation sweep whose removal measurably improves the merged intensities and reports what became of every frame, and decides the resolution cut on the crystal's own diffraction rather than on its ice rings.
* The rugnux results report is machine-readable - every line that is not `KEY= value` data starts with `#` - and states the build it was written by, its authorship and its terms of use (`REPORT_VERSION= 8`).
* `jfjoch_viewer`: improvements in the file manager (CBF frames beside HDF5 datasets, a remembered root), the dataset plots, the inspector and the image statistics, plus a settable font size, a view of the rugnux results report, usable performance over a remote display (`ssh -X`) and a reset of all settings to defaults; the reciprocal-space window is removed.
+8
View File
@@ -677,6 +677,14 @@ std::optional<RotationIndexerResult> IndexAndRefine::FinalizeRotationIndexing()
return {};
}
std::optional<RotationIndexer::ConstrainedRefit>
IndexAndRefine::RefineRotationLatticeConstrained(const CrystalLattice &latt,
gemmi::CrystalSystem system) const {
if (!rotation_indexer)
return {};
return rotation_indexer->RefineConstrained(latt, system);
}
IndexAndRefine &IndexAndRefine::ReferenceIntensities(std::vector<MergedReflection> &reference) {
// An external reference is trusted to be in the correct hand, so use it to break the merohedral
// indexing ambiguity per image (serial stills index each crystal independently).
+6
View File
@@ -147,6 +147,12 @@ public:
std::optional<RotationIndexerResult> FinalizeRotationIndexing();
// Re-refine a lattice with its metric constrained to one crystal system, against the rotation
// indexing's own accumulated spots - for a caller that has adopted a symmetry the indexing never
// refined under. Empty for a run with no rotation indexer. See RotationIndexer::RefineConstrained.
[[nodiscard]] std::optional<RotationIndexer::ConstrainedRefit>
RefineRotationLatticeConstrained(const CrystalLattice &latt, gemmi::CrystalSystem system) const;
std::optional<UnitCell> GetConsensusUnitCell() const;
// Not thread safe, need to be run after processing is all done
@@ -495,3 +495,40 @@ double MetricDeviation(const LatticeSearchResult &sr) {
}
return std::max(angle / LATTICE_SEARCH_ANGLE_TOLERANCE_DEG, length / LATTICE_SEARCH_DIST_TOLERANCE);
}
CrystalLattice SymmetrizeMetric(const CrystalLattice &L, const gemmi::SpaceGroup &sg) {
const Coord v[3] = {L.Vec0(), L.Vec1(), L.Vec2()};
Eigen::Matrix3d A;
for (int i = 0; i < 3; i++)
A.row(i) << v[i].x, v[i].y, v[i].z;
const Eigen::Matrix3d G = A * A.transpose();
// The rotations alone: a centring translation acts on the lattice points, not on the metric.
const auto &ops = sg.operations().sym_ops;
Eigen::Matrix3d G_sym = Eigen::Matrix3d::Zero();
for (const gemmi::Op &op : ops) {
Eigen::Matrix3d R;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
R(i, j) = static_cast<double>(op.rot[i][j]) / gemmi::Op::DEN;
G_sym += R.transpose() * G * R;
}
G_sym /= static_cast<double>(ops.size());
// Symmetric square root of a positive-definite metric, or its inverse.
const auto root = [](const Eigen::Matrix3d &M, bool inverse) {
const Eigen::SelfAdjointEigenSolver<Eigen::Matrix3d> es(M);
Eigen::Vector3d d = es.eigenvalues().cwiseSqrt();
if (inverse)
d = d.cwiseInverse();
return Eigen::Matrix3d(es.eigenvectors() * d.asDiagonal() * es.eigenvectors().transpose());
};
// As a change of basis rather than three rebuilt vectors: the three-Coord constructor enforces a
// right-handed basis, and a lattice that arrives left-handed would come back with one axis flipped
// and its free angle replaced by the supplement (beta 132.23 -> 47.77, measured). A projection of
// the metric has no business changing the hand of the cell its reflections are indexed on.
const Eigen::Matrix3d M = root(G_sym, false) * root(G, true);
return L.Multiply(gemmi::Mat33(M(0, 0), M(0, 1), M(0, 2),
M(1, 0), M(1, 1), M(1, 2),
M(2, 0), M(2, 1), M(2, 2)));
}
@@ -55,3 +55,12 @@ std::optional<LatticeSearchResult> LatticeSearchForClass(const CrystalLattice& L
// difference between them is the difference between a lattice and one drifting away from a lattice.
// A triclinic match has no condition to violate, so it is 0.
double MetricDeviation(const LatticeSearchResult &sr);
// The nearest lattice whose metric the rotations of `sg` leave invariant, with L's own orientation.
// Averaging the metric tensor over the group is the projection onto the metrics it fixes, and
// A' = G_sym^(1/2) G^(-1/2) A is the lattice carrying that metric in A's own orientation - the
// smallest change to the cell that lets the group describe it at all. For a caller that has ADOPTED a
// group on a cell no fit under that group ever produced: a C 1 2 1 whose alpha is 88.5 is not a cell
// to report, whatever confirmed the two-fold. Where the spots can be asked instead - a constrained
// re-refinement - they are the better answer; this is what is left when that is refused.
CrystalLattice SymmetrizeMetric(const CrystalLattice &L, const gemmi::SpaceGroup &sg);
@@ -172,15 +172,7 @@ void RotationIndexer::RunIndexing() {
// Re-accumulate the reciprocal spots under a refined geometry/axis, to score a refined cell.
auto accumulate = [&](const DiffractionGeometry &g, const std::optional<GoniometerAxis> &ax) {
std::vector<Coord> c;
c.reserve(accumulated_spots);
for (int i = 0; i < v_.size(); i++) {
const float a = angle_deg_[i].value_or(ax->GetAngle_deg(i) + ax->GetWedge_deg() / 2.0f);
const auto rot = ax->GetTransformationAngle(a);
for (const auto &s : v_[i])
c.emplace_back(rot * s.ReciprocalCoord(g));
}
return c;
return AccumulateReciprocal(g, ax);
};
// The FFT offers a few candidate cells (its best reduction plus, for large/elongated cells, a
@@ -586,6 +578,62 @@ void RotationIndexer::ProcessImage(int64_t image, const std::vector<SpotToSave>
accumulated_spots += v_[image].size();
}
std::vector<Coord> RotationIndexer::AccumulateReciprocal(const DiffractionGeometry &g,
const std::optional<GoniometerAxis> &ax) const {
std::vector<Coord> c;
c.reserve(accumulated_spots);
for (int i = 0; i < v_.size(); i++) {
const float a = angle_deg_[i].value_or(ax->GetAngle_deg(i) + ax->GetWedge_deg() / 2.0f);
const auto rot = ax->GetTransformationAngle(a);
for (const auto &s : v_[i])
c.emplace_back(rot * s.ReciprocalCoord(g));
}
return c;
}
std::optional<RotationIndexer::ConstrainedRefit>
RotationIndexer::RefineConstrained(const CrystalLattice &latt, gemmi::CrystalSystem system) const {
std::unique_lock ul(m);
if (!axis_ || accumulated_spots == 0 || latt.CalcVolume() <= 1.0)
return {};
const UnitCell uc = latt.GetUnitCell();
XtalOptimizerData d{
.geom = updated_geom_,
.latt = latt,
.crystal_system = system == gemmi::CrystalSystem::Trigonal ? gemmi::CrystalSystem::Hexagonal
: system,
.min_spots = experiment.GetIndexingSettings().GetViableCellMinSpots(),
.max_length_A = 1.2f * std::max({uc.a, uc.b, uc.c}),
// The indexers' bound, so a monoclinic beta outside [60,120] is refined and not clamped.
.min_angle_deg = 30.0f,
.max_angle_deg = 150.0f,
// The detector and the axis stay where the indexing left them - see the header.
.refine_beam_center = false,
.refine_detector_angles = false,
.refine_rotation_axis = false,
.index_ice_rings = index_ice_rings,
.max_iterations = real_time ? 0 : ROT_REFINE_ITERATIONS,
.axis = axis_
};
// One cloud for both scores: the geometry is held, so the lattice that comes out is scored on the
// same spots the one that went in is.
const auto cloud = AccumulateReciprocal(updated_geom_, axis_);
const float tol = experiment.GetIndexingSettings().GetTolerance();
const float before = IndexedFraction(latt, cloud, tol);
if (!XtalOptimizer(d, v_, 4))
return {};
return ConstrainedRefit{
.lattice = d.latt,
.indexed_fraction = IndexedFraction(d.latt, cloud, tol),
.indexed_fraction_before = before,
};
}
std::optional<RotationIndexerResult> RotationIndexer::GetLattice() const {
std::unique_lock ul(m);
@@ -59,6 +59,11 @@ class RotationIndexer {
std::optional<CrystalLattice> indexed_lattice;
std::optional<std::string> indexer_error_;
// The accumulated per-image spots as one reciprocal-space cloud, back-rotated to phi = 0 under the
// given geometry and axis. The caller holds the lock.
[[nodiscard]] std::vector<Coord> AccumulateReciprocal(const DiffractionGeometry &g,
const std::optional<GoniometerAxis> &ax) const;
public:
// real_time: bound the candidate-cell refinements by WALL CLOCK, as online acquisition must - it
// has a real budget. Offline (rugnux, the viewer) passes false and they are bounded by iteration
@@ -83,4 +88,25 @@ public:
void ForceResult(const RotationIndexerResult& result);
// True once the accumulation buffer is full (used to stop feeding a consecutive-frame scheme).
[[nodiscard]] bool AccumulationFull() const;
// A lattice re-refined with its metric CONSTRAINED to one crystal system, and what the constraint
// costs on the spots that decided the free one.
struct ConstrainedRefit {
CrystalLattice lattice;
float indexed_fraction = 0.0f; // the refined constrained lattice, over the accumulated cloud
float indexed_fraction_before = 0.0f; // the lattice handed in, over the same cloud
};
// Re-refine `latt` under the metric constraint of `system`, against the same accumulated cloud the
// indexing ran on and at the geometry and axis it settled on. For a caller that has ADOPTED a
// symmetry this indexing never refined under: the intensities can confirm a two-fold the spot
// POSITIONS never offered - the Bravais class is decided on the unrefined candidate, and the free
// refinement can walk into a class afterwards - and such a caller then holds a cell in a group's
// setting whose metric is still the free fit's. The detector and the axis are held: by the time
// that question is asked the reflections have been integrated at that geometry, so what is being
// asked is which cell the spots support under the constraint, not where the detector is. The BASIS
// is held too - this never reindexes - so `latt` has to arrive in the setting the constraint is
// written in (unique axis b for monoclinic, as XtalResidual parameterises it); a caller that cannot
// guarantee that has to check the answer against its own group.
[[nodiscard]] std::optional<ConstrainedRefit> RefineConstrained(const CrystalLattice &latt,
gemmi::CrystalSystem system) const;
};
+103
View File
@@ -5857,6 +5857,109 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
logger.Info("{}", msg);
stats_text << " " << msg << "\n\n";
}
// The group has to be able to DESCRIBE the cell it is adopted on, and where the
// promotion was made HERE it cannot. A two-fold the intensities confirm is one the spot
// positions never offered - the Bravais class is decided on the unrefined indexing
// candidate, and the free refinement can walk into a class afterwards - so no fit under
// this group's constraint has ever been run, and what goes to the report, the master
// file and the MTZ is the free fit's metric re-expressed in the group's setting: a
// C 1 2 1 whose alpha is 88.5, which no monoclinic refinement could have produced. Ask
// the spots what cell the constraint supports; where they refuse it, report the metric
// the group fixes rather than one it cannot describe. A lattice already refined under
// this class leaves only float noise behind and is left alone, which is what the bound
// tests - so a de-novo run whose indexing found the class behaves exactly as it did.
{
std::optional<CrystalLattice> &adopted =
commit_reindex ? commit_lattice : end_msg.rotation_lattice;
// The band between the two bounds is where the cell is merely UNCONSTRAINED. Above
// the upper one it is the wrong cell for this group - the axes permuted, the fold
// running along an axis the group does not put it on - and nothing here can repair
// that: a constrained refit has no cell to find and the metric projection would
// manufacture one, turning a visible mismatch into a plausible-looking lie. That
// case is refused where the group comes in (MAX_METRIC_VIOLATION), and is left
// exactly as it is here.
const double violation = adopted.has_value()
? MetricViolation(adopted->GetUnitCell(), sg) : 0.0;
if (adopted.has_value() && violation > MAX_CONSTRAINED_METRIC_NOISE
&& violation <= MAX_METRIC_VIOLATION) {
const UnitCell before = adopted->GetUnitCell();
const auto refit = indexer->RefineRotationLatticeConstrained(*adopted,
sg.crystal_system());
// A refit is usable only when the constraint it was written in is the one this
// group needs: XtalResidual parameterises monoclinic with the unique axis along
// b, and the search can adopt a group that puts it elsewhere (C 1 1 2), whose
// metric such a fit does not satisfy. Ask the group, rather than enumerate the
// settings - a fit made in the right constraint lands at float noise.
const bool fits_group = refit.has_value()
&& MetricViolation(refit->lattice.GetUnitCell(), sg)
<= MAX_CONSTRAINED_METRIC_NOISE;
// ...and the spots have to keep it. Half is the bar the indexer's own
// pseudo-symmetry guard uses for this very comparison: a constrained cell that
// indexes half of what the free one did is not describing these images, and the
// metric and the intensities are then saying different things - which is a
// geometry error to go and find, not a cell to report.
const bool spots_keep_it = refit.has_value()
&& refit->indexed_fraction > 0.5f * refit->indexed_fraction_before;
if (fits_group && spots_keep_it) {
*adopted = refit->lattice;
const UnitCell uc = adopted->GetUnitCell();
logger.Info("{} was adopted on a cell no fit under it had produced (the "
"promotion was made after integration, on the freely refined "
"metric). Re-refined under the constraint: a={:.3f} b={:.3f} "
"c={:.3f} alpha={:.2f} beta={:.2f} gamma={:.2f}, from a={:.3f} "
"b={:.3f} c={:.3f} alpha={:.2f} beta={:.2f} gamma={:.2f} - it "
"indexes {:.1f} % of the accumulated spots against {:.1f} % for "
"the free cell.",
sg.xhm(), uc.a, uc.b, uc.c, uc.alpha, uc.beta, uc.gamma,
before.a, before.b, before.c, before.alpha, before.beta,
before.gamma, 100.0 * refit->indexed_fraction,
100.0 * refit->indexed_fraction_before);
} else {
*adopted = SymmetrizeMetric(*adopted, sg);
const UnitCell uc = adopted->GetUnitCell();
const std::string msg = fmt::format(
"The cell {} was adopted on is one that group cannot describe - "
"a={:.3f} b={:.3f} c={:.3f} alpha={:.2f} beta={:.2f} gamma={:.2f}, "
"refined FREE, because the symmetry was confirmed from the intensities "
"after integration and no constrained fit had produced this lattice. {} "
"The reported cell is the nearest metric the group fixes (alpha={:.2f} "
"beta={:.2f} gamma={:.2f}); the reflections are unchanged. A deviation "
"this size is not refinement noise - the geometry the images were "
"integrated at is the place to look.",
sg.xhm(), before.a, before.b, before.c, before.alpha, before.beta,
before.gamma,
refit.has_value()
? (fits_group
? fmt::format("Re-refining under the constraint indexes {:.1f} % "
"of the accumulated spots against {:.1f} % free, so "
"it was refused.",
100.0 * refit->indexed_fraction,
100.0 * refit->indexed_fraction_before)
: "The constrained refinement is not written in this group's "
"setting, so it could not be asked.")
: "There was no rotation spot cloud to re-refine against.",
uc.alpha, uc.beta, uc.gamma);
logger.Warning("{}", msg);
stats_text << " !! " << msg << "\n\n";
}
// Carry the corrected lattice into the cell that is reported and merged on.
// Where a reindex is pending the block below does that and re-ingests; where
// there is none, this is the only place it happens.
if (commit_reindex) {
commit_cell = adopted->GetUnitCell();
} else {
result.consensus_cell = adopted->GetUnitCell();
end_msg.unit_cell = result.consensus_cell;
if (rsm) {
rsm.emplace(experiment_, indexer->GetIntegrationOutcome(),
result.consensus_cell, static_cast<int>(config_.scaling_iter),
config_.nthreads, logger, config_.observation_dump_path);
rsm->Ingest();
}
}
}
}
// A reindex is committed only when the intensity re-test confirmed a higher symmetry in a
// centred setting: bring the integrated reflections + cell into that setting and re-ingest
// the rotation merge, so the final scaling/merging folds the equivalents correctly.
+8
View File
@@ -382,6 +382,14 @@ double MetricViolation(const UnitCell &uc, const gemmi::SpaceGroup &sg);
// 113 runs if it were used here.
constexpr double MAX_METRIC_VIOLATION = 0.1;
// What a cell refined UNDER a group's own constraint leaves of that group's metric violation: float
// rounding on the lattice vectors, and nothing else (the constraint holds the angles at 90 exactly,
// so the residue is 1e-6 and below). A cell scoring above this was refined under something else -
// which is what has happened wherever the group was adopted AFTER integration, on a metric no
// constrained fit ever produced. Two orders below MAX_METRIC_VIOLATION, which bounds a cell that is
// WRONG for its group rather than merely unconstrained: the first says re-refine, the second refuse.
constexpr double MAX_CONSTRAINED_METRIC_NOISE = 1e-3;
// Callbacks for progress and live results. Methods may be called from worker threads, so an
// implementation must be thread-safe. The default no-ops suit the CLIs.
class RugnuxObserver {
+43
View File
@@ -596,3 +596,46 @@ TEST_CASE("CrystalLattice::ToPrimitive gives an R-centred lattice its rhombohedr
CHECK(back.c == Catch::Approx(c).epsilon(1e-4));
CHECK(back.gamma == Catch::Approx(120.0).epsilon(1e-4));
}
// SymmetrizeMetric is what a run falls back on when it has adopted a group on a cell no fit under
// that group produced. It has to do two things: leave the group's metric exactly satisfied, and move
// the cell as little as that requires - which means the orientation it arrives in is kept.
TEST_CASE("SymmetrizeMetric puts a cell onto the metric its group fixes") {
const auto &c2 = *gemmi::find_spacegroup_by_name("C 1 2 1");
// A C-centred monoclinic setting whose alpha is 1.5 deg off, as a freely refined metric promoted
// after integration arrives: the group cannot describe it.
const CrystalLattice off(160.0, 140.0, 90.0, 88.5, 119.9, 90.1);
const auto fixed = SymmetrizeMetric(off, c2).GetUnitCell();
CHECK(fixed.alpha == Catch::Approx(90.0).margin(1e-3));
CHECK(fixed.gamma == Catch::Approx(90.0).margin(1e-3));
// The free angle and the lengths stay where they were, to well inside the move it had to make.
CHECK(fixed.beta == Catch::Approx(119.9).margin(0.2));
CHECK(fixed.a == Catch::Approx(160.0).epsilon(2e-3));
CHECK(fixed.b == Catch::Approx(140.0).epsilon(2e-3));
CHECK(fixed.c == Catch::Approx(90.0).epsilon(2e-3));
// A cell the group already describes is not moved at all, whatever orientation it is in.
const CrystalLattice ok(80.0, 50.0, 60.0, 90.0, 105.0, 90.0);
const auto same = SymmetrizeMetric(ok, c2).GetUnitCell();
check_uc(same, 80.0, 50.0, 60.0, 90.0, 105.0, 90.0, 1e-3, 1e-3);
// ...including one that is not axis-aligned: the answer is a property of the lattice, not of the
// frame it is written in, and the orientation it came in is the orientation it goes out in.
const RotMatrix rot(0.7, Coord(1.0f, 2.0f, 3.0f).Normalize());
const CrystalLattice turned = off.Multiply(rot);
// A LEFT-handed basis keeps its hand and its angles: the three-Coord constructor would flip an
// axis and hand back the supplement of beta, which is a different cell from the one the
// reflections are indexed on - and a metric projection may not change the cell that much.
const CrystalLattice flipped = off.Multiply(gemmi::Mat33(-1, 0, 0, 0, 1, 0, 0, 0, 1));
REQUIRE(flipped.CalcVolume() < 0);
const auto left = SymmetrizeMetric(flipped, c2);
CHECK(left.CalcVolume() < 0);
CHECK(left.GetUnitCell().beta == Catch::Approx(flipped.GetUnitCell().beta).margin(1e-3));
CHECK(left.GetUnitCell().alpha == Catch::Approx(90.0).margin(1e-3));
const auto turned_fixed = SymmetrizeMetric(turned, c2);
check_uc(turned_fixed.GetUnitCell(), fixed.a, fixed.b, fixed.c, 90.0, fixed.beta, 90.0, 1e-3, 1e-3);
CHECK(turned_fixed.Vec0() * turned.Vec0()
== Catch::Approx(turned_fixed.Vec0().Length() * turned.Vec0().Length()).epsilon(1e-4));
}
+82
View File
@@ -98,3 +98,85 @@ TEST_CASE("RotationIndexer") {
CHECK(ret->search_result.centering == 'P');
CHECK(ret->search_result.system == gemmi::CrystalSystem::Orthorhombic);
}
// RefineConstrained is what a caller reaches for when it has ADOPTED a symmetry the indexing never
// refined under - the intensities confirm a two-fold the spot positions never offered - and so holds
// a cell whose metric is still the free fit's. Give it such a cell: the lattice this crystal indexes
// on, sheared so that alpha is a degree off, which is what that situation looks like. The constraint
// has to take it back to a cell the group can describe, and the spots have to be happier for it.
TEST_CASE("RotationIndexer::RefineConstrained puts a free metric back on its class") {
DiffractionExperiment exp_i;
exp_i.IncidentEnergy_keV(WVL_1A_IN_KEV)
.BeamX_pxl(1000)
.BeamY_pxl(1000)
.DetectorDistance_mm(200)
.ImagesPerTrigger(50);
IndexingSettings settings;
#ifdef JFJOCH_USE_CUDA
settings.Algorithm(IndexingAlgorithmEnum::FFT);
#elif JFJOCH_USE_FFTW
settings.Algorithm(IndexingAlgorithmEnum::FFTW);
#else
return;
#endif
settings.RotationIndexing(true).RotationIndexingAngularStride_deg(1.0).RotationIndexingMinAngularRange_deg(30.0);
exp_i.ImportIndexingSettings(settings);
const CrystalLattice latt_base =
CrystalLattice(40, 50, 80, 90, 105, 90).Multiply(RotMatrix(2.0, Coord(sqrt(3)/3, sqrt(3)/3, sqrt(3)/3)));
GoniometerAxis axis("omega", 0.0f, 1.0f, Coord(1, 0, 0), std::nullopt);
exp_i.Goniometer(axis);
BraggPredictionSettings prediction_settings{ .high_res_A = 1.3, .ewald_dist_cutoff = 0.002 };
IndexerThreadPool indexer_thread_pool(exp_i.GetIndexingSettings());
RotationIndexer indexer(exp_i, indexer_thread_pool);
BraggPrediction prediction;
for (int img = 0; img < 50; ++img) {
std::vector<SpotToSave> spots;
const float angle_deg = axis.GetAngle_deg(img) + axis.GetWedge_deg() / 2.0f;
const CrystalLattice latt_img = latt_base.Multiply(axis.GetTransformationAngle(angle_deg).transpose());
const auto n = prediction.Calc(exp_i, latt_img, prediction_settings);
for (int i = 0; i < n; ++i) {
const auto &r = prediction.GetReflections().at(i);
SpotToSave s{};
s.x = r.predicted_x;
s.y = r.predicted_y;
s.image = img;
s.intensity = 1.0f;
s.phi = angle_deg;
s.ice_ring = false;
s.indexed = true;
spots.push_back(s);
}
indexer.ProcessImage(img, spots);
if (img == 30)
indexer.RunIndexing();
}
REQUIRE(indexer.GetLattice().has_value());
// Shear c along b: the orientation and two of the axes are untouched, and alpha - which the class
// fixes at 90 - moves by about a degree. A free refinement that has walked into a class leaves
// exactly this, a cell the group cannot describe standing in the group's own setting.
const CrystalLattice sheared(latt_base.Vec0(), latt_base.Vec1(),
latt_base.Vec2() + latt_base.Vec1() * 0.02f);
CHECK(std::fabs(sheared.GetUnitCell().alpha - 90.0) > 0.5);
const auto refit = indexer.RefineConstrained(sheared, gemmi::CrystalSystem::Monoclinic);
REQUIRE(refit.has_value());
const auto uc = refit->lattice.GetUnitCell();
CHECK(uc.alpha == Catch::Approx(90.0).margin(1e-3));
CHECK(uc.gamma == Catch::Approx(90.0).margin(1e-3));
// ...and it is the cell the crystal has, not merely a cell obeying the constraint.
CHECK(uc.a == Catch::Approx(40.0).margin(0.2));
CHECK(uc.b == Catch::Approx(50.0).margin(0.2));
CHECK(uc.c == Catch::Approx(80.0).margin(0.2));
CHECK(uc.beta == Catch::Approx(105.0).margin(0.2));
// The spots decide whether a caller keeps it, so the fractions have to be the real comparison:
// the sheared cell indexes worse than the one the constraint brings back.
CHECK(refit->indexed_fraction > refit->indexed_fraction_before);
CHECK(refit->indexed_fraction > 0.5f);
}