Files
Jungfraujoch/image_analysis/scale_merge/CrystalSetting.cpp
T
leonarski_fandClaude Opus 5.5 19f399a973 Rugnux: write the standard setting, or the user's; match a reference MTZ on its own axes
The files were written on the axes the space-group search named, which for
P2221/P21212 puts the unique axis wherever the a<b<c indexing put it (a 52.51
87.87 137.72 crystal came out P 2 21 21 where XDS writes 87.87 137.72 52.51
P 21 21 2), and a reference MTZ was matched in the data's frame: on permuted
axes its free-R flags landed on unrelated reflections while the log reported a
high matched count.

- New CrystalSetting (scale_merge): changes of basis between settings of one
  lattice (cell, group, index operator, basis matrix), the {-1,0,1} det +1
  candidates (CellMappingOperators, moved from ModelValidation), MetricViolation
  (moved from Rugnux), ChooseOutputSetting and SeatGroupByAbsences.
- Output setting: after every decision the merge, the integrated reflections
  (unmerged MTZ), the P1 cross-check, the lattice and the _process.h5 reindex
  matrix are relabelled into the ITA standard setting - or, in priority order,
  a reference MTZ's, a fitting model's, the -C axis order, a non-standard -S
  symbol's. Free-R flags are drawn again on the written axes. Reported as
  SETTING_OPERATOR / SETTING_SOURCE.
- Reference MTZ: the group is kept in its setting; after the merge every cell
  mapping onto the reference cell (times the twin laws) is scored by the
  reference CC, the best is re-seated and re-merged, and the free flags are
  inherited only where CC >= 0.5 over >= 50% of the reference range
  (REFERENCE_MISMATCH otherwise; --mode scale gates the same way).
  REFERENCE_OPERATOR / _CC / _MATCHED_FRACTION / _FREE_FLAGS_INHERITED.
- -S: a fixed group is put on the axes its absences name before merging
  (SeatGroupByAbsences), fixing -S 18 on a cell whose pure axis is not c.
- --model: a model in another setting is now a claim the null tests; where it
  fits, the data are written in its setting and the validation is remade on
  those axes (KeepModelVerdict carries the decisions over).

Tests: [setting] (synthetic #18/#17/I222/C222/c-unique P21/C2 beta/I2->C2/P1,
-C and -S order, absence seating, permuted reference with flags).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D1G8gJVAy6gp1K5Dz3NE5C
2026-09-25 20:16:42 +02:00

336 lines
14 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "CrystalSetting.h"
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <map>
#include "../../common/JFJochMath.h" // PI
namespace {
constexpr double DEG = PI / 180.0;
// The origin shifts every crystallographic setting change lies on, the common ones first.
constexpr int TWELFTHS[] = {0, 6, 3, 9, 4, 8, 2, 10, 1, 5, 7, 11};
// Is `g` the group whose sorted operator list is `target`, on some origin? The rotations and the
// centring are compared first - they do not depend on the origin - so the shift search only runs
// for a candidate that can match.
bool SameGroupUpToOrigin(const gemmi::GroupOps &g, const gemmi::GroupOps &target,
const std::vector<gemmi::Op> &target_sorted) {
if (g.sym_ops.size() != target.sym_ops.size() || !g.has_same_centring(target))
return false;
for (const gemmi::Op &op : g.sym_ops)
if (target.find_by_rotation(op.rot) == nullptr)
return false;
for (int i : TWELFTHS)
for (int j : TWELFTHS)
for (int k : TWELFTHS) {
gemmi::Op shift = gemmi::Op::identity();
shift.tran = {i * gemmi::Op::DEN / 12, j * gemmi::Op::DEN / 12, k * gemmi::Op::DEN / 12};
gemmi::GroupOps s = g;
s.change_basis_forward(shift);
if (s.all_ops_sorted() == target_sorted)
return true;
}
return false;
}
// How far a change of basis is from leaving everything where it is: the entries that differ from
// the identity, then the negative ones. Only ever used to break a tie between equal candidates.
int IdentityDistance(const gemmi::Op &cob) {
int changed = 0, negative = 0;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
const int e = cob.rot[i][j] / gemmi::Op::DEN;
changed += e != (i == j ? 1 : 0);
negative += e < 0;
}
return 10 * changed + negative;
}
}
gemmi::Op HklOperator(const gemmi::Op &cob) {
gemmi::Op op = cob.inverse();
op.tran = {0, 0, 0};
return op;
}
std::string IndexTriplet(const gemmi::Op &hkl_op) {
std::string out;
for (int i = 0; i < 3; i++) {
std::string term;
for (int j = 0; j < 3; j++) {
const int e = hkl_op.rot[j][i] / gemmi::Op::DEN; // h'_i = sum_j rot[j][i] h_j
if (e == 0)
continue;
if (e < 0)
term += "-";
else if (!term.empty())
term += "+";
if (std::abs(e) != 1)
term += std::to_string(std::abs(e));
term += "hkl"[j];
}
out += (i ? "," : "") + (term.empty() ? std::string("0") : term);
}
return out;
}
gemmi::Mat33 BasisMatrix(const gemmi::Op &cob) {
// h' = P h and apply_to_hkl computes rot^T h, so P is the transpose of the index operator.
const gemmi::Op hkl = HklOperator(cob);
gemmi::Mat33 p;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
p.a[i][j] = static_cast<double>(hkl.rot[j][i]) / gemmi::Op::DEN;
return p;
}
UnitCell CellInBasis(const UnitCell &cell, const gemmi::Op &cob) {
gemmi::UnitCell g = cell;
gemmi::Op rot_only = cob;
rot_only.tran = {0, 0, 0};
const gemmi::UnitCell c = g.changed_basis_forward(rot_only, false);
return UnitCell{static_cast<float>(c.a), static_cast<float>(c.b), static_cast<float>(c.c),
static_cast<float>(c.alpha), static_cast<float>(c.beta), static_cast<float>(c.gamma)};
}
const gemmi::SpaceGroup *SpaceGroupInBasis(const gemmi::SpaceGroup &sg, const gemmi::Op &cob) {
for (int i : TWELFTHS)
for (int j : TWELFTHS)
for (int k : TWELFTHS) {
gemmi::Op shifted = cob;
shifted.tran = {i * gemmi::Op::DEN / 12, j * gemmi::Op::DEN / 12, k * gemmi::Op::DEN / 12};
gemmi::GroupOps gops = sg.operations();
gops.change_basis_forward(shifted);
if (const gemmi::SpaceGroup *found = gemmi::find_spacegroup_by_ops(gops))
return found;
}
return nullptr;
}
double MetricViolation(const UnitCell &uc, const gemmi::SpaceGroup &sg) {
const double a = uc.a, b = uc.b, c = uc.c;
const double ab = a * b * std::cos(uc.gamma * DEG);
const double ac = a * c * std::cos(uc.beta * DEG);
const double bc = b * c * std::cos(uc.alpha * DEG);
const double g[3][3] = {{a * a, ab, ac}, {ab, b * b, bc}, {ac, bc, c * c}};
const double scale = std::max({a * a, b * b, c * c});
if (!(scale > 0.0))
return 0.0;
double worst = 0.0;
for (const gemmi::Op &op : sg.operations()) {
double r[3][3];
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;
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++) {
double t = 0.0;
for (int k = 0; k < 3; k++)
for (int l = 0; l < 3; l++)
t += r[k][i] * g[k][l] * r[l][j];
worst = std::max(worst, std::fabs(t - g[i][j]) / scale);
}
}
return worst;
}
bool CellsCorrespond(const gemmi::UnitCell &a, const gemmi::UnitCell &b) {
auto len = [](double x, double y) { return std::fabs(x - y) <= 0.05 * std::max(x, y); };
auto ang = [](double x, double y) { return std::fabs(x - y) <= 3.0; };
return len(a.a, b.a) && len(a.b, b.b) && len(a.c, b.c)
&& ang(a.alpha, b.alpha) && ang(a.beta, b.beta) && ang(a.gamma, b.gamma);
}
const std::vector<gemmi::Op> &UnimodularOperators() {
static const std::vector<gemmi::Op> ops = [] {
std::vector<gemmi::Op> out{gemmi::Op::identity()};
for (int pattern = 0; pattern < 19683; pattern++) { // 3^9 matrices over {-1,0,1}
int e[9], v = pattern;
for (int &x : e) { x = v % 3 - 1; v /= 3; }
const int det = e[0] * (e[4] * e[8] - e[5] * e[7])
- e[1] * (e[3] * e[8] - e[5] * e[6])
+ e[2] * (e[3] * e[7] - e[4] * e[6]);
if (det != 1)
continue;
gemmi::Op op = gemmi::Op::identity();
for (int i = 0; i < 3; i++)
for (int j = 0; j < 3; j++)
op.rot[i][j] = e[3 * i + j] * gemmi::Op::DEN;
if (!(op == gemmi::Op::identity()))
out.push_back(op);
}
return out;
}();
return ops;
}
std::vector<gemmi::Op> CellMappingOperators(const gemmi::UnitCell &from, const gemmi::UnitCell &to) {
std::vector<gemmi::Op> out;
for (const gemmi::Op &op : UnimodularOperators()) {
gemmi::UnitCell moved = from; // changed_basis_forward is not const
if (CellsCorrespond(moved.changed_basis_forward(op, false), to))
out.push_back(op);
}
return out;
}
gemmi::Op ChooseOutputSetting(const UnitCell &cell, const gemmi::SpaceGroup &sg,
const gemmi::SpaceGroup *target, const std::optional<UnitCell> &target_cell) {
// A triclinic cell has no symmetry axis to name; the reduced cell the run indexed is its standard.
if (!target_cell && sg.number <= 2)
return gemmi::Op::identity();
// The settings a candidate may land on: the one asked for, the reference one, or - to follow a
// cell given on its own - any setting of the group.
std::vector<const gemmi::SpaceGroup *> settings;
if (target != nullptr)
settings.push_back(target);
else if (!target_cell)
settings.push_back(gemmi::find_spacegroup_by_number(sg.number));
else
for (const gemmi::SpaceGroup &s : gemmi::spacegroup_tables::main)
if (s.number == sg.number)
settings.push_back(&s);
std::vector<gemmi::GroupOps> setting_ops;
std::vector<std::vector<gemmi::Op>> setting_sorted;
for (const gemmi::SpaceGroup *s : settings) {
setting_ops.push_back(s->operations());
setting_sorted.push_back(setting_ops.back().all_ops_sorted());
}
const bool monoclinic = sg.crystal_system() == gemmi::CrystalSystem::Monoclinic;
const bool orthorhombic = sg.crystal_system() == gemmi::CrystalSystem::Orthorhombic;
const auto obliquity = [](const UnitCell &c) {
return std::max({std::fabs(c.alpha - 90.0), std::fabs(c.beta - 90.0), std::fabs(c.gamma - 90.0)});
};
const auto distance = [&](const UnitCell &c) {
const UnitCell &t = *target_cell;
return std::fabs(c.a - t.a) / t.a + std::fabs(c.b - t.b) / t.b + std::fabs(c.c - t.c) / t.c
+ (std::fabs(c.alpha - t.alpha) + std::fabs(c.beta - t.beta) + std::fabs(c.gamma - t.gamma)) / 180.0;
};
// Is candidate x a better choice than y?
const auto better = [&](const gemmi::Op &x, const UnitCell &cx, const gemmi::Op &y, const UnitCell &cy) {
if (target_cell) {
const double dx = distance(cx), dy = distance(cy);
if (std::fabs(dx - dy) > 1e-6)
return dx < dy;
} else if (monoclinic) {
const double ox = obliquity(cx), oy = obliquity(cy);
if (std::fabs(ox - oy) > 0.05)
return ox < oy;
} else if (orthorhombic) {
const double lx[3] = {cx.a, cx.b, cx.c}, ly[3] = {cy.a, cy.b, cy.c};
for (int i = 0; i < 3; i++)
if (std::fabs(lx[i] - ly[i]) > 1e-3 * std::max(lx[i], ly[i]))
return lx[i] < ly[i];
}
return IdentityDistance(x) < IdentityDistance(y);
};
const gemmi::GroupOps ops = sg.operations();
std::optional<gemmi::Op> best;
UnitCell best_cell{};
for (const gemmi::Op &cob : UnimodularOperators()) {
const UnitCell c = CellInBasis(cell, cob);
if (target_cell && !CellsCorrespond(c, *target_cell))
continue;
// beta >= 90 (or whichever angle the unique axis leaves free), as ITA writes it.
if (!target_cell && monoclinic && std::min({c.alpha, c.beta, c.gamma}) < 90.0 - 1e-3)
continue;
gemmi::GroupOps g = ops;
g.change_basis_forward(cob);
bool lands = false;
for (size_t i = 0; i < settings.size() && !lands; i++)
lands = SameGroupUpToOrigin(g, setting_ops[i], setting_sorted[i]);
if (!lands)
continue;
if (!best || better(cob, c, *best, best_cell)) {
best = cob;
best_cell = c;
}
}
// A cell given with -C that no description of this lattice comes near says nothing about axes.
if (!best && target_cell)
return ChooseOutputSetting(cell, sg, target, std::nullopt);
return best.value_or(gemmi::Op::identity());
}
gemmi::Op SeatGroupByAbsences(const UnitCell &cell, const gemmi::SpaceGroup &sg,
const std::vector<ReflectionZ> &reflections) {
const gemmi::GroupOps g = sg.operations();
// The centring is the lattice's and is the same for every candidate, so its absences say nothing
// about which axis is which.
gemmi::GroupOps lattice;
lattice.sym_ops = {gemmi::Op::identity()};
lattice.cen_ops = g.cen_ops;
// The candidates: the group as the current axes would see it, were the reflections put through each
// change of basis that keeps a cell the group can describe and the centring the lattice has.
std::vector<gemmi::Op> cobs;
std::vector<gemmi::GroupOps> seen_here;
for (const gemmi::Op &cob : UnimodularOperators()) {
if (MetricViolation(CellInBasis(cell, cob), sg) > MAX_METRIC_VIOLATION)
continue;
gemmi::GroupOps here = g;
here.change_basis_backward(cob);
if (!here.has_same_centring(g))
continue;
cobs.push_back(cob);
seen_here.push_back(here);
}
if (cobs.size() < 2)
return gemmi::Op::identity();
// Every reflection some candidate predicts absent falls in a class of reflections that the same
// candidates predict absent (a parity class of one axial row, say). Each class is read as the ratio
// of its mean I/sigma to that of the reflections no candidate predicts absent: near 0 absent, near
// 1 present. A candidate is charged the ratio for each class it calls absent and one minus it for
// each class it calls present. A class nothing measured is not a class here, so it charges no
// candidate - a screw on a row the sweep never recorded is neither for nor against.
std::map<std::vector<bool>, std::pair<double, int>> classes;
double control_sum = 0.0;
int control_n = 0;
for (const auto &r : reflections) {
if (lattice.is_systematically_absent(r.hkl))
continue;
std::vector<bool> absent_in(cobs.size());
bool any = false;
for (size_t i = 0; i < cobs.size(); i++) {
absent_in[i] = seen_here[i].is_systematically_absent(r.hkl);
any = any || absent_in[i];
}
if (any) {
auto &c = classes[absent_in];
c.first += r.z;
c.second++;
} else {
control_sum += r.z;
control_n++;
}
}
if (classes.empty() || control_n == 0 || control_sum <= 0.0)
return gemmi::Op::identity();
const double control = control_sum / control_n;
size_t best = 0;
double best_cost = 0.0;
for (size_t i = 0; i < cobs.size(); i++) {
double cost = 0.0;
for (const auto &[absent_in, sum_n] : classes) {
const double ratio = std::clamp(sum_n.first / sum_n.second / control, 0.0, 1.0);
cost += absent_in[i] ? ratio : 1.0 - ratio;
}
if (i == 0 || cost < best_cost) {
best = i;
best_cost = cost;
}
}
return cobs[best];
}