Skip to content

Commit ec3d0f0

Browse files
authored
Update tables to ElectrodesTable/FrequencyBandsTable earlier (#2112)
1 parent e1d0612 commit ec3d0f0

File tree

7 files changed

+75
-37
lines changed

7 files changed

+75
-37
lines changed

CHANGELOG.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
# PyNWB Changelog
22

3-
## PyNWB 3.1.1 (July 14, 2025)
3+
## PyNWB 3.1.1 (July 16, 2025)
44

55
### Bug fixes
6-
6+
- Fixed reading and exporting of files written with NWB Schema < 2.9.0 that contained a reference to the electrodes table. @rly [#2112](https://github.com/NeurodataWithoutBorders/pynwb/pull/2112)
77
- Skip streaming tests gracefully if offline. @rly [#2113](https://github.com/NeurodataWithoutBorders/pynwb/pull/2113)
88

9+
910
## PyNWB 3.1.0 (July 8, 2025)
1011

1112
### Breaking changes
@@ -41,7 +42,7 @@
4142
- Fixed caching of the type map when using HDMF 4.1.0. @rly [#2087](https://github.com/NeurodataWithoutBorders/pynwb/pull/2087)
4243
- Removed use of complex numbers in scratch tutorial because of incompatibilities with HDMF 4.1.0. @stephprince [#2090](https://github.com/NeurodataWithoutBorders/pynwb/pull/2090/)
4344
- Made `ImagingPlane.description` optional to conform with the NWB Schema. @rly [#2051](https://github.com/NeurodataWithoutBorders/pynwb/pull/2051)
44-
45+
4546
### Documentation and tutorial enhancements
4647
- Added NWB AI assistant to the home page of the documentation. @magland [#2076](https://github.com/NeurodataWithoutBorders/pynwb/pull/2076)
4748

src/pynwb/io/epoch.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ class TimeIntervalsMap(DynamicTableMap):
1414
def columns_carg(self, builder, manager):
1515
# handle the case when a TimeIntervals is read with a non-TimeSeriesReferenceVectorData "timeseries" column
1616
# this is the case for NWB schema v2.4 and earlier, where the timeseries column was a regular VectorData.
17+
# NOTE: the below machinery might be movable into the NWBFileMap.construct override which might better
18+
# handle the rare edge case where the "timeseries" column is referenced in another field elsewhere in the file.
19+
1720
timeseries_builder = builder.get('timeseries')
1821

1922
# handle the case when the TimeIntervals has a "timeseries" column that is a link (e.g., it exists in

src/pynwb/io/file.py

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from dateutil.parser import parse as dateutil_parse
2+
import typing
23

3-
from hdmf.build import ObjectMapper
4+
from hdmf.build import ObjectMapper, Builder, GroupBuilder
5+
from hdmf.utils import docval, get_docval
46

57
from .. import register_map
68
from ..file import NWBFile, Subject
@@ -130,6 +132,34 @@ def __init__(self, spec):
130132
self.map_spec('scratch_containers', scratch_spec.get_neurodata_type('NWBContainer'))
131133
self.map_spec('scratch_containers', scratch_spec.get_neurodata_type('DynamicTable'))
132134

135+
@docval(*get_docval(ObjectMapper.construct))
136+
def construct(self, **kwargs):
137+
nwbfile_builder = kwargs["builder"]
138+
electrodes_builder = nwbfile_builder.get("general", dict()).get("extracellular_ephys", dict()).get("electrodes")
139+
if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'):
140+
electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable'
141+
electrodes_builder.attributes['namespace'] = 'core'
142+
143+
def apply_to_child_builders(builder: Builder, funcs: list[typing.Callable]):
144+
# iterate recursively through each builder (which is just a dict of dicts) and make migration changes
145+
for bchild_value in builder.values():
146+
if isinstance(bchild_value, Builder):
147+
for func in funcs:
148+
func(bchild_value)
149+
apply_to_child_builders(bchild_value, funcs)
150+
151+
def update_builder_frequency_bands_table(builder: Builder):
152+
if (isinstance(builder, GroupBuilder) and
153+
builder.attributes.get('namespace') == 'core' and
154+
builder.attributes.get('neurodata_type') == 'DecompositionSeries' and
155+
builder.groups['bands'].attributes['neurodata_type'] == 'DynamicTable'):
156+
builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable'
157+
builder.groups['bands'].attributes['namespace'] = 'core'
158+
159+
apply_to_child_builders(nwbfile_builder, [update_builder_frequency_bands_table])
160+
161+
return super().construct(**kwargs)
162+
133163
@ObjectMapper.object_attr('scratch_datas')
134164
def scratch_datas(self, container, manager):
135165
"""Set the value for the 'scratch_datas' spec on NWBFile to a list of ScratchData objects.
@@ -185,24 +215,6 @@ def scratch(self, builder, manager):
185215
ret.append(manager.construct(d))
186216
return tuple(ret) if len(ret) > 0 else None
187217

188-
@ObjectMapper.constructor_arg('electrodes')
189-
def electrodes(self, builder, manager):
190-
try:
191-
electrodes_builder = builder['general']['extracellular_ephys']['electrodes']
192-
except KeyError:
193-
# Note: This is here because the ObjectMapper pulls argname from docval and checks to see
194-
# if there is an override even if the file doesn't have what is looking for. In this case,
195-
# electrodes for NWBFile.
196-
electrodes_builder = None
197-
if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'):
198-
electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable'
199-
electrodes_builder.attributes['namespace'] = 'core'
200-
manager.clear_cache()
201-
new_container = manager.construct(electrodes_builder)
202-
return new_container
203-
else:
204-
return None
205-
206218
@ObjectMapper.constructor_arg('session_start_time')
207219
def dateconversion(self, builder, manager):
208220
"""Set the constructor arg for 'session_start_time' to a datetime object.

src/pynwb/io/misc.py

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,7 @@
11
from hdmf.common.io.table import DynamicTableMap
2-
from .base import TimeSeriesMap
32

43
from .. import register_map
5-
from pynwb.misc import Units, DecompositionSeries
6-
7-
8-
@register_map(DecompositionSeries)
9-
class DecompositionSeriesMap(TimeSeriesMap):
10-
11-
@TimeSeriesMap.constructor_arg('bands')
12-
def bands(self, builder, manager):
13-
if builder.groups['bands'].attributes['neurodata_type'] != 'FrequencyBandsTable':
14-
builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable'
15-
builder.groups['bands'].attributes['namespace'] = 'core'
16-
manager.clear_cache()
17-
new_container = manager.construct(builder.groups['bands'])
18-
return new_container
4+
from pynwb.misc import Units
195

206

217
@register_map(Units)

src/pynwb/testing/make_test_files.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import numpy as np
33
from pathlib import Path
44
from pynwb import NWBFile, NWBHDF5IO, __version__, TimeSeries, get_class, load_namespaces
5+
from pynwb.ecephys import ElectricalSeries
56
from pynwb.file import Subject
67
from pynwb.image import ImageSeries
78
from pynwb.misc import DecompositionSeries
@@ -237,6 +238,20 @@ def _make_electrodes_dynamic_table():
237238
label=f"shank{i}electrode{j}",
238239
)
239240

241+
nwbfile.add_unit(
242+
spike_times=[0.1, 0.2, 0.3],
243+
electrodes=[0, 1],
244+
)
245+
246+
eseries = ElectricalSeries(
247+
name="ElectricalSeries",
248+
description="Test electrodes reference",
249+
data=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
250+
timestamps=[0.1, 0.2, 0.3],
251+
electrodes=nwbfile.create_electrode_table_region(region=[2, 3], description="electrodes table indices 2 and 3"),
252+
)
253+
nwbfile.add_acquisition(eseries)
254+
240255
test_name = 'electrodes_dynamic_table'
241256
_write(test_name, nwbfile)
242257

7.8 KB
Binary file not shown.

tests/back_compat/test_read.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
from pathlib import Path
3+
import tempfile
34
import warnings
45

56
from pynwb import NWBHDF5IO, validate, TimeSeries
@@ -140,6 +141,26 @@ def test_read_electrodes_table_as_neurodata_type(self):
140141
read_nwbfile = io.read()
141142
assert isinstance(read_nwbfile.electrodes, ElectrodesTable)
142143

144+
# test that references to the electrodes table are also ElectrodesTable
145+
assert read_nwbfile.units.electrodes.table is read_nwbfile.electrodes
146+
assert read_nwbfile.acquisition["ElectricalSeries"].electrodes.table is read_nwbfile.electrodes
147+
148+
# test that export writes the correct builders
149+
temp_dir = tempfile.TemporaryDirectory()
150+
export_file = Path(temp_dir.name) / "3.0.0_electrodes_dynamic_table_export.nwb"
151+
with NWBHDF5IO(export_file, 'w') as export_io:
152+
export_io.export(io)
153+
154+
with self.get_io(export_file) as read_export_io:
155+
read_export_nwbfile = read_export_io.read()
156+
assert isinstance(read_export_nwbfile.electrodes, ElectrodesTable)
157+
158+
# test that references to the electrodes table are also ElectrodesTable
159+
units_table_ref = read_export_nwbfile.units.electrodes.table
160+
assert units_table_ref is read_export_nwbfile.electrodes
161+
eseries_table_ref = read_export_nwbfile.acquisition["ElectricalSeries"].electrodes.table
162+
assert eseries_table_ref is read_export_nwbfile.electrodes
163+
143164
def test_read_bands_table_as_neurodata_type(self):
144165
"""Test that a "bands" table written as a DynamicTable is read as an FrequencyBandsTable"""
145166
f = Path(__file__).parent / '3.0.0_decompositionseries_bands_dynamic_table.nwb'

0 commit comments

Comments
 (0)