"""Device-clock → host-clock conversion for CereLink streams.
Two things live here:
* :func:`device_to_monotonic_offset` — the small, reusable conversion that the
signal and spike producers reach through their ``_produce`` methods. It wraps
:meth:`pycbsdk.Session.device_to_monotonic` and leaves the no-sync fallback
policy to the caller.
* :class:`CbtimeToMonotonic` / :class:`CbtimeToMonotonicTransformer` — a
standalone ezmsg unit that re-stamps a *device-time* :class:`AxisArray` (one
produced by a source with ``cbtime=True``, whose ``time`` axis offset is
device-ns / 1e9) onto ``time.monotonic()``. It opens its **own** read-only
client :class:`~pycbsdk.Session` to the same device — CereLink's shared memory
lets a second session attach to a device a source already opened (same host).
"""
from __future__ import annotations
import asyncio
import logging
import ezmsg.core as ez
from ezmsg.baseproc import processor_state
from ezmsg.baseproc.stateful import BaseStatefulTransformer
from ezmsg.baseproc.units import BaseTransformerUnit
from ezmsg.util.messages.axisarray import AxisArray, replace
from pycbsdk import DeviceType, Session
logger = logging.getLogger(__name__)
[docs]
def device_to_monotonic_offset(session: Session, device_ns: int, *, stream_id: int = -1) -> float | None:
"""Convert a device timestamp (ns) to a ``time.monotonic()`` offset in seconds.
Thin wrapper over :meth:`pycbsdk.Session.device_to_monotonic` that returns
``None`` instead of raising when no clock sync is available yet, leaving the
fallback policy to the caller.
``stream_id >= 0`` engages the library's per-stream monotonicity enforcement
(host time kept non-decreasing, with a floor reset across a genuine clock
re-sync); ``-1`` is a stateless conversion. Use a stable id per logical
stream — the scalar call routes through ``device_to_monotonic_batch`` under
the hood, so a stable id is all that's needed for enforcement.
"""
try:
return session.device_to_monotonic(device_ns, stream_id)
except RuntimeError:
return None
[docs]
def device_to_monotonic_batch_offsets(session: Session, device_ns, *, stream_id: int = -1) -> list[float] | None:
"""Convert a whole batch of device timestamps to ``time.monotonic()`` seconds.
Like :func:`device_to_monotonic_offset` but for an iterable of device
timestamps, returning ``None`` (instead of raising) when no clock sync is
available yet.
Converting the *full* batch (rather than only its first sample) matters for
monotonicity: the library advances the per-stream floor to the **last**
converted value, so the next batch's start is clamped to be ``>=`` this
batch's end. Converting only the first sample would protect batch-start
offsets but leave the reconstructed per-sample grid free to step backward at
a batch boundary when the clock offset slews between batches.
"""
try:
return session.device_to_monotonic_batch(device_ns, stream_id)
except RuntimeError:
return None
[docs]
class CbtimeToMonotonicSettings(ez.Settings):
"""Settings for :class:`CbtimeToMonotonic`.
Input contract: the ``time`` axis offset is a device timestamp in seconds
(device-ns / 1e9), as emitted by ``CereLinkSignalSource`` /
``CereLinkSpikeSource`` with ``cbtime=True``.
"""
device_type: DeviceType | None = None
"""Device whose clock to attach to. ``None`` = idle (pure passthrough)."""
stream_id: int = 0
"""Per-stream monotonicity key. ``>= 0`` enforces a non-decreasing host
timeline (each transformer owns its own Session, so the id namespace is
private); ``-1`` is a stateless conversion."""
[docs]
@processor_state
class CbtimeToMonotonicState:
session: Session | None = None
[docs]
class CbtimeToMonotonic(
BaseTransformerUnit[CbtimeToMonotonicSettings, AxisArray, AxisArray, CbtimeToMonotonicTransformer]
):
"""ezmsg Unit wrapping :class:`CbtimeToMonotonicTransformer`."""
SETTINGS = CbtimeToMonotonicSettings
[docs]
def shutdown(self) -> None:
self.processor.close()