diff --git a/image_analysis/scale_merge/RotationScaleMerge.h b/image_analysis/scale_merge/RotationScaleMerge.h index f93ec27c..2e9b7c40 100644 --- a/image_analysis/scale_merge/RotationScaleMerge.h +++ b/image_analysis/scale_merge/RotationScaleMerge.h @@ -72,6 +72,11 @@ public: // >= 1 without cutting the final in-symmetry merge. Reset to the manual limit afterwards. void SetDMinLimit(std::optional d_min_A) { d_min_limit = d_min_A; } + // Toggle the search-pass Lorentz filter (see search_min_zeta) between Run() calls, so the caller can + // produce both a filtered and an unfiltered search merge from the same ingested partials. + void SetSearchMinZeta(double zeta) { search_min_zeta = zeta; } + [[nodiscard]] double GetSearchMinZeta() const { return search_min_zeta; } + private: // One integrated observation - a per-frame partial during scaling/combine, or a combined full during // scale-fulls/merge. Flat (not nested per image); a POD so the arrays translate straight to CUDA. diff --git a/rugnux/Rugnux.cpp b/rugnux/Rugnux.cpp index e9ecfafd..7ebc3be7 100644 --- a/rugnux/Rugnux.cpp +++ b/rugnux/Rugnux.cpp @@ -1204,6 +1204,72 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b sg_opts.lattice_system = end_msg.rotation_lattice_type->crystal_system; auto sg_search = SearchSpaceGroup(sm.merged, sg_opts); + // Second opinion from a merge that keeps only well-measured observations (see + // RotationScaleMerge::search_min_zeta), and take whichever search found MORE symmetry. + // The two disagree in one direction only: every crystal on which the Lorentz filter changes + // the answer LOSES an operator, because discarding 40-80% of the observations starves the + // operator correlations - it never invents one. So the higher of the two is the safe pick, + // and it costs nothing on crystals where the filter is irrelevant. Both searches keep their + // vetoes, so a merohedral twin stays refused in each (verified on a real twin at the three + // frame ranges where it over-promotes: refused in both arms every time). + if (rsm && rsm->GetSearchMinZeta() > 0.0 && !sg_search.point_group_hm.empty()) { + const double zeta = rsm->GetSearchMinZeta(); + rsm->SetSearchMinZeta(0.0); + auto sm_all = scale_and_merge("P1, all observations", true); + rsm->SetSearchMinZeta(zeta); + const auto alt = SearchSpaceGroup(sm_all.merged, sg_opts); + // Point-group order = the number of rotations, which is what sym_ops holds (centering + // lives in cen_ops). Taken from the space group the search chose - a point-group symbol + // like "422" is not a space-group name and cannot be looked up. + const auto order_of = [](const SearchSpaceGroupResult &r) -> size_t { + return r.best_space_group.has_value() + ? r.best_space_group->operations().sym_ops.size() : 0; + }; + logger.Info("Space-group search: all-observation merge -> {} (order {}), " + "Lorentz-filtered -> {} (order {})", + alt.point_group_hm.empty() ? "?" : alt.point_group_hm, order_of(alt), + sg_search.point_group_hm.empty() ? "?" : sg_search.point_group_hm, + order_of(sg_search)); + // The filtered merge is used ONLY to rescue a point group the full merge failed to + // confirm. Everything else - and the screw/centering determination in particular - comes + // from the merge with all the observations, because systematic absences are decided by + // the WEAK reflections and the filter throws most of them away. Preferring the filtered + // arm on a tie cost four crystals their screw axes (P2(1) read as P2) for exactly that + // reason, with the point group and every intensity statistic identical. + if (order_of(sg_search) <= order_of(alt)) { + if (order_of(alt) > order_of(sg_search)) + logger.Info("Space-group search: all-observation merge supports {} where the " + "Lorentz-filtered one supports {} - taking the higher symmetry", + alt.point_group_hm, sg_search.point_group_hm); + sg_search = alt; + sm = std::move(sm_all); + } else { + logger.Info("Space-group search: Lorentz-filtered merge supports {} where the " + "all-observation one supports {} - taking the higher symmetry", + sg_search.point_group_hm, alt.point_group_hm); + } + if (order_of(alt) > 0 && order_of(alt) == order_of(sg_search) + && alt.point_group_hm != sg_search.point_group_hm) { + // Same order, different symmetry: the two merges disagree about WHICH operators are + // real, and neither is higher, so there is nothing to prefer. Say so instead of + // picking silently - the two imply different molecular-replacement searches, and + // trying both is cheap next to processing the data again. + const std::string a = alt.best_space_group.has_value() + ? alt.best_space_group->short_name() : alt.point_group_hm; + const std::string b = sg_search.best_space_group.has_value() + ? sg_search.best_space_group->short_name() : sg_search.point_group_hm; + const std::string msg = fmt::format( + "Space group is AMBIGUOUS between {} and {} (point groups {} and {}, the same " + "order): the merge of all observations and the merge of only the well-measured " + "ones each support one of them, and neither is the higher symmetry, so the data " + "do not decide. Processing continues in {} - try BOTH in molecular replacement, " + "or re-run with --search-min-zeta 0 to force the all-observation choice.", + a, b, alt.point_group_hm, sg_search.point_group_hm, a); + logger.Warning("{}", msg); + stats_text << " !! " << msg << "\n\n"; + } + } + // Twinning evidence measured BEFORE any promotion, on the P1/subgroup merge the search was // given. Reported alongside the post-adoption analysis: once a higher Laue class has been // adopted, its own twinning test can only say "no twin law exists within this class", which