From 689d747eefce3c7cb6bcf218d4cab7fb48529da2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:49:58 +0000 Subject: [PATCH 01/40] Initial plan From 54fd1242ebcea8d9498a1bf96fa0ec8d1d6a5da5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:58:39 +0000 Subject: [PATCH 02/40] Apply remaining changes Co-authored-by: oruebel <10999845+oruebel@users.noreply.github.com> --- .github/workflows/tests.yml | 2 +- .github/workflows/upgrade_schema.yml | 2 +- src/nwb/NWBFile.cpp | 48 ++++++++++++++++++++++++++-- src/nwb/NWBFile.hpp | 42 ++++++++++++++++++++++-- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c8dde3474..285dffdf9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -144,7 +144,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install nwbinspector - nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --ignore=check_subject_exists --json-file-path out.json + nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --json-file-path out.json if ! grep -q '"messages": \[\]' out.json; then echo "NWBInspector found issues in the NWB files" exit 1 diff --git a/.github/workflows/upgrade_schema.yml b/.github/workflows/upgrade_schema.yml index 0f24dedb9..61db0427f 100644 --- a/.github/workflows/upgrade_schema.yml +++ b/.github/workflows/upgrade_schema.yml @@ -58,7 +58,7 @@ jobs: run: | mkdir -p nwb_files cp build/tests/data/*.nwb nwb_files/ - nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --ignore=check_subject_exists --json-file-path out.json + nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --json-file-path out.json if ! grep -q '"messages": \[\]' out.json; then echo "NWBInspector found issues in the NWB files" exit 1 diff --git a/src/nwb/NWBFile.cpp b/src/nwb/NWBFile.cpp index 226a63982..387be20e6 100644 --- a/src/nwb/NWBFile.cpp +++ b/src/nwb/NWBFile.cpp @@ -53,7 +53,8 @@ Status NWBFile::initialize(const std::string& identifierText, const std::string& description, const std::string& dataCollection, const std::string& sessionStartTime, - const std::string& timestampsReferenceTime) + const std::string& timestampsReferenceTime, + const std::optional& subject) { auto ioPtr = getIO(); if (!ioPtr) { @@ -93,7 +94,8 @@ Status NWBFile::initialize(const std::string& identifierText, description, dataCollection, useSessionStartTime, - useTimestampsReferenceTime); + useTimestampsReferenceTime, + subject); return createStatus; } else { return Status::Success; @@ -141,7 +143,8 @@ Status NWBFile::createFileStructure(const std::string& identifierText, const std::string& description, const std::string& dataCollection, const std::string& sessionStartTime, - const std::string& timestampsReferenceTime) + const std::string& timestampsReferenceTime, + const std::optional& subject) { auto ioPtr = getIO(); if (!ioPtr) { @@ -193,6 +196,45 @@ Status NWBFile::createFileStructure(const std::string& identifierText, ioPtr->createStringDataSet("/timestamps_reference_time", timestampsReferenceTime); ioPtr->createStringDataSet("/identifier", identifierText); + + // Create subject group if subject metadata is provided + if (subject.has_value()) { + const std::string subjectPath = + mergePaths(NWBFile::GENERAL_PATH, "subject"); + ioPtr->createGroup(subjectPath); + ioPtr->createAttribute("Subject", subjectPath, "neurodata_type"); + ioPtr->createAttribute("core", subjectPath, "namespace"); + const auto& s = subject.value(); + if (!s.subjectId.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "subject_id"), + s.subjectId); + } + if (!s.species.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "species"), + s.species); + } + if (!s.sex.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "sex"), s.sex); + } + if (!s.age.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "age"), s.age); + } + if (!s.description.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "description"), + s.description); + } + if (!s.genotype.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "genotype"), + s.genotype); + } + if (!s.strain.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "strain"), s.strain); + } + if (!s.weight.empty()) { + ioPtr->createStringDataSet(mergePaths(subjectPath, "weight"), s.weight); + } + } + return Status::Success; } diff --git a/src/nwb/NWBFile.hpp b/src/nwb/NWBFile.hpp index 9029b2aaa..394c331e5 100644 --- a/src/nwb/NWBFile.hpp +++ b/src/nwb/NWBFile.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -24,6 +25,35 @@ namespace AQNWB::NWB { +/** + * @brief Metadata about the experimental subject. + * + * All fields are optional. Pass an instance of this struct to + * NWBFile::initialize() to create a Subject group in the NWB file. To + * explicitly record that no subject information is available, pass + * @c std::nullopt for the subject parameter. + */ +struct SubjectMetadata +{ + /// @brief ID of animal/person used/participating in experiment (lab + /// convention). + std::string subjectId; + /// @brief Species of subject. + std::string species; + /// @brief Gender of subject. + std::string sex; + /// @brief Age of subject (e.g., "P90D" for 90 days post-natal). + std::string age; + /// @brief Description of subject and where subject came from. + std::string description; + /// @brief Genetic strain. If absent, assume Wild Type (WT). + std::string genotype; + /// @brief Strain of subject. + std::string strain; + /// @brief Weight at time of experiment. + std::string weight; +}; + /** * @brief The NWBFile class provides an interface for setting up and managing * the NWB file. @@ -98,12 +128,18 @@ class NWBFile : public NWBContainer * time. If empty (default), then the getCurrentTime() will be used. * @param timestampsReferenceTime ISO formatted time string with the timestamp * reference time. If empty (default), then the getCurrentTime() will be used. + * @param subject Optional subject metadata. By default an empty + * SubjectMetadata{} is used, which creates a Subject group in the NWB file. + * Pass @c std::nullopt to explicitly state that no subject should be created + * (e.g., when the subject is unknown). */ Status initialize(const std::string& identifierText, const std::string& description = "a recording session", const std::string& dataCollection = "", const std::string& sessionStartTime = "", - const std::string& timestampsReferenceTime = ""); + const std::string& timestampsReferenceTime = "", + const std::optional& subject = + SubjectMetadata {}); /** * @brief Check if the NWB file is initialized. @@ -256,13 +292,15 @@ class NWBFile : public NWBContainer * time * @param timestampsReferenceTime ISO formatted time string with the timestamp * reference time + * @param subject Optional subject metadata to write to the file. * @return Status The status of the file structure creation. */ Status createFileStructure(const std::string& identifierText, const std::string& description, const std::string& dataCollection, const std::string& sessionStartTime, - const std::string& timestampsReferenceTime); + const std::string& timestampsReferenceTime, + const std::optional& subject); private: /** From b5575c74da577c84f7dbfa46ef9fa7f06c60d7ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Aug 2026 05:05:29 +0000 Subject: [PATCH 03/40] Make Subject required by default in NWBFile::initialize() - Add SubjectMetadata struct to NWBFile.hpp with optional fields for species, sex, age, description, subjectId, genotype, strain, weight - Add subject parameter to NWBFile::initialize() defaulting to an empty SubjectMetadata{} (creates a Subject group by default); pass std::nullopt to explicitly opt out - Implement subject group creation in createFileStructure() with neurodata_type/namespace attributes for NWB compliance - Update testNWBFile.cpp to expect Subject in findOwnedTypes results - Update workflow example to demonstrate Subject usage with actual data - Remove --ignore=check_subject_exists from tests.yml and upgrade_schema.yml CI workflows Co-authored-by: oruebel <10999845+oruebel@users.noreply.github.com> --- tests/examples/testWorkflowExamples.cpp | 13 ++++++++++++- tests/testNWBFile.cpp | 10 ++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/tests/examples/testWorkflowExamples.cpp b/tests/examples/testWorkflowExamples.cpp index ec7135443..5c9a86b08 100644 --- a/tests/examples/testWorkflowExamples.cpp +++ b/tests/examples/testWorkflowExamples.cpp @@ -46,7 +46,18 @@ TEST_CASE("workflowExamples") // [example_workflow_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); + NWB::SubjectMetadata subject; + subject.subjectId = "mouse001"; + subject.species = "Mus musculus"; + subject.sex = "M"; + subject.age = "P90D"; + subject.description = "Wild type mouse used for electrophysiology study"; + Status initStatus = nwbfile->initialize(generateUuid(), + "a recording session", + "", + "", + "", + subject); AQNWB::checkStatus(initStatus, "NWBFile initialization"); // [example_workflow_nwbfile_snippet] REQUIRE(initStatus == Status::Success); diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 7977892a2..4346cc53f 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -68,10 +68,11 @@ TEST_CASE("initialize", "[nwb]") REQUIRE(initStatus == Status::Success); REQUIRE(nwbfile->isInitialized()); - // Since we didn't create any typed objects within the NWBFile, we should - // have no owned types + // The default initializes a Subject group so we should have one owned type auto result = nwbfile->findOwnedTypes(); - REQUIRE(result.size() == 0); + REQUIRE(result.size() == 1); + REQUIRE(result.count("/general/subject") == 1); + REQUIRE(result.at("/general/subject") == "core::Subject"); nwbfile->finalize(); // Good practice since we don't call stop recording, but // not essential @@ -268,6 +269,7 @@ TEST_CASE("createElectricalSeries", "[nwb]") } // Check that we can find all the types that we created + // - /general/subject : core::Subject (created by default in initialize) // - /general/extracellular_ephys/array0 : core::ElectrodeGroup // - /general/devices/array1 : core::Device // - /general/extracellular_ephys/electrodes : core::DynamicTable @@ -276,7 +278,7 @@ TEST_CASE("createElectricalSeries", "[nwb]") // - /general/extracellular_ephys/array1 : core::ElectrodeGroup // - /acquisition/esdata0 : core::ElectricalSeries auto result = nwbfile->findOwnedTypes(); - REQUIRE(result.size() == 7); + REQUIRE(result.size() == 8); // finalize the nwb file io->stopRecording(); From b4bd01e9fcb3adf5e9e5265245d2cf37a16f9ca6 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 12 Aug 2026 23:18:39 -0700 Subject: [PATCH 04/40] Added Subject type --- CMakeLists.txt | 1 + src/nwb/NWBFile.cpp | 73 +++++------------ src/nwb/NWBFile.hpp | 38 +-------- src/nwb/file/Subject.cpp | 152 +++++++++++++++++++++++++++++++++++ src/nwb/file/Subject.hpp | 146 +++++++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/testNWBFile.cpp | 4 +- tests/testRegisteredType.cpp | 3 + 8 files changed, 331 insertions(+), 87 deletions(-) create mode 100644 src/nwb/file/Subject.cpp create mode 100644 src/nwb/file/Subject.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1cb5bed82..ce4330299 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,7 @@ add_library( src/nwb/ecephys/SpikeEventSeries.cpp src/nwb/file/ElectrodeGroup.cpp src/nwb/file/ElectrodesTable.cpp + src/nwb/file/Subject.cpp src/nwb/misc/AnnotationSeries.cpp src/nwb/hdmf/base/Container.cpp src/nwb/hdmf/base/Data.cpp diff --git a/src/nwb/NWBFile.cpp b/src/nwb/NWBFile.cpp index 387be20e6..501d064be 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/file/Subject.hpp" #include "nwb/misc/AnnotationSeries.hpp" #include "spec/NamespaceRegistry.hpp" #include "spec/core.hpp" @@ -49,12 +50,13 @@ NWBFile::NWBFile(const std::string& path, std::shared_ptr io) NWBFile::~NWBFile() {} -Status NWBFile::initialize(const std::string& identifierText, - const std::string& description, - const std::string& dataCollection, - const std::string& sessionStartTime, - const std::string& timestampsReferenceTime, - const std::optional& subject) +Status NWBFile::initialize( + const std::string& identifierText, + const std::string& description, + const std::string& dataCollection, + const std::string& sessionStartTime, + const std::string& timestampsReferenceTime, + const std::optional& subjectSpec) { auto ioPtr = getIO(); if (!ioPtr) { @@ -89,17 +91,25 @@ Status NWBFile::initialize(const std::string& identifierText, // Check that the file is empty and initialize if it is bool fileInitialized = isInitialized(); + Status initStatus = Status::Success; if (!fileInitialized) { Status createStatus = createFileStructure(identifierText, description, dataCollection, useSessionStartTime, - useTimestampsReferenceTime, - subject); - return createStatus; - } else { - return Status::Success; + useTimestampsReferenceTime); + initStatus = initStatus && createStatus; } + + // Create subject group and its contents if subject metadata is provided + if (subjectSpec.has_value()) { + const std::string subjectPath = + mergePaths(NWBFile::GENERAL_PATH, "subject"); + auto subject = AQNWB::NWB::Subject::create(subjectPath, ioPtr); + Status subjectInitStatus = subject->initialize(subjectSpec.value()); + initStatus = initStatus && subjectInitStatus; + } + return initStatus; } bool NWBFile::isInitialized() const @@ -143,8 +153,7 @@ Status NWBFile::createFileStructure(const std::string& identifierText, const std::string& description, const std::string& dataCollection, const std::string& sessionStartTime, - const std::string& timestampsReferenceTime, - const std::optional& subject) + const std::string& timestampsReferenceTime) { auto ioPtr = getIO(); if (!ioPtr) { @@ -197,44 +206,6 @@ Status NWBFile::createFileStructure(const std::string& identifierText, timestampsReferenceTime); ioPtr->createStringDataSet("/identifier", identifierText); - // Create subject group if subject metadata is provided - if (subject.has_value()) { - const std::string subjectPath = - mergePaths(NWBFile::GENERAL_PATH, "subject"); - ioPtr->createGroup(subjectPath); - ioPtr->createAttribute("Subject", subjectPath, "neurodata_type"); - ioPtr->createAttribute("core", subjectPath, "namespace"); - const auto& s = subject.value(); - if (!s.subjectId.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "subject_id"), - s.subjectId); - } - if (!s.species.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "species"), - s.species); - } - if (!s.sex.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "sex"), s.sex); - } - if (!s.age.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "age"), s.age); - } - if (!s.description.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "description"), - s.description); - } - if (!s.genotype.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "genotype"), - s.genotype); - } - if (!s.strain.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "strain"), s.strain); - } - if (!s.weight.empty()) { - ioPtr->createStringDataSet(mergePaths(subjectPath, "weight"), s.weight); - } - } - return Status::Success; } diff --git a/src/nwb/NWBFile.hpp b/src/nwb/NWBFile.hpp index 394c331e5..1196cc74e 100644 --- a/src/nwb/NWBFile.hpp +++ b/src/nwb/NWBFile.hpp @@ -16,6 +16,7 @@ #include "nwb/base/ProcessingModule.hpp" #include "nwb/base/TimeSeries.hpp" #include "nwb/file/ElectrodesTable.hpp" +#include "nwb/file/Subject.hpp" #include "spec/core.hpp" /*! @@ -25,35 +26,6 @@ namespace AQNWB::NWB { -/** - * @brief Metadata about the experimental subject. - * - * All fields are optional. Pass an instance of this struct to - * NWBFile::initialize() to create a Subject group in the NWB file. To - * explicitly record that no subject information is available, pass - * @c std::nullopt for the subject parameter. - */ -struct SubjectMetadata -{ - /// @brief ID of animal/person used/participating in experiment (lab - /// convention). - std::string subjectId; - /// @brief Species of subject. - std::string species; - /// @brief Gender of subject. - std::string sex; - /// @brief Age of subject (e.g., "P90D" for 90 days post-natal). - std::string age; - /// @brief Description of subject and where subject came from. - std::string description; - /// @brief Genetic strain. If absent, assume Wild Type (WT). - std::string genotype; - /// @brief Strain of subject. - std::string strain; - /// @brief Weight at time of experiment. - std::string weight; -}; - /** * @brief The NWBFile class provides an interface for setting up and managing * the NWB file. @@ -138,8 +110,8 @@ class NWBFile : public NWBContainer const std::string& dataCollection = "", const std::string& sessionStartTime = "", const std::string& timestampsReferenceTime = "", - const std::optional& subject = - SubjectMetadata {}); + const std::optional& + subjectSpec = std::nullopt); /** * @brief Check if the NWB file is initialized. @@ -292,15 +264,13 @@ class NWBFile : public NWBContainer * time * @param timestampsReferenceTime ISO formatted time string with the timestamp * reference time - * @param subject Optional subject metadata to write to the file. * @return Status The status of the file structure creation. */ Status createFileStructure(const std::string& identifierText, const std::string& description, const std::string& dataCollection, const std::string& sessionStartTime, - const std::string& timestampsReferenceTime, - const std::optional& subject); + const std::string& timestampsReferenceTime); private: /** diff --git a/src/nwb/file/Subject.cpp b/src/nwb/file/Subject.cpp new file mode 100644 index 000000000..fd4f01b55 --- /dev/null +++ b/src/nwb/file/Subject.cpp @@ -0,0 +1,152 @@ +#include "nwb/file/Subject.hpp" + +#include "Utils.hpp" +#include "nwb/NWBFile.hpp" + +using namespace AQNWB::NWB; +using namespace AQNWB::IO; + +// Initialize the static registered_ member to trigger registration +REGISTER_SUBCLASS_IMPL(Subject) + +Subject::Subject(std::shared_ptr io) + : NWBContainer(mergePaths(NWBFile::GENERAL_PATH, "subject"), io) +{ +} + +// Constructor +Subject::Subject(const std::string& path, std::shared_ptr io) + : NWBContainer(mergePaths(NWBFile::GENERAL_PATH, "subject"), io) +{ + if (path != mergePaths(NWBFile::GENERAL_PATH, "subject")) { + std::cerr << "WARNING: Subject object path must be /general/subject. " + "Ignoring provided path." + << std::endl; + } +} + +// Initialize the object +Status Subject::initialize(const SubjectSpec& subjectSpec) +{ + // Get the IO object` + auto ioPtr = getIO(); + if (!ioPtr) { + std::cerr << "Subject::initialize IO object has been deleted." << std::endl; + return Status::Failure; + } + if (!ioPtr->canModifyObjects()) { + return Status::Failure; + } + + // Call parent initialize method. + Status initStatus = Status::Success; + Status parentInitStatus = NWBContainer::initialize(); + initStatus = initStatus && parentInitStatus; + + // Initialize attributes, datasets, and groups + // Initialize age dataset and age/reference attribute if age is provided + if (subjectSpec.age.has_value()) { + Status ageStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "age"), subjectSpec.age.value()); + initStatus = initStatus && ageStatus; + if (!ageStatus) { + std::cerr << "Failed to create age dataset." << std::endl; + } else { + std::string ageReference = subjectSpec.ageReference.value_or("birth"); + Status ageRefStatus = ioPtr->createAttribute( + ageReference, mergePaths(this->m_path, "age"), "reference"); + initStatus = initStatus && ageRefStatus; + if (!ageRefStatus) { + std::cerr << "Failed to create age reference attribute." << std::endl; + } + } + } + + // Initialize date_of_birth dataset if date_of_birth is provided + if (subjectSpec.dateOfBirth.has_value()) { + Status dobStatus = + ioPtr->createStringDataSet(mergePaths(this->m_path, "date_of_birth"), + subjectSpec.dateOfBirth.value()); + initStatus = initStatus && dobStatus; + if (!dobStatus) { + std::cerr << "Failed to create date_of_birth dataset." << std::endl; + } + if (isISO8601Date(subjectSpec.dateOfBirth.value()) == false) { + std::cerr << "Warning: date_of_birth is not in ISO8601 format: " + << subjectSpec.dateOfBirth.value() << std::endl; + } + } + // Initialize description dataset if description is provided + if (subjectSpec.description.has_value()) { + Status descStatus = + ioPtr->createStringDataSet(mergePaths(this->m_path, "description"), + subjectSpec.description.value()); + initStatus = initStatus && descStatus; + if (!descStatus) { + std::cerr << "Failed to create description dataset." << std::endl; + } + } + + // Initialize genotype dataset if genotype is provided + if (subjectSpec.genotype.has_value()) { + Status genotypeStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "genotype"), subjectSpec.genotype.value()); + initStatus = initStatus && genotypeStatus; + if (!genotypeStatus) { + std::cerr << "Failed to create genotype dataset." << std::endl; + } + } + + // Initialize sex dataset if sex is provided + if (subjectSpec.sex.has_value()) { + Status sexStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "sex"), subjectSpec.sex.value()); + initStatus = initStatus && sexStatus; + if (!sexStatus) { + std::cerr << "Failed to create sex dataset." << std::endl; + } + } + + // Initialize species dataset if species is provided + if (subjectSpec.species.has_value()) { + Status speciesStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "species"), subjectSpec.species.value()); + initStatus = initStatus && speciesStatus; + if (!speciesStatus) { + std::cerr << "Failed to create species dataset." << std::endl; + } + } + + // Initialize strain dataset if strain is provided + if (subjectSpec.strain.has_value()) { + Status strainStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "strain"), subjectSpec.strain.value()); + initStatus = initStatus && strainStatus; + if (!strainStatus) { + std::cerr << "Failed to create strain dataset." << std::endl; + } + } + + // Initialize subject_id dataset if subject_id is provided + if (subjectSpec.subjectId.has_value()) { + Status subjectIdStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "subject_id"), subjectSpec.subjectId.value()); + initStatus = initStatus && subjectIdStatus; + if (!subjectIdStatus) { + std::cerr << "Failed to create subject_id dataset." << std::endl; + } + } + + // Initialize weight dataset if weight is provided + if (subjectSpec.weight.has_value()) { + Status weightStatus = ioPtr->createStringDataSet( + mergePaths(this->m_path, "weight"), subjectSpec.weight.value()); + initStatus = initStatus && weightStatus; + if (!weightStatus) { + std::cerr << "Failed to create weight dataset." << std::endl; + } + } + + // Return the overall status of the initialization + return initStatus; +} diff --git a/src/nwb/file/Subject.hpp b/src/nwb/file/Subject.hpp new file mode 100644 index 000000000..dcd8380b9 --- /dev/null +++ b/src/nwb/file/Subject.hpp @@ -0,0 +1,146 @@ +#pragma once + +// Common STL includes +#include +#include +#include +#include +// Base AqNWB includes for IO and RegisteredType +#include "io/BaseIO.hpp" +#include "io/ReadIO.hpp" +#include "nwb/RegisteredType.hpp" +// Include for parent type +#include "nwb/base/NWBContainer.hpp" +// Include for the namespace schema header +#include "spec/core.hpp" + +namespace AQNWB::NWB +{ + +/** + * @brief Information about the animal or person from which the data was + * measured. + */ +class Subject : public AQNWB::NWB::NWBContainer +{ +public: + /** + * @brief Metadata about the experimental subject. + * + * All fields are optional. Pass an instance of this struct to + * Subject::initialize() to create a Subject group in the NWB file. + */ + struct SubjectSpec + { + /// @brief Age of subject (e.g., "P90D" for 90 days post-natal). + std::optional age = std::nullopt; + /// @brief Age is with reference to this event. Can be ‘birth’ or + /// ‘gestational’. If reference is omitted, ‘birth’ is implied. + std::optional ageReference = std::nullopt; + /// @brief Date of birth of subject as iso formatted date string + std::optional dateOfBirth = std::nullopt; + /// @brief Description of subject and where subject came from. + std::optional description = std::nullopt; + /// @brief Genetic strain. If absent, assume Wild Type (WT). + std::optional genotype = std::nullopt; + /// @brief Biological sex of subject. + std::optional sex = std::nullopt; + /// @brief Species of subject. + std::optional species = std::nullopt; + /// @brief Strain of subject. + std::optional strain = std::nullopt; + /// @brief ID of animal/person used/participating in experiment (lab + /// convention). + std::optional subjectId = std::nullopt; + /// @brief Weight at time of experiment. + std::optional weight = std::nullopt; + }; + + /** + * @brief Constructor for NWBFile class. + * @param io The shared pointer to the IO object. + */ + explicit Subject(std::shared_ptr io); + + /** + * @brief Constructor + * @param path Path to the object in the file + * @param io IO object for reading/writing + */ + Subject(const std::string& path, std::shared_ptr io); + + /** + * @brief Virtual destructor. + */ + virtual ~Subject() override {} + + // TODO: Update the initialize method as appropriate. + /** + * @brief Initialize the object + * @param subjectSpec The SubjectSpec object with the subject metadata + * @return Status::Success if successful, otherwise Status::Failure. + */ + Status initialize(const SubjectSpec& subjectSpec); + + // Define read methods + DEFINE_DATASET_FIELD( + readAge, + recordAge, + std::string, + "age", + "Age of subject. Can be supplied instead of date_of_birth.") + + DEFINE_DATASET_FIELD( + readDateOfBirth, + recordDateOfBirth, + std::string, + "date_of_birth", + "Date of birth of subject. Can be supplied instead of age.") + + DEFINE_DATASET_FIELD(readDescription, + recordDescription, + std::string, + "description", + "Description of subject and where subject came from " + "(e.g. - breeder - if animal).") + + DEFINE_DATASET_FIELD(readGenotype, + recordGenotype, + std::string, + "genotype", + "Genetic strain. If absent - assume Wild Type (WT).") + + DEFINE_DATASET_FIELD( + readSex, recordSex, std::string, "sex", "Gender of subject.") + + DEFINE_DATASET_FIELD( + readSpecies, recordSpecies, std::string, "species", "Species of subject.") + + DEFINE_DATASET_FIELD( + readStrain, recordStrain, std::string, "strain", "Strain of subject.") + + DEFINE_DATASET_FIELD( + readSubjectId, + recordSubjectId, + std::string, + "subject_id", + "ID of animal/person used/participating in experiment (lab convention).") + + DEFINE_DATASET_FIELD(readWeight, + recordWeight, + std::string, + "weight", + "Weight at time of experiment - at time of surgery and " + "at other important times.") + + DEFINE_ATTRIBUTE_FIELD( + readAgeReference, + std::string, + "age/reference", + "Age is with reference to this event. Can be birth or gestational. If " + "reference is omitted - birth is implied.") + + REGISTER_SUBCLASS(Subject, NWBContainer, AQNWB::SPEC::CORE::namespaceName) +}; + +} // namespace AQNWB::NWB diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c599ac3df..fcd4edcaa 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -31,6 +31,7 @@ add_executable(aqnwb_test testRecordingWorkflow.cpp testRecordingObjects.cpp testRegisteredType.cpp + testSubject.cpp testTimeSeries.cpp testTypes.cpp testUtilsFunctions.cpp diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 7977892a2..afeb61f39 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -542,8 +542,8 @@ TEST_CASE("testAttributeAndDatasetFields", "[nwb]") #else std::time_t utc_epoch = timegm(&parsed); #endif - long offset_total = - ((long)offset_h * 3600 + offset_m * 60) * (offset_sign == '-' ? -1 : 1); + long offset_total = (static_cast(offset_h) * 3600 + offset_m * 60) + * (offset_sign == '-' ? -1 : 1); // Convert local time to UTC by subtracting the offset utc_epoch -= offset_total; auto written_tp = std::chrono::system_clock::from_time_t(utc_epoch); diff --git a/tests/testRegisteredType.cpp b/tests/testRegisteredType.cpp index b8bee3343..1cc621f00 100644 --- a/tests/testRegisteredType.cpp +++ b/tests/testRegisteredType.cpp @@ -84,6 +84,9 @@ TEST_CASE("RegisterType", "[base]") } else if (subclassFullName == "core::ElectrodesTable") { examplePath = ElectrodesTable::electrodesTablePath; exampleName = "electrodes"; + } else if (subclassFullName == "core::Subject") { + examplePath = "/general/subject"; + exampleName = "subject"; } else { examplePath = "/example/path"; exampleName = "path"; From d5a741eaee3b4d14c09fdda05e2f78b84848979f Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 12 Aug 2026 23:33:07 -0700 Subject: [PATCH 05/40] Update unit tests --- tests/examples/testWorkflowExamples.cpp | 23 +++++++++++++---------- tests/testNWBFile.cpp | 21 ++++++++++++++------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/tests/examples/testWorkflowExamples.cpp b/tests/examples/testWorkflowExamples.cpp index 5c9a86b08..99c10fe9c 100644 --- a/tests/examples/testWorkflowExamples.cpp +++ b/tests/examples/testWorkflowExamples.cpp @@ -13,6 +13,7 @@ #include "nwb/NWBFile.hpp" #include "nwb/ecephys/ElectricalSeries.hpp" #include "nwb/file/ElectrodesTable.hpp" +#include "nwb/file/Subject.hpp" #include "testUtils.hpp" using namespace AQNWB; @@ -46,18 +47,20 @@ TEST_CASE("workflowExamples") // [example_workflow_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - NWB::SubjectMetadata subject; - subject.subjectId = "mouse001"; - subject.species = "Mus musculus"; - subject.sex = "M"; - subject.age = "P90D"; - subject.description = "Wild type mouse used for electrophysiology study"; + AQNWB::NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.subjectId = "mouse001"; + subjectSpec.species = "Mus musculus"; + subjectSpec.sex = "M"; + subjectSpec.age = "P90D"; + subjectSpec.description = + "Wild type mouse used for electrophysiology study"; + std::string currentTime = getCurrentTime(); Status initStatus = nwbfile->initialize(generateUuid(), "a recording session", - "", - "", - "", - subject); + "data collection info", + currentTime, + currentTime, + subjectSpec); AQNWB::checkStatus(initStatus, "NWBFile initialization"); // [example_workflow_nwbfile_snippet] REQUIRE(initStatus == Status::Success); diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index a8d406cb6..0a0b61fd2 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -13,6 +13,7 @@ #include "nwb/NWBFile.hpp" #include "nwb/base/TimeSeries.hpp" #include "nwb/ecephys/SpikeEventSeries.hpp" +#include "nwb/file/Subject.hpp" #include "nwb/misc/AnnotationSeries.hpp" #include "spec/core.hpp" #include "testUtils.hpp" @@ -68,12 +69,6 @@ TEST_CASE("initialize", "[nwb]") REQUIRE(initStatus == Status::Success); REQUIRE(nwbfile->isInitialized()); - // The default initializes a Subject group so we should have one owned type - auto result = nwbfile->findOwnedTypes(); - REQUIRE(result.size() == 1); - REQUIRE(result.count("/general/subject") == 1); - REQUIRE(result.at("/general/subject") == "core::Subject"); - nwbfile->finalize(); // Good practice since we don't call stop recording, but // not essential io->close(); // close the io @@ -212,7 +207,19 @@ TEST_CASE("createElectricalSeries", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + AQNWB::NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.subjectId = "mouse001"; + subjectSpec.species = "Mus musculus"; + subjectSpec.sex = "M"; + subjectSpec.age = "P90D"; + subjectSpec.description = "Wild type mouse used for electrophysiology study"; + std::string currentTime = getCurrentTime(); + nwbfile->initialize(generateUuid(), + "a recording session", + "data collection info", + currentTime, + currentTime, + subjectSpec); // create the Electrodes Table std::vector mockArrays = getMockChannelArrays(); From a52666271be9ecd83bb0d82975b35a7176458c50 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 12 Aug 2026 23:43:06 -0700 Subject: [PATCH 06/40] Update Changelog and fix docstring --- CHANGELOG.md | 5 ++++- src/nwb/NWBFile.hpp | 7 +++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8efc7a403..89ffa3716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Added `ElectricalSeries::writeAllChannels` method and `IO::writeElectricalSeriesData` overload to simplify zero-copy interleaved multichannel writes. (@copilot, @oruebel, [#293](https://github.com/NeurodataWithoutBorders/aqnwb/pull/293)) * Added `ElectricalSeries::channelsAtSameSampleOffset` method to check if all channels are at the same sample offset, which is a requirement for using `writeAllChannels`. (@copilot, @oruebel, [#293](https://github.com/NeurodataWithoutBorders/aqnwb/pull/293)) - +* Added `Subject` class to represent the `/general/subject` group in NWB files, with corresponding `SubjectSpec` for initialization. (@copilot, @oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) + * Updated nwbinspector tests to remove `--ignore=check_subject_exists` option to require subject (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) + * Updated `NWBFile::initialize` to accept a `SubjectSpec` argument for subject metadata initialization (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) + ### Changed * **[BREAKING]** Moved `disableSWMRMode` option from `HDF5IO` constructor to a new `HDF5IO::startRecording(bool disableSWMRMode)` overload. The `BaseIO`-compliant `startRecording()` override is preserved and defaults to SWMR enabled. * **Migration Note**: Code using `HDF5IO(path, true)` must be updated to `HDF5IO(path)` followed by `startRecording(true)`. When the `HDF5IO` object is held as a `std::shared_ptr` (e.g., from `createIO`), downcast with `std::dynamic_pointer_cast` to access the overload. (@oruebel [#297](https://github.com/NeurodataWithoutBorders/aqnwb/pull/297)) diff --git a/src/nwb/NWBFile.hpp b/src/nwb/NWBFile.hpp index 1196cc74e..e87cf05a9 100644 --- a/src/nwb/NWBFile.hpp +++ b/src/nwb/NWBFile.hpp @@ -100,10 +100,9 @@ class NWBFile : public NWBContainer * time. If empty (default), then the getCurrentTime() will be used. * @param timestampsReferenceTime ISO formatted time string with the timestamp * reference time. If empty (default), then the getCurrentTime() will be used. - * @param subject Optional subject metadata. By default an empty - * SubjectMetadata{} is used, which creates a Subject group in the NWB file. - * Pass @c std::nullopt to explicitly state that no subject should be created - * (e.g., when the subject is unknown). + * @param subjectSpec Optional subject metadata. Pass @c std::nullopt + * (default)to explicitly state that no subject should be created (e.g., when + * the subject is unknown). */ Status initialize(const std::string& identifierText, const std::string& description = "a recording session", From b732aeaf3f6002fefed779f45623027d3279039a Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 12 Aug 2026 23:45:16 -0700 Subject: [PATCH 07/40] Add missing testSubject.cpp file --- tests/testSubject.cpp | 125 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 tests/testSubject.cpp diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp new file mode 100644 index 000000000..22935eb68 --- /dev/null +++ b/tests/testSubject.cpp @@ -0,0 +1,125 @@ +#include +#include +#include + +#include + +#include "Utils.hpp" +#include "io/BaseIO.hpp" +#include "io/hdf5/HDF5IO.hpp" +#include "nwb/NWBFile.hpp" +#include "nwb/file/Subject.hpp" +#include "testUtils.hpp" + +using namespace AQNWB; + +TEST_CASE("Subject", "[file]") +{ + SECTION("is registered and always uses the canonical subject path") + { + auto registry = NWB::RegisteredType::getRegistry(); + REQUIRE(registry.find("core::Subject") != registry.end()); + + auto io = createIO("HDF5", getTestFilePath("subject_constructor.nwb")); + auto subject = NWB::Subject::create("/not/the/subject/path", io); + + REQUIRE(subject->getPath() == "/general/subject"); + REQUIRE(subject->getIO() == io); + } + + SECTION("writes and reads every metadata field") + { + const std::string filename = getTestFilePath("subject_all_fields.nwb"); + auto io = std::make_shared(filename); + io->open(); + io->createGroup("/general"); + + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.age = "P90D"; + subjectSpec.ageReference = "gestational"; + subjectSpec.dateOfBirth = "2024-01-15T00:00:00.000000+00:00"; + subjectSpec.description = "Test subject"; + subjectSpec.genotype = "wt/wt"; + subjectSpec.sex = "M"; + subjectSpec.species = "Mus musculus"; + subjectSpec.strain = "C57BL/6J"; + subjectSpec.subjectId = "subject-001"; + subjectSpec.weight = "25 g"; + + auto subject = NWB::Subject::create("/general/subject", io); + REQUIRE(subject->initialize(subjectSpec) == Status::Success); + io->close(); + + auto readIO = std::make_shared(filename); + readIO->open(IO::FileMode::ReadOnly); + auto readSubject = std::dynamic_pointer_cast( + NWB::RegisteredType::create("/general/subject", readIO)); + + REQUIRE(readSubject != nullptr); + REQUIRE(readSubject->readAge()->values().data + == std::vector {"P90D"}); + REQUIRE(readSubject->readAgeReference()->values().data + == std::vector {"gestational"}); + REQUIRE(readSubject->readDateOfBirth()->values().data + == std::vector {"2024-01-15T00:00:00.000000+00:00"}); + REQUIRE(readSubject->readDescription()->values().data + == std::vector {"Test subject"}); + REQUIRE(readSubject->readGenotype()->values().data + == std::vector {"wt/wt"}); + REQUIRE(readSubject->readSex()->values().data + == std::vector {"M"}); + REQUIRE(readSubject->readSpecies()->values().data + == std::vector {"Mus musculus"}); + REQUIRE(readSubject->readStrain()->values().data + == std::vector {"C57BL/6J"}); + REQUIRE(readSubject->readSubjectId()->values().data + == std::vector {"subject-001"}); + REQUIRE(readSubject->readWeight()->values().data + == std::vector {"25 g"}); + + readIO->close(); + } + + SECTION("uses birth as the default age reference and omits unset fields") + { + const std::string filename = getTestFilePath("subject_optional_fields.nwb"); + auto io = std::make_shared(filename); + io->open(); + io->createGroup("/general"); + + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.age = "P7D"; + auto subject = NWB::Subject::create("/general/subject", io); + + REQUIRE(subject->initialize(subjectSpec) == Status::Success); + REQUIRE(subject->readAgeReference()->values().data + == std::vector {"birth"}); + REQUIRE_FALSE(subject->readSpecies()->exists()); + REQUIRE_FALSE(subject->readDateOfBirth()->exists()); + + io->close(); + } + + SECTION("is created by NWBFile initialization when metadata is supplied") + { + const std::string filename = getTestFilePath("nwbfile_subject.nwb"); + auto io = std::make_shared(filename); + io->open(); + + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.subjectId = "subject-from-nwbfile"; + auto nwbFile = NWB::NWBFile::create(io); + + REQUIRE(nwbFile->initialize(generateUuid(), + "Subject integration test", + "Subject test data collection", + "", + "", + std::optional(subjectSpec)) + == Status::Success); + REQUIRE(io->objectExists("/general/subject")); + REQUIRE(io->objectExists("/general/subject/subject_id")); + + io->close(); + } +} From dabddc7ed4c609e4c62f31582abf36b05c568c4a Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 00:08:21 -0700 Subject: [PATCH 08/40] Fix nwb-inspector validation errors --- tests/testSubject.cpp | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp index 22935eb68..9a8e33314 100644 --- a/tests/testSubject.cpp +++ b/tests/testSubject.cpp @@ -20,7 +20,7 @@ TEST_CASE("Subject", "[file]") auto registry = NWB::RegisteredType::getRegistry(); REQUIRE(registry.find("core::Subject") != registry.end()); - auto io = createIO("HDF5", getTestFilePath("subject_constructor.nwb")); + auto io = createIO("HDF5", getTestFilePath("subject_constructor.h5")); auto subject = NWB::Subject::create("/not/the/subject/path", io); REQUIRE(subject->getPath() == "/general/subject"); @@ -29,7 +29,7 @@ TEST_CASE("Subject", "[file]") SECTION("writes and reads every metadata field") { - const std::string filename = getTestFilePath("subject_all_fields.nwb"); + const std::string filename = getTestFilePath("subject_all_fields.h5"); auto io = std::make_shared(filename); io->open(); io->createGroup("/general"); @@ -82,7 +82,7 @@ TEST_CASE("Subject", "[file]") SECTION("uses birth as the default age reference and omits unset fields") { - const std::string filename = getTestFilePath("subject_optional_fields.nwb"); + const std::string filename = getTestFilePath("subject_optional_fields.h5"); auto io = std::make_shared(filename); io->open(); io->createGroup("/general"); @@ -107,14 +107,23 @@ TEST_CASE("Subject", "[file]") io->open(); NWB::Subject::SubjectSpec subjectSpec; - subjectSpec.subjectId = "subject-from-nwbfile"; + subjectSpec.age = "P90D"; + subjectSpec.ageReference = "gestational"; + subjectSpec.dateOfBirth = "2024-01-15T00:00:00.000000+00:00"; + subjectSpec.description = "Test subject"; + subjectSpec.genotype = "wt/wt"; + subjectSpec.sex = "M"; + subjectSpec.species = "Mus musculus"; + subjectSpec.strain = "C57BL/6J"; + subjectSpec.subjectId = "subject-001"; + subjectSpec.weight = "25 g"; + auto currentTime = getCurrentTime(); auto nwbFile = NWB::NWBFile::create(io); - REQUIRE(nwbFile->initialize(generateUuid(), "Subject integration test", "Subject test data collection", - "", - "", + currentTime, + currentTime, std::optional(subjectSpec)) == Status::Success); REQUIRE(io->objectExists("/general/subject")); From cbd9a1f26db12d2868b7881ea985c4040a2b44d0 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 00:34:32 -0700 Subject: [PATCH 09/40] Fix validation tests --- .../examples/test_link_timeseries_example.cpp | 7 ++- tests/testNWBFile.cpp | 45 ++++++++++++++++--- tests/testProcessingModule.cpp | 28 ++++++++++-- tests/testRecordingWorkflow.cpp | 7 ++- tests/testUtils.hpp | 17 +++++++ 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/tests/examples/test_link_timeseries_example.cpp b/tests/examples/test_link_timeseries_example.cpp index c494e5a4d..ae7d87415 100644 --- a/tests/examples/test_link_timeseries_example.cpp +++ b/tests/examples/test_link_timeseries_example.cpp @@ -21,7 +21,12 @@ TEST_CASE("LinkTimeSeriesExamples", "[timeseries][link]") io->open(); auto nwbfile = NWB::NWBFile::create(io); - auto status = nwbfile->initialize(generateUuid()); + auto status = nwbfile->initialize(generateUuid(), + "Test linked time series", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); REQUIRE(status == Status::Success); // [example_link_timeseries_setup] diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 0a0b61fd2..04c903efa 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -65,7 +65,12 @@ TEST_CASE("initialize", "[nwb]") REQUIRE(initStatus == Status::Failure); // check that regular init with current times works - initStatus = nwbfile->initialize(generateUuid()); + initStatus = nwbfile->initialize(generateUuid(), + "Test initialized NWB file", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); REQUIRE(initStatus == Status::Success); REQUIRE(nwbfile->isInitialized()); @@ -83,7 +88,12 @@ TEST_CASE("createElectrodesTable", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test electrodes table", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // create the Electrodes Table std::vector mockArrays = getMockChannelArrays(1, 2); @@ -101,7 +111,12 @@ TEST_CASE("createElectricalSeriesWithSubsetOfElectrodes", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test electrical series subset", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // Create electrode table with full set of electrodes (4 channels) std::vector allElectrodes = getMockChannelArrays(4, 1); @@ -151,7 +166,12 @@ TEST_CASE("createElectricalSeriesFailsWithoutElectrodesTable", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test multiple ecephys datasets", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // Attempt to create electrical series without creating electrodes table first std::vector recordingElectrodes = @@ -177,7 +197,12 @@ TEST_CASE("createElectricalSeriesFailsWithOutOfRangeIndices", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test annotation series", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // Create electrode table with 2 channels std::vector tableElectrodes = @@ -428,7 +453,12 @@ TEST_CASE("setCanModifyObjectsMode", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); + Status initStatus = nwbfile->initialize(generateUuid(), + "Test recording mode", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); REQUIRE(initStatus == Status::Success); // start recording @@ -480,7 +510,8 @@ TEST_CASE("testAttributeAndDatasetFields", "[nwb]") description, dataCollection, sessionStartTime, - timestampsReferenceTime); + timestampsReferenceTime, + getTestSubjectSpec()); REQUIRE(initStatus == Status::Success); REQUIRE(nwbfile->isInitialized()); diff --git a/tests/testProcessingModule.cpp b/tests/testProcessingModule.cpp index 28e469e5d..93563bf10 100644 --- a/tests/testProcessingModule.cpp +++ b/tests/testProcessingModule.cpp @@ -30,7 +30,12 @@ TEST_CASE("createProcessingModule", "[processingmodule]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test processing module", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // create and initialize a ProcessingModule auto processingModule = nwbfile->createProcessingModule("test_module"); @@ -64,7 +69,12 @@ TEST_CASE("createMultipleProcessingModules", "[processingmodule]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test processing modules", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // create and initialize two ProcessingModules auto module1 = nwbfile->createProcessingModule("module1"); @@ -104,7 +114,12 @@ TEST_CASE("ProcessingModule createNWBDataInterface and readNWBDataInterface", std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test processing module time series", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // create processing module auto processingModule = nwbfile->createProcessingModule("ecephys"); @@ -191,7 +206,12 @@ TEST_CASE("ProcessingModule createDynamicTable and readDynamicTable", std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test processing module dynamic table", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // create processing module auto processingModule = nwbfile->createProcessingModule("analysis_module"); diff --git a/tests/testRecordingWorkflow.cpp b/tests/testRecordingWorkflow.cpp index e52a8a48d..062f4c224 100644 --- a/tests/testRecordingWorkflow.cpp +++ b/tests/testRecordingWorkflow.cpp @@ -46,7 +46,12 @@ TEST_CASE("writeContinuousData", "[recording]") // 3. create NWBFile object auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + nwbfile->initialize(generateUuid(), + "Test continuous recording", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); // 4. create an electrodes table. nwbfile->createElectrodesTable(mockRecordingArrays); diff --git a/tests/testUtils.hpp b/tests/testUtils.hpp index c3fe3f29d..e57f53ba3 100644 --- a/tests/testUtils.hpp +++ b/tests/testUtils.hpp @@ -11,6 +11,7 @@ #include "Channel.hpp" #include "Types.hpp" #include "io/hdf5/HDF5IO.hpp" +#include "nwb/file/Subject.hpp" using namespace AQNWB; using namespace AQNWB::IO; @@ -49,6 +50,22 @@ inline std::string getTestFilePath(const std::string& filename) return filepath.generic_string(); } +inline NWB::Subject::SubjectSpec getTestSubjectSpec() +{ + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.age = "P90D"; + subjectSpec.ageReference = "birth"; + subjectSpec.dateOfBirth = "2024-01-15T00:00:00.000000+00:00"; + subjectSpec.description = "Test subject"; + subjectSpec.genotype = "wt/wt"; + subjectSpec.sex = "U"; + subjectSpec.species = "Mus musculus"; + subjectSpec.strain = "C57BL/6J"; + subjectSpec.subjectId = "test-subject"; + subjectSpec.weight = "25 g"; + return subjectSpec; +} + inline std::vector getMockChannelArrays( SizeType numChannels = 2, SizeType numArrays = 2, From 10de7dd4223058f3f1ec9f9aaaff3a1548a6ebf0 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 00:48:20 -0700 Subject: [PATCH 10/40] Fix nwb-inspector validation errors --- tests/testNWBFile.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 04c903efa..e6b4fcb24 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -394,7 +394,14 @@ TEST_CASE("createAnnotationSeries", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + auto currentTime = getCurrentTime(); + auto subjectSpec = getTestSubjectSpec(); + nwbfile->initialize(generateUuid(), + "a recording session", + "data collection info", + currentTime, + currentTime, + subjectSpec); // create Annotation Series std::vector mockAnnotationNames = {"annotations1", From bcb223e6a85156ddfcb88d2434171a576db5cab6 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 01:07:22 -0700 Subject: [PATCH 11/40] Fix nwb-inspector validation errors --- .github/workflows/tests.yml | 2 +- .github/workflows/upgrade_schema.yml | 2 +- CHANGELOG.md | 1 + tests/testNWBFile.cpp | 11 ++++++++++- 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 285dffdf9..6a06a7584 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -144,7 +144,7 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install nwbinspector - nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --json-file-path out.json + nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --ignore=check_electrodes_location_allen_ccf --json-file-path out.json if ! grep -q '"messages": \[\]' out.json; then echo "NWBInspector found issues in the NWB files" exit 1 diff --git a/.github/workflows/upgrade_schema.yml b/.github/workflows/upgrade_schema.yml index 61db0427f..d32ed0804 100644 --- a/.github/workflows/upgrade_schema.yml +++ b/.github/workflows/upgrade_schema.yml @@ -58,7 +58,7 @@ jobs: run: | mkdir -p nwb_files cp build/tests/data/*.nwb nwb_files/ - nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --json-file-path out.json + nwbinspector nwb_files --threshold BEST_PRACTICE_VIOLATION --ignore=check_electrodes_location_allen_ccf --json-file-path out.json if ! grep -q '"messages": \[\]' out.json; then echo "NWBInspector found issues in the NWB files" exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 89ffa3716..3411ed017 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Migration Note**: Code using `HDF5IO(path, true)` must be updated to `HDF5IO(path)` followed by `startRecording(true)`. When the `HDF5IO` object is held as a `std::shared_ptr` (e.g., from `createIO`), downcast with `std::dynamic_pointer_cast` to access the overload. (@oruebel [#297](https://github.com/NeurodataWithoutBorders/aqnwb/pull/297)) ### Fixed +* Updated nwbinspector validation tests to ignore the Allen CCF electrode location check when validating mock electrode locations. (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) * Updated nwbinspector validation tests in the CI to: 1) `--ignore=check_subject_exists` and 2) remove dependency on `sanitizer` tests to speed up CI (@oruebel, [#289](https://github.com/NeurodataWithoutBorders/aqnwb/pull/289)) * Fixed `get_utc_offset_seconds` to correctly account for daylight saving time using platform-specific APIs (`tm_gmtoff` on Unix/macOS; `_get_timezone` + `_get_dstbias` on Windows), preventing `session_start_time` from being written ~1 hour ahead of UTC during DST (@cboulay, [#295](https://github.com/NeurodataWithoutBorders/aqnwb/pull/295)) * Fixed HDF5 string type creation to explicitly use UTF-8 character set for fixed-length and variable-length strings in datasets and attributes, improving compatibility with hdmf/PyNWB string decoding (@copilot, @oruebel [#319](https://github.com/NeurodataWithoutBorders/aqnwb/pull/319)) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index e6b4fcb24..ca125622a 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -1,5 +1,6 @@ #include #include +#include #include #include @@ -324,7 +325,15 @@ TEST_CASE("createMultipleEcephysDatasets", "[nwb]") std::shared_ptr io = std::make_shared(filename); io->open(); auto nwbfile = AQNWB::NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.species = "Mus musculus"; + auto currentTime = getCurrentTime(); + nwbfile->initialize(generateUuid(), + "a recording session", + "data collection info", + currentTime, + currentTime, + std::optional(subjectSpec)); // create ElectrodesTable std::vector mockArrays = getMockChannelArrays(2, 2); From d3f1893984f06908aa730441430a34bb36d02728 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 01:43:46 -0700 Subject: [PATCH 12/40] Fix subject metadata nwb-inspector test --- tests/testNWBFile.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index ca125622a..e22c4696d 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -1,6 +1,5 @@ #include #include -#include #include #include @@ -325,15 +324,13 @@ TEST_CASE("createMultipleEcephysDatasets", "[nwb]") std::shared_ptr io = std::make_shared(filename); io->open(); auto nwbfile = AQNWB::NWB::NWBFile::create(io); - NWB::Subject::SubjectSpec subjectSpec; - subjectSpec.species = "Mus musculus"; auto currentTime = getCurrentTime(); nwbfile->initialize(generateUuid(), "a recording session", "data collection info", currentTime, currentTime, - std::optional(subjectSpec)); + getTestSubjectSpec()); // create ElectrodesTable std::vector mockArrays = getMockChannelArrays(2, 2); From 49f9ea635b8bb79660b1aa6e03b27d1678b435b9 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 02:05:24 -0700 Subject: [PATCH 13/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/nwb/file/Subject.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nwb/file/Subject.hpp b/src/nwb/file/Subject.hpp index dcd8380b9..4104413f3 100644 --- a/src/nwb/file/Subject.hpp +++ b/src/nwb/file/Subject.hpp @@ -57,7 +57,7 @@ class Subject : public AQNWB::NWB::NWBContainer }; /** - * @brief Constructor for NWBFile class. + * @brief Constructor for Subject. * @param io The shared pointer to the IO object. */ explicit Subject(std::shared_ptr io); From b01612571277b50694e95dabdeec418b2f236da6 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 02:05:50 -0700 Subject: [PATCH 14/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/nwb/file/Subject.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/nwb/file/Subject.cpp b/src/nwb/file/Subject.cpp index fd4f01b55..2093b6748 100644 --- a/src/nwb/file/Subject.cpp +++ b/src/nwb/file/Subject.cpp @@ -28,7 +28,7 @@ Subject::Subject(const std::string& path, std::shared_ptr io) // Initialize the object Status Subject::initialize(const SubjectSpec& subjectSpec) { - // Get the IO object` + // Get the IO object auto ioPtr = getIO(); if (!ioPtr) { std::cerr << "Subject::initialize IO object has been deleted." << std::endl; From 7d16947a4f8945b63926b1024c124dfd51113a4b Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 02:06:24 -0700 Subject: [PATCH 15/40] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/nwb/NWBFile.hpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/nwb/NWBFile.hpp b/src/nwb/NWBFile.hpp index e87cf05a9..e8855a1ed 100644 --- a/src/nwb/NWBFile.hpp +++ b/src/nwb/NWBFile.hpp @@ -100,9 +100,9 @@ class NWBFile : public NWBContainer * time. If empty (default), then the getCurrentTime() will be used. * @param timestampsReferenceTime ISO formatted time string with the timestamp * reference time. If empty (default), then the getCurrentTime() will be used. - * @param subjectSpec Optional subject metadata. Pass @c std::nullopt - * (default)to explicitly state that no subject should be created (e.g., when - * the subject is unknown). + * @param subjectSpec Optional subject metadata. By default, an empty Subject + * is created. Pass @c std::nullopt to explicitly state that no subject should + * be created (e.g., when the subject is unknown). */ Status initialize(const std::string& identifierText, const std::string& description = "a recording session", From 7bb903ea41ab6da5d42311c80b2fb7f2c859fd53 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 02:11:32 -0700 Subject: [PATCH 16/40] Address review comment --- src/nwb/NWBFile.cpp | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/nwb/NWBFile.cpp b/src/nwb/NWBFile.cpp index 501d064be..9dfed07b1 100644 --- a/src/nwb/NWBFile.cpp +++ b/src/nwb/NWBFile.cpp @@ -105,9 +105,15 @@ Status NWBFile::initialize( if (subjectSpec.has_value()) { const std::string subjectPath = mergePaths(NWBFile::GENERAL_PATH, "subject"); - auto subject = AQNWB::NWB::Subject::create(subjectPath, ioPtr); - Status subjectInitStatus = subject->initialize(subjectSpec.value()); - initStatus = initStatus && subjectInitStatus; + if (!ioPtr->objectExists(subjectPath)) { + auto subject = AQNWB::NWB::Subject::create(subjectPath, ioPtr); + Status subjectInitStatus = subject->initialize(subjectSpec.value()); + initStatus = initStatus && subjectInitStatus; + } else { + std::cerr << "Subject group already exists in the file. Skipping " + "subject initialization." + << std::endl; + } } return initStatus; } From e6427bd4eed8def1ed0b068eb3b637ad3f8156ca Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 02:16:55 -0700 Subject: [PATCH 17/40] Avoid overwriting exiting subject --- tests/testNWBFile.cpp | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index e22c4696d..783d215b4 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -79,6 +79,34 @@ TEST_CASE("initialize", "[nwb]") io->close(); // close the io } +TEST_CASE("initialize preserves existing subject metadata", "[nwb]") +{ + const std::string filename = + getTestFilePath("testInitializeExistingSubject.nwb"); + auto io = std::make_shared(filename); + io->open(); + + auto nwbfile = NWB::NWBFile::create(io); + REQUIRE(nwbfile->initialize(generateUuid()) == Status::Success); + + auto existingSubjectSpec = getTestSubjectSpec(); + existingSubjectSpec.subjectId = "existing-subject"; + auto subject = NWB::Subject::create("/general/subject", io); + REQUIRE(subject->initialize(existingSubjectSpec) == Status::Success); + + REQUIRE(nwbfile->initialize(generateUuid(), + "Test initialized NWB file", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()) + == Status::Success); + REQUIRE(subject->readSubjectId()->values().data + == std::vector {"existing-subject"}); + + io->close(); +} + TEST_CASE("createElectrodesTable", "[nwb]") { std::string filename = getTestFilePath("createElectrodesTable.nwb"); From 5e39cd2522498c96d79a3bc916f1d37d8aa8b3a4 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 13 Aug 2026 02:21:15 -0700 Subject: [PATCH 18/40] Prevent invalid Subject data write --- src/nwb/file/Subject.cpp | 16 +++++++++------- tests/testSubject.cpp | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/nwb/file/Subject.cpp b/src/nwb/file/Subject.cpp index 2093b6748..93067abb3 100644 --- a/src/nwb/file/Subject.cpp +++ b/src/nwb/file/Subject.cpp @@ -64,16 +64,18 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) // Initialize date_of_birth dataset if date_of_birth is provided if (subjectSpec.dateOfBirth.has_value()) { - Status dobStatus = - ioPtr->createStringDataSet(mergePaths(this->m_path, "date_of_birth"), - subjectSpec.dateOfBirth.value()); - initStatus = initStatus && dobStatus; - if (!dobStatus) { - std::cerr << "Failed to create date_of_birth dataset." << std::endl; - } if (isISO8601Date(subjectSpec.dateOfBirth.value()) == false) { std::cerr << "Warning: date_of_birth is not in ISO8601 format: " << subjectSpec.dateOfBirth.value() << std::endl; + initStatus = Status::Failure; + } else { + Status dobStatus = + ioPtr->createStringDataSet(mergePaths(this->m_path, "date_of_birth"), + subjectSpec.dateOfBirth.value()); + initStatus = initStatus && dobStatus; + if (!dobStatus) { + std::cerr << "Failed to create date_of_birth dataset." << std::endl; + } } } // Initialize description dataset if description is provided diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp index 9a8e33314..1cd40ec0e 100644 --- a/tests/testSubject.cpp +++ b/tests/testSubject.cpp @@ -100,6 +100,24 @@ TEST_CASE("Subject", "[file]") io->close(); } + SECTION("rejects invalid date of birth without writing it") + { + const std::string filename = getTestFilePath("subject_invalid_dob.h5"); + auto io = std::make_shared(filename); + io->open(); + io->createGroup("/general"); + + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.dateOfBirth = "January 15, 2024"; + auto subject = NWB::Subject::create("/general/subject", io); + + REQUIRE(subject->initialize(subjectSpec) == Status::Failure); + REQUIRE_FALSE(subject->readDateOfBirth()->exists()); + REQUIRE_FALSE(io->objectExists("/general/subject/date_of_birth")); + + io->close(); + } + SECTION("is created by NWBFile initialization when metadata is supplied") { const std::string filename = getTestFilePath("nwbfile_subject.nwb"); From 597be22a63a6d1671c0ed33837212b33133d8786 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 12:02:23 -0700 Subject: [PATCH 19/40] Make Subject constructor protected --- src/nwb/file/Subject.hpp | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/nwb/file/Subject.hpp b/src/nwb/file/Subject.hpp index 4104413f3..a3b32ba3f 100644 --- a/src/nwb/file/Subject.hpp +++ b/src/nwb/file/Subject.hpp @@ -24,6 +24,8 @@ namespace AQNWB::NWB class Subject : public AQNWB::NWB::NWBContainer { public: + REGISTER_SUBCLASS(Subject, NWBContainer, AQNWB::SPEC::CORE::namespaceName) + /** * @brief Metadata about the experimental subject. * @@ -56,25 +58,11 @@ class Subject : public AQNWB::NWB::NWBContainer std::optional weight = std::nullopt; }; - /** - * @brief Constructor for Subject. - * @param io The shared pointer to the IO object. - */ - explicit Subject(std::shared_ptr io); - - /** - * @brief Constructor - * @param path Path to the object in the file - * @param io IO object for reading/writing - */ - Subject(const std::string& path, std::shared_ptr io); - /** * @brief Virtual destructor. */ virtual ~Subject() override {} - // TODO: Update the initialize method as appropriate. /** * @brief Initialize the object * @param subjectSpec The SubjectSpec object with the subject metadata @@ -140,7 +128,19 @@ class Subject : public AQNWB::NWB::NWBContainer "Age is with reference to this event. Can be birth or gestational. If " "reference is omitted - birth is implied.") - REGISTER_SUBCLASS(Subject, NWBContainer, AQNWB::SPEC::CORE::namespaceName) +protected: + /** + * @brief Constructor for Subject. + * @param io The shared pointer to the IO object. + */ + explicit Subject(std::shared_ptr io); + + /** + * @brief Constructor + * @param path Path to the object in the file + * @param io IO object for reading/writing + */ + Subject(const std::string& path, std::shared_ptr io); }; } // namespace AQNWB::NWB From 0a430ce90cf279008b41456490d5dc077ead2ec1 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 12:18:47 -0700 Subject: [PATCH 20/40] Fix missing subject in unit test from parent branch --- tests/testNWBFile.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index ae39ebe11..499bfb88d 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -114,7 +114,13 @@ TEST_CASE("createEventsTable", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + Status fileStatus = nwbfile->initialize(generateUuid(), + "Test initialized NWB file", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); + REQUIRE(fileStatus == Status::Success); // create the Events Table auto eventsTable = nwbfile->createEventsTable("test_events", From 5dab595f20b086f791e5a82dc44be552d509e480 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 12:19:21 -0700 Subject: [PATCH 21/40] Added missing convenience Subject::create to fix the path --- src/nwb/file/Subject.cpp | 6 ++++++ src/nwb/file/Subject.hpp | 8 ++++++++ tests/testSubject.cpp | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/src/nwb/file/Subject.cpp b/src/nwb/file/Subject.cpp index 93067abb3..d53689b3e 100644 --- a/src/nwb/file/Subject.cpp +++ b/src/nwb/file/Subject.cpp @@ -25,6 +25,12 @@ Subject::Subject(const std::string& path, std::shared_ptr io) } } +std::shared_ptr Subject::create(std::shared_ptr io) +{ + return RegisteredType::create( + mergePaths(NWBFile::GENERAL_PATH, "subject"), io); +} + // Initialize the object Status Subject::initialize(const SubjectSpec& subjectSpec) { diff --git a/src/nwb/file/Subject.hpp b/src/nwb/file/Subject.hpp index a3b32ba3f..2383158dc 100644 --- a/src/nwb/file/Subject.hpp +++ b/src/nwb/file/Subject.hpp @@ -58,6 +58,14 @@ class Subject : public AQNWB::NWB::NWBContainer std::optional weight = std::nullopt; }; + /** \brief Convenience factor method since the path is fixed to + * '/general/subject' + * @param io A shared pointer to the IO object. + * @return A shared pointer to the created NWBFile object, or nullptr if + * creation failed. + */ + static std::shared_ptr create(std::shared_ptr io); + /** * @brief Virtual destructor. */ diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp index 1cd40ec0e..b544a30da 100644 --- a/tests/testSubject.cpp +++ b/tests/testSubject.cpp @@ -27,6 +27,15 @@ TEST_CASE("Subject", "[file]") REQUIRE(subject->getIO() == io); } + SECTION("convience create method creates a Subject at the canonical path") + { + auto io = createIO("HDF5", getTestFilePath("subject_create.h5")); + auto subject = NWB::Subject::create(io); + + REQUIRE(subject->getPath() == "/general/subject"); + REQUIRE(subject->getIO() == io); + } + SECTION("writes and reads every metadata field") { const std::string filename = getTestFilePath("subject_all_fields.h5"); From 425ff1340c0759ea4b67af3967b2c2ca4cc355ab Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 12:21:40 -0700 Subject: [PATCH 22/40] Fix codespell --- tests/testSubject.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp index b544a30da..42d2c95c2 100644 --- a/tests/testSubject.cpp +++ b/tests/testSubject.cpp @@ -27,7 +27,7 @@ TEST_CASE("Subject", "[file]") REQUIRE(subject->getIO() == io); } - SECTION("convience create method creates a Subject at the canonical path") + SECTION("convenience create method creates a Subject at the canonical path") { auto io = createIO("HDF5", getTestFilePath("subject_create.h5")); auto subject = NWB::Subject::create(io); From b164a7a175ffd93037f6868c536f207e78b5595a Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 12:32:45 -0700 Subject: [PATCH 23/40] Added missing Subject to unit test to fix nwb-inspector error --- tests/examples/testEventsWorkflowExamples.cpp | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tests/examples/testEventsWorkflowExamples.cpp b/tests/examples/testEventsWorkflowExamples.cpp index 021d450ca..61086ec5a 100644 --- a/tests/examples/testEventsWorkflowExamples.cpp +++ b/tests/examples/testEventsWorkflowExamples.cpp @@ -32,7 +32,20 @@ TEST_CASE("eventsWorkflowExamples") // [example_events_rowbased_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); + AQNWB::NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.subjectId = "mouse001"; + subjectSpec.species = "Mus musculus"; + subjectSpec.sex = "M"; + subjectSpec.age = "P90D"; + subjectSpec.description = + "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"); // [example_events_rowbased_nwbfile_snippet] REQUIRE(initStatus == Status::Success); From 87af098abf202f2669a389cd3feea909019112cc Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 12:42:47 -0700 Subject: [PATCH 24/40] Fix missing Subject in Event workflow --- tests/examples/testEventsWorkflowExamples.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/examples/testEventsWorkflowExamples.cpp b/tests/examples/testEventsWorkflowExamples.cpp index 61086ec5a..8b420076a 100644 --- a/tests/examples/testEventsWorkflowExamples.cpp +++ b/tests/examples/testEventsWorkflowExamples.cpp @@ -135,8 +135,21 @@ TEST_CASE("eventsWorkflowExamples") // [example_events_colbased_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); - REQUIRE(initStatus == Status::Success); + AQNWB::NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.subjectId = "mouse001"; + subjectSpec.species = "Mus musculus"; + subjectSpec.sex = "M"; + subjectSpec.age = "P90D"; + subjectSpec.description = + "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"); // [example_events_colbased_nwbfile_snippet] REQUIRE(initStatus == Status::Success); From 2efab35eab4fed1522e5cab54d85cf13eb12bca7 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 13:11:17 -0700 Subject: [PATCH 25/40] Allow function changing in SubjectSpec to ease initalization with optionals --- src/nwb/file/Subject.hpp | 112 ++++++++++++++++++ tests/examples/testEventsWorkflowExamples.cpp | 46 ++++--- tests/examples/testWorkflowExamples.cpp | 15 +-- tests/testSubject.cpp | 26 ++++ 4 files changed, 177 insertions(+), 22 deletions(-) diff --git a/src/nwb/file/Subject.hpp b/src/nwb/file/Subject.hpp index 2383158dc..534460db7 100644 --- a/src/nwb/file/Subject.hpp +++ b/src/nwb/file/Subject.hpp @@ -56,6 +56,118 @@ class Subject : public AQNWB::NWB::NWBContainer std::optional subjectId = std::nullopt; /// @brief Weight at time of experiment. std::optional weight = std::nullopt; + + /** + * @brief Set the age of the subject. + * @param value Age of subject (e.g., "P90D" for 90 days post-natal). + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withAge(std::string value) + { + age = std::move(value); + return *this; + } + + /** + * @brief Set the age reference event of the subject. + * @param value Age is with reference to this event. Can be ‘birth’ or + * ‘gestational’. If reference is omitted, ‘birth’ is implied. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withAgeReference(std::string value) + { + ageReference = std::move(value); + return *this; + } + + /** + * @brief Set the date of birth of the subject. + * @param value Date of birth of subject as iso formatted date string. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withDateOfBirth(std::string value) + { + dateOfBirth = std::move(value); + return *this; + } + + /** + * @brief Set the description of the subject. + * @param value Description of subject and where subject came from. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withDescription(std::string value) + { + description = std::move(value); + return *this; + } + + /** + * @brief Set the genotype of the subject. + * @param value Genetic strain. If absent, assume Wild Type (WT). + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withGenotype(std::string value) + { + genotype = std::move(value); + return *this; + } + + /** + * @brief Set the biological sex of the subject. + * @param value Biological sex of subject. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withSex(std::string value) + { + sex = std::move(value); + return *this; + } + + /** + * @brief Set the species of the subject. + * @param value Species of subject. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withSpecies(std::string value) + { + species = std::move(value); + return *this; + } + + /** + * @brief Set the strain of the subject. + * @param value Strain of subject. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withStrain(std::string value) + { + strain = std::move(value); + return *this; + } + + /** + * @brief Set the ID of the subject. + * @param value ID of animal/person used/participating in experiment (lab + * convention). + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withSubjectId(std::string value) + { + subjectId = std::move(value); + return *this; + } + + /** + * @brief Set the weight of the subject. + * @param value Weight at time of experiment. + * @return Reference to this SubjectSpec, to allow method chaining. + */ + SubjectSpec& withWeight(std::string value) + { + weight = std::move(value); + return *this; + } }; /** \brief Convenience factor method since the path is fixed to diff --git a/tests/examples/testEventsWorkflowExamples.cpp b/tests/examples/testEventsWorkflowExamples.cpp index 8b420076a..e77d3916b 100644 --- a/tests/examples/testEventsWorkflowExamples.cpp +++ b/tests/examples/testEventsWorkflowExamples.cpp @@ -32,13 +32,14 @@ TEST_CASE("eventsWorkflowExamples") // [example_events_rowbased_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - AQNWB::NWB::Subject::SubjectSpec subjectSpec; - subjectSpec.subjectId = "mouse001"; - subjectSpec.species = "Mus musculus"; - subjectSpec.sex = "M"; - subjectSpec.age = "P90D"; - subjectSpec.description = - "Wild type mouse used for electrophysiology study"; + auto subjectSpec = + AQNWB::NWB::Subject::SubjectSpec() + .withSubjectId("mouse001") + .withSpecies("Mus musculus") + .withSex("M") + .withAge("P90D") + .withDescription( + "Wild type mouse used for electrophysiology study"); std::string currentTime = getCurrentTime(); Status initStatus = nwbfile->initialize(generateUuid(), "a recording session", @@ -135,13 +136,14 @@ TEST_CASE("eventsWorkflowExamples") // [example_events_colbased_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - AQNWB::NWB::Subject::SubjectSpec subjectSpec; - subjectSpec.subjectId = "mouse001"; - subjectSpec.species = "Mus musculus"; - subjectSpec.sex = "M"; - subjectSpec.age = "P90D"; - subjectSpec.description = - "Wild type mouse used for electrophysiology study"; + auto subjectSpec = + AQNWB::NWB::Subject::SubjectSpec() + .withSubjectId("mouse001") + .withSpecies("Mus musculus") + .withSex("M") + .withAge("P90D") + .withDescription( + "Wild type mouse used for electrophysiology study"); std::string currentTime = getCurrentTime(); Status initStatus = nwbfile->initialize(generateUuid(), "a recording session", @@ -256,7 +258,21 @@ TEST_CASE("eventsWorkflowExamples") // [example_events_meanings_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); + auto subjectSpec = + AQNWB::NWB::Subject::SubjectSpec() + .withSubjectId("mouse001") + .withSpecies("Mus musculus") + .withSex("M") + .withAge("P90D") + .withDescription( + "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"); // [example_events_meanings_nwbfile_snippet] REQUIRE(initStatus == Status::Success); diff --git a/tests/examples/testWorkflowExamples.cpp b/tests/examples/testWorkflowExamples.cpp index 99c10fe9c..a1bb343d9 100644 --- a/tests/examples/testWorkflowExamples.cpp +++ b/tests/examples/testWorkflowExamples.cpp @@ -47,13 +47,14 @@ TEST_CASE("workflowExamples") // [example_workflow_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - AQNWB::NWB::Subject::SubjectSpec subjectSpec; - subjectSpec.subjectId = "mouse001"; - subjectSpec.species = "Mus musculus"; - subjectSpec.sex = "M"; - subjectSpec.age = "P90D"; - subjectSpec.description = - "Wild type mouse used for electrophysiology study"; + auto subjectSpec = + AQNWB::NWB::Subject::SubjectSpec() + .withSubjectId("mouse001") + .withSpecies("Mus musculus") + .withSex("M") + .withAge("P90D") + .withDescription( + "Wild type mouse used for electrophysiology study"); std::string currentTime = getCurrentTime(); Status initStatus = nwbfile->initialize(generateUuid(), "a recording session", diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp index 42d2c95c2..acb4b4a3c 100644 --- a/tests/testSubject.cpp +++ b/tests/testSubject.cpp @@ -36,6 +36,32 @@ TEST_CASE("Subject", "[file]") REQUIRE(subject->getIO() == io); } + SECTION("setting parameters via with function chainging") + { + NWB::Subject::SubjectSpec subjectSpec; + subjectSpec.withAge("P90D") + .withAgeReference("gestational") + .withDateOfBirth("2024-01-15T00:00:00.000000+00:00") + .withDescription("Test subject") + .withGenotype("wt/wt") + .withSex("M") + .withSpecies("Mus musculus") + .withStrain("C57BL/6J") + .withSubjectId("subject-001") + .withWeight("25 g"); + + REQUIRE(subjectSpec.age == "P90D"); + REQUIRE(subjectSpec.ageReference == "gestational"); + REQUIRE(subjectSpec.dateOfBirth == "2024-01-15T00:00:00.000000+00:00"); + REQUIRE(subjectSpec.description == "Test subject"); + REQUIRE(subjectSpec.genotype == "wt/wt"); + REQUIRE(subjectSpec.sex == "M"); + REQUIRE(subjectSpec.species == "Mus musculus"); + REQUIRE(subjectSpec.strain == "C57BL/6J"); + REQUIRE(subjectSpec.subjectId == "subject-001"); + REQUIRE(subjectSpec.weight == "25 g"); + } + SECTION("writes and reads every metadata field") { const std::string filename = getTestFilePath("subject_all_fields.h5"); From bef3132734a6a394885ed6f7ef1c9a5a5af94b71 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 13:19:09 -0700 Subject: [PATCH 26/40] Fix codespell --- tests/testSubject.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testSubject.cpp b/tests/testSubject.cpp index acb4b4a3c..4d804ade6 100644 --- a/tests/testSubject.cpp +++ b/tests/testSubject.cpp @@ -36,7 +36,7 @@ TEST_CASE("Subject", "[file]") REQUIRE(subject->getIO() == io); } - SECTION("setting parameters via with function chainging") + SECTION("setting parameters via with function chaining") { NWB::Subject::SubjectSpec subjectSpec; subjectSpec.withAge("P90D") From b28ea09c2737d140ea4af8d99bb6f7f6b2586bec Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 13:34:43 -0700 Subject: [PATCH 27/40] Add missing Subject to time intervals tests --- .../testTimeIntervalsWorkflowExamples.cpp | 34 +++++++++++++++++-- tests/testNWBFile.cpp | 9 +++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tests/examples/testTimeIntervalsWorkflowExamples.cpp b/tests/examples/testTimeIntervalsWorkflowExamples.cpp index 9cf18df59..67b8d7ab5 100644 --- a/tests/examples/testTimeIntervalsWorkflowExamples.cpp +++ b/tests/examples/testTimeIntervalsWorkflowExamples.cpp @@ -30,7 +30,21 @@ TEST_CASE("timeIntervalsWorkflowExamples") // [example_timeintervals_rowbased_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); + auto subjectSpec = + AQNWB::NWB::Subject::SubjectSpec() + .withSubjectId("mouse001") + .withSpecies("Mus musculus") + .withSex("M") + .withAge("P90D") + .withDescription( + "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"); // [example_timeintervals_rowbased_nwbfile_snippet] REQUIRE(initStatus == Status::Success); @@ -119,8 +133,22 @@ TEST_CASE("timeIntervalsWorkflowExamples") // [example_timeintervals_colbased_nwbfile_snippet] auto nwbfile = NWB::NWBFile::create(io); - Status initStatus = nwbfile->initialize(generateUuid()); - REQUIRE(initStatus == Status::Success); + auto subjectSpec = + AQNWB::NWB::Subject::SubjectSpec() + .withSubjectId("mouse001") + .withSpecies("Mus musculus") + .withSex("M") + .withAge("P90D") + .withDescription( + "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"); // [example_timeintervals_colbased_nwbfile_snippet] REQUIRE(initStatus == Status::Success); diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index dd153700c..d0d40bfce 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -114,8 +114,13 @@ TEST_CASE("createTimeIntervalsTables", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); - + REQUIRE(nwbfile->initialize(generateUuid(), + "Test initialized NWB file", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()) + == Status::Success); // create the Epochs Table auto epochsTable = nwbfile->createEpochs(false, 50); REQUIRE(epochsTable != nullptr); From 699e4de8102b9b1a624bdbf73b10e64d1dc4307f Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 15:42:13 -0700 Subject: [PATCH 28/40] Updated CHANGELOG to group related changes for the upcoming release --- CHANGELOG.md | 92 +++++++++++++++++++++++++++++----------------------- 1 file changed, 51 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9daff3f3..777e070b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,59 +10,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Upcoming [0.4.0] (~August 2026) ### Added -* Added `DynamicTable::toString` to support converting rows to a string representation. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `DynamicTable::getNumberOfRows` to support retrieving the number of rows in a `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `TimeIntervals` type for annotating time intervals and updated `NWBFile` to support the standard `intervals` tables (e.g., `trials`, `invalid_times`, `epochs`). (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `VectorIndex` type for storing index columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `VectorData::appendData` and `VectorIndex::appendData` to simplify appending values to columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `DynamicTable::readRows` to support reading of full rows, along with `VectorData::readCellValues` and `VectorIndex::readIndexedCellValues`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * **Added support for streaming data read of remote NWB files:** * Added `HDF5IO::openS3(...)` method to support opening an existing remote file in S3 in read-only mode using the HDF5 ROS3 driver. (@oruebel, [#307](https://github.com/NeurodataWithoutBorders/aqnwb/pull/307)) * Added `HDF5IO::openRemote()` method to read remote NWB files over HTTP(S) using the [remfile-cpp](https://github.com/catalystneuro/remfile-cpp) virtual file driver (a C++ port of the Python [remfile](https://github.com/magland/remfile) package), imported as an optional CMake dependency (`AQNWB_USE_REMFILE`, requires `libcurl`). Unlike ROS3, remfile does not require HDF5 to be built with ROS3 support and works with any HTTP(S) server that supports byte-range requests. (@bendichter, [#309](https://github.com/NeurodataWithoutBorders/aqnwb/pull/309)) + * Added new `BaseIO::findObject` and `RegisteredType::findOwnedObject` methods to simplify searching for objects by name. Added `HDF5IO::findObject` override method to optimize the search for HDF5 objects. (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308)) * Added demo for benchmarking ROS3 and remfile performance and comparing with PyNWB S3 reads (`demo/remote_read_benchmark`). (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308); @bendichter, [#309](https://github.com/NeurodataWithoutBorders/aqnwb/pull/309)) * Added tutorial on using the ROS3 and remfile drivers to read NWB files in S3 (`docs/pages/userdocs/reads3.dox`) (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308); @bendichter, [#309](https://github.com/NeurodataWithoutBorders/aqnwb/pull/309)) -* Added `ElectricalSeries::writeAllChannels` method and `IO::writeElectricalSeriesData` overload to simplify zero-copy interleaved multichannel writes. (@copilot, @oruebel, [#293](https://github.com/NeurodataWithoutBorders/aqnwb/pull/293)) -* Added `ElectricalSeries::channelsAtSameSampleOffset` method to check if all channels are at the same sample offset, which is a requirement for using `writeAllChannels`. (@copilot, @oruebel, [#293](https://github.com/NeurodataWithoutBorders/aqnwb/pull/293)) -* Added `TimestampVectorData` and `DurationVectorData` types to support event timing data. (@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) -* Added `DynamicTable::addColumn(DataSpecPtr)` overload consistent with the `DataSpec`-based API used by `initialize()` and `createDefaultDataSpecs()`.(@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) -* Added new `MeaningsTable` type to support adding meanings to `DynamicTable` columns. (@cline, @oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/302)) -* Added `DynamicTable::createMeaningsTable` and `DynamicTable::readMeaningsTable` methods to support creating and reading `MeaningsTable` objects associated with a `DynamicTable`. (@cline, @oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/302)) -* Added new `EventsTable` type to support recording of event data. (@cline, @oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) -* Added `NWBFile::createEventsTable` convenience function (@cline, @oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) -* Added `Data::DataSpecBase` and `Data::DataSpec` to support runtime configuration of `DynamicTable` column layouts and to decouple table schema definition from object initialization. Each `Data` subclass (e.g., `VectorData`, `ElementIdentifiers`, `TimestampVectorData`, `DurationVectorData`) now exposes a nested `DataSpec` struct that bundles the dataset configuration, description, and any type-specific parameters needed to create and initialize the column. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) -* Added `DynamicTable::createDefaultDataSpecs` static factory to return the default ordered list of `DataSpec` objects for a table (currently just the `id` column). Subclasses (`ElectrodesTable`, `EventsTable`, `MeaningsTable`) override this to append their own required columns, allowing callers to retrieve, inspect, and customize the default column configuration before passing it to `initialize()`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) -* Added `DynamicTable::addRow` and `DynamicTable::addRows` methods to support row-based data insertion into a `DynamicTable`. Rows are specified as `RowData` (an `unordered_map` from column name to `CellValue` variant), enabling type-safe, column-keyed writes without requiring callers to manage per-column buffers directly. Row IDs are auto-generated if not provided. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)). - * Enhanced the new `CellValue` to support both scalar and vector data, enabling support for ragged array columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) - * Moved `CellValue` and `RowData` definitions to `Types.hpp` to avoid circular dependencies, and moved `VectorData` implementation details to `VectorData.cpp`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) - * Updated `DynamicTable::addRows` and related methods to support ragged array column writes. This also included moving of `CellValue` and `RowData` definitions to `Types.hpp`; enhancement of `CellValue` to support scalar and vector types for ragged columns; and addition of `VectorData::appendData` and `VectorIndex::appendData` (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `BaseDataType::BaseDataVariant` scalar variant type and `BaseDataType::createEmptyVectorVariant` helper to support runtime-typed single-cell and buffer operations needed by `addRow`/`addRows`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) -* Added tutorials for the new `MeaningsTable` and `EventsTable` types in `docs/pages/userdocs/events.dox`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) -* Added tutorials for the new `TimeIntervals` type in `docs/pages/userdocs/time_intervals.dox`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Added `DyanmicTable::validateDataSpecs` (and `DynamicTable::checkRequiredColumnNames` helper function) to validate column specifications as part of `DyanmicTable::initialize` before initialization. Also updated subclassed of `DynamicTable` (e.g., `ElectrodesTable`, `EventsTable`, `MeaningsTable`) to override `validateDataSpecs` to provide their specific validation logic. (@oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) -* Added new `BaseIO::findObject` and `RegisteredType::findOwnedObject` methods to simplify searching for objects by name. Added `HDF5IO::findObject` override method to optimize the search for HDF5 objects. (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308)) -* Added `Subject` class to represent the `/general/subject` group in NWB files, with corresponding `SubjectSpec` for initialization. (@copilot, @oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) - * Updated nwbinspector tests to remove `--ignore=check_subject_exists` option to require subject (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) - * Updated `NWBFile::initialize` to accept a `SubjectSpec` argument for subject metadata initialization (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) +* **Added support for NWB schema 2.10 and EventTable, TimeIntervals, MeaningsTable, VectorIndex, and Subject:** + * Updated schema in the `spec/` module to the latest NWB releases: (i) NWB 2.10, (ii) HDMF Common 1.10, and (iii) HDMF Experimental 0.6 (@oruebel, [#300](https://github.com/NeurodataWithoutBorders/aqnwb/pull/300)) + * Added `TimestampVectorData` and `DurationVectorData` types to support event timing data. (@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) + * Updated `DynamicTable::readColumn` using type traits to support reading both `VectorData` columns with a specific data type and columns that are a subtype of `VectorData` (e.g., the new `TimestampVectorData` and `DurationVectorData`). (@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) + * Added new `EventsTable` type to support recording of event data and updated `NWBFile` to `NWBFile::initialize` to create the new top-level `events` group and add create and read methods to support events (e.g., `NWBFile::createEventsTable`). (@cline, @oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) + * Added `TimeIntervals` type for annotating time intervals and updated `NWBFile` to support the standard `intervals` tables (e.g., `trials`, `invalid_times`, `epochs`). (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added new `MeaningsTable` type to support adding meanings to `DynamicTable` columns. (@cline, @oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/302)) + * Added `DynamicTable::createMeaningsTable` and `DynamicTable::readMeaningsTable` methods to support creating and reading `MeaningsTable` objects associated with a `DynamicTable`. (@cline, @oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/302)) + * Added tutorials for the new `MeaningsTable` and `EventsTable` types in `docs/pages/userdocs/events.dox`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) + * Added tutorials for the new `TimeIntervals` type in `docs/pages/userdocs/time_intervals.dox`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `Subject` class to represent the `/general/subject` group in NWB files. Added corresponding `SubjectSpec` to simplify configuration and initialization of `Subject`. Updated `NWBFile::initialize` to accept a `SubjectSpec` argument for subject metadata initialization (@copilot, @oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) +* **Added support for single-ragged array columns in `DynamicTable`:** + * Added `VectorIndex` type for storing index columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `VectorData::appendData` and `VectorIndex::appendBuffer` to simplify appending values to columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Updated `DynamicTable::addRows` and related methods to support ragged array column writes. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) +* **Added support for row-based read and write of `DynamicTable`:** + * Added `DynamicTable::addRow` and `DynamicTable::addRows` methods to support row-based data insertion into a `DynamicTable`. Rows are specified as `RowData` (an `unordered_map` from column name to `CellValue` variant), enabling type-safe, column-keyed writes without requiring callers to manage per-column buffers directly. Row IDs are auto-generated if not provided. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)). + * Added new `Types::CellValue` variant type to support single-cell values for `DynamicTable` rows. `CellValue` supports all scalar types, as well as `std::vector` for ragged array columns. Also defined `Types::RowData` as an `unordered_map` to represent a single row of data in a `DynamicTable`. (@cline, @oruebel, Originally defined in [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305) and then subsequently enhanced and moved in [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `DynamicTable::readRows` to support reading of full rows, along with `VectorData::readCellValues` and `VectorIndex::readIndexedCellValues`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `DynamicTable::toString` to support converting rows to a string representation. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `DynamicTable::getNumberOfRows` to support retrieving the number of rows in a `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Updated the user tutorial on reading data to discuss reading from `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) +* **Added support for `DataSpec`-based column configuration in `DynamicTable`:** + * **[BREAKING]** Updated `DynamicTable::initialize`, `ElectrodesTable::initialize`, `EventsTable::initialize`, and `MeaningsTable::initialize` to accept a `std::vector` column-spec list instead of individual `rowChunkSize` / resolution / flag parameters. This approach separated configuration from initialization, allowing users to fully configure all columns (including custom columns) beforehand such that `initialize` can then handle the full setup of the table. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) + **Migration Note:** Callers should use the corresponding `createDefaultDataSpecs(...)` static factory to obtain the default spec vector, optionally modify it, and pass it to `initialize()`. `NWBFile::createElectrodesTable` and `NWBFile::createEventsTable` have been updated accordingly. + * Added `DynamicTable::addColumn(DataSpecPtr)` overload consistent with the `DataSpec`-based API used by `initialize()` and `createDefaultDataSpecs()`.(@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) + * Added `Data::DataSpecBase` and `Data::DataSpec` to support runtime configuration of `DynamicTable` column layouts and to decouple table schema definition from object initialization. Each `Data` subclass (e.g., `VectorData`, `ElementIdentifiers`, `TimestampVectorData`, `DurationVectorData`) now exposes a nested `DataSpec` struct that bundles the dataset configuration, description, and any type-specific parameters needed to create and initialize the column. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) + * Added `DynamicTable::createDefaultDataSpecs` static factory to return the default ordered list of `DataSpec` objects for a table (currently just the `id` column). Subclasses (`ElectrodesTable`, `EventsTable`, `MeaningsTable`) override this to append their own required columns, allowing callers to retrieve, inspect, and customize the default column configuration before passing it to `initialize()`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) + * Added `BaseDataType::BaseDataVariant` scalar variant type and `BaseDataType::createEmptyVectorVariant` helper to support runtime-typed single-cell and buffer operations needed by `addRow`/`addRows`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) + * Added `DyanmicTable::validateDataSpecs` (and `DynamicTable::checkRequiredColumnNames` helper function) to validate column specifications as part of `DyanmicTable::initialize` before initialization. Also updated subclasses of `DynamicTable` (e.g., `ElectrodesTable`, `EventsTable`, `MeaningsTable`) to override `validateDataSpecs` to provide their specific validation logic. (@oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) +* **Added support for zero-copy interleaved multichannel writes:** + * Added `ElectricalSeries::writeAllChannels` method and `IO::writeElectricalSeriesData` overload to simplify zero-copy interleaved multichannel writes. (@copilot, @oruebel, [#293](https://github.com/NeurodataWithoutBorders/aqnwb/pull/293)) + * Added `ElectricalSeries::channelsAtSameSampleOffset` method to check if all channels are at the same sample offset, which is a requirement for using `writeAllChannels`. (@copilot, @oruebel, [#293](https://github.com/NeurodataWithoutBorders/aqnwb/pull/293)) ### Changed - -* **[BREAKING]** Updated `RegisteredType::create` to retrieve objects from the `RecordingObjects` cache if they already exist, rather than creating a new instance. This ensures that multiple requests for the same object path return the exact same object instance. This change is mostly transparent to users, but it centralizes the recording state. As part of this, the `BaseRecordingData` cache has been moved from `RegisteredType` to `BaseIO`. Users who were manually accessing or clearing the cache via `RegisteredType::getCacheRecordingData()` or `RegisteredType::clearRecordingDataCache()` will now need to use `BaseIO::getRecordingDataCache()` and `BaseIO::clearRecordingDataCache()` instead. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) -* Updated the user tutorial on reading data to discuss reading from `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Updated schema in the `spec/` module to the latest NWB releases: (i) NWB 2.10, (ii) HDMF Common 1.10, and (iii) HDMF Experimental 0.6 (@oruebel, [#300](https://github.com/NeurodataWithoutBorders/aqnwb/pull/300)) -* Updated `DynamicTable::readColumn` using type traits to support reading both `VectorData` columns with a specific data type and columns that are a subtype of `VectorData` (e.g., the new `TimestampVectorData` and `DurationVectorData`). (@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) -* Updated `NWBFile::initialize` to create the new top-level `events` group. (@cline, @oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) -* Updated `lint.yml` workflow to install `clang-format` via `pipx` to ensure use of the latest version of `clang-format` (@copilot, @oruebel [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) +* **Enhanced caching of `RegisteredType` (types) and `BaseRecordingData` (datasets) to centralize recording state and avoid duplicate object creation.** + * **[BREAKING]** Updated `RegisteredType::create` to retrieve objects from the `RecordingObjects` cache if they already exist, rather than creating a new instance. This ensures that multiple requests for the same object path return the exact same object instance to ensure that we have one `RegisteredType` object per `neurodata_type` (path) in a file. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) + **Migration Note:** This change is mostly transparent to users, but it centralizes the recording state. + * **[BREAKING]** Updated the caching of `BaseRecordingData` to move from a per-object cache as part of `RegisteredType` objects (e.g., `TimeSeries`) to a central cache managed by the `BaseIO` I/O object. This change ensures that we have one `BaseRecordingData` object per dataset in a file, and that all `RegisteredType` objects that reference the same dataset will share the same `BaseRecordingData` object. This change centralizes the recording state. + **Migration Note:** This change mainly affects users who were manually accessing or clearing the cache via `RegisteredType::getCacheRecordingData()` or `RegisteredType::clearRecordingDataCache()`. Instead, access the cache using the I/O object using `BaseIO::getRecordingDataCache()` and `BaseIO::clearRecordingDataCache()`. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) * **[BREAKING]** Moved `disableSWMRMode` option from `HDF5IO` constructor to a new `HDF5IO::startRecording(bool disableSWMRMode)` overload. The `BaseIO`-compliant `startRecording()` override is preserved and defaults to SWMR enabled. * **Migration Note**: Code using `HDF5IO(path, true)` must be updated to `HDF5IO(path)` followed by `startRecording(true)`. When the `HDF5IO` object is held as a `std::shared_ptr` (e.g., from `createIO`), downcast with `std::dynamic_pointer_cast` to access the overload. (@oruebel [#297](https://github.com/NeurodataWithoutBorders/aqnwb/pull/297)) -* **[BREAKING]** Refactored `DynamicTable` so it creates and initializes its `ElementIdentifiers` `id` column internally during `initialize()`. As part of this change, `setRowIDs()` now takes only the row ID values and writes them directly to the table's built-in `id` column instead of accepting a separate `ElementIdentifiers` object. In practice this aligns the API with the intended usage, since callers should have always used the table's own `id` dataset. (@oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) +* **[BREAKING]** Updated `Types.hpp` to use a `namespace` instead of `class` to group types. (@oruebel, [#325]((https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * **Migration Note:** For most users this change should not require any code changes since the syntax for accessing the types is the same (e.g., `AQNWB::Types::Status`), but for users who were using `using` declarations to import the type can now use `using namespace AQNWB::Types`. +* **[BREAKING]** Refactored `DynamicTable` so it creates and initializes its `ElementIdentifiers` `id` column internally during `initialize()`. As part of this change, `setRowIDs()` now takes only the row ID values and writes them directly to the table's built-in `id` column instead of accepting a separate `ElementIdentifiers` object. (@oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) + * **Migration Notes:** In practice this aligns the API with the intended usage, since callers should have always used the table's own `id` dataset. The main change is in the signature of `DynamicTable::setRowIDs()`. The change in initialization behavior is part of the move to `DataSpec`-based table configurations such that migration is covered by the changes to `DynamicTable::initialize`. * **[BREAKING]** Updated `DynamicTable` column-name management to be immediate and table-driven. `addColumnName()` now flushes new column names to the file as columns are added, `setColNames()` is now restricted to reordering the existing set of columns (it no longer acts as a general overwrite mechanism), and `finalize()` no longer writes the `colnames` attribute because column-name updates are persisted when they occur. This ensures that the `colnames` attribute is always consistent with the actual columns in the table and helps avoid creation of invalid files. (@oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) -* **[BREAKING]** Updated `Types.hpp` to use a `namespace` instead of `class` to group types. For most users this change should not require any code changes since the syntax for accessing the types is the same (e.g., `AQNWB::Types::Status`), but for users who were using `using` declarations to import the type can now use `using namespace AQNWB::Types`. (@oruebel, [#325]((https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) -* Updated `DynamicTable::initialize`, `ElectrodesTable::initialize`, `EventsTable::initialize`, and `MeaningsTable::initialize` to accept a `std::vector` column-spec list instead of individual `rowChunkSize` / resolution / flag parameters. Callers should use the corresponding `createDefaultDataSpecs(...)` static factory to obtain the default spec vector, optionally modify it, and pass it to `initialize()`. `NWBFile::createElectrodesTable` and `NWBFile::createEventsTable` have been updated accordingly. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) -* Updated `build-demo.yml` and `tests.yml` CI to support testing of the new ROS3 and remfile features and scripts (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308)) + * **Migration Note:** For most users this should not require code changes. The main change is that `setColNames()` now enforces that the new column names are consistent with the existing columns in the tables and, hence, cannot be used to arbitrarily overwrite the column names, but the new column names must be a reordering of the existing column names. If you need to add new columns, use `addColumn()` instead. +* **Updated CI test workflows:** + * Updated `build-demo.yml` and `tests.yml` CI to support testing of the new ROS3 and remfile features and scripts (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308)) + * Updated `lint.yml` workflow to install `clang-format` via `pipx` to ensure use of the latest version of `clang-format` (@copilot, @oruebel [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Updated nwbinspector validation tests to ignore the Allen CCF electrode location check when validating mock electrode locations. (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) + * Updated nwbinspector validation tests in the CI to remove dependency on `sanitizer` tests to speed up CI (@oruebel, [#289](https://github.com/NeurodataWithoutBorders/aqnwb/pull/289)) + * Updated unit tests to create `Subject` for all NWB files generated in the test suite to comply with NWB best practices. `nwbinspector` tests require subject by default now. (@oruebel, [#289](https://github.com/NeurodataWithoutBorders/aqnwb/pull/289) removed this requirements by specifying `--ignore=check_subject_exists` and [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320) removed the ignore to ensure that subject is always present in the test files) ### Fixed -* Updated nwbinspector validation tests to ignore the Allen CCF electrode location check when validating mock electrode locations. (@oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) -* Updated nwbinspector validation tests in the CI to: 1) `--ignore=check_subject_exists` and 2) remove dependency on `sanitizer` tests to speed up CI (@oruebel, [#289](https://github.com/NeurodataWithoutBorders/aqnwb/pull/289)) * Fixed `get_utc_offset_seconds` to correctly account for daylight saving time using platform-specific APIs (`tm_gmtoff` on Unix/macOS; `_get_timezone` + `_get_dstbias` on Windows), preventing `session_start_time` from being written ~1 hour ahead of UTC during DST (@cboulay, [#295](https://github.com/NeurodataWithoutBorders/aqnwb/pull/295)) * Fixed `resources/utils/schematype_to_aqnwb.py` to facilitate generation of new types: * Emit forward declarations rather than full header includes for referenced generated types, avoiding circular header dependencies. This issue surfaced due to the update to the latest HDMF Common schema types where `DynamicTable` references `MeaningsTable`, which is itself a `DynamicTable` (@copilot, @oruebel, [#300](https://github.com/NeurodataWithoutBorders/aqnwb/pull/300)) @@ -73,6 +82,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fixed HDF5 string type creation to explicitly use UTF-8 character set for fixed-length and variable-length strings in datasets and attributes, improving compatibility with hdmf/PyNWB string decoding (@copilot, @oruebel [#319](https://github.com/NeurodataWithoutBorders/aqnwb/pull/319)) * Fixed Windows CI by updating the CMake generator in `CMakePresets.json` from `"Visual Studio 17 2022"` to `"Visual Studio 18 2026"` to match the updated `windows-latest` runner (@copilot, @oruebel [#319](https://github.com/NeurodataWithoutBorders/aqnwb/pull/319)) + ## [0.3.0] - 2026-02-23 ### Added From 3b0005e354566501daed8901bd0297497bfcc773 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Thu, 20 Aug 2026 16:14:55 -0700 Subject: [PATCH 29/40] Added missing Subject in new unit test --- tests/testNWBFile.cpp | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 24ce995be..7d6901810 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -279,7 +279,13 @@ TEST_CASE("createEventsTable with post initialization", "[nwb]") std::make_shared(filename); io->open(); auto nwbfile = NWB::NWBFile::create(io); - nwbfile->initialize(generateUuid()); + Status fileStatus = nwbfile->initialize(generateUuid(), + "Test initialized NWB file", + "Test data collection", + getCurrentTime(), + getCurrentTime(), + getTestSubjectSpec()); + REQUIRE(fileStatus == Status::Success); // create the Events Table auto eventsTable = nwbfile->createEventsTable("test_events"); From 526e6f55ea7741d8c2f971fe8ebdb0b8e8a9a3c9 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Fri, 21 Aug 2026 17:06:51 -0700 Subject: [PATCH 30/40] Fix failing unit test --- tests/testNWBFile.cpp | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/testNWBFile.cpp b/tests/testNWBFile.cpp index 26e1cabe1..0fd76fdd9 100644 --- a/tests/testNWBFile.cpp +++ b/tests/testNWBFile.cpp @@ -78,10 +78,9 @@ TEST_CASE("initialize", "[nwb]") REQUIRE(io->objectExists("/events") == false); REQUIRE(io->objectExists("/intervals") == false); - // Since we didn't create any typed objects within the NWBFile, we should - // have no owned types + // We created a Subject so we should have 1 owned type auto result = nwbfile->findOwnedTypes(); - REQUIRE(result.size() == 0); + REQUIRE(result.size() == 1); nwbfile->finalize(); // Good practice since we don't call stop recording, but // not essential From 6d044539b9b88506a5d2ec21447060740868b01f Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Fri, 21 Aug 2026 17:15:49 -0700 Subject: [PATCH 31/40] Fix NWB scherma version in change log --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 777e070b3..c389aa5b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added new `BaseIO::findObject` and `RegisteredType::findOwnedObject` methods to simplify searching for objects by name. Added `HDF5IO::findObject` override method to optimize the search for HDF5 objects. (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308)) * Added demo for benchmarking ROS3 and remfile performance and comparing with PyNWB S3 reads (`demo/remote_read_benchmark`). (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308); @bendichter, [#309](https://github.com/NeurodataWithoutBorders/aqnwb/pull/309)) * Added tutorial on using the ROS3 and remfile drivers to read NWB files in S3 (`docs/pages/userdocs/reads3.dox`) (@oruebel, [#308](https://github.com/NeurodataWithoutBorders/aqnwb/pull/308); @bendichter, [#309](https://github.com/NeurodataWithoutBorders/aqnwb/pull/309)) -* **Added support for NWB schema 2.10 and EventTable, TimeIntervals, MeaningsTable, VectorIndex, and Subject:** - * Updated schema in the `spec/` module to the latest NWB releases: (i) NWB 2.10, (ii) HDMF Common 1.10, and (iii) HDMF Experimental 0.6 (@oruebel, [#300](https://github.com/NeurodataWithoutBorders/aqnwb/pull/300)) +* **Added support for NWB schema 2.11 and EventTable, TimeIntervals, MeaningsTable, VectorIndex, and Subject:** + * Updated schema in the `spec/` module to the latest NWB releases: (i) NWB 2.11, (ii) HDMF Common 1.10, and (iii) HDMF Experimental 0.6 (@oruebel, [#300](https://github.com/NeurodataWithoutBorders/aqnwb/pull/300)) * Added `TimestampVectorData` and `DurationVectorData` types to support event timing data. (@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) * Updated `DynamicTable::readColumn` using type traits to support reading both `VectorData` columns with a specific data type and columns that are a subtype of `VectorData` (e.g., the new `TimestampVectorData` and `DurationVectorData`). (@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) * Added new `EventsTable` type to support recording of event data and updated `NWBFile` to `NWBFile::initialize` to create the new top-level `events` group and add create and read methods to support events (e.g., `NWBFile::createEventsTable`). (@cline, @oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) From 1f64903c4201c98aaf40c09797579dc8c740f95a Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Sat, 22 Aug 2026 13:33:48 -0700 Subject: [PATCH 32/40] Fix column docstrings in EventsTable::createDefaultDataSpecs to match the spec --- src/nwb/event/EventsTable.cpp | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/nwb/event/EventsTable.cpp b/src/nwb/event/EventsTable.cpp index 19ff7a402..4f8efea14 100644 --- a/src/nwb/event/EventsTable.cpp +++ b/src/nwb/event/EventsTable.cpp @@ -41,8 +41,8 @@ std::vector EventsTable::createDefaultDataSpecs( specs.push_back(std::make_shared( "timestamp", timestampConfig, - "A 1-dimensional VectorData that stores timestamps in seconds from " - "thesession start time. Timestamp are not required to be sorted in time.", + "Column containing the time that each event occurred " + "- in seconds - from the session start time.", timestampResolution)); if (createDurationColumn) { @@ -51,8 +51,9 @@ std::vector EventsTable::createDefaultDataSpecs( specs.push_back(std::make_shared( "duration", durationConfig, - "A 1-dimensional VectorData that stores the durations of the events in " - "seconds. Durations are not required to be sorted in time.", + "Optional column containing the duration of each event - in seconds. A " + "value of NaN can be used for events without a duration or with a " + "duration that is not yet specified.", durationResolution)); } @@ -62,8 +63,7 @@ std::vector EventsTable::createDefaultDataSpecs( specs.push_back(std::make_shared( "annotation", annotationConfig, - "A 1-dimensional VectorData that stores annotations for the events. " - "Annotations are not required to be sorted in time.")); + "Column containing user annotations about events.")); } return specs; From 2d431005d16088ebc95292e03d23d17c7e15788f Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Sat, 22 Aug 2026 13:39:08 -0700 Subject: [PATCH 33/40] Update MeaningsTable.target doc --- src/nwb/hdmf/table/MeaningsTable.hpp | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/nwb/hdmf/table/MeaningsTable.hpp b/src/nwb/hdmf/table/MeaningsTable.hpp index ca644ee90..205350efe 100644 --- a/src/nwb/hdmf/table/MeaningsTable.hpp +++ b/src/nwb/hdmf/table/MeaningsTable.hpp @@ -96,11 +96,11 @@ class MeaningsTable : public AQNWB::NWB::DynamicTable "meaning", "The meaning of the value in the linked VectorData object.") - DEFINE_REFERENCED_REGISTERED_FIELD( - readTarget, - AQNWB::NWB::VectorData, - "target", - "The VectorData object for which this table provides meanings.") + DEFINE_REFERENCED_REGISTERED_FIELD(readTarget, + AQNWB::NWB::VectorData, + "target", + "Reference to the VectorData object for " + "which this table provides meanings.") REGISTER_SUBCLASS(MeaningsTable, DynamicTable, From 907d4f91dad8a497e64c4ee8792ee5f559114112 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Sun, 23 Aug 2026 01:20:09 -0700 Subject: [PATCH 34/40] Update changelog to fix formatting --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea3b91ad9..148013a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Updated the user tutorial on reading data to discuss reading from `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * **Added support for `DataSpec`-based column configuration in `DynamicTable`:** * **[BREAKING]** Updated `DynamicTable::initialize`, `ElectrodesTable::initialize`, `EventsTable::initialize`, and `MeaningsTable::initialize` to accept a `std::vector` column-spec list instead of individual `rowChunkSize` / resolution / flag parameters. This approach separated configuration from initialization, allowing users to fully configure all columns (including custom columns) beforehand such that `initialize` can then handle the full setup of the table. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) - **Migration Note:** Callers should use the corresponding `createDefaultDataSpecs(...)` static factory to obtain the default spec vector, optionally modify it, and pass it to `initialize()`. `NWBFile::createElectrodesTable` and `NWBFile::createEventsTable` have been updated accordingly. + * **Migration Note:** Callers should use the corresponding `createDefaultDataSpecs(...)` static factory to obtain the default spec vector optionally modify it, and pass it to `initialize()`. `NWBFile::createElectrodesTable` and `NWBFile::createEventsTable` have been updated accordingly. * Added `DynamicTable::addColumn(DataSpecPtr)` overload consistent with the `DataSpec`-based API used by `initialize()` and `createDefaultDataSpecs()`.(@cline, @oruebel, [#301](https://github.com/NeurodataWithoutBorders/aqnwb/pull/301)) * Added `Data::DataSpecBase` and `Data::DataSpec` to support runtime configuration of `DynamicTable` column layouts and to decouple table schema definition from object initialization. Each `Data` subclass (e.g., `VectorData`, `ElementIdentifiers`, `TimestampVectorData`, `DurationVectorData`) now exposes a nested `DataSpec` struct that bundles the dataset configuration, description, and any type-specific parameters needed to create and initialize the column. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) * Added `DynamicTable::createDefaultDataSpecs` static factory to return the default ordered list of `DataSpec` objects for a table (currently just the `id` column). Subclasses (`ElectrodesTable`, `EventsTable`, `MeaningsTable`) override this to append their own required columns, allowing callers to retrieve, inspect, and customize the default column configuration before passing it to `initialize()`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) @@ -53,9 +53,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * **Enhanced caching of `RegisteredType` (types) and `BaseRecordingData` (datasets) to centralize recording state and avoid duplicate object creation.** * **[BREAKING]** Updated `RegisteredType::create` to retrieve objects from the `RecordingObjects` cache if they already exist, rather than creating a new instance. This ensures that multiple requests for the same object path return the exact same object instance to ensure that we have one `RegisteredType` object per `neurodata_type` (path) in a file. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) - **Migration Note:** This change is mostly transparent to users, but it centralizes the recording state. + * **Migration Note:** This change is mostly transparent to users, but it centralizes the recording state. * **[BREAKING]** Updated the caching of `BaseRecordingData` to move from a per-object cache as part of `RegisteredType` objects (e.g., `TimeSeries`) to a central cache managed by the `BaseIO` I/O object. This change ensures that we have one `BaseRecordingData` object per dataset in a file, and that all `RegisteredType` objects that reference the same dataset will share the same `BaseRecordingData` object. This change centralizes the recording state. - **Migration Note:** This change mainly affects users who were manually accessing or clearing the cache via `RegisteredType::getCacheRecordingData()` or `RegisteredType::clearRecordingDataCache()`. Instead, access the cache using the I/O object using `BaseIO::getRecordingDataCache()` and `BaseIO::clearRecordingDataCache()`. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) + * **Migration Note:** This change mainly affects users who were manually accessing or clearing the cache via `RegisteredType::getCacheRecordingData()` or `RegisteredType::clearRecordingDataCache()`. Instead, access the cache using the I/O object using `BaseIO::getRecordingDataCache()` and `BaseIO::clearRecordingDataCache()`. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) * **[BREAKING]** Moved `disableSWMRMode` option from `HDF5IO` constructor to a new `HDF5IO::startRecording(bool disableSWMRMode)` overload. The `BaseIO`-compliant `startRecording()` override is preserved and defaults to SWMR enabled. * **Migration Note**: Code using `HDF5IO(path, true)` must be updated to `HDF5IO(path)` followed by `startRecording(true)`. When the `HDF5IO` object is held as a `std::shared_ptr` (e.g., from `createIO`), downcast with `std::dynamic_pointer_cast` to access the overload. (@oruebel [#297](https://github.com/NeurodataWithoutBorders/aqnwb/pull/297)) * **[BREAKING]** Updated `Types.hpp` to use a `namespace` instead of `class` to group types. (@oruebel, [#325]((https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) From dd449bbf76844af6e825757eaeefaf1d0a23f85e Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Sat, 29 Aug 2026 17:39:21 -0700 Subject: [PATCH 35/40] Fix bad link in CHANGLEOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 148013a4d..b7e27d7a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,7 +60,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Migration Note**: Code using `HDF5IO(path, true)` must be updated to `HDF5IO(path)` followed by `startRecording(true)`. When the `HDF5IO` object is held as a `std::shared_ptr` (e.g., from `createIO`), downcast with `std::dynamic_pointer_cast` to access the overload. (@oruebel [#297](https://github.com/NeurodataWithoutBorders/aqnwb/pull/297)) * **[BREAKING]** Updated `Types.hpp` to use a `namespace` instead of `class` to group types. (@oruebel, [#325]((https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * **Migration Note:** For most users this change should not require any code changes since the syntax for accessing the types is the same (e.g., `AQNWB::Types::Status`), but for users who were using `using` declarations to import the type can now use `using namespace AQNWB::Types`. -* **[BREAKING]** Refactored `DynamicTable` so it creates and initializes its `ElementIdentifiers` `id` column internally during `initialize()`. As part of this change, `setRowIDs()` now takes only the row ID values and writes them directly to the table's built-in `id` column instead of accepting a separate `ElementIdentifiers` object. (@oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) +* **[BREAKING]** Refactored `DynamicTable` so it creates and initializes its `ElementIdentifiers` `id` column internally during `initialize()`. As part of this change, `setRowIDs()` now takes only the row ID values and writes them directly to the table's built-in `id` column instead of accepting a separate `ElementIdentifiers` object. (@oruebel, [#302](https://github.com/NeurodataWithoutBorders/aqnwb/pull/302)) * **Migration Notes:** In practice this aligns the API with the intended usage, since callers should have always used the table's own `id` dataset. The main change is in the signature of `DynamicTable::setRowIDs()`. The change in initialization behavior is part of the move to `DataSpec`-based table configurations such that migration is covered by the changes to `DynamicTable::initialize`. * **[BREAKING]** Updated `DynamicTable` column-name management to be immediate and table-driven. `addColumnName()` now flushes new column names to the file as columns are added, `setColNames()` is now restricted to reordering the existing set of columns (it no longer acts as a general overwrite mechanism), and `finalize()` no longer writes the `colnames` attribute because column-name updates are persisted when they occur. This ensures that the `colnames` attribute is always consistent with the actual columns in the table and helps avoid creation of invalid files. (@oruebel, [#304](https://github.com/NeurodataWithoutBorders/aqnwb/pull/304)) * **Migration Note:** For most users this should not require code changes. The main change is that `setColNames()` now enforces that the new column names are consistent with the existing columns in the tables and, hence, cannot be used to arbitrarily overwrite the column names, but the new column names must be a reordering of the existing column names. If you need to add new columns, use `addColumn()` instead. From 7c3630c117fb6ae7290a03339a3f50f4fadb3003 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 2 Sep 2026 12:53:03 -0700 Subject: [PATCH 36/40] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e27d7a3..7cdb2e885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added tutorials for the new `MeaningsTable` and `EventsTable` types in `docs/pages/userdocs/events.dox`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) * Added tutorials for the new `TimeIntervals` type in `docs/pages/userdocs/time_intervals.dox`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * Added `Subject` class to represent the `/general/subject` group in NWB files. Added corresponding `SubjectSpec` to simplify configuration and initialization of `Subject`. Updated `NWBFile::initialize` to accept a `SubjectSpec` argument for subject metadata initialization (@copilot, @oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) + * Added `isPathOrDescendant` utility functoin to simplify path initalization checks for root groups, e.g, `/events` and `/intervals` (@copilot, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * **Added support for single-ragged array columns in `DynamicTable`:** * Added `VectorIndex` type for storing index columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * Added `VectorData::appendData` and `VectorIndex::appendBuffer` to simplify appending values to columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) From 64ee4a623daa517f0f87933e6690fab9cd150334 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 2 Sep 2026 13:22:01 -0700 Subject: [PATCH 37/40] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cdb2e885..610538f38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added `DynamicTable::toString` to support converting rows to a string representation. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * Added `DynamicTable::getNumberOfRows` to support retrieving the number of rows in a `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * Updated the user tutorial on reading data to discuss reading from `DynamicTable`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `BaseDataType::isCompatibleCellValue()`  and  `BaseDataType::isCompatibleVector()` for efficient runtime storage-type/variant compatibility check used in `VectorData::appendData()`  and  `appendBuffer()` to safeguard mismatching data types on write. (@copilot, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * **Added support for `DataSpec`-based column configuration in `DynamicTable`:** * **[BREAKING]** Updated `DynamicTable::initialize`, `ElectrodesTable::initialize`, `EventsTable::initialize`, and `MeaningsTable::initialize` to accept a `std::vector` column-spec list instead of individual `rowChunkSize` / resolution / flag parameters. This approach separated configuration from initialization, allowing users to fully configure all columns (including custom columns) beforehand such that `initialize` can then handle the full setup of the table. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) * **Migration Note:** Callers should use the corresponding `createDefaultDataSpecs(...)` static factory to obtain the default spec vector optionally modify it, and pass it to `initialize()`. `NWBFile::createElectrodesTable` and `NWBFile::createEventsTable` have been updated accordingly. From 616b39e8f319bf07bbf9642ca16cd1b4ecee0c7b Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 2 Sep 2026 14:37:23 -0700 Subject: [PATCH 38/40] Ensure cached registered objects match their concrete on-disk type, keep typed data facades transient, and document the revised factory behavior. --- CHANGELOG.md | 4 +-- docs/pages/devdocs/read_design.dox | 13 +++++++++ docs/pages/devdocs/record_design.dox | 21 +++++++++----- docs/pages/devdocs/registered_types.dox | 31 +++++++++++++-------- src/nwb/RegisteredType.cpp | 20 +++++++++---- src/nwb/RegisteredType.hpp | 37 +++++++++++++++++-------- src/nwb/hdmf/base/Data.cpp | 3 +- src/nwb/hdmf/table/DynamicTable.hpp | 26 ++++++++++++++++- src/nwb/hdmf/table/VectorData.cpp | 4 ++- tests/testData.cpp | 18 ++++++++++++ tests/testEventsTable.cpp | 4 +++ tests/testVectorData.cpp | 11 ++++++++ 12 files changed, 151 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 610538f38..06807e5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,8 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * **Enhanced caching of `RegisteredType` (types) and `BaseRecordingData` (datasets) to centralize recording state and avoid duplicate object creation.** - * **[BREAKING]** Updated `RegisteredType::create` to retrieve objects from the `RecordingObjects` cache if they already exist, rather than creating a new instance. This ensures that multiple requests for the same object path return the exact same object instance to ensure that we have one `RegisteredType` object per `neurodata_type` (path) in a file. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) - * **Migration Note:** This change is mostly transparent to users, but it centralizes the recording state. + * **[BREAKING]** Updated `RegisteredType::create()` to cache and reuse one canonical registered object per path. Factory methods resolve the concrete on-disk registered type before reusing a cached object and return `nullptr` for incompatible cached types rather than creating a duplicate or returning the wrong wrapper. `DataTyped`, `VectorDataTyped`, and `NWBDataTyped` remain transient typed facades and are not registered in `RecordingObjects`. (@cline, @oruebel, @copilot, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328), [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) + * **Migration Note:** This change is mostly transparent to users, but it centralizes recording state. Code using `RegisteredType::create()` must handle a `nullptr` result when an incompatible type is already cached at the requested path; use the specialized factories for typed facades. * **[BREAKING]** Updated the caching of `BaseRecordingData` to move from a per-object cache as part of `RegisteredType` objects (e.g., `TimeSeries`) to a central cache managed by the `BaseIO` I/O object. This change ensures that we have one `BaseRecordingData` object per dataset in a file, and that all `RegisteredType` objects that reference the same dataset will share the same `BaseRecordingData` object. This change centralizes the recording state. * **Migration Note:** This change mainly affects users who were manually accessing or clearing the cache via `RegisteredType::getCacheRecordingData()` or `RegisteredType::clearRecordingDataCache()`. Instead, access the cache using the I/O object using `BaseIO::getRecordingDataCache()` and `BaseIO::clearRecordingDataCache()`. (@cline, @oruebel, [#328](https://github.com/NeurodataWithoutBorders/aqnwb/pull/328)) * **[BREAKING]** Moved `disableSWMRMode` option from `HDF5IO` constructor to a new `HDF5IO::startRecording(bool disableSWMRMode)` overload. The `BaseIO`-compliant `startRecording()` override is preserved and defaults to SWMR enabled. diff --git a/docs/pages/devdocs/read_design.dox b/docs/pages/devdocs/read_design.dox index c88157ecb..ab377e093 100644 --- a/docs/pages/devdocs/read_design.dox +++ b/docs/pages/devdocs/read_design.dox @@ -291,6 +291,12 @@ * - The \ref AQNWB::NWB::RegisteredType::create "RegisteredType::create" method is used to create * an instance of a registered subclass by name. This method looks up the subclass name in the * registry and calls the corresponding factory function to create an instance. + * - Each I/O object caches one canonical registered object per file path. + * The path-only \ref AQNWB::NWB::RegisteredType::create "create" overload + * resolves the object's on-disk ``namespace`` and ``neurodata_type`` before + * reusing a cache entry. A request whose registered type conflicts with an + * existing cache entry fails rather than returning a different type or + * creating a second object for the same path. * * 5. **Class Name and Namespace Retrieval**: * - The \ref AQNWB::NWB::RegisteredType::getTypeName "getTypeName" and @@ -339,6 +345,13 @@ * with the type already set. The same approach is also applied in the case of \ref AQNWB::NWB::Data "Data" and its derived * class \ref AQNWB::NWB::DataTyped "DataTyped". * + * ``DataTyped``, ``VectorDataTyped``, and + * ``NWBDataTyped`` are typed read facades, not canonical registered + * objects. They are intentionally not stored in + * \ref AQNWB::IO::RecordingObjects "RecordingObjects"; use their specialized + * ``create`` or ``from...`` methods to obtain a typed view of a dataset without + * changing the canonical object cached for its path. + * * For further details and alternative approaches for implementing templated \ref AQNWB::NWB::RegisteredType "RegisteredType" * classes, see \ref implement_templated_registered_type. * diff --git a/docs/pages/devdocs/record_design.dox b/docs/pages/devdocs/record_design.dox index f292cda36..c77dd18ba 100644 --- a/docs/pages/devdocs/record_design.dox +++ b/docs/pages/devdocs/record_design.dox @@ -232,13 +232,20 @@ * facilitate reliable memory management. * * \note - * Registration with the \ref AQNWB::IO::RecordingObjects "RecordingObjects" of the I/O occurs when a - * \ref AQNWB::NWB::RegisteredType "RegisteredType" is being created via the static - * \ref AQNWB::NWB::RegisteredType::create "RegisteredType::create" factory methods. - * These factory methods also automatically cache and reuse existing objects that are registered in - * \ref AQNWB::IO::RecordingObjects "RecordingObjects". Calling `create` multiple times - * for the same I/O and path will return the exact same object instance, ensuring that the state - * of the object is maintained and preventing unintended interactions from duplicate objects. + * Canonical \ref AQNWB::NWB::RegisteredType "RegisteredType" objects created + * through \ref AQNWB::NWB::RegisteredType::create "RegisteredType::create" + * are registered with the I/O's \ref AQNWB::IO::RecordingObjects + * "RecordingObjects". The cache has one canonical registered object per I/O + * and path; repeated compatible factory calls return that same instance. A + * request for a conflicting registered type fails rather than creating a + * second object for the path. + * + * Typed dataset facades such as \ref AQNWB::NWB::DataTyped "DataTyped", + * \ref AQNWB::NWB::VectorDataTyped "VectorDataTyped", and + * \ref AQNWB::NWB::NWBDataTyped "NWBDataTyped" are intentionally transient + * and are not registered with RecordingObjects. They share the I/O's + * BaseRecordingData cache with the canonical object when accessing the same + * dataset. * * \section recording_design_further_reading Further Reading * diff --git a/docs/pages/devdocs/registered_types.dox b/docs/pages/devdocs/registered_types.dox index 25609c7d7..9ce5fec9b 100644 --- a/docs/pages/devdocs/registered_types.dox +++ b/docs/pages/devdocs/registered_types.dox @@ -392,19 +392,25 @@ * the \ref DEFINE_REGISTERED_FIELD macro, we can set the data type for read at compile time, simplifying read. * * \note - * Since the templacted \ref AQNWB::NWB::VectorDataTyped "VectorDataTyped" does not call - * \ref REGISTER_SUBCLASS we must manually declare: 1) `friend class AQNWB::NWB::RegisteredType;` - * and 2) `using VectorData::VectorData;` (as protected) to make sure we have access to the - * constructors and define `static std::shared_ptr create( const std::string& path, std::shared_ptr io) { return RegisteredType::create(path, io); }` - * to make sure we have the factory method available. - * + * Since the templated \ref AQNWB::NWB::VectorDataTyped + * "VectorDataTyped" does not call \ref REGISTER_SUBCLASS, it must + * declare `friend class AQNWB::NWB::RegisteredType;` and inherit the + * protected `VectorData` constructors with `using VectorData::VectorData;` + * so its specialized factory can construct it. That factory must create a + * transient typed view rather than delegate to + * \ref AQNWB::NWB::RegisteredType::create + * "RegisteredType::create", because that factory registers canonical + * objects in \ref AQNWB::IO::RecordingObjects "RecordingObjects". + * `DataTyped` and `NWBDataTyped` follow the same transient + * typed-view pattern; only their non-templated base classes are canonical + * registered types. + * + * \par * \note - * The `std::unique_ptr<..>` template type is not covariant, i.e., `std::unique_ptr` does - * not automatically convert to `std::unique_ptr`. I.e., while \ref AQNWB::NWB::VectorDataTyped "VectorDataTyped" - * can be used anywhere \ref AQNWB::NWB::VectorData "VectorData" is being used, when using `std::unique_ptr<..>` - * we cannot rely on the compiler to automatically upcast for us, but we will need to explicitly release - * and upcast `std::unique_ptr` if `std::unique_ptr` is required. Since - * defining the `DTYPE` is primarily useful for read, we therefore typically use + * AqNWB uses `std::shared_ptr` for registered and typed-view objects, so a + * `std::shared_ptr>` converts to + * `std::shared_ptr` when needed. Since defining `DTYPE` is + * primarily useful for read, we therefore typically use * \ref AQNWB::NWB::VectorDataTyped "VectorDataTyped" on read while * using \ref AQNWB::NWB::VectorData "VectorData" otherwise. * @@ -532,6 +538,7 @@ * than the requested version. In practice, this is mainly relevant if you are generating classes for the * NWB `core` and `hdmf-common` namespaces. * + * \par * \note * When generating the test app via the `--generate-test-app` option we also * need to generate the schema headers files and save them in the `/spec` folder. diff --git a/src/nwb/RegisteredType.cpp b/src/nwb/RegisteredType.cpp index a8eef367c..45aa29d76 100644 --- a/src/nwb/RegisteredType.cpp +++ b/src/nwb/RegisteredType.cpp @@ -93,7 +93,14 @@ std::shared_ptr RegisteredType::create( // Check if the object already exists in the cache auto existing = getExistingRecordingObject(path, io); if (existing) { - return existing; + if (existing->getFullTypeName() == fullClassName) { + return existing; + } + std::cerr << "RegisteredType::create: cached object at path " << path + << " has type " << existing->getFullTypeName() + << ", which does not match the requested type " << fullClassName + << "." << std::endl; + return nullptr; } // Look up the factory RegisteredType for the fullClassName the registry @@ -131,14 +138,15 @@ std::shared_ptr RegisteredType::create( std::shared_ptr io, bool fallbackToBase) { - // Check if the object already exists in the cache - auto existing = getExistingRecordingObject(path, io); - if (existing) { - return existing; + if (!io) { + std::cerr << "RegisteredType::create: IO object is not available." + << std::endl; + return nullptr; } try { - // Read the "neurodata_type" attribute + // Resolve the on-disk type before consulting the cache so a generic + // wrapper cannot be returned for a derived registered type. std::string fullClassName = io->getFullTypeName(path); // Create an instance of the corresponding RegisteredType subclass return AQNWB::NWB::RegisteredType::create( diff --git a/src/nwb/RegisteredType.hpp b/src/nwb/RegisteredType.hpp index 0214787f2..751349e4b 100644 --- a/src/nwb/RegisteredType.hpp +++ b/src/nwb/RegisteredType.hpp @@ -200,7 +200,11 @@ class RegisteredType : public std::enable_shared_from_this getFactoryMap(); /** - * @brief Create an instance of a registered subclass by name. + * @brief Create or retrieve the canonical instance of a registered subclass. + * + * Each I/O object caches at most one canonical RegisteredType per path. If a + * cached instance exists, its full registered type must match fullClassName; + * otherwise this function logs the mismatch and returns nullptr. * * @param fullClassName The combined namespace and class name to instantiate, * i.e., namespace::class @@ -211,8 +215,8 @@ class RegisteredType : public std::enable_shared_from_this * m_defaultUnregisteredGroupTypeClass and * m_defaultUnregisteredDatasetTypeClass depending on whether the type is a * group or dataset. - * @return A unique_ptr to the created instance of the subclass, or nullptr if - * the subclass is not found. + * @return The canonical instance of the subclass, or nullptr if the subclass + * is not found or a cached instance has a different registered type. */ static std::shared_ptr create( const std::string& fullClassName, @@ -221,13 +225,14 @@ class RegisteredType : public std::enable_shared_from_this bool fallbackToBase = false); /** - * @brief Factory method to create an instance of a subclass of RegisteredType - * from file + * @brief Create or retrieve the canonical RegisteredType instance from file. * * The function: 1) reads the "namespace" and "neurodata_type" attributes at * the given path, 2) looks up the corresponding subclass of RegisteredType * for that type in the type registry 3) instantiates the subclass to - * represent the object at the path. + * represent the object at the path. The on-disk type is resolved before a + * cached object is used, ensuring that a cached instance has the same + * registered type as the file object. * * @param path The path of the registered type. * @param io A shared pointer to the IO object. @@ -237,8 +242,8 @@ class RegisteredType : public std::enable_shared_from_this * m_defaultUnregisteredDatasetTypeClass depending on whether the type is a * group or dataset. * - * @return A unique pointer to the created RegisteredType instance, or nullptr - * if creation fails. + * @return The canonical RegisteredType instance, or nullptr if creation + * fails or a cached instance has a different registered type. */ static std::shared_ptr create( const std::string& path, @@ -246,13 +251,19 @@ class RegisteredType : public std::enable_shared_from_this bool fallbackToBase = false); /** - * @brief Factory method to create an instance of a subclass of RegisteredType - * by type. + * @brief Create or retrieve a canonical instance of a RegisteredType subtype. + * + * If a cached object exists at path, it is returned only when it can be cast + * to T. A type mismatch is reported and returns nullptr rather than creating + * a second instance for the same path. Non-registered typed facades, such as + * DataTyped and VectorDataTyped, provide their own create methods and are not + * cached by this factory. * * @tparam T The subclass of RegisteredType to instantiate. * @param path The path of the container. * @param io A shared pointer to the IO object. - * @return A unique_ptr to the created instance of the subclass. + * @return The canonical instance of T, or nullptr if a cached instance has an + * incompatible type. */ template static inline std::shared_ptr create(const std::string& path, @@ -266,6 +277,10 @@ class RegisteredType : public std::enable_shared_from_this if (casted) { return casted; } + std::cerr << "RegisteredType::create: cached object at path " << path + << " has type " << existing->getFullTypeName() + << ", which does not match the requested type." << std::endl; + return nullptr; } auto result = std::shared_ptr(new T(path, io)); result->registerRecordingObject(); diff --git a/src/nwb/hdmf/base/Data.cpp b/src/nwb/hdmf/base/Data.cpp index 764292a2f..8f4b1a191 100644 --- a/src/nwb/hdmf/base/Data.cpp +++ b/src/nwb/hdmf/base/Data.cpp @@ -187,8 +187,9 @@ std::shared_ptr> DataTyped::create( new DataTyped(path, io)); } } + return std::shared_ptr>(new DataTyped(path, io)); } - return RegisteredType::create>(path, io); + return nullptr; } // Explicitly instantiate the DataTyped template for all common data types. diff --git a/src/nwb/hdmf/table/DynamicTable.hpp b/src/nwb/hdmf/table/DynamicTable.hpp index 0f25041af..8b2dd2392 100644 --- a/src/nwb/hdmf/table/DynamicTable.hpp +++ b/src/nwb/hdmf/table/DynamicTable.hpp @@ -272,6 +272,22 @@ class DynamicTable : public Container VectorDataTyped>::type; }; + /** + * @brief Type trait identifying non-registered typed VectorData facades. + * + * VectorDataTyped instances provide compile-time value typing without + * becoming the single canonical RegisteredType cached for a dataset path. + */ + template + struct IsVectorDataTyped : std::false_type + { + }; + + template + struct IsVectorDataTyped> : std::true_type + { + }; + /** * @brief Read an arbitrary column of the DynamicTable * @@ -300,7 +316,15 @@ class DynamicTable : public Container if (ioPtr->getStorageObjectType(columnPath) == StorageObjectType::Dataset) { - return ReturnType::create(columnPath, ioPtr); + if constexpr (std::is_base_of::value + && !IsVectorDataTyped::value) + { + // Resolve the concrete on-disk registered type before caching it. + auto column = RegisteredType::create(columnPath, ioPtr); + return std::dynamic_pointer_cast(column); + } else { + return ReturnType::create(columnPath, ioPtr); + } } } } else { diff --git a/src/nwb/hdmf/table/VectorData.cpp b/src/nwb/hdmf/table/VectorData.cpp index 9865d1dbf..b83d28227 100644 --- a/src/nwb/hdmf/table/VectorData.cpp +++ b/src/nwb/hdmf/table/VectorData.cpp @@ -286,8 +286,10 @@ std::shared_ptr> VectorDataTyped::create( new VectorDataTyped(path, io)); } } + return std::shared_ptr>( + new VectorDataTyped(path, io)); } - return RegisteredType::create>(path, io); + return nullptr; } // Explicitly instantiate the VectorDataTyped template for all common data diff --git a/tests/testData.cpp b/tests/testData.cpp index 600c02742..045661740 100644 --- a/tests/testData.cpp +++ b/tests/testData.cpp @@ -4,8 +4,10 @@ #include "Types.hpp" #include "Utils.hpp" #include "io/BaseIO.hpp" +#include "io/RecordingObjects.hpp" #include "io/hdf5/HDF5RecordingData.hpp" #include "nwb/RegisteredType.hpp" +#include "nwb/base/NWBData.hpp" #include "nwb/hdmf/base/Data.hpp" #include "testUtils.hpp" @@ -101,6 +103,22 @@ TEST_CASE("Data", "[base]") TEST_CASE("DataTyped", "[base]") { + SECTION("typed data facades are not registered") + { + auto io = createIO("HDF5", getTestFilePath("testDataTypedFacade.h5")); + const std::string dataPath = "/typed_data"; + + auto dataTyped = NWB::DataTyped::create(dataPath, io); + REQUIRE(dataTyped != nullptr); + REQUIRE(io->getRecordingObjects()->getRecordingObject(dataPath) == nullptr); + + auto nwbData = NWB::NWBData::create("/nwb_data", io); + auto nwbDataTyped = NWB::NWBDataTyped::fromNWBData(*nwbData); + REQUIRE(nwbDataTyped != nullptr); + REQUIRE(io->getRecordingObjects()->getRecordingObject("/nwb_data") + == nwbData); + } + SECTION("test DataTyped write/read") { std::string path = getTestFilePath("testDataTypedInt.h5"); diff --git a/tests/testEventsTable.cpp b/tests/testEventsTable.cpp index e6ec2b239..afbb36d15 100644 --- a/tests/testEventsTable.cpp +++ b/tests/testEventsTable.cpp @@ -109,6 +109,10 @@ TEST_CASE("EventsTable", "[event]") auto sourceDescriptionData = readEventsTable->readSourceDescription(); REQUIRE(sourceDescriptionData->values().data[0] == sourceDescription); + // readRows resolves and caches columns before the typed accessors below. + const auto rows = readEventsTable->readRows(); + REQUIRE(rows.size() == timestamps.size()); + // Read timestamps auto readTimestampColumn = readEventsTable->readTimestampColumn(); REQUIRE(readTimestampColumn != nullptr); diff --git a/tests/testVectorData.cpp b/tests/testVectorData.cpp index 3358d078f..29292eb38 100644 --- a/tests/testVectorData.cpp +++ b/tests/testVectorData.cpp @@ -4,6 +4,7 @@ #include "Types.hpp" #include "Utils.hpp" #include "io/BaseIO.hpp" +#include "io/RecordingObjects.hpp" #include "io/hdf5/HDF5RecordingData.hpp" #include "nwb/RegisteredType.hpp" #include "nwb/hdmf/table/VectorData.hpp" @@ -151,6 +152,16 @@ TEST_CASE("VectorData", "[base]") TEST_CASE("VectorDataTyped", "[base]") { + SECTION("typed vector data facades are not registered") + { + auto io = createIO("HDF5", getTestFilePath("testVectorDataTypedFacade.h5")); + const std::string dataPath = "/typed_vector_data"; + + auto vectorDataTyped = NWB::VectorDataTyped::create(dataPath, io); + REQUIRE(vectorDataTyped != nullptr); + REQUIRE(io->getRecordingObjects()->getRecordingObject(dataPath) == nullptr); + } + SECTION("test VectorDataTyped with int") { std::string path = getTestFilePath("testVectorDataTyped_int.h5"); From 51311910a236c0d02344027292d040ba0e28adb7 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 2 Sep 2026 14:49:09 -0700 Subject: [PATCH 39/40] Fix status checks in the Subject class --- CHANGELOG.md | 2 +- src/nwb/file/Subject.cpp | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06807e5ea..8a27ea841 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added tutorials for the new `MeaningsTable` and `EventsTable` types in `docs/pages/userdocs/events.dox`. (@cline, @oruebel, [#305](https://github.com/NeurodataWithoutBorders/aqnwb/pull/305)) * Added tutorials for the new `TimeIntervals` type in `docs/pages/userdocs/time_intervals.dox`. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * Added `Subject` class to represent the `/general/subject` group in NWB files. Added corresponding `SubjectSpec` to simplify configuration and initialization of `Subject`. Updated `NWBFile::initialize` to accept a `SubjectSpec` argument for subject metadata initialization (@copilot, @oruebel, [#320](https://github.com/NeurodataWithoutBorders/aqnwb/pull/320)) - * Added `isPathOrDescendant` utility functoin to simplify path initalization checks for root groups, e.g, `/events` and `/intervals` (@copilot, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) + * Added `isPathOrDescendant` utility functoin to simplify path initialization checks for root groups, e.g, `/events` and `/intervals` (@copilot, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * **Added support for single-ragged array columns in `DynamicTable`:** * Added `VectorIndex` type for storing index columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) * Added `VectorData::appendData` and `VectorIndex::appendBuffer` to simplify appending values to columns. (@cline, @oruebel, [#325](https://github.com/NeurodataWithoutBorders/aqnwb/pull/325)) diff --git a/src/nwb/file/Subject.cpp b/src/nwb/file/Subject.cpp index d53689b3e..0e7ab0f80 100644 --- a/src/nwb/file/Subject.cpp +++ b/src/nwb/file/Subject.cpp @@ -55,14 +55,14 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status ageStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "age"), subjectSpec.age.value()); initStatus = initStatus && ageStatus; - if (!ageStatus) { + if (ageStatus != Status::Success) { std::cerr << "Failed to create age dataset." << std::endl; } else { std::string ageReference = subjectSpec.ageReference.value_or("birth"); Status ageRefStatus = ioPtr->createAttribute( ageReference, mergePaths(this->m_path, "age"), "reference"); initStatus = initStatus && ageRefStatus; - if (!ageRefStatus) { + if (ageRefStatus != Status::Success) { std::cerr << "Failed to create age reference attribute." << std::endl; } } @@ -79,7 +79,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) ioPtr->createStringDataSet(mergePaths(this->m_path, "date_of_birth"), subjectSpec.dateOfBirth.value()); initStatus = initStatus && dobStatus; - if (!dobStatus) { + if (dobStatus != Status::Success) { std::cerr << "Failed to create date_of_birth dataset." << std::endl; } } @@ -90,7 +90,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) ioPtr->createStringDataSet(mergePaths(this->m_path, "description"), subjectSpec.description.value()); initStatus = initStatus && descStatus; - if (!descStatus) { + if (descStatus != Status::Success) { std::cerr << "Failed to create description dataset." << std::endl; } } @@ -100,7 +100,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status genotypeStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "genotype"), subjectSpec.genotype.value()); initStatus = initStatus && genotypeStatus; - if (!genotypeStatus) { + if (genotypeStatus != Status::Success) { std::cerr << "Failed to create genotype dataset." << std::endl; } } @@ -110,7 +110,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status sexStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "sex"), subjectSpec.sex.value()); initStatus = initStatus && sexStatus; - if (!sexStatus) { + if (sexStatus != Status::Success) { std::cerr << "Failed to create sex dataset." << std::endl; } } @@ -120,7 +120,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status speciesStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "species"), subjectSpec.species.value()); initStatus = initStatus && speciesStatus; - if (!speciesStatus) { + if (speciesStatus != Status::Success) { std::cerr << "Failed to create species dataset." << std::endl; } } @@ -130,7 +130,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status strainStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "strain"), subjectSpec.strain.value()); initStatus = initStatus && strainStatus; - if (!strainStatus) { + if (strainStatus != Status::Success) { std::cerr << "Failed to create strain dataset." << std::endl; } } @@ -140,7 +140,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status subjectIdStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "subject_id"), subjectSpec.subjectId.value()); initStatus = initStatus && subjectIdStatus; - if (!subjectIdStatus) { + if (subjectIdStatus != Status::Success) { std::cerr << "Failed to create subject_id dataset." << std::endl; } } @@ -150,7 +150,7 @@ Status Subject::initialize(const SubjectSpec& subjectSpec) Status weightStatus = ioPtr->createStringDataSet( mergePaths(this->m_path, "weight"), subjectSpec.weight.value()); initStatus = initStatus && weightStatus; - if (!weightStatus) { + if (weightStatus != Status::Success) { std::cerr << "Failed to create weight dataset." << std::endl; } } From 8fcb5413d1ea5158f2241e633f0bcc5c6537b3b1 Mon Sep 17 00:00:00 2001 From: Oliver Ruebel Date: Wed, 2 Sep 2026 14:59:35 -0700 Subject: [PATCH 40/40] =?UTF-8?q?Fix=20=C2=A0NWBFile::initialize()=C2=A0?= =?UTF-8?q?=20docstring=20to=20clarify=20subject=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/nwb/NWBFile.hpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/nwb/NWBFile.hpp b/src/nwb/NWBFile.hpp index 550dbabc6..1319e607a 100644 --- a/src/nwb/NWBFile.hpp +++ b/src/nwb/NWBFile.hpp @@ -115,9 +115,10 @@ class NWBFile : public NWBContainer * time. If empty (default), then the getCurrentTime() will be used. * @param timestampsReferenceTime ISO formatted time string with the timestamp * reference time. If empty (default), then the getCurrentTime() will be used. - * @param subjectSpec Optional subject metadata. By default, an empty Subject - * is created. Pass @c std::nullopt to explicitly state that no subject should - * be created (e.g., when the subject is unknown). + * @param subjectSpec Optional subject metadata. Defaults to @c std::nullopt, + * so no Subject group is created unless a SubjectSpec is supplied. For DANDI + * validation, the supplied SubjectSpec must include subjectId, species, sex, + * and either age or dateOfBirth. */ Status initialize(const std::string& identifierText, const std::string& description = "a recording session",