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
+13
View File
@@ -199,6 +199,19 @@ partner among them — and, on a group that has such a partner, `SPACE_GROUP_ENA
whether the hand is open (`UNDETERMINED`), asserted by the user (`GIVEN`), or taken from an accepted
model (`ASSUMED_FROM_MODEL`).
`SPACE_GROUP_SCREW_UNDETERMINED=` names the **axes whose screw these data could not decide at all**,
as one or more of `a`, `b`, `c`; when every screw the data could show was judged it reads `NONE`
and, like the other keys whose answer is "nothing to report", is written only with `--developer`. It
fires when the axial row a screw lives on was never recorded — it lay in the spindle's blind cone,
or outside the resolution range — so nothing was measured that could confirm or refuse the screw.
On such an axis `SPACE_GROUP_NAME=` is not an answer: the answer is that group **or** any of
`SPACE_GROUP_ALTERNATIVES`, and no measurement in the run chooses between them. What is written to
the `.mtz`/`.cif`/`.hkl` is unchanged — the member claiming no screw, because a reflection file must
carry one group — and the report prints the axis, why the row could not be judged, and what the
alternatives are. To settle it, record the missing row: a different crystal orientation, or a sweep
that reaches it. With `--developer` the space-group section names the same axes in prose beside the
per-zone screw table.
`SOHNCKE_SPACE_GROUP=` in section 2 is written on every run whose space group was determined by the
search; a run given its group with `-S` has no Sohncke candidate to name and omits the key. Where the
search found a glide plane, `SPACE_GROUP_NAME=` names the group with it and this names the best group
@@ -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
+36
View File
@@ -475,6 +475,42 @@ ReportDocument BuildReportDocument(const std::string &output_prefix,
alts += (alts.empty() ? "" : " | ") + alt.xhm();
Add(s, KeyText("SPACE_GROUP_ALTERNATIVES", alts.empty() ? "NONE" : alts));
// ...and WHICH axis is open, which the list of names above does not say. A screw the data
// could not test is not a near miss between two groups - it is a question nobody asked, and
// the group that gets written is the one claiming no screw, by convention. Say the axis, say
// why, and leave SPACE_GROUP_NAME and the written files exactly as they are: a reflection
// file cannot hold "maybe a screw".
std::string undet;
if (result.space_group_search.has_value())
for (const auto &u : result.space_group_search->undetermined_screws)
undet += (undet.empty() ? "" : " ") + std::string(1, u.axis);
Add(s, KeyText("SPACE_GROUP_SCREW_UNDETERMINED", undet.empty() ? "NONE" : undet,
undet.empty()));
if (!undet.empty() && result.space_group_search.has_value()) {
std::string why;
for (const auto &u : result.space_group_search->undetermined_screws)
why += fmt::format(
"\n {}: the {} row {}", u.axis, u.row_label,
u.n_observed == 0
? "holds no reflections in this merge at all, so a screw along this axis\n"
" was never tested - the row was not recorded (it can lie in the "
"spindle's blind\n cone, or outside the resolution range)."
: u.n_control == 0
? fmt::format("holds {} reflection(s) but no control class to judge their\n"
" strength against, so how weak they are says nothing.",
u.n_observed)
: fmt::format("does not separate the candidates ({} absent, {} control).",
u.n_absent, u.n_control));
Add(s, Prose(fmt::format(
" SPACE_GROUP_SCREW_UNDETERMINED names the axes whose SCREW these data could not\n"
" decide at all:{}\n"
" On those axes SPACE_GROUP_NAME is not an answer - the answer is it or any of\n"
" SPACE_GROUP_ALTERNATIVES, and no measurement here chooses. The lowest-numbered\n"
" group, the one claiming no screw, is what the .mtz/.cif/.hkl carry, because a\n"
" reflection file must name one group. To settle it, collect the missing row: a\n"
" different crystal orientation, or a sweep that reaches it.", why)));
}
// The hand is a separate question from the alternatives, decidable from the 22 groups' numbers
// alone. Written only where the group HAS an enantiomorphic partner: on every other group the
// answer is NOT_APPLICABLE, which answers a question nobody asked.
+30
View File
@@ -6353,6 +6353,36 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
logger.Info("{}", msg);
stats_text << " " << msg << "\n\n";
}
// An axis whose screw these data never tested. The group just adopted is then the
// member of a set that claims no screw, picked by convention because a reflection
// file must name one group - and a line saying only "Adopting space group P 6" reads
// as a determination. Say which axis is open and why, here, where the adoption is.
if (!sg_search.undetermined_screws.empty()) {
std::string axes, why;
for (const auto &u : sg_search.undetermined_screws) {
axes += (axes.empty() ? "" : ", ") + std::string(1, u.axis);
why += fmt::format(
"{}the {} row {}", why.empty() ? "" : "; ", u.row_label,
u.n_observed == 0
? std::string("holds no reflections in this merge at all - it was "
"never recorded")
: u.n_control == 0
? fmt::format("holds {} reflection(s) but no control class to "
"judge them against", u.n_observed)
: fmt::format("does not separate the candidates ({} absent, {} "
"control)", u.n_absent, u.n_control));
}
std::string alts;
for (const auto &alt : sg_search.alternatives)
alts += " or " + alt.xhm();
const std::string msg = fmt::format(
"The screw along {} is UNDETERMINED: {}. These data say {}{} and nothing "
"here chooses between them; {} is written because a reflection file must "
"carry one group. To settle it, record the missing row.",
axes, why, sg.xhm(), alts, sg.xhm());
logger.Warning("{}", 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
+48
View File
@@ -446,6 +446,54 @@ TEST_CASE("SearchSpaceGroup names an orthorhombic screw pair on the axes it lies
}
}
// A screw on a row the sweep never recorded is not a group the data refused, it is a question
// nobody asked: the run writes the member claiming no screw because a reflection file must carry
// one group, and without this the only trace of the ambiguity is a list of names that does not say
// which axis is open. Modelled on a real hexagonal set whose 00l row lies in the spindle's blind
// cone and which is reported as P 6 against a deposited P 63.
TEST_CASE("SearchSpaceGroup says which axis a missing row left the screw open on",
"[SearchSpaceGroup]") {
const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("P 21 21 21");
const auto merged = GenerateMergedReflectionsForSpaceGroup(sg, 14);
SearchSpaceGroupOptions opt;
opt.merge_friedel = true;
opt.lattice_system = gemmi::CrystalSystem::Orthorhombic;
SECTION("every row measured - nothing is open") {
const auto result = SearchSpaceGroup(merged, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
CHECK(result.best_space_group->xhm() == "P 21 21 21");
CHECK(result.undetermined_screws.empty());
}
SECTION("the 00l row removed - the c screw is undetermined") {
std::vector<MergedReflection> without_00l;
for (const auto& r : merged)
if (r.h != 0 || r.k != 0)
without_00l.push_back(r);
const auto result = SearchSpaceGroup(without_00l, opt);
INFO(SearchSpaceGroupResultToText(result));
REQUIRE(result.best_space_group.has_value());
REQUIRE(result.undetermined_screws.size() == 1);
CHECK(result.undetermined_screws[0].axis == 'c');
CHECK(result.undetermined_screws[0].row_label == "00l");
CHECK(result.undetermined_screws[0].n_observed == 0);
// The a and b screws were measured and are unaffected: what the missing row costs is the
// third condition, not the two the data still carry.
CHECK(result.best_space_group->operations().is_systematically_absent({{1, 0, 0}}));
CHECK(result.best_space_group->operations().is_systematically_absent({{0, 1, 0}}));
// ...and the group the c row would have decided between is offered, not silently dropped.
bool offers_a_group_without_the_c_screw = false;
for (const auto& alt : result.alternatives)
if (!alt.operations().is_systematically_absent({{0, 0, 1}}))
offers_a_group_without_the_c_screw = true;
CHECK((offers_a_group_without_the_c_screw
|| !result.best_space_group->operations().is_systematically_absent({{0, 0, 1}})));
}
}
// The centring half of the same widening. A, B and C centring on one orthorhombic cell are three
// different lattices, and only C is a reference setting, so an A-centred crystal used to have its
// centring refused (its absent class is not the one C predicts) and came out primitive. The
+14 -2
View File
@@ -23,6 +23,18 @@ scored with `sgequiv`. On the XDS arms only the point group is scored, because X
screw axis. A no-crystal control (`"expect": "no_lattice"`) passes only if no lattice is reported.
`score.py` has the details.
**A question the data could not answer is `unscored`, not a failure.** Two of these. The hand of an
enantiomorphic pair (P4_1 vs P4_3) is not in the intensities at all, and `sgequiv` already counts it
as a match. The other is a **screw axis on a row the sweep never recorded** - the axial row lies in
the spindle's blind cone, or outside the resolution range, so nothing was measured that could
confirm or refuse the screw. rugnux then offers the whole set in `SPACE_GROUP_ALTERNATIVES`, names
the open axis in `SPACE_GROUP_SCREW_UNDETERMINED`, and writes the member claiming no screw, because
a reflection file must carry one group. Such a set scores `unscored` / `screw_undetermined` rather
than `sym_screw`, on three conditions, all necessary: rugnux named the axis, the reference group is
among the ones it offered, and the only axis the two groups differ on is one it named. A screw
called wrongly where the row WAS measured meets none of them and stays a `sym_screw` failure - this
rule excuses an unanswerable question, never a wrong answer.
**Data quality is not scored.** A verdict is about the lattice and the symmetry only. Resolution,
CC1/2, R_meas (overall and lowest shell), completeness, multiplicity and ISa are reported beside
XDS's or the deposition's values, in the per-set tables and plots, for a human to judge. On the XDS
@@ -247,8 +259,8 @@ was run `--unforced`), so its forced XDS-arm rows are left out the same way.
| key | meaning |
|---|---|
| `set`, `arm`, `tags`, `input`, `cmd` | the set's id, arm, population tags, input file, and the exact command that ran |
| `verdict` | `pass`, `fail`, `unscored` (no reference) or `not_run` (input missing) |
| `cause` | why it failed: `crash`, `reader`, `indexing`, `timeout`, `lattice_halved`, `lattice_doubled`, `lattice_other`, `sym_under`, `sym_over`, `sym_screw`, `sym_other`, `false_lattice`; or `no_reference` / `no_input` / `reference_problem` |
| `verdict` | `pass`, `fail`, `unscored` (no reference, or a question the data could not answer) or `not_run` (input missing) |
| `cause` | why it failed: `crash`, `reader`, `indexing`, `timeout`, `lattice_halved`, `lattice_doubled`, `lattice_other`, `sym_under`, `sym_over`, `sym_screw`, `sym_other`, `false_lattice`; or `no_reference` / `no_input` / `reference_problem` / `screw_undetermined` |
| `reason` | one line for a human |
| `sgno`, `sg`, `pg` / `sgno_ref`, `sg_ref`, `pg_ref` | space group number and name, and point group: ours (the data's own determination) / the reference's |
| `sg_label` | the space group rugnux reported where `--model` put the model's enantiomorph on it, else null |
+37
View File
@@ -106,6 +106,23 @@ def cc_half_noise_ratio(cc, cc_xds):
return round((1 / max(cc, 0.001) - 1) / (1 / (cc_xds - XDS_CC_HALF_ROUNDING) - 1), 3)
_AXIS_ROW = {"a": (1, 0, 0), "b": (0, 1, 0), "c": (0, 0, 1)}
def screw_axes_differing(sg, ref_sg):
"""The axes on which two space groups disagree about a screw: the principal row one of them
extinguishes and the other does not.
Only ever asked of two groups of the SAME point group on the same lattice, so their centrings
are the same and a row a centring already kills is killed in both - what is left is the screw."""
out = set()
for ax, hkl in _AXIS_ROW.items():
a, b = (s.operations().is_systematically_absent(hkl) for s in (sg, ref_sg))
if a != b:
out.add(ax)
return out
def point_group(sgno):
return gemmi.find_spacegroup_by_number(int(sgno)).point_group_hm()
@@ -227,6 +244,26 @@ def judge(entry, rep, run_note):
order = len(gemmi.find_spacegroup_by_number(r["sgno"]).operations().sym_ops)
order_ref = len(gemmi.find_spacegroup_by_number(ref["sgno"]).operations().sym_ops)
if r["pg"] == r["pg_ref"]:
# A screw the data never tested is not a wrong answer, it is an unanswerable question:
# the axial row it lives on was not recorded at all (the spindle's blind cone), so the
# run offers every member of the set as an alternative and writes the one claiming no
# screw, because a reflection file must carry one group. Scoring that as a failure
# measures the sweep, not the program - the same reason the hand of an enantiomorphic
# pair is not scored. Three conditions, all necessary: rugnux SAYS the axis is open,
# the reference is among the groups it offered, and the ONLY axis the two differ on is
# one it named. A screw called wrongly where the row WAS measured meets none of them
# and stays a failure.
undetermined = set((rep.get("SPACE_GROUP_SCREW_UNDETERMINED") or "NONE").split())
offered = [gemmi.find_spacegroup_by_name(x.strip())
for x in (rep.get("SPACE_GROUP_ALTERNATIVES") or "").split("|")]
differ = screw_axes_differing(space_group(rep.get("SPACE_GROUP_NAME"), r["sgno"]),
space_group(ref.get("sg"), ref["sgno"]))
if (undetermined != {"NONE"} and differ and differ <= undetermined
and any(o and o.number == ref["sgno"] for o in offered)):
return verdict("unscored", "screw_undetermined",
f"{r['sg']} vs reference {r['sg_ref']}: the screw along "
f"{', '.join(sorted(differ))} is undeterminable from these data "
f"(offered {rep.get('SPACE_GROUP_ALTERNATIVES')})")
return verdict("fail", "sym_screw", f"{r['sg']} vs reference {r['sg_ref']}")
cause = ("sym_under" if order < order_ref else
"sym_over" if order > order_ref else "sym_other")