From 7ceaf4a3a363dcaf3c9d7a66f7bdcc30e487bb5f Mon Sep 17 00:00:00 2001 From: bendichter Date: Thu, 10 Nov 2022 10:18:10 -0500 Subject: [PATCH 1/5] Add feature to allow subject.age to be input as a timedelta Also added test for this --- src/pynwb/file.py | 33 +++++++++++++++++++++++++++++++-- tests/unit/test_file.py | 10 +++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/pynwb/file.py b/src/pynwb/file.py index c4f801efe..d16f1b43b 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -1,6 +1,7 @@ -from datetime import datetime +from datetime import datetime, timedelta from dateutil.tz import tzlocal from collections.abc import Iterable +from decimal import Decimal from warnings import warn import copy as _copy @@ -64,7 +65,7 @@ class Subject(NWBContainer): 'strain' ) - @docval({'name': 'age', 'type': str, + @docval({'name': 'age', 'type': (str, timedelta), 'doc': ('The age of the subject. The ISO 8601 Duration format is recommended, e.g., "P90D" for ' '90 days old.'), 'default': None}, {'name': 'description', 'type': str, @@ -105,6 +106,9 @@ def __init__(self, **kwargs): if isinstance(weight, float): args_to_set['weight'] = str(weight) + ' kg' + if isinstance(args_to_set["age"], timedelta): + args_to_set["age"] = self.timedelta_to_iso_str(args_to_set["age"]) + date_of_birth = args_to_set['date_of_birth'] if date_of_birth and date_of_birth.tzinfo is None: args_to_set['date_of_birth'] = _add_missing_timezone(date_of_birth) @@ -112,6 +116,31 @@ def __init__(self, **kwargs): for key, val in args_to_set.items(): setattr(self, key, val) + @staticmethod + def timedelta_to_iso_str(td: timedelta): + """ + Converts a duration in seconds to an ISO 8601 datestring in the + format 'PYMDTHMS' + + taken from https://github.com/gweis/isodate/issues/42#issuecomment-341664535 + """ + + s = Decimal(td.total_seconds()) + years, less_then_y = divmod(s, 3600*24*365) + days, less_then_d = divmod(less_then_y, 3600*24) + hours, less_then_h = divmod(less_then_d, 3600) + minutes, seconds = divmod(less_then_h, 60) + + y = str(years) + "Y" if years else "" + d = str(days) + "D" if days else "" + h = str(hours) + "H" if hours else "" + m = str(minutes) + "M" if minutes else "" + s = str(seconds) + "S" if seconds else "" + t = "T" if (h or m or s) else "" + + iso_string = "P{y}{d}{t}{h}{m}{s}".format(y=y, d=d, t=t, h=h, m=m, s=s) + return iso_string + @register_class('NWBFile', CORE_NAMESPACE) class NWBFile(MultiContainerInterface): diff --git a/tests/unit/test_file.py b/tests/unit/test_file.py index 6775180eb..dd1508c8b 100644 --- a/tests/unit/test_file.py +++ b/tests/unit/test_file.py @@ -1,7 +1,7 @@ import numpy as np import pandas as pd -from datetime import datetime +from datetime import datetime, timedelta from dateutil.tz import tzlocal, tzutc from pynwb import NWBFile, TimeSeries, NWBHDF5IO @@ -479,6 +479,14 @@ def test_weight_float(self): ) self.assertEqual(subject.weight, '2.3 kg') + def test_subject_age_duration(self): + subject = Subject( + subject_id='RAT123', + age=timedelta(seconds=99999) + ) + + self.assertEqual(subject.age, "P1DT3H46M39S") + class TestCacheSpec(TestCase): From 5f71b8d571865c358aa94c249c140a0c21364f54 Mon Sep 17 00:00:00 2001 From: bendichter Date: Thu, 10 Nov 2022 10:19:26 -0500 Subject: [PATCH 2/5] add CHANGELOG.md --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5172c40d8..4f3a55ec7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Upcoming +### Enhancements and minor changes +- `Subject.age` can be input as a `timedelta` + ### 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 From d5f157e1c751fde57c91900efe54df4f111a0c00 Mon Sep 17 00:00:00 2001 From: Ben Dichter Date: Thu, 10 Nov 2022 10:21:16 -0500 Subject: [PATCH 3/5] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f3a55ec7..4ba35fa21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,7 @@ ## Upcoming ### Enhancements and minor changes -- `Subject.age` can be input as a `timedelta` +- `Subject.age` can be input as a `timedelta`. @bendichter [#1590](https://github.com/NeurodataWithoutBorders/pynwb/pull/1590) ### 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 dddb6d08a10f1dd0f455d77936f8744d4d3c7755 Mon Sep 17 00:00:00 2001 From: bendichter Date: Thu, 10 Nov 2022 10:24:57 -0500 Subject: [PATCH 4/5] adjust docval --- src/pynwb/file.py | 52 ++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/pynwb/file.py b/src/pynwb/file.py index d16f1b43b..acc404e5f 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -65,29 +65,35 @@ class Subject(NWBContainer): 'strain' ) - @docval({'name': 'age', 'type': (str, timedelta), - 'doc': ('The age of the subject. The ISO 8601 Duration format is recommended, e.g., "P90D" for ' - '90 days old.'), 'default': None}, - {'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}, - {'name': 'sex', 'type': str, - 'doc': ('The sex of the subject. Using "F" (female), "M" (male), "U" (unknown), or "O" (other) ' - 'is recommended.'), 'default': None}, - {'name': 'species', 'type': str, - 'doc': ('The species of the subject. The formal latin binomal name is recommended, e.g., "Mus musculus"'), - 'default': None}, - {'name': 'subject_id', 'type': str, 'doc': 'A unique identifier for the subject, e.g., "A10"', - 'default': None}, - {'name': 'weight', 'type': (float, str), - 'doc': ('The weight of the subject, including units. Using kilograms is recommended. e.g., "0.02 kg". ' - 'If a float is provided, then the weight will be stored as "[value] kg".'), - 'default': None}, - {'name': 'date_of_birth', 'type': datetime, 'default': None, - 'doc': 'The datetime of the date of birth. May be supplied instead of age.'}, - {'name': 'strain', 'type': str, 'doc': 'The strain of the subject, e.g., "C57BL/6J"', 'default': None}) + @docval( + { + "name": "age", + "type": (str, timedelta), + "doc": 'The age of the subject. The ISO 8601 Duration format is recommended, e.g., "P90D" for 90 days old.' + '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': 'genotype', 'type': str, + 'doc': 'The genotype of the subject, e.g., "Sst-IRES-Cre/wt;Ai32(RCL-ChR2(H134R)_EYFP)/wt".', + 'default': None}, + {'name': 'sex', 'type': str, + 'doc': ('The sex of the subject. Using "F" (female), "M" (male), "U" (unknown), or "O" (other) ' + 'is recommended.'), 'default': None}, + {'name': 'species', 'type': str, + 'doc': 'The species of the subject. The formal latin binomal name is recommended, e.g., "Mus musculus"', + 'default': None}, + {'name': 'subject_id', 'type': str, 'doc': 'A unique identifier for the subject, e.g., "A10"', + 'default': None}, + {'name': 'weight', 'type': (float, str), + 'doc': ('The weight of the subject, including units. Using kilograms is recommended. e.g., "0.02 kg". ' + 'If a float is provided, then the weight will be stored as "[value] kg".'), + 'default': None}, + {'name': 'date_of_birth', 'type': datetime, 'default': None, + 'doc': 'The datetime of the date of birth. May be supplied instead of age.'}, + {'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", From 142441cad18051f550c3223c126c0ae1ae75a314 Mon Sep 17 00:00:00 2001 From: bendichter Date: Thu, 10 Nov 2022 15:29:51 -0500 Subject: [PATCH 5/5] use pandas to do iso 8601 conversion --- src/pynwb/file.py | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/src/pynwb/file.py b/src/pynwb/file.py index 476f2fe6c..eb2d97e5d 100644 --- a/src/pynwb/file.py +++ b/src/pynwb/file.py @@ -1,7 +1,6 @@ from datetime import datetime, timedelta from dateutil.tz import tzlocal from collections.abc import Iterable -from decimal import Decimal from warnings import warn import copy as _copy @@ -113,7 +112,7 @@ def __init__(self, **kwargs): args_to_set['weight'] = str(weight) + ' kg' if isinstance(args_to_set["age"], timedelta): - args_to_set["age"] = self.timedelta_to_iso_str(args_to_set["age"]) + args_to_set["age"] = pd.Timedelta(args_to_set["age"]).isoformat() date_of_birth = args_to_set['date_of_birth'] if date_of_birth and date_of_birth.tzinfo is None: @@ -122,31 +121,6 @@ def __init__(self, **kwargs): for key, val in args_to_set.items(): setattr(self, key, val) - @staticmethod - def timedelta_to_iso_str(td: timedelta): - """ - Converts a duration in seconds to an ISO 8601 datestring in the - format 'PYMDTHMS' - - taken from https://github.com/gweis/isodate/issues/42#issuecomment-341664535 - """ - - s = Decimal(td.total_seconds()) - years, less_then_y = divmod(s, 3600*24*365) - days, less_then_d = divmod(less_then_y, 3600*24) - hours, less_then_h = divmod(less_then_d, 3600) - minutes, seconds = divmod(less_then_h, 60) - - y = str(years) + "Y" if years else "" - d = str(days) + "D" if days else "" - h = str(hours) + "H" if hours else "" - m = str(minutes) + "M" if minutes else "" - s = str(seconds) + "S" if seconds else "" - t = "T" if (h or m or s) else "" - - iso_string = "P{y}{d}{t}{h}{m}{s}".format(y=y, d=d, t=t, h=h, m=m, s=s) - return iso_string - @register_class('NWBFile', CORE_NAMESPACE) class NWBFile(MultiContainerInterface):