Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
# PyNWB Changelog

## PyNWB 3.1.1 (July 14, 2025)
## PyNWB 3.1.1 (July 16, 2025)

### Bug fixes

- 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)
- Skip streaming tests gracefully if offline. @rly [#2113](https://github.com/NeurodataWithoutBorders/pynwb/pull/2113)


## PyNWB 3.1.0 (July 8, 2025)

### Breaking changes
Expand Down Expand Up @@ -41,7 +42,7 @@
- Fixed caching of the type map when using HDMF 4.1.0. @rly [#2087](https://github.com/NeurodataWithoutBorders/pynwb/pull/2087)
- 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/)
- Made `ImagingPlane.description` optional to conform with the NWB Schema. @rly [#2051](https://github.com/NeurodataWithoutBorders/pynwb/pull/2051)

### Documentation and tutorial enhancements
- Added NWB AI assistant to the home page of the documentation. @magland [#2076](https://github.com/NeurodataWithoutBorders/pynwb/pull/2076)

Expand Down
3 changes: 3 additions & 0 deletions src/pynwb/io/epoch.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ class TimeIntervalsMap(DynamicTableMap):
def columns_carg(self, builder, manager):
# handle the case when a TimeIntervals is read with a non-TimeSeriesReferenceVectorData "timeseries" column
# this is the case for NWB schema v2.4 and earlier, where the timeseries column was a regular VectorData.
# NOTE: the below machinery might be movable into the NWBFileMap.construct override which might better
# handle the rare edge case where the "timeseries" column is referenced in another field elsewhere in the file.

timeseries_builder = builder.get('timeseries')

# handle the case when the TimeIntervals has a "timeseries" column that is a link (e.g., it exists in
Expand Down
50 changes: 31 additions & 19 deletions src/pynwb/io/file.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from dateutil.parser import parse as dateutil_parse
import typing

from hdmf.build import ObjectMapper
from hdmf.build import ObjectMapper, Builder, GroupBuilder
from hdmf.utils import docval, get_docval

from .. import register_map
from ..file import NWBFile, Subject
Expand Down Expand Up @@ -130,6 +132,34 @@ def __init__(self, spec):
self.map_spec('scratch_containers', scratch_spec.get_neurodata_type('NWBContainer'))
self.map_spec('scratch_containers', scratch_spec.get_neurodata_type('DynamicTable'))

@docval(*get_docval(ObjectMapper.construct))
def construct(self, **kwargs):
nwbfile_builder = kwargs["builder"]
electrodes_builder = nwbfile_builder.get("general", dict()).get("extracellular_ephys", dict()).get("electrodes")
if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'):
electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable'
electrodes_builder.attributes['namespace'] = 'core'

def apply_to_child_builders(builder: Builder, funcs: list[typing.Callable]):
# iterate recursively through each builder (which is just a dict of dicts) and make migration changes
for bchild_value in builder.values():
if isinstance(bchild_value, Builder):
for func in funcs:
func(bchild_value)
apply_to_child_builders(bchild_value, funcs)

def update_builder_frequency_bands_table(builder: Builder):
if (isinstance(builder, GroupBuilder) and
builder.attributes.get('namespace') == 'core' and
builder.attributes.get('neurodata_type') == 'DecompositionSeries' and
builder.groups['bands'].attributes['neurodata_type'] == 'DynamicTable'):
builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable'
builder.groups['bands'].attributes['namespace'] = 'core'

apply_to_child_builders(nwbfile_builder, [update_builder_frequency_bands_table])

return super().construct(**kwargs)

@ObjectMapper.object_attr('scratch_datas')
def scratch_datas(self, container, manager):
"""Set the value for the 'scratch_datas' spec on NWBFile to a list of ScratchData objects.
Expand Down Expand Up @@ -185,24 +215,6 @@ def scratch(self, builder, manager):
ret.append(manager.construct(d))
return tuple(ret) if len(ret) > 0 else None

@ObjectMapper.constructor_arg('electrodes')
def electrodes(self, builder, manager):
try:
electrodes_builder = builder['general']['extracellular_ephys']['electrodes']
except KeyError:
# Note: This is here because the ObjectMapper pulls argname from docval and checks to see
# if there is an override even if the file doesn't have what is looking for. In this case,
# electrodes for NWBFile.
electrodes_builder = None
if (electrodes_builder is not None and electrodes_builder.attributes['neurodata_type'] != 'ElectrodesTable'):
electrodes_builder.attributes['neurodata_type'] = 'ElectrodesTable'
electrodes_builder.attributes['namespace'] = 'core'
manager.clear_cache()
new_container = manager.construct(electrodes_builder)
return new_container
else:
return None

@ObjectMapper.constructor_arg('session_start_time')
def dateconversion(self, builder, manager):
"""Set the constructor arg for 'session_start_time' to a datetime object.
Expand Down
16 changes: 1 addition & 15 deletions src/pynwb/io/misc.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,7 @@
from hdmf.common.io.table import DynamicTableMap
from .base import TimeSeriesMap

from .. import register_map
from pynwb.misc import Units, DecompositionSeries


@register_map(DecompositionSeries)
class DecompositionSeriesMap(TimeSeriesMap):

@TimeSeriesMap.constructor_arg('bands')
def bands(self, builder, manager):
if builder.groups['bands'].attributes['neurodata_type'] != 'FrequencyBandsTable':
builder.groups['bands'].attributes['neurodata_type'] = 'FrequencyBandsTable'
builder.groups['bands'].attributes['namespace'] = 'core'
manager.clear_cache()
new_container = manager.construct(builder.groups['bands'])
return new_container
from pynwb.misc import Units


@register_map(Units)
Expand Down
15 changes: 15 additions & 0 deletions src/pynwb/testing/make_test_files.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import numpy as np
from pathlib import Path
from pynwb import NWBFile, NWBHDF5IO, __version__, TimeSeries, get_class, load_namespaces
from pynwb.ecephys import ElectricalSeries
from pynwb.file import Subject
from pynwb.image import ImageSeries
from pynwb.misc import DecompositionSeries
Expand Down Expand Up @@ -237,6 +238,20 @@ def _make_electrodes_dynamic_table():
label=f"shank{i}electrode{j}",
)

nwbfile.add_unit(
spike_times=[0.1, 0.2, 0.3],
electrodes=[0, 1],
)

eseries = ElectricalSeries(
name="ElectricalSeries",
description="Test electrodes reference",
data=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]],
timestamps=[0.1, 0.2, 0.3],
electrodes=nwbfile.create_electrode_table_region(region=[2, 3], description="electrodes table indices 2 and 3"),
)
nwbfile.add_acquisition(eseries)

test_name = 'electrodes_dynamic_table'
_write(test_name, nwbfile)

Expand Down
Binary file modified tests/back_compat/3.0.0_electrodes_dynamic_table.nwb
Binary file not shown.
21 changes: 21 additions & 0 deletions tests/back_compat/test_read.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import numpy as np
from pathlib import Path
import tempfile
import warnings

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

# test that references to the electrodes table are also ElectrodesTable
assert read_nwbfile.units.electrodes.table is read_nwbfile.electrodes
assert read_nwbfile.acquisition["ElectricalSeries"].electrodes.table is read_nwbfile.electrodes

# test that export writes the correct builders
temp_dir = tempfile.TemporaryDirectory()
export_file = Path(temp_dir.name) / "3.0.0_electrodes_dynamic_table_export.nwb"
with NWBHDF5IO(export_file, 'w') as export_io:
export_io.export(io)

with self.get_io(export_file) as read_export_io:
read_export_nwbfile = read_export_io.read()
assert isinstance(read_export_nwbfile.electrodes, ElectrodesTable)

# test that references to the electrodes table are also ElectrodesTable
units_table_ref = read_export_nwbfile.units.electrodes.table
assert units_table_ref is read_export_nwbfile.electrodes
eseries_table_ref = read_export_nwbfile.acquisition["ElectricalSeries"].electrodes.table
assert eseries_table_ref is read_export_nwbfile.electrodes

def test_read_bands_table_as_neurodata_type(self):
"""Test that a "bands" table written as a DynamicTable is read as an FrequencyBandsTable"""
f = Path(__file__).parent / '3.0.0_decompositionseries_bands_dynamic_table.nwb'
Expand Down
Loading