diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26eb487a0..ab38cce40 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,6 +13,7 @@ * Rugnux keeps the screw axes it found when a higher point group is adopted after the twin-law check, instead of writing the group without screws (e.g. P 4 2 2 for P4_2 2_1 2). * Rugnux drops a rotation reflection whose spot held a saturated (overloaded) pixel, as XDS does, and reports the count as `OBSERVATIONS_REJECTED_OVERLOAD=`. * Rugnux fits a rotation sweep's per-frame scale on the partial reflections, even with few reflections per frame, when scaling from the fulls alone leaves no measurable error model. +* Rugnux no longer refuses a genuine high-symmetry point group because one of its operators agrees with the data unusually well; it refuses a group whose operators split into an agreeing subgroup and a disagreeing rest. * Rugnux names glide planes in groups without a centre of symmetry (e.g. I-42d, Fdd2), reports a group with the same absences (C2/c and Cc) as an alternative, and states how the centre was decided (`SPACE_GROUP_CENTRE`, `CENTRE_STATISTICS_*`). ### 1.0.0-rc.173 diff --git a/image_analysis/scale_merge/SearchSpaceGroup.cpp b/image_analysis/scale_merge/SearchSpaceGroup.cpp index e9c0ef21d..e2a83b6bb 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.cpp +++ b/image_analysis/scale_merge/SearchSpaceGroup.cpp @@ -1394,7 +1394,8 @@ SearchSpaceGroupResult SearchSpaceGroup( // reason as h_ratio: to report the adopted candidate's whether or not it decided. double r_added = std::numeric_limits::quiet_NaN(); double r_over_floor = std::numeric_limits::quiet_NaN(); - // The candidate's OWN operators, worst R over best R (see max_operator_r_spread). + // The widest R gap between a subgroup of the candidate's own and the rest (see + // max_operator_r_spread). double r_spread = std::numeric_limits::quiet_NaN(); // Report-only, for the finalist ledger: the subgroup b the tests below compared // this candidate against, whether it survived every consistency test, and - when @@ -1653,11 +1654,12 @@ SearchSpaceGroupResult SearchSpaceGroup( if (n_new > 0 && pairs_new >= opt.min_pairs_for_h) r_added = r_new / n_new; } - // Homogeneity of the candidate's OWN operators: the worst-agreeing over the best-agreeing, - // over all of them, parent and added alike (see max_operator_r_spread). + // Homogeneity of the candidate's OWN operators, parent and added alike (see + // max_operator_r_spread): the largest factor by which every operator outside a subgroup agrees + // worse than every operator inside it. A subgroup that agrees better than all the rest is a + // prefix of the operators sorted by R, so each prefix that closes into a group is one split. { - double r_lo = std::numeric_limits::infinity(), r_hi = 0.0; - int n_op = 0; + std::vector> by_r; for (const auto &rot : c.pg->rotations) { if (rot.rot == gemmi::Op::identity().rot) continue; @@ -1665,10 +1667,19 @@ SearchSpaceGroupResult SearchSpaceGroup( if (os.n_pairs < opt.min_pairs_per_operator || !std::isfinite(os.r_stat) || os.r_stat <= 0.0) continue; - r_lo = std::min(r_lo, os.r_stat); r_hi = std::max(r_hi, os.r_stat); ++n_op; + by_r.emplace_back(os.r_stat, rot); + } + std::sort(by_r.begin(), by_r.end(), + [](const auto &a, const auto &b) { return a.first < b.first; }); + if (by_r.size() >= 2) { + c.r_spread = 1.0; + std::vector subgroup; + for (size_t k = 1; k < by_r.size(); ++k) { + subgroup.push_back(by_r[k - 1].second); + if (GeneratedRotationSet(subgroup).size() == subgroup.size() + 1) + c.r_spread = std::max(c.r_spread, by_r[k].first / by_r[k - 1].first); + } } - if (n_op >= 2) - c.r_spread = r_hi / r_lo; } c.parent_b_used = parent_b; c.r_added = r_added; @@ -1732,8 +1743,9 @@ SearchSpaceGroupResult SearchSpaceGroup( // operators with false ones. The gates above read summaries - mean H, mean R - so a promotion // that adds two operators at the noise floor together with four several times worse reads a // mean between them and passes, while the group it names is a mixture that cannot be a - // symmetry of one crystal. Asking instead that the candidate's operators agree with EACH OTHER - // sees the mixture directly (see max_operator_r_spread). + // symmetry of one crystal. Asking instead whether the candidate's operators split into a + // subgroup that agrees and a remainder that does not sees the mixture directly (see + // max_operator_r_spread). const bool spread_refused = std::isfinite(c.r_spread) && c.r_spread > opt.max_operator_r_spread; if (spread_refused) consistent = false; @@ -1825,10 +1837,10 @@ SearchSpaceGroupResult SearchSpaceGroup( + "x) - at full resolution the added operator relates reflections that " "disagree far beyond measurement error, i.e. it is not a real symmetry"; else if (spread_refused) - refused_why = "its own operators disagree among themselves - the worst-agreeing " - "reads " + FormatDouble(c.r_spread, 1) - + "x the intensity-weighted R of the best-agreeing one in the SAME " - "group (bound " + FormatDouble(opt.max_operator_r_spread, 1) + refused_why = "its own operators split in two - every operator outside one of its " + "subgroups reads at least " + FormatDouble(c.r_spread, 1) + + "x the intensity-weighted R of every operator inside it (bound " + + FormatDouble(opt.max_operator_r_spread, 1) + "x) - so the group pools operators the data support with operators " "they do not, and cannot be a symmetry of one crystal"; else if (l_refused) diff --git a/image_analysis/scale_merge/SearchSpaceGroup.h b/image_analysis/scale_merge/SearchSpaceGroup.h index 58f087b9b..e4801be60 100644 --- a/image_analysis/scale_merge/SearchSpaceGroup.h +++ b/image_analysis/scale_merge/SearchSpaceGroup.h @@ -488,17 +488,27 @@ struct SearchSpaceGroupOptions { // horizontal 2-folds among the six a 622 promotion adds, and their R at the noise floor buys // the other four a mean well inside the contrast bound. The mixture is invisible to any summary // of the added set and obvious the moment the candidate's operators are compared with EACH - // OTHER: a point group is the claim that all of its operators are symmetries of one crystal, so - // they must agree with the merge about equally well. + // OTHER: a point group is the claim that all of its operators are symmetries of one crystal. // - // Measured over the 129 rotation datasets of one battery run that adopted a group of order 3 or - // more, as worst-agreeing over best-agreeing operator WITHIN the adopted group: every correct - // adoption sits at 1.0-2.64, the highest being the weak cubic crystal whose near-spindle 3-fold - // is unusually clean (the same crystal that motivated the contrast above), then 2.30 for a - // fine-sliced tetragonal one. The one over-called point group on that run reads 4.87. 3.2 sits - // in that gap with 0.56 of headroom below and 1.67 above - deliberately well clear of the - // 2.0-2.6 spread a genuine group reaches, which is exactly where the old ratio-to-best gate's - // bound of 2.0 sat and why that gate had to go. + // What a mixture looks like is specific: the operators that are real form a SUBGROUP (the true + // group's intersection with the candidate), and every operator outside it is false. So the + // statistic is the largest factor by which every operator outside some subgroup reads a worse R + // than every operator inside it - a GAP between a subgroup and the rest. + // + // It was worst-over-best until it was measured to divide by an extreme order statistic, the pole + // the contrast above had already been introduced to remove. Genuine operators do not all agree + // equally well: anisotropic systematic error spreads them in a continuum, widest where a few + // dozen strong reflections dominate R. On a weak cubic crystal that spread alone took + // worst-over-best past the bound once dropping its overloaded reflections made its cleanest + // operator cleaner still, and the genuine group was refused; its widest gap stays near 2. A + // real mixture is a gap and reads about the same either way: a c-axis 2-fold and two horizontal + // ones at 0.04-0.06 under a 622 whose other eight operators read 0.23-0.26 give 3.6 + // (worst-over-best 6.0), and a 222 that adds two false 2-folds to one real one 3.8 either way. + // The gap is never above worst-over-best, so no group admitted before can be refused now. + // + // The bound was calibrated as worst-over-best over the 129 rotation datasets of one battery run + // that adopted a group of order 3 or more: every correct adoption sat at 1.0-2.64 and the one + // over-called point group on that run at 4.87, and 3.2 sits in that gap. double max_operator_r_spread = 3.2; // Above this reduced chi^2 for the best subgroup (chi2_ref), the merged error model is treated as @@ -724,7 +734,7 @@ struct PointGroupLedgerEntry { double r_over_best = std::numeric_limits::quiet_NaN(); // ... over the best-agreeing operator anywhere double r_contrast = std::numeric_limits::quiet_NaN(); // ... 0 = unrelated reflections, 1 = that best operator double r_over_floor = std::numeric_limits::quiet_NaN(); // ... over the merge's random-noise R floor - double r_spread = std::numeric_limits::quiet_NaN(); // this group's own worst operator R over its best + double r_spread = std::numeric_limits::quiet_NaN(); // widest R gap between a subgroup of its own and the rest bool adopted = false; // this is the group the search took bool eligible = false; // it passed every consistency test (so it COULD have been taken) std::string refused_reason; // why it did not, when it was the highest refusal