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
31 changes: 25 additions & 6 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -1392,10 +1392,10 @@ def _maybe_unbox_scalar(value):

def _round_ratio(numerator, denominator, digits):
"""Round numerator/denominator, cheaply for scalars."""
# Inputs are always scalar-like (multi-element arrays are rejected
# by the pd.isnull check above) and rounding python floats is
# ~10x faster than numpy scalars, hence the float conversion.
ratio = _maybe_unbox_scalar(numerator / denominator)
if isinstance(ratio, np.ndarray): # multi-element array inputs
return np.round(ratio, digits)
# rounding python floats is ~10x faster than numpy scalars.
return round(float(ratio), digits)

zero = _TD64_ZERO if is_timedelta64(step) else 0
Expand Down Expand Up @@ -1520,6 +1520,19 @@ def sort(self, reverse=False) -> tuple[BaseCoord, slice | ArrayLike]:
out = self._new_grid(new_start, new_step, len(self))
return out, slice(None, None, -1)

def _get_zero_step_index(self, value, forward):
"""
Get the index of a value for a coord with a step of 0.

Every sample of such a coord equals start, so the index is either the
first sample or one just outside the coord, which makes the
selection degenerate.
"""
start = self.start
if forward: # index of the first sample >= value
return 0 if value <= start else len(self)
return 0 if value >= start else -1

def _get_index(self, value, forward=True):
"""Get the index corresponding to a value."""
if (value := self._get_compatible_value(value)) is None:
Expand All @@ -1529,9 +1542,15 @@ def _get_index(self, value, forward=True):
# Scalar fast path; avoids several small-array allocations.
# Due to float weirdness we need a little bit of a fudge factor.
# (float() first since rounding numpy scalars is ~10x slower)
fraction = round(float((value - start) / step), 10)
if not math.isfinite(fraction): # e.g. a step of 0
return None
# A step of 0 is handled after the division (python scalars raise,
# numpy scalars give inf/nan) since testing a numpy step for
# truthiness up front costs ~10x more on this hot path.
try:
fraction = round(float((value - start) / step), 10)
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)
if forward and out < 0:
return None
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 @@ -1369,6 +1369,35 @@ def test_coord_range_len_1(self):
assert len(new) == 1
assert np.all(new.values == np.zeros(0))

@pytest.mark.parametrize(
"start,unit",
[
(0, 1),
(np.float64(0), np.float64(1)),
(np.datetime64("2020-01-01", "ns"), np.timedelta64(1, "s")),
],
)
def test_select_step_of_0(self, start, unit):
"""All samples of a step of 0 coord equal start; select on that."""
step = start - start # a zero step of the right type.
coord = CoordRange(start=start, stop=start, step=step)
# numpy scalars warn (rather than raise) on the zero division.
with np.errstate(divide="ignore", invalid="ignore"):
# a range which contains start keeps the sample.
kept, index = coord.select((start - unit, start + unit))
# ranges which don't are degenerate.
after, _ = coord.select((start + unit, start + 2 * unit))
before, _ = coord.select((start - 2 * unit, start - unit))
assert kept == coord
assert index == slice(None, 1, None)
assert after.degenerate and before.degenerate

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))
assert len(coord) == 10
assert coord.step == 1.0

def test_reversed_coord(self, evenly_sampled_reversed_coord):
"""Ensure reverse sampling works for evenly sampled coord."""
coord = evenly_sampled_reversed_coord
Expand Down
3 changes: 3 additions & 0 deletions tests/test_utils/test_io_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1240,6 +1240,7 @@ def _memory_file(self, name: str) -> UPath:
fi.write(b"dascore" * 64)
return path

@pytest.mark.concurrency
def test_racing_callers_download_once(self, monkeypatch, run_in_threads):
"""Callers wanting one resource agree on the path and download it once."""
resource = self._memory_file("shared.bin")
Expand All @@ -1256,6 +1257,7 @@ def _counted(path, local_path):
assert len(downloads) == 1
assert results[0].exists()

@pytest.mark.concurrency
def test_distinct_resources_are_not_serialized(self, monkeypatch, run_in_threads):
"""Unrelated downloads run at once; one global lock would time out here."""
resources = [self._memory_file(f"file_{i}.bin") for i in range(4)]
Expand Down Expand Up @@ -1311,6 +1313,7 @@ def test_reinit_drops_download_locks(self):
class TestIOResourceManagerConcurrency:
"""One manager hands every caller the same handle per type."""

@pytest.mark.concurrency
def test_racing_callers_share_one_handle(self, tmp_path, run_in_threads):
"""get_resource opens each required type exactly once."""
path = tmp_path / "concurrent_resource.bin"
Expand Down
Loading