Source code for ezmsg.sigproc.resampleconcat

"""Resample one stream onto another's clock and concatenate them in one step.

This fuses :class:`~ezmsg.sigproc.resample.ResampleProcessor` and
:class:`~ezmsg.sigproc.concat.ConcatProcessor` for the common case of combining two
acquisition streams that share a nominal sampling rate but drift slightly:

* ``INPUT_REFERENCE`` -- the *reference* stream (its clock is the master timebase, and
  its data becomes one half of the concatenated output).
* ``INPUT_SIGNAL`` -- the stream that is resampled onto the reference clock.
* ``OUTPUT_SIGNAL`` -- the two streams concatenated along ``concat_axis``.

Because the resampler emits the reference signal gathered onto the *exact* grid it
resampled the signal onto (``output_reference=True``), the two halves share an identical
alignment axis by construction. No second time-alignment (``Align``) is needed, so this
collapses the otherwise-diamond graph

    REF  -> RESAMPLE.INPUT_REFERENCE
    SIG  -> RESAMPLE.INPUT_SIGNAL
    REF  -> MERGE.INPUT_SIGNAL_A
    RESAMPLE.OUTPUT -> MERGE.INPUT_SIGNAL_B

into a single linear two-input unit. All interpolation logic is reused from
``resample.py`` and all axis/attr-merge logic from ``concat.py``; only the orchestration
is new.
"""

from __future__ import annotations

import typing

import ezmsg.core as ez
import numpy as np
from ezmsg.util.messages.axisarray import AxisArray

from .concat import ConcatProcessor, ConcatSettings
from .resample import ResampleProcessor, ResampleSettings
from .util.buffer import UpdateStrategy
from .util.message import has_samples_along


[docs] class ResampleConcatSettings(ez.Settings): axis: str = "time" """Alignment axis: the reference stream's clock that the signal is resampled onto.""" concat_axis: str = "ch" """Axis along which the reference and resampled signals are concatenated.""" # --- Resample passthrough --- buffer_duration: float = 2.0 fill_value: str = "extrapolate" max_chunk_delay: float = np.inf buffer_update_strategy: UpdateStrategy = "immediate" reference_reset_after_chunks: float = 3 """See :attr:`ezmsg.sigproc.resample.ResampleSettings.reference_reset_after_chunks`.""" # --- Concat passthrough --- relabel_axis: bool = True label_a: str = "_a" """Per-side label for the reference (A) side. See :class:`~ezmsg.sigproc.concat.ConcatSettings`.""" label_b: str = "_b" """Per-side label for the resampled-signal (B) side.""" assert_identical_shared_axes: bool = False new_key: str | None = None auto_coerce_backend: bool = False
[docs] class ResampleConcatProcessor: """Resample ``INPUT_SIGNAL`` onto ``INPUT_REFERENCE``'s clock, then concatenate. Composes an unmodified :class:`ResampleProcessor` (with ``output_reference=True``) and an unmodified :class:`ConcatProcessor`. The reference signal (A) and the resampled signal (B) are concatenated only when the resampler yields both on the same grid, so the two are aligned by construction. """
[docs] def __init__(self, settings: ResampleConcatSettings): self.settings = settings self._resampler = ResampleProcessor( settings=ResampleSettings( axis=settings.axis, resample_rate=None, # reference-driven; required for output_reference buffer_duration=settings.buffer_duration, fill_value=settings.fill_value, max_chunk_delay=settings.max_chunk_delay, buffer_update_strategy=settings.buffer_update_strategy, output_reference=True, reference_reset_after_chunks=settings.reference_reset_after_chunks, ) ) self._concat = ConcatProcessor( ConcatSettings( axis=settings.concat_axis, align_axis=settings.axis, relabel_axis=settings.relabel_axis, label_a=settings.label_a, label_b=settings.label_b, assert_identical_shared_axes=settings.assert_identical_shared_axes, new_key=settings.new_key, auto_coerce_backend=settings.auto_coerce_backend, ) )
@property def resample_state(self): """Expose resampler state for introspection / tests.""" return self._resampler.state @property def concat_state(self): """Expose concat state for introspection / tests.""" return self._concat.state
[docs] def push_reference(self, message: AxisArray) -> None: """Feed a reference (clock + side-A) message.""" self._resampler.push_reference(message)
[docs] def push_signal(self, message: AxisArray) -> None: """Feed a signal message to be resampled onto the reference clock (side B).""" self._resampler(message)
def __call__(self, message: AxisArray) -> AxisArray | None: """Push a signal message and return the next concatenated chunk, if any.""" self.push_signal(message) return next(self) async def __acall__(self, message: AxisArray) -> AxisArray | None: return self(message) def __next__(self) -> AxisArray | None: b = next(self._resampler) # resampled signal (side B) if b.data.shape[0] == 0: return None a = self._resampler.state.reference_output # reference on the same grid (side A) if a is None: return None return self._concat._concat(a, b)
[docs] class ResampleConcat(ez.Unit): """One-step resample-onto-reference + concatenate. See module docstring. ``INPUT_REFERENCE`` is the master clock and side A; ``INPUT_SIGNAL`` is resampled onto it and becomes side B. """ SETTINGS = ResampleConcatSettings INPUT_SIGNAL = ez.InputStream(AxisArray) INPUT_REFERENCE = ez.InputStream(AxisArray) OUTPUT_SIGNAL = ez.OutputStream(AxisArray)
[docs] async def initialize(self) -> None: self.processor = ResampleConcatProcessor(self.SETTINGS)
def _drain(self) -> typing.Iterator[AxisArray]: """Yield every chunk the processor can currently produce. Event-driven draining after each push is lossless here: the composed resampler is always reference-driven (``resample_rate=None``), and in that mode output readiness only ever changes on new input -- the wall-clock ``max_chunk_delay`` extrapolation applies to prescribed-rate mode only. A single ``next()`` consumes all currently-eligible reference values, so this loop runs at most twice. """ while True: result = next(self.processor) # None / a missing or empty resample axis means "nothing ready"; a # chunk that is empty only along other axes (e.g. the concatenated # feature axis) is still a real output and must be published. if result is None or not has_samples_along(result, self.SETTINGS.axis): return yield result
[docs] @ez.subscriber(INPUT_REFERENCE) @ez.publisher(OUTPUT_SIGNAL) async def on_reference(self, message: AxisArray) -> typing.AsyncGenerator: self.processor.push_reference(message) for out in self._drain(): yield self.OUTPUT_SIGNAL, out
[docs] @ez.subscriber(INPUT_SIGNAL) @ez.publisher(OUTPUT_SIGNAL) async def on_signal(self, message: AxisArray) -> typing.AsyncGenerator: self.processor.push_signal(message) for out in self._drain(): yield self.OUTPUT_SIGNAL, out