Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update dandi.inspector_config.yaml #247

Merged
merged 24 commits into from
Aug 24, 2022
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
89f89bd
Update dandi.inspector_config.yaml
bendichter Aug 3, 2022
1ce696b
Merge branch 'dev' into dandi-promotion-1
CodyCBakerPhD Aug 16, 2022
4637113
enchanged
CodyCBakerPhD Aug 16, 2022
161dc3e
fix tests
CodyCBakerPhD Aug 16, 2022
203c1d7
Merge branch 'dandi-promotion-1' into enhance_age_check
CodyCBakerPhD Aug 16, 2022
89ac295
remove unused lines
CodyCBakerPhD Aug 16, 2022
328bcce
separate logic; cleanup
CodyCBakerPhD Aug 16, 2022
5b27745
fix tests
CodyCBakerPhD Aug 16, 2022
0b28261
Update nwbinspector/checks/nwbfile_metadata.py
CodyCBakerPhD Aug 17, 2022
b39da03
Update nwbinspector/checks/nwbfile_metadata.py
CodyCBakerPhD Aug 17, 2022
bf359f0
Update tests/unit_tests/test_nwbfile_metadata.py
CodyCBakerPhD Aug 17, 2022
4d6dad6
Update tests/unit_tests/test_nwbfile_metadata.py
CodyCBakerPhD Aug 17, 2022
09309af
update tests
CodyCBakerPhD Aug 17, 2022
cd586c6
Update tests/unit_tests/test_nwbfile_metadata.py
CodyCBakerPhD Aug 17, 2022
d471d15
Update tests/unit_tests/test_nwbfile_metadata.py
CodyCBakerPhD Aug 17, 2022
6e2b3ac
remove unused fields
CodyCBakerPhD Aug 17, 2022
9e8df7c
Merge pull request #251 from NeurodataWithoutBorders/enhance_age_check
CodyCBakerPhD Aug 17, 2022
afa05c5
propose changes to best practices (#252)
bendichter Aug 17, 2022
6ffc4e5
Merge branch 'dev' into dandi-promotion-1
CodyCBakerPhD Aug 18, 2022
82546b1
Include check_subject_proper_age_range to DANDI config (#255)
CodyCBakerPhD Aug 18, 2022
8c53c0b
Merge branch 'dev' into dandi-promotion-1
CodyCBakerPhD Aug 19, 2022
8276be6
Merge branch 'dev' into dandi-promotion-1
CodyCBakerPhD Aug 24, 2022
44e052a
Update CHANGELOG.md
CodyCBakerPhD Aug 24, 2022
2faa201
Merge branch 'dev' into dandi-promotion-1
CodyCBakerPhD Aug 24, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Upcoming

### DANDI Configuration
* `check_subject_sex`, `check_subject_species`, `check_subject_age`, `check_subject_proper_age_range` are now elevated to `CRITICAL` importance when using the "DANDI" configuration. Therefore, these are now required for passing `dandi validate`.

### Improvements
* Enhanced human-readability of the return message from `check_experimenter_form`. [PR #254](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/254)
* Add check for existence of ``IntracellularElectrode.cell_id`` [PR #256](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/256)
* Extended check for ``Subject.age`` field with estimated age range using '/' separator [PR #247](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/247)

### New Checks
* Added check for existence of ``IntracellularElectrode.cell_id`` [PR #256](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/256)
* Added check that bounds of age range for ``Subject.age`` using the '/' separator are properly increasing. [PR #247](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/247)
6 changes: 5 additions & 1 deletion docs/best_practices/nwbfile_metadata.rst
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,11 @@ Age

The ``age`` of a :nwb-schema:ref:`sec-Subject` should use the :wikipedia:`ISO 8601 Duration <ISO_8601#Durations>`
format. For instance indicating an age of 90 days would be 'P90D'. It is not necessary to include both ``age`` and
``date_of_birth``, but at least one of them is recommended.
``date_of_birth``, but at least one of them is required by the DANDI Archive and recommended in general.

If the precise age is unknown, an age range can be given by "[lower bound]/[upper bound]" e.g. "P10D/P20D" would mean
that the age is in between 10 and 20 days. If only the lower bound is known, then including only the slash after that lower bound can be used to indicate a
missing bound. For instance, "P90Y/" would indicate that the age is 90 years or older.

Check function: :py:meth:`~nwbinspector.checks.nwbfile_metadata.check_subject_age`

Expand Down
48 changes: 41 additions & 7 deletions nwbinspector/checks/nwbfile_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import re
from datetime import datetime

from pandas import Timedelta
from pynwb import NWBFile, ProcessingModule
from pynwb.file import Subject

Expand Down Expand Up @@ -114,17 +115,50 @@ def check_doi_publications(nwbfile: NWBFile):

@register_check(importance=Importance.BEST_PRACTICE_SUGGESTION, neurodata_type=Subject)
def check_subject_age(subject: Subject):
"""Check if the Subject age is in ISO 8601."""
"""Check if the Subject age is in ISO 8601 or our extension of it for ranges."""
if subject.age is None:
if subject.date_of_birth is None:
return InspectorMessage(message="Subject is missing age and date_of_birth.")
elif not re.fullmatch(duration_regex, subject.age):
return InspectorMessage(
message=(
f"Subject age, '{subject.age}', does not follow ISO 8601 duration format, e.g. 'P2Y' for 2 years "
"or 'P23W' for 23 weeks."
)
else:
return
if re.fullmatch(pattern=duration_regex, string=subject.age):
return

if "/" in subject.age:
subject_lower_age_bound, subject_upper_age_bound = subject.age.split("/")

if re.fullmatch(pattern=duration_regex, string=subject_lower_age_bound) and (
re.fullmatch(pattern=duration_regex, string=subject_upper_age_bound) or subject_upper_age_bound == ""
):
return

return InspectorMessage(
message=(
f"Subject age, '{subject.age}', does not follow ISO 8601 duration format, e.g. 'P2Y' for 2 years "
"or 'P23W' for 23 weeks. You may also specify a range using a '/' separator, e.g., 'P1D/P3D' for an "
"age range somewhere from 1 to 3 days. If you cannot specify the upper bound of the range, "
"you may leave the right side blank, e.g., 'P90Y/' means 90 years old or older."
)
)


@register_check(importance=Importance.BEST_PRACTICE_SUGGESTION, neurodata_type=Subject)
def check_subject_proper_age_range(subject: Subject):
"""Check if the Subject age, if specified as duration range (e.g., 'P1D/P3D'), has properly increasing bounds."""
if subject.age is not None and "/" in subject.age:
subject_lower_age_bound, subject_upper_age_bound = subject.age.split("/")

if (
re.fullmatch(pattern=duration_regex, string=subject_lower_age_bound)
and re.fullmatch(pattern=duration_regex, string=subject_upper_age_bound)
and Timedelta(subject_lower_age_bound) >= Timedelta(subject_upper_age_bound)
):
return InspectorMessage(
message=(
f"The durations of the Subject age range, '{subject.age}', are not strictly increasing. "
"The upper (right) bound should be a longer duration than the lower (left) bound."
)
)


@register_check(importance=Importance.BEST_PRACTICE_SUGGESTION, neurodata_type=Subject)
Expand Down
7 changes: 4 additions & 3 deletions nwbinspector/internal_configs/dandi.inspector_config.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
CRITICAL: # All the fields under CRITICAL will be required for dandi validate to pass
- check_subject_exists
- check_subject_id_exists
- check_subject_sex
- check_subject_species
CodyCBakerPhD marked this conversation as resolved.
Show resolved Hide resolved
- check_subject_age
- check_subject_proper_age_range
BEST_PRACTICE_VIOLATION:
- check_subject_sex # these are planned to be elevated to CRITICAL when required for DANDI validate
- check_subject_species # these are planned to be elevated to CRITICAL when required for DANDI validate
- check_subject_age # these are planned to be elevated to CRITICAL when required for DANDI validate
- check_data_orientation # not 100% accurate, so need to deelevate from CRITICAL to skip it in dandi validate
8 changes: 6 additions & 2 deletions tests/test_check_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@ def test_load_config(self):
self.assertDictEqual(
d1=config,
d2=dict(
CRITICAL=["check_subject_exists", "check_subject_id_exists"],
BEST_PRACTICE_VIOLATION=[
CRITICAL=[
"check_subject_exists",
"check_subject_id_exists",
"check_subject_sex",
"check_subject_species",
"check_subject_age",
"check_subject_proper_age_range",
],
BEST_PRACTICE_VIOLATION=[
"check_data_orientation",
],
),
Expand Down
94 changes: 83 additions & 11 deletions tests/unit_tests/test_nwbfile_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
check_subject_id_exists,
check_subject_sex,
check_subject_age,
check_subject_proper_age_range,
check_subject_species_exists,
check_subject_species_latin_binomial,
check_processing_module_name,
Expand Down Expand Up @@ -278,7 +279,7 @@ def test_check_subject_sex():


def test_check_subject_sex_other_value():
subject = Subject(subject_id="001", sex="Male")
subject = Subject(subject_id="001", sex="Female")

assert check_subject_sex(subject) == InspectorMessage(
message="Subject.sex should be one of: 'M' (male), 'F' (female), 'O' (other), or 'U' (unknown).",
Expand All @@ -290,8 +291,13 @@ def test_check_subject_sex_other_value():
)


def test_pass_check_subject_age_with_dob():
subject = Subject(subject_id="001", sex="F", date_of_birth=datetime.now())
assert check_subject_age(subject) is None


def test_check_subject_age_missing():
subject = Subject(subject_id="001", sex="Male")
subject = Subject(subject_id="001")
assert check_subject_age(subject) == InspectorMessage(
message="Subject is missing age and date_of_birth.",
importance=Importance.BEST_PRACTICE_SUGGESTION,
Expand All @@ -302,12 +308,19 @@ def test_check_subject_age_missing():
)


def test_check_subject_age_iso8601():
subject = Subject(subject_id="001", sex="Male", age="9 months")
def test_check_subject_age_iso8601_pass():
subject = Subject(subject_id="001", age="P1D")
assert check_subject_age(subject) is None


def test_check_subject_age_iso8601_fail():
subject = Subject(subject_id="001", age="9 months")
assert check_subject_age(subject) == InspectorMessage(
message=(
"Subject age, '9 months', does not follow ISO 8601 duration format, e.g. 'P2Y' for 2 years or 'P23W' "
"for 23 weeks."
"Subject age, '9 months', does not follow ISO 8601 duration format, e.g. 'P2Y' for 2 years "
"or 'P23W' for 23 weeks. You may also specify a range using a '/' separator, e.g., 'P1D/P3D' for an "
"age range somewhere from 1 to 3 days. If you cannot specify the upper bound of the range, "
"you may leave the right side blank, e.g., 'P90Y/' means 90 years old or older."
),
importance=Importance.BEST_PRACTICE_SUGGESTION,
check_function_name="check_subject_age",
Expand All @@ -317,11 +330,70 @@ def test_check_subject_age_iso8601():
)


def test_pass_check_subject_age_with_dob():
subject = Subject(subject_id="001", sex="Male", date_of_birth=datetime.now())
def test_check_subject_age_iso8601_range_pass_1():
subject = Subject(subject_id="001", age="P1D/P3D")
assert check_subject_age(subject) is None


def test_check_subject_age_iso8601_range_pass_2():
subject = Subject(subject_id="001", age="P1D/")
assert check_subject_age(subject) is None


def test_check_subject_age_iso8601_range_fail_1():
subject = Subject(subject_id="001", age="9 months/12 months")
assert check_subject_age(subject) == InspectorMessage(
message=(
"Subject age, '9 months/12 months', does not follow ISO 8601 duration format, e.g. 'P2Y' for 2 years "
"or 'P23W' for 23 weeks. You may also specify a range using a '/' separator, e.g., 'P1D/P3D' for an "
"age range somewhere from 1 to 3 days. If you cannot specify the upper bound of the range, "
"you may leave the right side blank, e.g., 'P90Y/' means 90 years old or older."
),
importance=Importance.BEST_PRACTICE_SUGGESTION,
check_function_name="check_subject_age",
object_type="Subject",
object_name="subject",
location="/general/subject",
)


def test_check_subject_age_iso8601_range_fail_2():
subject = Subject(subject_id="001", age="9 months/")
assert check_subject_age(subject) == InspectorMessage(
message=(
"Subject age, '9 months/', does not follow ISO 8601 duration format, e.g. 'P2Y' for 2 years "
"or 'P23W' for 23 weeks. You may also specify a range using a '/' separator, e.g., 'P1D/P3D' for an "
"age range somewhere from 1 to 3 days. If you cannot specify the upper bound of the range, "
"you may leave the right side blank, e.g., 'P90Y/' means 90 years old or older."
),
importance=Importance.BEST_PRACTICE_SUGGESTION,
check_function_name="check_subject_age",
object_type="Subject",
object_name="subject",
location="/general/subject",
)


def test_check_subject_proper_age_range_pass():
subject = Subject(subject_id="001", age="P1D/P3D")
assert check_subject_proper_age_range(subject) is None


def test_check_subject_proper_age_range_fail():
subject = Subject(subject_id="001", age="P3D/P1D")
assert check_subject_proper_age_range(subject) == InspectorMessage(
message=(
"The durations of the Subject age range, 'P3D/P1D', are not strictly increasing. "
"The upper (right) bound should be a longer duration than the lower (left) bound."
),
importance=Importance.BEST_PRACTICE_SUGGESTION,
check_function_name="check_subject_proper_age_range",
object_type="Subject",
object_name="subject",
location="/general/subject",
)


def test_pass_check_subject_species_exists():
subject = Subject(subject_id="001", species="Homo sapiens")
assert check_subject_species_exists(subject) is None
Expand Down Expand Up @@ -358,7 +430,7 @@ def test_check_subject_species_not_binomial():


def test_pass_check_subject_age():
subject = Subject(subject_id="001", sex="Male", age="P9M")
subject = Subject(subject_id="001", age="P9M")
assert check_subject_age(subject) is None


Expand All @@ -375,12 +447,12 @@ def test_check_subject_exists():

def test_pass_check_subject_exists():
nwbfile = NWBFile(session_description="", identifier=str(uuid4()), session_start_time=datetime.now().astimezone())
nwbfile.subject = Subject(subject_id="001", sex="Male")
nwbfile.subject = Subject(subject_id="001")
assert check_subject_exists(nwbfile) is None


def test_check_subject_id_exists():
subject = Subject(sex="Male")
subject = Subject(sex="F")
assert check_subject_id_exists(subject) == InspectorMessage(
message="subject_id is missing.",
importance=Importance.BEST_PRACTICE_SUGGESTION,
Expand Down