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
2 changes: 2 additions & 0 deletions benchmarks/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pytest benchmarks/ --codspeed
pytest benchmarks/test_patch_benchmarks.py --codspeed
pytest benchmarks/test_io_benchmarks.py --codspeed
pytest benchmarks/test_spool_benchmarks.py --codspeed
pytest benchmarks/test_import_benchmarks.py --codspeed
```

## Benchmark Structure
Expand All @@ -27,6 +28,7 @@ Benchmarks are now organized as pytest tests in the `benchmarks/` directory:
- `test_io_benchmarks.py` - File I/O operations benchmarks
- `test_spool_benchmarks.py` - Spool chunking and selection benchmarks
- `test_lookup_benchmarks.py` - In-memory lookups on hot paths (format resolution, remote-cache and IO handle resolution, repeat spool access). These are deliberately small: a change of a few microseconds per lookup is invisible in the end-to-end benchmarks above, because one file read costs far more than the lookups it makes.
- `test_import_benchmarks.py` - Import benchmarks (dascore's own modules; third party dependencies stay warm)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Hyphenate the compound modifier.

Change third party dependencies to third-party dependencies.

🧰 Tools
🪛 LanguageTool

[grammar] ~31-~31: Use a hyphen to join words.
Context: ...benchmarks (dascore's own modules; third party dependencies stay warm) Each benc...

(QB_NEW_EN_HYPHEN)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@benchmarks/readme.md` at line 31, Update the benchmark description for
test_import_benchmarks.py to hyphenate the compound modifier, changing “third
party dependencies” to “third-party dependencies.”

Source: Linters/SAST tools


Each benchmark uses the `@pytest.mark.benchmark` decorator to automatically measure performance.

Expand Down
53 changes: 53 additions & 0 deletions benchmarks/test_import_benchmarks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Benchmarks for importing dascore using pytest-codspeed."""

from __future__ import annotations

import importlib
import sys
import warnings

import pint
import pytest


def _dascore_module_names():
"""Get the names of all currently imported dascore modules."""
return [x for x in sys.modules if x == "dascore" or x.startswith("dascore.")]


class TestImportBenchmarks:
"""
Benchmarks for re-executing dascore's modules.

Only dascore's own modules are removed from the module cache, so these
measure the cost of executing dascore's module bodies, not the one-time
cost of importing third party dependencies. A fresh interpreter (eg a
CLI call) also pays the latter, but it cannot be measured here; a
subprocess falls outside the region CodSpeed instruments. Instead, see
tests/test_imports.py for the guards which keep slow dependencies out of
the import chain entirely.
"""

@pytest.fixture()
def restore_dascore_modules(self):
"""Put the original dascore modules back after re-importing them."""
# Import here so third party dependencies are warm before timing,
# even when this file is the only one collected.
importlib.import_module("dascore")
saved = {x: sys.modules[x] for x in _dascore_module_names()}
# Re-importing dascore makes (and installs) a new pint registry.
registry = pint.get_application_registry().get()
# dascore adds a warning filter on import; catch_warnings undoes that.
with warnings.catch_warnings():
yield
for name in _dascore_module_names():
del sys.modules[name]
sys.modules.update(saved)
pint.set_application_registry(registry)

@pytest.mark.benchmark
def test_reimport_dascore(self, restore_dascore_modules):
"""Time re-importing the top-level dascore module."""
for name in _dascore_module_names():
del sys.modules[name]
importlib.import_module("dascore")
47 changes: 47 additions & 0 deletions benchmarks/test_patch_benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,48 @@ def test_slope_mute(self, example_patch):
patch.slope_mute(slopes=(1000, 3000))


class TestPatchConstructionBenchmarks:
"""Benchmarks for the patch construction paths."""

@pytest.fixture(scope="module")
def new_data(self, example_patch):
"""Data for constructing new patches; built outside the timed call."""
return np.asarray(example_patch.data) * 2

@pytest.fixture(scope="module")
def decimated(self, example_patch):
"""A coord manager with one dimension shortened."""
coord = example_patch.get_coord("time")[::2]
return example_patch.coords.update(time=coord)

@pytest.mark.benchmark
def test_new_data_only(self, example_patch, new_data):
"""Time new when only data changes; coords and attrs are reused."""
example_patch.new(data=new_data)

@pytest.mark.benchmark
def test_new_with_coords(self, example_patch, decimated):
"""Time new when the coords change, so attrs must be rebuilt."""
example_patch.new(data=example_patch.data[:, ::2], coords=decimated)

@pytest.mark.benchmark
def test_new_with_coords_and_attrs(self, example_patch, new_data):
"""Time new when both coords and attrs are passed."""
patch = example_patch
patch.new(data=new_data, coords=patch.coords, attrs=patch.attrs)

@pytest.mark.benchmark
def test_patch_init(self, example_patch, new_data):
"""
Time the normal constructor.

This is a control; it should not move, since the strict path is
deliberately left alone.
"""
patch = example_patch
dc.Patch(data=new_data, coords=patch.coords, dims=patch.dims, attrs=patch.attrs)


class TestTransformBenchmarks:
"""Benchmarks for patch transform operations."""

Expand Down Expand Up @@ -353,6 +395,11 @@ def test_rolling_large_roller_mean(self, big_roller):
"""Time rolling mean calculation."""
big_roller.mean()

@pytest.mark.benchmark
def test_rolling_mean_full_call(self, example_patch):
"""Time a complete rolling mean, including roller construction."""
example_patch.rolling(time=5, samples=True, center=True).mean()


class TestAlignBenchmarks:
"""Benchmarks for align_to_coord operation."""
Expand Down
24 changes: 21 additions & 3 deletions dascore/core/coords.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ def _reduce_time_like(func, data):
return np.atleast_1d(out)


def _validate_new_length(length) -> int:
"""Ensure a requested coordinate length is a non-negative integer."""
# bool is an int subclass; True/False are never a sensible length.
if isinstance(length, bool) or not isinstance(length, int | np.integer):
msg = f"change_length requires an integer length, not {length!r}."
raise ParameterError(msg)
if length < 0:
msg = f"change_length requires a non-negative length, not {length}."
raise ParameterError(msg)
return int(length)


def _get_dtype(value, dtype):
"""Get the data type based on the first argument."""
if dtype is not None and dtype != "":
Expand Down Expand Up @@ -399,7 +411,7 @@ def _select_by_sample_array(self, array):
msg = "Using an array input for select with samples requires integer dtype."
raise CoordError(msg)
# Filter out bad indices
if self.ndim > 1:
if self.ndim != 1:
msg = "Select only works on 1D coords."
raise CoordError(msg)
inds = np.arange(len(self))
Expand Down Expand Up @@ -1148,7 +1160,12 @@ def change_length(self, length: int) -> Self:
Parameters
----------
length
The output length.
The output length. Must be a non-negative integer.

Raises
------
ParameterError
If length is not a non-negative integer.
"""
msg = f"Coordinate type {self.__class__} does not implement change_length"
raise NotImplementedError(msg)
Expand Down Expand Up @@ -1308,7 +1325,7 @@ def change_length(self, length: int) -> Self:
if self.ndim != 1:
msg = "change_length only works on 1D coords."
raise CoordError(msg)
return get_coord(shape=(length,))
return get_coord(shape=(_validate_new_length(length),))

def to_summary(self, dims=()) -> CoordSummary:
"""Get the summary info about the coord."""
Expand Down Expand Up @@ -1668,6 +1685,7 @@ def change_length(self, length: int) -> Self:
"""
# CoordRange is always 1D by construction; keep as an internal invariant.
assert self.ndim == 1, "Can only change length for 1D coords."
length = _validate_new_length(length)
if len(self) == length:
return self
# Only the sample count changes; start/step are already valid.
Expand Down
5 changes: 3 additions & 2 deletions dascore/io/dasvader/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ class DASVaderV1(FiberIO):
Notes
-----
Legacy DASVader files may contain anonymous JLD2 object references. DASCore
detects those files and raises `DASVaderCompatibilityError` with compatibility
instructions instead of failing inside `h5py`. A known working stack for
reads these references when supported by HDF5 and raises
`DASVaderCompatibilityError` with compatibility instructions when
dereferencing fails. A known working stack for
such legacy files is `h5py<3.16` with `HDF5 1.14.x`.
"""

Expand Down
12 changes: 7 additions & 5 deletions dascore/io/dasvader/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

import h5py
import numpy as np
from h5py import h5r
from h5py.h5r import Reference, dereference

import dascore as dc
Expand Down Expand Up @@ -77,15 +76,18 @@ def _raise_legacy_ref_error(h5, field_name: str) -> None:


def _dereference(h5, value, field_name: str):
"""Resolve an HDF5 reference, rejecting legacy anonymous DASVader refs."""
"""Resolve an HDF5 reference or raise a clear compatibility error."""
if not isinstance(value, Reference):
return value
if h5r.get_name(value, h5.id) is None:
_raise_legacy_ref_error(h5, field_name)
try:
return h5[value]
except KeyError:
return h5py.Dataset(dereference(value, h5.id))
# The high-level lookup fails for some references HDF5 can still
# resolve directly, so try that before giving up.
try:
return h5py.Dataset(dereference(value, h5.id))
except Exception:
_raise_legacy_ref_error(h5, field_name)
Comment on lines 78 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does h5py Group.__getitem__ raise ValueError for an unresolvable object reference?

💡 Result:

Yes, h5py Group.getitem raises a ValueError when provided with an unresolvable or "null" object reference [1][2]. In the implementation of Group.getitem, h5py checks if the provided key is an instance of h5r.Reference [1]. If it is, the library attempts to dereference it using h5r.dereference [1]. If the dereferencing process returns None (which occurs for zero-filled or null references), h5py explicitly raises a ValueError with the message "Invalid HDF5 object reference" [1][3][2]. This behavior is specific to object references. In contrast, when accessing group members by string path, h5py typically raises a KeyError if the object does not exist [4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching utils.py:\n'
fd 'utils.py$' . || true

printf '\nTarget file outline:\n'
ast-grep outline dascore/io/dasvader/utils.py --view expanded || true

printf '\nRelevant portion of dascore/io/dasvader/utils.py:\n'
cat -n dascore/io/dasvader/utils.py | sed -n '1,140p'

printf '\nUsages of _dereference and DASVaderCompatibilityError:\n'
rg -n "_dereference|DASVaderCompatibilityError|h5yp|Reference" dascore/io/dasvader/utils.py dascore io 2>/dev/null | head -200 || true

printf '\nPython module availability and h5py reference resolution behavior probe:\n'
python3 - <<'PY'
import inspect, struct, tempfile, os
try:
    import h5py, h5py.h5r as h5r
except Exception as exc:
    print(f"h5py import error: {type(exc).__name__}: {exc}")
    raise SystemExit(0)

print("h5py version:", getattr(h5py, "__version__", "unknown"))

src = inspect.getsource(h5py.h5r.Reference)
print(f"\nh5py.h5r.Reference.source:\n{src[:3000]}")

src = inspect.getsource(h5py.Group.__getitem__)
print(f"\nh5py.Group.__getitem__.source:\n{src}")

def make_zero_ref():
    return b'0'*48

for dtype in ("u8", "i8", "<u8", ">u8", "<i8", ">i8", None):
    arr = make_zero_ref()
    buf = arr[:]
    if dtype is None or dtype.startswith("<"):
        buf = buf.swapbytes()
    print(dtype, repr(buf), "starts with zero bytes:", buf[:4] == b'\x00\0\0\0')

with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as f:
    path = f.name
try:
    with h5py.File(path, "w") as f:
        f.attrs["zero_ref"] = make_zero_ref()
        f.attrs["one_ref"] = make_zero_ref()
        f.attrs["one_ref"][0] = b"\x0F"[0] if isinstance(make_zero_ref(), bytes) else 15

    with h5py.File(path, "r") as f:
        zero = f.attrs["zero_ref"]
        one = f.attrs["one_ref"]
        print("\nReading attrs:", zero, one)
        print("is Reference?", isinstance(zero, h5r.Reference), isinstance(one, h5r.Reference))
        for label, ref in [("zero_ref", zero), ("one_ref", one)]:
            try:
                val = f[ref]
                print("getitem succeed", label, type(val))
            except Exception as exc:
                print("getitem", label, type(exc).__name__, str(exc))
            try:
                res = h5r.dereference(ref, f.id)
                print("h5r.dereference raw", label, res, type(res))
            except Exception as exc:
                print("h5r.dereference", label, type(exc).__name__, str(exc))
finally:
    os.unlink(path)
PY

Repository: DASDAE/dascore

Length of output: 9980


🌐 Web query:

h5py _hl group.py Group.__getitem__ Reference None ValueError Invalid HDF5 object reference

💡 Result:

The ValueError "Invalid HDF5 object reference" in h5py is raised within the Group.getitem method when an attempt to dereference an HDF5 object reference fails [1][2]. Specifically, in h5py/_hl/group.py, when a user attempts to access an item in a group by passing an object reference (as opposed to a string path), h5py calls h5r.dereference [1][3]. If the result of this dereference is None—indicating that the reference points to a non-existent, invalid, or inaccessible object—h5py explicitly raises this ValueError [1][2]. This error typically indicates one of the following issues: 1. Broken Links: The reference may point to an object that has been deleted, moved, or is part of a broken external link [4]. 2. Corrupted File or Reference: The HDF5 file or the specific reference data might be corrupted, leading to an undefined object address [5]. 3. Incorrect Reference Handling: The reference might have been created or stored incorrectly, such as when using external C++ code to generate HDF5 files where pointers or reference arrays are not managed correctly [2]. If you encounter this error while accessing data, you can investigate by using command-line HDF5 tools like h5ls or h5dump to inspect the structure of the file and verify if the target object exists or if links are broken [4]. Additionally, ensure that any references were generated in a manner compatible with the HDF5 library's requirements [2].

Citations:


Catch ValueError too, so the fallback and clear error always trigger.

_dereference only catches KeyError from h5[value]. h5py raises ValueError with message Invalid HDF5 object reference for unresolvable object references, so those cases skip the low-level fallback and expose a raw exception instead. Add ValueError to the caught exception on line 84.

🐛 Proposed fix
     try:
         return h5[value]
-    except KeyError:
+    except (KeyError, ValueError):
         # The high-level lookup fails for some references HDF5 can still
         # resolve directly, so try that before giving up.
         try:
             return h5py.Dataset(dereference(value, h5.id))
         except Exception:
             _raise_legacy_ref_error(h5, field_name)

Keep the narrow Exception catch in the low-level fallback only if it must absorb unrelated resource failures, but chain the original error so DASVaderCompatibilityError preserves traceback context.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _dereference(h5, value, field_name: str):
"""Resolve an HDF5 reference, rejecting legacy anonymous DASVader refs."""
"""Resolve an HDF5 reference or raise a clear compatibility error."""
if not isinstance(value, Reference):
return value
if h5r.get_name(value, h5.id) is None:
_raise_legacy_ref_error(h5, field_name)
try:
return h5[value]
except KeyError:
return h5py.Dataset(dereference(value, h5.id))
# The high-level lookup fails for some references HDF5 can still
# resolve directly, so try that before giving up.
try:
return h5py.Dataset(dereference(value, h5.id))
except Exception:
_raise_legacy_ref_error(h5, field_name)
def _dereference(h5, value, field_name: str):
"""Resolve an HDF5 reference or raise a clear compatibility error."""
if not isinstance(value, Reference):
return value
try:
return h5[value]
except (KeyError, ValueError):
# The high-level lookup fails for some references HDF5 can still
# resolve directly, so try that before giving up.
try:
return h5py.Dataset(dereference(value, h5.id))
except Exception:
_raise_legacy_ref_error(h5, field_name)
🧰 Tools
🪛 Ruff (0.16.0)

[warning] 89-89: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/dasvader/utils.py` around lines 78 - 90, Update _dereference to
catch both KeyError and ValueError from the initial h5[value] lookup, allowing
invalid references to reach the low-level dereference fallback and
_raise_legacy_ref_error. Preserve the fallback’s existing behavior, and chain
the original lookup exception when raising DASVaderCompatibilityError so its
traceback context is retained.



# --- Metadata parsing
Expand Down
45 changes: 29 additions & 16 deletions dascore/proc/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,10 @@ def normalize(
"""
Normalize a patch along a specified dimension.

NaN values are ignored when computing the norm. They remain NaN in the
output but do not affect any other sample. Slices with a norm of zero,
meaning they contain nothing but zeros and NaN, are returned unscaled.

Parameters
----------
dim
Expand Down Expand Up @@ -348,26 +352,24 @@ def normalize(
data = self.data
if norm in {"l1", "l2"}:
order = int(norm[-1])
norm_values = np.linalg.norm(self.data, axis=axis, ord=order)
# Equivalent to np.linalg.norm, but skips NaN rather than letting a
# single null blank every sample sharing its slice. The float exponent
# promotes ints so the powers cannot overflow a narrow dtype.
norm_values = np.nansum(np.abs(data) ** float(order), axis=axis) ** (1 / order)
divisor = np.expand_dims(norm_values, axis=axis)
elif norm == "max":
norm_values = np.max(np.abs(data), axis=axis)
divisor = np.expand_dims(np.nanmax(np.abs(data), axis=axis), axis=axis)
elif norm == "bit":
pass
divisor = np.abs(data)
else:
msg = (
f"Norm value of {norm} is not supported. "
f"Supported values are {('l1', 'l2', 'max', 'bit')}"
)
raise ValueError(msg)
if norm == "bit":
new_data = np.divide(
data, np.abs(data), out=np.zeros_like(data), where=np.abs(data) != 0
)
else:
expanded_norm = np.expand_dims(norm_values, axis=axis)
new_data = np.divide(
data, expanded_norm, out=np.zeros_like(data), where=expanded_norm != 0
)
# A zero divisor means there is nothing but zeros and nulls to scale, so
# divide those by one; the zeros stay zero and the nulls stay null.
new_data = data / np.where(divisor == 0, 1, divisor)
Comment on lines 362 to +372

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
import numpy as np

value = np.array([np.iinfo(np.int8).min], dtype=np.int8)
assert (value / np.abs(value))[0] == 1.0
PY

Repository: DASDAE/dascore

Length of output: 152


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate relevant files =="
git ls-files | rg '(^dascore/proc/basic\.py$|^tests/test_proc/test_basic\.py$)' || true

echo
echo "== basic.py normalize implementation around bit norm =="
sed -n '320,400p' dascore/proc/basic.py

echo
echo "== test_basic.py integer normalization tests =="
sed -n '220,275p' tests/test_proc/test_basic.py

echo
echo "== search for normalize signature and usage =="
rg -n "def normalize|normalize\\(|norm =|\"bit\"|'bit'" dascore tests/test_proc/test_basic.py -S

Repository: DASDAE/dascore

Length of output: 9655


Preserve the sign of signed-integer minimum values in bit normalization.

np.abs cannot represent the absolute value of a signed dtype minimum. For example, np.int8(-128) / np.abs(np.int8(-128)) produces +1, not -1.

  • dascore/proc/basic.py#L362-L372: convert signed integer data to the floating result dtype before np.abs, or use an overflow-free sign calculation.
  • tests/test_proc/test_basic.py#L245-256: add a bit regression case for np.iinfo(np.int8).min and assert that its result is -1.
📍 Affects 2 files
  • dascore/proc/basic.py#L362-L372 (this comment)
  • tests/test_proc/test_basic.py#L245-L256
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/proc/basic.py` around lines 362 - 372, The bit normalization branch
around norm must avoid signed-integer overflow when computing the divisor,
preserving the sign of the minimum representable value; convert signed integer
data to the floating result dtype before applying np.abs or use an equivalent
overflow-free sign calculation. In dascore/proc/basic.py lines 362-372, update
the bit normalization logic accordingly. In tests/test_proc/test_basic.py lines
245-256, add a regression case using np.iinfo(np.int8).min and assert that
normalization returns -1.

return self.new(data=new_data)


Expand All @@ -385,6 +387,9 @@ def standardize(
where u is the mean of the training samples or zero if with_mean=False,
and s is the standard deviation of the training samples or one if with_std=False.

NaN values are ignored when computing the mean and standard deviation. They
remain NaN in the output but do not affect any other sample.

Parameters
----------
dim
Expand All @@ -406,8 +411,8 @@ def standardize(
"""
axis = self.get_axis(dim)
data = self.data
mean = np.mean(data, axis=axis, keepdims=True)
std = np.std(data, axis=axis, keepdims=True)
mean = np.nanmean(data, axis=axis, keepdims=True)
std = np.nanstd(data, axis=axis, keepdims=True)
new_data = (data - mean) / std
return self.new(data=new_data)

Expand Down Expand Up @@ -810,6 +815,10 @@ def demedian(patch, dim: str = "time"):
"""
Remove the median along a given dimension of a DASCore patch.

NaN values are ignored when computing the median, consistent with
[Patch.median](`dascore.proc.aggregate.median`). They remain NaN in the
output but do not affect any other sample.

Parameters
----------
patch :
Expand Down Expand Up @@ -856,7 +865,7 @@ def demedian(patch, dim: str = "time"):
data = patch.data

# Compute median along axis, keep dims for broadcasting
med = np.median(data, axis=axis, keepdims=True)
med = np.nanmedian(data, axis=axis, keepdims=True)

new_data = data - med

Expand All @@ -869,6 +878,10 @@ def demean(patch, dim: str = "time"):
"""
Remove the mean along a given dimension of a DASCore patch.

NaN values are ignored when computing the mean, consistent with
[Patch.mean](`dascore.proc.aggregate.mean`). They remain NaN in the output
but do not affect any other sample.

Parameters
----------
patch :
Expand Down Expand Up @@ -915,7 +928,7 @@ def demean(patch, dim: str = "time"):
data = patch.data

# Compute mean along axis, keep dims for broadcasting
mea = np.mean(data, axis=axis, keepdims=True)
mea = np.nanmean(data, axis=axis, keepdims=True)

new_data = data - mea

Expand Down
Loading
Loading