Source code for ezmsg.nwb.writer

"""
This module provides a sink node that writes incoming data to an NWB file.

Iteratively writing data to an NWB file has a few quirks.

The main approach to iterative writing in the docs -- wrapping the data source in a DataChunkIterator -- does not work
 well for our scenario. First, we would need to use some sort of queues to communicate between the data-receiving
 function and the thread / task that is yielding data to the iterator; this seems doable. Second, it's not clear if
 each DataChunkIterator must exhaust itself before moving onto the next one or if they can run in parallel; this is a
 deal-breaker for us. Third, it seems impossible to add new TimeSeries after the io.write(nwbfile) process has begun;
 this is also a deal-breaker for us.

Instead, we use the "user-defined dataset write" approach, described
`here <https://pynwb.readthedocs.io/en/stable/tutorials/advanced_io/plot_iterative_write.html#alternative-approach-user-defined-dataset-write>`_.
With this approach, we use pynwb to construct the NWB structure, but we specify the data containers (both data and
timestamps) as H5DataIO objects. After the NWB structure is written to disk, the containers expose h5py Dataset objects
that we can resize and append to as data arrive.

This approach has some quirks that we have worked around and landed on the following strategies:

A - When all incoming streams are known *a priori*

* Load metadata (shape, rate, channel labels) from a yaml or json file.
* Create all H5DataIO objects for epochs and trials: id, start_time, stop_time, and custom "label" column.
  * Append dummy event to table. Remove this step after https://github.com/hdmf-dev/hdmf/issues/1000
* Create device, electrode group, and add all known electrodes.
* Create all H5DataIO objects for continuous data (.data and .timestamps)
* Attach .data and .timestamps to new TimeSeries, or new ElectricalSeries if electrodes are known.
* Flush the NWBFile to disk -- Slow!! but necessary to create h5py Datasets
* Reopen the NWBFile in append mode -- Fast; necessary to make tables appendable, but freezes the electrodes table.
* Add any .rate attributes to the timestamps h5py datasets.
* Get references to the .data and .timestamps datasets for each continuous data appending.

B - When streams are not known *a priori*

* For the first message of a stream...
  * If it is continuous data, even size-zero
    * Create the H5DataIO objects for .data and .timestamps
    * If it is the FIRST continuous data stream with channel labels then create the device, elec group and electrodes
        * "FIRST" limitation should go away: https://github.com/hdmf-dev/hdmf/issues/1181
    * Attach .data and .timestamps to TimeSeries, or ElectricalSeries if electrodes are known (i.e. FIRST).
    * Flush the NWBFile to disk -- Slow!! but necessary to create h5py Datasets
    * Add any .rate attributes to the timestamps h5py datasets

  * If it is an event...
    * Create all H5DataIO objects for epochs and trials: id, start_time, stop_time, and custom "label" column.
    * Append dummy event to table. Remove this step after https://github.com/hdmf-dev/hdmf/issues/1000
    * Flush the NWBFile to disk -- Slow!! but necessary to create h5py Datasets
    * Reopen the NWBFile in append mode -- Fast; necessary to make tables appendable, but freezes the electrodes table.

In this case, we must call io.write(nwbfile) once for each stream, which can significantly slow down performance
for 1.5-4 seconds * number of unique streams.
Thus, we strongly recommend running NWBSink in a separate process where possible.

C - When closing a file on quit or when splitting

* When done appending to datasets and events tables, Flush the NWB file once more -- Slow!!
* If the file has no data other than the dummy event and recording is disabled, delete the file.

D - When opening the *next* file when splitting

* Copy as much information from the old NWBFile to the new NWBFile as possible.
* Close as in C.
* Follow pattern A where the metadata comes from the old datasets instead of loaded from a file.

"""

import asyncio
import dataclasses
import datetime
import os
import re
import threading
import time
import typing
import warnings
from pathlib import Path
from uuid import uuid4

import ezmsg.core as ez
import h5py
import numpy as np
import pynwb
from ezmsg.baseproc import BaseConsumerUnit, BaseStatefulConsumer, processor_state
from ezmsg.util.messages.axisarray import AxisArray
from ezmsg.util.messages.util import replace
from hdmf.backends.hdf5.h5_utils import H5DataIO

from ._dictutil import DeepDict, dict_deep_update, load_dict_from_file
from .util import (
    ReferenceClockType,
    build_nwb_fname,
)

try:
    from ezmsg.baseproc import SampleTriggerMessage

    _HAS_SAMPLE_TRIGGER = True
except ImportError:
    _HAS_SAMPLE_TRIGGER = False


[docs] class NWBSinkSettings(ez.Settings): filepath: typing.Union[str, os.PathLike] overwrite_old: bool = False axis: str = "time" recording: bool = True inc_clock: ReferenceClockType = ReferenceClockType.SYSTEM meta_yaml: typing.Optional[typing.Union[str, os.PathLike]] = None split_bytes: int = 0 expected_series: typing.Optional[typing.Union[str, os.PathLike]] = None
[docs] @dataclasses.dataclass class SeriesState: """Per-stream bookkeeping for one NWB series. ``shape`` is the trailing (non-time) shape used by :meth:`NWBSinkConsumer._check_msg_consistency`. ``data`` and ``ts`` hold the live ``h5py.Dataset`` (or pynwb container) references used by :meth:`NWBSinkConsumer._process` to append rows. ``bytes_written`` tracks cumulative append size for the file-split threshold. """ shape: typing.Tuple[int, ...] data: typing.Any = None ts: typing.Any = None bytes_written: int = 0
[docs] @processor_state class NWBSinkState: hash: int = -1 filepath: typing.Optional[Path] = None io: typing.Optional[pynwb.NWBHDF5IO] = None nwbfile: typing.Optional[pynwb.NWBFile] = None series: typing.Optional[typing.Dict[str, SeriesState]] = None start_timestamp: float = 0.0 split_count: int = 0 # Per-file map of annotation-series name → its h5py string dataset. # Reset on every ``_reset_state`` so a fresh file starts empty; the # entry is created on first sight of each ``table_name`` in an # incoming annotation row. annotation_data: typing.Optional[typing.Dict[str, typing.Any]] = None annotation_ts: typing.Optional[typing.Dict[str, typing.Any]] = None
[docs] class NWBSinkConsumer(BaseStatefulConsumer[NWBSinkSettings, AxisArray, NWBSinkState]): # Session start datetime. It should have a valid timezone and that should be UTC. shared_start_datetime: typing.Optional[datetime.datetime] = None # Session start time.time. It does not have a timezone but unqualified conversions assume local time. shared_t0: typing.Optional[float] = None shared_clock_type: typing.Optional[ReferenceClockType] = None # Fields that can change without reconstructing the NWB file. Both are # read live from ``self.settings`` inside ``_process``. Everything else # (filepath, axis, inc_clock, meta_yaml, expected_series, overwrite_old) # is consumed in ``_reset_state`` side effects; ``update_settings`` # requests a reset so the next message closes the old file and reopens. NONRESET_SETTINGS_FIELDS = frozenset({"recording", "split_bytes"})
[docs] def __init__(self, *args, settings: typing.Optional[NWBSinkSettings] = None, **kwargs): super().__init__(*args, settings=settings, **kwargs) # Serializes ``_process`` appends and ``write_annotation`` calls # from the annotation subscriber; HDF5 writes from two threads to # the same file are unsafe. self._lock = threading.RLock() # ``_current_msg`` is scratch space shared between ``_process`` and # helpers like ``_prep_continuous_io`` / ``_prep_from_meta``. Deferred # migration into state — see _prep_from_meta cleanup follow-up. self._current_msg: typing.Optional[AxisArray] = None # Eagerly open the file so construction-time errors (file exists, # permission denied, bad metadata yaml) surface immediately rather # than on the first message. Match _hash_message() so the first # inbound message does not re-trigger reset. self._reset_state(None) self._hash = 0
def _hash_message(self, message: typing.Optional[AxisArray]) -> int: # Reset is driven exclusively by settings changes via # ``_request_reset``; message identity does not force a rebuild. return 0 def _reset_state(self, message: typing.Optional[AxisArray]) -> None: """(Re)open the NWB file using the current settings. Called at construction (with ``None``) and again after ``update_settings`` flags a reset, which happens whenever any non- ``NONRESET_SETTINGS_FIELDS`` field changes. In the reset case the prior file is flushed and closed before the new one opens. """ # Settings-triggered reset: flush the prior file before rebuilding. if self._state.io is not None: self.close(write=True) self._state.filepath = Path(self.settings.filepath) self._state.series = {} self._state.start_timestamp = 0.0 self._state.split_count = 0 self._state.annotation_data = {} self._state.annotation_ts = {} self._check_filepath() self._nwb_create_or_fail() if self.settings.expected_series is not None and Path(self.settings.expected_series).expanduser().exists(): expected_series = Path(self.settings.expected_series).expanduser() meta = load_dict_from_file(expected_series) _ = self.get_session_datetime(None) self._state.start_timestamp = self.get_session_timestamp(None) self._prep_from_meta(meta) def __del__(self): if not hasattr(self, "_state"): return self.close(write=False, log=False) async def _aprocess(self, message: AxisArray) -> None: """Run _process in a thread since NWB I/O can be slow.""" await asyncio.to_thread(self._process, message) def _process(self, message: AxisArray) -> None: with self._lock: self._current_msg = message # Adjust incoming data if _HAS_SAMPLE_TRIGGER and isinstance(self._current_msg, SampleTriggerMessage): # SampleTriggerMessage. Rewrite as AxisArray. timestamp = self._current_msg.timestamp period = self._current_msg.period if period is not None and len(period) > 0: timestamp = timestamp + period[0] # Wrap value in a 2D shape (1, 1) so ``_append_events`` iterates # rows of label-iterables (matching the convention used by the # plain ``key="epochs"`` AxisArray path). A 1D shape would make # ``",".join(ev_str)`` join the characters of a single string. self._current_msg = AxisArray( data=np.array([[self._current_msg.value]]), dims=["time", "ch"], axes={"time": AxisArray.LinearAxis(gain=1.0, offset=timestamp)}, key="epochs", ) elif not hasattr(self._current_msg, "data"): return else: axis = self.settings.axis targ_ax_ix = self._current_msg.get_axis_idx(axis) if targ_ax_ix != 0: self._current_msg = replace( self._current_msg, data=np.moveaxis(self._current_msg.data, targ_ax_ix, 0), dims=[axis] + self._current_msg.dims[:targ_ax_ix] + self._current_msg.dims[targ_ax_ix + 1 :], ) # Is this a new series? b_new = self._state.io is None b_new = b_new or self._current_msg.key not in self._state.series # If inc message key is in state.series but properties do not match previous dataset properties # then close io and raise error if not b_new and not self._check_msg_consistency(): nwbfile = self._state.nwbfile b_final_write = hasattr(nwbfile, "epochs") and nwbfile.epochs is not None b_final_write = b_final_write or (hasattr(nwbfile, "trials") and nwbfile.trials is not None) self.close(write=b_final_write) raise ValueError("Data provided to NWBSink has changed shape. Closing NWB file.") if b_new: # Use first incoming timestamp to set the session start time. key = self._current_msg.key axis = self.settings.axis t0 = None if axis in ["time", "win"] or "time" in self._current_msg.axes: targ_dim = axis if axis in ["time", "win"] else "time" if hasattr(self._current_msg.axes[targ_dim], "data"): t0 = self._current_msg.axes[targ_dim].data[0] else: t0 = self._current_msg.axes[targ_dim].offset _ = self.get_session_datetime(t0) self._state.start_timestamp = self.get_session_timestamp(t0) if self.settings.inc_clock == ReferenceClockType.MONOTONIC: self._state.start_timestamp += time.monotonic() - time.time() # Create the container(s) for the new stream. if key in ["epochs", "trials"]: self._prep_event_io() self._flush_io(reopen=True) elif self._current_msg.data.dtype.type is np.str_: raise ValueError( f"Cannot stream varlen str data to series {key}. Use 'epochs' or 'trials' instead." ) else: self._prep_continuous_io() self._flush_io(reopen=True) self._update_rate_for_current() if self.settings.recording and self._current_msg.data.size: axis = self.settings.axis timestamps = None if axis in ["time", "win"] or "time" in self._current_msg.axes: targ_dim = axis if axis in ["time", "win"] else "time" time_ax = self._current_msg.axes[targ_dim] if hasattr(time_ax, "data"): timestamps = time_ax.data - self._state.start_timestamp else: timestamps = (np.arange(len(self._current_msg.data)) * time_ax.gain) + ( time_ax.offset - self._state.start_timestamp ) key = self._current_msg.key if key in ["epochs", "trials"]: self._append_events(key, timestamps, self._current_msg.data) else: series_state = self._state.series[key] # Write data dataset = series_state.data dataset.resize(len(dataset) + len(self._current_msg.data), axis=0) dataset[-len(self._current_msg.data) :] = self._current_msg.data series_state.bytes_written += self._current_msg.data.nbytes # Write timestamps if timestamps is not None: ts = series_state.ts ts.resize(len(ts) + len(timestamps), axis=0) ts[-len(timestamps) :] = timestamps series_state.bytes_written += timestamps.nbytes total_bytes = sum(s.bytes_written for s in self._state.series.values()) if 0 < self.settings.split_bytes <= total_bytes and "%d" not in str(self._state.filepath): self._state.split_count += 1 self.path_on_disk.unlink(missing_ok=True) new_nwbfile, new_meta = self._copy_nwb() self.close() self._nwb_create_or_fail(nwbfile=new_nwbfile) self._prep_from_meta(new_meta) @property def path_on_disk(self) -> Path: fp = Path(self._state.filepath) if "%d" in str(fp): return Path(re.sub("%d", "0", str(fp))) # Suffix when ``split_bytes`` is configured (uniform "<stem>_NN" naming # for the whole split-set, starting at _00) OR when a settings-driven # rotation has incremented split_count past 0 (the original file kept # its bare name, subsequent segments get "_01", "_02", ...). if self.settings.split_bytes > 0 or self._state.split_count > 0: return fp.parent / (fp.stem + f"_{self._state.split_count:02}" + fp.suffix) return fp
[docs] def get_session_datetime(self, try_t0: typing.Optional[float] = None) -> datetime.datetime: """ Retrieve session datetime. If it does not already exist, set it with try_t0 if provided and clock is understood, else with datetime.now. Args: try_t0: (Optional) The first incoming timestamp. If provided, it is used to set the session start time. The value is timezone-naive and assumed to be in the same clock as time.time(). Returns: Common session starttime among all instances of this class """ inc_clock = self.settings.inc_clock if self.__class__.shared_clock_type is not None and self.__class__.shared_clock_type != inc_clock: raise ValueError( f"All instances must share the same clock type. {inc_clock} != {self.__class__.shared_clock_type}" ) if self.__class__.shared_start_datetime is None: if try_t0 is not None and inc_clock in [ ReferenceClockType.SYSTEM, ReferenceClockType.MONOTONIC, ]: if inc_clock == ReferenceClockType.MONOTONIC: try_t0 = try_t0 - time.monotonic() + time.time() self.__class__.shared_start_datetime = datetime.datetime.fromtimestamp(try_t0, datetime.timezone.utc) else: self.__class__.shared_start_datetime = datetime.datetime.now(datetime.timezone.utc) # Latch the clock type so subsequent instances are forced to # match — otherwise the mismatch check above never fires. self.__class__.shared_clock_type = inc_clock return self.__class__.shared_start_datetime
[docs] def get_session_timestamp(self, try_t0: typing.Optional[float] = None) -> float: """ Retrieve session timestamp. If it does not already exist, set it with try_t0 if provided. Args: try_t0: (Optional) The first incoming timestamp. Returns: Common session timestamp among all instances of this class. """ inc_clock = self.settings.inc_clock if self.__class__.shared_clock_type is not None and self.__class__.shared_clock_type != inc_clock: raise ValueError( f"All instances must share the same clock type. {inc_clock} != {self.__class__.shared_clock_type}" ) if self.__class__.shared_t0 is None: if try_t0 is None or inc_clock in [ ReferenceClockType.SYSTEM, ReferenceClockType.MONOTONIC, ]: self.__class__.shared_t0 = self.get_session_datetime(try_t0).timestamp() else: self.__class__.shared_t0 = try_t0 if try_t0 is not None else 0.0 ez.logger.warning( "Clock type is UNKNOWN. Timestamps are relative to the first incoming timestamp " "but this value is NOT recoverable as it is not stored in the NWB file." ) # Latch the clock type if get_session_datetime didn't already # (the UNKNOWN branch above bypasses it). if self.__class__.shared_clock_type is None: self.__class__.shared_clock_type = inc_clock return self.__class__.shared_t0
def _check_filepath(self) -> None: """ Normalize ``self._state.filepath`` (reading the raw path from ``self.settings.filepath``). If the resolved path already exists, delete it when ``overwrite_old`` is enabled or raise otherwise. """ _suffix = ".nwb" filepath = Path(self._state.filepath) if filepath.name.startswith("."): raise FileNotFoundError( f"filepath {filepath} name begins with `.` -- cannot discriminate name from extension." ) filepath = filepath.expanduser() # If provided path is merely a directory then create a new filename. is_dir = (isinstance(filepath, Path) and filepath.is_dir()) or ( isinstance(filepath, str) and filepath[-1] == "/" ) if is_dir: meta = self._read_meta_dict() if "Subject" not in meta: meta["Subject"] = {"subject_id": "P001"} if "session_start_time" not in meta["NWBFile"]: meta["NWBFile"]["session_start_time"] = self.get_session_datetime() filepath = filepath / build_nwb_fname(meta) filepath.parent.mkdir(parents=True, exist_ok=True) if not filepath.suffix: filepath = filepath.parent / (filepath.name + _suffix) self._state.filepath = filepath if self.path_on_disk.exists(): age = (time.time() - os.path.getctime(self.path_on_disk)) / 60 ez.logger.info(f"File at {self.path_on_disk} is {age:.2f} minutes old.") if self.settings.overwrite_old: ez.logger.info("File will be overwritten.") self.path_on_disk.unlink(missing_ok=False) else: msg = "File exists but overwriting is disabled. Set overwrite_old=True to overwrite." ez.logger.error(msg) raise ValueError(msg) def _read_meta_dict(self) -> typing.Union[typing.Mapping, dict]: """ Load the metadata from ``self.settings.meta_yaml`` if that path exists, else load it from the default location. Returns: A dict containing the metadata for this NWB file. """ meta_yaml = self.settings.meta_yaml if meta_yaml is None or not Path(meta_yaml).expanduser().exists(): default_path = Path(__file__).parent meta_dict = DeepDict() for yaml_name in ["nwb_metadata", "nwb_session"]: yaml_path = default_path / f"{yaml_name}.yaml" meta_dict = dict_deep_update(meta_dict, load_dict_from_file(yaml_path)) else: yaml_path = Path(meta_yaml).expanduser() meta_dict = load_dict_from_file(yaml_path) return meta_dict def _prep_from_meta(self, meta: dict): """Pre-allocate NWB containers from a metadata dict. ``meta`` maps each stream name to ``{"fs": <hz>, "shape": <list>, "dtype": <numpy-dtype-spec>}``. Special keys ``"epochs"`` and ``"trials"`` create event tables and ignore ``dtype`` / ``fs``. ``dtype`` is optional and defaults to ``float64`` for continuous streams — picked to match the timestamp datasets and the typical units of physiological data. Args: meta: The metadata dict to use to create the NWBFile and NWBHDF5IO objects. """ def _sanitize_shape( shape: typing.Union[typing.List[int], typing.Tuple[int, ...]], ) -> typing.List[int]: shape = [0 if _ in [None, -1] else _ for _ in shape] n_zero = len([_ for _ in shape if _ == 0]) if n_zero > 1: raise ValueError(f"Cannot have more than one 0 in shape: {shape}") else: shape = [0] + [_ for _ in shape if _ != 0] return shape # Create the trials and epochs tables for key in ["epochs", "trials"]: if key in meta: ss = meta[key] shape = _sanitize_shape(ss["shape"]) self._current_msg = AxisArray( data=np.zeros(shape, dtype="U"), dims=["time", "ch"] + [f"dim{_}" for _ in range(len(shape) - 2)], axes={"time": AxisArray.CoordinateAxis(np.array([]), dims=["time"], unit="s")}, key=key, ) self._prep_event_io() for key, ss in meta.items(): if key in ("epochs", "trials"): continue shape = _sanitize_shape(ss["shape"]) # Each stream gets its own dtype from the meta dict. Reading # ``self._current_msg.data.dtype`` here used to be the bug — # ``_current_msg`` is ``None`` on the construction-time prep # path and silently carries the previous iteration's dtype on # subsequent loops, so streams with different dtypes ended up # sharing whichever type happened to land last. dtype = np.dtype(ss.get("dtype", "float64")) self._current_msg = AxisArray( data=np.zeros(shape, dtype=dtype), dims=["time", "ch"] + [f"dim{_}" for _ in range(len(shape) - 2)], axes={ "time": AxisArray.TimeAxis(fs=ss["fs"]), }, key=key, ) self._prep_continuous_io() # Flush and reopen to make h5py datasets self._flush_io(reopen=True) # Add the rate attribute to the timestamps series. Can only do this after flushing. for key, ss in meta.items(): if key in ("epochs", "trials"): continue series = self._state.nwbfile.acquisition[key] series.timestamps.attrs["rate"] = ss["fs"]
[docs] def close(self, write=False, log=True) -> None: """ Close the file. This will also delete the file if it is empty. Args: write: Set True to write the file to disk before closing. log: Set True to log the closing and deletion of the file. This must be kept False when calling from __del__. """ state = getattr(self, "_state", None) if state is None or state.io is None: return # If __init__ raised before the lock was set up, fall through without # locking; nothing else can be racing on this half-built instance. lock = getattr(self, "_lock", None) if lock is None: self._close_locked(state, write=write, log=log) return with lock: self._close_locked(state, write=write, log=log)
def _close_locked(self, state: NWBSinkState, write: bool, log: bool) -> None: if state.io is None: return nwbfile = state.nwbfile io = state.io if write: io.write(nwbfile) src_str = f"{io.source}" b_delete = sum(s.bytes_written for s in state.series.values()) == 0 for key in ["epochs", "trials"]: if hasattr(nwbfile, key) and getattr(nwbfile, key) is not None: b_delete = b_delete and len(getattr(nwbfile, key)) == 1 # EZNWB-START # Annotation rows are also "content" — keep the file even if no # acquisition data was written. if state.annotation_ts and any(len(ts) > 0 for ts in state.annotation_ts.values()): b_delete = False # empty file is the intended artifact even if no data was streamed. if self.settings.expected_series is not None: b_delete = False io.close() state.nwbfile = None state.io = None state.series = {} state.annotation_data = {} state.annotation_ts = {} if log: ez.logger.info(f"Closed file at {src_str}") if b_delete: self.path_on_disk.unlink(missing_ok=True) if log: ez.logger.info(f"Deleted empty file at {src_str}.")
[docs] def toggle_recording(self, recording: typing.Optional[bool] = None): """Deprecated. Send a ``NWBSinkSettings`` update with the desired ``recording`` value instead — the update is routed through :meth:`update_settings` and takes effect on the next message. """ warnings.warn( "NWBSinkConsumer.toggle_recording is deprecated; publish a " "NWBSinkSettings update with the desired `recording` value " "instead.", DeprecationWarning, stacklevel=2, ) new_value = recording if recording is not None else not self.settings.recording self.update_settings(dataclasses.replace(self.settings, recording=new_value))
def _check_msg_consistency(self) -> bool: axis = self.settings.axis key = self._current_msg.key series_state = self._state.series[key] in_ax = self._current_msg.axes[axis] b_rate_change = ( axis in self._current_msg.axes and series_state.ts is not None and not hasattr(in_ax, "data") and series_state.ts.attrs["rate"] != 1 / in_ax.gain ) b_shape_change = series_state.shape != self._current_msg.data.shape[1:] return not (b_rate_change or b_shape_change) def _update_rate_for_current(self): axis = self.settings.axis if axis in ["time", "win"]: time_ax = self._current_msg.axes[axis] if hasattr(time_ax, "data"): rate = 0.0 else: rate = 1 / time_ax.gain if time_ax.gain != 0 else 0 self._state.series[self._current_msg.key].ts.attrs["rate"] = rate def _copy_nwb(self) -> typing.Tuple[pynwb.NWBFile, dict]: copy_keys = [ "session_description", "session_start_time", "experimenter", "experiment_description", "session_id", "institution", "notes", "pharmacology", "protocol", "related_publications", "slices", "source_script", "source_script_file_name", "data_collection", "surgery", "virus", "stimulus_notes", "lab", ] old_nwbfile = self._state.nwbfile new_nwb_kwargs = {k: getattr(old_nwbfile, k) for k in copy_keys if hasattr(old_nwbfile, k)} new_nwb_kwargs["keywords"] = ( old_nwbfile.keywords if isinstance(old_nwbfile.keywords, list) else old_nwbfile.keywords[:].tolist() ) nwbfile = pynwb.NWBFile(identifier=str(uuid4()), **new_nwb_kwargs) nwbfile.subject = pynwb.file.Subject(**old_nwbfile.subject.fields) meta = {} for key in ["epochs", "trials"]: if hasattr(old_nwbfile, key) and getattr(old_nwbfile, key) is not None: meta[key] = {"fs": 0.0, "shape": (0, 1)} for key, ss in self._state.series.items(): if key not in ["epochs", "trials"]: # Carry dtype across the split so the rebuilt file matches # the closed one. Without this, the rebuilt series would # default to float64 in ``_prep_from_meta`` and silently # change shape/type at every split. meta[key] = { "fs": ss.ts.attrs["rate"], "shape": (0,) + ss.shape, "dtype": ss.data.dtype, } return nwbfile, meta def _nwb_create_or_fail(self, nwbfile: typing.Optional[pynwb.NWBFile] = None) -> None: """ Create the NWBFile and NWBHDF5IO for writing. Note: This does not yet write the NWBFile object to disk. """ if self.path_on_disk.exists(): raise ValueError(f"File {self.path_on_disk} already exists. Set overwrite_old=True to overwrite.") if nwbfile is None: meta_dict = self._read_meta_dict() nwbfile = pynwb.NWBFile( identifier=str(uuid4()), session_start_time=self.get_session_datetime(), **meta_dict["NWBFile"], ) if "Subject" in meta_dict: nwbfile.subject = pynwb.file.Subject(**meta_dict["Subject"]) if "%d" in str(self._state.filepath): io_file = h5py.File( name=self._state.filepath, mode="w", driver="family", memb_size=self.settings.split_bytes, ) io = pynwb.NWBHDF5IO(file=io_file, mode="w") else: io = pynwb.NWBHDF5IO(self.path_on_disk, "w") self._state.io = io self._state.nwbfile = nwbfile # Fresh series map; prior entries (if any) belong to the closed file. self._state.series = {} def _flush_io(self, reopen: bool = True): """ Write the header to the NWBFile. This step is also necessary to: * enable appending to our epochs/trials table (but only after it has an entry). * create the appendable datasets for our continuous data. """ self._state.io.write(self._state.nwbfile) if reopen: if self._state.io: self._state.io.close() if "%d" in str(self._state.filepath): io_file = h5py.File( name=self._state.filepath, mode="a", driver="family", memb_size=self.settings.split_bytes, ) io = pynwb.NWBHDF5IO(file=io_file, mode="a") else: io = pynwb.NWBHDF5IO(self.path_on_disk, "a") self._state.io = io self._state.nwbfile = self._state.io.read() # Get references to our continuous datasets for k, ss in self._state.series.items(): if k in self._state.nwbfile.acquisition: series = self._state.nwbfile.acquisition[k] if isinstance(series.data, H5DataIO): ss.data = series.data.dataset ss.ts = series.timestamps.dataset else: ss.data = series.data ss.ts = series.timestamps # Annotation series datasets share the same file handle; reopen # invalidates them, so refresh from the live nwbfile. if self._state.annotation_data is not None: for name in list(self._state.annotation_data.keys()): if name in self._state.nwbfile.acquisition: series = self._state.nwbfile.acquisition[name] self._state.annotation_data[name] = ( series.data.dataset if isinstance(series.data, H5DataIO) else series.data ) self._state.annotation_ts[name] = ( series.timestamps.dataset if isinstance(series.timestamps, H5DataIO) else series.timestamps ) def _append_events( self, key: str, timestamps: typing.Iterable[float], data: typing.Iterable[typing.Iterable[str]], ): nwbfile = self._state.nwbfile fun = {"epochs": nwbfile.add_epoch, "trials": nwbfile.add_trial}[key] for ev_t, ev_str in zip(timestamps, data): fun(start_time=ev_t, stop_time=ev_t + 0, **{"label": ",".join(ev_str)}) # ------------------------------------------------------------------ # Annotation series writer # # The unit's ``INPUT_ANNOTATION`` subscriber forwards anything with a # ``flatten_for_table()`` method (e.g. ``PipelineSettingsEvent``) to # ``write_annotation``. Each row becomes one entry in a per-file # ``pynwb.misc.AnnotationSeries`` named after ``msg.table_name``, # auto-created on first sight and appended to thereafter. # ------------------------------------------------------------------ def _prep_annotation_series(self, name: str, description: str = "") -> None: """Create an empty appendable ``AnnotationSeries`` and stash its datasets.""" if self._state.annotation_data is None: self._state.annotation_data = {} self._state.annotation_ts = {} if name in self._state.annotation_data: return nwbfile = self._state.nwbfile if nwbfile is None: raise RuntimeError("Cannot prepare annotation series before NWB file is open.") dataio = H5DataIO( data=np.array([], dtype=h5py.string_dtype()), maxshape=(None,), chunks=True, ) tsio = H5DataIO( shape=(0,), dtype=np.float64, maxshape=(None,), fillvalue=np.nan, ) series = pynwb.misc.AnnotationSeries( name=name, data=dataio, timestamps=tsio, description=description or f"Annotations written by NWBSink to '{name}'", ) nwbfile.add_acquisition(series) # Flush + reopen to materialize h5py datasets we can append to. self._flush_io(reopen=True) live_series = self._state.nwbfile.acquisition[name] data_ds = live_series.data.dataset if isinstance(live_series.data, H5DataIO) else live_series.data ts_ds = ( live_series.timestamps.dataset if isinstance(live_series.timestamps, H5DataIO) else live_series.timestamps ) self._state.annotation_data[name] = data_ds self._state.annotation_ts[name] = ts_ds
[docs] def write_annotation(self, table_name: str, timestamp: float, data: str) -> None: """Append one row to the named ``AnnotationSeries``, creating it if needed. ``timestamp`` is wall-clock seconds. The value stored in the file is ``timestamp - start_timestamp`` once a data message has set ``start_timestamp`` (the file-relative convention NWB expects); before then, the wall-clock value is passed through unmodified — the sink has no baseline to subtract against, and inventing one (e.g. from ``datetime.now()``) would silently desync from the eventual data anchor. Annotations written before any data arrives therefore carry whatever baseline their producer chose; downstream readers should treat them accordingly. """ with self._lock: if self._state.io is None: # File closed (between reset and first message); silently drop. return if self._state.annotation_data is None or table_name not in self._state.annotation_data: self._prep_annotation_series(table_name) if self._state.start_timestamp != 0.0: relative_ts = float(timestamp) - self._state.start_timestamp else: relative_ts = float(timestamp) data_ds = self._state.annotation_data[table_name] ts_ds = self._state.annotation_ts[table_name] data_ds.resize(len(data_ds) + 1, axis=0) data_ds[-1] = data ts_ds.resize(len(ts_ds) + 1, axis=0) ts_ds[-1] = relative_ts
def _prep_event_io(self): """ Prepare the NWB file to receive event data, either "epochs" or "trials". """ colname = "label" key = self._current_msg.key nwbfile = self._state.nwbfile fun = { "epochs": nwbfile.add_epoch_column, "trials": nwbfile.add_trial_column, }[key] fun(name=colname, description=f"{colname} {key}") self._state.series[key] = SeriesState(shape=self._current_msg.data.shape[1:]) table = {"epochs": nwbfile.epochs, "trials": nwbfile.trials}[key] table.id.set_data_io(H5DataIO, {"maxshape": (None,)}) table.start_time.set_data_io(H5DataIO, {"maxshape": (None,)}) table.stop_time.set_data_io(H5DataIO, {"maxshape": (None,)}) getattr(table, colname).set_data_io(H5DataIO, {"maxshape": (None,)}) # Note: We cannot io.write(...) yet because a bug in hdmf requires that a custom column # must have data before it may be written: # https://github.com/hdmf-dev/hdmf/issues/1000 # So we append a dummy event self._append_events(key, [0.0], [["EZNWB-START"]]) def _prep_continuous_io(self): """ Prepare NWB file to receive continuous data into a pynwb.TimeSeries (or pynwb.ecephys.ElectricalSeries if channel info is provided). """ key = self._current_msg.key nwbfile = self._state.nwbfile targ_ax_ix = self._current_msg.get_axis_idx(self.settings.axis) shape = self._current_msg.data.shape[:targ_ax_ix] + self._current_msg.data.shape[targ_ax_ix + 1 :] self._state.series[key] = SeriesState(shape=shape) dataio = H5DataIO( shape=(0,) + shape, dtype=self._current_msg.data.dtype, maxshape=(None,) + shape, fillvalue=np.nan, ) tsio = H5DataIO( shape=(0,), dtype=np.float64, maxshape=(None,), fillvalue=np.nan, ) series_description = "created by ezmsg nwbsink" # If channel label info is provided then we create an electrode table and our timeseries is # a pynwb.ecephys.ElectricalSeries. if ( "ch" in self._current_msg.axes and hasattr(self._current_msg.axes["ch"], "data") and len(self._current_msg.axes["ch"].data) ): b_first = nwbfile.electrodes is None or "label" not in nwbfile.electrodes.colnames if b_first: nwbfile.add_electrode_column(name="label", description="electrode label") dev_name = "unified device" if dev_name in nwbfile.devices: device = nwbfile.devices[dev_name] else: device = nwbfile.create_device(name=dev_name, description="created by ezmsg nwbsink") el_grp_name = "unified electrode group" if el_grp_name in nwbfile.electrode_groups: el_grp = nwbfile.electrode_groups[el_grp_name] else: el_grp = nwbfile.create_electrode_group( name=el_grp_name, description="electrode group created by ezmsg nwbsink", device=device, location="unknown", ) if not b_first: self._flush_io(reopen=True) # _flush_io swaps nwbfile out from under us; refresh. nwbfile = self._state.nwbfile el_df = nwbfile.electrodes.to_dataframe() el_df = el_df[el_df["group"] == el_grp] for ll in self._current_msg.axes["ch"].data: if ll not in el_df["label"].values: nwbfile.add_electrode(label=ll, location="unknown", group=el_grp) if type(nwbfile.electrodes.id.data) is list: for fn in ["id", "location", "group_name", "group", "label"]: getattr(nwbfile.electrodes, fn).set_data_io(H5DataIO, {"maxshape": (None,), "chunks": True}) el_df = nwbfile.electrodes.to_dataframe() el_df = el_df[el_df["group"] == el_grp] b_in = el_df["label"].isin(self._current_msg.axes["ch"].data) el_tbl_region = nwbfile.create_electrode_table_region( region=el_df[b_in].index.tolist(), description=f"electrodes for {key}", ) series = pynwb.ecephys.ElectricalSeries( name=key, data=dataio, timestamps=tsio, conversion=1e-6, electrodes=el_tbl_region, description=series_description, ) else: series = pynwb.TimeSeries( name=key, data=dataio, timestamps=tsio, unit="volts", conversion=1e-6, description=series_description, ) nwbfile.add_acquisition(series)
[docs] class NWBSink(BaseConsumerUnit[NWBSinkSettings, AxisArray, NWBSinkConsumer]): SETTINGS = NWBSinkSettings # Accept any object exposing the ``NWBPointRow`` shape (``table_name``, # ``timestamp``, ``flatten_for_table`` returning ``{"data": str}``). # Typed as ``object`` so the sink is open to multiple producer types # (pipeline settings, user-defined annotation messages, …) without # requiring a base class. Routing is by duck-typing in ``on_annotation``. INPUT_ANNOTATION = ez.InputStream(object)
[docs] @ez.subscriber(INPUT_ANNOTATION) async def on_annotation(self, msg: typing.Any) -> None: """Append one row to the per-table ``AnnotationSeries`` named by ``msg.table_name``. Ignores messages that don't quack like an ``NWBPointRow`` so other traffic on this stream (if any) doesn't crash the sink. Any exception from the writer is logged and swallowed — annotation logging is best-effort and must not break primary acquisition recording. """ flatten = getattr(msg, "flatten_for_table", None) table_name = getattr(msg, "table_name", None) timestamp = getattr(msg, "timestamp", None) if not callable(flatten) or not isinstance(table_name, str) or not isinstance(timestamp, (int, float)): return try: row = flatten() except Exception as exc: ez.logger.warning(f"{self.address} annotation flatten_for_table raised: {exc}") return if row is None: # Producer's signal that this is a control message (e.g. the # ``INIT_FINAL_COMPONENT_ADDRESS`` sentinel from the pipeline- # settings producer). No row to write; silently skip. return data = row.get("data") if isinstance(row, typing.Mapping) else None if not isinstance(data, str): ez.logger.warning( f"{self.address} annotation row missing 'data: str' (got {type(data).__name__}); skipping." ) return try: await asyncio.to_thread(self.processor.write_annotation, table_name, float(timestamp), data) except Exception as exc: ez.logger.warning(f"{self.address} failed to write annotation to '{table_name}': {exc}")
[docs] async def shutdown(self) -> None: await super().shutdown() self.processor.close()