diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f45ea8b..cd242d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * 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) * Added `check_spike_times_not_in_samples` to flag when spike times appear to be stored as sample indices rather than seconds, detected by all values being integer-valued with implausibly large magnitudes. +* Added `check_spike_times_without_nans` to detect NaN values in Units spike times, which indicate conversion bugs or unclean array padding. [#689](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/689) * Added `check_units_table_has_spikes` to flag Units tables that do not contain a spike_times column. [#691](https://github.com/NeurodataWithoutBorders/nwbinspector/pull/691) diff --git a/docs/best_practices/ecephys.rst b/docs/best_practices/ecephys.rst index 9af1e9cf..1a3989e3 100644 --- a/docs/best_practices/ecephys.rst +++ b/docs/best_practices/ecephys.rst @@ -151,6 +151,19 @@ Check function: :py:meth:`~nwbinspector.checks._ecephys.check_spike_times_not_in +.. _best_practice_spike_times_without_nans: + +No NaN Spike Times +~~~~~~~~~~~~~~~~~~ + +Spike times are physical event timestamps and must always be valid floating-point numbers. A NaN value in the +``spike_times`` column typically indicates a conversion bug or array padding that was not cleaned up before writing +to NWB. Unlike missing data in continuous signals, a spike either occurred at a specific time or it did not, so +NaN is never meaningful. Clean your spike time arrays to remove any NaN values before adding them to the Units table. + +Check function: :py:meth:`~nwbinspector.checks._ecephys.check_spike_times_without_nans` + + .. _best_practice_ascending_spike_times: Ascending Spike Times diff --git a/docs/checks_by_importance.rst b/docs/checks_by_importance.rst new file mode 100644 index 00000000..aaaba01d --- /dev/null +++ b/docs/checks_by_importance.rst @@ -0,0 +1,95 @@ +Checks by Importance +====================== + +This section lists the available checks organized by their importance level. + +CRITICAL +--------- + +* :py:func:`~nwbinspector.checks._behavior.check_spatial_series_dims` +* :py:func:`~nwbinspector.checks._ecephys.check_electrical_series_dims` +* :py:func:`~nwbinspector.checks._ecephys.check_ascending_spike_times` +* :py:func:`~nwbinspector.checks._ecephys.check_units_table_duration` +* :py:func:`~nwbinspector.checks._general.check_name_slashes` +* :py:func:`~nwbinspector.checks._image_series.check_image_series_external_file_valid` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_session_start_time_future_date` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_exists` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_age` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_id_exists` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_weight` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_sex` +* :py:func:`~nwbinspector.checks._ophys.check_roi_response_series_dims` +* :py:func:`~nwbinspector.checks._tables.check_dynamic_table_region_data_validity` +* :py:func:`~nwbinspector.checks._tables.check_ids_unique` +* :py:func:`~nwbinspector.checks._tables.check_time_intervals_duration` +* :py:func:`~nwbinspector.checks._time_series.check_data_orientation` +* :py:func:`~nwbinspector.checks._time_series.check_timestamps_match_first_dimension` +* :py:func:`~nwbinspector.checks._time_series.check_rate_is_not_zero` +* :py:func:`~nwbinspector.checks._time_series.check_rate_is_positive` + +BEST PRACTICE VIOLATION +------------------------ + +* :py:func:`~nwbinspector.checks._behavior.check_compass_direction_unit` +* :py:func:`~nwbinspector.checks._behavior.check_spatial_series_radians_magnitude` +* :py:func:`~nwbinspector.checks._behavior.check_spatial_series_degrees_magnitude` +* :py:func:`~nwbinspector.checks._ecephys.check_negative_spike_times` +* :py:func:`~nwbinspector.checks._ecephys.check_electrical_series_reference_electrodes_table` +* :py:func:`~nwbinspector.checks._ecephys.check_spike_times_not_in_unobserved_interval` +* :py:func:`~nwbinspector.checks._ecephys.check_electrical_series_unscaled_data` +* :py:func:`~nwbinspector.checks._ecephys.check_electrodes_location_allen_ccf` +* :py:func:`~nwbinspector.checks._icephys.check_intracellular_electrode_cell_id_exists` +* :py:func:`~nwbinspector.checks._icephys.check_sweeptable_deprecated` +* :py:func:`~nwbinspector.checks._icephys.check_intracellular_electrode_location_allen_ccf` +* :py:func:`~nwbinspector.checks._image_series.check_image_series_external_file_relative` +* :py:func:`~nwbinspector.checks._image_series.check_image_series_data_size` +* :py:func:`~nwbinspector.checks._image_series.check_image_series_starting_frame_without_external_file` +* :py:func:`~nwbinspector.checks._images.check_order_of_images_unique` +* :py:func:`~nwbinspector.checks._images.check_order_of_images_len` +* :py:func:`~nwbinspector.checks._images.check_index_series_points_to_image` +* :py:func:`~nwbinspector.checks._nwb_containers.check_large_dataset_compression` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_species_exists` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_species_form` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_session_id_no_slashes` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_id_no_slashes` +* :py:func:`~nwbinspector.checks._ogen.check_optogenetic_stimulus_site_has_optogenetic_series` +* :py:func:`~nwbinspector.checks._ophys.check_roi_response_series_link_to_plane_segmentation` +* :py:func:`~nwbinspector.checks._ophys.check_emission_lambda_in_nm` +* :py:func:`~nwbinspector.checks._ophys.check_excitation_lambda_in_nm` +* :py:func:`~nwbinspector.checks._ophys.check_plane_segmentation_image_mask_shape_against_ref_images` +* :py:func:`~nwbinspector.checks._ophys.check_imaging_plane_location_allen_ccf` +* :py:func:`~nwbinspector.checks._tables.check_empty_table` +* :py:func:`~nwbinspector.checks._tables.check_time_interval_time_columns` +* :py:func:`~nwbinspector.checks._tables.check_time_intervals_stop_after_start` +* :py:func:`~nwbinspector.checks._tables.check_table_values_for_dict` +* :py:func:`~nwbinspector.checks._time_series.check_regular_timestamps` +* :py:func:`~nwbinspector.checks._time_series.check_timestamps_ascending` +* :py:func:`~nwbinspector.checks._time_series.check_timestamps_without_nans` +* :py:func:`~nwbinspector.checks._time_series.check_missing_unit` +* :py:func:`~nwbinspector.checks._time_series.check_resolution` +* :py:func:`~nwbinspector.checks._time_series.check_time_series_duration` +* :py:func:`~nwbinspector.checks._time_series.check_time_series_data_is_not_empty` +* :py:func:`~nwbinspector.checks._time_series.check_rate_not_below_threshold` + +BEST PRACTICE SUGGESTION +------------------------- + +* :py:func:`~nwbinspector.checks._general.check_name_colons` +* :py:func:`~nwbinspector.checks._general.check_description` +* :py:func:`~nwbinspector.checks._nwb_containers.check_small_dataset_compression` +* :py:func:`~nwbinspector.checks._nwb_containers.check_empty_string_for_optional_attribute` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_session_start_time_old_date` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_experimenter_exists` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_experimenter_form` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_experiment_description` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_institution` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_keywords` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_doi_publications` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_subject_proper_age_range` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_processing_module_name` +* :py:func:`~nwbinspector.checks._nwbfile_metadata.check_file_extension` +* :py:func:`~nwbinspector.checks._tables.check_column_binary_capability` +* :py:func:`~nwbinspector.checks._tables.check_single_row` +* :py:func:`~nwbinspector.checks._tables.check_col_not_nan` +* :py:func:`~nwbinspector.checks._tables.check_table_time_columns_are_not_negative` +* :py:func:`~nwbinspector.checks._time_series.check_timestamp_of_the_first_sample_is_not_negative` diff --git a/src/nwbinspector/checks/__init__.py b/src/nwbinspector/checks/__init__.py index 97eee2f2..aa7d674f 100644 --- a/src/nwbinspector/checks/__init__.py +++ b/src/nwbinspector/checks/__init__.py @@ -13,6 +13,7 @@ check_negative_spike_times, check_spike_times_not_in_samples, check_spike_times_not_in_unobserved_interval, + check_spike_times_without_nans, check_units_resolution_is_set, check_units_resolution_is_valid, check_units_table_duration, @@ -184,6 +185,7 @@ "check_spatial_series_dims", "check_spatial_series_degrees_magnitude", "check_ascending_spike_times", + "check_spike_times_without_nans", "check_electrical_series_unscaled_data", "check_units_resolution_is_valid", "check_units_resolution_is_set", diff --git a/src/nwbinspector/checks/_ecephys.py b/src/nwbinspector/checks/_ecephys.py index dd28edc5..da5eaf6b 100644 --- a/src/nwbinspector/checks/_ecephys.py +++ b/src/nwbinspector/checks/_ecephys.py @@ -244,6 +244,23 @@ def check_spike_times_not_in_unobserved_interval(units_table: Units, nunits: int return None +@register_check(importance=Importance.CRITICAL, neurodata_type=Units) +def check_spike_times_without_nans(units_table: Units) -> Optional[InspectorMessage]: + """ + Check if the Units table contains NaN values in spike times. + + Best Practice: :ref:`best_practice_spike_times_without_nans` + """ + if "spike_times" not in units_table: + return None + + if np.any(np.isnan(np.asarray(units_table["spike_times"].target.data[:]))): + return InspectorMessage( + message="Units table contains NaN spike times. Spike times should be valid timestamps in seconds." + ) + return None + + @register_check(importance=Importance.CRITICAL, neurodata_type=Units) def check_ascending_spike_times(units_table: Units, nelems: Optional[int] = NELEMS) -> Optional[InspectorMessage]: """ diff --git a/tests/unit_tests/test_ecephys.py b/tests/unit_tests/test_ecephys.py index f331ed9c..c2bf4037 100644 --- a/tests/unit_tests/test_ecephys.py +++ b/tests/unit_tests/test_ecephys.py @@ -20,6 +20,7 @@ check_negative_spike_times, check_spike_times_not_in_samples, check_spike_times_not_in_unobserved_interval, + check_spike_times_without_nans, check_units_resolution_is_set, check_units_resolution_is_valid, check_units_table_duration, @@ -437,6 +438,30 @@ def test_check_spike_times_not_in_unobserved_interval_multiple_units(): ) +class TestCheckSpikeTimesWithoutNans(TestCase): + def setUp(self): + self.units_table = Units() + + def test_spike_times_without_nans_valid(self): + self.units_table.add_unit(spike_times=[0.0, 0.1, 0.2]) + self.units_table.add_unit(spike_times=[1.0, 1.1, 1.2]) + assert check_spike_times_without_nans(units_table=self.units_table) is None + + def test_spike_times_with_nans(self): + self.units_table.add_unit(spike_times=[0.0, np.nan, 0.2]) + assert check_spike_times_without_nans(units_table=self.units_table) == InspectorMessage( + message="Units table contains NaN spike times. Spike times should be valid timestamps in seconds.", + importance=Importance.CRITICAL, + check_function_name="check_spike_times_without_nans", + object_type="Units", + object_name="Units", + location="/", + ) + + def test_spike_times_without_nans_empty(self): + assert check_spike_times_without_nans(units_table=self.units_table) is None + + class TestCheckAscendingSpikeTimes(TestCase): def setUp(self): self.units_table = Units()