Skip to content

fix(types): allow one object in several unnamed groups - #918

Open
ehennestad wants to merge 2 commits into
mainfrom
allow-shared-entry-in-unnamed-groups
Open

ehennestad wants to merge 2 commits into
mainfrom
allow-shared-entry-in-unnamed-groups

Conversation

@ehennestad

@ehennestad ehennestad commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Motivation

Fixes a bug where nwbRead fails on a file in which one object belongs to two groups of its parent, as in the DANDI:000541 files, which could not be read since v2.11.0.

Background — Since v2.11.0 (#705), every entry in a type's unnamed groups is also available as a property with the entry's name, for example module.MyData for an entry of a ProcessingModule. A name must therefore identify one object.

Problem — Reading a file in which one object belongs to two groups fails. This happens with the ndx-multichannel-volume extension, for example in DANDI:000541. Its ImagingVolume extends ImagingPlane: it inherits the required opticalchannel group (type OpticalChannel) and adds an opticalchannelplus group (type OpticalChannelPlus, a subtype of OpticalChannel). The file's one channel, GFP-GCaMP, is an OpticalChannelPlus, so it belongs in both groups; PyNWB also places it in both. MatNWB rejects any name that appears in more than one group, so creating the ImagingVolume fails and nwbRead stops.

Solution — A name may appear in several groups when every occurrence is the same object. That object has one location in the file and is written once. Different objects with the same name are still rejected, because both would be written to the same location in the file.

Related: #567 reports slow reads of the DANDI:000541 files. Since v2.11.0 they could not be read at all.

What changed

  • Files with an object that belongs to more than one group can be read again.
  • The rules for entry names are documented in the class help of matnwb.mixin.HasUnnamedGroups:
    • The same object may appear in several groups and gets one property. Assigning to that property updates every group that accepts the new value.
    • Different objects may not share a name. The error message now says why.
    • The property is removed when no group holds the name any more.
  • remove removes a name from every group that holds it.
  • Rejecting an entry whose name is already used no longer deletes the property of the entry that was there first.
Implementation notes
  • createDynamicProperty accepts a name found in several groups when isSameObjectInAllGroups is true (handle identity, not equality), and creates the property only once.
  • The dynamic property's getter reads from the first group that holds the name, and its setter writes to every group that holds it. A group that rejects the new value drops the name.
  • onSetEntryRemoved deletes the property only when no group holds the name. Before, removing the rejected entry from the new group also deleted the property created for the existing entry.
  • The test schema sharedEntrySchema reproduces the extension's layout: an ImagingPlane subtype that adds a group for an OpticalChannel subtype.

Examples

Reading a DANDI:000541 file

This reads one file from DANDI:000541 (sub-20190924-01/sub-20190924-01_ses-20190924_ophys.nwb, 1.46 GB) and prints which groups of the ImagingVolume hold the optical channel.

filename = "sub-20190924-01_ses-20190924_ophys.nwb";

try
    nwb = nwbRead(filename);
catch exception
    fprintf("Error: %s\n", exception.message);
    fprintf("Caused by: %s\n", exception.cause{1}.message);
    return
end

volume = nwb.general_optophysiology.get("CalciumImVol");
channel = volume.opticalchannelplus.get("GFP-GCaMP");
fprintf("opticalchannel: %s\n", strjoin(volume.opticalchannel.keys(), ", "));
fprintf("opticalchannelplus: %s\n", strjoin(volume.opticalchannelplus.keys(), ", "));
fprintf("same object in both groups: %d\n", volume.opticalchannel.get("GFP-GCaMP") == channel);
fprintf("class: %s\n", class(channel));

Before — The read stops while creating the ImagingVolume, because GFP-GCaMP is in two groups.

Error: Failed to create object of type "types.ndx_multichannel_volume.ImagingVolume" in file location "/general/optophysiology/CalciumImVol".
Caused by: Error using matnwb.mixin.HasUnnamedGroups/createDynamicProperty (line 391)
An entry with name `GFP-GCaMP` was detected in multiple contained groups.
Removed entry from group `opticalchannelplus`.

After — The file is read. Both groups hold the channel, and both hold the same object.

opticalchannel: GFP-GCaMP
opticalchannelplus: GFP-GCaMP
same object in both groups: 1
class: types.ndx_multichannel_volume.OpticalChannelPlus

How to test

Run the example above with the file from DANDI:000541. Without the download, the same layout is covered by a test schema, including an export and read that writes the shared object once:

runtests("tests.unit.schema.SharedEntryTest")

Checklist

  • Have you ensured the PR description clearly describes the problem and solutions?
  • Have you checked to ensure that there aren't other open or previously closed Pull Requests for the same change?
  • If this PR fixes an issue, is the first line of the PR description fix #XX where XX is the issue number?

🤖 Generated with Claude Code

@codecov

codecov Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.66667% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 95.47%. Comparing base (ff0d00e) to head (67628bc).

Files with missing lines Patch % Lines
+matnwb/+mixin/HasUnnamedGroups.m 91.66% 4 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #918   +/-   ##
=======================================
  Coverage   95.46%   95.47%           
=======================================
  Files         240      240           
  Lines        8869     8901   +32     
=======================================
+ Hits         8467     8498   +31     
- Misses        402      403    +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ehennestad
ehennestad requested a review from bendichter October 6, 2026 10:30
@bendichter

Copy link
Copy Markdown
Contributor

Let's take a look into this extension. It appears they wanted to specialize OpticalChannel into OpticalChannelPlus. Did they intend to keep OpticalChannel?

ehennestad and others added 2 commits October 6, 2026 20:14
HasUnnamedGroups exposes each entry of a type's unnamed groups as a
property with the entry's name, and raised DuplicateEntry whenever a
name appeared in more than one group. An object whose type matches
more than one group is placed in each of them: an OpticalChannelPlus
in an ndx-multichannel-volume ImagingVolume fills both the inherited
optical channel group and the extension's own group, as it does in
PyNWB. Creating such an object, and so reading any file with this
layout, failed.

A name may now appear in several groups when every occurrence is the
same object. It has one location in the file and is exported once, so
it gets one property. Different objects with the same name are still
rejected, because both would be written to the same location. The
property reads from any group that holds the name, assigning to it
updates every group that accepts the value, remove() removes the name
from all groups, and the property is deleted only when no group holds
the name. Before, rejecting a duplicate also deleted the property of
the entry that was already there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- numEntries counts the unique entry names across all unnamed groups
  instead of summing each Set's Count, so an object held by two groups
  under one name is one entry. The count appears only in the display
  header of container-like types. ImagingPlaneWithSubtypeChannels is not
  container-like, and no public method reaches numEntries, so no test
  covers the shared case. HasUnnamedGroupsTest/testObjectDisplay still
  goes through the new code for a ProcessingModule.
- The class help says that each group exports a shared object to the
  same location, so the file holds one copy. It no longer says that the
  object is written once.
- The DuplicateEntry message ends with "Use a different name." add()
  rejects any existing name, so "add the same object" was not reachable
  through add().
- Add a test that assigning a value no group accepts raises
  NWB:Set:FailedValidation and leaves every group unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ehennestad
ehennestad force-pushed the allow-shared-entry-in-unnamed-groups branch from 20431af to 67628bc Compare October 6, 2026 18:14
@ehennestad ehennestad added this to the v2.12.0 milestone Oct 7, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants