From e5b69647c5666deb8a28592afb6bb0fd38a461ac Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 13 Oct 2026 14:43:44 +0200 Subject: [PATCH] Space group: a screw one axial reflection could not confirm is reported open A screw candidate whose absent class no reflection contradicts, refused only because its evidence could not reach min_screw_absence_evidence even with every predicted-absent axial reflection at the floor (one such reflection reaches 6.9 nats; a row without a control class reaches nothing), left the symmorphic group adopted and the report saying the group follows from the absences. Such a row is now SPACE_GROUP_SCREW_UNDETERMINED, with the screw group in SPACE_GROUP_ALTERNATIVES. Report only: the result is kept apart (unreached_screws) and folded in after every decision, so the written group and files do not change. Measured on a monoclinic set whose merge holds five 0k0 reflections at noise level, one of them predicted absent: P2 was reported as decided by the absences against a deposited P21; it is now P2 with the b screw open and P21 offered. Over the full battery's candidate tables the condition holds on that set and one private set (the same P2/P21 case) only. ci tier: no change. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3 --- .../scale_merge/SearchSpaceGroup.cpp | 46 +++++++++++++++++++ image_analysis/scale_merge/SearchSpaceGroup.h | 8 ++++ rugnux/RugnuxScaleMerge.cpp | 19 ++++++++ tests/SearchSpaceGroupTest.cpp | 38 +++++++++++++++ 4 files changed, 111 insertions(+) diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp index 7eb93b418..59a9c3630 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.cpp +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -2935,6 +2935,52 @@ SearchSpaceGroupResult SearchSpaceGroup( } } + // A screw no axial reflection here could have confirmed (unreached_screws). The winner claims no + // screw and no glide; the candidate differs from it only by screws - same centring class, no glide - + // no reflection of its absent classes is a violation, and its evidence could not have reached the + // claim bar even with every predicted-absent reflection at the floor. + if (result.best_space_group.has_value() && result.candidates.front().screw_absent == 0 + && result.candidates.front().glide_absent == 0) { + const SpaceGroupCandidateScore& w = result.candidates.front(); + for (const auto& s : result.candidates) { + if (s.selected || !s.consistent || s.screw_absent == 0 + || s.glide_absent != 0 || s.space_group.centring_type() != w.space_group.centring_type() + || s.centering_absence_evidence != w.centering_absence_evidence) + continue; + int scored = 0; + double reach = 0.0; + for (const auto& z : s.screw_zones) + if (z.n_control > 0) { + scored += z.n_absent; + reach = std::max(reach, ScrewZoneEvidence(0.0, z.n_absent)); + } + reach = std::max(reach, ScrewZoneEvidence(0.0, scored)); + if (reach >= opt.min_screw_absence_evidence) + continue; + result.unreached_screw_groups.push_back(s.space_group); + result.unreached_screw_groups.insert(result.unreached_screw_groups.end(), s.same_absences.begin(), + s.same_absences.end()); + const gemmi::GroupOps g = s.space_group.operations(); + 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]}}; + if (!g.is_systematically_absent(hkl) || CenteringAbsent(g, hkl) + || std::any_of(result.unreached_screws.begin(), result.unreached_screws.end(), + [&](const auto& u) { return u.row == row; })) + continue; + int n_absent = 0, n_control = 0; + for (const auto& z : s.screw_zones) + if (z.row == row) { + n_absent = z.n_absent; + n_control = z.n_control; + } + const auto it = axial_row_observed.find(row); + result.unreached_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 diff --git a/image_analysis/scale_merge/SearchSpaceGroup.h b/image_analysis/scale_merge/SearchSpaceGroup.h index 71d0b5ff6..2e5042e8e 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.h +++ b/image_analysis/scale_merge/SearchSpaceGroup.h @@ -877,6 +877,14 @@ struct SearchSpaceGroupResult { int n_control = 0; // ...and the rest of the row, which an absence is judged against }; std::vector undetermined_screws; + // A screw the symmorphic winner claims no screw against only because nothing here could have shown + // one: a candidate whose absences no axial reflection contradicts, refused for want of evidence that + // its axial rows could not have reached however dead they were (one predicted-absent reflection + // reaches 6.9 nats, under min_screw_absence_evidence; one without a control class reaches nothing). + // Its rows, and the candidate with the groups that predict its absences. Report-only: kept apart + // from undetermined_screws and alternatives, which decisions downstream read. + std::vector unreached_screws; + std::vector unreached_screw_groups; // 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 diff --git a/rugnux/RugnuxScaleMerge.cpp b/rugnux/RugnuxScaleMerge.cpp index cbf45a967..dd497a6ba 100644 --- a/rugnux/RugnuxScaleMerge.cpp +++ b/rugnux/RugnuxScaleMerge.cpp @@ -2997,6 +2997,25 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) { WarnIfMetricHostsMoreSymmetry(result.consensus_cell, result.space_group.value_or(gemmi::get_spacegroup_p1())); } + // A screw no axial reflection here could have shown (SearchSpaceGroupResult::unreached_screws), where the + // group that search chose is the one adopted: the screw is open, as on a row never recorded. Report only - + // nothing above read it. + if (!sg_search.unreached_screws.empty() && result.space_group && !sg_search.candidates.empty() + && sg_search.candidates.front().space_group.xhm() == result.space_group->xhm()) { + std::string axes, alts; + for (const auto &u : sg_search.unreached_screws) { + axes += (axes.empty() ? "" : ", ") + std::string(1, u.axis); + sg_search.undetermined_screws.push_back(u); + } + for (const auto &g : sg_search.unreached_screw_groups) { + alts += " or " + g.xhm(); + sg_search.alternatives.push_back(g); + } + logger.Warning("The screw along {} is UNDETERMINED: its axial row holds too few reflections a screw would " + "extinguish to show one, however weak they are, and none that contradicts it. These data " + "say {}{}; {} is written because a reflection file must carry one group.", + axes, result.space_group->xhm(), alts, result.space_group->xhm()); + } result.space_group_search = sg_search; // Was a twin law possibly absorbed? Where a twin gate refused the adopted group and was diff --git a/tests/SearchSpaceGroupTest.cpp b/tests/SearchSpaceGroupTest.cpp index 27544da52..026ad2b77 100644 --- a/tests/SearchSpaceGroupTest.cpp +++ b/tests/SearchSpaceGroupTest.cpp @@ -551,6 +551,44 @@ TEST_CASE("SearchSpaceGroup says which axis a missing row left the screw open on } } +// One predicted-absent axial reflection cannot confirm a screw however weak it is (it reaches 6.9 nats, +// under the claim bar), so a symmorphic group adopted against it is a default, not a determination: +// the screw is reported open, beside the decision, which it does not touch. +TEST_CASE("SearchSpaceGroup leaves a screw open that one axial reflection could not confirm", + "[SearchSpaceGroup]") { + const gemmi::SpaceGroup& sg = gemmi::get_spacegroup_by_name("P 1 21 1"); + const auto merged = GenerateMergedReflectionsForSpaceGroup(sg, 12); + + SearchSpaceGroupOptions opt; + opt.merge_friedel = true; + + SECTION("the whole 0k0 row - the screw is claimed") { + const auto result = SearchSpaceGroup(merged, opt); + INFO(SearchSpaceGroupResultToText(result)); + REQUIRE(result.best_space_group.has_value()); + CHECK(result.best_space_group->xhm() == "P 1 21 1"); + CHECK(result.unreached_screws.empty()); + } + + SECTION("0k0 holds k = 2, 3, 4 only - the screw is open") { + std::vector short_row; + for (const auto& r : merged) + if (r.h != 0 || r.l != 0 || (std::abs(r.k) >= 2 && std::abs(r.k) <= 4)) + short_row.push_back(r); + const auto result = SearchSpaceGroup(short_row, opt); + INFO(SearchSpaceGroupResultToText(result)); + REQUIRE(result.best_space_group.has_value()); + CHECK(result.best_space_group->xhm() == "P 1 2 1"); + REQUIRE(result.unreached_screws.size() == 1); + CHECK(result.unreached_screws[0].axis == 'b'); + CHECK(result.unreached_screws[0].n_absent == 1); + REQUIRE(result.unreached_screw_groups.size() == 1); + CHECK(result.unreached_screw_groups[0].xhm() == "P 1 21 1"); + CHECK(result.undetermined_screws.empty()); + CHECK(result.alternatives.empty()); + } +} + // 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