diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f45ea8b..c25dc0bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### New Checks +* Added `check_spatial_series_unit` to validate that SpatialSeries objects (outside of CompassDirection) have a recognized unit string from a curated allowlist of SI length units, angular units, and pixels. [#685](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/685) * Added `check_imaging_plane_location_allen_ccf`, `check_electrodes_location_allen_ccf`, and `check_intracellular_electrode_location_allen_ccf` to validate location fields against Allen Mouse Brain CCF ontology terms when subject species is mouse. [#671](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/671) * Added `check_time_intervals_start_time_not_constant` to flag TimeIntervals tables where all start_time values are identical, indicating times were likely not set relative to session start. [#677](https://github.com/NeurodataWithoutBorders/nwbinspector/issues/677) * Added `check_units_resolution_is_set` to flag when the Units table has spike_times but resolution is not set to a meaningful positive float. [#686](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/686) diff --git a/docs/best_practices/behavior.rst b/docs/best_practices/behavior.rst index df68e696..b422d547 100644 --- a/docs/best_practices/behavior.rst +++ b/docs/best_practices/behavior.rst @@ -28,6 +28,19 @@ When a :ref:`nwb-schema:sec-SpatialSeries` is in a :ref:`nwb-schema:sec-CompassD Check function: :py:meth:`~nwbinspector.checks._behavior.check_compass_direction_unit` +.. _best_practice_spatial_series_general_units: + +SpatialSeries General Units +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A :ref:`nwb-schema:sec-SpatialSeries` that is not inside a :ref:`nwb-schema:sec-CompassDirection` should have a unit from +the following recognized set: "meters", "centimeters", "millimeters", "micrometers", "degrees", "radians", "pixels", +or "n.a." (not available) as a placeholder when the unit is not known. +These follow the PyNWB convention of full SI words in plural form. + +Check function: :py:meth:`~nwbinspector.checks._behavior.check_spatial_series_unit` + + .. _best_practice_spatial_series_values: SpatialSeries Data Values diff --git a/src/nwbinspector/checks/__init__.py b/src/nwbinspector/checks/__init__.py index 97eee2f2..c01ad2ab 100644 --- a/src/nwbinspector/checks/__init__.py +++ b/src/nwbinspector/checks/__init__.py @@ -3,6 +3,7 @@ check_spatial_series_degrees_magnitude, check_spatial_series_dims, check_spatial_series_radians_magnitude, + check_spatial_series_unit, ) from ._ecephys import ( check_ascending_spike_times, @@ -183,6 +184,7 @@ "check_spatial_series_radians_magnitude", "check_spatial_series_dims", "check_spatial_series_degrees_magnitude", + "check_spatial_series_unit", "check_ascending_spike_times", "check_electrical_series_unscaled_data", "check_units_resolution_is_valid", diff --git a/src/nwbinspector/checks/_behavior.py b/src/nwbinspector/checks/_behavior.py index bca5af86..cb8ee3a2 100644 --- a/src/nwbinspector/checks/_behavior.py +++ b/src/nwbinspector/checks/_behavior.py @@ -76,3 +76,37 @@ def check_spatial_series_degrees_magnitude( ) return None + + +VALID_SPATIAL_SERIES_UNITS = { + "meters", + "centimeters", + "millimeters", + "micrometers", + "degrees", + "radians", + "pixels", + "n.a.", +} + + +@register_check(importance=Importance.BEST_PRACTICE_VIOLATION, neurodata_type=SpatialSeries) +def check_spatial_series_unit(spatial_series: SpatialSeries) -> Optional[InspectorMessage]: + """ + Check that SpatialSeries.unit is a recognized spatial unit. + + Best Practice: :ref:`best_practice_spatial_series_general_units` + """ + if spatial_series.get_ancestor("CompassDirection") is not None: + return None + + if spatial_series.unit not in VALID_SPATIAL_SERIES_UNITS: + return InspectorMessage( + message=( + f"SpatialSeries unit '{spatial_series.unit}' is not a valid spatial unit. " + f"Valid units are: {', '.join(sorted(VALID_SPATIAL_SERIES_UNITS))}. " + "If the unit is not known, use 'n.a.' (not available) as a placeholder." + ) + ) + + return None diff --git a/tests/unit_tests/test_behavior.py b/tests/unit_tests/test_behavior.py index ee40e619..dd2154b2 100644 --- a/tests/unit_tests/test_behavior.py +++ b/tests/unit_tests/test_behavior.py @@ -7,6 +7,7 @@ check_spatial_series_degrees_magnitude, check_spatial_series_dims, check_spatial_series_radians_magnitude, + check_spatial_series_unit, ) @@ -149,3 +150,68 @@ def test_check_spatial_series_radians_magnitude(): location="/", object_type="SpatialSeries", ) + + +def test_pass_check_spatial_series_unit(): + for unit in ("meters", "centimeters", "millimeters", "micrometers", "degrees", "radians", "pixels", "n.a."): + spatial_series = SpatialSeries( + name="SpatialSeries", + description="description", + data=np.ones((10,)), + rate=3.0, + reference_frame="reference_frame", + unit=unit, + ) + assert check_spatial_series_unit(spatial_series) is None + + +def test_skip_check_spatial_series_unit_in_compass_direction(): + compass_direction = CompassDirection( + spatial_series=SpatialSeries( + name="SpatialSeries", + description="description", + data=np.ones((10,)), + rate=3.0, + reference_frame="reference_frame", + unit="kilometers", + ) + ) + spatial_series = compass_direction.spatial_series["SpatialSeries"] + assert check_spatial_series_unit(spatial_series) is None + + +def test_fail_check_spatial_series_unit(): + spatial_series = SpatialSeries( + name="SpatialSeries", + description="description", + data=np.ones((10,)), + rate=3.0, + reference_frame="reference_frame", + unit="kilometers", + ) + result = check_spatial_series_unit(spatial_series) + assert result == InspectorMessage( + message=( + "SpatialSeries unit 'kilometers' is not a valid spatial unit. " + "Valid units are: centimeters, degrees, meters, micrometers, millimeters, n.a., pixels, radians. " + "If the unit is not known, use 'n.a.' (not available) as a placeholder." + ), + importance=Importance.BEST_PRACTICE_VIOLATION, + check_function_name="check_spatial_series_unit", + object_type="SpatialSeries", + object_name="SpatialSeries", + location="/", + ) + + +def test_fail_check_spatial_series_unit_none(): + spatial_series = SpatialSeries( + name="SpatialSeries", + description="description", + data=np.ones((10,)), + rate=3.0, + reference_frame="reference_frame", + ) + spatial_series.fields["unit"] = None + result = check_spatial_series_unit(spatial_series) + assert result is not None