Skip to content

Commit 286de2d

Browse files
mavaylon1claudebendichterrly
authored
HERD Changes internal file (#2111)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Ben Dichter <ben.dichter@gmail.com> Co-authored-by: Ryan Ly <310197+rly@users.noreply.github.com>
1 parent 2850074 commit 286de2d

9 files changed

Lines changed: 192 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Fixed broken and redirecting links in documentation. @bendichter [#2165](https://github.com/NeurodataWithoutBorders/pynwb/pull/2165)
88

99
### Added
10+
- Added support for HERD (HDMF External Resources Data Structure) as the `external_resources` field on `NWBFile`, enabling users to associate external resource annotations (e.g., ontology term mappings) with their NWB files. `link_resources` and `get_external_resources` are inherited from `HERDManager` in hdmf. @mavaylon1, @rly [#2111](https://github.com/NeurodataWithoutBorders/pynwb/pull/2111)
1011
- Added `get_starting_time()` and `get_duration()` methods to `TimeSeries` to get the starting time and duration of the time series. @h-mayorquin [#2146](https://github.com/NeurodataWithoutBorders/pynwb/pull/2146)
1112
- Added `get_starting_time()` and `get_duration()` methods to `TimeIntervals` to get the earliest start time and total duration (span from earliest start to latest stop) of all intervals. @h-mayorquin [#2146](https://github.com/NeurodataWithoutBorders/pynwb/pull/2146)
1213
- Added `get_starting_time()` and `get_duration()` methods to `Units` to get the earliest spike time and total duration (span from earliest to latest spike) across all units. @h-mayorquin [#2164](https://github.com/NeurodataWithoutBorders/pynwb/pull/2164)

environment-ros3.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@ channels:
66
dependencies:
77
- python==3.14
88
- h5py==3.15.1
9+
- hdmf==5.1.0
910
- matplotlib==3.10.8
1011
- numpy==2.4.2
11-
- pandas==3.0.1
12+
- pandas==2.3.3
1213
- python-dateutil==2.9.0
1314
- setuptools
1415
- pytest==9.0.2
@@ -17,6 +18,5 @@ dependencies:
1718
- aiohttp==3.13.3
1819
- pip
1920
- pip:
20-
- hdmf==5.0.0 # not yet available on conda-forge
2121
- remfile==0.1.13
22-
- dandi==0.62.1 # NOTE: dandi is not available on conda for osx-arm64
22+
- dandi==0.74.3 # NOTE: dandi is not available on conda for osx-arm64

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ classifiers = [
3434
]
3535
dependencies = [
3636
"h5py>=3.6.0",
37-
"hdmf>=5.0.0,<6",
37+
"hdmf>=5.1.0,<6",
3838
"numpy>=1.24.0",
39-
"pandas>=1.4.0",
39+
"pandas>=1.4.0,<3",
4040
"python-dateutil>=2.8.2",
4141
"platformdirs>=4.2.2"
4242
]

requirements-min.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# minimum versions of package dependencies for installing PyNWB
22
h5py==3.6.0
3-
hdmf==5.0.0
3+
hdmf==5.1.0
44
numpy==1.24.0
55
pandas==1.4.0
66
python-dateutil==2.8.2

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# pinned dependencies to reproduce an entire development environment to use PyNWB
22
h5py==3.16.0
3-
hdmf==5.0.0
3+
hdmf==5.1.0
44
numpy==2.4.3
55
pandas==2.3.3
66
python-dateutil==2.9.0.post0

src/pynwb/file.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import numpy as np
88
import pandas as pd
99

10-
from hdmf.common import DynamicTableRegion, DynamicTable
10+
from hdmf.common import DynamicTableRegion, DynamicTable, HERD
1111
from hdmf.container import HERDManager
1212
from hdmf.utils import docval, getargs, get_docval, popargs, popargs_to_dict, AllowPositional
1313

@@ -287,6 +287,7 @@ class NWBFile(MultiContainerInterface, HERDManager):
287287
{'name': 'trials', 'child': True, 'required_name': 'trials'},
288288
{'name': 'units', 'child': True, 'required_name': 'units'},
289289
{'name': 'subject', 'child': True, 'required_name': 'subject'},
290+
{'name': 'external_resources', 'child': True, 'required_name': 'external_resources'},
290291
{'name': 'sweep_table', 'child': True, 'required_name': 'sweep_table'},
291292
{'name': 'invalid_times', 'child': True, 'required_name': 'invalid_times'},
292293
# icephys_filtering is temporary. /intracellular_ephys/filtering dataset will be deprecated
@@ -339,6 +340,8 @@ class NWBFile(MultiContainerInterface, HERDManager):
339340
{'name': 'keywords', 'type': 'array_data', 'doc': 'Terms to search over', 'default': None},
340341
{'name': 'notes', 'type': str,
341342
'doc': 'Notes about the experiment.', 'default': None},
343+
{'name': 'external_resources', 'type': HERD,
344+
'doc': 'the HERD external resources object for this NWBFile', 'default': None},
342345
{'name': 'pharmacology', 'type': str,
343346
'doc': 'Description of drugs used, including how and when they were administered. '
344347
'Anesthesia(s), painkiller(s), etc., plus dosage, concentration, etc.', 'default': None},
@@ -480,7 +483,8 @@ def __init__(self, **kwargs):
480483
'icephys_simultaneous_recordings',
481484
'icephys_sequential_recordings',
482485
'icephys_repetitions',
483-
'icephys_experimental_conditions'
486+
'icephys_experimental_conditions',
487+
'external_resources'
484488
]
485489
args_to_set = popargs_to_dict(keys_to_set, kwargs)
486490
kwargs['name'] = 'root'
@@ -1152,4 +1156,4 @@ def ElectrodeTable(name='electrodes',
11521156
description='metadata about extracellular electrodes'):
11531157
warn("The ElectrodeTable convenience function is deprecated. Please create a new instance of "
11541158
"the ElectrodesTable class instead.", DeprecationWarning)
1155-
return ElectrodesTable()
1159+
return ElectrodesTable()

src/pynwb/io/file.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ def __init__(self, spec):
112112

113113
self.map_spec('subject', general_spec.get_group('subject'))
114114

115+
self.map_spec('external_resources', general_spec.get_group('external_resources'))
116+
115117
device_spec = general_spec.get_group('devices')
116118
self.unmap(device_spec)
117119
self.map_spec('devices', device_spec.get_neurodata_type('Device'))

tests/unit/test_resources.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,53 @@
1+
import os
2+
import tempfile
13
import warnings
4+
from datetime import datetime
5+
from uuid import uuid4
26

7+
import numpy as np
8+
from dateutil import tz
9+
10+
from pynwb import NWBHDF5IO, NWBFile
11+
from pynwb.file import Subject
312
from pynwb.resources import HERD
413
from pynwb.testing import TestCase
514

615

716
class TestNWBContainer(TestCase):
17+
def setUp(self):
18+
self.tmpdir = tempfile.TemporaryDirectory()
19+
self.path = os.path.join(self.tmpdir.name, "resources_file.nwb")
20+
self.export_path = os.path.join(self.tmpdir.name, "export_file.nwb")
21+
22+
def tearDown(self):
23+
self.tmpdir.cleanup()
24+
25+
def _create_nwbfile_with_herd(self):
26+
"""Create an NWBFile with a Subject and HERD containing a species annotation."""
27+
session_start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz("US/Pacific"))
28+
nwbfile = NWBFile(
29+
session_description="ECoG recording during audio speech perception task",
30+
identifier=str(uuid4()),
31+
session_start_time=session_start_time,
32+
)
33+
subject = Subject(
34+
subject_id="001",
35+
age="P26Y",
36+
description="human subject",
37+
species="Homo sapiens",
38+
sex="M",
39+
)
40+
nwbfile.subject = subject
41+
herd = HERD()
42+
nwbfile.external_resources = herd
43+
nwbfile.external_resources.add_ref(
44+
container=nwbfile.subject,
45+
key=nwbfile.subject.species,
46+
entity_id="NCBI_TAXON:9606",
47+
entity_uri="https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?mode=Info&id=9606",
48+
)
49+
return nwbfile, subject
50+
851
def test_constructor(self):
952
"""
1053
Test constructor
@@ -17,3 +60,134 @@ def test_constructor(self):
1760
)
1861
er = HERD()
1962
self.assertIsInstance(er, HERD)
63+
64+
def test_nwbfile_init_herd(self):
65+
session_start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz("US/Pacific"))
66+
herd = HERD()
67+
nwbfile = NWBFile(
68+
session_description="ECoG recording during audio speech perception task",
69+
identifier=str(uuid4()),
70+
session_start_time=session_start_time,
71+
external_resources=herd,
72+
)
73+
self.assertIsInstance(nwbfile.external_resources, HERD)
74+
75+
def test_nwbfile_set_herd(self):
76+
session_start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz("US/Pacific"))
77+
herd = HERD()
78+
nwbfile = NWBFile(
79+
session_description="ECoG recording during audio speech perception task",
80+
identifier=str(uuid4()),
81+
session_start_time=session_start_time,
82+
)
83+
nwbfile.external_resources = herd
84+
self.assertIsInstance(nwbfile.external_resources, HERD)
85+
self.assertEqual(nwbfile.external_resources.parent, nwbfile)
86+
87+
def test_resources_roundtrip(self):
88+
nwbfile, subject = self._create_nwbfile_with_herd()
89+
90+
with NWBHDF5IO(self.path, "w") as io:
91+
io.write(nwbfile)
92+
93+
with NWBHDF5IO(self.path, "r") as io:
94+
read_nwbfile = io.read()
95+
self.assertEqual(
96+
read_nwbfile.external_resources.keys[:],
97+
np.array(
98+
[("Homo sapiens",)],
99+
dtype=[("key", "O")],
100+
),
101+
)
102+
self.assertEqual(
103+
read_nwbfile.external_resources.entities[:],
104+
np.array(
105+
[
106+
(
107+
"NCBI_TAXON:9606",
108+
"https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?mode=Info&id=9606",
109+
)
110+
],
111+
dtype=[("entity_id", "O"), ("entity_uri", "O")],
112+
),
113+
)
114+
self.assertEqual(
115+
read_nwbfile.external_resources.objects[:],
116+
np.array(
117+
[(0, subject.object_id, "Subject", "", "")],
118+
dtype=[
119+
("files_idx", "<u4"),
120+
("object_id", "O"),
121+
("object_type", "O"),
122+
("relative_path", "O"),
123+
("field", "O"),
124+
],
125+
),
126+
)
127+
128+
def test_get_external_resources(self):
129+
"""Test get_external_resources returns the correct HERD based on the linked parameter."""
130+
nwbfile, subject = self._create_nwbfile_with_herd()
131+
original_herd = nwbfile.external_resources
132+
133+
linked_herd = HERD()
134+
nwbfile.link_resources(linked_herd)
135+
136+
self.assertIs(nwbfile.get_external_resources(linked=False), original_herd)
137+
self.assertIs(nwbfile.get_external_resources(linked=True), linked_herd)
138+
# attribute returns the original, not the linked one
139+
self.assertIs(nwbfile.external_resources, original_herd)
140+
141+
def test_link_resources(self):
142+
"""Make sure that the original HERD is not overwritten on export."""
143+
nwbfile, subject = self._create_nwbfile_with_herd()
144+
145+
with NWBHDF5IO(self.path, "w") as io:
146+
io.write(nwbfile)
147+
148+
with NWBHDF5IO(self.path, mode="r") as read_io:
149+
read_nwbfile = read_io.read()
150+
read_nwbfile.link_resources(HERD())
151+
152+
linked = read_nwbfile.get_external_resources(linked=True)
153+
self.assertEqual(linked.keys.data, [])
154+
self.assertEqual(linked.entities.data, [])
155+
self.assertEqual(linked.objects.data, [])
156+
157+
with NWBHDF5IO(self.export_path, mode="w") as export_io:
158+
export_io.export(src_io=read_io, nwbfile=read_nwbfile)
159+
160+
with NWBHDF5IO(self.export_path, mode="r") as read_export_io:
161+
read_export_nwbfile = read_export_io.read()
162+
self.assertEqual(
163+
read_export_nwbfile.external_resources.keys[:],
164+
np.array(
165+
[("Homo sapiens",)],
166+
dtype=[("key", "O")],
167+
),
168+
)
169+
self.assertEqual(
170+
read_export_nwbfile.external_resources.entities[:],
171+
np.array(
172+
[
173+
(
174+
"NCBI_TAXON:9606",
175+
"https://www.ncbi.nlm.nih.gov/Taxonomy/Browser/wwwtax.cgi?mode=Info&id=9606",
176+
)
177+
],
178+
dtype=[("entity_id", "O"), ("entity_uri", "O")],
179+
),
180+
)
181+
self.assertEqual(
182+
read_export_nwbfile.external_resources.objects[:],
183+
np.array(
184+
[(0, subject.object_id, "Subject", "", "")],
185+
dtype=[
186+
("files_idx", "<u4"),
187+
("object_id", "O"),
188+
("object_type", "O"),
189+
("relative_path", "O"),
190+
("field", "O"),
191+
],
192+
),
193+
)

0 commit comments

Comments
 (0)