Merge branch 'c-screw-undetermined' into pool-C

This commit is contained in:
2026-09-20 18:45:17 +02:00
8 changed files with 281 additions and 2 deletions
@@ -484,6 +484,15 @@ RowModulation FindRowModulation(const gemmi::UnitCell& cell, const std::array<in
return out;
}
// The crystal axis a screw on this row would run along: h00 -> a, 0k0 -> b, 00l -> c. ' ' for a
// row that is not a principal one, which no screw extinguishes.
char AxisOf(std::array<int, 3> row) {
if (row[1] == 0 && row[2] == 0) return 'a';
if (row[0] == 0 && row[2] == 0) return 'b';
if (row[0] == 0 && row[1] == 0) return 'c';
return ' ';
}
std::string FormatDouble(double v, int decimals) {
std::ostringstream o;
o << std::fixed << std::setprecision(decimals) << v;
@@ -1039,6 +1048,17 @@ SearchSpaceGroupResult SearchSpaceGroup(
row_modulation.emplace(row, std::move(m));
}
}
// How many reflections of each principal axial row the merge holds at all. A screw lives on one
// row, so a row the sweep never recorded - the spindle's blind cone points down it, or its
// reflections fall outside the resolution range - is a screw nobody could have tested, and the
// difference between "tested and found absent" and "never looked at" is invisible in every other
// number here. Counted once over the merge, because it is a property of the data and not of a
// candidate. Only the three principal rows: a screw extinguishes no other.
std::map<AxialRow, int> axial_row_observed;
for (size_t i = 0; i < n; ++i)
if (pass_absence[i] && (H[i] == 0) + (K[i] == 0) + (L[i] == 0) == 2)
++axial_row_observed[RowOf(H[i], K[i], L[i])];
std::vector<double> tncs_expected(n, 1.0);
for (size_t i = 0; i < n && !row_modulation.empty(); ++i) {
if (!pass_absence[i] || absence_shell[i] < 0)
@@ -2355,6 +2375,46 @@ SearchSpaceGroupResult SearchSpaceGroup(
}
}
// Which axis, if any, these data could not decide the screw on. The ambiguity itself is already
// in `alternatives` - the selected set holds groups predicting different axial absences - but a
// list of names does not say WHICH axis is open, nor why, and the scalar best_space_group reads
// as a determination either way. So name it per row: a row is undetermined when two SELECTED
// candidates disagree about whether it carries screw absences at all, which is exactly the case
// where nothing in this merge separated them. An enantiomorphic or origin-ambiguous pair
// (P4_1/P4_3, I222/I2_12_12_1) predicts the SAME absences on every row and is not named here -
// that ambiguity is the hand, or the origin, and is reported as itself.
if (result.best_space_group.has_value()) {
for (const AxialRow& row : {RowOf(1, 0, 0), RowOf(0, 1, 0), RowOf(0, 0, 1)}) {
const gemmi::Op::Miller hkl{{row[0], row[1], row[2]}};
bool claimed = false, unclaimed = false;
int n_absent = 0, n_control = 0;
for (const auto& s : result.candidates) {
if (!s.selected)
continue;
const gemmi::GroupOps g = s.space_group.operations();
// The row's absences must come from a SCREW: in a centred group the axial condition
// is the centring's own (I222 extinguishes h00 with h odd because h+k+l is odd), and
// that is decided elsewhere, on the whole centring class.
if (g.is_systematically_absent(hkl) && !CenteringAbsent(g, hkl)) {
claimed = true;
for (const auto& z : s.screw_zones)
if (z.row == row) {
n_absent = std::max(n_absent, z.n_absent);
n_control = std::max(n_control, z.n_control);
}
} else {
unclaimed = true;
}
}
if (claimed && unclaimed) {
const auto it = axial_row_observed.find(row);
result.undetermined_screws.push_back(
{row, RowLabel(row), AxisOf(row),
it == axial_row_observed.end() ? 0 : it->second, n_absent, n_control});
}
}
}
// Both answers, on every run. The winner above may be a non-Sohncke group, which is right for a
// small molecule and impossible for a protein; a user who knows their sample is chiral has to be
// able to read the Sohncke answer off the same run, and a user who is not sure has to be able to
@@ -2647,6 +2707,31 @@ std::string SearchSpaceGroupResultToText(const SearchSpaceGroupResult& result,
<< SettingName(*result.sohncke_space_group) << ".\n";
}
// The axes the data left open, said in words. The zone table above is printed for the SELECTED
// candidate only, and the selected candidate in exactly this case is the one with NO screw zones
// - so without this the run's own account of an undecidable screw is a blank where the table
// would have been.
if (!result.undetermined_screws.empty()) {
os << "Screw axes these data cannot decide\n";
for (const auto& u : result.undetermined_screws) {
os << " " << u.axis << ": ";
if (u.n_observed == 0)
os << "the " << u.row_label << " row holds no reflections in this merge at all, so "
"nothing here\n could test a screw along " << u.axis
<< " - the row was not recorded (it can lie in the spindle's\n blind cone, or "
"outside the resolution range).\n";
else if (u.n_control == 0)
os << "the " << u.row_label << " row holds " << u.n_observed << " reflection(s) but no "
"control class for its absences,\n so how weak they are cannot be judged.\n";
else
os << "the " << u.row_label << " row does not separate the candidates (" << u.n_absent
<< " absent, " << u.n_control << " control).\n";
}
os << " A reflection file must carry ONE group, so the groups below are reported with the\n"
" lowest-numbered - the one claiming no screw - standing for the set. That is a\n"
" convention, not a measurement: on the axes named here the answer is all of them.\n";
}
if (result.best_space_group.has_value()) {
os << "Best space group: " << SettingName(*result.best_space_group);
for (const auto& alt : result.alternatives)
@@ -723,6 +723,24 @@ struct SearchSpaceGroupResult {
double r_over_best_bound = 0.0;
double r_over_floor_bound = 0.0;
// An axial row whose SCREW these data could not decide: the selected group and at least one of
// its `alternatives` disagree about whether that row carries screw absences, and nothing in this
// merge separated them. The adopted group is then the lowest-numbered member of that set - the
// symmorphic one, by convention and not by measurement - so a report printing only
// best_space_group reads as a determination where the honest answer is "P2 or P2_1, no answer
// possible". The written files still carry one group, because a reflection file cannot hold
// "maybe a screw"; this is what lets the report, the log and a scorer say which axis is open.
// Empty on the usual run, where every screw the data could show was judged.
struct UndeterminedScrew {
std::array<int, 3> row{}; // sign-canonicalised as everywhere else, e.g. {0,0,-1} for 00l
std::string row_label; // the row as a crystallographer names it: "h00", "0k0", "00l"
char axis = ' '; // the axis a screw would run along: 'a', 'b' or 'c'
int n_observed = 0; // reflections of the row in this merge at all - 0 = never recorded
int n_absent = 0; // of those, the ones a claiming candidate predicts absent
int n_control = 0; // ...and the rest of the row, which an absence is judged against
};
std::vector<UndeterminedScrew> undetermined_screws;
// The best SOHNCKE candidate, always, even when a glide plane was confirmed and best_space_group
// therefore names a non-Sohncke group. Both are reported on every run: a chiral (protein) crystal
// cannot have a glide, so a user who knows their sample is a protein must be able to see the