From 9e59d06d29c9b5f8a368b3ee4764f648adc4dc79 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 16 Oct 2023 13:38:00 +0200 Subject: [PATCH 1/5] add complete session start time to FicTrac --- .../behavior/fictrac/fictracdatainterface.py | 75 ++++++++++++++----- .../test_on_data/test_behavior_interfaces.py | 5 ++ 2 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py index fe8adb992..73005082f 100644 --- a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py +++ b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py @@ -1,7 +1,9 @@ +import warnings +from datetime import datetime from pathlib import Path from typing import Optional +from zoneinfo import ZoneInfo -import numpy as np from pynwb.behavior import CompassDirection, SpatialSeries from pynwb.file import NWBFile @@ -22,6 +24,9 @@ class FicTracDataInterface(BaseDataInterface): "visual fixation", ] + # The full header can be found in: + # https://github.com/rjdmoore/fictrac/blob/master/doc/data_header.txt + data_columns = [ "frame_counter", "rotation_delta_x_cam", @@ -56,7 +61,7 @@ def __init__( verbose: bool = True, ): """ - Interface for writing fictract files to nwb. + Interface for writing FicTrac files to nwb. Parameters ---------- @@ -72,17 +77,12 @@ def __init__( def get_metadata(self): metadata = super().get_metadata() - from datetime import datetime - - config_file = self.file_path.parent / "fictrac_config.txt" - if config_file.exists(): - self._config_file = parse_fictrac_config(config_file) - date_string = self._config_file["build_date"] - date_object = datetime.strptime(date_string, "%b %d %Y") - metadata["NWBFile"].update( - session_start_time=date_object, - ) + try: + session_start_time = extract_session_start_time(self.file_path) + metadata["NWBFile"].update(session_start_time=session_start_time) + except: + warnings.warn("Unable to extract the session start time from the FicTrac data file. ") return metadata @@ -102,17 +102,25 @@ def add_to_nwbfile( import pandas as pd - fictrac_data_df = pd.read_csv(self.file_path, sep=",", header=None, names=self.data_columns) + # The first row only contains the session start time and unvalid data + fictrac_data_df = pd.read_csv(self.file_path, sep=",", skiprows=1, header=None, names=self.data_columns) # Get the timestamps timestamps_milliseconds = fictrac_data_df["timestamp"].values timestamps = timestamps_milliseconds / 1000.0 - rate = calculate_regular_series_rate(series=timestamps) # Returns None if it is not regular - write_timestamps = True - if rate: - write_timestamps = False - processing_module = get_module(nwbfile=nwbfile, name="Behavior") + # Shift to the session start time + metadata = metadata or self.get_metadata() + interface_session_start_time = extract_session_start_time(self.file_path) + conversion_session_start_time = metadata["NWBFile"]["session_start_time"] + starting_time = interface_session_start_time.timestamp() - conversion_session_start_time.timestamp() + timestamps_shifted = timestamps + starting_time + + # Note: The last values of the timestamps look very irregular for the sample file in catalyst neuro gin repo + # The reason, most likely, is that FicTrac is relying on OpenCV to get the timestamps from the video + # And we know that OpenCV is not very accurate with the timestamps, specially the last ones. + rate = calculate_regular_series_rate(series=timestamps_shifted) + write_timestamps = False if rate else True # calculate_regular_series_rate returns None if it is not regular # All the units in FicTrac are in radians, the radius of the ball required to transform to # Distances is not specified in the format @@ -148,6 +156,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -182,6 +191,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -216,6 +226,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -250,6 +261,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -274,6 +286,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -300,6 +313,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -321,6 +335,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -351,6 +366,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -379,6 +395,7 @@ def add_to_nwbfile( spatial_seriess_kwargs["timestamps"] = timestamps else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) @@ -401,14 +418,36 @@ def add_to_nwbfile( else: spatial_seriess_kwargs["rate"] = rate + spatial_seriess_kwargs["starting_time"] = starting_time spatial_series = SpatialSeries(**spatial_seriess_kwargs) compass_direction_container.add_spatial_series(spatial_series) # Add the compass direction container to the processing module + processing_module = get_module(nwbfile=nwbfile, name="Behavior") processing_module.add_data_interface(compass_direction_container) +def extract_session_start_time(file_path: FilePathType) -> datetime: + """ + Lazily extract the session start datetime from a FicTrac data file. + + In FicTrac the column 22 in the data has the timestamps which are given in milliseconds since the epoch. + + The epoch in Linux is 1970-01-01 00:00:00 UTC. + """ + with open(file_path, "r") as file: + # Read the first data line + first_line = file.readline() + + # Split by comma and extract the timestamp (the 22nd column) + utc_timestamp = float(first_line.split(",")[21]) / 1000.0 # Transform to seconds + + utc_datetime = datetime.utcfromtimestamp(utc_timestamp).replace(tzinfo=ZoneInfo("UTC")) + + return utc_datetime + + def parse_fictrac_config(filename) -> dict: """ Parse a FicTrac configuration file and return a dictionary of its parameters. See the diff --git a/tests/test_on_data/test_behavior_interfaces.py b/tests/test_on_data/test_behavior_interfaces.py index e3967180d..e0cd4e06a 100644 --- a/tests/test_on_data/test_behavior_interfaces.py +++ b/tests/test_on_data/test_behavior_interfaces.py @@ -1,6 +1,7 @@ import unittest from datetime import datetime from pathlib import Path +from zoneinfo import ZoneInfo import numpy as np from natsort import natsorted @@ -37,6 +38,10 @@ class TestFicTracDataInterface(DataInterfaceTestMixin, unittest.TestCase): save_directory = OUTPUT_PATH + def check_extracted_metadata(self, metadata: dict): + expected_session_start_time = datetime(2023, 7, 24, 9, 30, 55, 440600, tzinfo=ZoneInfo("UTC")) + assert metadata["NWBFile"]["session_start_time"] == expected_session_start_time + class TestVideoInterface(VideoInterfaceMixin, unittest.TestCase): data_interface_cls = VideoInterface From c2f4cc957e9b90dd60a47cd316c9a8c5c1f276fe Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 16 Oct 2023 15:44:01 +0200 Subject: [PATCH 2/5] changelog and import zoneinfo --- CHANGELOG.md | 8 ++++++-- .../behavior/fictrac/fictracdatainterface.py | 8 ++++++-- .../datainterfaces/behavior/fictrac/requirements.txt | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b82ca5850..d9038b46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,18 @@ # (Upcoming) -### Fixes -Remove `starting_time` reset to default value (0.0) when adding the rate and updating the `photon_series_kwargs` or `roi_response_series_kwargs`, in `add_photon_series` or `add_fluorescence_traces`. [PR #595](https://github.com/catalystneuro/neuroconv/pull/595) + +### Features +Addedd `session_start_time` extraction to `FicTracDataInterface`. [PR #598](https://github.com/catalystneuro/neuroconv/pull/598) + ### Fixes +Remove `starting_time` reset to default value (0.0) when adding the rate and updating the `photon_series_kwargs` or `roi_response_series_kwargs`, in `add_photon_series` or `add_fluorescence_traces`. [PR #595](https://github.com/catalystneuro/neuroconv/pull/595) * Changed the date parsing in `OpenEphysLegacyRecordingInterface` to `datetime.strptime` with the expected date format explicitly set to `"%d-%b-%Y %H%M%S"`. [PR #577](https://github.com/catalystneuro/neuroconv/pull/577) * Pin lower bound HDMF version to `3.10.0`. [PR #586](https://github.com/catalystneuro/neuroconv/pull/586) + # v0.4.4 ### Features diff --git a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py index 73005082f..a1303a22f 100644 --- a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py +++ b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py @@ -2,7 +2,11 @@ from datetime import datetime from pathlib import Path from typing import Optional -from zoneinfo import ZoneInfo + +try: + from zoneinfo import ZoneInfo +except ImportError: + from backports.zoneinfo import ZoneInfo from pynwb.behavior import CompassDirection, SpatialSeries from pynwb.file import NWBFile @@ -102,7 +106,7 @@ def add_to_nwbfile( import pandas as pd - # The first row only contains the session start time and unvalid data + # The first row only contains the session start time and invalid data fictrac_data_df = pd.read_csv(self.file_path, sep=",", skiprows=1, header=None, names=self.data_columns) # Get the timestamps diff --git a/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt b/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt index e69de29bb..8982c34de 100644 --- a/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt +++ b/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt @@ -0,0 +1 @@ +backports.zoneinfo;python_version<"3.9" From 620f38693f850085610328c4f4a1a52e95af56de Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 16 Oct 2023 18:04:47 +0200 Subject: [PATCH 3/5] remove try-except and warnings --- .../behavior/fictrac/fictracdatainterface.py | 32 +++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py index a1303a22f..3eb5d83c6 100644 --- a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py +++ b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py @@ -1,13 +1,8 @@ -import warnings +import importlib.util from datetime import datetime from pathlib import Path from typing import Optional -try: - from zoneinfo import ZoneInfo -except ImportError: - from backports.zoneinfo import ZoneInfo - from pynwb.behavior import CompassDirection, SpatialSeries from pynwb.file import NWBFile @@ -82,11 +77,8 @@ def __init__( def get_metadata(self): metadata = super().get_metadata() - try: - session_start_time = extract_session_start_time(self.file_path) - metadata["NWBFile"].update(session_start_time=session_start_time) - except: - warnings.warn("Unable to extract the session start time from the FicTrac data file. ") + session_start_time = extract_session_start_time(self.file_path) + metadata["NWBFile"].update(session_start_time=session_start_time) return metadata @@ -122,13 +114,16 @@ def add_to_nwbfile( # Note: The last values of the timestamps look very irregular for the sample file in catalyst neuro gin repo # The reason, most likely, is that FicTrac is relying on OpenCV to get the timestamps from the video - # And we know that OpenCV is not very accurate with the timestamps, specially the last ones. + # In my experience, OpenCV is not very accurate with the timestamps at the end of the video. rate = calculate_regular_series_rate(series=timestamps_shifted) write_timestamps = False if rate else True # calculate_regular_series_rate returns None if it is not regular # All the units in FicTrac are in radians, the radius of the ball required to transform to # Distances is not specified in the format - compass_direction_container = CompassDirection(name="FicTrac") + compass_direction_container = CompassDirection( + name="FicTrac" + ) # TODO: this is just for the heading angle. I will change this to Position in a subsequent PR. + # Position and SpatialSeries nested into it # Add rotation delta from camera rotation_delta_cam_columns = [ @@ -432,6 +427,15 @@ def add_to_nwbfile( processing_module.add_data_interface(compass_direction_container) +def import_zone_info(): + if importlib.util.find_spec("zoneinfo"): + return importlib.import_module("zoneinfo").ZoneInfo + elif importlib.util.find_spec("backports.zoneinfo"): + return importlib.import_module("backports.zoneinfo").ZoneInfo + else: + raise ImportError("Neither 'zoneinfo' nor 'backports.zoneinfo' is available.") + + def extract_session_start_time(file_path: FilePathType) -> datetime: """ Lazily extract the session start datetime from a FicTrac data file. @@ -440,6 +444,7 @@ def extract_session_start_time(file_path: FilePathType) -> datetime: The epoch in Linux is 1970-01-01 00:00:00 UTC. """ + ZoneInfo = import_zone_info() with open(file_path, "r") as file: # Read the first data line first_line = file.readline() @@ -452,6 +457,7 @@ def extract_session_start_time(file_path: FilePathType) -> datetime: return utc_datetime +# TODO: Parse probably will do this in a simpler way. def parse_fictrac_config(filename) -> dict: """ Parse a FicTrac configuration file and return a dictionary of its parameters. See the From c3b8c4d43bbc40e3f021216178f017b9598f52c7 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Mon, 16 Oct 2023 20:48:31 +0200 Subject: [PATCH 4/5] fix test --- CHANGELOG.md | 2 +- tests/test_on_data/test_behavior_interfaces.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e3b9e62..428166b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # (Upcoming) ### Features -Addedd `session_start_time` extraction to `FicTracDataInterface`. [PR #598](https://github.com/catalystneuro/neuroconv/pull/598) +Added `session_start_time` extraction to `FicTracDataInterface`. [PR #598](https://github.com/catalystneuro/neuroconv/pull/598) ### Fixes diff --git a/tests/test_on_data/test_behavior_interfaces.py b/tests/test_on_data/test_behavior_interfaces.py index e0cd4e06a..cf00f21d6 100644 --- a/tests/test_on_data/test_behavior_interfaces.py +++ b/tests/test_on_data/test_behavior_interfaces.py @@ -1,7 +1,6 @@ import unittest -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path -from zoneinfo import ZoneInfo import numpy as np from natsort import natsorted @@ -39,7 +38,7 @@ class TestFicTracDataInterface(DataInterfaceTestMixin, unittest.TestCase): save_directory = OUTPUT_PATH def check_extracted_metadata(self, metadata: dict): - expected_session_start_time = datetime(2023, 7, 24, 9, 30, 55, 440600, tzinfo=ZoneInfo("UTC")) + expected_session_start_time = datetime(2023, 7, 24, 9, 30, 55, 440600, tzinfo=timezone.utc) assert metadata["NWBFile"]["session_start_time"] == expected_session_start_time From 32df08a6461568bbea5c82205f6cf1038b16b196 Mon Sep 17 00:00:00 2001 From: Heberto Mayorquin Date: Tue, 17 Oct 2023 18:24:26 +0200 Subject: [PATCH 5/5] remove logic to adjuste for session start time --- .../behavior/fictrac/fictracdatainterface.py | 31 ++++++------------- .../behavior/fictrac/requirements.txt | 1 - 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py index 3eb5d83c6..d6e6229ff 100644 --- a/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py +++ b/src/neuroconv/datainterfaces/behavior/fictrac/fictracdatainterface.py @@ -1,5 +1,5 @@ import importlib.util -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional @@ -8,7 +8,7 @@ # from ....basetemporalalignmentinterface import BaseTemporalAlignmentInterface TODO: Add timing methods from ....basedatainterface import BaseDataInterface -from ....tools import get_module, get_package +from ....tools import get_module from ....utils import FilePathType, calculate_regular_series_rate @@ -105,18 +105,15 @@ def add_to_nwbfile( timestamps_milliseconds = fictrac_data_df["timestamp"].values timestamps = timestamps_milliseconds / 1000.0 - # Shift to the session start time - metadata = metadata or self.get_metadata() - interface_session_start_time = extract_session_start_time(self.file_path) - conversion_session_start_time = metadata["NWBFile"]["session_start_time"] - starting_time = interface_session_start_time.timestamp() - conversion_session_start_time.timestamp() - timestamps_shifted = timestamps + starting_time - # Note: The last values of the timestamps look very irregular for the sample file in catalyst neuro gin repo # The reason, most likely, is that FicTrac is relying on OpenCV to get the timestamps from the video # In my experience, OpenCV is not very accurate with the timestamps at the end of the video. - rate = calculate_regular_series_rate(series=timestamps_shifted) - write_timestamps = False if rate else True # calculate_regular_series_rate returns None if it is not regular + rate = calculate_regular_series_rate(series=timestamps) # Returns None if the series is not regular + if rate: + write_timestamps = False + starting_time = timestamps[0] + else: + write_timestamps = True # All the units in FicTrac are in radians, the radius of the ball required to transform to # Distances is not specified in the format @@ -427,15 +424,6 @@ def add_to_nwbfile( processing_module.add_data_interface(compass_direction_container) -def import_zone_info(): - if importlib.util.find_spec("zoneinfo"): - return importlib.import_module("zoneinfo").ZoneInfo - elif importlib.util.find_spec("backports.zoneinfo"): - return importlib.import_module("backports.zoneinfo").ZoneInfo - else: - raise ImportError("Neither 'zoneinfo' nor 'backports.zoneinfo' is available.") - - def extract_session_start_time(file_path: FilePathType) -> datetime: """ Lazily extract the session start datetime from a FicTrac data file. @@ -444,7 +432,6 @@ def extract_session_start_time(file_path: FilePathType) -> datetime: The epoch in Linux is 1970-01-01 00:00:00 UTC. """ - ZoneInfo = import_zone_info() with open(file_path, "r") as file: # Read the first data line first_line = file.readline() @@ -452,7 +439,7 @@ def extract_session_start_time(file_path: FilePathType) -> datetime: # Split by comma and extract the timestamp (the 22nd column) utc_timestamp = float(first_line.split(",")[21]) / 1000.0 # Transform to seconds - utc_datetime = datetime.utcfromtimestamp(utc_timestamp).replace(tzinfo=ZoneInfo("UTC")) + utc_datetime = datetime.utcfromtimestamp(utc_timestamp).replace(tzinfo=timezone.utc) return utc_datetime diff --git a/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt b/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt index 8982c34de..e69de29bb 100644 --- a/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt +++ b/src/neuroconv/datainterfaces/behavior/fictrac/requirements.txt @@ -1 +0,0 @@ -backports.zoneinfo;python_version<"3.9"