From 4cd3030f147a60d1f2472993789021dd25e8667c Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Thu, 22 Oct 2026 14:25:36 -0700 Subject: [PATCH 1/2] Respect inclusive endpoints when constructing position windows --- allel/stats/window.py | 12 +++---- allel/test/test_position_windows.py | 51 +++++++++++++++++++++++++++++ allel/test/test_stats.py | 8 ++--- 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 allel/test/test_position_windows.py diff --git a/allel/stats/window.py b/allel/stats/window.py index c6eb37ee..69775096 100644 --- a/allel/stats/window.py +++ b/allel/stats/window.py @@ -97,6 +97,9 @@ def position_windows(pos, size, start, stop, step): """Convenience function to construct windows for the :func:`windowed_statistic` and :func:`windowed_count` functions. + Both endpoints are inclusive. The final window is clipped to `stop` + and never exceeds `size` bases. + """ last = False @@ -110,17 +113,14 @@ def position_windows(pos, size, start, stop, step): step = size windows = [] - for window_start in range(start, stop, step): + for window_start in range(start, stop + 1, step): - # determine window stop - window_stop = window_start + size + # Both endpoints are inclusive, so a full window spans size bases. + window_stop = window_start + size - 1 if window_stop >= stop: # last window window_stop = stop last = True - else: - window_stop -= 1 - windows.append([window_start, window_stop]) if last: diff --git a/allel/test/test_position_windows.py b/allel/test/test_position_windows.py new file mode 100644 index 00000000..9edeb716 --- /dev/null +++ b/allel/test/test_position_windows.py @@ -0,0 +1,51 @@ +"""Regressions for inclusive physical-position window boundaries (#363).""" +import numpy as np +import pytest +from numpy.testing import assert_array_equal + +import allel + + +@pytest.mark.parametrize('size,step,expected', [ + (1, None, [[1, 1], [2, 2], [3, 3], [4, 4]]), + (3, None, [[1, 3], [4, 4]]), + (2, None, [[1, 2], [3, 4]]), + (5, None, [[1, 4]]), + (2, 1, [[1, 2], [2, 3], [3, 4]]), + (2, 3, [[1, 2], [4, 4]]), +]) +def test_inclusive_position_windows(size, step, expected): + positions = [1, 2, 3, 4] + counts, windows = allel.windowed_count(positions, size=size, step=step) + assert_array_equal(windows, expected) + assert_array_equal(counts, [right - left + 1 for left, right in expected]) + + +@pytest.mark.parametrize('size', [1, 10]) +def test_single_position_window(size): + counts, windows = allel.windowed_count([17], size=size) + assert_array_equal(windows, [[17, 17]]) + assert_array_equal(counts, [1]) + + +def test_final_position_window_diversity(): + positions = [1, 2, 3, 4] + counts = [[4, 0], [3, 1], [2, 2], [1, 3]] + diversity, windows, bases, n_variants = allel.windowed_diversity( + positions, counts, size=3) + assert_array_equal(windows, [[1, 3], [4, 4]]) + # Four haplotypes give six unordered pairs: 0, 3, 4, 3 differ per site. + np.testing.assert_allclose(diversity, [7 / 18, 1 / 2]) + assert_array_equal(bases, [3, 1]) + assert_array_equal(n_variants, [3, 1]) + + +def test_inaccessible_final_position_window(): + diversity, windows, bases, n_variants = allel.windowed_diversity( + [1, 2, 3, 4], np.array([[4, 0], [3, 1], [2, 2], [1, 3]]), size=3, + start=1, stop=4, + is_accessible=[True, True, True, False]) + assert_array_equal(windows, [[1, 3], [4, 4]]) + assert_array_equal(bases, [3, 0]) + assert_array_equal(n_variants, [3, 0]) + assert np.isnan(diversity[-1]) diff --git a/allel/test/test_stats.py b/allel/test/test_stats.py index 42c0d5f0..02d45b1a 100644 --- a/allel/test/test_stats.py +++ b/allel/test/test_stats.py @@ -74,7 +74,7 @@ def test_masked_windowed_diversity(self): # mean pairwise diversity # expect = [0, 3/6, 4/6, 3/6, 0, 5/6, 5/6, 1, -1] pos = SortedIndex([2, 4, 7, 14, 15, 18, 19, 25, 27]) - mask = np.tile(np.repeat(np.array([True, False]), 5), 3) + mask = np.tile(np.repeat(np.array([True, False]), 5), 4) # expected is every other window with size 5 expect, _, _, _ = allel.windowed_diversity(pos, ac, size=5, start=1, stop=31) @@ -100,7 +100,7 @@ def test_masked_windowed_divergence(self): ac1 = h1.count_alleles() ac2 = h2.count_alleles() pos = SortedIndex([2, 4, 7, 14, 15, 18, 19, 25, 27]) - mask = np.tile(np.repeat(np.array([True, False]), 5), 3) + mask = np.tile(np.repeat(np.array([True, False]), 5), 4) expect, _, _, _ = allel.windowed_divergence(pos, ac1, ac2, size=5, start=1, stop=31) expect = expect[::2] @@ -339,7 +339,7 @@ def test_windowed_diversity(self): # mean pairwise diversity # expect = [0, 3/6, 4/6, 3/6, 0, 5/6, 5/6, 1, -1] pos = SortedIndex([2, 4, 7, 14, 15, 18, 19, 25, 27]) - expect = [(7/6)/10, (13/6)/10, 1/11] + expect = [(7/6)/10, (13/6)/10, 1/10, 0] actual, _, _, _ = allel.windowed_diversity(pos, ac, size=10, start=1, stop=31) assert_array_almost_equal(expect, actual) @@ -383,7 +383,7 @@ def test_windowed_divergence(self): # mean pairwise divergence # expect = [0/4, 2/4, 4/4, 2/4, 0/4, 4/4, 3/4, -1, -1] pos = SortedIndex([2, 4, 7, 14, 15, 18, 19, 25, 27]) - expect = [(6/4)/10, (9/4)/10, 0/11] + expect = [(6/4)/10, (9/4)/10, 0/10, 0] actual, _, _, _ = allel.windowed_divergence( pos, ac1, ac2, size=10, start=1, stop=31 ) From 25e1de15714811cd56adc2e20bfd01120df75163 Mon Sep 17 00:00:00 2001 From: Anton Soloviev Date: Thu, 22 Oct 2026 14:43:34 -0700 Subject: [PATCH 2/2] Normalize integer scalars before inclusive window arithmetic --- allel/stats/window.py | 4 ++++ allel/test/test_position_windows.py | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/allel/stats/window.py b/allel/stats/window.py index 69775096..7ad04677 100644 --- a/allel/stats/window.py +++ b/allel/stats/window.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import operator import numpy as np @@ -108,6 +109,9 @@ def position_windows(pos, size, start, stop, step): start = pos[0] if stop is None: stop = pos[-1] + # Use Python integers for inclusive-bound arithmetic at NumPy dtype limits. + stop = operator.index(stop) + size = operator.index(size) if step is None: # non-overlapping step = size diff --git a/allel/test/test_position_windows.py b/allel/test/test_position_windows.py index 9edeb716..776842a6 100644 --- a/allel/test/test_position_windows.py +++ b/allel/test/test_position_windows.py @@ -28,6 +28,18 @@ def test_single_position_window(size): assert_array_equal(counts, [1]) +@pytest.mark.parametrize('dtype', ['u1', 'i2', 'u4', 'i8', 'u8']) +@pytest.mark.parametrize('numpy_size', [False, True]) +def test_position_windows_at_integer_limit(dtype, numpy_size): + limit = np.iinfo(dtype).max + positions = np.array([limit - 1, limit], dtype=dtype) + size = positions.dtype.type(1) if numpy_size else 1 + counts, windows = allel.windowed_count(positions, size=size) + expected = np.column_stack((positions, positions)) + assert_array_equal(windows, expected) + assert_array_equal(counts, [1, 1]) + + def test_final_position_window_diversity(): positions = [1, 2, 3, 4] counts = [[4, 0], [3, 1], [2, 2], [1, 3]]