Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dascore/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ def map(self, fn: Callable, iterable: Iterable, /) -> Iterable:
select_values_description = """
Any dimension name can be passed as key, and the values can be:
- a tuple of (min, max) for that dimension, or an equivalent slice.
`None` and ... both indicate open intervals.
`None` and ... both indicate open intervals, as does an infinite
bound pointing away from the data, eg `(min, np.inf)`.
- an integer, when `samples=True`, to select a single row or column.
- an array of values to select, which must be a subset of the
coordinate array.
Expand Down
22 changes: 20 additions & 2 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -1651,13 +1651,31 @@ def _get_index(self, value, forward=True):
except ZeroDivisionError:
return self._get_zero_step_index(value, forward)
if not math.isfinite(fraction):
return self._get_zero_step_index(value, forward)
out = math.ceil(fraction) if forward else math.floor(fraction)
# A zero step, whose samples all equal start, has a
# degenerate but defined index. Ask it by truthiness: a
# timedelta step compared to a bare 0 warns (and will
# eventually raise) about the generic unit that implies.
if not step:
return self._get_zero_step_index(value, forward)
# Otherwise the fraction is infinite: a bound past one end of
# the coord, either because the bound itself is infinite or
# because it sits far enough from start to overflow the
# division. Its sign says which end, and the range checks
# below turn the end the samples are not on into an open
# side. It cannot be NaN; a null bound, NaN and NaT alike,
# is already None by the time it gets here.
out = len(self) if fraction > 0 else -1
else:
out = math.ceil(fraction) if forward else math.floor(fraction)
if forward and out < 0:
return None
if not forward and out >= len(self):
return None
return out
# A 0d array is Sized but holds a single bound, so unbox it rather
# than let the array path cast its infinity to the smallest int64.
if getattr(value, "ndim", 1) == 0:
return self._get_index(value[()], forward=forward)
array = np.atleast_1d(value)
func = np.ceil if forward else np.floor
# Due to float weirdness we need a little bit of a fudge factor here.
Expand Down
2 changes: 1 addition & 1 deletion docs/tutorial/patch.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ The following methods help trim, reshape, and manipulate coordinates.

## Select

Patches are trimmed using the [`Patch.select`](`dascore.Patch.select`) method. Unlike [`Patch.order`](`dascore.Patch.order`), `select` will not change the order of the affected dimensions, it will only remove elements. Most commonly, `select` takes the coordinate name and a tuple of (lower_limit, upper_limit) as the values. Either limit can be `...` indicating an open interval.
Patches are trimmed using the [`Patch.select`](`dascore.Patch.select`) method. Unlike [`Patch.order`](`dascore.Patch.order`), `select` will not change the order of the affected dimensions, it will only remove elements. Most commonly, `select` takes the coordinate name and a tuple of (lower_limit, upper_limit) as the values. Either limit can be `...` or `None`, indicating an open interval, as can an infinite bound pointing away from the data, such as `(lower_limit, np.inf)`.

```{python}
import numpy as np
Expand Down
29 changes: 29 additions & 0 deletions tests/test_core/test_coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,35 @@ def test_select_step_of_0(self, start, unit):
assert index == slice(None, 1, None)
assert after.degenerate and before.degenerate

@pytest.mark.parametrize("bound", [np.inf, np.nan])
def test_select_non_finite_bound(self, bound):
"""A non-finite bound means an open side, not a zero-step index."""
coord = get_coord(start=0, stop=100, step=1)
for value in (coord.min(), 42, coord.max()):
assert coord.select((value, bound)) == coord.select((value, ...))
assert coord.select((-bound, value)) == coord.select((..., value))
assert coord.select((-bound, bound)) == coord.select((..., ...))
# The open side must not swallow the finite one; 42 really trims.
assert len(coord.select((42, bound))[0]) < len(coord)

def test_select_infinite_bound_pointing_at_the_data(self):
"""An infinite bound is open on the side it points, empty on the other."""
coord = get_coord(start=0, stop=100, step=1)
# No sample is >= inf, nor <= -inf, so these ask for nothing.
assert not len(coord.select((np.inf, None))[0])
assert not len(coord.select((None, -np.inf))[0])

def test_select_non_finite_bound_in_0d_array(self):
"""A 0d array holds one bound, so it is open like the scalar is."""
coord = get_coord(start=0, stop=100, step=1)
assert coord.select((42, np.array(np.inf))) == coord.select((42, ...))

def test_select_bound_which_overflows_the_division(self):
"""A bound too far from start to divide is outside, not open."""
coord = get_coord(start=0.0, stop=1e-305, step=1e-308)
assert not len(coord.select((1e308, None))[0])
assert not len(coord.select((None, -1e308))[0])

def test_zero_dim_array_inputs(self):
"""Ensure 0d arrays (which aren't unboxed) can init a CoordRange."""
coord = CoordRange(start=np.array(0.0), stop=np.array(10.0), step=np.array(1.0))
Expand Down
10 changes: 10 additions & 0 deletions tests/test_proc/test_proc_coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,16 @@ def _assert_data_shape_unchanged(self, original_patch, selected_patch):
assert selected_patch.data.shape == original_patch.data.shape
assert np.array_equal(selected_patch.data, original_patch.data)

def test_select_infinite_bound(self, random_patch):
"""An infinite bound is an open one, like ... or None."""
coord = random_patch.get_coord("distance")
middle = coord.values[len(coord) // 2]
expected = random_patch.select(distance=(middle, ...))
assert random_patch.select(distance=(middle, np.inf)).equals(expected)
assert random_patch.select(distance=(-np.inf, ...)).equals(random_patch)
# The infinite side must not swallow the finite one.
assert expected.shape != random_patch.shape

Comment thread
coderabbitai[bot] marked this conversation as resolved.
def test_select_by_distance(self, random_patch):
"""Ensure distance can be used to filter patch."""
dmin, dmax = 100, 200
Expand Down
Loading