diff --git a/CMakeLists.txt b/CMakeLists.txt index 8fbe6787..f2287f36 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,6 +32,7 @@ add_library( src/nwb/ecephys/SpikeEventSeries.cpp src/nwb/file/ElectrodeGroup.cpp src/nwb/file/ElectrodeTable.cpp + src/nwb/misc/AnnotationSeries.cpp src/nwb/hdmf/base/Container.cpp src/nwb/hdmf/base/Data.cpp src/nwb/hdmf/table/DynamicTable.cpp diff --git a/src/io/hdf5/HDF5RecordingData.cpp b/src/io/hdf5/HDF5RecordingData.cpp index c7f6d8b6..7fc80e90 100644 --- a/src/io/hdf5/HDF5RecordingData.cpp +++ b/src/io/hdf5/HDF5RecordingData.cpp @@ -53,7 +53,8 @@ Status HDF5RecordingData::writeDataBlock( || type.type == BaseDataType::Type::T_STR) { std::cerr << "HDF5RecordingData::writeDataBlock called for string data, " - "use HDF5RecordingData::writeStringDataBlock instead." + "use HDF5RecordingData::writeDataBlock with a string array " + "data input instead of void* data." << std::endl; return Status::Failure; } diff --git a/src/nwb/NWBFile.cpp b/src/nwb/NWBFile.cpp index 4d3b4743..dc23df75 100644 --- a/src/nwb/NWBFile.cpp +++ b/src/nwb/NWBFile.cpp @@ -16,6 +16,7 @@ #include "nwb/ecephys/ElectricalSeries.hpp" #include "nwb/ecephys/SpikeEventSeries.hpp" #include "nwb/file/ElectrodeGroup.hpp" +#include "nwb/misc/AnnotationSeries.hpp" #include "spec/core.hpp" #include "spec/hdmf_common.hpp" #include "spec/hdmf_experimental.hpp" @@ -298,6 +299,35 @@ Status NWBFile::createSpikeEventSeries( return Status::Success; } +Status NWBFile::createAnnotationSeries(std::vector recordingNames, + RecordingContainers* recordingContainers, + std::vector& containerIndexes) +{ + if (!m_io->canModifyObjects()) { + return Status::Failure; + } + + for (size_t i = 0; i < recordingNames.size(); ++i) { + const std::string& recordingName = recordingNames[i]; + + std::string annotationSeriesPath = + AQNWB::mergePaths(acquisitionPath, recordingName); + + // Setup annotation series datasets + auto annotationSeries = + std::make_unique(annotationSeriesPath, m_io); + annotationSeries->initialize( + "Stores user annotations made during an experiment", + "no comments", + SizeArray {0}, + SizeArray {CHUNK_XSIZE}); + recordingContainers->addContainer(std::move(annotationSeries)); + containerIndexes.push_back(recordingContainers->size() - 1); + } + + return Status::Success; +} + template void NWBFile::cacheSpecifications( const std::string& specPath, diff --git a/src/nwb/NWBFile.hpp b/src/nwb/NWBFile.hpp index a9e014ce..69ed79bf 100644 --- a/src/nwb/NWBFile.hpp +++ b/src/nwb/NWBFile.hpp @@ -131,6 +131,20 @@ class NWBFile : public Container RecordingContainers* recordingContainers = nullptr, std::vector& containerIndexes = emptyContainerIndexes); + /** @brief Create AnnotationSeries objects to record data into. + * Created objects are stored in recordingContainers. + * @param recordingNames vector indicating the names of the AnnotationSeries + * within the acquisition group + * @param recordingContainers The container to store the created TimeSeries. + * @param containerIndexes The indexes of the containers added to + * recordingContainers + * @return Status The status of the object creation operation. + */ + Status createAnnotationSeries( + std::vector recordingNames, + RecordingContainers* recordingContainers = nullptr, + std::vector& containerIndexes = emptyContainerIndexes); + protected: /** * @brief Creates the default file structure. diff --git a/src/nwb/RecordingContainers.cpp b/src/nwb/RecordingContainers.cpp index dc21d29e..f65b785e 100644 --- a/src/nwb/RecordingContainers.cpp +++ b/src/nwb/RecordingContainers.cpp @@ -4,6 +4,7 @@ #include "nwb/ecephys/ElectricalSeries.hpp" #include "nwb/ecephys/SpikeEventSeries.hpp" #include "nwb/hdmf/base/Container.hpp" +#include "nwb/misc/AnnotationSeries.hpp" using namespace AQNWB::NWB; // Recording Container @@ -86,3 +87,19 @@ Status RecordingContainers::writeSpikeEventData(const SizeType& containerInd, return ses->writeSpike( numSamples, numChannels, data, timestamps, controlInput); } + +Status RecordingContainers::writeAnnotationSeriesData( + const SizeType& containerInd, + const SizeType& numSamples, + const std::vector data, + const void* timestamps, + const void* controlInput) +{ + AnnotationSeries* as = + dynamic_cast(getContainer(containerInd)); + + if (as == nullptr) + return Status::Failure; + + return as->writeAnnotation(numSamples, data, timestamps, controlInput); +} \ No newline at end of file diff --git a/src/nwb/RecordingContainers.hpp b/src/nwb/RecordingContainers.hpp index 33f2b1da..f57bcf0d 100644 --- a/src/nwb/RecordingContainers.hpp +++ b/src/nwb/RecordingContainers.hpp @@ -111,6 +111,22 @@ class RecordingContainers const void* timestamps, const void* controlInput = nullptr); + /** + * @brief Write AnnotationSeries data to a recordingContainer dataset. + * @param containerInd The index of the AnnotationSeries dataset within the + * AnnotationSeries containers. + * @param numSamples Number of samples in the time for the single event. + * @param data A vector of strings of data to write. + * @param timestamps A pointer to the timestamps block + * @param controlInput A pointer to the control block data (optional) + * @return The status of the write operation. + */ + Status writeAnnotationSeriesData(const SizeType& containerInd, + const SizeType& numSamples, + const std::vector data, + const void* timestamps, + const void* controlInput = nullptr); + /** * @brief Get the number of recording containers */ diff --git a/src/nwb/misc/AnnotationSeries.cpp b/src/nwb/misc/AnnotationSeries.cpp new file mode 100644 index 00000000..5e5db340 --- /dev/null +++ b/src/nwb/misc/AnnotationSeries.cpp @@ -0,0 +1,71 @@ + +#include "nwb/misc/AnnotationSeries.hpp" + +#include "Utils.hpp" + +using namespace AQNWB::NWB; + +// AnnotationSeries +// Initialize the static registered_ member to trigger registration +REGISTER_SUBCLASS_IMPL(AnnotationSeries) + +/** Constructor */ +AnnotationSeries::AnnotationSeries(const std::string& path, + std::shared_ptr io) + : TimeSeries(path, io) +{ +} + +/** Destructor */ +AnnotationSeries::~AnnotationSeries() {} + +/** Initialization function*/ +void AnnotationSeries::initialize(const std::string& description, + const std::string& comments, + const SizeArray& dsetSize, + const SizeArray& chunkSize) +{ + TimeSeries::initialize( + IO::BaseDataType::V_STR, // fixed to string according to schema + "n/a", // unit fixed to "n/a" + description, + comments, + dsetSize, + chunkSize, + 1.0f, // conversion fixed to 1.0, since unit is n/a + -1.0f, // resolution fixed to -1.0 + 0.0f); // offset fixed to 0.0, since unit is n/a +} + +Status AnnotationSeries::writeAnnotation(const SizeType& numSamples, + std::vector dataInput, + const void* timestampsInput, + const void* controlInput) +{ + std::vector dataShape = {numSamples}; + std::vector positionOffset = {this->m_samplesRecorded}; + + // Write timestamps + Status tsStatus = Status::Success; + tsStatus = this->timestamps->writeDataBlock( + dataShape, positionOffset, this->timestampsType, timestampsInput); + + // Write the data + Status dataStatus = this->data->writeDataBlock( + dataShape, positionOffset, this->m_dataType, dataInput); + + // Write the control data if it exists + if (controlInput != nullptr) { + tsStatus = this->control->writeDataBlock( + dataShape, positionOffset, this->controlType, controlInput); + } + + // track samples recorded + m_samplesRecorded += numSamples; + + if ((dataStatus != Status::Success) || (tsStatus != Status::Success)) { + return Status::Failure; + } else { + return Status::Success; + } +} \ No newline at end of file diff --git a/src/nwb/misc/AnnotationSeries.hpp b/src/nwb/misc/AnnotationSeries.hpp new file mode 100644 index 00000000..a9ac2ac1 --- /dev/null +++ b/src/nwb/misc/AnnotationSeries.hpp @@ -0,0 +1,71 @@ + +#pragma once + +#include + +#include "Utils.hpp" +#include "io/BaseIO.hpp" +#include "io/ReadIO.hpp" +#include "nwb/base/TimeSeries.hpp" + +namespace AQNWB::NWB +{ +/** + * @brief TimeSeries storing text-based records about the experiment. + */ +class AnnotationSeries : public TimeSeries +{ +public: + // Register the AnnotationSeries + REGISTER_SUBCLASS(AnnotationSeries, "core") + + /** + * @brief Constructor. + * @param path The location of the AnnotationSeries in the file. + * @param io A shared pointer to the IO object. + */ + AnnotationSeries(const std::string& path, std::shared_ptr io); + + /** + * @brief Destructor + */ + ~AnnotationSeries(); + + /** + * @brief Initializes the AnnotationSeries + * @param description The description of the AnnotationSeries. + * @param dsetSize Initial size of the main dataset. This must be a vector + * with one element specifying the length in time. + * @param chunkSize Chunk size to use. + */ + void initialize(const std::string& description, + const std::string& comments, + const SizeArray& dsetSize, + const SizeArray& chunkSize); + + /** + * @brief Writes a channel to an AnnotationSeries dataset. + * @param numSamples The number of samples to write (length in time). + * @param dataInput A vector of strings. + * @param timestampsInput A pointer to the timestamps block. + * @param controlInput A pointer to the control block data (optional) + * @return The status of the write operation. + */ + Status writeAnnotation(const SizeType& numSamples, + const std::vector dataInput, + const void* timestampsInput, + const void* controlInput = nullptr); + + DEFINE_FIELD(readData, + DatasetField, + std::string, + "data", + Annotations made during an experiment.) + +private: + /** + * @brief The number of samples already written per channel. + */ + SizeType m_samplesRecorded = 0; +}; +} // namespace AQNWB::NWB diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c5288219..8615085e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,6 +18,7 @@ add_executable(aqnwb_test testElementIdentifiers.cpp testFile.cpp testHDF5IO.cpp + testMisc.cpp testNWBFile.cpp testRecordingWorkflow.cpp testRegisteredType.cpp diff --git a/tests/testMisc.cpp b/tests/testMisc.cpp new file mode 100644 index 00000000..0770e1fc --- /dev/null +++ b/tests/testMisc.cpp @@ -0,0 +1,73 @@ +#include +#include +#include + +#include "Types.hpp" +#include "Utils.hpp" +#include "io/BaseIO.hpp" +#include "nwb/misc/AnnotationSeries.hpp" +#include "testUtils.hpp" + +using namespace AQNWB; + +TEST_CASE("AnnotationSeries", "[misc]") +{ + // setup recording info + SizeType numSamples = 3; + std::string dataPath = "/annotations"; + std::vector mockAnnotations = { + "Subject moved", + "Break started", + "Break ended", + }; + std::vector mockTimestamps = getMockTimestamps(numSamples, 1); + std::vector mockTimestamps2 = mockTimestamps; + for (double& value : mockTimestamps2) { + value += 5; + } + + SECTION("test writing annotations") + { + // setup io object + std::string path = getTestFilePath("AnnotationSeries.h5"); + std::shared_ptr io = createIO("HDF5", path); + io->open(); + + // setup annotation series + NWB::AnnotationSeries as = NWB::AnnotationSeries(dataPath, io); + as.initialize( + "Test annotations", "Test comments", SizeArray {0}, SizeArray {1}); + + // write annotations multiple times to test adding to same dataset + Status writeStatus = + as.writeAnnotation(numSamples, mockAnnotations, mockTimestamps.data()); + REQUIRE(writeStatus == Status::Success); + Status writeStatus2 = + as.writeAnnotation(numSamples, mockAnnotations, mockTimestamps2.data()); + REQUIRE(writeStatus2 == Status::Success); + io->flush(); + + // Read annotations back from file + std::vector expectedAnnotations = mockAnnotations; + expectedAnnotations.insert(expectedAnnotations.end(), + mockAnnotations.begin(), + mockAnnotations.end()); + std::vector dataOut(expectedAnnotations.size()); + + auto readDataWrapper = as.readData(); + auto readAnnotationsDataTyped = readDataWrapper->values(); + REQUIRE(readAnnotationsDataTyped.data == expectedAnnotations); + + // Read timestamps + std::vector expectedTimestamps = mockTimestamps; + expectedTimestamps.insert(expectedTimestamps.end(), + mockTimestamps2.begin(), + mockTimestamps2.end()); + std::vector timestampsOut(expectedTimestamps.size()); + + auto readTimestampsWrapper = as.readTimestamps(); + auto readTimestampsDataTyped = readTimestampsWrapper->values(); + REQUIRE_THAT(readTimestampsDataTyped.data, + Catch::Matchers::Approx(expectedTimestamps)); + } +} diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 35939c3f..9bbd8f29 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -10,6 +10,7 @@ #include "nwb/RecordingContainers.hpp" #include "nwb/base/TimeSeries.hpp" #include "nwb/ecephys/SpikeEventSeries.hpp" +#include "nwb/misc/AnnotationSeries.hpp" #include "testUtils.hpp" using namespace AQNWB; @@ -164,6 +165,60 @@ TEST_CASE("createMultipleEcephysDatasets", "[nwb]") nwbfile.finalize(); } +TEST_CASE("createAnnotationSeries", "[nwb]") +{ + std::string filename = getTestFilePath("createAnnotationSeries.nwb"); + + // initialize nwbfile object and create base structure + std::shared_ptr io = + std::make_shared(filename); + io->open(); + NWB::NWBFile nwbfile(io); + nwbfile.initialize(generateUuid()); + + // create Annotation Series + std::vector mockAnnotationNames = {"annotations1", + "annotations2"}; + std::unique_ptr recordingContainers = + std::make_unique(); + Status resultCreate = nwbfile.createAnnotationSeries( + mockAnnotationNames, recordingContainers.get()); + REQUIRE(resultCreate == Status::Success); + + // start recording + Status resultStart = io->startRecording(); + REQUIRE(resultStart == Status::Success); + + // write annotation data + std::vector mockAnnotations = { + "Start recording", "Subject moved", "End recording"}; + std::vector mockTimestamps = {0.1, 0.5, 1.0}; + std::vector positionOffset = {0}; + SizeType dataShape = mockAnnotations.size(); + + // write to both annotation series + recordingContainers->writeAnnotationSeriesData( + 0, dataShape, mockAnnotations, mockTimestamps.data()); + recordingContainers->writeAnnotationSeriesData( + 1, dataShape, mockAnnotations, mockTimestamps.data()); + + // test searching for all AnnotationSeries objects + std::unordered_set typesToSearch = {"core::AnnotationSeries"}; + std::unordered_map found_types = + io->findTypes("/", typesToSearch, IO::SearchMode::CONTINUE_ON_TYPE); + REQUIRE(found_types.size() + == 2); // We should have annotations1 and annotations2 + for (const auto& pair : found_types) { + // only AnnotationSeries should be found + REQUIRE(pair.second == "core::AnnotationSeries"); + // only annotations1 or annotations2 should be in the list + REQUIRE(((pair.first == "/acquisition/annotations1") + || (pair.first == "/acquisition/annotations2"))); + } + + nwbfile.finalize(); +} + TEST_CASE("setCanModifyObjectsMode", "[nwb]") { std::string filename = getTestFilePath("testCanModifyObjectsMode.nwb");