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 8af0be087..fa8f91f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,12 @@ # PyNWB Changelog -## Upcoming +## PyNWB 2.3.0 (February 23, 2023) ### Enhancements and minor changes -- `Subject.age` can be input as a `timedelta`. @bendichter [#1590](https://github.com/NeurodataWithoutBorders/pynwb/pull/1590) +- Added support for NWB Schema 2.6.0. @mavaylon1 [#1636](https://github.com/NeurodataWithoutBorders/pynwb/pull/1636) +- 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) +- Added `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) - Added module `pynwb.testing.mock.icephys` and corresponding tests. @bendichter @@ -13,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) - Increased the stacklevel of the warning from `_add_missing_timezone` in `pynwb.file` to make identification of which datetime field is missing a timezone easier. @CodyCBakerPhD [#1641](https://github.com/NeurodataWithoutBorders/pynwb/pull/1641) ### Documentation and tutorial enhancements: 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. 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. diff --git a/docs/source/conf.py b/docs/source/conf.py index 8cd05198b..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'] @@ -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 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: diff --git a/requirements-min.txt b/requirements-min.txt index 3f6151bc5..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.4.0 +hdmf==3.5.1 numpy==1.16 pandas==1.1.5 python-dateutil==2.7.3 diff --git a/requirements.txt b/requirements.txt index c2fb8fa3b..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.4.6 +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' diff --git a/setup.py b/setup.py index 4829b47ad..9c311efcf 100755 --- a/setup.py +++ b/setup.py @@ -20,7 +20,7 @@ reqs = [ 'h5py>=2.10,<4', - 'hdmf>=3.4.2,<4', + 'hdmf>=3.5.1,<4', 'numpy>=1.16,<1.24', 'pandas>=1.1.5,<2', 'python-dateutil>=2.7.3,<3', diff --git a/src/pynwb/file.py b/src/pynwb/file.py index 05079ae87..31b2d8e1e 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -55,6 +55,7 @@ class Subject(NWBContainer): __nwbfields__ = ( 'age', + "age__reference", 'description', 'genotype', 'sex', @@ -73,8 +74,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}, @@ -95,18 +108,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..bb1f4957f --- /dev/null +++ b/src/pynwb/io/utils.py @@ -0,0 +1,36 @@ +import re +from typing import Tuple + +from hdmf.build import Builder + + +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" 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. + """ + 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_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/src/pynwb/nwb-schema b/src/pynwb/nwb-schema index 761a0d783..b4f8838cb 160000 --- a/src/pynwb/nwb-schema +++ b/src/pynwb/nwb-schema @@ -1 +1 @@ -Subproject commit 761a0d7838304864643f8bc3ab88c93bfd437f2a +Subproject commit b4f8838cbfbb7f8a117bd7e0aad19133d26868b4 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/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/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/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 000000000..076db9487 Binary files /dev/null and b/tests/back_compat/2.2.0_subject_no_age__reference.nwb differ 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 b93a697c5..46be0a55a 100644 --- a/tests/integration/hdf5/test_nwbfile.py +++ b/tests/integration/hdf5/test_nwbfile.py @@ -204,15 +204,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/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/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..e712bb557 --- /dev/null +++ b/tests/integration/utils/test_io_utils.py @@ -0,0 +1,48 @@ +"""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) + + 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") diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index bc00fe8dd..bb5c9c1e1 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -444,29 +444,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') @@ -486,6 +492,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', 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):