Source code for ezmsg.tools.chmeta

"""Turning a structured ``ch`` coordinate axis into per-channel display names.

An AxisArray's ``ch`` axis usually carries a structured array with one row per
channel. What is *in* those rows depends on the acquisition system: an LSL or
NWB source typically offers a ``label``; a Blackrock source additionally offers
``bank`` and ``elec``, which is what its users actually read off the front panel.
A plot needs one string per channel, so something has to choose.

This module makes that choice explicit and configurable rather than hard-coding
one system's convention. ``label`` is the default because it is the field most
sources populate and the one most likely to be meaningful to whoever recorded
the data; a GUI that knows better can ask for other fields.
"""

import typing

import numpy as np

__all__ = ["available_fields", "channel_names"]


[docs] def available_fields(ch_axis_data: typing.Optional[np.ndarray]) -> typing.List[str]: """Field names present on a structured ``ch`` axis, for building a chooser.""" if ch_axis_data is None: return [] return list((ch_axis_data.dtype.fields or {}).keys())
def _field_text(row_value: typing.Any) -> str: """One field of one channel as display text, or "" if it carries nothing. Numeric fields use 0 as their "unset" value -- a Blackrock ``elec`` of 0 means no electrode, not electrode zero -- so it is treated as absent. Bytes arrive from numpy ``S`` dtypes and are decoded leniently: a mangled label is still more use than an exception. """ if isinstance(row_value, bytes): row_value = row_value.decode("utf8", errors="replace") if isinstance(row_value, np.generic): row_value = row_value.item() if isinstance(row_value, bytes): row_value = row_value.decode("utf8", errors="replace") if isinstance(row_value, (int, float)) and not isinstance(row_value, bool): return "" if row_value == 0 else f"{row_value:g}" return str(row_value).strip()
[docs] def channel_names( ch_axis_data: typing.Optional[np.ndarray], n_ch: typing.Optional[int] = None, *, fields: typing.Sequence[str] = ("label",), sep: str = "-", fallback: str = "ch{index}", ) -> typing.List[str]: """Per-channel display names from a structured ``ch`` axis. :param ch_axis_data: The axis's structured array, or None if the source provided no ``ch`` axis. :param n_ch: Channel count, used only when ``ch_axis_data`` is None or unstructured. Otherwise the axis's own length governs. :param fields: Field names to join, in order. Fields absent from the dtype are skipped; fields present but empty for a given channel are skipped for that channel only, so a partially-populated column degrades per-row rather than for the whole array. :param sep: Joins the field values, e.g. ``("bank", "elec")`` -> ``"A-1"``. :param fallback: Format string used when no requested field yields anything for a channel. ``{index}`` is the channel's index. :return: One name per channel. Never empty strings, so a caller can render the result without further guarding. """ if ch_axis_data is None or ch_axis_data.dtype.fields is None: n = int(n_ch or 0) if ch_axis_data is None else int(ch_axis_data.shape[0]) return [fallback.format(index=i) for i in range(n)] present = [f for f in fields if f in ch_axis_data.dtype.fields] n = int(ch_axis_data.shape[0]) if not present: return [fallback.format(index=i) for i in range(n)] columns = [ch_axis_data[f] for f in present] names = [] for i in range(n): parts = [t for t in (_field_text(col[i]) for col in columns) if t] names.append(sep.join(parts) if parts else fallback.format(index=i)) return names