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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,4 +161,5 @@ cython_debug/

src/ezmsg/blackrock/__version__.py
tests/.test_cache/
uv.lock
uv.lock
docs/source/api/generated
106 changes: 106 additions & 0 deletions docs/source/guides/processing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Processing modules

Beyond acquisition, `ezmsg-blackrock` ships a few stateful transformers that
operate on the `AxisArray` stream coming off the device. Each is available both
as an ezmsg `Unit` (for use in a graph) and as a plain transformer/processor
(for use in a script or test). Full signatures and settings live in the
{doc}`API reference </api/index>`; this page covers what each does and when to
reach for it.

## Channel mapping

{class}`~ezmsg.blackrock.ChannelMapUnit` attaches Blackrock `.cmp` channel-map
metadata to the `ch` axis of the stream. The incoming `ch` axis is replaced with
a structured `CoordinateAxis` carrying, per channel, the fields `x`, `y`,
`label`, `bank`, `elec`, and `device` — so downstream components (plots, spatial
filters, electrode-geometry-aware steps) can address channels by physical
position and label rather than by raw index.

The unit takes the *complete* set of per-headstage overlays in a single settings
object: {class}`~ezmsg.blackrock.ChannelMapUnitSettings` holds a tuple of
{class}`~ezmsg.blackrock.ChannelMapSettings`, one per headstage. On each reset it
rebuilds the `ch` axis from scratch in three phases:

1. **Base layer** — labels are taken from the incoming `ch` axis (falling back to
`ch1`, `ch2`, … when none are present), and pass-through fields such as
`device` are carried forward.
2. **CMP overlays** — for each configured `.cmp` file, the parsed entries are
written at their channel index (`chan_id - 1`, with `chan_id` offset by
`start_chan`), filling in position, bank, electrode, and label. When `hs_id`
is nonzero, labels are prefixed `hs{hs_id}-`. A mask records which channels a
CMP claimed.
3. **Auto-grid fill** — any channel no CMP claimed is laid out on a generated
grid, positioned below and to the right of the CMP geometry so the synthetic
coordinates never collide with mapped electrodes.

Pushing an empty `cmp_configs` tuple clears the map and yields a pure auto-grid.
The same {class}`~ezmsg.blackrock.ChannelMapSettings` record doubles as the
per-headstage entry in `CereLinkSignalSettings.cmp_configs`.

## CerePlex impedance measurement

{class}`~ezmsg.blackrock.CerePlexImpedance` extracts per-channel electrode
impedance from a CerePlex headstage's built-in impedance sweep. During the sweep
the headstage injects a 1 kHz, 1 nA sine wave for ~100 ms into one channel at a
time, cycling sequentially through the array; every channel not under test reads
*exactly* zero. The processor watches that exclusivity to lock onto the active
channel, buffers its burst, and recovers the impedance from a single-bin DFT at
1 kHz — impedance in kOhm is the peak-to-peak voltage (µV) divided by the
peak-to-peak test current (nA): `Z = V_pp / I_pp`.

Each impedance update emits an `AxisArray` whose data is a `(1, n_ch)` row of
values in kOhm, with `NaN` for channels not yet measured. Multiple headstages
are tracked independently via
{attr}`~ezmsg.blackrock.CerePlexImpedanceSettings.headstage_channel_offsets`,
each free to be at a different point in its own sweep.

Two requirements matter for correct results:

- **The input must be in microvolts.** Passing raw ADC counts scales every
impedance by the ADC factor. When the data comes from
`CereLinkSignalSource`, set `microvolts=True`.
- **Device-side filtering must be disabled.** A filter leaves small non-zero
residuals on idle channels, which defeats the exact-zero exclusivity checks
the tracker relies on.

## Sampling-delay alignment

{class}`~ezmsg.blackrock.SamplingDelayAlignment` corrects the per-channel timing
skew introduced by the front-end's sequential analog-to-digital converter. The
CerePlex headstages sample channels in banks of 32, one every ~969.7 ns, so within
a bank channel *c*'s sample is the signal delayed by `c × channel_sample_interval`
relative to the bank start. For any cross-channel operation — common-average
referencing, whitening, beamforming — this skew smears the common mode at high
frequency: negligible at 60 Hz (~0.65°) but ~81° across a bank near 7.5 kHz, so
CAR's common-mode rejection collapses toward Nyquist.

The transformer removes the skew by delaying each channel back onto a common
time grid (the bank start) with a per-slot windowed-sinc fractional-delay FIR
filter. There are only `bank_size` distinct delays, so only that many distinct
filters. A windowed sinc is used deliberately instead of linear interpolation:
linear interpolation is a delay-dependent low-pass that would impose a
*different* high-frequency rolloff per channel — coloring the band exactly where
the misalignment mattered.

The per-channel delay defaults to acquisition order (slot `c % bank_size`). If
the `ch` axis carries `bank`/`elec` metadata — for example after
{class}`~ezmsg.blackrock.ChannelMapUnit` has run — the slot is taken from `elec`
instead, so each channel's delay stays correct even when channels have been
reordered relative to hardware acquisition.

Set `filter_len` to `0` to disable alignment entirely — the transformer becomes
a pass-through that returns its input unchanged, handy for A/B comparisons or for
leaving the unit wired in but inert.

A few things to keep in mind:

- **Latency.** The causal FIR adds a common bulk delay of `(filter_len-1)//2`
samples (the per-channel fractional delays ride on top). The output time-axis
offset is shifted so timestamps stay physically correct.
- **It resamples the raw data.** Downstream sees interpolated samples — fine for
cross-channel cleaning, but be deliberate if a later step needs raw waveforms.
- **Railing.** Clipped samples are corrupt and a fractional-delay filter would
spread that corruption across its support. Setting `rail_threshold` holds
railed samples at the last valid value before filtering as a basic mitigation.
- **Backend portability.** The module is Array-API compatible: it detects the
input's namespace and runs on numpy, MLX, torch, jax, cupy, and friends.
17 changes: 17 additions & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
```{include} ../../README.md
```

## Documentation

```{toctree}
:maxdepth: 2
:caption: Contents:

guides/processing
api/index
```

## Indices and tables

- {ref}`genindex`
- {ref}`modindex`
96 changes: 0 additions & 96 deletions docs/source/index.rst

This file was deleted.

8 changes: 8 additions & 0 deletions src/ezmsg/blackrock/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
ChannelMapUnit,
ChannelMapUnitSettings,
)
from .sampling_delay_alignment import (
SamplingDelayAlignment,
SamplingDelayAlignmentSettings,
SamplingDelayAlignmentTransformer,
)

__all__ = [
"__version__",
Expand All @@ -46,5 +51,8 @@
"DeviceConfig",
"DeviceStatus",
"DeviceType",
"SamplingDelayAlignment",
"SamplingDelayAlignmentSettings",
"SamplingDelayAlignmentTransformer",
"SliceConfig",
]
Loading
Loading