report: name the groups the data could not separate, as keys

SPACE_GROUP_NAME is a scalar and reads like a determination, and the docs
nominate a grep of it as the interface. Where several groups predict the same
absences it is one of them, picked by convention: the report said so in prose
and nothing machine-readable carried it, so a script recorded a coin flip as an
answer. Refining against a deposited model in the wrong enantiomorph gives
R = 0.549.

Four keys, none of which change which group is adopted: the alternatives the
data cannot separate; whether the enantiomorph was determined, given, or is
undetermined - decidable from the group number alone, so it is answered even
where the search did not run; and the higher point group whose promotion was
refused, with its reason, which until now existed only as prose.

REPORT_VERSION is 5. No existing key changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-02 09:18:22 +02:00
co-authored by Claude Opus 5
parent 247130a841
commit 200a2d53b7
3 changed files with 55 additions and 2 deletions
+1
View File
@@ -11,6 +11,7 @@
* `rugnux` leaves an event out of the unmerged MTZ when less of its rocking curve was captured than `--min-captured-fraction`, as the merge does, since the exported rows are declared full.
* `rugnux` writes `FreeR_flag` with 0 for the test set and 1 for the working set, the CCP4 convention REFMAC5 defaults to; it was the other way round.
* `rugnux -S` refuses or re-seats a fixed space group whose symmetry axes the indexed cell does not carry, not only one whose centring differs; `--mode scale` refuses it too.
* The rugnux results report names the groups the data cannot separate, the enantiomorph state and any refused higher point group as `SPACE_GROUP_ALTERNATIVES`, `SPACE_GROUP_ENANTIOMORPH`, `SPACE_GROUP_REFUSED_POINT_GROUP` and `SPACE_GROUP_REFUSED_REASON`; `REPORT_VERSION` is 5.
* `rugnux` handles symmetry better: the lattice, the point group, the setting and the systematic absences.
* `rugnux` fits the direction of the goniometer axis and not its length, so the cell chosen by the first pass is the one its own refinement scored.
* `rugnux` reports the detector geometry it measured - the direct beam, the tilt and the beam centre - and what a single sweep can and cannot determine.
+48 -1
View File
@@ -20,7 +20,7 @@
namespace {
// The version of this file format. Bumped when a key is renamed or removed, a table column moves,
// or a reason code changes meaning - a consumer can gate on it.
constexpr int REPORT_VERSION = 4;
constexpr int REPORT_VERSION = 5;
const char *BANNER = " ******************************************************************************";
@@ -231,6 +231,53 @@ std::string RenderResultReport(const std::string &output_prefix,
// ---------------------------------------------- 4. SPACE GROUP DETERMINATION
Section(os, "4. SPACE GROUP DETERMINATION");
// What the data could NOT decide, as keys rather than only as prose. SPACE_GROUP_NAME above is a
// scalar and reads like a determination; where several groups predict the same absences it is one
// of them, chosen by convention. A script that greps only the name records a coin-flip as an
// answer - measured, refining against the deposited model in the wrong enantiomorph gives
// R = 0.549 - so the ambiguity has to survive the same grep.
{
std::string alts;
if (result.space_group_search.has_value() && result.space_group.has_value())
for (const auto &alt : result.space_group_search->alternatives)
if (alt.number != result.space_group->number)
alts += (alts.empty() ? "" : " | ") + alt.xhm();
Key(os, "SPACE_GROUP_ALTERNATIVES", alts.empty() ? "NONE" : alts);
// The hand is a separate question from the alternatives list, and it is decidable from the
// 22 groups' numbers alone, so it is answered even where the search did not run. Merged
// intensities never decide it: an enantiomorphic pair has the same absences and the same
// Laue class, and only a model, a substructure or an anomalous signal names the hand.
const char *enantiomorph = "NOT_APPLICABLE";
if (result.space_group.has_value() && result.space_group->is_enantiomorphic()) {
if (result.model_validation.has_value() && result.model_validation->ok)
enantiomorph = "DETERMINED_FROM_MODEL";
else if (!result.space_group_search.has_value())
enantiomorph = "GIVEN"; // -S, or a reference MTZ: the user's assertion
else
enantiomorph = "UNDETERMINED";
}
Key(os, "SPACE_GROUP_ENANTIOMORPH", enantiomorph);
// A higher point group whose operators the intensities confirmed and whose promotion was
// refused. It exists only as prose in the search text below, so nothing can act on it - and
// a refusal is exactly the case where a user might want to try the higher group as well.
const bool refused = result.space_group_search.has_value()
&& !result.space_group_search->refused_point_group_hm.empty();
Key(os, "SPACE_GROUP_REFUSED_POINT_GROUP",
refused ? result.space_group_search->refused_point_group_hm : std::string("NONE"));
if (refused)
Key(os, "SPACE_GROUP_REFUSED_REASON", result.space_group_search->refused_reason);
os << "\n SPACE_GROUP_ALTERNATIVES names every group these data cannot separate from the one\n"
<< " adopted (enantiomorphic partners, origin-ambiguous pairs, groups a gap in the data\n"
<< " leaves untested); NONE means the absences single the answer out. SPACE_GROUP_ENANTIOMORPH\n"
<< " is UNDETERMINED whenever the group is one of the 22 that come in enantiomorphic pairs and\n"
<< " nothing outside the merged intensities named the hand - the intensities cannot, so this\n"
<< " is the normal outcome, not a failure. SPACE_GROUP_REFUSED_POINT_GROUP is a higher point\n"
<< " group the operator correlations supported and the consistency tests would not take.\n";
}
if (result.space_group_search.has_value()) {
Key(os, "SPACE_GROUP_SEARCH", "DE_NOVO");
os << "\n" << SearchSpaceGroupResultToText(*result.space_group_search) << "\n";
+6 -1
View File
@@ -69,10 +69,15 @@ TEST_CASE("ResultReport_Render", "[Diagnostics]") {
const auto text = RenderResultReport("prefix", "in.h5", x, result);
// The stable keys a consumer greps for.
CHECK(text.find("\nREPORT_VERSION= 4\n") != std::string::npos);
CHECK(text.find("\nREPORT_VERSION= 5\n") != std::string::npos);
CHECK(text.find("\nOUTPUT_PREFIX= prefix\n") != std::string::npos);
CHECK(text.find("\nINDEXING_RATE= 0.8700\n") != std::string::npos);
CHECK(text.find("\nSPACE_GROUP_NUMBER= 96\n") != std::string::npos);
// What the data could NOT decide, beside what they did. The group here was given rather
// than searched for, and it is one of the 22 that come in enantiomorphic pairs.
CHECK(text.find("\nSPACE_GROUP_ALTERNATIVES= NONE\n") != std::string::npos);
CHECK(text.find("\nSPACE_GROUP_ENANTIOMORPH= GIVEN\n") != std::string::npos);
CHECK(text.find("\nSPACE_GROUP_REFUSED_POINT_GROUP= NONE\n") != std::string::npos);
CHECK(text.find("\nSWEEP_QUALITY_STATUS= COMPUTED\n") != std::string::npos);
CHECK(text.find("\nSWEEP_QUALITY_COUNT= 2\n") != std::string::npos);
CHECK(text.find("\nSWEEP_QUALITY_REASONS= no_diffraction crystal_out_of_beam weak_diffraction "