Skip to content

Commit

Permalink
Merge pull request #32 from catalystneuro/Neuropixels/fix-timeshift-p…
Browse files Browse the repository at this point in the history
…rocessed-trials

[Neuropixels] Add time shift to processed behavior data timestamp columns
  • Loading branch information
weiglszonja authored Dec 5, 2024
2 parents c11db70 + ed6e26d commit 2cacfe4
Show file tree
Hide file tree
Showing 3 changed files with 177 additions and 23 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ def add_to_nwbfile(
if column_name_mapping is not None:
columns_to_add = [column for column in column_name_mapping.keys() if column in data.keys()]

assert (
self._center_port_column_name in data
), f"'{self._center_port_column_name}' column must be present in the data to align the trials."
center_port_onset_times = [center_port_times[0] for center_port_times in data[self._center_port_column_name]]
center_port_offset_times = [center_port_times[1] for center_port_times in data[self._center_port_column_name]]

time_shift = 0.0
if nwbfile.trials is None:
assert trial_start_times is not None, "'trial_start_times' must be provided if trials table is not added."
assert trial_stop_times is not None, "'trial_stop_times' must be provided if trials table is not added."
Expand All @@ -126,6 +133,8 @@ def add_to_nwbfile(
trial_start_times = trial_start_times[:num_trials]
trial_stop_times = trial_stop_times[:num_trials]

time_shift = trial_start_times[0] - center_port_onset_times[0]

assert (
len(trial_start_times) == num_trials
), f"Length of 'trial_start_times' ({len(trial_start_times)}) must match the number of trials ({num_trials})."
Expand All @@ -140,20 +149,108 @@ def add_to_nwbfile(
check_ragged=False,
)

# break it into onset and offset time columns
if self._center_port_column_name in columns_to_add:
columns_to_add.remove(self._center_port_column_name)
trials_table.add_column(
name="center_poke_onset_time",
description="The time of center port LED on for each trial.",
data=[center_poke_times[0] for center_poke_times in data[self._center_port_column_name]],
)
trials_table.add_column(
name="center_poke_offset_time",
description="The time of center port LED off for each trial.",
data=[center_poke_times[1] for center_poke_times in data[self._center_port_column_name]],
)
# break 'Cled' into onset and offset time columns
trials_table.add_column(
name="center_port_onset_time",
description="The time of center port LED on for each trial.",
data=center_port_onset_times + time_shift,
)
trials_table.add_column(
name="center_port_offset_time",
description="The time of center port LED off for each trial.",
data=center_port_offset_times + time_shift,
)

side_port_columns = ["Cled", "Lled", "Rled", "l_opt", "r_opt"]
missing_columns = [col for col in side_port_columns if col not in data]
if missing_columns:
raise ValueError(f"Missing required columns in data: {', '.join(missing_columns)}")

# During the delay between the center light turning off and the reward arriving, the side light turns on.
# The side light turns off when the reward is available, then stays off until the animal collects the reward.
# When the animal nose pokes to collect the reward, the light flashes on/off.
reward_side_light_onset_times = []
reward_side_light_offset_times = []
reward_side_light_flash_onset_times = []
reward_side_light_flash_offset_times = []

opt_out_side_light_onset_times = []
opt_out_side_light_offset_times = []
opt_out_reward_port_turns_off = []
opt_out_reward_port_light_turns_off = []

for i in range(num_trials):
rewarded_side = data["RewardedSide"][i]
if rewarded_side == "Left":
side_port_column_name = "Lled"
# the opt-out port is the opposite of the rewarded side
opt_out_port_column_name = "r_opt"
elif rewarded_side == "Right":
side_port_column_name = "Rled"
opt_out_port_column_name = "l_opt"
else:
raise ValueError(f"Invalid rewarded side '{rewarded_side}'.")

reward_side_light_onset_times.append(data[side_port_column_name][i][0])
reward_side_light_offset_times.append(data[side_port_column_name][i][1])
reward_side_light_flash_onset_times.append(data[side_port_column_name][i][2])
reward_side_light_flash_offset_times.append(data[side_port_column_name][i][3])

opt_out_side_light_onset_times.append(data[opt_out_port_column_name][i][0])
opt_out_side_light_offset_times.append(data[opt_out_port_column_name][i][1])
opt_out_reward_port_turns_off.append(data[side_port_column_name][i][3])
opt_out_reward_port_light_turns_off.append(data[opt_out_port_column_name][i][3])

trials_table.add_column(
name="rewarded_port_onset_time",
description="The time of reward port light on for each trial. During the delay between the center light turning off and the reward arriving, the side light turns on.",
data=reward_side_light_onset_times + time_shift,
)

trials_table.add_column(
name="rewarded_port_offset_time",
description="The time of reward port light off for each trial. The side light turns off when the reward is available, then stays off until the animal collects the reward.",
data=reward_side_light_offset_times + time_shift,
)

trials_table.add_column(
name="rewarded_port_flash_onset_time",
description="The time of reward port light flash on for each trial. When the animal nose pokes to collect the reward, the light flashes on/off.",
data=reward_side_light_flash_onset_times + time_shift,
)

trials_table.add_column(
name="rewarded_port_flash_offset_time",
description="The time of reward port light flash off for each trial. When the animal nose pokes to collect the reward, the light flashes on/off.",
data=reward_side_light_flash_offset_times + time_shift,
)

trials_table.add_column(
name="opt_out_port_onset_time",
description=f"The time of side light turns on when the animal opts out by poking into the port opposite to the rewarded side.",
data=opt_out_side_light_onset_times + time_shift,
)

trials_table.add_column(
name="opt_out_port_offset_time",
description=f"The time of side light turns off when the animal opts out by poking into the port opposite to the rewarded side.",
data=opt_out_side_light_offset_times + time_shift,
)

trials_table.add_column(
name=f"opt_out_reward_port_offset_time",
description="The time of rewarded port turns off when the animal opts out by poking into the port opposite to the rewarded side.",
data=opt_out_reward_port_turns_off + time_shift,
)

trials_table.add_column(
name=f"opt_out_reward_port_light_offset_time",
description="The time of rewarded port light turns off when the animal opts out by poking into the port opposite to the rewarded side.",
data=opt_out_reward_port_light_turns_off + time_shift,
)

# filter columns to add, these columns were added separately
columns_to_add = [column for column in columns_to_add if column not in side_port_columns]
for column_name in columns_to_add:
name = column_name_mapping.get(column_name, column_name) if column_name_mapping is not None else column_name
description = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,27 +196,80 @@ column_descriptions = dict(
)
```

### Temporal alignment
### Session start time

Align TTL signals to Raw Bpod trial times:
Compute the time shift from raw Bpod trial start times to the aligned center port timestamps.
The aligned center port times can be accessed from the processed behavior data using the `"Cled"` field.
The session start time is the reference time for all timestamps in the NWB file. We are using `session_start_time` from the Bpod output. (The start time of the session in the Bpod data can be accessed from the "Info" struct, with "SessionDate" and "SessionStartTime_UTC" fields.)

### Bpod trial start time

We are extracting the trial start times from the Bpod output using the "TrialStartTimestamp" field.

```python
from ndx_structured_behavior.utils import loadmat
from pymatreader import read_mat

bpod_data = loadmat("path/to/bpod_session.mat")["SessionData"] # should contain "SessionData" named struct
S_struct_data = loadmat("path/to/processed_behavior.mat")["S"] # should contain "S" named struct
bpod_data = read_mat("raw_Bpod/J076/DataFiles/J076_RWTautowait2_20231212_145250.mat")["SessionData"] # should contain "SessionData" named struct

# The trial start times from the Bpod data
bpod_trial_start_times = bpod_data['TrialStartTimestamp']

bpod_trial_start_times[:7]
>>> [19.988, 39.7154, 43.6313, 46.8732, 59.4011, 77.7451, 79.4653]
```

### NIDAQ trial start time

The aligned trial start times can be accessed from the processed behavior data using the `"Cled"` field.

```python
from pymatreader import read_mat

S_struct_data = read_mat("J076_2023-12-12.mat")["S"] # should contain "S" named struct
# "Cled" field contains the aligned onset and offset times for each trial [2 x ntrials]
center_port_aligned_onset_times = [center_port_times[0] for center_port_times in S_struct_data["Cled"]]
time_shift = bpod_trial_start_times[0] - center_port_aligned_onset_times[0]
center_port_onset_times = [center_port_times[0] for center_port_times in S_struct_data["Cled"]]
center_port_offset_times = [center_port_times[1] for center_port_times in S_struct_data["Cled"]]

center_port_onset_times[:7]
>>> [48.57017236918037, 68.2978722016674, 72.2138230625031, 75.45578122765313, 87.98392024937102, 106.3281781420765, 108.04842315623304]
```

## Alignment

We are aligning the starting time of the recording and sorting interfaces to the Bpod interface.

We are computing the time shift from the Bpod trial start time to the NIDAQ trial start time.

From a conceptual point of view, the trial start and the central port onset are equivalent: a trial starts when the first time the central port is on.


```python
time_shift = bpod_trial_start_times[0] - center_port_onset_times[0]
>>> -28.58217236918037
```

We are applying this time_shift to the timestamps for the raw recording as:

```python
from neuroconv.datainterfaces import OpenEphysRecordingInterface
recording_folder_path = "J076_2023-12-12_14-52-04/Record Node 117"
ap_stream_name = "Record Node 117#Neuropix-PXI-119.ProbeA-AP"
recording_interface = OpenEphysRecordingInterface(recording_folder_path, ap_stream_name)

unaligned_timestamps = recording_interface.get_timestamps()
unaligned_timestamps[:7]
>>> [29.74, 29.74003333, 29.74006667, 29.7401, 29.74013333, 29.74016667, 29.7402]

aligned_timestamps = unaligned_timestamps + time_shift
>>> [1.15782763, 1.15786096, 1.1578943 , 1.15792763, 1.15796096, 1.1579943 , 1.15802763]
```

We are using this computed time shift to shift the ephys timestamps.
1) When the time shift is negative and the first aligned timestamp of the recording trace is negative:
- shift back bpod (from every column that has a timestamp they have to be shifted back)
- shift back session start time
- don't have to move recording nor the center_port_onset_times and center_port_offset_times
2) When the time shift is negative and the first aligned timestamp of the recording trace is positive
- we move the recording, center_port_onset_times and center_port_offset_times backward
3) When time shift is positive
- we move the recording, center_port_onset_times and center_port_offset_times forward


### Mapping to NWB
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ def __init__(
def get_metadata(self):
metadata = super().get_metadata()

# Explicity set session_start_time to Bpod start time
session_start_time = self.data_interface_objects["RawBehavior"].get_metadata()["NWBFile"]["session_start_time"]
metadata["NWBFile"].update(session_start_time=session_start_time)

if "Electrodes" not in metadata["Ecephys"]:
metadata["Ecephys"]["Electrodes"] = []

Expand Down

0 comments on commit 2cacfe4

Please sign in to comment.