aqnwb 0.4.0
Loading...
Searching...
No Matches
Acquiring Event Data 📋

Overview

AqNWB provides the EventsTable type for recording discrete events (such as TTL pulses, licks, rewards, stimulus onsets, or other event-based data), where each event is anchored at a single timestamp. An optional duration column can be added when the length of each event is known. Additional user-defined columns can be appended to store any per-event metadata.

Each EventsTable should hold events of a single type or related types so that all rows share the same set of columns. Event types with different metadata needs (e.g., behavioral events and TTL pulses) should be stored in separate EventsTable instances.

AqNWB supports two complementary acquisition patterns:

Pattern When to use
Row-based (addRow / addRows) Events arrive one at a time during acquisition; the total count is not known in advance.
Column-based (write full column vectors) All event data are available in memory at once and can be written in a single bulk call per column. Add additional complete columns after acquisition is completed.

Both patterns share the same setup steps (I/O, NWBFile, table creation, and startRecording) and differ only in how data are written to the table. The two patterns are not exclusive and hybrid approaches are possible, but require caution. In particular when using column-based append, we must ensure that all columns have been appropriately updated such that all columns have indeed the same number rows before we use addRow or addRows to avoid inconsistent data.

Note
Chunked storage is required for when the total number of rows is not known in advance and we need to expand the columns at runtime. The rowChunkSize parameter of the createDefaultDataSpecs controls how many rows are stored per HDF5 chunk. In practice, the number of rows per chunk should typically be the same for all columns of a table. The chunking behavior can also be customized via the individual DataSpec of the columns. Avoid creating too small chunks (e.g., single rows) as well as too large chunks (e.g., millions of rows per chunk).

Setup: I/O, NWBFile, and table creation

1. Create the I/O object

std::shared_ptr<BaseIO> io = createIO("HDF5", path);
io->open();

2. Create the NWBFile

auto nwbfile = NWB::NWBFile::create(io);
auto subjectSpec =
.withSubjectId("mouse001")
.withSpecies("Mus musculus")
.withSex("M")
.withAge("P90D")
"Wild type mouse used for electrophysiology study");
std::string currentTime = getCurrentTime();
Status initStatus = nwbfile->initialize(generateUuid(),
"a recording session",
"data collection info",
currentTime,
currentTime,
subjectSpec);
AQNWB::checkStatus(initStatus, "NWBFile initialization");

3. Configure and create the EventsTable

Before creating the table, build a list of column specifications using EventsTable::createDefaultDataSpecs. This factory returns the default set of columns (always including the id and timestamp columns) and allows you to decide to include the optional duration and annotation columns and control the HDF5 chunk size used for each column.

To add a custom column to the table, create a VectorData::DataSpec (or a spec for any other VectorData subtype) and push it onto the spec vector before passing it to NWBFile::createEventsTable. The spec bundles the column name, dataset configuration (data type, initial size, chunk size), and description in one object. Once the spec list is ready, pass it to NWBFile::createEventsTable together with the table name, description, and an optional source description:

// Create an EventsTable for lick events. The table will live at
// /events/licks inside the NWB file. We request a timestamp column
// (resolution 1/30000 s) and an optional annotation column; no duration
// column is needed for this event type.
float timestampResolution = 1.0f / 30000.0f;
timestampResolution,
false, // omit duration column
std::nullopt, // duration resolution is ignored since no duration
// column
true, // create annotation column
100); // number of rows in a chunk (chunked storage is required to
// support append when the total number of rows is not known in
// advance)
// Optionally add a custom column to the spec list before creating the
// table. Here we add a float32 "confidence" column with a chunk size of 100
// rows.
IO::ArrayDataSetConfig confidenceConfig(
BaseDataType::F32, // data type
SizeArray {0}, // initial size (0 = extensible)
SizeArray {100}); // chunk size
columnSpecs.push_back(NWB::VectorData::createDataSpec(
"confidence", confidenceConfig, "Detection confidence score [0, 1]."));
auto eventsTable = nwbfile->createEventsTable(
"licks", // name of the table
"Lick events detected from the lickometer signal.", // description
"Thresholding of lickometer analog signal at 1.5 V", // source
// description
columnSpecs); // pre-built column spec list
Note
If you do not require custom columns (or want to create them later via addColumn), then you can bypass the creation of the table configuration and instead configure the table directly via NWBFile::createEventsTable.

4. Start the recording

Status startStatus = io->startRecording();
Note
When using HDF5IO, calling startRecording enables SWMR mode by default. No new datasets or groups can be added after this point unless the file is closed and reopened.

Pattern 1: Row-based acquisition

Use this pattern when events are detected one at a time during an ongoing recording session and the total event count is not known in advance.

Add rows

Call addRow to append a single event, or addRows to append several events at once. Each row is expressed as a DynamicTable::RowData, which is an std::unordered_map<std::string, CellValue> that maps column names to typed scalar values. Row IDs are auto-generated (0, 1, 2, …) when not explicitly supplied.

// During acquisition, append individual rows, each
// representing a detected event using addRow().
// Each row is a map from column name to value. The row ID is
// auto-generated (0, 1, 2, …) when not supplied.
// All columns — including custom ones — must be provided for every row.
AQNWB::Types::RowData row0 = {{"timestamp", 0.123f},
{"annotation", std::string("lick")},
{"confidence", 0.95f}};
Status s0 = eventsTable->addRow(row0);
// We can also append multiple rows at once using addRows().
std::vector<AQNWB::Types::RowData> moreRows = {
{{"timestamp", 0.456f},
{"annotation", std::string("lick")},
{"confidence", 0.87f}},
{{"timestamp", 0.789f},
{"annotation", std::string("lick")},
{"confidence", 0.91f}}};
Status s1 = eventsTable->addRows(moreRows);
io->flush(); // optional, flush data to disk
Note
addRow and addRows write data immediately to the underlying HDF5 datasets by extending the chunked arrays one row (or batch of rows) at a time. Call io->flush() at any point to ensure data are moved to disk.

Stop the recording

io->stopRecording();
io->close();

Pattern 2: Column-based (bulk) acquisition

Use this pattern when all event data are collected in memory first and can be written to the file in a single pass. This is common for post-acquisition or offline processing pipelines or when events are buffered and flushed at the end of a trial.

Add data to columns

After startRecording, retrieve each column object via the corresponding read*Column() accessor on the table, then call recordData on the column and writeDataBlock to write the full column vector in one call. Finally, set the row IDs with setRowIDs.

// Create an EventsTable for stimulus onset events. Here we include both
// a timestamp column and a duration column (resolution 1/30000 s).
float timestampResolution = 1.0f / 30000.0f;
float durationResolution = 1.0f / 30000.0f;
auto columnSpecs =
true,
durationResolution,
false, // no annotation column
100); // row chunk size
auto eventsTable =
nwbfile->createEventsTable("stimulus_onsets",
"Stimulus onset events for visual gratings.",
"Hardware TTL pulse on channel 1",
columnSpecs);

Custom columns that were not part of the original spec list can be added after table creation but before startRecording() using addColumn. Create a VectorData object, initialize it with an ArrayDataSetConfig, and pass it to addColumn:

// Custom columns can also be added after table creation but before
// startRecording() using addColumn(DataSpecPtr). This is the recommended
// approach and is consistent with the DataSpec-based approach used by
// initialize(). The alternative approach would be to create VectorData
// columns directly and then adding them via addColumn().
auto conditionSpec = NWB::VectorData::createDataSpec(
"condition",
IO::ArrayDataSetConfig(BaseDataType::V_STR, // variable-length string
SizeArray {0}, // initial size (0 = extensible)
SizeArray {100}), // chunk size
"Stimulus condition label.");
Status addColStatus = eventsTable->addColumn(conditionSpec);
REQUIRE(addColStatus == Status::Success);
Status startStatus = io->startRecording();

Write all columns — including any custom ones — after startRecording:

// After the recording session, write all event data as full columns in a
// single call per column. This is efficient when the complete dataset is
// available in memory at write time.
std::vector<float> timestamps = {0.100f, 0.600f, 1.100f, 1.600f};
std::vector<float> durations = {0.250f, 0.250f, 0.250f, 0.250f};
std::vector<std::string> conditions = {
"grating_0", "grating_90", "grating_0", "grating_90"};
std::vector<int> rowIds = {0, 1, 2, 3};
SizeArray dataShape = {timestamps.size()};
SizeArray positionOffset = {0};
// Write the timestamp column
auto timestampColumn = eventsTable->readTimestampColumn();
Status tsStatus = timestampColumn->recordData()->writeDataBlock(
dataShape, positionOffset, BaseDataType::F32, timestamps.data());
REQUIRE(tsStatus == Status::Success);
// Write the duration column
auto durationColumn = eventsTable->readDurationColumn();
Status durStatus = durationColumn->recordData()->writeDataBlock(
dataShape, positionOffset, BaseDataType::F32, durations.data());
REQUIRE(durStatus == Status::Success);
// Write the custom "condition" column
auto conditionCol = eventsTable->readColumn<NWB::VectorData>("condition");
Status condStatus = conditionCol->recordData()->writeDataBlock(
dataShape, positionOffset, BaseDataType::V_STR, conditions);
REQUIRE(condStatus == Status::Success);
// Write the row IDs
Status idStatus = eventsTable->setRowIDs(rowIds);
REQUIRE(idStatus == Status::Success);
io->flush(); // optional, flush data to disk

Stop the recording

io->stopRecording();
io->close();

Annotating columns with a MeaningsTable

When an EventsTable column stores coded values (e.g., integer event-type codes, stimulus IDs, or category labels), a MeaningsTable can be attached to that column to document what each code means. The MeaningsTable lists every possible value together with a human-readable description, making the data self-documenting and easier to interpret.

Setup: create the EventsTable with a coded column

First, create the EventsTable as usual and include the column whose values you want to annotate. In this example we add an integer event_type column:

// Create an EventsTable for behavioral events that have an "event_type"
// column storing integer codes. We will attach a MeaningsTable to that
// column so that readers can look up what each integer code means.
float timestampResolution = 1.0f / 30000.0f;
timestampResolution,
false,
std::nullopt, // omit duration column
false, // no annotation column
100); // row chunk size
// Add an integer "event_type" column that stores event-type codes.
IO::ArrayDataSetConfig eventTypeConfig(
BaseDataType::I32, // integer event-type codes
SizeArray {0}, // initial size (0 = extensible / chunked)
SizeArray {100}); // chunk size
columnSpecs.push_back(NWB::VectorData::createDataSpec(
"event_type",
eventTypeConfig,
"Integer code identifying the type of behavioral event."));
auto eventsTable =
nwbfile->createEventsTable("behavioral_events",
"Behavioral events with typed event codes.",
"Behavioral monitoring system",
columnSpecs);

Create the MeaningsTable

Before calling startRecording(), call createMeaningsTable on the EventsTable (which is a DynamicTable) and pass the name of the column to annotate. The method automatically:

  • creates the MeaningsTable at the path meanings_tables/<columnName>_meanings inside the table group,
  • adds value and meaning columns with the correct data type, and
  • links the MeaningsTable back to the target VectorData column.

Since MeaningsTable is a subclass of DynamicTable, it supports the same approaches to add custom columns via addColumn as well as the same row- and column-based write patterns described above.

// Before startRecording(), create a MeaningsTable for the "event_type"
// column. The MeaningsTable is stored at
// "meanings_tables/event_type_meanings" inside the EventsTable group and
// maps each integer code to a human-readable label.
auto meaningsTable = eventsTable->createMeaningsTable("event_type");

Start the recording

Status startStatus = io->startRecording();

Write event data

Write the event rows to the EventsTable using addRow / addRows as usual:

// Write event rows using addRows(). Each row supplies a timestamp and the
// integer event_type code.
std::vector<AQNWB::Types::RowData> eventRows = {
{{"timestamp", 0.100f}, {"event_type", 1}},
{{"timestamp", 0.350f}, {"event_type", 2}},
{{"timestamp", 0.700f}, {"event_type", 1}},
{{"timestamp", 1.200f}, {"event_type", 3}},
};
Status eventsStatus = eventsTable->addRows(eventRows);

Write meanings data

Then populate the MeaningsTable by writing the value and meaning columns. All possible values of the coded column may be listed, even if they do not appear in the recorded data. Since meanings only list values, we do not need to record the events first, i.e., if the meanings data is known beforehand, then we can also populate the MeaningsTable before writing the data to the main table.

// Populate the MeaningsTable: list every possible event_type code together
// with its human-readable meaning. All possible values should be present
// even if they do not appear in the recorded data.
std::vector<AQNWB::Types::RowData> meaningsRows = {
{{"value", 1}, {"meaning", "lick"}},
{{"value", 2}, {"meaning", "reward_delivery"}},
{{"value", 3}, {"meaning", "air_puff"}},
};
Status meaningsStatus = meaningsTable->addRows(meaningsRows);
io->flush(); // optional, flush data to disk

Stop the recording

io->stopRecording();
io->close();

Tips and best practices

  • One table per event type. Keep event types with different metadata, e.g., lick events, TTL, or reward deliveries in separate EventsTable instances so that every row in a table shares the same column schema.
  • Choose the right chunk size. For row-based acquisition, a rowChunkSize of 100–1000 may be a good trade-off between write and read performance. Larger chunks reduce overhead when reading but can waste space if the chunks are not aligned well with rows of the table.
  • Avoid many small write operations: Depending on the number and rate of events, it may be useful to collect multiple rows in memory when using row-based acquisition in order to use addRows to write larger blocks of data to avoid many small I/O operations and repeated updates to the same chunks in the file.
  • Customize the column layout. Call EventsTable::createDefaultDataSpecs to obtain the default spec vector, modify individual entries (e.g., change the chunk size of a specific column), and pass the modified vector to NWBFile::createEventsTable.
  • Add custom columns. After table creation (but before startRecording), call DynamicTable::addColumn to attach additional VectorData columns for any per-event metadata not covered by the built-in columns.
  • Document coded columns with a MeaningsTable. When a column stores integer codes or other enumerated values, call createMeaningsTable before startRecording to attach a MeaningsTable that maps every possible code to a human-readable label. You may list all possible values in the MeaningsTable, even if a particular allowed value does not appear in the recorded data.