Skip to content

Read EventsTable back when iterating an NWB file (keeping AnnotationSeries support) #15

Description

@cboulay

Summary

Neither NWBSlicer nor NWBAxisArrayIterator knows about EventsTable. Once #14 lands and annotations are written to the top-level /events group, they will be invisible on readback. We should discover EventsTable instances as event streams, and — for files written before the migration — keep discovering AnnotationSeries too.

Why EventsTable won't be found today

NWBSlicer._load() (src/ezmsg/nwb/slicer.py:200) discovers exactly two kinds of thing:

  1. Interval tablesgetattr(nwbfile, "intervals", None), iterated at slicer.py:232-258, producing StreamInfo(is_event=True, table_ref=table).
  2. TimeSeries_extract_timeseries_from_container() over the root and each processing module (slicer.py:261-263).

An EventsTable lives in nwbfile.events, not nwbfile.intervals, and is not a TimeSeries — so it falls through both. Even if it were routed into the interval branch it would break immediately: that code reads table.start_time / table.stop_time, and an EventsTable has a single timestamp column instead.

AnnotationSeries, by contrast, is a TimeSeries subclass, so it is currently picked up by the timeseries branch — with rate inferred from np.diff(timestamps) variance (slicer.py:300-304), which for sparse annotations lands on rate=0.0 and hence a CoordinateAxis. That's roughly the right behaviour, but it's incidental rather than intentional and it's untested through the slicer/iterator (the existing tests at tests/test_integration.py:436 and tests/test_writer.py:580,651 assert on the raw pynwb container, never on iterated messages).

Proposed change

Discovery (NWBSlicer._load)

  • Add a third discovery pass over getattr(nwbfile, "events", None), emitting StreamInfo(is_event=True, ...) per EventsTable, honouring the stream_keys filter the same way the interval pass does.
  • Columns: use the timestamp column for the time vector; project the remaining columns (e.g. annotation, plus any user-added columns) into the ch CoordinateAxis and a stringified dset, mirroring slicer.py:238-239.
  • stop_time: interval tables use table.stop_time[-1]; an EventsTable has no duration by default (there is an optional DurationVectorData). Use timestamp[-1] (plus duration[-1] when a duration column is present).
  • Keep the existing AnnotationSeries path working. Preferably make it explicit — recognize pynwb.misc.AnnotationSeries in the timeseries branch and force rate=0.0 / event-like handling rather than relying on the variance heuristic to land there by accident.

Abstract the event time accessor

table.start_time is hardcoded in three places on the event path and needs to become column-agnostic (start_time for TimeIntervals, timestamp for EventsTable):

  • slicer.py:236-237 (start/stop time bounds)
  • slicer.py:472 (read_by_time)
  • iterator.py:110 (_build_chunk_messages_static)

Simplest fix: store the resolved timestamp array on StreamInfo.timestamps (already done) and use that in the two read paths instead of re-reaching into table_ref. That would let table_ref become purely informational and remove the coupling entirely.

Iterator

_preload() (iterator.py:305-310) and _build_chunk_messages_static() (iterator.py:107-127) already branch on info.is_event generically, so once discovery produces well-formed event StreamInfos they should need little or no change beyond the table.start_time fix above. Worth confirming the one-message-per-event emission is the behaviour we want for annotations, or whether all events in a chunk should coalesce into a single message.

Backwards compatibility

  • Files written by the current writer keep working: AnnotationSeries in /acquisition is still discovered.
  • Files written after Migrate annotation writing from deprecated AnnotationSeries to EventsTable #14 expose EventsTable in /events.
  • A file could contain both (e.g. written across a version bump, or an AnnotationSeries from another tool alongside our EventsTable). Both should surface as independent streams. Decide what happens on a stream-key collision — probably let the NWB container names disambiguate, since they can't collide within one file across different groups... except that our stream dict is keyed by bare name. Worth an explicit tie-break rule.
  • pynwb.event only exists on pynwb >= 4.0.0, so the new discovery pass must be import-guarded (or gated on the dependency floor decided in Migrate annotation writing from deprecated AnnotationSeries to EventsTable #14) if we support older pynwb on the read side.

Acceptance criteria

  • An EventsTable in /events is discovered by NWBSlicer and appears in stream_names.
  • NWBSlicer.read_by_time() returns its events with correct absolute timestamps (ts_off applied).
  • NWBAxisArrayIterator emits those events in the correct chunks, interleaved with continuous streams.
  • An AnnotationSeries in /acquisition is still discovered and read back — with a test that goes through the slicer/iterator, not just raw pynwb.
  • A file containing both types yields both streams.
  • stream_keys filtering applies to EventsTable streams.
  • EventsTable extra columns (beyond timestamp/annotation) surface on the ch axis.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions