Anisotropy verdict: the directional limits are a second hypothesis beside the tensor

The verdict gated only deltaB_linear, the Debye-Waller part of the tensor, against the data set's own
systematic floor. A deficit that does not follow s^2 (a FLAT signature) can stop the signal far earlier
in one direction and still read NOT_DETECTED: one open-arm set reported directional limits of
3.18/3.18/2.14 A beside NOT_DETECTED. The cone limits (<I/sigma> = 2 in 20 deg cones) were computed but
never consulted.

Now each statement is decided on its own evidence and either establishes anisotropy:
- tensor: unchanged gate;
- directional limits: at the best direction's limit the weakest cone's <I/sigma> is below the best
  cone's by more than 3.5 of their joint standard error (both read linearly in s^2 between shell
  centres, as the limits are), the weakest limit is measured (not censored at the data edge), and the
  limits are at least 0.25 A apart - the spread the report already calls "noticeable".

Why the magnitude term: with the standard errors alone (the counting statistics of the cones) the
weakest cone is "significantly" below the best on 172 of 216 battery sets, down to limits 0.5% apart
(measured with a replica of ConeLimits on the a897b08 battery's merged data). The cones differ
systematically (multiplicity and coverage depend on direction), so significance alone made DETECTED
near-universal. A refusal (CANNOT_DETERMINE: dose ramp, short sweep, triclinic, no unmerged data) is not
overruled - its reasons bias the cones too - but now reports the measured directional limits.

Report: ANISOTROPY_DETECTED_BY (TENSOR/DIRECTIONAL/BOTH/NONE); developer keys for the two cones'
<I/sigma> and errors; the summary row names the test that carried the verdict, and CANNOT_DETERMINE
rows give the directional limits; the mmCIF verdict comment says so. Report only: no intensity, flag or
decision downstream reads the verdict (the French-Wilson prior uses the tensor regardless).

Measured (battery replica, 225 sets with limits): directional statement established on 95; 23
NOT_DETECTED -> DETECTED (directional ratio 1.14-1.59); of the 17 sets with ratio > 1.2 and an
uncensored weakest limit, 11 flip, 5 stay CANNOT_DETERMINE with limits reported, 1 (sparse, noisy
cones) stays NOT_DETECTED. ANISOTROPY warnings (spread > 0.5 A) appear on 6 more sets. Confirmed with
rugnux runs on the ci tier, those 17 and the private arm: p.hkl and MTZ data identical to the base
(e34a5fb88) on every set compared.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-11 02:21:32 +02:00
co-authored by Claude Opus 5.5
parent e34a5fb88f
commit fe181ca042
5 changed files with 149 additions and 22 deletions
+23
View File
@@ -157,6 +157,29 @@ TEST_CASE("Anisotropy of an isotropic synthetic merge is small", "[anisotropy]")
CHECK(std::fabs(result.delta_b) < 1.0);
}
// The directional limits are a second hypothesis beside the tensor: the signal stops earlier along c*
// than along a* and b*, and the cones see it with no model of the fall-off. Without unmerged
// observations the tensor is refused, and a refusal stands - the cones overrule only NOT_DETECTED -
// but the directional statement is still made and reported.
TEST_CASE("Anisotropy directional limits establish a difference the cones measure", "[anisotropy]") {
const gemmi::UnitCell cell = Cell(79, 79, 38, 90, 90, 90);
const gemmi::SpaceGroup *sg = gemmi::find_spacegroup_by_name("P 43 21 2");
// B 70 A^2 along a*/b* and 100 A^2 along c*: <I/sigma> = 2 near 2.4 A and 2.8 A respectively.
const auto merged = SyntheticMerge(cell, sg, 2.0, 80.0, {-10.0, -10.0, 20.0, 0.0, 0.0, 0.0});
const auto result = AnalyzeAnisotropy(merged, {}, cell, sg);
REQUIRE(result.n_cells > 0);
REQUIRE(std::isfinite(result.weak_cone_i_over_sigma));
CHECK(result.d_min_spread > ANISOTROPY_SPREAD_NOTICEABLE_A);
CHECK(result.weak_cone_i_over_sigma < result.best_cone_i_over_sigma);
CHECK(result.directional_detected);
CHECK_FALSE(result.tensor_detected);
CHECK(result.verdict == AnisotropyVerdict::CannotDetermine);
// The same fall-off in every direction: the limits agree and nothing is established.
const auto isotropic = AnalyzeAnisotropy(SyntheticMerge(cell, sg, 2.0, 80.0, {0, 0, 0, 0, 0, 0}), {}, cell, sg);
CHECK_FALSE(isotropic.directional_detected);
}
namespace {
// One partial of reflection (h,k,l) on image `frame`, with the fields ScaledObservations reads.
Reflection Partial(int h, int k, int l, float frame, float I, float partiality) {