From 626da24429c31e1766bcfbaf90277b1e193f07dd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 03:47:09 +0000 Subject: [PATCH 1/2] Refuse malformed channels and mixed settings in range_check_issues Channels go through channel_indices (no int() coercion of True, 0.9 or '1'); narrow checks of one channel made with different settings raise IncompatibleRangeChecksError naming the planes instead of reporting the first plane's settings for all. Fixes #59. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- docs/contract.md | 5 +- src/morphobricks_normalization/__init__.py | 8 +++- src/morphobricks_normalization/ranges.py | 50 +++++++++++++++++++- tests/test_range_check_settings.py | 53 ++++++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) create mode 100644 tests/test_range_check_settings.py diff --git a/docs/contract.md b/docs/contract.md index 4a1bbc4..70a4d47 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -289,7 +289,10 @@ histogram's passes (table above). A `RangeCheck` holds: `narrow_histogram_range` warning (§9.2) per channel with a narrow plane, listing the planes in `items`, with `channel` set and the fewest central bins, the pixels outside the robust limits and the robust central bins in the message. Only the preprocessing brick reports it, -in its step 3; `fit` adds no warning and its zero-spread error is unchanged (N57). +in its step 3. A channel must be a whole number 0 or above (nothing is rounded or parsed), +and the narrow planes of one channel must share one set of settings (range, central +percentiles, robust quantiles, `min_central_bins`); otherwise `IncompatibleRangeChecksError` +(a `ValueError`) names the planes of each setting (issue #59). `fit` adds no warning and its zero-spread error is unchanged (N57). Parameters fitted from pixels (§4.5) use min-max histograms; the setting applies to `plane_histograms.csv`. diff --git a/src/morphobricks_normalization/__init__.py b/src/morphobricks_normalization/__init__.py index 8adfcff..81f5c1e 100644 --- a/src/morphobricks_normalization/__init__.py +++ b/src/morphobricks_normalization/__init__.py @@ -34,7 +34,12 @@ WeightedHistogram, pooled_statistics, ) -from .ranges import HistogramSettings, RangeCheck, range_check_issues +from .ranges import ( + HistogramSettings, + IncompatibleRangeChecksError, + RangeCheck, + range_check_issues, +) from .records import SINGLE, PlaneRecord, group_records, make_plane_id from .rgb import apply_rgb, rgb_percentile from .scopes import group_key, group_name, scope_groups @@ -52,6 +57,7 @@ "HistogramSettings", "HistogramTail", "ImageSummary", + "IncompatibleRangeChecksError", "IncompatibleSummariesError", "IntensityCheckSettings", "Issue", diff --git a/src/morphobricks_normalization/ranges.py b/src/morphobricks_normalization/ranges.py index 0eb29b5..003de30 100644 --- a/src/morphobricks_normalization/ranges.py +++ b/src/morphobricks_normalization/ranges.py @@ -30,6 +30,7 @@ from ._version import __version__ from .issues import NARROW_HISTOGRAM_RANGE, WARNING, Issue +from .parameter_checks import channel_indices HISTOGRAM_RANGES = ("minmax", "robust") DEFAULT_ROBUST_QUANTILES = (0.01, 99.99) @@ -185,6 +186,43 @@ def narrow(self) -> bool: return self.central_bins < self.min_central_bins +class IncompatibleRangeChecksError(ValueError): + """Raised for narrow range checks of one channel made with different settings. + + Planes checked with another histogram range, central percentiles, robust + quantiles or ``min_central_bins`` measure different things; one warning + cannot describe them, and none of the settings is taken as representative. + """ + + +def _settings_of(check: RangeCheck) -> tuple: + return ( + check.histogram_range, + tuple(check.central_percentiles), + tuple(check.robust_quantiles), + check.min_central_bins, + ) + + +def _check_one_setting(channel: int, found: list[tuple[str, RangeCheck]]) -> None: + """Refuse narrow checks of one channel that were made with different settings.""" + by_settings: dict[tuple, list[str]] = {} + for plane_id, check in found: + by_settings.setdefault(_settings_of(check), []).append(plane_id) + if len(by_settings) > 1: + parts = "; ".join( + f"range={sig[0]}, central percentiles={sig[1]}, robust quantiles={sig[2]}, " + f"min_central_bins={sig[3]}: {', '.join(dict.fromkeys(ids))}" + for sig, ids in sorted(by_settings.items(), key=lambda kv: repr(kv[0])) + ) + raise IncompatibleRangeChecksError( + f"The narrow range checks of channel C{channel} were made with different " + f"settings ({parts}). One warning cannot describe them, and no setting is " + "taken as representative. Check all planes with the same histogram settings, " + "or pass the planes of each setting separately." + ) + + def range_check_issues(checks: Iterable[tuple[str, int, RangeCheck | None]]) -> list[Issue]: """Warnings for the narrow central ranges among ``checks``, one per channel (N60). @@ -200,15 +238,25 @@ def range_check_issues(checks: Iterable[tuple[str, int, RangeCheck | None]]) -> One ``narrow_histogram_range`` warning per channel with at least one narrow plane, in channel order; ``items`` lists the planes and ``channel`` holds the channel. Empty when no range is narrow. + + Raises + ------ + ValueError + For a channel that is not a whole number 0 or above (nothing is rounded + or parsed). + IncompatibleRangeChecksError + For narrow planes of one channel whose checks used different settings. """ by_channel: dict[int, list[tuple[str, RangeCheck]]] = {} for plane_id, channel, check in checks: + (channel,) = channel_indices([channel]) if check is not None and check.narrow: - by_channel.setdefault(int(channel), []).append((plane_id, check)) + by_channel.setdefault(channel, []).append((plane_id, check)) issues = [] for channel, found in sorted(by_channel.items()): planes = list(dict.fromkeys(plane_id for plane_id, _ in found)) first = found[0][1] + _check_one_setting(channel, found) low, high = (_percent(q) for q in first.central_percentiles) q_low, q_high = (_percent(q) for q in first.robust_quantiles) fewest = min(check.central_bins for _, check in found) diff --git a/tests/test_range_check_settings.py b/tests/test_range_check_settings.py new file mode 100644 index 0000000..05ed8c7 --- /dev/null +++ b/tests/test_range_check_settings.py @@ -0,0 +1,53 @@ +"""range_check_issues refuses malformed channels and mixed settings (issue #59).""" + +import numpy as np +import pytest + +from morphobricks_normalization import ( + IncompatibleRangeChecksError, + RangeCheck, + range_check_issues, +) + + +def _narrow(**changes): + values = { + "histogram_range": "minmax", + "central_percentiles": (1.0, 99.8), + "central_bins": 5, + "robust_quantiles": (0.01, 99.99), + "robust_limits": (0, 1), + "outside_robust_count": 3, + "robust_central_bins": 900, + "min_central_bins": 100, + } + return RangeCheck(**{**values, **changes}) + + +@pytest.mark.parametrize( + "change", + [ + {"histogram_range": "robust"}, + {"robust_quantiles": (1.0, 99.0)}, + {"central_percentiles": (2.0, 99.0)}, + {"min_central_bins": 200}, + ], +) +def test_range_check_issues_refuse_mixed_settings_in_either_order(change): + pair = [("A", 0, _narrow()), ("B", 0, _narrow(**change))] + for order in (pair, pair[::-1]): + with pytest.raises(IncompatibleRangeChecksError, match="channel C0.*A.*B|channel C0.*B.*A"): + range_check_issues(order) + # the same settings in another channel are not mixed + assert len(range_check_issues([("A", 0, _narrow()), ("B", 1, _narrow(**change))])) == 2 + + +@pytest.mark.parametrize("channel", [True, 1.0, 0.9, "1", -1, None]) +def test_range_check_issues_refuse_malformed_channels(channel): + with pytest.raises(ValueError, match="whole number counted from 0"): + range_check_issues([("A", channel, _narrow())]) + + +def test_range_check_issues_accept_numpy_integer_channels(): + (issue,) = range_check_issues([("A", np.int64(2), _narrow())]) + assert issue.channel == 2 and type(issue.channel) is int From a533f86c98f5862c1f2d936a328c2071b0ade47a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 03:48:23 +0000 Subject: [PATCH 2/2] Make the #59 regression tests fail on main by behaviour The tests no longer import the new error class: they expect a generic ValueError, so on main they run and fail because the channel is coerced by int() and the first setting is silently taken. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VbytvF3Gsa85ix8Ne2yMDW --- tests/test_range_check_settings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_range_check_settings.py b/tests/test_range_check_settings.py index 05ed8c7..075120d 100644 --- a/tests/test_range_check_settings.py +++ b/tests/test_range_check_settings.py @@ -4,7 +4,6 @@ import pytest from morphobricks_normalization import ( - IncompatibleRangeChecksError, RangeCheck, range_check_issues, ) @@ -36,13 +35,14 @@ def _narrow(**changes): def test_range_check_issues_refuse_mixed_settings_in_either_order(change): pair = [("A", 0, _narrow()), ("B", 0, _narrow(**change))] for order in (pair, pair[::-1]): - with pytest.raises(IncompatibleRangeChecksError, match="channel C0.*A.*B|channel C0.*B.*A"): + with pytest.raises(ValueError, match="channel C0.*A.*B|channel C0.*B.*A") as refused: range_check_issues(order) + assert type(refused.value).__name__ == "IncompatibleRangeChecksError" # the same settings in another channel are not mixed assert len(range_check_issues([("A", 0, _narrow()), ("B", 1, _narrow(**change))])) == 2 -@pytest.mark.parametrize("channel", [True, 1.0, 0.9, "1", -1, None]) +@pytest.mark.parametrize("channel", [True, 1.0, 1.5, "1", -1, None]) def test_range_check_issues_refuse_malformed_channels(channel): with pytest.raises(ValueError, match="whole number counted from 0"): range_check_issues([("A", channel, _narrow())])