From d82f0a4465f5938673e48d14376aba3dcd90ca48 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Aug 2026 14:02:00 +0200 Subject: [PATCH 1/2] fix: guard degenerate ZStackLoop when calculating home index A 1-plane ZStackLoop with dZStep=0 and dZLow == dZHigh (written by NIS-Elements for single-plane acquisitions on systems with a piezo Z device) made _calc_zstack_home_index divide by zero, so .experiment, .metadata, .sizes and .asarray() all raised ZeroDivisionError on otherwise valid 2D files. Return 0 for count <= 1 (which also stops the fallback branch returning -1 for count == 0), and 0 when dZLow == dZHigh, where every plane sits at the same z and no index is more "home" than another. Also replace the `(inverted and home_range_i) or home_range_f` idiom with real ternaries: it silently fell back to the non-inverted range whenever the inverted one was exactly 0.0 (i.e. home == high for types 2/3, or home == low for types 6/7). Fixes #305 Co-Authored-By: Claude Opus 5 --- src/nd2/_parse/_parse.py | 12 ++++++-- tests/test_parse.py | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/nd2/_parse/_parse.py b/src/nd2/_parse/_parse.py index 3f42cc8..f7f8737 100644 --- a/src/nd2/_parse/_parse.py +++ b/src/nd2/_parse/_parse.py @@ -124,18 +124,24 @@ def _calc_zstack_home_index( step_um: float, tol: float = 0.05, ) -> int: + if count <= 1: + return 0 + home_range_f = abs(low_um - home_um) home_range_i = abs(high_um - home_um) if type_ in {2, 3}: - hrange = (inverted and home_range_i) or home_range_f + hrange = home_range_i if inverted else home_range_f elif type_ in {6, 7}: - hrange = (inverted and home_range_f) or home_range_i + hrange = home_range_f if inverted else home_range_i else: return (count - 1) // 2 if step_um <= 0: - return min(int((count - 1) * hrange / abs(high_um - low_um)), count - 1) + z_range = abs(high_um - low_um) + if not z_range: # degenerate loop: every plane at the same z + return 0 + return min(int((count - 1) * hrange / z_range), count - 1) else: return min(int(abs(ceil((hrange - tol * step_um) / step_um))), count - 1) diff --git a/tests/test_parse.py b/tests/test_parse.py index 69319de..26f3a40 100644 --- a/tests/test_parse.py +++ b/tests/test_parse.py @@ -174,3 +174,65 @@ def _clx_int32_field(name: str, val: int) -> bytes: result2 = json_from_clx_lite_variant(chunk2, strip_prefix=False) assert isinstance(result2["Data"], dict) assert result2["Data"]["Answer"] == 42 + + +def test_degenerate_zstack_loop(): + """A 1-plane ZStackLoop with dZStep=0 and dZLow == dZHigh must not divide by zero. + + Regression test for https://github.com/tlambert03/nd2/issues/305 + NIS-Elements writes such a loop for single-plane acquisitions on systems with + a piezo Z device configured. + """ + loop = _parse._parse_z_stack_loop( + { + "uiCount": 1, + "iType": 3, + "bZInverted": True, + "dZHome": 100.0, + "dZLow": 100.0, + "dZStep": 0.0, + "dZHigh": 100.0, + "wsZDevice": "NIDAQ Piezo Z (name: Piezo Z)", + } + ) + assert loop.count == 1 + assert loop.parameters.homeIndex == 0 + assert loop.parameters.stepUm == 0.0 + + +def test_zstack_home_index_zero_range(): + """A multi-plane loop with dZLow == dZHigh has no meaningful home index.""" + assert ( + _parse._calc_zstack_home_index( + False, 3, 3, home_um=100.0, low_um=100.0, high_um=100.0, step_um=0.0 + ) + == 0 + ) + + +@pytest.mark.parametrize("type_", [2, 3]) +def test_zstack_home_index_inverted_at_high(type_: int): + """For types 2/3 an inverted loop uses the distance from dZHigh... + + ...even when that distance is exactly zero. + """ + assert ( + _parse._calc_zstack_home_index( + True, 5, type_, home_um=4.0, low_um=0.0, high_um=4.0, step_um=1.0 + ) + == 0 + ) + + +@pytest.mark.parametrize("type_", [6, 7]) +def test_zstack_home_index_inverted_at_low(type_: int): + """For types 6/7 an inverted loop uses the distance from dZLow... + + ...even when that distance is exactly zero. + """ + assert ( + _parse._calc_zstack_home_index( + True, 5, type_, home_um=0.0, low_um=0.0, high_um=4.0, step_um=1.0 + ) + == 0 + ) From 7b26c0475f300e54c342d941c99edcbfb20b2dbd Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 26 Aug 2026 14:13:44 +0200 Subject: [PATCH 2/2] test: bump minimum xarray to 2023.9.0 for numpy 2 compatibility The ubuntu-latest (3.12) [lowest-direct] job failed at collection with `AttributeError: np.unicode_ was removed in the NumPy 2.0 release`, raised from inside xarray 2023.1.0. Under lowest-direct only *direct* deps are lowered, so xarray drops to its floor while transitive deps stay at their highest: pandas resolves to 3.x, which requires numpy >= 2, and xarray 2023.1.0 still refers to np.unicode_ at import time. pandas 3.x requires python >= 3.11, which is why only the 3.12 job broke and the 3.9/3.10 lowest-direct jobs pass. 2023.9.0 is the first xarray release that imports cleanly under numpy 2, and it still supports python >= 3.9, so the floor can be raised unconditionally. Co-Authored-By: Claude Opus 5 --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6a15bd2..61a2293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,8 @@ test = [ "pytest-pretty>=1.0.0", "pytest>=7.0.0", "tifffile>=2023.4.12", - "xarray>=2023.1.0", + # 2023.9.0 is the first release that works with numpy 2 (np.unicode_ removal) + "xarray>=2023.9.0", ] dev = [ { include-group = "test" },