diff --git a/src/morphobricks_normalization/normalization.py b/src/morphobricks_normalization/normalization.py index cfc0df3..747ee76 100644 --- a/src/morphobricks_normalization/normalization.py +++ b/src/morphobricks_normalization/normalization.py @@ -99,7 +99,14 @@ def robust_z_score( def _parameters(method: str, offset: float, scale: float) -> NormalizationParameters: - if not np.isfinite(scale) or scale <= 0: + """Make the parameters, refusing a scale that is not finite or not positive, by cause.""" + if not np.isfinite(scale): + raise ValueError( + f"The {method} normalization cannot be used for this group: its intensity spread " + f"is {scale!r}, not a finite number, because the pixel values are too large for " + "float64 sums of squares (overflow). Choose another method or a different range." + ) + if scale <= 0: raise ValueError( f"The {method} normalization cannot be used for this group: its intensity spread " "is zero (all pixels have the same value). Choose a larger group or another method." diff --git a/src/morphobricks_normalization/pooling.py b/src/morphobricks_normalization/pooling.py index 5ee29a1..32d3323 100644 --- a/src/morphobricks_normalization/pooling.py +++ b/src/morphobricks_normalization/pooling.py @@ -361,7 +361,29 @@ def pooled_statistics( def _mean_with_tails( values: np.ndarray, weights: np.ndarray, tails: list[tuple[float, float, float]], total: float ) -> float: - return (weighted_sum(weights, values) + math.fsum(s for _, s, _ in tails)) / total + sums = [s for _, s, _ in tails] + if not all(math.isfinite(s) for s in sums): + # math.fsum would raise a bare ValueError or OverflowError from the standard library. + raise ValueError(_non_finite_tail_message(sums)) + return (weighted_sum(weights, values) + math.fsum(sums)) / total + + +def _non_finite_tail_message(sums: list[float]) -> str: + """Refusal text for tail sums that are infinite or NaN, naming each such tail.""" + bad = ", ".join( + f"tail {number} of {len(sums)} has sum {s!r}" + for number, s in enumerate(sums, 1) + if not math.isfinite(s) + ) + opposing = any(s == math.inf for s in sums) and any(s == -math.inf for s in sums) + reason = ( + "infinite sums of opposite sign have no mean" if opposing else "a sum that is not finite" + ) + return ( + f"The mean cannot be pooled: {bad} ({reason}). The pixels outside the robust range " + "are too large for float64 sums. Use a method that does not need the mean, or " + "another range." + ) def _std_with_tails( diff --git a/tests/test_normalization.py b/tests/test_normalization.py index 5a2eb52..4c3d616 100644 --- a/tests/test_normalization.py +++ b/tests/test_normalization.py @@ -56,3 +56,11 @@ def test_zero_spread_is_rejected_with_a_clear_message(): for method in (min_max, z_score, robust_z_score): with pytest.raises(ValueError, match="spread is zero"): method(constant) + + +def test_non_finite_scale_is_refused_as_overflow_not_zero_spread(): + plane = np.array([[-1.7e308, 1.7e308], [-1.7e308, 1.7e308]]) + pooled = PooledHistogram([compute_plane_histogram(plane)]) + with pytest.raises(ValueError, match="not a finite number") as caught: + z_score(pooled) + assert "spread is zero" not in str(caught.value) diff --git a/tests/test_pooling.py b/tests/test_pooling.py index a2555a5..b825989 100644 --- a/tests/test_pooling.py +++ b/tests/test_pooling.py @@ -9,8 +9,11 @@ pooled_statistics, ) from morphobricks_normalization.pooling import DEFAULT_PERCENTILES +from morphobricks_normalization.ranges import HistogramSettings from simulation import direct_stats, make_records, simulate_experiment +ROBUST = HistogramSettings(histogram_range="robust", robust_quantiles=(1.0, 99.0)) + LEVELS = ("image", "technical_repeat", "biological_repeat", "condition") @@ -83,3 +86,17 @@ def test_percentile_limits(): assert pooled.percentile(0) == 0 and pooled.percentile(100) == 9 with pytest.raises(ValueError): pooled.percentile(101) + + +def test_opposing_infinite_tail_sums_are_a_named_refusal(): + # Float64 pixels at both ends overflow the tails' sums to +inf and -inf; the mean then + # needs a refusal that says why, not a bare error from math.fsum. + plane = np.full((80, 80), 1.0) + plane.ravel()[:5] = -1.7e308 + plane.ravel()[5:10] = 1.7e308 + plane.ravel()[10:] = np.linspace(0, 10, 6390) + histogram = compute_plane_histogram(plane, settings=ROBUST) + assert histogram.underflow.sum == -np.inf and histogram.overflow.sum == np.inf + with pytest.raises(ValueError, match="opposite sign") as caught: + _ = PooledHistogram([histogram]).mean + assert "tail 1 of 2" in str(caught.value) and "tail 2 of 2" in str(caught.value)