From 3c585b24ad6fc5d5da866c61623487780241d981 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Wed, 14 Dec 2022 11:41:12 -0500 Subject: [PATCH 01/21] add age__reference and adjust tests (#1540) * add age__reference and adjust tests * add to CHANGELOG.md * add period * Update file.py * Update CHANGELOG.md * Update src/pynwb/file.py * add arg check for Subject. If age__reference is provided, age must also be provided * add regression test for subject age * remove raising ValueError when age is omitted * flake8 * Update CHANGELOG.md * Update CHANGELOG.md * Update src/pynwb/file.py * update to allow Subject.age__reference to be None * test for Subject.age__reference == None * Update CHANGELOG.md * Update CHANGELOG.md * use mapper to allow None * update schema * forbid passing age__reference=None to Subject.__init__ * Update comment * fix flake8 * Run backwards compat tests in coverage * Add tests for get_nwb_version * Fix flake8 * Run IO utils tests in test suite Co-authored-by: Ryan Ly --- .github/workflows/run_coverage.yml | 2 +- CHANGELOG.md | 3 +- src/pynwb/file.py | 51 +++++++++---- src/pynwb/io/file.py | 14 ++++ src/pynwb/io/utils.py | 27 +++++++ src/pynwb/nwb-schema | 2 +- src/pynwb/testing/make_test_files.py | 21 ++++++ test.py | 2 + .../2.2.0_subject_no_age__reference.nwb | Bin 0 -> 181616 bytes tests/back_compat/test_read.py | 7 ++ tests/integration/hdf5/test_nwbfile.py | 46 +++++++++--- tests/integration/utils/__init__.py | 0 tests/integration/utils/test_io_utils.py | 30 ++++++++ tests/unit/test_file.py | 67 +++++++++++++----- 14 files changed, 229 insertions(+), 43 deletions(-) create mode 100644 src/pynwb/io/utils.py create mode 100644 tests/back_compat/2.2.0_subject_no_age__reference.nwb create mode 100644 tests/integration/utils/__init__.py create mode 100644 tests/integration/utils/test_io_utils.py diff --git a/.github/workflows/run_coverage.yml b/.github/workflows/run_coverage.yml index cece6aa21..a74f04371 100644 --- a/.github/workflows/run_coverage.yml +++ b/.github/workflows/run_coverage.yml @@ -71,7 +71,7 @@ jobs: - name: Run integration tests and generate coverage report run: | - python -m coverage run -p test.py --integration + python -m coverage run -p test.py --integration --backwards # validation CLI tests generate separate .coverage files that need to be merged python -m coverage combine python -m coverage xml # codecov uploader requires xml format diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b5a878bd..6e10f3def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,8 @@ ## Upcoming ### Enhancements and minor changes -- `Subject.age` can be input as a `timedelta`. @bendichter [#1590](https://github.com/NeurodataWithoutBorders/pynwb/pull/1590) +- `Subject.age` can be input as a `timedelta` type. @bendichter [#1590](https://github.com/NeurodataWithoutBorders/pynwb/pull/1590) +- Add `Subject.age__reference` field. @bendichter ([#1540](https://github.com/NeurodataWithoutBorders/pynwb/pull/1540)) - `IntracellularRecordingsTable.add_recording`: the `electrode` arg is now optional, and is automatically populated from the stimulus or response. [#1597](https://github.com/NeurodataWithoutBorders/pynwb/pull/1597) - Add module `pynwb.testing.mock.icephys` and corresponding tests. @bendichter diff --git a/src/pynwb/file.py b/src/pynwb/file.py index eb2d97e5d..96e8f4f59 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -54,6 +54,7 @@ class Subject(NWBContainer): __nwbfields__ = ( 'age', + "age__reference", 'description', 'genotype', 'sex', @@ -72,8 +73,20 @@ class Subject(NWBContainer): 'A timedelta will automatically be converted to The ISO 8601 Duration format.', "default": None, }, - {'name': 'description', 'type': str, - 'doc': 'A description of the subject, e.g., "mouse A10".', 'default': None}, + { + "name": "age__reference", + "type": str, + "doc": "Age is with reference to this event. Can be 'birth' or 'gestational'. If reference is omitted, " + "then 'birth' is implied. Value can be None when read from an NWB file with schema version " + "2.0 to 2.5 where age__reference is missing.", + "default": "birth", + }, + { + "name": "description", + "type": str, + "doc": 'A description of the subject, e.g., "mouse A10".', + "default": None, + }, {'name': 'genotype', 'type': str, 'doc': 'The genotype of the subject, e.g., "Sst-IRES-Cre/wt;Ai32(RCL-ChR2(H134R)_EYFP)/wt".', 'default': None}, @@ -94,18 +107,30 @@ class Subject(NWBContainer): {'name': 'strain', 'type': str, 'doc': 'The strain of the subject, e.g., "C57BL/6J"', 'default': None}, ) def __init__(self, **kwargs): - keys_to_set = ("age", - "description", - "genotype", - "sex", - "species", - "subject_id", - "weight", - "date_of_birth", - "strain") + keys_to_set = ( + "age", + "age__reference", + "description", + "genotype", + "sex", + "species", + "subject_id", + "weight", + "date_of_birth", + "strain", + ) args_to_set = popargs_to_dict(keys_to_set, kwargs) - kwargs['name'] = 'subject' - super().__init__(**kwargs) + super().__init__(name="subject", **kwargs) + + # NOTE when the Subject I/O mapper (see pynwb.io.file.py) reads an age__reference value of None from an + # NWB 2.0-2.5 file, it sets the value to "unspecified" so that when Subject.__init__ is called, the incoming + # age__reference value is NOT replaced by the default value ("birth") specified in the docval. + # then we replace "unspecified" with None here. the user will never see the value "unspecified". + # the ONLY way that age__reference can now be None is if it is read as None from an NWB 2.0-2.5 file. + if self._in_construct_mode and args_to_set["age__reference"] == "unspecified": + args_to_set["age__reference"] = None + elif args_to_set["age__reference"] not in ("birth", "gestational"): + raise ValueError("age__reference, if supplied, must be 'birth' or 'gestational'.") weight = args_to_set['weight'] if isinstance(weight, float): diff --git a/src/pynwb/io/file.py b/src/pynwb/io/file.py index 068cccd3e..ccbfb8e47 100644 --- a/src/pynwb/io/file.py +++ b/src/pynwb/io/file.py @@ -5,6 +5,7 @@ from .. import register_map from ..file import NWBFile, Subject from ..core import ScratchData +from .utils import get_nwb_version @register_map(NWBFile) @@ -220,3 +221,16 @@ def dateconversion(self, builder, manager): datestr = dob_builder.data date = dateutil_parse(datestr) return date + + @ObjectMapper.constructor_arg("age__reference") + def age_reference_none(self, builder, manager): + age_builder = builder.get("age") + age_reference = None + if age_builder is not None: + age_reference = age_builder["attributes"].get("reference") + if age_reference is None: + if get_nwb_version(builder) < (2, 6, 0): + return "unspecified" # this is handled specially in Subject.__init__ + else: + return "birth" + return age_reference diff --git a/src/pynwb/io/utils.py b/src/pynwb/io/utils.py new file mode 100644 index 000000000..24dfb7933 --- /dev/null +++ b/src/pynwb/io/utils.py @@ -0,0 +1,27 @@ +import re +from typing import Tuple + +from hdmf.build import Builder + + +def get_nwb_version(builder: Builder) -> Tuple[int, ...]: + """Get the version of the NWB file from the root of the given builder, as a tuple. + + If the "nwb_version" attribute on the root builder equals "2.5.1", then (2, 5, 1) is returned. + If the "nwb_version" attribute on the root builder equals "2.5.1-alpha", then (2, 5, 1) is returned. + + :param builder: Any builder within an NWB file. + :type builder: Builder + :return: The version of the NWB file, as a tuple. + :rtype: tuple + :raises ValueError: if the 'nwb_version' attribute is missing from the root of the NWB file. + """ + temp_builder = builder + while temp_builder.parent is not None: + temp_builder = temp_builder.parent + root_builder = temp_builder + nwb_version = root_builder.attributes.get("nwb_version") + if nwb_version is None: + raise ValueError("'nwb_version' attribute is missing from the root of the NWB file.") + nwb_version = re.match(r"(\d+\.\d+\.\d+)", nwb_version)[0] # trim off any non-numeric symbols at end + return tuple([int(i) for i in nwb_version.split(".")]) diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index 761a0d783..c9b11b252 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit 761a0d7838304864643f8bc3ab88c93bfd437f2a +Subproject commit c9b11b252588b4986fa717cccec507c9267e48ff diff --git a/src/pynwb/testing/make_test_files.py b/src/pynwb/testing/make_test_files.py index 301381688..2311989ca 100644 --- a/src/pynwb/testing/make_test_files.py +++ b/src/pynwb/testing/make_test_files.py @@ -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.file import Subject from pynwb.image import ImageSeries from pynwb.spec import NWBNamespaceBuilder, export_spec, NWBGroupSpec, NWBAttributeSpec @@ -197,6 +198,23 @@ def _make_empty_with_extension(): _write(test_name, nwbfile) +def _make_subject_without_age_reference(): + """Create a test file without a value for age_reference.""" + nwbfile = NWBFile(session_description='ADDME', + identifier='ADDME', + session_start_time=datetime.now().astimezone()) + subject = Subject( + age="P90D", + description="A rat", + subject_id="RAT123", + ) + + nwbfile.subject = subject + + test_name = 'subject_no_age__reference' + _write(test_name, nwbfile) + + if __name__ == '__main__': # install these versions of PyNWB and run this script to generate new files # python src/pynwb/testing/make_test_files.py @@ -221,3 +239,6 @@ def _make_empty_with_extension(): _make_imageseries_non_external_format() _make_imageseries_nonmatch_starting_frame() _make_empty_with_extension() + + if __version__ == "2.2.0": + _make_subject_without_age_reference() diff --git a/test.py b/test.py index 401a75e5c..70c42caf4 100755 --- a/test.py +++ b/test.py @@ -227,6 +227,8 @@ def run_integration_tests(verbose=True): else: logging.info('all classes have integration tests') + run_test_suite("tests/integration/utils", "integration utils tests", verbose=verbose) + # also test the validation script run_test_suite("tests/validation", "validation tests", verbose=verbose) diff --git a/tests/back_compat/2.2.0_subject_no_age__reference.nwb b/tests/back_compat/2.2.0_subject_no_age__reference.nwb new file mode 100644 index 0000000000000000000000000000000000000000..076db948768f27ae1e255f311f0d6dc39aa61e7a GIT binary patch literal 181616 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
CNJO~@^eKQ?4v5i*7cM?8n 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 Hll%RD7=wh< literal 0 HcmV?d00001 diff --git a/tests/back_compat/test_read.py b/tests/back_compat/test_read.py index 593c97183..919ae6bde 100644 --- a/tests/back_compat/test_read.py +++ b/tests/back_compat/test_read.py @@ -113,3 +113,10 @@ def test_read_imageseries_nonmatch_starting_frame(self): with NWBHDF5IO(str(f), 'r') as io: read_nwbfile = io.read() np.testing.assert_array_equal(read_nwbfile.acquisition['test_imageseries'].starting_frame, [1, 2, 3]) + + def test_read_subject_no_age__reference(self): + """Test that reading a Subject without an age__reference set with NWB schema 2.5.0 sets the value to None""" + f = Path(__file__).parent / '2.2.0_subject_no_age__reference.nwb' + with NWBHDF5IO(str(f), 'r') as io: + read_nwbfile = io.read() + self.assertIsNone(read_nwbfile.subject.age__reference) diff --git a/tests/integration/hdf5/test_nwbfile.py b/tests/integration/hdf5/test_nwbfile.py index 90c02aac5..9464aebd3 100644 --- a/tests/integration/hdf5/test_nwbfile.py +++ b/tests/integration/hdf5/test_nwbfile.py @@ -203,15 +203,43 @@ class TestSubjectIO(NWBH5IOMixin, TestCase): def setUpContainer(self): """ Return the test Subject """ - return Subject(age='P90D', - description='An unfortunate rat', - genotype='WT', - sex='M', - species='Rattus norvegicus', - subject_id='RAT123', - weight='2 kg', - date_of_birth=datetime(1970, 1, 1, 12, tzinfo=tzutc()), - strain='my_strain') + return Subject( + age="P90D", + age__reference="gestational", + description="An unfortunate rat", + genotype="WT", + sex="M", + species="Rattus norvegicus", + subject_id="RAT123", + weight="2 kg", + date_of_birth=datetime(1970, 1, 1, 12, tzinfo=tzutc()), + strain="my_strain", + ) + + def addContainer(self, nwbfile): + """ Add the test Subject to the given NWBFile """ + nwbfile.subject = self.container + + def getContainer(self, nwbfile): + """ Return the test Subject from the given NWBFile """ + return nwbfile.subject + + +class TestSubjectAgeReferenceNotSetIO(NWBH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test Subject """ + return Subject( + age="P90D", + description="An unfortunate rat", + genotype="WT", + sex="M", + species="Rattus norvegicus", + subject_id="RAT123", + weight="2 kg", + date_of_birth=datetime(1970, 1, 1, 12, tzinfo=tzutc()), + strain="my_strain", + ) def addContainer(self, nwbfile): """ Add the test Subject to the given NWBFile """ diff --git a/tests/integration/utils/__init__.py b/tests/integration/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/utils/test_io_utils.py b/tests/integration/utils/test_io_utils.py new file mode 100644 index 000000000..5d9ed6bea --- /dev/null +++ b/tests/integration/utils/test_io_utils.py @@ -0,0 +1,30 @@ +"""Tests related to pynwb.io.utils.""" +import pytest + +from hdmf.build import GroupBuilder +from pynwb.io.utils import get_nwb_version +from pynwb.testing import TestCase + + +class TestGetNWBVersion(TestCase): + + def test_get_nwb_version(self): + """Get the NWB version from a builder.""" + builder1 = GroupBuilder(name="root") + builder1.set_attribute(name="nwb_version", value="2.0.0") + builder2 = GroupBuilder(name="another") + builder1.set_group(builder2) + assert get_nwb_version(builder1) == (2, 0, 0) + assert get_nwb_version(builder2) == (2, 0, 0) + + def test_get_nwb_version_missing(self): + """Get the NWB version from a builder where the root builder does not have an nwb_version attribute.""" + builder1 = GroupBuilder(name="root") + builder2 = GroupBuilder(name="another") + builder1.set_group(builder2) + + with pytest.raises(ValueError, match="'nwb_version' attribute is missing from the root of the NWB file."): + get_nwb_version(builder1) + + with pytest.raises(ValueError, match="'nwb_version' attribute is missing from the root of the NWB file."): + get_nwb_version(builder1) diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index dd1508c8b..299b5dcb1 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -437,29 +437,35 @@ def test_multi_publications(self): class SubjectTest(TestCase): def setUp(self): - self.subject = Subject(age='P90D', - description='An unfortunate rat', - genotype='WT', - sex='M', - species='Rattus norvegicus', - subject_id='RAT123', - weight='2 kg', - date_of_birth=datetime(2017, 5, 1, 12, tzinfo=tzlocal()), - strain='my_strain') + self.subject = Subject( + age='P90D', + age__reference="birth", + description='An unfortunate rat', + genotype='WT', + sex='M', + species='Rattus norvegicus', + subject_id='RAT123', + weight='2 kg', + date_of_birth=datetime(2017, 5, 1, 12, tzinfo=tzlocal()), + strain='my_strain', + ) self.start = datetime(2017, 5, 1, 12, tzinfo=tzlocal()) self.path = 'nwbfile_test.h5' - self.nwbfile = NWBFile('a test session description for a test NWBFile', - 'FILE123', - self.start, - experimenter='A test experimenter', - lab='a test lab', - institution='a test institution', - experiment_description='a test experiment description', - session_id='test1', - subject=self.subject) + self.nwbfile = NWBFile( + 'a test session description for a test NWBFile', + 'FILE123', + self.start, + experimenter='A test experimenter', + lab='a test lab', + institution='a test institution', + experiment_description='a test experiment description', + session_id='test1', + subject=self.subject, + ) def test_constructor(self): self.assertEqual(self.subject.age, 'P90D') + self.assertEqual(self.subject.age__reference, "birth") self.assertEqual(self.subject.description, 'An unfortunate rat') self.assertEqual(self.subject.genotype, 'WT') self.assertEqual(self.subject.sex, 'M') @@ -479,6 +485,31 @@ def test_weight_float(self): ) self.assertEqual(subject.weight, '2.3 kg') + def test_age_reference_arg_check(self): + with self.assertRaisesWith(ValueError, "age__reference, if supplied, must be 'birth' or 'gestational'."): + Subject(subject_id='RAT123', age='P90D', age__reference='brth') + + def test_age_regression_1(self): + subject = Subject( + age='P90D', + description='An unfortunate rat', + subject_id='RAT123', + ) + + self.assertEqual(subject.age, 'P90D') + self.assertEqual(subject.age__reference, "birth") + self.assertEqual(subject.description, 'An unfortunate rat') + self.assertEqual(subject.subject_id, 'RAT123') + + def test_age_regression_2(self): + subject = Subject( + description='An unfortunate rat', + subject_id='RAT123', + ) + + self.assertEqual(subject.description, 'An unfortunate rat') + self.assertEqual(subject.subject_id, 'RAT123') + def test_subject_age_duration(self): subject = Subject( subject_id='RAT123', From 27902b7428a69b2b978772ec642900a173434bc7 Mon Sep 17 00:00:00 2001 From: Cody Baker <51133164+CodyCBakerPhD@users.noreply.github.com> Date: Wed, 21 Dec 2022 00:49:11 -0500 Subject: [PATCH 02/21] Adding OnePhotonSeries class (#1593) Co-authored-by: Oliver Ruebel --- CHANGELOG.md | 1 + src/pynwb/nwb-schema | 2 +- src/pynwb/ophys.py | 81 ++++++++++++++++++++++++++++ src/pynwb/testing/mock/ophys.py | 58 ++++++++++++++++++++ tests/integration/hdf5/test_ophys.py | 32 +++++++++++ tests/unit/test_mock.py | 2 + tests/unit/test_ophys.py | 62 ++++++++++++++++++++- 7 files changed, 235 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e10f3def..1bb1823b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Upcoming ### Enhancements and minor changes +- Added a class and tests for the `OnePhotonSeries` new in NWB v2.6.0. @CodyCBakerPhD [#1593](https://github.com/NeurodataWithoutBorders/pynwb/pull/1593)(see also NWB Schema [#523](https://github.com/NeurodataWithoutBorders/nwb-schema/pull/523) - `Subject.age` can be input as a `timedelta` type. @bendichter [#1590](https://github.com/NeurodataWithoutBorders/pynwb/pull/1590) - Add `Subject.age__reference` field. @bendichter ([#1540](https://github.com/NeurodataWithoutBorders/pynwb/pull/1540)) - `IntracellularRecordingsTable.add_recording`: the `electrode` arg is now optional, and is automatically populated from the stimulus or response. diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index c9b11b252..803906f6d 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit c9b11b252588b4986fa717cccec507c9267e48ff +Subproject commit 803906f6de91128364a02c79ce60dec3f8519b5b diff --git a/src/pynwb/ophys.py b/src/pynwb/ophys.py index 493d5dd85..b09267ff6 100644 --- a/src/pynwb/ophys.py +++ b/src/pynwb/ophys.py @@ -120,6 +120,87 @@ def __init__(self, **kwargs): setattr(self, key, val) +@register_class("OnePhotonSeries", CORE_NAMESPACE) +class OnePhotonSeries(ImageSeries): + """Image stack recorded over time from 1-photon microscope.""" + + __nwbfields__ = ( + "imaging_plane", "pmt_gain", "scan_line_rate", "exposure_time", "binning", "power", "intensity" + ) + + @docval( + *get_docval(ImageSeries.__init__, "name"), # required + {"name": "imaging_plane", "type": ImagingPlane, "doc": "Imaging plane class/pointer."}, # required + *get_docval(ImageSeries.__init__, "data", "unit", "format"), + {"name": "pmt_gain", "type": float, "doc": "Photomultiplier gain.", "default": None}, + { + "name": "scan_line_rate", + "type": float, + "doc": ( + "Lines imaged per second. This is also stored in /general/optophysiology but is kept " + "here as it is useful information for analysis, and so good to be stored w/ the actual data." + ), + "default": None, + }, + { + "name": "exposure_time", + "type": float, + "doc": "Exposure time of the sample; often the inverse of the frequency.", + "default": None, + }, + { + "name": "binning", + "type": (int, "uint"), + "doc": "Amount of pixels combined into 'bins'; could be 1, 2, 4, 8, etc.", + "default": None, + }, + { + "name": "power", + "type": float, + "doc": "Power of the excitation in mW, if known.", + "default": None, + }, + { + "name": "intensity", + "type": float, + "doc": "Intensity of the excitation in mW/mm^2, if known.", + "default": None, + }, + *get_docval( + ImageSeries.__init__, + "external_file", + "starting_frame", + "bits_per_pixel", + "dimension", + "resolution", + "conversion", + "timestamps", + "starting_time", + "rate", + "comments", + "description", + "control", + "control_description", + "device", + "offset", + ) + ) + def __init__(self, **kwargs): + keys_to_set = ( + "imaging_plane", "pmt_gain", "scan_line_rate", "exposure_time", "binning", "power", "intensity" + ) + args_to_set = popargs_to_dict(keys_to_set, kwargs) + super().__init__(**kwargs) + + if args_to_set["binning"] < 0: + raise ValueError(f"Binning value must be >= 0: {args_to_set['binning']}") + if isinstance(args_to_set["binning"], int): + args_to_set["binning"] = np.uint(args_to_set["binning"]) + + for key, val in args_to_set.items(): + setattr(self, key, val) + + @register_class('TwoPhotonSeries', CORE_NAMESPACE) class TwoPhotonSeries(ImageSeries): """Image stack recorded over time from 2-photon microscope.""" diff --git a/src/pynwb/testing/mock/ophys.py b/src/pynwb/testing/mock/ophys.py index 7c63a3008..f35d19720 100644 --- a/src/pynwb/testing/mock/ophys.py +++ b/src/pynwb/testing/mock/ophys.py @@ -5,6 +5,7 @@ RoiResponseSeries, OpticalChannel, ImagingPlane, + OnePhotonSeries, TwoPhotonSeries, PlaneSegmentation, ImageSegmentation, @@ -63,6 +64,63 @@ def mock_ImagingPlane( ) +def mock_OnePhotonSeries( + name=None, + imaging_plane=None, + data=None, + rate=50.0, + unit="n.a.", + exposure_time=None, + binning=None, + power=None, + intensity=None, + format=None, + pmt_gain=None, + scan_line_rate=None, + external_file=None, + starting_frame=[0], + bits_per_pixel=None, + dimension=None, + resolution=-1.0, + conversion=1.0, + offset=0.0, + timestamps=None, + starting_time=None, + comments="no comments", + description="no description", + control=None, + control_description=None, + device=None, +): + return OnePhotonSeries( + name=name if name is not None else name_generator("OnePhotonSeries"), + imaging_plane=imaging_plane or mock_ImagingPlane(), + data=data if data is not None else np.ones((20, 5, 5)), + unit=unit, + exposure_time=exposure_time, + binning=binning, + power=power, + intensity=intensity, + format=format, + pmt_gain=pmt_gain, + scan_line_rate=scan_line_rate, + external_file=external_file, + starting_frame=starting_frame, + bits_per_pixel=bits_per_pixel, + dimension=dimension, + resolution=resolution, + conversion=conversion, + timestamps=timestamps, + starting_time=starting_time, + rate=rate, + comments=comments, + description=description, + control=control, + control_description=control_description, + device=device, + ) + + def mock_TwoPhotonSeries( name=None, imaging_plane=None, diff --git a/tests/integration/hdf5/test_ophys.py b/tests/integration/hdf5/test_ophys.py index d6e691620..3863e7c0e 100644 --- a/tests/integration/hdf5/test_ophys.py +++ b/tests/integration/hdf5/test_ophys.py @@ -7,6 +7,7 @@ OpticalChannel, PlaneSegmentation, ImageSegmentation, + OnePhotonSeries, TwoPhotonSeries, RoiResponseSeries, MotionCorrection, @@ -135,6 +136,37 @@ def getContainer(self, nwbfile): return nwbfile.processing['ophys'].data_interfaces['MotionCorrection'] +class TestOnePhotonSeriesIO(AcquisitionH5IOMixin, TestCase): + + def setUpContainer(self): + """ Return the test OnePhotonSeries to read/write """ + self.device, self.optical_channel, self.imaging_plane = make_imaging_plane() + data = np.ones((10, 2, 2)) + timestamps = list(map(lambda x: x/10, range(10))) + ret = OnePhotonSeries( + name='test_2ps', + imaging_plane=self.imaging_plane, + data=data, + unit='image_unit', + format='raw', + pmt_gain=1.7, + scan_line_rate=3.4, + exposure_time=123., + binning=2, + power=9001., + intensity=5., + timestamps=timestamps, + dimension=[2], + ) + return ret + + def addContainer(self, nwbfile): + """ Add the test OnePhotonSeries as an acquisition and add Device and ImagingPlane to the given NWBFile """ + nwbfile.add_device(self.device) + nwbfile.add_imaging_plane(self.imaging_plane) + nwbfile.add_acquisition(self.container) + + class TestTwoPhotonSeriesIO(AcquisitionH5IOMixin, TestCase): def setUpContainer(self): diff --git a/tests/unit/test_mock.py b/tests/unit/test_mock.py index 272603e00..6f59c2007 100644 --- a/tests/unit/test_mock.py +++ b/tests/unit/test_mock.py @@ -4,6 +4,7 @@ from pynwb.testing.mock.ophys import ( mock_ImagingPlane, + mock_OnePhotonSeries, mock_TwoPhotonSeries, mock_RoiResponseSeries, mock_PlaneSegmentation, @@ -52,6 +53,7 @@ @pytest.mark.parametrize( "mock_function", [ mock_ImagingPlane, + mock_OnePhotonSeries, mock_TwoPhotonSeries, mock_RoiResponseSeries, mock_PlaneSegmentation, diff --git a/tests/unit/test_ophys.py b/tests/unit/test_ophys.py index 2fb725a6c..1ebb7c640 100644 --- a/tests/unit/test_ophys.py +++ b/tests/unit/test_ophys.py @@ -5,8 +5,19 @@ from pynwb.base import TimeSeries from pynwb.device import Device from pynwb.image import ImageSeries -from pynwb.ophys import (TwoPhotonSeries, RoiResponseSeries, DfOverF, Fluorescence, PlaneSegmentation, - ImageSegmentation, OpticalChannel, ImagingPlane, MotionCorrection, CorrectedImageStack) +from pynwb.ophys import ( + OnePhotonSeries, + TwoPhotonSeries, + RoiResponseSeries, + DfOverF, + Fluorescence, + PlaneSegmentation, + ImageSegmentation, + OpticalChannel, + ImagingPlane, + MotionCorrection, + CorrectedImageStack +) from pynwb.testing import TestCase @@ -171,6 +182,53 @@ def test_unit_deprecated(self): ) +class OnePhotonSeriesConstructor(TestCase): + + def test_init(self): + ip = create_imaging_plane() + one_photon_series = OnePhotonSeries( + name="test_one_photon_series", + unit="unit", + imaging_plane=ip, + pmt_gain=1., + scan_line_rate=2., + exposure_time=123., + binning=2, + power=9001., + intensity=5., + external_file=["external_file"], + starting_frame=[0], + format="external", + timestamps=list(), + ) + self.assertEqual(one_photon_series.name, 'test_one_photon_series') + self.assertEqual(one_photon_series.unit, 'unit') + self.assertEqual(one_photon_series.imaging_plane, ip) + self.assertEqual(one_photon_series.pmt_gain, 1.) + self.assertEqual(one_photon_series.scan_line_rate, 2.) + self.assertEqual(one_photon_series.exposure_time, 123.) + self.assertEqual(one_photon_series.binning, 2) + self.assertEqual(one_photon_series.power, 9001.) + self.assertEqual(one_photon_series.intensity, 5.) + self.assertEqual(one_photon_series.external_file, ["external_file"]) + self.assertEqual(one_photon_series.starting_frame, [0]) + self.assertEqual(one_photon_series.format, "external") + self.assertIsNone(one_photon_series.dimension) + + def test_negative_binning_assertion(self): + ip = create_imaging_plane() + + with self.assertRaisesWith(exc_type=ValueError, exc_msg="Binning value must be >= 0: -1"): + OnePhotonSeries( + name="test_one_photon_series_binning_assertion", + unit="unit", + data=np.empty(shape=(10, 100, 100)), + imaging_plane=ip, + rate=1., + binning=-1, + ) + + class TwoPhotonSeriesConstructor(TestCase): def test_init(self): From e3381a0e0420f2e29d0a867cf006a8bd51ba33d8 Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 09:29:13 -0800 Subject: [PATCH 03/21] nwb_schema_2.6.0 (#1636) * Check nwb_version on read (#1612) * Added NWBHDF5IO.nwb_version property and check for version on NWBHDF5IO.read * Updated icephys tests to skip version check when writing non NWBFile container * Add tests for NWB version check on read * Add unit tests for NWBHDF5IO.nwb_version property * Updated changelog Co-authored-by: Ryan Ly * Bump setuptools from 65.4.1 to 65.5.1 (#1614) Bumps [setuptools](https://github.com/pypa/setuptools) from 65.4.1 to 65.5.1. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/CHANGES.rst) - [Commits](https://github.com/pypa/setuptools/compare/v65.4.1...v65.5.1) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * modify export.rst to have proper links to the NWBFile API docs (#1615) * Create project_action.yml (#1617) * Create project_action.yml * Update project_action.yml * Update project_action.yml * Update project_action.yml (#1620) * Update project_action.yml (#1623) * Project action (#1626) * Create project_action.yml * Update project_action.yml * Update project_action.yml * Update project_action.yml * Show recommended usaege for hdf5plugin in tutorial (#1630) * Show recommended usaege for hdf5plugin in tutorial * Update docs/gallery/advanced_io/h5dataio.py * Update docs/gallery/advanced_io/h5dataio.py Co-authored-by: Heberto Mayorquin Co-authored-by: Ben Dichter Co-authored-by: Heberto Mayorquin * Update iterative write and parallel I/O tutorial (#1633) * Update iterative write tutorial * Update doc makefiles to clean up files created by the advanced io tutorial * Fix #1514 Update parallel I/O tutorial to use H5DataIO instead of DataChunkIterator to setup data for parallel write * Update changelog * Fix flake8 * Fix broken external links * Update make.bat * Update CHANGELOG.md * Update plot_iterative_write.py * Update docs/gallery/advanced_io/plot_iterative_write.py Co-authored-by: Ryan Ly * Update project_action.yml (#1632) * nwb_schema_2.6.0 * Update CHANGELOG.md * remove Signed-off-by: dependabot[bot] Co-authored-by: Oliver Ruebel Co-authored-by: Ryan Ly Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ben Dichter Co-authored-by: Heberto Mayorquin --- .github/workflows/project_action.yml | 34 +++++ CHANGELOG.md | 23 +-- docs/Makefile | 3 +- docs/gallery/advanced_io/h5dataio.py | 2 +- docs/gallery/advanced_io/parallelio.py | 29 +--- ...ative_write.py => plot_iterative_write.py} | 142 +++++++----------- docs/gallery/general/read_basics.py | 2 +- docs/make.bat | 3 + docs/source/conf.py | 1 + docs/source/export.rst | 31 ++-- docs/source/index.rst | 2 +- docs/source/software_process.rst | 2 +- requirements.txt | 2 +- src/pynwb/__init__.py | 50 +++++- src/pynwb/nwb-schema | 2 +- tests/integration/hdf5/test_io.py | 76 +++++++++- tests/unit/test_icephys_metadata_tables.py | 2 +- 17 files changed, 264 insertions(+), 142 deletions(-) create mode 100644 .github/workflows/project_action.yml rename docs/gallery/advanced_io/{iterative_write.py => plot_iterative_write.py} (89%) diff --git a/.github/workflows/project_action.yml b/.github/workflows/project_action.yml new file mode 100644 index 000000000..860d34dac --- /dev/null +++ b/.github/workflows/project_action.yml @@ -0,0 +1,34 @@ +name: Add issues to Development Project Board + +on: + issues: + types: + - opened + +jobs: + add-to-project: + name: Add issue to project + runs-on: ubuntu-latest + steps: + - name: GitHub App token + id: generate_token + uses: tibdex/github-app-token@v1.7.0 + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PEM }} + + - name: Add to Developer Board + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + uses: actions/add-to-project@v0.4.0 + with: + project-url: https://github.com/orgs/NeurodataWithoutBorders/projects/7 + github-token: ${{ env.TOKEN }} + + - name: Add to Community Board + env: + TOKEN: ${{ steps.generate_token.outputs.token }} + uses: actions/add-to-project@v0.4.0 + with: + project-url: https://github.com/orgs/NeurodataWithoutBorders/projects/8 + github-token: ${{ env.TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb1823b6..e480ea54e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,26 +8,31 @@ - Add `Subject.age__reference` field. @bendichter ([#1540](https://github.com/NeurodataWithoutBorders/pynwb/pull/1540)) - `IntracellularRecordingsTable.add_recording`: the `electrode` arg is now optional, and is automatically populated from the stimulus or response. [#1597](https://github.com/NeurodataWithoutBorders/pynwb/pull/1597) -- Add module `pynwb.testing.mock.icephys` and corresponding tests. @bendichter +- Added module `pynwb.testing.mock.icephys` and corresponding tests. @bendichter [1595](https://github.com/NeurodataWithoutBorders/pynwb/pull/1595) -- Remove redundant object mapper code. @rly [#1600](https://github.com/NeurodataWithoutBorders/pynwb/pull/1600) -- Fix pending deprecations and issues in CI. @rly [#1594](https://github.com/NeurodataWithoutBorders/pynwb/pull/1594) +- Removed redundant object mapper code. @rly [#1600](https://github.com/NeurodataWithoutBorders/pynwb/pull/1600) +- Fixed pending deprecations and issues in CI. @rly [#1594](https://github.com/NeurodataWithoutBorders/pynwb/pull/1594) +- Added ``NWBHDF5IO.nwb_version`` property to get the NWB version from an NWB HDF5 file @oruebel [#1612](https://github.com/NeurodataWithoutBorders/pynwb/pull/1612) +- Updated ``NWBHDF5IO.read`` to check NWB version before read and raise more informative error if an unsupported version is found @oruebel [#1612](https://github.com/NeurodataWithoutBorders/pynwb/pull/1612) +- Updated NWB Schema tp 2.6.0 @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) ### Documentation and tutorial enhancements: - Adjusted [ecephys tutorial](https://pynwb.readthedocs.io/en/stable/tutorials/domain/ecephys.html) to create fake data with proper dimensions @bendichter [#1581](https://github.com/NeurodataWithoutBorders/pynwb/pull/1581) - Refactored testing documentation, including addition of section on ``pynwb.testing.mock`` submodule. @bendichter [#1583](https://github.com/NeurodataWithoutBorders/pynwb/pull/1583) -- Update round trip tutorial to the newer ``NWBH5IOMixin`` and ``AcquisitionH5IOMixin`` classes. @bendichter +- Updated round trip tutorial to the newer ``NWBH5IOMixin`` and ``AcquisitionH5IOMixin`` classes. @bendichter [#1586](https://github.com/NeurodataWithoutBorders/pynwb/pull/1586) -- More informative error message for common installation error. @bendichter, @rly +- Added more informative error message for common installation error. @bendichter, @rly [#1591](https://github.com/NeurodataWithoutBorders/pynwb/pull/1591) -- Update citation for PyNWB in docs and duecredit to use the eLife NWB paper. @oruebel [#1604](https://github.com/NeurodataWithoutBorders/pynwb/pull/1604) -- Fix docs build warnings due to use of hardcoded links. @oruebel [#1604](https://github.com/NeurodataWithoutBorders/pynwb/pull/1604) +- Updated citation for PyNWB in docs and duecredit to use the eLife NWB paper. @oruebel [#1604](https://github.com/NeurodataWithoutBorders/pynwb/pull/1604) +- Fixed docs build warnings due to use of hardcoded links. @oruebel [#1604](https://github.com/NeurodataWithoutBorders/pynwb/pull/1604) +- Updated the [iterative write tutorial](https://pynwb.readthedocs.io/en/stable/tutorials/advanced_io/iterative_write.html) to reference the new ``GenericDataChunkIterator`` functionality and use the new ``H5DataIO.dataset`` property to simplify the custom I/O section. @oruebel [#1633](https://github.com/NeurodataWithoutBorders/pynwb/pull/1633) +- Updated the [parallel I/O tutorial](https://pynwb.readthedocs.io/en/stable/tutorials/advanced_io/parallelio.html) to use the new ``H5DataIO.dataset`` feature to set up an empty dataset for parallel write. @oruebel [#1633](https://github.com/NeurodataWithoutBorders/pynwb/pull/1633) ### Bug fixes -- Add shape constraint to `PatchClampSeries.data`. @bendichter +- Added shape constraint to `PatchClampSeries.data`. @bendichter [#1596](https://github.com/NeurodataWithoutBorders/pynwb/pull/1596) -- Update the [images tutorial](https://pynwb.readthedocs.io/en/stable/tutorials/domain/images.html) to provide example usage of an ``IndexSeries`` +- Updated the [images tutorial](https://pynwb.readthedocs.io/en/stable/tutorials/domain/images.html) to provide example usage of an ``IndexSeries`` with a reference to ``Images``. @bendichter [#1602](https://github.com/NeurodataWithoutBorders/pynwb/pull/1602) - Fixed an issue with the `tox` tool when upgrading to tox 4. @rly [#1608](https://github.com/NeurodataWithoutBorders/pynwb/pull/1608) diff --git a/docs/Makefile b/docs/Makefile index 4cb0b9d41..80492bbf2 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -9,6 +9,7 @@ PAPER = BUILDDIR = _build SRCDIR = ../src RSTDIR = source +GALLERYDIR = gallery PKGNAME = pynwb # Internal variables. @@ -45,7 +46,7 @@ help: @echo " apidoc to build RST from source code" clean: - -rm -rf $(BUILDDIR)/* $(RSTDIR)/$(PKGNAME)*.rst $(RSTDIR)/tutorials + -rm -rf $(BUILDDIR)/* $(RSTDIR)/$(PKGNAME)*.rst $(RSTDIR)/tutorials $(GALLERYDIR)/advanced_io/*.npy $(GALLERYDIR)/advanced_io/*.nwb html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html diff --git a/docs/gallery/advanced_io/h5dataio.py b/docs/gallery/advanced_io/h5dataio.py index 8ddd1fcf1..554d4394b 100644 --- a/docs/gallery/advanced_io/h5dataio.py +++ b/docs/gallery/advanced_io/h5dataio.py @@ -234,7 +234,7 @@ wrapped_data = H5DataIO( data=data, - compression=hdf5plugin.Zstd().filter_id, + **hdf5plugin.Zstd(clevel=3), # set the compression and compression_opts parameters allow_plugin_filters=True, ) diff --git a/docs/gallery/advanced_io/parallelio.py b/docs/gallery/advanced_io/parallelio.py index a91e567d5..39fed657a 100644 --- a/docs/gallery/advanced_io/parallelio.py +++ b/docs/gallery/advanced_io/parallelio.py @@ -30,7 +30,7 @@ # from dateutil import tz # from pynwb import NWBHDF5IO, NWBFile, TimeSeries # from datetime import datetime -# from hdmf.data_utils import DataChunkIterator +# from hdmf.backends.hdf5.h5_utils import H5DataIO # # start_time = datetime(2018, 4, 25, 2, 30, 3, tzinfo=tz.gettz('US/Pacific')) # fname = 'test_parallel_pynwb.nwb' @@ -40,9 +40,11 @@ # # write in parallel but we do not write any data # if rank == 0: # nwbfile = NWBFile('aa', 'aa', start_time) -# data = DataChunkIterator(data=None, maxshape=(4,), dtype=np.dtype('int')) +# data = H5DataIO(shape=(4,), +# maxshape=(4,), +# dtype=np.dtype('int')) # -# nwbfile.add_acquisition(TimeSeries('ts_name', description='desc', data=data, +# nwbfile.add_acquisition(TimeSeries(name='ts_name', description='desc', data=data, # rate=100., unit='m')) # with NWBHDF5IO(fname, 'w') as io: # io.write(nwbfile) @@ -58,24 +60,9 @@ # print(io.read().acquisition['ts_name'].data[rank]) #################### -# To specify details about chunking, compression and other HDF5-specific I/O options, -# we can wrap data via ``H5DataIO``, e.g, # -# .. code-block:: python -# -# data = H5DataIO(DataChunkIterator(data=None, maxshape=(100000, 100), -# dtype=np.dtype('float')), -# chunks=(10, 10), maxshape=(None, None)) +# .. note:: # -# would initialize your dataset with a shape of (100000, 100) and maxshape of (None, None) -# and your own custom chunking of (10, 10). - -#################### -# Disclaimer -# ---------------- +# Using :py:class:`hdmf.backends.hdf5.h5_utils.H5DataIO` we can also specify further +# details about the data layout, e.g., via the chunking and compression parameters. # -# External links included in the tutorial are being provided as a convenience and for informational purposes only; -# they do not constitute an endorsement or an approval by the authors of any of the products, services or opinions of -# the corporation or organization or individual. The authors bear no responsibility for the accuracy, legality or -# content of the external site or for that of subsequent links. Contact the external site for answers to questions -# regarding its content. diff --git a/docs/gallery/advanced_io/iterative_write.py b/docs/gallery/advanced_io/plot_iterative_write.py similarity index 89% rename from docs/gallery/advanced_io/iterative_write.py rename to docs/gallery/advanced_io/plot_iterative_write.py index 26f7d1a9d..3884c333a 100644 --- a/docs/gallery/advanced_io/iterative_write.py +++ b/docs/gallery/advanced_io/plot_iterative_write.py @@ -42,6 +42,7 @@ # * **Data generators** Data generators are in many ways similar to data streams only that the # data is typically being generated locally and programmatically rather than from an external # data source. +# # * **Sparse data arrays** In order to reduce storage size of sparse arrays a challenge is that while # the data array (e.g., a matrix) may be large, only few values are set. To avoid storage overhead # for storing the full array we can employ (in HDF5) a combination of chunking, compression, and @@ -71,6 +72,13 @@ # This is useful for buffered I/O operations, e.g., to improve performance by accumulating data in memory and # writing larger blocks at once. # +# * :py:class:`~hdmf.data_utils.GenericDataChunkIterator` is a semi-abstract version of a +# :py:class:`~hdmf.data_utils.AbstractDataChunkIterator` that automatically handles the selection of +# buffer regions and resolves communication of compatible chunk regions. Users specify chunk +# and buffer shapes or sizes and the iterator will manage how to break the data up for write. +# For further details, see the +# :hdmf-docs:`GenericDataChunkIterator tutorial `. +# #################### # Iterative Data Write: API @@ -107,11 +115,15 @@ from pynwb import NWBHDF5IO -def write_test_file(filename, data): +def write_test_file(filename, data, close_io=True): """ + Simple helper function to write an NWBFile with a single timeseries containing data :param filename: String with the name of the output file :param data: The data of the timeseries + :param close_io: Close and destroy the NWBHDF5IO object used for writing (default=True) + + :returns: None if close_io==True otherwise return NWBHDF5IO object used for write """ # Create a test NWBfile @@ -133,7 +145,11 @@ def write_test_file(filename, data): # Write the data to file io = NWBHDF5IO(filename, 'w') io.write(nwbfile) - io.close() + if close_io: + io.close() + del io + io = None + return io #################### @@ -196,12 +212,6 @@ def iter_sin(chunk_length=10, max_chunks=100): str(data.dtype))) #################### -# ``[Out]:`` -# -# .. code-block:: python -# -# maxshape=(None, 10), recommended_data_shape=(1, 10), dtype=float64 -# # As we can see :py:class:`~hdmf.data_utils.DataChunkIterator` automatically recommends # in its ``maxshape`` that the first dimensions of our array should be unlimited (``None``) and the second # dimension be ``10`` (i.e., the length of our chunk. Since :py:class:`~hdmf.data_utils.DataChunkIterator` @@ -216,8 +226,11 @@ def iter_sin(chunk_length=10, max_chunks=100): # :py:class:`~hdmf.data_utils.DataChunkIterator` assumes that our generators yields in **consecutive order** # **single** complete element along the **first dimension** of our a array (i.e., iterate over the first # axis and yield one-element-at-a-time). This behavior is useful in many practical cases. However, if -# this strategy does not match our needs, then you can alternatively implement our own derived -# :py:class:`~hdmf.data_utils.AbstractDataChunkIterator`. We show an example of this next. +# this strategy does not match our needs, then using :py:class:`~hdmf.data_utils.GenericDataChunkIterator` +# or implementing your own derived :py:class:`~hdmf.data_utils.AbstractDataChunkIterator` may be more +# appropriate. We show an example of how to implement your own :py:class:`~hdmf.data_utils.AbstractDataChunkIterator` +# next. See the :hdmf-docs:`GenericDataChunkIterator tutorial ` as +# part of the HDMF documentation for details on how to use :py:class:`~hdmf.data_utils.GenericDataChunkIterator`. # @@ -387,26 +400,6 @@ def maxshape(self): print(" Reduction : %.2f x" % (expected_size / file_size_largechunks_compressed)) #################### -# ``[Out]:`` -# -# .. code-block:: python -# -# 1) Sparse Matrix Size: -# Expected Size : 8000000.00 MB -# Occupied Size : 0.80000 MB -# 2) NWB HDF5 file (no compression): -# File Size : 0.89 MB -# Reduction : 9035219.28 x -# 3) NWB HDF5 file (with GZIP compression): -# File Size : 0.88847 MB -# Reduction : 9004283.79 x -# 4) NWB HDF5 file (large chunks): -# File Size : 80.08531 MB -# Reduction : 99893.47 x -# 5) NWB HDF5 file (large chunks with compression): -# File Size : 1.14671 MB -# Reduction : 6976450.12 x -# # Discussion # ^^^^^^^^^^ # @@ -490,7 +483,7 @@ def maxshape(self): # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # # Note, we here use a generator for simplicity but we could equally well also implement our own -# :py:class:`~hdmf.data_utils.AbstractDataChunkIterator`. +# :py:class:`~hdmf.data_utils.AbstractDataChunkIterator` or use :py:class:`~hdmf.data_utils.GenericDataChunkIterator`. def iter_largearray(filename, shape, dtype='float64'): @@ -553,15 +546,6 @@ def iter_largearray(filename, shape, dtype='float64'): else: print("ERROR: Mismatch between data") - -#################### -# ``[Out]:`` -# -# .. code-block:: python -# -# Success: All data values match - - #################### # Example: Convert arrays stored in multiple files # ----------------------------------------------------- @@ -705,46 +689,37 @@ def maxshape(self): # from hdmf.backends.hdf5.h5_utils import H5DataIO -write_test_file(filename='basic_alternative_custom_write.nwb', - data=H5DataIO(data=np.empty(shape=(0, 10), dtype='float'), - maxshape=(None, 10), # <-- Make the time dimension resizable - chunks=(131072, 2), # <-- Use 2MB chunks - compression='gzip', # <-- Enable GZip compression - compression_opts=4, # <-- GZip aggression - shuffle=True, # <-- Enable shuffle filter - fillvalue=np.nan # <-- Use NAN as fillvalue - ) - ) +# Use H5DataIO to specify how to setup the dataset in the file +dataio = H5DataIO( + shape=(0, 10), # Initial shape. If the shape is known then set to full shape + dtype=np.dtype('float'), # dtype of the dataset + maxshape=(None, 10), # Make the time dimension resizable + chunks=(131072, 2), # Use 2MB chunks + compression='gzip', # Enable GZip compression + compression_opts=4, # GZip aggression + shuffle=True, # Enable shuffle filter + fillvalue=np.nan # Use NAN as fillvalue +) + +# Write a test NWB file with our dataset and keep the NWB file (i.e., the NWBHDF5IO object) open +io = write_test_file( + filename='basic_alternative_custom_write.nwb', + data=dataio, + close_io=False +) #################### # Step 2: Get the dataset(s) to be updated # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ # -from pynwb import NWBHDF5IO # noqa - -io = NWBHDF5IO('basic_alternative_custom_write.nwb', mode='a') -nwbfile = io.read() -data = nwbfile.get_acquisition('synthetic_timeseries').data - -# Let's check what the data looks like -print("Shape %s, Chunks: %s, Maxshape=%s" % (str(data.shape), str(data.chunks), str(data.maxshape))) - -#################### -# ``[Out]:`` -# -# .. code-block:: python -# -# Shape (0, 10), Chunks: (131072, 2), Maxshape=(None, 10) -# -#################### -# Step 3: Implement custom write -# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -# +# Let's check what the data looks like before we write +print("Before write: Shape= %s, Chunks= %s, Maxshape=%s" % + (str(dataio.dataset.shape), str(dataio.dataset.chunks), str(dataio.dataset.maxshape))) -data.resize((8, 10)) # <-- Allocate the space with need -data[0:3, :] = 1 # <-- Write timesteps 0,1,2 -data[3:6, :] = 2 # <-- Write timesteps 3,4,5, Note timesteps 6,7 are not being initialized +dataio.dataset.resize((8, 10)) # <-- Allocate space. Only needed if we didn't set the initial shape large enough +dataio.dataset[0:3, :] = 1 # <-- Write timesteps 0,1,2 +dataio.dataset[3:6, :] = 2 # <-- Write timesteps 3,4,5, Note timesteps 6,7 are not being initialized io.close() # <-- Close the file @@ -756,20 +731,13 @@ def maxshape(self): io = NWBHDF5IO('basic_alternative_custom_write.nwb', mode='a') nwbfile = io.read() -data = nwbfile.get_acquisition('synthetic_timeseries').data -print(data[:]) +dataset = nwbfile.get_acquisition('synthetic_timeseries').data +print("After write: Shape= %s, Chunks= %s, Maxshape=%s" % + (str(dataset.shape), str(dataset.chunks), str(dataset.maxshape))) +print(dataset[:]) io.close() #################### -# ``[Out]:`` -# -# .. code-block:: python -# -# [[ 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.] -# [ 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.] -# [ 1. 1. 1. 1. 1. 1. 1. 1. 1. 1.] -# [ 2. 2. 2. 2. 2. 2. 2. 2. 2. 2.] -# [ 2. 2. 2. 2. 2. 2. 2. 2. 2. 2.] -# [ 2. 2. 2. 2. 2. 2. 2. 2. 2. 2.] -# [ nan nan nan nan nan nan nan nan nan nan] -# [ nan nan nan nan nan nan nan nan nan nan]] +# We allocated our data to be ``shape=(8, 10)`` but we only wrote data to the first 6 rows of the +# array. As expected, we therefore, see our ``fillvalue`` of ``nan`` in the last two rows of the data. +# diff --git a/docs/gallery/general/read_basics.py b/docs/gallery/general/read_basics.py index 30dd93285..13fa3e9b7 100644 --- a/docs/gallery/general/read_basics.py +++ b/docs/gallery/general/read_basics.py @@ -331,7 +331,7 @@ # object and accessing its attributes, but it may be useful to explore the data in a # more interactive, visual way. # -# You can use `NWBWidgets `_, +# You can use `NWBWidgets `_, # a package containing interactive widgets for visualizing NWB data, # or you can use the `HDFView `_ # tool, which can open any generic HDF5 file, which an NWB file is. diff --git a/docs/make.bat b/docs/make.bat index 1e2a19ff4..dcafe003d 100644 --- a/docs/make.bat +++ b/docs/make.bat @@ -10,6 +10,7 @@ if "%SPHINXAPIDOC%" == "" ( ) set BUILDDIR=_build set RSTDIR=source +set GALLERYDIR=gallery set SRCDIR=../src set PKGNAME=pynwb set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% %RSTDIR% @@ -51,6 +52,8 @@ if "%1" == "clean" ( del /q /s %BUILDDIR%\* del /q %RSTDIR%\%PKGNAME%*.rst rmdir /q /s %RSTDIR%\tutorials + del /q /s %GALLERYDIR%\advanced_io\*.npy + del /q /s %GALLERYDIR%\advanced_io\*.nwb goto end ) diff --git a/docs/source/conf.py b/docs/source/conf.py index ca7131e3c..8cd05198b 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -151,6 +151,7 @@ def __call__(self, filename): 'nwb_extension': ('https://github.com/nwb-extensions/%s', ''), 'pynwb': ('https://github.com/NeurodataWithoutBorders/pynwb/%s', ''), 'nwb_overview': ('https://nwb-overview.readthedocs.io/en/latest/%s', ''), + 'hdmf-docs': ('https://hdmf.readthedocs.io/en/stable/%s', ''), 'dandi': ('https://www.dandiarchive.org/%s', '')} # Add any paths that contain templates here, relative to this directory. diff --git a/docs/source/export.rst b/docs/source/export.rst index 255a76d76..1f0bd6762 100644 --- a/docs/source/export.rst +++ b/docs/source/export.rst @@ -7,14 +7,14 @@ You can use the export feature of PyNWB to create a modified version of an exist original file. To do so, first open the NWB file using :py:class:`~pynwb.NWBHDF5IO`. Then, read the NWB file into an -:py:class:`~pynwb.file.NWBFile` object, -modify the ``NWBFile`` object or its child objects, and export the modified ``NWBFile`` object to a new file path. -The modifications will appear in the exported file and not the original file. +:py:class:`~pynwb.file.NWBFile` object, modify the :py:class:`~pynwb.file.NWBFile` object or its child objects, and +export the modified :py:class:`~pynwb.file.NWBFile` object to a new file path. The modifications will appear in the +exported file and not the original file. These modifications can consist of removals of containers, additions of containers, and changes to container attributes. If container attributes are changed, then :py:meth:`NWBFile.set_modified() ` must be called -on the ``NWBFile`` before exporting. +on the :py:class:`~pynwb.file.NWBFile` before exporting. .. code-block:: python @@ -31,7 +31,7 @@ on the ``NWBFile`` before exporting. Modifications to :py:class:`h5py.Dataset ` objects act *directly* on the read file on disk. Changes are applied immediately and do not require exporting or writing the file. If you want to modify a dataset only in the new file, than you should replace the whole object with a new array holding the modified data. To - prevent unintentional changes to the source file, the source file should be opened with ``mode='r'``. + prevent unintentional changes to the source file, the source file should be opened with :py:code:`mode='r'`. .. note:: @@ -41,9 +41,10 @@ on the ``NWBFile`` before exporting. .. note:: - After exporting an ``NWBFile``, the object IDs of the ``NWBFile`` and its child containers will be identical to the - object IDs of the read ``NWBFile`` and its child containers. The object ID of a container uniquely identifies the - container within a file, but should *not* be used to distinguish between two different files. + After exporting an :py:class:`~pynwb.file.NWBFile`, the object IDs of the :py:class:`~pynwb.file.NWBFile` and its + child containers will be identical to the object IDs of the read :py:class:`~pynwb.file.NWBFile` and its child + containers. The object ID of a container uniquely identifies the container within a file, but should *not* be + used to distinguish between two different files. .. seealso:: @@ -65,9 +66,9 @@ See also this `h5copy tutorial ` on the ``NWBFile`` to generate -a new set of object IDs for the ``NWBFile`` and all of its children, recursively. Then export the ``NWBFile``. -The original NWB file is preserved. +:py:meth:`generate_new_id ` on the :py:class:`~pynwb.file.NWBFile` +to generate a new set of object IDs for the ``NWBFile`` and all of its children, recursively. Then export the +:py:class:`~pynwb.file.NWBFile`. The original NWB file is preserved. .. code-block:: python @@ -106,10 +107,10 @@ from the HDF5 Group. See also this `h5copy tutorial ` website, which provides an entry point for researchers and developers interested in using NWB. -`Neurodata Without Borders (NWB) `_ is a project to develop a +`Neurodata Without Borders (NWB) `_ is a project to develop a unified data format for cellular-based neurophysiology data, focused on the dynamics of groups of neurons measured under a large range of experimental conditions. diff --git a/docs/source/software_process.rst b/docs/source/software_process.rst index 6c2e34cb3..07e809c9f 100644 --- a/docs/source/software_process.rst +++ b/docs/source/software_process.rst @@ -30,7 +30,7 @@ codecov_, and the other badge shows the percentage coverage reported from codeco codecov_, which shows line by line which lines are covered by the tests. .. _coverage: https://coverage.readthedocs.io -.. _codecov: https://codecov.io/gh/NeurodataWithoutBorders/pynwb/tree/dev/src/pynwb +.. _codecov: https://app.codecov.io/gh/NeurodataWithoutBorders/pynwb/tree/dev/src/pynwb -------------------------- Requirement Specifications diff --git a/requirements.txt b/requirements.txt index f41b5eb86..c2fb8fa3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,4 +6,4 @@ numpy==1.21.5;python_version<'3.8' # note that numpy 1.22 dropped python 3.7 su pandas==1.5.0;python_version>='3.8' pandas==1.3.5;python_version<'3.8' # note that pandas 1.4 dropped python 3.7 support python-dateutil==2.8.2 -setuptools==65.4.1 +setuptools==65.5.1 diff --git a/src/pynwb/__init__.py b/src/pynwb/__init__.py index 77cb74b26..a5d3bdfc7 100644 --- a/src/pynwb/__init__.py +++ b/src/pynwb/__init__.py @@ -211,6 +211,7 @@ class NWBHDF5IO(_HDF5IO): def __init__(self, **kwargs): path, mode, manager, extensions, load_namespaces, file_obj, comm, driver =\ popargs('path', 'mode', 'manager', 'extensions', 'load_namespaces', 'file', 'comm', 'driver', kwargs) + # Define the BuildManager to use if load_namespaces: if manager is not None: warn("loading namespaces from file - ignoring 'manager'") @@ -237,8 +238,54 @@ def __init__(self, **kwargs): manager = get_manager(extensions=extensions) elif manager is None: manager = get_manager() + # Open the file super().__init__(path, manager=manager, mode=mode, file=file_obj, comm=comm, driver=driver) + @property + def nwb_version(self): + """ + Get the version of the NWB file opened via this NWBHDF5IO object. + + :returns: Tuple consisting of: 1) the original version string as stored in the file and + 2) a tuple with the parsed components of the version string, consisting of integers + and strings, e.g., (2, 5, 1, beta). (None, None) will be returned if the nwb_version + is missing, e.g., in the case when no data has been written to the file yet. + """ + # Get the version string for the NWB file + try: + nwb_version_string = self._file.attrs['nwb_version'] + # KeyError occurs when the file is empty (e.g., when creating a new file nothing has been written) + # or when the HDF5 file is not a valid NWB file + except KeyError: + return None, None + # Parse the version string + nwb_version_parts = nwb_version_string.replace("-", ".").replace("_", ".").split(".") + nwb_version = tuple([int(i) if i.isnumeric() else i + for i in nwb_version_parts]) + return nwb_version_string, nwb_version + + @docval(*get_docval(_HDF5IO.read), + {'name': 'skip_version_check', 'type': bool, 'doc': 'skip checking of NWB version', 'default': False}) + def read(self, **kwargs): + """ + Read the NWB file from the IO source. + + :raises TypeError: If the NWB file version is missing or not supported + + :return: NWBFile container + """ + # Check that the NWB file is supported + skip_verison_check = popargs('skip_version_check', kwargs) + if not skip_verison_check: + file_version_str, file_version = self.nwb_version + if file_version is None: + raise TypeError("Missing NWB version in file. The file is not a valid NWB file.") + if file_version[0] < 2: + raise TypeError("NWB version %s not supported. PyNWB supports NWB files version 2 and above." % + str(file_version_str)) + # read the file + return super().read(**kwargs) + @docval({'name': 'src_io', 'type': HDMFIO, 'doc': 'the HDMFIO object (such as NWBHDF5IO) that was used to read the data to export'}, {'name': 'nwbfile', 'type': 'NWBFile', @@ -247,7 +294,8 @@ def __init__(self, **kwargs): {'name': 'write_args', 'type': dict, 'doc': 'arguments to pass to :py:meth:`write_builder`', 'default': None}) def export(self, **kwargs): - """Export an NWB file to a new NWB file using the HDF5 backend. + """ + Export an NWB file to a new NWB file using the HDF5 backend. If ``nwbfile`` is provided, then the build manager of ``src_io`` is used to build the container, and the resulting builder will be exported to the new backend. So if ``nwbfile`` is provided, diff --git a/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index 803906f6d..b4f8838cb 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit 803906f6de91128364a02c79ce60dec3f8519b5b +Subproject commit b4f8838cbfbb7f8a117bd7e0aad19133d26868b4 diff --git a/tests/integration/hdf5/test_io.py b/tests/integration/hdf5/test_io.py index c49abb92a..8009faa74 100644 --- a/tests/integration/hdf5/test_io.py +++ b/tests/integration/hdf5/test_io.py @@ -418,9 +418,83 @@ def setUp(self): def tearDown(self): remove_test_file(self.path) + def test_nwb_version_property(self): + """Test reading of files with missing nwb_version""" + # check empty version before write + with NWBHDF5IO(self.path, 'w') as io: + self.assertTupleEqual(io.nwb_version, (None, None)) + # write the example file + with NWBHDF5IO(self.path, 'w') as io: + io.write(self.nwbfile) + # check behavior for various different version strings + for ver in [("2.0.5", (2, 0, 5)), + ("2.0.5-alpha", (2, 0, 5, "alpha")), + ("1.0.4_beta", (1, 0, 4, "beta")), + ("bad_version", ("bad", "version", ))]: + # Set version string + with File(self.path, mode='a') as io: + io.attrs['nwb_version'] = ver[0] + # Assert expected result for nwb_version tuple + with NWBHDF5IO(self.path, 'r') as io: + self.assertEqual(io.nwb_version[0], ver[0]) + self.assertTupleEqual(io.nwb_version[1], ver[1]) + # check empty version attribute + with File(self.path, mode='a') as io: + del io.attrs['nwb_version'] + with NWBHDF5IO(self.path, 'r') as io: + self.assertTupleEqual(io.nwb_version, (None, None)) + + def test_check_nwb_version_ok(self): + """Test that opening a current NWBFile passes the version check""" + with NWBHDF5IO(self.path, 'w') as io: + io.write(self.nwbfile) + with NWBHDF5IO(self.path, 'r') as io: + self.assertIsNotNone(io.nwb_version[0]) + self.assertIsNotNone(io.nwb_version[1]) + self.assertGreater(io.nwb_version[1][0], 1) + read_file = io.read() + self.assertContainerEqual(read_file, self.nwbfile) + + def test_check_nwb_version_missing_version(self): + """Test reading of files with missing nwb_version""" + # write the example file + with NWBHDF5IO(self.path, 'w') as io: + io.write(self.nwbfile) + # remove the version attribute + with File(self.path, mode='a') as io: + del io.attrs['nwb_version'] + # test that reading the file without a version strings fails + with self.assertRaisesWith( + TypeError, + "Missing NWB version in file. The file is not a valid NWB file."): + with NWBHDF5IO(self.path, 'r') as io: + _ = io.read() + # test that reading the file when skipping the version check works + with NWBHDF5IO(self.path, 'r') as io: + read_file = io.read(skip_version_check=True) + self.assertContainerEqual(read_file, self.nwbfile) + + def test_check_nwb_version_old_version(self): + """Test reading of files with version less than 2 """ + # write the example file + with NWBHDF5IO(self.path, 'w') as io: + io.write(self.nwbfile) + # remove the version attribute + with File(self.path, mode='a') as io: + io.attrs['nwb_version'] = "1.0.5" + # test that reading the file without a version strings fails + with self.assertRaisesWith( + TypeError, + "NWB version 1.0.5 not supported. PyNWB supports NWB files version 2 and above."): + with NWBHDF5IO(self.path, 'r') as io: + _ = io.read() + # test that reading the file when skipping the version check works + with NWBHDF5IO(self.path, 'r') as io: + read_file = io.read(skip_version_check=True) + self.assertContainerEqual(read_file, self.nwbfile) + def test_round_trip_with_path_string(self): """Opening a NWBHDF5IO with a path string should work correctly""" - path_str = self.path with NWBHDF5IO(path_str, 'w') as io: io.write(self.nwbfile) diff --git a/tests/unit/test_icephys_metadata_tables.py b/tests/unit/test_icephys_metadata_tables.py index 75d0d157a..a111aa6fd 100644 --- a/tests/unit/test_icephys_metadata_tables.py +++ b/tests/unit/test_icephys_metadata_tables.py @@ -579,7 +579,7 @@ def test_round_trip_container_no_data(self): with NWBHDF5IO(self.path, 'w') as io: io.write(curr) with NWBHDF5IO(self.path, 'r') as io: - incon = io.read() + incon = io.read(skip_version_check=True) self.assertListEqual(incon.categories, curr.categories) for n in curr.categories: # empty columns from file have dtype int64 or float64 but empty in-memory columns have dtype object From 28cb573fbcd7f24b7ab6356f30b24376fe3527aa Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 09:58:52 -0800 Subject: [PATCH 04/21] Update Legal.txt --- Legal.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Legal.txt b/Legal.txt index 766c0f322..08061bfbe 100644 --- a/Legal.txt +++ b/Legal.txt @@ -1,4 +1,4 @@ -“pynwb” Copyright (c) 2017-2022, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. +“pynwb” Copyright (c) 2017-2023, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. If you have questions about your rights to use or distribute this software, please contact Berkeley Lab's Innovation & Partnerships Office at IPO@lbl.gov. From 435534706d7ec00bb4dc3e2c9c6b5e4bf535c6e5 Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 09:59:18 -0800 Subject: [PATCH 05/21] Update README.rst --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index c4a5f8029..857f19891 100644 --- a/README.rst +++ b/README.rst @@ -112,7 +112,7 @@ Citing NWB LICENSE ======= -"pynwb" Copyright (c) 2017-2022, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. +"pynwb" Copyright (c) 2017-2023, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: (1) Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. @@ -128,7 +128,7 @@ You are under no obligation whatsoever to provide any bug fixes, patches, or upg COPYRIGHT ========= -"pynwb" Copyright (c) 2017-2022, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. +"pynwb" Copyright (c) 2017-2023, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. If you have questions about your rights to use or distribute this software, please contact Berkeley Lab's Innovation & Partnerships Office at IPO@lbl.gov. NOTICE. This Software was developed under funding from the U.S. Department of Energy and the U.S. Government consequently retains certain rights. As such, the U.S. Government has been granted for itself and others acting on its behalf a paid-up, nonexclusive, irrevocable, worldwide license in the Software to reproduce, distribute copies to the public, prepare derivative works, and perform publicly and display publicly, and to permit other to do so. From df8983b5686322ac8276710118529faa669accfd Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 09:59:39 -0800 Subject: [PATCH 06/21] Update license.txt --- license.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/license.txt b/license.txt index b0a6bf4e9..7e54d7dbd 100644 --- a/license.txt +++ b/license.txt @@ -1,4 +1,4 @@ -“pynwb” Copyright (c) 2017-2022, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. +“pynwb” Copyright (c) 2017-2023, The Regents of the University of California, through Lawrence Berkeley National Laboratory (subject to receipt of any required approvals from the U.S. Dept. of Energy). All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: From a3e10d5495ebf49ea493b192a0cd30dd0730adad Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 10:00:05 -0800 Subject: [PATCH 07/21] Update conf.py --- docs/source/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 8cd05198b..c1bc91d5d 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -169,7 +169,7 @@ def __call__(self, filename): # General information about the project. project = u'PyNWB' -copyright = u'2017-2022, Neurodata Without Borders' +copyright = u'2017-2023, Neurodata Without Borders' # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the From 0a0465ce9b40d4251a6073f08be3dbb3f25e2ddd Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 13:43:47 -0800 Subject: [PATCH 08/21] Update requirements-min.txt --- requirements-min.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-min.txt b/requirements-min.txt index 3f6151bc5..6c96654d2 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -1,6 +1,6 @@ # minimum versions of package dependencies for installing PyNWB h5py==2.10 # support for selection of datasets with list of indices added in 2.10 -hdmf==3.4.0 +hdmf==3.5.0 numpy==1.16 pandas==1.1.5 python-dateutil==2.7.3 From 6fb30a5cdacd516a8b96fb1f4beaf43c2b35675c Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 13:44:07 -0800 Subject: [PATCH 09/21] Update requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index c2fb8fa3b..b6ed8f3de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # pinned dependencies to reproduce an entire development environment to use PyNWB h5py==3.7.0 -hdmf==3.4.6 +hdmf==3.5.0 numpy==1.23.3;python_version>='3.8' numpy==1.21.5;python_version<'3.8' # note that numpy 1.22 dropped python 3.7 support pandas==1.5.0;python_version>='3.8' From 0b4e9594259b6cddb19aee44b49e953fd1703f51 Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 17 Jan 2023 18:35:16 -0800 Subject: [PATCH 10/21] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4829b47ad..d097a4a32 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ reqs = [ 'h5py>=2.10,<4', - 'hdmf>=3.4.2,<4', + 'hdmf>=3.5.0,<4', 'numpy>=1.16,<1.24', 'pandas>=1.1.5,<2', 'python-dateutil>=2.7.3,<3', From bbc5e0e339e7d01ec146355a51fb2d4c24b6e785 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 18 Jan 2023 12:03:12 -0800 Subject: [PATCH 11/21] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4c551d16..314692f89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PyNWB Changelog -## Upcoming +## PyNWB 2.3.0 (January 19, 2023) ### Enhancements and minor changes - Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) From d1f48bce51a7fe46e7c19c35ace876a1675df02c Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Tue, 24 Jan 2023 13:58:52 -0800 Subject: [PATCH 12/21] Add flag to get_nwb_version to include prerelease info (#1639) --- src/pynwb/io/utils.py | 17 +++++++++++++---- tests/integration/utils/test_io_utils.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/pynwb/io/utils.py b/src/pynwb/io/utils.py index 24dfb7933..bb1f4957f 100644 --- a/src/pynwb/io/utils.py +++ b/src/pynwb/io/utils.py @@ -4,14 +4,19 @@ from hdmf.build import Builder -def get_nwb_version(builder: Builder) -> Tuple[int, ...]: +def get_nwb_version(builder: Builder, include_prerelease=False) -> Tuple[int, ...]: """Get the version of the NWB file from the root of the given builder, as a tuple. If the "nwb_version" attribute on the root builder equals "2.5.1", then (2, 5, 1) is returned. - If the "nwb_version" attribute on the root builder equals "2.5.1-alpha", then (2, 5, 1) is returned. + If the "nwb_version" attribute on the root builder equals "2.5.1-alpha" and include_prerelease=False, + then (2, 5, 1) is returned. + If the "nwb_version" attribute on the root builder equals "2.5.1-alpha" and include_prerelease=True, + then (2, 5, 1, "alpha") is returned. :param builder: Any builder within an NWB file. :type builder: Builder + :param include_prerelease: Whether to include prerelease information in the returned tuple. + :type include_prerelease: bool :return: The version of the NWB file, as a tuple. :rtype: tuple :raises ValueError: if the 'nwb_version' attribute is missing from the root of the NWB file. @@ -23,5 +28,9 @@ def get_nwb_version(builder: Builder) -> Tuple[int, ...]: nwb_version = root_builder.attributes.get("nwb_version") if nwb_version is None: raise ValueError("'nwb_version' attribute is missing from the root of the NWB file.") - nwb_version = re.match(r"(\d+\.\d+\.\d+)", nwb_version)[0] # trim off any non-numeric symbols at end - return tuple([int(i) for i in nwb_version.split(".")]) + nwb_version_match = re.match(r"(\d+\.\d+\.\d+)", nwb_version)[0] # trim off any non-numeric symbols at end + version_list = [int(i) for i in nwb_version_match.split(".")] + if include_prerelease: + prerelease_info = nwb_version[nwb_version.index("-")+1:] + version_list.append(prerelease_info) + return tuple(version_list) diff --git a/tests/integration/utils/test_io_utils.py b/tests/integration/utils/test_io_utils.py index 5d9ed6bea..e712bb557 100644 --- a/tests/integration/utils/test_io_utils.py +++ b/tests/integration/utils/test_io_utils.py @@ -28,3 +28,21 @@ def test_get_nwb_version_missing(self): with pytest.raises(ValueError, match="'nwb_version' attribute is missing from the root of the NWB file."): get_nwb_version(builder1) + + def test_get_nwb_version_prerelease_false(self): + """Get the NWB version from a builder.""" + builder1 = GroupBuilder(name="root") + builder1.set_attribute(name="nwb_version", value="2.0.0-alpha") + assert get_nwb_version(builder1) == (2, 0, 0) + + def test_get_nwb_version_prerelease_true1(self): + """Get the NWB version from a builder.""" + builder1 = GroupBuilder(name="root") + builder1.set_attribute(name="nwb_version", value="2.0.0-alpha") + assert get_nwb_version(builder1, include_prerelease=True) == (2, 0, 0, "alpha") + + def test_get_nwb_version_prerelease_true2(self): + """Get the NWB version from a builder.""" + builder1 = GroupBuilder(name="root") + builder1.set_attribute(name="nwb_version", value="2.0.0-alpha.sha-test.5114f85") + assert get_nwb_version(builder1, include_prerelease=True) == (2, 0, 0, "alpha.sha-test.5114f85") From 5a384a5491bb7715cf7f56cd8cd25e99efe87cf2 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Mon, 6 Feb 2023 14:24:10 -0800 Subject: [PATCH 13/21] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f4a94f8b..6c48fcecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PyNWB Changelog -## PyNWB 2.3.0 (January 19, 2023) +## PyNWB 2.3.0 (March 8, 2023) ### Enhancements and minor changes - Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) From 1681c9ba656cac76b2ff5b5a54ffa44d5036e05b Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 8 Feb 2023 09:04:15 -0800 Subject: [PATCH 14/21] Update requirements-min.txt --- requirements-min.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements-min.txt b/requirements-min.txt index 6c96654d2..c45e99846 100644 --- a/requirements-min.txt +++ b/requirements-min.txt @@ -1,6 +1,6 @@ # minimum versions of package dependencies for installing PyNWB h5py==2.10 # support for selection of datasets with list of indices added in 2.10 -hdmf==3.5.0 +hdmf==3.5.1 numpy==1.16 pandas==1.1.5 python-dateutil==2.7.3 From 94cf1e3e62b7fa941c134522bfe1ba1ec4d4b7b8 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 8 Feb 2023 09:04:39 -0800 Subject: [PATCH 15/21] Update requirements.txt --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index b6ed8f3de..0bfbd920f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ # pinned dependencies to reproduce an entire development environment to use PyNWB h5py==3.7.0 -hdmf==3.5.0 +hdmf==3.5.1 numpy==1.23.3;python_version>='3.8' numpy==1.21.5;python_version<'3.8' # note that numpy 1.22 dropped python 3.7 support pandas==1.5.0;python_version>='3.8' From 174de14c91ac27e1c9242f8a4edc792eac5bc4c3 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 8 Feb 2023 09:05:04 -0800 Subject: [PATCH 16/21] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d097a4a32..9c311efcf 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ reqs = [ 'h5py>=2.10,<4', - 'hdmf>=3.5.0,<4', + 'hdmf>=3.5.1,<4', 'numpy>=1.16,<1.24', 'pandas>=1.1.5,<2', 'python-dateutil>=2.7.3,<3', From 0eda3163072a7542cf1801fd548b899cc8f4d387 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 8 Feb 2023 09:06:19 -0800 Subject: [PATCH 17/21] Update CHANGELOG.md --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c48fcecf..ea2390495 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PyNWB Changelog -## PyNWB 2.3.0 (March 8, 2023) +## PyNWB 2.3.0 (February 9, 2023) ### Enhancements and minor changes - Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) @@ -16,6 +16,7 @@ - Added ``NWBHDF5IO.nwb_version`` property to get the NWB version from an NWB HDF5 file @oruebel [#1612](https://github.com/NeurodataWithoutBorders/pynwb/pull/1612) - Updated ``NWBHDF5IO.read`` to check NWB version before read and raise more informative error if an unsupported version is found @oruebel [#1612](https://github.com/NeurodataWithoutBorders/pynwb/pull/1612) - Added the `driver` keyword argument to the `pynwb.validate` function as well as the corresponding namespace caching. @CodyCBakerPhD [#1588](https://github.com/NeurodataWithoutBorders/pynwb/pull/1588) +- Updated HDMF requirement to version 3.5.1. [#1611](https://github.com/NeurodataWithoutBorders/pynwb/pull/1611) ### Documentation and tutorial enhancements: - Adjusted [ecephys tutorial](https://pynwb.readthedocs.io/en/stable/tutorials/domain/ecephys.html) to create fake data with proper dimensions @bendichter [#1581](https://github.com/NeurodataWithoutBorders/pynwb/pull/1581) From d3e643515d583e68a5419286abf32c91270db7b1 Mon Sep 17 00:00:00 2001 From: mavaylon1 Date: Tue, 14 Feb 2023 09:35:05 -0800 Subject: [PATCH 18/21] sphinx conf --- docs/source/conf.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index c1bc91d5d..cc21ff44e 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -146,13 +146,13 @@ def __call__(self, filename): 'fsspec': ("https://filesystem-spec.readthedocs.io/en/latest/", None), } -extlinks = {'incf_lesson': ('https://training.incf.org/lesson/%s', ''), - 'incf_collection': ('https://training.incf.org/collection/%s', ''), - 'nwb_extension': ('https://github.com/nwb-extensions/%s', ''), - 'pynwb': ('https://github.com/NeurodataWithoutBorders/pynwb/%s', ''), - 'nwb_overview': ('https://nwb-overview.readthedocs.io/en/latest/%s', ''), - 'hdmf-docs': ('https://hdmf.readthedocs.io/en/stable/%s', ''), - 'dandi': ('https://www.dandiarchive.org/%s', '')} +extlinks = {'incf_lesson': ('https://training.incf.org/lesson/%s', '%s'), + 'incf_collection': ('https://training.incf.org/collection/%s', '%s'), + 'nwb_extension': ('https://github.com/nwb-extensions/%s', '%s'), + 'pynwb': ('https://github.com/NeurodataWithoutBorders/pynwb/%s', '%s'), + 'nwb_overview': ('https://nwb-overview.readthedocs.io/en/latest/%s', '%s'), + 'hdmf-docs': ('https://hdmf.readthedocs.io/en/stable/%s', '%s'), + 'dandi': ('https://www.dandiarchive.org/%s', '%s')} # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] From 4540f9724f750467e07672cedec792e878d9def2 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Tue, 14 Feb 2023 10:35:12 -0800 Subject: [PATCH 19/21] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea2390495..a1fdd7a4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PyNWB Changelog -## PyNWB 2.3.0 (February 9, 2023) +## PyNWB 2.3.0 (February 15, 2023) ### Enhancements and minor changes - Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) From 60ad87e8b1b463e4721688214e00bced25be9989 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 22 Feb 2023 15:11:41 -0800 Subject: [PATCH 20/21] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5bf2bb8e..0420efebf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PyNWB Changelog -## PyNWB 2.3.0 (February 15, 2023) +## PyNWB 2.3.0 (February 22, 2023) ### Enhancements and minor changes - Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) From 5734d0f2a18bbe6a9bd45c312ec14f1ca28d6d79 Mon Sep 17 00:00:00 2001 From: Ryan Ly Date: Wed, 22 Feb 2023 15:12:24 -0800 Subject: [PATCH 21/21] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0420efebf..fa8f91f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # PyNWB Changelog -## PyNWB 2.3.0 (February 22, 2023) +## PyNWB 2.3.0 (February 23, 2023) ### Enhancements and minor changes - Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636)