Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 48 additions & 6 deletions src/ezmsg/sigproc/slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ def parse_slice(
s: str,
axinfo: AxisArray.CoordinateAxis | None = None,
field: str | None = None,
allow_empty: bool = False,
) -> tuple[slice | int, ...]:
"""
Parses a string representation of a slice and returns a tuple of slice objects.
Expand Down Expand Up @@ -90,9 +91,14 @@ def parse_slice(
field: (Optional) Which field of a structured `axinfo.data` to match tokens
against. None uses the "label" field when present. An explicit field raises
ValueError if the axis data is missing, unstructured, or lacks that field.
allow_empty: (Optional) If True, a label/regex token that matches nothing
returns no indices instead of raising. In a comma-separated selection,
non-matching tokens are dropped and the matching ones kept; if every
token matches nothing the result is an empty tuple (a 0-length slice).

Returns:
A tuple of slice objects and/or ints.
A tuple of slice objects and/or ints. May be empty when allow_empty is
True and nothing matched.
"""
if s.lower() in ["", ":", "none"]:
return (slice(None),)
Expand All @@ -112,13 +118,15 @@ def parse_slice(
hits = tuple(ix for ix, label in enumerate(labels) if pattern.fullmatch(str(label)))
if hits:
return hits
if allow_empty:
return ()
raise ValueError(
f"Selection {parts[0]!r} matched no "
f"{'labels' if field is None else f'values in field {field!r}'} "
f"on the target axis (neither exactly nor as a regex)."
) from None
return (slice(*(int(part.strip()) if part else None for part in parts)),)
suplist = [parse_slice(_, axinfo=axinfo, field=field) for _ in s.split(",")]
suplist = [parse_slice(_, axinfo=axinfo, field=field, allow_empty=allow_empty) for _ in s.split(",")]
return tuple([item for sublist in suplist for item in sublist])


Expand All @@ -136,6 +144,15 @@ class SlicerSettings(ez.Settings):
no longer positional indices (use slice syntax like "3:4" for positions) — and
raises an error if the axis has no such field."""

allow_empty: bool = False
"""When False (default), a label/regex selection that matches nothing raises,
which catches typos and wrong-axis mistakes. When True, a token that matches
nothing is skipped instead; if the whole selection matches nothing the output
is empty (0-length along ``axis``) and a warning is logged once per stream
configuration. Use this when a hub/stream legitimately may not contain any of
the selected entries (e.g. a per-source region selection where a given source
carries none of the requested regions)."""


@processor_state
class SlicerState:
Expand All @@ -157,13 +174,29 @@ def _reset_state(self, message: AxisArray) -> None:
self._state.b_change_dims = False

# Calculate the slice
_slices = parse_slice(self.settings.selection, message.axes.get(axis, None), field=self.settings.field)
_slices = parse_slice(
self.settings.selection,
message.axes.get(axis, None),
field=self.settings.field,
allow_empty=self.settings.allow_empty,
)
if len(_slices) == 1:
self._state.slice_ = _slices[0]
self._state.b_change_dims = isinstance(self._state.slice_, int)
else:
indices = np.arange(message.data.shape[axis_idx])
indices = np.hstack([indices[_] for _ in _slices])
if _slices:
indices = np.hstack([indices[_] for _ in _slices])
else:
# allow_empty: nothing matched -> select no entries (0-length),
# rather than np.hstack([]) which would raise.
indices = indices[:0]
ez.logger.warning(
"Slicer: selection %r matched no entries on axis %r; emitting "
"an empty (0-length) result (allow_empty=True).",
self.settings.selection,
axis,
)
self._state.slice_ = np.s_[indices]

# Create the output axis
Expand Down Expand Up @@ -197,7 +230,12 @@ class Slicer(BaseTransformerUnit[SlicerSettings, AxisArray, AxisArray, SlicerTra
SETTINGS = SlicerSettings


def slicer(selection: str = "", axis: str | None = None, field: str | None = None) -> SlicerTransformer:
def slicer(
selection: str = "",
axis: str | None = None,
field: str | None = None,
allow_empty: bool = False,
) -> SlicerTransformer:
"""
Slice along a particular axis.

Expand All @@ -206,8 +244,12 @@ def slicer(selection: str = "", axis: str | None = None, field: str | None = Non
axis: The name of the axis to slice along. If None, the last axis is used.
field: Which field of a structured coordinate axis to match selection values
against. See :obj:`SlicerSettings` for details.
allow_empty: If True, a selection that matches nothing yields an empty
(0-length) result instead of raising. See :obj:`SlicerSettings`.

Returns:
:obj:`SlicerTransformer`
"""
return SlicerTransformer(SlicerSettings(selection=selection, axis=axis, field=field))
return SlicerTransformer(
SlicerSettings(selection=selection, axis=axis, field=field, allow_empty=allow_empty)
)
39 changes: 39 additions & 0 deletions tests/unit/test_slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,3 +285,42 @@ def test_slicer_regex_selection():
msg_out = xformer(msg_in)
assert np.array_equal(msg_out.data, in_dat[:, 2:5])
assert np.array_equal(msg_out.axes["ch"].data, labels[2:5])


def test_parse_slice_allow_empty():
ax = AxisArray.CoordinateAxis(data=np.array(["Fp1", "Fp2", "C3", "C4"]), dims=["ch"])
# Default: a non-matching selection raises.
with pytest.raises(ValueError, match="matched no labels"):
parse_slice("XYZ.*", axinfo=ax)
# allow_empty: a non-matching token yields no indices instead of raising.
assert parse_slice("XYZ.*", axinfo=ax, allow_empty=True) == ()
# Comma-separated: non-matching tokens are dropped, matching ones kept, in order.
assert parse_slice("XYZ.*, C.*", axinfo=ax, allow_empty=True) == (2, 3)
# Every token non-matching -> empty.
assert parse_slice("XYZ.*, ABC.*", axinfo=ax, allow_empty=True) == ()
# A matching selection is unaffected by allow_empty.
assert parse_slice("C.*", axinfo=ax, allow_empty=True) == (2, 3)


def test_slicer_allow_empty_emits_empty():
data = np.arange(3 * 4).reshape(3, 4).astype(float)
msg = AxisArray(
data=data,
dims=["time", "ch"],
axes={
"time": AxisArray.TimeAxis(fs=1.0),
"ch": AxisArray.CoordinateAxis(data=np.array(["C3", "C4", "O1", "O2"]), dims=["ch"]),
},
key="allow_empty",
)
# Default: no match raises.
with pytest.raises(ValueError, match="matched no labels"):
SlicerTransformer(SlicerSettings(selection="Fp.*", axis="ch"))(msg)
# allow_empty: no match -> 0-length along ch, time axis intact.
out = SlicerTransformer(SlicerSettings(selection="Fp.*", axis="ch", allow_empty=True))(msg)
assert out.data.shape == (3, 0)
assert len(out.axes["ch"].data) == 0
# allow_empty with a partial match keeps only the matching channels.
out2 = SlicerTransformer(SlicerSettings(selection="Fp.*, O.*", axis="ch", allow_empty=True))(msg)
assert out2.data.shape == (3, 2)
assert [str(x) for x in out2.axes["ch"].data] == ["O1", "O2"]
Loading