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 subjectSpec =
"Wild type mouse used for electrophysiology study");
"a recording session",
"data collection info",
currentTime,
currentTime,
subjectSpec);
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:
float timestampResolution = 1.0f / 30000.0f;
timestampResolution,
false,
std::nullopt,
true,
100);
BaseDataType::F32,
"confidence", confidenceConfig, "Detection confidence score [0, 1]."));
auto eventsTable = nwbfile->createEventsTable(
"licks",
"Lick events detected from the lickometer signal.",
"Thresholding of lickometer analog signal at 1.5 V",
columnSpecs);
- 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.
{"annotation", std::string("lick")},
{"confidence", 0.95f}};
Status s0 = eventsTable->addRow(row0);
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();
- 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.
float timestampResolution = 1.0f / 30000.0f;
float durationResolution = 1.0f / 30000.0f;
auto columnSpecs =
true,
durationResolution,
false,
100);
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:
"condition",
"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:
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};
auto timestampColumn = eventsTable->readTimestampColumn();
Status tsStatus = timestampColumn->recordData()->writeDataBlock(
dataShape, positionOffset, BaseDataType::F32, timestamps.data());
REQUIRE(tsStatus == Status::Success);
auto durationColumn = eventsTable->readDurationColumn();
Status durStatus = durationColumn->recordData()->writeDataBlock(
dataShape, positionOffset, BaseDataType::F32, durations.data());
REQUIRE(durStatus == Status::Success);
Status condStatus = conditionCol->recordData()->writeDataBlock(
dataShape, positionOffset, BaseDataType::V_STR, conditions);
REQUIRE(condStatus == Status::Success);
Status idStatus = eventsTable->setRowIDs(rowIds);
REQUIRE(idStatus == Status::Success);
io->flush();
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:
float timestampResolution = 1.0f / 30000.0f;
timestampResolution,
false,
std::nullopt,
false,
100);
BaseDataType::I32,
"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.
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:
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.
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();
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.